diff --git a/.gitignore b/.gitignore index 28ed554bb..77aaf7519 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ # Editors .vscode +.idea # Logs logs diff --git a/action.yml b/action.yml index 66df94139..a3bf25ba3 100644 --- a/action.yml +++ b/action.yml @@ -19,6 +19,12 @@ inputs: mask-aws-account-id: description: "Whether to set the AWS account ID for these credentials as a secret value, so that it is masked in logs. Valid values are 'true' and 'false'. Defaults to true" required: false + role-to-assume: + description: "Use the provided credentials to assume a Role and output the assumed credentials for that Role rather than the provided credentials" + required: false + role-duration-seconds: + description: "Role duration in seconds (default: 6 hours)" + required: false outputs: aws-account-id: description: 'The AWS account ID for the provided credentials' diff --git a/index.js b/index.js index fb3a1a464..ef4072fe7 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,94 @@ const core = require('@actions/core'); const aws = require('aws-sdk'); +const assert = require('assert'); +const util = require('util'); + +// The max time that a GitHub action is allowed to run is 6 hours. +// That seems like a reasonable default to use if no role duration is defined. +const MAX_ACTION_RUNTIME = 6 * 3600; +const USER_AGENT = 'configure-aws-credentials-for-github-actions'; + +async function assumeRole(params) { + // Assume a role to get short-lived credentials using longer-lived credentials. + const isDefined = i => !!i; + + const {roleToAssume, roleDurationSeconds, accessKeyId, secretAccessKey, sessionToken, region} = params; + assert( + [roleToAssume, roleDurationSeconds, accessKeyId, secretAccessKey, region].every(isDefined), + "Missing required input when assuming a Role." + ); + + const {GITHUB_REPOSITORY, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_ACTOR, GITHUB_REF, GITHUB_SHA} = process.env; + assert( + [GITHUB_REPOSITORY, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_ACTOR, GITHUB_REF, GITHUB_SHA].every(isDefined), + 'Missing required environment value. Are you running in GitHub Actions?' + ); + + const endpoint = util.format('https://sts.%s.amazonaws.com', region); + + const sts = new aws.STS({ + accessKeyId, secretAccessKey, sessionToken, region, endpoint, customUserAgent: USER_AGENT + }); + return sts.assumeRole({ + RoleArn: roleToAssume, + RoleSessionName: 'GitHubActions', + DurationSeconds: roleDurationSeconds, + Tags: [ + {Key: 'GitHub', Value: 'Actions'}, + {Key: 'Repository', Value: GITHUB_REPOSITORY}, + {Key: 'Workflow', Value: GITHUB_WORKFLOW}, + {Key: 'Action', Value: GITHUB_ACTION}, + {Key: 'Actor', Value: GITHUB_ACTOR}, + {Key: 'Branch', Value: GITHUB_REF}, + {Key: 'Commit', Value: GITHUB_SHA}, + ] + }) + .promise() + .then(function (data) { + return { + accessKeyId: data.Credentials.AccessKeyId, + secretAccessKey: data.Credentials.SecretAccessKey, + sessionToken: data.Credentials.SessionToken, + }; + }); +} + +function exportCredentials(params){ + // Configure the AWS CLI and AWS SDKs using environment variables + const {accessKeyId, secretAccessKey, sessionToken} = params; + + // AWS_ACCESS_KEY_ID: + // Specifies an AWS access key associated with an IAM user or role + core.exportVariable('AWS_ACCESS_KEY_ID', accessKeyId); + + // AWS_SECRET_ACCESS_KEY: + // Specifies the secret key associated with the access key. This is essentially the "password" for the access key. + core.exportVariable('AWS_SECRET_ACCESS_KEY', secretAccessKey); + + // AWS_SESSION_TOKEN: + // Specifies the session token value that is required if you are using temporary security credentials. + if (sessionToken) { + core.exportVariable('AWS_SESSION_TOKEN', sessionToken); + } +} + +function exportRegion(region) { + // AWS_DEFAULT_REGION and AWS_REGION: + // Specifies the AWS Region to send requests to + core.exportVariable('AWS_DEFAULT_REGION', region); + core.exportVariable('AWS_REGION', region); +} + +async function exportAccountId(maskAccountId) { + // Get the AWS account ID + const sts = new aws.STS({customUserAgent: USER_AGENT}); + const identity = await sts.getCallerIdentity().promise(); + const accountId = identity.Account; + core.setOutput('aws-account-id', accountId); + if (!maskAccountId || maskAccountId.toLowerCase() == 'true') { + core.setSecret(accountId); + } +} async function run() { try { @@ -9,41 +98,32 @@ async function run() { const region = core.getInput('aws-region', { required: true }); const sessionToken = core.getInput('aws-session-token', { required: false }); const maskAccountId = core.getInput('mask-aws-account-id', { required: false }); + const roleToAssume = core.getInput('role-to-assume', {required: false}); + const roleDurationSeconds = core.getInput('role-duration-seconds', {required: false}) || MAX_ACTION_RUNTIME; - // Configure the AWS CLI and AWS SDKs using environment variables - - // AWS_ACCESS_KEY_ID: - // Specifies an AWS access key associated with an IAM user or role - core.exportVariable('AWS_ACCESS_KEY_ID', accessKeyId); - - // AWS_SECRET_ACCESS_KEY: - // Specifies the secret key associated with the access key. This is essentially the "password" for the access key. - core.exportVariable('AWS_SECRET_ACCESS_KEY', secretAccessKey); - - // AWS_SESSION_TOKEN: - // Specifies the session token value that is required if you are using temporary security credentials. - if (sessionToken) { - core.exportVariable('AWS_SESSION_TOKEN', sessionToken); + // Get role credentials if configured to do so + if (roleToAssume) { + const roleCredentials = await assumeRole( + {accessKeyId, secretAccessKey, sessionToken, region, roleToAssume, roleDurationSeconds} + ); + exportCredentials(roleCredentials); + } else { + exportCredentials({accessKeyId, secretAccessKey, sessionToken}); } - // AWS_DEFAULT_REGION and AWS_REGION: - // Specifies the AWS Region to send requests to - core.exportVariable('AWS_DEFAULT_REGION', region); - core.exportVariable('AWS_REGION', region); - - // Get the AWS account ID - const sts = new aws.STS({ - customUserAgent: 'configure-aws-credentials-for-github-actions' - }); - const identity = await sts.getCallerIdentity().promise(); - const accountId = identity.Account; - core.setOutput('aws-account-id', accountId); - if (!maskAccountId || maskAccountId.toLowerCase() == 'true') { - core.setSecret(accountId); - } + exportRegion(region); + + await exportAccountId(maskAccountId); } catch (error) { core.setFailed(error.message); + + const showStackTrace = process.env.SHOW_STACK_TRACE; + + if (showStackTrace === 'true') { + throw(error) + } + } } diff --git a/index.test.js b/index.test.js index 1a12c6555..cf5ca3aa1 100644 --- a/index.test.js +++ b/index.test.js @@ -1,89 +1,148 @@ const core = require('@actions/core'); +const assert = require('assert'); const run = require('.'); jest.mock('@actions/core'); +const FAKE_ACCESS_KEY_ID = 'MY-AWS-ACCESS-KEY-ID'; +const FAKE_SECRET_ACCESS_KEY = 'MY-AWS-SECRET-ACCESS-KEY'; +const FAKE_SESSION_TOKEN = 'MY-AWS-SESSION-TOKEN'; +const FAKE_STS_ACCESS_KEY_ID = 'STS-AWS-ACCESS-KEY-ID'; +const FAKE_STS_SECRET_ACCESS_KEY = 'STS-AWS-SECRET-ACCESS-KEY'; +const FAKE_STS_SESSION_TOKEN = 'STS-AWS-SESSION-TOKEN'; +const FAKE_REGION = 'fake-region-1'; +const FAKE_ACCOUNT_ID = '123456789012'; +const ROLE_NAME = 'MY-ROLE'; +const ENVIRONMENT_VARIABLE_OVERRIDES = { + SHOW_STACK_TRACE: 'true', + GITHUB_REPOSITORY: 'MY-REPOSITORY-NAME', + GITHUB_WORKFLOW: 'MY-WORKFLOW-ID', + GITHUB_ACTION: 'MY-ACTION-NAME', + GITHUB_ACTOR: 'MY-USERNAME', + GITHUB_REF: 'MY-BRANCH', + GITHUB_SHA: 'MY-COMMIT-ID', +}; + +function mockGetInput(requestResponse) { + return function (name, options) { // eslint-disable-line no-unused-vars + return requestResponse[name] + } +} +const REQUIRED_INPUTS = { + 'aws-access-key-id': FAKE_ACCESS_KEY_ID, + 'aws-secret-access-key': FAKE_SECRET_ACCESS_KEY +}; +const DEFAULT_INPUTS = { + ...REQUIRED_INPUTS, + 'aws-session-token': FAKE_SESSION_TOKEN, + 'aws-region': FAKE_REGION, + 'mask-aws-account-id': 'TRUE' +}; +const ASSUME_ROLE_INPUTS = {...REQUIRED_INPUTS, 'role-to-assume': ROLE_NAME, 'aws-region': FAKE_REGION}; + const mockStsCallerIdentity = jest.fn(); +const mockStsAssumeRole = jest.fn(); + jest.mock('aws-sdk', () => { return { STS: jest.fn(() => ({ - getCallerIdentity: mockStsCallerIdentity + getCallerIdentity: mockStsCallerIdentity, + assumeRole: mockStsAssumeRole, })) }; }); describe('Configure AWS Credentials', () => { + const OLD_ENV = process.env; beforeEach(() => { + jest.resetModules(); + process.env = {...OLD_ENV, ...ENVIRONMENT_VARIABLE_OVERRIDES}; + jest.clearAllMocks(); core.getInput = jest .fn() - .mockReturnValueOnce('MY-AWS-ACCESS-KEY-ID') // aws-access-key-id - .mockReturnValueOnce('MY-AWS-SECRET-ACCESS-KEY') // aws-secret-access-key - .mockReturnValueOnce('us-east-2') // aws-default-region - .mockReturnValueOnce('MY-AWS-SESSION-TOKEN') // aws-session-token - .mockReturnValueOnce('TRUE'); // mask-aws-account-id + .mockImplementation(mockGetInput(DEFAULT_INPUTS)); mockStsCallerIdentity.mockImplementation(() => { return { promise() { - return Promise.resolve({ Account: '123456789012' }); + return Promise.resolve({ Account: FAKE_ACCOUNT_ID }); } }; }); + + mockStsAssumeRole.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + Credentials: { + AccessKeyId: FAKE_STS_ACCESS_KEY_ID, + SecretAccessKey: FAKE_STS_SECRET_ACCESS_KEY, + SessionToken: FAKE_STS_SESSION_TOKEN + } + }); + } + } + }); + }); + + afterEach(() => { + process.env = OLD_ENV; }); test('exports env vars', async () => { await run(); + expect(mockStsAssumeRole).toHaveBeenCalledTimes(0); expect(core.exportVariable).toHaveBeenCalledTimes(5); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MY-AWS-ACCESS-KEY-ID'); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MY-AWS-SECRET-ACCESS-KEY'); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'MY-AWS-SESSION-TOKEN'); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'us-east-2'); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'us-east-2'); - expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '123456789012'); - expect(core.setSecret).toHaveBeenCalledWith('123456789012'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_ACCESS_KEY_ID); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_SECRET_ACCESS_KEY); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', FAKE_SESSION_TOKEN); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', FAKE_REGION); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', FAKE_REGION); + expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID); + expect(core.setSecret).toHaveBeenCalledWith(FAKE_ACCOUNT_ID); }); test('session token is optional', async () => { + const mockInputs = {...REQUIRED_INPUTS, 'aws-region': 'eu-west-1'}; core.getInput = jest .fn() - .mockReturnValueOnce('MY-AWS-ACCESS-KEY-ID') // aws-access-key-id - .mockReturnValueOnce('MY-AWS-SECRET-ACCESS-KEY') // aws-secret-access-key - .mockReturnValueOnce('eu-west-1'); // aws-default-region + .mockImplementation(mockGetInput(mockInputs)); await run(); + expect(mockStsAssumeRole).toHaveBeenCalledTimes(0); expect(core.exportVariable).toHaveBeenCalledTimes(4); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MY-AWS-ACCESS-KEY-ID'); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MY-AWS-SECRET-ACCESS-KEY'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_ACCESS_KEY_ID); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_SECRET_ACCESS_KEY); expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'eu-west-1'); expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'eu-west-1'); - expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '123456789012'); - expect(core.setSecret).toHaveBeenCalledWith('123456789012'); + expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID); + expect(core.setSecret).toHaveBeenCalledWith(FAKE_ACCOUNT_ID); }); test('can opt out of masking account ID', async () => { + const mockInputs = {...REQUIRED_INPUTS, 'aws-region': 'us-east-1', 'mask-aws-account-id': 'false'}; core.getInput = jest .fn() - .mockReturnValueOnce('MY-AWS-ACCESS-KEY-ID') // aws-access-key-id - .mockReturnValueOnce('MY-AWS-SECRET-ACCESS-KEY') // aws-secret-access-key - .mockReturnValueOnce('us-east-1') // aws-default-region - .mockReturnValueOnce('') // aws-session-token - .mockReturnValueOnce('false'); // mask-aws-account-id + .mockImplementation(mockGetInput(mockInputs)); await run(); + expect(mockStsAssumeRole).toHaveBeenCalledTimes(0); expect(core.exportVariable).toHaveBeenCalledTimes(4); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MY-AWS-ACCESS-KEY-ID'); - expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MY-AWS-SECRET-ACCESS-KEY'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_ACCESS_KEY_ID); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_SECRET_ACCESS_KEY); expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'us-east-1'); expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'us-east-1'); - expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '123456789012'); + expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID); expect(core.setSecret).toHaveBeenCalledTimes(0); }); - test('error is caught by core.setFailed', async () => { + test('error is caught by core.setFailed and caught', async () => { + process.env.SHOW_STACK_TRACE = 'false'; + mockStsCallerIdentity.mockImplementation(() => { throw new Error(); }); @@ -92,4 +151,77 @@ describe('Configure AWS Credentials', () => { expect(core.setFailed).toBeCalled(); }); + + test('error is caught by core.setFailed and passed', async () => { + + mockStsCallerIdentity.mockImplementation(() => { + throw new Error(); + }); + + await assert.rejects(() => run()); + + expect(core.setFailed).toBeCalled(); + }); + + test('basic role assumption exports', async () => { + core.getInput = jest + .fn() + .mockImplementation(mockGetInput(ASSUME_ROLE_INPUTS)); + + await run(); + expect(mockStsAssumeRole).toHaveBeenCalledTimes(1); + expect(core.exportVariable).toHaveBeenCalledTimes(5); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_STS_ACCESS_KEY_ID); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_STS_SECRET_ACCESS_KEY); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', FAKE_STS_SESSION_TOKEN); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', FAKE_REGION); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', FAKE_REGION); + expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID); + expect(core.setSecret).toHaveBeenCalledWith(FAKE_ACCOUNT_ID); + }); + + test('role assumption tags', async () => { + core.getInput = jest + .fn() + .mockImplementation(mockGetInput(ASSUME_ROLE_INPUTS)); + + await run(); + expect(mockStsAssumeRole).toHaveBeenCalledWith({ + RoleArn: ROLE_NAME, + RoleSessionName: 'GitHubActions', + DurationSeconds: 6 * 3600, + Tags: [ + {Key: 'GitHub', Value: 'Actions'}, + {Key: 'Repository', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REPOSITORY}, + {Key: 'Workflow', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_WORKFLOW}, + {Key: 'Action', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTION}, + {Key: 'Actor', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTOR}, + {Key: 'Branch', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REF}, + {Key: 'Commit', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_SHA}, + ] + }) + }); + + test('role assumption duration provided', async () => { + core.getInput = jest + .fn() + .mockImplementation(mockGetInput({...ASSUME_ROLE_INPUTS, 'role-duration-seconds': 5})); + + await run(); + expect(mockStsAssumeRole).toHaveBeenCalledWith({ + RoleArn: ROLE_NAME, + RoleSessionName: 'GitHubActions', + DurationSeconds: 5, + Tags: [ + {Key: 'GitHub', Value: 'Actions'}, + {Key: 'Repository', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REPOSITORY}, + {Key: 'Workflow', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_WORKFLOW}, + {Key: 'Action', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTION}, + {Key: 'Actor', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTOR}, + {Key: 'Branch', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REF}, + {Key: 'Commit', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_SHA}, + ] + }) + }); + });