Clean up exported credentials when the workflow finishes (#67)

* Clean up exported credentials when the workflow finishes

* Fix conditional and log
This commit is contained in:
Seth Vargo 2021-12-01 12:38:47 -05:00 committed by GitHub
parent c6fa692def
commit 1e9245c68a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 2065 additions and 18 deletions

View File

@ -9,7 +9,7 @@ on:
- 'main' - 'main'
concurrency: concurrency:
group: '${{ github.head_ref || github.ref }}' group: '${{github.workflow}}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@ -41,7 +41,7 @@ jobs:
unit: unit:
name: 'unit' name: 'unit'
needs: install_and_compile needs: 'install_and_compile'
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
steps: steps:
@ -63,7 +63,7 @@ jobs:
credentials_json: credentials_json:
name: 'credentials_json' name: 'credentials_json'
needs: install_and_compile needs: 'install_and_compile'
runs-on: '${{ matrix.os }}' runs-on: '${{ matrix.os }}'
strategy: strategy:
fail-fast: false fail-fast: false
@ -125,7 +125,7 @@ jobs:
workload_identity_federation: workload_identity_federation:
name: 'workload_identity_federation' name: 'workload_identity_federation'
needs: install_and_compile needs: 'install_and_compile'
runs-on: '${{ matrix.os }}' runs-on: '${{ matrix.os }}'
strategy: strategy:
fail-fast: false fail-fast: false
@ -189,3 +189,37 @@ jobs:
token_format: 'id_token' token_format: 'id_token'
id_token_audience: 'https://secretmanager.googleapis.com/' id_token_audience: 'https://secretmanager.googleapis.com/'
id_token_include_email: true id_token_include_email: true
# This test ensures that the GOOGLE_APPLICATION_CREDENTIALS environment
# variable is shared with the container and that the path of the file is on
# the shared filesystem with the container and that the USER for the container
# has permissions to read the file.
docker:
name: 'docker'
needs: 'install_and_compile'
runs-on: 'ubuntu-latest'
strategy:
fail-fast: false
steps:
- uses: 'actions/checkout@v2'
- uses: 'actions/setup-node@v2'
with:
node-version: '12.x'
- name: 'npm ci'
run: 'npm ci'
- name: 'npm build'
run: 'npm run build'
- name: 'auth-default'
uses: './'
with:
credentials_json: '${{ secrets.AUTH_SA_KEY_JSON }}'
- name: 'docker'
uses: 'docker://alpine:3'
with:
entrypoint: '/bin/sh'
args: '-euc "test -n "${GOOGLE_APPLICATION_CREDENTIALS}" && test -r "${GOOGLE_APPLICATION_CREDENTIALS}"'

View File

@ -31,7 +31,7 @@ and permissions on Google Cloud.
- For authenticating via Workload Identity Federation, you must create and - For authenticating via Workload Identity Federation, you must create and
configure a Google Cloud Workload Identity Provider. See [setup](#setup) configure a Google Cloud Workload Identity Provider. See [setup](#setup)
for instructions. for instructions.
## Limitations ## Limitations
- This Action does not support authenticating through service accounts via - This Action does not support authenticating through service accounts via
@ -165,6 +165,9 @@ regardless of the authentication mechanism.
identities to use for impersonation in the chain. By default there are no identities to use for impersonation in the chain. By default there are no
delegates. delegates.
- `cleanup_credentials`: (Optional) If true, the action will remove any
generated credentials from the filesystem upon completion. The default is
true.
## Outputs ## Outputs

View File

@ -71,6 +71,12 @@ inputs:
impersonation in the chain. impersonation in the chain.
default: '' default: ''
required: false required: false
cleanup_credentials:
description: |-
If true, the action will remove any generated credentials from the
filesystem upon completion.
default: true
required: false
# access token params # access token params
access_token_lifetime: access_token_lifetime:
@ -130,4 +136,5 @@ branding:
runs: runs:
using: 'node12' using: 'node12'
main: 'dist/index.js' main: 'dist/main/index.js'
post: 'dist/post/index.js'

View File

@ -234,11 +234,21 @@ function run() {
// fails, which means continue-on-error actions will still have the file // fails, which means continue-on-error actions will still have the file
// available. // available.
if (createCredentialsFile) { if (createCredentialsFile) {
const runnerTempDir = process.env.RUNNER_TEMP; // Note: We explicitly and intentionally export to GITHUB_WORKSPACE
if (!runnerTempDir) { // instead of RUNNER_TEMP, because RUNNER_TEMP is not shared with
throw new Error('$RUNNER_TEMP is not set'); // Docker-based actions on the filesystem. Exporting to GITHUB_WORKSPACE
// ensures that the exported credentials are automatically available to
// Docker-based actions without user modification.
//
// This has the unintended side-effect of leaking credentials over time,
// because GITHUB_WORKSPACE is not automatically cleaned up on self-hosted
// runners. To mitigate this issue, this action defines a post step to
// remove any created credentials.
const githubWorkspace = process.env.GITHUB_WORKSPACE;
if (!githubWorkspace) {
throw new Error('$GITHUB_WORKSPACE is not set');
} }
const credentialsPath = yield client.createCredentialsFile(runnerTempDir); const credentialsPath = yield client.createCredentialsFile(githubWorkspace);
(0, core_1.setOutput)('credentials_file_path', credentialsPath); (0, core_1.setOutput)('credentials_file_path', credentialsPath);
// CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE is picked up by gcloud to use // CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE is picked up by gcloud to use
// a specific credential file (subject to change and equivalent to auth/credential_file_override) // a specific credential file (subject to change and equivalent to auth/credential_file_override)
@ -600,7 +610,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.writeSecureFile = void 0; exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0;
const fs_1 = __webpack_require__(747); const fs_1 = __webpack_require__(747);
const crypto_1 = __importDefault(__webpack_require__(417)); const crypto_1 = __importDefault(__webpack_require__(417));
const path_1 = __importDefault(__webpack_require__(622)); const path_1 = __importDefault(__webpack_require__(622));
@ -627,6 +637,37 @@ function writeSecureFile(outputDir, data) {
}); });
} }
exports.writeSecureFile = writeSecureFile; exports.writeSecureFile = writeSecureFile;
/**
* removeExportedCredentials removes any exported credentials file. If the file
* does not exist, it does nothing.
*
* @returns Path of the file that was removed.
*/
function removeExportedCredentials() {
return __awaiter(this, void 0, void 0, function* () {
// Look up the credentials path, if one exists. Note that we only check the
// environment variable set by our action, since we don't want to
// accidentially clean up if someone set GOOGLE_APPLICATION_CREDENTIALS or
// another environment variable manually.
const credentialsPath = process.env['GOOGLE_GHA_CREDS_PATH'];
if (!credentialsPath) {
return '';
}
// Delete the file.
try {
yield fs_1.promises.unlink(credentialsPath);
return credentialsPath;
}
catch (err) {
if (err instanceof Error)
if (err && err.message && err.message.includes('ENOENT')) {
return '';
}
throw new Error(`failed to remove exported credentials: ${err}`);
}
});
}
exports.removeExportedCredentials = removeExportedCredentials;
/** /**
* Converts a multi-line or comma-separated collection of strings into an array * Converts a multi-line or comma-separated collection of strings into an array
* of trimmed strings. * of trimmed strings.
@ -1917,7 +1958,7 @@ module.exports = require("util");
/***/ 731: /***/ 731:
/***/ (function(module) { /***/ (function(module) {
module.exports = {"name":"@google-github-actions/auth","version":"0.4.0","description":"Authenticate to Google Cloud using OIDC tokens or JSON service account keys.","main":"dist/index.js","scripts":{"build":"ncc build src/main.ts","lint":"eslint . --ext .ts,.tsx","format":"prettier --write **/*.ts","test":"mocha -r ts-node/register -t 120s 'tests/**/*.test.ts'"},"repository":{"type":"git","url":"https://github.com/google-github-actions/auth"},"keywords":["actions","google cloud","identity","auth","oidc"],"author":"GoogleCloudPlatform","license":"Apache-2.0","dependencies":{"@actions/core":"^1.6.0"},"devDependencies":{"@types/chai":"^4.2.21","@types/mocha":"^9.0.0","@types/node":"^16.9.1","@typescript-eslint/eslint-plugin":"^4.31.0","@typescript-eslint/parser":"^4.31.0","@zeit/ncc":"^0.22.3","chai":"^4.3.4","eslint":"^7.32.0","eslint-config-prettier":"^8.3.0","eslint-plugin-prettier":"^4.0.0","husky":"^7.0.2","mocha":"^9.1.1","prettier":"^2.4.0","ts-node":"^10.2.1","typescript":"^4.3.5"}}; module.exports = {"name":"@google-github-actions/auth","version":"0.4.0","description":"Authenticate to Google Cloud using OIDC tokens or JSON service account keys.","main":"dist/main/index.js","scripts":{"build":"ncc build src/main.ts -o dist/main && ncc build src/post.ts -o dist/post","lint":"eslint . --ext .ts,.tsx","format":"prettier --write **/*.ts","test":"mocha -r ts-node/register -t 120s 'tests/**/*.test.ts'"},"repository":{"type":"git","url":"https://github.com/google-github-actions/auth"},"keywords":["actions","google cloud","identity","auth","oidc"],"author":"GoogleCloudPlatform","license":"Apache-2.0","dependencies":{"@actions/core":"^1.6.0"},"devDependencies":{"@types/chai":"^4.2.21","@types/mocha":"^9.0.0","@types/node":"^16.9.1","@typescript-eslint/eslint-plugin":"^4.31.0","@typescript-eslint/parser":"^4.31.0","@zeit/ncc":"^0.22.3","chai":"^4.3.4","eslint":"^7.32.0","eslint-config-prettier":"^8.3.0","eslint-plugin-prettier":"^4.0.0","husky":"^7.0.2","mocha":"^9.1.1","prettier":"^2.4.0","ts-node":"^10.2.1","typescript":"^4.3.5"}};
/***/ }), /***/ }),

1860
dist/post/index.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,9 @@
"name": "@google-github-actions/auth", "name": "@google-github-actions/auth",
"version": "0.4.0", "version": "0.4.0",
"description": "Authenticate to Google Cloud using OIDC tokens or JSON service account keys.", "description": "Authenticate to Google Cloud using OIDC tokens or JSON service account keys.",
"main": "dist/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
"build": "ncc build src/main.ts", "build": "ncc build src/main.ts -o dist/main && ncc build src/post.ts -o dist/post",
"lint": "eslint . --ext .ts,.tsx", "lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write **/*.ts", "format": "prettier --write **/*.ts",
"test": "mocha -r ts-node/register -t 120s 'tests/**/*.test.ts'" "test": "mocha -r ts-node/register -t 120s 'tests/**/*.test.ts'"

View File

@ -81,12 +81,22 @@ async function run(): Promise<void> {
// fails, which means continue-on-error actions will still have the file // fails, which means continue-on-error actions will still have the file
// available. // available.
if (createCredentialsFile) { if (createCredentialsFile) {
const runnerTempDir = process.env.RUNNER_TEMP; // Note: We explicitly and intentionally export to GITHUB_WORKSPACE
if (!runnerTempDir) { // instead of RUNNER_TEMP, because RUNNER_TEMP is not shared with
throw new Error('$RUNNER_TEMP is not set'); // Docker-based actions on the filesystem. Exporting to GITHUB_WORKSPACE
// ensures that the exported credentials are automatically available to
// Docker-based actions without user modification.
//
// This has the unintended side-effect of leaking credentials over time,
// because GITHUB_WORKSPACE is not automatically cleaned up on self-hosted
// runners. To mitigate this issue, this action defines a post step to
// remove any created credentials.
const githubWorkspace = process.env.GITHUB_WORKSPACE;
if (!githubWorkspace) {
throw new Error('$GITHUB_WORKSPACE is not set');
} }
const credentialsPath = await client.createCredentialsFile(runnerTempDir); const credentialsPath = await client.createCredentialsFile(githubWorkspace);
setOutput('credentials_file_path', credentialsPath); setOutput('credentials_file_path', credentialsPath);
// CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE is picked up by gcloud to use // CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE is picked up by gcloud to use
// a specific credential file (subject to change and equivalent to auth/credential_file_override) // a specific credential file (subject to change and equivalent to auth/credential_file_override)

27
src/post.ts Normal file
View File

@ -0,0 +1,27 @@
'use strict';
import { getBooleanInput, setFailed, info as logInfo } from '@actions/core';
import { removeExportedCredentials } from './utils';
/**
* Executes the post action, documented inline.
*/
export async function run(): Promise<void> {
try {
const cleanupCredentials: boolean = getBooleanInput('cleanup_credentials');
if (!cleanupCredentials) {
return;
}
const exportedPath = await removeExportedCredentials();
if (exportedPath) {
logInfo(`Removed exported credentials at ${exportedPath}`);
} else {
logInfo('No exported credentials found');
}
} catch (err) {
setFailed(`google-github-actions/auth post failed with: ${err}`);
}
}
run();

View File

@ -27,6 +27,36 @@ export async function writeSecureFile(outputDir: string, data: string): Promise<
return pth; return pth;
} }
/**
* removeExportedCredentials removes any exported credentials file. If the file
* does not exist, it does nothing.
*
* @returns Path of the file that was removed.
*/
export async function removeExportedCredentials(): Promise<string> {
// Look up the credentials path, if one exists. Note that we only check the
// environment variable set by our action, since we don't want to
// accidentially clean up if someone set GOOGLE_APPLICATION_CREDENTIALS or
// another environment variable manually.
const credentialsPath = process.env['GOOGLE_GHA_CREDS_PATH'];
if (!credentialsPath) {
return '';
}
// Delete the file.
try {
await fs.unlink(credentialsPath);
return credentialsPath;
} catch (err) {
if (err instanceof Error)
if (err && err.message && err.message.includes('ENOENT')) {
return '';
}
throw new Error(`failed to remove exported credentials: ${err}`);
}
}
/** /**
* Converts a multi-line or comma-separated collection of strings into an array * Converts a multi-line or comma-separated collection of strings into an array
* of trimmed strings. * of trimmed strings.

35
tests/utils.test.ts Normal file
View File

@ -0,0 +1,35 @@
'use strict';
import 'mocha';
import { expect } from 'chai';
import { tmpdir } from 'os';
import { existsSync } from 'fs';
import { removeExportedCredentials } from '../src/utils';
import { writeSecureFile } from '../src/utils';
describe('post', () => {
describe('#removeExportedCredentials', () => {
it('does nothing when GOOGLE_GHA_CREDS_PATH is unset', async () => {
delete process.env.GOOGLE_GHA_CREDS_PATH;
const pth = await removeExportedCredentials();
expect(pth).to.eq('');
});
it('deletes the file', async () => {
const filePath = await writeSecureFile(tmpdir(), 'my data');
process.env.GOOGLE_GHA_CREDS_PATH = filePath;
const pth = await removeExportedCredentials();
expect(existsSync(filePath)).to.be.false;
expect(pth).to.eq(filePath);
});
it('does not fail if the file does not exist', async () => {
const filePath = '/not/a/file';
process.env.GOOGLE_GHA_CREDS_PATH = filePath;
const pth = await removeExportedCredentials();
expect(pth).to.eq('');
});
});
});