diff --git a/README.md b/README.md index 471a9a8c6..07ca6de94 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,17 @@ We recommend following [Amazon IAM best practices](https://docs.aws.amazon.com/I * [Grant least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege) to the credentials used in GitHub Actions workflows. Grant only the permissions required to perform the actions in your GitHub Actions workflows. * [Monitor the activity](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#keep-a-log) of the credentials used in GitHub Actions workflows. +To prevent accidental deploys to the incorrect environment, you may define a comma-separated list of allowed account IDs to configure credentials for: + +```yaml +- name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + role-to-assume: arn:aws:iam::123456789100:role/my-github-actions-role + aws-region: us-east-2 + allowed-account-ids: 123456789100 +``` + ## Assuming a Role We recommend using [GitHub's OIDC provider](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) to get short-lived credentials needed for your actions. Specifying `role-to-assume` **without** providing an `aws-access-key-id` or a `web-identity-token-file` will signal to the action that you wish to use the OIDC provider. diff --git a/action.yml b/action.yml index b99f07f5e..e9e8bd9a4 100644 --- a/action.yml +++ b/action.yml @@ -55,8 +55,10 @@ inputs: role-skip-session-tagging: description: 'Skip session tagging during role assumption' required: false - http-proxy: - description: 'Proxy to use for the AWS SDK agent' + allowed-account-ids: + description: >- + Comma-separated list of allowed AWS account IDs to prevent accidental + deploys to the wrong environment. required: false outputs: aws-account-id: diff --git a/index.js b/index.js index 06c546b16..0f6bb38f3 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,7 @@ const MAX_TAG_VALUE_LENGTH = 256; const SANITIZATION_CHARACTER = '_'; const ROLE_SESSION_NAME = 'GitHubActions'; const REGION_REGEX = /^[a-z0-9-]+$/g; +const ACCOUNT_ID_LIST_REGEX = /^\d{12}(,\s?\d{12})*$/ async function assumeRole(params) { // Assume a role to get short-lived credentials using longer-lived credentials. @@ -300,6 +301,7 @@ async function run() { const roleSkipSessionTagging = roleSkipSessionTaggingInput.toLowerCase() === 'true'; const webIdentityTokenFile = core.getInput('web-identity-token-file', { required: false }); const proxyServer = core.getInput('http-proxy', { required: false }); + const allowedAccountIds = core.getInput('allowed-account-ids', { required: false }); if (!region.match(REGION_REGEX)) { throw new Error(`Region is not valid: ${region}`); @@ -307,6 +309,14 @@ async function run() { exportRegion(region); + if (allowedAccountIds && !allowedAccountIds.match(ACCOUNT_ID_LIST_REGEX)) { + let errorMessage = "Allowed account ID list is not valid, must be comma-separated list of 12-digit IDs"; + if (maskAccountId.toLowerCase() == 'false') { + errorMessage += `: ${allowedAccountIds}`; + } + throw new Error(errorMessage); + } + // This wraps the logic for deciding if we should rely on the GH OIDC provider since we may need to reference // the decision in a few differennt places. Consolidating it here makes the logic clearer elsewhere. const useGitHubOIDCProvider = () => { @@ -375,7 +385,20 @@ async function run() { if (!process.env.GITHUB_ACTIONS || accessKeyId) { await validateCredentials(roleCredentials.accessKeyId); } - await exportAccountId(maskAccountId, region); + sourceAccountId = await exportAccountId(maskAccountId, region); + } + + // Check if configured account ID is in the provided allow-list + if (allowedAccountIds) { + // Convert string to a list and trim whitespace. + const accountIdList = allowedAccountIds.split(",").map((id) => id.trim()); + if (!accountIdList.includes(sourceAccountId)) { + let errorMessage = "Account ID of the provided credentials is not in 'allowed-account-ids'"; + if (maskAccountId.toLowerCase() == 'false') { + errorMessage += `: ${sourceAccountId}`; + } + throw new Error(errorMessage); + } } } catch (error) { diff --git a/index.test.js b/index.test.js index e6655adc7..f3ac9f314 100644 --- a/index.test.js +++ b/index.test.js @@ -809,6 +809,37 @@ describe('Configure AWS Credentials', () => { await run(); }); + + test('denies accounts not defined in allow-account-ids', async () => { + process.env.SHOW_STACK_TRACE = 'false'; + + core.getInput = jest + .fn() + .mockImplementation(mockGetInput({ ...DEFAULT_INPUTS, 'allowed-account-ids': '000000000000' })); + + await run(); + expect(core.setFailed).toHaveBeenCalledWith(`Account ID of the provided credentials is not in 'allowed-account-ids'`); + }); + + test('allows accounts defined in allow-account-ids', async () => { + core.getInput = jest + .fn() + .mockImplementation(mockGetInput({ ...DEFAULT_INPUTS, 'allowed-account-ids': `${FAKE_ACCOUNT_ID}, 000000000000` })); + + await run(); + }); + + test('throws if account id list is invalid', async () => { + process.env.SHOW_STACK_TRACE = 'false'; + + // account id is too short + core.getInput = jest + .fn() + .mockImplementation(mockGetInput({ ...DEFAULT_INPUTS, 'allowed-account-ids': '0' })); + + await run(); + expect(core.setFailed).toHaveBeenCalledWith('Allowed account ID list is not valid, must be comma-separated list of 12-digit IDs'); + }) describe('proxy settings', () => {