-
Notifications
You must be signed in to change notification settings - Fork 36
/
utils.ts
268 lines (230 loc) · 9.86 KB
/
utils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
import * as core from '@actions/core'
import {
SecretsManagerClient,
GetSecretValueCommand,
ListSecretsCommand,
ListSecretsResponse,
ListSecretsCommandInput
} from "@aws-sdk/client-secrets-manager";
import { CLEANUP_NAME, LIST_SECRETS_MAX_RESULTS } from "./constants";
import "aws-sdk-client-mock-jest";
export interface SecretValueResponse {
name: string,
secretValue: string
}
export type TransformationFunc = (input: string) => string;
/**
* Gets the unique list of all secrets to be requested
*
* @param client: SecretsManager client
* @param configInputs: List of secret names, ARNs, and prefixes provided by user
* @param nameTransformation: Transforms the secret name
*/
export async function buildSecretsList(client: SecretsManagerClient, configInputs: string[], nameTransformation?: TransformationFunc): Promise<string[]> {
const finalSecretsList = new Set<string>();
// Prefix filters should be at least 3 characters, ending in *
const validFilter = new RegExp('^[a-zA-Z0-9\\/_+=.@-]{3,}\\*$');
for (const configInput of configInputs) {
if (configInput.includes('*')) {
const [secretAlias, secretPrefix] = extractAliasAndSecretIdFromInput(configInput, nameTransformation);
if (!validFilter.test(secretPrefix)) {
throw new Error('Please use a valid prefix search (should be at least 3 characters and end in *)');
}
// Find and add results for a given prefix
const prefixMatches: string[] = await getSecretsWithPrefix(client, secretPrefix, !!secretAlias);
// Add back the alias, if one was requested
prefixMatches.forEach(secret => finalSecretsList.add(secretAlias ? `${secretAlias},${secret}` : secret));
} else {
finalSecretsList.add(configInput);
}
}
return [...finalSecretsList];
}
/**
* Uses ListSecrets to find secrets for a given prefix
*
* @param client: SecretsManager client
* @param prefix: Name to search for
* @param hasAlias: Flag to indicate that an alias was requested (can only match 1 secret)
*/
export async function getSecretsWithPrefix(client: SecretsManagerClient, prefix: string, hasAlias: boolean): Promise<string[]> {
const params = {
Filters: [
{
Key: "name",
Values: [
prefix.replace('*', ''),
]
},
],
MaxResults: LIST_SECRETS_MAX_RESULTS,
} as ListSecretsCommandInput;
const response: ListSecretsResponse = await client.send(new ListSecretsCommand(params));
if (response.SecretList){
const secretsList = response.SecretList;
if (secretsList.length === 0){
throw new Error(`No matching secrets were returned for prefix "${prefix}".`);
} else if (hasAlias && secretsList.length > 1){
// If an alias was requested, we cannot match more than one result
throw new Error(`A unique alias was requested for prefix "${prefix}", but the search result for this prefix returned multiple results.`);
} else if (response.NextToken) {
// If there is a second page of results, this exceeds the max number of matches
throw new Error(`A search for prefix "${prefix}" matched more than the maximum of ${LIST_SECRETS_MAX_RESULTS} secrets per prefix.`);
}
return secretsList.reduce((foundSecrets, secret) => {
if (secret.Name) {
foundSecrets.push(secret.Name);
}
return foundSecrets;
}, [] as string[]);
} else {
throw new Error('Invalid response from ListSecrets occurred');
}
}
/**
* Retrieves a secret from Secrets Manager
*
* @param client: SecretsManager client
* @param secretId: The name or full ARN of a secret
* @returns SecretValueResponse
*/
export async function getSecretValue(client: SecretsManagerClient, secretId: string): Promise<SecretValueResponse> {
let secretValue = '';
const data = await client.send(new GetSecretValueCommand({SecretId: secretId}));
if (data.SecretString) {
secretValue = data.SecretString as string;
} else if (data.SecretBinary) {
// Only string and JSON string values are supported in Github env
secretValue = Buffer.from(data.SecretBinary).toString('ascii');
}
if (!(data.Name)){
throw new Error('Invalid name for secret');
}
return {
name: data.Name,
secretValue
} as SecretValueResponse;
}
/**
* Transforms and injects secret as a masked environmental variable
*
* @param secretName: Name of the secret
* @param secretValue: Value to set for secret
* @param parseJsonSecrets: Indicates whether to deserialize JSON secrets
* @param nameTransformation: Transforms the secret name
* @param tempEnvName: If parsing JSON secrets, contains the current name for the env variable
*/
export function injectSecret(
secretName: string,
secretValue: string,
parseJsonSecrets: boolean,
nameTransformation?: TransformationFunc,
tempEnvName?: string): string[] {
let secretsToCleanup = [] as string[];
if(parseJsonSecrets && isJSONString(secretValue)){
// Recursively parses json secrets
const secretMap = JSON.parse(secretValue) as Record<string, string | object>;
for (const k in secretMap) {
const keyValue = typeof secretMap[k] === 'string' ? secretMap[k] as string : JSON.stringify(secretMap[k]);
// Append the current key to the name of the env variable and check to avoid prepending an underscore
const newEnvName = [
tempEnvName || transformToValidEnvName(secretName, nameTransformation),
transformToValidEnvName(k, nameTransformation)
]
.filter(elem => elem) // Uses truthy-ness of elem to determine if it remains
.join("_"); // Join the remaining elements with an underscore
secretsToCleanup = [...secretsToCleanup, ...injectSecret(secretName, keyValue, parseJsonSecrets, nameTransformation, newEnvName)];
}
} else {
const envName = transformToValidEnvName(tempEnvName ? tempEnvName : secretName, nameTransformation);
// Fail the action if this variable name is already in use, or is our cleanup name
if (process.env[envName] || envName === CLEANUP_NAME){
throw new Error(`The environment name '${envName}' is already in use. Please use an alias to ensure that each secret has a unique environment name`);
}
// Inject a single secret
core.setSecret(secretValue);
// Export variable
core.debug(`Injecting secret ${secretName} as environment variable '${envName}'.`);
core.exportVariable(envName, secretValue);
secretsToCleanup.push(envName);
}
return secretsToCleanup;
}
/*
* Checks if the given secret is a valid JSON value
*/
export function isJSONString(secretValue: string): boolean {
try {
// Not valid JSON if the parsed result is null/falsy, not an object, or is an array
const parsedObject = JSON.parse(secretValue);
return !!parsedObject && (typeof parsedObject === 'object') && !Array.isArray(parsedObject);
} catch {
// Not JSON if the string fails to parse
return false;
}
}
/*
* Transforms the secret name into a valid environmental variable name
* It should consist of only upper case letters, digits, and underscores and cannot begin with a number
*/
export function transformToValidEnvName(secretName: string, nameTransformation?: TransformationFunc): string {
// Leading digits are invalid
if (secretName.match(/^[0-9]/)){
secretName = '_'.concat(secretName);
}
// Remove invalid characters
secretName = secretName.replace(/[^a-zA-Z0-9_]/g, '_');
// Apply the name transformation. When no transformation is defined fallback to the "uppercase" transformation
return nameTransformation ? nameTransformation(secretName) : secretName.toUpperCase();
}
/**
* Checks if the given secretId is an ARN
*
* @param secretId: Value to test
* @returns Boolean
*/
export function isSecretArn(secretId: string): boolean {
const validArn = new RegExp('^arn:aws:secretsmanager:.*:[0-9]{12,}:secret:.*$');
return validArn.test(secretId);
}
/*
* Separates a secret alias from the secret name/arn, if one was provided
*/
export function extractAliasAndSecretIdFromInput(input: string, nameTransformation?: TransformationFunc): [undefined | string, string] {
const parsedInput = input.split(',');
if (parsedInput.length > 1){
const alias = parsedInput[0].trim();
const secretId = parsedInput[1].trim();
// Validate that the alias is valid environment name
const validateEnvName = transformToValidEnvName(alias, nameTransformation);
if (alias !== validateEnvName){
throw new Error(`The alias '${alias}' is not a valid environment name. Please verify that it has uppercase letters, numbers, and underscore only.`);
}
// Return [alias, id]
return [alias, secretId];
}
// No alias
return [ undefined , input.trim() ];
}
/*
* Cleans up an environment variable
*/
export function cleanVariable(variableName: string) {
core.exportVariable(variableName, '');
delete process.env[variableName];
}
/*
* Converts name of the transformation to the actual function that performs the transformation.
*/
export function parseTransformationFunction(config: string): TransformationFunc {
switch (config.toLowerCase()) {
case 'uppercase':
return (input: string) => input.toUpperCase();
case 'lowercase':
return (input: string) => input.toLowerCase();
case 'none':
return (input: string) => input;
default:
throw new Error(`'${config}' is unsupported transformation name. Allowed options are: 'uppercase', 'lowercase' and 'none'`);
}
}