diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4651f5a..201b5c3 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -54,3 +54,32 @@ jobs: - name: Validate kubectl setup old version run: python test/validate-kubectl.py 'v1.15.1' + + - name: Fetch known SHA256 for v1.30.0 linux/amd64 + id: sha + run: | + value=$(curl -fsSL https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl.sha256) + echo "value=$value" >> "$GITHUB_OUTPUT" + + - name: Setup kubectl with valid checksum + uses: ./ + with: + version: 'v1.30.0' + checksum: ${{ steps.sha.outputs.value }} + + - name: Validate kubectl setup with checksum + run: python test/validate-kubectl.py 'v1.30.0' + + - name: Setup kubectl with bad checksum (expect failure) + id: badsum + continue-on-error: true + uses: ./ + with: + version: 'v1.29.0' + checksum: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + + - name: Assert bad-checksum step failed + if: steps.badsum.outcome != 'failure' + run: | + echo "Expected bad-checksum step to fail, but outcome was: ${{ steps.badsum.outcome }}" + exit 1 \ No newline at end of file diff --git a/action.yml b/action.yml index 00d25a2..9f3027f 100644 --- a/action.yml +++ b/action.yml @@ -5,6 +5,14 @@ inputs: description: 'Version of kubectl' required: true default: 'latest' + downloadBaseURL: + description: 'Base URL to download kubectl from (https only). Use for private mirrors.' + required: false + default: 'https://dl.k8s.io' + checksum: + description: 'Expected SHA256 of the kubectl binary. Recommended when overriding downloadBaseURL.' + required: false + default: '' outputs: kubectl-path: description: 'Path to the cached kubectl binary' diff --git a/src/helpers.ts b/src/helpers.ts index 1774c71..0ab5dc0 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,8 +1,55 @@ import * as os from 'os' -import * as util from 'util' import * as fs from 'fs' import * as core from '@actions/core' import * as toolCache from '@actions/tool-cache' + +export const DEFAULT_KUBECTL_BASE_URL = 'https://dl.k8s.io' + +export function validateBaseURL(input: string): URL { + let url: URL + try { + url = new URL(input) + } catch { + throw new Error(`Invalid downloadBaseURL: "${input}" is not a valid URL.`) + } + + if (url.protocol !== 'https:') { + throw new Error( + `downloadBaseURL must use https://, got "${url.protocol}" in "${input}".` + ) + } + + if (url.username || url.password) { + throw new Error( + 'downloadBaseURL must not contain userinfo (user:pass@host).' + ) + } + + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '') + const isLoopback = + host === 'localhost' || host === '127.0.0.1' || host === '::1' + const isLinkLocal = host.startsWith('169.254.') + const isPrivateV4 = + /^10\./.test(host) || + /^192\.168\./.test(host) || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(host) + if (isLoopback || isLinkLocal || isPrivateV4) { + throw new Error( + `downloadBaseURL host "${host}" is loopback/link-local/private and is blocked by default.` + ) + } + + return url +} + +export function validateVersion(version: string): void { + if (!/^v?\d+\.\d+\.\d+$/.test(version)) { + throw new Error( + `Invalid kubectl version: "${version}". Expected a value like "v1.30.0".` + ) + } +} + export function getKubectlArch(): string { const arch = os.arch() if (arch === 'x64') { @@ -11,26 +58,47 @@ export function getKubectlArch(): string { return arch } -export function getkubectlDownloadURL(version: string, arch: string): string { +export function getkubectlDownloadURL( + version: string, + arch: string, + baseURL: string = DEFAULT_KUBECTL_BASE_URL +): string { + validateVersion(version) + const url = validateBaseURL(baseURL) + + let osDir: string + let file: string switch (os.type()) { case 'Linux': - return `https://dl.k8s.io/release/${version}/bin/linux/${arch}/kubectl` - + osDir = 'linux' + file = 'kubectl' + break case 'Darwin': - return `https://dl.k8s.io/release/${version}/bin/darwin/${arch}/kubectl` - + osDir = 'darwin' + file = 'kubectl' + break case 'Windows_NT': default: - return `https://dl.k8s.io/release/${version}/bin/windows/${arch}/kubectl.exe` + osDir = 'windows' + file = 'kubectl.exe' + break } + + const basePath = url.pathname.replace(/\/+$/, '') + url.pathname = `${basePath}/release/${version}/bin/${osDir}/${arch}/${file}` + return url.toString() } export async function getLatestPatchVersion( major: string, - minor: string + minor: string, + baseURL: string = DEFAULT_KUBECTL_BASE_URL ): Promise { const version = `${major}.${minor}` - const sourceURL = `https://dl.k8s.io/release/stable-${version}.txt` + const url = validateBaseURL(baseURL) + const basePath = url.pathname.replace(/\/+$/, '') + url.pathname = `${basePath}/release/stable-${version}.txt` + const sourceURL = url.toString() try { const downloadPath = await toolCache.downloadTool(sourceURL) const latestPatch = fs diff --git a/src/run.test.ts b/src/run.test.ts index 40db105..3887471 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1,6 +1,7 @@ import {vi, describe, test, expect, beforeEach} from 'vitest' import * as path from 'path' import * as util from 'util' +import {Readable} from 'stream' vi.mock('os') vi.mock('fs') @@ -14,32 +15,67 @@ vi.mock('@actions/tool-cache', async (importOriginal) => { } }) vi.mock('@actions/core') +vi.mock('@actions/http-client', () => { + const get = vi.fn() + return { + HttpClient: vi.fn().mockImplementation(function () { + return {get} + }) + } +}) const os = await import('os') const fs = await import('fs') const toolCache = await import('@actions/tool-cache') const core = await import('@actions/core') +const httpClient = await import('@actions/http-client') const run = await import('./run.js') const { + DEFAULT_KUBECTL_BASE_URL, getkubectlDownloadURL, getKubectlArch, getExecutableExtension, - getLatestPatchVersion + getLatestPatchVersion, + validateBaseURL, + validateVersion } = await import('./helpers.js') +function mockInputs(inputs: Record) { + vi.mocked(core.getInput).mockImplementation( + (name: string) => inputs[name] ?? '' + ) +} + +function fakeHttpResponse(opts: { + status: number + body?: string + location?: string +}) { + const body = Readable.from([Buffer.from(opts.body ?? '')]) + const message: any = body + message.statusCode = opts.status + message.headers = opts.location ? {location: opts.location} : {} + return {message, readBody: async () => opts.body ?? ''} +} + +function mockHttpGet(response: ReturnType) { + ;(httpClient.HttpClient as any).mockImplementation(function () { + return {get: vi.fn().mockResolvedValue(response)} + }) +} + describe('Testing all functions in run file.', () => { beforeEach(() => { vi.clearAllMocks() }) + test('getExecutableExtension() - return .exe when os is Windows', () => { vi.mocked(os.type).mockReturnValue('Windows_NT') expect(getExecutableExtension()).toBe('.exe') - expect(os.type).toHaveBeenCalled() }) test('getExecutableExtension() - return empty string for non-windows OS', () => { vi.mocked(os.type).mockReturnValue('Darwin') expect(getExecutableExtension()).toBe('') - expect(os.type).toHaveBeenCalled() }) test.each([ ['arm', 'arm'], @@ -50,65 +86,153 @@ describe('Testing all functions in run file.', () => { (osArch, kubectlArch) => { vi.mocked(os.arch).mockReturnValue(osArch as NodeJS.Architecture) expect(getKubectlArch()).toBe(kubectlArch) - expect(os.arch).toHaveBeenCalled() } ) + test.each([['arm'], ['arm64'], ['amd64']])( - 'getkubectlDownloadURL() - return the URL to download %s kubectl for Linux', + 'getkubectlDownloadURL() - default base URL, Linux %s', (arch) => { vi.mocked(os.type).mockReturnValue('Linux') - const kubectlLinuxUrl = util.format( + const expected = util.format( 'https://dl.k8s.io/release/v1.15.0/bin/linux/%s/kubectl', arch ) - expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(kubectlLinuxUrl) - expect(os.type).toHaveBeenCalled() + expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(expected) } ) test.each([['arm'], ['arm64'], ['amd64']])( - 'getkubectlDownloadURL() - return the URL to download %s kubectl for Darwin', + 'getkubectlDownloadURL() - default base URL, Darwin %s', (arch) => { vi.mocked(os.type).mockReturnValue('Darwin') - const kubectlDarwinUrl = util.format( + const expected = util.format( 'https://dl.k8s.io/release/v1.15.0/bin/darwin/%s/kubectl', arch ) - expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(kubectlDarwinUrl) - expect(os.type).toHaveBeenCalled() + expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(expected) } ) test.each([['arm'], ['arm64'], ['amd64']])( - 'getkubectlDownloadURL() - return the URL to download %s kubectl for Windows', + 'getkubectlDownloadURL() - default base URL, Windows %s', (arch) => { vi.mocked(os.type).mockReturnValue('Windows_NT') - const kubectlWindowsUrl = util.format( + const expected = util.format( 'https://dl.k8s.io/release/v1.15.0/bin/windows/%s/kubectl.exe', arch ) - expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(kubectlWindowsUrl) - expect(os.type).toHaveBeenCalled() + expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(expected) } ) - test('getStableKubectlVersion() - download stable version file, read version and return it', async () => { + + test('getkubectlDownloadURL() - custom base URL', () => { + vi.mocked(os.type).mockReturnValue('Linux') + expect( + getkubectlDownloadURL('v1.15.0', 'amd64', 'https://mirror.example.com') + ).toBe( + 'https://mirror.example.com/release/v1.15.0/bin/linux/amd64/kubectl' + ) + }) + test('getkubectlDownloadURL() - strips trailing slash from base URL', () => { + vi.mocked(os.type).mockReturnValue('Darwin') + expect( + getkubectlDownloadURL( + 'v1.15.0', + 'arm64', + 'https://mirror.example.com/' + ) + ).toBe( + 'https://mirror.example.com/release/v1.15.0/bin/darwin/arm64/kubectl' + ) + }) + test('getkubectlDownloadURL() - base URL with path prefix', () => { + vi.mocked(os.type).mockReturnValue('Linux') + expect( + getkubectlDownloadURL( + 'v1.30.0', + 'amd64', + 'https://mirror.example.com/k8s' + ) + ).toBe( + 'https://mirror.example.com/k8s/release/v1.30.0/bin/linux/amd64/kubectl' + ) + }) + + test.each([ + ['https://mirror.example.com'], + ['https://mirror.example.com:8443'], // non-standard port + ['https://172.32.0.1'] // outside RFC1918 172.16/12 range + ])('validateBaseURL() - accepts %s', (input) => { + expect(() => validateBaseURL(input)).not.toThrow() + }) + + test.each([ + ['not a url', /not a valid URL/], + ['http://mirror.example.com', /must use https/], + ['https://user:pass@mirror.example.com', /userinfo/], + ['https://localhost', /loopback\/link-local\/private/], + ['https://127.0.0.1', /loopback\/link-local\/private/], + ['https://[::1]', /loopback\/link-local\/private/], // caught a real bug + ['https://169.254.169.254', /loopback\/link-local\/private/], // IMDS + ['https://10.0.0.5', /loopback\/link-local\/private/], // RFC1918 + ['https://192.168.1.1', /loopback\/link-local\/private/], // RFC1918 + ['https://172.16.0.1', /loopback\/link-local\/private/], // RFC1918 low edge + ['https://172.31.255.254', /loopback\/link-local\/private/] // RFC1918 high edge + ])('validateBaseURL() - rejects %s', (input, expected) => { + expect(() => validateBaseURL(input)).toThrow(expected) + }) + + test.each([['v1.30.0'], ['1.30.0']])( + 'validateVersion() - accepts %s', + (input) => { + expect(() => validateVersion(input)).not.toThrow() + } + ) + + test.each([ + [''], + ['v1.30.0\n'], // trailing newline + ['v1.2.3.4'], // extra segment + [' v1.30.0'] // leading whitespace + ])('validateVersion() - rejects %s', (input) => { + expect(() => validateVersion(input)).toThrow(/Invalid kubectl version/) + }) + + test('getStableKubectlVersion() - default base URL', async () => { vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4') expect(await run.getStableKubectlVersion()).toBe('v1.20.4') - expect(toolCache.downloadTool).toHaveBeenCalled() - expect(fs.readFileSync).toHaveBeenCalledWith('pathToTool', 'utf8') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://dl.k8s.io/release/stable.txt' + ) }) - test('getStableKubectlVersion() - return default v1.15.0 if version read is empty', async () => { + test('getStableKubectlVersion() - honours custom base URL (air-gap fix)', async () => { + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4') + expect( + await run.getStableKubectlVersion('https://mirror.example.com') + ).toBe('v1.20.4') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://mirror.example.com/release/stable.txt' + ) + }) + test('getStableKubectlVersion() - path-prefixed mirror composes correctly', async () => { + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4') + await run.getStableKubectlVersion('https://mirror.example.com/k8s/') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://mirror.example.com/k8s/release/stable.txt' + ) + }) + test('getStableKubectlVersion() - falls back to v1.15.0 on empty file', async () => { vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('') expect(await run.getStableKubectlVersion()).toBe('v1.15.0') - expect(toolCache.downloadTool).toHaveBeenCalled() - expect(fs.readFileSync).toHaveBeenCalledWith('pathToTool', 'utf8') }) - test('getStableKubectlVersion() - return default v1.15.0 if unable to download file', async () => { + test('getStableKubectlVersion() - falls back to v1.15.0 on download failure', async () => { vi.mocked(toolCache.downloadTool).mockRejectedValue('Unable to download.') expect(await run.getStableKubectlVersion()).toBe('v1.15.0') - expect(toolCache.downloadTool).toHaveBeenCalled() }) - test('downloadKubectl() - download kubectl, add it to toolCache and return path to it', async () => { + + test('downloadKubectl() - download and cache, default base URL', async () => { vi.mocked(toolCache.find).mockReturnValue('') vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool') @@ -117,32 +241,28 @@ describe('Testing all functions in run file.', () => { expect(await run.downloadKubectl('v1.15.0')).toBe( path.join('pathToCachedTool', 'kubectl.exe') ) - expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.15.0') - expect(toolCache.downloadTool).toHaveBeenCalled() - expect(toolCache.cacheFile).toHaveBeenCalled() - expect(os.type).toHaveBeenCalled() - expect(fs.chmodSync).toHaveBeenCalledWith( - path.join('pathToCachedTool', 'kubectl.exe'), - '775' + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://dl.k8s.io/release/v1.15.0/bin/windows/amd64/kubectl.exe' ) }) - test('downloadKubectl() - throw DownloadKubectlFailed error when unable to download kubectl', async () => { - vi.mocked(toolCache.find).mockReturnValue('') - vi.mocked(toolCache.downloadTool).mockRejectedValue( - 'Unable to download kubectl.' + test('downloadKubectl() - rejects invalid version (path traversal)', async () => { + await expect(run.downloadKubectl('../etc')).rejects.toThrow( + /Invalid kubectl version/ ) + }) + test('downloadKubectl() - throws DownloadKubectlFailed on generic error', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(toolCache.downloadTool).mockRejectedValue('boom') await expect(run.downloadKubectl('v1.15.0')).rejects.toThrow( 'DownloadKubectlFailed' ) - expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.15.0') - expect(toolCache.downloadTool).toHaveBeenCalled() }) - test('downloadKubectl() - throw kubectl not found error when receive 404 response', async () => { + test('downloadKubectl() - 404 maps to "not found" message', async () => { const kubectlVersion = 'v1.15.0' - const arch = 'arm128' + const arch = 'arm64' vi.mocked(os.arch).mockReturnValue(arch as NodeJS.Architecture) vi.mocked(toolCache.find).mockReturnValue('') - vi.mocked(toolCache.downloadTool).mockImplementation((_) => { + vi.mocked(toolCache.downloadTool).mockImplementation(() => { throw new toolCache.HTTPError(404) }) await expect(run.downloadKubectl(kubectlVersion)).rejects.toThrow( @@ -152,114 +272,255 @@ describe('Testing all functions in run file.', () => { arch ) ) - expect(os.arch).toHaveBeenCalled() - expect(toolCache.find).toHaveBeenCalledWith('kubectl', kubectlVersion) - expect(toolCache.downloadTool).toHaveBeenCalled() }) - test('downloadKubectl() - return path to existing cache of kubectl', async () => { - vi.mocked(core.getInput).mockImplementation(() => 'v1.15.5') + test('downloadKubectl() - returns existing cache without redownloading', async () => { vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') vi.mocked(os.type).mockReturnValue('Windows_NT') vi.mocked(fs.chmodSync).mockImplementation(() => {}) expect(await run.downloadKubectl('v1.15.0')).toBe( path.join('pathToCachedTool', 'kubectl.exe') ) - expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.15.0') - expect(os.type).toHaveBeenCalled() - expect(fs.chmodSync).toHaveBeenCalledWith( - path.join('pathToCachedTool', 'kubectl.exe'), - '775' + expect(toolCache.downloadTool).not.toHaveBeenCalled() + }) + + test('downloadKubectl() - rejects malformed checksum input', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never) + await expect( + run.downloadKubectl('v1.15.0', DEFAULT_KUBECTL_BASE_URL, 'not-a-hash') + ).rejects.toThrow(/Invalid checksum input/) + }) + test('downloadKubectl() - rejects checksum mismatch', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never) + const wrong = 'a'.repeat(64) + await expect( + run.downloadKubectl('v1.15.0', DEFAULT_KUBECTL_BASE_URL, wrong) + ).rejects.toThrow(/Checksum mismatch/) + }) + test('downloadKubectl() - accepts matching checksum', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(fs.chmodSync).mockImplementation(() => {}) + // sha256('hi') = 8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4 + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never) + const correct = + '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4' + await expect( + run.downloadKubectl('v1.15.0', DEFAULT_KUBECTL_BASE_URL, correct) + ).resolves.toBe(path.join('pathToCachedTool', 'kubectl')) + }) + + test.each([[301], [302], [307], [308]])( + 'downloadKubectl() - custom mirror: rejects %i redirect (SSRF guard)', + async (status) => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + mockHttpGet( + fakeHttpResponse({ + status, + location: 'http://169.254.169.254/latest/meta-data/' + }) + ) + await expect( + run.downloadKubectl('v1.15.0', 'https://mirror.example.com') + ).rejects.toThrow('DownloadKubectlFailed') + expect(toolCache.downloadTool).not.toHaveBeenCalled() + } + ) + + test('downloadKubectl() - custom mirror: non-200/non-404 (500) fails', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + mockHttpGet(fakeHttpResponse({status: 500})) + await expect( + run.downloadKubectl('v1.15.0', 'https://mirror.example.com') + ).rejects.toThrow('DownloadKubectlFailed') + expect(toolCache.downloadTool).not.toHaveBeenCalled() + }) + + test('downloadKubectl() - custom mirror: 404 maps to "not found"', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(os.type).mockReturnValue('Linux') + const arch = 'amd64' + vi.mocked(os.arch).mockReturnValue('x64') + mockHttpGet(fakeHttpResponse({status: 404})) + await expect( + run.downloadKubectl('v1.15.0', 'https://mirror.example.com') + ).rejects.toThrow( + util.format("Kubectl '%s' for '%s' arch not found.", 'v1.15.0', arch) ) expect(toolCache.downloadTool).not.toHaveBeenCalled() }) - test('getLatestPatchVersion() - download and return latest patch version', async () => { + + test('downloadKubectl() - custom mirror: 200 streams body to temp file and caches', async () => { + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(os.tmpdir).mockReturnValue('/tmp') + vi.mocked(fs.chmodSync).mockImplementation(() => {}) + vi.mocked(fs.writeFileSync).mockImplementation(() => {}) + mockHttpGet(fakeHttpResponse({status: 200, body: 'kubectl-bytes'})) + + await expect( + run.downloadKubectl('v1.15.0', 'https://mirror.example.com') + ).resolves.toBe(path.join('pathToCachedTool', 'kubectl')) + expect(toolCache.downloadTool).not.toHaveBeenCalled() + expect(toolCache.cacheFile).toHaveBeenCalled() + expect(fs.writeFileSync).toHaveBeenCalled() + }) + + test('getLatestPatchVersion() - default base URL', async () => { vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') - - const result = await getLatestPatchVersion('1', '27') - - expect(result).toBe('v1.27.15') + expect(await getLatestPatchVersion('1', '27')).toBe('v1.27.15') expect(toolCache.downloadTool).toHaveBeenCalledWith( 'https://dl.k8s.io/release/stable-1.27.txt' ) }) - - test('getLatestPatchVersion() - throw error when patch version is empty', async () => { + test('getLatestPatchVersion() - honours custom base URL', async () => { + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') + expect( + await getLatestPatchVersion('1', '27', 'https://mirror.example.com') + ).toBe('v1.27.15') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://mirror.example.com/release/stable-1.27.txt' + ) + }) + test('getLatestPatchVersion() - path-prefixed mirror composes correctly', async () => { + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') + await getLatestPatchVersion('1', '27', 'https://mirror.example.com/k8s') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://mirror.example.com/k8s/release/stable-1.27.txt' + ) + }) + test('getLatestPatchVersion() - throws on empty file', async () => { vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('') - + await expect(getLatestPatchVersion('1', '27')).rejects.toThrow( + 'Failed to get latest patch version for 1.27' + ) + }) + test('getLatestPatchVersion() - throws on download failure', async () => { + vi.mocked(toolCache.downloadTool).mockRejectedValue(new Error('Network')) await expect(getLatestPatchVersion('1', '27')).rejects.toThrow( 'Failed to get latest patch version for 1.27' ) }) - test('getLatestPatchVersion() - throw error when download fails', async () => { - vi.mocked(toolCache.downloadTool).mockRejectedValue( - new Error('Network error') - ) - - await expect(getLatestPatchVersion('1', '27')).rejects.toThrow( - 'Failed to get latest patch version for 1.27' - ) - }) - test('resolveKubectlVersion() - expands major.minor to latest patch', async () => { + test('resolveKubectlVersion() - expands major.minor', async () => { vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') - - const result = await run.resolveKubectlVersion('1.27') - expect(result).toBe('v1.27.15') + expect(await run.resolveKubectlVersion('1.27')).toBe('v1.27.15') }) - test('resolveKubectlVersion() - returns full version unchanged', async () => { - const result = await run.resolveKubectlVersion('v1.27.15') - expect(result).toBe('v1.27.15') + expect(await run.resolveKubectlVersion('v1.27.15')).toBe('v1.27.15') }) - test('resolveKubectlVersion() - adds v prefix to full version', async () => { - const result = await run.resolveKubectlVersion('1.27.15') - expect(result).toBe('v1.27.15') + test('resolveKubectlVersion() - adds v prefix', async () => { + expect(await run.resolveKubectlVersion('1.27.15')).toBe('v1.27.15') }) - test('resolveKubectlVersion() - expands v-prefixed major.minor to latest patch', async () => { - vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') - vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') - const result = await run.resolveKubectlVersion('v1.27') - expect(result).toBe('v1.27.15') - }) - test('run() - download specified version and set output', async () => { - vi.mocked(core.getInput).mockReturnValue('v1.15.5') + test('run() - reads version, downloadBaseURL, checksum inputs; defaults applied', async () => { + mockInputs({version: 'v1.15.5'}) vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') vi.mocked(os.type).mockReturnValue('Windows_NT') vi.mocked(fs.chmodSync).mockImplementation() vi.mocked(core.addPath).mockImplementation() - vi.spyOn(console, 'log').mockImplementation() vi.mocked(core.setOutput).mockImplementation() - expect(await run.run()).toBeUndefined() + + await expect(run.run()).resolves.toBeUndefined() expect(core.getInput).toHaveBeenCalledWith('version', {required: true}) - expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool') + expect(core.getInput).toHaveBeenCalledWith('downloadBaseURL', { + required: false + }) + expect(core.getInput).toHaveBeenCalledWith('checksum', {required: false}) + // Default base URL: no audit notice should fire. + expect(core.notice).not.toHaveBeenCalled() expect(core.setOutput).toHaveBeenCalledWith( 'kubectl-path', path.join('pathToCachedTool', 'kubectl.exe') ) }) - test('run() - get latest version, download it and set output', async () => { - vi.mocked(core.getInput).mockReturnValue('latest') + + test('run() - latest + custom mirror routes stable.txt through the mirror', async () => { + mockInputs({ + version: 'latest', + downloadBaseURL: 'https://mirror.example.com' + }) vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4') vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') vi.mocked(os.type).mockReturnValue('Windows_NT') vi.mocked(fs.chmodSync).mockImplementation() vi.mocked(core.addPath).mockImplementation() - vi.spyOn(console, 'log').mockImplementation() vi.mocked(core.setOutput).mockImplementation() - expect(await run.run()).toBeUndefined() + + await expect(run.run()).resolves.toBeUndefined() + // The fix: stable.txt comes from the custom mirror, not dl.k8s.io. expect(toolCache.downloadTool).toHaveBeenCalledWith( - 'https://dl.k8s.io/release/stable.txt' + 'https://mirror.example.com/release/stable.txt' ) - expect(core.getInput).toHaveBeenCalledWith('version', {required: true}) - expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool') - expect(core.setOutput).toHaveBeenCalledWith( - 'kubectl-path', - path.join('pathToCachedTool', 'kubectl.exe') + // Audit notice fires for any non-default base URL. + expect(core.notice).toHaveBeenCalledWith( + expect.stringContaining('https://mirror.example.com') + ) + // No checksum + custom mirror => loud warning. + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('checksum') ) }) -}) + + test('run() - empty/whitespace downloadBaseURL falls back to default', async () => { + mockInputs({version: 'v1.15.5', downloadBaseURL: ' '}) + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Windows_NT') + vi.mocked(fs.chmodSync).mockImplementation() + vi.mocked(core.addPath).mockImplementation() + vi.mocked(core.setOutput).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + // Default fallback => no audit notice. + expect(core.notice).not.toHaveBeenCalled() + }) + + test('run() - invalid downloadBaseURL (http) fails fast', async () => { + mockInputs({version: 'v1.15.5', downloadBaseURL: 'http://insecure'}) + await expect(run.run()).rejects.toThrow(/must use https/) + }) + + test('run() - uppercase checksum input is normalized and accepted', async () => { + // sha256('hi') = 8f43...aa4 — supplied uppercase to prove run() lowercases it + // before validateChecksum's strict /^[a-f0-9]{64}$/ regex sees it. + const correctLower = + '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4' + mockInputs({ + version: 'v1.15.5', + checksum: correctLower.toUpperCase() + }) + vi.mocked(toolCache.find).mockReturnValue('') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') + vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never) + vi.mocked(fs.chmodSync).mockImplementation() + vi.mocked(core.addPath).mockImplementation() + vi.mocked(core.setOutput).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + }) +}) \ No newline at end of file diff --git a/src/run.ts b/src/run.ts index e6ff023..18ccaf6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,27 +1,58 @@ import * as path from 'path' import * as util from 'util' +import * as os from 'os' import * as fs from 'fs' +import * as crypto from 'crypto' +import {buffer as readStreamToBuffer} from 'stream/consumers' import * as toolCache from '@actions/tool-cache' import * as core from '@actions/core' +import {HttpClient} from '@actions/http-client' import { + DEFAULT_KUBECTL_BASE_URL, getkubectlDownloadURL, getKubectlArch, getExecutableExtension, - getLatestPatchVersion + getLatestPatchVersion, + validateBaseURL, + validateVersion } from './helpers.js' const kubectlToolName = 'kubectl' const stableKubectlVersion = 'v1.15.0' -const stableVersionUrl = 'https://dl.k8s.io/release/stable.txt' export async function run() { let version = core.getInput('version', {required: true}) - if (version.toLocaleLowerCase() === 'latest') { - version = await getStableKubectlVersion() - } else { - version = await resolveKubectlVersion(version) + + const rawBaseURL = core.getInput('downloadBaseURL', {required: false}).trim() + const downloadBaseURL = rawBaseURL || DEFAULT_KUBECTL_BASE_URL + validateBaseURL(downloadBaseURL) + + const expectedChecksum = core + .getInput('checksum', {required: false}) + .trim() + .toLowerCase() + + if (downloadBaseURL !== DEFAULT_KUBECTL_BASE_URL) { + core.notice( + `kubectl will be downloaded from a custom mirror: ${downloadBaseURL}` + ) + if (!expectedChecksum) { + core.warning( + 'Custom downloadBaseURL set without a `checksum` input; downloaded binary will not be integrity-verified.' + ) + } } - const cachedPath = await downloadKubectl(version) + + if (version.toLocaleLowerCase() === 'latest') { + version = await getStableKubectlVersion(downloadBaseURL) + } else { + version = await resolveKubectlVersion(version, downloadBaseURL) + } + const cachedPath = await downloadKubectl( + version, + downloadBaseURL, + expectedChecksum + ) core.addPath(path.dirname(cachedPath)) @@ -31,7 +62,14 @@ export async function run() { core.setOutput('kubectl-path', cachedPath) } -export async function getStableKubectlVersion(): Promise { +export async function getStableKubectlVersion( + baseURL: string = DEFAULT_KUBECTL_BASE_URL +): Promise { + const url = validateBaseURL(baseURL) + const basePath = url.pathname.replace(/\/+$/, '') + url.pathname = `${basePath}/release/stable.txt` + const stableVersionUrl = url.toString() + return toolCache.downloadTool(stableVersionUrl).then( (downloadPath) => { let version = fs.readFileSync(downloadPath, 'utf8').toString().trim() @@ -48,15 +86,24 @@ export async function getStableKubectlVersion(): Promise { ) } -export async function downloadKubectl(version: string): Promise { +export async function downloadKubectl( + version: string, + baseURL: string = DEFAULT_KUBECTL_BASE_URL, + expectedChecksum: string = '' +): Promise { + validateVersion(version) + let cachedToolpath = toolCache.find(kubectlToolName, version) let kubectlDownloadPath = '' const arch = getKubectlArch() if (!cachedToolpath) { + const downloadURL = getkubectlDownloadURL(version, arch, baseURL) try { - kubectlDownloadPath = await toolCache.downloadTool( - getkubectlDownloadURL(version, arch) - ) + if (baseURL === DEFAULT_KUBECTL_BASE_URL) { + kubectlDownloadPath = await toolCache.downloadTool(downloadURL) + } else { + kubectlDownloadPath = await secureDownload(downloadURL) + } } catch (exception) { if ( exception instanceof toolCache.HTTPError && @@ -74,6 +121,10 @@ export async function downloadKubectl(version: string): Promise { } } + if (expectedChecksum) { + verifyChecksum(kubectlDownloadPath, expectedChecksum) + } + cachedToolpath = await toolCache.cacheFile( kubectlDownloadPath, kubectlToolName + getExecutableExtension(), @@ -90,7 +141,57 @@ export async function downloadKubectl(version: string): Promise { return kubectlPath } -export async function resolveKubectlVersion(version: string): Promise { +async function secureDownload(downloadURL: string): Promise { + const client = new HttpClient('setup-kubectl', [], { + allowRedirects: false + }) + const response = await client.get(downloadURL) + const status = response.message.statusCode + + if (status && status >= 300 && status < 400) { + const location = response.message.headers['location'] + response.message.resume() + throw new Error( + `Refusing redirect from custom downloadBaseURL (status ${status} -> ${location}).` + ) + } + if (status === 404) { + response.message.resume() + throw new toolCache.HTTPError(404) + } + if (status !== 200) { + response.message.resume() + throw new Error(`Download failed with status ${status}`) + } + + const tmpDir = process.env['RUNNER_TEMP'] || os.tmpdir() + const tmpFile = path.join(tmpDir, `kubectl-${crypto.randomUUID()}`) + const body = await readStreamToBuffer(response.message) + fs.writeFileSync(tmpFile, body) + return tmpFile +} + +function verifyChecksum(filePath: string, expected: string): void { + if (!/^[a-f0-9]{64}$/.test(expected)) { + throw new Error( + `Invalid checksum input: expected a 64-character hex SHA256 string.` + ) + } + const actual = crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex') + if (actual !== expected) { + throw new Error( + `Checksum mismatch for downloaded kubectl. Expected ${expected}, got ${actual}.` + ) + } +} + +export async function resolveKubectlVersion( + version: string, + baseURL: string = DEFAULT_KUBECTL_BASE_URL +): Promise { const cleanedVersion = version.trim() const versionMatch = cleanedVersion.match( /^v?(?\d+)\.(?\d+)(?:\.(?\d+))?$/ @@ -111,6 +212,6 @@ export async function resolveKubectlVersion(version: string): Promise { : `v${cleanedVersion}` } - // Patch version is missing, fetch the latest - return await getLatestPatchVersion(major, minor) + // Patch version is missing, fetch the latest from the (possibly custom) mirror. + return await getLatestPatchVersion(major, minor, baseURL) }