agenttuneup 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/redact.js ADDED
@@ -0,0 +1,1950 @@
1
+ // Local scrubbing (v2): runs on the user's machine before any text goes to a remote model. It replaces secrets and
2
+ // personal data with stable placeholders ([EMAIL_1], [KEY_2], [SECRET_3]…) so the judge still sees that the same value
3
+ // came up twice without seeing the value, and a separate residual scan then holds the whole text back if anything
4
+ // secret-looking survived. The design is "when in doubt, remove more": a missed secret is far worse than an
5
+ // over-scrubbed setting.
6
+ //
7
+ // Why a redesign (v2): four review rounds kept finding new leaks because the old pass capped a value at 200 characters
8
+ // and matched credential names loosely. v2 is line-oriented, removes a credential value to the END of its line or field
9
+ // (with continuations), reads whole PEM blocks, understands headers and URL userinfo, and adds a second, independent
10
+ // residual mechanism that fails closed. Name matching runs on a "folded view" of the text (invisible characters,
11
+ // combining marks, fullwidth forms and PowerShell backticks removed, every kept character still pointing at its
12
+ // original index), so a disguised name or term is still found and the original span, disguise included, is replaced.
13
+
14
+ export const SCRUBBER_VERSION = '2.0.0';
15
+
16
+ // Private-use sentinels wrap every placeholder WHILE scrubbing, so no following pattern can match text inside a placeholder
17
+ // (e.g. the EMAIL rule can't eat part of a [SECRET_1]). They become the public [KIND_n] form at the very end.
18
+ const OPEN = '\uE000', CLOSE = '\uE001';
19
+ const SENT = /\uE000([A-Z]+)_(\d+)\uE001/g;
20
+ // Only the kinds this scrubber mints are reserved tokens: a value such as `password=[ATTACK_1]` is live data, not a
21
+ // placeholder, and must be scrubbed and residual-checked like any other (PR #16 review).
22
+ const PUB = /\[(KEY|SECRET|PRIVATE|EMAIL|CARD|SSN|PHONE|IP|LINK)_(\d+)\]/g;
23
+
24
+ // Characters attackers insert to hide a credential NAME from a word match. We strip/normalise these only to decide
25
+ // "is this a credential"; the output text itself is never changed except by replacement.
26
+ const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF\u00AD]/g;
27
+ const CONFUSABLE = { 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'х': 'x', 'у': 'y', 'і': 'i', 'ѕ': 's', 'ј': 'j', 'α': 'a', 'ο': 'o', 'ρ': 'p', 'ϲ': 'c',
28
+ 'А': 'A', 'В': 'B', 'Е': 'E', 'К': 'K', 'М': 'M', 'Н': 'H', 'О': 'O', 'Р': 'P', 'С': 'C', 'Т': 'T', 'Х': 'X', 'І': 'I',
29
+ 'Α': 'A', 'Β': 'B', 'Ε': 'E', 'Ζ': 'Z', 'Η': 'H', 'Ι': 'I', 'Κ': 'K', 'Μ': 'M', 'Ν': 'N', 'Ο': 'O', 'Ρ': 'P', 'Τ': 'T', 'Υ': 'Y', 'Χ': 'X' };
30
+ // A Cyrillic or Greek look-alike is mapped to its Latin twin when the word it sits in (its run of letters) also holds a
31
+ // Latin letter ("p\u0430ssword", and "\u0455\u0435\u0441ret" with three look-alikes side by side; PR #27 review), never inside a
32
+ // genuine Cyrillic or Greek word ("\u043F\u0430\u0440\u043E\u043B\u044C" must stay itself to match its own entry). The scan looks at most
33
+ // 64 letters each way, so a long Cyrillic text stays linear.
34
+ const LATIN = /[A-Za-z]/, LETTER = /\p{L}/u;
35
+ const confusableAt = (c, idx, str) => {
36
+ if (!CONFUSABLE[c]) return c;
37
+ for (let j = idx - 1, k = 0; j >= 0 && k < 64 && LETTER.test(str[j]); j--, k++) if (LATIN.test(str[j])) return CONFUSABLE[c];
38
+ for (let j = idx + 1, k = 0; j < str.length && k < 64 && LETTER.test(str[j]); j++, k++) if (LATIN.test(str[j])) return CONFUSABLE[c];
39
+ return c;
40
+ };
41
+ function foldName(name) {
42
+ let s = String(name).replace(ZERO_WIDTH, '').normalize('NFKC');
43
+ return s.replace(/[\u0400-\u04FF\u0370-\u03FF]/g, confusableAt);
44
+ }
45
+ // A name may arrive escaped: JSON "pass\u0077ord" or a URL parameter pass%77ord. Decode before the credential check.
46
+ function decodeName(name) {
47
+ // HTML character references too (p&#97;ssword, p&#x61;ssword; PR #27 review).
48
+ // Named references: the basic five plus accented Latin letters (p&aacute;ssword), which the name check then folds.
49
+ name = name.replace(/&(amp|lt|gt|quot|apos|nbsp);/gi, (m, n) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' })[n.toLowerCase()])
50
+ .replace(/&([A-Za-z])(acute|grave|circ|uml|tilde|ring|cedil|caron);/g, (m, c, k) => (c + { acute: '\u0301', grave: '\u0300', circ: '\u0302', uml: '\u0308', tilde: '\u0303', ring: '\u030A', cedil: '\u0327', caron: '\u030C' }[k]).normalize('NFC'));
51
+ name = name.replace(/&#(\d{1,7});/g, (m, d) => String.fromCodePoint(Math.min(Number(d), 0x10FFFF))).replace(/&#x([0-9a-f]{1,6});/gi, (m, h) => String.fromCodePoint(Math.min(parseInt(h, 16), 0x10FFFF)));
52
+ return name.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => String.fromCodePoint(Math.min(parseInt(h, 16), 0x10FFFF))) // \u{61} (PR #27 review)
53
+ .replace(/\\u([0-9a-fA-F]{4})/g, (m, h) => String.fromCharCode(parseInt(h, 16)))
54
+ .replace(/\\x([0-9a-fA-F]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16))) // JS/Python \xNN (PR #27 review)
55
+ // Java .properties drops a backslash before any other character (p\assword is password; PR #27 review).
56
+ .replace(/\\([^uUxX\\])/g, '$1')
57
+ // A run of %HH bytes is UTF-8 (contrase%C3%B1a is contraseña; PR #27 review); a run that is not valid UTF-8 falls
58
+ // back to one character per byte.
59
+ .replace(/(?:%[0-9a-fA-F]{2})+/g, (m) => { try { return decodeURIComponent(m); } catch { return m.replace(/%([0-9a-fA-F]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))); } });
60
+ }
61
+
62
+ // --- Folded view. One linear pass builds a copy of the text with zero-width characters, soft hyphens, combining marks
63
+ // and backticks removed, fullwidth and decomposed letters folded to their plain form and Turkish dotless/dotted i folded
64
+ // to i. `map[j]` is the original index of folded character j and `rev[i]` the folded index at original index i, so a
65
+ // match on the view maps back to the exact original span (invisible characters included). ASCII takes a fast path, so
66
+ // ordinary chat costs one comparison per character.
67
+ const DROP = /[\u200B-\u200D\u2060\uFEFF\u00AD`\u202A-\u202E\u2066-\u2069]/;
68
+ const MARK = /\p{M}/u;
69
+ const DECOMPOSABLE = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
70
+ const NEEDS_FOLD = /[^\x20-\x5F\x61-\x7E\n\r\t]|\\\r?\n/; // anything non-ASCII, a control character, a backtick, or a backslash-newline
71
+ // Per-character fold results, cached: a text uses a small alphabet, so each distinct character is normalised once.
72
+ const FOLD_CACHE = new Map();
73
+ function foldChar(c) {
74
+ let r = FOLD_CACHE.get(c);
75
+ if (r !== undefined) return r;
76
+ if (DROP.test(c)) r = '';
77
+ else if (MARK.test(c)) r = '';
78
+ else {
79
+ // Decompose and drop the accent only for Latin, Greek and Cyrillic letters (é → e, й → и); kana and other scripts
80
+ // keep their precomposed form (パ must not become ハ). Compatibility forms expand (ffi → ffi, A → A).
81
+ const d = c.normalize('NFD');
82
+ r = DECOMPOSABLE.test(d[0]) ? d[0] : c;
83
+ const k = r.normalize('NFKC'); if (k.length >= 1 && k.length <= 4) r = k;
84
+ if (r === 'ı') r = 'i'; else if (r === 'İ') r = 'I';
85
+ }
86
+ if (FOLD_CACHE.size < 8192) FOLD_CACHE.set(c, r);
87
+ return r;
88
+ }
89
+ // A view maps folded offsets to original ones with o(j) and back with f(i). Plain ASCII text without backticks (most
90
+ // chat) is its own view, so the common case costs one regex test and no copy.
91
+ function fold(s) {
92
+ const n = s.length;
93
+ if (!NEEDS_FOLD.test(s)) return { folded: s, o: (j) => j, f: (i) => i };
94
+ const parts = [], map = [], rev = new Array(n + 1);
95
+ for (let i = 0; i < n; i++) {
96
+ rev[i] = parts.length;
97
+ const code = s.charCodeAt(i);
98
+ if (code < 0x80) {
99
+ // A backslash that ends the line joins it to the next (Java .properties, shell): drop both so "pass\<newline>word"
100
+ // reads as one name on the view. Backticks are PowerShell's escape and are dropped too.
101
+ if (code === 0x5C && (s[i + 1] === '\n' || (s[i + 1] === '\r' && s[i + 2] === '\n'))) { i += s[i + 1] === '\r' ? 2 : 1; rev[i] = parts.length; continue; }
102
+ if (code === 0x60) continue;
103
+ // A control character other than newline, carriage return or tab (e.g. U+001E record separator) reads as a space.
104
+ parts.push(code < 0x20 && code !== 0x0A && code !== 0x0D && code !== 0x09 || code === 0x7F ? ' ' : s[i]); map.push(i); continue;
105
+ }
106
+ if (code >= 0x80 && code <= 0x9F) { parts.push(' '); map.push(i); continue; }
107
+ if (code >= 0xD800 && code <= 0xDBFF && i + 1 < n) { parts.push(s[i], s[i + 1]); map.push(i, i + 1); rev[i + 1] = parts.length - 1; i++; continue; }
108
+ let r = foldChar(s[i]);
109
+ if (r.length === 1) r = confusableAt(r, i, s); // look-alike → Latin only next to a Latin letter
110
+ for (let k = 0; k < r.length; k++) { parts.push(r[k]); map.push(i); }
111
+ }
112
+ rev[n] = parts.length;
113
+ return { folded: parts.join(''), o: (j) => (j < map.length ? map[j] : n), f: (i) => rev[i] };
114
+ }
115
+ const foldStr = (t) => fold(String(t)).folded;
116
+ // Passes that run back to back on unchanged text share one view instead of folding again (strings are immutable, so
117
+ // an identical reference means an identical view).
118
+ function viewer() { let last = null, view = null; return (s) => (s === last ? view : (last = s, view = fold(s))); }
119
+
120
+ // A name is a credential when one of its parts (split on _ - . and camelCase) is a credential word, or the parts glue
121
+ // into a compound signal like apikey/accesskey/clientsecret/authtoken. So "secretary" and "tokenizer" (single parts)
122
+ // are not credentials, while "SECRET_KEY", "clientSecret", "db.password" and "OPENAI-API-KEY" are. "token" alone is a
123
+ // credential unless it sits next to a usage word (tokenLimit, max_tokens, token_count), which are model-size settings.
124
+ // A credential word counts when it is not immediately followed by another letter, so it matches at the end of a longer
125
+ // name ("PGPASSWORD", "dbSecret") or as a plural ("passwords:", "secrets:") but not as the prefix of an unrelated word
126
+ // ("secretary", "tokenizer", "author"). A few storage suffixes glued on still count ("DBPASSWORDHASH", "MYSECRETVALUE";
127
+ // PR #27 review).
128
+ const STORE_SUFFIX = 'hash|value|val|str|string|text|data|enc|encrypted|b64|base64|plain|raw|key|keys|token|tokens|file|env|var|prod|production|dev|development|test|testing|staging|stage|qa|uat|live|local|sandbox|old|new|backup|bak|primary|secondary|repeat|repeated|confirm|confirmation|confirmed|retype|again|verify|current|previous|prev'; // repeat/confirm form fields (passwordrepeat; PR #27 review) // environment suffixes too (APIKEYPROD; PR #27) // BEARERTOKEN too (PR #27)
129
+ // "pass" at the end of an ordinary word (compass, bypass, trespass, surpass, overpass) is not a credential (PR #16 review).
130
+ const STRONG = new RegExp(`(password|passwd|pwd|passphrase|passcode|secret|credential|bearer|auth|(?<!com|by|tres|sur|over|under|fly)pass)s?(?:${STORE_SUFFIX})?(?![a-z])`);
131
+ // A compound must END at the end of a name part (optionally after a plural "s" or a storage suffix), so "cookiecutter"
132
+ // is not a cookie; the generic-looking ones must also START at a part, so "monkeyvalue" and "hockeyname" stay plain,
133
+ // while "DBAPIKEY" and "OPENAI_API_KEY" still count (viaknox PR #27 review).
134
+ const COMPOUND_TAIL = new RegExp(`^s?(?:${STORE_SUFFIX})?$`);
135
+ const COMPOUND_HEAD = /^(?:cookie|setcookie|keyname|keyvalue)$/;
136
+ const COMPOUND_LOOSE = /^(?:cookie|setcookie|keyname|keyvalue|authorization|proxyauthorization|sessionid|jsessionid|phpsessid|x509data|derencodedkeyvalue)$/;
137
+ function hasCompound(parts) {
138
+ const joined = parts.join(''), starts = new Set(), ends = [];
139
+ let at = 0; for (const p of parts) { starts.add(at); at += p.length; ends.push(at); }
140
+ const re = new RegExp(COMPOUND.source, 'g');
141
+ for (let i = 0; i < joined.length; i++) {
142
+ re.lastIndex = i; const m = re.exec(joined); if (!m) break; i = m.index;
143
+ const end = m.index + m[0].length, partEnd = ends.find(e => e >= end);
144
+ // A distinctive compound (authtoken, apikey, clientsecret, …) counts whatever follows it in its part (AUTHTOKENJWT;
145
+ // PR #16 review); the generic-looking ones still need a known storage suffix or the part's end.
146
+ if (!COMPOUND_TAIL.test(joined.slice(end, partEnd)) && COMPOUND_LOOSE.test(m[0])) continue;
147
+ if (COMPOUND_HEAD.test(m[0]) && !starts.has(m.index)) continue;
148
+ return true;
149
+ }
150
+ return false;
151
+ }
152
+ const COMPOUND = /(onetimecode|onetimepasscode|onetimepassword|otpcode|seedphrase|seedwords|recoveryphrase|secretphrase|walletphrase|mnemonicphrase|apikey|accesskey|privatekey|secretkey|sessionkey|authtoken|accesstoken|apitoken|clientsecret|refreshtoken|authorization|proxyauthorization|setcookie|cookie|recoverycode|backupcode|verificationcode|securitycode|authcode|2facode|mfacode|keyname|keyvalue|x509data|derencodedkeyvalue|pwfile|passfile|passwdfile|passwordfile|secretfile|tokenfile|keyfile|credfile|credentialsfile|sessionid|jsessionid|phpsessid)/;
153
+ // 'pat' is a personal access token (GITHUB_PAT=…; PR #27 review). HTML attributes may have any whitespace, line breaks
154
+ // included, around "=" (ATTR_ANY below; PR #27 review).
155
+ const WHOLE = new Set(['pass', 'pw', 'pin', 'otp', 'pat', 'pats', 'creds', 'cred', 'session', 'sessions', 'sid', 'sids', 'otps', 'cvv', 'cvv2', 'cvc', 'cvc2', 'csc', 'cvn', 'totp', 'hotp', // card verification codes (PR #27)
156
+ 'mnemonic', 'mnemonics', // wallet recovery words (PR #27) // whole parts only: cloudant_pw, db_pass, pin; never passage, spinner
157
+ // the same words in other languages (name parts are lower-cased and NFKC-folded before this lookup)
158
+ 'passe', 'mdp', 'clé', 'cle', 'jeton', 'passwort', 'kennwort', 'geheimnis', 'schlüssel', 'schluessel', 'schlussel', 'psk', 'zugangsdaten', // psk: WPA pre-shared key (PR #27)
159
+ 'contraseña', 'contrasena', 'clave', 'secreto', 'credenciales', 'senha', 'chave', 'chiave', 'segreto', 'wachtwoord', 'sleutel',
160
+ 'пароль', 'секрет', 'ключ', 'токен', 'パスワード', '秘密鍵', 'トークン', '密码', '密钥', '口令', '令牌', '비밀번호', '암호']);
161
+ // "key"/"keys" as the LAST part of a multi-part name is a credential (ssh_key, signing-key, iam-key) unless the part
162
+ // before it says the key is structural, not secret (public_key, primary_key, cache_key…). A bare "key:" stays plain.
163
+ const STRUCTURAL_KEY = new Set(['public', 'foreign', 'primary', 'sort', 'partition', 'hash', 'cache', 'row', 'index', 'composite', 'idempotency', 'dedup', 'translation', 'i18n', 'shortcut', 'hot', 'map', 'dict', 'object', 'array', 'lookup']);
164
+ // Words that only ever describe token COUNTS, checked anywhere in the name (max_output_tokens); words such as rotation or
165
+ // policy describe a credential's lifecycle and are only checked next to "token" (ROTATION_TOKEN stays a credential; PR #27).
166
+ const TOKEN_COUNT = new Set(['max', 'min', 'output', 'input', 'completion', 'completions', 'cached', 'prompt', 'context', 'reasoning', 'total', 'used', 'usage', 'remaining', 'count', 'counts', 'limit', 'limits', 'budget']);
167
+ const USAGE = new Set(['limit', 'limits', 'budget', 'count', 'counts', 'usage', 'size', 'max', 'min', 'window', 'rate', 'length', 'cost', 'costs', 'price', 'per', 'requirements', 'policy', 'rotation', 'economy']);
168
+ function nameParts(name) {
169
+ // Split on separators, camelCase, AND letter/digit boundaries, so "PASSWORD2" and "api_key_v2" break into whole
170
+ // words ("password", "2") while "secretary" and "tokenizer" stay single parts and are not credentials.
171
+ // Accents on Latin letters are dropped for the name check (pássword, contraseña -> contrasena); other scripts keep theirs.
172
+ const flat = foldName(name).normalize('NFD').replace(/([A-Za-z])\p{M}+/gu, '$1').normalize('NFC').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').replace(/(\d)([A-Za-z])/g, '$1 $2');
173
+ return flat.split(/[\s_\-.:$]+/).map(p => p.toLowerCase()).filter(Boolean);
174
+ }
175
+ // Why exported: transcripts.js and tests reuse the exact same "is this a credential setting" decision.
176
+ export function isCredentialName(name) {
177
+ const raw = nameParts(name);
178
+ if (!raw.length) return false;
179
+ // A compact version suffix (PASSWORDV2, APIKEYV3) splits as "passwordv" + "2": drop the "v" before a number part
180
+ // (viaknox PR #27 review). The unsplit form is checked too, so nothing that matched before stops matching.
181
+ const devers = raw.map((p, i) => (p.length > 1 && p.endsWith('v') && /^\d+$/.test(raw[i + 1] || '') ? p.slice(0, -1) : p));
182
+ return credParts(raw) || (devers.some((p, i) => p !== raw[i]) && credParts(devers));
183
+ }
184
+ function credParts(parts) {
185
+ // Numbers split off by the letter/digit cut are glued back for the compound check too ("APIKEYB64"; PR #27 review).
186
+ const glued = parts.reduce((a, p) => (/^\d+$/.test(p) && a.length ? (a[a.length - 1] += p, a) : (a.push(p), a)), []);
187
+ if (hasCompound(parts) || hasCompound(glued)) return true;
188
+ if (parts.length >= 2 && /^keys?$/.test(parts[parts.length - 1]) && !STRUCTURAL_KEY.has(parts[parts.length - 2])) return true;
189
+ return parts.some((p, i) => {
190
+ if (STRONG.test(p) || WHOLE.has(p)) return true;
191
+ // The unambiguous password words take any glued tail (DB_PASSWORDJWT; PR #16 review), except "passwordless".
192
+ if (/(?:password(?!less)|passwd|pwd|passphrase|passcode)[a-z]/.test(p)) return true;
193
+ // The same for secret/credential/bearer stems (SECRETJWT, CREDENTIALJSON; PR #27 review), except ordinary words that
194
+ // start with them (secretary, secretion, secrete, secretive).
195
+ if (/^(?:\w*?)(?:secret(?!ar|e|ion|iv)|credential|bearer)[a-z]/.test(p)) return true;
196
+ // The letter/digit split cuts "dbpasswordb64" into "dbpasswordb" + "64"; glue a number back on so the b64 suffix is
197
+ // still seen (viaknox PR #27 review).
198
+ if (/^\d+$/.test(parts[i + 1] || '') && STRONG.test(p + parts[i + 1])) return true;
199
+ // A usage word anywhere in the name keeps it a setting (max_output_tokens, cached_input_tokens; PR #27 review).
200
+ // When "token" is the last part, the name IS a token (ROTATION_TOKEN) unless a count word says otherwise; when more
201
+ // follows it (token_rotation_policy), the usual usage words next to it make it a setting.
202
+ if (/^tokens?$/.test(p)) return !parts.some(q => TOKEN_COUNT.has(q)) && (i === parts.length - 1 || (!USAGE.has(parts[i + 1] || '') && !USAGE.has(parts[i - 1] || '')));
203
+ return false;
204
+ });
205
+ }
206
+ // HTTP header names whose value is a credential wherever the name appears (HTML meta http-equiv, attributes).
207
+ const HEADER_CRED = /^(?:set-cookie|cookie|authorization|proxy-authorization)$/i;
208
+
209
+ // --- PEM: whole private-key blocks, header to footer, in one forward pass (so thousands of headers can't make it slow).
210
+ // A header with no footer takes the rest of the input, because a cut-off key still leaks. A JSON-escaped block (the
211
+ // header is followed on the same line by the two characters backslash-n) ends at the closing quote instead, so the
212
+ // surrounding JSON survives. Public keys and certificates are meant to be shared, so only PRIVATE KEY blocks go. Extra
213
+ // spaces or tabs between the header words still count.
214
+ const PEM_HEAD = new RegExp('-----BEGIN[ \\t]+((?:[A-Z0-9][A-Z0-9_.-]*[ \\t]+){0,32})PRIVATE[ \\t]+KEY-----', 'g');
215
+ // The footer that closes a block must carry the header's type words (an RSA block ends at an RSA footer, not at an EC
216
+ // one); a mismatched footer is treated like no footer and the block runs to the end of the input (PR #16 review).
217
+ const pemFootFor = (label) => new RegExp('-----END[ \\t]+' + label.trim().split(/[ \t]+/).filter(Boolean).map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[ \\t]+').join('') + 'PRIVATE[ \\t]+KEY-----', 'g');
218
+ function scrubPem(s, label) {
219
+ let out = '', at = 0; PEM_HEAD.lastIndex = 0;
220
+ for (let h; (h = PEM_HEAD.exec(s));) {
221
+ if (h.index < at) { PEM_HEAD.lastIndex = at; continue; }
222
+ const afterHead = h.index + h[0].length;
223
+ let stop;
224
+ if (s[afterHead] === '\\' && s[afterHead + 1] === 'n') {
225
+ // A JSON-escaped key ends at the first unescaped quote, even if real newlines occur before it. No local bound: a
226
+ // block with no closing quote takes the rest of the input (the overall size cap still applies), so a long key is
227
+ // never cut short (PR #16 review). The scan stays linear because the next header search resumes at `stop`.
228
+ let q = afterHead;
229
+ while (q < s.length) { if (s[q] === '\\') { q += 2; continue; } if (s[q] === '"') break; q++; } // backslash pairs, not just the previous character (PR #16 review)
230
+ stop = q;
231
+ } else {
232
+ const PEM_FOOT = pemFootFor(h[1]); PEM_FOOT.lastIndex = afterHead;
233
+ const foot = PEM_FOOT.exec(s);
234
+ stop = foot ? foot.index + foot[0].length : s.length;
235
+ }
236
+ out += s.slice(at, h.index) + label('KEY', s.slice(h.index, stop));
237
+ at = stop; PEM_HEAD.lastIndex = stop;
238
+ }
239
+ return out + s.slice(at);
240
+ }
241
+
242
+ // A value that STARTS with a placeholder this pass already made (any kind but PRIVATE) is already scrubbed and must not
243
+ // be scrubbed again: that would mint a second, pointless placeholder and break idempotence (e.g. a report line
244
+ // `(agent said: "Authorization: [SECRET_1]")`). A PRIVATE placeholder at the start is different: `PASSWORD=[PRIVATE_1]-abc`
245
+ // is a never-send word inside a live secret, and the whole value must still go (the nested-restore case). Every
246
+ // value-capturing pass routes its label through this guard.
247
+ // The exemption holds only when nothing but closers, punctuation and whitespace follows the leading placeholder(s) on
248
+ // that line (`[SECRET_1]`, `"[KEY_1]"`, `[SECRET_1]")`). If a word follows (`[SECRET_1]CorrectHorse9!`, `[KEY_2] abc`),
249
+ // the whole value, placeholder included, is scrubbed: a secret pasted next to an old placeholder is still a secret (the
250
+ // placeholder's number is reserved, so the stored value is that literal text and unscrub restores it).
251
+ // A placeholder followed by a field delimiter is complete only when a PROVEN sibling follows the delimiter: another
252
+ // placeholder (`[SECRET_1], [SECRET_2]`) or a named field (`[SECRET_1],"after":"x"`, `[SECRET_1]; next=1`). Anything else
253
+ // after a closer or delimiter (`[SECRET_1])CorrectHorse9!`, `[SECRET_1], CorrectHorse9!`) is data pasted beside an old
254
+ // placeholder, and the whole value goes (PR #16 review).
255
+ const SIBLING_AFTER = /^[\s"'`)\]}]*[,;][\s"']*(?:\uE000[A-Z]+_\d+\uE001|(?:"[^"\n]{1,160}"|'[^'\n]{1,160}'|[\w.\-]{1,160})[ \t]*[:=])/;
256
+ // Ordinary English after "the token is …" / "its token …": a contraction ("its token won't be lost") or an unquoted
257
+ // status word ("the password is required", "the token is not set"). The scrubber still hides these (it stays
258
+ // conservative: a status word can be a real password); the transcript check uses this only so it does not report that
259
+ // the agent revealed a password setting when every hidden value is such prose (viaknox Codex chat 2026-10-16; #26).
260
+ const PROSE_STATUS = new Set(['not', 'no', 'never', 'required', 'needed', 'missing', 'invalid', 'valid', 'expired', 'revoked', 'wrong', 'incorrect', 'correct', 'empty', 'blank', 'null', 'none', 'unset', 'optional', 'mandatory', 'saved', 'stored', 'sent', 'shown', 'hidden', 'available', 'unavailable', 'active', 'inactive', 'enabled', 'disabled', 'ready', 'pending', 'generated', 'created', 'encrypted', 'hashed', 'protected', 'still', 'already', 'now', 'only', 'also', 'being', 'too', 'used']);
261
+ // Contractions are a closed list of lowercase stems (won't, can't, it'll), not any word before an apostrophe, so a value
262
+ // such as AbCdE'd or qwerty'll is still taken (tune-up PR #26 review).
263
+ const CONTRACTION_STEMS = new Set(['won', 'can', 'don', 'doesn', 'didn', 'isn', 'wasn', 'aren', 'weren', 'couldn', 'wouldn', 'shouldn', 'hasn', 'haven', 'hadn', 'mustn', 'needn', 'mightn', 'shan', 'ain', 'it', 'that', 'there', 'they', 'we', 'you', 'he', 'she', 'who', 'what']);
264
+ // After not / no / never, only these words make a status phrase ("not set", "no longer valid", "never stored"), so a
265
+ // negation-led passphrase (not correct horse battery staple) is still taken (tune-up PR #26 review).
266
+ const PROSE_TAIL = new Set(['set', 'longer', 'yet', 'valid', 'required', 'needed', 'working', 'available', 'stored', 'saved', 'shared', 'shown', 'sent', 'given', 'provided', 'found', 'accepted', 'recognized', 'active', 'enabled', 'expired', 'generated', 'created', 'used']);
267
+ export const isProseValue = (v, after) => {
268
+ const t = String(v).trim();
269
+ if (/^["'`]/.test(t)) return false;
270
+ // won't, can't, it'll: the stem, its suffix, and the rest of the clause all plain lowercase words up to a sentence end
271
+ // ("won't be lost."), so "can't be CorrectHorse9!" is not prose (tune-up PR #26 review). A curly apostrophe (won’t) is
272
+ // captured inside the value, so it is split off here.
273
+ const cm = /^(\p{Ll}+)(['\u2019](?:t|ll|re|ve|d)[.,;:!?]?)$/u.exec(t); // won’t. keeps its sentence mark with the suffix
274
+ const [stem, tail] = cm ? [cm[1], cm[2] + after] : [t, after];
275
+ if (CONTRACTION_STEMS.has(stem) && /^['\u2019](?:t|ll|re|ve|d)(?:[ \t]+\p{Ll}[\p{Ll}'\u2019-]*)*[ \t]*(?:[.,;:!?](?=\s|$)|\n|$)/u.test(tail)) return true;
276
+ // The words keep their case: only all-lowercase prose counts, so "Not Correct" or "NOT SET" as a passphrase is still
277
+ // taken (tune-up PR #26 review). One word must be a status word; several only when led by a negation ("not set",
278
+ // "no longer valid"), so a passphrase that starts with one (correct horse battery staple) is still taken.
279
+ // Only one closing "." or "," is read as sentence punctuation: "required!" may be a real password (tune-up PR #26).
280
+ const w = t.replace(/[.,]$/, '').split(/\s+/);
281
+ if (!w.every(x => /^[\p{Ll}-]+$/u.test(x))) return false;
282
+ return w.length === 1 ? PROSE_STATUS.has(w[0]) : w.length <= 3 && /^(?:not|no|never)$/.test(w[0]) && w.slice(1).every(x => PROSE_TAIL.has(x) || PROSE_STATUS.has(x));
283
+ };
284
+ const isPlaceholderValue = (v) => {
285
+ const t = String(v).trim(), m = /^["']?(?:\uE000(?!PRIVATE_)[A-Z]+_\d+\uE001)+/.exec(t);
286
+ if (!m) return false;
287
+ let [rest, ...more] = t.slice(m[0].length).split('\n');
288
+ // Inside a quoted value the placeholder must be followed by that value's closing quote before any sibling is accepted:
289
+ // "[SECRET_1], next:x" is all one value (PR #16 review).
290
+ const q0 = /^["']/.exec(t); if (q0) { const r = rest.replace(/^[ \t]*/, ''); if (r[0] !== q0[0]) return false; rest = r.slice(1); }
291
+ // Live data on any later line of the same value (password="[SECRET_1]\nhuntertwo") means it is not already scrubbed
292
+ // (PR #16 review).
293
+ if (more.some(l => !/^[\s"'`)\]},;.]*$/.test(l))) return false;
294
+ return /^[\s"'`)\]},;.]*$/.test(rest) || SIBLING_AFTER.test(rest);
295
+ };
296
+
297
+ // Index just past the `]` that closes the `[` at `start`, skipping quoted strings; the text end if unterminated. Linear.
298
+ function bracketEnd(s, start) {
299
+ let depth = 0;
300
+ for (let i = start; i < s.length; i++) {
301
+ const c = s[i];
302
+ if (c === '"' || c === "'") { for (i++; i < s.length && s[i] !== c; i++) if (s[i] === '\\') i++; continue; }
303
+ if (c === '[') depth++;
304
+ else if (c === ']' && --depth === 0) return i + 1;
305
+ }
306
+ return s.length;
307
+ }
308
+
309
+ // How far a credential value reaches from the first value character. A quoted value runs to the matching quote (escaped
310
+ // quotes skipped); an unterminated quote runs onto following lines until it closes; a trailing backslash continues onto
311
+ // the next line; a YAML block scalar (| or >) or an empty value takes the deeper-indented block below it; otherwise the
312
+ // value runs to the end of the line (so a trailing # comment goes too; plan D1: nothing on the line after a credential
313
+ // separator survives, not even after a comma).
314
+ // True when the text just before `start` is a quoted key and a colon, with any whitespace between (no fixed lookback
315
+ // window: {"password":<200 spaces>\n"x"}; PR #16 review). Only the whitespace run is walked.
316
+ function quotedKeyBefore(s, start) {
317
+ let b = start - 1; while (b >= 0 && /\s/.test(s[b])) b--;
318
+ if (s[b] !== ':') return false; b--; while (b >= 0 && /\s/.test(s[b])) b--;
319
+ return s[b] === '"' || s[b] === "'";
320
+ }
321
+ function valueEnd(s, start) {
322
+ // Multi-line literals run to their own closing delimiter: triple quotes, backticks and shell/HCL heredocs
323
+ // (password = <<EOF … EOF; PR #27 review). An unclosed one takes the rest of the input.
324
+ const tq = s.slice(start, start + 3);
325
+ // A basic multi-line string (""") honours backslash escapes, so \" does not close it (PR #16 review); a literal one (''')
326
+ // has no escapes.
327
+ if (tq === '"""' || tq === "'''") { for (let i = start + 3; i < s.length; i++) { if (tq === '"""' && s[i] === '\\') { i++; continue; } if (s.startsWith(tq, i)) { let e = i + 3; while (e < s.length && e < i + 5 && s[e] === tq[0]) e++; return e; } } return s.length; }
328
+ if (s[start] === '`') { for (let i = start + 1; i < s.length; i++) { if (s[i] === '\\') { i++; continue; } if (s[i] === '`') return i + 1; } return s.length; }
329
+ const hd = /^<<[-~]?[ \t]*(["']?)([A-Za-z_]\w{0,40})\1/.exec(s.slice(start, start + 60));
330
+ if (hd) {
331
+ for (let ls = s.indexOf('\n', start); ls >= 0; ) { const le0 = s.indexOf('\n', ls + 1), le = le0 < 0 ? s.length : le0; if (s.slice(ls + 1, le).trim() === hd[2]) return le; if (le0 < 0) break; ls = le0; }
332
+ return s.length;
333
+ }
334
+ // JSON allows any whitespace after the colon, newlines included ({"password":\n"x"}; PR #16 review): a quoted key
335
+ // whose value starts on a later line takes that quoted value, whatever its indent.
336
+ if ((s[start] === '\n' || s[start] === '\r') && quotedKeyBefore(s, start)) {
337
+ let p = start; while (p < s.length && /\s/.test(s[p])) p++;
338
+ if (s[p] === '"') { for (let i = p + 1; i < s.length; i++) { if (s[i] === '\\') { i++; continue; } if (s[i] === '"') return i + 1; } return s.length; }
339
+ // Arrays, objects and bare scalars after the line break go too ({"password":\n["x"]}, {"pin":\n123456}; PR #16 review).
340
+ if (s[p] === '[') return bracketEnd(s, p);
341
+ if (s[p] === '{') return Math.min(s.length, braceEnd(s, p) + 1);
342
+ if (p < s.length) { let e = p; while (e < s.length && !',}]\n'.includes(s[e])) e++; return e; }
343
+ }
344
+ const q = s[start];
345
+ if (q === '"' || q === "'") {
346
+ let i = start + 1;
347
+ // Escapes: a backslash, a PowerShell backtick inside "…", and a doubled quote ('foo''bar', "a""b"; PR #16 review).
348
+ for (; i < s.length; i++) { const c = s[i]; if (c === '\\' || (c === '`' && q === '"')) { i++; continue; } if (c === q) { if (s[i + 1] === q) { i++; continue; } break; } }
349
+ if (i >= s.length) return s.length;
350
+ // A trailing "# comment" (or INI ";") on the same line goes with the value, to the real line end however long it is
351
+ // (password: "x" # backup y; PR #16 review).
352
+ let j = i + 1; while (s[j] === ' ' || s[j] === '\t') j++;
353
+ if (s[j] === '#' || s[j] === ';') { const le = s.indexOf('\n', j); return le < 0 ? s.length : le; }
354
+ return i + 1;
355
+ }
356
+ let nl = s.indexOf('\n', start);
357
+ if (nl < 0) nl = s.length;
358
+ const firstLine = s.slice(start, nl);
359
+ const trimmed = firstLine.trim();
360
+ // Block-scalar headers allow the indent and chomping indicators in either order (|-2 and |2-), PR #16 review.
361
+ // A comment-only first line (password: # prod) and a bare scheme word (Authorization: Bearer, folded onto the next
362
+ // line) are empty values too: the deeper-indented lines below hold the credential (PR #16 review).
363
+ // A YAML tag or anchor alone (password: !!str / &a, the scalar on the next indented line) is empty too (PR #16 review).
364
+ const emptyish = trimmed === '' || /^#/.test(trimmed) || /^(?:basic|bearer|digest)$/i.test(trimmed) || /^(?:(?:![^\s|>]*|&[^\s|>]+)[ \t]*)+(?:#.*)?$/.test(trimmed);
365
+ if (emptyish || /^(?:!\S*[ \t]+)?[|>](?:[+-]?\d*|\d+[+-])(?:[ \t]+#.*)?$/.test(trimmed)) {
366
+ // The key's own indent, read from its line start (a lastIndexOf, not a slice-and-match over the whole prefix, which
367
+ // made thousands of empty "password:" lines quadratic).
368
+ const lineStart0 = s.lastIndexOf('\n', start - 1) + 1;
369
+ let indent = 0; while (s[lineStart0 + indent] === ' ' || s[lineStart0 + indent] === '\t') indent++;
370
+ // An assignment whose value starts on the next, unindented line (const password =\n"x";) takes that quoted string
371
+ // (PR #27 review); an indented block below is handled by the walk that follows.
372
+ if (trimmed === '' && nl < s.length) {
373
+ const ls = nl + 1; let le = s.indexOf('\n', ls); if (le < 0) le = s.length;
374
+ const q = /^([ \t]*)(["'`])/.exec(s.slice(ls, le));
375
+ if (q && q[1].length <= indent) { let k = ls + q[0].length; for (; k < le; k++) { if (s[k] === '\\') { k++; continue; } if (s[k] === q[2]) break; } if (k < le) return k + 1; }
376
+ }
377
+ let i = nl;
378
+ while (i < s.length) {
379
+ const lineStart = i + 1;
380
+ let lineEnd = s.indexOf('\n', lineStart); if (lineEnd < 0) lineEnd = s.length;
381
+ const line = s.slice(lineStart, lineEnd);
382
+ // An indentless YAML sequence (password:\n- x) at the key's own indent still belongs to an empty value (PR #27).
383
+ if (line.trim() !== '' && (line.match(/^\s*/) || [''])[0].length <= indent && !(emptyish && (line.match(/^\s*/) || [''])[0].length === indent && /^\s*-(?:\s|$)/.test(line))) break;
384
+ i = lineEnd;
385
+ }
386
+ return i;
387
+ }
388
+ // A trailing backslash continues the value on the next line; the line may end in \r before the \n (CRLF input).
389
+ // Walked in a loop, not by recursion, so thousands of continued lines cannot overflow the stack (PR #16 review).
390
+ let line = firstLine;
391
+ while (/\\\r?$/.test(line) && nl < s.length) {
392
+ const from = nl + 1;
393
+ nl = s.indexOf('\n', from); if (nl < 0) nl = s.length;
394
+ line = s.slice(from, nl);
395
+ }
396
+ // A header or YAML plain scalar folds onto more-indented lines (Cookie: a=1;\n b=2, Authorization: Custom\n x,
397
+ // password: abc\n def; RFC 9112 obs-fold, YAML 1.2 plain scalars; PR #16 review). Only after a bare "name:" key, so
398
+ // JSON and code are untouched; the walk stops at the first blank or not-deeper line.
399
+ const ls0 = s.lastIndexOf('\n', start - 1) + 1;
400
+ if (nl < s.length && /^[ \t]*[A-Za-z][\w.-]{0,80}[ \t]*:[ \t]*$/.test(s.slice(ls0, start))) {
401
+ let ind = 0; while (s[ls0 + ind] === ' ' || s[ls0 + ind] === '\t') ind++;
402
+ while (nl < s.length) {
403
+ const from = nl + 1; let e = s.indexOf('\n', from); if (e < 0) e = s.length;
404
+ let k = 0; while (s[from + k] === ' ' || s[from + k] === '\t') k++;
405
+ if (k <= ind || from + k >= e || s[from + k] === '\r') break;
406
+ nl = e;
407
+ }
408
+ }
409
+ return nl;
410
+ }
411
+
412
+ // Replaces every [start, end, kind] interval in one ordered pass; overlapping or empty spans and spans that are already
413
+ // placeholders are skipped. Passes that discover several spans at once (tables, scopes) gather intervals and call this.
414
+ function applyIntervals(s, ivs, label) {
415
+ if (!ivs.length) return s;
416
+ ivs.sort((a, b) => a[0] - b[0]);
417
+ let out = '', at = 0;
418
+ for (const [a, b, kind] of ivs) { if (a < at || b <= a) continue; const v = s.slice(a, b); if (!v.trim() || isPlaceholderValue(v)) continue; out += s.slice(at, a) + label(kind, v); at = b; }
419
+ return out + s.slice(at);
420
+ }
421
+
422
+ // Runs a regex over the folded view and replaces, in the ORIGINAL text, the span that `pick` chooses from each match
423
+ // (folded offsets), so a token hidden with invisible characters is removed whole. Spans already consumed are skipped.
424
+ function applyOnView(s, view, re, label, pick) {
425
+ const { folded } = view; let out = '', at = 0; re.lastIndex = 0;
426
+ for (let m; (m = re.exec(folded));) {
427
+ const r = pick(m); if (!r) continue;
428
+ const so = view.o(r.from), eo = view.o(r.to);
429
+ if (so < at) continue;
430
+ const span = s.slice(so, eo);
431
+ if (isPlaceholderValue(span)) continue;
432
+ out += s.slice(at, so) + label(r.kind, span); at = eo;
433
+ if (re.lastIndex < r.to) re.lastIndex = r.to;
434
+ }
435
+ return out + s.slice(at);
436
+ }
437
+
438
+ // Matches a setting name and its separator at a word boundary: a bare name (API_KEY=, client_secret:) or a quoted one
439
+ // ("password":, 'PASSWORD'=). Separators: = : := => -> ~= != == <<< and the Unicode look-alikes (ratio ∶, modifier colon ꞉,
440
+ // small colon ﹕, fullwidth = :, arrow →); a comparison still shows the password. The name class accepts backslashes and
441
+ // percent signs (escaped names are decoded before the check) and the internal placeholder sentinels (a never-send word
442
+ // already turned into a placeholder is resolved back). Bounded classes only, so hostile input stays linear.
443
+ const ASSIGN_START = new RegExp(
444
+ '(?<![\\p{L}\\p{N}_$.\\-])' +
445
+ '(["\']?)((?:\\\\u\\{[0-9A-Fa-f]{1,6}\\}|\\\\u[0-9A-Fa-f]{4}|\\\\x[0-9A-Fa-f]{2}|%[0-9A-Fa-f]{2}|[\\p{L}\\p{N}_$\\uE000\\uFF21-\\uFF5A\\u200B-\\u200D\\u2060\\uFEFF])(?:\\\\u\\{[0-9A-Fa-f]{1,6}\\}|[\\p{L}\\p{N}_$.\\-\\\\%\\u200B-\\u200D\\u2060\\uFEFF\\uE000\\uE001]){0,160})\\1' +
446
+ // A short qualifier may sit between the name and the separator: "password (prod):", "token [staging]:", "key/prod:",
447
+ // and an empty or indexed array suffix (password[]=x, password[0]=x; PR #16 review). A line comment may also end the
448
+ // name's line with the operator on the next one (const password // prod\n= "x"; PR #27 review). A C# accessor block too
449
+ // (public string Password { get; set; } = "x"; PR #27 review), also spread over several lines and with comments
450
+ // between accessors (viaknox PR #28, tune-up PR #20 reviews).
451
+ // Each whitespace run is matched atomically ((?=(x*))\N), so a credential word followed by thousands of spaces is
452
+ // read once instead of being re-split between adjacent runs (that was cubic; PR #16 review). Groups 3-6 are only
453
+ // these runs; callers use m[0], m[1] and m[2].
454
+ '(?:(?<=["\'])(?=([ \\t\\r\\n]*))\\3)?(?=([ \\t]*))\\4(?:(?:(?:\\r?\\n[ \\t]*)?\\{(?:(?:[ \\t\\r\\n]|/\\*(?:[^*/]|\\*(?!/)|/(?!\\*)){0,4000}\\*/|//[^\\n]{0,1000}\\n)*(?:(?:public|private|protected|internal)[ \\t]+)?(?:get|set|init)(?:[ \\t\\r\\n]|/\\*(?:[^*/]|\\*(?!/)|/(?!\\*)){0,4000}\\*/|//[^\\n]{0,1000}\\n)*;){1,3}(?:[ \\t\\r\\n]|/\\*(?:[^*/]|\\*(?!/)|/(?!\\*)){0,4000}\\*/|//[^\\n]{0,1000}\\n)*\\}(?:[ \\t]*\\r?\\n){0,3}|//[^\\n]{0,80}\\n[ \\t]*|/\\*(?:[^*]|\\*(?!/)){0,200}\\*/|\\([^()\\n]{1,40}\\)|\\[[^\\[\\]\\n]{0,40}\\]|/[\\p{L}\\p{N}_\\-]{1,40})(?=([ \\t]*))\\5)?\\]?(?=([ \\t]*))\\6(::=|:=|\\?\\?=|\\|\\|=|&&=|\\?=|\\+=|=>|->|~=|!=|==|<<<|[:=\\u2236\\uA789\\uFE55\\uFF1D\\uFF1A\\u2192])[ \\t]*', 'gu');
455
+
456
+ // Scans the folded view for a credential NAME + separator and removes the value (measured on the original text, so
457
+ // quotes, brackets and continuation lines are honoured) to its end; a non-credential name is left in place and scanning
458
+ // continues inside its value, so "settings: API_KEY=…" still catches the inner one. Runs after never_send so a disguised
459
+ // name (OPENAI-API-KEY with never_send OpenAI) is resolved back before the check.
460
+ function scrubAssignments(s, view, label, resolve) {
461
+ const { folded } = view;
462
+ let out = '', at = 0; ASSIGN_START.lastIndex = 0;
463
+ for (let m; (m = ASSIGN_START.exec(folded));) {
464
+ const sepEndF = m.index + m[0].length;
465
+ const sepEnd = view.o(sepEndF);
466
+ if (sepEnd < at) continue;
467
+ const name = resolve(decodeName(m[2]));
468
+ // A credential-named field whose value is an object ("credentials": { … }), or an array of objects, is structural:
469
+ // descend into it rather than blanking the brace, so the secret fields inside are found one by one. A plain array
470
+ // ("api_keys": ["…", "…"]) is a list of secrets and goes whole, to its closing bracket.
471
+ // A brace only opens a structure when it holds a key/value and closes the value; "{noop}hunter2" and
472
+ // "{bcrypt}$2a$…" are password-storage prefixes and the whole value goes (viaknox PR #27 review).
473
+ // The brace must open with a key and a non-empty value, and a shell "export NAME={…}" is always a string
474
+ // (export PASSWORD={hunter2:}; PR #16 review).
475
+ const structured = s[sepEnd] === '{' && !/(?:^|\n)[ \t]*export[ \t]+[^\n]*$/.test(s.slice(Math.max(0, sepEnd - 200), sepEnd)) && (() => { const e = braceEnd(s, sepEnd); return /^\s*(?:"[^"\n]*"|'[^'\n]*'|\[[^\]\n]*\]|[\p{L}\p{N}_$.\-]+)\s*[:=]\s*[^\s,;}]/u.test(s.slice(sepEnd + 1, e)) && /^[ \t]*(?:[,;}\]\r\n]|$)/.test(s.slice(e + 1, e + 3)); })();
476
+ if (isCredentialName(name) && !structured) {
477
+ let end;
478
+ // An array under a credential name goes whole even when it holds objects: the fields inside (`value`, `key`) are
479
+ // often not credential names themselves, so descending would leave them (PR #27 review).
480
+ if (s[sepEnd] === '[') { end = bracketEnd(s, sepEnd); }
481
+ else {
482
+ // An empty value whose indented block is itself a YAML map ("creds:" then " token: …") is structural too:
483
+ // descend into it so each child key is judged on its own, instead of blanking the whole map.
484
+ let nl = s.indexOf('\n', sepEnd); if (nl < 0) nl = s.length;
485
+ if (s.slice(sepEnd, nl).trim() === '' && /^[ \t]+[^\s:#\-][^\s:]{0,160}:(?:[ \t]|$)/m.test(s.slice(nl + 1, nl + 200).split(/\n(?=[ \t]*\S)/)[0] || '')) { ASSIGN_START.lastIndex = sepEndF; continue; }
486
+ end = valueEnd(s, sepEnd);
487
+ }
488
+ const value = s.slice(sepEnd, end), t = value.trim();
489
+ if (t && !isPlaceholderValue(t)) {
490
+ out += s.slice(at, sepEnd) + label('SECRET', value);
491
+ at = end; ASSIGN_START.lastIndex = view.f(end); continue;
492
+ }
493
+ }
494
+ // Not a credential: resume right after the NAME (not after the separator), so a qualifier or separator that the
495
+ // match swallowed ("example.test/login?password=") is scanned again and the real name is found.
496
+ ASSIGN_START.lastIndex = m.index + m[1].length + m[2].length;
497
+ }
498
+ return out + s.slice(at);
499
+ }
500
+
501
+ // A quoted key that is not identifier-shaped ("api key": …, 'password field': …) is read whole and judged like any
502
+ // other setting name (PR #27 review). Identifier-shaped keys are left to scrubAssignments.
503
+ // JSON allows a line break before the colon too ({"api key"\n:"x"}; PR #16 review).
504
+ const QUOTED_KEY = /(["'])([^"'\n]{1,80})\1[ \t\r\n]*[:=][ \t]*/gu;
505
+ function scrubQuotedKeys(s, label, resolve) {
506
+ let out = '', at = 0; QUOTED_KEY.lastIndex = 0;
507
+ for (let m; (m = QUOTED_KEY.exec(s));) {
508
+ const name = resolve(m[2]);
509
+ if (/^[\p{L}\p{N}_$.\-]+$/u.test(name) || !isCredentialName(name)) continue;
510
+ const vs = m.index + m[0].length;
511
+ // An object under a quoted multiword credential key ({"api key":{…}}) goes whole; no other pass walks it (PR #16).
512
+ let ve; if (s[vs] === '{') { const c = braceEnd(s, vs); ve = s[c] === '}' ? c + 1 : s.length; } else ve = s[vs] === '[' ? bracketEnd(s, vs) : valueEnd(s, vs);
513
+ const v = s.slice(vs, ve);
514
+ if (!v.trim() || isPlaceholderValue(v.trim())) continue;
515
+ out += s.slice(at, vs) + label('SECRET', v); at = ve; QUOTED_KEY.lastIndex = ve;
516
+ }
517
+ return out + s.slice(at);
518
+ }
519
+
520
+ // Line forms that carry no separator at all, handled line by line in one forward walk:
521
+ // - two tokens, the first a credential name at the line start (after an optional export/setenv/set, or a prefix with no
522
+ // spaces such as //registry/:): "password CorrectHorse9!", "export PASSWORD x", "//r/:_authToken x". Prose such as
523
+ // "I forgot my password yesterday" has more tokens and the name is not at the line start, so it survives;
524
+ // - a credential name followed by a comma (CSV row "password,"…"", a quoted field may span lines);
525
+ // - a CSV header with a credential column: that column goes in every following row with the same number of commas,
526
+ // until a blank line;
527
+ // - a YAML explicit key "? password" followed by a line starting with ":".
528
+ const TWO_TOKENS = /^[ \t]*(?:(?:export|setenv|set)[ \t]+)?(\S+)[ \t]+(\S+)[ \t]*$/;
529
+ const NAME_COMMA = /^[ \t]*["']?([\p{L}\p{N}_$.\-]{1,160})["']?,/u;
530
+ const IDENT_CELL = /^[ \t]*["']?[\p{L}\p{N}_$.\-]{1,60}["']?[ \t]*$/u;
531
+ // A short multiword column label ("Login Name", "Web Site URL"): up to four words.
532
+ const LABEL_CELL = /^[ \t]*["']?[\p{L}\p{N}_$.\-]{1,40}(?:[ \t]+[\p{L}\p{N}_$.\-]{1,40}){1,3}["']?[ \t]*$/u;
533
+ const YAML_Q = /^[ \t]*\?[ \t]+([\p{L}\p{N}_$.\-]{1,160})[ \t]*$/u;
534
+ const ENV_LINE = /^[ \t]*(?:ENV|ARG|export|set|setenv|env)[ \t]+/;
535
+ // "key"/"keys" as the whole name is a credential only in the dotenv form: the line is key=value or export key=value.
536
+ // A YAML/JSON label (key: user_id, {"key": "user_id"}) is a structural key and stays (integrator decision, review 5).
537
+ const BARE_KEY_LINE = /^[ \t]*(?:export[ \t]+)?keys?[ \t]*=[ \t]*/i;
538
+ const BARE_NAME = /^[ \t]*(["']?)([\p{L}_$][\p{L}\p{N}_$.\-]{0,160})\1[ \t]*(?:[;#][^\n]*)?$/u; // an inline ; or # comment may follow
539
+ const SEP_LINE = /^[ \t]*(:=|=>|->|~=|[:=\u2236\uA789\uFE55\uFF1D\uFF1A\u2192])[ \t]*/;
540
+ // One CSV row from `i`, honouring double-quoted cells (which may contain commas and newlines). Returns the cell spans
541
+ // and the index of the newline that ends the row.
542
+ function csvRow(s, i, delim = ',') {
543
+ const cells = []; let j = i, start = i, q = false;
544
+ // A quote opens a quoted field only at the start of a cell (RFC 4180); a stray quote inside a value (hun"ter2) is a
545
+ // literal character and must not swallow the delimiters after it (tune-up PR #20 adversarial review).
546
+ // seen = a non-space character has appeared in this cell (a flag, not a re-slice, so many quotes stay linear).
547
+ for (let seen = false; j < s.length; j++) {
548
+ const c = s[j];
549
+ if (c === '"') { if (q) { if (s[j + 1] === '"') j++; else q = false; } else if (!seen) q = true; seen = true; continue; }
550
+ if (!q && c === delim) { cells.push([start, j]); start = j + 1; seen = false; } else if (!q && c === '\n') break; else if (c !== ' ' && c !== '\t') seen = true;
551
+ }
552
+ cells.push([start, j]); return { cells, end: j };
553
+ }
554
+ // A header cell is an identifier or a short label; a quoted cell is unescaped by CSV rules ("" is a quote) first, so
555
+ // "last,name" and "last,""name""" count as labels (tune-up PR #20 / viaknox PR #29 reviews).
556
+ const headerCellOk = (c) => { const v = /^[ \t]*"/.test(c) ? headerCellText(c) : c; return IDENT_CELL.test(v) || LABEL_CELL.test(v); };
557
+ // Declaration modifiers that mark the word after them as a type (public Name get(), private Name x;).
558
+ const MODIFIER_BEFORE = /\b(?:public|private|protected|internal|static|final|readonly|const|override|virtual|abstract|sealed|async)[ \t]+$/;
559
+ // A header cell's text as a name: quotes unescaped by CSV rules ("" is a quote), commas and quotes as spaces, so
560
+ // "access,token" reads as access token for both the shape check and the credential check (tune-up PR #20 review).
561
+ const headerCellText = (c) => { const q = /^[ \t]*"((?:[^"]|"")*)"[ \t]*$/.exec(c); return q ? q[1].replace(/""/g, '"').replace(/[",]+/g, ' ').replace(/ {2,}/g, ' ').trim() : c.trim().replace(/^["']|["']$/g, ''); };
562
+ const trailingName = (tok) => (tok.replace(/[:=]+$/, '').replace(/["']$/, '').match(/[\p{L}\p{N}_$.\-]+$/u) || [''])[0];
563
+ // The judge's own role labels ("USER: AUTH x", "AGENT: …") are not part of the line a person typed (PR #27 review).
564
+ const ROLE_PREFIX = /^(?:USER|AGENT|AGENT INSTRUCTIONS|USER ACTION \(not a message the user typed\)):[ \t]*/;
565
+ function scrubLineForms(s, label, resolve) {
566
+ let out = '', at = 0, i = 0; let csv = null;
567
+ while (i < s.length) {
568
+ let nl = s.indexOf('\n', i); if (nl < 0) nl = s.length;
569
+ let line = s.slice(i, nl).replace(/\r$/, '');
570
+ { const rp = ROLE_PREFIX.exec(line); if (rp) { i += rp[0].length; line = line.slice(rp[0].length); } }
571
+ let m;
572
+ if (csv && line.trim() === '') csv = null;
573
+ if ((m = BARE_KEY_LINE.exec(line)) && line.length > m[0].length) {
574
+ const vStart = i + m[0].length, end = valueEnd(s, vStart);
575
+ if (vStart >= at && s.slice(vStart, end).trim() && !isPlaceholderValue(s.slice(vStart, end))) { out += s.slice(at, vStart) + label('SECRET', s.slice(vStart, end)); at = end; nl = Math.max(nl, end); }
576
+ } else if ((m = ENV_LINE.exec(line)) && !TWO_TOKENS.test(line)) {
577
+ // ENV USER=app PASSWORD x HOST=db: a bare credential name token takes the token after it.
578
+ const toks = []; const TOK = /\S+/g; TOK.lastIndex = m[0].length;
579
+ for (let t; (t = TOK.exec(line));) toks.push({ text: t[0], at: i + t.index });
580
+ for (let k = 0; k + 1 < toks.length; k++) {
581
+ const t = toks[k], v = toks[k + 1];
582
+ if (!/[=:]/.test(t.text) && isCredentialName(resolve(t.text)) && !/[=:]/.test(v.text) && v.at >= at && !isPlaceholderValue(v.text)) { out += s.slice(at, v.at) + label('SECRET', v.text); at = v.at + v.text.length; k++; }
583
+ }
584
+ } else if ((m = TWO_TOKENS.exec(line)) && isCredentialName(resolve(trailingName(m[1])))) {
585
+ const vStart = i + line.lastIndexOf(m[2]), vEnd = vStart + m[2].length;
586
+ if (vStart >= at && !isPlaceholderValue(m[2])) { out += s.slice(at, vStart) + label('SECRET', m[2]); at = vEnd; }
587
+ } else if (!csv && (m = NAME_COMMA.exec(line)) && isCredentialName(resolve(m[1]))) { // inside a table a row is data, not a name (Other1pass,amy; PR #20 review)
588
+ const vStart = i + m[0].length, end = valueEnd(s, vStart);
589
+ if (vStart >= at && s.slice(vStart, end).trim() && !isPlaceholderValue(s.slice(vStart, end))) { out += s.slice(at, vStart) + label('SECRET', s.slice(vStart, end)); at = end; nl = Math.max(nl, end); }
590
+ // A credential-first CSV header (password,username) also starts a table, so the rows below lose their credential
591
+ // cells too (PR #27 review). The rest of the header line still goes, which errs on the safe side.
592
+ // The header is split with the quote-aware CSV parser so it counts columns the same way as the rows below
593
+ // (password,"last,name" is two columns; viaknox PR #28 review).
594
+ const hcells = csvRow(line, 0).cells.map(([a, b]) => line.slice(a, b));
595
+ while (hcells.length > 2 && !hcells[hcells.length - 1].trim()) hcells.pop(); // trailing commas from a spreadsheet export
596
+ if (!csv && hcells.length >= 2 && hcells.every(headerCellOk)) {
597
+ const cols = new Set(); hcells.forEach((c, k) => { if (isCredentialName(resolve(headerCellText(c)))) cols.add(k); });
598
+ if (cols.size) csv = { n: hcells.length, cols, dl: ',' };
599
+ }
600
+ } else if ((m = YAML_Q.exec(line)) && isCredentialName(resolve(m[1])) && nl < s.length) {
601
+ let nl2 = s.indexOf('\n', nl + 1); if (nl2 < 0) nl2 = s.length;
602
+ const next = s.slice(nl + 1, nl2), c = /^[ \t]*:[ \t]*/.exec(next);
603
+ if (c) { const vStart = nl + 1 + c[0].length, end = valueEnd(s, vStart); if (vStart >= at && s.slice(vStart, end).trim()) { out += s.slice(at, vStart) + label('SECRET', s.slice(vStart, end)); at = end; nl = Math.max(nl, end); } }
604
+ } else if ((m = BARE_NAME.exec(line)) && isCredentialName(resolve(m[2])) && nl < s.length) {
605
+ // name / separator / value on consecutive lines: the name alone on its line, the separator on the next (with or
606
+ // without the value), the value on the line after.
607
+ let nl2 = s.indexOf('\n', nl + 1); if (nl2 < 0) nl2 = s.length;
608
+ const c = SEP_LINE.exec(s.slice(nl + 1, nl2));
609
+ if (c) {
610
+ let vStart = nl + 1 + c[0].length; if (vStart >= nl2 && nl2 < s.length) vStart = nl2 + 1;
611
+ const end = valueEnd(s, vStart);
612
+ if (vStart >= at && s.slice(vStart, end).trim() && !isPlaceholderValue(s.slice(vStart, end))) { out += s.slice(at, vStart) + label('SECRET', s.slice(vStart, end)); at = end; nl = Math.max(nl, end); }
613
+ } else {
614
+ // No separator at all, but the next line is indented deeper than the name: that block is the value
615
+ // (aws_secret_access_key on one line, the key on the next, indented).
616
+ const nameIndent = (line.match(/^[ \t]*/) || [''])[0].length, next = s.slice(nl + 1, nl2), nextIndent = (next.match(/^[ \t]*/) || [''])[0].length;
617
+ if (next.trim() && nextIndent > nameIndent) {
618
+ let end = nl2;
619
+ while (end < s.length) { let e2 = s.indexOf('\n', end + 1); if (e2 < 0) e2 = s.length; const l2 = s.slice(end + 1, e2); if (!l2.trim() || (l2.match(/^[ \t]*/) || [''])[0].length <= nameIndent) break; end = e2; }
620
+ const vStart = nl + 1 + nextIndent;
621
+ if (vStart >= at && !isPlaceholderValue(s.slice(vStart, end))) { out += s.slice(at, vStart) + label('SECRET', s.slice(vStart, end)); at = end; nl = Math.max(nl, end); }
622
+ }
623
+ }
624
+ } else if (csv) {
625
+ // A data row under a credential column (cells may be quoted and span lines): scrub those cells.
626
+ const row = csvRow(s, i, csv.dl);
627
+ while (row.cells.length > csv.n && !s.slice(...row.cells[row.cells.length - 1]).replace(/\r$/, '').trim()) row.cells.pop(); // trailing commas
628
+ if (row.cells.length === csv.n) {
629
+ for (let k = 0; k < row.cells.length; k++) {
630
+ const [a, b] = row.cells[k]; const cell = s.slice(a, b), lead = cell.length - cell.trimStart().length, core = cell.trim();
631
+ if (csv.cols.has(k) && core && a + lead >= at && !isPlaceholderValue(core)) { out += s.slice(at, a + lead) + label('SECRET', core); at = a + lead + core.length; }
632
+ }
633
+ nl = Math.max(nl, row.end);
634
+ } else {
635
+ // A row with a different width than the header cannot be aligned to the credential column, so the whole row
636
+ // goes (fails closed; viaknox PR #29 security review).
637
+ const [a0] = row.cells[0], core = s.slice(a0, row.end).replace(/\r$/, ''), lead = core.length - core.trimStart().length, body = core.trim();
638
+ if (body && a0 + lead >= at) { out += s.slice(at, a0 + lead) + label('SECRET', body); at = a0 + lead + body.length; }
639
+ nl = Math.max(nl, row.end);
640
+ }
641
+ } else if ((line.includes(',') || line.includes('\t')) && !csv) {
642
+ // Header cells may be multiword labels ("Login Name", "Password"; PR #27 review): each column is judged on its own.
643
+ // Tab-separated pastes (a spreadsheet copy) too, when the header has tabs and no commas (PR #27 review).
644
+ const dl = line.includes(',') ? ',' : '\t', hdr = csvRow(s, i, dl), cells = hdr.cells.map(([a, b]) => s.slice(a, b).replace(/\r$/, '').replace(/^\uFEFF/, '')); // a UTF-8 BOM is not part of the first label (PR #27 review)
645
+ while (cells.length > 2 && !cells[cells.length - 1].trim()) cells.pop(); // trailing commas from a spreadsheet export
646
+ if (cells.length >= 2 && hdr.end <= nl && cells.every(headerCellOk)) {
647
+ const cols = new Set(); cells.forEach((c, k) => { if (isCredentialName(resolve(headerCellText(c)))) cols.add(k); });
648
+ if (cols.size) csv = { n: cells.length, cols, dl };
649
+ }
650
+ }
651
+ i = nl + 1;
652
+ }
653
+ return out + s.slice(at);
654
+ }
655
+
656
+ // Credential scopes: a section or block whose NAME is a credential makes the generic value keys inside it credentials.
657
+ // - INI/TOML "[password]" or "[[credentials]]" until the next "[" header;
658
+ // - HCL/HOCON `credentials { … }` and Terraform `variable "db_password" { … default = "…" }` until the matching brace
659
+ // (bounded at 64 KB);
660
+ // - a Kubernetes Secret (a "kind: Secret" line): every scalar under data: and stringData:, whatever its key.
661
+ // Inside such a scope EVERY scalar value goes (plan: when in doubt, remove more), nested objects are descended and their
662
+ // scalars go too; only the sibling text outside the scope survives.
663
+ // [section], [[table]], and git-config's [section "subsection"]: the section name before the quoted part decides.
664
+ // A quoted TOML table name may hold spaces (["database credentials"]; PR #27 review): groups 2-4 are the name.
665
+ const INI_HEADER = /^[ \t]*\[\[?[ \t]*(?:"([^"\n]{1,120})"|'([^'\n]{1,120})'|([^\]"'\n\s]{1,120}))(?:[ \t]+"[^"\n]*")?[ \t]*\]\]?[ \t]*(?:[#;].*)?$/; // a trailing comment after the header too ([credentials] # prod; PR #16 review)
666
+ // Quoted and numeric keys too ("login name" = x inside [credentials]; PR #16 review).
667
+ // A dotted table name with quoted parts (["database credentials".prod]): any credential-named part counts (PR #27 review).
668
+ const INI_DOTTED = /^[ \t]*\[\[?[ \t]*((?:"[^"\n]{1,120}"|'[^'\n]{1,120}'|[\w-]{1,120})(?:[ \t]*\.[ \t]*(?:"[^"\n]{1,120}"|'[^'\n]{1,120}'|[\w-]{1,120})){1,8})[ \t]*\]\]?[ \t]*(?:[#;].*)?$/;
669
+ const INI_ASSIGN = /^[ \t]*(?:"[^"\n]{1,160}"|'[^'\n]{1,160}'|[A-Za-z0-9_][\w.\-]{0,60})[ \t]*[=:][ \t]*/;
670
+ // An HCL block type may carry string labels before its brace (credentials "prod" { … }; HCL spec, PR #27 review):
671
+ // group 2 is the type, group 3 the labels.
672
+ const BLOCK_NAME = /(["']?)([\p{L}_][\p{L}\p{N}_.\-]{0,80})\1((?:[ \t]+"[^"\n]{0,80}")*)[ \t]*$/u;
673
+ const GENERIC_ASSIGN = /(^|[\s{,;])(["']?)([\p{L}_$][\p{L}\p{N}_$.\-]{0,60})\2[ \t]*[=:][ \t]*/gu;
674
+ // A credential-named object or flow map: "credentials": { … }, credentials: { … }, "secret": { "metadata": { … } }.
675
+ // Escaped names ({"cred\\u0065ntials":{…}}) are decoded before the check (PR #27 review).
676
+ const OBJ_KEY = /(?<![\p{L}\p{N}_$.\-])(["']?)((?:\\u\{[0-9A-Fa-f]{1,6}\}|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|%[0-9A-Fa-f]{2}|[\p{L}_$])(?:\\u\{[0-9A-Fa-f]{1,6}\}|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|%[0-9A-Fa-f]{2}|[\p{L}\p{N}_$.\-]){0,160})\1[ \t]*[:=][ \t]*\{/gu;
677
+ // Any quoted property counts as a key inside a credential container ("alice@example.com", "my key", "1st"), not only
678
+ // identifier-shaped ones (viaknox PR #27 review).
679
+ // Computed keys ({ [provider]: "…" }) count too (PR #27 review).
680
+ const KV = /("[^"\n]{0,400}"|'[^'\n]{0,400}'|\[[^\[\]\n]{1,160}\]|[\p{L}\p{N}_$][\p{L}\p{N}_$.\-]{0,160})[ \t]*[:=][ \t]*/gu; // numeric keys too ({123: …}; PR #27 review)
681
+ // Every scalar value between `open` ({) and `close` (its match): quoted strings whole, bare values up to , } ] or the
682
+ // line end; a nested { or [ is simply scanned through, so nested keys are seen one by one.
683
+ const FIELD_WORD = /^(?:user(?:[_-]?name)?|login|email|e-mail|id|name|type|kind|url|uri|endpoint|host(?:name)?|port|region|scopes?|expires?(?:[_-]?(?:at|in))?|created(?:[_-]?at)?|updated(?:[_-]?at)?|description|label|enabled|provider|account|tenant|project|audience|issuer|algorithm|format|version|value)$/i;
684
+ function objectScalars(s, open, close, ivs) {
685
+ KV.lastIndex = open + 1;
686
+ for (let m; (m = KV.exec(s)) && m.index < close;) {
687
+ const vs = m.index + m[0].length, c = s[vs];
688
+ // A key can be the secret itself in a token-indexed map ({"api_keys":{"hunter2":{…}}}; PR #16 review): a key whose
689
+ // value is an object or an array, or that carries a digit, goes too.
690
+ // Any other key goes too unless it is a credential name or a common field word ({"api_keys":{"huntertwo":"alice"}};
691
+ // PR #16 review), so a record keeps its shape ({"credentials":{"username":…,"password":…}}).
692
+ const bareKey = m[1].replace(/^["'\[]|["'\]]$/g, '').trim();
693
+ if (bareKey && (c === '{' || c === '[' || /\d/.test(m[1]) || !(isCredentialName(bareKey) || FIELD_WORD.test(bareKey)))) ivs.push([m.index, m.index + m[1].length, 'SECRET']);
694
+ // An array of plain values inside a credential object goes whole ({"credentials":{"items":["…"]}}; PR #27 review);
695
+ // an array of objects is scanned through so each object's fields are found.
696
+ // Any array inside a credential container goes whole, mixed ones included (["…", {…}]; PR #27 review).
697
+ if (c === '[') { const ae = Math.min(bracketEnd(s, vs), close); if (s.slice(vs + 1, ae - 1).trim()) ivs.push([vs, ae, 'SECRET']); KV.lastIndex = ae; continue; }
698
+ if (c === '{') continue;
699
+ let ve;
700
+ if (c === '"' || c === "'") { ve = vs + 1; while (ve < close && s[ve] !== c) { if (s[ve] === '\\') ve++; ve++; } ve = Math.min(ve + 1, close); }
701
+ else { ve = vs; while (ve < close && !',}]\n'.includes(s[ve])) ve++; }
702
+ if (s.slice(vs, ve).trim()) ivs.push([vs, ve, 'SECRET']);
703
+ KV.lastIndex = ve;
704
+ }
705
+ }
706
+ // An optional list marker: "- credentials:" is a container too (PR #27 review); group 4 is the marker.
707
+ const YAML_MAP_KEY = /^([ \t]*)(-[ \t]+)?(["']?)([\p{L}_$][\p{L}\p{N}_$.\-]{0,160})\3:[ \t]*$/u;
708
+ // A child line inside a credential block: `key: …`, `- key: …` or a bare list item `- …`; the match is the prefix before
709
+ // the scalar. A map head (`nested:` with nothing after it) matches too and is told apart by having no value.
710
+ const YAML_CHILD = /^[ \t]*(?:(?:-[ \t]+)?(["']?)[^\s:#"'\-][^:\n]{0,160}\1:[ \t]*|-[ \t]+)/;
711
+ // A transcript role label may sit before the manifest's first line ("USER: kind: Secret"; PR #27 review).
712
+ const K8S_SECRET = /^[ \t]*(?:(?:USER|AGENT|User|Bot|Assistant|Human|AI)[ \t]*:[ \t]*)?(?:-[ \t]+)?(["']?)kind\1[ \t]*:[ \t]*(?:[!&]\S+[ \t]+){0,4}(["']?)Secret\2[ \t]*(?:#.*)?$/m; // a quoted "kind" key too (PR #16 review) // quoted, tagged or commented kinds too (PR #27 review)
713
+ // braceEnd work is budgeted per scrub (PR #27 review): many unclosed openers would otherwise each scan a 64 KB window.
714
+ // Past the budget the scrub throws, and sanitize() holds the text back (fail closed) rather than spending seconds on it.
715
+ let braceBudget = Infinity;
716
+ function braceEnd(s, open) {
717
+ let d = 0; const lim = Math.min(s.length, open + 65536);
718
+ let i = open;
719
+ for (; i < lim; i++) {
720
+ const c = s[i];
721
+ if (c === '"' || c === "'") { for (i++; i < lim && s[i] !== c; i++) if (s[i] === '\\') i++; continue; }
722
+ // A brace inside an HCL/HOCON/JS comment is not structure (# }, // }, /* } */; PR #16 review).
723
+ if (c === '#' || (c === '/' && s[i + 1] === '/')) { while (i < lim && s[i] !== '\n') i++; continue; }
724
+ if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2); i = e < 0 || e >= lim ? lim : e + 1; continue; }
725
+ if (c === '{') d++; else if (c === '}' && --d === 0) break;
726
+ }
727
+ if ((braceBudget -= i - open + 1) < 0) throw new ScrubError('scrubbing took too long; holding back', true);
728
+ return i < lim ? i : lim;
729
+ }
730
+ // A JSON "kind": "Secret" pair, with JSON escapes in the key or value decoded ("\u0053ecret" is "Secret"; PR #16 review).
731
+ const decJson = (x) => { if (!x.includes('\\')) return x; try { return JSON.parse(`"${x}"`); } catch { return x; } };
732
+ function jsonKindSecret(t) {
733
+ if (!t.includes('"')) return false;
734
+ const KV = /"((?:\\.|[^"\\\n])*)"\s*:\s*"((?:\\.|[^"\\\n])*)"/g;
735
+ for (let m; (m = KV.exec(t));) { if (decJson(m[1]) === 'kind' && decJson(m[2]) === 'Secret') return true; KV.lastIndex = m.index + 1; }
736
+ return false;
737
+ }
738
+ // kind: *s where &s anchors the scalar Secret elsewhere in the document is a Secret too (PR #27 review).
739
+ function aliasSecret(t) {
740
+ const k = /^[ \t]*(?:-[ \t]+)?(["']?)kind\1[ \t]*:[ \t]*\*([^\s,\]}]{1,64})[ \t]*(?:#.*)?$/m.exec(t); if (!k) return false;
741
+ const name = k[2].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
742
+ return new RegExp('&' + name + '[ \\t]+(?:![^\\s]+[ \\t]+)?(["\']?)Secret\\1[ \\t]*(?:#.*)?$', 'm').test(t);
743
+ }
744
+ function scrubScopes(s, label, resolve) {
745
+ // A JSON manifest ("kind": "Secret") is a Kubernetes Secret too (PR #16 review).
746
+ const ivs = [];
747
+ // Kubernetes Secret scope is per YAML document ("---" separates them), so a ConfigMap after a Secret keeps its data
748
+ // (PR #27 review). `docs` holds [start, isSecret] for each document.
749
+ // "..." ends a YAML document too (PR #16 review).
750
+ const docs = []; { let d0 = 0; const SEP = /^(?:---|\.\.\.)[ \t]*(?:#.*)?$/gm; const add = (a, b) => { const t = s.slice(a, b); docs.push([a, K8S_SECRET.test(t) || jsonKindSecret(t) || aliasSecret(t)]); };
751
+ for (let m; (m = SEP.exec(s));) { add(d0, m.index); d0 = m.index + m[0].length; } add(d0, s.length); }
752
+ let di = 0, k8s = docs[0][1];
753
+ // In a JSON Secret, data/stringData may sit across lines from its colon and brace ({"data"\n:\n{…}}; PR #27 review).
754
+ docs.forEach(([a, sec], d) => { if (!sec) return; const b = d + 1 < docs.length ? docs[d + 1][0] : s.length; const JD = /(["'])((?:\\.|(?!\1)[^\\\n])*)\1\s*:\s*\{/g; JD.lastIndex = a;
755
+ let covered = -1;
756
+ for (let j; (j = JD.exec(s)) && j.index < b;) { if (j.index + j[0].length - 1 <= covered) continue; if (!/^(?:data|stringData)$/.test(j[1] === '"' ? decJson(j[2]) : j[2])) { JD.lastIndex = j.index + 1; continue; } const open = j.index + j[0].length - 1, close = braceEnd(s, open);
757
+ // No closing brace inside the 64 KB window (or before the end): the rest of the text goes (PR #16 review).
758
+ if (s[close] !== '}') { ivs.push([open + 1, s.length, 'SECRET']); covered = s.length; break; }
759
+ objectScalars(s, open, close, ivs); covered = close; } });
760
+ // JSON objects and YAML flow maps under a credential key ("credentials": { … }, credentials: { … }), wherever they sit
761
+ // on a line: every scalar inside goes, nested objects included.
762
+ // Each scope is parsed once (PR #27 review): an opener inside an object already walked is skipped (objectScalars covers
763
+ // nested objects), and an unclosed one ends the scan, so thousands of "credentials:{" stay linear.
764
+ if (s.includes('{')) { OBJ_KEY.lastIndex = 0; let covered = -1; for (let m; (m = OBJ_KEY.exec(s));) if (m.index + m[0].length - 1 > covered && isCredentialName(resolve(decodeName(m[2])))) { const open = m.index + m[0].length - 1, close = braceEnd(s, open);
765
+ // A credential object with no closing brace in the 64 KB scan window (or before the end) fails closed: everything
766
+ // after its brace goes (PR #16 review).
767
+ if (s[close] !== '}') { ivs.push([open + 1, s.length, 'SECRET']); break; }
768
+ objectScalars(s, open, close, ivs); covered = close; OBJ_KEY.lastIndex = open + 1; } }
769
+ let i = 0, ini = false;
770
+ while (i < s.length) {
771
+ let nl = s.indexOf('\n', i); if (nl < 0) nl = s.length;
772
+ const line = s.slice(i, nl).replace(/\r$/, ''); let m; // CRLF behaves like LF (PR #16 review)
773
+ while (di + 1 < docs.length && docs[di + 1][0] <= i) k8s = docs[++di][1];
774
+ if ((m = INI_HEADER.exec(line))) ini = isCredentialName(resolve(m[1] ?? m[2] ?? m[3]));
775
+ else if ((m = INI_DOTTED.exec(line))) ini = (m[1].match(/"[^"\n]*"|'[^'\n]*'|[\w-]+/g) || []).some(c => isCredentialName(resolve(c.replace(/^["']|["']$/g, ''))));
776
+ else if (ini && (m = INI_ASSIGN.exec(line))) {
777
+ // More-indented lines below continue the value (Python configparser; PR #16 review).
778
+ const vs = i + m[0].length; let ve = valueEnd(s, vs); const ind = (line.match(/^[ \t]*/) || [''])[0].length;
779
+ while (ve < s.length) { const f = ve + 1; let e = s.indexOf('\n', f); if (e < 0) e = s.length; let k = 0; while (s[f + k] === ' ' || s[f + k] === '\t') k++; if (k <= ind || f + k >= e || s[f + k] === '\r') break; ve = e; }
780
+ ivs.push([vs, ve, 'SECRET']);
781
+ }
782
+ // HCL / HOCON block "name { … }" (no separator before the brace): every assignment inside goes. Each brace on the
783
+ // line is tried, since the block name may follow an outer brace.
784
+ // Linear per line (PR #27 review): a brace not preceded (past spaces) by a name or label character is skipped at once,
785
+ // and at most the 512 characters since the previous brace are looked at, so a line of 59 000 "{" costs no rescans.
786
+ // The head never reaches back past the previous brace, so the scan over a line is linear overall.
787
+ for (let bo = line.indexOf('{'), prev = -1; bo >= 0; prev = bo, bo = line.indexOf('{', bo + 1)) {
788
+ let pb = bo - 1; while (pb > prev && (line[pb] === ' ' || line[pb] === '\t')) pb--;
789
+ if (pb <= prev || !/[\p{L}\p{N}_.\-"']/u.test(line[pb])) continue;
790
+ const head = line.slice(Math.max(prev + 1, bo - 512), bo);
791
+ if (/[:=][ \t]*$/.test(head) || !(m = BLOCK_NAME.exec(head)) || ![m[2], ...(m[3].match(/"[^"\n]*"/g) || []).map(l => l.slice(1, -1))].some(n => n && isCredentialName(resolve(n)))) continue;
792
+ const open = i + bo, close = braceEnd(s, open);
793
+ // No real closing brace inside the 64 KB window (or before the end): everything after the block's brace goes
794
+ // (PR #16 review), as for JSON credential objects.
795
+ if (s[close] !== '}') { ivs.push([open + 1, s.length, 'SECRET']); break; }
796
+ const body = s.slice(open + 1, close); GENERIC_ASSIGN.lastIndex = 0;
797
+ for (let g; (g = GENERIC_ASSIGN.exec(body));) { const vs = open + 1 + g.index + g[0].length; if (s[vs] !== '{') ivs.push([vs, Math.min(valueEnd(s, vs), close), 'SECRET']); }
798
+ // A sibling block later on the same line (credentials { … }, auth { … }) is looked at too (PR #27 review).
799
+ if (s[close] === '}' && close < i + line.length) { bo = close - i; continue; }
800
+ break;
801
+ }
802
+ // A YAML block map under a credential key (an OpenAPI ApiKeyAuth: block, a credentials: record) is a credential
803
+ // record: every scalar inside it goes, at any depth. A nested map head (`nested:`) stays as structure and its
804
+ // children are walked; a block scalar (`blob: |`) takes its indented block; a list item loses its scalar or the
805
+ // value after `- key:`. A nested map used to turn the whole scope back into a container (PR #16 review).
806
+ if ((m = YAML_MAP_KEY.exec(line)) && isCredentialName(resolve(m[4])) && nl < s.length) {
807
+ // A list-item key's children sit at the key's own column, after the marker; the next "- " item ends the record.
808
+ const base = m[1].length + (m[2] ? m[2].length : 0); let j = nl;
809
+ while (j < s.length) {
810
+ const ls = j + 1; let le = s.indexOf('\n', ls); if (le < 0) le = s.length; const l2 = s.slice(ls, le);
811
+ // A blank line inside the record does not end it (PR #16 review); only a line at or above the key's indent does.
812
+ if (!l2.trim()) { j = le; continue; } { const ind2 = (l2.match(/^[ \t]*/) || [''])[0].length; if (ind2 < base || (ind2 === base && !/^[ \t]*-(?:[ \t]|$)/.test(l2))) break; }
813
+ const c = YAML_CHILD.exec(l2);
814
+ if (c && c[0].length < l2.replace(/[ \t\r]+$/, '').length) { const vs = ls + c[0].length, ve = valueEnd(s, vs); ivs.push([vs, ve, 'SECRET']); j = Math.max(le, ve); }
815
+ else j = le;
816
+ }
817
+ }
818
+ // Flow-style maps under data:/stringData: ({username: admin, db: x}) lose every scalar too (viaknox PR #27 review).
819
+ if (k8s) { const FM = /(?:^|[\s{,])(["']?)(?:data|stringData)\1[ \t]*:[ \t]*\{/g; let covered = -1; for (let f; (f = FM.exec(line));) { const open = i + f.index + f[0].length - 1; if (open <= covered) continue; covered = braceEnd(s, open); if (s[covered] !== '}') { ivs.push([open + 1, s.length, 'SECRET']); covered = s.length; break; } objectScalars(s, open, covered, ivs); } } // each scope once (PR #27 review)
820
+ if (k8s && (m = /^([ \t]*)(["']?)(?:data|stringData)\2:[ \t]*$/.exec(line))) { // quoted keys too ("stringData":; PR #16 review)
821
+ const ind = m[1].length; let j = nl;
822
+ while (j < s.length) {
823
+ const ls = j + 1; let le = s.indexOf('\n', ls); if (le < 0) le = s.length;
824
+ const l2 = s.slice(ls, le); if (l2.trim() === '') { j = le; continue; }
825
+ if ((l2.match(/^[ \t]*/) || [''])[0].length <= ind) break;
826
+ const km = /^[ \t]*[^\s:]+:[ \t]*/.exec(l2);
827
+ if (km && km[0].length < l2.length) { const vs = ls + km[0].length, ve = valueEnd(s, vs); ivs.push([vs, ve, 'SECRET']); j = Math.max(le, ve); }
828
+ // Any other content line inside a Secret's data block (explicit "? key" / ": value" pairs, flow sequences, bare
829
+ // continuation text) goes too, after its indicator (PR #16 review): every scalar here is Secret data.
830
+ else { const xm = /^[ \t]*(?:[?:-][ \t]*)?/.exec(l2); const vs = ls + xm[0].length; if (vs < le && s[vs] !== '#') { const ve = valueEnd(s, vs); ivs.push([vs, ve, 'SECRET']); j = Math.max(le, ve); } else j = le; }
831
+ }
832
+ }
833
+ i = nl + 1;
834
+ }
835
+ return applyIntervals(s, ivs, label);
836
+ }
837
+
838
+ // Table columns: a header that names a credential column marks that column in every row. SQL INSERT … VALUES (…),
839
+ // SQL COPY … FROM stdin rows up to "\.", Markdown tables (header, rule row, rows) and HTML tables (<th> index → <td>).
840
+ const SQL_INSERT = /\bINSERT[ \t]+INTO[ \t]+[\w."\x60\[\]]+[ \t]*\(([^()\n]{1,2000})\)[ \t]*VALUES[ \t]*/gi;
841
+ const SQL_COPY = /\bCOPY[ \t]+[\w."]+[ \t]*\(([^()\n]{1,2000})\)[ \t]+FROM[ \t]+stdin[^\n]*\n/gi;
842
+ const colIndexes = (list, resolve) => list.split(',').map(c => c.trim().replace(/^["'\x60\[]|["'\x60\]]$/g, '')).map((c, k) => (isCredentialName(resolve(c)) ? k : -1)).filter(k => k >= 0);
843
+ function scrubTables(s, label, resolve) {
844
+ const ivs = [];
845
+ if (/\bVALUES\b/i.test(s)) {
846
+ SQL_INSERT.lastIndex = 0;
847
+ for (let m; (m = SQL_INSERT.exec(s));) {
848
+ const cols = colIndexes(m[1], resolve); if (!cols.length) continue;
849
+ let p = m.index + m[0].length;
850
+ while (p < s.length) {
851
+ while (p < s.length && (s[p] === ' ' || s[p] === ',' || s[p] === '\n' || s[p] === '\r' || s[p] === '\t')) p++;
852
+ if (s[p] !== '(') break;
853
+ let depth = 0, k = 0, cellStart = p + 1, i = p + 1, done = false;
854
+ for (; i < s.length; i++) {
855
+ const c = s[i];
856
+ if (c === "'" || c === '"') { for (i++; i < s.length && s[i] !== c; i++) if (s[i] === '\\') i++; continue; }
857
+ // PostgreSQL dollar quotes ($$…$$, $tag$…$tag$) and array brackets keep their commas (PR #27 review).
858
+ if (c === '$') { const dq = /^\$[A-Za-z_]?\w{0,30}\$/.exec(s.slice(i, i + 34)); if (dq) { const e = s.indexOf(dq[0], i + dq[0].length); i = e < 0 ? s.length : e + dq[0].length - 1; continue; } }
859
+ if (c === '[' || c === '{') { depth++; continue; }
860
+ if (c === ']' || c === '}') { depth--; continue; }
861
+ if (c === '(') depth++;
862
+ else if (c === ')') { if (depth === 0) { if (cols.includes(k)) ivs.push([cellStart, i, 'SECRET']); done = true; break; } depth--; }
863
+ else if (c === ',' && depth === 0) { if (cols.includes(k)) ivs.push([cellStart, i, 'SECRET']); k++; cellStart = i + 1; }
864
+ }
865
+ if (!done) break; p = i + 1;
866
+ }
867
+ }
868
+ }
869
+ if (/\bstdin\b/i.test(s)) {
870
+ SQL_COPY.lastIndex = 0;
871
+ for (let m; (m = SQL_COPY.exec(s));) {
872
+ const cols = colIndexes(m[1], resolve); if (!cols.length) continue;
873
+ // The COPY options pick the row format: CSV (quotes honoured) or a declared DELIMITER; text format is tab-separated
874
+ // (postgresql.org COPY; PR #27 review).
875
+ const opts = m[0], dm = /DELIMITER[ \t]+(?:AS[ \t]+)?E?'(\\t|[^'\\])'/i.exec(opts);
876
+ // CSV keeps its quote rules with any declared delimiter (FORMAT csv, DELIMITER '|'; PR #27 review).
877
+ const csvCopy = /\bCSV\b|FORMAT[ \t]+["']?csv/i.test(opts);
878
+ const delim = dm ? (dm[1] === '\\t' ? '\t' : dm[1]) : csvCopy ? ',' : '\t';
879
+ let i = m.index + m[0].length;
880
+ while (i < s.length) {
881
+ let nl = s.indexOf('\n', i); if (nl < 0) nl = s.length;
882
+ const line = s.slice(i, nl); if (/^\\\.[ \t\r]*$/.test(line)) break;
883
+ if (csvCopy) { csvRow(s, i, delim).cells.forEach(([a, b], k) => { if (cols.includes(k) && s.slice(a, b).trim()) ivs.push([a, b, 'SECRET']); }); }
884
+ else { let pos = i; line.split(delim).forEach((cell, k) => { if (cols.includes(k) && cell.trim()) ivs.push([pos, pos + cell.length, 'SECRET']); pos += cell.length + 1; }); }
885
+ i = nl + 1;
886
+ }
887
+ }
888
+ }
889
+ if (s.includes('|')) {
890
+ // Markdown: a "| a | b |" header, a rule row of dashes, then rows with the same number of cells.
891
+ let i = 0, cols = null, n = 0;
892
+ while (i < s.length) {
893
+ let nl = s.indexOf('\n', i); if (nl < 0) nl = s.length;
894
+ // The judge's role label ("USER: | user | password |") is not a cell (PR #16 review).
895
+ const line0 = s.slice(i, nl).replace(/\r$/, ''), rp = ROLE_PREFIX.exec(line0), base = i + (rp ? rp[0].length : 0), line = rp ? line0.slice(rp[0].length) : line0;
896
+ // A pipe-less row only counts inside a table already found or right above a rule row, so ordinary lines with a
897
+ // stray "|" (YAML block scalars) cost nothing.
898
+ const ruleNext = () => { let n2 = s.indexOf('\n', nl + 1); if (n2 < 0) n2 = s.length; return /^[ \t]*(?:\|[ \t]*)?:?-{3,}/.test(s.slice(nl + 1, n2)); };
899
+ if (/^[ \t]*\|.*\|[ \t]*$/.test(line) || (line.includes('|') && (cols !== null || ruleNext()))) {
900
+ // A backslash-escaped pipe (`a\|b`) is cell text, not a boundary (PR #16 review). Outer pipes are optional in
901
+ // Markdown ("user | password"), so the text before the first and after the last pipe is a cell when it is not
902
+ // blank (viaknox PR #27 review).
903
+ const pipes = []; for (let k = line.indexOf('|'); k >= 0; k = line.indexOf('|', k + 1)) if (!(k > 0 && line[k - 1] === '\\')) pipes.push(k);
904
+ const bounds = [-1, ...pipes, line.length], cells = [];
905
+ for (let q = 0; q + 1 < bounds.length; q++) { const a = bounds[q] + 1, b = bounds[q + 1]; if ((q === 0 || q === bounds.length - 2) && !line.slice(a, b).trim()) continue; cells.push([base + a, base + b]); }
906
+ if (cols === null) {
907
+ let nl2 = s.indexOf('\n', nl + 1); if (nl2 < 0) nl2 = s.length;
908
+ if (/^[ \t]*(?:\|[ \t]*)?:?-{3,}/.test(s.slice(nl + 1, nl2))) { const found = cells.map(([a, b], k) => (isCredentialName(resolve(s.slice(a, b).trim())) ? k : -1)).filter(k => k >= 0); if (found.length) { cols = new Set(found); n = cells.length; } }
909
+ } else if (cells.length === n && !/^[ \t]*(?:\|[ \t]*)?:?-{3,}/.test(line)) {
910
+ for (const k of cols) if (cells[k]) { const [a, b] = cells[k], cell = s.slice(a, b), lead = cell.length - cell.trimStart().length; if (cell.trim()) ivs.push([a + lead, a + lead + cell.trim().length, 'SECRET']); }
911
+ }
912
+ } else if (line.trim() === '') cols = null;
913
+ i = nl + 1;
914
+ }
915
+ }
916
+ if (/<th\b/i.test(s)) {
917
+ // A cell may hold inline markup (<td><code>…</code></td>); the header text is read with tags removed (PR #27 review).
918
+ const CELL = /<t([hd])\b[^<>]*>((?:[^<]|<(?!\/?t[hdr]\b|\/table))*)<\/t[hd]>/gi;
919
+ // Tag names are case-insensitive (<TR>, </Table>; PR #16 review): searched on an ASCII-lower-cased copy, same length.
920
+ const lc = s.replace(/[A-Z]+/g, (c) => c.toLowerCase());
921
+ // Each table range is parsed once (PR #27 review): an opener inside a range already walked is skipped, and the next
922
+ // "</table>" is searched again only after passing the last one found (none found means none later either).
923
+ let covered = -1, nextClose = -2;
924
+ for (let t = lc.indexOf('<table'); t >= 0; t = lc.indexOf('<table', t + 6)) {
925
+ if (t < covered) continue;
926
+ if (nextClose !== -1 && nextClose < t) nextClose = lc.indexOf('</table>', t);
927
+ let end = nextClose < 0 ? s.length : nextClose; end = Math.min(end, t + 65536); covered = end;
928
+ let cols = null; const ROW = /<tr\b[^<>]*>/gi; ROW.lastIndex = t;
929
+ for (let r; (r = ROW.exec(s)) && r.index < end;) {
930
+ let rowEnd = lc.indexOf('</tr>', r.index); if (rowEnd < 0 || rowEnd > end) rowEnd = end;
931
+ const cells = []; CELL.lastIndex = r.index + r[0].length;
932
+ for (let c; (c = CELL.exec(s)) && c.index < rowEnd;) cells.push({ th: c[1].toLowerCase() === 'h', a: c.index + c[0].length - c[2].length - 5 - 0, text: c[2], start: c.index + c[0].indexOf('>') + 1 });
933
+ if (cells.some(c => c.th)) { const found = cells.map((c, k) => (isCredentialName(resolve(c.text.replace(/<[^<>]*>/g, '').trim())) ? k : -1)).filter(k => k >= 0); cols = found.length ? new Set(found) : null; }
934
+ else if (cols) for (const k of cols) if (cells[k] && cells[k].text.trim()) ivs.push([cells[k].start, cells[k].start + cells[k].text.length, 'SECRET']);
935
+ ROW.lastIndex = rowEnd;
936
+ }
937
+ }
938
+ }
939
+ return applyIntervals(s, ivs, label);
940
+ }
941
+
942
+ // HTML attributes: in a tag whose name=, id= or http-equiv= attribute is a credential name or a credential header
943
+ // (<input name="password" value="…">, <meta http-equiv="Set-Cookie" content="…">), the value= and content= attribute
944
+ // values go. One bounded scan per tag.
945
+ // Quoted attribute values may contain ">" (value="a>b"; PR #16 review).
946
+ const CUE_TAG = /<[A-Za-z][\w:.\-]{0,40}(?=[\s/>])[^<>]{0,300}?(?<![\w-])(?:name|id|type|autocomplete|for)[ \t]*=[ \t]*["']?[^"'\s>]{0,60}?(?:pass|pwd|secret|token|key|pin|otp|cvv|cred|auth)/gi;
947
+ const TAG = /<[A-Za-z](?:"[^"\n]{0,2000}"|'[^'\n]{0,2000}'|[^<>"']){0,2000}>/g;
948
+ // Attributes that say what a field IS (name, id, http-equiv, aria-label, placeholder, data-field, data-name,
949
+ // data-key, title, for): when one of them names a credential, the element's other attribute values and text go.
950
+ const CUE_ATTR = /^(?:name|id|key|http-equiv|aria-label|placeholder|data-field|data-name|data-key|title|for)$/i;
951
+ const IDENT_ATTR = /^(?:name|id|key|http-equiv|type|class|aria-label|placeholder|data-field|data-name|data-key|title|for)$/i;
952
+ const ATTR_VALUE = /\b(value|content)([ \t]*=[ \t]*)(\{(?:[^{}\n]|\{[^{}\n]{0,200}\}){0,400}\}|"[^"]*"|'[^']*'|[^\s>]+)/gi;
953
+ // A JSX expression value ({"x"}, {`x`}, one level of nested braces) is taken whole (PR #27 review).
954
+ const ATTR_ANY = /([A-Za-z_:][\w:.\-]{0,60})(\s*=\s*)(\{(?:[^{}\n]|\{[^{}\n]{0,200}\}){0,400}\}|"[^"]*"|'[^']*'|[^\s>"']+)/g;
955
+ const CHILD_VALUE = /^[\s]*<([\w:.\-]*Value)\b[^<>]*>([^<]{1,4000})<\//i;
956
+ function scrubHtmlAttrs(s, label, resolve) {
957
+ if (!s.includes('<')) return s;
958
+ const ivs = []; TAG.lastIndex = 0;
959
+ // HTML tag names are case-insensitive (</TEXTAREA> closes <textarea>), so the closing tag is searched on a
960
+ // length-preserving ASCII lower-casing of the text, built once per call and only when a credential element needs it;
961
+ // indexOf keeps the scan linear (PR #16 review).
962
+ // Tags are visited left to right, so the last search per name is remembered: a search that found nothing (or a hit
963
+ // not yet passed) answers the next one without rescanning, which keeps 5000 unclosed <textarea name=password> linear.
964
+ let lower = null; const lastClose = new Map();
965
+ const closeOf = (name, from) => {
966
+ if (lower === null) lower = s.replace(/[A-Z]+/g, (u) => u.toLowerCase());
967
+ // The complete end-tag name, counting nested same-name elements (<x><x>…</x>secret</x>, and </xy> is not </x>;
968
+ // PR #16 review). At most 256 tags of that name inside the 64 KB window; past that the window is taken (fail closed).
969
+ const key = name.toLowerCase(), last = lastClose.get(key);
970
+ if (last && from >= last.from && last.found < 0) return -1;
971
+ const re = new RegExp('<(/?)' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=[\\s>/])', 'g'); re.lastIndex = from;
972
+ let depth = 1, steps = 0, m;
973
+ for (; (m = re.exec(lower)) && m.index - from <= 65536 && steps < 256; steps++) { if (m[1]) { if (--depth === 0) return m.index; } else { const gt = lower.indexOf('>', m.index); if (gt < 0 || lower[gt - 1] !== '/') depth++; } } // <x/> opens nothing (PR #16 review)
974
+ // Past the window the rest of the text goes, not just the window: the real closer may lie beyond it (PR #16 review).
975
+ if (steps >= 256 || (m && m.index - from > 65536)) return s.length;
976
+ lastClose.set(key, { from, found: -1 }); return -1;
977
+ };
978
+ const seen = new Set();
979
+ for (let m; (m = TAG.exec(s));) {
980
+ seen.add(m.index);
981
+ const tag = m[0], tagName = (tag.match(/^<([\w:.\-]+)/) || ['', ''])[1];
982
+ let byAttr = false; ATTR_ANY.lastIndex = 0;
983
+ // type="password" marks a password field on its own (PR #27 review).
984
+ for (let a; (a = ATTR_ANY.exec(tag));) { if ((/^type$/i.test(a[1]) && /^["']?password["']?$/i.test(a[3])) || (/^autocomplete$/i.test(a[1]) && /^["']?(?:current-password|new-password|one-time-code|cc-csc)["']?$/i.test(a[3]))) { byAttr = true; break; } if (!CUE_ATTR.test(a[1])) continue; const n = resolve(decodeName(a[3].replace(/^["']|["']$/g, ''))); if (isCredentialName(n) || HEADER_CRED.test(n.trim())) { byAttr = true; break; } }
985
+ const byTag = isCredentialName(resolve(tagName));
986
+ if (!byAttr && !byTag) continue;
987
+ // Every attribute value goes except the ones that identify the element (name, id, http-equiv, type, class).
988
+ let hit = false; ATTR_ANY.lastIndex = 0;
989
+ // A cue attribute keeps its value only when that value is itself a credential NAME (it is the label); an aria-label
990
+ // or placeholder that carries the secret itself goes like any other attribute.
991
+ for (let v; (v = ATTR_ANY.exec(tag));) {
992
+ const raw = v[3].replace(/^["']|["']$/g, '');
993
+ // An autocomplete token (current-password) names the field, it is not a value (PR #16 review).
994
+ if (/^(?:name|id|http-equiv|type|class)$/i.test(v[1]) || (/^autocomplete$/i.test(v[1]) && /^["']?[a-z\- ]{1,60}["']?$/i.test(raw)) || (IDENT_ATTR.test(v[1]) && (isCredentialName(resolve(decodeName(raw))) || HEADER_CRED.test(raw.trim())))) continue;
995
+ const vs = m.index + v.index + v[0].length - v[3].length; ivs.push([vs, vs + v[3].length, 'SECRET']); hit = true;
996
+ }
997
+ // <… Name="password"><…Value>text</…Value>: the next *Value child's text is the credential; and the element's own
998
+ // text (<textarea name="password">…</textarea>) when the tag is not self-closing.
999
+ if (!hit && byAttr) { const c = CHILD_VALUE.exec(s.slice(m.index + tag.length, m.index + tag.length + 4200)); if (c) { const vs = m.index + tag.length + c[0].length - c[2].length - 2; ivs.push([vs, vs + c[2].length, 'SECRET']); } }
1000
+ if (byAttr && !tag.endsWith('/>') && tagName) { const open = m.index + tag.length, close = closeOf(tagName, open); if (close > open && s.slice(open, close).trim()) ivs.push([open, close, 'SECRET']); } // nested markup goes too (PR #27)
1001
+ }
1002
+ // A credential-cue tag that TAG could not parse (an attribute value past its 2 KB bound, an unclosed quote) holds the
1003
+ // text back rather than leaving its element's value unscrubbed (PR #16 review).
1004
+ CUE_TAG.lastIndex = 0;
1005
+ for (let c; (c = CUE_TAG.exec(s));) if (!seen.has(c.index)) throw new ScrubError('a credential tag too long to parse; holding back'); // unclosed syntax, not a size cap: shortening may not help (tune-up PR #21 review)
1006
+ return applyIntervals(s, ivs, label);
1007
+ }
1008
+
1009
+ // A name written as a concatenation of string literals in code ("pass" + "word": …, ['pass' + 'word']: …) is joined
1010
+ // before the check; the value after the separator then goes like any assignment.
1011
+ const CONCAT_NAME = /(?:\[[ \t]*)?(["'])([\w.\-]{1,60})\1(?:[ \t]*\+[ \t]*(["'])([\w.\-]{1,60})\3){1,4}[ \t]*\]?[ \t]*(:=|=>|[:=])[ \t]*/g;
1012
+ function scrubConcatNames(s, label, resolve) {
1013
+ if (!s.includes('+')) return s;
1014
+ let out = '', at = 0; CONCAT_NAME.lastIndex = 0;
1015
+ for (let m; (m = CONCAT_NAME.exec(s));) {
1016
+ const joined = (m[0].match(/(["'])([\w.\-]{1,60})\1/g) || []).map(p => p.slice(1, -1)).join('');
1017
+ const sepEnd = m.index + m[0].length;
1018
+ if (sepEnd < at || !isCredentialName(resolve(joined)) || s[sepEnd] === '{') continue;
1019
+ const end = valueEnd(s, sepEnd), value = s.slice(sepEnd, end);
1020
+ if (!value.trim() || isPlaceholderValue(value)) continue;
1021
+ out += s.slice(at, sepEnd) + label('SECRET', value); at = end; CONCAT_NAME.lastIndex = end;
1022
+ }
1023
+ return out + s.slice(at);
1024
+ }
1025
+
1026
+ // Code forms: a tuple ('password', 'x') / ("password", "x") inside parentheses or brackets, and a C++ designated
1027
+ // initialiser `.password = "x"` (a leading dot before the name after "{", "," or whitespace).
1028
+ const TUPLE_PAIR = /[(\[][ \t]*(["']?)([\p{L}_$][\p{L}\p{N}_$.\-]{0,60})\1[ \t]*,[ \t]*("[^"\n]*"|'[^'\n]*'|[^,\]\)}\n]+)/gu;
1029
+ // Clojure/EDN keyword maps: {:password "x" :user "ada"}.
1030
+ const EDN_PAIR = /(?<![\p{L}\p{N}:/]):([\p{L}_$][\p{L}\p{N}_$.\-]{0,60})[ \t]+("[^"\n]*"|'[^'\n]*')/gu;
1031
+ // YAML aliases: password: *s means the anchored value "&s …" elsewhere in the document is the password.
1032
+ const ALIAS_REF = /(?<![\p{L}\p{N}_$.\-])(["']?)([\p{L}_$][\p{L}\p{N}_$.\-]{0,160})\1[ \t]*:[ \t]*\*([\w.\-]{1,80})/gu;
1033
+ const DESIGNATED = /(?<=[{,\s])\.([A-Za-z_]\w{0,60})[ \t]*=[ \t]*/g;
1034
+ function scrubCodeForms(s, label, resolve) {
1035
+ const ivs = [];
1036
+ if (s.includes(',')) { TUPLE_PAIR.lastIndex = 0; for (let m; (m = TUPLE_PAIR.exec(s));) if (isCredentialName(resolve(m[2]))) ivs.push([m.index + m[0].length - m[3].length, m.index + m[0].length, 'SECRET']); }
1037
+ if (s.includes(':')) { EDN_PAIR.lastIndex = 0; for (let m; (m = EDN_PAIR.exec(s));) if (isCredentialName(resolve(m[1]))) ivs.push([m.index + m[0].length - m[2].length, m.index + m[0].length, 'SECRET']); }
1038
+ if (s.includes('*') && s.includes('&')) {
1039
+ const aliases = new Set(); ALIAS_REF.lastIndex = 0;
1040
+ for (let m; (m = ALIAS_REF.exec(s));) if (isCredentialName(resolve(m[2]))) aliases.add(m[3]);
1041
+ for (const a of aliases) { const re = new RegExp('(?<![\\w&])&' + a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w.\\-])[ \\t]*', 'g'); for (let m; (m = re.exec(s));) { const vs = m.index + m[0].length; const ve = valueEnd(s, vs); if (s.slice(vs, ve).trim()) ivs.push([vs, ve, 'SECRET']); } }
1042
+ }
1043
+ if (s.includes('.')) { DESIGNATED.lastIndex = 0; for (let m; (m = DESIGNATED.exec(s));) if (isCredentialName(resolve(m[1])) && s[m.index + m[0].length] !== '{') { const vs = m.index + m[0].length; ivs.push([vs, valueEnd(s, vs), 'SECRET']); } }
1044
+ return applyIntervals(s, ivs, label);
1045
+ }
1046
+
1047
+ // Name/value sibling pairs: when a field that SAYS WHAT A SETTING IS (name, key, label, field, var, variable, id, env,
1048
+ // option, setting, property, param, parameter) holds a credential name as its value, the value-like sibling in the
1049
+ // same entry (value, val, default, defaultValue, default_value, string, data, content, secret) is the secret. One
1050
+ // rule covers YAML list items, JSON objects, TOML tables, INI sections, flow maps, Go struct tags, Java/C#/Rust
1051
+ // attributes, PHP/Ruby/Python/JS literals, GraphQL and protobuf text. The look-ahead is bounded to the entry: it stops
1052
+ // at the next name-like field, an unbalanced closing bracket, a less-indented line, or 20 lines.
1053
+ const NAME_FIELD = /(?<![\p{L}\p{N}_$.\-])(["']?)(name|key|label|field|var|variable|id|env|option|setting|property|param|parameter)\1[ \t]*(?:=>|[:=])[ \t]*(["']?)(--?)?([\p{L}_$][\p{L}\p{N}_$.\-]{0,160})\3/giu;
1054
+ const NAME_FIELD_NEXT = new RegExp(NAME_FIELD.source, 'giu');
1055
+ const VALUE_FIELD = /(?<![\p{L}\p{N}_$.\-])(["']?)(value|val|default|defaultValue|default_value|string|data|content|secret)\1[ \t]*(?:=>|[:=])[ \t]*/giu;
1056
+ // Indexed or suffixed variables whose key ends in NAME: env[0].name=DB_PASSWORD, app.env[0].name=TOKEN, ENV_0_NAME=PASSWORD;
1057
+ // the line with the same prefix and a VALUE-like suffix holds the secret.
1058
+ const INDEXED_NAME = /^[ \t]*([\w.\[\]\-]{0,120}?)([._]?)(name)[ \t]*[=:][ \t]*(["']?)([\w.\-]{1,160})\4[ \t]*$/im;
1059
+ // A value inside an entry: a quoted string whole, or a bare run up to a comma, a closing bracket or the line end.
1060
+ function entryValueEnd(s, vs, limit) {
1061
+ const q = s[vs];
1062
+ if (q === '"' || q === "'") { let i = vs + 1; while (i < limit && s[i] !== q) { if (s[i] === '\\') i++; i++; } return Math.min(i + 1, limit); }
1063
+ let i = vs; while (i < limit && !',}])\n'.includes(s[i])) i++; return i;
1064
+ }
1065
+ // Where the entry that starts at `from` ends (see the rule above). `nameIndent` is the column of the name field.
1066
+ function entryEnd(s, from, nameIndent) {
1067
+ let end = from; for (let k = 0; k < 20 && end < s.length; k++) { const nl = s.indexOf('\n', end + 1); end = nl < 0 ? s.length : nl; }
1068
+ NAME_FIELD_NEXT.lastIndex = from; const nx = NAME_FIELD_NEXT.exec(s); if (nx && nx.index < end) end = nx.index;
1069
+ let depth = 0;
1070
+ for (let j = from; j < end; j++) { const c = s[j]; if (c === '{' || c === '[' || c === '(') depth++; else if (c === '}' || c === ']' || c === ')') { if (depth === 0) { end = j; break; } depth--; } }
1071
+ for (let j = s.indexOf('\n', from); j >= 0 && j < end; j = s.indexOf('\n', j + 1)) {
1072
+ const ls = j + 1; let le = s.indexOf('\n', ls); if (le < 0) le = s.length; const l2 = s.slice(ls, le);
1073
+ if (l2.trim() && (l2.match(/^[ \t]*/) || [''])[0].length < nameIndent) { end = ls; break; }
1074
+ }
1075
+ return end;
1076
+ }
1077
+ function scrubNameValuePairs(s, label, resolve) {
1078
+ const ivs = [];
1079
+ NAME_FIELD.lastIndex = 0;
1080
+ // Names are visited left to right, so the last value-field search is remembered: the first value field at or after
1081
+ // `known.from` is `known.index` (-1: none). A name with no value field anywhere used to scan to the end of the text,
1082
+ // which made 4000 <input name="password"> lines quadratic (found with the PR #16 perf payloads).
1083
+ let known = null;
1084
+ for (let m; (m = NAME_FIELD.exec(s));) {
1085
+ if (!isCredentialName(resolve(m[5]))) continue;
1086
+ const from = m.index + m[0].length, nameIndent = m.index - (s.lastIndexOf('\n', m.index - 1) + 1);
1087
+ const end = entryEnd(s, from, nameIndent);
1088
+ // A value field may come BEFORE the name in the same entry ({"value": …, "name": "password"}, or a YAML list item
1089
+ // "- value: …" then " name: password"); find the entry start and scrub those too (viaknox PR #27 review).
1090
+ // Looked up inside a bounded window behind the name, so thousands of names on one line stay linear.
1091
+ let start = -1;
1092
+ const win = Math.max(0, m.index - 4000), back = s.slice(win, m.index), obr = back.lastIndexOf('{'), ob = obr < 0 ? -1 : win + obr;
1093
+ if (ob >= 0 && !s.slice(ob, m.index).includes('}')) start = ob;
1094
+ else if (back.includes('\n')) {
1095
+ let ls = win + back.lastIndexOf('\n') + 1;
1096
+ for (let k = 0; k < 20 && ls > 0; k++) {
1097
+ const ps = s.lastIndexOf('\n', ls - 2) + 1, pl = s.slice(ps, ls - 1); if (!pl.trim()) break;
1098
+ const ind = (pl.match(/^[ \t]*/) || [''])[0].length;
1099
+ if (/^[ \t]*-[ \t]/.test(pl) && ind + 2 === nameIndent) { start = ps; break; }
1100
+ if (ind !== nameIndent) break; ls = ps;
1101
+ }
1102
+ }
1103
+ if (start >= 0) {
1104
+ const VB = new RegExp(VALUE_FIELD.source, 'giu'); VB.lastIndex = start;
1105
+ for (let v; (v = VB.exec(s)) && v.index < m.index;) { const vs = v.index + v[0].length, ve = entryValueEnd(s, vs, m.index); if (ve <= m.index && s.slice(vs, ve).trim()) ivs.push([vs, ve, 'SECRET']); VB.lastIndex = Math.max(ve, vs + 1); }
1106
+ }
1107
+ let at = from;
1108
+ if (known && from >= known.from) { if (known.index < 0) continue; if (known.index >= from) at = known.index; }
1109
+ for (;;) {
1110
+ VALUE_FIELD.lastIndex = at; const v = VALUE_FIELD.exec(s); known = { from: at, index: v ? v.index : -1 };
1111
+ if (!v || v.index >= end) break;
1112
+ const vs = v.index + v[0].length, ve = entryValueEnd(s, vs, end); if (s.slice(vs, ve).trim()) ivs.push([vs, ve, 'SECRET']); at = ve;
1113
+ }
1114
+ }
1115
+ // Indexed / suffixed variables: the NAME line decides, the VALUE line with the same prefix (within 20 lines) goes.
1116
+ if (/name[ \t]*[=:]/i.test(s)) {
1117
+ const re = new RegExp(INDEXED_NAME.source, 'gim');
1118
+ for (let m; (m = re.exec(s));) {
1119
+ if (!m[1] || !isCredentialName(resolve(m[5]))) continue;
1120
+ const prefix = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), sep = m[2] ? '[._]' : '';
1121
+ const vre = new RegExp('^[ \\t]*' + prefix + sep + '(?:value|val|default|defaultValue|default_value|data|content|secret)[ \\t]*[=:][ \\t]*', 'gim');
1122
+ let limit = m.index; for (let k = 0; k < 20 && limit < s.length; k++) { const nl = s.indexOf('\n', limit + 1); limit = nl < 0 ? s.length : nl; }
1123
+ // Searched inside those 20 lines only (a name with no value line used to scan to the end of the text: quadratic).
1124
+ const base = s.indexOf('\n', m.index + m[0].length); if (base < 0 || base >= limit) continue;
1125
+ const seg = s.slice(base, limit);
1126
+ for (let v; (v = vre.exec(seg));) { const vs = base + v.index + v[0].length; ivs.push([vs, valueEnd(s, vs), 'SECRET']); }
1127
+ }
1128
+ }
1129
+ // plist: <key>Password</key> followed by the next <string> or <data> element.
1130
+ if (s.includes('<key>')) { const re = /<key>([^<]{1,160})<\/key>\s*<(?:string|data)>([^<]*)</g; for (let m; (m = re.exec(s));) if (isCredentialName(resolve(m[1])) && m[2].trim()) { const ve = m.index + m[0].length - 1; ivs.push([ve - m[2].length, ve, 'SECRET']); } }
1131
+ // A call whose first argument is the option name ('--password', default='x'): click, argparse and friends.
1132
+ if (s.includes('(')) {
1133
+ const re = /\([ \t]*(["'])(--?[\p{L}\p{N}_\-]{1,60})\1/gu;
1134
+ for (let m; (m = re.exec(s));) {
1135
+ if (!isCredentialName(resolve(m[2].replace(/^-+/, '')))) continue;
1136
+ const from = m.index + m[0].length, close = Math.min(s.length, from + 2000);
1137
+ let depth = 0, end = close; for (let j = from; j < close; j++) { const c = s[j]; if (c === '(') depth++; else if (c === ')') { if (depth === 0) { end = j; break; } depth--; } }
1138
+ VALUE_FIELD.lastIndex = from;
1139
+ for (let v; (v = VALUE_FIELD.exec(s)) && v.index < end;) { const vs = v.index + v[0].length, ve = entryValueEnd(s, vs, end); if (s.slice(vs, ve).trim()) ivs.push([vs, ve, 'SECRET']); VALUE_FIELD.lastIndex = ve; }
1140
+ }
1141
+ }
1142
+ return applyIntervals(s, ivs, label);
1143
+ }
1144
+
1145
+ // URL path segments: a segment that is a credential name (/token/, /password/, /api-key/) makes the next segment a
1146
+ // secret (https://x/api/token/<x>/status).
1147
+ const AUTH_ROUTES = /^(?:v\d{1,2}(?:\.\d{1,2})?|authorize|callback|login|logout|signin|signup|consent|userinfo|device|register|revoke|introspect|jwks|token|oauth2?|openid|verify|google|github|microsoft|azure|apple)$/i;
1148
+ function scrubPathSegments(s, label, resolve) {
1149
+ if (!s.includes('/')) return s;
1150
+ // The slash after the segment is only looked at, not consumed, so "/api/token/x" is retried at "token".
1151
+ const ivs = [], re = /\/([\p{L}\p{N}_\-]{1,40})(?=\/[^\/\s?#"'<>)]+)/gu;
1152
+ // After /auth/ or /oauth/, only known route words are left (/auth/v1/oauth/authorize, the Codex sign-in URL; /oauth2/v2.0/authorize, viaknox PR #36 review); any other
1153
+ // value, short codes like /auth/123456 included, is still taken. /password/, /token/ and the rest are unchanged (PR #24).
1154
+ for (let m; (m = re.exec(s));) if (isCredentialName(resolve(m[1]))) { const vs = m.index + m[0].length + 1; let ve = vs; while (ve < s.length && !'/?#"\'<>) \t\r\n'.includes(s[ve])) ve++;
1155
+ if (/^o?auth2?$/i.test(m[1]) && AUTH_ROUTES.test(s.slice(vs, ve))) continue; ivs.push([vs, ve, 'SECRET']); }
1156
+ return applyIntervals(s, ivs, label);
1157
+ }
1158
+
1159
+ // Multipart form data: a part whose Content-Disposition names a credential field; its body (after the blank line, up to
1160
+ // the next boundary line starting with "--" or the end) goes.
1161
+ // The whole quoted name is read ("api key"; PR #27 review); an unquoted one runs to a space or semicolon.
1162
+ // Folded headers (a parameter on an indented continuation line, RFC 5322 §2.2.3) are read as one (PR #16 review).
1163
+ const PART_HEADER = /Content-Disposition:(?:[^\r\n]|\r?\n(?=[ \t]))*?\bname=(?:"([^"\n]{1,160})"|'([^'\n]{1,160})'|([^"'\s;\n]{1,160}))/gi;
1164
+ function scrubMultipart(s, label, resolve) {
1165
+ if (!/Content-Disposition/i.test(s)) return s;
1166
+ const ivs = []; PART_HEADER.lastIndex = 0;
1167
+ for (let m; (m = PART_HEADER.exec(s));) {
1168
+ if (!isCredentialName(resolve(m[1] ?? m[2] ?? m[3]))) continue;
1169
+ const blank = s.indexOf('\n\n', m.index), blankCr = s.indexOf('\r\n\r\n', m.index);
1170
+ const start = blank < 0 ? (blankCr < 0 ? -1 : blankCr + 4) : (blankCr >= 0 && blankCr < blank ? blankCr + 4 : blank + 2);
1171
+ if (start < 0 || start - m.index > 4000) continue;
1172
+ // Stop only at this part's own boundary line (the last "--x" line before the header); a body line that merely starts
1173
+ // with "--" is content. With no boundary in sight the body runs to the end of the input (PR #27 review).
1174
+ const before = s.slice(Math.max(0, m.index - 4000), m.index).split(/\r?\n/).reverse().find(l => /^--\S/.test(l));
1175
+ const boundary = before ? before.trim() : null;
1176
+ let end = start;
1177
+ while (end < s.length) { let le = s.indexOf('\n', end); if (le < 0) le = s.length; const ln = s.slice(end, le).replace(/\r$/, '').trim(); if (boundary && (ln === boundary || ln === boundary + '--')) break; end = le + 1; }
1178
+ if (end > s.length) end = s.length;
1179
+ const body = s.slice(start, end).replace(/\r?\n$/, ''); if (body.trim()) ivs.push([start, start + body.length, 'SECRET']);
1180
+ }
1181
+ return applyIntervals(s, ivs, label);
1182
+ }
1183
+
1184
+ // XML/HTML elements whose tag name is a credential name (<ageToken>…</ageToken>, <snykToken>…</snykToken>): the content
1185
+ // between the tags goes, the tags stay. Linear: each opening tag is found once and the closing tag with one indexOf;
1186
+ // content is bounded at 64 KB so a tag never opened is not a quadratic scan.
1187
+ const XML_OPEN = /<([\p{L}_\uE000][\p{L}\p{N}_.\-:\uE000\uE001]{0,80})(?:\s[^<>\n]{0,200})?>/gu; // Unicode names too (<пароль>; PR #27 review)
1188
+ function scrubXml(s, label, resolve) {
1189
+ if (!s.includes('<')) return s;
1190
+ // Every "</" position once, so each opening tag finds its closing tag by binary search instead of an indexOf that,
1191
+ // for a tag never closed, would scan to the end of the text (20000 unclosed tags made that quadratic).
1192
+ // An end tag inside a comment or CDATA section is text, not markup (<password><!-- </password> -->x</password>; PR #16
1193
+ // review): those spans are found once, left to right, and their "</" are skipped. An unterminated one runs to the end.
1194
+ const skip = []; for (let k = 0; k < s.length;) {
1195
+ const c = s.indexOf('<!--', k), d = s.indexOf('<![CDATA[', k); if (c < 0 && d < 0) break;
1196
+ const a = c < 0 ? d : d < 0 ? c : Math.min(c, d), cd = a === d, e = s.indexOf(cd ? ']]>' : '-->', a + (cd ? 9 : 4));
1197
+ const b = e < 0 ? s.length : e + 3; skip.push([a, b]); k = b;
1198
+ }
1199
+ const closes = []; for (let k = s.indexOf('</'), si = 0; k >= 0; k = s.indexOf('</', k + 2)) { while (si < skip.length && skip[si][1] <= k) si++; if (si < skip.length && skip[si][0] <= k) { k = skip[si][1] - 2; continue; } closes.push(k); }
1200
+ const nextClose = (from) => { let lo = 0, hi = closes.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (closes[mid] < from) lo = mid + 1; else hi = mid; } return lo < closes.length ? closes[lo] : -1; };
1201
+ let out = '', at = 0; XML_OPEN.lastIndex = 0;
1202
+ for (let m; (m = XML_OPEN.exec(s));) {
1203
+ const name = resolve(m[1]);
1204
+ if (!isCredentialName(name)) continue;
1205
+ const open = m.index + m[0].length;
1206
+ // The matching closing tag may come after nested elements (<password><value>…</value></password>; PR #27 review):
1207
+ // walk the closing tags inside the 64 KB window until this element's own one.
1208
+ let ci = closes.length; { let lo = 0, hi = closes.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (closes[mid] < open) lo = mid + 1; else hi = mid; } ci = lo; }
1209
+ // XML allows whitespace before the end tag's ">" (</password >; PR #27 review).
1210
+ let close = -1; const want = '</' + m[1], isEnd = (at) => s.slice(at, at + want.length).toLowerCase() === want.toLowerCase() && // HTML tag names ignore case (<Password>…</PASSWORD>; PR #16 review)
1211
+ /^[ \t\r\n]*>/.test(s.slice(at + want.length, at + want.length + 40));
1212
+ let k = ci, n = 0;
1213
+ // Same-name elements nested inside count too (<password><password>a</password>b</password>; PR #16 review).
1214
+ const openRe = new RegExp('<' + m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=[\\s>/])', 'gi');
1215
+ let depth = 1, from = open;
1216
+ for (; k < closes.length && closes[k] - open <= 65536 && n < 256; k++, n++) {
1217
+ if (!isEnd(closes[k])) continue;
1218
+ // A self-closing same-name element (<password/>) opens nothing (PR #16 review).
1219
+ openRe.lastIndex = from; for (let o; (o = openRe.exec(s)) && o.index < closes[k];) { const gt = s.indexOf('>', o.index); if (gt < 0 || s[gt - 1] !== '/') depth++; }
1220
+ from = closes[k];
1221
+ if (--depth === 0) { close = closes[k]; break; }
1222
+ }
1223
+ // Too many nested closing tags to walk: take the rest of the 64 KB window rather than risk leaving the value (PR #27).
1224
+ if (close < 0 && (n >= 256 || (k < closes.length && closes[k] - open > 65536))) close = s.length; // the rest of the text, not just the window (PR #16 review)
1225
+ // No matching end tag (pasted or cut-off markup: <password>hunter2, <password>x</foo>; PR #27 review): the value runs
1226
+ // to the next tag or the end of its line (the next non-blank line when the tag ends its own line).
1227
+ if (close < 0 && /\/\s*>$/.test(m[0])) continue; // <password /> is empty, not unclosed
1228
+ if (close < 0) { const lead = /^[ \t]*(?:\r?\n[ \t\r\n]*)?/.exec(s.slice(open, open + 4096))[0].length; const R = /[<\n]|$/g; R.lastIndex = open + lead; close = Math.min(R.exec(s).index, open + 65536); }
1229
+ const content = s.slice(open, close);
1230
+ if (content.trim() && !isPlaceholderValue(content)) { out += s.slice(at, open) + label('SECRET', content); at = close; }
1231
+ XML_OPEN.lastIndex = close;
1232
+ }
1233
+ return out + s.slice(at);
1234
+ }
1235
+
1236
+ // A credential passed as a quoted call argument in code: std::string secret("…"), setPassword('…'). The name before the
1237
+ // parenthesis is checked like a setting name; only the quoted literal goes.
1238
+ function scrubCallArg(s, label, resolve) {
1239
+ // SMTP/IMAP "AUTH PLAIN <base64>" initial responses (RFC 4954; PR #27 review).
1240
+ s = s.replace(/^((?:[ \t]*(?:USER|AGENT|C|S|>>?|<<?):?)?[ \t]*(?:\w+[ \t]+)?AUTH(?:ENTICATE)?[ \t]+(?:PLAIN|LOGIN|XOAUTH2?|OAUTHBEARER|CRAM-MD5|DIGEST-MD5|SCRAM-SHA-\d+(?:-PLUS)?)[ \t]+)(\S+)/gim, (m, pre, v) =>
1241
+ v === '=' || isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1242
+ // SASL challenge-response (RFC 4954 §4; IMAP "+" continuations, RFC 9051): after an AUTH command, each client line
1243
+ // answering a server challenge ("334 …" or "+ …") is a Base64 credential response (PR #27 review).
1244
+ if (/\bAUTH(?:ENTICATE)?\b/i.test(s)) s = s.replace(/^([ \t]*(?:(?:S|SERVER|AGENT|<<?):?[ \t]*)?(?:334|\+)(?:[ \t][^\n]*)?\r?\n[ \t]*(?:(?:C|CLIENT|USER|>>?):?[ \t]*)?)([A-Za-z0-9+\/]{2,}={0,2})(?=[ \t]*\r?$)/gm, (m, pre, v) => v === '*' || isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1245
+ // Redis "AUTH [username] password" (redis.io/docs/latest/commands/auth; PR #27 review): the last argument is the
1246
+ // password. On a redis-cli line, after a redis prompt (host:port>), or as an uppercase AUTH command at line start;
1247
+ // a SASL mechanism after AUTH is the SMTP/IMAP form above, not this one.
1248
+ const REDIS_AUTH = /^((?:[ \t]*(?:USER|AGENT|C|S):?)?(?:.*\bredis-cli\b.*?[ \t]|[ \t]*[\w.\-]+(?::\d+)?(?:\[\d+\])?>[ \t]*|[ \t]*)(?:AUTH|auth)[ \t]+(?!(?:PLAIN|LOGIN|XOAUTH2?|OAUTHBEARER|CRAM-MD5|DIGEST-MD5|SCRAM-SHA-\d+(?:-PLUS)?)\b)(?:(?:"[^"\n]*"|'[^'\n]*'|\S+)[ \t]+)?)("[^"\n]*"|'[^'\n]*'|\S+)[ \t]*$/gm;
1249
+ s = s.replace(REDIS_AUTH, (m, pre, v) => /^[a-z]/.test(pre.trimStart()) && !/\bredis-cli\b|>/.test(pre) && /^[ \t]*auth\b/.test(pre) ? m : (isPlaceholderValue(v) ? m : pre + label('SECRET', v)));
1250
+ // IMAP "tag LOGIN user password" (RFC 9051 §6.2.3; PR #27 review): the second argument is the plaintext password.
1251
+ s = s.replace(/^((?:[ \t]*(?:USER|AGENT|C|S):?)?[ \t]*[A-Za-z0-9.]{1,20}[ \t]+LOGIN[ \t]+(?:"[^"\n]*"|\S+)[ \t]+)("[^"\n]*"|\S+)/gim, (m, pre, v) =>
1252
+ isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1253
+ // MongoDB db.auth("user", "pw") and similar (user, password) signatures: the second quoted argument goes too
1254
+ // (PR #27 review).
1255
+ s = s.replace(/\b(?:db\.auth|auth|login|signIn|authenticate)[ \t]*\([ \t]*("[^"\n]*"|'[^'\n]*')[ \t]*,[ \t]*("[^"\n]*"|'[^'\n]*')/gu, (m, u, v) =>
1256
+ isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1257
+ return s.replace(/\b([\p{L}_$\uE000][\p{L}\p{N}_$.\uE000\uE001]{0,60})[ \t]*[({][ \t]*("[^"\n]*"|'[^'\n]*')/gu, (m, name, v) =>
1258
+ (!isCredentialName(resolve(name)) || isPlaceholderValue(v)) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1259
+ }
1260
+
1261
+ // SQL account statements: MySQL "IDENTIFIED [WITH plugin] BY [PASSWORD] 'x'", PostgreSQL "[WITH] [ENCRYPTED] PASSWORD 'x'"
1262
+ // after CREATE/ALTER USER or ROLE, SQL Server "WITH PASSWORD = 'x'" (PR #27 review).
1263
+ const SQL_IDENTIFIED = /\bIDENTIFIED[ \t]+(?:WITH[ \t]+\S+[ \t]+)?BY[ \t]+(?:PASSWORD[ \t]+)?('(?:''|[^'\n])*'|"[^"\n]*"|[^\s;,)]+)/gi;
1264
+ const SQL_SET_PASSWORD = /\bSET[ \t]+PASSWORD\b[^;\n]{0,200}?=[ \t]*(?:PASSWORD[ \t]*\([ \t]*)?('(?:''|[^'\n])*'|"[^"\n]*")/gi; // MySQL SET PASSWORD [FOR u] = '…' (PR #27)
1265
+ const SQL_PASSWORD = /\b(?:CREATE|ALTER)[ \t]+(?:USER|ROLE|LOGIN)\b[^;\n]{0,200}?\bPASSWORD[ \t]*=?[ \t]*('(?:''|[^'\n])*'|"[^"\n]*")/gi;
1266
+ function scrubSqlAuth(s, label) {
1267
+ if (!/IDENTIFIED|PASSWORD/i.test(s)) return s;
1268
+ const f = (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v);
1269
+ return s.replace(SQL_IDENTIFIED, f).replace(SQL_PASSWORD, f).replace(SQL_SET_PASSWORD, f);
1270
+ }
1271
+
1272
+ // PostgreSQL password file lines: hostname:port:database:username:password, fields may hold \: escapes; the fifth field
1273
+ // goes (PR #27 review). The port must be a number or *, which keeps times and IPv6 out.
1274
+ const PGPASS = /^((?:\\.|[^:\\\s])+):(\d{1,5}|\*):((?:\\.|[^:\\\n])*):((?:\\.|[^:\\\n])*):((?:\\.|[^\\\n])+)$/gm;
1275
+ function scrubPgpass(s, label) {
1276
+ if (!s.includes(':')) return s;
1277
+ return s.replace(PGPASS, (m, h, p, d, u, pw) => isPlaceholderValue(pw) ? m : m.slice(0, m.length - pw.length) + label('SECRET', pw));
1278
+ }
1279
+
1280
+ // netrc: "password <value>" / "account <value>" with no separator. Applied only when the text is actually a netrc file
1281
+ // (it has a line starting with "machine" or "default"), and only to a value that is not itself a separator — so prose
1282
+ // like "Password requirements: 12 characters" and the assignment "password = x" are left for the other passes.
1283
+ const NETRC_ANCHOR = /^[ \t]*(?:machine|default)\b/im;
1284
+ function scrubNetrc(s, label) {
1285
+ if (!NETRC_ANCHOR.test(s)) return s;
1286
+ return s.replace(/^[ \t]*(?:machine|default|login|password|account)\b[^\n]*$/gim,
1287
+ (line) => line.replace(/\b(password|account)[ \t]+(?![=:])("(?:\\.|[^"\\\n])*"?|(?:\\.|\S)+)/gi, // a quoted token or backslash-escaped spaces (PR #16 review)
1288
+ (m, k, v) => isPlaceholderValue(v) ? m : `${k} ${label('SECRET', v)}`));
1289
+ }
1290
+
1291
+ // Credential words and phrases that appear in prose: "the password is X", "your recovery code is X". The copula "is"
1292
+ // is the only link accepted, so "I forgot my password yesterday" is left alone.
1293
+ // Credential words and phrases in prose, in English and the other languages the scrubber knows. Guards are
1294
+ // Unicode-aware lookarounds (JS \b only knows ASCII letters, so "пароль" or "密码" would never sit at a \b).
1295
+ const MULTI_CRED = 'mot[ \\t]+de[ \\t]+passe|mdp|cl[eé][ \\t]+(?:api|secr[eè]te)|cl[eé]|jeton|passw[oö]rter|passwort|kennwort|geheimnis|schl[uü]ssel|zugangsdaten|contrase[ñn]a|clave|secreto|credenciales|senha|chave|chiave|segreto|wachtwoord|sleutel|пароль|секрет|ключ|токен|パスワード|秘密鍵|トークン|密码|密钥|口令|令牌|비밀번호|암호';
1296
+ const PROSE_CRED = '(?:one[ _-]?time[ _-]?(?:pass)?codes?|one[ _-]?time[ _-]?passwords?|totp|hotp|(?:seed|recovery|mnemonic|secret|wallet)[ _-]?phrases?|mnemonics?|cvv2?|cvc2?|passwords?|passphrases?|passwd|pwd|pw|passcodes?|pins?|otp|secrets?|creds?|credentials?|tokens?|api[ _-]?keys?|auth[ _-]?tokens?|access[ _-]?tokens?|(?:recovery|backup|verification|security|auth|2fa|mfa)[ _-]?codes?|' + MULTI_CRED + ')';
1297
+ const NOT_L = '(?![\\p{L}\\p{N}])', NOT_L_BEHIND = '(?<![\\p{L}\\p{N}])';
1298
+ // "password is X", "creds are user:pass", "pw's X", "le mot de passe est X", "das Passwort ist X", "la contraseña es X":
1299
+ // a copula (is/are/est/ist/lautet/es/é/è/это/—) or a possessive 's, then the value.
1300
+ // Up to two plain words may sit between the noun and the copula ("the secret phrase is X", "my api key here is X") when
1301
+ // the value carries a digit or a symbol, so "Our API key rotation policy is documented" (a plain word) is left alone.
1302
+ // Words that describe a password rather than spell it ("is not working", "is too short") never extend a value.
1303
+ const EXT_WORD = '(?!(?:not|no|wrong|incorrect|invalid|expired|too|missing|required|empty|blank|weak|strong|short|long|working|broken|locked|reset|changed|the|a|an|my|your|our|their|in|on|at|for|to|of|and|but|or|so|very|still|now|again|anymore|i|it|that|this|please|help|me|you|we|they)(?![\\p{L}\\p{N}]))[\\p{L}\\p{N}]+';
1304
+ // An unquoted value takes the following lowercase words too when they run to the end of the clause, so a passphrase
1305
+ // ("correct horse battery staple") or a 24-word recovery phrase goes whole (PR #27 review).
1306
+ const COPULA_RE = new RegExp(NOT_L_BEHIND + PROSE_CRED + NOT_L + '(?:[ \\t]+(?:is|are|est|ist|lautet|es|é|è|это|—)(?:[ \\t]*[:=][ \\t]*|[ \\t]+)|[\'’]s[ \\t]+)("[^"\\n]*"|\'[^\'\\n]*\'|\\S+(?:(?:[ \\t]+' + EXT_WORD + '){1,23}(?=[ \\t]*(?:[.,;!?\\n]|$)))?)', 'giu');
1307
+ // "recovery phrase: abandon ability …", "seed phrase = …": a wallet phrase cue with a separator takes the rest of the line.
1308
+ const WALLET_RE = new RegExp(NOT_L_BEHIND + '(?:(?:seed|recovery|mnemonic|secret|wallet)[ _-]?phrases?|mnemonics?)' + NOT_L + '[ \\t]*(?:[:=\\uFF1A][ \\t]*)([^\\n]+)', 'giu');
1309
+ // "aws configure set aws_secret_access_key X", "npm config set _authToken X": a config command naming a credential.
1310
+ const CONFIG_SET_RE = /\b(?:configure|config)[ \t]+set[ \t]+(["']?)([\w.\-:\/]{1,80})\1[ \t]+([^\n]+)/g;
1311
+ // Past and changed forms ("was", "will be", "changed to") take a value only when it is quoted or token-like (has a digit,
1312
+ // punctuation or symbol), so "the password was reset yesterday" keeps its words (PR #27 review).
1313
+ const PAST_RE = new RegExp(NOT_L_BEHIND + PROSE_CRED + NOT_L + '[ \\t]+(?:was|were|will[ \\t]+be|(?:has|had|have)[ \\t]+been|(?:is|was)[ \\t]+now|(?:changed|set|reset|updated)[ \\t]+to)[ \\t]+("[^"\\n]+"|\'[^\'\\n]+\'|(?=\\S*[\\p{N}\\p{S}]|\\S*\\p{P}\\S)\\S+)', 'giu');
1314
+ const QUAL_COPULA_RE = new RegExp(NOT_L_BEHIND + PROSE_CRED + NOT_L + '(?:[ \\t]+\\p{L}{1,20}){1,2}' + '[ \\t]+(?:is|are|est|ist|lautet|es|é|è|это|—)[ \\t]+("[^"\\n]+"|\'[^\'\\n]+\'|(?=\\S*\\p{N}|\\S*[\\p{P}\\p{S}]\\S)\\S+)', 'giu'); // a digit anywhere, the last character too (hunter2; PR #27 review)
1315
+ // A credential noun, after a prose word ("use the password", "enter api key"), followed directly by its value: a quoted
1316
+ // token that carries a digit or a symbol before its last character ("use password x9k2"), so "my password yesterday."
1317
+ // is left alone (letters only, and a final full stop does not count).
1318
+ // The gap before the noun is bounded (1-4 blanks): an unbounded [ \t]+ lookbehind re-scanned every blank of a long
1319
+ // indent at each position and made 2000 growing-indent YAML lines take 1.8 s on Node 22 (PR #16 CI on Linux).
1320
+ const NOUN_VALUE_RE = new RegExp('(?<=\\p{L}[ \\t]{1,4})' + PROSE_CRED + NOT_L + '[ \\t]+("[^"\\n]+"|\'[^\'\\n]+\'|(?=\\S*[\\p{N}\\p{P}\\p{S}]\\S)[\\p{L}\\p{N}][^\\s"\']*)', 'giu');
1321
+ // For the report only (never for scrubbing or anything sent out): the same text with the credential word of each
1322
+ // prose-shaped capture swapped for "thing" ("its token won't be lost" -> "its thing won't be lost"), so a fresh scrub
1323
+ // counts only real-looking settings. Assignments, flags, links and every other rule are untouched, and it is one pass
1324
+ // per regex (tune-up PR #26 review).
1325
+ const PROSE_CRED_LEAD = new RegExp('^' + PROSE_CRED, 'iu');
1326
+ export function neutralizeProse(text) {
1327
+ let t = String(text);
1328
+ for (const re of [COPULA_RE, QUAL_COPULA_RE, NOUN_VALUE_RE]) {
1329
+ re.lastIndex = 0;
1330
+ t = t.replace(re, (m, v, o, str) => {
1331
+ if (!isProseValue(v, str.slice(o + m.length))) return m;
1332
+ const lead = PROSE_CRED_LEAD.exec(m); return lead ? 'thing' + m.slice(lead[0].length) : m;
1333
+ });
1334
+ }
1335
+ return t;
1336
+ }
1337
+ // A multi-word foreign name before a separator: "Mot de passe: X", "clé API = X".
1338
+ const PHRASE2_RE = new RegExp(NOT_L_BEHIND + '(?:mot[ \\t]+de[ \\t]+passe|cl[eé][ \\t]+(?:api|secr[eè]te)|chave[ \\t]+(?:de[ \\t]+)?api)' + NOT_L + '[ \\t]*(?:->|[:=\\u2236\\uFF1D\\uFF1A\\u2192])[ \\t]*("[^"\\n]*"|\'[^\'\\n]*\'|[^\\n]+)', 'giu');
1339
+ // "I changed the password to …", "set my PIN to …" (PR #27 review).
1340
+ const CHANGE_RE = new RegExp(NOT_L_BEHIND + '(?:changed|set|reset|updated|switched)[ \\t]+(?:(?:the|my|our|your|his|her|their)[ \\t]+)?(?:\\p{L}{1,20}[ \\t]+)?' + PROSE_CRED + NOT_L + '[ \\t]+to[ \\t]+("[^"\\n]*"|\'[^\'\\n]*\'|\\S+)', 'giu');
1341
+ function scrubCopula(s, label) {
1342
+ s = s.replace(WALLET_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1343
+ s = s.replace(CONFIG_SET_RE, (m, q, name, v) => !isCredentialName(name) || isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1344
+ s = s.replace(CHANGE_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1345
+ s = s.replace(PAST_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1346
+ const out = s.replace(COPULA_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1347
+ const mid = out.replace(QUAL_COPULA_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1348
+ return mid.replace(NOUN_VALUE_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1349
+ }
1350
+
1351
+ // A credential phrase written as separate words before a colon or equals: "api key: X", "openai api key = X",
1352
+ // "recovery code: X". One or more qualifier words then key/token/secret/password/credential/code, then the value. A
1353
+ // colon or equals is required, so prose like "Our API key rotation policy" (no separator) is left alone. At least one
1354
+ // qualifier word followed by a space, so a single-word "password:" is left to the assignment pass (which also handles
1355
+ // its continuation lines).
1356
+ const PHRASE_RE = /\b(?:(?:api|access|secret|private|client|auth|refresh|session|bearer|recovery|backup|verification|security|2fa|mfa)[ \t]+)+(?:key|token|secret|password|credential|codes?)\b(?:[ \t]*(?:\([^()\n]{1,40}\)|\[[^\[\]\n]{1,40}\]|\/[\p{L}\p{N}_\-]{1,40}))?[ \t]*[:=][ \t]*("[^"\n]*"|'[^'\n]*'|[^\n]+)/giu;
1357
+ function scrubPhrase(s, label) {
1358
+ const out = s.replace(PHRASE_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1359
+ return out.replace(PHRASE2_RE, (m, v) => isPlaceholderValue(v) ? m : m.slice(0, m.length - v.length) + label('SECRET', v));
1360
+ }
1361
+
1362
+ // Command-line credential flags, matched on the folded view (PowerShell's -Client`Secret is -ClientSecret there): any
1363
+ // -flag or --flag whose name is a credential name takes the next value, bare or quoted, after "=" or ":" (spaces allowed
1364
+ // around them: --password = x, Java -Dpassword:x) or a space (-Password x). A bare value may continue on the next line
1365
+ // after a backslash. curl's -u / --user take the whole user:pass. mysql's glued -pVALUE is handled first so its "p" is
1366
+ // not read as a flag name.
1367
+ const FLAG_P = /(^|\s)(-p)([^\s'"]+)/g; // no minimum: -pX is a password too (PR #16 review)
1368
+ const FLAG_USER = /(^|\s)(-u|-U|--user|--proxy-user)(=|[ \t]+|(?<=-[uU])(?=[^\s=-]))((?:"[^"\n]*"|'[^'\n]*'|[^\s'"])+)/g;
1369
+ const FLAG_ANY = /(^|[\s'"(])(--?)([A-Za-z][\w.\-]{0,60})([ \t]*[=:][ \t]*|[ \t]+)((?:"(?:\\.|[^"\\\n])*"|'[^'\n]*'|\\\r?\n|\\.|[^\s'"\\])+)/g; // the whole shell word: adjacent quoted, escaped and bare fragments ('foo'\''bar', PowerShell 'foo''bar'; PR #16 review)
1370
+ // SQL Server tools take the password as -P (sqlcmd -S db -U alice -P x, or -Px); only on a line naming one of them,
1371
+ // since -P is a port elsewhere (PR #27 review).
1372
+ const SQLCMD_LINE = /^.*\b(?:sqlcmd|osql|isql|bcp)\b.*$/gim;
1373
+ function scrubFlags(s, label, V) {
1374
+ // Positional-password commands that need no "-" run before the fast path (vault login x; PR #27 review).
1375
+ s = s.replace(/^.*\bvault[ \t]+login\b.*$/gim, (line) => line.replace(/(\bvault[ \t]+login(?:[ \t]+-\S+)*[ \t]+)("[^"\n]*"|'[^'\n]*'|[^\s"'=-][^\s"'=]*)(?=[ \t]*$)/i, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v)));
1376
+ // rabbitmqctl add_user|change_password|authenticate_user <user> <password> (rabbitmq.com rabbitmqctl(8); PR #27 review).
1377
+ s = s.replace(/(\brabbitmqctl\b(?:[ \t]+-\S+(?:[ \t]+[^\s-]\S*)?)*[ \t]+(?:add_user|change_password|authenticate_user)[ \t]+(?:"[^"\n]*"|'[^'\n]*'|\S+)[ \t]+)("[^"\n]*"|'[^'\n]*'|[^\s"']+)/gi, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1378
+ if (!s.includes('-')) return s;
1379
+ s = s.replace(/^.*\bsshpass\b.*$/gim, (line) => line.replace(/(^|\s)-p[ \t]+("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : `${pre}-p ${label('SECRET', v)}`)); // sshpass -p x (PR #27)
1380
+ // htpasswd -b … user password: the last argument is the password; smbclient/rpcclient/smbmap -U user%password (PR #27).
1381
+ s = s.replace(/^(.*\bhtpasswd\b.*[ \t]-\w*b\w*\b.*[ \t])("[^"\n]*"|'[^'\n]*'|[^\s"']+)[ \t]*$/gim, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1382
+ s = s.replace(/^.*\b(?:smbclient|rpcclient|smbmap|smbget|smbcacls|net|mount\.cifs)\b.*$/gim, (line) => line.replace(/((?:^|\s)(?:-U|--user(?:name)?)(?:=|[ \t]+)["']?[^\s%"']*%)("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v)));
1383
+ // kubectl create secret … --from-literal=key=value: every literal is Secret data, whatever its key (kubernetes.io
1384
+ // kubectl create secret generic; PR #27 review).
1385
+ s = s.replace(/^.*\b(?:kubectl|oc)\b.*\bcreate[ \t]+secret\b.*$/gim, (line) => line.replace(/(--from-literal(?:=|[ \t]+)["']?[^=\s"']*=)("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v)));
1386
+ // docker/podman/buildah/nerdctl/helm … login -p x: -p is --password there (docs.docker.com docker login; PR #27 review).
1387
+ s = s.replace(/^.*\b(?:docker|podman|buildah|nerdctl|skopeo|helm|oras)\b.*\blogin\b.*$/gim, (line) => line.replace(/(^|\s)-p[ \t]+("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : `${pre}-p ${label('SECRET', v)}`));
1388
+ s = s.replace(/^.*\bredis-cli\b.*$/gim, (line) => line.replace(/(^|\s)-a[ \t]+("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : `${pre}-a ${label('SECRET', v)}`)); // redis-cli -a x (PR #27)
1389
+ // mosquitto_pub/_sub/_rr -P <password> (mosquitto.org mosquitto_pub(1); PR #27 review).
1390
+ s = s.replace(/^.*\bmosquitto_\w+\b.*$/gim, (line) => line.replace(/(^|\s)-P(?:[ \t]+)("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : `${pre}-P ${label('SECRET', v)}`));
1391
+ s = s.replace(SQLCMD_LINE, (line) => line.replace(/(^|\s)-P(?:[ \t]+)?("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : `${pre}-P ${label('SECRET', v)}`));
1392
+ s = applyOnView(s, V(s), FLAG_P, label, (m) => ({ from: m.index + m[1].length + 2, to: m.index + m[0].length, kind: 'SECRET' }));
1393
+ s = applyOnView(s, V(s), FLAG_USER, label, (m) => m[4].includes(':') ? { from: m.index + m[0].length - m[4].length, to: m.index + m[0].length, kind: 'SECRET' } : null);
1394
+ // --password-stdin (docker login, helm registry login, …) takes no argument; the password is the output of an echo or
1395
+ // printf piped into the command on the same line (docs.docker.com docker login; PR #27 review).
1396
+ s = s.replace(/^.*--[\w-]*pass(?:word)?-stdin\b.*$/gim, (line) => line.replace(/(\b(?:echo|printf)(?:[ \t]+-[a-zA-Z]+)*(?:[ \t]+(?:'%s\\?n?'|"%s\\?n?"))?[ \t]+)([^|\n]*?)([ \t]*\|)/g, (m, pre, v, pipe) => !v.trim() || isPlaceholderValue(v) ? m : pre + label('SECRET', v) + pipe));
1397
+ // A here-string (--password-stdin <<< x) or a heredoc body feeds standard input too (docs.docker.com; PR #27 review).
1398
+ s = s.replace(/^.*--[\w-]*pass(?:word)?-stdin\b.*$/gim, (line) => line.replace(/(<<<[ \t]*)("[^"\n]*"|'[^'\n]*'|[^\s|;&]+)/g, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v)));
1399
+ s = scrubHeredocStdin(s, label);
1400
+ return applyOnView(s, V(s), FLAG_ANY, label, (m) => isCredentialName(m[3]) && !/-stdin$/i.test(m[3]) ? { from: m.index + m[0].length - m[5].length, to: m.index + m[0].length, kind: 'SECRET' } : null);
1401
+ }
1402
+
1403
+ // The body of a heredoc on a --password-stdin line is the password: every non-blank line up to the terminator goes. An
1404
+ // unterminated heredoc fails closed (the rest of the text goes) and ends the scan, so many of them stay linear.
1405
+ function scrubHeredocStdin(s, label) {
1406
+ if (!/-stdin\b/i.test(s) || !s.includes('<<')) return s;
1407
+ let out = '', at = 0, i = 0;
1408
+ while (i < s.length) {
1409
+ let nl = s.indexOf('\n', i); if (nl < 0) nl = s.length;
1410
+ const line = s.slice(i, nl), h = line.includes('<<') && /--[\w-]*pass(?:word)?-stdin\b/i.test(line) ? /(?<!<)<<-?[ \t]*(["']?)([A-Za-z_]\w{0,40})\1(?![\w'"])/.exec(line) : null;
1411
+ if (!h || nl >= s.length) { i = nl + 1; continue; }
1412
+ const bodyStart = nl + 1; let j = bodyStart, bodyEnd = s.length, found = false;
1413
+ while (j < s.length) { let n2 = s.indexOf('\n', j); if (n2 < 0) n2 = s.length; if (s.slice(j, n2).replace(/\r$/, '').trim() === h[2]) { bodyEnd = j; found = true; break; } j = n2 + 1; }
1414
+ out += s.slice(at, bodyStart) + s.slice(bodyStart, bodyEnd).replace(/^([ \t]*)(\S[^\n]*?)([ \t]*\r?)$/gm, (x, a, v, b) => isPlaceholderValue(v) ? x : a + label('SECRET', v) + b);
1415
+ at = bodyEnd; if (!found) break; i = bodyEnd;
1416
+ }
1417
+ return out + s.slice(at);
1418
+ }
1419
+
1420
+ // Command forms: Windows `net use … /user:DOMAIN\\alice <pass>` (the token after the /user: token) and PowerShell
1421
+ // `ConvertTo-SecureString '<x>' -AsPlainText` (the quoted argument). A credential header name followed by a space
1422
+ // instead of a colon inside a bracket list ([Set-Cookie sessionid=x; Path=/, …]) takes the value up to the "," or "]".
1423
+ function scrubCommands(s, label) {
1424
+ let out = s.replace(/(\/user:\S+[ \t]+)([^\s/][^\s]*)/gi, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1425
+ out = out.replace(/(ConvertTo-SecureString[ \t]+)("[^"\n]*"|'[^'\n]*')/gi, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1426
+ // New-Object pscredential 'user', '<x>' / PSCredential('user', '<x>') / [pscredential]::new('user', '<x>'): the second
1427
+ // quoted argument is the password.
1428
+ out = out.replace(/(pscredential\b[^\n(]{0,40}\(?[ \t]*(?:"[^"\n]*"|'[^'\n]*')[ \t]*,[ \t]*)("[^"\n]*"|'[^'\n]*')/gi, (m, pre, v) => isPlaceholderValue(v) ? m : pre + label('SECRET', v));
1429
+ return out.replace(/\b(Set-Cookie|Cookie|Authorization|Proxy-Authorization)([ \t]+)((?:Basic|Bearer|Digest)\b[^,\]\n]*|[^,\]\n]*=[^,\]\n]*)/gi, (m, h, sp, v) => isPlaceholderValue(v) ? m : h + sp + label('SECRET', v));
1430
+ }
1431
+
1432
+ // URL query parameters whose name is a credential (code, auth, sig, token, session, …, or anything isCredentialName
1433
+ // accepts) have their value replaced.
1434
+ const QUERY_NAMES = new Set(['code', 'auth', 'sig', 'signature', 'key', 'token', 'secret', 'password', 'pwd', 'pass', 'access_token', 'refresh_token', 'id_token', 'apikey', 'api_key', 'session', 'sid', 'jwt', 'otp', 'code_verifier']);
1435
+ // OAuth sign-in values (state, PKCE challenge, client id) are taken only when they look generated: 16+ characters, after
1436
+ // trimming trailing punctuation and decoding, with a digit or both letter cases. A pasted sign-in URL is then scrubbed
1437
+ // rather than holding the chat, while ?state=open or ?state=please-open-modal stay readable (PR #24 review).
1438
+ const OAUTH_NAMES = new Set(['state', 'nonce', 'code_challenge', 'client_id']); // code_verifier redeems the code: a SECRET above
1439
+ const OAUTH_BEFORE = /[?&#](?:state|nonce|code_challenge|client_id)=$/i;
1440
+ function scrubQuery(s, label, resolve) {
1441
+ if (!s.includes('=')) return s;
1442
+ // Percent escapes anywhere in the name are decoded before the check (?%70assword=…; PR #27 review), and "+" is a space
1443
+ // as in form encoding (?api+key=…; PR #27 review). An array suffix (password[]=, password%5B0%5D=) is read past (PR #16).
1444
+ // A fragment-mode response (#state=…, #access_token=…) is read like a query (viaknox PR #36 review).
1445
+ return s.replace(/([?&#]((?:[A-Za-z0-9_.\-+]|%[0-9A-Fa-f]{2}){1,60})(?:\[[^\]\s&=#]{0,40}\]|%5[Bb](?:[^&=\s#%]|%(?![5][Dd])[0-9A-Fa-f]{2}){0,40}%5[Dd])?=)([^&\s#\]]+)/g, (m, pre, name, v) => {
1446
+ const n = decodeName(name.replace(/\+/g, ' '));
1447
+ const ln = n.toLowerCase();
1448
+ if (isPlaceholderValue(v)) return m;
1449
+ if (QUERY_NAMES.has(ln) || isCredentialName(resolve(n))) return pre + label('SECRET', v);
1450
+ if (!OAUTH_NAMES.has(ln)) return m;
1451
+ // A one-time sign-in value is hidden as a link part, not a SECRET: an agent showing its sign-in URL is not revealing a
1452
+ // password setting. Trailing punctuation stays outside ([Sign in](…?state=…). keeps ")."), trimmed with a loop, not
1453
+ // /[…]+$/ (quadratic). It recurs like a secret, so a bare repeat in a log line is replaced too (PR #24 review).
1454
+ let e = v.length; while (e > 0 && TRAIL_PUNCT.has(v[e - 1])) e--; const core = v.slice(0, e), dv = decodeName(core.replace(/\+/g, ' ')); // form encoding: + is a space (PR #24/#36 review)
1455
+ if (!(dv.length >= 16 && (/\d/.test(dv) || (/[a-z]/.test(dv) && /[A-Z]/.test(dv))))) return m;
1456
+ // Only a plain token (base64/base64url, hex, a UUID): a value holding "=", "@" or ":" (state=password=…, an email)
1457
+ // is left for the credential and PII rules, which still flag it (viaknox PR #36 review).
1458
+ // Any base64 or base64url character may lead after decoding (%2F…, %2B…; tune-up PR #24 review), but not . or ~.
1459
+ if (!/^[A-Za-z0-9_+\/-][A-Za-z0-9._~+\/-]*={0,2}$/.test(dv)) return m;
1460
+ // A known key shape (ghp_…) or a card number is left for the key and PII rules, so it is counted and echo-checked as
1461
+ // what it is (tune-up PR #24 review).
1462
+ if (KEY_SHAPE_ONE.test(dv) || (/^\d{13,19}$/.test(dv) && luhn(dv))) return m;
1463
+ return pre + label('LINK', core, true, dv !== core ? dv : null) + v.slice(core.length); // decoded spelling recurs too
1464
+ });
1465
+ }
1466
+
1467
+ // URL userinfo scheme://user:pass@host — the whole user:pass is replaced (a token-as-user form too). No length ceiling:
1468
+ // a 300-character generated password must go too, and the classes exclude whitespace, slash and @, so the scan stays
1469
+ // linear without one (PR #16 review).
1470
+ const URL_USERINFO = /((?=:\/\/)(?<=[a-z][a-z0-9+.\-]*):\/\/|(?<![\w:\/])\/\/(?=[^\s/@:]*:[^\s/@]+@))([^\s/@:]*:[^\s/@]+|[^\s/@:]{8,})@/gi; // and scheme-relative //user:pass@host (RFC 3986 network-path; PR #27 review) // a scheme may be one letter (x://) (PR #16 review)
1471
+
1472
+ const SCHEME_PROSE = /^(?:authentication|authorization|authorisation|credentials|tokens?|headers?|scheme|schemes|access|support|supported|login|logins)$/i;
1473
+ // Bare auth scheme tokens anywhere (inside a value or a curl -H string): Basic/Bearer + token, or Digest + its params.
1474
+ // Named header lines (Authorization:, Cookie:) are removed by scrubAssignments to the end of the line first; this is the
1475
+ // safety net for a scheme token that is not introduced by a header name.
1476
+ // After a lowercase scheme word, these plain words are prose ("basic understanding", "bearer instruments"); any other
1477
+ // lowercase value is treated as a token (send bearer correcthorse now; PR #16 review).
1478
+ const SCHEME_LOWER_PROSE = /^(?:understanding|knowledge|instruments?|bonds?|configuration|config|information|info|usage|idea|ideas|setup|version|plan|plans|account|level|training|rules?|needs|features?|principles?|functionality|structure|requirements?|skills?|questions?|steps?|stuff|things?|example|examples|form|format|search|support|package|tier|mode|level|policy|checks?|tests?|test|syntax|concepts?|terms|operations?|of|and|or|for|to|the|a|an|in|on|with|is|was)$/i;
1479
+ function scrubBareAuth(s, label) {
1480
+ // No minimum length (`Basic YTpi` is a:b in base64; PR #16 review); a short plain lowercase word ("Bearer token",
1481
+ // "Basic auth") is prose about the scheme, not a token, and stays.
1482
+ // Words that name the scheme in prose ("Basic authentication", "Bearer tokens") stay too, at any length; a Digest
1483
+ // credential always carries name=value parameters, so Digest without an "=" is prose (PR #27 review).
1484
+ // Scheme names are case-insensitive (bearer, BASIC; PR #16 review), and count after "_" too (id_Basic …; PR #16 review). After a scheme not written as "Basic"/"Bearer", a
1485
+ // plain lowercase word of any length is prose ("basic understanding"), so only a token-looking value goes.
1486
+ let out = s.replace(/(?<![A-Za-z0-9])(basic|bearer)[ \t]+([A-Za-z0-9._~+/\-]+={0,2})/gi, (m, sch, v) => (isPlaceholderValue(v) || (v.length < 8 && /^[a-z]+$/.test(v)) || (!/^(?:Basic|Bearer)$/.test(sch) && SCHEME_LOWER_PROSE.test(v.replace(/\.+$/, ''))) || SCHEME_PROSE.test(v.replace(/\.+$/, ''))) ? m : `${sch} ${label('KEY', v)}`);
1487
+ return out.replace(/(?<![A-Za-z0-9])(digest)[ \t]+(?=[^\n]*=)([^\n]*=[^\n]*)/gi, (m, sch, v) => isPlaceholderValue(v) ? m : `${sch} ${label('KEY', v)}`);
1488
+ }
1489
+
1490
+ // Known token shapes, shared by the main pass (KEY, matched on the folded view) and the residual scan. Assembled from
1491
+ // pieces where a literal would trip the repo's own secret gate. Sources for the shapes: gitleaks (MIT) and
1492
+ // detect-secrets (Apache-2.0) rule sets.
1493
+ const KEY_SHAPES = [
1494
+ 'sk-or-v1-[A-Za-z0-9]{20,}', 'sk-ant-[A-Za-z0-9_-]{10,}', 'sk-[A-Za-z0-9_-]{16,}', 'gh[pousr]_[A-Za-z0-9]{20,}',
1495
+ 'xox' + '[a-z]-[A-Za-z0-9-]{10,}', '(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}', 'AIza[0-9A-Za-z_-]{30,}',
1496
+ 'eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{6,}', 'AGE-SECRET-KEY-1[A-Z0-9]{58}',
1497
+ 'ABSK[A-Za-z0-9+/]{100,}={0,2}', 'gl(?:pat|rt|dt|ptt|soat|ft|oas|cbt|agent)-[A-Za-z0-9_-]{16,}',
1498
+ '[0-9a-f]{32}-us\\d{1,2}', 'v1\\.0-[a-f0-9]{24}-[a-f0-9]{146}',
1499
+ '[A-Za-z0-9_~.]{3}\\dQ~[A-Za-z0-9_~.-]{31,34}', 'api\\.softlayer\\.com/soap/v3(?:\\.1)?/[a-z0-9]{64}',
1500
+ '(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}', 'whsec_[A-Za-z0-9]{10,}', 'npm_[A-Za-z0-9]{20,}', 'ya29\\.[A-Za-z0-9_-]{20,}', 'pypi-[A-Za-z0-9_-]{20,}',
1501
+ ];
1502
+ const KEY_SHAPE_ONE = new RegExp('^(?:' + KEY_SHAPES.join('|') + ')'); // a whole value that starts as a known key
1503
+ const KEY_SHAPE_RE = new RegExp('(?<![A-Za-z0-9])(?:' + KEY_SHAPES.join('|') + ')', 'g'); // not \\b: id_sk-… must match (PR #16 review)
1504
+
1505
+ const EMAIL_WHOLE = /(?<![\p{L}\p{M}\p{N}!#$%&'*+\/=?^_`{|}~.-])[\p{L}\p{M}\p{N}!#$%&'*+\/=?^_`{|}~.-]{1,64}@[\p{L}\p{M}\p{N}.-]{1,253}\.[\p{L}\p{M}]{2,63}(?![\p{L}\p{M}\p{N}])/gu;
1506
+ const PATTERNS = [
1507
+ // Internationalised addresses too (usér@exämple.com, δοκιμή@παράδειγμα.δοκιμή): letters of any script, bounded classes.
1508
+ ['EMAIL', /"[^"\n]{1,64}"@[\p{L}\p{M}\p{N}.-]{1,253}\.[\p{L}\p{M}]{2,63}(?![\p{L}\p{M}\p{N}])/gu], // quoted local part ("john doe"@x.com; PR #27)
1509
+ // Combining marks count as letters, so a decomposed exa\u0301mple.com is still one address (PR #16 review).
1510
+ // The whole RFC 5322 atext alphabet in the local part (o'connor@…, alice!@…; PR #27 review).
1511
+ ['EMAIL', /(?<![\p{L}\p{M}\p{N}!#$%&'*+\/=?^_`{|}~.-])[\p{L}\p{M}\p{N}!#$%&'*+\/=?^_`{|}~.-]{1,64}@[\p{L}\p{M}\p{N}.-]{1,253}\.[\p{L}\p{M}]{2,63}(?![\p{L}\p{M}\p{N}])/gu],
1512
+ ['CARD', /(?<![\p{L}\p{N}])(?:\d[ -]?){12,18}\d(?![\p{L}\p{N}])/gu], // not \b: "id_4111…" must match (PR #16 review)
1513
+ ['SSN', /(?<![\p{L}\p{N}])(?<!\p{N}-)(?<!\p{N}\.)\d{3}([-. ])\d{2}\1\d{4}(?!\p{N}|[.-]\p{N})/gu], // a sentence-ending \".\" is not part of the number (PR #16 review) // dashes, spaces or dots, used consistently (123 45 6789; PR #27 review) // not \b: "id_123-45-6789" must match (PR #16 review)
1514
+ // A nine-digit SSN with no dashes counts after an SSN cue ("SSN: 123456789"; PR #27 review).
1515
+ // Cue-led shapes check for a digit first, and their gaps are unambiguous ([ \t]*(?:[:#=][ \t]*)?), so a long run of
1516
+ // spaces is walked once ("password" followed by thousands of spaces was cubic; PR #16 review).
1517
+ ['SSN', /(?=\+?\d)(?<=\b(?:ssn|social[ \t]+security)(?:[ \t]+(?:number|no\.?|#))?[ \t]+(?:is|was)[ \t]+)\d{9}(?!\d)/gi], // "my SSN is 123456789" (PR #27 review)
1518
+ ['SSN', /(?=\+?\d)(?<=\b(?:ssn|SSN|social[ \t]+security(?:[ \t]+(?:number|no\.?|#))?)[ \t]*(?:[:#=-][ \t]*)?)\d{9}\b/gi],
1519
+ ['PHONE', /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)[\s.-]?|\b\d{2,4}[\s.-])\d{3,4}[\s.-]\d{3,4}\b/g],
1520
+ // Compact numbers: E.164 (+14155552671) anywhere, and 10–15 digits after a phone cue (PR #27 review).
1521
+ ['PHONE', /(?<![A-Za-z0-9+])\+\d{10,15}(?!\d)/g],
1522
+ // International numbers with longer groups (+91-98765-43210, +49 1512 3456789): 8-15 digits in total (PR #16 review).
1523
+ ['PHONE', /(?<![A-Za-z0-9+])\+\d{1,3}(?:[ .-]\d{2,8}){1,5}(?![\w-])/g], // \"id_+14155552671\" too (PR #16 review)
1524
+ ['PHONE', /(?=\+?\d)(?<=\b(?:phone|tel|telephone|mobile|cell|call|text|whatsapp|sms)[ \t]*(?:[:#=][ \t]*)?)\d{10,15}\b/gi],
1525
+ // …and after "phone (number) is/was" (my phone is 4155552671; PR #27 review).
1526
+ ['PHONE', /(?=\+?\d)(?<=\b(?:phone|tel|telephone|mobile|cell|whatsapp)(?:[ \t]+(?:number|no\.?|#))?[ \t]+(?:is|was|are)[ \t]+)\+?\d{10,15}\b/gi],
1527
+ ['IP', /(?<![A-Za-z0-9])(?:\d{1,3}\.){3}\d{1,3}(?![A-Za-z0-9])/g], // not \b: "host_192.168.1.1" must match (PR #16 review)
1528
+ // IPv6, full or compressed; the callback keeps short look-alikes such as times (10:30:00) and C++ scopes (PR #27).
1529
+ ['IP', /(?<![\w:])(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}(?![\w:])|(?<![\w:])(?:[0-9a-f]{1,4}:){1,6}:(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,5})?(?![\w:])|(?<![\w:])::(?:[0-9a-f]{1,4}:){1,6}[0-9a-f]{1,4}(?![\w:])/gi], // ::ffff:c000:0280 (leading compression; PR #27 review)
1530
+ ];
1531
+
1532
+ function luhn(digits) {
1533
+ let sum = 0, alt = false;
1534
+ for (let i = digits.length - 1; i >= 0; i--) {
1535
+ let d = Number(digits[i]);
1536
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
1537
+ sum += d; alt = !alt;
1538
+ }
1539
+ return sum % 10 === 0;
1540
+ }
1541
+
1542
+ // --- Residual scan: a SECOND, independent mechanism (brief item 6), by shape and entropy rather than by context, so a
1543
+ // secret the main pass missed is still caught and the whole text is held back. Pure function; returns only the reason,
1544
+ // never the value.
1545
+ const PREFIX_SHAPES = [
1546
+ ['known key shape', new RegExp('(?<![A-Za-z0-9])(?:' + KEY_SHAPES.join('|') + ')')],
1547
+ ['pem private key', new RegExp('-----BEGIN[ \\t]+(?:[A-Z0-9][A-Z0-9_.-]*[ \\t]+){0,32}PRIVATE[ \\t]+KEY')],
1548
+ ['http auth header', /\b(?:Authorization|Proxy-Authorization)[ \t]*:[ \t]*(?![\-\uE000])[^\s"'()\[\]{},;\uE000]{6,}/i],
1549
+ // A plain lowercase word after the scheme is prose ("Basic authentication"), as in the main pass (PR #27 review).
1550
+ ['auth scheme token', /(?<![A-Za-z0-9])(?:Basic|Bearer)[ \t]+(?![a-z]+(?![A-Za-z0-9._~+/\-]))[A-Za-z0-9._~+/\-]{12,}/],
1551
+ // Whitespace runs are atomic ((?=(x*))\N) so a long run of spaces after the word is read once (PR #16 review).
1552
+ ['credential assignment', new RegExp('(?<![\\p{L}\\p{N}])(?:password|passwd|secret|api[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|mot de passe|mdp|passwort|kennwort|contrase[ñn]a|senha|wachtwoord|пароль|パスワード|密码|비밀번호)(?![\\p{L}\\p{N}])(?=([ \\t]*))\\1["\']?(?=([ \\t]*))\\2[:=][ \\t]*["\']?(?![\\-\\uE000])[^\\s"\'()\\[\\]{},;\\uE000]{3,}', 'iu')],
1553
+ ['spaced hex groups', /\b(?:[0-9a-f]{4}[ -]){5,}[0-9a-f]{4}\b/i], // recovery codes written as groups of four
1554
+ ];
1555
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1556
+ function shannon(str) {
1557
+ const freq = Object.create(null);
1558
+ for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
1559
+ let h = 0; const len = str.length;
1560
+ for (const k in freq) { const p = freq[k] / len; h -= p * Math.log2(p); }
1561
+ return h;
1562
+ }
1563
+ // A link to a known sharing site (a Claude artifact, a ChatGPT share, a Google Doc, a Notion page, a Figma file, a Loom
1564
+ // video) is replaced whole by [LINK_n] in the main pass. Its random ID is not a key, so the chat is not held for it
1565
+ // (smoke test 2026-10-15: one claude.ai artifact link held a whole chat), but anyone with the link can open the file,
1566
+ // so the ID never goes to the checker either (viaknox PR #31 security review). Only the scheme, host, route and ONE
1567
+ // ID segment are taken: anything after it (an extra segment, "?", "#") stays in the text and is scanned as usual.
1568
+ // Scheme and host match in any case; routes are case-sensitive (a look-alike claude.ai/ARTIFACT/… is scanned, not
1569
+ // taken). Nothing glued to a letter, mark or digit of any script counts as a link start (tune-up PR #21 review).
1570
+ const ci = (h) => h.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`).replace(/\./g, '\\.');
1571
+ const SEG = '(?:[\\p{L}\\p{M}\\p{N}_\\-.]|%[0-9A-Fa-f]{2})*(?:[\\p{L}\\p{M}\\p{N}_\\-]|%[0-9A-Fa-f]{2})'; // letters of any script (Notion: Café-<id>); never ends on "." (a full stop stays outside), may end on _ or - (opaque Drive IDs) (tune-up PR #21 review)
1572
+ const SHARE_LINK = new RegExp('(?<![\\p{L}\\p{M}\\p{N}])' + ci('http') + '[sS]?://(?:'
1573
+ + ci('claude.ai') + '/(?:code/session_|(?:code/)?(?:artifact|chat|share|project)/)'
1574
+ + '|(?:' + ci('chatgpt.com') + '|' + ci('chat.openai.com') + ')/(?:g/' + SEG + '/c/|share/e/|share/|c/|g/|s/)' // /g/<gpt>/c/<chat>: a custom GPT's chat; s/: shared tasks and other shares (help.openai.com/en/articles/7925741)
1575
+ + '|' + ci('docs.google.com') + '/(?:document|spreadsheets|presentation|forms|drawings|videos)/(?:u/\\d{1,2}/)?d/(?:e/)?'
1576
+ + '|' + ci('drive.google.com') + '/(?:file/(?:u/\\d{1,2}/)?d/|drive/(?:u/\\d{1,2}/)?folders/|(?:open|uc)\\?(?:[A-Za-z0-9_\\-.=%~+]*&)*id=)' // also /open?id=<id> (viaknox PR #31 review)
1577
+ + '|(?:' + ci('www.') + ')?' + ci('figma.com') + '/(?:file|design|board|proto|slides|deck|site|buzz|make)/' // help.figma.com/hc/en-us/articles/1500005554982
1578
+ + '|(?:' + ci('www.') + ')?' + ci('loom.com') + '/share/'
1579
+ + '|(?:(?:' + ci('www.') + ')?' + ci('notion.so') + '|' + ci('app.notion.com') + '/p|[A-Za-z0-9-]{1,63}\\.' + ci('notion.site') + ')/(?:[A-Za-z0-9_-]{1,64}/)?'
1580
+ + ')' + SEG, 'gu');
1581
+ // A [LINK_n] placeholder followed by more of its URL (path, query, fragment, even after //), up to whitespace or a quote.
1582
+ // The tail may also start with a placeholder (an email rule can take "?contact=…@…" before this runs). Another [LINK_n]
1583
+ // never joins a tail, so scrubbing already-scrubbed text changes nothing ([LINK_1][LINK_2] stays; PR #23 review).
1584
+ const TRAIL_PUNCT = new Set(['.', ',', ';', ':', '!', '?', ')', ']', "'", '"']);
1585
+ // Placeholders an earlier rule put inside the tail ([PRIVATE_1], [SECRET_1]) are passed over, not taken as its end, so
1586
+ // the rest of the link (a resourcekey after a never-send term) still joins it (tune-up PR #22 post-merge review).
1587
+ const LINK_TAIL = /(\uE000LINK_\d+\uE001)((?:[\/?&#]|\uE000(?!LINK_)[A-Z]+_\d+\uE001)(?:[^\s\uE000\uE001"'<>]|\uE000(?!LINK_)[A-Z]+_\d+\uE001)*)/g;
1588
+
1589
+ export function residual(text, { terms = [] } = {}) {
1590
+ // Our own placeholders are safe. Each becomes a sentinel character (not a space) so "PASSWORD=[SECRET_1]" is not
1591
+ // mistaken for a live credential and, after "--password=[SECRET_1] --api-key=[SECRET_2]", the next flag is not read
1592
+ // as the first one's value: the value classes below exclude the sentinel and refuse a leading "-".
1593
+ const s = String(text ?? '').replace(PUB, OPEN);
1594
+ const reasons = [];
1595
+ for (const [why, re] of PREFIX_SHAPES) if (re.test(s)) reasons.push(why);
1596
+ // Entropy runs, looked at token by token so the whole string is never re-scanned per position. A hex run needs a
1597
+ // letter and at least 3 bits/char of entropy; a base64-class run needs at least two of {upper, lower, digit} (which keeps
1598
+ // lowercase URL paths and camelCase identifiers out) and entropy over 4 bits/char.
1599
+ // Public keys and certificates are meant to be shared: their bodies are not entropy evidence (PR #27 review).
1600
+ const sp = s.replace(/-----BEGIN[ \t]+(?:PUBLIC KEY|RSA PUBLIC KEY|CERTIFICATE|TRUSTED CERTIFICATE|CERTIFICATE REQUEST|NEW CERTIFICATE REQUEST|X509 CRL|PKCS7|SSH2 PUBLIC KEY)-----[\s\S]{0,65536}?-----END[ \t]+[A-Z0-9 ]{1,40}-----/g, ' ')
1601
+ // One-line OpenSSH public keys (authorized_keys, known_hosts) too, only when the base64 body really encodes the named
1602
+ // key type, so an arbitrary token after "ssh-rsa " still counts (PR #27 review).
1603
+ .replace(/(?<![A-Za-z0-9-])((?:ssh-(?:rsa|dss|ed25519|ed448)|ecdsa-sha2-nistp(?:256|384|521)|sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)(?:-cert-v01@openssh\.com)?)[ \t]+(AAAA[A-Za-z0-9+/]{16,16384}={0,3})(?![A-Za-z0-9+/=])/g,
1604
+ (m, type, b64) => { let b; try { b = atob(b64.slice(0, Math.min(80, b64.length - (b64.length % 4)))); } catch { return m; } const n = b.length >= 4 ? ((b.charCodeAt(0) << 24) | (b.charCodeAt(1) << 16) | (b.charCodeAt(2) << 8) | b.charCodeAt(3)) : 0; return n === type.length && b.slice(4, 4 + n) === type ? ' ' : m; });
1605
+ for (const mm of sp.matchAll(/[A-Za-z0-9+/=_-]{20,}/g)) {
1606
+ const run = mm[0];
1607
+ if (UUID.test(run) || run.startsWith(OPEN)) continue;
1608
+ // A lowercase 40/64-hex run labelled as a git commit/revision is a public identifier, not a secret (PR #27 review).
1609
+ // A bare "sha"/"hash" label is not enough: those still hold (a password hash is not public).
1610
+ if (/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(run) && /\b(?:commit|commits|committed|revision|merge|merged|cherry-pick|cherry-picked|revert|reverted|blob|tree|git)\b[^\n]{0,40}$/i.test(sp.slice(Math.max(0, mm.index - 60), mm.index))) continue;
1611
+ if (/^[0-9a-fA-F]+$/.test(run)) { if (/[a-fA-F]/.test(run) && shannon(run) >= 3.0) { reasons.push('high-entropy hex run'); break; } continue; }
1612
+ // A camel/Pascal-case identifier made of word-like parts (AbstractQueuedSynchronizer) is code, not a token (PR #27
1613
+ // review), but only where the text shows it is code: a member access or call (pkg.Name, Name.method, Name(, Name<,
1614
+ // Name::, Name#) or a declaration keyword before it. A bare word-like passphrase (PurpleMonkeyDishwasherSatellite)
1615
+ // still counts (tune-up PR #19 / viaknox PR #28 review).
1616
+ // Every rule below also needs the whole line to read as code (a ; { } or => on it, or a stack frame "at pkg.Name"),
1617
+ // no credential word on the line, and no digit or # straight after the word, so a passphrase in prose
1618
+ // ("ok so Name#2024 is it", "bob, Name, admin") still counts (tune-up PR #20 adversarial review).
1619
+ const ls = sp.lastIndexOf('\n', mm.index) + 1, le0 = sp.indexOf('\n', mm.index), ln = sp.slice(ls, le0 < 0 ? sp.length : le0);
1620
+ const codeLine = (/[;{}]|=>/.test(ln) || /^[ \t]*at[ \t]+[\w$./]+\(/.test(ln) || /^[ \t]*(?:public|private|protected|internal)\b/.test(ln)) && !/pass(?:word|wd|phrase|code)?|pwd|secret|token|credential|api[-_ ]?key|\bkey\b/i.test(ln)
1621
+ && !/^(?:#|(?:[(<.]|::)?\d)/.test(sp.slice(mm.index + run.length, mm.index + run.length + 3));
1622
+ if (codeLine && /^[A-Za-z][a-z]+(?:[A-Z][a-z]{2,}){1,}$/.test(run)) {
1623
+ const before = sp.slice(Math.max(0, mm.index - 12), mm.index), after = sp.slice(mm.index + run.length, mm.index + run.length + 80);
1624
+ if (/\.$/.test(before) || /^(?:\.[A-Za-z_]|<|::)/.test(after) || /\b(?:new|class|extends|implements|interface|import|struct|enum|throws|catch)[ \t]+$/.test(before)) continue;
1625
+ // Type positions (tune-up PR #20 review): a generic argument (List<Name>, Map<K, Name>), an array type (Name[]),
1626
+ // a declaration or method return type after a modifier (private Name ident; / public Name ident() and a parameter
1627
+ // inside a list ((Name ident, / , Name ident)). Prose ("use Name tonight;") has no modifier, so it still counts.
1628
+ if (/[<,][ \t]*$/.test(before) && /^[ \t]*[>,\[]/.test(after)) continue;
1629
+ if (/^\[\]/.test(after)) continue;
1630
+ if (MODIFIER_BEFORE.test(before) && /^\??[ \t]+[A-Za-z_$][\w$]*(?:[ \t]*[;=]|\()/.test(after)) continue; // Type? is a nullable type
1631
+ if (/[(,][ \t]*$/.test(before) && /^\??[ \t]+[A-Za-z_$][\w$]*[ \t]*[,)]/.test(after)) continue;
1632
+ }
1633
+ const classes = (/[A-Z]/.test(run) ? 1 : 0) + (/[a-z]/.test(run) ? 1 : 0) + (/[0-9]/.test(run) ? 1 : 0);
1634
+ if (classes >= 2 && shannon(run) > 4.0) { reasons.push('high-entropy token'); break; }
1635
+ }
1636
+ // A generated password full of symbols (N7!qP2@vL9#sR4$x, N7:qP2-vL9:sR4-x) breaks into short runs above, so whole
1637
+ // tokens are looked at too: upper, lower, digit and at least two punctuation characters of any kind, 10-64 characters,
1638
+ // no run of four lowercase letters (a word), not a URL or an email (PR #27 review).
1639
+ if (!reasons.includes('high-entropy token')) for (const tok of sp.match(/[^\s"'`<>()\[\]{},;\uE000\uE001]{10,64}/g) || []) {
1640
+ if (tok.includes('://') || /^[^@]+@[\w.-]+\.[A-Za-z]{2,}$/.test(tok) || !/[a-z]/.test(tok) || !/[A-Z]/.test(tok) || !/[0-9]/.test(tok) || /[a-z]{4}/.test(tok)) continue;
1641
+ if ((tok.match(/[^A-Za-z0-9]/g) || []).length >= 2 && shannon(tok) >= 3.3) { reasons.push('high-entropy token'); break; }
1642
+ }
1643
+ if (terms.length) {
1644
+ // The same folding as the main pass, so a term hidden with invisible characters or look-alike forms is still seen.
1645
+ const folded = foldName(foldStr(s)).toLowerCase();
1646
+ // Whole words only, as the scrubber matches them ("Ann" is not in "announce"; PR #27 review); plurals still count.
1647
+ for (const t of terms) {
1648
+ const tt = foldName(foldStr(t)).trim().toLowerCase();
1649
+ if (tt && new RegExp(`(?<![\\p{L}\\p{N}])${tt.split(/\s+/).map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+')}(?:['’]s|es|s)?(?![\\p{L}\\p{N}])`, 'u').test(folded)) { reasons.push('never-send term'); break; }
1650
+ }
1651
+ }
1652
+ return { held: reasons.length > 0, reasons };
1653
+ }
1654
+
1655
+ // A sanitized value: the only thing allowed past the outbound boundary. Frozen and brand-checked (a plain object with
1656
+ // the same keys is NOT a Sanitized), so a caller cannot hand the judge un-scrubbed text. It carries only safe fields and
1657
+ // never the real values (the scrubber keeps the restore map; a Sanitized never does).
1658
+ // Only this module holds MINT, so only sanitize() can make one; `new Sanitized(...)` elsewhere throws (PR #16 review).
1659
+ const MINT = Symbol('sanitized');
1660
+ export class Sanitized {
1661
+ #brand = true;
1662
+ constructor({ text, held, reasons, version }, mint) {
1663
+ if (mint !== MINT) throw new TypeError('Sanitized values are made only by scrubber().sanitize()');
1664
+ this.text = text; this.held = held; this.reasons = reasons; this.version = version;
1665
+ Object.freeze(this);
1666
+ }
1667
+ static is(x) { try { return x instanceof Sanitized && x.#brand === true; } catch { return false; } }
1668
+ }
1669
+ // cap = true when the size, time or work cap stopped scrubbing; false when a scanner itself failed, so a report only
1670
+ // says "too long or complex" when that is the cause (tune-up PR #21 review).
1671
+ export class ScrubError extends Error { constructor(m, cap = false) { super(m); this.name = 'ScrubError'; this.cap = cap; } }
1672
+
1673
+ // Returns a scrubber that keeps one placeholder per distinct value across every text it sees, so the judge can tell the
1674
+ // same secret recurred without seeing it.
1675
+ export function scrubber({ terms = [] } = {}) {
1676
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1677
+ // A term that folds to nothing (only zero-width or combining characters) is dropped: it would make an empty regex
1678
+ // alternative that matches at every offset (PR #27 review).
1679
+ const own = terms.map(t => String(t).trim()).filter(t => t && foldStr(t).trim()).sort((a, b) => b.length - a.length);
1680
+ // Never-send words: whole-word, any case, not inside another word and not inside an email/host (so a one-letter term
1681
+ // can't eat the "x" in jo@x.example). Matched on the folded view with folded terms, so "A\u200Dcme", "Acme",
1682
+ // "ıstanbul" and a decomposed "Café" are all the term.
1683
+ // The \uE000 in the lookbehind stops a term from matching inside a placeholder already made (a PEM block labelled
1684
+ // [KEY_1] must not have its "KEY" re-matched by never-send term "KEY").
1685
+ // A plural or possessive of the term ("Acmes", "Acme's") is still the term; "Ivory" for "Ivo" and "ACMEcorp" are not.
1686
+ // A multiword term matches across any whitespace run ("Acme Corp", a tab, a line break; PR #16 review).
1687
+ const ownBody = `(?:${own.map(t => foldStr(t).trim().split(/\s+/).map(esc).join('\\s+')).join('|')})(?:['’]s|es|s)?`;
1688
+ const ownRe = own.length ? new RegExp(`(?<![\\p{L}\\p{N}@\\uE000])${ownBody}(?![\\p{L}\\p{N}@])`, 'giu') : null;
1689
+ // The same words with "@" allowed beside them, for a second sweep after the email rule has run (see scrubOnce).
1690
+ const ownAtRe = own.length ? new RegExp(`(?<![\\p{L}\\p{N}\\uE000])${ownBody}(?![\\p{L}\\p{N}])`, 'giu') : null;
1691
+ let deadline = Infinity; // set by sanitize() for its run, so long loops can stop early
1692
+ const seen = new Map(); // kind\u0000value -> sentinel-wrapped placeholder
1693
+ const real = new Map(); // public [KIND_n] -> real value, for unscrub and echoes
1694
+ const recurKeys = new Set(); // kind\u0000value labelled with recur (inventoried for bare repeats)
1695
+ const counts = {}; // distinct values found, per kind (what counts() reports)
1696
+ const next = {}; // last placeholder number handed out, per kind
1697
+ const reserved = new Map(); // kind -> numbers that already appeared as literal [KIND_n] text in some input
1698
+ const inventory = []; // { kind, value } of every value scrubbed, for echoes()
1699
+ const ambiguous = new Set(); // public [KIND_n] we minted that later also arrived as literal text: never restored
1700
+ // Why numbers are reserved: if the input already holds a literal "[SECRET_1]", our own first SECRET must not also be
1701
+ // "[SECRET_1]", or unscrub would map one placeholder to a value that contains itself and the two would be
1702
+ // indistinguishable. counts() still reports distinct values only; numbering just skips the reserved ones.
1703
+ const label = (kind, value, recur = false, alias = null) => {
1704
+ const k = kind + '\u0000' + value;
1705
+ // A recurring value seen before without recur (an ordinary link part) is upgraded, so its bare repeat is replaced too
1706
+ // (viaknox PR #36 review).
1707
+ // A Set, not a scan of the inventory: thousands of repeated values stay linear (tune-up PR #24 review).
1708
+ if (seen.has(k) && recur && !recurKeys.has(k)) {
1709
+ recurKeys.add(k); inventory.push({ kind, value, recur }); if (alias) inventory.push({ kind, value: alias, recur });
1710
+ }
1711
+ if (!seen.has(k)) {
1712
+ let n = (next[kind] || 0) + 1; while (reserved.has(kind) && reserved.get(kind).has(n)) n++;
1713
+ next[kind] = n; counts[kind] = (counts[kind] || 0) + 1;
1714
+ const pub = `[${kind}_${n}]`;
1715
+ seen.set(k, OPEN + kind + '_' + n + CLOSE);
1716
+ if (recur) recurKeys.add(k);
1717
+ // The decoded spelling maps to the same placeholder, so a decoded repeat reads as the same value (PR #24 review).
1718
+ if (alias && !seen.has(kind + '\u0000' + alias)) seen.set(kind + '\u0000' + alias, seen.get(k));
1719
+ // A SECRET value may already hold a PRIVATE sentinel; store the value in public [KIND_n] form so unscrub nests.
1720
+ real.set(pub, value.replace(SENT, (m, kk, nn) => `[${kk}_${nn}]`));
1721
+ inventory.push(recur ? { kind, value, recur } : { kind, value });
1722
+ if (alias) inventory.push({ kind, value: alias, recur });
1723
+ if (kind === 'SECRET') {
1724
+ // A quoted value followed by a comment ("hunter2" # backup) inventories the text inside the quotes (PR #16 review).
1725
+ let core = value.trim(); const qm = /^(["'])((?:\\.|(?!\1)[^\n])*)\1[ \t]*(?:[#;][^\n]*)?$/.exec(core);
1726
+ core = qm ? qm[2] : core.replace(/^["']|["']$/g, ''); if (core && core !== value) inventory.push({ kind, value: core });
1727
+ // The decoded spelling too ("hunter\u0074wo" is echoed as huntertwo; PR #16 review).
1728
+ if (/[\\%&]/.test(core)) { const dec = decodeName(core); if (dec !== core && dec.trim().length >= 3) inventory.push({ kind, value: dec }); }
1729
+ // Form encoding: a query value's "+" is a space (password=correct+horse+battery; tune-up PR #22 review). Applied to every
1730
+ // secret, not just query values: a pasted form body (password=correct+horse+battery) has no ? to mark it, and a
1731
+ // missed decoded echo is a leak while an extra one only over-redacts (viaknox PR #35 security review).
1732
+ if (/^\S*\+\S*$/.test(core)) { const sp = decodeName(core.replace(/\+/g, ' ')); if (sp.trim().length >= 3) inventory.push({ kind, value: sp }); }
1733
+ // The token itself, without a trailing comment or sibling setting ("hunter2 # temporary"), so an echo of just
1734
+ // the token is still found (viaknox PR #27 review).
1735
+ // Only when a comment or a sibling setting really follows: spaces alone may be part of the credential
1736
+ // ("correct horse battery staple" must not make "correct" a secret; PR #16 review).
1737
+ const tm = /^([^\s,;#]+)(?:[ \t]+#|[ \t]*[,;][ \t]*[\p{L}_][\p{L}\p{N}_.\-]*[ \t]*[=:])/u.exec(core), tok = tm && tm[1];
1738
+ if (tok && tok !== core) inventory.push({ kind, value: tok });
1739
+ // A literal inside a wrapper (password = ("hunter2"), ["hunter2"], String("hunter2"), (hunter2)) is inventoried
1740
+ // too, so a bare repeat later is still caught (PR #27 review). Bounded: the first 16 quoted literals of 3+ chars,
1741
+ // or the single bare token a bracket or call wraps.
1742
+ const lits = []; const LIT = /(["'`])((?:\\.|(?!\1)[^\\\n]){3,4096})\1/g;
1743
+ const head = value.slice(0, 8192); // bounded work on a huge value
1744
+ for (let lm; lits.length < 16 && (lm = LIT.exec(head));) lits.push(lm[2]);
1745
+ const wm = /^[ \t]*[\p{L}_$][\p{L}\p{N}_$.]{0,80}[ \t]*\([ \t]*([^\s()\[\]{},"'`]{3,4096})[ \t]*\)[ \t]*;?[ \t]*$|^[ \t]*[(\[{][ \t]*([^\s()\[\]{},"'`]{3,4096})[ \t]*[)\]}][ \t]*;?[ \t]*$/u.exec(head);
1746
+ if (wm) lits.push(wm[1] || wm[2]);
1747
+ for (const lit of lits) if (lit !== value && lit !== core && lit.trim().length >= 3) inventory.push({ kind, value: lit });
1748
+ }
1749
+ }
1750
+ return seen.get(k);
1751
+ };
1752
+ const resolve = (name) => name.replace(SENT, (m, kind, n) => real.get(`[${kind}_${n}]`) ?? m);
1753
+
1754
+ function scrubOnce(text) {
1755
+ braceBudget = 8_000_000;
1756
+ let s = String(text ?? '').replace(/[\uE000\uE001]/g, '');
1757
+ // Protect placeholders that were ALREADY in the input: wrap them so no rule re-matches them, and reserve their
1758
+ // numbers so a placeholder this scrubber makes can never equal them. They reveal back unchanged and are not counted.
1759
+ // A literal that equals a placeholder we already minted can no longer be told apart from it, so unscrub leaves that
1760
+ // placeholder as text from now on rather than risk turning a literal into the real value (PR #16 review).
1761
+ s = s.replace(PUB, (m, kind, n) => { if (real.has(m)) ambiguous.add(m); if (!reserved.has(kind)) reserved.set(kind, new Set()); reserved.get(kind).add(Number(n)); return OPEN + kind + '_' + n + CLOSE; });
1762
+ // Share links first, before any rule inserts a placeholder inside one (a never-send term in a Notion title would
1763
+ // split the link and leave its ID behind; tune-up PR #21 review). See SHARE_LINK.
1764
+ // A Drive open?…&id= link: the parameters before id= go through the query credential rule first (open?password=x&id=…
1765
+ // gives one [SECRET_n], inventoried for later repeats), then the whole link is one [LINK_n] wrapping it (tune-up PR #22
1766
+ // post-merge review; viaknox PR #35 review: splitting it let a later rule count the secret twice).
1767
+ s = s.replace(SHARE_LINK, (m) => { const q = m.indexOf('?'), i = m.lastIndexOf('id=');
1768
+ return label('LINK', q < 0 || i === q + 1 ? m : m.slice(0, q) + scrubQuery(m.slice(q, i), label, resolve) + m.slice(i)); });
1769
+ const V = viewer();
1770
+ s = scrubPem(s, label);
1771
+ if (ownRe) s = applyOnView(s, V(s), ownRe, label, (m) => ({ from: m.index, to: m.index + m[0].length, kind: 'PRIVATE' }));
1772
+ s = scrubPgpass(s, label);
1773
+ s = scrubSqlAuth(s, label);
1774
+ s = scrubNetrc(s, label);
1775
+ s = scrubCopula(s, label);
1776
+ s = scrubPhrase(s, label);
1777
+ s = scrubFlags(s, label, V);
1778
+ s = scrubCommands(s, label);
1779
+ // The password part of user:password is inventoried on its own too, so a later bare repeat of it is found
1780
+ // (https://alice:x@db … "use x"; PR #16 review).
1781
+ s = s.replace(URL_USERINFO, (m, scheme, userinfo) => { const pw = userinfo.includes(':') ? userinfo.slice(userinfo.indexOf(':') + 1) : ''; if (pw.length >= 3 && !/[\uE000\uE001]/.test(pw)) inventory.push({ kind: 'SECRET', value: pw }); return scheme + label('KEY', userinfo) + '@'; });
1782
+ // Oracle JDBC thin/oci URLs carry user/password before "@" with no "//" (jdbc:oracle:thin:scott/tiger@//db:1521/x;
1783
+ // docs.oracle.com JDBC "Data Sources and URLs"; PR #27 review).
1784
+ s = s.replace(/(jdbc:oracle:[a-z0-9]+:)([^\s\/@:"']+\/(?:"[^"\n]*"|[^\s@"']+))@/gi, (m, pre, userinfo) => pre + label('KEY', userinfo) + '@');
1785
+ s = scrubQuery(s, label, resolve);
1786
+ s = scrubXml(s, label, resolve);
1787
+ s = scrubHtmlAttrs(s, label, resolve);
1788
+ s = scrubMultipart(s, label, resolve);
1789
+ s = scrubNameValuePairs(s, label, resolve);
1790
+ s = scrubPathSegments(s, label, resolve);
1791
+ s = scrubScopes(s, label, resolve);
1792
+ s = scrubTables(s, label, resolve);
1793
+ s = scrubCodeForms(s, label, resolve);
1794
+ s = scrubConcatNames(s, label, resolve);
1795
+ s = scrubQuotedKeys(s, label, resolve);
1796
+ s = scrubAssignments(s, V(s), label, resolve);
1797
+ s = scrubLineForms(s, label, resolve);
1798
+ s = scrubCallArg(s, label, resolve);
1799
+ s = scrubBareAuth(s, label);
1800
+ // Any quoted string carrying an escaped quote (\") is a smuggling attempt: scrub the whole string. Benign values
1801
+ // like "jo" have no escaped quote and stay. The pattern is the classic linear JSON-string match.
1802
+ s = s.replace(/"(?:\\.|[^"\\\n])*"/g, (m) => (/\\"/.test(m) ? label('SECRET', m) : m));
1803
+ // Known key shapes on the folded view (a soft hyphen inside "ghp_…" does not hide it), then the plain PII shapes.
1804
+ s = applyOnView(s, V(s), KEY_SHAPE_RE, label, (m) => ({ from: m.index, to: m.index + m[0].length, kind: 'KEY' }));
1805
+ for (const [kind, re] of PATTERNS) {
1806
+ s = s.replace(re, (m) => {
1807
+ if (kind === 'CARD') { const d = m.replace(/\D/g, ''); if (d.length < 13 || !luhn(d)) return m; }
1808
+ if (kind === 'PHONE' && m.startsWith('+')) { const d = m.replace(/\D/g, '').length; if (d < 8 || d > 15) return m; }
1809
+ // A two-group compressed address counts when its first group is a real IPv6 prefix (fe80::1 link-local, fd00::,
1810
+ // 2001::, ff02::; RFC 4291), so times (10::30) and short scopes still stay (PR #27 review).
1811
+ if (kind === 'IP' && m.includes(':') && !(m.split(':').filter(Boolean).length >= 3 && /[0-9a-f]{3,4}/i.test(m)) && !(m.includes('::') && /^(?:fe[89ab][0-9a-f]|f[cd][0-9a-f]{2}|2[0-9a-f]{3}|ff[0-9a-f]{2})$/i.test(m.split(':')[0]))) return m;
1812
+ return label(kind, m.trim());
1813
+ });
1814
+ }
1815
+ // The rest of a share link (more path, the query: a Drive resourcekey, Figma's t=, a redirect) is taken only now,
1816
+ // after every credential, key and PII rule has run on it (a phone in ?contact= is inventoried, so its echo is an echo; PR #22 post-merge review) (?password=…, /password/…), so those are still found and inventoried;
1817
+ // whatever is left joins the link as its own [LINK_n] and is never sent (tune-up PR #22 review). A trailing "." or ","
1818
+ // stays outside.
1819
+ // Trailing punctuation is trimmed with a loop, not /[...]+$/: that regex restarts at every position of a long run that
1820
+ // is followed by another character, which is quadratic (a 300 KB run stalled for minutes; tune-up PR #22 follow-up).
1821
+ // A [LINK_n] in OAuth position (?state=[LINK_n]) is a sign-in value, not a share link: the URL after it stays
1822
+ // readable, also when already-scrubbed text is scrubbed again by a fresh scrubber (viaknox PR #36, tune-up PR #24).
1823
+ s = s.replace(LINK_TAIL, (m, ph, tail, off, str) => { if (OAUTH_BEFORE.test(str.slice(Math.max(0, off - 40), off))) return m; let e = tail.length; while (e > 0 && TRAIL_PUNCT.has(tail[e - 1])) e--; const t = tail.slice(0, e); if (!t) return m; return ph + label('LINK', t) + tail.slice(e); });
1824
+ // A term beside "@" that the email rule did not consume (`user@Acme`, `jo@acme`: a handle or a single-label host) is
1825
+ // still the term. The first sweep keeps "@" out so a one-letter term cannot eat a host label inside a real address
1826
+ // (jo@x.example); real addresses are [EMAIL_n] by now, so this sweep may touch "@" freely (PR #16 review).
1827
+ if (ownAtRe) s = applyOnView(s, V(s), ownAtRe, label, (m) => ({ from: m.index, to: m.index + m[0].length, kind: 'PRIVATE' }));
1828
+ // A secret already recognised anywhere (in this text or an earlier one) is replaced wherever it recurs bare, e.g. an
1829
+ // agent repeating "CorrectHorse9!" after the user's PASSWORD=CorrectHorse9! (viaknox PR #27 review).
1830
+ // A value wrapping a never-send placeholder (password=Acme-x is [PRIVATE_1]-x) still recurs as that same text, so it
1831
+ // stays eligible; the part outside the placeholder must still be at least four characters (PR #16 review).
1832
+ const recurs = (v) => { const bare = v.replace(/\uE000PRIVATE_\d+\uE001/g, ''); return !/[\uE000\uE001\n]/.test(bare) && bare.trim().length >= 3; };
1833
+ const known = [...new Set(inventory.filter(e => (e.kind === 'SECRET' || e.kind === 'KEY' || e.recur) && recurs(e.value)).map(e => `${e.kind}\u0000${e.value.trim()}`))]
1834
+ .map(k => k.split('\u0000')).sort((a, b) => b[1].length - a[1].length);
1835
+ // One forward pass looking values up by length (not a full-text scan per secret, which was quadratic with thousands of
1836
+ // settings), and
1837
+ // only whole occurrences: a value that begins or ends with a letter or digit is not taken out of a longer word, so
1838
+ // password=pass does not turn "compass" into "com[SECRET_1]" (PR #16 review).
1839
+ if (known.length) {
1840
+ const kindOf = new Map(); for (const [kind, v] of known) if (!kindOf.has(v)) kindOf.set(v, kind);
1841
+ // Candidate lengths are indexed by the value's first three characters (every value has at least three), so a
1842
+ // position is only tried against values that can start there.
1843
+ const byPre = new Map(); for (const v of kindOf.keys()) { const p = v.slice(0, 3); if (!byPre.has(p)) byPre.set(p, new Set()); byPre.get(p).add(v.length); }
1844
+ for (const [p, set] of byPre) byPre.set(p, [...set].sort((a, b) => b - a));
1845
+ const firsts = new Set([...kindOf.keys()].map(v => v[0]));
1846
+ const word = (c) => c !== undefined && /[\p{L}\p{N}]/u.test(c);
1847
+ let out = '', at = 0;
1848
+ for (let i = 0; i < s.length; i++) {
1849
+ // The sanitize deadline is checked inside the loop: many distinct lengths sharing a first character could
1850
+ // otherwise run far past it before the check after scrubbing (PR #16 review). Past it, sanitize holds.
1851
+ if ((i & 1023) === 0 && Date.now() > deadline) throw new ScrubError('scrubbing took too long; holding back', true);
1852
+ if (!firsts.has(s[i])) continue;
1853
+ const lens = byPre.get(s.substr(i, 3)); if (!lens) continue;
1854
+ for (const L of lens) {
1855
+ const v = s.substr(i, L), kind = kindOf.get(v);
1856
+ if (kind === undefined || (word(v[0]) && word(s[i - 1])) || (word(v[L - 1]) && word(s[i + L]))) continue;
1857
+ out += s.slice(at, i) + label(kind, v); at = i + L; i = at - 1; break;
1858
+ }
1859
+ }
1860
+ s = out + s.slice(at);
1861
+ }
1862
+ // Card and SSN numbers recur with other separators (123-45-6789 echoed as 123456789; PR #27 review): match the digits
1863
+ // with any of space, dot or dash between them and reuse the same placeholder.
1864
+ for (const { kind, value } of inventory) {
1865
+ if (kind !== 'SSN' && kind !== 'CARD') continue;
1866
+ const d = value.replace(/\D/g, ''); if (d.length < 9) continue;
1867
+ const re = new RegExp(`(?<![\\d\\uE000])${d.split('').join('[ .-]?')}(?!\\d)`, 'g');
1868
+ s = s.replace(re, () => label(kind, value));
1869
+ }
1870
+ return s.replace(SENT, (m, kind, n) => `[${kind}_${n}]`);
1871
+ }
1872
+
1873
+ // True when `value` occurs in `s` not glued to a letter or digit on a side where the value itself starts or ends with one.
1874
+ const wordCh = (c) => c !== undefined && /[\p{L}\p{N}]/u.test(c);
1875
+ const wholeIn = (s, value) => {
1876
+ const L = wordCh(value[0]), R = wordCh(value[value.length - 1]);
1877
+ for (let i = s.indexOf(value); i >= 0; i = s.indexOf(value, i + 1)) if (!(L && wordCh(s[i - 1])) && !(R && wordCh(s[i + value.length]))) return true;
1878
+ return false;
1879
+ };
1880
+ return {
1881
+ scrub(text) { return scrubOnce(text); },
1882
+ // Puts the real values back into text returned by a model, locally. One pass in REVERSE insertion order: a
1883
+ // placeholder's value can only contain placeholders created before it (a SECRET made after a PRIVATE may wrap
1884
+ // it, never the other way round), so restoring newest first is dependency order and no fixed-point loop is needed
1885
+ // (a loop could run forever if a value ever contained its own placeholder).
1886
+ unscrub(text) {
1887
+ let s = String(text ?? '');
1888
+ for (const [ph, v] of [...real].reverse()) if (!ambiguous.has(ph)) s = s.split(ph).join(v);
1889
+ return s;
1890
+ },
1891
+ counts: () => ({ ...counts }),
1892
+ // True if the raw text holds a never-send term anywhere, even inside a value another rule replaced first (an email
1893
+ // like employee@acme.example becomes [EMAIL_1] before the term sweep, so counts().PRIVATE alone misses it).
1894
+ mentionsTerm(text) {
1895
+ if (!ownAtRe) return false;
1896
+ const view = foldStr(String(text ?? '').replace(/[\uE000\uE001]/g, ''));
1897
+ ownAtRe.lastIndex = 0; const hit = ownAtRe.test(view); ownAtRe.lastIndex = 0; return hit;
1898
+ },
1899
+ // scrub + the independent residual scan, returned as a frozen Sanitized. Throws ScrubError past the caps so a stuck
1900
+ // or enormous input means "no call", never a silent partial scrub.
1901
+ sanitize(text, { maxMs = 2000, maxChars = 4_000_000 } = {}) {
1902
+ const s = String(text ?? '');
1903
+ if (s.length > maxChars) throw new ScrubError(`input of ${s.length} characters is over the ${maxChars} cap; holding back`, true);
1904
+ const t0 = Date.now();
1905
+ // Any unexpected failure inside the scrubber is a ScrubError, so callers hold the text back instead of crashing.
1906
+ let scrubbed;
1907
+ deadline = t0 + maxMs;
1908
+ try { scrubbed = scrubOnce(s); } catch (e) { if (e instanceof ScrubError) throw e; throw new ScrubError(`scrubber failed (${e && e.name}); holding back`); } finally { deadline = Infinity; }
1909
+ // The residual scan is inside the time budget too (PR #16 review).
1910
+ let r; try { r = residual(scrubbed, { terms: own }); } catch (e) { throw new ScrubError(`residual scan failed (${e && e.name}); holding back`); }
1911
+ if (Date.now() - t0 > maxMs) throw new ScrubError(`scrubbing took longer than ${maxMs} ms; holding back`, true);
1912
+ return new Sanitized({ text: scrubbed, held: r.held, reasons: r.reasons, version: SCRUBBER_VERSION }, MINT);
1913
+ },
1914
+ // `text` with every inventoried user-side value blanked out, so a caller can ask whether a key, card or SSN shape is
1915
+ // still there once the echoes are gone, i.e. whether it is new (PR #27 review). Never returns the values themselves:
1916
+ // the result only ever has less of the caller's own text.
1917
+ withoutEchoes(text) {
1918
+ let s = String(text ?? '');
1919
+ for (const { value } of inventory) if (value && value.length >= 3 && s.includes(value)) s = s.split(value).join(' ');
1920
+ return s;
1921
+ },
1922
+ // Kinds whose inventoried user-side values appear verbatim in `text` (brief item 9: an exact echo, not a re-scan).
1923
+ // Values shorter than 4 characters are ignored. Never returns the values themselves.
1924
+ echoes(text) {
1925
+ const s = String(text ?? ''), kinds = new Set();
1926
+ // Contact details are compared normalised: emails without case, phones by their last ten digits, so a reformatted
1927
+ // echo ((415) 555-2671 -> 415-555-2671, Alice@ -> alice@) still counts (PR #27 review).
1928
+ const lower = s.toLowerCase();
1929
+ // The addresses in this text, found once per call and looked up per inventoried email, so many emails times many
1930
+ // turns stays linear in each (PR #27 review). Same address shape as the EMAIL rule, whole addresses only.
1931
+ let emailSet = null;
1932
+ const emailsIn = () => emailSet || (emailSet = new Set(lower.match(EMAIL_WHOLE) || []));
1933
+ const phones = (s.match(/\+?\d[\d\s().-]{6,}\d/g) || []).map(x => x.replace(/\D/g, '').slice(-10));
1934
+ const digitRuns = (s.match(/\d(?:[ \t.-]?\d){8,18}/g) || []).map(x => x.replace(/\D/g, ''));
1935
+ for (const { kind, value } of inventory) {
1936
+ if (!value || value.length < 3) continue;
1937
+ // A value that begins or ends with a letter or digit counts only as a whole occurrence, so PASSWORD=pass is not
1938
+ // echoed by "compass" or "passed" (PR #27 review).
1939
+ // Whole occurrences checked by index, not a RegExp built from the value: a 50 KB value made "regular expression
1940
+ // too large" (PR #27 review).
1941
+ if (kind !== 'EMAIL' && s.includes(value) && wholeIn(s, value)) kinds.add(kind);
1942
+ else if (kind === 'EMAIL' && (value.startsWith('"') ? wholeIn(lower, value.toLowerCase()) : emailsIn().has(value.toLowerCase()))) kinds.add(kind); // the whole address, not a suffix of data@… (PR #16 review)
1943
+ else if (kind === 'PHONE') { const d = value.replace(/\D/g, '').slice(-10); if (d.length >= 7 && phones.includes(d)) kinds.add(kind); }
1944
+ // Cards and SSNs are compared by their digits, so 123-45-6789 echoed as 123 45 6789 still counts (PR #27 review).
1945
+ else if (kind === 'CARD' || kind === 'SSN') { const d = value.replace(/\D/g, ''); if (d.length >= 9 && digitRuns.includes(d)) kinds.add(kind); }
1946
+ }
1947
+ return [...kinds];
1948
+ },
1949
+ };
1950
+ }