@liflig/load-secrets 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +1 -0
- package/.github/workflows/ci.yaml +50 -0
- package/.husky/commit-msg +1 -0
- package/.tool-versions +1 -0
- package/Makefile +9 -0
- package/README.md +90 -0
- package/commitlint.config.mjs +3 -0
- package/eslint.config.mjs +33 -0
- package/package.json +41 -0
- package/src/cli/reporter.ts +54 -0
- package/src/index.ts +1 -0
- package/src/load-secrets/index.ts +2 -0
- package/src/load-secrets/load-secrets.ts +463 -0
- package/src/load-secrets/types.ts +40 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @capralifecycle/team-infra
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches:
|
|
5
|
+
- "**"
|
|
6
|
+
|
|
7
|
+
defaults:
|
|
8
|
+
run:
|
|
9
|
+
# NOTE: A bit stricter than the default bash options used by GitHub Actions
|
|
10
|
+
# (bash --noprofile --norc -e -o pipefail {0})
|
|
11
|
+
shell: bash --noprofile --norc -euo pipefail {0}
|
|
12
|
+
|
|
13
|
+
# NOTE: Set concurrency for the current workflow to 1
|
|
14
|
+
concurrency: ci-${{ github.ref }}-${{ github.workflow }}
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build-and-release:
|
|
18
|
+
timeout-minutes: 60
|
|
19
|
+
runs-on: ubuntu-24.04
|
|
20
|
+
permissions:
|
|
21
|
+
contents: write
|
|
22
|
+
pull-requests: write
|
|
23
|
+
issues: write
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
26
|
+
|
|
27
|
+
- uses: capralifecycle/actions-lib/check-runtime-dependencies@7887a32ad872a79a8e00817659a30876f0a8f1be # v1.5.5
|
|
28
|
+
|
|
29
|
+
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
|
30
|
+
with:
|
|
31
|
+
node-version: 22
|
|
32
|
+
|
|
33
|
+
- uses: capralifecycle/actions-lib/configure-npm@7887a32ad872a79a8e00817659a30876f0a8f1be # v1.5.5
|
|
34
|
+
|
|
35
|
+
- name: install dependencies
|
|
36
|
+
run: npm ci
|
|
37
|
+
|
|
38
|
+
- name: lint
|
|
39
|
+
run: npm run lint
|
|
40
|
+
|
|
41
|
+
- name: conditionally pack
|
|
42
|
+
if: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }}
|
|
43
|
+
run: npm pack
|
|
44
|
+
|
|
45
|
+
- name: conditionally semantic release
|
|
46
|
+
if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
|
47
|
+
env:
|
|
48
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
49
|
+
NPM_TOKEN: ${{ secrets.SHARED_NPMJS_TOKEN }}
|
|
50
|
+
run: npm run semantic-release
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx --no-install commitlint --edit "$1"
|
package/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodejs 22
|
package/Makefile
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# load-secrets
|
|
2
|
+
|
|
3
|
+
Library for loading secrets into AWS Secrets Mananager from a configuration file.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
$ npm install @capraconsulting/load-secrets
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### 1. Create a script for loading secrets into AWS Secrets Manager
|
|
14
|
+
|
|
15
|
+
Create a script in your project for loading project secrets into AWS Secrets Manager.
|
|
16
|
+
|
|
17
|
+
Example: `load-secrets-demo-service.ts`
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// 1. Import the library
|
|
21
|
+
import { loadSecrets } from "@capraconsulting/load-secrets";
|
|
22
|
+
|
|
23
|
+
// 2. Define the secrets
|
|
24
|
+
const demoServiceApiKey: loadSecrets.Secret = {
|
|
25
|
+
name: "demo-service-api-key",
|
|
26
|
+
description: "API key for the demo service",
|
|
27
|
+
type: "string"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 3. Assign the secrets to SecretGroups (collections of associations between secrets and accounts)
|
|
31
|
+
const secretGroups: loadSecrets.SecretGroup[] = [
|
|
32
|
+
{
|
|
33
|
+
accountId: "123412341234",
|
|
34
|
+
region: "eu-west-1",
|
|
35
|
+
description: "dev",
|
|
36
|
+
namePrefix: "/dev/demo-svc/",
|
|
37
|
+
secrets: [demoServiceApiKey]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
accountId: "234523452345",
|
|
41
|
+
region: "eu-west-1",
|
|
42
|
+
description: "staging",
|
|
43
|
+
namePrefix: "/staging/demo-svc/",
|
|
44
|
+
secrets: [demoServiceApiKey]
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// 4. Load the secrets into AWS Secrets Manager
|
|
49
|
+
loadSecrets.loadSecretsCli({ secretGroups })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Assume an AWS role with the required permissions
|
|
53
|
+
|
|
54
|
+
Example: `aws-vault exec team-squirrel-demo-service`
|
|
55
|
+
|
|
56
|
+
Say, for example, that this command assumes AWS Account 123412341234.
|
|
57
|
+
|
|
58
|
+
### 3. Run the script
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
$ ./load-secrets-demo-service.ts
|
|
62
|
+
info Checking account for current credentials
|
|
63
|
+
info If any error is given, make sure you have valid credentials active
|
|
64
|
+
info Running for account 123412341234
|
|
65
|
+
|
|
66
|
+
Select secret to write:
|
|
67
|
+
|
|
68
|
+
dev (prefix: /dev/demo-svc/)
|
|
69
|
+
(0) demo-service-api-key (not yet created)
|
|
70
|
+
|
|
71
|
+
Enter index (or enter to quit): 0
|
|
72
|
+
|
|
73
|
+
Secret: /dev/demo-svc/demo-service-api-key
|
|
74
|
+
The secret does not already exist and will be created
|
|
75
|
+
|
|
76
|
+
Enter value (Ctrl+C to abort): test
|
|
77
|
+
Storing secret value:
|
|
78
|
+
test
|
|
79
|
+
|
|
80
|
+
Secret stored:
|
|
81
|
+
ARN: arn:aws:secretsmanager:eu-west-1:123412341234:secret:/dev/demo-svc/demo-service-api-key-UIqJ8N
|
|
82
|
+
Version: bdbf33c1-eda8-4aa1-b744-7c2006eae338
|
|
83
|
+
|
|
84
|
+
Select secret to write:
|
|
85
|
+
|
|
86
|
+
dev (prefix: /dev/demo-svc/)
|
|
87
|
+
0) test-secret-delete-me (last changed 2024-12-03T13:50:44.237Z)
|
|
88
|
+
|
|
89
|
+
Enter index (or enter to quit):
|
|
90
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import eslint from "@eslint/js";
|
|
2
|
+
import prettierPluginRecommended from "eslint-plugin-prettier/recommended";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
|
|
5
|
+
export default tseslint.config(
|
|
6
|
+
{
|
|
7
|
+
ignores: ["lib/"],
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
extends: [
|
|
11
|
+
eslint.configs.recommended,
|
|
12
|
+
...tseslint.configs.recommended,
|
|
13
|
+
// Must be last to override other rules
|
|
14
|
+
prettierPluginRecommended,
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
rules: {
|
|
18
|
+
"@typescript-eslint/no-unused-vars": [
|
|
19
|
+
"error",
|
|
20
|
+
{
|
|
21
|
+
argsIgnorePattern: "^_",
|
|
22
|
+
varsIgnorePattern: "^_",
|
|
23
|
+
caughtErrorsIgnorePattern: "^_",
|
|
24
|
+
ignoreRestSiblings: true,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
28
|
+
"@typescript-eslint/consistent-type-imports": "error",
|
|
29
|
+
"@typescript-eslint/no-non-null-assertion": "off",
|
|
30
|
+
"@typescript-eslint/explicit-module-boundary-types": "off",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liflig/load-secrets",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Library for loading project secrets into AWS Secrets Manager",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/capralifecycle/load-secrets.git"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"prepare": "husky",
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"lint:fix": "eslint --fix .",
|
|
17
|
+
"semantic-release": "semantic-release"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@aws-sdk/client-secrets-manager": "3.699.0",
|
|
24
|
+
"@aws-sdk/client-sts": "3.699.0",
|
|
25
|
+
"chalk": "4.0.0",
|
|
26
|
+
"typescript": "5.7.2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@commitlint/cli": "19.6.0",
|
|
30
|
+
"@commitlint/config-conventional": "19.6.0",
|
|
31
|
+
"@eslint/js": "9.16.0",
|
|
32
|
+
"@types/node": "22.10.1",
|
|
33
|
+
"@types/read": "0.0.32",
|
|
34
|
+
"eslint": "9.16.0",
|
|
35
|
+
"eslint-config-prettier": "9.1.0",
|
|
36
|
+
"eslint-plugin-prettier": "5.2.1",
|
|
37
|
+
"husky": "9.1.7",
|
|
38
|
+
"semantic-release": "24.2.0",
|
|
39
|
+
"typescript-eslint": "8.17.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import readline from "readline";
|
|
3
|
+
|
|
4
|
+
const CLEAR_WHOLE_LINE = 0;
|
|
5
|
+
|
|
6
|
+
export function createReporter(argv: Record<string, unknown>): CLIReporter {
|
|
7
|
+
return new CLIReporter({
|
|
8
|
+
verbose: !!argv.verbose,
|
|
9
|
+
nonInteractive: !!argv.nonInteractive,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function clearLine(stdout: NodeJS.WriteStream) {
|
|
14
|
+
readline.clearLine(stdout, CLEAR_WHOLE_LINE);
|
|
15
|
+
readline.cursorTo(stdout, 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class CLIReporter {
|
|
19
|
+
public constructor(
|
|
20
|
+
opts: {
|
|
21
|
+
nonInteractive?: boolean;
|
|
22
|
+
verbose?: boolean;
|
|
23
|
+
} = {},
|
|
24
|
+
) {
|
|
25
|
+
this.nonInteractive = !!opts.nonInteractive;
|
|
26
|
+
this.isVerbose = !!opts.verbose;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public stdout = process.stdout;
|
|
30
|
+
public stderr = process.stderr;
|
|
31
|
+
public nonInteractive: boolean;
|
|
32
|
+
public isVerbose: boolean;
|
|
33
|
+
public format: typeof chalk = chalk;
|
|
34
|
+
|
|
35
|
+
public error(msg: string): void {
|
|
36
|
+
clearLine(this.stderr);
|
|
37
|
+
this.stderr.write(`${this.format.red("error")} ${msg}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public log(msg: string): void {
|
|
41
|
+
clearLine(this.stdout);
|
|
42
|
+
this.stdout.write(`${msg}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public warn(msg: string): void {
|
|
46
|
+
clearLine(this.stderr);
|
|
47
|
+
this.stderr.write(`${this.format.yellow("warning")} ${msg}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public info(msg: string): void {
|
|
51
|
+
clearLine(this.stdout);
|
|
52
|
+
this.stdout.write(`${this.format.blue("info")} ${msg}\n`);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as loadSecrets from "./load-secrets";
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DescribeSecretResponse,
|
|
3
|
+
Tag,
|
|
4
|
+
} from "@aws-sdk/client-secrets-manager";
|
|
5
|
+
import {
|
|
6
|
+
CreateSecretCommand,
|
|
7
|
+
DescribeSecretCommand,
|
|
8
|
+
GetSecretValueCommand,
|
|
9
|
+
PutSecretValueCommand,
|
|
10
|
+
RestoreSecretCommand,
|
|
11
|
+
SecretsManagerClient,
|
|
12
|
+
TagResourceCommand,
|
|
13
|
+
UntagResourceCommand,
|
|
14
|
+
ReplicateSecretToRegionsCommand,
|
|
15
|
+
RemoveRegionsFromReplicationCommand,
|
|
16
|
+
} from "@aws-sdk/client-secrets-manager";
|
|
17
|
+
import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
|
|
18
|
+
import { ResourceNotFoundException } from "@aws-sdk/client-secrets-manager";
|
|
19
|
+
import read from "read";
|
|
20
|
+
import type { CLIReporter } from "../cli/reporter";
|
|
21
|
+
import { createReporter } from "../cli/reporter";
|
|
22
|
+
|
|
23
|
+
import type { JsonSecret, Secret, SecretGroup } from "./types";
|
|
24
|
+
|
|
25
|
+
class LoadSecrets {
|
|
26
|
+
private readonly smClientForRegions: Record<string, SecretsManagerClient> =
|
|
27
|
+
{};
|
|
28
|
+
private readonly stsClient: STSClient;
|
|
29
|
+
|
|
30
|
+
private readonly reporter: CLIReporter;
|
|
31
|
+
private readonly silent: boolean;
|
|
32
|
+
|
|
33
|
+
constructor(props: { reporter: CLIReporter; silent: boolean }) {
|
|
34
|
+
this.stsClient = new STSClient({
|
|
35
|
+
region: "eu-west-1",
|
|
36
|
+
});
|
|
37
|
+
this.reporter = props.reporter;
|
|
38
|
+
this.silent = props.silent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private getSmClient(region: string): SecretsManagerClient {
|
|
42
|
+
if (!this.smClientForRegions[region]) {
|
|
43
|
+
this.smClientForRegions[region] = new SecretsManagerClient({
|
|
44
|
+
region,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this.smClientForRegions[region];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getInput(options: read.Options): Promise<string> {
|
|
52
|
+
return new Promise<string>((resolve, reject) => {
|
|
53
|
+
read(options, (err, answer) => {
|
|
54
|
+
if (err) {
|
|
55
|
+
reject(err);
|
|
56
|
+
}
|
|
57
|
+
resolve(answer);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getSecretDetails(
|
|
63
|
+
client: SecretsManagerClient,
|
|
64
|
+
secretId: string,
|
|
65
|
+
): Promise<DescribeSecretResponse | null> {
|
|
66
|
+
try {
|
|
67
|
+
return await client.send(
|
|
68
|
+
new DescribeSecretCommand({ SecretId: secretId }),
|
|
69
|
+
);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e instanceof ResourceNotFoundException) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async handleStringUpdate() {
|
|
80
|
+
return await this.getInput({
|
|
81
|
+
prompt: "Enter value (Ctrl+C to abort): ",
|
|
82
|
+
silent: this.silent,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async handleJsonUpdate(secret: JsonSecret) {
|
|
86
|
+
this.reporter.log("The secret is of type JSON with these expected fields:");
|
|
87
|
+
for (const field of secret.fields) {
|
|
88
|
+
const key = typeof field === "string" ? field : field.key;
|
|
89
|
+
const desc =
|
|
90
|
+
typeof field === "string"
|
|
91
|
+
? ""
|
|
92
|
+
: field.description
|
|
93
|
+
? ` (${field.description})`
|
|
94
|
+
: "";
|
|
95
|
+
this.reporter.log(` - ${key}${desc}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.reporter.log("");
|
|
99
|
+
|
|
100
|
+
// TODO: Ability to specify full json value as one line.
|
|
101
|
+
|
|
102
|
+
const collectedValues: Record<string, string | null> = {};
|
|
103
|
+
for (const field of secret.fields) {
|
|
104
|
+
const key = typeof field === "string" ? field : field.key;
|
|
105
|
+
|
|
106
|
+
this.reporter.log(`Field: ${this.reporter.format.greenBright(key)}`);
|
|
107
|
+
|
|
108
|
+
if (typeof field !== "string" && field.example != null) {
|
|
109
|
+
this.reporter.log(
|
|
110
|
+
`Example: ${this.reporter.format.magentaBright(field.example)}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const value = await this.getInput({
|
|
115
|
+
prompt: "Enter value (Ctrl+C to abort): ",
|
|
116
|
+
silent: this.silent,
|
|
117
|
+
});
|
|
118
|
+
collectedValues[key] = value;
|
|
119
|
+
|
|
120
|
+
this.reporter.log("");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return JSON.stringify(collectedValues, undefined, " ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getFullName(secretGroup: SecretGroup, secret: Secret) {
|
|
127
|
+
return `${secretGroup.namePrefix}${secret.name}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async syncTags(
|
|
131
|
+
client: SecretsManagerClient,
|
|
132
|
+
secret: DescribeSecretResponse,
|
|
133
|
+
tags: Tag[],
|
|
134
|
+
) {
|
|
135
|
+
const keysToRemove = secret
|
|
136
|
+
.Tags!.filter(
|
|
137
|
+
(existingTag) => !tags.some((it) => it.Key! === existingTag.Key!),
|
|
138
|
+
)
|
|
139
|
+
.map((it) => it.Key!);
|
|
140
|
+
|
|
141
|
+
if (keysToRemove.length > 0) {
|
|
142
|
+
this.reporter.log(`Removing obsolete tags: ${keysToRemove.join(", ")}`);
|
|
143
|
+
await client.send(
|
|
144
|
+
new UntagResourceCommand({
|
|
145
|
+
SecretId: secret.ARN!,
|
|
146
|
+
TagKeys: keysToRemove,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const tagsToUpdate = tags.filter((expectedTag) => {
|
|
152
|
+
const existing = secret.Tags!.find((it) => it.Key! === expectedTag.Key!);
|
|
153
|
+
return existing == null || existing.Value != expectedTag.Value;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (tagsToUpdate.length > 0) {
|
|
157
|
+
this.reporter.log(
|
|
158
|
+
`Storing tags: ${tagsToUpdate.map((it) => it.Key!).join(", ")}`,
|
|
159
|
+
);
|
|
160
|
+
await client.send(
|
|
161
|
+
new TagResourceCommand({
|
|
162
|
+
SecretId: secret.ARN!,
|
|
163
|
+
Tags: tagsToUpdate,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getSecretValue(client: SecretsManagerClient, secretId: string) {
|
|
170
|
+
const result = await client.send(
|
|
171
|
+
new GetSecretValueCommand({
|
|
172
|
+
SecretId: secretId,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (result.SecretString == null) {
|
|
177
|
+
throw new Error("Missing SecretString (is it a binary?)");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result.SecretString;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async handleUpdate(secretGroup: SecretGroup, secret: Secret) {
|
|
184
|
+
const client = this.getSmClient(secretGroup.region);
|
|
185
|
+
|
|
186
|
+
const fullName = this.getFullName(secretGroup, secret);
|
|
187
|
+
const describeSecret = await this.getSecretDetails(client, fullName);
|
|
188
|
+
|
|
189
|
+
this.reporter.log(`Secret: ${this.reporter.format.greenBright(fullName)}`);
|
|
190
|
+
|
|
191
|
+
if (describeSecret == null) {
|
|
192
|
+
this.reporter.log(
|
|
193
|
+
"The secret does not already exist and will be created",
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
this.reporter.log("Current value:");
|
|
197
|
+
this.reporter.log(
|
|
198
|
+
this.reporter.format.yellowBright(
|
|
199
|
+
(await this.getSecretValue(client, fullName)).replace(/^/gm, " "),
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.reporter.log("");
|
|
205
|
+
|
|
206
|
+
let secretValue: string;
|
|
207
|
+
|
|
208
|
+
if (secret.type === "json") {
|
|
209
|
+
try {
|
|
210
|
+
secretValue = await this.handleJsonUpdate(secret);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e instanceof Error && e.message === "canceled") {
|
|
213
|
+
this.reporter.log("Aborted");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
} else if (secret.type === "string") {
|
|
219
|
+
secretValue = await this.handleStringUpdate();
|
|
220
|
+
} else {
|
|
221
|
+
throw new Error(`Unsupported type`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.reporter.log("Storing secret value:");
|
|
225
|
+
this.reporter.log(
|
|
226
|
+
this.reporter.format.yellowBright(secretValue.replace(/^/gm, " ")),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const tags: Tag[] = [
|
|
230
|
+
{
|
|
231
|
+
Key: "Source",
|
|
232
|
+
Value: "load-secrets script",
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
let arn: string;
|
|
237
|
+
let version: string;
|
|
238
|
+
let newReplicaRegions: string[] = [];
|
|
239
|
+
let removedReplicaRegions: string[] = [];
|
|
240
|
+
|
|
241
|
+
if (describeSecret == null) {
|
|
242
|
+
newReplicaRegions = secret.replicaRegions ?? [];
|
|
243
|
+
const createResult = await client.send(
|
|
244
|
+
new CreateSecretCommand({
|
|
245
|
+
Name: fullName,
|
|
246
|
+
AddReplicaRegions: secret.replicaRegions
|
|
247
|
+
? secret.replicaRegions.map((replicaRegion) => ({
|
|
248
|
+
Region: replicaRegion,
|
|
249
|
+
}))
|
|
250
|
+
: undefined,
|
|
251
|
+
Description: "Created by load-secrets",
|
|
252
|
+
SecretString: secretValue,
|
|
253
|
+
Tags: tags,
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (createResult.VersionId == null) {
|
|
258
|
+
throw new Error("Expected versionId");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
arn = createResult.ARN!;
|
|
262
|
+
version = createResult.VersionId;
|
|
263
|
+
} else {
|
|
264
|
+
if (describeSecret.DeletedDate != null) {
|
|
265
|
+
await client.send(
|
|
266
|
+
new RestoreSecretCommand({
|
|
267
|
+
SecretId: fullName,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const updateResult = await client.send(
|
|
273
|
+
new PutSecretValueCommand({
|
|
274
|
+
SecretId: fullName,
|
|
275
|
+
SecretString: secretValue,
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
const currentReplicaRegions =
|
|
279
|
+
describeSecret.ReplicationStatus?.map(
|
|
280
|
+
(replicationStatus) => replicationStatus.Region,
|
|
281
|
+
) ?? [];
|
|
282
|
+
newReplicaRegions =
|
|
283
|
+
secret.replicaRegions?.filter(
|
|
284
|
+
(region) => !currentReplicaRegions.includes(region),
|
|
285
|
+
) ?? [];
|
|
286
|
+
removedReplicaRegions = currentReplicaRegions
|
|
287
|
+
.filter(
|
|
288
|
+
(region): region is string => !!region && typeof region === "string",
|
|
289
|
+
)
|
|
290
|
+
.filter((region) => !(secret.replicaRegions || []).includes(region));
|
|
291
|
+
if (newReplicaRegions.length > 0) {
|
|
292
|
+
await client.send(
|
|
293
|
+
new ReplicateSecretToRegionsCommand({
|
|
294
|
+
SecretId: fullName,
|
|
295
|
+
AddReplicaRegions: newReplicaRegions.map((region) => ({
|
|
296
|
+
Region: region,
|
|
297
|
+
})),
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (removedReplicaRegions.length > 0) {
|
|
302
|
+
await client.send(
|
|
303
|
+
new RemoveRegionsFromReplicationCommand({
|
|
304
|
+
SecretId: fullName,
|
|
305
|
+
RemoveReplicaRegions: removedReplicaRegions,
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (updateResult.VersionId == null) {
|
|
311
|
+
throw new Error("Expected versionId");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await this.syncTags(client, describeSecret, tags);
|
|
315
|
+
|
|
316
|
+
arn = updateResult.ARN!;
|
|
317
|
+
version = updateResult.VersionId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this.reporter.log("");
|
|
321
|
+
this.reporter.log("Secret stored:");
|
|
322
|
+
this.reporter.log(`ARN: ${this.reporter.format.greenBright(arn)}`);
|
|
323
|
+
this.reporter.log(`Version: ${this.reporter.format.greenBright(version)}`);
|
|
324
|
+
if (newReplicaRegions.length > 0) {
|
|
325
|
+
this.reporter.log(
|
|
326
|
+
`Read replicas added to regions: ${newReplicaRegions
|
|
327
|
+
.map((r) => this.reporter.format.greenBright(r))
|
|
328
|
+
.join(", ")}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (removedReplicaRegions.length > 0) {
|
|
332
|
+
this.reporter.log(
|
|
333
|
+
`Read replicas removed from regions: ${removedReplicaRegions
|
|
334
|
+
.map((r) => this.reporter.format.redBright(r))
|
|
335
|
+
.join(", ")}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
checkSecretGroup(secretGroup: SecretGroup) {
|
|
341
|
+
if (
|
|
342
|
+
!secretGroup.namePrefix.startsWith("/") ||
|
|
343
|
+
!secretGroup.namePrefix.endsWith("/")
|
|
344
|
+
) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`namePrefix should start and end with /. Current value: ${secretGroup.namePrefix}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getSecretDescription(details: DescribeSecretResponse | null) {
|
|
352
|
+
return details == null
|
|
353
|
+
? "not yet created"
|
|
354
|
+
: details?.DeletedDate != null
|
|
355
|
+
? `scheduled for deletion ${details.DeletedDate.toISOString()}`
|
|
356
|
+
: `last changed ${details.LastChangedDate?.toISOString() ?? "unknown"}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Returns false if aborted.
|
|
361
|
+
*/
|
|
362
|
+
async selectAndUpdate(secretGroups: SecretGroup[]): Promise<boolean> {
|
|
363
|
+
const secrets: { secretGroup: SecretGroup; secret: Secret }[] = [];
|
|
364
|
+
|
|
365
|
+
this.reporter.log("Select secret to write:");
|
|
366
|
+
this.reporter.log("");
|
|
367
|
+
|
|
368
|
+
for (const secretGroup of secretGroups) {
|
|
369
|
+
this.reporter.log(
|
|
370
|
+
`${secretGroup.description} (prefix: ${secretGroup.namePrefix})`,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
for (let i = 0; i < secretGroup.secrets.length; i++) {
|
|
374
|
+
const offset = secrets.length;
|
|
375
|
+
const secret = secretGroup.secrets[i];
|
|
376
|
+
|
|
377
|
+
secrets.push({
|
|
378
|
+
secret: secret,
|
|
379
|
+
secretGroup,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const client = this.getSmClient(secretGroup.region);
|
|
383
|
+
const details = await this.getSecretDetails(
|
|
384
|
+
client,
|
|
385
|
+
this.getFullName(secretGroup, secret),
|
|
386
|
+
);
|
|
387
|
+
const desc = this.getSecretDescription(details);
|
|
388
|
+
|
|
389
|
+
this.reporter.log(` (${offset}) ${secret.name} (${desc})`);
|
|
390
|
+
}
|
|
391
|
+
this.reporter.log("");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let index: number;
|
|
395
|
+
try {
|
|
396
|
+
const answer = await this.getInput({
|
|
397
|
+
prompt: "Enter index (or enter to quit): ",
|
|
398
|
+
});
|
|
399
|
+
if (answer.trim() === "") {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
index = parseInt(answer);
|
|
404
|
+
if (!secrets[index]) {
|
|
405
|
+
throw new Error();
|
|
406
|
+
}
|
|
407
|
+
} catch (_) {
|
|
408
|
+
this.reporter.warn("Secret not found - aborting");
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
this.reporter.log("");
|
|
413
|
+
await this.handleUpdate(secrets[index].secretGroup, secrets[index].secret);
|
|
414
|
+
this.reporter.log("");
|
|
415
|
+
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async process(secretGroups: SecretGroup[]) {
|
|
420
|
+
this.reporter.info("Checking account for current credentials");
|
|
421
|
+
this.reporter.info(
|
|
422
|
+
"If any error is given, make sure you have valid credentials active",
|
|
423
|
+
);
|
|
424
|
+
const currentAccount = await this.stsClient.send(
|
|
425
|
+
new GetCallerIdentityCommand({}),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
this.reporter.info(`Running for account ${currentAccount.Account!}`);
|
|
429
|
+
this.reporter.log("");
|
|
430
|
+
|
|
431
|
+
const matchedSecretGroups = secretGroups.filter(
|
|
432
|
+
(it) => it.accountId === currentAccount.Account!,
|
|
433
|
+
);
|
|
434
|
+
if (matchedSecretGroups.length === 0) {
|
|
435
|
+
this.reporter.error(`No secrets specified for this account - aborting`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
for (const secretGroup of matchedSecretGroups) {
|
|
440
|
+
this.checkSecretGroup(secretGroup);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// eslint-disable-next-line no-empty
|
|
444
|
+
while (await this.selectAndUpdate(matchedSecretGroups)) {}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Load secrets interactively into Secrets Manager.
|
|
450
|
+
*/
|
|
451
|
+
export function loadSecretsCli(props: { secretGroups: SecretGroup[] }): void {
|
|
452
|
+
const loadSecrets = new LoadSecrets({
|
|
453
|
+
reporter: createReporter({}),
|
|
454
|
+
// For now, we show the secrets, so that we get positive feedback that the value
|
|
455
|
+
// is correctly entered.
|
|
456
|
+
silent: false,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
loadSecrets.process(props.secretGroups).catch((error) => {
|
|
460
|
+
console.error(error.stack || error.message || error);
|
|
461
|
+
process.exitCode = 1;
|
|
462
|
+
});
|
|
463
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface BaseSecret {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
/**
|
|
5
|
+
* A list of regions to create read replicas
|
|
6
|
+
* of the secret in.
|
|
7
|
+
*/
|
|
8
|
+
replicaRegions?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type JsonSecretSimpleField = string;
|
|
12
|
+
|
|
13
|
+
export interface JsonSecretDescribedField {
|
|
14
|
+
key: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
example?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Used for secrets that are a single plaintext string,
|
|
21
|
+
* and do not require JSON formating.
|
|
22
|
+
*/
|
|
23
|
+
export interface StringSecret extends BaseSecret {
|
|
24
|
+
type: "string";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JsonSecret extends BaseSecret {
|
|
28
|
+
type: "json";
|
|
29
|
+
fields: (JsonSecretSimpleField | JsonSecretDescribedField)[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Secret = JsonSecret | StringSecret;
|
|
33
|
+
|
|
34
|
+
export interface SecretGroup {
|
|
35
|
+
accountId: string;
|
|
36
|
+
region: string;
|
|
37
|
+
description: string;
|
|
38
|
+
namePrefix: string;
|
|
39
|
+
secrets: Secret[];
|
|
40
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"esModuleInterop": true,
|
|
4
|
+
"skipLibCheck": true,
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"verbatimModuleSyntax": false,
|
|
13
|
+
"useUnknownInCatchVariables": false,
|
|
14
|
+
|
|
15
|
+
"noEmit": false,
|
|
16
|
+
"declaration": true,
|
|
17
|
+
|
|
18
|
+
"module": "preserve",
|
|
19
|
+
"outDir": "./lib",
|
|
20
|
+
|
|
21
|
+
"lib": ["es2022"]
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"],
|
|
24
|
+
"exclude": ["cdk.out", "node_modules"]
|
|
25
|
+
}
|