Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to skip masking for specific env vars, numeric, or specific values #3

Merged
merged 2 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,28 @@ To use the action, add a step to your workflow that uses the following syntax.

- `parse-json-secrets`

(Optional - default false) By default, the action sets the environment variable value to the entire JSON string in the secret value.
(Optional - default `false`) By default, the action sets the environment variable value to the entire JSON string in the secret value.

Set `parse-json-secrets` to `true` to create environment variables for each key/value pair in the JSON.

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.

- `public-env-vars`

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

The value of this option should be a list of environment variable names as ultimately resolved.

- `public-numerics`

(Optional - default `false`) Treat numeric secrets as standard environment variables (unmasked).

Set `public-numerics` to `true` to prevent numeric values from being masked.

- `public-values`

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

### Examples
**Example 1: Get secrets by name and by ARN**
Expand Down
204 changes: 197 additions & 7 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ const TEST_ARN_INPUT = ENV_NAME_4 + "," + TEST_ARN_1;
// 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] ),
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) => ''),
setFailed: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
warning: jest.fn(),
exportVariable: jest.fn((name: string, val: string) => process.env[name] = val),
setSecret: jest.fn(),
exportVariable: jest.fn((name: string, val: string) => process.env[name] = val),
setSecret: jest.fn(),
};
});

Expand All @@ -53,7 +53,7 @@ describe('Test main action', () => {
beforeEach(() => {
jest.clearAllMocks();
smMockClient.reset();
process.env = {...OLD_ENV, ...DEFAULT_TEST_ENV};
process.env = { ...OLD_ENV, ...DEFAULT_TEST_ENV };
});

afterEach(() => {
Expand All @@ -63,10 +63,10 @@ describe('Test main action', () => {
test('Retrieves and sets the requested secrets as environment variables, parsing JSON', async () => {
// Mock all Secrets Manager calls
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1})
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, {SecretId: TEST_NAME_2 })
.resolves({ Name: TEST_NAME_2, SecretString: SECRET_2 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.resolves({ Name: TEST_NAME_2, SecretString: SECRET_2 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_3 })
.resolves({ Name: TEST_NAME_3, SecretString: SECRET_3 })
.on(GetSecretValueCommand, { // Retrieve arn secret
Expand Down Expand Up @@ -303,4 +303,194 @@ describe('Test main action', () => {
expect(core.warning).not.toHaveBeenCalled();
});
})

describe('public-numerics', () => {
test('false - calls setSecret on numeric values', async () => {
smMockClient
.on(GetSecretValueCommand, { SecretId: 'numeric_secret' })
.resolves({ Name: 'numeric_secret', SecretString: '1234' })

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce(['numeric_secret']);
jest.spyOn(core, 'getBooleanInput').mockImplementation((option: string) => {
switch (option) {
case 'parse-json-secrets':
return true;
case 'public-numerics':
return false;
default:
return false;
}
});
jest.spyOn(core, 'getInput').mockReturnValueOnce('');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.setSecret).toHaveBeenCalled();
})

test.each(['0', '1', '123'])('true - does not call setSecret on numeric values: %s', async (secretValue: string) => {
smMockClient
.on(GetSecretValueCommand, { SecretId: 'numeric_secret' })
.resolves({ Name: 'numeric_secret', SecretString: secretValue })

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce(['numeric_secret']);
jest.spyOn(core, 'getBooleanInput').mockImplementation((option: string) => {
switch (option) {
case 'parse-json-secrets':
return true;
case 'public-numerics':
return true;
default:
return false;
}
});
jest.spyOn(core, 'getInput').mockReturnValueOnce('');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.setSecret).not.toHaveBeenCalled();
})


test.each(['string', 'false', '123abc', 'abc123', 'a1b2c'])('true - calls setSecret on non-numeric values: %s', async (secretValue: string) => {
smMockClient
.on(GetSecretValueCommand, { SecretId: 'numeric_secret' })
.resolves({ Name: 'numeric_secret', SecretString: secretValue })

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce(['numeric_secret']);
jest.spyOn(core, 'getBooleanInput').mockImplementation((option: string) => {
switch (option) {
case 'parse-json-secrets':
return true;
case 'public-numerics':
return true;
default:
return false;
}
});
jest.spyOn(core, 'getInput').mockReturnValueOnce('');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.setSecret).toHaveBeenCalledWith(secretValue);
})
})

describe('public-env-vars', () => {
test('does not call setSecret for provided env vars', async () => {
// Mock all Secrets Manager calls
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.resolves({ Name: TEST_NAME_2, SecretString: SECRET_2 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_3 })
.resolves({ Name: TEST_NAME_3, SecretString: SECRET_3 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: TEST_NAME_1
},
{
Name: TEST_NAME_2
}
]
});

jest.spyOn(core, 'getMultilineInput').mockImplementation((option: string) => {
switch (option) {
case 'secret-ids':
return [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT];
case 'public-env-vars':
return ['TEST_ONE_USER', 'TEST_TWO_USER'];
default:
return [];
}
});
jest.spyOn(core, 'getBooleanInput').mockReturnValueOnce(true);
jest.spyOn(core, 'getInput').mockReturnValueOnce('');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.exportVariable).toHaveBeenCalledTimes(7);

expect(core.exportVariable).toHaveBeenCalledWith('TEST_ONE_USER', 'admin');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_ONE_PASSWORD', 'adminpw');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_TWO_USER', 'integ');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_TWO_PASSWORD', 'integpw');

expect(core.setSecret).not.toHaveBeenCalledWith('admin');
expect(core.setSecret).toHaveBeenCalledWith('adminpw');
expect(core.setSecret).not.toHaveBeenCalledWith('integ');
expect(core.setSecret).toHaveBeenCalledWith('integpw');
})
})

describe('public-values', () => {
test('does not call setSecret for provided env var values', async () => {
// Mock all Secrets Manager calls
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.resolves({ Name: TEST_NAME_2, SecretString: SECRET_2 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_3 })
.resolves({ Name: TEST_NAME_3, SecretString: SECRET_3 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: TEST_NAME_1
},
{
Name: TEST_NAME_2
}
]
});

jest.spyOn(core, 'getMultilineInput').mockImplementation((option: string) => {
switch (option) {
case 'secret-ids':
return [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT];
case 'public-env-vars':
return [];
case 'public-values':
return ['admin', 'integ'];
default:
return [];
}
});
jest.spyOn(core, 'getBooleanInput').mockReturnValueOnce(true);
jest.spyOn(core, 'getInput').mockReturnValueOnce('');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.exportVariable).toHaveBeenCalledTimes(7);

expect(core.exportVariable).toHaveBeenCalledWith('TEST_ONE_USER', 'admin');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_ONE_PASSWORD', 'adminpw');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_TWO_USER', 'integ');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_TWO_PASSWORD', 'integpw');

expect(core.setSecret).not.toHaveBeenCalledWith('admin');
expect(core.setSecret).toHaveBeenCalledWith('adminpw');
expect(core.setSecret).not.toHaveBeenCalledWith('integ');
expect(core.setSecret).toHaveBeenCalledWith('integpw');
})
})
});
27 changes: 13 additions & 14 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
extractAliasAndSecretIdFromInput,
transformToValidEnvName,
validateOverwriteMode,
OverwriteMode
OverwriteMode,
Options
} from "../src/utils";

import { CLEANUP_NAME, LIST_SECRETS_MAX_RESULTS } from "../src/constants";
Expand All @@ -33,17 +34,15 @@ const TEST_NAME_1 = 'test/secret1';
const VALID_ARN_2 = 'arn:aws:secretsmanager:ap-south-1:123456789000:secret:test2-aBcdef';
const TEST_NAME_2 = 'test/secret2';

const NOT_MATCHING_ARN_3 = 'arn:aws:secretsmanager:us-east-1:123456789000:secret:alternativeSecret-aBcdef';
const NOT_MATCHING_TEST = 'alternativeSecret';

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: []};

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

const smClient = new SecretsManagerClient({}); // Cannot send mock directly because of type enforcement
const smMockClient = mockClient(smClient);


describe('Test secret value retrieval', () => {
beforeEach(() => {
smMockClient.reset();
Expand Down Expand Up @@ -179,7 +178,7 @@ describe('Test secret value retrieval', () => {
});

test('Throws an error if a prefix filter returns too many results', async () => {
let input = ["too/many/matches/*"];
const input = ["too/many/matches/*"];
const expectedParams = {
Filters: [
{
Expand Down Expand Up @@ -212,7 +211,7 @@ describe('Test secret value retrieval', () => {
});

test('Throws an error if a prefix filter has no results', async () => {
let input = ["no/matches/*"];
const input = ["no/matches/*"];
const expectedParams = {
Filters: [
{
Expand All @@ -235,7 +234,7 @@ describe('Test secret value retrieval', () => {
});

test('Throws an error if a prefix filter with an alias returns more than 1 result', async () => {
let input = ["SECRET_ALIAS,test/*"];
const input = ["SECRET_ALIAS,test/*"];
const expectedParams = {
Filters: [
{
Expand Down Expand Up @@ -290,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});
injectSecret(TEST_NAME, undefined, TEST_VALUE, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR, publicEnvVars: [], publicNumerics: false, publicValues: []});
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, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
injectSecret(TEST_NAME, 'ALIAS_1', TEST_VALUE, 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, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, 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, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
injectSecret(CLEANUP_NAME, undefined, TEST_VALUE, DEFAULT_OPTIONS);
}).toThrow();
});

test('Stores a variable for each JSON key value when parseJson is true', () => {
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, {parseJsonSecrets: true, overwriteMode: OverwriteMode.ERROR});
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, {...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, {parseJsonSecrets: true, overwriteMode: OverwriteMode.ERROR});
injectSecret(TEST_NAME, undefined, NESTED_JSON_SECRET, {...DEFAULT_OPTIONS, parseJsonSecrets: 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 Down
12 changes: 12 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ inputs:
description: '(Optional) Define how to handle overwriting secrets.'
required: false
default: 'error'
public-env-vars:
description: '(Optional) Treat specific secrets as standard environment variables (unmasked).'
required: false
default: ''
public-numerics:
description: '(Optional) Treat numeric secrets as standard environment variables (unmasked).'
required: false
default: 'false'
public-values:
description: '(Optional) Treat specific secret values as standard environment variables values (unmasked).'
required: false
default: ''
runs:
using: 'node16'
main: 'dist/index.js'
Expand Down
Loading