1/** 2 * @factory/observability — span/log PII + secret scrubber (TASK-0135). 3 * 4 * The phase-15 spec: spans must never carry secrets or PII — "no stack, no 5 * email, no token; redact query text to the operation shape" — and the NFR 6 * doc (NFR-P2) makes it a platform invariant. This is the SPAN-side sibling 7 * of the API's log-side `common/log-scrubber.ts` (same rules, span-attr 8 * shape): run it on every span attribute map before the span is exported. 9 * 10 * Applied centrally in `otel.ts` (a global span-processor hook would require 11 * a private SDK API, so each span-creation site calls `spanScrubber` — the 12 * wiring in the four apps is the conformance surface; this module is the 13 * tested behavior). 14 */ 15 16/** Attribute keys that are DROPPED wholesale (never exported, at all). */ 17const DROP_KEYS = new Set<string>([ 18 // stacks + raw payloads 19 "stack", 20 "stacktrace", 21 "error.stack", 22 "sql", 23 "query", 24 "raw", 25 "body", 26 "payload", 27 // credentials 28 "token", 29 "accesstoken", 30 "refreshtoken", 31 "authorization", 32 "auth", 33 "apikey", 34 "api_key", 35 "password", 36 "passwd", 37 "secret", 38 "credential", 39 "credentials", 40 "cookie", 41 "setcookie", 42 "xapikey", 43 // PII 44 "email", 45 "emailaddress", 46 "phone", 47 "phonenumber", 48 "ip", 49 "ipaddress", 50 "clientip", 51 "address", 52 "name", // personal-name attributes — span identity uses ids, not names 53 "username" 54]); 55 56const REDACT_KEYS = new Set<string>([ 57 "q", // search query text → keep the key so dashboards can still slice on it 58 "searchquery", 59 "text", // free-text content (block text, email subject) — shape only 60 "subject" 61]); 62 63/** The fixed redaction marker. */ 64export const REDACTED = "[redacted]"; 65 66/** Value-shape patterns (belt-and-braces: even an innocently-named key that 67 * carries a token/email as its VALUE gets masked). */ 68// NOTE: tool-bug workaround — no `\"` literals in these regexes (the file-edit 69// JSON layer drops backslashes before quotes); single quotes are safe. 70const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g; 71const BEARER_RE = /bearer\s+[a-z0-9._~+\/-]+/gi; 72const APIKEY_RE = /\b(api[_-]?key|apikey)\b[:=]\s*([a-z0-9_\-]{8,})/gi; 73const URL_CRED_RE = /:\/\/[^/@\s]+:[^/@\s]+@/g; 74const JWT_RE = /\beyJ[a-z0-9_-]{6,}\.[a-z0-9._-]{6,}/gi; 75const HEX64_RE = /\b[a-f0-9]{64}\b/gi; // sha256-hex secrets/tokens 76 77function redactValue(v: string): string { 78 let out = v; 79 out = out.replace(URL_CRED_RE, "://[redacted]@"); 80 out = out.replace(JWT_RE, REDACTED); 81 out = out.replace(BEARER_RE, "bearer " + REDACTED); 82 out = out.replace(APIKEY_RE, (_m, k: string) => `${k}: ${REDACTED}`); 83 out = out.replace(HEX64_RE, REDACTED); 84 out = out.replace(EMAIL_RE, REDACTED); 85 return out; 86} 87 88/** 89 * Scrub a span attribute map (or a log-attribute map — same shape) in place 90 * and return the same object (callers hold the reference). 91 * 92 * - keys in `DROP_KEYS` (case-insensitive) are DELETED; 93 * - keys in `REDACT_KEYS` are set to `"[redacted]"`; 94 * - every remaining string value is masked against the value patterns 95 * (email, bearer/apikey/jwt tokens, URL credentials, 64-hex secrets); 96 * - non-string values pass through unchanged (ids, numbers, booleans). 97 */ 98export function spanScrubber<T extends Record<string, unknown>>(attrs: T): T { 99 // Mutate through a plain record (generic `T` is read-only for writes). 100 const rec = attrs as unknown as Record<string, unknown>; 101 for (const key of Object.keys(rec)) { 102 const lower = key.toLowerCase(); 103 if (DROP_KEYS.has(lower)) { 104 delete rec[key]; 105 continue; 106 } 107 const value = rec[key]; 108 if (value === undefined) continue; 109 if (REDACT_KEYS.has(lower)) { 110 // The VALUE is the sensitive part (query text, free text) — full 111 // redaction, not just pattern-masking. 112 rec[key] = REDACTED; 113 continue; 114 } 115 if (typeof value === "string") { 116 rec[key] = redactValue(value); 117 continue; 118 } 119 if (Array.isArray(value)) { 120 rec[key] = value.map((v) => (typeof v === "string" ? redactValue(v) : v)); 121 } 122 } 123 return attrs; 124} 125 126/** 127 * Redact a SQL/Prisma query string to its OPERATION SHAPE (the spec: "query 128 * text redacted to the operation shape"). `SELECT ... FROM blocks WHERE ...` 129 * → `SELECT ... FROM blocks WHERE ...` keeps the verb + table, drops every 130 * literal: 131 * 132 * `SELECT id, title FROM blocks WHERE pageId = 'pg_1' LIMIT 50` 133 * → `SELECT id, title FROM blocks WHERE pageId = $1 LIMIT $2` 134 * 135 * The table/verbs survive (that is the "shape"); the data does not. 136 */ 137export function redactQuery(query: string): string { 138 let out = query; 139 // single-quoted string literals (escape-aware: '' is an escaped quote 140 // inside SQL) → positional placeholders, in order of appearance. 141 const placeholders: string[] = []; 142 const next = (): string => { 143 placeholders.push(String(placeholders.length + 1)); 144 return `$${placeholders[placeholders.length - 1]}`; 145 }; 146 out = out.replace(/'(?:[^']|'')*'/g, () => next()); 147 // numeric literals → positional placeholders (same sequence). 148 out = out.replace(/\b\d+(\.\d+)?\b/g, () => next()); 149 // collapse whitespace 150 return out.replace(/\s+/g, " ").trim(); 151} 152