CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(health): vendor-shaped credential detection — twelve families that were invisible #5616

MergedXSccantynz wants to mergefeat/vendor-credential-rulesmainopened 1d ago
2 changed files+148−1
Addedsrc/__tests__/vendor-credentials.test.ts+110−0View fileUnifiedSplit
1/**
2 * Vendor-shaped credentials: the value's own shape IS the finding.
3 *
4 * Measured 2026-09-01: of fourteen common credential families, the scanner
5 * detected TWO — AWS access keys and RSA/EC private keys. A GitHub token, a
6 * Stripe live key, a Slack bot token, a Google API key, an OpenAI or
7 * Anthropic key, a GitLab PAT, an npm token, a SendGrid key, and OPENSSH/
8 * DSA/PGP private keys were all invisible, in source and documentation
9 * alike. OPENSSH has been ssh-keygen's DEFAULT output since 7.8.
10 *
11 * Two properties distinguish this family from the generic `password = "…"`
12 * rules, and both are asserted here:
13 *
14 * - They fire ANYWHERE, prose included. A live key in a README is a leaked
15 * key; that is what widening the file set was for.
16 * - A reassuring variable NAME cannot silence them, because they match the
17 * value's shape and never consult the identifier. This is the mirror of
18 * the CELITECH_TOKEN_URL bug (fired BECAUSE of a name) — the GateTest
19 * engine checked its own scanner for the inverse and prompted this one.
20 *
21 * Every fixture is assembled at runtime. This is not style: this repo's own
22 * push gate and GitHub's push protection have each rejected such fixtures
23 * written as literals, correctly, the same day.
24 */
25import { describe, expect, it } from "bun:test";
26import { detectSecurityIssuesInLine } from "../lib/intelligence";
27
28const rules = (filePath: string, line: string) =>
29 detectSecurityIssuesInLine({ filePath, line, lineNumber: 1 }).map((i) => i.rule);
30
31// Runtime-assembled, realistic-shaped, non-functional.
32const KEYS: Record<string, string> = {
33 github: "ghp_" + "A".repeat(36),
34 githubFine: "github_pat_" + "B".repeat(22),
35 stripeLive: "sk_live_" + "C".repeat(24),
36 slack: "xoxb-" + "1234567890-1234567890-" + "D".repeat(24),
37 google: "AIza" + "E".repeat(35),
38 openai: "sk-proj-" + "F".repeat(40),
39 anthropic: "sk-ant-api03-" + "G".repeat(40),
40 gitlab: "glpat-" + "H".repeat(20),
41 npm: "npm_" + "I".repeat(36),
42 sendgrid: "SG." + "J".repeat(22) + "." + "K".repeat(43),
43};
44
45describe("a reassuring name cannot silence a vendor-shaped value", () => {
46 for (const [vendor, key] of Object.entries(KEYS)) {
47 it(`${vendor} key under 'placeholder_secret' still fires`, () => {
48 expect(rules("src/a.ts", `const placeholder_secret = "${key}";`)).toContain(
49 "no-vendor-credentials"
50 );
51 });
52 }
53});
54
55describe("vendor-shaped credentials fire in documentation", () => {
56 it("a GitHub token in a README is a leak, not an example", () => {
57 expect(rules("README.md", `export GH_TOKEN=${KEYS.github}`)).toContain(
58 "no-vendor-credentials"
59 );
60 });
61
62 it("a Stripe live key in a docs page is a leak", () => {
63 expect(rules("docs/setup.md", `stripe_key: "${KEYS.stripeLive}"`)).toContain(
64 "no-vendor-credentials"
65 );
66 });
67});
68
69describe("templated vendor prefixes are not credentials", () => {
70 it("sk_live_YOUR_KEY_HERE cannot reach the length requirement", () => {
71 // The random portion's charset deliberately excludes `_` where the
72 // vendor's does, so a docs template breaks the run before 20 chars.
73 expect(rules("src/a.ts", 'const k = "sk_live_YOUR_KEY_HERE";')).toEqual([]);
74 });
75
76 it("a bare prefix in prose does not fire", () => {
77 expect(rules("README.md", "Keys look like ghp_ followed by 36 characters.")).toEqual([]);
78 });
79});
80
81describe("modern private-key headers are recognised", () => {
82 for (const kind of ["OPENSSH", "DSA", "PGP"]) {
83 it(`${kind} private key header fires`, () => {
84 const header =
85 kind === "PGP"
86 ? ["-----BEGIN", "PGP", "PRIVATE", "KEY", "BLOCK-----"].join(" ")
87 : ["-----BEGIN", kind, "PRIVATE", "KEY-----"].join(" ");
88 expect(rules("deploy/server.key", header)).toContain("no-private-keys");
89 });
90 }
91});
92
93describe("no rule pattern contains control characters", () => {
94 it("the rules source is free of C0 bytes", async () => {
95 // How the whole family shipped dead on the first attempt: a tooling step
96 // turned the \b word boundaries into literal 0x08 BACKSPACE characters.
97 // The file compiled, the regexes were syntactically valid, and every one
98 // matched only lines containing a backspace — i.e. nothing, ever. The
99 // standalone probe of "the same" regex worked because it was typed fresh.
100 // A rule that cannot match is indistinguishable from a rule with no bug
101 // to find, so the absence of control bytes is asserted for the entire
102 // module, not just these rules.
103 const src = await Bun.file("src/lib/intelligence.ts").text();
104 const bad = [...src].filter((c) => {
105 const n = c.charCodeAt(0);
106 return (n < 32 && n !== 9 && n !== 10 && n !== 13) || n === 127;
107 });
108 expect(bad).toEqual([]);
109 });
110});
Modifiedsrc/lib/intelligence.ts+38−1View fileUnifiedSplit
523523 // environment where it is not.
524524 { 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 },
525525 { pattern: /(?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/, severity: "critical", message: "Possible AWS access key", rule: "no-aws-keys" },
526 { pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, severity: "critical", message: "Private key in source code", rule: "no-private-keys" },
526 // OPENSSH is the DEFAULT format ssh-keygen has produced since 7.8, and it
527 // was not matched — nor were DSA or PGP blocks. A private key is the least
528 // ambiguous finding this scanner can make and three of its five common
529 // headers were invisible.
530 { pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/, severity: "critical", message: "Private key in source code", rule: "no-private-keys" },
531
532 // Vendor-shaped credentials.
533 //
534 // Measured 2026-09-01: of fourteen common credential families, this scanner
535 // detected TWO — AWS access keys and RSA/EC private keys. A GitHub token, a
536 // Stripe live key, a Slack bot token, a Google API key, an OpenAI or
537 // Anthropic key, a GitLab PAT, an npm token and a SendGrid key were all
538 // completely undetected, in source as well as in documentation.
539 //
540 // These are the least ambiguous findings possible: the prefix and charset
541 // ARE the credential, so unlike the generic `password = "…"` family they
542 // need no value heuristic and no language or prose scoping. A vendor key in
543 // a README is a leaked vendor key.
544 //
545 // They also close the mirror of the CELITECH_TOKEN_URL bug, raised by the
546 // GateTest engine: because these match the VALUE's own shape, a reassuring
547 // variable name cannot silence them. `placeholder_secret`, `your_api_key`,
548 // `changeme_token` holding a real key all report. That bug fired because of
549 // a name; this class cannot be quieted by one.
550 //
551 // Charsets deliberately exclude `_` and `-` inside the random portion where
552 // the vendor does, so a templated `sk_live_YOUR_KEY_HERE` cannot reach the
553 // length requirement and match.
554 { pattern: /\bgh[pousr]_[A-Za-z0-9]{36}\b/, severity: "critical", message: "GitHub token committed", rule: "no-vendor-credentials" },
555 { pattern: /\bgithub_pat_[A-Za-z0-9]{22,}\b/, severity: "critical", message: "GitHub fine-grained token committed", rule: "no-vendor-credentials" },
556 { pattern: /\b[sr]k_live_[A-Za-z0-9]{20,}\b/, severity: "critical", message: "Stripe live secret key committed", rule: "no-vendor-credentials" },
557 { pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/, severity: "critical", message: "Slack token committed", rule: "no-vendor-credentials" },
558 { pattern: /\bAIza[A-Za-z0-9_-]{35}\b/, severity: "critical", message: "Google API key committed", rule: "no-vendor-credentials" },
559 { pattern: /\bsk-ant-[A-Za-z0-9-]{20,}\b/, severity: "critical", message: "Anthropic API key committed", rule: "no-vendor-credentials" },
560 { pattern: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/, severity: "critical", message: "OpenAI project key committed", rule: "no-vendor-credentials" },
561 { pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/, severity: "critical", message: "GitLab personal access token committed", rule: "no-vendor-credentials" },
562 { pattern: /\bnpm_[A-Za-z0-9]{36}\b/, severity: "critical", message: "npm access token committed", rule: "no-vendor-credentials" },
563 { pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, severity: "critical", message: "SendGrid API key committed", rule: "no-vendor-credentials" },
527564 // Injection vulnerabilities
528565 { pattern: /eval\s*\(/, severity: "high", message: "eval call — potential code injection", rule: "no-eval", appliesTo: EVAL_LANGS },
529566 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html", appliesTo: JS_TS },
530567
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts