CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
push-policy-secret-scan.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.
| 91b054e | 1 | /** |
| 2 | * Tests for the unconditional pre-receive secret scan added to | |
| 3 | * src/lib/push-policy.ts (2026-07-16). Previously a critical secret | |
| 4 | * (AWS/GitHub/Anthropic/Stripe key, PEM private key) pushed directly to a | |
| 5 | * repo was only caught after the fact by the async post-receive scan in | |
| 6 | * security-scan.ts — the secret was already in the object store by then. | |
| 7 | * This scan runs inside the same pre-receive hook that already enforces | |
| 8 | * ruleset pack-content rules, and rejects the push before objects are | |
| 9 | * promoted. | |
| 10 | * | |
| 11 | * The hook's eval.js is a standalone, no-project-imports script (see | |
| 12 | * buildEvalScript()'s doc comment) that runs via a real `bun run` subprocess | |
| 13 | * inside a temp hooksPath dir — these tests exercise the actual generated | |
| 14 | * script exactly as production does, rather than re-implementing its logic. | |
| 15 | */ | |
| 16 | ||
| 17 | import { describe, expect, it } from "bun:test"; | |
| 18 | import { writeFileSync } from "fs"; | |
| 19 | import { join } from "path"; | |
| 20 | import { | |
| 21 | hookSecretPatternTypes, | |
| 22 | installPackInspectionHook, | |
| 23 | } from "../lib/push-policy"; | |
| 24 | import { SECRET_PATTERNS } from "../lib/security-scan"; | |
| 25 | ||
| 26 | describe("hook secret patterns stay in sync with security-scan.ts", () => { | |
| 27 | it("covers every critical-severity pattern in SECRET_PATTERNS, and nothing else", () => { | |
| 28 | const criticalTypes = SECRET_PATTERNS.filter((p) => p.severity === "critical").map((p) => p.type).sort(); | |
| 29 | const hookTypes = hookSecretPatternTypes().sort(); | |
| 30 | expect(hookTypes).toEqual(criticalTypes); | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 34 | describe("pre-receive secret scan (real eval.js subprocess)", () => { | |
| 35 | async function runEval(contentLines: string[]): Promise<{ active: string[]; stderr: string }> { | |
| 36 | const hook = await installPackInspectionHook([], { secretScan: true }); | |
| 37 | expect(hook).not.toBeNull(); | |
| 38 | if (!hook) throw new Error("unreachable"); | |
| 39 | const dir = hook.env.GIT_CONFIG_VALUE_0; | |
| 40 | try { | |
| 41 | const commitsPath = join(dir, "commits.txt"); | |
| 42 | const sizesPath = join(dir, "sizes.txt"); | |
| 43 | const contentsPath = join(dir, "contents.txt"); | |
| 44 | writeFileSync(commitsPath, ""); | |
| 45 | writeFileSync(sizesPath, ""); | |
| 46 | writeFileSync( | |
| 47 | contentsPath, | |
| 48 | contentLines | |
| 49 | .map((l) => l) // already "<path>\t<base64>" per line | |
| 50 | .join("\n") + (contentLines.length ? "\n" : "") | |
| 51 | ); | |
| 52 | ||
| 53 | const evalScriptPath = join(dir, "eval.js"); | |
| 54 | const rulesJsonPath = join(dir, "rules.json"); | |
| 55 | const proc = Bun.spawnSync([ | |
| 56 | "bun", | |
| 57 | "run", | |
| 58 | evalScriptPath, | |
| 59 | "--", | |
| 60 | rulesJsonPath, | |
| 61 | commitsPath, | |
| 62 | sizesPath, | |
| 63 | contentsPath, | |
| 64 | ]); | |
| 65 | const stdout = new TextDecoder().decode(proc.stdout).trim(); | |
| 66 | const stderr = new TextDecoder().decode(proc.stderr); | |
| 67 | const active = stdout | |
| 68 | .split("\n") | |
| 69 | .filter(Boolean) | |
| 70 | .filter((line) => line.startsWith("active\t")) | |
| 71 | .map((line) => line.slice("active\t".length)); | |
| 72 | return { active, stderr }; | |
| 73 | } finally { | |
| 74 | await hook.cleanup(); | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | function fileLine(path: string, content: string): string { | |
| 79 | return `${path}\t${Buffer.from(content, "utf8").toString("base64")}`; | |
| 80 | } | |
| 81 | ||
| 82 | it("blocks a critical secret (AWS access key) in a normal source file", async () => { | |
| 83 | const { active, stderr } = await runEval([ | |
| 84 | fileLine("src/config.ts", "const AWS_KEY = 'AKIAABCDEFGH1234IJKL';\n"), | |
| 85 | ]); | |
| 86 | expect(stderr).toBe(""); | |
| 87 | expect(active.length).toBe(1); | |
| 88 | expect(active[0]).toContain("AWS Access Key"); | |
| 89 | expect(active[0]).toContain("src/config.ts:1"); | |
| 90 | }); | |
| 91 | ||
| 92 | it("does not leak the secret value itself into the rejection message", async () => { | |
| 93 | const { active } = await runEval([ | |
| 94 | fileLine("src/config.ts", "const AWS_KEY = 'AKIAABCDEFGH1234IJKL';\n"), | |
| 95 | ]); | |
| 96 | expect(active[0]).not.toContain("AKIAABCDEFGH1234IJKL"); | |
| 97 | }); | |
| 98 | ||
| 99 | it("does not block an obvious placeholder value", async () => { | |
| 100 | const { active } = await runEval([ | |
| 101 | fileLine("src/config.ts", "const AWS_KEY = 'AKIAEXAMPLE1234EXAMP'; // example only\n"), | |
| 102 | ]); | |
| 103 | expect(active.length).toBe(0); | |
| 104 | }); | |
| 105 | ||
| 106 | it("does not scan skip-listed paths (build output, lockfiles)", async () => { | |
| 107 | const { active } = await runEval([ | |
| 108 | fileLine("dist/bundle.js", "const AWS_KEY = 'AKIAABCDEFGH1234IJKL';\n"), | |
| 109 | ]); | |
| 110 | expect(active.length).toBe(0); | |
| 111 | }); | |
| 112 | ||
| 113 | it("does not block a clean file with no secret patterns", async () => { | |
| 114 | const { active } = await runEval([ | |
| 115 | fileLine("README.md", "# Hello\n\nJust a normal readme with no secrets.\n"), | |
| 116 | ]); | |
| 117 | expect(active.length).toBe(0); | |
| 118 | }); | |
| 119 | ||
| 120 | it("scans multiple files and reports one violation per match", async () => { | |
| 121 | const { active } = await runEval([ | |
| 122 | fileLine("a.ts", "AKIAABCDEFGH1234IJKL\n"), | |
| 123 | fileLine("b.ts", "-----BEGIN RSA PRIVATE KEY-----\n"), | |
| 124 | fileLine("c.ts", "nothing interesting here\n"), | |
| 125 | ]); | |
| 126 | expect(active.length).toBe(2); | |
| 127 | }); | |
| 128 | }); |