diff --git a/README.md b/README.md index 03f7a43e2..0cf03a803 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ See [action.yml](./action.yml) for more detail. | disable-retry | Disabled retry/backoff logic for assume role calls. By default, retries are enabled. | No | | retry-max-attempts | Limits the number of retry attempts before giving up. Defaults to 12. | No | | special-characters-workaround | Uncommonly, some environments cannot tolerate special characters in a secret key. This option will retry fetching credentials until the secret access key does not contain special characters. This option overrides disable-retry and retry-max-attempts. | No | +| allowed-account-ids | You may define a comma-separated list of allowed account IDs to configure credentials for. This is to prevent accidentally deploying to the wrong environment. | No | #### Credential Lifetime The default session duration is **1 hour**. diff --git a/action.yml b/action.yml index 7e98591a9..feb31e105 100644 --- a/action.yml +++ b/action.yml @@ -73,6 +73,9 @@ inputs: special-characters-workaround: description: Some environments do not support special characters in AWS_SECRET_ACCESS_KEY. This option will retry fetching credentials until the secret access key does not contain special characters. This option overrides disable-retry and retry-max-attempts. This option is disabled by default required: false + allowed-account-ids: + description: Comma-separated list of allowed AWS account IDs to prevent accidentally deploying to the wrong environment. + required: false outputs: aws-account-id: description: The AWS account ID for the provided credentials diff --git a/src/index.ts b/src/index.ts index 46272e019..5bf8225cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds) const ROLE_SESSION_NAME = 'GitHubActions'; const REGION_REGEX = /^[a-z0-9-]+$/g; +const ACCOUNT_ID_LIST_REGEX = /^\d{12}(,\s?\d{12})*$/; export async function run() { try { @@ -60,6 +61,7 @@ export async function run() { const specialCharacterWorkaroundInput = core.getInput('special-characters-workaround', { required: false }) || 'false'; const specialCharacterWorkaround = specialCharacterWorkaroundInput.toLowerCase() === 'true'; + const allowedAccountIds = core.getInput('allowed-account-ids', { required: false }); let maxRetries = Number.parseInt(core.getInput('retry-max-attempts', { required: false })) || 12; switch (true) { case specialCharacterWorkaround: @@ -111,9 +113,18 @@ export async function run() { } exportRegion(region); + //validate the provided allowed account ID list + if (allowedAccountIds && !allowedAccountIds.match(ACCOUNT_ID_LIST_REGEX)) { + let errorMessage = 'Allowed account ID list is not valid; it must be a comma-separated list of 12-digit IDs'; + if (!maskAccountId) { + errorMessage += `: ${allowedAccountIds}`; + } + throw new Error(errorMessage); + } + // Instantiate credentials client const credentialsClient = new CredentialsClient({ region, proxyServer }); - let sourceAccountId: string; + let sourceAccountId: string | undefined = undefined; let webIdentityToken: string; // If OIDC is being used, generate token @@ -190,6 +201,20 @@ export async function run() { } else { core.info('Proceeding with IAM user credentials'); } + + if (allowedAccountIds) { + const accountIdList = allowedAccountIds.split(',').map((id) => id.trim()); + if (sourceAccountId === undefined) { + sourceAccountId = await exportAccountId(credentialsClient, maskAccountId); + } + if (!accountIdList.includes(sourceAccountId)) { + let errorMessage = "Account ID of the provided credentials is not in 'allowed-account-ids'"; + if (!maskAccountId) { + errorMessage += `: ${sourceAccountId}`; + } + throw new Error(errorMessage); + } + } } catch (error) { core.setFailed(errorMessage(error)); diff --git a/test/index.test.ts b/test/index.test.ts index e65b5071f..d3747290a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -300,4 +300,37 @@ describe('Configure AWS Credentials', {}, () => { expect(core.setFailed).toHaveBeenCalled(); }); }); + + describe('Allowed-id-list', {}, () => { + it('denies accounts not defined in allow-account-ids', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ ...mocks.IAM_USER_INPUTS, 'allowed-account-ids': '000000000000'})); + mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...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', + }); + await run(); + expect(core.setFailed).toHaveBeenCalledWith(`Account ID of the provided credentials is not in 'allowed-account-ids': 111111111111`); + }) + it('allows accounts defined in allow-account-ids', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ ...mocks.IAM_USER_INPUTS, 'allowed-account-ids': '111111111111'})); + mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...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', + }); + await run(); + }) + it('throws if account id list is invalid', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ ...mocks.IAM_USER_INPUTS, 'allowed-account-ids': '0'})); + mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...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', + }); + await run(); + expect(core.setFailed).toHaveBeenCalledWith('Allowed account ID list is not valid; it must be a comma-separated list of 12-digit IDs: 0'); + }) + }); + });