mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-09-02 05:55:10 +09:00
aa6526434b
* 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.
839 lines
29 KiB
TypeScript
839 lines
29 KiB
TypeScript
import * as core from '@actions/core';
|
|
import { fs, vol } from 'memfs';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
ensureAwsDirectoryExists,
|
|
getProfileFilePaths,
|
|
mergeProfileSection,
|
|
parseIni,
|
|
stringifyIni,
|
|
validateProfileName,
|
|
writeProfileFiles,
|
|
} from '../src/profileManager';
|
|
|
|
vi.mock('node:fs');
|
|
vi.mock('@actions/core');
|
|
|
|
describe('Profile Manager', {}, () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.clearAllMocks();
|
|
vol.reset();
|
|
});
|
|
|
|
describe('parseIni', {}, () => {
|
|
it('parses a single section', {}, () => {
|
|
const result = parseIni('[dev]\naws_access_key_id=AKIA\naws_secret_access_key=secret\n');
|
|
expect(result.dev).toEqual({ aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' });
|
|
});
|
|
|
|
it('parses multiple sections', {}, () => {
|
|
const result = parseIni('[dev]\nkey=dev_val\n\n[prod]\nkey=prod_val\n');
|
|
expect(result.dev.key).toBe('dev_val');
|
|
expect(result.prod.key).toBe('prod_val');
|
|
});
|
|
|
|
it('skips comments and empty lines', {}, () => {
|
|
const result = parseIni('# comment\n; another comment\n\n[dev]\nkey=val\n');
|
|
expect(result.dev).toEqual({ key: 'val' });
|
|
});
|
|
|
|
it('trims whitespace around keys and values', {}, () => {
|
|
const result = parseIni('[dev]\n key = val \n');
|
|
expect(result.dev.key).toBe('val');
|
|
});
|
|
|
|
it('preserves section names with spaces (e.g. profile prefix)', {}, () => {
|
|
const result = parseIni('[profile dev]\nregion=us-east-1\n');
|
|
expect(result['profile dev']).toEqual({ region: 'us-east-1' });
|
|
});
|
|
|
|
it('guards against __proto__ section pollution', {}, () => {
|
|
const result = parseIni('[__proto__]\npolluted=true\n[safe]\nkey=val\n');
|
|
expect(Object.getPrototypeOf(result)).not.toHaveProperty('polluted');
|
|
expect(result.safe).toEqual({ key: 'val' });
|
|
});
|
|
|
|
it('guards against __proto__ key pollution', {}, () => {
|
|
const result = parseIni('[dev]\n__proto__=evil\naws_access_key_id=AKIA\n');
|
|
expect(result.dev).toEqual({ aws_access_key_id: 'AKIA' });
|
|
expect(result.dev).not.toHaveProperty('__proto__', 'evil');
|
|
});
|
|
|
|
it('handles values containing equals signs', {}, () => {
|
|
const result = parseIni('[dev]\naws_session_token=FwoGZXIvYXdzEBYa/base64==\n');
|
|
expect(result.dev.aws_session_token).toBe('FwoGZXIvYXdzEBYa/base64==');
|
|
});
|
|
|
|
it('handles empty values', {}, () => {
|
|
const result = parseIni('[dev]\ncli_pager=\n');
|
|
expect(result.dev.cli_pager).toBe('');
|
|
});
|
|
|
|
it('returns empty object for empty input', {}, () => {
|
|
expect(parseIni('')).toEqual({});
|
|
});
|
|
|
|
it('returns empty object for whitespace-only input', {}, () => {
|
|
expect(parseIni(' \n\n \n')).toEqual({});
|
|
});
|
|
|
|
it('handles Windows line endings (CRLF)', {}, () => {
|
|
const result = parseIni('[dev]\r\naws_access_key_id=AKIA\r\naws_secret_access_key=secret\r\n');
|
|
expect(result.dev).toEqual({ aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' });
|
|
});
|
|
});
|
|
|
|
describe('stringifyIni', {}, () => {
|
|
it('serializes a single section', {}, () => {
|
|
const result = stringifyIni({ dev: { key: 'val' } });
|
|
expect(result).toBe('[dev]\nkey = val\n');
|
|
});
|
|
|
|
it('serializes multiple sections with blank line separator', {}, () => {
|
|
const result = stringifyIni({ dev: { a: '1' }, prod: { b: '2' } });
|
|
expect(result).toBe('[dev]\na = 1\n\n[prod]\nb = 2\n');
|
|
});
|
|
|
|
it('round-trips through parseIni', {}, () => {
|
|
const data = {
|
|
dev: { aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' },
|
|
'profile prod': { region: 'us-west-2' },
|
|
};
|
|
const roundTripped = parseIni(stringifyIni(data));
|
|
expect(roundTripped).toEqual(data);
|
|
});
|
|
|
|
it('handles empty data object', {}, () => {
|
|
const result = stringifyIni({});
|
|
expect(result).toBe('\n');
|
|
expect(parseIni(result)).toEqual({});
|
|
});
|
|
|
|
it('handles section with no keys', {}, () => {
|
|
const result = stringifyIni({ dev: {} });
|
|
expect(result).toBe('[dev]\n');
|
|
});
|
|
|
|
it('rejects values containing newlines', {}, () => {
|
|
expect(() =>
|
|
stringifyIni({ dev: { aws_session_token: 'token\n[injected]\ncredential_process = evil' } }),
|
|
).toThrow('must not contain newline characters');
|
|
});
|
|
|
|
it('rejects keys containing newlines', {}, () => {
|
|
expect(() => stringifyIni({ dev: { 'key\ninjected': 'val' } })).toThrow('must not contain newline characters');
|
|
});
|
|
|
|
it('rejects section names containing newlines', {}, () => {
|
|
expect(() => stringifyIni({ 'dev\r\n[injected]': { key: 'val' } })).toThrow(
|
|
'must not contain newline characters',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('validateProfileName', {}, () => {
|
|
it('accepts valid profile names', {}, () => {
|
|
expect(() => validateProfileName('dev')).not.toThrow();
|
|
expect(() => validateProfileName('production')).not.toThrow();
|
|
expect(() => validateProfileName('my-profile-123')).not.toThrow();
|
|
expect(() => validateProfileName('default')).not.toThrow();
|
|
});
|
|
|
|
it('rejects empty profile names', {}, () => {
|
|
expect(() => validateProfileName('')).toThrow('aws-profile must not be empty');
|
|
expect(() => validateProfileName(' ')).toThrow('aws-profile must not be empty');
|
|
});
|
|
|
|
it('rejects profile names with whitespace', {}, () => {
|
|
expect(() => validateProfileName('my profile')).toThrow('aws-profile must not contain whitespace');
|
|
expect(() => validateProfileName('dev\ntest')).toThrow('aws-profile must not contain whitespace');
|
|
expect(() => validateProfileName('prod\tenv')).toThrow('aws-profile must not contain whitespace');
|
|
});
|
|
|
|
it('rejects profile names with brackets', {}, () => {
|
|
expect(() => validateProfileName('dev[test]')).toThrow('aws-profile must not contain brackets');
|
|
expect(() => validateProfileName('[profile]')).toThrow('aws-profile must not contain brackets');
|
|
});
|
|
|
|
it('rejects profile names with path separators', {}, () => {
|
|
expect(() => validateProfileName('dev/test')).toThrow('aws-profile must not contain path separators');
|
|
expect(() => validateProfileName('dev\\test')).toThrow('aws-profile must not contain path separators');
|
|
expect(() => validateProfileName('../etc/passwd')).toThrow('aws-profile must not contain path separators');
|
|
});
|
|
});
|
|
|
|
describe('getProfileFilePaths', {}, () => {
|
|
it('returns default paths when env vars not set', {}, () => {
|
|
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
|
delete process.env.AWS_CONFIG_FILE;
|
|
|
|
const paths = getProfileFilePaths();
|
|
|
|
expect(paths.credentials).toMatch(/\.aws[/\\]credentials$/);
|
|
expect(paths.config).toMatch(/\.aws[/\\]config$/);
|
|
});
|
|
|
|
it('respects AWS_SHARED_CREDENTIALS_FILE env var', {}, () => {
|
|
process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom/path/credentials';
|
|
process.env.AWS_CONFIG_FILE = '/custom/path/config';
|
|
|
|
const paths = getProfileFilePaths();
|
|
|
|
expect(paths.credentials).toBe('/custom/path/credentials');
|
|
expect(paths.config).toBe('/custom/path/config');
|
|
});
|
|
});
|
|
|
|
describe('ensureAwsDirectoryExists', {}, () => {
|
|
it('creates directory if it does not exist', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
|
|
ensureAwsDirectoryExists(filePath);
|
|
|
|
expect(fs.existsSync('/home/runner/.aws')).toBe(true);
|
|
});
|
|
|
|
it('does not error if directory already exists', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
|
|
expect(() => ensureAwsDirectoryExists(filePath)).not.toThrow();
|
|
});
|
|
|
|
it('creates nested directories', {}, () => {
|
|
const filePath = '/home/runner/custom/path/.aws/credentials';
|
|
|
|
ensureAwsDirectoryExists(filePath);
|
|
|
|
expect(fs.existsSync('/home/runner/custom/path/.aws')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('mergeProfileSection', {}, () => {
|
|
it('creates new file with profile section', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
|
aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
},
|
|
false,
|
|
);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.dev).toBeDefined();
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
|
expect(parsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
|
});
|
|
|
|
it('merges with existing profiles', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
|
|
// Create initial profile
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
|
aws_secret_access_key: 'devSecretKey',
|
|
},
|
|
false,
|
|
);
|
|
|
|
// Add second profile
|
|
mergeProfileSection(
|
|
filePath,
|
|
'prod',
|
|
{
|
|
aws_access_key_id: 'AKIAPRODEXAMPLE',
|
|
aws_secret_access_key: 'prodSecretKey',
|
|
},
|
|
false,
|
|
);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.dev).toBeDefined();
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
|
expect(parsed.prod).toBeDefined();
|
|
expect(parsed.prod.aws_access_key_id).toBe('AKIAPRODEXAMPLE');
|
|
});
|
|
|
|
it('overwrites existing profile with same name', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
|
|
// Create initial profile
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'OLD_KEY',
|
|
aws_secret_access_key: 'oldSecretKey',
|
|
aws_session_token: 'oldSessionToken',
|
|
},
|
|
false,
|
|
);
|
|
|
|
// Overwrite with new credentials
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'NEW_KEY',
|
|
aws_secret_access_key: 'newSecretKey',
|
|
aws_session_token: 'newSessionToken',
|
|
},
|
|
true,
|
|
);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.dev.aws_access_key_id).toBe('NEW_KEY');
|
|
expect(parsed.dev.aws_secret_access_key).toBe('newSecretKey');
|
|
expect(parsed.dev.aws_session_token).toBe('newSessionToken');
|
|
});
|
|
|
|
it('overwriting a profile removes stale keys', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
|
|
// Create profile with session token
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'AKIA',
|
|
aws_secret_access_key: 'secret',
|
|
aws_session_token: 'old-token',
|
|
},
|
|
false,
|
|
);
|
|
|
|
// Overwrite without session token
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'AKIA2',
|
|
aws_secret_access_key: 'secret2',
|
|
},
|
|
true,
|
|
);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIA2');
|
|
expect(parsed.dev.aws_secret_access_key).toBe('secret2');
|
|
expect(parsed.dev.aws_session_token).toBeUndefined();
|
|
});
|
|
|
|
it('handles empty existing file', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
|
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'AKIA',
|
|
aws_secret_access_key: 'secret',
|
|
},
|
|
false,
|
|
);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIA');
|
|
expect(parsed.dev.aws_secret_access_key).toBe('secret');
|
|
});
|
|
|
|
it('errors if profile name already exists but overwrite flag is false', {}, () => {
|
|
const filePath = '/home/runner/.aws/credentials';
|
|
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
|
|
|
// Create initial profile
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'OLD_KEY',
|
|
aws_secret_access_key: 'oldSecretKey',
|
|
},
|
|
false,
|
|
);
|
|
|
|
// Overwrite with new credentials
|
|
expect(() =>
|
|
mergeProfileSection(
|
|
filePath,
|
|
'dev',
|
|
{
|
|
aws_access_key_id: 'NEW_KEY',
|
|
aws_secret_access_key: 'newSecretKey',
|
|
aws_session_token: 'sessionToken',
|
|
},
|
|
false,
|
|
),
|
|
).toThrow(
|
|
`Profile with name "dev" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`,
|
|
);
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.dev.aws_access_key_id).toBe('OLD_KEY');
|
|
expect(parsed.dev.aws_secret_access_key).toBe('oldSecretKey');
|
|
expect(parsed.dev.aws_session_token).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('writeProfileFiles', {}, () => {
|
|
beforeEach(() => {
|
|
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
|
delete process.env.AWS_CONFIG_FILE;
|
|
});
|
|
|
|
it('writes credentials and config for new profile', {}, () => {
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
);
|
|
|
|
// Check credentials file
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
const credParsed = parseIni(credContent);
|
|
|
|
expect(credParsed.dev).toBeDefined();
|
|
expect(credParsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
|
expect(credParsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
|
expect(credParsed.dev.aws_session_token).toBe('FwoGZXIvYXdzEBYaDEXAMPLE');
|
|
|
|
// Check config file
|
|
const configPath = getProfileFilePaths().config;
|
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
const configParsed = parseIni(configContent);
|
|
|
|
expect(configParsed['profile dev']).toBeDefined();
|
|
expect(configParsed['profile dev'].region).toBe('us-east-1');
|
|
});
|
|
|
|
it('refuses to write credentials containing newlines instead of injecting profiles', {}, () => {
|
|
expect(() =>
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
SessionToken: 'token\n[injected]\ncredential_process = evil-command',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
),
|
|
).toThrow('must not contain newline characters');
|
|
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
expect(fs.existsSync(credsPath)).toBe(false);
|
|
});
|
|
|
|
it('uses correct section naming for default profile', {}, () => {
|
|
writeProfileFiles(
|
|
'default',
|
|
{
|
|
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
},
|
|
'us-west-2',
|
|
false,
|
|
);
|
|
|
|
// Check credentials file uses [default]
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
const credParsed = parseIni(credContent);
|
|
|
|
expect(credParsed.default).toBeDefined();
|
|
expect(credParsed['profile default']).toBeUndefined();
|
|
|
|
// Check config file uses [default] (not [profile default])
|
|
const configPath = getProfileFilePaths().config;
|
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
const configParsed = parseIni(configContent);
|
|
|
|
expect(configParsed.default).toBeDefined();
|
|
expect(configParsed['profile default']).toBeUndefined();
|
|
});
|
|
|
|
it('supports multiple profiles', {}, () => {
|
|
// Write first profile
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIADEV',
|
|
SecretAccessKey: 'devSecret',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
);
|
|
|
|
// Write second profile
|
|
writeProfileFiles(
|
|
'prod',
|
|
{
|
|
AccessKeyId: 'AKIAPROD',
|
|
SecretAccessKey: 'prodSecret',
|
|
SessionToken: 'prodToken',
|
|
},
|
|
'us-west-2',
|
|
false,
|
|
);
|
|
|
|
// Verify both profiles exist
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
const credParsed = parseIni(credContent);
|
|
|
|
expect(credParsed.dev).toBeDefined();
|
|
expect(credParsed.prod).toBeDefined();
|
|
expect(credParsed.dev.aws_access_key_id).toBe('AKIADEV');
|
|
expect(credParsed.prod.aws_access_key_id).toBe('AKIAPROD');
|
|
});
|
|
|
|
it('handles credentials without session token', {}, () => {
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
);
|
|
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
const credParsed = parseIni(credContent);
|
|
|
|
expect(credParsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
|
expect(credParsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
|
expect(credParsed.dev.aws_session_token).toBeUndefined();
|
|
});
|
|
|
|
it('throws error for invalid profile name', {}, () => {
|
|
expect(() =>
|
|
writeProfileFiles(
|
|
'invalid profile',
|
|
{
|
|
AccessKeyId: 'AKIA',
|
|
SecretAccessKey: 'secret',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
),
|
|
).toThrow('Failed to write AWS profile');
|
|
expect(() =>
|
|
writeProfileFiles(
|
|
'invalid profile',
|
|
{
|
|
AccessKeyId: 'AKIA',
|
|
SecretAccessKey: 'secret',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
),
|
|
).toThrow('whitespace');
|
|
});
|
|
|
|
it('respects custom file paths from env vars', {}, () => {
|
|
process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom/credentials';
|
|
process.env.AWS_CONFIG_FILE = '/custom/config';
|
|
|
|
fs.mkdirSync('/custom', { recursive: true });
|
|
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIA',
|
|
SecretAccessKey: 'secret',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
);
|
|
|
|
expect(fs.existsSync('/custom/credentials')).toBe(true);
|
|
expect(fs.existsSync('/custom/config')).toBe(true);
|
|
});
|
|
|
|
it('logs info messages', {}, () => {
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIA',
|
|
SecretAccessKey: 'secret',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
);
|
|
|
|
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
|
expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev');
|
|
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev');
|
|
});
|
|
|
|
it('preserves pre-existing unrelated profiles in credentials file', {}, () => {
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
credsPath,
|
|
'[personal]\naws_access_key_id=AKIAPERSONAL\naws_secret_access_key=personalSecret\naws_session_token=personalToken\n',
|
|
);
|
|
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false);
|
|
|
|
const content = fs.readFileSync(credsPath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
// Pre-existing profile must be fully intact
|
|
expect(parsed.personal).toEqual({
|
|
aws_access_key_id: 'AKIAPERSONAL',
|
|
aws_secret_access_key: 'personalSecret',
|
|
aws_session_token: 'personalToken',
|
|
});
|
|
// New profile also present
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIADEV');
|
|
});
|
|
|
|
it('preserves pre-existing config with extra keys', {}, () => {
|
|
const configPath = getProfileFilePaths().config;
|
|
fs.mkdirSync(require('node:path').dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, '[profile personal]\nregion=eu-west-1\noutput=json\ncli_pager=\n');
|
|
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false);
|
|
|
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed['profile personal']).toEqual({
|
|
region: 'eu-west-1',
|
|
output: 'json',
|
|
cli_pager: '',
|
|
});
|
|
expect(parsed['profile dev'].region).toBe('us-east-1');
|
|
});
|
|
|
|
it('preserves pre-existing default profile when writing a named profile', {}, () => {
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
|
fs.writeFileSync(credsPath, '[default]\naws_access_key_id=AKIADEFAULT\naws_secret_access_key=defaultSecret\n');
|
|
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-west-2', false);
|
|
|
|
const content = fs.readFileSync(credsPath, 'utf-8');
|
|
const parsed = parseIni(content);
|
|
|
|
expect(parsed.default).toEqual({
|
|
aws_access_key_id: 'AKIADEFAULT',
|
|
aws_secret_access_key: 'defaultSecret',
|
|
});
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIADEV');
|
|
});
|
|
|
|
it('comments in pre-existing files are stripped on round-trip', {}, () => {
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
credsPath,
|
|
'# My important comment\n[personal]\naws_access_key_id=AKIA\naws_secret_access_key=secret\n',
|
|
);
|
|
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false);
|
|
|
|
const content = fs.readFileSync(credsPath, 'utf-8') as string;
|
|
|
|
// Comment is lost (known trade-off), but profile data is preserved
|
|
expect(content).not.toContain('# My important comment');
|
|
const parsed = parseIni(content);
|
|
expect(parsed.personal.aws_access_key_id).toBe('AKIA');
|
|
expect(parsed.dev.aws_access_key_id).toBe('AKIADEV');
|
|
});
|
|
|
|
it('writes empty section when credentials object has no keys', {}, () => {
|
|
writeProfileFiles('dev', {}, 'us-east-1', false);
|
|
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
const credParsed = parseIni(credContent);
|
|
|
|
// Section exists but has no credential keys
|
|
expect(credParsed.dev).toEqual({});
|
|
|
|
// Config still gets region
|
|
const configPath = getProfileFilePaths().config;
|
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
const configParsed = parseIni(configContent);
|
|
expect(configParsed['profile dev'].region).toBe('us-east-1');
|
|
});
|
|
|
|
it('resolves credentials and config paths independently from env vars', {}, () => {
|
|
process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom-creds/credentials';
|
|
// AWS_CONFIG_FILE is NOT set — should use default path
|
|
|
|
fs.mkdirSync('/custom-creds', { recursive: true });
|
|
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false);
|
|
|
|
expect(fs.existsSync('/custom-creds/credentials')).toBe(true);
|
|
// Config file should be at the default path (under homedir)
|
|
const defaultConfigPath = require('node:path').join(require('node:os').homedir(), '.aws', 'config');
|
|
expect(fs.existsSync(defaultConfigPath)).toBe(true);
|
|
});
|
|
|
|
it('produces AWS CLI-compatible INI output (golden file)', {}, () => {
|
|
writeProfileFiles(
|
|
'dev',
|
|
{
|
|
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE',
|
|
},
|
|
'us-east-1',
|
|
false,
|
|
);
|
|
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
|
|
const configPath = getProfileFilePaths().config;
|
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
|
|
// Verify exact byte-for-byte format matching AWS CLI style:
|
|
// - [section] header on its own line
|
|
// - key = value with spaces around =
|
|
// - LF line endings, trailing newline
|
|
expect(credContent).toBe(
|
|
'[dev]\n' +
|
|
'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' +
|
|
'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' +
|
|
'aws_session_token = FwoGZXIvYXdzEBYaDEXAMPLE\n',
|
|
);
|
|
expect(configContent).toBe('[profile dev]\n' + 'region = us-east-1\n');
|
|
});
|
|
|
|
it('golden file for multi-profile output', {}, () => {
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false);
|
|
writeProfileFiles(
|
|
'prod',
|
|
{ AccessKeyId: 'AKIAPROD', SecretAccessKey: 'prodSecret', SessionToken: 'prodToken' },
|
|
'us-west-2',
|
|
false,
|
|
);
|
|
|
|
const credsPath = getProfileFilePaths().credentials;
|
|
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
|
|
|
const configPath = getProfileFilePaths().config;
|
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
|
|
expect(credContent).toBe(
|
|
'[dev]\n' +
|
|
'aws_access_key_id = AKIADEV\n' +
|
|
'aws_secret_access_key = devSecret\n' +
|
|
'\n' +
|
|
'[prod]\n' +
|
|
'aws_access_key_id = AKIAPROD\n' +
|
|
'aws_secret_access_key = prodSecret\n' +
|
|
'aws_session_token = prodToken\n',
|
|
);
|
|
expect(configContent).toBe(
|
|
'[profile dev]\n' + 'region = us-east-1\n' + '\n' + '[profile prod]\n' + 'region = us-west-2\n',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('symlink hardening', {}, () => {
|
|
const credsPath = '/home/user/.aws/credentials';
|
|
const configPath = '/home/user/.aws/config';
|
|
|
|
beforeEach(() => {
|
|
process.env.AWS_SHARED_CREDENTIALS_FILE = credsPath;
|
|
process.env.AWS_CONFIG_FILE = configPath;
|
|
});
|
|
|
|
it('mergeProfileSection refuses when the credentials path is a symlink and leaves the target unchanged', {}, () => {
|
|
fs.mkdirSync('/home/user/.aws', { recursive: true });
|
|
fs.mkdirSync('/etc', { recursive: true });
|
|
fs.writeFileSync('/etc/passwd', 'root:x:0:0::/root:/bin/sh');
|
|
fs.symlinkSync('/etc/passwd', credsPath);
|
|
|
|
expect(() => mergeProfileSection(credsPath, 'dev', { aws_access_key_id: 'AKIA' }, true)).toThrow(
|
|
/Refusing .* \(.* symbolic link\)/,
|
|
);
|
|
expect(fs.readFileSync('/etc/passwd', 'utf-8')).toBe('root:x:0:0::/root:/bin/sh');
|
|
});
|
|
|
|
it('mergeProfileSection refuses when the config path is a symlink', {}, () => {
|
|
fs.mkdirSync('/home/user/.aws', { recursive: true });
|
|
fs.mkdirSync('/etc', { recursive: true });
|
|
fs.writeFileSync('/etc/sensitive', 'do not overwrite');
|
|
fs.symlinkSync('/etc/sensitive', configPath);
|
|
|
|
expect(() => mergeProfileSection(configPath, 'profile dev', { region: 'us-east-1' }, true)).toThrow(
|
|
/Refusing .* \(.* symbolic link\)/,
|
|
);
|
|
expect(fs.readFileSync('/etc/sensitive', 'utf-8')).toBe('do not overwrite');
|
|
});
|
|
|
|
it('ensureAwsDirectoryExists refuses when ~/.aws is a symlink', {}, () => {
|
|
fs.mkdirSync('/real-target', { recursive: true });
|
|
fs.mkdirSync('/home/user', { recursive: true });
|
|
fs.symlinkSync('/real-target', '/home/user/.aws');
|
|
|
|
expect(() => ensureAwsDirectoryExists(credsPath)).toThrow(/Refusing .* \(.* symbolic link\)/);
|
|
});
|
|
|
|
it('writeProfileFiles refuses to overwrite a pre-existing symlink at the credentials path', {}, () => {
|
|
fs.mkdirSync('/home/user/.aws', { recursive: true });
|
|
fs.mkdirSync('/etc', { recursive: true });
|
|
fs.writeFileSync('/etc/passwd', 'root:x:0:0::/root:/bin/sh');
|
|
fs.symlinkSync('/etc/passwd', credsPath);
|
|
|
|
expect(() =>
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', true),
|
|
).toThrow(/Refusing .* \(.* symbolic link\)/);
|
|
|
|
expect(fs.lstatSync(credsPath).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync('/etc/passwd', 'utf-8')).toBe('root:x:0:0::/root:/bin/sh');
|
|
});
|
|
|
|
it('happy path still writes both files with mode 0o600 when no symlinks are present', {}, () => {
|
|
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false);
|
|
|
|
expect(fs.statSync(credsPath).mode & 0o777).toBe(0o600);
|
|
expect(fs.statSync(configPath).mode & 0o777).toBe(0o600);
|
|
expect(fs.lstatSync(credsPath).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(configPath).isSymbolicLink()).toBe(false);
|
|
});
|
|
});
|
|
});
|