@checkstack/secrets-backend 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,236 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ createServiceRef,
5
+ } from "@checkstack/backend-api";
6
+ import {
7
+ pluginMetadata,
8
+ secretsAccessRules,
9
+ secretsContract,
10
+ } from "@checkstack/secrets-common";
11
+ import type { PluginMetadata } from "@checkstack/common";
12
+ import {
13
+ secretBackendExtensionPoint,
14
+ type SecretBackend,
15
+ } from "./secret-backend";
16
+ import {
17
+ createSecretBackendRegistry,
18
+ type SecretBackendRegistry,
19
+ } from "./secret-backend-registry";
20
+ import {
21
+ createSecretResolverService,
22
+ type SecretResolverService,
23
+ } from "./resolver-service";
24
+ import {
25
+ createSecretAdminService,
26
+ type SecretAdminService,
27
+ } from "./admin-service";
28
+ import {
29
+ createInternalSecretsService,
30
+ type InternalSecretsService,
31
+ } from "./internal-secrets-service";
32
+ import {
33
+ createBackendConfigStore,
34
+ type BackendConfigStore,
35
+ } from "./backend-config-store";
36
+ import { createActiveBackendStore } from "./active-backend";
37
+ import { createSecretsRouter } from "./router";
38
+ import { secretsChangedHook } from "./hooks";
39
+
40
+ /** Built-in default backend id. The local backend plugin registers under this. */
41
+ const DEFAULT_BACKEND_ID = "local";
42
+
43
+ /**
44
+ * Cross-plugin secret resolution service. Consumer plugins (gitops,
45
+ * automation, healthcheck) inject this to resolve `${{ secrets.NAME }}`
46
+ * templates and a run's least-privilege env allowlist against the active
47
+ * backend. Service-typed and backend-only — never exposed to a browser.
48
+ */
49
+ export const secretResolverRef = createServiceRef<SecretResolverService>(
50
+ "secrets.resolver",
51
+ );
52
+
53
+ /**
54
+ * Cross-plugin secret administration service. Consumers (e.g. gitops)
55
+ * inject this to manage secrets through the active backend so there is a
56
+ * single source of truth. Metadata/write only — never returns a value.
57
+ */
58
+ export const secretAdminRef = createServiceRef<SecretAdminService>(
59
+ "secrets.admin",
60
+ );
61
+
62
+ /**
63
+ * Cross-plugin service for platform-INTERNAL secrets (registry token,
64
+ * connection credentials, …). Always backed by the local backend (never
65
+ * Vault, which is read-through), so internal writes never break when an
66
+ * external backend is active. Hidden from the user-facing Secrets UI.
67
+ * Backend-only — never exposed to a browser.
68
+ */
69
+ export const internalSecretsRef = createServiceRef<InternalSecretsService>(
70
+ "secrets.internal",
71
+ );
72
+
73
+ interface EnvStash {
74
+ backends: SecretBackendRegistry;
75
+ configStore?: BackendConfigStore;
76
+ emitChanged?: (input: {
77
+ name: string;
78
+ change: "created" | "rotated" | "deleted";
79
+ }) => Promise<void>;
80
+ }
81
+
82
+ export default createBackendPlugin({
83
+ metadata: pluginMetadata,
84
+
85
+ register(env) {
86
+ const backends = createSecretBackendRegistry();
87
+ (env as unknown as EnvStash).backends = backends;
88
+
89
+ env.registerAccessRules(secretsAccessRules);
90
+
91
+ env.registerExtensionPoint(secretBackendExtensionPoint, {
92
+ registerSecretBackend: (
93
+ backend: SecretBackend,
94
+ _metadata: PluginMetadata,
95
+ ) => {
96
+ backends.register(backend);
97
+ },
98
+ });
99
+
100
+ // The active backend id is config-selected (persisted via the config
101
+ // store, set in `init`). Falls back to the local backend when no choice
102
+ // is persisted or the persisted choice is not currently registered.
103
+ const fallbackBackendId = (): string => {
104
+ if (backends.has(DEFAULT_BACKEND_ID)) return DEFAULT_BACKEND_ID;
105
+ const ids = backends.ids();
106
+ if (ids.length === 0) {
107
+ throw new Error(
108
+ "No secret backend is registered. Ensure secrets-backend-local is installed.",
109
+ );
110
+ }
111
+ return ids[0];
112
+ };
113
+
114
+ const getActiveBackendId = async (): Promise<string> => {
115
+ const store = (env as unknown as EnvStash).configStore;
116
+ const redacted = await store?.loadRedacted();
117
+ const persisted = redacted?.activeBackend;
118
+ if (persisted && backends.has(persisted)) return persisted;
119
+ return fallbackBackendId();
120
+ };
121
+
122
+ const setActiveBackendId = async (id: string): Promise<void> => {
123
+ const store = (env as unknown as EnvStash).configStore;
124
+ if (!store) {
125
+ throw new Error("Backend config store is not initialized yet.");
126
+ }
127
+ const current = (await store.load()) ?? { activeBackend: id };
128
+ await store.save({ ...current, activeBackend: id });
129
+ };
130
+
131
+ // SecretStore backed by whichever backend is active — switching the
132
+ // active backend (local → vault) re-routes resolution with no other
133
+ // change. Throws on a missing secret so a required reference fails clearly.
134
+ const secretStore = createActiveBackendStore({
135
+ backends,
136
+ getActiveBackendId,
137
+ });
138
+
139
+ const resolver = createSecretResolverService({ secretStore });
140
+ env.registerService(secretResolverRef, resolver);
141
+
142
+ const emitChanged = async (input: {
143
+ name: string;
144
+ change: "created" | "rotated" | "deleted";
145
+ }): Promise<void> => {
146
+ await (env as unknown as EnvStash).emitChanged?.(input);
147
+ };
148
+
149
+ const getActiveBackend = async (): Promise<SecretBackend> =>
150
+ backends.get(await getActiveBackendId());
151
+
152
+ const adminService = createSecretAdminService({
153
+ getActiveBackend,
154
+ onChanged: emitChanged,
155
+ // No logger in the register() phase; the short-secret warning is
156
+ // surfaced on the user-facing setSecret RPC (router, which has one).
157
+ });
158
+ env.registerService(secretAdminRef, adminService);
159
+
160
+ // Internal secrets always use the local backend (always-writable),
161
+ // never the active external backend.
162
+ const internalSecrets = createInternalSecretsService({
163
+ getLocalBackend: () => backends.get(DEFAULT_BACKEND_ID),
164
+ });
165
+ env.registerService(internalSecretsRef, internalSecrets);
166
+
167
+ env.registerInit({
168
+ deps: {
169
+ logger: coreServices.logger,
170
+ rpc: coreServices.rpc,
171
+ config: coreServices.config,
172
+ },
173
+ init: async ({ logger, rpc, config }) => {
174
+ logger.debug("🔐 Initializing Secrets Backend...");
175
+
176
+ (env as unknown as EnvStash).configStore = createBackendConfigStore({
177
+ config,
178
+ });
179
+
180
+ const router = createSecretsRouter({
181
+ backends,
182
+ getActiveBackendId,
183
+ setActiveBackendId,
184
+ emitChanged,
185
+ logger,
186
+ });
187
+ rpc.registerRouter(router, secretsContract);
188
+
189
+ logger.debug("✅ Secrets Backend initialized.");
190
+ },
191
+
192
+ afterPluginsReady: async ({ logger, emitHook }) => {
193
+ (env as unknown as EnvStash).emitChanged = async (input) => {
194
+ await emitHook(secretsChangedHook, input);
195
+ };
196
+ logger.debug("✅ Secrets Backend afterPluginsReady complete.");
197
+ },
198
+ });
199
+ },
200
+ });
201
+
202
+ // ─── Public surface ──────────────────────────────────────────────────────
203
+
204
+ export {
205
+ secretBackendExtensionPoint,
206
+ type SecretBackend,
207
+ type SecretBackendExtensionPoint,
208
+ } from "./secret-backend";
209
+ export {
210
+ createSecretBackendRegistry,
211
+ type SecretBackendRegistry,
212
+ } from "./secret-backend-registry";
213
+ export {
214
+ resolveSecretsBySchema,
215
+ type SecretStore,
216
+ type SecretResolutionResult,
217
+ } from "./secret-resolver";
218
+ export { walkSecretFields } from "./walk-secret-fields";
219
+ export {
220
+ createSecretResolverService,
221
+ type SecretResolverService,
222
+ } from "./resolver-service";
223
+ export {
224
+ createSecretAdminService,
225
+ type SecretAdminService,
226
+ } from "./admin-service";
227
+ export {
228
+ createInternalSecretsService,
229
+ type InternalSecretsService,
230
+ } from "./internal-secrets-service";
231
+ export {
232
+ createMaskingContext,
233
+ EMPTY_MASKING_CONTEXT,
234
+ type SecretMaskingContext,
235
+ } from "./masking-context";
236
+ export { secretsChangedHook } from "./hooks";
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { internalSecretName } from "@checkstack/secrets-common";
3
+ import { createInternalSecretsService } from "./internal-secrets-service";
4
+ import { createSecretAdminService } from "./admin-service";
5
+ import type { SecretBackend } from "./secret-backend";
6
+
7
+ /** In-memory local-style backend (writable). */
8
+ function memoryBackend(): SecretBackend & { store: Map<string, string> } {
9
+ const store = new Map<string, string>();
10
+ return {
11
+ id: "local",
12
+ store,
13
+ get: async ({ name }) => store.get(name),
14
+ set: async ({ name, value }) => {
15
+ store.set(name, value);
16
+ },
17
+ delete: async ({ name }) => {
18
+ store.delete(name);
19
+ },
20
+ list: async () =>
21
+ [...store.keys()].map((name) => ({
22
+ id: name,
23
+ name,
24
+ description: null,
25
+ hasValue: true,
26
+ backend: "local",
27
+ createdBy: null,
28
+ createdAt: new Date(),
29
+ updatedAt: new Date(),
30
+ })),
31
+ };
32
+ }
33
+
34
+ describe("InternalSecretsService", () => {
35
+ it("round-trips an internal secret on the local backend under the reserved prefix", async () => {
36
+ const local = memoryBackend();
37
+ const internal = createInternalSecretsService({
38
+ getLocalBackend: () => local,
39
+ });
40
+
41
+ await internal.set({
42
+ parts: ["script-packages", "registry-auth-token"],
43
+ value: "npm_token_value",
44
+ });
45
+ expect(
46
+ await internal.get({ parts: ["script-packages", "registry-auth-token"] }),
47
+ ).toBe("npm_token_value");
48
+ // Stored under the reserved internal name.
49
+ expect(
50
+ local.store.get(
51
+ internalSecretName("script-packages", "registry-auth-token"),
52
+ ),
53
+ ).toBe("npm_token_value");
54
+ });
55
+
56
+ it("delete is idempotent", async () => {
57
+ const internal = createInternalSecretsService({
58
+ getLocalBackend: memoryBackend,
59
+ });
60
+ await internal.delete({ parts: ["x", "y"] });
61
+ expect(await internal.get({ parts: ["x", "y"] })).toBeUndefined();
62
+ });
63
+ });
64
+
65
+ describe("admin list hides internal secrets", () => {
66
+ it("excludes reserved internal names from the user-facing list", async () => {
67
+ const local = memoryBackend();
68
+ const internal = createInternalSecretsService({
69
+ getLocalBackend: () => local,
70
+ });
71
+ const admin = createSecretAdminService({
72
+ getActiveBackend: async () => local,
73
+ onChanged: async () => {},
74
+ });
75
+
76
+ // A user-managed secret + an internal one.
77
+ await admin.setSecret({ name: "jira_token", value: "user-value" });
78
+ await internal.set({ parts: ["connection", "c1", "apiToken"], value: "v" });
79
+
80
+ const listed = await admin.list();
81
+ const names = listed.map((m) => m.name);
82
+ expect(names).toContain("jira_token");
83
+ expect(names.some((n) => n.startsWith("__internal__:"))).toBe(false);
84
+ });
85
+ });
@@ -0,0 +1,54 @@
1
+ import { internalSecretName } from "@checkstack/secrets-common";
2
+ import type { SecretBackend } from "./secret-backend";
3
+
4
+ /**
5
+ * Cross-plugin service for platform-internal secrets (exposed via
6
+ * `internalSecretsRef`). These are NOT user-managed named secrets — they
7
+ * back a specific feature (e.g. the script-package registry token, or a
8
+ * connection's credential fields).
9
+ *
10
+ * Internal secrets ALWAYS live on the local (always-writable, AES-GCM)
11
+ * backend, never the active external backend: Vault is read-through with no
12
+ * `set`, so routing internal writes through the active backend would break
13
+ * when Vault is selected. They are stored under a reserved name prefix so
14
+ * the user-facing Secrets UI never shows them.
15
+ *
16
+ * No method returns a value to a browser — this is a backend-only service.
17
+ */
18
+ export interface InternalSecretsService {
19
+ /** Store / rotate an internal secret value under `namespace:key...`. */
20
+ set(input: { parts: string[]; value: string }): Promise<void>;
21
+ /** Resolve an internal secret value, or undefined if absent. */
22
+ get(input: { parts: string[] }): Promise<string | undefined>;
23
+ /** Delete an internal secret. Idempotent. */
24
+ delete(input: { parts: string[] }): Promise<void>;
25
+ }
26
+
27
+ export function createInternalSecretsService({
28
+ getLocalBackend,
29
+ }: {
30
+ /** Resolve the local (always-writable) backend. Throws if unavailable. */
31
+ getLocalBackend: () => SecretBackend;
32
+ }): InternalSecretsService {
33
+ return {
34
+ async set({ parts, value }) {
35
+ const backend = getLocalBackend();
36
+ if (!backend.set) {
37
+ throw new Error("Local secret backend does not support writes.");
38
+ }
39
+ await backend.set({ name: internalSecretName(...parts), value });
40
+ },
41
+
42
+ async get({ parts }) {
43
+ const backend = getLocalBackend();
44
+ return backend.get({ name: internalSecretName(...parts) });
45
+ },
46
+
47
+ async delete({ parts }) {
48
+ const backend = getLocalBackend();
49
+ if (backend.delete) {
50
+ await backend.delete({ name: internalSecretName(...parts) });
51
+ }
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Platform-wide leak-guard suite.
3
+ *
4
+ * One focused, explicit place asserting the core guarantee end-to-end: a
5
+ * secret VALUE never crosses any DTO / persisted-record / user-facing
6
+ * surface, across every path the platform exposes. Individual path tests
7
+ * live with their features; this suite is the cross-cutting regression net
8
+ * that wires the real services together and proves the invariant holds.
9
+ *
10
+ * The single canonical secret value used throughout; if it appears in any
11
+ * surface output below, the test fails.
12
+ */
13
+ import { describe, it, expect } from "bun:test";
14
+ import { z, configString } from "@checkstack/backend-api";
15
+ import { createSecretBackendRegistry } from "./secret-backend-registry";
16
+ import { createActiveBackendStore } from "./active-backend";
17
+ import { createSecretResolverService } from "./resolver-service";
18
+ import { createSecretAdminService } from "./admin-service";
19
+ import { createInternalSecretsService } from "./internal-secrets-service";
20
+ import { createMaskingContext } from "./masking-context";
21
+ import type { SecretBackend } from "./secret-backend";
22
+ import type { SecretMetadata } from "@checkstack/secrets-common";
23
+
24
+ const SECRET = "gh_TOPSECRET_value_9999";
25
+
26
+ /** In-memory local-style backend (writable). */
27
+ function localBackend(seed: Record<string, string> = {}): SecretBackend {
28
+ const store = new Map<string, string>(Object.entries(seed));
29
+ return {
30
+ id: "local",
31
+ get: async ({ name }) => store.get(name),
32
+ set: async ({ name, value }) => {
33
+ store.set(name, value);
34
+ },
35
+ delete: async ({ name }) => {
36
+ store.delete(name);
37
+ },
38
+ list: async (): Promise<SecretMetadata[]> =>
39
+ [...store.entries()].map(([name]) => ({
40
+ id: name,
41
+ name,
42
+ description: null,
43
+ hasValue: true,
44
+ backend: "local",
45
+ createdBy: null,
46
+ createdAt: new Date(),
47
+ updatedAt: new Date(),
48
+ })),
49
+ };
50
+ }
51
+
52
+ /** Fake read-through Vault-style backend (no set/delete). */
53
+ function vaultBackend(seed: Record<string, string>): SecretBackend {
54
+ const store = new Map<string, string>(Object.entries(seed));
55
+ return {
56
+ id: "vault",
57
+ get: async ({ name }) => store.get(name),
58
+ list: async (): Promise<SecretMetadata[]> =>
59
+ [...store.keys()].map((name) => ({
60
+ id: name,
61
+ name,
62
+ description: null,
63
+ hasValue: true,
64
+ backend: "vault",
65
+ createdBy: null,
66
+ createdAt: new Date(),
67
+ updatedAt: new Date(),
68
+ })),
69
+ };
70
+ }
71
+
72
+ describe("leak guard: list / get DTO surfaces", () => {
73
+ it("listSecrets metadata never contains the value (local backend)", async () => {
74
+ const local = localBackend({ db_pass: SECRET });
75
+ const admin = createSecretAdminService({
76
+ getActiveBackend: async () => local,
77
+ onChanged: async () => {},
78
+ });
79
+ const list = await admin.list();
80
+ expect(JSON.stringify(list)).not.toContain(SECRET);
81
+ expect(list.every((m) => !("value" in m))).toBe(true);
82
+ });
83
+
84
+ it("listSecrets metadata never contains the value (Vault backend)", async () => {
85
+ const admin = createSecretAdminService({
86
+ getActiveBackend: async () => vaultBackend({ db_pass: SECRET }),
87
+ onChanged: async () => {},
88
+ });
89
+ expect(JSON.stringify(await admin.list())).not.toContain(SECRET);
90
+ });
91
+
92
+ it("internal secrets are excluded from the user-facing list", async () => {
93
+ const local = localBackend();
94
+ const admin = createSecretAdminService({
95
+ getActiveBackend: async () => local,
96
+ onChanged: async () => {},
97
+ });
98
+ const internal = createInternalSecretsService({
99
+ getLocalBackend: () => local,
100
+ });
101
+ await admin.setSecret({ name: "jira_token", value: "user-secret-aaa" });
102
+ await internal.set({ parts: ["connection", "c1", "apiToken"], value: SECRET });
103
+ const names = (await admin.list()).map((m) => m.name);
104
+ expect(names).toContain("jira_token");
105
+ expect(names.some((n) => n.startsWith("__internal__:"))).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe("leak guard: run output masking (central + satellite)", () => {
110
+ // The same maskScriptRunOutput path backs the central action runner, the
111
+ // satellite collector, and the automation step result. resolveForRun
112
+ // yields the run-scoped masking context that redacts the value.
113
+ it("a script run that echoes its injected secret is fully masked", async () => {
114
+ const local = localBackend({ jira_token: SECRET });
115
+ const resolver = createSecretResolverService({
116
+ secretStore: createActiveBackendStore({
117
+ backends: registryWith(local),
118
+ getActiveBackendId: async () => "local",
119
+ }),
120
+ });
121
+ const { env, masking } = await resolver.resolveForRun({
122
+ secretEnv: { API_TOKEN: "${{ secrets.jira_token }}" },
123
+ });
124
+ // The env carries the real value (it must, to inject into the run)...
125
+ expect(env.API_TOKEN).toBe(SECRET);
126
+ // ...but every output surface masked with the run context is redacted.
127
+ const stdout = masking.maskText(`echo ${SECRET}`);
128
+ const result = masking.maskDeep({ token: SECRET, nested: [SECRET] });
129
+ expect(stdout).toBe("echo ****");
130
+ expect(result).toEqual({ token: "****", nested: ["****"] });
131
+ expect(JSON.stringify({ stdout, result })).not.toContain(SECRET);
132
+ });
133
+
134
+ it("an automation step result payload is masked deeply", async () => {
135
+ const masking = createMaskingContext({ values: [SECRET] });
136
+ const stepResult = masking.maskDeep({
137
+ externalId: "x",
138
+ detail: { auth: `Bearer ${SECRET}` },
139
+ lines: [`logged ${SECRET}`],
140
+ });
141
+ expect(JSON.stringify(stepResult)).not.toContain(SECRET);
142
+ });
143
+ });
144
+
145
+ describe("leak guard: Vault-backed resolution routes + masks", () => {
146
+ it("resolving through the active Vault backend yields a masking context that redacts the value", async () => {
147
+ const backends = createSecretBackendRegistry();
148
+ backends.register(localBackend());
149
+ backends.register(vaultBackend({ vault_secret: SECRET }));
150
+ const resolver = createSecretResolverService({
151
+ secretStore: createActiveBackendStore({
152
+ backends,
153
+ getActiveBackendId: async () => "vault", // active = vault
154
+ }),
155
+ });
156
+ const { env, masking } = await resolver.resolveForRun({
157
+ secretEnv: { TOKEN: "${{ secrets.vault_secret }}" },
158
+ });
159
+ expect(env.TOKEN).toBe(SECRET);
160
+ expect(masking.maskText(`x=${SECRET}`)).toBe("x=****");
161
+ });
162
+
163
+ it("gitops/connection-style x-secret field resolves a ${{ secrets.NAME }} reference through the active Vault backend", async () => {
164
+ // Mirrors how gitops descriptors and connection credentials resolve:
165
+ // resolveBySchema walks x-secret fields and resolves the template
166
+ // through whichever backend is active. With Vault active, a referenced
167
+ // secret originating from Vault resolves through the ONE channel.
168
+ const backends = createSecretBackendRegistry();
169
+ backends.register(localBackend());
170
+ backends.register(vaultBackend({ db_pass: SECRET }));
171
+ const resolver = createSecretResolverService({
172
+ secretStore: createActiveBackendStore({
173
+ backends,
174
+ getActiveBackendId: async () => "vault",
175
+ }),
176
+ });
177
+ const schema = z.object({
178
+ host: z.string(),
179
+ // configString({ "x-secret": true }) marks this resolvable.
180
+ password: configString({ "x-secret": true }),
181
+ });
182
+ const { resolved } = await resolver.resolveBySchema({
183
+ value: { host: "db.internal", password: "${{ secrets.db_pass }}" },
184
+ schema,
185
+ });
186
+ expect(resolved).toEqual({ host: "db.internal", password: SECRET });
187
+ });
188
+ });
189
+
190
+ function registryWith(backend: SecretBackend) {
191
+ const r = createSecretBackendRegistry();
192
+ r.register(backend);
193
+ return r;
194
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { createMaskingContext, EMPTY_MASKING_CONTEXT } from "./masking-context";
3
+
4
+ describe("SecretMaskingContext", () => {
5
+ it("redacts held values in text and deep payloads", () => {
6
+ const ctx = createMaskingContext({
7
+ values: ["token-abc-123", "db-pass-456"],
8
+ });
9
+ expect(ctx.size).toBe(2);
10
+ expect(ctx.maskText("Authorization: token-abc-123")).toBe(
11
+ "Authorization: ****",
12
+ );
13
+ expect(
14
+ ctx.maskDeep({ stdout: "pw=db-pass-456", meta: { n: 1 } }),
15
+ ).toEqual({ stdout: "pw=****", meta: { n: 1 } });
16
+ });
17
+
18
+ it("is a no-op when it holds no values", () => {
19
+ expect(EMPTY_MASKING_CONTEXT.size).toBe(0);
20
+ const text = "nothing to mask token-abc-123";
21
+ expect(EMPTY_MASKING_CONTEXT.maskText(text)).toBe(text);
22
+ const payload = { a: "token-abc-123" };
23
+ expect(EMPTY_MASKING_CONTEXT.maskDeep(payload)).toBe(payload);
24
+ });
25
+
26
+ it("masks a script echoing its own injected secret", () => {
27
+ const ctx = createMaskingContext({ values: ["gh_injectedSecret999"] });
28
+ const stdout = "echo: gh_injectedSecret999\nexit 0";
29
+ expect(ctx.maskText(stdout)).toBe("echo: ****\nexit 0");
30
+ });
31
+ });
@@ -0,0 +1,50 @@
1
+ import { maskSecrets, maskSecretsDeep } from "@checkstack/secrets-common";
2
+
3
+ /**
4
+ * Run-scoped masking context: holds the resolved secret VALUES for a
5
+ * single run (the consumer's least-privilege allowlist only) and applies
6
+ * Jenkins-style by-value redaction at every output boundary before the
7
+ * output is persisted or returned.
8
+ *
9
+ * The value set is the run's resolved secrets, NOT the whole secret store
10
+ * — this is both least-privilege and avoids masking unrelated coincidental
11
+ * strings. Create one per run, register the run's resolved values, and
12
+ * pass the result of `mask*` through to persistence/RPC.
13
+ */
14
+ export interface SecretMaskingContext {
15
+ /** Number of distinct values held (for diagnostics, never the values). */
16
+ readonly size: number;
17
+ /** Redact every literal occurrence of a held value in `text`. */
18
+ maskText(text: string): string;
19
+ /** Recursively redact held values in a JSON-like payload (keys + leaves). */
20
+ maskDeep(value: unknown): unknown;
21
+ }
22
+
23
+ /**
24
+ * Build a masking context from a run's resolved secret values.
25
+ */
26
+ export function createMaskingContext({
27
+ values,
28
+ }: {
29
+ values: Iterable<string>;
30
+ }): SecretMaskingContext {
31
+ const valueSet = new Set<string>(values);
32
+ return {
33
+ get size() {
34
+ return valueSet.size;
35
+ },
36
+ maskText(text) {
37
+ if (valueSet.size === 0) return text;
38
+ return maskSecrets({ text, values: valueSet });
39
+ },
40
+ maskDeep(value) {
41
+ if (valueSet.size === 0) return value;
42
+ return maskSecretsDeep({ value, values: valueSet });
43
+ },
44
+ };
45
+ }
46
+
47
+ /** A no-op context (no values) for paths with no resolved secrets. */
48
+ export const EMPTY_MASKING_CONTEXT: SecretMaskingContext = createMaskingContext(
49
+ { values: [] },
50
+ );