Blame · Line-by-line history
secret-egress.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 5e0d7ba | 1 | /** |
| 2 | * No user-controlled execution path receives platform secrets. | |
| 3 | * | |
| 4 | * Two real breaches of this rule, both found by auditing rather than by any | |
| 5 | * test: | |
| 6 | * | |
| 7 | * - src/lib/preview-builder.ts ran `sh -c <repo.previewBuildCommand>` — a | |
| 8 | * string any repo owner sets from the repo settings form — with the FULL | |
| 9 | * process.env, and captured stdout/stderr into `buildLog`, which is | |
| 10 | * rendered back to that same user. `previewBuildCommand = "env"` therefore | |
| 11 | * printed DATABASE_URL (production Neon credentials), ANTHROPIC_API_KEY, | |
| 12 | * GLUECRON_PAT, WORKFLOW_SECRETS_KEY, SERVER_TARGETS_KEY and the OAuth | |
| 13 | * secrets into a page the attacker controls. Complete platform compromise | |
| 14 | * from a settings field. | |
| 15 | * | |
| 16 | * - src/lib/hosted-claude-loop.ts passed ANTHROPIC_API_KEY and GLUECRON_PAT | |
| 17 | * into the env of arbitrary user-supplied JavaScript whose stdout is | |
| 18 | * returned to the caller. | |
| 19 | * | |
| 20 | * src/lib/workflow-runner.ts already had the correct pattern — a curated | |
| 21 | * allowlist plus a credential-shaped denylist — for user `run:` steps. The | |
| 22 | * rule these tests enforce is that EVERY user-code path uses it, because the | |
| 23 | * threat model is identical and the decision should not be made twice. | |
| 24 | */ | |
| 25 | ||
| 26 | import { describe, expect, it } from "bun:test"; | |
| 27 | import { readFileSync } from "fs"; | |
| 28 | ||
| 29 | const SECRET_NAMES = [ | |
| 30 | "ANTHROPIC_API_KEY", | |
| 31 | "GLUECRON_PAT", | |
| 32 | "DATABASE_URL", | |
| 33 | "WORKFLOW_SECRETS_KEY", | |
| 34 | "SERVER_TARGETS_KEY", | |
| 35 | "GOOGLE_OAUTH_CLIENT_SECRET", | |
| 36 | "STRIPE_SECRET_KEY", | |
| 37 | "EMERGENCY_PAT_SECRET", | |
| 38 | ]; | |
| 39 | ||
| 40 | /** | |
| 41 | * Every site that executes a user-supplied command or script. Adding a new one | |
| 42 | * without adding it here is what this suite is designed to catch — see the | |
| 43 | * "no unlisted shell-exec sites" test below. | |
| 44 | */ | |
| 45 | const USER_CODE_PATHS = [ | |
| 46 | "src/lib/workflow-runner.ts", // user `run:` steps from workflow YAML | |
| 47 | "src/lib/preview-builder.ts", // repo.previewBuildCommand | |
| 48 | "src/lib/hosted-claude-loop.ts", // user-authored loop source | |
| 49 | ]; | |
| 50 | ||
| 51 | describe("user-code paths do not inherit the process environment", () => { | |
| 52 | for (const path of USER_CODE_PATHS) { | |
| 53 | it(`${path} never spreads process.env into a spawn`, () => { | |
| 54 | const src = readFileSync(path, "utf8") | |
| 55 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 56 | .replace(/^\s*\/\/.*$/gm, ""); | |
| 57 | // `env: { ...process.env }` is the exact shape that leaked everything. | |
| 58 | expect(src).not.toMatch(/env:\s*\{\s*\.\.\.process\.env/); | |
| 59 | }); | |
| 60 | } | |
| 61 | }); | |
| 62 | ||
| 63 | describe("no secret is named in a user-code path's env construction", () => { | |
| 64 | for (const path of USER_CODE_PATHS) { | |
| 65 | const src = readFileSync(path, "utf8") | |
| 66 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 67 | .replace(/^\s*\/\/.*$/gm, ""); | |
| 68 | for (const secret of SECRET_NAMES) { | |
| 69 | it(`${path} does not read process.env.${secret}`, () => { | |
| 70 | // Assert on the READ, not the mention. workflow-runner names these | |
| 71 | // secrets in its RUNNER_ENV_DENYLIST, which is the correct thing to | |
| 72 | // do — a substring check would flag the very defence being asserted. | |
| 73 | // | |
| 74 | // hosted-claude-loop still reads ANTHROPIC_API_KEY, but only behind an | |
| 75 | // explicit operator opt-in, asserted separately below. | |
| 76 | if (path.includes("hosted-claude-loop") && secret === "ANTHROPIC_API_KEY") return; | |
| 77 | expect(src).not.toMatch(new RegExp(`process\\.env\\.${secret}\\b`)); | |
| 78 | expect(src).not.toMatch(new RegExp(`process\\.env\\[["'\`]${secret}["'\`]\\]`)); | |
| 79 | }); | |
| 80 | } | |
| 81 | } | |
| 82 | }); | |
| 83 | ||
| 84 | describe("the shared allowlist is the only way user code gets an env", () => { | |
| 85 | it("preview-builder uses buildRunnerEnv rather than its own env object", () => { | |
| 86 | const src = readFileSync("src/lib/preview-builder.ts", "utf8"); | |
| 87 | expect(src).toContain("buildRunnerEnv"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("workflow-runner still filters through allowlist AND denylist", () => { | |
| 91 | const src = readFileSync("src/lib/workflow-runner.ts", "utf8"); | |
| 92 | expect(src).toContain("RUNNER_ENV_ALLOWLIST"); | |
| 93 | expect(src).toContain("RUNNER_ENV_DENYLIST"); | |
| 94 | // The denylist is the net: even an allowlisted key that looks like a | |
| 95 | // credential is dropped. | |
| 96 | expect(src).toMatch(/RUNNER_ENV_DENYLIST\.test\(key\)/); | |
| 97 | }); | |
| 98 | ||
| 99 | it("the allowlist contains no secret-shaped names", () => { | |
| 100 | const src = readFileSync("src/lib/workflow-runner.ts", "utf8"); | |
| 101 | const list = src.slice( | |
| 102 | src.indexOf("const RUNNER_ENV_ALLOWLIST"), | |
| 103 | src.indexOf("];", src.indexOf("const RUNNER_ENV_ALLOWLIST")) | |
| 104 | ); | |
| 105 | for (const secret of SECRET_NAMES) expect(list).not.toContain(secret); | |
| 106 | expect(list).not.toMatch(/KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | describe("hosted loops keep the platform key opt-in", () => { | |
| 111 | // Comments stripped: this file documents the original bug, and that prose | |
| 112 | // necessarily contains the very strings being asserted against. | |
| 113 | const src = readFileSync("src/lib/hosted-claude-loop.ts", "utf8") | |
| 114 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 115 | .replace(/^\s*\/\/.*$/gm, ""); | |
| 116 | ||
| 117 | it("the operator PAT is never injected", () => { | |
| 118 | expect(src).not.toMatch(/env\.GLUECRON_PAT\s*=/); | |
| 119 | expect(src).not.toMatch(/GLUECRON_PAT:/); | |
| 120 | }); | |
| 121 | ||
| 122 | it("the Anthropic key requires an explicit opt-in flag", () => { | |
| 123 | expect(src).toContain('process.env.HOSTED_LOOPS_PLATFORM_KEY === "1"'); | |
| 124 | // Never as a bare property on the env literal — that was the bug. | |
| 125 | expect(src).not.toMatch(/^\s*ANTHROPIC_API_KEY:/m); | |
| 126 | }); | |
| 127 | }); | |
| 128 | ||
| 129 | describe("no unlisted shell-exec site", () => { | |
| 130 | it("every `sh -c` / `bash -c` spawn lives in a declared user-code path", () => { | |
| 131 | const { readdirSync, statSync } = require("fs") as typeof import("fs"); | |
| 132 | const { join } = require("path") as typeof import("path"); | |
| 133 | const walk = (d: string): string[] => | |
| 134 | readdirSync(d).flatMap((e) => { | |
| 135 | const p = join(d, e); | |
| 136 | return statSync(p).isDirectory() ? walk(p) : [p]; | |
| 137 | }); | |
| 138 | ||
| 139 | const found: string[] = []; | |
| 140 | for (const dir of ["src/lib", "src/routes", "src/hooks"]) { | |
| 141 | for (const f of walk(dir)) { | |
| 142 | if (!/\.tsx?$/.test(f)) continue; | |
| 143 | const src = readFileSync(f, "utf8").replace(/^\s*\/\/.*$/gm, ""); | |
| 144 | if (/\[\s*["'`](?:ba)?sh["'`]\s*,\s*["'`]-c["'`]/.test(src)) { | |
| 145 | found.push(f.replace(/\\/g, "/")); | |
| 146 | } | |
| 147 | } | |
| 148 | } | |
| 149 | // A new shell-exec site must be reviewed and added to USER_CODE_PATHS, | |
| 150 | // which subjects it to every assertion above. | |
| 151 | expect(found.sort()).toEqual( | |
| 152 | USER_CODE_PATHS.filter((p) => found.includes(p)).sort() | |
| 153 | ); | |
| 154 | }); | |
| 155 | }); |