feat: support account id allowlist (#1456)

* feat: support account id allowlist

* chore: update readme

---------

Co-authored-by: Michael Lehmann <lehmanmj@amazon.com>
This commit is contained in:
Tom Keller
2025-09-02 10:38:09 -07:00
committed by kellertk
parent ae5e76d11f
commit 9fb1716ceb
6 changed files with 202 additions and 14 deletions
+22 -4
View File
@@ -3,7 +3,7 @@ import { STSClient } from '@aws-sdk/client-sts';
import type { AwsCredentialIdentity } from '@aws-sdk/types';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { errorMessage } from './helpers';
import { errorMessage, getCallerIdentity } from './helpers';
const USER_AGENT = 'configure-aws-credentials-for-github-actions';
@@ -40,7 +40,11 @@ export class CredentialsClient {
return this._stsClient;
}
public async validateCredentials(expectedAccessKeyId?: string, roleChaining?: boolean) {
public async validateCredentials(
expectedAccessKeyId?: string,
roleChaining?: boolean,
expectedAccountIds?: string[],
) {
let credentials: AwsCredentialIdentity;
try {
credentials = await this.loadCredentials();
@@ -50,13 +54,27 @@ export class CredentialsClient {
} catch (error) {
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
}
if (expectedAccountIds && expectedAccountIds.length > 0 && expectedAccountIds[0] !== '') {
let callerIdentity: Awaited<ReturnType<typeof getCallerIdentity>>;
try {
callerIdentity = await getCallerIdentity(this.stsClient);
} catch (error) {
throw new Error(`Could not validate account ID of credentials: ${errorMessage(error)}`);
}
if (!callerIdentity.Account || !expectedAccountIds.includes(callerIdentity.Account)) {
throw new Error(
`The account ID of the provided credentials (${
callerIdentity.Account ?? 'unknown'
}) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`,
);
}
}
if (!roleChaining) {
const actualAccessKeyId = credentials.accessKeyId;
if (expectedAccessKeyId && expectedAccessKeyId !== actualAccessKeyId) {
throw new Error(
'Unexpected failure: Credentials loaded by the SDK do not match the access key ID configured by the action',
'Credentials loaded by the SDK do not match the expected access key ID configured by the action',
);
}
}
+11 -7
View File
@@ -1,5 +1,5 @@
import * as core from '@actions/core';
import type { Credentials } from '@aws-sdk/client-sts';
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
import { GetCallerIdentityCommand } from '@aws-sdk/client-sts';
import type { CredentialsClient } from './CredentialsClient';
@@ -109,15 +109,19 @@ export function exportRegion(region: string, outputEnvCredentials?: boolean) {
}
}
// Obtains account ID from STS Client and sets it as output
export async function exportAccountId(credentialsClient: CredentialsClient, maskAccountId?: boolean) {
const client = credentialsClient.stsClient;
export async function getCallerIdentity(client: STSClient): Promise<{ Account: string; Arn: string; UserId?: string }> {
const identity = await client.send(new GetCallerIdentityCommand({}));
const accountId = identity.Account;
const arn = identity.Arn;
if (!accountId || !arn) {
if (!identity.Account || !identity.Arn) {
throw new Error('Could not get Account ID or ARN from STS. Did you set credentials?');
}
return { Account: identity.Account, Arn: identity.Arn, UserId: identity.UserId };
}
// Obtains account ID from STS Client and sets it as output
export async function exportAccountId(credentialsClient: CredentialsClient, maskAccountId?: boolean) {
const identity = await getCallerIdentity(credentialsClient.stsClient);
const accountId = identity.Account;
const arn = identity.Arn;
if (maskAccountId) {
core.setSecret(accountId);
core.setSecret(arn);
+11 -3
View File
@@ -52,6 +52,10 @@ export async function run() {
const specialCharacterWorkaround = getBooleanInput('special-characters-workaround', { required: false });
const useExistingCredentials = core.getInput('use-existing-credentials', { required: false });
let maxRetries = Number.parseInt(core.getInput('retry-max-attempts', { required: false })) || 12;
const expectedAccountIds = core
.getInput('allowed-account-ids', { required: false })
.split(',')
.map((s) => s.trim());
const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false });
if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) {
@@ -145,7 +149,7 @@ export async function run() {
exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, outputEnvCredentials);
} else if (!webIdentityTokenFile && !roleChaining) {
// Proceed only if credentials can be picked up
await credentialsClient.validateCredentials();
await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds);
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
}
@@ -153,7 +157,7 @@ export async function run() {
// Validate that the SDK can actually pick up credentials.
// This validates cases where this action is using existing environment credentials,
// and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings.
await credentialsClient.validateCredentials(AccessKeyId, roleChaining);
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
}
@@ -189,7 +193,11 @@ export async function run() {
// is set to `true` then we are NOT in a self-hosted runner.
// Second: Customer provided credentials manually (IAM User keys stored in GH Secrets)
if (!process.env.GITHUB_ACTIONS || AccessKeyId) {
await credentialsClient.validateCredentials(roleCredentials.Credentials?.AccessKeyId);
await credentialsClient.validateCredentials(
roleCredentials.Credentials?.AccessKeyId,
roleChaining,
expectedAccountIds,
);
}
if (outputEnvCredentials) {
await exportAccountId(credentialsClient, maskAccountId);