diff --git a/README.md b/README.md index 865023cb..4149d548 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,31 @@ If both `version` and `version-file` are set, an explicitly requested `version` > > If you rely on a certain version, you should explicitly request that version instead of `latest`. +Freshly downloaded Helm archives are verified against the SHA-256 checksum published beside the archive before extraction. With the default `checksum-policy: auto`, the action verifies a matching checksum when one is available as a bare 64-character hexadecimal digest at `.sha256`; when the checksum is not found, the action warns and continues downloading. Existing tool-cache entries continue to be reused without downloading or re-verifying the archive. + +Set `checksum-policy` to control this behavior: + +- `auto` (default): verify checksums when available and warn when they are absent. +- `required`: require a valid matching checksum from every download location. +- `if-available`: explicitly select the same verify-or-warn behavior as `auto`. +- `disabled`: skip checksum verification and emit a warning. + +Malformed or mismatched checksums always fail unless verification is explicitly `disabled`. In `if-available` and `auto` modes, only a checksum response of HTTP 404 or 410 is treated as absent; other download errors fail closed. + +Because each checksum is retrieved from the same mirror as its archive, verification detects corruption or an incomplete mirror sync; it does not protect against a mirror that has been compromised together with its checksum files. + +An optional fallback mirror can be used when version resolution, archive download, or checksum verification fails against the primary location: + +```yaml +- uses: azure/setup-helm@v5 + with: + version: 'v4.3.1' + downloadBaseURL: 'https://primary.example/helm' + downloadBaseURLFallback: 'https://fallback.example/helm' +``` + +The fallback archive follows the same checksum policy as the primary mirror. This capability follows the use case proposed by @mvtavares in [#227](https://github.com/Azure/setup-helm/pull/227). + The cached helm binary path is prepended to the PATH environment variable as well as stored in the helm-path output variable. Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-helm/blob/master/action.yml diff --git a/action.yml b/action.yml index f9198e0c..cb08d022 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,14 @@ inputs: description: 'Set the download base URL' required: false default: 'https://get.helm.sh' + downloadBaseURLFallback: + description: 'Fallback base URL used when version resolution, download, or checksum verification fails against downloadBaseURL' + required: false + default: '' + checksum-policy: + description: "Checksum verification policy: 'auto' verifies checksums when available and warns when absent; 'required' always requires them; 'if-available' is an explicit alias for auto behavior; 'disabled' skips verification" + required: false + default: 'auto' latest-fallback: description: "When 'version' is 'latest' and the latest version cannot be determined after retrying, install the built-in default version instead of failing. Set to 'false' to fail the step instead, since the built-in default can be a major version behind what 'latest' resolves to." required: false diff --git a/src/run.test.ts b/src/run.test.ts index fffbc935..51bca3f3 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1,5 +1,7 @@ import {vi, describe, test, expect, afterEach} from 'vitest' import * as path from 'path' +import {createHash} from 'crypto' +import {Readable} from 'stream' // Mock os module vi.mock('os', async (importOriginal) => { @@ -20,6 +22,7 @@ vi.mock('fs', async (importOriginal) => { statSync: vi.fn(), chmodSync: vi.fn(), readFileSync: vi.fn(), + createReadStream: vi.fn(), existsSync: vi.fn() } }) @@ -62,6 +65,18 @@ import * as core from '@actions/core' describe('run.ts', () => { const downloadBaseURL = 'https://test.tld' + const archive = Buffer.from('helm archive') + const archiveChecksum = createHash('sha256').update(archive).digest('hex') + + const mockVerifiedDownload = () => { + vi.mocked(toolCache.downloadTool) + .mockResolvedValueOnce('pathToChecksum') + .mockResolvedValueOnce('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue(archiveChecksum) + vi.mocked(fs.createReadStream).mockReturnValue( + Readable.from([archive]) as fs.ReadStream + ) + } // Cleanup mocks after each test to ensure that subsequent tests are not affected by the mocks. afterEach(() => { @@ -83,6 +98,19 @@ describe('run.ts', () => { expect(os.platform).toHaveBeenCalled() }) + test('getChecksumPolicy() - default to auto and reject invalid values', () => { + expect(run.getChecksumPolicy('')).toBe('auto') + expect(run.getChecksumPolicy(' REQUIRED ')).toBe('required') + expect(() => run.getChecksumPolicy('warn')).toThrow( + "Invalid checksum-policy 'warn'" + ) + }) + + test('resolveChecksumPolicy() - auto verifies checksums when available', () => { + expect(run.resolveChecksumPolicy('auto')).toBe('if-available') + expect(run.resolveChecksumPolicy('required')).toBe('required') + }) + test('getHelmDownloadURL() - return the URL to download helm for Linux amd64', () => { vi.mocked(os.platform).mockReturnValue('linux') vi.mocked(os.arch).mockReturnValue('x64') @@ -365,12 +393,14 @@ describe('run.ts', () => { const inputs = ( version: string, versionFile: string, - latestFallback: string = '' + latestFallback: string = '', + downloadFallback: string = '' ) => vi.mocked(core.getInput).mockImplementation((name: string) => { if (name === 'version') return version if (name === 'version-file') return versionFile if (name === 'downloadBaseURL') return downloadBaseURL + if (name === 'downloadBaseURLFallback') return downloadFallback if (name === 'latest-fallback') return latestFallback return '' }) @@ -630,6 +660,29 @@ describe('run.ts', () => { expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4') }) + test('run() - resolve a major.minor version from the fallback mirror', async () => { + stubDownloadChain() + inputs('3.14', '', '', 'https://fallback.tld') + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input) + if (url.startsWith(downloadBaseURL)) { + throw new Error('Primary unavailable') + } + return { + ok: true, + text: async () => + 'helm-v3.14.4-linux-amd64.tar.gz' + } as Response + }) + + await run.run() + + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4') + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Trying fallback 'https://fallback.tld'") + ) + }) + test('run() - resolve the latest patch for a wildcard patch version input', async () => { stubDownloadChain() inputs('v3.12.x', '') @@ -755,9 +808,7 @@ describe('run.ts', () => { test('downloadHelm() - download helm and return path to it', async () => { vi.mocked(toolCache.find).mockReturnValue('') - vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') - const response = JSON.stringify([{tag_name: 'v4.0.0'}]) - vi.mocked(fs.readFileSync).mockReturnValue(response) + mockVerifiedDownload() vi.mocked(os.platform).mockReturnValue('win32') vi.mocked(os.arch).mockReturnValue('x64') vi.mocked(fs.chmodSync).mockImplementation(() => {}) @@ -776,7 +827,12 @@ describe('run.ts', () => { path.join('pathToCachedDir', 'helm.exe') ) expect(toolCache.find).toHaveBeenCalledWith('helm', 'v4.0.0') - expect(toolCache.downloadTool).toHaveBeenCalledWith( + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 1, + 'https://test.tld/helm-v4.0.0-windows-amd64.zip.sha256' + ) + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 2, 'https://test.tld/helm-v4.0.0-windows-amd64.zip' ) expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755') @@ -797,10 +853,12 @@ describe('run.ts', () => { const downloadUrl = 'https://test.tld/helm-v3.2.1-windows-amd64.zip' await expect(run.downloadHelm(downloadBaseURL, 'v3.2.1')).rejects.toThrow( - `Failed to download Helm from location ${downloadUrl}` + `Failed to download and verify Helm from location ${downloadUrl}` ) expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.2.1') - expect(toolCache.downloadTool).toHaveBeenCalledWith(`${downloadUrl}`) + expect(toolCache.downloadTool).toHaveBeenCalledWith( + `${downloadUrl}.sha256` + ) }) test('downloadHelm() - return path to helm tool with same version from toolCache', async () => { @@ -832,9 +890,8 @@ describe('run.ts', () => { test('downloadHelm() - throw error is helm is not found in path', async () => { vi.mocked(toolCache.find).mockReturnValue('') - vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + mockVerifiedDownload() vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir') - vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath') vi.mocked(os.platform).mockReturnValue('win32') vi.mocked(os.arch).mockReturnValue('x64') @@ -856,4 +913,182 @@ describe('run.ts', () => { expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755') expect(toolCache.extractZip).toHaveBeenCalledWith('pathToTool') }) + + test('downloadAndVerifyHelm() - reject a checksum mismatch', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool) + .mockResolvedValueOnce('pathToChecksum') + .mockResolvedValueOnce('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue('0'.repeat(64)) + vi.mocked(fs.createReadStream).mockReturnValue( + Readable.from([archive]) as fs.ReadStream + ) + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4') + ).rejects.toThrow('SHA-256 checksum mismatch') + }) + + test('downloadAndVerifyHelm() - reject an invalid checksum file', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool).mockResolvedValueOnce('pathToChecksum') + vi.mocked(fs.readFileSync).mockReturnValue('not-a-checksum') + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4') + ).rejects.toThrow('Invalid SHA-256 checksum') + }) + + test('downloadAndVerifyHelm() - auto mode warns and continues when a custom mirror has no checksum', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool) + .mockRejectedValueOnce(new toolCache.HTTPError(404)) + .mockResolvedValueOnce('pathToTool') + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4') + ).resolves.toBe('pathToTool') + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 1, + 'https://test.tld/helm-v3.18.4-linux-amd64.tar.gz.sha256' + ) + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 2, + 'https://test.tld/helm-v3.18.4-linux-amd64.tar.gz' + ) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('continuing without verification') + ) + }) + + test('downloadAndVerifyHelm() - auto mode warns when get.helm.sh has no checksum', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool) + .mockRejectedValueOnce(new toolCache.HTTPError(404)) + .mockResolvedValueOnce('pathToTool') + + await expect( + run.downloadAndVerifyHelm('https://get.helm.sh', 'v3.18.4') + ).resolves.toBe('pathToTool') + expect(toolCache.downloadTool).toHaveBeenCalledTimes(2) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('continuing without verification') + ) + }) + + test('downloadAndVerifyHelm() - required mode blocks a missing checksum', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool).mockRejectedValueOnce( + new toolCache.HTTPError(404) + ) + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'required') + ).rejects.toThrow('Unexpected HTTP response: 404') + expect(toolCache.downloadTool).toHaveBeenCalledTimes(1) + expect(core.warning).not.toHaveBeenCalled() + }) + + test('downloadAndVerifyHelm() - if-available mode does not ignore checksum server errors', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool).mockRejectedValueOnce( + new toolCache.HTTPError(503) + ) + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'if-available') + ).rejects.toThrow('Unexpected HTTP response: 503') + expect(toolCache.downloadTool).toHaveBeenCalledTimes(1) + }) + + test('downloadAndVerifyHelm() - disabled mode downloads without requesting a checksum', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool).mockResolvedValueOnce('pathToTool') + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'disabled') + ).resolves.toBe('pathToTool') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://test.tld/helm-v3.18.4-linux-amd64.tar.gz' + ) + expect(toolCache.downloadTool).toHaveBeenCalledTimes(1) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('Checksum verification is disabled') + ) + }) + + test('downloadHelm() - use a verified fallback after a primary checksum mismatch', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(os.platform).mockReturnValue('win32') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool) + .mockResolvedValueOnce('primaryChecksum') + .mockResolvedValueOnce('primaryTool') + .mockResolvedValueOnce('fallbackChecksum') + .mockResolvedValueOnce('fallbackTool') + vi.mocked(fs.readFileSync).mockImplementation((file) => + file === 'primaryChecksum' ? '0'.repeat(64) : archiveChecksum + ) + vi.mocked(fs.createReadStream).mockImplementation( + () => Readable.from([archive]) as fs.ReadStream + ) + vi.mocked(fs.chmodSync).mockImplementation(() => {}) + vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath') + vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir') + vi.mocked(fs.readdirSync).mockReturnValue([ + 'helm.exe' as unknown as fs.Dirent + ]) + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false + } as fs.Stats) + + expect( + await run.downloadHelm( + downloadBaseURL, + 'v3.18.4', + 'https://fallback.tld' + ) + ).toBe(path.join('pathToCachedDir', 'helm.exe')) + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 3, + 'https://fallback.tld/helm-v3.18.4-windows-amd64.zip.sha256' + ) + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 4, + 'https://fallback.tld/helm-v3.18.4-windows-amd64.zip' + ) + expect(toolCache.extractZip).toHaveBeenCalledWith('fallbackTool') + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('Trying fallback') + ) + }) + + test('downloadHelm() - fail when primary and fallback verification fail', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.downloadTool) + .mockResolvedValueOnce('primaryChecksum') + .mockResolvedValueOnce('primaryTool') + .mockResolvedValueOnce('fallbackChecksum') + .mockResolvedValueOnce('fallbackTool') + vi.mocked(fs.readFileSync).mockReturnValue('0'.repeat(64)) + vi.mocked(fs.createReadStream).mockImplementation( + () => Readable.from([archive]) as fs.ReadStream + ) + + await expect( + run.downloadHelm(downloadBaseURL, 'v3.18.4', 'https://fallback.tld') + ).rejects.toThrow( + "Failed to download and verify Helm from 'https://test.tld' and fallback 'https://fallback.tld'" + ) + expect(toolCache.extractTar).not.toHaveBeenCalled() + }) }) diff --git a/src/run.ts b/src/run.ts index cf575aaf..594b98bc 100644 --- a/src/run.ts +++ b/src/run.ts @@ -5,6 +5,7 @@ import * as os from 'os' import * as path from 'path' import * as util from 'util' import * as fs from 'fs' +import {createHash} from 'crypto' import * as toolCache from '@actions/tool-cache' import * as core from '@actions/core' @@ -12,6 +13,8 @@ import * as core from '@actions/core' const helmToolName = 'helm' export const stableHelmVersion = 'v4.3.1' +export type ChecksumPolicy = 'auto' | 'required' | 'if-available' | 'disabled' + export async function run() { let version = core.getInput('version') const versionFile = core.getInput('version-file') @@ -32,6 +35,12 @@ export async function run() { } const downloadBaseURL = core.getInput('downloadBaseURL', {required: false}) + const downloadBaseURLFallback = core.getInput('downloadBaseURLFallback', { + required: false + }) + const checksumPolicy = getChecksumPolicy( + core.getInput('checksum-policy', {required: false}) + ) if (version.toLocaleLowerCase() === 'latest') { // Only an explicit 'false' turns the fallback off, matching the @@ -40,7 +49,20 @@ export async function run() { core.getInput('latest-fallback').toLowerCase() !== 'false' version = await getLatestHelmVersion(fallbackToDefault) } else if (isMajorMinorShaped(version)) { - version = await resolveLatestPatchVersion(downloadBaseURL, version) + try { + version = await resolveLatestPatchVersion(downloadBaseURL, version) + } catch (error) { + if (!downloadBaseURLFallback) { + throw error + } + core.warning( + `Unable to resolve the latest Helm patch from '${downloadBaseURL}': ${error instanceof Error ? error.message : String(error)}. Trying fallback '${downloadBaseURLFallback}'` + ) + version = await resolveLatestPatchVersion( + downloadBaseURLFallback, + version + ) + } core.info(`Resolved latest patch Helm version to '${version}'`) } else if (version[0] !== 'v') { version = getValidVersion(version) @@ -48,7 +70,12 @@ export async function run() { } core.startGroup(`Installing ${version}`) - const cachedPath = await downloadHelm(downloadBaseURL, version) + const cachedPath = await downloadHelm( + downloadBaseURL, + version, + downloadBaseURLFallback, + checksumPolicy + ) core.endGroup() try { @@ -68,6 +95,21 @@ export function getValidVersion(version: string): string { return 'v' + version } +export function getChecksumPolicy(value: string): ChecksumPolicy { + const policy = value.trim().toLowerCase() || 'auto' + if ( + policy !== 'auto' && + policy !== 'required' && + policy !== 'if-available' && + policy !== 'disabled' + ) { + throw new Error( + `Invalid checksum-policy '${value}'. Expected auto, required, if-available, or disabled` + ) + } + return policy +} + // Matches a complete semantic version (major.minor.patch with optional // pre-release / build metadata). This is the official regex suggested at // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string @@ -336,9 +378,76 @@ export function getHelmDownloadURL(baseURL: string, version: string): string { return url.toString() } +export async function downloadAndVerifyHelm( + baseURL: string, + version: string, + policy: ChecksumPolicy = 'auto' +): Promise { + const downloadURL = getHelmDownloadURL(baseURL, version) + const effectivePolicy = resolveChecksumPolicy(policy) + + if (effectivePolicy === 'disabled') { + core.warning( + `Checksum verification is disabled for Helm downloads from '${baseURL}'` + ) + return toolCache.downloadTool(downloadURL) + } + + let checksum: string | undefined + try { + const checksumPath = await toolCache.downloadTool(`${downloadURL}.sha256`) + checksum = fs.readFileSync(checksumPath, 'utf8').trim() + } catch (error) { + if ( + effectivePolicy === 'if-available' && + isChecksumNotFoundError(error) + ) { + core.warning( + `No SHA-256 checksum is available for ${downloadURL}; continuing without verification. Set checksum-policy to 'required' to fail instead.` + ) + return toolCache.downloadTool(downloadURL) + } + throw error + } + + if (!/^[a-fA-F0-9]{64}$/.test(checksum)) { + throw new Error(`Invalid SHA-256 checksum from ${downloadURL}.sha256`) + } + + const archivePath = await toolCache.downloadTool(downloadURL) + const hash = createHash('sha256') + await new Promise((resolve, reject) => { + const archive = fs.createReadStream(archivePath) + archive.on('data', (chunk) => hash.update(chunk)) + archive.on('error', reject) + archive.on('end', resolve) + }) + const actual = hash.digest('hex') + if (actual.toLowerCase() !== checksum.toLowerCase()) { + throw new Error(`SHA-256 checksum mismatch for ${downloadURL}`) + } + + return archivePath +} + +export function resolveChecksumPolicy( + policy: ChecksumPolicy +): Exclude { + return policy === 'auto' ? 'if-available' : policy +} + +function isChecksumNotFoundError(error: unknown): boolean { + return ( + error instanceof toolCache.HTTPError && + (error.httpStatusCode === 404 || error.httpStatusCode === 410) + ) +} + export async function downloadHelm( baseURL: string, - version: string + version: string, + fallbackBaseURL?: string, + checksumPolicy: ChecksumPolicy = 'auto' ): Promise { let cachedToolpath = toolCache.find(helmToolName, version) if (cachedToolpath) { @@ -347,15 +456,40 @@ export async function downloadHelm( core.info(`Downloading '${version}' from '${baseURL}'`) let helmDownloadPath try { - helmDownloadPath = await toolCache.downloadTool( - getHelmDownloadURL(baseURL, version) + helmDownloadPath = await downloadAndVerifyHelm( + baseURL, + version, + checksumPolicy ) - } catch (exception) { + } catch (error) { + if (fallbackBaseURL) { + core.warning( + `Unable to download and verify Helm from '${baseURL}': ${error instanceof Error ? error.message : String(error)}. Trying fallback '${fallbackBaseURL}'` + ) + try { + helmDownloadPath = await downloadAndVerifyHelm( + fallbackBaseURL, + version, + checksumPolicy + ) + } catch (fallbackError) { + throw new Error( + `Failed to download and verify Helm from '${baseURL}' and fallback '${fallbackBaseURL}': ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}` + ) + } + } else { + throw new Error( + `Failed to download and verify Helm from location ${getHelmDownloadURL( + baseURL, + version + )}: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + if (!helmDownloadPath) { throw new Error( - `Failed to download Helm from location ${getHelmDownloadURL( - baseURL, - version - )}` + `Failed to download and verify Helm version '${version}'` ) }