mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-09-02 05:55:10 +09:00
@@ -0,0 +1,67 @@
|
||||
import * as core from '@actions/core';
|
||||
import {
|
||||
AssumeRoleWithWebIdentityCommand,
|
||||
GetCallerIdentityCommand,
|
||||
STSClient,
|
||||
} from '@aws-sdk/client-sts';
|
||||
import { mockClient } from 'aws-sdk-client-mock';
|
||||
import { fs, vol } from 'memfs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as helpers from '../src/helpers';
|
||||
import { run } from '../src/index';
|
||||
import mocks from './mockinputs.test';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@actions/core');
|
||||
|
||||
const mockedSTSClient = mockClient(STSClient);
|
||||
|
||||
describe('assumeRoleWithWebIdentityTokenFile', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockedSTSClient.reset();
|
||||
vol.reset();
|
||||
helpers.withsleep(() => Promise.resolve());
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.WEBIDENTITY_TOKEN_FILE_INPUTS));
|
||||
vi.mocked(core.getMultilineInput).mockReturnValue([]);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env = { ...mocks.envs };
|
||||
fs.mkdirSync('/home/github', { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
helpers.reset();
|
||||
});
|
||||
|
||||
it('refuses when the token file is a symlink and never calls STS', async () => {
|
||||
fs.mkdirSync('/etc', { recursive: true });
|
||||
fs.writeFileSync('/etc/passwd', 'root:x:0:0::/root:/bin/sh');
|
||||
fs.symlinkSync('/etc/passwd', '/home/github/file.txt');
|
||||
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(expect.stringMatching(/Refusing .* \(.* symbolic link\)/));
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand)).toHaveLength(0);
|
||||
expect(fs.readFileSync('/etc/passwd', 'utf-8')).toBe('root:x:0:0::/root:/bin/sh');
|
||||
});
|
||||
|
||||
it('preserves the existing missing-file error when the token file does not exist', async () => {
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Web identity token file does not exist'));
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes token contents to STS when the file is regular', async () => {
|
||||
fs.writeFileSync('/home/github/file.txt', 'real-token');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
const calls = mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.args[0].input.WebIdentityToken).toBe('real-token');
|
||||
});
|
||||
});
|
||||
+99
-1
@@ -1,11 +1,16 @@
|
||||
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.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
vol.reset();
|
||||
});
|
||||
it('removes brackets from GitHub Actor', {}, () => {
|
||||
const actor = 'actor[bot]';
|
||||
@@ -97,4 +102,97 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
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\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
writeProfileFiles,
|
||||
} from '../src/profileManager';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@actions/core');
|
||||
|
||||
describe('Profile Manager', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mock('node:fs');
|
||||
vi.clearAllMocks();
|
||||
vol.reset();
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('parseIni', {}, () => {
|
||||
@@ -721,4 +722,69 @@ describe('Profile Manager', {}, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user