// The source mirror's gate: does anything about to be published carry a // literal the operator denied? (`archilyzer source publish`, source.ts.) // // Three sweeps, all of them byte searches for the SAME literal list (the // denylist plus every scrub rule's left side — source.ts builds it): // - auditObjects: EVERY object in a git dir, read in one // `git cat-file --batch-all-objects --batch` stream — blobs whole, commits // and tags whole (message AND the author/committer/tagger lines), trees by // entry NAME. Reachable or not: a leftover of the private history in a // pack would be published with it, so it is read with it. // - auditFiles: every file staged beside the mirror (the raw tree, the index // pages, the tarball decompressed, the ref files, the manifest), and every // staged PATH. // - runGitleaks: the secret scanner over the mirror's history, when it is // installed (a WARNING, not a refusal, when it is not). // // THE REPORT NEVER PRINTS A LITERAL, AND NO BYTES OF THE OBJECT AROUND ONE. A // literal is named by where the operator wrote it — `denylist line 3 (len 5)`, // `scrub line 2 lhs (len 11)`, `built-in home rule (len 11)` — never by any of // its characters. A hit is named by its object (kind, id, the path when one // is known), the byte offset, and for a commit or tag the field it sits in // (author, committer, tagger, message): a context window would print what sits // NEXT to a denied name (a surname, the rest of an address), which is exactly // as private. Paths and child lines quoted in the log are masked // (`[REDACTED]`). import { spawn } from "node:child_process"; import { existsSync, statSync, accessSync, constants } from "node:fs"; import { lstat, readdir, readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { gunzipSync } from "node:zlib"; import { runChildIntoLog } from "../jobs/runChild"; /** A refusal: the step stops, publishes nothing, and says why. */ export class SourceRefusal extends Error { constructor(message: string) { super(message); this.name = "SourceRefusal"; } } // ── literals ──────────────────────────────────────────────────────────────── /** * One denied literal. `ci` literals match ASCII case-insensitively: their * `bytes` are stored folded, and are searched for in a folded copy. `from` * says where the operator wrote it (`denylist line 3`), which is how a report * names it. */ export type Literal = { bytes: Buffer; ci: boolean; from?: string }; const LOWER_A = 0x61; const UPPER_A = 0x41; const UPPER_Z = 0x5a; /** A copy of `buf` with A–Z folded to a–z (every other byte as it is). */ export function foldAscii(buf: Uint8Array): Buffer { const out = Buffer.from(buf); for (let i = 0; i < out.length; i++) { const b = out[i]; if (b >= UPPER_A && b <= UPPER_Z) out[i] = b - UPPER_A + LOWER_A; } return out; } /** * An operator file's text as lines: a UTF-8 byte-order mark at the start is * dropped and CRLF endings become LF. An editor that writes either must not * turn the first rule (and the denial it implies) into one that never matches. */ export function operatorLines(text: string): string[] { return text.replace(/^\uFEFF/, "").split("\n").map((l) => l.replace(/\r$/, "")); } /** * The denylist file: one literal per line. `i:` in front makes it ASCII * case-insensitive. Blank lines and lines starting with `#` are skipped; * surrounding whitespace is trimmed. Duplicates collapse (the first line * names it). */ export function parseDenylist(text: string): Literal[] { const out: Literal[] = []; operatorLines(text).forEach((raw, i) => { const line = raw.trim(); if (line === "" || line.startsWith("#")) return; const from = `denylist line ${i + 1}`; if (line.startsWith("i:")) { const lit = line.slice(2).trim(); if (lit) out.push({ bytes: foldAscii(Buffer.from(lit, "utf8")), ci: true, from }); } else { out.push({ bytes: Buffer.from(line, "utf8"), ci: false, from }); } }); return dedupeLiterals(out); } export function dedupeLiterals(list: Literal[]): Literal[] { const seen = new Set(); const out: Literal[] = []; for (const l of list) { const key = `${l.ci ? "i" : "x"}:${l.bytes.toString("hex")}`; if (seen.has(key) || l.bytes.length === 0) continue; seen.add(key); out.push(l); } return out; } /** * How a literal is named in a report: where it was written and its length — * never any of its characters (a first letter and a length all but spell a * short first name). */ export function literalLabel(literals: readonly Literal[], index: number): string { const l = literals[index]; return `${l.from ?? `literal ${index + 1}`} (len ${l.bytes.length}${l.ci ? ", any case" : ""})`; } // ── scanning ──────────────────────────────────────────────────────────────── export type Hit = { lit: number; offset: number; length: number }; /** Every occurrence of every literal in `buf`, by offset. */ export function scanBuffer(buf: Buffer, literals: readonly Literal[]): Hit[] { const hits: Hit[] = []; let folded: Buffer | null = null; literals.forEach((l, lit) => { let hay = buf; if (l.ci) { folded ??= foldAscii(buf); hay = folded; } let at = hay.indexOf(l.bytes); while (at !== -1) { hits.push({ lit, offset: at, length: l.bytes.length }); at = hay.indexOf(l.bytes, at + 1); } }); return hits.sort((a, b) => a.offset - b.offset || a.lit - b.lit); } /** `text` with every occurrence of every literal replaced by `[REDACTED]`. */ export function maskLiterals(text: string, literals: readonly Literal[]): string { const buf = Buffer.from(text, "utf8"); const hits = scanBuffer(buf, literals); if (hits.length === 0) return text; // Merge overlapping occurrences into runs, then splice one mark per run. const runs: Array<[number, number]> = []; for (const h of hits) { const last = runs[runs.length - 1]; if (last && h.offset <= last[1]) last[1] = Math.max(last[1], h.offset + h.length); else runs.push([h.offset, h.offset + h.length]); } const parts: Buffer[] = []; let pos = 0; for (const [a, b] of runs) { parts.push(buf.subarray(pos, a), Buffer.from("[REDACTED]")); pos = b; } parts.push(buf.subarray(pos)); return Buffer.concat(parts).toString("utf8"); } // ── the audit's result ────────────────────────────────────────────────────── export type HitKind = "blob" | "commit" | "tree" | "tag" | "file" | "gitleaks"; const KIND_ORDER: readonly HitKind[] = ["blob", "commit", "tree", "tag", "file", "gitleaks"]; export type AuditHit = { kind: HitKind; // An object id (blob/commit/tree/tag) — with the blob's path in history // once known — a path relative to the staged dir (file), or the finding // (gitleaks). Paths are masked. where: string; // A literal's index, or -1 for a gitleaks finding. lit: number; // The byte offset of the hit in the object or file (-1 for gitleaks). offset: number; // Where in the object, without its bytes: a commit's or tag's header field // (`author`, `committer`, `tagger`, …) or `message`; a tree's `entry N`. field?: string; }; export type AuditResult = { literals: number; objects: number; commits: number; files: number; gitleaks: "clean" | "skipped" | "not run"; hits: AuditHit[]; }; // Only this many hits get a line of their own: a literal every commit carries // (the gate's own planted `Co-Authored-By`) would otherwise print thousands. const LISTED = 20; export function emptyAudit(literals: number): AuditResult { return { literals, objects: 0, commits: 0, files: 0, gitleaks: "not run", hits: [] }; } function record( result: AuditResult, kind: HitKind, where: string, hits: Hit[], fieldOf?: (offset: number) => string, // A tree's hits are offsets into its joined entry NAMES, not the object: // the entry number says where, and the offset is left out. withOffset = true, ): void { for (const h of hits) { result.hits.push({ kind, where, lit: h.lit, offset: withOffset ? h.offset : -1, ...(fieldOf ? { field: fieldOf(h.offset) } : {}), }); } } /** * Which part of a commit or tag object `offset` falls in: `message` past the * blank line that ends the headers, else the header line's keyword (`author`, * `committer`, `tagger`, `tree`, `parent`, …) — a word git wrote, never the * operator's bytes. */ export function objectField(data: Buffer, offset: number): string { const end = data.indexOf("\n\n"); if (end !== -1 && offset > end) return "message"; let lineStart = data.lastIndexOf(0x0a, offset - 1) + 1; // A continuation line (a signature, a mergetag) begins with a space: walk // back to the header it continues. while (lineStart > 0 && data[lineStart] === 0x20) { lineStart = data.lastIndexOf(0x0a, lineStart - 2) + 1; } const sp = data.indexOf(0x20, lineStart); const word = data.subarray(lineStart, sp === -1 ? lineStart : sp).toString("latin1"); return /^[a-z][a-z-]{0,15}$/.test(word) ? word : "header"; } /** A tree hit's entry number (1-based) in the NUL-separated names buffer. */ function treeEntry(names: Buffer, offset: number): string { let n = 1; for (let i = names.indexOf(0, 0); i !== -1 && i < offset; i = names.indexOf(0, i + 1)) n++; return `entry ${n}`; } // ── the object walk ───────────────────────────────────────────────────────── /** The git environment a child must not inherit: it would point git elsewhere. */ export function cleanGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = { ...env }; for (const k of Object.keys(out)) { if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|PREFIX|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|COMMON_DIR|NAMESPACE|CEILING_DIRECTORIES|CONFIG|CONFIG_PARAMETERS|CONFIG_COUNT)$/.test(k)) { out[k] = undefined; } } return out; } /** A tree object's entry names, NUL-separated (a literal never holds a NUL). */ export function treeEntryNames(data: Buffer, hashBytes: number): Buffer { const names: Buffer[] = []; let i = 0; while (i < data.length) { const sp = data.indexOf(0x20, i); if (sp === -1) break; const nul = data.indexOf(0x00, sp + 1); if (nul === -1) break; names.push(data.subarray(sp + 1, nul), Buffer.from([0])); i = nul + 1 + hashBytes; } return Buffer.concat(names); } /** * Read every object in `gitDir` once, scan it, and count. One child, one * stream: `cat-file --batch` frames each object as ` \n`, * then the bytes, then `\n`. */ export async function auditObjects( gitDir: string, literals: readonly Literal[], opts: { signal?: AbortSignal; env?: NodeJS.ProcessEnv; result?: AuditResult } = {}, ): Promise { const result = opts.result ?? emptyAudit(literals.length); const child = spawn( "git", ["--git-dir", gitDir, "cat-file", "--batch-all-objects", "--unordered", "--batch"], { stdio: ["ignore", "pipe", "pipe"], env: cleanGitEnv(opts.env), signal: opts.signal }, ); let stderr = ""; child.stderr.on("data", (c: Buffer) => { if (stderr.length < 4000) stderr += c.toString("utf8"); }); const exited = new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code) => resolve(code ?? 1)); }); exited.catch(() => {}); // awaited below; a throw in the loop must not orphan it let state: "header" | "body" | "lf" = "header"; let headerParts: Buffer[] = []; let oid = ""; let type = ""; let body = Buffer.alloc(0); let filled = 0; const onObject = () => { result.objects++; if (literals.length === 0) return; if (type === "tree") { const names = treeEntryNames(body, oid.length / 2); const hits = scanBuffer(names, literals); if (hits.length) record(result, "tree", oid, hits, (o) => treeEntry(names, o), false); return; } const hits = scanBuffer(body, literals); if (hits.length === 0) return; if (type === "commit" || type === "tag") { const data = body; record(result, type, oid, hits, (o) => objectField(data, o)); } else { record(result, "blob", oid, hits); } }; let drained = false; try { for await (const chunk of child.stdout as AsyncIterable) { let i = 0; // One pass per framing step; the three steps run in order inside one // turn, so an empty object goes header -> body -> newline at once. while (i < chunk.length) { if (state === "header") { const nl = chunk.indexOf(0x0a, i); if (nl === -1) { headerParts.push(chunk.subarray(i)); break; } headerParts.push(chunk.subarray(i, nl)); i = nl + 1; const header = Buffer.concat(headerParts).toString("latin1"); headerParts = []; const [o, t, size] = header.split(" "); if (t === "missing" || size === undefined) { throw new SourceRefusal(`git cat-file: unexpected header "${header.slice(0, 80)}"`); } oid = o; type = t; body = Buffer.allocUnsafe(Number(size)); filled = 0; state = "body"; if (type === "commit") result.commits++; } if (state === "body") { const n = Math.min(body.length - filled, chunk.length - i); chunk.copy(body, filled, i, i + n); filled += n; i += n; if (filled < body.length) break; state = "lf"; } if (state === "lf") { if (i >= chunk.length) break; i++; // the framing newline onObject(); state = "header"; } } } drained = true; } finally { // Only a loop that threw leaves a child to stop; a drained one is exiting. if (!drained) child.kill(); } const code = await exited; if (code !== 0) { throw new SourceRefusal( `git cat-file over ${tildify(gitDir)} exited ${code}: ${stderr.trim().split("\n")[0] ?? ""}`, ); } if (state !== "header" || headerParts.length > 0) { throw new SourceRefusal(`git cat-file over ${tildify(gitDir)} ended mid-object`); } return result; } /** * Where each blob sits in history (the first path it was seen at), for the * report. Only asked for when a blob hit, so a clean audit never pays for it. */ async function blobPaths( gitDir: string, wanted: Set, env?: NodeJS.ProcessEnv, ): Promise> { const out = new Map(); const child = spawn("git", ["--git-dir", gitDir, "rev-list", "--objects", "--all"], { stdio: ["ignore", "pipe", "ignore"], env: cleanGitEnv(env), }); let rest = ""; for await (const chunk of child.stdout as AsyncIterable) { const lines = (rest + chunk.toString("utf8")).split("\n"); rest = lines.pop() ?? ""; for (const line of lines) { const sp = line.indexOf(" "); if (sp === -1) continue; const o = line.slice(0, sp); if (wanted.has(o) && !out.has(o)) out.set(o, line.slice(sp + 1)); } } await new Promise((r) => child.once("close", r)); return out; } // ── the staged files ──────────────────────────────────────────────────────── /** * Every file under `dir` except `skip` (the packs, which the object walk read * decompressed), plus every path. A `.gz` is scanned decompressed — its * compressed bytes would never match. A symlink is a hit of its own: nothing * staged may point outside the stage. */ export async function auditFiles( dir: string, literals: readonly Literal[], opts: { skip?: RegExp; result?: AuditResult } = {}, ): Promise { const skip = opts.skip ?? /\.(pack|idx)$/; const result = opts.result ?? emptyAudit(literals.length); const walk = async (rel: string): Promise => { const abs = path.join(dir, rel); for (const ent of await readdir(abs, { withFileTypes: true })) { const r = rel ? `${rel}/${ent.name}` : ent.name; const nameHits = scanBuffer(Buffer.from(r, "utf8"), literals); if (nameHits.length) record(result, "file", maskLiterals(r, literals), nameHits, () => "path"); if (ent.isSymbolicLink()) { throw new SourceRefusal(`the stage holds a symlink (${maskLiterals(r, literals)}); nothing published may point outside it`); } if (ent.isDirectory()) { await walk(r); continue; } result.files++; if (skip.test(ent.name)) continue; let data = await readFile(path.join(abs, ent.name)); if (ent.name.endsWith(".gz")) data = gunzipSync(data); const hits = scanBuffer(data, literals); if (hits.length) { record(result, "file", maskLiterals(r, literals), hits, () => (ent.name.endsWith(".gz") ? "decompressed" : "contents")); } } }; if ((await lstat(dir)).isDirectory()) await walk(""); return result; } // ── gitleaks ──────────────────────────────────────────────────────────────── /** A bare name resolved against PATH the way a spawn would, or null. */ export function onPath(bin: string, envPath: string | undefined): string | null { if (bin.includes("/")) return existsSync(bin) ? bin : null; for (const d of (envPath ?? "").split(path.delimiter)) { if (!d) continue; const p = path.join(d, bin); try { accessSync(p, constants.X_OK); if (statSync(p).isFile()) return p; } catch { /* not here */ } } return null; } type GitleaksFinding = { RuleID?: string; File?: string; Commit?: string }; /** * gitleaks over the mirror's history. Exit 0 is clean, 3 is findings (parsed * from its JSON report), anything else is a refusal. Not installed: "skipped", * with a WARNING line — the literal audit still ran. */ export async function runGitleaks( gitDir: string, opts: { bin: string; scratch: string; literals: readonly Literal[]; onLog: (line: string) => void; signal: AbortSignal; env?: NodeJS.ProcessEnv; timeoutMs?: number; }, ): Promise<{ status: "clean" | "skipped"; hits: AuditHit[] }> { const env = cleanGitEnv(opts.env ?? process.env); if (!onPath(opts.bin, env.PATH)) { opts.onLog( `[source] WARNING: ${opts.bin} is not on PATH — the secret scan is skipped (the literal audit still ran). Install gitleaks to add it.`, ); return { status: "skipped", hits: [] }; } const report = path.join(opts.scratch, "gitleaks.json"); const timeout = AbortSignal.timeout(opts.timeoutMs ?? 300_000); const code = await runChildIntoLog( (line) => opts.onLog(`[gitleaks] ${maskLiterals(line, opts.literals)}`), AbortSignal.any([opts.signal, timeout]), { command: opts.bin, args: [ "git", "--no-banner", "--no-color", "--redact", "--exit-code", "3", "--report-format", "json", "--report-path", report, gitDir, ], // Its own ignore file is read from the cwd: the scratch dir has none. cwd: opts.scratch, env, }, ); if (timeout.aborted) throw new SourceRefusal("gitleaks timed out"); if (code === 0) return { status: "clean", hits: [] }; if (code !== 3) throw new SourceRefusal(`gitleaks exited ${code}`); let findings: GitleaksFinding[] = []; try { findings = JSON.parse(await readFile(report, "utf8")) as GitleaksFinding[]; } catch { throw new SourceRefusal("gitleaks reported findings but its report did not parse"); } return { status: "clean", hits: findings.map((f) => ({ kind: "gitleaks" as const, where: maskLiterals( `${f.RuleID ?? "?"} in ${f.File ?? "?"} @ ${(f.Commit ?? "").slice(0, 12)}`, opts.literals, ), lit: -1, offset: -1, })), }; } // ── together ──────────────────────────────────────────────────────────────── /** * The gate over a git dir: the object walk, then (only when it is clean — a * refusal is already certain otherwise) gitleaks. Blob hits are given their * path in history. */ export async function auditBare( gitDir: string, literals: readonly Literal[], opts: { scratch: string; onLog: (line: string) => void; signal: AbortSignal; gitleaks: string | null; env?: NodeJS.ProcessEnv; }, ): Promise { const result = await auditObjects(gitDir, literals, { signal: opts.signal, env: opts.env }); const blobs = new Set(result.hits.filter((h) => h.kind === "blob").map((h) => h.where)); if (blobs.size > 0) { const where = await blobPaths(gitDir, blobs, opts.env); for (const h of result.hits) { const p = h.kind === "blob" ? where.get(h.where) : undefined; if (p) h.where = `${h.where} ${maskLiterals(p, literals)}`; } } if (result.hits.length > 0) return result; if (opts.gitleaks === null) { result.gitleaks = "skipped"; return result; } const g = await runGitleaks(gitDir, { bin: opts.gitleaks, scratch: opts.scratch, literals, onLog: opts.onLog, signal: opts.signal, env: opts.env, }); result.gitleaks = g.status; result.hits.push(...g.hits); return result; } /** A path under the home directory as `~/…`: the home dir names the user. */ export function tildify(p: string): string { const home = os.homedir(); return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p; } /** * The report, as lines. Clean: one line of counts. Hits: the counts per * literal and kind, then the first hits — each by object, field and byte * offset, never by its bytes — and what to do. No line carries a literal or * anything read from beside one. */ export function formatAuditReport( result: AuditResult, literals: readonly Literal[], opts: { scrubFile: string }, ): string[] { const read = `${result.objects.toLocaleString("en-US")} objects (${result.commits.toLocaleString("en-US")} commit${result.commits === 1 ? "" : "s"})` + (result.files ? `, ${result.files.toLocaleString("en-US")} staged files` : "") + ` against ${result.literals} denied literal${result.literals === 1 ? "" : "s"}; gitleaks ${result.gitleaks}`; if (result.hits.length === 0) return [`[source] audit clean: ${read}`]; const lines = [`[source] AUDIT REFUSED: ${result.hits.length} hit${result.hits.length === 1 ? "" : "s"} in ${read}`]; const byLit = new Map>(); for (const h of result.hits) { const m = byLit.get(h.lit) ?? new Map(); m.set(h.kind, (m.get(h.kind) ?? 0) + 1); byLit.set(h.lit, m); } for (const [lit, kinds] of [...byLit].sort((a, b) => a[0] - b[0])) { const label = lit === -1 ? "gitleaks findings" : literalLabel(literals, lit); const parts = KIND_ORDER.filter((k) => kinds.has(k)).map((k) => { const n = kinds.get(k)!; return `${n} in ${k}${n === 1 ? "" : "s"}`; }); lines.push(`[source] ${label}: ${parts.join(", ")}`); } const shown = result.hits.slice(0, LISTED); for (const h of shown) { const where = h.kind === "file" || h.kind === "gitleaks" ? h.where : shortWhere(h.where); const at = [h.field, h.offset >= 0 ? `byte ${h.offset}` : ""].filter(Boolean).join(", "); const label = h.lit === -1 ? "" : `: ${literalLabel(literals, h.lit)}`; lines.push(`[source] ${h.kind} ${where}${at ? ` (${at})` : ""}${label}`); } if (result.hits.length > shown.length) { lines.push(`[source] … and ${result.hits.length - shown.length} more`); } lines.push( `[source] add a rule to ${maskLiterals(tildify(opts.scrubFile), literals)} or drop the file from history, then re-run.`, ); return lines; } // " " → " "; a bare oid → oid12. function shortWhere(where: string): string { const sp = where.indexOf(" "); return sp === -1 ? where.slice(0, 12) : `${where.slice(0, 12)}${where.slice(sp)}`; }