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
|
||||
|
||||
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