diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 322fc696b..b6cfc6c01 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -298,6 +298,33 @@ const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0- * this stays cheap on a multi-megabyte buffer. */ const UUID_CONTEXT_CHARS = 40; +/** + * True when an `internal.hostname` span is really the tail of a dotenv + * FILENAME (`.env.local`, `.env.staging`, `.env.prod`) rather than a host. + * + * The hostname pattern ends in `.local|.prod|.staging|…`, so `.env.local` + * matches on `env.local` — a false positive on one of the most commonly + * committed filenames there is. It arrives via npm scripts + * (`--env-file=.env.local`), READMEs, `.gitignore` and setup docs, i.e. on + * ordinary branches that leak nothing, which is the noise that teaches people + * to skim past MEDIUM findings. + * + * Deliberately narrow, in the same spirit as `insideUuid`: it exempts ONLY a + * span beginning `env.` that is immediately preceded by a dot — the literal + * `.env.` form. A real host still reports, `api.corp` and + * `build-7.internal` included, and so does `myenv.local`, which is not a + * dotenv file. + */ +export function isDotenvFilename(match: RegExpExecArray): boolean { + const input = match.input ?? ""; + const span = match[1] ?? match[0]; + if (!/^env\./i.test(span)) return false; + // Mirror the engine: capture group 1 when present, else the whole match. + const spanStartInMatch = match[1] !== undefined ? match[0].indexOf(match[1]) : 0; + const spanStart = match.index + Math.max(0, spanStartInMatch); + return spanStart > 0 && input[spanStart - 1] === "."; +} + /** * True when the matched span sits ENTIRELY inside a UUID. * @@ -751,6 +778,8 @@ export const PATTERNS: RedactPattern[] = [ category: "internal", description: "Internal hostname (*.internal/.corp/.local/.prod/.staging)", regex: /\b([a-z0-9][a-z0-9\-]*\.(?:internal|corp|local|lan|prod|staging))\b/i, + // `.env.local` and friends are filenames, not hosts. See isDotenvFilename. + validate: (_span, match) => !isDotenvFilename(match), }, { id: "internal.url_private", diff --git a/test/redact-dotenv-filename-false-positive.test.ts b/test/redact-dotenv-filename-false-positive.test.ts new file mode 100644 index 000000000..9bb4eaeba --- /dev/null +++ b/test/redact-dotenv-filename-false-positive.test.ts @@ -0,0 +1,77 @@ +/** + * internal.hostname vs dotenv FILENAMES. + * + * `.env.local` ends in `.local`, so the internal-hostname pattern matches on + * `env.local` and reports a filename as a leaked internal host. This is not an + * exotic collision: `--env-file=.env.local` in an npm script, `.env.staging` in + * a README, `.env.prod` in a .gitignore. It fires on branches that leak + * nothing, and a scanner that cries wolf on package.json is a scanner people + * learn to skim past — which costs far more than the finding was ever worth. + * + * The guard has to stay narrow, so this file pins BOTH directions. The + * negative controls are the point: an exemption written as "any span ending + * .local" would pass the dotenv half while quietly gutting the pattern for + * every real host. + */ +import { describe, test, expect } from "bun:test"; +import { scan } from "../lib/redact-engine"; +import { isDotenvFilename } from "../lib/redact-patterns"; + +const flagsHost = (s: string): boolean => + scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === "internal.hostname"); + +describe("internal.hostname — real internal hosts stay flagged", () => { + const REAL_HOSTS: [string, string][] = [ + [".internal", "curl http://build-7.internal/health"], + [".corp", "ssh jump.corp"], + [".local", "ping printer.local"], + [".lan", "nas.lan is down"], + [".prod", "deploy to shipping.prod now"], + [".staging", "hit api.staging first"], + ["multi-label, dotted prefix", "host: api.corp.local"], + ["not a dotenv file", "myenv.local resolves"], + ["env as a real subdomain", "https://env.prod//status"], + ]; + for (const [label, input] of REAL_HOSTS) { + test(label, () => { + expect(flagsHost(input)).toBe(true); + }); + } +}); + +describe("internal.hostname — dotenv filenames are not hosts", () => { + const DOTENV: [string, string][] = [ + ["npm script", '"dev": "tsx --env-file=.env.local scripts/x.ts"'], + ["bare filename", "copy .env.example to .env.local"], + ["staging", "secrets live in .env.staging"], + ["prod", "never commit .env.prod"], + ["gitignore line", ".env.local"], + ["path prefix", "apps/web/.env.local"], + ]; + for (const [label, input] of DOTENV) { + test(label, () => { + expect(flagsHost(input)).toBe(false); + }); + } +}); + +describe("isDotenvFilename — unit", () => { + const matchFor = (input: string): RegExpExecArray => { + const re = /\b([a-z0-9][a-z0-9\-]*\.(?:internal|corp|local|lan|prod|staging))\b/i; + const m = re.exec(input); + if (!m) throw new Error(`pattern did not match: ${input}`); + return m; + }; + + test("exempts a dot-prefixed env filename", () => { + expect(isDotenvFilename(matchFor("--env-file=.env.local"))).toBe(true); + }); + + test("does not exempt env.local without the leading dot", () => { + expect(isDotenvFilename(matchFor("host env.local here"))).toBe(false); + }); + + test("does not exempt a dot-prefixed host that is not env", () => { + expect(isDotenvFilename(matchFor("api.corp.local"))).toBe(false); + }); +});