Skip to content
Open
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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ To use the action, add a step to your workflow that uses the following syntax.
ENV_VAR_NAME, secretId2
name-transformation: (Optional) uppercase|lowercase|none
parse-json-secrets: (Optional) true|false
json-secret-keys: (Optional) |
key1
key2
auto-select-family-attempt-timeout: (Optional) positive integer
```
Parameters
Expand All @@ -60,6 +63,19 @@ Set `parse-json-secrets` to `true` to create environment variables for each key/

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.

- `json-secret-keys`

(Optional) When `parse-json-secrets` is set to `true`, you can specify which keys from the JSON secret should be extracted as environment variables. This prevents over-masking of secret values by only marking the specified keys as secrets.

If not provided, all keys in the JSON will be extracted (default behavior). If an empty list is provided, all keys will be extracted.

Each key should be listed on a separate line. For example:
```yaml
json-secret-keys: |
DATABASE_PASSWORD
API_KEY
```

- `auto-select-family-attempt-timeout`

(Optional - default 1000) Specifies the timeout (in milliseconds) for attempting to connect to the first IP address in a dual-stack DNS lookup. This setting is crucial especially when GitHub Action workers are geographically distant from the target region where the secrets are stored. The timeout must be greater than ot equal to 10 ms
Expand Down Expand Up @@ -220,6 +236,42 @@ TEST_SECRET: secretValue1
PROD_SECRET: secretValue2
```

**Example 6 Selective JSON key extraction to prevent over-masking**
The following example extracts only specific keys from a JSON secret to prevent over-masking of secret values.

```
- name: Get secrets with selective key extraction
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
database/credentials
parse-json-secrets: true
json-secret-keys: |
password
api_key
```

If the secret `database/credentials` has the following JSON value:

```json
{
"username": "admin",
"password": "super-secret-password",
"host": "db.example.com",
"port": "5432",
"api_key": "sk-1234567890abcdef"
}
```

Only these environment variables would be created:

```
DATABASE_CREDENTIALS_PASSWORD: "super-secret-password"
DATABASE_CREDENTIALS_API_KEY: "sk-1234567890abcdef"
```

The values "admin", "db.example.com", and "5432" would NOT be marked as secrets, preventing them from being masked in logs and making debugging easier.

## Security

See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
Expand Down
23 changes: 23 additions & 0 deletions __integration_tests__/selective_json_keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
describe('json-secret-keys: selective key extraction', () => {
it('Extracts only specified keys from JSON secret', () => {
// These environment variables should be set by the GitHub Action workflow
// when testing with json-secret-keys parameter
expect(process.env.SELECTIVE_JSON_SECRET_API_KEY).not.toBeUndefined();
expect(process.env.SELECTIVE_JSON_SECRET_DATABASE_PASSWORD).not.toBeUndefined();

// These should NOT be set since they weren't specified in json-secret-keys
expect(process.env.SELECTIVE_JSON_SECRET_API_USER).toBeUndefined();
expect(process.env.SELECTIVE_JSON_SECRET_DATABASE_HOST).toBeUndefined();
expect(process.env.SELECTIVE_JSON_SECRET_CONFIG_ACTIVE).toBeUndefined();
});

it('Falls back to all keys when json-secret-keys is not provided', () => {
// These environment variables should be set by the GitHub Action workflow
// when testing without json-secret-keys parameter (default behavior)
expect(process.env.FALLBACK_JSON_SECRET_API_USER).not.toBeUndefined();
expect(process.env.FALLBACK_JSON_SECRET_API_KEY).not.toBeUndefined();
expect(process.env.FALLBACK_JSON_SECRET_DATABASE_HOST).not.toBeUndefined();
expect(process.env.FALLBACK_JSON_SECRET_DATABASE_PASSWORD).not.toBeUndefined();
expect(process.env.FALLBACK_JSON_SECRET_CONFIG_ACTIVE).not.toBeUndefined();
});
});
66 changes: 50 additions & 16 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,16 @@ describe('Test main action', () => {
}
});
const booleanSpy = jest.spyOn(core, "getBooleanInput").mockReturnValue(true);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockReturnValue(
[TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT, BLANK_ALIAS_INPUT]
);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockImplementation((name) => {
switch(name) {
case 'secret-ids':
return [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT, BLANK_ALIAS_INPUT];
case 'json-secret-keys':
return []; // Empty array means extract all keys (default behavior)
default:
return [];
}
});


// Mock all Secrets Manager calls
Expand Down Expand Up @@ -169,9 +176,16 @@ describe('Test main action', () => {

test('Defaults to correct behavior with empty string alias', async () => {
const booleanSpy = jest.spyOn(core, "getBooleanInput").mockReturnValue(false);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockReturnValue(
[BLANK_ALIAS_INPUT_2, BLANK_ALIAS_INPUT_3]
);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockImplementation((name) => {
switch(name) {
case 'secret-ids':
return [BLANK_ALIAS_INPUT_2, BLANK_ALIAS_INPUT_3];
case 'json-secret-keys':
return []; // Empty array means extract all keys (default behavior)
default:
return [];
}
});

smMockClient
.on(GetSecretValueCommand, { SecretId: BLANK_NAME_2 })
Expand Down Expand Up @@ -202,9 +216,16 @@ describe('Test main action', () => {

test('Fails the action when an error occurs in Secrets Manager', async () => {
const booleanSpy = jest.spyOn(core, "getBooleanInput").mockReturnValue(true);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockReturnValue(
[TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT]
);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockImplementation((name) => {
switch(name) {
case 'secret-ids':
return [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT];
case 'json-secret-keys':
return []; // Empty array means extract all keys (default behavior)
default:
return [];
}
});

smMockClient.onAnyCommand().resolves({});

Expand All @@ -217,9 +238,16 @@ describe('Test main action', () => {

test('Fails the action when multiple secrets exported the same variable name', async () => {
const booleanSpy = jest.spyOn(core, "getBooleanInput").mockReturnValue(true);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockReturnValue(
[TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT]
);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockImplementation((name) => {
switch(name) {
case 'secret-ids':
return [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT];
case 'json-secret-keys':
return []; // Empty array means extract all keys (default behavior)
default:
return [];
}
});
const nameTransformationSpy = jest.spyOn(core, 'getInput').mockReturnValue('uppercase');

smMockClient
Expand Down Expand Up @@ -276,10 +304,16 @@ describe('Test main action', () => {
});

const booleanSpy = jest.spyOn(core, "getBooleanInput").mockReturnValue(true);
const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockReturnValue(
[TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT, BLANK_ALIAS_INPUT]
);

const multilineInputSpy = jest.spyOn(core, "getMultilineInput").mockImplementation((name) => {
switch(name) {
case 'secret-ids':
return [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT, BLANK_ALIAS_INPUT];
case 'json-secret-keys':
return []; // Empty array means extract all keys (default behavior)
default:
return [];
}
});

// Mock all Secrets Manager calls
smMockClient
Expand Down
41 changes: 41 additions & 0 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,47 @@ describe('Test secret parsing and handling', () => {
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_OPTIONS_B', 'NO');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_OPTIONS_C', '100');
});
test('Stores only specified keys when allowedKeys is provided', () => {
const allowedKeys = ['api_key'];
injectSecret(TEST_NAME, SIMPLE_JSON_SECRET, true, undefined, undefined, allowedKeys);
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_API_KEY', 'testkey');
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith('testkey');
});
test('Stores multiple specified keys when allowedKeys contains multiple keys', () => {
const allowedKeys = ['api_key', 'user'];
injectSecret(TEST_NAME, SIMPLE_JSON_SECRET, true, undefined, undefined, allowedKeys);
expect(core.exportVariable).toHaveBeenCalledTimes(2);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_API_KEY', 'testkey');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_USER', 'testuser');
expect(core.setSecret).toHaveBeenCalledTimes(2);
});
test('Skips non-allowed keys when allowedKeys is provided', () => {
const allowedKeys = ['nonexistent'];
injectSecret(TEST_NAME, SIMPLE_JSON_SECRET, true, undefined, undefined, allowedKeys);
expect(core.exportVariable).not.toHaveBeenCalled();
expect(core.setSecret).not.toHaveBeenCalled();
});
test('Falls back to all keys when allowedKeys is empty array', () => {
const allowedKeys: string[] = [];
injectSecret(TEST_NAME, SIMPLE_JSON_SECRET, true, undefined, undefined, allowedKeys);
expect(core.exportVariable).toHaveBeenCalledTimes(2);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_API_KEY', 'testkey');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_USER', 'testuser');
});
test('Selective key extraction works with nested JSON', () => {
const allowedKeys = ['host', 'config'];
injectSecret(TEST_NAME, NESTED_JSON_SECRET, true, undefined, undefined, allowedKeys);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_HOST', '127.0.0.1');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_DB_USER', 'testuser');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_DB_PASSWORD', 'testpw');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_OPTIONS_A', 'YES');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_OPTIONS_B', 'NO');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_CONFIG_OPTIONS_C', '100');
// Should not have called for 'port' since it's not in allowedKeys
expect(core.exportVariable).not.toHaveBeenCalledWith('TEST_SECRET_PORT', '3600');
});

test('Maintains single underscore between prefix and numeric properties', () => {
const secretName = 'DB';
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ inputs:
description: '(Optional) Timeout (ms) for dual-stack DNS first IP connection attempt. Needed for geographically distant GitHub action workers'
required: false
default: '1000'
json-secret-keys:
description: '(Optional) When parse-json-secrets is true, specify which keys from the JSON should be extracted as environment variables. If not provided, all keys are extracted (default behavior).'
required: false
runs:
using: 'node20'
main: 'dist/index.js'
Expand Down
9 changes: 7 additions & 2 deletions dist/cleanup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -83435,13 +83435,18 @@ function getSecretValue(client, secretId) {
* @param parseJsonSecrets: Indicates whether to deserialize JSON secrets
* @param nameTransformation: Transforms the secret name
* @param tempEnvName: If parsing JSON secrets, contains the current name for the env variable
* @param allowedKeys: If provided, only these keys will be extracted from JSON secrets
*/
function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformation, tempEnvName) {
function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformation, tempEnvName, allowedKeys) {
let secretsToCleanup = [];
if (parseJsonSecrets && isJSONString(secretValue)) {
// Recursively parses json secrets
const secretMap = JSON.parse(secretValue);
for (const k in secretMap) {
// If allowedKeys is provided and we're at the top level (no tempEnvName), check if this key is allowed
if (allowedKeys && allowedKeys.length > 0 && !tempEnvName && !allowedKeys.includes(k)) {
continue; // Skip this key if it's not in the allowed list
}
const keyValue = typeof secretMap[k] === 'string' ? secretMap[k] : JSON.stringify(secretMap[k]);
// Append the current key to the name of the env variable and check to avoid prepending an underscore
const newEnvName = [
Expand All @@ -83450,7 +83455,7 @@ function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformat
]
.filter(elem => elem) // Uses truthy-ness of elem to determine if it remains
.join("_"); // Join the remaining elements with an underscore
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName)];
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName, allowedKeys)];
}
}
else {
Expand Down
12 changes: 9 additions & 3 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -83240,6 +83240,7 @@ function run() {
const secretConfigInputs = [...new Set(core.getMultilineInput('secret-ids'))];
const parseJsonSecrets = core.getBooleanInput('parse-json-secrets');
const nameTransformation = (0, utils_1.parseTransformationFunction)(core.getInput('name-transformation'));
const jsonSecretKeys = [...new Set(core.getMultilineInput('json-secret-keys'))].filter(key => key.trim() !== '');
// Get final list of secrets to request
core.info('Building secrets list...');
const secretIds = yield (0, utils_1.buildSecretsList)(client, secretConfigInputs, nameTransformation);
Expand All @@ -83263,7 +83264,7 @@ function run() {
if (secretAlias === undefined) {
secretAlias = isArn ? secretValueResponse.name : secretId;
}
const injectedSecrets = (0, utils_1.injectSecret)(secretAlias, secretValue, parseJsonSecrets, nameTransformation);
const injectedSecrets = (0, utils_1.injectSecret)(secretAlias, secretValue, parseJsonSecrets, nameTransformation, undefined, jsonSecretKeys);
secretsToCleanup = [...secretsToCleanup, ...injectedSecrets];
}
catch (err) {
Expand Down Expand Up @@ -83464,13 +83465,18 @@ function getSecretValue(client, secretId) {
* @param parseJsonSecrets: Indicates whether to deserialize JSON secrets
* @param nameTransformation: Transforms the secret name
* @param tempEnvName: If parsing JSON secrets, contains the current name for the env variable
* @param allowedKeys: If provided, only these keys will be extracted from JSON secrets
*/
function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformation, tempEnvName) {
function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformation, tempEnvName, allowedKeys) {
let secretsToCleanup = [];
if (parseJsonSecrets && isJSONString(secretValue)) {
// Recursively parses json secrets
const secretMap = JSON.parse(secretValue);
for (const k in secretMap) {
// If allowedKeys is provided and we're at the top level (no tempEnvName), check if this key is allowed
if (allowedKeys && allowedKeys.length > 0 && !tempEnvName && !allowedKeys.includes(k)) {
continue; // Skip this key if it's not in the allowed list
}
const keyValue = typeof secretMap[k] === 'string' ? secretMap[k] : JSON.stringify(secretMap[k]);
// Append the current key to the name of the env variable and check to avoid prepending an underscore
const newEnvName = [
Expand All @@ -83479,7 +83485,7 @@ function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformat
]
.filter(elem => elem) // Uses truthy-ness of elem to determine if it remains
.join("_"); // Join the remaining elements with an underscore
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName)];
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName, allowedKeys)];
}
}
else {
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions dist/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,18 @@ function getSecretValue(client, secretId) {
* @param parseJsonSecrets: Indicates whether to deserialize JSON secrets
* @param nameTransformation: Transforms the secret name
* @param tempEnvName: If parsing JSON secrets, contains the current name for the env variable
* @param allowedKeys: If provided, only these keys will be extracted from JSON secrets
*/
function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformation, tempEnvName) {
function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformation, tempEnvName, allowedKeys) {
let secretsToCleanup = [];
if (parseJsonSecrets && isJSONString(secretValue)) {
// Recursively parses json secrets
const secretMap = JSON.parse(secretValue);
for (const k in secretMap) {
// If allowedKeys is provided and we're at the top level (no tempEnvName), check if this key is allowed
if (allowedKeys && allowedKeys.length > 0 && !tempEnvName && !allowedKeys.includes(k)) {
continue; // Skip this key if it's not in the allowed list
}
const keyValue = typeof secretMap[k] === 'string' ? secretMap[k] : JSON.stringify(secretMap[k]);
// Append the current key to the name of the env variable and check to avoid prepending an underscore
const newEnvName = [
Expand All @@ -182,7 +187,7 @@ function injectSecret(secretName, secretValue, parseJsonSecrets, nameTransformat
]
.filter(elem => elem) // Uses truthy-ness of elem to determine if it remains
.join("_"); // Join the remaining elements with an underscore
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName)];
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName, allowedKeys)];
}
}
else {
Expand Down
Loading