diff --git a/plugin/scripts/guard-hook.mjs b/plugin/scripts/guard-hook.mjs index 0802ce7..8eb6400 100644 --- a/plugin/scripts/guard-hook.mjs +++ b/plugin/scripts/guard-hook.mjs @@ -5812,28 +5812,41 @@ function cursorHooksPath(homedir2) { function redacted(kind, hint) { return hint ? `[REDACTED:${kind}:${hint}]` : `[REDACTED:${kind}]`; } -var AWS_ACCESS_KEY_ID = /\b((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b/g; +var LEAD2 = String.raw`(?:\b|(?<=\\[nrtfb])|(?<=\\u[0-9A-Fa-f]{4})|(?<=\x1b\[[0-9;:?]{0,32}[@-~])|(?<=\\(?:u001[bB]|x1[bB]|033)\[[0-9;:?]{0,32}[@-~])|(?<=\x1b\([0-9A-Za-z])|(?<=\\(?:u001[bB]|x1[bB]|033)\([0-9A-Za-z]))`; +function led(source, flags) { + return new RegExp(`${LEAD2}${source}`, flags); +} +var AWS_ACCESS_KEY_ID = led( + String.raw`((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b`, + "g" +); var AWS_SECRET_ACCESS_KEY = new RegExp( `((?:aws[_-]?)?secret[_-]?access[_-]?key)(["']?\\s*[=:]\\s*["']?)((?![0-9a-f]{40}(?![A-Za-z0-9/+=]))[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])`, "gi" ); -var OPENAI_KEY = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b/g; -var GITHUB_TOKEN = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g; -var SLACK_TOKEN = /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g; -var BEARER_TOKEN = /\bBearer\s+([A-Za-z0-9._~+/-]{8,})=*/gi; -var CONNECTION_STRING = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)/gi; -var JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g; +var OPENAI_KEY = led(String.raw`sk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b`, "g"); +var GITHUB_TOKEN = led( + String.raw`(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|github_pat_[A-Za-z0-9_]{22,255}\b)`, + "g" +); +var SLACK_TOKEN = led(String.raw`xox[baprs]-[A-Za-z0-9-]{10,255}\b`, "g"); +var BEARER_TOKEN = led(String.raw`Bearer\s+([A-Za-z0-9._~+/-]{8,})=*`, "gi"); +var CONNECTION_STRING = led( + String.raw`(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)`, + "gi" +); +var JWT = led(String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`, "g"); var PEM_PRIVATE_KEY = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]{0,8192}?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g; -var BASIC_AUTH_URL = /\b(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])/gi; +var BASIC_AUTH_URL = led(String.raw`(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])`, "gi"); var ENV_SECRET_ASSIGNMENT = (() => { const sentinel2 = String.fromCharCode(57344); const key = "([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN|CREDENTIALS?)[A-Z0-9_]*)"; const value = `(?:"([^"${sentinel2}]{4,})"|'([^'${sentinel2}]{4,})'|([^\\s"',;${sentinel2}]{4,}))`; - return new RegExp(`\\b${key}(["']?\\s*[=:]\\s*)${value}`, "gi"); + return new RegExp(`${LEAD2}${key}(["']?\\s*[=:]\\s*)${value}`, "gi"); })(); -var EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b/g; -var SSN = /\b(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g; -var CREDIT_CARD = /\b\d{13,19}\b|\b\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b/g; +var EMAIL = led(String.raw`[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b`, "g"); +var SSN = led(String.raw`(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b`, "g"); +var CREDIT_CARD = led(String.raw`(?:\d{13,19}\b|\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b)`, "g"); var PATTERNS = [ { id: "aws-access-key-id", diff --git a/plugin/scripts/guard-scan.mjs b/plugin/scripts/guard-scan.mjs index 72743c0..69a83e5 100644 --- a/plugin/scripts/guard-scan.mjs +++ b/plugin/scripts/guard-scan.mjs @@ -8605,28 +8605,41 @@ function redactSecrets(input) { function redacted(kind, hint) { return hint ? `[REDACTED:${kind}:${hint}]` : `[REDACTED:${kind}]`; } -var AWS_ACCESS_KEY_ID = /\b((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b/g; +var LEAD2 = String.raw`(?:\b|(?<=\\[nrtfb])|(?<=\\u[0-9A-Fa-f]{4})|(?<=\x1b\[[0-9;:?]{0,32}[@-~])|(?<=\\(?:u001[bB]|x1[bB]|033)\[[0-9;:?]{0,32}[@-~])|(?<=\x1b\([0-9A-Za-z])|(?<=\\(?:u001[bB]|x1[bB]|033)\([0-9A-Za-z]))`; +function led(source, flags) { + return new RegExp(`${LEAD2}${source}`, flags); +} +var AWS_ACCESS_KEY_ID = led( + String.raw`((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b`, + "g" +); var AWS_SECRET_ACCESS_KEY = new RegExp( `((?:aws[_-]?)?secret[_-]?access[_-]?key)(["']?\\s*[=:]\\s*["']?)((?![0-9a-f]{40}(?![A-Za-z0-9/+=]))[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])`, "gi" ); -var OPENAI_KEY = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b/g; -var GITHUB_TOKEN = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g; -var SLACK_TOKEN = /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g; -var BEARER_TOKEN = /\bBearer\s+([A-Za-z0-9._~+/-]{8,})=*/gi; -var CONNECTION_STRING = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)/gi; -var JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g; +var OPENAI_KEY = led(String.raw`sk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b`, "g"); +var GITHUB_TOKEN = led( + String.raw`(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|github_pat_[A-Za-z0-9_]{22,255}\b)`, + "g" +); +var SLACK_TOKEN = led(String.raw`xox[baprs]-[A-Za-z0-9-]{10,255}\b`, "g"); +var BEARER_TOKEN = led(String.raw`Bearer\s+([A-Za-z0-9._~+/-]{8,})=*`, "gi"); +var CONNECTION_STRING = led( + String.raw`(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)`, + "gi" +); +var JWT = led(String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`, "g"); var PEM_PRIVATE_KEY = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]{0,8192}?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g; -var BASIC_AUTH_URL = /\b(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])/gi; +var BASIC_AUTH_URL = led(String.raw`(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])`, "gi"); var ENV_SECRET_ASSIGNMENT = (() => { const sentinel2 = String.fromCharCode(57344); const key = "([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN|CREDENTIALS?)[A-Z0-9_]*)"; const value = `(?:"([^"${sentinel2}]{4,})"|'([^'${sentinel2}]{4,})'|([^\\s"',;${sentinel2}]{4,}))`; - return new RegExp(`\\b${key}(["']?\\s*[=:]\\s*)${value}`, "gi"); + return new RegExp(`${LEAD2}${key}(["']?\\s*[=:]\\s*)${value}`, "gi"); })(); -var EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b/g; -var SSN = /\b(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g; -var CREDIT_CARD = /\b\d{13,19}\b|\b\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b/g; +var EMAIL = led(String.raw`[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b`, "g"); +var SSN = led(String.raw`(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b`, "g"); +var CREDIT_CARD = led(String.raw`(?:\d{13,19}\b|\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b)`, "g"); var PATTERNS = [ { id: "aws-access-key-id", diff --git a/src/__tests__/scan-leak-regression.test.ts b/src/__tests__/scan-leak-regression.test.ts index 322b3f7..7371258 100644 --- a/src/__tests__/scan-leak-regression.test.ts +++ b/src/__tests__/scan-leak-regression.test.ts @@ -19,6 +19,7 @@ import { compileCatalog } from "../core/rules.js"; import { aggregateScan, redactForReport, type ScanCorpus } from "../core/scan-report.js"; import type { GuardRule } from "../core/types.js"; import { session, toolUse, turn } from "./scan-fixtures.js"; +import { ESCAPE_BOUNDARY_FIXTURES } from "./scrub-corpus.js"; const META: ReportMeta = { version: "0.1.0", generatedAt: new Date("2026-10-01T09:00:00.000Z") }; @@ -290,6 +291,16 @@ describe("end to end through the HTML and JSON surfaces", () => { }); }); +describe("a secret after an escape sequence or a control code", () => { + // The report keeps the secret's own label: the format pass catches it, not only the + // generic fallback that masks a random-looking run together with its neighbours. + it.each(ESCAPE_BOUNDARY_FIXTURES)("$name", ({ input, secret, kind }) => { + const out = redactForReport(input); + expect(out).not.toContain(secret); + expect(out).toContain(`[REDACTED:secret:${kind}`); + }); +}); + describe("kept readable on purpose", () => { it.each([ ["a branch pushed as a plain operand", "git push origin acme-billing-fix"], diff --git a/src/__tests__/scrub-corpus.ts b/src/__tests__/scrub-corpus.ts index 522bf2b..2967395 100644 --- a/src/__tests__/scrub-corpus.ts +++ b/src/__tests__/scrub-corpus.ts @@ -264,6 +264,75 @@ export const INTERACTION_FIXTURES: readonly InteractionFixture[] = [ }, ]; +/** + * Secrets that sit right after an escape sequence or a terminal control code. A word + * boundary cannot see the start of the secret there: the character before it is the + * `n` of an escaped newline, the `m` of a colour code, or a hex digit of `\uXXXX`. + * + * Each entry is `[description, secret, placeholder kind]`. The GitHub token and the + * JWT are assembled at runtime; the AWS key is AWS's own documentation example. + */ +export const ESCAPE_BOUNDARY_SECRETS: ReadonlyArray = [ + ["a GitHub token", `${["gh", "p_"].join("")}${"R2l0SHViVG9rZW4".padEnd(36, "q")}`, "github"], + ["an AWS access key", "AKIAIOSFODNN7EXAMPLE", "aws"], + [ + "a JWT", + `${["ey", "J"].join("")}hbGciOiJIUzI1NiJ9.${["ey", "J"].join("")}zdWIiOiIxMjMifQ.c2lnbmF0dXJlLXZhbHVl`, + "jwt", + ], +]; + +/** Each position where the start of a secret is hidden from a word boundary. */ +export const ESCAPE_BOUNDARY_CONTEXTS: ReadonlyArray< + readonly [string, (secret: string) => string] +> = [ + // What a JSON string holds for a two-line output: a backslash and an `n`, not a newline. + ["line 2 of a JSON-escaped output", (s) => `{"output":"line one\\n${s}\\nline three"}`], + ["after an escaped tab", (s) => `name\\t${s}`], + ["after an escaped CRLF", (s) => `first\\r\\n${s}`], + ["after a \\uXXXX escape", (s) => `key\\u003d${s}`], + ["after a raw ANSI colour code", (s) => `\x1b[32m${s}\x1b[0m`], + ["after an escaped ANSI colour code", (s) => `\\u001b[1;32m${s}\\u001b[0m`], + ["after an ANSI code escaped for a shell", (s) => `\\x1b[31m${s}`], + ["in a JSON argument after a newline", (s) => JSON.stringify({ query: `find\n${s}` })], + ["after an erase-line code, as a spinner prints", (s) => `\r\x1b[K${s}`], + ["after an erase-whole-line code", (s) => `\x1b[2K${s}`], + ["after an erase and a cursor move", (s) => `\x1b[2K\x1b[1G${s}`], + ["after an escaped erase-line code", (s) => JSON.stringify({ o: `progress\r\x1b[K${s}` })], + ["after a colon-form colour code", (s) => `\x1b[38:5:208m${s}`], + ["after a character-set switch", (s) => `\x1b(B${s}`], + ["after a JSON-escaped form feed", (s) => JSON.stringify({ o: `a\f${s}` })], + ["after a JSON-escaped backspace", (s) => JSON.stringify({ o: `a\b${s}` })], +]; + +/** A secret in an escape-boundary position: it must be redacted once, as `kind`. */ +export interface EscapeBoundaryFixture { + readonly name: string; + readonly input: string; + readonly secret: string; + readonly kind: string; +} + +/** Every secret in every escape-boundary context. */ +export const ESCAPE_BOUNDARY_FIXTURES: readonly EscapeBoundaryFixture[] = + ESCAPE_BOUNDARY_SECRETS.flatMap(([what, secret, kind]) => + ESCAPE_BOUNDARY_CONTEXTS.map(([where, wrap]) => ({ + name: `${what} ${where}`, + input: wrap(secret), + secret, + kind, + })), + ); + +/** + * A secret glued to a word is still not a secret on its own: the escape-aware start + * only adds positions after an escape or a control code, never inside a word. + */ +export const GLUED_TO_A_WORD: readonly string[] = [ + "xAKIAIOSFODNN7EXAMPLE", + `prefix${["gh", "p_"].join("")}${"R2l0SHViVG9rZW4".padEnd(36, "q")}`, +]; + /** * Every input, flattened. Order is stable so a failing * case is identified the same way on every run. @@ -272,6 +341,8 @@ export const CORPUS: readonly string[] = [ ...POSITIVE_FIXTURES.map((f) => f.input), ...NEGATIVE_FIXTURES.map((f) => f.input), ...INTERACTION_FIXTURES.map((f) => f.input), + ...ESCAPE_BOUNDARY_FIXTURES.map((f) => f.input), + ...GLUED_TO_A_WORD, // Engine-level edge cases. They carry no catalog expectation of their own, but // the two scrubbers must still agree on them exactly. "", diff --git a/src/__tests__/scrub.test.ts b/src/__tests__/scrub.test.ts index d9c96d7..a1a2995 100644 --- a/src/__tests__/scrub.test.ts +++ b/src/__tests__/scrub.test.ts @@ -10,7 +10,13 @@ import { describe, expect, it } from "vitest"; import { PATTERN_IDS, scrubText } from "../core/scrub.js"; -import { INTERACTION_FIXTURES, NEGATIVE_FIXTURES, POSITIVE_FIXTURES } from "./scrub-corpus.js"; +import { + ESCAPE_BOUNDARY_FIXTURES, + GLUED_TO_A_WORD, + INTERACTION_FIXTURES, + NEGATIVE_FIXTURES, + POSITIVE_FIXTURES, +} from "./scrub-corpus.js"; const SENTINEL = String.fromCharCode(0xe000); @@ -87,6 +93,35 @@ describe("correctness — benign strings survive untouched", () => { }); }); +describe("correctness — a secret after an escape sequence or a control code", () => { + // In JSON-escaped text or coloured terminal output the character before a secret is a + // letter or a hex digit (`\n`, `ESC[32m`, `\u001b`), so a plain word boundary does not + // hold there. Each case must still be redacted, once, with its own placeholder. + it.each(ESCAPE_BOUNDARY_FIXTURES)("$name", ({ input, secret, kind }) => { + const result = scrubText(input); + expect(result.text).not.toContain(secret); + expect(result.text).toContain(`[REDACTED:secret:${kind}`); + expect(result.total).toBe(1); + }); + + it.each(GLUED_TO_A_WORD)("a secret glued to a word stays as it is: %s", (input) => { + expect(scrubText(input)).toEqual({ text: input, redactions: {}, total: 0 }); + }); + + it("stays linear on long runs of escapes and unterminated control codes", () => { + const inputs = [ + "\\n".repeat(50_000), + "\x1b[".repeat(20_000), + `\x1b[${"1;".repeat(50_000)}`, + "\\u001b[".repeat(20_000), + "\\u00".repeat(40_000), + ]; + const start = performance.now(); + for (const input of inputs) scrubText(input); + expect(performance.now() - start).toBeLessThan(2_000); + }); +}); + describe("engine semantics carried over from the source of truth", () => { it("an empty string short-circuits", () => { expect(scrubText("")).toEqual({ text: "", redactions: {}, total: 0 }); diff --git a/src/core/scrub.ts b/src/core/scrub.ts index fad795c..2837415 100644 --- a/src/core/scrub.ts +++ b/src/core/scrub.ts @@ -1,3 +1,4 @@ +// cspell:words nghp mghp nrtfb /** * The secret scrubber: the `standard` pattern set, applied to a single string. * @@ -48,6 +49,33 @@ function redacted(kind: string, hint?: string): string { return hint ? `[REDACTED:${kind}:${hint}]` : `[REDACTED:${kind}]`; } +/** + * Where a secret may start: a word boundary, or right after an escape sequence or a + * terminal control code that a word boundary cannot see. + * + * Text reaches the scrubber JSON-escaped (`line one\nghp_…`) or coloured by a terminal + * (`ESC[32mghp_…`, or `\u001b[32m…` once escaped). In each of those the character right + * before the secret is a letter (`n`, `t`, `m`, `K`) or a hex digit, so `\b` does not hold. + * Each shape keeps `\b` and adds lookbehinds for an escaped `\n` `\r` `\t` `\f` `\b`, a + * `\uXXXX` escape, any ANSI CSI sequence (`ESC[` parameters, then any final byte `@` to + * `~`: colour codes including the colon form, erase-line, cursor moves) raw or escaped as + * `\u001b[`, `\x1b[` or `\033[`, and a character-set switch (`ESC(B`), raw or escaped. + * + * It only adds match positions after one of those sequences, so a secret glued to a word + * is still not matched, and the text is scrubbed where it lies, so each placeholder lands + * at the secret's own offset. Each lookbehind is bounded (at most 8 characters, or 32 + * parameter characters of a CSI sequence). + * + * Not covered: URL-encoded characters (`%0A`), hex or octal escapes in a string literal + * (`\x0a`, `\012`), and other encodings that would need decoding first. + */ +const LEAD = String.raw`(?:\b|(?<=\\[nrtfb])|(?<=\\u[0-9A-Fa-f]{4})|(?<=\x1b\[[0-9;:?]{0,32}[@-~])|(?<=\\(?:u001[bB]|x1[bB]|033)\[[0-9;:?]{0,32}[@-~])|(?<=\x1b\([0-9A-Za-z])|(?<=\\(?:u001[bB]|x1[bB]|033)\([0-9A-Za-z]))`; + +/** A pattern that starts at `LEAD`. `source` is written without a leading `\b`. */ +function led(source: string, flags: string): RegExp { + return new RegExp(`${LEAD}${source}`, flags); +} + // ── Secrets ────────────────────────────────────────────────────────────────── /** @@ -55,8 +83,10 @@ function redacted(kind: string, hint?: string): string { * the label carries NO part of the id, not even the last-4 — those four characters * fingerprint the key, and a shared scan report should disclose nothing that identifies it. */ -const AWS_ACCESS_KEY_ID = - /\b((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b/g; +const AWS_ACCESS_KEY_ID = led( + String.raw`((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b`, + "g", +); /** * AWS secret access key — 40-char base64-ish value, **context-anchored** to an @@ -73,14 +103,16 @@ const AWS_SECRET_ACCESS_KEY = new RegExp( ); /** OpenAI-style key: `sk-` + 20-64 url-safe chars (covers `sk-proj-` too). */ -const OPENAI_KEY = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b/g; +const OPENAI_KEY = led(String.raw`sk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b`, "g"); /** GitHub PAT / OAuth / app tokens: `ghp_` `gho_` `ghu_` `ghs_` `ghr_` `github_pat_`. */ -const GITHUB_TOKEN = - /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g; +const GITHUB_TOKEN = led( + String.raw`(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|github_pat_[A-Za-z0-9_]{22,255}\b)`, + "g", +); /** Slack tokens: `xoxb-`/`xoxp-`/`xoxa-`/`xoxr-`/`xoxs-` + dotted segments. */ -const SLACK_TOKEN = /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g; +const SLACK_TOKEN = led(String.raw`xox[baprs]-[A-Za-z0-9-]{10,255}\b`, "g"); /** * HTTP Bearer token in an Authorization-style value. **Case-insensitive** — raw @@ -89,7 +121,7 @@ const SLACK_TOKEN = /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g; * an incidental prose "bearer <8+ token-ish chars>" is also redacted: losing one * benign word beats leaking a real lowercase bearer token. */ -const BEARER_TOKEN = /\bBearer\s+([A-Za-z0-9._~+/-]{8,})=*/gi; +const BEARER_TOKEN = led(String.raw`Bearer\s+([A-Za-z0-9._~+/-]{8,})=*`, "gi"); /** * Credentialed connection string for common datastores. The whole `user:pass@host` @@ -100,11 +132,13 @@ const BEARER_TOKEN = /\bBearer\s+([A-Za-z0-9._~+/-]{8,})=*/gi; * `ssw0rd` and `pa/ss` misses entirely, persisting the full credential — but the captured * host is used only for that anchoring, never echoed into the placeholder. */ -const CONNECTION_STRING = - /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)/gi; +const CONNECTION_STRING = led( + String.raw`(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)`, + "gi", +); /** JWT: three base64url segments. Claims are NEVER stored in the placeholder. */ -const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g; +const JWT = led(String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`, "g"); /** PEM private-key block (RSA / EC / OPENSSH / generic). */ const PEM_PRIVATE_KEY = @@ -115,7 +149,7 @@ const PEM_PRIVATE_KEY = * Same robust userinfo shape as `CONNECTION_STRING`, so a password containing * `@` or `/` is fully consumed rather than leaving a residual `ssw0rd@`. */ -const BASIC_AUTH_URL = /\b(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])/gi; +const BASIC_AUTH_URL = led(String.raw`(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])`, "gi"); /** * `.env`-style secret assignment: a KEY whose name implies a secret, followed by @@ -135,16 +169,16 @@ const ENV_SECRET_ASSIGNMENT = (() => { // sentinel so an already-inserted placeholder is never re-consumed. const value = `(?:"([^"${sentinel}]{4,})"` + `|'([^'${sentinel}]{4,})'` + `|([^\\s"',;${sentinel}]{4,}))`; - return new RegExp(`\\b${key}(["']?\\s*[=:]\\s*)${value}`, "gi"); + return new RegExp(`${LEAD}${key}(["']?\\s*[=:]\\s*)${value}`, "gi"); })(); // ── PII ────────────────────────────────────────────────────────────────────── /** Email address. */ -const EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b/g; +const EMAIL = led(String.raw`[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b`, "g"); /** US Social Security Number (`123-45-6789` / `123 45 6789`). */ -const SSN = /\b(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g; +const SSN = led(String.raw`(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b`, "g"); /** * Candidate card number (Luhn-validated below). Two non-bridging shapes: @@ -152,7 +186,7 @@ const SSN = /\b(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g; * CONSISTENT separator (the `\1` backreference is what stops the pattern * bridging two unrelated numbers across a stray space or dash). */ -const CREDIT_CARD = /\b\d{13,19}\b|\b\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b/g; +const CREDIT_CARD = led(String.raw`(?:\d{13,19}\b|\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b)`, "g"); /** * The ordered catalog — the `standard` patterns, in application order.