1
0
Fork 0
mirror of synced 2026-06-05 11:15:14 +00:00

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:
Tom Keller 2026-05-06 15:22:56 -07:00 committed by GitHub
commit 61f50f630f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 3 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.history
node_modules node_modules
coverage coverage
.DS_Store .DS_Store

View file

@ -382,6 +382,21 @@ with:
Actor 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
Session policies are not required, but they allow you to limit the scope of the Session policies are not required, but they allow you to limit the scope of the

View file

@ -1,4 +1,3 @@
name: '"Configure AWS Credentials" Action for GitHub Actions' name: '"Configure AWS Credentials" Action for GitHub Actions'
description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow
runs: runs:
@ -101,6 +100,9 @@ inputs:
action-timeout-s: action-timeout-s:
required: false 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. 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: outputs:
aws-account-id: aws-account-id:

View file

@ -78,6 +78,73 @@ export interface assumeRoleParams {
webIdentityToken?: string; webIdentityToken?: string;
inlineSessionPolicy?: string; inlineSessionPolicy?: string;
managedSessionPolicies?: { arn: 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) { export async function assumeRole(params: assumeRoleParams) {
@ -94,6 +161,7 @@ export async function assumeRole(params: assumeRoleParams) {
webIdentityToken, webIdentityToken,
inlineSessionPolicy, inlineSessionPolicy,
managedSessionPolicies, managedSessionPolicies,
customTags,
} = { ...params }; } = { ...params };
// Load GitHub environment variables // Load GitHub environment variables
@ -111,17 +179,24 @@ export async function assumeRole(params: assumeRoleParams) {
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) }, { Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
{ Key: 'Commit', Value: GITHUB_SHA }, { Key: 'Commit', Value: GITHUB_SHA },
]; ];
if (process.env.GITHUB_REF) { if (process.env.GITHUB_REF) {
tagArray.push({ tagArray.push({
Key: 'Branch', Key: 'Branch',
Value: sanitizeGitHubVariables(process.env.GITHUB_REF), Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
}); });
} }
if (customTags) {
const parsed = parseAndValidateCustomTags(customTags, tagArray);
tagArray.push(...parsed);
}
const tags = roleSkipSessionTagging ? undefined : tagArray; const tags = roleSkipSessionTagging ? undefined : tagArray;
if (!tags) { if (!tags) {
core.debug('Role session tagging has been skipped.'); core.debug('Role session tagging has been skipped.');
} else { } 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 //only populate transitiveTagKeys array if user is actually using session tagging

View file

@ -43,6 +43,7 @@ export async function run() {
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false }); const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false }); const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false });
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY; 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 inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => { const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
return { arn: p }; return { arn: p };
@ -190,6 +191,13 @@ export async function run() {
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds); await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId); 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 // Get role credentials if configured to do so
if (roleToAssume) { if (roleToAssume) {
let roleCredentials: AssumeRoleCommandOutput; let roleCredentials: AssumeRoleCommandOutput;
@ -209,6 +217,7 @@ export async function run() {
webIdentityToken, webIdentityToken,
inlineSessionPolicy, inlineSessionPolicy,
managedSessionPolicies, managedSessionPolicies,
customTags,
}); });
}, },
!disableRetry, !disableRetry,

View file

@ -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', {}, () => { describe('Odd inputs', {}, () => {
it('fails when github env vars are missing', {}, async () => { it('fails when github env vars are missing', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS)); vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
@ -269,6 +348,7 @@ describe('Configure AWS Credentials', {}, () => {
await run(); await run();
expect(core.setFailed).toHaveBeenCalled(); 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 // This should mean we retry one time
vi.mocked(core.getInput).mockImplementation( vi.mocked(core.getInput).mockImplementation(
@ -712,7 +792,7 @@ describe('Configure AWS Credentials', {}, () => {
// Get the timeout callback function // Get the timeout callback function
const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void; const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void;
// Execute the timeout callback // Execute the timeout callback
timeoutCallback(); timeoutCallback();

View file

@ -6,6 +6,46 @@ const inputs = {
'aws-region': 'fake-region-1', 'aws-region': 'fake-region-1',
'special-characters-workaround': 'true', '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: { IAM_USER_INPUTS: {
'aws-access-key-id': 'MYAWSACCESSKEYID', 'aws-access-key-id': 'MYAWSACCESSKEYID',
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY', 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',