@checkstack/secrets-common 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.
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ maskSecrets,
4
+ maskSecretsDeep,
5
+ DEFAULT_MASK_TOKEN,
6
+ MIN_MASKABLE_LENGTH,
7
+ } from "./masking";
8
+
9
+ describe("maskSecrets", () => {
10
+ it("redacts a literal secret value anywhere in the text", () => {
11
+ const out = maskSecrets({
12
+ text: "connecting with token=s3cret-token-value done",
13
+ values: ["s3cret-token-value"],
14
+ });
15
+ expect(out).toBe(`connecting with token=${DEFAULT_MASK_TOKEN} done`);
16
+ expect(out).not.toContain("s3cret-token-value");
17
+ });
18
+
19
+ it("redacts every occurrence, not just the first", () => {
20
+ const out = maskSecrets({
21
+ text: "a=topsecret b=topsecret c=topsecret",
22
+ values: ["topsecret"],
23
+ });
24
+ expect(out).toBe("a=**** b=**** c=****");
25
+ });
26
+
27
+ it("masks a script echoing its own injected secret (leak guard)", () => {
28
+ // Simulates a user script that does `console.log(process.env.API_TOKEN)`.
29
+ const stdout = "API_TOKEN is gh_aBcDeF123456 and nothing else";
30
+ const out = maskSecrets({ text: stdout, values: ["gh_aBcDeF123456"] });
31
+ expect(out).not.toContain("gh_aBcDeF123456");
32
+ expect(out).toContain("API_TOKEN is **** and nothing else");
33
+ });
34
+
35
+ it("skips trivially-short values to avoid over-masking", () => {
36
+ const shortValue = "ab"; // below MIN_MASKABLE_LENGTH
37
+ expect(shortValue.length).toBeLessThan(MIN_MASKABLE_LENGTH);
38
+ const out = maskSecrets({
39
+ text: "absolutely fabulous tabular",
40
+ values: [shortValue],
41
+ });
42
+ expect(out).toBe("absolutely fabulous tabular");
43
+ });
44
+
45
+ it("masks a value at exactly the minimum length boundary", () => {
46
+ const value = "a".repeat(MIN_MASKABLE_LENGTH); // exactly 4 chars -> masked
47
+ const out = maskSecrets({ text: `x=${value}`, values: [value] });
48
+ expect(out).toBe("x=****");
49
+ const tooShort = "a".repeat(MIN_MASKABLE_LENGTH - 1); // 3 chars -> skipped
50
+ expect(maskSecrets({ text: `y=${tooShort}`, values: [tooShort] })).toBe(
51
+ `y=${tooShort}`,
52
+ );
53
+ });
54
+
55
+ it("redacts a value embedded mid-string with no surrounding whitespace", () => {
56
+ const out = maskSecrets({
57
+ text: "prefix" + "tok-abcdef" + "suffix",
58
+ values: ["tok-abcdef"],
59
+ });
60
+ expect(out).toBe("prefix****suffix");
61
+ });
62
+
63
+ it("redacts a value across multiple lines of output", () => {
64
+ const out = maskSecrets({
65
+ text: "line1 sk-live-9999\nline2 sk-live-9999\nclean",
66
+ values: ["sk-live-9999"],
67
+ });
68
+ expect(out).toBe("line1 ****\nline2 ****\nclean");
69
+ });
70
+
71
+ it("DOCUMENTED LIMIT: an encoded/transformed form of a secret is NOT masked", () => {
72
+ const secret = "topsecretvalue";
73
+ const base64 = Buffer.from(secret).toString("base64");
74
+ // Masking is by literal occurrence only. A script that base64-encodes a
75
+ // secret before printing it defeats masking — this is the documented
76
+ // limitation, asserted here so the contract is explicit + regression-safe.
77
+ const out = maskSecrets({
78
+ text: `encoded=${base64} raw=${secret}`,
79
+ values: [secret],
80
+ });
81
+ expect(out).toContain(base64); // encoded form survives (NOT masked)
82
+ expect(out).toContain("raw=****"); // literal form IS masked
83
+ expect(out).not.toContain(`raw=${secret}`);
84
+ });
85
+
86
+ it("masks longer values before shorter overlapping ones", () => {
87
+ // "supersecret" contains "secret"; both are secrets. The whole long
88
+ // value must be redacted as one unit, not partially by the short one.
89
+ const out = maskSecrets({
90
+ text: "value=supersecret",
91
+ values: ["secret", "supersecret"],
92
+ });
93
+ expect(out).toBe("value=****");
94
+ });
95
+
96
+ it("supports a custom token", () => {
97
+ const out = maskSecrets({
98
+ text: "pw=hunter2pw",
99
+ values: ["hunter2pw"],
100
+ token: "[REDACTED]",
101
+ });
102
+ expect(out).toBe("pw=[REDACTED]");
103
+ });
104
+
105
+ it("returns text unchanged when no secret occurs", () => {
106
+ const out = maskSecrets({ text: "nothing here", values: ["absent-value"] });
107
+ expect(out).toBe("nothing here");
108
+ });
109
+
110
+ it("dedupes values without error", () => {
111
+ const out = maskSecrets({
112
+ text: "x=dup-value-here y=dup-value-here",
113
+ values: ["dup-value-here", "dup-value-here"],
114
+ });
115
+ expect(out).toBe("x=**** y=****");
116
+ });
117
+ });
118
+
119
+ describe("maskSecretsDeep", () => {
120
+ it("masks strings in nested objects and arrays", () => {
121
+ const out = maskSecretsDeep({
122
+ value: {
123
+ log: ["line with topsecretvalue", "clean line"],
124
+ nested: { token: "topsecretvalue" },
125
+ count: 3,
126
+ },
127
+ values: ["topsecretvalue"],
128
+ });
129
+ expect(out).toEqual({
130
+ log: ["line with ****", "clean line"],
131
+ nested: { token: "****" },
132
+ count: 3,
133
+ });
134
+ });
135
+
136
+ it("masks object keys too", () => {
137
+ const out = maskSecretsDeep({
138
+ value: { topsecretvalue: 1 },
139
+ values: ["topsecretvalue"],
140
+ });
141
+ expect(out).toEqual({ "****": 1 });
142
+ });
143
+
144
+ it("returns the value unchanged when no maskable values are given", () => {
145
+ const value = { a: "short", b: ["x"] };
146
+ expect(maskSecretsDeep({ value, values: ["ab"] })).toBe(value);
147
+ });
148
+
149
+ it("leaves non-string leaves untouched", () => {
150
+ const out = maskSecretsDeep({
151
+ value: { n: 42, b: true, z: null },
152
+ values: ["topsecretvalue"],
153
+ });
154
+ expect(out).toEqual({ n: 42, b: true, z: null });
155
+ });
156
+ });
package/src/masking.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Jenkins-style by-value secret masking.
3
+ *
4
+ * This is the platform's non-negotiable leak guarantee: any text returned
5
+ * to a user or persisted is scanned for known secret values and every
6
+ * literal occurrence is replaced with a redaction token.
7
+ *
8
+ * Documented limit: only LITERAL occurrences are masked. Encoded or
9
+ * transformed forms of a secret (base64, hashed, URL-encoded, split
10
+ * across lines) cannot be detected — scripts must not transform-then-print
11
+ * a secret they were given.
12
+ */
13
+
14
+ /** Default redaction token. */
15
+ export const DEFAULT_MASK_TOKEN = "****";
16
+
17
+ /**
18
+ * Values shorter than this are NOT masked. Trivially short secrets (e.g.
19
+ * a 1-3 char value) would over-mask unrelated coincidental substrings of
20
+ * normal output, corrupting it. Anything below the threshold is skipped.
21
+ */
22
+ export const MIN_MASKABLE_LENGTH = 4;
23
+
24
+ /**
25
+ * Replace every literal occurrence of each known secret value in `text`
26
+ * with `token`. Pure and synchronous.
27
+ *
28
+ * - Skips values shorter than {@link MIN_MASKABLE_LENGTH}.
29
+ * - Longer values are masked first, so a secret that contains a shorter
30
+ * secret as a substring is fully redacted before the shorter one runs.
31
+ * - `values` is the run-scoped, least-privilege set — only the consumer's
32
+ * resolved secrets, never the whole secret store.
33
+ *
34
+ * @returns The text with all qualifying secret values redacted.
35
+ */
36
+ export function maskSecrets({
37
+ text,
38
+ values,
39
+ token = DEFAULT_MASK_TOKEN,
40
+ }: {
41
+ text: string;
42
+ values: Iterable<string>;
43
+ token?: string;
44
+ }): string {
45
+ return maskString(text, toMaskable(values), token);
46
+ }
47
+
48
+ /**
49
+ * Dedupe + drop trivially-short values, then sort longest-first so a value
50
+ * that embeds a shorter value is redacted as a whole. Shared by both the
51
+ * text and deep entry points so the maskable-set policy lives in one place.
52
+ */
53
+ function toMaskable(values: Iterable<string>): string[] {
54
+ return [...new Set(values)]
55
+ .filter((value) => value.length >= MIN_MASKABLE_LENGTH)
56
+ .toSorted((a, b) => b.length - a.length);
57
+ }
58
+
59
+ /**
60
+ * Recursively mask secret values in an arbitrary JSON-like payload. Walks
61
+ * strings, arrays, and plain objects; non-string leaves are returned
62
+ * unchanged. Used to redact structured result payloads (e.g. an
63
+ * automation run step's `result_payload`) before persist/return.
64
+ *
65
+ * Object KEYS are masked too — a script that returns `{ "<secret>": 1 }`
66
+ * would otherwise leak the value through the key.
67
+ */
68
+ export function maskSecretsDeep({
69
+ value,
70
+ values,
71
+ token = DEFAULT_MASK_TOKEN,
72
+ }: {
73
+ value: unknown;
74
+ values: Iterable<string>;
75
+ token?: string;
76
+ }): unknown {
77
+ const maskable = toMaskable(values);
78
+
79
+ if (maskable.length === 0) {
80
+ return value;
81
+ }
82
+
83
+ return walk(value, maskable, token);
84
+ }
85
+
86
+ function walk(value: unknown, maskable: string[], token: string): unknown {
87
+ if (typeof value === "string") {
88
+ return maskString(value, maskable, token);
89
+ }
90
+ if (Array.isArray(value)) {
91
+ return value.map((item) => walk(item, maskable, token));
92
+ }
93
+ if (value !== null && typeof value === "object") {
94
+ const result: Record<string, unknown> = {};
95
+ for (const [key, val] of Object.entries(value)) {
96
+ result[maskString(key, maskable, token)] = walk(val, maskable, token);
97
+ }
98
+ return result;
99
+ }
100
+ return value;
101
+ }
102
+
103
+ function maskString(text: string, maskable: string[], token: string): string {
104
+ let result = text;
105
+ for (const value of maskable) {
106
+ if (result.includes(value)) {
107
+ result = result.split(value).join(token);
108
+ }
109
+ }
110
+ return result;
111
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Public metadata for a secret. NEVER carries the value — only the name,
5
+ * a human description, provenance, and `hasValue` (whether a value is
6
+ * currently stored). This is the only shape that crosses to a browser.
7
+ */
8
+ export const secretMetadataSchema = z.object({
9
+ id: z.string(),
10
+ name: z.string(),
11
+ description: z.string().nullable(),
12
+ /** Whether a value is currently stored for this secret. */
13
+ hasValue: z.boolean(),
14
+ /** Backend id that owns this secret (e.g. "local", "vault"). */
15
+ backend: z.string(),
16
+ createdBy: z.string().nullable(),
17
+ createdAt: z.date(),
18
+ updatedAt: z.date(),
19
+ });
20
+
21
+ export type SecretMetadata = z.infer<typeof secretMetadataSchema>;
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the Secrets platform. Exported from common so both
5
+ * backend and frontend reference the same `pluginId`.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "secrets",
9
+ });
package/src/routes.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { createRoutes } from "@checkstack/common";
2
+
3
+ /**
4
+ * Route definitions for the Secrets admin UI (Settings → Secrets).
5
+ */
6
+ export const secretsRoutes = createRoutes("secrets", {
7
+ home: "/",
8
+ });
@@ -0,0 +1,108 @@
1
+ import { createClientDefinition, proc } from "@checkstack/common";
2
+ import { z } from "zod";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import { secretsAccess } from "./access";
5
+ import { secretNameSchema } from "./secret-field";
6
+ import { secretMetadataSchema } from "./metadata";
7
+ import {
8
+ backendConfigDtoSchema,
9
+ setBackendConfigInputSchema,
10
+ testBackendResultSchema,
11
+ } from "./backend-config";
12
+
13
+ /**
14
+ * Secrets platform RPC contract.
15
+ *
16
+ * Hard rule: NO endpoint ever returns a secret VALUE to a browser client.
17
+ * `listSecrets` returns metadata only (`hasValue`, never the value);
18
+ * `listSecretNames` returns names only. Resolution to values is a
19
+ * service-typed, internal `secretResolverRef` method, not part of this
20
+ * contract.
21
+ */
22
+ export const secretsContract = {
23
+ /** List secrets as metadata only (names, descriptions, `hasValue`). */
24
+ listSecrets: proc({
25
+ operationType: "query",
26
+ userType: "authenticated",
27
+ access: [secretsAccess.secret.read],
28
+ }).output(z.array(secretMetadataSchema)),
29
+
30
+ /**
31
+ * List secret names only. Used by the editor `${{ secrets.* }}`
32
+ * autocomplete and the secret→env mapping UI. Never returns values.
33
+ */
34
+ listSecretNames: proc({
35
+ operationType: "query",
36
+ userType: "authenticated",
37
+ access: [secretsAccess.secret.read],
38
+ }).output(z.array(z.string())),
39
+
40
+ /**
41
+ * Create or rotate a secret. Write-only: the value is accepted as input
42
+ * and stored encrypted; it is never returned by any endpoint. If a
43
+ * secret with `name` already exists its value is rotated.
44
+ */
45
+ setSecret: proc({
46
+ operationType: "mutation",
47
+ userType: "authenticated",
48
+ access: [secretsAccess.secret.manage],
49
+ })
50
+ .input(
51
+ z.object({
52
+ name: secretNameSchema,
53
+ value: z.string().min(1),
54
+ description: z.string().optional(),
55
+ }),
56
+ )
57
+ .output(z.object({ id: z.string(), name: z.string() })),
58
+
59
+ /** Delete a secret by name. */
60
+ deleteSecret: proc({
61
+ operationType: "mutation",
62
+ userType: "authenticated",
63
+ access: [secretsAccess.secret.manage],
64
+ })
65
+ .route({ method: "DELETE" })
66
+ .input(z.object({ name: secretNameSchema }))
67
+ .output(z.object({ success: z.boolean() })),
68
+
69
+ /**
70
+ * Active backend configuration (metadata only). Never returns backend
71
+ * auth credentials or any secret value.
72
+ */
73
+ getBackendConfig: proc({
74
+ operationType: "query",
75
+ userType: "authenticated",
76
+ access: [secretsAccess.secret.read],
77
+ }).output(backendConfigDtoSchema),
78
+
79
+ /**
80
+ * Select the active backend and (for Vault) configure the connection.
81
+ * The Vault auth credential is write-only — accepted here, stored
82
+ * encrypted, never returned. `manage`-gated.
83
+ */
84
+ setBackendConfig: proc({
85
+ operationType: "mutation",
86
+ userType: "authenticated",
87
+ access: [secretsAccess.secret.manage],
88
+ })
89
+ .input(setBackendConfigInputSchema)
90
+ .output(z.object({ success: z.boolean() })),
91
+
92
+ /**
93
+ * Validate connectivity / auth for a backend (the active one, or the id
94
+ * given). Returns status only — never a secret value.
95
+ */
96
+ testBackend: proc({
97
+ operationType: "mutation",
98
+ userType: "authenticated",
99
+ access: [secretsAccess.secret.manage],
100
+ })
101
+ .input(z.object({ backend: z.string().optional() }))
102
+ .output(testBackendResultSchema),
103
+ };
104
+
105
+ export type SecretsContract = typeof secretsContract;
106
+
107
+ /** Type-safe client definition for `forPlugin` usage. */
108
+ export const SecretsApi = createClientDefinition(secretsContract, pluginMetadata);
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ secretNameSchema,
4
+ secretTemplateSchema,
5
+ collectSecretNames,
6
+ } from "./secret-field";
7
+ import { secretEnvMappingSchema } from "./env-mapping";
8
+
9
+ describe("secretNameSchema", () => {
10
+ it("accepts valid names", () => {
11
+ for (const name of ["jira_token", "API-KEY", "a", "Db1"]) {
12
+ expect(secretNameSchema.safeParse(name).success).toBe(true);
13
+ }
14
+ });
15
+
16
+ it("rejects names not starting with a letter or with bad chars", () => {
17
+ for (const name of ["1token", "_x", "has space", "dot.name", ""]) {
18
+ expect(secretNameSchema.safeParse(name).success).toBe(false);
19
+ }
20
+ });
21
+ });
22
+
23
+ describe("secretTemplateSchema", () => {
24
+ it("accepts a ${{ secrets.NAME }} reference", () => {
25
+ expect(secretTemplateSchema.safeParse("${{ secrets.DB_PASS }}").success).toBe(
26
+ true,
27
+ );
28
+ expect(
29
+ secretTemplateSchema.safeParse("prefix-${{ secrets.X }}-suffix").success,
30
+ ).toBe(true);
31
+ });
32
+
33
+ it("rejects strings without a reference", () => {
34
+ expect(secretTemplateSchema.safeParse("plain").success).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("collectSecretNames", () => {
39
+ it("collects unique names from a nested value tree", () => {
40
+ const names = collectSecretNames({
41
+ value: {
42
+ a: "${{ secrets.ONE }}",
43
+ b: ["x", "${{ secrets.TWO }}"],
44
+ c: { d: "u:${{ secrets.ONE }}@${{ secrets.THREE }}" },
45
+ },
46
+ });
47
+ expect([...names].sort()).toEqual(["ONE", "THREE", "TWO"]);
48
+ });
49
+ });
50
+
51
+ describe("secretEnvMappingSchema", () => {
52
+ it("accepts env→template maps", () => {
53
+ const res = secretEnvMappingSchema.safeParse({
54
+ API_TOKEN: "${{ secrets.jira_token }}",
55
+ _PRIVATE: "${{ secrets.x }}",
56
+ });
57
+ expect(res.success).toBe(true);
58
+ });
59
+
60
+ it("rejects bad env names", () => {
61
+ expect(
62
+ secretEnvMappingSchema.safeParse({ "bad name": "${{ secrets.x }}" })
63
+ .success,
64
+ ).toBe(false);
65
+ });
66
+
67
+ it("tolerates a bare secret name (accepted unchanged; normalized at use)", () => {
68
+ // A bare name (e.g. authored via YAML shorthand or legacy data) is now
69
+ // accepted. The schema is a plain union with NO transform (so it stays
70
+ // representable in JSON Schema for the plugin config UI); the bare name is
71
+ // normalized to `${{ secrets.NAME }}` later, at the consumption boundary
72
+ // (`normalizeSecretEnvValue`), not on parse.
73
+ const res = secretEnvMappingSchema.safeParse({ TOKEN: "not-a-template" });
74
+ expect(res.success).toBe(true);
75
+ if (res.success) {
76
+ expect(res.data).toEqual({ TOKEN: "not-a-template" });
77
+ }
78
+ });
79
+
80
+ it("rejects a value that is neither a template nor a valid secret name", () => {
81
+ // Spaces / leading digit are not a valid bare name and not a template.
82
+ expect(
83
+ secretEnvMappingSchema.safeParse({ TOKEN: "not a template" }).success,
84
+ ).toBe(false);
85
+ expect(
86
+ secretEnvMappingSchema.safeParse({ TOKEN: "1bad" }).success,
87
+ ).toBe(false);
88
+ });
89
+ });
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Valid characters for a secret name: must start with a letter, then
5
+ * letters, digits, underscores, and hyphens. This is the canonical
6
+ * definition for the whole platform — the legacy copy in
7
+ * `@checkstack/gitops-common` mirrors this regex (kept in sync).
8
+ *
9
+ * Must stay in sync with the capture group in {@link SECRET_TEMPLATE_REGEX}.
10
+ */
11
+ export const SECRET_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
12
+
13
+ /**
14
+ * Zod schema for validating secret names at creation time.
15
+ * Ensures the name can be referenced via `${{ secrets.NAME }}`.
16
+ */
17
+ export const secretNameSchema = z
18
+ .string()
19
+ .min(1)
20
+ .max(63)
21
+ .regex(
22
+ SECRET_NAME_REGEX,
23
+ "Secret names must start with a letter and contain only letters, digits, underscores, or hyphens",
24
+ );
25
+
26
+ export type SecretName = z.infer<typeof secretNameSchema>;
27
+
28
+ /**
29
+ * Regex matching `${{ secrets.NAME }}` template expressions in strings.
30
+ * Captures the secret name in group 1. Global so multiple occurrences in
31
+ * a single string are matched.
32
+ *
33
+ * @example
34
+ * "${{ secrets.DB_PASS }}" → captures "DB_PASS"
35
+ * "postgres://u:${{ secrets.PASS }}@${{ secrets.HOST }}/db" → "PASS", "HOST"
36
+ */
37
+ export const SECRET_TEMPLATE_REGEX =
38
+ /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
39
+
40
+ /**
41
+ * Zod schema for validating secret template strings.
42
+ * Matches strings containing at least one `${{ secrets.NAME }}` pattern.
43
+ */
44
+ export const secretTemplateSchema = z.string().regex(
45
+ /\$\{\{\s*secrets\.[a-zA-Z0-9_-]+\s*\}\}/,
46
+ "Must contain a ${{ secrets.NAME }} reference",
47
+ );
48
+
49
+ /**
50
+ * Recursively walks a value and collects all unique secret names
51
+ * referenced via `${{ secrets.NAME }}` patterns in string values.
52
+ *
53
+ * @returns Deduplicated array of secret names found in the value tree.
54
+ */
55
+ export function collectSecretNames({ value }: { value: unknown }): string[] {
56
+ const names = new Set<string>();
57
+ collectFromValue(value, names);
58
+ return [...names];
59
+ }
60
+
61
+ function collectFromValue(value: unknown, names: Set<string>): void {
62
+ if (value === null || value === undefined) {
63
+ return;
64
+ }
65
+
66
+ if (typeof value === "string") {
67
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
68
+ let match: RegExpExecArray | null;
69
+ while ((match = SECRET_TEMPLATE_REGEX.exec(value)) !== null) {
70
+ names.add(match[1]);
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (Array.isArray(value)) {
76
+ for (const item of value) {
77
+ collectFromValue(item, names);
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (typeof value === "object") {
83
+ for (const val of Object.values(value)) {
84
+ collectFromValue(val, names);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { buildTestSecretEnv, secretTestPlaceholder } from "./test-secret-env";
3
+ import { secretEnvMappingSchema } from "./env-mapping";
4
+
5
+ describe("secretTestPlaceholder", () => {
6
+ it("formats a stable named placeholder", () => {
7
+ expect(secretTestPlaceholder("jira_token")).toBe("__SECRET_jira_token__");
8
+ });
9
+ });
10
+
11
+ describe("buildTestSecretEnv", () => {
12
+ it("uses placeholders by default and never resolves a real value", () => {
13
+ const { env, maskValues } = buildTestSecretEnv({
14
+ secretEnv: { API_TOKEN: "${{ secrets.jira_token }}" },
15
+ });
16
+ expect(env).toEqual({ API_TOKEN: "__SECRET_jira_token__" });
17
+ expect(maskValues).toEqual([]);
18
+ });
19
+
20
+ it("injects a user override and marks it for masking", () => {
21
+ const { env, maskValues } = buildTestSecretEnv({
22
+ secretEnv: { A: "${{ secrets.alpha }}", B: "${{ secrets.beta }}" },
23
+ secretOverrides: { beta: "real-override" },
24
+ });
25
+ expect(env).toEqual({ A: "__SECRET_alpha__", B: "real-override" });
26
+ expect(maskValues).toEqual(["real-override"]);
27
+ });
28
+
29
+ it("returns empty when no secretEnv is declared (least-privilege)", () => {
30
+ expect(buildTestSecretEnv({})).toEqual({ env: {}, maskValues: [] });
31
+ expect(buildTestSecretEnv({ secretOverrides: { x: "y" } })).toEqual({
32
+ env: {},
33
+ maskValues: [],
34
+ });
35
+ });
36
+
37
+ it("handles inline interpolation by using the first referenced secret", () => {
38
+ const { env } = buildTestSecretEnv({
39
+ secretEnv: { CONN: "user:${{ secrets.pw }}@host" },
40
+ });
41
+ expect(env.CONN).toBe("__SECRET_pw__");
42
+ });
43
+
44
+ it("injects placeholders for a bare-name mapping once normalized", () => {
45
+ // A `secretEnv` authored with a bare name (e.g. via YAML) is normalized
46
+ // to a template by the schema, then yields a `__SECRET_<NAME>__`
47
+ // placeholder in an override-less test (so `process.env.<env>` is set).
48
+ const normalized = secretEnvMappingSchema.parse({ secret: "SECRET" });
49
+ const noOverride = buildTestSecretEnv({ secretEnv: normalized });
50
+ expect(noOverride.env).toEqual({ secret: "__SECRET_SECRET__" });
51
+ expect(noOverride.maskValues).toEqual([]);
52
+
53
+ // With an explicit override, the override is injected and masked.
54
+ const withOverride = buildTestSecretEnv({
55
+ secretEnv: normalized,
56
+ secretOverrides: { SECRET: "test-value" },
57
+ });
58
+ expect(withOverride.env).toEqual({ secret: "test-value" });
59
+ expect(withOverride.maskValues).toEqual(["test-value"]);
60
+ });
61
+ });