Compare commits
1 commit
main
...
kellertk/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b99b21a14 |
8 changed files with 1269 additions and 1978 deletions
2600
package-lock.json
generated
2600
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,7 @@
|
||||||
"@biomejs/biome": "2.4.13",
|
"@biomejs/biome": "2.4.13",
|
||||||
"@smithy/property-provider": "^4.3.4",
|
"@smithy/property-provider": "^4.3.4",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"aws-sdk-client-mock": "^4.1.0",
|
"aws-sdk-client-mock": "^4.1.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"generate-license-file": "^4.2.1",
|
"generate-license-file": "^4.2.1",
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"memfs": "^4.57.2",
|
"memfs": "^4.57.2",
|
||||||
"standard-version": "^9.5.0",
|
"standard-version": "^9.5.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.1.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^2.0.2",
|
"@actions/core": "^2.0.2",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import type { AssumeRoleCommandInput, STSClient, Tag } from '@aws-sdk/client-sts';
|
import type { AssumeRoleCommandInput, STSClient, Tag } from '@aws-sdk/client-sts';
|
||||||
import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
|
import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
|
||||||
import type { CredentialsClient } from './CredentialsClient';
|
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) {
|
async function assumeRoleWithOIDC(params: AssumeRoleCommandInput, client: STSClient, webIdentityToken: string) {
|
||||||
delete params.Tags;
|
delete params.Tags;
|
||||||
|
|
@ -36,12 +35,12 @@ async function assumeRoleWithWebIdentityTokenFile(
|
||||||
const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile)
|
const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile)
|
||||||
? webIdentityTokenFile
|
? webIdentityTokenFile
|
||||||
: path.join(workspace, 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}`);
|
throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`);
|
||||||
}
|
}
|
||||||
core.info('Assuming role with web identity token file');
|
core.info('Assuming role with web identity token file');
|
||||||
try {
|
try {
|
||||||
const webIdentityToken = fs.readFileSync(webIdentityTokenFilePath, 'utf8');
|
|
||||||
delete params.Tags;
|
delete params.Tags;
|
||||||
const creds = await client.send(
|
const creds = await client.send(
|
||||||
new AssumeRoleWithWebIdentityCommand({
|
new AssumeRoleWithWebIdentityCommand({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
|
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
|
||||||
import { GetCallerIdentityCommand } 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\``,
|
`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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import type { Credentials } from '@aws-sdk/client-sts';
|
import type { Credentials } from '@aws-sdk/client-sts';
|
||||||
|
import { mkdir, readFileUtf8, writeFileUtf8 } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an INI-format string into a nested object.
|
* Parse an INI-format string into a nested object.
|
||||||
|
|
@ -87,10 +87,8 @@ export function getProfileFilePaths(): ProfileFilePaths {
|
||||||
*/
|
*/
|
||||||
export function ensureAwsDirectoryExists(filePath: string): void {
|
export function ensureAwsDirectoryExists(filePath: string): void {
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
if (!fs.existsSync(dir)) {
|
core.debug(`Ensuring directory exists: ${dir}`);
|
||||||
core.debug(`Creating directory: ${dir}`);
|
mkdir(dir, 0o700);
|
||||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -127,14 +125,8 @@ export function mergeProfileSection(
|
||||||
data: Record<string, string>,
|
data: Record<string, string>,
|
||||||
overwriteAwsProfile: boolean,
|
overwriteAwsProfile: boolean,
|
||||||
): void {
|
): void {
|
||||||
let existingContent: Record<string, Record<string, string>> = {};
|
const fileContent = readFileUtf8(filePath);
|
||||||
|
const existingContent: Record<string, Record<string, string>> = fileContent === null ? {} : parseIni(fileContent);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingContent[sectionName] && !overwriteAwsProfile) {
|
if (existingContent[sectionName] && !overwriteAwsProfile) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -147,7 +139,7 @@ export function mergeProfileSection(
|
||||||
const content = stringifyIni(existingContent);
|
const content = stringifyIni(existingContent);
|
||||||
|
|
||||||
core.debug(`Writing profile to ${filePath}`);
|
core.debug(`Writing profile to ${filePath}`);
|
||||||
fs.writeFileSync(filePath, content, { mode: 0o600 });
|
writeFileUtf8(filePath, content, 0o600);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
67
test/assumeRole.test.ts
Normal file
67
test/assumeRole.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
|
import { fs, vol } from 'memfs';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as helpers from '../src/helpers';
|
import * as helpers from '../src/helpers';
|
||||||
|
|
||||||
|
vi.mock('node:fs');
|
||||||
|
vi.mock('@actions/core');
|
||||||
|
|
||||||
describe('Configure AWS Credentials helpers', {}, () => {
|
describe('Configure AWS Credentials helpers', {}, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
vi.clearAllMocks();
|
||||||
|
vol.reset();
|
||||||
});
|
});
|
||||||
it('removes brackets from GitHub Actor', {}, () => {
|
it('removes brackets from GitHub Actor', {}, () => {
|
||||||
const actor = 'actor[bot]';
|
const actor = 'actor[bot]';
|
||||||
|
|
@ -97,4 +102,97 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||||
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
|
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
|
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,
|
writeProfileFiles,
|
||||||
} from '../src/profileManager';
|
} from '../src/profileManager';
|
||||||
|
|
||||||
|
vi.mock('node:fs');
|
||||||
|
vi.mock('@actions/core');
|
||||||
|
|
||||||
describe('Profile Manager', {}, () => {
|
describe('Profile Manager', {}, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.mock('node:fs');
|
vi.clearAllMocks();
|
||||||
vol.reset();
|
vol.reset();
|
||||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
|
||||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseIni', {}, () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue