1
0
Fork 0
mirror of synced 2026-06-05 12:48:19 +00:00

Compare commits

...

1 commit

Author SHA1 Message Date
Tom Keller
9b99b21a14 fix: additional filesystem checks
Release-As: 6.1.2
2026-05-26 09:42:40 -07:00
8 changed files with 1269 additions and 1978 deletions

2600
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@
"@biomejs/biome": "2.4.13",
"@smithy/property-provider": "^4.3.4",
"@types/node": "^25.9.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^4.1.6",
"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": "^3.2.4"
"vitest": "^4.1.6"
},
"dependencies": {
"@actions/core": "^2.0.2",

View file

@ -1,11 +1,10 @@
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import * as core from '@actions/core';
import type { AssumeRoleCommandInput, STSClient, Tag } from '@aws-sdk/client-sts';
import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
import type { CredentialsClient } from './CredentialsClient';
import { errorMessage, isDefined, sanitizeGitHubVariables } from './helpers';
import { errorMessage, isDefined, readFileUtf8, sanitizeGitHubVariables } from './helpers';
async function assumeRoleWithOIDC(params: AssumeRoleCommandInput, client: STSClient, webIdentityToken: string) {
delete params.Tags;
@ -36,12 +35,12 @@ async function assumeRoleWithWebIdentityTokenFile(
const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile)
? webIdentityTokenFile
: path.join(workspace, webIdentityTokenFile);
if (!fs.existsSync(webIdentityTokenFilePath)) {
const webIdentityToken = readFileUtf8(webIdentityTokenFilePath);
if (webIdentityToken === null) {
throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`);
}
core.info('Assuming role with web identity token file');
try {
const webIdentityToken = fs.readFileSync(webIdentityTokenFilePath, 'utf8');
delete params.Tags;
const creds = await client.send(
new AssumeRoleWithWebIdentityCommand({

View file

@ -1,3 +1,5 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as core from '@actions/core';
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
import { GetCallerIdentityCommand } from '@aws-sdk/client-sts';
@ -268,3 +270,88 @@ export function getBooleanInput(name: string, options?: core.InputOptions & { de
`Support boolean input list: \`true | True | TRUE | false | False | FALSE\``,
);
}
// 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 isSymlink(filePath: string): boolean {
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
// Refuses if filePath or its parent directory is a symbolic link.
function refuseSymlinkOnPath(filePath: string): void {
const parent = path.dirname(filePath);
if (parent !== filePath && isSymlink(parent)) {
throw new Error(`Refusing ${filePath} (parent directory is a symbolic link)`);
}
if (isSymlink(filePath)) {
throw new Error(`Refusing ${filePath} (path is a symbolic link)`);
}
}
function assertRegularFile(fd: number, filePath: string): void {
const stats = fs.fstatSync(fd);
if (!stats.isFile()) {
throw new Error(`${filePath} (path is not a regular file)`);
}
}
// ENOENT: file does not exist
// ELOOP: too many symbolic links (from NOFOLLOW)
export function readFileUtf8(filePath: string): string | null {
refuseSymlinkOnPath(filePath);
let fd: number;
try {
fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return null;
if (code === 'ELOOP') {
throw new Error(`Refusing ${filePath} (path is a symbolic link)`);
}
throw err;
}
try {
assertRegularFile(fd, filePath);
return fs.readFileSync(fd, 'utf-8');
} finally {
fs.closeSync(fd);
}
}
export function writeFileUtf8(filePath: string, content: string, mode = 0o600): void {
refuseSymlinkOnPath(filePath);
let fd: number;
try {
fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | O_NOFOLLOW, mode);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ELOOP') {
throw new Error(`Refusing ${filePath} (path is a symbolic link)`);
}
throw err;
}
try {
assertRegularFile(fd, filePath);
// openSync only applies mode on creation.
// If the file already exists, we need to ensure the mode is correct.
if (process.platform !== 'win32') {
fs.fchmodSync(fd, mode);
}
fs.writeFileSync(fd, content);
} finally {
fs.closeSync(fd);
}
}
export function mkdir(dir: string, mode = 0o700): void {
fs.mkdirSync(dir, { recursive: true, mode });
if (isSymlink(dir)) {
throw new Error(`Refusing ${dir} (path is a symbolic link)`);
}
}

View file

@ -1,8 +1,8 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import * as core from '@actions/core';
import type { Credentials } from '@aws-sdk/client-sts';
import { mkdir, readFileUtf8, writeFileUtf8 } from './helpers';
/**
* Parse an INI-format string into a nested object.
@ -87,10 +87,8 @@ export function getProfileFilePaths(): ProfileFilePaths {
*/
export function ensureAwsDirectoryExists(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
core.debug(`Creating directory: ${dir}`);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
core.debug(`Ensuring directory exists: ${dir}`);
mkdir(dir, 0o700);
}
/**
@ -127,14 +125,8 @@ export function mergeProfileSection(
data: Record<string, string>,
overwriteAwsProfile: boolean,
): void {
let existingContent: Record<string, Record<string, string>> = {};
// Read existing file if it exists
if (fs.existsSync(filePath)) {
core.debug(`Reading existing file: ${filePath}`);
const fileContent = fs.readFileSync(filePath, 'utf-8');
existingContent = parseIni(fileContent);
}
const fileContent = readFileUtf8(filePath);
const existingContent: Record<string, Record<string, string>> = fileContent === null ? {} : parseIni(fileContent);
if (existingContent[sectionName] && !overwriteAwsProfile) {
throw new Error(
@ -147,7 +139,7 @@ export function mergeProfileSection(
const content = stringifyIni(existingContent);
core.debug(`Writing profile to ${filePath}`);
fs.writeFileSync(filePath, content, { mode: 0o600 });
writeFileUtf8(filePath, content, 0o600);
}
/**

67
test/assumeRole.test.ts Normal file
View file

@ -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');
});
});

View file

@ -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\)/);
});
});
});
});

View file

@ -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);
});
});
});