Skip to content

Commit 7089111

Browse files
committed
.github/actions/akv-secret: add action to get secrets
Add a new JavaScript GitHub Action to download secrets from Azure Key Vault using the `az` CLI, mask the secret values, and store them as: * outputs, * environment variables, or * files; Values are all masked for safe consumption by other steps in a workflow. Callers of this action can optionally perform base64 decoding of secret values using the syntax: `INPUT base64> OUTPUT`. It is assumed that the `az login` command has already been run prior to this action being invoked. Signed-off-by: Matthew John Cheetham <[email protected]>
1 parent 4829785 commit 7089111

File tree

2 files changed

+189
-0
lines changed

2 files changed

+189
-0
lines changed

.github/actions/akv-secret/action.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Get Azure Key Vault Secrets
2+
3+
description: |
4+
Get secrets from Azure Key Vault and store the results as masked step outputs,
5+
environment variables, or files.
6+
7+
inputs:
8+
vault:
9+
required: true
10+
description: Name of the Azure Key Vault.
11+
secrets:
12+
required: true
13+
description: |
14+
Comma- or newline-separated list of secret names in Azure Key Vault.
15+
The output and encoding of secrets can be specified using this syntax:
16+
17+
SECRET ENCODING> $output:OUTPUT
18+
SECRET ENCODING> $env:ENVAR
19+
SECRET ENCODING> FILE
20+
21+
SECRET Name of the secret in Azure Key Vault.
22+
ENCODING (optional) Encoding of the secret: base64.
23+
OUTPUT Name of a step output variable.
24+
ENVAR Name of an environment variable.
25+
FILE File path (relative or absolute).
26+
27+
If no output format is specified the default is a step output variable
28+
with the same name as the secret. I.e, SECRET > $output:SECRET.
29+
30+
Examples:
31+
32+
Assign output variable named `raw-var` to the raw value of the secret
33+
`raw-secret`:
34+
35+
raw-secret > $output:raw-var
36+
37+
Assign output variable named `decoded-var` to the base64 decoded value
38+
of the secret `encoded-secret`:
39+
40+
encoded-secret base64> $output:decoded-var
41+
42+
Download the secret named `tls-certificate` to the file path
43+
`.certs/tls.cert`:
44+
45+
tls-certificate > .certs/tls.cert
46+
47+
Assign environment variable `ENV_SECRET` to the base64 decoded value of
48+
the secret `encoded-secret`:
49+
50+
encoded-secret base64> $env:ENV_SECRET
51+
52+
runs:
53+
using: node20
54+
main: index.js

.github/actions/akv-secret/index.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
const { spawnSync } = require('child_process');
2+
const fs = require('fs');
3+
const os = require('os');
4+
5+
// Note that we are not using the `@actions/core` package as it is not available
6+
// without either committing node_modules/ to the repository, or using something
7+
// like ncc to bundle the code.
8+
9+
// See https://github.com/actions/toolkit/blob/%40actions/core%401.1.0/packages/core/src/command.ts#L81-L87
10+
const escapeData = (s) => {
11+
return s
12+
.replace(/%/g, '%25')
13+
.replace(/\r/g, '%0D')
14+
.replace(/\n/g, '%0A')
15+
}
16+
17+
const writeCommand = (file, name, value) => {
18+
// Unique delimiter to avoid conflicts with actual values
19+
let delim;
20+
for (let count = 0; ; count++) {
21+
delim = `XXXXXX${count}`;
22+
if (!name.includes(delim) && !value.includes(delim)) {
23+
break;
24+
}
25+
}
26+
27+
fs.appendFileSync(file, `${name}<<${delim}${os.EOL}${value}${os.EOL}${delim}${os.EOL}`);
28+
}
29+
30+
const setSecret = (value) => {
31+
process.stdout.write(`::add-mask::${escapeData(value)}${os.EOL}`);
32+
}
33+
34+
const setOutput = (name, value) => {
35+
writeCommand(process.env.GITHUB_OUTPUT, name, value);
36+
}
37+
38+
const exportVariable = (name, value) => {
39+
writeCommand(process.env.GITHUB_ENV, name, value);
40+
}
41+
42+
const logInfo = (message) => {
43+
process.stdout.write(`${message}${os.EOL}`);
44+
}
45+
46+
const setFailed = (error) => {
47+
process.stdout.write(`::error::${escapeData(error.message)}${os.EOL}`);
48+
process.exitCode = 1;
49+
}
50+
51+
(async () => {
52+
const vault = process.env.INPUT_VAULT;
53+
const secrets = process.env.INPUT_SECRETS;
54+
// Parse and normalize secret mappings
55+
const secretMappings = secrets
56+
.split(/[\n,]+/)
57+
.map((entry) => entry.trim())
58+
.filter((entry) => entry)
59+
.map((entry) => {
60+
const [input, encoding, output] = entry.split(/(\S+)?>/).map((part) => part?.trim());
61+
return { input, encoding, output: output || `\$output:${input}` }; // Default output to $output:input if not specified
62+
});
63+
64+
if (secretMappings.length === 0) {
65+
throw new Error('No secrets provided.');
66+
}
67+
68+
// Fetch secrets from Azure Key Vault
69+
for (const { input: secretName, encoding, output } of secretMappings) {
70+
let secretValue = '';
71+
72+
const az = spawnSync('az',
73+
[
74+
'keyvault',
75+
'secret',
76+
'show',
77+
'--vault-name',
78+
vault,
79+
'--name',
80+
secretName,
81+
'--query',
82+
'value',
83+
'--output',
84+
'tsv'
85+
],
86+
{
87+
stdio: ['ignore', 'pipe', 'inherit'],
88+
shell: true // az is a batch script on Windows
89+
}
90+
);
91+
92+
if (az.error) throw new Error(az.error, { cause: az.error });
93+
if (az.status !== 0) throw new Error(`az failed with status ${az.status}`);
94+
95+
secretValue = az.stdout.toString('utf-8').trim();
96+
97+
// Mask the raw secret value in logs
98+
setSecret(secretValue);
99+
100+
// Handle encoded values if specified
101+
// Sadly we cannot use the `--encoding` parameter of the `az keyvault
102+
// secret (show|download)` command as the former does not support it, and
103+
// the latter must be used with `--file` (we could use /dev/stdout on UNIX
104+
// but not on Windows).
105+
if (encoding) {
106+
switch (encoding.toLowerCase()) {
107+
case 'base64':
108+
secretValue = Buffer.from(secretValue, 'base64').toString();
109+
break;
110+
default:
111+
// No decoding needed
112+
}
113+
114+
setSecret(secretValue); // Mask the decoded value as well
115+
}
116+
117+
if (output.startsWith('$env:')) {
118+
// Environment variable
119+
const envVarName = output.replace('$env:', '').trim();
120+
exportVariable(envVarName, secretValue);
121+
logInfo(`Secret set as environment variable: ${envVarName}`);
122+
} else if (output.startsWith('$output:')) {
123+
// GitHub Actions output variable
124+
const outputName = output.replace('$output:', '').trim();
125+
setOutput(outputName, secretValue);
126+
logInfo(`Secret set as output variable: ${outputName}`);
127+
} else {
128+
// File output
129+
const filePath = output.trim();
130+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
131+
fs.writeFileSync(filePath, secretValue);
132+
logInfo(`Secret written to file: ${filePath}`);
133+
}
134+
}
135+
})().catch(setFailed);

0 commit comments

Comments
 (0)