From 98f33d2eaf29c215bd8a595c94896c64d126dbb7 Mon Sep 17 00:00:00 2001 From: Daniel Wright Date: Wed, 9 Aug 2023 05:48:26 +1000 Subject: [PATCH] feat: provide input to optionally mask output docker password (#491) This will allow the user to specify an optional input to mask the Docker password from being leaked in workflow logs via either explicitly printing or running a job in debug mode. Since many users rely on the output, the default behaviour has been false to ensure this is not a breaking change. This also re-arranges the inputs throughout the code and in the GH action spec to be in alphabetical order as a styling improvement. Signed-off-by: danielwright@bitgo.com --- README.md | 22 +++++++++++++++ action.yml | 27 +++++++++++------- index.js | 11 +++++--- index.test.js | 76 +++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 104 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 91069057..4c4882ef 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,28 @@ Logs in the local Docker client to one or more Amazon ECR Private registries or docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG ``` +#### Login to Amazon ECR Private, then build and push a Docker image masking the password: + +> [!WARNING] +> Setting mask-password to true will prevent the password GitHub output from being shared between separate jobs. + +```yaml + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + with: + mask-password: 'true' + + - name: Build, tag, and push docker image to Amazon ECR + env: + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: my-ecr-repo + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . + docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG +``` + #### Login to Amazon ECR Public, then build and push a Docker image: ```yaml - name: Login to Amazon ECR Public diff --git a/action.yml b/action.yml index 2575a5a5..dec0089e 100644 --- a/action.yml +++ b/action.yml @@ -4,30 +4,37 @@ branding: icon: 'cloud' color: 'orange' inputs: - registries: + http-proxy: description: >- - A comma-delimited list of AWS account IDs that are associated with the ECR Private registries. - If you do not specify a registry, the default ECR Private registry is assumed. - If 'public' is given as input to 'registry-type', this input is ignored. + Proxy to use for the AWS SDK agent. required: false - skip-logout: + mask-password: description: >- - Whether to skip explicit logout of the registries during post-job cleanup. - Exists for backward compatibility on self-hosted runners. - Not recommended. + Mask the docker password to prevent it being printed to logs to std-out. This will prevent the + password GitHub output from being shared between separate jobs. Options: ['true', 'false'] required: false default: 'false' + registries: + description: >- + A comma-delimited list of AWS account IDs that are associated with the ECR Private registries. + If you do not specify a registry, the default ECR Private registry is assumed. + If 'public' is given as input to 'registry-type', this input is ignored. + required: false registry-type: description: >- Which ECR registry type to log into. Options: [private, public] required: false default: private - http-proxy: + skip-logout: description: >- - Proxy to use for the AWS SDK agent. + Whether to skip explicit logout of the registries during post-job cleanup. + Exists for backward compatibility on self-hosted runners. + Not recommended. + Options: ['true', 'false'] required: false + default: 'false' outputs: registry: description: >- diff --git a/index.js b/index.js index 9d824c07..4fe9bea3 100644 --- a/index.js +++ b/index.js @@ -9,10 +9,11 @@ const ECR_LOGIN_GITHUB_ACTION_USER_AGENT = 'amazon-ecr-login-for-github-actions' const ECR_PUBLIC_REGISTRY_URI = 'public.ecr.aws'; const INPUTS = { - skipLogout: 'skip-logout', + httpProxy: 'http-proxy', + maskPassword: 'mask-password', registries: 'registries', registryType: 'registry-type', - httpProxy: 'http-proxy' + skipLogout: 'skip-logout' }; const OUTPUTS = { @@ -104,10 +105,11 @@ function replaceSpecialCharacters(registryUri) { async function run() { // Get inputs - const skipLogout = core.getInput(INPUTS.skipLogout, { required: false }).toLowerCase() === 'true'; + const httpProxy = core.getInput(INPUTS.httpProxy, { required: false }); + const maskPassword = core.getInput(INPUTS.maskPassword, { required: false }).toLowerCase() === 'true'; const registries = core.getInput(INPUTS.registries, { required: false }); const registryType = core.getInput(INPUTS.registryType, { required: false }).toLowerCase() || REGISTRY_TYPES.private; - const httpProxy = core.getInput(INPUTS.httpProxy, { required: false }); + const skipLogout = core.getInput(INPUTS.skipLogout, { required: false }).toLowerCase() === 'true'; const registryUriState = []; @@ -169,6 +171,7 @@ async function run() { // Output docker username and password const secretSuffix = replaceSpecialCharacters(registryUri); + if (maskPassword) core.setSecret(creds[1]); core.setOutput(`${OUTPUTS.dockerUsername}_${secretSuffix}`, creds[0]); core.setOutput(`${OUTPUTS.dockerPassword}_${secretSuffix}`, creds[1]); diff --git a/index.test.js b/index.test.js index df0ae6dd..85a0fc6d 100644 --- a/index.test.js +++ b/index.test.js @@ -15,17 +15,19 @@ function mockGetInput(requestResponse) { } const ECR_DEFAULT_INPUTS = { + 'http-proxy': '', + 'mask-password': '', 'registries': '', - 'skip-logout': '', 'registry-type': '', - 'http-proxy': '' + 'skip-logout': '' }; const ECR_PUBLIC_DEFAULT_INPUTS = { + 'http-proxy': '', + 'mask-password': '', 'registries': '', - 'skip-logout': '', 'registry-type': 'public', - 'http-proxy': '' + 'skip-logout': '' }; const defaultAuthToken = { @@ -76,9 +78,10 @@ describe('Login to ECR', () => { test('gets auth token from ECR and logins the Docker client for each provided registry', async () => { const mockInputs = { + 'mask-password': '', 'registries': '123456789012,111111111111', - 'skip-logout': '', - 'registry-type': '' + 'registry-type': '', + 'skip-logout': '' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); ecrMock.on(GetAuthorizationTokenCommand).resolves({ @@ -116,9 +119,10 @@ describe('Login to ECR', () => { test('outputs the registry ID if a single registry is provided in the input', async () => { const mockInputs = { + 'mask-password': '', 'registries': '111111111111', - 'skip-logout': '', - 'registry-type': '' + 'registry-type': '', + 'skip-logout': '' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); ecrMock.on(GetAuthorizationTokenCommand).resolves({ @@ -160,9 +164,10 @@ describe('Login to ECR', () => { test('logged-in registries are saved as state even if the action fails', async () => { const mockInputs = { + 'mask-password': '', 'registries': '123456789012,111111111111', - 'skip-logout': '', - 'registry-type': '' + 'registry-type': '', + 'skip-logout': '' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); ecrMock.on(GetAuthorizationTokenCommand).resolves({ @@ -261,9 +266,10 @@ describe('Login to ECR', () => { test('skips logout when specified and logging into default registry', async () => { ecrMock.on(GetAuthorizationTokenCommand).resolves(defaultOutputToken); const mockInputs = { + 'mask-password': '', 'registries': '', - 'skip-logout': 'true', - 'registry-type': '' + 'registry-type': '', + 'skip-logout': 'true' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); @@ -280,9 +286,10 @@ describe('Login to ECR', () => { test('skips logout when specified and logging into multiple registries', async () => { const mockInputs = { + 'mask-password': '', 'registries': '123456789012,111111111111', - 'skip-logout': 'true', - 'registry-type': '' + 'registry-type': '', + 'skip-logout': 'true' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); ecrMock.on(GetAuthorizationTokenCommand).resolves({ @@ -314,9 +321,10 @@ describe('Login to ECR', () => { test('sets the Actions outputs to the docker credentials', async () => { const mockInputs = { + 'mask-password': '', 'registries': '123456789012,111111111111', - 'skip-logout': 'true', - 'registry-type': '' + 'registry-type': '', + 'skip-logout': 'true' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); ecrMock.on(GetAuthorizationTokenCommand).resolves({ @@ -341,6 +349,37 @@ describe('Login to ECR', () => { expect(core.setOutput).toHaveBeenNthCalledWith(4, 'docker_password_111111111111_dkr_ecr_aws_region_1_amazonaws_com', 'bar'); }); + test('sets the Actions outputs to the docker credentials with masked password', async () => { + const mockInputs = { + 'mask-password': 'true', + 'registries': '123456789012,111111111111', + 'registry-type': '', + 'skip-logout': 'true' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); + ecrMock.on(GetAuthorizationTokenCommand).resolves({ + authorizationData: [ + { + authorizationToken: Buffer.from('hello:world').toString('base64'), + proxyEndpoint: 'https://123456789012.dkr.ecr.aws-region-1.amazonaws.com' + }, + { + authorizationToken: Buffer.from('foo:bar').toString('base64'), + proxyEndpoint: 'https://111111111111.dkr.ecr.aws-region-1.amazonaws.com' + } + ] + }); + + await run(); + + expect(core.setOutput).toHaveBeenCalledTimes(4); + expect(core.setSecret).toHaveBeenCalledTimes(2); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'docker_username_123456789012_dkr_ecr_aws_region_1_amazonaws_com', 'hello'); + expect(core.setOutput).toHaveBeenNthCalledWith(2, 'docker_password_123456789012_dkr_ecr_aws_region_1_amazonaws_com', 'world'); + expect(core.setOutput).toHaveBeenNthCalledWith(3, 'docker_username_111111111111_dkr_ecr_aws_region_1_amazonaws_com', 'foo'); + expect(core.setOutput).toHaveBeenNthCalledWith(4, 'docker_password_111111111111_dkr_ecr_aws_region_1_amazonaws_com', 'bar'); + }); + describe('proxy settings', () => { afterEach(() => { process.env = {}; @@ -392,9 +431,10 @@ describe('Login to ECR Public', () => { describe('inputs and outputs', () => { test('error is caught by core.setFailed for invalid registry-type input', async () => { const mockInputs = { + 'mask-password': '', 'registries': '', - 'skip-logout': '', - 'registry-type': 'invalid' + 'registry-type': 'invalid', + 'skip-logout': '' }; core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); ecrPublicMock.on(GetAuthorizationTokenCommandPublic).resolves(defaultAuthToken);