Skip to content

Commit

Permalink
Merge pull request #4 from useparagon/feat/file-output
Browse files Browse the repository at this point in the history
Add support for output-file
  • Loading branch information
tedparagon authored Aug 9, 2024
2 parents 11cae0c + fa7cd7a commit bcf7ad6
Show file tree
Hide file tree
Showing 10 changed files with 168 additions and 49 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ To use the action, add a step to your workflow that uses the following syntax.

Note that if the JSON uses case-sensitive keys such as "name" and "Name", the action will have duplicate name conflicts. In this case, set `parse-json-secrets` to `false` and parse the JSON secret value separately.

- `recurse-json-secrets`

(Optional - default `false`) If true, JSON secrets will be deserialized recursively instead of just at the top level. Since AWS Secrets Manager can store sets of secrets as JSON this will allow parsing those without also then parsing possible JSON strings within those child values.

- `public-env-vars`

(Optional) Treat specific secrets as standard environment variables (unmasked).
Expand All @@ -84,6 +88,10 @@ To use the action, add a step to your workflow that uses the following syntax.

(Optional) Treat specific values as standard environment variables (unmasked).

- `output-file`

(Optional) Path to file that will be populated with all `KEY=VALUE` pairs. This is in addition to injecting in environment.

### Examples
**Example 1: Get secrets by name and by ARN**
Expand Down
42 changes: 40 additions & 2 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as core from '@actions/core'
import * as fs from 'fs';
import { mockClient } from "aws-sdk-client-mock";
import {
GetSecretValueCommand, ListSecretsCommand,
Expand Down Expand Up @@ -32,12 +33,14 @@ const ENV_NAME_4 = 'ARN_ALIAS';
const SECRET_4 = "secretString2";
const TEST_ARN_INPUT = ENV_NAME_4 + "," + TEST_ARN_1;

const TEST_FILE = '.env-output-file';

// Mock the inputs for Github action
jest.mock('@actions/core', () => {
return {
getMultilineInput: jest.fn((name: string, options?: core.InputOptions) => [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT]),
getBooleanInput: jest.fn((name: string, options?: core.InputOptions) => true),
getInput: jest.fn((name: string, options?: core.InputOptions) => ''),
getInput: jest.fn((name: string, options?: core.InputOptions) => name === 'output-file' ? TEST_FILE : ''),
setFailed: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
Expand All @@ -47,6 +50,17 @@ jest.mock('@actions/core', () => {
};
});

// Mock the fs commands
jest.mock('fs', () => {
const actualFs = jest.requireActual('fs');
return {
...actualFs,
appendFileSync: jest.fn((path, data) => { }),
existsSync: jest.fn((path) => true),
truncateSync: jest.fn((path, len) => { }),
};
});

describe('Test main action', () => {
const OLD_ENV = process.env;

Expand Down Expand Up @@ -493,4 +507,28 @@ describe('Test main action', () => {
expect(core.setSecret).toHaveBeenCalledWith('integpw');
})
})
});

describe('output-file', () => {
test('Appending secrets to file', async () => {
// Mock all Secrets Manager calls
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: TEST_NAME_1
}
]
});

await run();

expect(fs.existsSync).toHaveBeenCalledWith(TEST_FILE);
expect(fs.truncateSync).toHaveBeenCalled();
expect(fs.appendFileSync).toHaveBeenCalledWith(TEST_FILE, 'TEST_ONE_USER=admin\n');
expect(fs.appendFileSync).toHaveBeenCalledWith(TEST_FILE, 'TEST_ONE_PASSWORD=adminpw\n');
})
})
});
30 changes: 19 additions & 11 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const TEST_NAME = 'test/secret';
const TEST_ENV_NAME = 'TEST_SECRET';
const TEST_VALUE = 'test!secret!value!';
const SIMPLE_JSON_SECRET = '{"api_key": "testkey", "user": "testuser"}';
const NESTED_JSON_SECRET = '{"host":"127.0.0.1", "port": "3600", "config":{"db_user":"testuser","db_password":"testpw","options":{"a":"YES","b":"NO", "c": 100 }}}';
const NESTED_JSON_SECRET = '{"host":"127.0.0.1", "port": "3600", "config":{"db_user":"testuser","db_password":"testpw","options":{"a":"YES","b":"NO","c":100}}}';

const VALID_ARN_1 = 'arn:aws:secretsmanager:us-east-1:123456789000:secret:test1-aBcdef';
const TEST_NAME_1 = 'test/secret1';
Expand All @@ -36,7 +36,7 @@ const TEST_NAME_2 = 'test/secret2';

const INVALID_ARN = 'aws:secretsmanager:us-east-1:123456789000:secret:test3-aBcdef';

const DEFAULT_OPTIONS : Options = {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR, publicEnvVars: [], publicNumerics: false, publicValues: []};
const DEFAULT_OPTIONS: Options = { parseJsonSecrets: false, recurseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR, publicEnvVars: [], publicNumerics: false, publicValues: [], outputFile: '', aggregate: new Map<string, string>() };

jest.mock('@actions/core');

Expand Down Expand Up @@ -88,7 +88,7 @@ describe('Test secret value retrieval', () => {
});

test('Throws an error if unable to retrieve the secret', async () => {
const error = new ResourceNotFoundException({$metadata: {} as any, Message: 'Error'});
const error = new ResourceNotFoundException({ $metadata: {} as any, Message: 'Error' });
smMockClient.on(GetSecretValueCommand).rejects(error);
await expect(getSecretValue(smClient, TEST_NAME)).rejects.toThrow(error);
});
Expand Down Expand Up @@ -289,38 +289,38 @@ describe('Test secret parsing and handling', () => {
* Test: injectSecret()
*/
test('Stores a simple secret', () => {
injectSecret(TEST_NAME, undefined, TEST_VALUE, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR, publicEnvVars: [], publicNumerics: false, publicValues: []});
injectSecret(TEST_NAME, undefined, TEST_VALUE, true, { parseJsonSecrets: false, recurseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR, publicEnvVars: [], publicNumerics: false, publicValues: [], outputFile: '', aggregate: new Map<string, string>() });
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith(TEST_ENV_NAME, TEST_VALUE);
});

test('Stores a simple secret with alias', () => {
injectSecret(TEST_NAME, 'ALIAS_1', TEST_VALUE, DEFAULT_OPTIONS);
injectSecret(TEST_NAME, 'ALIAS_1', TEST_VALUE, true, DEFAULT_OPTIONS);
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith('ALIAS_1', TEST_VALUE);
});

test('Stores a JSON secret as string when parseJson is false', () => {
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, DEFAULT_OPTIONS);
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, true, DEFAULT_OPTIONS);
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith(TEST_ENV_NAME, SIMPLE_JSON_SECRET);
});

test('Throws an error if reserved name is used', () => {
expect(() => {
injectSecret(CLEANUP_NAME, undefined, TEST_VALUE, DEFAULT_OPTIONS);
injectSecret(CLEANUP_NAME, undefined, TEST_VALUE, true, DEFAULT_OPTIONS);
}).toThrow();
});

test('Stores a variable for each JSON key value when parseJson is true', () => {
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, {...DEFAULT_OPTIONS, parseJsonSecrets: true});
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, true, { ...DEFAULT_OPTIONS, parseJsonSecrets: true });
expect(core.exportVariable).toHaveBeenCalledTimes(2);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_API_KEY', 'testkey');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_USER', 'testuser');
});

test('Stores a variable for nested JSON key values when parseJson is true', () => {
injectSecret(TEST_NAME, undefined, NESTED_JSON_SECRET, {...DEFAULT_OPTIONS, parseJsonSecrets: true});
test('Stores a variable for nested JSON key values when parseJson and recurseJson are true', () => {
injectSecret(TEST_NAME, undefined, NESTED_JSON_SECRET, true, { ...DEFAULT_OPTIONS, parseJsonSecrets: true, recurseJsonSecrets: true });
expect(core.setSecret).toHaveBeenCalledTimes(7);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_HOST', '127.0.0.1');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_PORT', '3600');
Expand All @@ -331,6 +331,14 @@ describe('Test secret parsing and handling', () => {
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_OPTIONS_C', '100');
});

test('Stores a variable for top level JSON key values when parseJson is true and recurseJson is false', () => {
injectSecret(TEST_NAME, undefined, NESTED_JSON_SECRET, true, { ...DEFAULT_OPTIONS, parseJsonSecrets: true });
expect(core.setSecret).toHaveBeenCalledTimes(3);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_HOST', '127.0.0.1');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_PORT', '3600');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG', '{"db_user":"testuser","db_password":"testpw","options":{"a":"YES","b":"NO","c":100}}');
});

/*
* Test: parseAliasFromId()
*/
Expand Down Expand Up @@ -427,4 +435,4 @@ describe('#validateOverwriteMode', () => {
validateOverwriteMode('invalid')
}).toThrowError("Invalid overwrite mode 'invalid'.")
})
})
})
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ inputs:
description: '(Optional) If true, JSON secrets will be deserialized, creating a secret environment variable for each key-value pair.'
required: false
default: 'false'
recurse-json-secrets:
description: '(Optional) If true, JSON secrets will be deserialized recursively instead of just at the top level.'
required: false
default: 'false'
overwrite-mode:
description: '(Optional) Define how to handle overwriting secrets.'
required: false
Expand All @@ -28,6 +32,10 @@ inputs:
description: '(Optional) Treat specific secret values as standard environment variables values (unmasked).'
required: false
default: ''
output-file:
description: '(Optional) Path to file that will be populated with all `KEY=VALUE` pairs. This is in addition to injecting in environment.'
required: false
default: ''
runs:
using: 'node16'
main: 'dist/index.js'
Expand Down
12 changes: 8 additions & 4 deletions dist/cleanup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19350,10 +19350,10 @@ exports.getSecretValue = getSecretValue;
* @param options: {@link Options}
* @param tempEnvName: If parsing JSON secrets, contains the current name for the env variable
*/
function injectSecret(secretName, secretAlias, secretValue, options, tempEnvName) {
function injectSecret(secretName, secretAlias, secretValue, topLevel, options, tempEnvName) {
let secretsToCleanup = [];
const { parseJsonSecrets, overwriteMode } = options;
if (parseJsonSecrets && isJSONString(secretValue)) {
const { parseJsonSecrets, recurseJsonSecrets, overwriteMode } = options;
if (parseJsonSecrets && isJSONString(secretValue) && (recurseJsonSecrets || topLevel)) {
// Recursively parses json secrets
const secretMap = JSON.parse(secretValue);
for (const k in secretMap) {
Expand All @@ -19362,7 +19362,7 @@ function injectSecret(secretName, secretAlias, secretValue, options, tempEnvName
const prefix = tempEnvName || (secretAlias && transformToValidEnvName(secretAlias)) || (secretAlias === undefined && transformToValidEnvName(secretName));
const envName = transformToValidEnvName(k);
const fullEnvName = prefix ? `${prefix}_${envName}` : envName;
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, secretAlias, keyValue, options, fullEnvName)];
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, secretAlias, keyValue, false, options, fullEnvName)];
}
}
else {
Expand Down Expand Up @@ -19394,6 +19394,10 @@ function injectSecret(secretName, secretAlias, secretValue, options, tempEnvName
core.debug(`Injecting secret ${secretName} as environment variable '${envName}'.`);
core.exportVariable(envName, secretValue);
secretsToCleanup.push(envName);
// Aggregate values for output file later
if (options.outputFile) {
options.aggregate.set(envName, secretValue.replace(/\n/g, '\\n'));
}
}
return secretsToCleanup;
}
Expand Down
34 changes: 28 additions & 6 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19142,6 +19142,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.run = void 0;
const core = __importStar(__nccwpck_require__(2186));
const fs = __importStar(__nccwpck_require__(7147));
const client_secrets_manager_1 = __nccwpck_require__(9600);
const utils_1 = __nccwpck_require__(1314);
const constants_1 = __nccwpck_require__(9042);
Expand All @@ -19153,15 +19154,22 @@ function run() {
const secretConfigInputs = [...new Set(core.getMultilineInput('secret-ids'))];
const overwriteMode = (0, utils_1.validateOverwriteMode)(core.getInput('overwrite-mode'));
const parseJsonSecrets = core.getBooleanInput('parse-json-secrets');
const recurseJsonSecrets = core.getBooleanInput('recurse-json-secrets');
const publicEnvVars = [...new Set(core.getMultilineInput('public-env-vars'))];
const publicNumerics = core.getBooleanInput('public-numerics');
const publicValues = [...new Set(core.getMultilineInput('public-values'))];
const outputFile = core.getInput('output-file');
// Get final list of secrets to request
core.info('Building secrets list...');
const secretIds = yield (0, utils_1.buildSecretsList)(client, secretConfigInputs);
// Keep track of secret names that will need to be cleaned from the environment
let secretsToCleanup = [];
core.info('Your secret names may be transformed in order to be valid environment variables (see README). Enable Debug logging in order to view the new environment names.');
// Clear existing file
if (outputFile && fs.existsSync(outputFile)) {
fs.truncateSync(outputFile, 0);
}
const aggregate = new Map();
// Get and inject secret values
for (let secretId of secretIds) {
// Optionally let user set an alias, i.e. `ENV_NAME,secret_name`
Expand All @@ -19172,12 +19180,15 @@ function run() {
try {
const secretValueResponse = yield (0, utils_1.getSecretValue)(client, secretId);
const secretName = isArn ? secretValueResponse.name : secretId;
const injectedSecrets = (0, utils_1.injectSecret)(secretName, secretAlias, secretValueResponse.secretValue, {
const injectedSecrets = (0, utils_1.injectSecret)(secretName, secretAlias, secretValueResponse.secretValue, true, {
overwriteMode,
parseJsonSecrets,
recurseJsonSecrets,
publicEnvVars,
publicNumerics,
publicValues
publicValues,
outputFile,
aggregate
});
secretsToCleanup = [...secretsToCleanup, ...injectedSecrets];
}
Expand All @@ -19186,6 +19197,13 @@ function run() {
core.setFailed(`Failed to fetch secret: '${secretId}'. Reason: ${err}`);
}
}
// Write output file
if (outputFile && aggregate.size > 0) {
const sorted = Array.from(aggregate.entries()).sort(([key1], [key2]) => key1.localeCompare(key2));
sorted.forEach(([key, value]) => {
fs.appendFileSync(outputFile, `${key}=${value}\n`);
});
}
// Export the names of variables to clean up after completion
core.exportVariable(constants_1.CLEANUP_NAME, JSON.stringify(secretsToCleanup));
core.info("Completed adding secrets.");
Expand Down Expand Up @@ -19364,10 +19382,10 @@ exports.getSecretValue = getSecretValue;
* @param options: {@link Options}
* @param tempEnvName: If parsing JSON secrets, contains the current name for the env variable
*/
function injectSecret(secretName, secretAlias, secretValue, options, tempEnvName) {
function injectSecret(secretName, secretAlias, secretValue, topLevel, options, tempEnvName) {
let secretsToCleanup = [];
const { parseJsonSecrets, overwriteMode } = options;
if (parseJsonSecrets && isJSONString(secretValue)) {
const { parseJsonSecrets, recurseJsonSecrets, overwriteMode } = options;
if (parseJsonSecrets && isJSONString(secretValue) && (recurseJsonSecrets || topLevel)) {
// Recursively parses json secrets
const secretMap = JSON.parse(secretValue);
for (const k in secretMap) {
Expand All @@ -19376,7 +19394,7 @@ function injectSecret(secretName, secretAlias, secretValue, options, tempEnvName
const prefix = tempEnvName || (secretAlias && transformToValidEnvName(secretAlias)) || (secretAlias === undefined && transformToValidEnvName(secretName));
const envName = transformToValidEnvName(k);
const fullEnvName = prefix ? `${prefix}_${envName}` : envName;
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, secretAlias, keyValue, options, fullEnvName)];
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, secretAlias, keyValue, false, options, fullEnvName)];
}
}
else {
Expand Down Expand Up @@ -19408,6 +19426,10 @@ function injectSecret(secretName, secretAlias, secretValue, options, tempEnvName
core.debug(`Injecting secret ${secretName} as environment variable '${envName}'.`);
core.exportVariable(envName, secretValue);
secretsToCleanup.push(envName);
// Aggregate values for output file later
if (options.outputFile) {
options.aggregate.set(envName, secretValue.replace(/\n/g, '\\n'));
}
}
return secretsToCleanup;
}
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

Loading

0 comments on commit bcf7ad6

Please sign in to comment.