Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add PKI capability #564

Merged
merged 2 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Improvements:
Features:

* `secretId` is no longer required for approle to support advanced use cases like machine login when `bind_secret_id` is false. [GH-522](https://github.com/hashicorp/vault-action/pull/522)
* Use `pki` configuration to generate certificates from Vault [GH-564](https://github.com/hashicorp/vault-action/pull/564)

## 3.0.0 (February 15, 2024)

Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,12 +417,34 @@ secret/data/test

Note that the full path is not `secret/test`, but `secret/data/test`.

## PKI Certificate Requests

You can use the `pki` option to generate a certificate and private key for a given role.

````yaml
with:
pki: |
pki/issue/rolename {"common_name": "role.mydomain.com", "ttl": "1h"} ;
pki/issue/otherrole {"common_name": "otherrole.mydomain.com", "ttl": "1h"} ;
```

Resulting in:

```bash
ROLENAME_CA=-----BEGIN CERTIFICATE-----...
ROLENAME_CERT=-----BEGIN CERTIFICATE-----...
ROLENAME_KEY=-----BEGIN RSA PRIVATE KEY-----...
ROLENAME_CA_CHAIN=-----BEGIN CERTIFICATE-----...
OTHERROLE_CA=-----BEGIN CERTIFICATE-----...
OTHERROLE_CERT=-----BEGIN CERTIFICATE-----...
OTHERROLE_KEY=-----BEGIN RSA PRIVATE KEY-----...
OTHERROLE_CA_CHAIN=-----BEGIN CERTIFICATE-----...
````

## Other Secret Engines

Vault Action currently supports retrieving secrets from any engine where secrets
are retrieved via `GET` requests. This means secret engines such as PKI are currently
not supported due to their requirement of sending parameters along with the request
(such as `common_name`).
are retrieved via `GET` requests, except for the PKI engine as noted above.

For example, to request a secret from the `cubbyhole` secret engine:

Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ inputs:
secrets:
description: 'A semicolon-separated list of secrets to retrieve. These will automatically be converted to environmental variable keys. See README for more details'
required: false
pki:
description: 'A semicolon-separated list of certificates to generate. These will automatically be converted to environment variable keys. Cannot be used with "secrets". See README for more details'
required: false
namespace:
description: 'The Vault namespace from which to query secrets. Vault Enterprise only, unset by default'
required: false
Expand Down
82 changes: 82 additions & 0 deletions integrationTests/basic/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,69 @@ describe('integration', () => {
"other-Secret-dash": 'OTHERCUSTOMSECRET',
},
});

// Enable pki engine
try {
await got(`${vaultUrl}/v1/sys/mounts/pki`, {
method: 'POST',
headers: {
'X-Vault-Token': vaultToken,
},
json: {
type: 'pki'
}
});
} catch (error) {
const {response} = error;
if (response.statusCode === 400 && response.body.includes("path is already in use")) {
// Engine might already be enabled from previous test runs
} else {
throw error;
}
}

// Configure Root CA
try {
await got(`${vaultUrl}/v1/pki/root/generate/internal`, {
method: 'POST',
headers: {
'X-Vault-Token': vaultToken,
},
json: {
common_name: 'test',
ttl: '24h',
},
});
} catch (error) {
const {response} = error;
if (response.statusCode === 400 && response.body.includes("already exists")) {
// Root CA might already be configured from previous test runs
} else {
throw error;
}
}

// Configure PKI Role
try {
await got(`${vaultUrl}/v1/pki/roles/Test`, {
method: 'POST',
headers: {
'X-Vault-Token': vaultToken,
},
json: {
allowed_domains: ['test'],
allow_bare_domains: true,
max_ttl: '1h',
},
});
} catch (error) {
const {response} = error;
if (response.statusCode === 400 && response.body.includes("already exists")) {
// Role might already be configured from previous test runs
} else {
throw error;
}
}
});

beforeEach(() => {
Expand All @@ -132,6 +195,12 @@ describe('integration', () => {
.mockReturnValueOnce(secrets);
}

function mockPkiInput(pki) {
when(core.getInput)
.calledWith('pki', expect.anything())
.mockReturnValueOnce(pki);
}

function mockIgnoreNotFound(shouldIgnore) {
when(core.getInput)
.calledWith('ignoreNotFound', expect.anything())
Expand Down Expand Up @@ -162,6 +231,19 @@ describe('integration', () => {
expect(core.exportVariable).toBeCalledWith('NAMED_SECRET', 'SUPERSECRET');
})

it('gets a pki certificate', async () => {
mockPkiInput('pki/issue/Test {"common_name":"test","ttl":"1h"}');

await exportSecrets();

expect(core.exportVariable).toBeCalledTimes(4);

expect(core.exportVariable).toBeCalledWith('TEST_KEY', expect.anything());
expect(core.exportVariable).toBeCalledWith('TEST_CERT', expect.anything());
expect(core.exportVariable).toBeCalledWith('TEST_CA', expect.anything());
expect(core.exportVariable).toBeCalledWith('TEST_CA_CHAIN', expect.anything());
});

it('get simple secret', async () => {
mockInput('secret/data/test secret');

Expand Down
61 changes: 54 additions & 7 deletions src/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const jsonata = require('jsonata');
const { normalizeOutputKey } = require('./utils');
const { WILDCARD } = require('./constants');

const { auth: { retrieveToken }, secrets: { getSecrets } } = require('./index');
const { auth: { retrieveToken }, secrets: { getSecrets }, pki: { getCertificates } } = require('./index');

const AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes', 'ldap', 'userpass'];
const ENCODING_TYPES = ['base64', 'hex', 'utf8'];
Expand All @@ -22,6 +22,16 @@ async function exportSecrets() {
const secretsInput = core.getInput('secrets', { required: false });
const secretRequests = parseSecretsInput(secretsInput);

const pkiInput = core.getInput('pki', { required: false });
let pkiRequests = [];
if (pkiInput) {
if (secretsInput) {
throw Error('You cannot provide both "secrets" and "pki" inputs.');
}

pkiRequests = parsePkiInput(pkiInput);
}

const secretEncodingType = core.getInput('secretEncodingType', { required: false });

const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase();
Expand Down Expand Up @@ -84,12 +94,12 @@ async function exportSecrets() {
core.exportVariable('VAULT_TOKEN', `${vaultToken}`);
}

const requests = secretRequests.map(request => {
const { path, selector } = request;
return request;
});

const results = await getSecrets(requests, client);
let results = [];
if (pkiRequests.length > 0) {
results = await getCertificates(pkiRequests, client);
} else {
results = await getSecrets(secretRequests, client);
}


for (const result of results) {
Expand Down Expand Up @@ -128,6 +138,43 @@ async function exportSecrets() {
* @property {string} selector
*/

/**
* Parses a pki input string into key paths and the request parameters.
* @param {string} pkiInput
*/
function parsePkiInput(pkiInput) {
if (!pkiInput) {
return []
}

const secrets = pkiInput
.split(';')
.filter(key => !!key)
.map(key => key.trim())
.filter(key => key.length !== 0);

return secrets.map(secret => {
const path = secret.substring(0, secret.indexOf(' '));
const parameters = secret.substring(secret.indexOf(' ') + 1);

core.debug(`ℹ Parsing PKI: ${path} with parameters: ${parameters}`);

if (!path || !parameters) {
throw Error(`You must provide a valid path and parameters. Input: "${secret}"`);
}

let outputVarName = path.split('/').pop();
let envVarName = normalizeOutputKey(outputVarName);

return {
path,
envVarName,
outputVarName,
parameters: JSON.parse(parameters),
};
});
}

/**
* Parses a secrets input string into key paths and their resulting environment variable name.
* @param {string} secretsInput
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const auth = require('./auth');
const secrets = require('./secrets');
const pki = require('./pki');

module.exports = {
auth,
secrets
secrets,
pki
};
76 changes: 76 additions & 0 deletions src/pki.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const { normalizeOutputKey } = require('./utils');
const core = require('@actions/core');

/** A map of postfix values mapped to the key in the certificate response and a transformer function */
const outputMap = {
cert: { key: 'certificate', tx: (v) => v },
key: { key: 'private_key', tx: (v) => v },
ca: { key: 'issuing_ca', tx: (v) => v },
ca_chain: { key: 'ca_chain', tx: (v) => v.join('\n') },
};

/**
* @typedef PkiRequest
* @type {object}
* @property {string} path - The path to the PKI endpoint
* @property {Record<string, any>} parameters - The parameters to send to the PKI endpoint
* @property {string} envVarName - The name of the environment variable to set
* @property {string} outputVarName - The name of the output variable to set
*/

/**
* @typedef {Object} PkiResponse
* @property {PkiRequest} request
* @property {string} value
* @property {boolean} cachedResponse
*/

/**
* Generate and return the certificates from the PKI engine
* @param {Array<PkiRequest>} pkiRequests
* @param {import('got').Got} client
* @return {Promise<Array<PkiResponse>>}
*/
async function getCertificates(pkiRequests, client) {
/** @type Array<PkiResponse> */
let results = [];

for (const pkiRequest of pkiRequests) {
const { path, parameters } = pkiRequest;

const requestPath = `v1/${path}`;
let body;
try {
const result = await client.post(requestPath, {
body: JSON.stringify(parameters),
});
body = result.body;
} catch (error) {
core.error(`✘ ${error.response?.body ?? error.message}`);
throw error;
}

body = JSON.parse(body);

core.info(`✔ Successfully generated certificate (serial number ${body.data.serial_number})`);

Object.entries(outputMap).forEach(([key, value]) => {
const val = value.tx(body.data[value.key]);
results.push({
request: {
...pkiRequest,
envVarName: normalizeOutputKey(`${pkiRequest.envVarName}_${key}`, true),
outputVarName: normalizeOutputKey(`${pkiRequest.outputVarName}_${key}`),
},
value: val,
cachedResponse: false,
});
});
}

return results;
}

module.exports = {
getCertificates,
};
Loading