diff --git a/src/assumeRole.ts b/src/assumeRole.ts index 4506594..1f05923 100644 --- a/src/assumeRole.ts +++ b/src/assumeRole.ts @@ -81,6 +81,62 @@ export interface assumeRoleParams { 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' && value !== null) { + 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) { const { credentialsClient, @@ -122,19 +178,8 @@ export async function assumeRole(params: assumeRoleParams) { } if (customTags) { - try { - const parsed = JSON.parse(customTags); - - // Then do the mapping - const newTags = Object.entries(parsed).map(([Key, Value]) => ({ - Key, - Value: String(Value), - })); - - tagArray.push(...newTags); - } catch { - throw new Error('Invalid custom-tags, json is not valid'); - } + const parsed = parseAndValidateCustomTags(customTags, tagArray); + tagArray.push(...parsed); } const tags = roleSkipSessionTagging ? undefined : tagArray; diff --git a/src/index.ts b/src/index.ts index 47657f8..b7e5c9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -191,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; diff --git a/test/helpers.test.ts b/test/helpers.test.ts index ca9929a..09e768a 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -14,7 +14,7 @@ describe('Configure AWS Credentials helpers', {}, () => { }); it('can sleep', {}, async () => { const sleep = helpers.defaultSleep(10); - expect(Promise.race([sleep, new Promise((_, reject) => setTimeout(reject, 20))])).resolves.toBe(undefined); + await expect(Promise.race([sleep, new Promise((_, reject) => setTimeout(reject, 20))])).resolves.toBe(undefined); }); it('removes special characters from workflow names', {}, () => { expect(helpers.sanitizeGitHubVariables('sdf234@#$%$^&*()_+{}|:"<>?')).toEqual('sdf234@__________+___:____'); diff --git a/test/index.test.ts b/test/index.test.ts index 4db19dc..84e25a1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -248,13 +248,13 @@ describe('Configure AWS Credentials', {}, () => { .mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' }); }); it('rejects invalid JSON in custom tags', {}, async () => { - vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_JSON_INPUTS)); + vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_JSON_INPUTS)); await run(); - expect(core.setFailed).toHaveBeenCalledWith('Invalid custom-tags, json is not valid'); - //expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0); + expect(core.setFailed).toHaveBeenCalledWith('custom-tags: input is not valid JSON'); + expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0); }); - it('handles object custom tags', {}, async () => { - vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_OBJECT_INPUTS)); + 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'); @@ -268,9 +268,53 @@ describe('Configure AWS Credentials', {}, () => { { 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', {}, () => { @@ -305,7 +349,7 @@ describe('Configure AWS Credentials', {}, () => { expect(core.setFailed).toHaveBeenCalled(); }); - it('handles improper retry-max-attempts input', async () => { + it('handles improper retry-max-attempts input', {}, async () => { // This should mean we retry one time vi.mocked(core.getInput).mockImplementation( mocks.getInput({ @@ -748,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(); @@ -1051,4 +1095,3 @@ describe('Configure AWS Credentials', {}, () => { }); }); }); - diff --git a/test/mockinputs.test.ts b/test/mockinputs.test.ts index f83ec60..a8bdd2a 100644 --- a/test/mockinputs.test.ts +++ b/test/mockinputs.test.ts @@ -6,13 +6,6 @@ const inputs = { 'aws-region': 'fake-region-1', 'special-characters-workaround': 'true', }, - CUSTOM_TAGS_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', - 'custom-tags': '{"Environment": "Production", "Team": "DevOps"}', - }, CUSTOM_TAGS_INVALID_JSON_INPUTS: { 'aws-access-key-id': 'MYAWSACCESSKEYID', 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY', @@ -21,6 +14,30 @@ const inputs = { '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',