1
0
Fork 0
mirror of synced 2026-06-05 09:35:13 +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
coverage
.DS_Store

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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,

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', {}, () => {
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();

View file

@ -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',