Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

secret-egress.test.tsBlame155 lines · 1 contributor
5e0d7baccantynz-alt1/**
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
26import { describe, expect, it } from "bun:test";
27import { readFileSync } from "fs";
28
29const 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 */
45const 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
51describe("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
63describe("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
84describe("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");
8de7b28ccantynz-alt87 expect(src).toContain("buildRunnerEnv(");
5e0d7baccantynz-alt88 });
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
110describe("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
129describe("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});