argusqa-os 9.9.0 → 10.0.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.
@@ -1,428 +1,444 @@
1
- /**
2
- * Aegis — secret / PII pattern core (Step 1 of REDACTION_BOUNDARY_MAX_PLAN.md).
3
- *
4
- * Pure, zero-dependency detection primitives shared by the sensitivity
5
- * classifier and every egress guard. NOTHING here does I/O, touches Chrome,
6
- * spawns a process, or makes a network call — it is regex + arithmetic only,
7
- * so it runs in microseconds and is trivially unit-testable.
8
- *
9
- * SECURITY POSTURE — Aegis FAILS CLOSED (the one deliberate inversion from the
10
- * rest of Argus): every primitive here is built so that ambiguity redacts MORE,
11
- * never less. `findSensitiveSpans` is the union of independent layers — a miss
12
- * by one is caught by another — and `scrubText` is the projector all sinks call.
13
- *
14
- * Layers implemented here:
15
- * L2 Secret regexes — SECRET_RULES (JWT / AWS / Google / Slack / OpenAI /
16
- * Anthropic / GitHub / PEM / Bearer / basic-auth URL /
17
- * generic assignment).
18
- * L3 Statistical rarity — Token Efficiency (BPE) when a tokenizer is injected
19
- * via setTokenizer(); Shannon entropy is the zero-dep
20
- * FALLBACK so the layer ALWAYS functions without a dep
21
- * (js-tiktoken / gpt-tokenizer are absent by default).
22
- * L4 Structured PII — PII_RULES (email / E.164 phone / Luhn-valid card /
23
- * US SSN / IPv4 / IPv6 / private host).
24
- * L5 Context boosting — CONTEXT_WORDS (+/-) lower/raise the L3 threshold
25
- * near words like "token"/"secret" vs "example"/"test".
26
- *
27
- * Layer 1 (category rules) and the report walk live in sensitivity-classifier.js.
28
- *
29
- * `scrubSecrets` (the GitHub-token-only redactor) is RE-EXPORTED from
30
- * github-api.js at the bottom so callers can import it from here while
31
- * github-api.js stays byte-for-byte unchanged (its tests keep passing).
32
- */
33
-
34
- // ── Secret regexes (Layer 2) ────────────────────────────────────────────────
35
- // Each rule = { name, regex }. The regex MUST be global (/g) — findSensitiveSpans
36
- // iterates matchAll. When a rule wants to redact only PART of the match (e.g. the
37
- // token after "Bearer "), it uses a capture group named `secret` OR group 1; the
38
- // span is that group when present, else the whole match.
39
-
40
- /** @type {{name:string, regex:RegExp}[]} */
41
- export const SECRET_RULES = [
42
- // JSON Web Token — three base64url segments. The header always starts `eyJ`.
43
- { name: 'jwt', regex: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g },
44
- // Anthropic key — checked BEFORE the generic OpenAI `sk-` rule (more specific).
45
- { name: 'anthropic_key', regex: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
46
- // OpenAI key (incl. sk-proj-…) — negative lookahead excludes the Anthropic shape.
47
- { name: 'openai_key', regex: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}/g },
48
- // AWS access key id.
49
- { name: 'aws_access_key_id', regex: /\b(?:AKIA|ASIA|AROA|AIDA|AGPA|ANPA)[0-9A-Z]{16}\b/g },
50
- // AWS secret access key — anchored on the assignment to avoid 40-char base64 FPs.
51
- { name: 'aws_secret_access_key', regex: /aws_secret_access_key\s*[=:]\s*["']?(?<secret>[A-Za-z0-9/+]{40})/gi },
52
- // Google API key.
53
- { name: 'google_api_key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
54
- // Slack token.
55
- { name: 'slack_token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g },
56
- // GitHub fine-grained PAT (has underscores) — checked before the short ghX_ rule.
57
- { name: 'github_pat', regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
58
- { name: 'github_token', regex: /\bgh[posru]_[A-Za-z0-9_]{10,}/g },
59
- // PEM private-key header (any algorithm).
60
- { name: 'private_key_pem', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g },
61
- // Bearer token — span is the token only (keep the literal "Bearer " readable).
62
- { name: 'bearer', regex: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{8,}/gi },
63
- // basic-auth URL — span is the userinfo `user:pass` (before the @).
64
- { name: 'basic_auth_url', regex: /(?<=:\/\/)(?<secret>[^/\s:@]+:[^/\s:@]+)(?=@)/g },
65
- // generic assignment: password= / api_key: / secret= / token= … — span is the value.
66
- { name: 'generic_assignment', regex: /\b(?:password|passwd|pwd|api[_-]?key|secret|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[=:]\s*["']?(?<secret>[^\s"'<>&]{6,})/gi },
67
- ];
68
-
69
- // ── Structured PII (Layer 4) ────────────────────────────────────────────────
70
- // validate? — an optional gate run over the matched span (group `secret`/1 or
71
- // whole match); the span is dropped if it returns false (e.g. Luhn for cards).
72
-
73
- /** @type {{name:string, regex:RegExp, validate?:(s:string)=>boolean}[]} */
74
- export const PII_RULES = [
75
- { name: 'email', regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
76
- { name: 'phone_e164', regex: /\+[1-9]\d{7,14}\b/g },
77
- {
78
- name: 'credit_card',
79
- regex: /\b(?:\d[ -]?){13,19}\b/g,
80
- validate: (s) => luhnValid(s),
81
- },
82
- { name: 'ssn_us', regex: /\b\d{3}-\d{2}-\d{4}\b/g },
83
- {
84
- name: 'ipv4',
85
- regex: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g,
86
- },
87
- { name: 'ipv6', regex: /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g },
88
- {
89
- name: 'private_host',
90
- regex: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|[A-Za-z0-9-]+\.internal|[A-Za-z0-9-]+\.local)\b/g,
91
- },
92
- ];
93
-
94
- // ── Context words (Layer 5) ─────────────────────────────────────────────────
95
- export const CONTEXT_WORDS = {
96
- positive: ['token', 'secret', 'password', 'passwd', 'pwd', 'authorization', 'auth',
97
- 'bearer', 'cookie', 'apikey', 'api_key', 'api-key', 'access_key', 'accesskey',
98
- 'private_key', 'privatekey', 'credential', 'credentials', 'session', 'signature'],
99
- negative: ['example', 'sample', 'dummy', 'test', 'placeholder', 'your', 'fake',
100
- 'redacted', 'lorem', 'foobar', 'changeme', 'xxxx'],
101
- };
102
-
103
- // ── Statistical-rarity tuning (Layer 3) ─────────────────────────────────────
104
- const ENTROPY_BASE64 = 4.0; // bits/char default for a base64-charset token
105
- const ENTROPY_HEX = 3.0; // bits/char for an all-hex token
106
- const MIN_LEN_NO_CONTEXT = 20; // a bare high-entropy token must be this long…
107
- const MIN_LEN_CONTEXT = 12; // …unless a positive context word sits nearby
108
- const CONTEXT_WINDOW = 24; // chars before the token scanned for context
109
- const TOKEN_EFFICIENCY_FLOOR = 2.5; // BPE efficiency below this ⇒ secret-shaped
110
-
111
- const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
112
- const HEX_RE = /^[0-9a-fA-F]+$/;
113
- // A token candidate for the entropy scan: a run of secret-shaped chars.
114
- const TOKEN_SCAN_RE = /[A-Za-z0-9+/=_-]{8,}/g;
115
-
116
- // Optional BPE tokenizer (Token Efficiency, plan §13.1). Absent by default; the
117
- // production opt-in (ARGUS_REDACT_TOKEN_EFFICIENCY) wires js-tiktoken's encode in
118
- // via setTokenizer(). When null, Layer 3 uses Shannon entropy — the zero-dep
119
- // fallback — so the layer ALWAYS functions.
120
- let _encode = null;
121
-
122
- /**
123
- * Inject a BPE encoder (e.g. js-tiktoken's `encode`) to switch Layer 3 to Token
124
- * Efficiency. Pass null/undefined to revert to the Shannon-entropy fallback.
125
- * @param {((s:string)=>unknown[])|null} encodeFn
126
- */
127
- export function setTokenizer(encodeFn) {
128
- _encode = typeof encodeFn === 'function' ? encodeFn : null;
129
- }
130
-
131
- // Optional reversible-vault tokenizer (Aegis Step 8, plan §5.3). Injected by
132
- // aegis-vault.js when ARGUS_REDACT_VAULT=1; turns scrubText's `token` mode into a
133
- // deterministic, information-free vault token. secret-patterns.js stays I/O-free —
134
- // the I/O (mapping persistence) lives entirely in the injected function. When null,
135
- // `token` mode FALLS BACK TO MASK (never emits the raw value — fail closed).
136
- let _vaultTokenFn = null;
137
-
138
- /**
139
- * Inject the vault tokenizer (aegis-vault's `tokenFor`) to enable scrubText's
140
- * `token` mode. Pass null/undefined to disable it (token mode then masks).
141
- * @param {((value:string)=>string)|null} fn
142
- */
143
- export function setVaultTokenizer(fn) {
144
- _vaultTokenFn = typeof fn === 'function' ? fn : null;
145
- }
146
-
147
- /**
148
- * Token Efficiency = len(string) / len(BPE tokens). Real secrets fragment into
149
- * many short tokens (low efficiency); natural language compresses (high). Returns
150
- * null when no tokenizer is injected (caller falls back to Shannon entropy).
151
- * @param {string} str
152
- * @returns {number|null}
153
- */
154
- export function tokenEfficiency(str) {
155
- if (!_encode || !str) return null;
156
- try {
157
- const tokens = _encode(str);
158
- const n = Array.isArray(tokens) ? tokens.length : Number(tokens?.length ?? 0);
159
- if (!n) return null;
160
- return str.length / n;
161
- } catch {
162
- return null; // fail toward the entropy fallback, never throw
163
- }
164
- }
165
-
166
- /**
167
- * Shannon entropy in bits/char.
168
- * @param {string} str
169
- * @returns {number}
170
- */
171
- export function shannonEntropy(str) {
172
- if (!str) return 0;
173
- const freq = Object.create(null);
174
- for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
175
- let H = 0;
176
- const len = str.length;
177
- for (const ch in freq) {
178
- const p = freq[ch] / len;
179
- H -= p * Math.log2(p);
180
- }
181
- return H;
182
- }
183
-
184
- /**
185
- * Luhn (mod-10) checksum validator for a candidate card number (may contain
186
- * spaces/hyphens). Cuts the credit-card false-positive rate dramatically.
187
- * @param {string} input
188
- * @returns {boolean}
189
- */
190
- export function luhnValid(input) {
191
- const digits = String(input ?? '').replace(/[\s-]/g, '');
192
- if (!/^\d{13,19}$/.test(digits)) return false;
193
- let sum = 0;
194
- let alt = false;
195
- for (let i = digits.length - 1; i >= 0; i--) {
196
- let d = digits.charCodeAt(i) - 48;
197
- if (alt) { d *= 2; if (d > 9) d -= 9; }
198
- sum += d;
199
- alt = !alt;
200
- }
201
- return sum % 10 === 0;
202
- }
203
-
204
- /**
205
- * True when a context word from `list` appears within CONTEXT_WINDOW chars before
206
- * `start` in `text`.
207
- * @param {string} text
208
- * @param {number} start
209
- * @param {string[]} list
210
- * @returns {boolean}
211
- */
212
- function hasContext(text, start, list) {
213
- const before = text.slice(Math.max(0, start - CONTEXT_WINDOW), start).toLowerCase();
214
- return list.some((w) => before.includes(w));
215
- }
216
-
217
- /**
218
- * Layer-3 decision for one candidate token: is it statistically rare enough
219
- * (secret-shaped) to flag? Uses Token Efficiency when a tokenizer is injected,
220
- * else Shannon entropy. UUIDs and short/low-diversity tokens are suppressed
221
- * (the gitleaks-style false-positive gate). Positive context lowers the length
222
- * bar; negative context raises the entropy bar.
223
- *
224
- * @param {string} token
225
- * @param {{ entropyThreshold?: number, positiveContext?: boolean, negativeContext?: boolean }} [opts]
226
- * @returns {boolean}
227
- */
228
- export function isStatisticallyRare(token, opts = {}) {
229
- if (!token) return false;
230
- if (UUID_RE.test(token)) return false; // canonical UUID — never a secret
231
-
232
- const len = token.length;
233
- const minLen = opts.positiveContext ? MIN_LEN_CONTEXT : MIN_LEN_NO_CONTEXT;
234
- if (len < minLen) return false;
235
-
236
- // Charset-diversity gate: require ≥2 of {lower, upper, digit} OR base64 padding,
237
- // so an all-lowercase English-ish run or a CSS class name isn't flagged.
238
- const classes = (/[a-z]/.test(token) ? 1 : 0) + (/[A-Z]/.test(token) ? 1 : 0) +
239
- (/[0-9]/.test(token) ? 1 : 0);
240
- if (classes < 2 && !/[+/=]/.test(token)) return false;
241
-
242
- // Token Efficiency first (when available), else Shannon entropy.
243
- const eff = tokenEfficiency(token);
244
- if (eff !== null) {
245
- const floor = opts.negativeContext ? TOKEN_EFFICIENCY_FLOOR - 0.6 : TOKEN_EFFICIENCY_FLOOR;
246
- return eff < floor;
247
- }
248
-
249
- const isHex = HEX_RE.test(token);
250
- let threshold = opts.entropyThreshold ?? (isHex ? ENTROPY_HEX : ENTROPY_BASE64);
251
- if (opts.negativeContext) threshold += 0.8; // make a "test"/"example" token harder to flag
252
- if (opts.positiveContext) threshold -= 0.8; // a "token="/"secret=" neighbour promotes it
253
- return shannonEntropy(token) >= threshold;
254
- }
255
-
256
- // Zero-width / soft-hyphen characters an attacker can splice into a secret to
257
- // break a regex without changing how it renders.
258
- const ZERO_WIDTH_RE = /[\u200B-\u200D\uFEFF\u00AD\u2060]/g;
259
-
260
- /**
261
- * Strip zero-width / soft-hyphen chars an attacker splices into a secret to break
262
- * a regex without changing how it renders. PRESERVES whitespace + word boundaries
263
- * (so \b-anchored rules like email / private-host still fire). Boundary-safe.
264
- *
265
- * @param {string} text
266
- * @returns {string}
267
- */
268
- export function stripZeroWidth(text) {
269
- return (typeof text === 'string' ? text : String(text ?? '')).replace(ZERO_WIDTH_RE, '');
270
- }
271
-
272
- // NOTE: a whitespace-REJOINING normaliser (to rebuild a secret split across
273
- // spaces/newlines) was prototyped and REMOVED — on natural-language blobs it
274
- // fused ordinary words into spurious high-entropy tokens, over-redacting benign
275
- // findings. Whitespace/newline-split secrets in benign free-text are therefore a
276
- // documented limitation, defended instead by the category + body-field catch-alls
277
- // (a security-category finding or any finding carrying a body field drops ALL its
278
- // free text). See REDACTION_BOUNDARY_MAX_PLAN.md Progress Log.
279
-
280
- // ── Span union scan ─────────────────────────────────────────────────────────
281
-
282
- /**
283
- * The union of all detection layers over `text`. Returns NON-overlapping spans
284
- * (overlaps merged) sorted by start, each `{start, end, kind, name}` where kind ∈
285
- * {secret, pii, entropy}. This is the single source of truth that scrubText
286
- * redacts and that the fuzz invariant proves redactForEgress never leaks.
287
- *
288
- * @param {string} text
289
- * @param {{ entropyThreshold?: number }} [opts]
290
- * @returns {{start:number, end:number, kind:'secret'|'pii'|'entropy', name:string}[]}
291
- */
292
- export function findSensitiveSpans(text, opts = {}) {
293
- const s = typeof text === 'string' ? text : String(text ?? '');
294
- if (!s) return [];
295
- /** @type {{start:number,end:number,kind:string,name:string}[]} */
296
- const spans = [];
297
-
298
- const pushFromRule = (rule, kind) => {
299
- rule.regex.lastIndex = 0;
300
- for (const m of s.matchAll(rule.regex)) {
301
- // Prefer the named `secret` group, else group 1, else the whole match.
302
- const captured = (m.groups && m.groups.secret !== undefined) ? m.groups.secret : (m[1] ?? m[0]);
303
- if (captured === undefined || captured === '') continue;
304
- if (rule.validate && !rule.validate(captured)) continue;
305
- // Locate the captured span inside the full match (handles lookbehind + groups).
306
- const matchStart = m.index ?? s.indexOf(m[0]);
307
- const offset = m[0].indexOf(captured);
308
- const start = matchStart + (offset >= 0 ? offset : 0);
309
- spans.push({ start, end: start + captured.length, kind, name: rule.name });
310
- }
311
- };
312
-
313
- for (const rule of SECRET_RULES) pushFromRule(rule, 'secret');
314
- for (const rule of PII_RULES) pushFromRule(rule, 'pii');
315
-
316
- // Layer 3 entropy / token-efficiency over candidate tokens.
317
- TOKEN_SCAN_RE.lastIndex = 0;
318
- for (const m of s.matchAll(TOKEN_SCAN_RE)) {
319
- const token = m[0];
320
- const start = m.index ?? 0;
321
- const positiveContext = hasContext(s, start, CONTEXT_WORDS.positive);
322
- const negativeContext = hasContext(s, start, CONTEXT_WORDS.negative);
323
- if (isStatisticallyRare(token, { entropyThreshold: opts.entropyThreshold, positiveContext, negativeContext })) {
324
- spans.push({ start, end: start + token.length, kind: 'entropy', name: 'high_entropy' });
325
- }
326
- }
327
-
328
- return mergeSpans(spans);
329
- }
330
-
331
- /**
332
- * Merge overlapping/adjacent spans into the widest covering span so scrubText
333
- * replaces each region once. Keeps the first contributing span's name/kind.
334
- * @param {{start:number,end:number,kind:string,name:string}[]} spans
335
- */
336
- export function mergeSpans(spans) {
337
- if (spans.length <= 1) return spans.slice();
338
- const sorted = spans.slice().sort((a, b) => a.start - b.start || a.end - b.end);
339
- const out = [sorted[0]];
340
- for (let i = 1; i < sorted.length; i++) {
341
- const prev = out[out.length - 1];
342
- const cur = sorted[i];
343
- if (cur.start <= prev.end) {
344
- prev.end = Math.max(prev.end, cur.end); // overlap → extend
345
- } else {
346
- out.push(cur);
347
- }
348
- }
349
- return out;
350
- }
351
-
352
- // ── Redaction modes ─────────────────────────────────────────────────────────
353
- const MASK_CHAR = '█';
354
-
355
- /**
356
- * Replacement string for one span's text under the chosen mode. INVARIANT
357
- * (mode=mask): the result is never LONGER than the original span and never
358
- * reproduces the full secret verbatim.
359
- * @param {string} value the raw span substring
360
- * @param {string} kind
361
- * @param {string} name
362
- * @param {string} mode mask | label | hash | token | drop
363
- * @returns {string}
364
- */
365
- function maskValue(value, kind, name, mode) {
366
- switch (mode) {
367
- case 'drop':
368
- return '';
369
- case 'label':
370
- return `[REDACTED_${String(name || kind).toUpperCase()}]`;
371
- case 'token': {
372
- // Reversible vault token (plan §5.2/§5.3). The token is an HMAC — information-
373
- // free, so the secret never crosses; the token→original mapping lives only in
374
- // the local 0600 vault. FAILS CLOSED: no vault wired ⇒ fall back to mask.
375
- if (_vaultTokenFn) {
376
- try { return _vaultTokenFn(value); } catch { /* fall through to mask */ }
377
- }
378
- const keepT = Math.min(4, Math.floor(value.length / 3));
379
- return value.slice(0, keepT) + MASK_CHAR.repeat(value.length - keepT);
380
- }
381
- case 'hash': {
382
- // Length-stable, collision-resistant-enough correlation tag (no crypto dep).
383
- let h = 0;
384
- for (let i = 0; i < value.length; i++) h = (Math.imul(31, h) + value.charCodeAt(i)) | 0;
385
- const hex = (h >>> 0).toString(16).padStart(8, '0');
386
- return `«${name}:${hex}»`;
387
- }
388
- case 'mask':
389
- default: {
390
- // Keep a SHORT prefix (≤4 chars, never ≥ half the secret) for debuggability;
391
- // mask the rest. Same length never lengthens; full secret never survives.
392
- const keep = Math.min(4, Math.floor(value.length / 3));
393
- return value.slice(0, keep) + MASK_CHAR.repeat(value.length - keep);
394
- }
395
- }
396
- }
397
-
398
- /**
399
- * Replace every sensitive span in `text` per `mode`. Idempotent
400
- * (scrub(scrub(x)) === scrub(x)) and, for mode=mask, never lengthens the input.
401
- * This SUPERSEDES the GitHub-only scrubSecrets for free-text egress.
402
- *
403
- * @param {string} text
404
- * @param {{ mode?: string, entropyThreshold?: number }} [opts]
405
- * @returns {string}
406
- */
407
- export function scrubText(text, opts = {}) {
408
- const s = typeof text === 'string' ? text : (text == null ? '' : String(text));
409
- if (!s) return s;
410
- const mode = opts.mode || 'mask';
411
- const spans = findSensitiveSpans(s, opts);
412
- if (spans.length === 0) return s;
413
-
414
- let out = '';
415
- let cursor = 0;
416
- for (const sp of spans) {
417
- if (sp.start < cursor) continue; // defensive (merged spans shouldn't overlap)
418
- out += s.slice(cursor, sp.start);
419
- out += maskValue(s.slice(sp.start, sp.end), sp.kind, sp.name, mode);
420
- cursor = sp.end;
421
- }
422
- out += s.slice(cursor);
423
- return out;
424
- }
425
-
426
- // Re-export the GitHub-token-only redactor so callers can import it from here
427
- // while github-api.js stays byte-for-byte unchanged (its existing tests pass).
428
- export { scrubSecrets } from './github-api.js';
1
+ /**
2
+ * Aegis — secret / PII pattern core (Step 1 of REDACTION_BOUNDARY_MAX_PLAN.md).
3
+ *
4
+ * Pure, zero-dependency detection primitives shared by the sensitivity
5
+ * classifier and every egress guard. NOTHING here does I/O, touches Chrome,
6
+ * spawns a process, or makes a network call — it is regex + arithmetic only,
7
+ * so it runs in microseconds and is trivially unit-testable.
8
+ *
9
+ * SECURITY POSTURE — Aegis FAILS CLOSED (the one deliberate inversion from the
10
+ * rest of Argus): every primitive here is built so that ambiguity redacts MORE,
11
+ * never less. `findSensitiveSpans` is the union of independent layers — a miss
12
+ * by one is caught by another — and `scrubText` is the projector all sinks call.
13
+ *
14
+ * Layers implemented here:
15
+ * L2 Secret regexes — SECRET_RULES (JWT / AWS / Google / Slack / OpenAI /
16
+ * Anthropic / GitHub / PEM / Bearer / basic-auth URL /
17
+ * generic assignment).
18
+ * L3 Statistical rarity — Token Efficiency (BPE) when a tokenizer is injected
19
+ * via setTokenizer(); Shannon entropy is the zero-dep
20
+ * FALLBACK so the layer ALWAYS functions without a dep
21
+ * (js-tiktoken / gpt-tokenizer are absent by default).
22
+ * L4 Structured PII — PII_RULES (email / E.164 phone / Luhn-valid card /
23
+ * US SSN / IPv4 / IPv6 / private host).
24
+ * L5 Context boosting — CONTEXT_WORDS (+/-) lower/raise the L3 threshold
25
+ * near words like "token"/"secret" vs "example"/"test".
26
+ *
27
+ * Layer 1 (category rules) and the report walk live in sensitivity-classifier.js.
28
+ *
29
+ * `scrubSecrets` (the GitHub-token-only redactor) is RE-EXPORTED from
30
+ * github-api.js at the bottom so callers can import it from here while
31
+ * github-api.js stays byte-for-byte unchanged (its tests keep passing).
32
+ */
33
+
34
+ // ── Secret regexes (Layer 2) ────────────────────────────────────────────────
35
+ // Each rule = { name, regex }. The regex MUST be global (/g) — findSensitiveSpans
36
+ // iterates matchAll. When a rule wants to redact only PART of the match (e.g. the
37
+ // token after "Bearer "), it uses a capture group named `secret` OR group 1; the
38
+ // span is that group when present, else the whole match.
39
+
40
+ /** @type {{name:string, regex:RegExp}[]} */
41
+ export const SECRET_RULES = [
42
+ // JSON Web Token — three base64url segments. The header always starts `eyJ`.
43
+ { name: 'jwt', regex: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g },
44
+ // Anthropic key — checked BEFORE the generic OpenAI `sk-` rule (more specific).
45
+ { name: 'anthropic_key', regex: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
46
+ // OpenAI key (incl. sk-proj-…) — negative lookahead excludes the Anthropic shape.
47
+ { name: 'openai_key', regex: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}/g },
48
+ // AWS access key id.
49
+ { name: 'aws_access_key_id', regex: /\b(?:AKIA|ASIA|AROA|AIDA|AGPA|ANPA)[0-9A-Z]{16}\b/g },
50
+ // AWS secret access key — anchored on the assignment to avoid 40-char base64 FPs.
51
+ { name: 'aws_secret_access_key', regex: /aws_secret_access_key\s*[=:]\s*["']?(?<secret>[A-Za-z0-9/+]{40})/gi },
52
+ // Google API key.
53
+ { name: 'google_api_key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
54
+ // Slack token.
55
+ { name: 'slack_token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g },
56
+ // GitHub fine-grained PAT (has underscores) — checked before the short ghX_ rule.
57
+ { name: 'github_pat', regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
58
+ { name: 'github_token', regex: /\bgh[posru]_[A-Za-z0-9_]{10,}/g },
59
+ // PEM private-key header (any algorithm).
60
+ { name: 'private_key_pem', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g },
61
+ // Bearer token — span is the token only (keep the literal "Bearer " readable).
62
+ { name: 'bearer', regex: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{8,}/gi },
63
+ // basic-auth URL — span is the userinfo `user:pass` (before the @).
64
+ { name: 'basic_auth_url', regex: /(?<=:\/\/)(?<secret>[^/\s:@]+:[^/\s:@]+)(?=@)/g },
65
+ // generic assignment: password= / api_key: / secret= / token= … — span is the value.
66
+ { name: 'generic_assignment', regex: /\b(?:password|passwd|pwd|api[_-]?key|secret|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[=:]\s*["']?(?<secret>[^\s"'<>&]{6,})/gi },
67
+ ];
68
+
69
+ // ── Structured PII (Layer 4) ────────────────────────────────────────────────
70
+ // validate? — an optional gate run over the matched span (group `secret`/1 or
71
+ // whole match); the span is dropped if it returns false (e.g. Luhn for cards).
72
+
73
+ /** @type {{name:string, regex:RegExp, validate?:(s:string)=>boolean}[]} */
74
+ export const PII_RULES = [
75
+ { name: 'email', regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
76
+ { name: 'phone_e164', regex: /\+[1-9]\d{7,14}\b/g },
77
+ {
78
+ name: 'credit_card',
79
+ regex: /\b(?:\d[ -]?){13,19}\b/g,
80
+ validate: (s) => luhnValid(s),
81
+ },
82
+ { name: 'ssn_us', regex: /\b\d{3}-\d{2}-\d{4}\b/g },
83
+ {
84
+ name: 'ipv4',
85
+ regex: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g,
86
+ },
87
+ { name: 'ipv6', regex: /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g },
88
+ {
89
+ name: 'private_host',
90
+ regex: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|[A-Za-z0-9-]+\.internal|[A-Za-z0-9-]+\.local)\b/g,
91
+ },
92
+ ];
93
+
94
+ // ── Context words (Layer 5) ─────────────────────────────────────────────────
95
+ export const CONTEXT_WORDS = {
96
+ positive: ['token', 'secret', 'password', 'passwd', 'pwd', 'authorization', 'auth',
97
+ 'bearer', 'cookie', 'apikey', 'api_key', 'api-key', 'access_key', 'accesskey',
98
+ 'private_key', 'privatekey', 'credential', 'credentials', 'session', 'signature'],
99
+ negative: ['example', 'sample', 'dummy', 'test', 'placeholder', 'your', 'fake',
100
+ 'redacted', 'lorem', 'foobar', 'changeme', 'xxxx'],
101
+ };
102
+
103
+ // ── Statistical-rarity tuning (Layer 3) ─────────────────────────────────────
104
+ const ENTROPY_BASE64 = 4.0; // bits/char default for a base64-charset token
105
+ const ENTROPY_HEX = 3.0; // bits/char for an all-hex token
106
+ const MIN_LEN_NO_CONTEXT = 20; // a bare high-entropy token must be this long…
107
+ const MIN_LEN_CONTEXT = 12; // …unless a positive context word sits nearby
108
+ const CONTEXT_WINDOW = 24; // chars before the token scanned for context
109
+ const TOKEN_EFFICIENCY_FLOOR = 2.5; // BPE efficiency below this ⇒ secret-shaped
110
+
111
+ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
112
+ const HEX_RE = /^[0-9a-fA-F]+$/;
113
+ // A token candidate for the entropy scan: a run of secret-shaped chars.
114
+ const TOKEN_SCAN_RE = /[A-Za-z0-9+/=_-]{8,}/g;
115
+
116
+ // Optional BPE tokenizer (Token Efficiency, plan §13.1). Absent by default; the
117
+ // production opt-in (ARGUS_REDACT_TOKEN_EFFICIENCY) wires js-tiktoken's encode in
118
+ // via setTokenizer(). When null, Layer 3 uses Shannon entropy — the zero-dep
119
+ // fallback — so the layer ALWAYS functions.
120
+ let _encode = null;
121
+
122
+ /**
123
+ * Inject a BPE encoder (e.g. js-tiktoken's `encode`) to switch Layer 3 to Token
124
+ * Efficiency. Pass null/undefined to revert to the Shannon-entropy fallback.
125
+ * @param {((s:string)=>unknown[])|null} encodeFn
126
+ */
127
+ export function setTokenizer(encodeFn) {
128
+ _encode = typeof encodeFn === 'function' ? encodeFn : null;
129
+ }
130
+
131
+ // Optional reversible-vault tokenizer (Aegis Step 8, plan §5.3). Injected by
132
+ // aegis-vault.js when ARGUS_REDACT_VAULT=1; turns scrubText's `token` mode into a
133
+ // deterministic, information-free vault token. secret-patterns.js stays I/O-free —
134
+ // the I/O (mapping persistence) lives entirely in the injected function. When null,
135
+ // `token` mode FALLS BACK TO MASK (never emits the raw value — fail closed).
136
+ let _vaultTokenFn = null;
137
+
138
+ /**
139
+ * Inject the vault tokenizer (aegis-vault's `tokenFor`) to enable scrubText's
140
+ * `token` mode. Pass null/undefined to disable it (token mode then masks).
141
+ * @param {((value:string)=>string)|null} fn
142
+ */
143
+ export function setVaultTokenizer(fn) {
144
+ _vaultTokenFn = typeof fn === 'function' ? fn : null;
145
+ }
146
+
147
+ /**
148
+ * Token Efficiency = len(string) / len(BPE tokens). Real secrets fragment into
149
+ * many short tokens (low efficiency); natural language compresses (high). Returns
150
+ * null when no tokenizer is injected (caller falls back to Shannon entropy).
151
+ * @param {string} str
152
+ * @returns {number|null}
153
+ */
154
+ export function tokenEfficiency(str) {
155
+ if (!_encode || !str) return null;
156
+ try {
157
+ const tokens = _encode(str);
158
+ const n = Array.isArray(tokens) ? tokens.length : Number(tokens?.length ?? 0);
159
+ if (!n) return null;
160
+ return str.length / n;
161
+ } catch {
162
+ return null; // fail toward the entropy fallback, never throw
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Shannon entropy in bits/char.
168
+ * @param {string} str
169
+ * @returns {number}
170
+ */
171
+ export function shannonEntropy(str) {
172
+ if (!str) return 0;
173
+ const freq = Object.create(null);
174
+ for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
175
+ let H = 0;
176
+ const len = str.length;
177
+ for (const ch in freq) {
178
+ const p = freq[ch] / len;
179
+ H -= p * Math.log2(p);
180
+ }
181
+ return H;
182
+ }
183
+
184
+ /**
185
+ * Luhn (mod-10) checksum validator for a candidate card number (may contain
186
+ * spaces/hyphens). Cuts the credit-card false-positive rate dramatically.
187
+ * @param {string} input
188
+ * @returns {boolean}
189
+ */
190
+ export function luhnValid(input) {
191
+ const digits = String(input ?? '').replace(/[\s-]/g, '');
192
+ if (!/^\d{13,19}$/.test(digits)) return false;
193
+ let sum = 0;
194
+ let alt = false;
195
+ for (let i = digits.length - 1; i >= 0; i--) {
196
+ let d = digits.charCodeAt(i) - 48;
197
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
198
+ sum += d;
199
+ alt = !alt;
200
+ }
201
+ return sum % 10 === 0;
202
+ }
203
+
204
+ /**
205
+ * True when a context word from `list` appears within CONTEXT_WINDOW chars before
206
+ * `start` in `text`.
207
+ * @param {string} text
208
+ * @param {number} start
209
+ * @param {string[]} list
210
+ * @returns {boolean}
211
+ */
212
+ function hasContext(text, start, list) {
213
+ const before = text.slice(Math.max(0, start - CONTEXT_WINDOW), start).toLowerCase();
214
+ return list.some((w) => before.includes(w));
215
+ }
216
+
217
+ /**
218
+ * Layer-3 decision for one candidate token: is it statistically rare enough
219
+ * (secret-shaped) to flag? Uses Token Efficiency when a tokenizer is injected,
220
+ * else Shannon entropy. UUIDs and short/low-diversity tokens are suppressed
221
+ * (the gitleaks-style false-positive gate). Positive context lowers the length
222
+ * bar; negative context raises the entropy bar.
223
+ *
224
+ * @param {string} token
225
+ * @param {{ entropyThreshold?: number, positiveContext?: boolean, negativeContext?: boolean }} [opts]
226
+ * @returns {boolean}
227
+ */
228
+ export function isStatisticallyRare(token, opts = {}) {
229
+ if (!token) return false;
230
+ if (UUID_RE.test(token)) return false; // canonical UUID — never a secret
231
+
232
+ const len = token.length;
233
+ const minLen = opts.positiveContext ? MIN_LEN_CONTEXT : MIN_LEN_NO_CONTEXT;
234
+ if (len < minLen) return false;
235
+
236
+ // Charset-diversity gate: require ≥2 of {lower, upper, digit} OR base64 padding,
237
+ // so an all-lowercase English-ish run or a CSS class name isn't flagged.
238
+ const classes = (/[a-z]/.test(token) ? 1 : 0) + (/[A-Z]/.test(token) ? 1 : 0) +
239
+ (/[0-9]/.test(token) ? 1 : 0);
240
+ if (classes < 2 && !/[+/=]/.test(token)) return false;
241
+
242
+ // Token Efficiency first (when available), else Shannon entropy.
243
+ const eff = tokenEfficiency(token);
244
+ if (eff !== null) {
245
+ const floor = opts.negativeContext ? TOKEN_EFFICIENCY_FLOOR - 0.6 : TOKEN_EFFICIENCY_FLOOR;
246
+ return eff < floor;
247
+ }
248
+
249
+ const isHex = HEX_RE.test(token);
250
+ let threshold = opts.entropyThreshold ?? (isHex ? ENTROPY_HEX : ENTROPY_BASE64);
251
+ if (opts.negativeContext) threshold += 0.8; // make a "test"/"example" token harder to flag
252
+ if (opts.positiveContext) threshold -= 0.8; // a "token="/"secret=" neighbour promotes it
253
+ return shannonEntropy(token) >= threshold;
254
+ }
255
+
256
+ // Zero-width / soft-hyphen characters an attacker can splice into a secret to
257
+ // break a regex without changing how it renders.
258
+ const ZERO_WIDTH_RE = /[\u200B-\u200D\uFEFF\u00AD\u2060]/g;
259
+
260
+ /**
261
+ * Strip zero-width / soft-hyphen chars an attacker splices into a secret to break
262
+ * a regex without changing how it renders. PRESERVES whitespace + word boundaries
263
+ * (so \b-anchored rules like email / private-host still fire). Boundary-safe.
264
+ *
265
+ * @param {string} text
266
+ * @returns {string}
267
+ */
268
+ export function stripZeroWidth(text) {
269
+ return (typeof text === 'string' ? text : String(text ?? '')).replace(ZERO_WIDTH_RE, '');
270
+ }
271
+
272
+ // NOTE: a whitespace-REJOINING normaliser (to rebuild a secret split across
273
+ // spaces/newlines) was prototyped and REMOVED — on natural-language blobs it
274
+ // fused ordinary words into spurious high-entropy tokens, over-redacting benign
275
+ // findings. Whitespace/newline-split secrets in benign free-text are therefore a
276
+ // documented limitation, defended instead by the category + body-field catch-alls
277
+ // (a security-category finding or any finding carrying a body field drops ALL its
278
+ // free text). See REDACTION_BOUNDARY_MAX_PLAN.md Progress Log.
279
+
280
+ // ── Span union scan ─────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * The union of all detection layers over `text`. Returns NON-overlapping spans
284
+ * (overlaps merged) sorted by start, each `{start, end, kind, name}` where kind ∈
285
+ * {secret, pii, entropy}. This is the single source of truth that scrubText
286
+ * redacts and that the fuzz invariant proves redactForEgress never leaks.
287
+ *
288
+ * The optional `ruleFilter` (Aegis engine `policy` param redaction-policy.js) can
289
+ * DISABLE a named secret/PII rule; `extraSecretRules` ADD org-custom secret rules.
290
+ * Neither can touch the entropy layer (the non-toggleable floor), so an actual
291
+ * secret still cannot leak even with a named rule turned off.
292
+ *
293
+ * @param {string} text
294
+ * @param {{ entropyThreshold?: number, ruleFilter?: (name:string, kind:string)=>boolean, extraSecretRules?: {name:string, regex:RegExp}[] }} [opts]
295
+ * @returns {{start:number, end:number, kind:'secret'|'pii'|'entropy', name:string}[]}
296
+ */
297
+ export function findSensitiveSpans(text, opts = {}) {
298
+ const s = typeof text === 'string' ? text : String(text ?? '');
299
+ if (!s) return [];
300
+ /** @type {{start:number,end:number,kind:string,name:string}[]} */
301
+ const spans = [];
302
+ const ruleFilter = typeof opts.ruleFilter === 'function' ? opts.ruleFilter : null;
303
+ const allowed = (name, kind) => !ruleFilter || ruleFilter(name, kind) !== false;
304
+
305
+ const pushFromRule = (rule, kind) => {
306
+ rule.regex.lastIndex = 0;
307
+ for (const m of s.matchAll(rule.regex)) {
308
+ // Prefer the named `secret` group, else group 1, else the whole match.
309
+ const captured = (m.groups && m.groups.secret !== undefined) ? m.groups.secret : (m[1] ?? m[0]);
310
+ if (captured === undefined || captured === '') continue;
311
+ if (rule.validate && !rule.validate(captured)) continue;
312
+ // Locate the captured span inside the full match (handles lookbehind + groups).
313
+ const matchStart = m.index ?? s.indexOf(m[0]);
314
+ const offset = m[0].indexOf(captured);
315
+ const start = matchStart + (offset >= 0 ? offset : 0);
316
+ spans.push({ start, end: start + captured.length, kind, name: rule.name });
317
+ }
318
+ };
319
+
320
+ for (const rule of SECRET_RULES) if (allowed(rule.name, 'secret')) pushFromRule(rule, 'secret');
321
+ // Org-custom secret rules (policy `rules.secret.extra`) — additive, already compiled + /g.
322
+ if (Array.isArray(opts.extraSecretRules)) {
323
+ for (const rule of opts.extraSecretRules) {
324
+ if (rule && rule.regex instanceof RegExp) pushFromRule(rule, 'secret');
325
+ }
326
+ }
327
+ for (const rule of PII_RULES) if (allowed(rule.name, 'pii')) pushFromRule(rule, 'pii');
328
+
329
+ // Layer 3 — entropy / token-efficiency over candidate tokens.
330
+ TOKEN_SCAN_RE.lastIndex = 0;
331
+ for (const m of s.matchAll(TOKEN_SCAN_RE)) {
332
+ const token = m[0];
333
+ const start = m.index ?? 0;
334
+ const positiveContext = hasContext(s, start, CONTEXT_WORDS.positive);
335
+ const negativeContext = hasContext(s, start, CONTEXT_WORDS.negative);
336
+ if (isStatisticallyRare(token, { entropyThreshold: opts.entropyThreshold, positiveContext, negativeContext })) {
337
+ spans.push({ start, end: start + token.length, kind: 'entropy', name: 'high_entropy' });
338
+ }
339
+ }
340
+
341
+ return mergeSpans(spans);
342
+ }
343
+
344
+ /**
345
+ * Merge overlapping/adjacent spans into the widest covering span so scrubText
346
+ * replaces each region once. Keeps the first contributing span's name/kind.
347
+ * @param {{start:number,end:number,kind:string,name:string}[]} spans
348
+ */
349
+ export function mergeSpans(spans) {
350
+ if (spans.length <= 1) return spans.slice();
351
+ const sorted = spans.slice().sort((a, b) => a.start - b.start || a.end - b.end);
352
+ const out = [sorted[0]];
353
+ for (let i = 1; i < sorted.length; i++) {
354
+ const prev = out[out.length - 1];
355
+ const cur = sorted[i];
356
+ if (cur.start <= prev.end) {
357
+ prev.end = Math.max(prev.end, cur.end); // overlap extend
358
+ } else {
359
+ out.push(cur);
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+
365
+ // ── Redaction modes ─────────────────────────────────────────────────────────
366
+ const MASK_CHAR = '█';
367
+
368
+ /**
369
+ * Replacement string for one span's text under the chosen mode. INVARIANT
370
+ * (mode=mask): the result is never LONGER than the original span and never
371
+ * reproduces the full secret verbatim.
372
+ * @param {string} value the raw span substring
373
+ * @param {string} kind
374
+ * @param {string} name
375
+ * @param {string} mode mask | label | hash | token | drop
376
+ * @returns {string}
377
+ */
378
+ function maskValue(value, kind, name, mode) {
379
+ switch (mode) {
380
+ case 'drop':
381
+ return '';
382
+ case 'label':
383
+ return `[REDACTED_${String(name || kind).toUpperCase()}]`;
384
+ case 'token': {
385
+ // Reversible vault token (plan §5.2/§5.3). The token is an HMAC — information-
386
+ // free, so the secret never crosses; the token→original mapping lives only in
387
+ // the local 0600 vault. FAILS CLOSED: no vault wired ⇒ fall back to mask.
388
+ if (_vaultTokenFn) {
389
+ try { return _vaultTokenFn(value); } catch { /* fall through to mask */ }
390
+ }
391
+ const keepT = Math.min(4, Math.floor(value.length / 3));
392
+ return value.slice(0, keepT) + MASK_CHAR.repeat(value.length - keepT);
393
+ }
394
+ case 'hash': {
395
+ // Length-stable, collision-resistant-enough correlation tag (no crypto dep).
396
+ let h = 0;
397
+ for (let i = 0; i < value.length; i++) h = (Math.imul(31, h) + value.charCodeAt(i)) | 0;
398
+ const hex = (h >>> 0).toString(16).padStart(8, '0');
399
+ return `«${name}:${hex}»`;
400
+ }
401
+ case 'mask':
402
+ default: {
403
+ // Keep a SHORT prefix (≤4 chars, never ≥ half the secret) for debuggability;
404
+ // mask the rest. Same length never lengthens; full secret never survives.
405
+ const keep = Math.min(4, Math.floor(value.length / 3));
406
+ return value.slice(0, keep) + MASK_CHAR.repeat(value.length - keep);
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Replace every sensitive span in `text` per `mode`. Idempotent
413
+ * (scrub(scrub(x)) === scrub(x)) and, for mode=mask, never lengthens the input.
414
+ * This SUPERSEDES the GitHub-only scrubSecrets for free-text egress.
415
+ *
416
+ * `opts` is forwarded to findSensitiveSpans, so the Aegis policy toggles
417
+ * (`ruleFilter`, `extraSecretRules`) apply here too.
418
+ *
419
+ * @param {string} text
420
+ * @param {{ mode?: string, entropyThreshold?: number, ruleFilter?: Function, extraSecretRules?: object[] }} [opts]
421
+ * @returns {string}
422
+ */
423
+ export function scrubText(text, opts = {}) {
424
+ const s = typeof text === 'string' ? text : (text == null ? '' : String(text));
425
+ if (!s) return s;
426
+ const mode = opts.mode || 'mask';
427
+ const spans = findSensitiveSpans(s, opts);
428
+ if (spans.length === 0) return s;
429
+
430
+ let out = '';
431
+ let cursor = 0;
432
+ for (const sp of spans) {
433
+ if (sp.start < cursor) continue; // defensive (merged spans shouldn't overlap)
434
+ out += s.slice(cursor, sp.start);
435
+ out += maskValue(s.slice(sp.start, sp.end), sp.kind, sp.name, mode);
436
+ cursor = sp.end;
437
+ }
438
+ out += s.slice(cursor);
439
+ return out;
440
+ }
441
+
442
+ // Re-export the GitHub-token-only redactor so callers can import it from here
443
+ // while github-api.js stays byte-for-byte unchanged (its existing tests pass).
444
+ export { scrubSecrets } from './github-api.js';