diff --git a/package-lock.json b/package-lock.json index 3a89c38..0208a29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@biomejs/biome": "2.4.13", "@smithy/property-provider": "^4.3.4", "@types/node": "^25.9.1", - "@vitest/coverage-v8": "^4.1.6", + "@vitest/coverage-v8": "4.1.5", "aws-sdk-client-mock": "^4.1.0", "esbuild": "^0.28.0", "generate-license-file": "^4.2.1", @@ -28,7 +28,7 @@ "memfs": "^4.57.2", "standard-version": "^9.5.0", "typescript": "^6.0.3", - "vitest": "^4.1.6" + "vitest": "4.1.5" }, "engines": { "node": ">= 16.3.0" @@ -2878,14 +2878,14 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.5", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2899,8 +2899,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2909,16 +2909,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2927,13 +2927,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2954,9 +2954,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -2967,13 +2967,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -2981,14 +2981,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2997,9 +2997,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -3007,13 +3007,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -9995,19 +9995,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10035,12 +10035,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 47d1eed..57be18e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@biomejs/biome": "2.4.13", "@smithy/property-provider": "^4.3.4", "@types/node": "^25.9.1", - "@vitest/coverage-v8": "^4.1.6", + "@vitest/coverage-v8": "4.1.5", "aws-sdk-client-mock": "^4.1.0", "esbuild": "^0.28.0", "generate-license-file": "^4.2.1", @@ -30,7 +30,7 @@ "memfs": "^4.57.2", "standard-version": "^9.5.0", "typescript": "^6.0.3", - "vitest": "^4.1.6" + "vitest": "4.1.5" }, "dependencies": { "@actions/core": "^2.0.2", diff --git a/src/helpers.ts b/src/helpers.ts index 66d5828..bf662d7 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -274,6 +274,20 @@ export function getBooleanInput(name: string, options?: core.InputOptions & { de // O_NOFOLLOW is undefined on Windows. This sets it to 0 if it's not defined. const O_NOFOLLOW: number = (fs.constants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +export function isAllowListed(filePath: string): boolean { + // Kubelet projects service-account tokens through a symlink chain + // (token -> ..data/token, ..data -> ../). The containing path is + // kubelet-controlled, so we allow symlink-following reads of this fixed + // location only. + const KUBERNETES_TOKEN_PATH_REGEX = /^\/var\/run\/secrets\/[^/]+\/serviceaccount\/token$/; + + if (process.platform !== 'win32') { + // No Kubernetes token paths on Windows + return KUBERNETES_TOKEN_PATH_REGEX.test(path.posix.normalize(filePath)); + } + return false; +} + export function isSymlink(filePath: string): boolean { try { return fs.lstatSync(filePath).isSymbolicLink(); @@ -305,10 +319,14 @@ function assertRegularFile(fd: number, filePath: string): void { // ELOOP: too many symbolic links (from NOFOLLOW) export function readFileUtf8(filePath: string): string | null { - refuseSymlinkOnPath(filePath); + const allowSymlink = isAllowListed(filePath); + if (!allowSymlink) { + refuseSymlinkOnPath(filePath); + } + const openFlags = fs.constants.O_RDONLY | (allowSymlink ? 0 : O_NOFOLLOW); let fd: number; try { - fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW); + fd = fs.openSync(filePath, openFlags); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') return null; diff --git a/test/helpers.test.ts b/test/helpers.test.ts index e72af41..25f2a96 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -154,6 +154,75 @@ describe('Configure AWS Credentials helpers', {}, () => { fs.mkdirSync('/dir/subdir', { recursive: true }); expect(() => helpers.readFileUtf8('/dir/subdir')).toThrow(/not a regular file/); }); + + it.skipIf(process.platform === 'win32')( + 'follows the kubelet projected-token symlink chain at /var/run/secrets/*/serviceaccount/token', + () => { + fs.mkdirSync('/var/run/secrets/eks.amazonaws.com/serviceaccount/..2026_05_28_00_00_00.123', { + recursive: true, + }); + fs.writeFileSync( + '/var/run/secrets/eks.amazonaws.com/serviceaccount/..2026_05_28_00_00_00.123/token', + 'jwt-token', + ); + fs.symlinkSync( + '..2026_05_28_00_00_00.123', + '/var/run/secrets/eks.amazonaws.com/serviceaccount/..data', + ); + fs.symlinkSync( + '..data/token', + '/var/run/secrets/eks.amazonaws.com/serviceaccount/token', + ); + expect(helpers.readFileUtf8('/var/run/secrets/eks.amazonaws.com/serviceaccount/token')).toBe('jwt-token'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'still refuses symlinks at lookalike paths outside the allowlist', + () => { + fs.mkdirSync('/var/run/secrets/eks.amazonaws.com/serviceaccount', { recursive: true }); + fs.writeFileSync('/var/run/secrets/eks.amazonaws.com/serviceaccount/secret', 'jwt-token'); + fs.symlinkSync( + '/var/run/secrets/eks.amazonaws.com/serviceaccount/secret', + '/var/run/secrets/eks.amazonaws.com/serviceaccount/token2', + ); + expect(() => + helpers.readFileUtf8('/var/run/secrets/eks.amazonaws.com/serviceaccount/token2'), + ).toThrow(/Refusing .* \(.* symbolic link\)/); + }, + ); + }); + + describe('isAllowListed', {}, () => { + it.skipIf(process.platform === 'win32')('matches the canonical kubelet projected-token path', () => { + expect( + helpers.isAllowListed('/var/run/secrets/eks.amazonaws.com/serviceaccount/token'), + ).toBe(true); + expect( + helpers.isAllowListed('/var/run/secrets/kubernetes.io/serviceaccount/token'), + ).toBe(true); + }); + + it.skipIf(process.platform === 'win32')('rejects nested or unrelated paths', () => { + expect(helpers.isAllowListed('/var/run/secrets/serviceaccount/token')).toBe(false); + expect( + helpers.isAllowListed('/var/run/secrets/a/b/serviceaccount/token'), + ).toBe(false); + expect( + helpers.isAllowListed('/var/run/secrets/eks.amazonaws.com/serviceaccount/token2'), + ).toBe(false); + expect( + helpers.isAllowListed('/etc/var/run/secrets/foo/serviceaccount/token'), + ).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('normalizes path traversal attempts', () => { + expect( + helpers.isAllowListed( + '/var/run/secrets/foo/serviceaccount/../../../../etc/passwd', + ), + ).toBe(false); + }); }); describe('writeFileUtf8', {}, () => { diff --git a/test/index.test.ts b/test/index.test.ts index 19f8b93..e44813e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -13,6 +13,8 @@ import { run } from '../src/index'; import * as profileManager from '../src/profileManager'; import mocks from './mockinputs.test'; +vi.mock('node:fs'); + const mockedSTSClient = mockClient(STSClient); describe('Configure AWS Credentials', {}, () => { @@ -153,7 +155,6 @@ describe('Configure AWS Credentials', {}, () => { vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.WEBIDENTITY_TOKEN_FILE_INPUTS)); mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS); mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY }); - vi.mock('node:fs'); vol.reset(); fs.mkdirSync('/home/github', { recursive: true }); fs.writeFileSync('/home/github/file.txt', 'test-token'); @@ -373,7 +374,6 @@ describe('Configure AWS Credentials', {}, () => { mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; - vi.mock('node:fs'); vol.reset(); fs.mkdirSync('/home/github', { recursive: true }); fs.writeFileSync('/home/github/file.txt', 'test-token'); @@ -828,7 +828,6 @@ describe('Configure AWS Credentials', {}, () => { beforeEach(() => { vi.clearAllMocks(); mockedSTSClient.reset(); - vi.mock('node:fs'); vol.reset(); });