diff --git a/action.yml b/action.yml index 84d314d..8679ba9 100644 --- a/action.yml +++ b/action.yml @@ -92,6 +92,7 @@ inputs: runs: using: 'node16' main: 'dist/index.js' + post: "dist/cache-save/index.js" branding: icon: 'unlock' color: 'gray-dark' diff --git a/docker-compose.yml b/docker-compose.yml index 6a8ee52..a039ae9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,26 +8,26 @@ services: ports: - 8200:8200 privileged: true - vault-enterprise: - image: hashicorp/vault-enterprise:latest - environment: - VAULT_DEV_ROOT_TOKEN_ID: testtoken - VAULT_LICENSE: ${VAULT_LICENSE_CI} - ports: - - 8200:8200 - privileged: true - vault-tls: - image: hashicorp/vault:latest - hostname: vault-tls - environment: - VAULT_CAPATH: /etc/vault/ca.crt - ports: - - 8200:8200 - privileged: true - volumes: - - ${PWD}/integrationTests/e2e-tls/configs:/etc/vault - - vault-data:/var/lib/vault:rw - entrypoint: vault server -config=/etc/vault/config.hcl + # vault-enterprise: + # image: hashicorp/vault-enterprise:latest + # environment: + # VAULT_DEV_ROOT_TOKEN_ID: testtoken + # VAULT_LICENSE: ${VAULT_LICENSE_CI} + # ports: + # - 8200:8200 + # privileged: true + # vault-tls: + # image: hashicorp/vault:latest + # hostname: vault-tls + # environment: + # VAULT_CAPATH: /etc/vault/ca.crt + # ports: + # - 8200:8200 + # privileged: true + # volumes: + # - ${PWD}/integrationTests/e2e-tls/configs:/etc/vault + # - vault-data:/var/lib/vault:rw + # entrypoint: vault server -config=/etc/vault/config.hcl volumes: vault-data: diff --git a/integrationTests/basic/revoke.test.js b/integrationTests/basic/revoke.test.js new file mode 100644 index 0000000..1a83013 --- /dev/null +++ b/integrationTests/basic/revoke.test.js @@ -0,0 +1,118 @@ +jest.mock('@actions/core'); +jest.mock('@actions/core/lib/command'); +const core = require('@actions/core'); + +const got = require('got'); +const { when } = require('jest-when'); + +const { revokeToken, getDefaultOptions, VAULT_TOKEN_STATE } = require('../../src/action'); +const { retrieveToken } = require('../../src/auth'); + +const vaultUrl = `http://${process.env.VAULT_HOST || 'localhost'}:${process.env.VAULT_PORT || '8200'}`; +const vaultToken = `${process.env.VAULT_TOKEN || 'testtoken'}` + +describe('authenticate with userpass', () => { + const username = `testUsername`; + const password = `testPassword`; + beforeAll(async () => { + try { + // Verify Connection + await got(`${vaultUrl}/v1/secret/config`, { + headers: { + 'X-Vault-Token': vaultToken, + }, + }); + + await got(`${vaultUrl}/v1/secret/data/userpass-test`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + data: { + secret: 'SUPERSECRET_WITH_USERPASS', + }, + }, + }); + + // Enable userpass + try { + await got(`${vaultUrl}/v1/sys/auth/userpass`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken + }, + json: { + type: 'userpass' + }, + }); + } catch (error) { + const { response } = error; + if (response.statusCode === 400 && response.body.includes("path is already in use")) { + // Userpass might already be enabled from previous test runs + } else { + throw error; + } + } + + // Create policies + await got(`${vaultUrl}/v1/sys/policies/acl/userpass-test`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken + }, + json: { + "name": "userpass-test", + "policy": `path \"auth/userpass/*\" {\n capabilities = [\"read\", \"list\"]\n}\npath \"auth/userpass/users/${username}\"\n{\n capabilities = [\"create\", \"read\", \"update\", \"delete\", \"list\"]\n}\n\npath \"secret/data/*\" {\n capabilities = [\"list\"]\n}\npath \"secret/metadata/*\" {\n capabilities = [\"list\"]\n}\n\npath \"secret/data/userpass-test\" {\n capabilities = [\"read\", \"list\"]\n}\npath \"secret/metadata/userpass-test\" {\n capabilities = [\"read\", \"list\"]\n}\n` + }, + }); + + // Create user + await got(`${vaultUrl}/v1/auth/userpass/users/${username}`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken + }, + json: { + password: `${password}`, + policies: 'userpass-test' + }, + }); + } catch (err) { + console.warn('Create user in userpass', err.response.body); + throw err; + } + }); + + beforeEach(() => { + jest.resetAllMocks(); + + when(core.getInput) + .calledWith('method', expect.anything()) + .mockReturnValueOnce('userpass'); + when(core.getInput) + .calledWith('username', expect.anything()) + .mockReturnValueOnce(username); + when(core.getInput) + .calledWith('password', expect.anything()) + .mockReturnValueOnce(password); + // also queried by revokeToken + when(core.getInput) + .calledWith('url', expect.anything()) + .mockReturnValue(`${vaultUrl}`); + when(core.getInput) + .calledWith('revokeToken', expect.anything()) + .mockReturnValueOnce('true'); + }); + + it('revoke token', async () => { + const defaultOptions = getDefaultOptions(); + const vaultToken = await retrieveToken("userpass", got.extend(defaultOptions)); + when(core.getState).calledWith(VAULT_TOKEN_STATE).mockReturnValue(vaultToken); + await revokeToken() + // token is now revoked so we can't revoke again + await expect(revokeToken()) + .rejects + .toThrow('failed to revoke vault token. code: ERR_NON_2XX_3XX_RESPONSE, message: Response code 403 (Forbidden), vaultResponse: {"errors":["permission denied"]}'); + }) +}); diff --git a/src/action.js b/src/action.js index 94c8f8e..eb1f887 100644 --- a/src/action.js +++ b/src/action.js @@ -8,27 +8,13 @@ const { WILDCARD } = require('./constants'); const { auth: { retrieveToken }, secrets: { getSecrets } } = require('./index'); +const VAULT_TOKEN_STATE = "VAULT_TOKEN"; const AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes', 'ldap', 'userpass']; const ENCODING_TYPES = ['base64', 'hex', 'utf8']; -async function exportSecrets() { + +function getDefaultOptions() { const vaultUrl = core.getInput('url', { required: true }); - const vaultNamespace = core.getInput('namespace', { required: false }); - const extraHeaders = parseHeadersInput('extraHeaders', { required: false }); - const exportEnv = core.getInput('exportEnv', { required: false }) != 'false'; - const outputToken = (core.getInput('outputToken', { required: false }) || 'false').toLowerCase() != 'false'; - const exportToken = (core.getInput('exportToken', { required: false }) || 'false').toLowerCase() != 'false'; - - const secretsInput = core.getInput('secrets', { required: false }); - const secretRequests = parseSecretsInput(secretsInput); - - const secretEncodingType = core.getInput('secretEncodingType', { required: false }); - - const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase(); - const authPayload = core.getInput('authPayload', { required: false }); - if (!AUTH_METHODS.includes(vaultMethod) && !authPayload) { - throw Error(`Sorry, the provided authentication method ${vaultMethod} is not currently supported and no custom authPayload was provided.`); - } const defaultOptions = { prefixUrl: vaultUrl, @@ -44,6 +30,8 @@ async function exportSecrets() { } } + const extraHeaders = parseHeadersInput('extraHeaders', { required: false }); + const vaultNamespace = core.getInput('namespace', { required: false }); const tlsSkipVerify = (core.getInput('tlsSkipVerify', { required: false }) || 'false').toLowerCase() != 'false'; if (tlsSkipVerify === true) { defaultOptions.https.rejectUnauthorized = false; @@ -56,12 +44,12 @@ async function exportSecrets() { const clientCertificateRaw = core.getInput('clientCertificate', { required: false }); if (clientCertificateRaw != null) { - defaultOptions.https.certificate = Buffer.from(clientCertificateRaw, 'base64').toString(); + defaultOptions.https.certificate = Buffer.from(clientCertificateRaw, 'base64').toString(); } const clientKeyRaw = core.getInput('clientKey', { required: false }); if (clientKeyRaw != null) { - defaultOptions.https.key = Buffer.from(clientKeyRaw, 'base64').toString(); + defaultOptions.https.key = Buffer.from(clientKeyRaw, 'base64').toString(); } for (const [headerName, headerValue] of extraHeaders) { @@ -72,13 +60,37 @@ async function exportSecrets() { defaultOptions.headers["X-Vault-Namespace"] = vaultNamespace; } + return defaultOptions +} + +async function exportSecrets() { + const exportEnv = core.getInput('exportEnv', { required: false }) != 'false'; + const revokeToken = core.getInput("revokeToken", { required: false }) !== 'false' + const outputToken = (core.getInput('outputToken', { required: false }) || 'false').toLowerCase() != 'false'; + const exportToken = (core.getInput('exportToken', { required: false }) || 'false').toLowerCase() != 'false'; + + const secretsInput = core.getInput('secrets', { required: false }); + const secretRequests = parseSecretsInput(secretsInput); + + const secretEncodingType = core.getInput('secretEncodingType', { required: false }); + + const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase(); + const authPayload = core.getInput('authPayload', { required: false }); + if (!AUTH_METHODS.includes(vaultMethod) && !authPayload) { + throw Error(`Sorry, the provided authentication method ${vaultMethod} is not currently supported and no custom authPayload was provided.`); + } + + const defaultOptions = getDefaultOptions(); const vaultToken = await retrieveToken(vaultMethod, got.extend(defaultOptions)); core.setSecret(vaultToken) + if (revokeToken) { + core.saveState(VAULT_TOKEN_STATE, vaultToken) + } defaultOptions.headers['X-Vault-Token'] = vaultToken; const client = got.extend(defaultOptions); if (outputToken === true) { - core.setOutput('vault_token', `${vaultToken}`); + core.setOutput('vault_token', `${vaultToken}`); } if (exportToken === true) { core.exportVariable('VAULT_TOKEN', `${vaultToken}`); @@ -134,7 +146,7 @@ async function exportSecrets() { */ function parseSecretsInput(secretsInput) { if (!secretsInput) { - return [] + return [] } const secrets = secretsInput @@ -219,9 +231,56 @@ function parseHeadersInput(inputKey, inputOptions) { }, new Map()); } +async function revokeToken() { + const token = core.getState(VAULT_TOKEN_STATE) + if (!token || token === "") { + core.debug(`provided token in state (${VAULT_TOKEN_STATE}) is empty. skipping...`) + return + } + core.setSecret(token) + + const defaultOptions = getDefaultOptions(); + defaultOptions.headers['X-Vault-Token'] = token; + const client = got.extend(defaultOptions); + try { + await revokeClientToken(client) + } catch (err) { + throw err + } +} + +/** + * @param {import('got').Got} client + */ +async function revokeClientToken(client) { + const path = "v1/auth/token/revoke-self" + /** @type {'json'} */ + const responseType = 'json'; + var options = { + responseType, + }; + + core.debug(`Revoking Vault Token from ${path} endpoint`); + + let response; + try { + response = await client.post(path, options); + } catch (err) { + if (err instanceof got.HTTPError) { + throw Error(`failed to revoke vault token. code: ${err.code}, message: ${err.message}, vaultResponse: ${JSON.stringify(err.response.body)}`) + } else { + throw err + } + } + core.debug('✔ Vault Token successfully revoked'); +} + module.exports = { exportSecrets, parseSecretsInput, parseHeadersInput, + getDefaultOptions, + revokeToken, + VAULT_TOKEN_STATE }; diff --git a/src/revoke.js b/src/revoke.js new file mode 100644 index 0000000..0c54a42 --- /dev/null +++ b/src/revoke.js @@ -0,0 +1,11 @@ +const core = require('@actions/core'); +const { revokeToken } = require('./action'); + +(async () => { + try { + await revokeToken() + } catch (error) { + core.setOutput("errorMessage", error.message); + core.setFailed(error.message); + } +})(); diff --git a/src/revoke.test.js b/src/revoke.test.js new file mode 100644 index 0000000..6f557b0 --- /dev/null +++ b/src/revoke.test.js @@ -0,0 +1,38 @@ +jest.mock('got'); +jest.mock('@actions/core'); + +const core = require('@actions/core'); +const got = require('got'); +const { when } = require('jest-when'); + +const { + revokeClientToken +} = require('./revoke'); + +function mockApiResponse() { + const response = "" + got.post = jest.fn() + got.post.mockReturnValue(response) +} +function mockApiFailure() { + const response = { "errors": ["permission denied"] } + got.post = jest.fn() + got.post.mockReturnValue(response) +} +describe("test revoke for token", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("test revoke with success", async () => { + mockApiResponse() + await revokeClientToken(got) + console.log(got.post.mock.calls[0][1]) + }) + + it("test revoke with error", async () => { + mockApiFailure() + await revokeClientToken(got) + console.log(got.post.mock.calls[0][1]) + }) +})