@crewhaus/secrets-manager 0.1.4 → 0.1.6
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/dist/backends/env-var.d.ts +13 -0
- package/dist/backends/env-var.js +37 -0
- package/dist/backends/file.d.ts +6 -0
- package/dist/backends/file.js +54 -0
- package/dist/backends/vault.d.ts +19 -0
- package/dist/backends/vault.js +67 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +99 -0
- package/package.json +11 -8
- package/src/backends/env-var.test.ts +0 -125
- package/src/backends/env-var.ts +0 -52
- package/src/backends/file.test.ts +0 -194
- package/src/backends/file.ts +0 -69
- package/src/backends/vault.test.ts +0 -242
- package/src/backends/vault.ts +0 -85
- package/src/index.test.ts +0 -340
- package/src/index.ts +0 -161
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 27 — `file` backend coverage.
|
|
3
|
-
*
|
|
4
|
-
* `node:fs` is fully mocked with an in-memory store so no real disk I/O
|
|
5
|
-
* occurs (no tmp dirs, no leaked handles, deterministic). `crypto` is the
|
|
6
|
-
* real WebCrypto for the auto-generate path, but we only assert its shape
|
|
7
|
-
* (64 hex chars), so it stays deterministic in intent. `mock.restore()` does
|
|
8
|
-
* NOT undo `mock.module`, and Bun shares one module registry across all test
|
|
9
|
-
* files (nondeterministic order) — so the `afterAll` below reinstalls the
|
|
10
|
-
* real `node:fs`, keeping the in-memory fake from leaking into sibling files.
|
|
11
|
-
*/
|
|
12
|
-
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
-
|
|
14
|
-
// Captured BEFORE the mock below so afterAll can reinstall the real module.
|
|
15
|
-
const realFs = require("node:fs") as typeof import("node:fs");
|
|
16
|
-
|
|
17
|
-
/** In-memory filesystem state shared with the node:fs mock. */
|
|
18
|
-
type FsState = {
|
|
19
|
-
files: Map<string, { content: string; mode?: number }>;
|
|
20
|
-
dirs: Set<string>;
|
|
21
|
-
writes: Array<{ path: string; content: string; mode?: number }>;
|
|
22
|
-
renames: Array<{ from: string; to: string }>;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
let fsState: FsState;
|
|
26
|
-
|
|
27
|
-
function freshState(): FsState {
|
|
28
|
-
return { files: new Map(), dirs: new Set(), writes: [], renames: [] };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Initialise before the module under test is imported.
|
|
32
|
-
fsState = freshState();
|
|
33
|
-
|
|
34
|
-
mock.module("node:fs", () => ({
|
|
35
|
-
existsSync: (p: string) => fsState.files.has(p) || fsState.dirs.has(p),
|
|
36
|
-
readFileSync: (p: string, _enc?: string) => {
|
|
37
|
-
const f = fsState.files.get(p);
|
|
38
|
-
if (!f) {
|
|
39
|
-
const err = new Error(`ENOENT: no such file ${p}`) as NodeJS.ErrnoException;
|
|
40
|
-
err.code = "ENOENT";
|
|
41
|
-
throw err;
|
|
42
|
-
}
|
|
43
|
-
return f.content;
|
|
44
|
-
},
|
|
45
|
-
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => {
|
|
46
|
-
fsState.files.set(p, { content: data, mode: opts?.mode });
|
|
47
|
-
fsState.writes.push({ path: p, content: data, mode: opts?.mode });
|
|
48
|
-
},
|
|
49
|
-
renameSync: (from: string, to: string) => {
|
|
50
|
-
const f = fsState.files.get(from);
|
|
51
|
-
if (f) {
|
|
52
|
-
fsState.files.set(to, f);
|
|
53
|
-
fsState.files.delete(from);
|
|
54
|
-
}
|
|
55
|
-
fsState.renames.push({ from, to });
|
|
56
|
-
},
|
|
57
|
-
mkdirSync: (p: string, _opts?: { recursive?: boolean; mode?: number }) => {
|
|
58
|
-
fsState.dirs.add(p);
|
|
59
|
-
},
|
|
60
|
-
readdirSync: (p: string) => {
|
|
61
|
-
if (!fsState.dirs.has(p)) {
|
|
62
|
-
const err = new Error(`ENOENT: no such dir ${p}`) as NodeJS.ErrnoException;
|
|
63
|
-
err.code = "ENOENT";
|
|
64
|
-
throw err;
|
|
65
|
-
}
|
|
66
|
-
const prefix = `${p}/`;
|
|
67
|
-
const out: string[] = [];
|
|
68
|
-
for (const key of fsState.files.keys()) {
|
|
69
|
-
if (key.startsWith(prefix)) out.push(key.slice(prefix.length));
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
},
|
|
73
|
-
}));
|
|
74
|
-
|
|
75
|
-
// Import AFTER the mock is registered so the SUT binds to the fake fs.
|
|
76
|
-
const { createFileBackend } = await import("../backends/file");
|
|
77
|
-
const { SecretsError } = await import("../index");
|
|
78
|
-
|
|
79
|
-
const ROOT = "/fake/secrets";
|
|
80
|
-
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
fsState = freshState();
|
|
83
|
-
fsState.dirs.add(ROOT);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
afterEach(() => {
|
|
87
|
-
// Reset the in-memory store so each test starts from a clean slate.
|
|
88
|
-
fsState = freshState();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
afterAll(() => {
|
|
92
|
-
// Reinstall the real module so the in-memory fake cannot outlive this file.
|
|
93
|
-
mock.module("node:fs", () => realFs);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("file backend — get()", () => {
|
|
97
|
-
test("returns file contents (utf8, whitespace preserved)", async () => {
|
|
98
|
-
fsState.files.set(`${ROOT}/API_KEY`, { content: " secret with spaces \n" });
|
|
99
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
100
|
-
expect(await backend.get("API_KEY")).toBe(" secret with spaces \n");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("throws SecretsError when the file is missing, leaking neither name nor path", async () => {
|
|
104
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
105
|
-
expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
|
|
106
|
-
await backend.get("MISSING").catch((e: unknown) => {
|
|
107
|
-
const msg = (e as Error).message;
|
|
108
|
-
expect(msg).toBe("secret file read failed (not found)");
|
|
109
|
-
// the secret name and the on-disk path must not appear in the message.
|
|
110
|
-
expect(msg).not.toContain("MISSING");
|
|
111
|
-
expect(msg).not.toContain(ROOT);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("rejects path-traversal names before touching fs, without echoing the name", async () => {
|
|
116
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
117
|
-
expect(backend.get("../../etc/passwd")).rejects.toBeInstanceOf(SecretsError);
|
|
118
|
-
await backend.get("../../etc/passwd").catch((e: unknown) => {
|
|
119
|
-
const msg = (e as Error).message;
|
|
120
|
-
expect(msg).toContain("invalid secret name");
|
|
121
|
-
expect(msg).not.toContain("../../etc/passwd");
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("file backend — rotate()", () => {
|
|
127
|
-
test("writes to a .tmp file (mode 0o600) then renames atomically", async () => {
|
|
128
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
129
|
-
|
|
130
|
-
const v = await backend.rotate("TOKEN", { newValue: "fresh-token" });
|
|
131
|
-
|
|
132
|
-
expect(v).toBe("fresh-token");
|
|
133
|
-
// The committed file holds the new value...
|
|
134
|
-
expect(fsState.files.get(`${ROOT}/TOKEN`)?.content).toBe("fresh-token");
|
|
135
|
-
// ...written first to the tmp path with restrictive mode...
|
|
136
|
-
const tmpWrite = fsState.writes.find((w) => w.path === `${ROOT}/TOKEN.tmp`);
|
|
137
|
-
expect(tmpWrite).toBeDefined();
|
|
138
|
-
expect(tmpWrite?.mode).toBe(0o600);
|
|
139
|
-
// ...and committed via rename(tmp -> final).
|
|
140
|
-
expect(fsState.renames).toContainEqual({
|
|
141
|
-
from: `${ROOT}/TOKEN.tmp`,
|
|
142
|
-
to: `${ROOT}/TOKEN`,
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("auto-generates a 64-char hex secret when newValue is omitted", async () => {
|
|
147
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
148
|
-
const v = await backend.rotate("AUTO");
|
|
149
|
-
expect(v).toMatch(/^[a-f0-9]{64}$/);
|
|
150
|
-
expect(fsState.files.get(`${ROOT}/AUTO`)?.content).toBe(v);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("rejects malformed names without writing", async () => {
|
|
154
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
155
|
-
expect(backend.rotate("path/with/slash")).rejects.toBeInstanceOf(SecretsError);
|
|
156
|
-
expect(fsState.writes.length).toBe(0);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("creates the root dir on first rotate instead of failing with ENOENT", async () => {
|
|
160
|
-
const freshRoot = "/fake/brand-new/secrets";
|
|
161
|
-
expect(fsState.dirs.has(freshRoot)).toBe(false);
|
|
162
|
-
const backend = createFileBackend({ rootDir: freshRoot });
|
|
163
|
-
|
|
164
|
-
const v = await backend.rotate("TOKEN", { newValue: "fresh" });
|
|
165
|
-
|
|
166
|
-
expect(v).toBe("fresh");
|
|
167
|
-
expect(fsState.dirs.has(freshRoot)).toBe(true);
|
|
168
|
-
expect(fsState.files.get(`${freshRoot}/TOKEN`)?.content).toBe("fresh");
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe("file backend — list()", () => {
|
|
173
|
-
test("lists committed secrets, skipping .tmp and dotfiles", async () => {
|
|
174
|
-
fsState.files.set(`${ROOT}/A`, { content: "1" });
|
|
175
|
-
fsState.files.set(`${ROOT}/B`, { content: "2" });
|
|
176
|
-
fsState.files.set(`${ROOT}/C.tmp`, { content: "x" });
|
|
177
|
-
fsState.files.set(`${ROOT}/.hidden`, { content: "y" });
|
|
178
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
179
|
-
|
|
180
|
-
const names = await backend.list?.();
|
|
181
|
-
|
|
182
|
-
expect([...(names ?? [])].sort()).toEqual(["A", "B"]);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("returns [] when the root dir does not exist", async () => {
|
|
186
|
-
const backend = createFileBackend({ rootDir: "/fake/does-not-exist" });
|
|
187
|
-
expect(await backend.list?.()).toEqual([]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("returns [] when the root dir exists but is empty", async () => {
|
|
191
|
-
const backend = createFileBackend({ rootDir: ROOT });
|
|
192
|
-
expect(await backend.list?.()).toEqual([]);
|
|
193
|
-
});
|
|
194
|
-
});
|
package/src/backends/file.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* file backend — reads `<rootDir>/<name>` (mode 0o600 enforced on write).
|
|
3
|
-
* Rotation is an atomic rewrite: write to `.<name>.tmp`, then rename.
|
|
4
|
-
*
|
|
5
|
-
* The file content is the raw secret value (no JSON wrapper, no
|
|
6
|
-
* trailing newline-stripping ambiguity). Whitespace is preserved as-is
|
|
7
|
-
* for tokens that may legitimately contain it.
|
|
8
|
-
*/
|
|
9
|
-
import {
|
|
10
|
-
existsSync,
|
|
11
|
-
mkdirSync,
|
|
12
|
-
readFileSync,
|
|
13
|
-
readdirSync,
|
|
14
|
-
renameSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
18
|
-
import { type SecretValue, type SecretsBackend, SecretsError } from "../index";
|
|
19
|
-
|
|
20
|
-
export type FileBackendOptions = {
|
|
21
|
-
/** Default: `.crewhaus/secrets`. */
|
|
22
|
-
readonly rootDir: string;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export function createFileBackend(opts: FileBackendOptions): SecretsBackend {
|
|
26
|
-
const rootDir = opts.rootDir;
|
|
27
|
-
|
|
28
|
-
function pathFor(name: string): string {
|
|
29
|
-
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
30
|
-
throw new SecretsError("invalid secret name (must match [A-Za-z0-9_.-]+)");
|
|
31
|
-
}
|
|
32
|
-
return join(rootDir, name);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
id: "file",
|
|
37
|
-
async get(name: string): Promise<SecretValue> {
|
|
38
|
-
const p = pathFor(name);
|
|
39
|
-
if (!existsSync(p)) {
|
|
40
|
-
throw new SecretsError("secret file read failed (not found)");
|
|
41
|
-
}
|
|
42
|
-
return readFileSync(p, "utf8");
|
|
43
|
-
},
|
|
44
|
-
async rotate(name: string, rotateOpts): Promise<SecretValue> {
|
|
45
|
-
const p = pathFor(name);
|
|
46
|
-
const newValue = rotateOpts?.newValue ?? generateRandomSecret();
|
|
47
|
-
// Create the secrets root on first use so a fresh checkout can rotate a
|
|
48
|
-
// secret without a raw ENOENT — recursive mkdir is a no-op when it
|
|
49
|
-
// already exists. 0o700 keeps the directory owner-only, consistent with
|
|
50
|
-
// the 0o600 secret files written into it.
|
|
51
|
-
mkdirSync(rootDir, { recursive: true, mode: 0o700 });
|
|
52
|
-
const tmp = `${p}.tmp`;
|
|
53
|
-
writeFileSync(tmp, newValue, { encoding: "utf8", mode: 0o600 });
|
|
54
|
-
renameSync(tmp, p);
|
|
55
|
-
return newValue;
|
|
56
|
-
},
|
|
57
|
-
async list(): Promise<ReadonlyArray<string>> {
|
|
58
|
-
if (!existsSync(rootDir)) return [];
|
|
59
|
-
return readdirSync(rootDir).filter((f) => !f.endsWith(".tmp") && !f.startsWith("."));
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function generateRandomSecret(): string {
|
|
65
|
-
// 32 bytes hex → 64 chars; sufficient for tokens.
|
|
66
|
-
const bytes = new Uint8Array(32);
|
|
67
|
-
crypto.getRandomValues(bytes);
|
|
68
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
69
|
-
}
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 27 — `vault` backend coverage.
|
|
3
|
-
*
|
|
4
|
-
* No real network: every test injects a deterministic `fetchImpl` that
|
|
5
|
-
* returns canned `Response`s. `VAULT_TOKEN` is saved in beforeEach and
|
|
6
|
-
* restored in afterEach so the surrounding process env is never mutated
|
|
7
|
-
* across tests. No real clock, no leaked handles.
|
|
8
|
-
*/
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
-
import { createVaultBackend } from "../backends/vault";
|
|
11
|
-
import { SecretsError } from "../index";
|
|
12
|
-
|
|
13
|
-
const ADDR = "http://127.0.0.1:8200";
|
|
14
|
-
|
|
15
|
-
/** Build a fetch stub plus a record of the calls it observed. */
|
|
16
|
-
function stubFetch(responder: (url: string, init?: RequestInit) => Response) {
|
|
17
|
-
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
18
|
-
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
19
|
-
calls.push({ url, init });
|
|
20
|
-
return responder(url, init);
|
|
21
|
-
}) as unknown as typeof fetch;
|
|
22
|
-
return { fetchImpl, calls };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let savedToken: string | undefined;
|
|
26
|
-
|
|
27
|
-
beforeEach(() => {
|
|
28
|
-
savedToken = process.env["VAULT_TOKEN"];
|
|
29
|
-
Reflect.deleteProperty(process.env, "VAULT_TOKEN");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
if (savedToken === undefined) {
|
|
34
|
-
Reflect.deleteProperty(process.env, "VAULT_TOKEN");
|
|
35
|
-
} else {
|
|
36
|
-
process.env["VAULT_TOKEN"] = savedToken;
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe("vault backend — id + token resolution", () => {
|
|
41
|
-
test("exposes the vault id", () => {
|
|
42
|
-
const backend = createVaultBackend({
|
|
43
|
-
addr: ADDR,
|
|
44
|
-
token: "t",
|
|
45
|
-
fetchImpl: (async () => new Response("{}")) as unknown as typeof fetch,
|
|
46
|
-
});
|
|
47
|
-
expect(backend.id).toBe("vault");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("falls back to VAULT_TOKEN env when no constructor token is given", async () => {
|
|
51
|
-
process.env["VAULT_TOKEN"] = "env-token";
|
|
52
|
-
const { fetchImpl, calls } = stubFetch(
|
|
53
|
-
() => new Response(JSON.stringify({ data: { data: { value: "v" } } }), { status: 200 }),
|
|
54
|
-
);
|
|
55
|
-
const backend = createVaultBackend({ addr: ADDR, fetchImpl });
|
|
56
|
-
|
|
57
|
-
await backend.get("K");
|
|
58
|
-
|
|
59
|
-
const sentToken = (calls[0]?.init?.headers as Record<string, string>)?.["X-Vault-Token"];
|
|
60
|
-
expect(sentToken).toBe("env-token");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("constructor token wins over VAULT_TOKEN env", async () => {
|
|
64
|
-
process.env["VAULT_TOKEN"] = "env-token";
|
|
65
|
-
const { fetchImpl, calls } = stubFetch(
|
|
66
|
-
() => new Response(JSON.stringify({ data: { data: { value: "v" } } }), { status: 200 }),
|
|
67
|
-
);
|
|
68
|
-
const backend = createVaultBackend({ addr: ADDR, token: "ctor-token", fetchImpl });
|
|
69
|
-
|
|
70
|
-
await backend.get("K");
|
|
71
|
-
|
|
72
|
-
const sentToken = (calls[0]?.init?.headers as Record<string, string>)?.["X-Vault-Token"];
|
|
73
|
-
expect(sentToken).toBe("ctor-token");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("throws when neither constructor token nor VAULT_TOKEN is set", async () => {
|
|
77
|
-
const { fetchImpl } = stubFetch(() => new Response("{}"));
|
|
78
|
-
const backend = createVaultBackend({ addr: ADDR, fetchImpl });
|
|
79
|
-
expect(backend.get("K")).rejects.toBeInstanceOf(SecretsError);
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe("vault backend — dataUrl name validation", () => {
|
|
84
|
-
test("get rejects names with illegal characters (no fetch issued)", async () => {
|
|
85
|
-
const { fetchImpl, calls } = stubFetch(() => new Response("{}"));
|
|
86
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
87
|
-
expect(backend.get("bad name!")).rejects.toBeInstanceOf(SecretsError);
|
|
88
|
-
await backend.get("bad name!").catch((e: unknown) => {
|
|
89
|
-
const msg = (e as Error).message;
|
|
90
|
-
expect(msg).toContain("invalid secret name");
|
|
91
|
-
expect(msg).not.toContain("bad name!");
|
|
92
|
-
});
|
|
93
|
-
expect(calls.length).toBe(0);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("rotate rejects names with illegal characters (no fetch issued)", async () => {
|
|
97
|
-
const { fetchImpl, calls } = stubFetch(() => new Response("{}"));
|
|
98
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
99
|
-
expect(backend.rotate("space here")).rejects.toBeInstanceOf(SecretsError);
|
|
100
|
-
await backend.rotate("space here").catch(() => {});
|
|
101
|
-
expect(calls.length).toBe(0);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("allows KV-v2 nested paths (slashes are legal)", async () => {
|
|
105
|
-
const { fetchImpl, calls } = stubFetch(
|
|
106
|
-
() => new Response(JSON.stringify({ data: { data: { value: "nested" } } }), { status: 200 }),
|
|
107
|
-
);
|
|
108
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
109
|
-
expect(await backend.get("app/db/password")).toBe("nested");
|
|
110
|
-
expect(calls[0]?.url).toBe(`${ADDR}/v1/secret/data/app/db/password`);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("uses a custom mount point when provided", async () => {
|
|
114
|
-
const { fetchImpl, calls } = stubFetch(
|
|
115
|
-
() => new Response(JSON.stringify({ data: { data: { value: "v" } } }), { status: 200 }),
|
|
116
|
-
);
|
|
117
|
-
const backend = createVaultBackend({ addr: ADDR, mount: "kv2", token: "t", fetchImpl });
|
|
118
|
-
await backend.get("K");
|
|
119
|
-
expect(calls[0]?.url).toBe(`${ADDR}/v1/kv2/data/K`);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe("vault backend — get() response handling", () => {
|
|
124
|
-
test("returns the KV-v2 value on 200", async () => {
|
|
125
|
-
const { fetchImpl } = stubFetch(
|
|
126
|
-
() =>
|
|
127
|
-
new Response(JSON.stringify({ data: { data: { value: "vault-secret" } } }), {
|
|
128
|
-
status: 200,
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
132
|
-
expect(await backend.get("K")).toBe("vault-secret");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("throws SecretsError on 404 (missing secret) without leaking name or url", async () => {
|
|
136
|
-
const { fetchImpl } = stubFetch(() => new Response("not found", { status: 404 }));
|
|
137
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
138
|
-
expect(backend.get("SENSITIVE_NAME")).rejects.toBeInstanceOf(SecretsError);
|
|
139
|
-
await backend.get("SENSITIVE_NAME").catch((e: unknown) => {
|
|
140
|
-
const msg = (e as Error).message;
|
|
141
|
-
expect(msg).toContain("404");
|
|
142
|
-
expect(msg).not.toContain("SENSITIVE_NAME");
|
|
143
|
-
// the url embeds the name + addr; it must not appear either.
|
|
144
|
-
expect(msg).not.toContain(ADDR);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("throws SecretsError on a non-ok, non-404 status (e.g. 500) with status but no name/body", async () => {
|
|
149
|
-
const { fetchImpl } = stubFetch(() => new Response("permission denied", { status: 500 }));
|
|
150
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
151
|
-
expect(backend.get("SENSITIVE_NAME")).rejects.toBeInstanceOf(SecretsError);
|
|
152
|
-
await backend.get("SENSITIVE_NAME").catch((e: unknown) => {
|
|
153
|
-
const msg = (e as Error).message;
|
|
154
|
-
// status is retained for debugging...
|
|
155
|
-
expect(msg).toContain("500");
|
|
156
|
-
// ...but the raw response body and the secret name are redacted.
|
|
157
|
-
expect(msg).not.toContain("permission denied");
|
|
158
|
-
expect(msg).not.toContain("SENSITIVE_NAME");
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("throws when KV-v2 body is missing data.data.value", async () => {
|
|
163
|
-
const { fetchImpl } = stubFetch(
|
|
164
|
-
() => new Response(JSON.stringify({ data: { data: {} } }), { status: 200 }),
|
|
165
|
-
);
|
|
166
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
167
|
-
expect(backend.get("SENSITIVE_NAME")).rejects.toBeInstanceOf(SecretsError);
|
|
168
|
-
await backend.get("SENSITIVE_NAME").catch((e: unknown) => {
|
|
169
|
-
const msg = (e as Error).message;
|
|
170
|
-
expect(msg).toContain("missing data.data.value");
|
|
171
|
-
expect(msg).not.toContain("SENSITIVE_NAME");
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("throws when the value is present but not a string", async () => {
|
|
176
|
-
const { fetchImpl } = stubFetch(
|
|
177
|
-
() => new Response(JSON.stringify({ data: { data: { value: 42 } } }), { status: 200 }),
|
|
178
|
-
);
|
|
179
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
180
|
-
expect(backend.get("K")).rejects.toBeInstanceOf(SecretsError);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("throws when the response is an empty object (no data key)", async () => {
|
|
184
|
-
const { fetchImpl } = stubFetch(() => new Response(JSON.stringify({}), { status: 200 }));
|
|
185
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
186
|
-
expect(backend.get("K")).rejects.toBeInstanceOf(SecretsError);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe("vault backend — rotate()", () => {
|
|
191
|
-
test("PUTs the supplied newValue and returns it", async () => {
|
|
192
|
-
let putBody = "";
|
|
193
|
-
const { fetchImpl, calls } = stubFetch((_url, init) => {
|
|
194
|
-
if (init?.method === "PUT") {
|
|
195
|
-
putBody = init.body as string;
|
|
196
|
-
return new Response("{}", { status: 204 });
|
|
197
|
-
}
|
|
198
|
-
return new Response("not found", { status: 404 });
|
|
199
|
-
});
|
|
200
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
201
|
-
|
|
202
|
-
const v = await backend.rotate("KEY", { newValue: "fresh" });
|
|
203
|
-
|
|
204
|
-
expect(v).toBe("fresh");
|
|
205
|
-
expect(JSON.parse(putBody)).toEqual({ data: { value: "fresh" } });
|
|
206
|
-
const putCall = calls.find((c) => c.init?.method === "PUT");
|
|
207
|
-
const headers = putCall?.init?.headers as Record<string, string>;
|
|
208
|
-
expect(headers?.["X-Vault-Token"]).toBe("t");
|
|
209
|
-
expect(headers?.["Content-Type"]).toBe("application/json");
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("auto-generates a 64-char hex secret when newValue is omitted", async () => {
|
|
213
|
-
let putBody = "";
|
|
214
|
-
const { fetchImpl } = stubFetch((_url, init) => {
|
|
215
|
-
putBody = (init?.body as string) ?? "";
|
|
216
|
-
return new Response("{}", { status: 200 });
|
|
217
|
-
});
|
|
218
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
219
|
-
|
|
220
|
-
const v = await backend.rotate("KEY");
|
|
221
|
-
|
|
222
|
-
expect(v).toMatch(/^[a-f0-9]{64}$/);
|
|
223
|
-
// the generated value is what got PUT to vault
|
|
224
|
-
expect(JSON.parse(putBody)).toEqual({ data: { value: v } });
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("throws SecretsError when the PUT is not ok, with status but no name/body", async () => {
|
|
228
|
-
const { fetchImpl } = stubFetch(() => new Response("sealed", { status: 503 }));
|
|
229
|
-
const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
|
|
230
|
-
expect(backend.rotate("SENSITIVE_NAME", { newValue: "x" })).rejects.toBeInstanceOf(
|
|
231
|
-
SecretsError,
|
|
232
|
-
);
|
|
233
|
-
await backend.rotate("SENSITIVE_NAME", { newValue: "x" }).catch((e: unknown) => {
|
|
234
|
-
const msg = (e as Error).message;
|
|
235
|
-
// status is retained for debugging...
|
|
236
|
-
expect(msg).toContain("503");
|
|
237
|
-
// ...but the raw response body and the secret name are redacted.
|
|
238
|
-
expect(msg).not.toContain("sealed");
|
|
239
|
-
expect(msg).not.toContain("SENSITIVE_NAME");
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
});
|
package/src/backends/vault.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vault backend — HashiCorp Vault KV v2 over HTTP. Reads
|
|
3
|
-
* `<addr>/v1/<mount>/data/<name>` and writes via PUT to the same path.
|
|
4
|
-
*
|
|
5
|
-
* Auth: token via `VAULT_TOKEN` (or constructor option). The simplest
|
|
6
|
-
* path that doesn't need approles or k8s service accounts.
|
|
7
|
-
*/
|
|
8
|
-
import { type SecretValue, type SecretsBackend, SecretsError } from "../index";
|
|
9
|
-
|
|
10
|
-
export type VaultBackendOptions = {
|
|
11
|
-
/** Vault address (e.g. `http://127.0.0.1:8200`). */
|
|
12
|
-
readonly addr: string;
|
|
13
|
-
/** KV v2 mount point (default: `secret`). */
|
|
14
|
-
readonly mount?: string;
|
|
15
|
-
/** Vault token. Falls back to `VAULT_TOKEN` env. */
|
|
16
|
-
readonly token?: string;
|
|
17
|
-
/** Optional fetch override for tests. */
|
|
18
|
-
readonly fetchImpl?: typeof fetch;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function createVaultBackend(opts: VaultBackendOptions): SecretsBackend {
|
|
22
|
-
const mount = opts.mount ?? "secret";
|
|
23
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
24
|
-
|
|
25
|
-
function getToken(): string {
|
|
26
|
-
const t = opts.token ?? process.env["VAULT_TOKEN"];
|
|
27
|
-
if (!t) {
|
|
28
|
-
throw new SecretsError(
|
|
29
|
-
"vault backend requires a token (constructor opts.token or VAULT_TOKEN env)",
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
return t;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function dataUrl(name: string): string {
|
|
36
|
-
if (!/^[A-Za-z0-9_/.-]+$/.test(name)) {
|
|
37
|
-
throw new SecretsError("invalid secret name for vault backend");
|
|
38
|
-
}
|
|
39
|
-
return `${opts.addr}/v1/${encodeURIComponent(mount)}/data/${name}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return {
|
|
43
|
-
id: "vault",
|
|
44
|
-
async get(name: string): Promise<SecretValue> {
|
|
45
|
-
const url = dataUrl(name);
|
|
46
|
-
const res = await fetchImpl(url, {
|
|
47
|
-
headers: { "X-Vault-Token": getToken() },
|
|
48
|
-
});
|
|
49
|
-
if (res.status === 404) {
|
|
50
|
-
throw new SecretsError("secret not found in vault (status 404)");
|
|
51
|
-
}
|
|
52
|
-
if (!res.ok) {
|
|
53
|
-
throw new SecretsError(`vault request failed (status ${res.status})`);
|
|
54
|
-
}
|
|
55
|
-
const body = (await res.json()) as { data?: { data?: { value?: string } } };
|
|
56
|
-
const v = body?.data?.data?.value;
|
|
57
|
-
if (typeof v !== "string") {
|
|
58
|
-
throw new SecretsError("vault response missing data.data.value (KV v2 expected)");
|
|
59
|
-
}
|
|
60
|
-
return v;
|
|
61
|
-
},
|
|
62
|
-
async rotate(name: string, rotateOpts): Promise<SecretValue> {
|
|
63
|
-
const url = dataUrl(name);
|
|
64
|
-
const newValue = rotateOpts?.newValue ?? generateRandomSecret();
|
|
65
|
-
const res = await fetchImpl(url, {
|
|
66
|
-
method: "PUT",
|
|
67
|
-
headers: {
|
|
68
|
-
"X-Vault-Token": getToken(),
|
|
69
|
-
"Content-Type": "application/json",
|
|
70
|
-
},
|
|
71
|
-
body: JSON.stringify({ data: { value: newValue } }),
|
|
72
|
-
});
|
|
73
|
-
if (!res.ok) {
|
|
74
|
-
throw new SecretsError(`vault request failed (status ${res.status})`);
|
|
75
|
-
}
|
|
76
|
-
return newValue;
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function generateRandomSecret(): string {
|
|
82
|
-
const bytes = new Uint8Array(32);
|
|
83
|
-
crypto.getRandomValues(bytes);
|
|
84
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
85
|
-
}
|