/** * The pre-publish redaction gate (product-scope §4.1): secrets travel nowhere * without a human seeing the redaction report. Everything here is pure — the * CLI wires the decision to a TTY prompt; tests exercise it directly. */ export interface RedactionSample { kind: string; /** the marker with ~40 chars of surrounding, already-redacted context */ context: string; } export interface RedactionReport { /** total `[REDACTED:…]` markers across the whole tale */ total: number; /** marker counts by kind, e.g. { "aws-access-key": 1 } */ counts: Record; /** up to three sample matches, shown in context */ samples: RedactionSample[]; } const MARKER = /\[REDACTED:([a-z-]+)\]/g; const MAX_SAMPLES = 3; /** context kept either side of a sample marker (~40 chars of surroundings in total) */ const CONTEXT_CHARS = 20; /** What the redactor caught, everywhere in the composed tale. */ export function buildRedactionReport(doc: object): RedactionReport { const counts: Record = {}; const samples: RedactionSample[] = []; let total = 0; for (const text of stringsOf(doc)) { for (const match of text.matchAll(MARKER)) { total += 1; const kind = match[1] ?? "unknown"; counts[kind] = (counts[kind] ?? 0) + 1; if (samples.length < MAX_SAMPLES) { samples.push({ kind, context: contextAround(text, match.index, match[0].length) }); } } } return { total, counts, samples }; } export type GateDecision = { action: "publish" } | { action: "prompt" } | { action: "refuse"; message: string }; /** * `--yes` publishes without a prompt; a TTY gets the interactive preview; * anything else (hooks, CI, pipes) is refused so a tale is never published unseen. */ export function decideGate(opts: { yes: boolean; tty: boolean }): GateDecision { if (opts.yes) return { action: "publish" }; if (opts.tty) return { action: "prompt" }; return { action: "refuse", message: "stdout is not a TTY and --yes was not given; refusing to publish a tale nobody previewed. " + "Hooks and CI must pass --yes to accept the redaction report unseen.", }; } /** * What the tale discloses that no secret scrubber can judge for you. A directory * listing naming every client you work for carries no secret pattern at all, and the * old preview — which reported only counts — let exactly that reach a public page. * These are the strings a human must eyeball, because only a human knows which of * them are confidential. */ export interface ExposureReport { /** absolute paths that live outside the project directory */ outsidePaths: string[]; /** external hosts linked or fetched */ hosts: string[]; /** email addresses */ emails: string[]; } // `~/` first: redaction rewrites home paths to tilde form, and an exposure report that // only hunted /Users would go quiet on exactly the paths it exists to surface const ABS_PATH = /(?:~\/|\/Users\/|\/home\/|[A-Z]:\\Users\\)[^\s"'`,;:)\]}]*/g; const URL_HOST = /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})/gi; /** a real address, not an npm spec — `taleseal@0.3.0`'s last label is numeric, an address's is not */ const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[a-z]{2,}\b/gi; /** Distinct, sorted, and capped for display. */ const collect = (values: Iterable, max: number): string[] => [...new Set(values)].sort().slice(0, max); export function buildExposureReport(doc: object, opts: { cwd?: string } = {}): ExposureReport { const cwd = opts.cwd; const paths: string[] = []; const hosts: string[] = []; const emails: string[] = []; for (const text of stringsOf(doc)) { for (const match of text.matchAll(ABS_PATH)) { // a trailing ellipsis is excerpt truncation, not part of the path — with it left on, // a clipped project path fails the cwd prefix test and shows up as false exposure const path = match[0].replace(/…+$/, ""); // the project itself is the point — but only true children, not /foo-bar when cwd is /foo if (cwd !== undefined && (path === cwd || path.startsWith(cwd.endsWith("/") ? cwd : `${cwd}/`))) continue; paths.push(path); } for (const match of text.matchAll(URL_HOST)) hosts.push((match[1] ?? "").toLowerCase()); for (const match of text.matchAll(EMAIL)) emails.push(match[0]); } // Truncated strings leave prefixes ("/Users/g" beside "/Users/goran/…", // "alternativeto.ne" beside "alternativeto.net"); a prefix discloses nothing the // longer string doesn't, and listing both buries the signal. return { outsidePaths: longestOnly(collect(paths, 400)).slice(0, 200), hosts: longestOnly(collect(hosts, 100)).slice(0, 50), emails: collect(emails, 20), }; } /** Drop every string that is a strict prefix of another in the list. */ const longestOnly = (values: string[]): string[] => values.filter((value) => !values.some((other) => other !== value && other.startsWith(value))); function* stringsOf(value: unknown): Generator { if (typeof value === "string") { yield value; return; } if (Array.isArray(value)) { for (const item of value) yield* stringsOf(item); return; } if (typeof value === "object" && value !== null) { for (const item of Object.values(value)) yield* stringsOf(item); } } function contextAround(text: string, index: number, length: number): string { const start = Math.max(0, index - CONTEXT_CHARS); const end = Math.min(text.length, index + length + CONTEXT_CHARS); const prefix = start > 0 ? "…" : ""; const suffix = end < text.length ? "…" : ""; return `${prefix}${text.slice(start, end).replace(/\s+/g, " ")}${suffix}`; }