Files
configure-aws-credentials/test/helpers.test.ts
Tom Keller aa6526434b fix: account-ids handling, mask proxy as secret in logs (#1943)
* fix: enforce allowed-account-ids when the list contains empty entries

An empty first element previously short-circuited the allowed account
check. Empty entries are now filtered out and validation applies
whenever any non-empty entry exists.

* fix: enforce allowed-account-ids on the use-existing-credentials path

The early return for valid pre-existing credentials skipped the
allowed-account-ids check, now included.

* fix: reject newlines in names and values when writing profile files

If the profile file writing was enabled, we emitted newlines into the
file verbatim, permitting injecting arbitrary profiles into the file.
Writing now fails instead.

* fix: honor configured STS endpoint for "ambient" credentials

Ambient credential resolution built a bare STS client, so a web-identity
token found by the SDK default chain (e.g. AWS_WEB_IDENTITY_TOKEN_FILE on
a self-hosted runner) was exchanged with public STS instead of any
operator-configured sts-endpoint. Resolution now passes the configured
region, endpoint, and proxy handler to the default provider chain.

* fix: mask proxy URL credentials in job logs

Basic-auth userinfo in the http-proxy input or HTTP(S)_PROXY environment
variables was never registered as a secret, so error messages carrying
the proxy URL printed the credentials unmasked in the job log.

* fix: omit account IDs from the allowed-account-ids failure message

The mismatch error is thrown before exportAccountId registers the
account-id mask, so setFailed wrote the raw account ID (and the
configured allow-list) into a public annotation. (C4)

* chore: remove outdated examples

All of the examples were out of date and we do not have a mechanism for
keeping them up to date. Removed the examples.
2026-08-31 14:31:24 -07:00

307 lines
14 KiB
TypeScript

import * as core from '@actions/core';
import { fs, vol } from 'memfs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as helpers from '../src/helpers';
vi.mock('node:fs');
vi.mock('@actions/core');
describe('Configure AWS Credentials helpers', {}, () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vol.reset();
});
it('removes brackets from GitHub Actor', {}, () => {
const actor = 'actor[bot]';
expect(helpers.sanitizeGitHubVariables(actor)).toBe('actor_bot_');
});
it('can sleep', {}, async () => {
const sleep = helpers.defaultSleep(10);
await expect(Promise.race([sleep, new Promise((_, reject) => setTimeout(reject, 20))])).resolves.toBe(undefined);
});
it('removes special characters from workflow names', {}, () => {
expect(helpers.sanitizeGitHubVariables('sdf234@#$%$^&*()_+{}|:"<>?')).toEqual('sdf234@__________+___:____');
});
it("doesn't retry non-retryable errors", {}, async () => {
const fn = vi.fn().mockRejectedValue('i am not retryable');
await expect(helpers.retryAndBackoff(fn, false)).rejects.toMatch('i am not retryable');
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries and logs with label at info level', {}, async () => {
helpers.withsleep(() => Promise.resolve());
const fn = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValueOnce('success');
const result = await helpers.retryAndBackoff(fn, true, 3, 0, 50, 'TestOp');
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry TestOp: attempt 1 of 3 failed'));
helpers.reset();
});
it('logs max retries reached with label', {}, async () => {
helpers.withsleep(() => Promise.resolve());
const fn = vi.fn().mockRejectedValue(new Error('persistent'));
await expect(helpers.retryAndBackoff(fn, true, 2, 0, 50, 'TestOp')).rejects.toThrow('persistent');
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry TestOp: reached max retries (2)'));
helpers.reset();
});
it('retries without a label (backward compat)', {}, async () => {
helpers.withsleep(() => Promise.resolve());
const fn = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValueOnce('ok');
await helpers.retryAndBackoff(fn, true, 3);
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry: attempt 1 of 3 failed'));
helpers.reset();
});
it('can output creds when told to', {}, () => {
vi.spyOn(core, 'setOutput').mockImplementation(() => {});
vi.spyOn(core, 'setSecret').mockImplementation(() => {});
vi.spyOn(core, 'exportVariable').mockImplementation(() => {});
helpers.exportCredentials(
{ AccessKeyId: 'test', SecretAccessKey: 'test', SessionToken: 'test', Expiration: new Date(8640000000000000) },
true,
true,
);
expect(core.setOutput).toHaveBeenCalledTimes(4);
expect(core.setSecret).toHaveBeenCalledTimes(3);
expect(core.exportVariable).toHaveBeenCalledTimes(3);
});
it('can unset credentials', {}, () => {
const env = process.env;
helpers.unsetCredentials();
expect(process.env.AWS_ACCESS_KEY_ID).toBeUndefined;
expect(process.env.AWS_SECRET_ACCESS_KEY).toBeUndefined;
expect(process.env.AWS_SESSION_TOKEN).toBeUndefined;
expect(process.env.AWS_REGION).toBeUndefined;
expect(process.env.AWS_DEFAULT_REGION).toBeUndefined;
process.env = env;
});
it(`won't output credentials to env if told not to`, {}, () => {
vi.spyOn(core, 'setOutput').mockImplementation(() => {});
vi.spyOn(core, 'setSecret').mockImplementation(() => {});
vi.spyOn(core, 'exportVariable').mockImplementation(() => {});
helpers.exportCredentials(
{ AccessKeyId: 'test', SecretAccessKey: 'test', SessionToken: 'test', Expiration: new Date(8640000000000000) },
true,
false,
);
helpers.unsetCredentials(false);
helpers.exportRegion('fake-test-region', false);
expect(core.setOutput).toHaveBeenCalledTimes(4);
expect(core.setSecret).toHaveBeenCalledTimes(3);
expect(core.exportVariable).toHaveBeenCalledTimes(0);
});
it('verifies credentials without special characters', {}, () => {
expect(helpers.verifyKeys({ AccessKeyId: 'AKIATEST', SecretAccessKey: 'secretkey' })).toBe(true);
expect(helpers.verifyKeys({ AccessKeyId: 'AKIA!@#$', SecretAccessKey: 'secret' })).toBe(false);
expect(helpers.verifyKeys(undefined)).toBe(false);
});
it('translates environment variables', {}, () => {
process.env.AWS_REGION = 'us-east-1';
process.env.HTTPS_PROXY = 'https://proxy:8080';
helpers.translateEnvVariables();
expect(process.env['INPUT_AWS-REGION']).toBe('us-east-1');
expect(process.env.HTTP_PROXY).toBe('https://proxy:8080');
});
it('handles getBooleanInput correctly', {}, () => {
vi.spyOn(core, 'getInput').mockReturnValue('true');
expect(helpers.getBooleanInput('test')).toBe(true);
vi.spyOn(core, 'getInput').mockReturnValue('false');
expect(helpers.getBooleanInput('test')).toBe(false);
vi.spyOn(core, 'getInput').mockReturnValue('');
expect(helpers.getBooleanInput('test', { default: true })).toBe(true);
vi.spyOn(core, 'getInput').mockReturnValue('invalid');
expect(() => helpers.getBooleanInput('test')).toThrow();
});
it('clears session token when not provided', {}, () => {
vi.spyOn(core, 'setSecret').mockImplementation(() => {});
vi.spyOn(core, 'exportVariable').mockImplementation(() => {});
process.env.AWS_SESSION_TOKEN = 'old-token';
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
});
describe('maskProxyCredentials', {}, () => {
it('masks username and password embedded in a proxy URL', {}, () => {
helpers.maskProxyCredentials('http://user:secretpass@proxy.example.com:8080');
expect(core.setSecret).toHaveBeenCalledWith('user');
expect(core.setSecret).toHaveBeenCalledWith('secretpass');
});
it('masks both encoded and decoded forms of the credentials', {}, () => {
helpers.maskProxyCredentials('http://user:p%40ss@proxy.example.com:8080');
expect(core.setSecret).toHaveBeenCalledWith('p%40ss');
expect(core.setSecret).toHaveBeenCalledWith('p@ss');
});
it('masks the whole value even without embedded credentials or when unparseable', {}, () => {
helpers.maskProxyCredentials('http://proxy.example.com:8080');
expect(core.setSecret).toHaveBeenCalledWith('http://proxy.example.com:8080');
helpers.maskProxyCredentials('not a url');
expect(core.setSecret).toHaveBeenCalledWith('not a url');
// no username/password parts, so exactly one mask per call
expect(core.setSecret).toHaveBeenCalledTimes(2);
});
});
describe('validateAccountId', {}, () => {
it('enforces the allow-list even when the first element is empty', {}, () => {
expect(() => helpers.validateAccountId(['', '999999999999'], '111111111111')).toThrow(/does not match/);
});
it('passes an allowed account despite empty entries in the list', {}, () => {
expect(() => helpers.validateAccountId(['', '111111111111'], '111111111111')).not.toThrow();
});
it('skips validation only when no non-empty entries exist', {}, () => {
expect(() => helpers.validateAccountId(undefined, '111111111111')).not.toThrow();
expect(() => helpers.validateAccountId([], '111111111111')).not.toThrow();
expect(() => helpers.validateAccountId([''], '111111111111')).not.toThrow();
});
});
describe('filesystem helpers', {}, () => {
describe('isSymlink', {}, () => {
it('returns true for a symlink', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
fs.writeFileSync('/dir/target', 'data');
fs.symlinkSync('/dir/target', '/dir/link');
expect(helpers.isSymlink('/dir/link')).toBe(true);
});
it('returns false for a regular file', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
fs.writeFileSync('/dir/file', 'data');
expect(helpers.isSymlink('/dir/file')).toBe(false);
});
it('returns false for a missing path', {}, () => {
expect(helpers.isSymlink('/nonexistent')).toBe(false);
});
});
describe('readFileUtf8', {}, () => {
it('returns content for a regular file', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
fs.writeFileSync('/dir/file', 'hello');
expect(helpers.readFileUtf8('/dir/file')).toBe('hello');
});
it('returns null when the file does not exist', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
expect(helpers.readFileUtf8('/dir/missing')).toBe(null);
});
it('refuses to read through a symlink at the target', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
fs.writeFileSync('/dir/secret', 'sensitive');
fs.symlinkSync('/dir/secret', '/dir/link');
expect(() => helpers.readFileUtf8('/dir/link')).toThrow(/Refusing .* \(.* symbolic link\)/);
});
it('refuses to read when the parent directory is a symlink', {}, () => {
fs.mkdirSync('/real/.aws', { recursive: true });
fs.writeFileSync('/real/.aws/credentials', 'data');
fs.mkdirSync('/home', { recursive: true });
fs.symlinkSync('/real/.aws', '/home/.aws');
expect(() => helpers.readFileUtf8('/home/.aws/credentials')).toThrow(/Refusing .* \(.* symbolic link\)/);
});
it('refuses to read when the path is a directory', {}, () => {
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', {}, () => {
it('writes content with the specified mode', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
helpers.writeFileUtf8('/dir/file', 'payload', 0o600);
expect(fs.readFileSync('/dir/file', 'utf-8')).toBe('payload');
expect(fs.statSync('/dir/file').mode & 0o777).toBe(0o600);
});
it('refuses to follow a symlink at the target and leaves the target file untouched', {}, () => {
fs.mkdirSync('/dir', { recursive: true });
fs.writeFileSync('/dir/target', 'original');
fs.symlinkSync('/dir/target', '/dir/link');
expect(() => helpers.writeFileUtf8('/dir/link', 'attacker', 0o600)).toThrow(/Refusing .* \(.* symbolic link\)/);
expect(fs.readFileSync('/dir/target', 'utf-8')).toBe('original');
});
it.skipIf(process.platform === 'win32')('tightens mode on existing files', () => {
fs.mkdirSync('/dir', { recursive: true });
fs.writeFileSync('/dir/file', 'old', { mode: 0o644 });
helpers.writeFileUtf8('/dir/file', 'new', 0o600);
expect(fs.statSync('/dir/file').mode & 0o777).toBe(0o600);
});
});
describe('mkdir', {}, () => {
it('is idempotent on a regular directory', {}, () => {
helpers.mkdir('/some/nested/dir', 0o700);
helpers.mkdir('/some/nested/dir', 0o700);
expect(fs.statSync('/some/nested/dir').isDirectory()).toBe(true);
});
it('refuses when the target directory is a symlink', {}, () => {
fs.mkdirSync('/real', { recursive: true });
fs.mkdirSync('/home', { recursive: true });
fs.symlinkSync('/real', '/home/.aws');
expect(() => helpers.mkdir('/home/.aws', 0o700)).toThrow(/Refusing .* \(.* symbolic link\)/);
});
});
});
});