From 61f50f630f383628add73c1eab3f1935ba07da2b Mon Sep 17 00:00:00 2001 From: Tom Keller <1083460+kellertk@users.noreply.github.com> Date: Wed, 6 May 2026 15:22:56 -0700 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 15 ++++++++ action.yml | 4 +- src/assumeRole.ts | 77 +++++++++++++++++++++++++++++++++++++- src/index.ts | 9 +++++ test/index.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++- test/mockinputs.test.ts | 40 ++++++++++++++++++++ 7 files changed, 225 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index bc18168..6841d21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.history node_modules coverage .DS_Store diff --git a/README.md b/README.md index f53c131..a05eb6f 100644 --- a/README.md +++ b/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 diff --git a/action.yml b/action.yml index e6448c7..1228533 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/src/assumeRole.ts b/src/assumeRole.ts index 879d81c..a0fd61c 100644 --- a/src/assumeRole.ts +++ b/src/assumeRole.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 5ea2a32..b7e5c9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/test/index.test.ts b/test/index.test.ts index f27ee13..84e25a1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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( @@ -712,7 +792,7 @@ describe('Configure AWS Credentials', {}, () => { // Get the timeout callback function const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void; - + // Execute the timeout callback timeoutCallback(); diff --git a/test/mockinputs.test.ts b/test/mockinputs.test.ts index 1caa1fe..a8bdd2a 100644 --- a/test/mockinputs.test.ts +++ b/test/mockinputs.test.ts @@ -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',