5
0
Fork 0
mirror of https://github.com/hashicorp/vault-action.git synced 2025-11-07 07:06:56 +00:00

feat: add initial code logic

This commit is contained in:
Richard Simpson 2019-09-20 15:09:58 -05:00
parent db817f6cf8
commit 200002c936
No known key found for this signature in database
GPG key ID: 0CECAF50D013D1E2
8 changed files with 5513 additions and 0 deletions

View file

@ -1 +1,95 @@
# vault-action
A helper action for retrieving vault secrets as env vars.
## Example Usage
```yaml
jobs:
build:
# ...
steps:
# ...
- name: Import Secrets
uses: richicoder1/vault-action
with:
vaultUrl: https://vault.mycompany.com
vaultToken: ${{ secrets.VaultToken }}
keys: |
ci_key ;
ci/aws > $.accessKey | AWS_ACCESS_KEY_ID ;
ci/aws > $.secretKey | AWS_SECRET_ACCESS_KEY ;
ci/npm_token | NPM_TOKEN
# ...
```
## Key Syntax
The `keys` parameter is multiple keys separated by the `;` character.
Each key is comprised of the `path` of they key, and optionally a [`JSONPath`](https://www.npmjs.com/package/jsonpath) expression and an output name.
```raw
{{ Key Path }} > {{ JSONPath Query }} | {{ Output Environment Variable Name }}
```
### Simple Key
To retrieve a key `ci/npm_token` that has value `somelongtoken` from vault you could do:
```yaml
with:
keys: ci/npm_token
```
`vault-action` will automatically normalize the given path, and output:
```bash
CI__NPM_TOKEN=somelongtoken
```
### Set Environment Variable Name
However, if you want to set it to a specific environmental variable, say `NPM_TOKEN`, you could do this instead:
```yaml
with:
keys: ci/npm_token | NPM_TOKEN
```
With that, `vault-action` will now use your request name and output:
```bash
NPM_TOKEN=somelongtoken
```
### JSON Key
Say you are storing a set of AWS keys as a JSON document in Vault like so:
```json
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
```
And you want to set them to `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` respectively so you could use the AWS CLI:
```yaml
with:
keys: |
ci/aws > $.accessKey | AWS_ACCESS_KEY_ID ;
ci/aws > $.secretKey | AWS_SECRET_ACCESS_KEY
```
This would output:
```bash
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
```
## Masking
This action uses Github Action's built in masking, so all variables will automatically be masked if printed to the console or to logs.

106
action.js Normal file
View file

@ -0,0 +1,106 @@
// @ts-check
// @ts-ignore
const core = require('@actions/core');
const got = require('got');
const jq = require('jsonpath');
async function exportSecrets() {
const vaultUrl = core.getInput('vaultUrl', { required: true });
const vaultToken = core.getInput('vaultToken', { required: true });
const keysInput = core.getInput('keys', { required: true });
const keys = parseKeysInput(keysInput);
for (const key of keys) {
const [keyPath, { name, query }] = key;
const result = await got(`${vaultUrl}/secret/data/${keyPath}`, {
headers: {
'Accept': 'application/json',
'X-Vault-Token': vaultToken
}
});
const parsedResponse = JSON.parse(result.body);
let value = parsedResponse.data;
if (query) {
value = jq.value(value, query);
}
core.exportSecret(name, value);
core.debug(`${keyPath} => ${name}`);
}
};
/**
* Parses a keys input string into key paths and their resulting environment variable name.
* @param {string} keys
*/
function parseKeysInput(keys) {
const keyPairs = keys
.split(';')
.filter(key => !!key)
.map(key => key.trim())
.filter(key => key.length !== 0);
/** @type {Map<string, { name: string; query: string; }>} */
const output = new Map();
for (const keyPair of keyPairs) {
let path = keyPair;
let mappedName = null;
let query = null;
const renameSigilIndex = keyPair.lastIndexOf('|');
if (renameSigilIndex > -1) {
path = keyPair.substring(0, renameSigilIndex).trim();
mappedName = keyPair.substring(renameSigilIndex + 1).trim();
if (mappedName.length < 1) {
throw Error(`You must provide a value when mapping a secret to a name. Input: "${keyPair}"`);
}
}
const pathPair = path;
const querySigilIndex = pathPair.indexOf('>');
if (querySigilIndex > -1) {
path = pathPair.substring(0, querySigilIndex).trim();
query = pathPair.substring(querySigilIndex + 1).trim();
try {
const expression = jq.parse(query);
if (expression.length === 0) {
throw Error(`Invalid query expression provided "${query}" from "${keyPair}".`);
}
} catch (_) {
throw Error(`Invalid query expression provided "${query}" from "${keyPair}".`);
}
}
if (path.length === 0) {
throw Error(`You must provide a valid path. Input: "${keyPair}"`)
}
// If we're not using a mapped name, normalize the key path into a variable name.
if (!mappedName) {
mappedName = normalizeKeyName(path);
}
output.set(path, {
name: mappedName,
query
});
}
return output;
}
/**
* Replaces any forward-slash characters to
* @param {string} keyPath
*/
function normalizeKeyName(keyPath) {
return keyPath.replace('/', '__').replace(/[^\w-]/, '').toUpperCase();
}
module.exports = {
exportSecrets,
parseKeysInput,
normalizeKeyName
};

149
action.test.js Normal file
View file

@ -0,0 +1,149 @@
jest.mock('got');
jest.mock('@actions/core');
const core = require('@actions/core');
const got = require('got');
const {
exportSecrets,
parseKeysInput,
} = require('./action');
const { when } = require('jest-when');
describe('parseKeysInput', () => {
it('parses simple key', () => {
const output = parseKeysInput('test');
expect(output.has('test')).toBeTruthy();
expect(output.get('test')).toMatchObject({
name: 'TEST',
query: null
});
});
it('parses mapped key', () => {
const output = parseKeysInput('test|testName');
expect(output.get('test')).toMatchObject({
name: 'testName',
query: null
});
});
it('fails on invalid mapped name', () => {
expect(() => parseKeysInput('test|'))
.toThrowError(`You must provide a value when mapping a secret to a name. Input: "test|"`)
});
it('fails on invalid path for mapped', () => {
expect(() => parseKeysInput('|testName'))
.toThrowError(`You must provide a valid path. Input: "|testName"`)
});
it('parses queried key', () => {
const output = parseKeysInput('test>$.test');
expect(output.get('test')).toMatchObject({
name: 'TEST',
query: '$.test'
});
});
it('fails on invalid query', () => {
expect(() => parseKeysInput('test>#'))
.toThrowError(`Invalid query expression provided "#" from "test>#".`)
});
it('parses queried and mapped key', () => {
const output = parseKeysInput('test>$.test|testName');
expect(output.get('test')).toMatchObject({
name: 'testName',
query: '$.test'
});
});
it('parses multiple keys', () => {
const output = parseKeysInput('first;second;');
expect(output.size).toBe(2);
expect(output.has('first')).toBeTruthy();
expect(output.has('second')).toBeTruthy();
});
it('parses multiple complex keys', () => {
const output = parseKeysInput('first;second|secondName;third>$.third');
expect(output.size).toBe(3);
expect(output.has('first')).toBeTruthy();
expect(output.get('second')).toMatchObject({
name: 'secondName'
});
expect(output.get('third')).toMatchObject({
query: '$.third'
});
});
it('parses multiline input', () => {
const output = parseKeysInput('\nfirst;\nsecond;\n');
expect(output.size).toBe(2);
expect(output.has('first')).toBeTruthy();
expect(output.has('second')).toBeTruthy();
})
});
describe('exportSecrets', () => {
beforeEach(() => {
jest.resetAllMocks();
when(core.getInput)
.calledWith('vaultUrl')
.mockReturnValue('https://vault');
when(core.getInput)
.calledWith('vaultToken')
.mockReturnValue('EXAMPLE');
});
function mockInput(key) {
when(core.getInput)
.calledWith('keys')
.mockReturnValue(key);
}
function mockVaultData(data) {
got.mockResolvedValue({
body: JSON.stringify({
data,
meta: {}
})
});
}
it('simple key retrieval', async () => {
mockInput('test');
mockVaultData('1');
await exportSecrets();
expect(core.exportSecret).toBeCalledWith('TEST', '1');
});
it('mapped key retrieval', async () => {
mockInput('test|TEST_NAME');
mockVaultData('1');
await exportSecrets();
expect(core.exportSecret).toBeCalledWith('TEST_NAME', '1');
});
it('queried data retrieval', async () => {
mockInput('test > $.key');
mockVaultData({
key: 'SECURE'
});
await exportSecrets();
expect(core.exportSecret).toBeCalledWith('TEST', 'SECURE');
});
});

15
action.yml Normal file
View file

@ -0,0 +1,15 @@
name: 'Vault Secrets'
description: 'A Github Action that allows you to consume vault secrets as secure environment variables'
inputs:
vaultUrl:
description: 'The URL for the vault endpoint'
required: true
vaultToken:
description: 'The Vault Token to be used to authenticate with Vault'
required: true
keys:
description: 'A semicolon-separated list of key paths to retrieve. These will automatically be converted to environmental variable keys. See README for more details'
required: true
runs:
using: 'node10'
main: 'index.js'

10
index.js Normal file
View file

@ -0,0 +1,10 @@
const core = require('@actions/core');
const { exportSecrets } = require('./action');
(async () => {
try {
await core.group('Get Vault Secrets', exportSecrets);
} catch (error) {
core.setFailed(error.message);
}
})();

10
jsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"typeAcquisition": {
"include": [
"jest"
]
},
"compilerOptions": {
"target": "es2018"
}
}

5092
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "vault-action",
"version": "0.1.0",
"description": "A Github Action that allows you to consume vault secrets as secure environment variables.",
"main": "index.js",
"scripts": {
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/RichiCoder1/vault-action.git"
},
"keywords": [
"hashicorp",
"vault",
"github",
"actions",
"github-actions",
"javascript"
],
"author": "Richard Simpson <richardsimpson@outlook.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/RichiCoder1/vault-action/issues"
},
"homepage": "https://github.com/RichiCoder1/vault-action#readme",
"dependencies": {
"@actions/core": "^1.1.1",
"got": "^9.6.0",
"jsonpath": "^1.0.2"
},
"devDependencies": {
"@types/jest": "^24.0.18",
"jest": "^24.9.0",
"jest-when": "^2.7.0"
}
}