CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(health): rules that fired in the wrong language, and files never scanned at all #5614

OpenXLccantynz wants to mergefix/rule-scope-and-saturationmain↑4opened 39m agoLive: 0 editing
5 changed files+672−14
Addedsrc/__tests__/chosen-password-heuristic.test.ts+123−0View fileUnifiedSplit
1/**
2 * The recall half of the credential rule, and the corpus that sets its
3 * threshold.
4 *
5 * `s00pers3cret` is a real password and was SILENTLY not a finding: it is an
6 * all-lowercase slug, and it contains no credential word (`s3cret` is not
7 * `secret`), so it fell through the last check and reported nothing. The
8 * GateTest engine caught it — because its rule keys on the IDENTIFIER and
9 * never inspects the value, which is the same insensitivity that made it emit
10 * nine false positives on axios's authentication docs.
11 *
12 * Two engines failing in opposite directions from one design choice. This is
13 * the half that was missing here.
14 *
15 * The threshold (two interleaved digits, not one) is MEASURED, not chosen. At
16 * one occurrence `log4j-config` fires. This corpus is the evidence; if the
17 * threshold ever moves, this is what has to move with it.
18 */
19import { describe, expect, it } from "bun:test";
20import { looksLikeChosenPassword, detectSecurityIssuesInLine } from "../lib/intelligence";
21
22describe("chosen passwords are recognised", () => {
23 for (const v of [
24 "s00pers3cret", // the case that exposed the asymmetry
25 "p4ssw0rd",
26 "tr0ub4dor",
27 "h4ck3rman",
28 "l3tm31n-now",
29 "c0rrect-h0rse-battery",
30 ]) {
31 it(`fires on ${v}`, () => expect(looksLikeChosenPassword(v)).toBe(true));
32 }
33});
34
35describe("technical slugs are not passwords", () => {
36 // The population the all-lowercase-slug rule exists to protect: MIME types,
37 // service names, versioned library names. A false positive here is a
38 // `critical` finding on a config value.
39 for (const v of [
40 "log4j-config", // fires at threshold 1 — the reason the threshold is 2
41 "utf8mb4-general",
42 "log4j2-appender",
43 "i18n-locale",
44 "a11y-checker",
45 "k8s-cluster-name",
46 "application-json",
47 "my-service-name",
48 "postgres",
49 "localhost-dev",
50 "utf-8-encoding",
51 "base64",
52 "sha256",
53 "md5",
54 "oauth2-client",
55 "s3-bucket-name",
56 "text-plain",
57 "content-type",
58 "user-agent",
59 "node-modules",
60 "https-only",
61 "h2-database",
62 "sqlite3-driver",
63 "python3-venv",
64 "ipv6-address",
65 "x11-forwarding",
66 "ec2-instance-role",
67 "v8-isolate",
68 "sha512-hmac",
69 ]) {
70 it(`does not fire on ${v}`, () => expect(looksLikeChosenPassword(v)).toBe(false));
71 }
72});
73
74describe("end to end through the rule", () => {
75 const rules = (filePath: string, line: string) =>
76 detectSecurityIssuesInLine({ filePath, line, lineNumber: 1 }).map((i) => i.rule);
77
78 it("the previously-missed password is now a finding in source", () => {
79 expect(rules("src/a.ts", " auth: { username: 'janedoe', password: 's00pers3cret' }")).toContain(
80 "no-hardcoded-secrets"
81 );
82 });
83
84 it("but is still NOT a finding in documentation", () => {
85 // Recall must not reopen the doc false-positive class it sits next to —
86 // that exact line is in axios's README.
87 expect(rules("README.md", " password: 's00pers3cret'")).toEqual([]);
88 });
89
90 it("a config value next to the word password stays silent in source", () => {
91 expect(rules("src/a.ts", 'const passwordEncoding = "utf-8-encoding";')).toEqual([]);
92 });
93});
94
95describe("the value judged is the one the rule matched", () => {
96 const rules = (filePath: string, line: string) =>
97 detectSecurityIssuesInLine({ filePath, line, lineNumber: 1 }).map((i) => i.rule);
98
99 it("a field to the LEFT of the credential does not get judged instead", () => {
100 // Found while testing the recall fix, and it is the more serious of the
101 // two. Without a capture group the rule fell back to the FIRST quoted
102 // string on the line — here `janedoe`, a preceding value, not a key —
103 // judged it too short to be a secret, and reported nothing. A real
104 // password went unreported because another field sat to its left.
105 expect(
106 rules("src/a.ts", " auth: { username: 'janedoe', password: 's00pers3cret' }")
107 ).toContain("no-hardcoded-secrets");
108 });
109
110 it("holds for the api-key rule too", () => {
111 expect(
112 rules("src/a.ts", " const cfg = { region: 'us-east-1', api_key: 'Tr0ub4dor3Staple' };")
113 ).toContain("no-hardcoded-secrets");
114 });
115
116 it("still judges the value, not the key name", () => {
117 // The trap the capture was protecting against in the first place: a short
118 // or identifier-shaped VALUE must still be cleared, whatever precedes it.
119 expect(
120 rules("src/a.ts", " auth: { username: 'janedoe', password: 'utf-8-encoding' }")
121 ).toEqual([]);
122 });
123});
Addedsrc/__tests__/merge-path-ai-reachability.test.ts+183−0View fileUnifiedSplit
1/**
2 * Which modules in the merge chain can call Anthropic at all.
3 *
4 * THE INVARIANT (owner-stated, 2026-08-10): "we shouldn't need credit balance
5 * to ship a product — if Anthropic ever went down then so would our
6 * websites." ship-path-ai-independence.test.ts pins the individual seams that
7 * keep that true. This suite pins the SET.
8 *
9 * Why the set and not the seams: on 2026-09-01 the platform's Anthropic
10 * balance ran out and `performGatedMerge` began returning
11 * `400 "Your credit balance is too low"` / REST `500`, blocking every merge
12 * platform-wide. It failed closed — nothing half-merged, main unmoved, PRs
13 * still open — but it failed, and the ship path is not supposed to depend on
14 * that balance at all.
15 *
16 * Auditing it by hand, every seam checked came back correctly guarded:
17 *
18 * generatePrRiskSummary isAiAvailable() + try/catch -> deterministicSummary
19 * (verified live: risk still computed, fallback prose)
20 * aiReviewGateState pure DB read over stored comments, no API call
21 * aiSecurityScanSafe isAiAvailable() + catch -> {findings: [], skipped}
22 * resolveConflict only reachable when a merge actually conflicts
23 * repairSecrets/Issues only reachable when a scan has already failed
24 *
25 * So the throwing call was NOT any of them, and could not be located from the
26 * API surface — the platform offers a requestId with no operator-reachable
27 * error log to resolve it against. A by-hand audit that cannot find the bug
28 * is exactly the audit worth automating, because the next person will start
29 * from zero.
30 *
31 * This test does not prove every call degrades — a static test cannot. It
32 * proves the SURFACE has not grown: if a module that can reach Anthropic
33 * becomes reachable from the merge chain, someone has to come here, say how
34 * it degrades, and add it. That converts a silent new dependency into a
35 * failing test.
36 */
37import { describe, expect, it } from "bun:test";
38import { statSync } from "fs";
39import { dirname, join, normalize, sep } from "path";
40
41const ENTRY = "src/lib/pr-merge-gated.ts";
42const AI_CALL = /getAnthropic\s*\(|messages\.create\s*\(/;
43
44/**
45 * Every module reachable from the merge entry point, following static and
46 * dynamic relative imports. Dynamic `await import("./x")` counts: the merge
47 * chain uses it, and a dependency is no less real for being lazy.
48 */
49async function reachableFrom(entry: string): Promise<Set<string>> {
50 const seen = new Set<string>();
51 const stack = [entry];
52
53 const resolve = (from: string, spec: string): string | null => {
54 if (!spec.startsWith(".")) return null;
55 const base = normalize(join(dirname(from), spec)).split(sep).join("/");
56 for (const ext of [".ts", ".tsx", "/index.ts"]) {
57 const cand = base + ext;
58 try {
59 if (statSync(cand).isFile()) return cand;
60 } catch {
61 /* not this one */
62 }
63 }
64 return null;
65 };
66
67 while (stack.length > 0) {
68 const file = stack.pop()!;
69 if (seen.has(file)) continue;
70 let src: string;
71 try {
72 src = await Bun.file(file).text();
73 } catch {
74 continue;
75 }
76 seen.add(file);
77 for (const re of [/from\s+"([^"]+)"/g, /import\(\s*"([^"]+)"\s*\)/g]) {
78 for (const m of src.matchAll(re)) {
79 const r = resolve(file, m[1]);
80 if (r) stack.push(r);
81 }
82 }
83 }
84 return seen;
85}
86
87/**
88 * The documented set. Each entry states how that module avoids making the
89 * ship path depend on a working Anthropic balance.
90 *
91 * Adding a module here is a deliberate act. If you are here because the test
92 * failed, the question to answer is not "how do I make this pass" but "what
93 * happens to a merge when this module's API call fails".
94 */
95const DOCUMENTED: Record<string, string> = {
96 "src/lib/ai-client.ts":
97 "the client factory itself; isAiAvailable() is a CONFIG predicate (key present), not a health check — it cannot detect an exhausted balance",
98 "src/lib/ai-cost-tracker.ts":
99 "best-effort metering; every write is wrapped and swallowed, never surfaced to the caller",
100 "src/lib/ai-provider.ts":
101 "provider selection/routing; resolves configuration, does not gate the merge",
102 "src/lib/ai-review-trio.ts":
103 "multi-model review, triggered asynchronously; the merge reads stored review COMMENTS, never the live call",
104 "src/lib/ai-review.ts":
105 "aiReviewGateState is a pure DB read over stored comments; the 'unavailable' state fails OPEN in gate.ts",
106 "src/lib/auto-repair.ts":
107 "repairSecrets/repairSecurityIssues run only when a scan has ALREADY failed, so a clean PR never reaches them",
108 "src/lib/merge-resolver.ts":
109 "resolveConflict is reached only when a merge genuinely conflicts; clean and clean-non-ff merges return before any AI call (pinned in ship-path-ai-independence)",
110 "src/lib/pr-risk.ts":
111 "generatePrRiskSummary is isAiAvailable()-guarded and try/catches to deterministicSummary; verified live during the outage",
112 "src/lib/security-scan.ts":
113 "aiSecurityScanSafe is isAiAvailable()-guarded and catches to {findings: [], skipped: true}; static rules still run",
114};
115
116describe("the guard itself is not vacuous", () => {
117 // If the import trace silently resolved nothing, every assertion below
118 // would pass while checking an empty set — a vacuously-passing check is
119 // indistinguishable from a working one, and both peer engines lost time to
120 // exactly that this week (a source-scan test passing against a fixed file,
121 // and a cross-engine comparison that was silence rather than agreement).
122 it("the trace actually walks the merge chain", async () => {
123 const reachable = await reachableFrom(ENTRY);
124 expect(reachable.size).toBeGreaterThan(20);
125 expect(reachable.has(ENTRY)).toBe(true);
126 // Modules the merge demonstrably depends on, as a shape check on the walk.
127 expect(reachable.has("src/lib/pr-risk.ts")).toBe(true);
128 expect(reachable.has("src/lib/gate.ts")).toBe(true);
129 });
130
131 it("the trace actually finds Anthropic call sites", async () => {
132 const reachable = await reachableFrom(ENTRY);
133 const withAi = [];
134 for (const f of reachable) {
135 if (AI_CALL.test(await Bun.file(f).text())) withAi.push(f);
136 }
137 // If this ever hits zero, the detector has broken, not the codebase.
138 expect(withAi.length).toBeGreaterThanOrEqual(5);
139 });
140
141 it("the detector recognises a call it should catch", async () => {
142 // Positive control on the regex itself.
143 expect(AI_CALL.test("const c = getAnthropic();")).toBe(true);
144 expect(AI_CALL.test("await client.messages.create({})")).toBe(true);
145 expect(AI_CALL.test("const x = deterministicSummary(signals);")).toBe(false);
146 });
147});
148
149describe("the merge chain's Anthropic surface is known and documented", () => {
150 it("no UNDOCUMENTED module that can call Anthropic is reachable from the merge", async () => {
151 const reachable = await reachableFrom(ENTRY);
152 const withAi: string[] = [];
153 for (const f of reachable) {
154 if (AI_CALL.test(await Bun.file(f).text())) withAi.push(f);
155 }
156 const undocumented = withAi.filter((f) => !(f in DOCUMENTED)).sort();
157
158 // A new AI dependency in the ship path must be a decision, not a diff.
159 expect(undocumented).toEqual([]);
160 });
161
162 it("every documented module is still actually reachable — no stale entries", async () => {
163 // A stale entry is its own hazard: it makes the list look like a
164 // considered audit when part of it describes code that moved.
165 const reachable = await reachableFrom(ENTRY);
166 const stale = Object.keys(DOCUMENTED)
167 .filter((f) => !reachable.has(f))
168 .sort();
169 expect(stale).toEqual([]);
170 });
171
172 it("every documented module carries a non-trivial reason", async () => {
173 for (const [file, reason] of Object.entries(DOCUMENTED)) {
174 expect(reason.length, `${file} needs a real degradation note`).toBeGreaterThan(40);
175 }
176 });
177
178 it("the entry point itself never calls Anthropic directly", async () => {
179 const src = await Bun.file(ENTRY).text();
180 expect(src).not.toContain("getAnthropic");
181 expect(src).not.toContain("messages.create");
182 });
183});
Modifiedsrc/__tests__/security-findings-summary.test.ts+34−0View fileUnifiedSplit
1717 summariseFindings,
1818 MAX_ISSUES_PER_RULE,
1919 scoreSecurityIssues,
20 securityScoreSaturated,
2021} from "../lib/intelligence";
2122
2223const issue = (rule: string, severity: string, n: number) =>
133134 expect(calls).toBeGreaterThanOrEqual(2);
134135 });
135136});
137
138describe("the score says when it has stopped being a measurement", () => {
139 it("flags saturation once criticals alone exhaust the budget", () => {
140 // Measured curve: 6 -> 0, 4 -> 0, 2 -> 0, 1 -> 15, 0 -> 40. So removing
141 // three FALSE criticals from a real report moved the score not at all,
142 // which reads as "nothing improved" rather than "the number bottomed out".
143 expect(securityScoreSaturated(issue("no-hardcoded-secrets", "critical", 4))).toBe(true);
144 expect(securityScoreSaturated(issue("no-hardcoded-secrets", "critical", 6))).toBe(true);
145 });
146
147 it("does not flag saturation while the score still moves", () => {
148 expect(securityScoreSaturated(issue("no-hardcoded-secrets", "critical", 3))).toBe(false);
149 expect(securityScoreSaturated(issue("no-hardcoded-secrets", "critical", 1))).toBe(false);
150 expect(securityScoreSaturated([])).toBe(false);
151 });
152
153 it("saturation is about criticals, not sheer volume", () => {
154 // 162 mediums cost 20 points, capped. Volume must not be mistaken for
155 // the uncapped-critical case the flag exists to describe.
156 expect(securityScoreSaturated(issue("no-inner-html", "medium", 162))).toBe(false);
157 });
158
159 it("the flag agrees with the score actually bottoming out", () => {
160 // Direction check tying the two together: wherever it says saturated,
161 // the score must in fact be 0, and adding another critical must not
162 // change it. If the penalty ever changes, this catches the drift.
163 for (const n of [4, 5, 6, 20]) {
164 const issues = issue("no-hardcoded-secrets", "critical", n);
165 expect(securityScoreSaturated(issues)).toBe(true);
166 expect(scoreSecurityIssues(issues)).toBe(0);
167 }
168 });
169});
Addedsrc/__tests__/security-rule-scope.test.ts+171−0View fileUnifiedSplit
1/**
2 * A rule may only fire in a language whose syntax supports it, and a
3 * credential may be committed in any file at all.
4 *
5 * Two opposite defects, found the same day by two engines:
6 *
7 * - FALSE POSITIVE: `services/waf-rs/src/owasp.rs` was reported `high` for
8 * `no-eval`. It is Rust, and `eval` there is a WAF *signature string*.
9 * `/eval\s*\(/` with no language awareness is the same bug as the
10 * credential rule reading `CELITECH_TOKEN_URL` and flagging a URL —
11 * matching a token and inferring a meaning the language cannot support.
12 *
13 * - FALSE NEGATIVE: documentation and config formats were not scanned at
14 * all, so a live key pasted into a README, a .tf or an .ini was invisible
15 * to every rule. The GateTest engine found the identical `.md` gap in its
16 * own secrets module, and only because it planted a real key as a positive
17 * control — a favourable cross-engine comparison was concealing it.
18 *
19 * The two fixes depend on each other: widening the file set is only safe
20 * because syntax-bound rules now declare where they apply.
21 */
22import { describe, it, expect } from "bun:test";
23import { detectSecurityIssuesInLine } from "../lib/intelligence";
24
25const at = (filePath: string, line: string) =>
26 detectSecurityIssuesInLine({ filePath, line, lineNumber: 1 });
27const rules = (filePath: string, line: string) => at(filePath, line).map((i) => i.rule);
28
29describe("syntax-bound rules respect the language", () => {
30 it("does not report eval() in a Rust WAF signature file", () => {
31 expect(rules("services/waf-rs/src/owasp.rs", 'r"eval\s*\(",')).not.toContain("no-eval");
32 });
33
34 it("still reports eval() in JavaScript", () => {
35 expect(rules("src/a.js", "const r = eval(userInput);")).toContain("no-eval");
36 });
37
38 it("still reports eval() in Python and Ruby, which have their own", () => {
39 expect(rules("app/a.py", "r = eval(user_input)")).toContain("no-eval");
40 expect(rules("app/a.rb", "r = eval(user_input)")).toContain("no-eval");
41 });
42
43 it("does not report innerHTML outside JS/TS", () => {
44 expect(rules("docs/guide.md", "el.innerHTML = userInput;")).not.toContain("no-inner-html");
45 expect(rules("main.go", "x.innerHTML = y")).not.toContain("no-inner-html");
46 });
47
48 it("still reports innerHTML in a .tsx component", () => {
49 expect(rules("src/C.tsx", "el.innerHTML = userInput;")).toContain("no-inner-html");
50 });
51
52 it("does not report a template-literal SQL pattern in Markdown", () => {
53 // `${...}` is JS syntax; in prose it is an example, not a query.
54 expect(rules("README.md", 'query(`SELECT * FROM t WHERE id = ${id}`)')).not.toContain(
55 "no-sql-injection"
56 );
57 });
58});
59
60describe("language-agnostic rules fire everywhere, including docs", () => {
61 it("finds an AWS key in a README", () => {
62 // The false negative: this file type was not scanned at all.
63 expect(rules("README.md", "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE")).toContain(
64 "no-aws-keys"
65 );
66 });
67
68 it("finds a private key in a docs file", () => {
69 // Assembled at runtime, not written literally.
70 //
71 // Gluecron's own push-time secret gate rejected this file when the PEM
72 // header was a literal — correctly. A scanner's fixtures are no more
73 // entitled to commit a key pattern than anyone else's code, and clicking
74 // past the gate would train the repository to wave through exactly what
75 // the rule exists to catch. (The GateTest engine hit the identical
76 // rejection from GitHub's push protection the same day, for the same
77 // reason, and resolved it the same way.)
78 const pem = ["-----BEGIN", "RSA", "PRIVATE", "KEY-----"].join(" ");
79 expect(rules("docs/setup.md", pem)).toContain("no-private-keys");
80 });
81
82 it("finds an AWS key in a Terraform file", () => {
83 expect(rules("infra/main.tf", 'access_key = "AKIAIOSFODNN7EXAMPLE"')).toContain(
84 "no-aws-keys"
85 );
86 });
87
88 it("finds a hardcoded password in an ini config", () => {
89 // Mixed case + digits + symbol: the shape of a real chosen password.
90 // An all-lowercase slug with no credential word is DELIBERATELY treated
91 // as an identifier — that rule exists so `application/json` next to the
92 // word "password" does not become a critical finding.
93 expect(rules("app.ini", 'password = "Tr0ub4dor&3-Staple"')).toContain(
94 "no-hardcoded-secrets"
95 );
96 });
97
98 it("keeps treating an all-lowercase slug in config as an identifier", () => {
99 expect(rules("app.ini", 'password_type = "application-json-ish"')).toEqual([]);
100 });
101
102 it("still rejects a placeholder in documentation", () => {
103 // Widening the file set must not reintroduce the placeholder FPs; docs
104 // are where placeholders live.
105 expect(rules("README.md", 'DATABASE_PASSWORD="<your-password>"')).not.toContain(
106 "no-hardcoded-secrets"
107 );
108 });
109});
110
111describe("the credential family splits: vendor-shaped vs generic", () => {
112 /**
113 * Widening the file set to documentation was right for VENDOR-shaped
114 * credentials and wrong for GENERIC ones, and "credential rules stay
115 * unconstrained" was too coarse a rule to express that.
116 *
117 * The GateTest engine measured the cost on axios: exempting docs from its
118 * confidence discount added NINE findings, every one example prose —
119 * `authentication.md` and `request-config.md` across four translations,
120 * plus the README. It predicted this scanner would do the same. It was
121 * right, and the prediction was checked here before being acted on:
122 * `password: "myPassword"` in a .md fired `critical`.
123 */
124
125 it("a generic password pattern in an auth guide is documentation, not a leak", () => {
126 expect(rules("docs/pages/advanced/authentication.md", ' password: "myPassword"')).toEqual([]);
127 });
128
129 it("the same pattern in source is still a finding", () => {
130 // The recall half. Scoping must cost nothing in code.
131 expect(rules("src/client.ts", ' password: "myPassword"')).toContain(
132 "no-hardcoded-secrets"
133 );
134 });
135
136 it("a generic api-key pattern in a README is documentation", () => {
137 expect(rules("README.md", 'api_key: "example-key-value-here-1234"')).toEqual([]);
138 });
139
140 it("an env-fallback credential in prose is an example, in code it is a default", () => {
141 // The credential word must precede the `:`/`=` — `const s = ...SECRET ?? x`
142 // does NOT match, and writing it that way is a fixture that pins a false
143 // negative which does not exist. Third time I've made that mistake today;
144 // this is the real shape, from services/sms/src/index.ts.
145 const line = 'twilioInboundSecret: env.SMS_TWILIO_INBOUND_SECRET ?? "twilio-dev-secret",';
146 expect(rules("docs/guide.md", line)).toEqual([]);
147 expect(rules("src/app.ts", line)).toContain("no-hardcoded-secrets");
148 });
149
150 it("VENDOR-shaped credentials still fire in prose — this is what widening was FOR", () => {
151 // An AWS key or a private key in a README is unambiguous in any file
152 // type. Losing these to kill the generic doc noise would undo the
153 // false-negative fix entirely.
154 expect(rules("SECURITY.md", "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE")).toContain(
155 "no-aws-keys"
156 );
157 const pem = ["-----BEGIN", "RSA", "PRIVATE", "KEY-----"].join(" ");
158 expect(rules("README.md", pem)).toContain("no-private-keys");
159 });
160
161 it("config formats are not prose — a generic pattern there is still a finding", () => {
162 // The boundary: .md/.txt are prose, .ini/.yaml/.toml are configuration
163 // and a credential in them is real.
164 expect(rules("app.ini", 'password = "Tr0ub4dor&3-Staple"')).toContain(
165 "no-hardcoded-secrets"
166 );
167 expect(rules("config.toml", 'api_key = "Tr0ub4dor3Staple99"')).toContain(
168 "no-hardcoded-secrets"
169 );
170 });
171});
Modifiedsrc/lib/intelligence.ts+161−14View fileUnifiedSplit
5858 * whole" this module keeps coming back from, so both numbers are here
5959 * and `byRule` is never truncated.
6060 */
61 /**
62 * True when the critical penalty alone exhausts the score budget, so
63 * `score` has bottomed out at 0 and no longer distinguishes 4 criticals
64 * from 40. Read `findings.bySeverity` instead when this is set.
65 */
66 scoreSaturated?: boolean;
6167 findings?: {
6268 total: number;
6369 listed: number;
438444
439445// ─── SECURITY ANALYSIS ───────────────────────────────────────
440446
447/**
448 * File extensions a language-specific rule may fire on.
449 *
450 * `eval` in a Rust WAF signature file is a rule PATTERN STRING, not a call:
451 * `services/waf-rs/src/owasp.rs` was reported `high` for `no-eval` because
452 * the matcher is `/eval\s*\(/` with no idea what language it is reading.
453 * That is the same defect as the credential rule reading the variable name
454 * (`CELITECH_TOKEN_URL` flagged for a URL) in a third costume — matching a
455 * token and inferring a meaning the surrounding language does not support.
456 *
457 * Only rules whose SYNTAX is language-bound are constrained. `${...}` inside
458 * a quoted string is a JS/TS template literal, `innerHTML` is a DOM property,
459 * `createHash` is a Node API. Committed credentials, AWS keys, private keys
460 * and security TODOs can appear in any file and stay unconstrained.
461 */
462const JS_TS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
463
464/**
465 * Prose formats, where a GENERIC credential pattern is documentation.
466 *
467 * `password: "myPassword"` in an authentication guide is what HTTP Basic
468 * auth docs look like. Measured on axios: exempting docs from the confidence
469 * discount turned 7 blocking findings into 8 and added NINE doc findings —
470 * `authentication.md` and `request-config.md` in four translations, plus the
471 * README — every one of them example prose. Confirmed against this scanner
472 * before acting: `password: "myPassword"` in a `.md` fired `critical`.
473 *
474 * So "credential rules stay unconstrained when we widened to docs" was too
475 * coarse. The family splits in two, and only the generic half is affected:
476 *
477 * VENDOR-SHAPED (`AKIA…`, a PEM header) — unambiguous in any file type.
478 * A real key in a README is exactly what widening was FOR, and these
479 * keep firing everywhere. Verified: both still fire in .md.
480 * GENERIC (`password|api_key|secret_key = ""`) — precisely what
481 * documentation contains, so these are code-and-config only.
482 *
483 * Found by the GateTest engine, which measured the cost and reverted; it
484 * could not express the split because its confidence layer emits one check
485 * per FILE with no per-pattern granularity. These rules are individually
486 * addressable, so it is expressible here.
487 */
488const PROSE = ["md", "markdown", "txt"];
489/** Everything scanned that is not prose — where a generic pattern is a finding. */
490const NOT_PROSE = [
491 "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java",
492 "php", "sh", "bash", "zsh", "yaml", "yml", "json", "toml", "ini", "cfg",
493 "conf", "properties", "tf", "tfvars", "xml", "gradle",
494];
495/** Languages that have an `eval` construct of their own. */
496const EVAL_LANGS = [...JS_TS, "py", "rb", "php"];
497
441498const SECURITY_PATTERNS: Array<{
442499 pattern: RegExp;
443500 severity: SecurityIssue["severity"];
444501 message: string;
445502 rule: string;
503 /** Extensions this rule may fire on. Omitted = every file. */
504 appliesTo?: readonly string[];
446505}> = [
447506 // Hardcoded secrets
448 { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{4,}/i, severity: "critical", message: "Possible hardcoded password", rule: "no-hardcoded-secrets" },
449 { pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*["'][^"']{8,}/i, severity: "critical", message: "Possible hardcoded API key", rule: "no-hardcoded-secrets" },
507 { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["']([^"']{4,})/i, severity: "critical", message: "Possible hardcoded password", rule: "no-hardcoded-secrets", appliesTo: NOT_PROSE },
508 { pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*["']([^"']{8,})/i, severity: "critical", message: "Possible hardcoded API key", rule: "no-hardcoded-secrets", appliesTo: NOT_PROSE },
450509 // The env-fallback idiom, which is where committed default credentials
451510 // actually live:
452511 //
462521 // This is the dangerous shape precisely BECAUSE it looks defensive: the
463522 // author believes the env var will be set, and the literal is live in every
464523 // environment where it is not.
465 { pattern: /(?:password|passwd|pwd|secret|api[_-]?key|apikey|token|credential)[A-Za-z0-9_]*\s*[:=]\s*[^;]*?(?:\?\?|\|\|)\s*["']([^"']{4,})["']/i, severity: "critical", message: "Hardcoded credential used as a fallback when the environment variable is unset", rule: "no-hardcoded-secrets" },
524 { pattern: /(?:password|passwd|pwd|secret|api[_-]?key|apikey|token|credential)[A-Za-z0-9_]*\s*[:=]\s*[^;]*?(?:\?\?|\|\|)\s*["']([^"']{4,})["']/i, severity: "critical", message: "Hardcoded credential used as a fallback when the environment variable is unset", rule: "no-hardcoded-secrets", appliesTo: NOT_PROSE },
466525 { pattern: /(?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/, severity: "critical", message: "Possible AWS access key", rule: "no-aws-keys" },
467526 { pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, severity: "critical", message: "Private key in source code", rule: "no-private-keys" },
468527 // Injection vulnerabilities
469 { pattern: /eval\s*\(/, severity: "high", message: "Use of eval() — potential code injection", rule: "no-eval" },
470 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html" },
471 { pattern: /document\.write\s*\(/, severity: "medium", message: "document.write usage — potential XSS", rule: "no-document-write" },
472 { pattern: /exec\s*\(\s*[`"'].*\$\{/, severity: "high", message: "Shell command with template literal — potential injection", rule: "no-shell-injection" },
528 { pattern: /eval\s*\(/, severity: "high", message: "Use of eval() — potential code injection", rule: "no-eval", appliesTo: EVAL_LANGS },
529 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html", appliesTo: JS_TS },
530 { pattern: /document\.write\s*\(/, severity: "medium", message: "document.write usage — potential XSS", rule: "no-document-write", appliesTo: JS_TS },
531 { pattern: /exec\s*\(\s*[`"'].*\$\{/, severity: "high", message: "Shell command with template literal — potential injection", rule: "no-shell-injection", appliesTo: JS_TS },
473532 // SQL injection
474 { pattern: /query\s*\(\s*[`"'].*\$\{/, severity: "high", message: "SQL query with interpolation — potential SQL injection", rule: "no-sql-injection" },
475 { pattern: /\.raw\s*\(\s*[`"'].*\$\{/, severity: "medium", message: "Raw query with interpolation", rule: "no-raw-sql-injection" },
533 { pattern: /query\s*\(\s*[`"'].*\$\{/, severity: "high", message: "SQL query with interpolation — potential SQL injection", rule: "no-sql-injection", appliesTo: JS_TS },
534 { pattern: /\.raw\s*\(\s*[`"'].*\$\{/, severity: "medium", message: "Raw query with interpolation", rule: "no-raw-sql-injection", appliesTo: JS_TS },
476535 // Crypto issues
477 { pattern: /createHash\s*\(\s*["']md5["']\)/, severity: "medium", message: "MD5 hash used — cryptographically weak", rule: "no-weak-crypto" },
478 { pattern: /createHash\s*\(\s*["']sha1["']\)/, severity: "low", message: "SHA1 hash — consider SHA-256+", rule: "weak-hash" },
536 { pattern: /createHash\s*\(\s*["']md5["']\)/, severity: "medium", message: "MD5 hash used — cryptographically weak", rule: "no-weak-crypto", appliesTo: JS_TS },
537 { pattern: /createHash\s*\(\s*["']sha1["']\)/, severity: "low", message: "SHA1 hash — consider SHA-256+", rule: "weak-hash", appliesTo: JS_TS },
479538 // Misc
480539 { pattern: /TODO.*(?:security|hack|fixme|unsafe|vulnerable)/i, severity: "info", message: "Security-related TODO found", rule: "security-todo" },
481540 { pattern: /(?:disable|ignore).*(?:eslint|tslint|security)/i, severity: "low", message: "Security linter rule disabled", rule: "no-security-disable" },
608667 const looksLikeIdentifier = /^[a-z][a-z0-9._-]*$/.test(value);
609668 if (!looksLikeIdentifier) return true;
610669
611 // An all-lowercase slug. Keep it only if it names itself as a credential.
612 return CREDENTIAL_WORDS.test(value);
670 // An all-lowercase slug. Keep it if it names itself as a credential, or if
671 // it carries the interleaved-digit shape of a chosen password.
672 return CREDENTIAL_WORDS.test(value) || looksLikeChosenPassword(value);
673}
674
675/**
676 * Does this lowercase slug look like a human-chosen password rather than a
677 * config value?
678 *
679 * The recall hole this closes: `s00pers3cret` is a real password and was
680 * SILENTLY not a finding. It matches the all-lowercase-slug shape, and it
681 * contains no credential word (`s3cret` is not `secret`), so it fell through
682 * the last check and reported nothing. The GateTest engine caught it because
683 * its rule keys on the identifier and never inspects the value at all — the
684 * same insensitivity that made it emit nine false positives on axios's auth
685 * documentation. Two engines failing in opposite directions from the same
686 * design choice: mine asks "does the VALUE look like a secret" and under-
687 * fires, theirs asks "does the IDENTIFIER look like one" and over-fires.
688 *
689 * The signal is leetspeak: a digit sitting BETWEEN letters, twice or more.
690 * `s00pers3cret` has `s00p` and `s3c`; `p4ssw0rd` has `p4s` and `w0r`. A
691 * technical slug puts its digits at a token boundary instead — `sqlite3-`,
692 * `-ipv6`, `oauth2-`, `base64`, `s3-bucket`.
693 *
694 * Two occurrences, not one, and that threshold is measured rather than
695 * chosen: at one, `log4j-config` fires. At two, a corpus of six leetspeak
696 * passwords and twenty-nine realistic technical slugs — including the
697 * adversarial `utf8mb4-general`, `log4j2-appender`, `i18n-locale`,
698 * `a11y-checker`, `k8s-cluster-name` — separates cleanly, 0 misses and 0
699 * false positives. The corpus is the test; if the threshold ever moves, that
700 * is the evidence that has to move with it.
701 *
702 * This is a heuristic and it is stated as one. It can only ADD findings —
703 * it is reached solely on the branch that was previously returning false —
704 * so its failure mode is a false positive on a lowercase slug with two
705 * interleaved digits, not a missed secret.
706 */
707export function looksLikeChosenPassword(value: string): boolean {
708 return (value.match(/[a-z]\d+[a-z]/g) || []).length >= 2;
613709}
614710
615711/**
845941 const found: SecurityIssue[] = [];
846942 if (line.includes("secrets-ok")) return found;
847943
944 const ext = (filePath.split(".").pop() || "").toLowerCase();
945
848946 for (const rule of SECURITY_PATTERNS) {
947 // A rule whose syntax is language-bound may only fire on that language.
948 // `eval` in a Rust WAF signature file is a rule pattern string, not a
949 // call; `${...}` is a JS template literal and means nothing in Markdown.
950 if (rule.appliesTo && !rule.appliesTo.includes(ext)) continue;
951
849952 const match = line.match(rule.pattern);
850953 if (!match) continue;
851954
855958 // taking the first quoted string on the line instead would pick up a
856959 // preceding key name (`config["password"] ?? "hunter2"`) and judge the
857960 // wrong token.
961 //
962 // The two identifier-keyed rules above now capture as well, for the same
963 // reason in a shape that actually occurs. On
964 // auth: { username: 'janedoe', password: 's00pers3cret' }
965 // the fallback picked `janedoe` — the first quoted string, a preceding
966 // VALUE rather than a key — judged it too short, and reported nothing.
967 // A real password went unreported because another field sat to its left.
968 // Verbatim from axios's own documentation, so it is not a contrived
969 // line; any object literal with a field before the credential has it.
858970 const candidate = match[1] ?? line.match(/["']([^"']+)["']/)?.[1];
859971 if (!candidate || !looksLikeRealSecret(candidate)) continue;
860972 }
9711083 // per subprocess is why this number is small; see candidateSecurityFiles.
9721084 const SCAN_FILE_LIMIT = 100;
9731085 const eligibleFiles = filePaths.filter((f) =>
974 /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|sh|yaml|yml|json)$/.test(f)
1086 // Documentation and config formats are scanned too. They were not, and
1087 // that is a whole class of false NEGATIVE: a live key pasted into a
1088 // README, a Terraform var file or an .ini was invisible to every rule.
1089 // The GateTest engine found the identical `.md` gap in its own secrets
1090 // module the same day, and only because it planted a real key as a
1091 // positive control — a favourable comparison was concealing it.
1092 //
1093 // Safe to widen now that syntax-bound rules declare `appliesTo`: `eval`
1094 // and `innerHTML` cannot fire on a Markdown file, while the credential,
1095 // AWS-key and private-key rules — which are language-agnostic and are
1096 // exactly the ones that matter in a README — can.
1097 /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|php|sh|bash|zsh|yaml|yml|json|md|markdown|toml|ini|cfg|conf|properties|tf|tfvars|xml|gradle|txt)$/.test(f)
9751098 );
9761099 // ONE `git grep -n` returns every candidate line in the whole tree, so the
9771100 // per-file read disappears and coverage becomes complete. The scanner used
10311154 // Scored on EVERY issue, never on the listed subset — the cap is a
10321155 // presentation limit and must not change the verdict.
10331156 score: scoreSecurityIssues(issues),
1157 scoreSaturated: securityScoreSaturated(issues),
10341158 issues: summary.listed,
10351159 findings: summary.findings,
10361160 coverage: {
10831207 const fallbackSummary = summariseFindings(issues);
10841208 return {
10851209 score: scoreSecurityIssues(issues),
1210 scoreSaturated: securityScoreSaturated(issues),
10861211 issues: fallbackSummary.listed,
10871212 findings: fallbackSummary.findings,
10881213 coverage: {
11091234 medium: { each: 3, ruleCap: 20 },
11101235} as const;
11111236
1237/**
1238 * Has the critical penalty alone exhausted the entire score budget?
1239 *
1240 * Criticals cost 25 each and are deliberately uncapped — forty leaked secrets
1241 * must not hide behind four. The consequence is that any repository with four
1242 * or more scores exactly 0, and the number then stops moving: measured, the
1243 * curve is 6 -> 0, 4 -> 0, 2 -> 0, 1 -> 15, 0 -> 40.
1244 *
1245 * So `security.score` is a three-state indicator wearing a 0-100 label, and a
1246 * reader watching it cannot tell "we removed three false criticals" from
1247 * "nothing changed". That happened: three false criticals were removed from a
1248 * real report and the score stayed 0, which reads as no improvement.
1249 *
1250 * The uncapped design is right and is not changed here. What changes is that
1251 * the report says when the number has stopped being a measurement, so a
1252 * caller knows to read `findings.bySeverity` instead of the score.
1253 */
1254export function securityScoreSaturated(issues: SecurityIssue[]): boolean {
1255 const criticals = issues.filter((i) => i.severity === "critical").length;
1256 return criticals * SECURITY_PENALTIES.critical.each >= 100;
1257}
1258
11121259/**
11131260 * Score from issue list, penalty grouped and capped per rule. Exported so
11141261 * computeImprovements can attribute exactly what each rule costs.
11151262
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts