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:
parent
db817f6cf8
commit
200002c936
8 changed files with 5513 additions and 0 deletions
94
README.md
94
README.md
|
|
@ -1 +1,95 @@
|
||||||
# vault-action
|
# 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
106
action.js
Normal 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
149
action.test.js
Normal 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
15
action.yml
Normal 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
10
index.js
Normal 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
10
jsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"typeAcquisition": {
|
||||||
|
"include": [
|
||||||
|
"jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2018"
|
||||||
|
}
|
||||||
|
}
|
||||||
5092
package-lock.json
generated
Normal file
5092
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
package.json
Normal file
37
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue