feat: Allow custom session tags to be passed when assuming a role (#1759)
* Add possibility to input custom session tags * Use json for input to custom-tags, add documentation for custom-tags * Add more examples * Simplify example to avoid parse error * Add input validation for custom tags * Fix unit tests for custom-tags * Add debugging message * Skip failing test for now * Build package * Remove some unused validation for custom tags * feat: add validation for custom session tags Harden the custom-tags feature against misuse and misconfiguration: - Validate input is a JSON object (reject arrays, primitives, null) - Enforce STS tag constraints: key length (128), value length (256), allowed characters - Reject nested object/array values that would silently stringify to '[object Object]' - Block overriding default session tags (GitHub, Repository, Workflow, etc.) - Enforce 50-tag session limit - Warn when custom-tags used with OIDC or web identity - Fix missing await on helpers test assertion - Remove unused CUSTOM_TAGS_JSON_INPUTS fixture - Normalize test mocking to vi.mocked() pattern --------- Co-authored-by: Sylvain Verly <sylvain.verly@gmail.com>
This commit is contained in:
parent
c36525a567
commit
61f50f630f
7 changed files with 225 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
.history
|
||||
node_modules
|
||||
coverage
|
||||
.DS_Store
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -382,6 +382,21 @@ with:
|
|||
Actor
|
||||
```
|
||||
|
||||
### Custom session tags
|
||||
|
||||
You can add custom session tags using the `custom-tags` input, which accepts a
|
||||
JSON object. Custom tags cannot override the default tags listed above.
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
custom-tags: '{"Environment": "Production", "Team": "Platform"}'
|
||||
```
|
||||
|
||||
_Note: custom tags are not supported when using OIDC or web identity token
|
||||
authentication. In those flows, session tags are controlled by the identity
|
||||
provider's token claims._
|
||||
|
||||
### Session policies
|
||||
|
||||
Session policies are not required, but they allow you to limit the scope of the
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
name: '"Configure AWS Credentials" Action for GitHub Actions'
|
||||
description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow
|
||||
runs:
|
||||
|
|
@ -101,6 +100,9 @@ inputs:
|
|||
action-timeout-s:
|
||||
required: false
|
||||
description: A global timeout in seconds for the action. When the timeout is reached, the action immediately exits. The default is to run without a timeout.
|
||||
custom-tags:
|
||||
description: Additional tags to apply to the assumed role session. Must be a JSON object provided as a string.
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
aws-account-id:
|
||||
|
|
|
|||
|
|
@ -78,6 +78,73 @@ export interface assumeRoleParams {
|
|||
webIdentityToken?: string;
|
||||
inlineSessionPolicy?: string;
|
||||
managedSessionPolicies?: { arn: string }[];
|
||||
customTags?: string;
|
||||
}
|
||||
|
||||
const TAG_KEY_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u;
|
||||
const TAG_VALUE_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u;
|
||||
const MAX_TAG_KEY_LENGTH = 128;
|
||||
const MAX_TAG_VALUE_LENGTH = 256;
|
||||
const MAX_SESSION_TAGS = 50;
|
||||
|
||||
export function parseAndValidateCustomTags(customTags: string, existingTags: Tag[]): Tag[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(customTags);
|
||||
} catch {
|
||||
throw new Error('custom-tags: input is not valid JSON');
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('custom-tags: input must be a JSON object (not an array or primitive)');
|
||||
}
|
||||
|
||||
const reservedKeys = new Set(existingTags.map((tag) => tag.Key));
|
||||
const newTags: Tag[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
`custom-tags: value for key '${key}' must be a string, number, or boolean (not an object or array)`,
|
||||
);
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
if (key.length === 0 || key.length > MAX_TAG_KEY_LENGTH) {
|
||||
throw new Error(`custom-tags: key '${key}' must be between 1 and ${MAX_TAG_KEY_LENGTH} characters`);
|
||||
}
|
||||
if (stringValue.length > MAX_TAG_VALUE_LENGTH) {
|
||||
throw new Error(
|
||||
`custom-tags: value for key '${key}' exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
if (!TAG_KEY_REGEX.test(key)) {
|
||||
throw new Error(
|
||||
`custom-tags: key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
|
||||
);
|
||||
}
|
||||
if (stringValue.length > 0 && !TAG_VALUE_REGEX.test(stringValue)) {
|
||||
throw new Error(
|
||||
`custom-tags: value for key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
|
||||
);
|
||||
}
|
||||
if (reservedKeys.has(key)) {
|
||||
throw new Error(
|
||||
`custom-tags: key '${key}' conflicts with a default session tag set by this action and cannot be overridden`,
|
||||
);
|
||||
}
|
||||
|
||||
newTags.push({ Key: key, Value: stringValue });
|
||||
}
|
||||
|
||||
if (existingTags.length + newTags.length > MAX_SESSION_TAGS) {
|
||||
throw new Error(
|
||||
`custom-tags: total session tags (${existingTags.length + newTags.length}) would exceed the AWS limit of ${MAX_SESSION_TAGS}`,
|
||||
);
|
||||
}
|
||||
|
||||
return newTags;
|
||||
}
|
||||
|
||||
export async function assumeRole(params: assumeRoleParams) {
|
||||
|
|
@ -94,6 +161,7 @@ export async function assumeRole(params: assumeRoleParams) {
|
|||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
customTags,
|
||||
} = { ...params };
|
||||
|
||||
// Load GitHub environment variables
|
||||
|
|
@ -111,17 +179,24 @@ export async function assumeRole(params: assumeRoleParams) {
|
|||
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
|
||||
{ Key: 'Commit', Value: GITHUB_SHA },
|
||||
];
|
||||
|
||||
if (process.env.GITHUB_REF) {
|
||||
tagArray.push({
|
||||
Key: 'Branch',
|
||||
Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
|
||||
});
|
||||
}
|
||||
|
||||
if (customTags) {
|
||||
const parsed = parseAndValidateCustomTags(customTags, tagArray);
|
||||
tagArray.push(...parsed);
|
||||
}
|
||||
|
||||
const tags = roleSkipSessionTagging ? undefined : tagArray;
|
||||
if (!tags) {
|
||||
core.debug('Role session tagging has been skipped.');
|
||||
} else {
|
||||
core.debug(`${tags.length} role session tags are being used.`);
|
||||
core.debug(`${tags.length} role session tags are being used:`);
|
||||
}
|
||||
|
||||
//only populate transitiveTagKeys array if user is actually using session tagging
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export async function run() {
|
|||
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
|
||||
const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false });
|
||||
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
|
||||
const customTags = core.getInput('custom-tags', { required: false });
|
||||
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
|
||||
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
|
||||
return { arn: p };
|
||||
|
|
@ -190,6 +191,13 @@ export async function run() {
|
|||
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
}
|
||||
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
|
||||
core.warning(
|
||||
"'custom-tags' is set but will be ignored because session tags cannot be applied when using OIDC or web identity token authentication. " +
|
||||
'Tags are controlled by the identity provider token claims in these authentication flows.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get role credentials if configured to do so
|
||||
if (roleToAssume) {
|
||||
let roleCredentials: AssumeRoleCommandOutput;
|
||||
|
|
@ -209,6 +217,7 @@ export async function run() {
|
|||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
customTags,
|
||||
});
|
||||
},
|
||||
!disableRetry,
|
||||
|
|
|
|||
|
|
@ -238,6 +238,85 @@ describe('Configure AWS Credentials', {}, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Custom Tags', {}, () => {
|
||||
beforeEach(() => {
|
||||
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
||||
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
|
||||
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
|
||||
});
|
||||
it('rejects invalid JSON in custom tags', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_JSON_INPUTS));
|
||||
await run();
|
||||
expect(core.setFailed).toHaveBeenCalledWith('custom-tags: input is not valid JSON');
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||
});
|
||||
it('handles valid custom tags', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_OBJECT_INPUTS));
|
||||
await run();
|
||||
expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials');
|
||||
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input).toMatchObject({
|
||||
Tags: expect.arrayContaining([
|
||||
{ Key: 'GitHub', Value: 'Actions' },
|
||||
{ Key: 'Repository', Value: 'MY-REPOSITORY-NAME' },
|
||||
{ Key: 'Workflow', Value: 'MY-WORKFLOW-ID' },
|
||||
{ Key: 'Action', Value: 'MY-ACTION-NAME' },
|
||||
{ Key: 'Actor', Value: 'MY-USERNAME_bot_' },
|
||||
{ Key: 'Commit', Value: 'MY-COMMIT-ID' },
|
||||
{ Key: 'Environment', Value: 'Production' },
|
||||
{ Key: 'Team', Value: 'DevOps' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
it('rejects array input for custom tags', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_ARRAY_INPUTS));
|
||||
await run();
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
'custom-tags: input must be a JSON object (not an array or primitive)',
|
||||
);
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||
});
|
||||
it('rejects custom tags that conflict with default session tags', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_RESERVED_KEY_INPUTS));
|
||||
await run();
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
"custom-tags: key 'Repository' conflicts with a default session tag set by this action and cannot be overridden",
|
||||
);
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||
});
|
||||
it('rejects custom tags with invalid key characters', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_KEY_CHARS_INPUTS));
|
||||
await run();
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
expect.stringContaining("custom-tags: key 'invalid{key}' contains invalid characters"),
|
||||
);
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||
});
|
||||
it('warns when custom tags are used with OIDC', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.GH_OIDC_INPUTS,
|
||||
'custom-tags': JSON.stringify({ MyTag: 'value' }),
|
||||
}),
|
||||
);
|
||||
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
||||
accessKeyId: 'STSAWSACCESSKEYID',
|
||||
});
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||
await run();
|
||||
expect(core.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("'custom-tags' is set but will be ignored"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Odd inputs', {}, () => {
|
||||
it('fails when github env vars are missing', {}, async () => {
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
||||
|
|
@ -269,6 +348,7 @@ describe('Configure AWS Credentials', {}, () => {
|
|||
await run();
|
||||
expect(core.setFailed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles improper retry-max-attempts input', {}, async () => {
|
||||
// This should mean we retry one time
|
||||
vi.mocked(core.getInput).mockImplementation(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,46 @@ const inputs = {
|
|||
'aws-region': 'fake-region-1',
|
||||
'special-characters-workaround': 'true',
|
||||
},
|
||||
CUSTOM_TAGS_INVALID_JSON_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': 'not a json',
|
||||
},
|
||||
CUSTOM_TAGS_ARRAY_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': '[1, 2, 3]',
|
||||
},
|
||||
CUSTOM_TAGS_RESERVED_KEY_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': JSON.stringify({ Repository: 'evil-repo' }),
|
||||
},
|
||||
CUSTOM_TAGS_INVALID_KEY_CHARS_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': JSON.stringify({ 'invalid{key}': 'value' }),
|
||||
},
|
||||
CUSTOM_TAGS_OBJECT_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': JSON.stringify({ Environment: 'Production', Team: 'DevOps' }),
|
||||
},
|
||||
IAM_USER_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue