@medusasec/sensitive-spans 0.1.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.
@@ -0,0 +1,499 @@
1
+ // Standalone backstop classifier — runs entirely inside the extension.
2
+ // Backstops the in-browser ONNX model: covers the window while the model
3
+ // loads, and runs when the model is disabled or unavailable.
4
+ //
5
+ // Scope: high-precision regex detectors for credentials and structured PII.
6
+ // This intentionally does NOT try to mimic the full ONNX model — there is no
7
+ // classifier here for free-form INJECTION patterns or unstructured PII. Those
8
+ // require the agent (or a future onnxruntime-web build, see roadmap).
9
+ //
10
+ // Each detector emits spans in the same shape as the agent's /classify
11
+ // endpoint: { category, text, start, end, confidence }. That way the
12
+ // background script's downstream code is mode-agnostic.
13
+
14
+ // ── SECRET detectors ────────────────────────────────────────────────────
15
+ // All confidence values reflect post-validation precision on adversarial
16
+ // corpora; tune in one place rather than per-call.
17
+ const SECRET_PATTERNS = [
18
+ { name: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/g, confidence: 0.99 },
19
+ { name: "aws_secret_key", re: /\b[A-Za-z0-9/+=]{40}\b(?=[^A-Za-z0-9/+=]|$)/g, confidence: 0.55, requireContext: /aws|secret|s3/i },
20
+ { name: "github_pat", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g, confidence: 0.99 },
21
+ // The (?!ant-|or-) lookahead avoids stealing matches from other
22
+ // sk-prefixed vendor formats below (anthropic, openrouter, etc).
23
+ // Without it openai_key wins by being listed first and the merge step
24
+ // hides that anthropic_key matched too.
25
+ { name: "openai_key", re: /\bsk-(?:proj-)?(?!ant-|or-)[A-Za-z0-9_-]{20,}\b/g, confidence: 0.99 },
26
+ // Real Anthropic keys carry mixed case + `_` in the suffix, e.g.
27
+ // sk-ant-api03-AbCd_Ef… The pre-1.0 regex was lowercase-only and
28
+ // missed every production key. The character class now matches the
29
+ // shape Anthropic actually mints.
30
+ { name: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, confidence: 0.99 },
31
+ { name: "stripe_live", re: /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{24,}\b/g, confidence: 0.99 },
32
+ { name: "stripe_test", re: /\b(?:sk|rk|pk)_test_[A-Za-z0-9]{24,}\b/g, confidence: 0.95 },
33
+ { name: "stripe_webhook", re: /\bwhsec_[A-Za-z0-9]{24,}\b/g, confidence: 0.99 },
34
+ { name: "slack_token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, confidence: 0.99 },
35
+ { name: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, confidence: 0.95 },
36
+ { name: "jwt", re: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, confidence: 0.95 },
37
+ { name: "private_key_block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]{20,}?-----END[^\n]+/g, confidence: 0.99 },
38
+ { name: "npm_token", re: /\bnpm_[A-Za-z0-9]{36}\b/g, confidence: 0.99 },
39
+ ];
40
+
41
+ // Partial / prefix credential detectors. These catch a CREDENTIAL FRAGMENT — a
42
+ // distinctive prefix without the full token — so a secret that's split across
43
+ // messages (see the session-window scan in background.js) or truncated is still
44
+ // flagged. Lower confidence than the full patterns above, which supersede these
45
+ // via the highest-confidence merge. The prefixes are specific enough to add ~0
46
+ // false positives on benign text (validated against the golden set); a minimum
47
+ // trailing length avoids matching a bare prefix word in prose. The full
48
+ // patterns require the exact full length + a word boundary, so a complete token
49
+ // never matches these (no internal boundary) — these fire only on fragments.
50
+ const PARTIAL_SECRET_PATTERNS = [
51
+ { name: "aws_key_fragment", re: /\bAKIA[0-9A-Z]{2,15}\b/g, confidence: 0.6 },
52
+ { name: "github_token_fragment", re: /\bgh[pousr]_[A-Za-z0-9]{6,35}\b/g, confidence: 0.6 },
53
+ { name: "anthropic_key_fragment", re: /\bsk-ant-[A-Za-z0-9_-]{2,19}/g, confidence: 0.6 },
54
+ { name: "openai_key_fragment", re: /\bsk-proj-[A-Za-z0-9_-]{2,19}/g, confidence: 0.6 },
55
+ { name: "stripe_key_fragment", re: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{4,23}\b/g, confidence: 0.6 },
56
+ { name: "slack_token_fragment", re: /\bxox[abprs]-[A-Za-z0-9-]{4,9}\b/g, confidence: 0.6 },
57
+ { name: "google_api_key_fragment", re: /\bAIza[0-9A-Za-z_-]{8,34}\b/g, confidence: 0.55 },
58
+ { name: "npm_token_fragment", re: /\bnpm_[A-Za-z0-9]{6,35}\b/g, confidence: 0.6 },
59
+ { name: "private_key_header", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g, confidence: 0.92 },
60
+ ];
61
+
62
+ // ── PII detectors ───────────────────────────────────────────────────────
63
+ //
64
+ // Structured PII only. Free-form name+address detection (unstructured
65
+ // PII) is what the ONNX agent is for — it does NER. We have one
66
+ // heuristic for "Name + street address" below as a fallback for when
67
+ // the agent's offline; it's deliberately conservative to keep false
68
+ // positives near zero.
69
+ const PII_PATTERNS = [
70
+ // US SSN — basic format. We don't validate the area/group/serial table because
71
+ // the SSA hasn't issued in pre-2011 patterns since randomization (~2011-06).
72
+ { name: "us_ssn", re: /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g, confidence: 0.85 },
73
+ // US ITIN — always 9XX area with a group in the ITIN ranges (50-65, 70-88,
74
+ // 90-92, 94-99). The 9XX area is what distinguishes it from an SSN (the SSN
75
+ // detector above excludes 9XX), so the two never collide.
76
+ { name: "us_itin", re: /\b9\d{2}-(?:5[0-9]|6[0-5]|7[0-9]|8[0-8]|9[0-2]|9[4-9])-\d{4}\b/g, confidence: 0.85 },
77
+ // US EIN (employer/tax id) — 2 digits, dash, 7 digits. Same shape as lots of
78
+ // numbers, so require a tax-id cue nearby to stay near-zero FP.
79
+ { name: "us_ein", re: /\b\d{2}-\d{7}\b/g, confidence: 0.75, requireContext: /\b(?:ein|employer\s+id|federal\s+tax|tax\s*id|tin)\b/i },
80
+ // US passport — 1 optional letter + 8 digits (or 9 digits). Bare 9-digit runs
81
+ // are everywhere, so gate on the word "passport".
82
+ { name: "us_passport", re: /\b[A-Z]?\d{8}\b/g, confidence: 0.8, requireContext: /\bpassport\b/i },
83
+ { name: "us_phone", re: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g, confidence: 0.65 },
84
+ { name: "email_addr", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, confidence: 0.7 },
85
+
86
+ // Free-form name + US street address. Catches the most common
87
+ // unstructured-PII shape ("john doe 123 main st") that slipped
88
+ // through the regex layer pre-1.1.1.
89
+ //
90
+ // Shape: <First> <Last> <number> <street> <type>
91
+ // - <First> <Last>: two consecutive capitalized words OR two
92
+ // lowercase words 2+ chars each (people type casually)
93
+ // - <number>: 1–6 digits with optional suffix letter
94
+ // - <street>: 1–3 short words
95
+ // - <type>: street-type abbreviation or full word (st, ave, blvd,
96
+ // …) — case-insensitive
97
+ //
98
+ // Confidence is moderate — false positives like "bob smith 42 north
99
+ // by northwest" are possible but unlikely in real prompts. The
100
+ // agent's ONNX NER will tighten this further when available.
101
+ {
102
+ name: "name_and_address",
103
+ re: /\b[A-Za-z][A-Za-z'-]{1,20}\s+[A-Za-z][A-Za-z'-]{1,20}\s+\d{1,6}[A-Za-z]?\s+(?:[A-Za-z][A-Za-z'-]{1,20}\s+){0,3}(?:st|st\.|street|ave|ave\.|avenue|blvd|blvd\.|boulevard|rd|rd\.|road|dr|dr\.|drive|ln|ln\.|lane|ct|ct\.|court|pl|pl\.|place|way|ter|terrace|cir|circle|hwy|highway|pkwy|parkway)\b/gi,
104
+ confidence: 0.7,
105
+ },
106
+ ];
107
+
108
+ // Credit cards need a Luhn check, so they get a custom finder.
109
+ const CC_RE = /\b(?:\d[ -]?){13,19}\b/g;
110
+
111
+ function luhnValid(digits) {
112
+ let sum = 0;
113
+ let alt = false;
114
+ for (let i = digits.length - 1; i >= 0; i--) {
115
+ let n = digits.charCodeAt(i) - 48;
116
+ if (n < 0 || n > 9) return false;
117
+ if (alt) {
118
+ n *= 2;
119
+ if (n > 9) n -= 9;
120
+ }
121
+ sum += n;
122
+ alt = !alt;
123
+ }
124
+ return sum % 10 === 0;
125
+ }
126
+
127
+ // ── FINANCIAL / PCI detectors ────────────────────────────────────────────
128
+ //
129
+ // Structured payment + banking identifiers → FINANCIAL. Same precision bar as
130
+ // the credit-card finder: validate a checksum where one exists (IBAN mod-97,
131
+ // ABA routing), and require a nearby keyword where the shape alone is too
132
+ // common (SWIFT/BIC, bank account, CVV, expiry, sort code). Cards themselves
133
+ // stay under PII (credit_card_luhn) for back-compat; the model tags them
134
+ // FINANCIAL, so both surface via the different-category merge.
135
+
136
+ // IBAN: 2-letter country + 2 check digits + BBAN, either contiguous or printed
137
+ // in the standard 4-char groups (anchoring to 4-char groups stops a trailing
138
+ // space from swallowing the next prose word). The mod-97 check does the real
139
+ // validation, so a shape-only false match is thrown out anyway.
140
+ const IBAN_RE = /\b[A-Za-z]{2}\d{2}(?:\s?[A-Za-z0-9]{4}){2,7}(?:\s?[A-Za-z0-9]{1,3})?\b/g;
141
+ function ibanValid(raw) {
142
+ const s = raw.replace(/\s+/g, "").toUpperCase();
143
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(s)) return false;
144
+ const rearranged = s.slice(4) + s.slice(0, 4);
145
+ let remainder = 0;
146
+ for (const ch of rearranged) {
147
+ const chunk = ch >= "A" && ch <= "Z" ? String(ch.charCodeAt(0) - 55) : ch;
148
+ for (const d of chunk) remainder = (remainder * 10 + (d.charCodeAt(0) - 48)) % 97;
149
+ }
150
+ return remainder === 1;
151
+ }
152
+
153
+ // US ABA routing number: 9 digits with the weighted mod-10 checksum. A raw
154
+ // 9-digit run is common (1-in-10 pass the checksum by chance), so it ALSO needs
155
+ // a routing/wire cue nearby — the pair is near-zero FP.
156
+ const ABA_RE = /\b\d{9}\b/g;
157
+ const ABA_CTX = /\b(?:routing|aba|rtn|ach|wire|transit|swift)\b/i;
158
+ function abaValid(d) {
159
+ if (!/^\d{9}$/.test(d)) return false;
160
+ const n = d.split("").map((c) => c.charCodeAt(0) - 48);
161
+ const sum = 3 * (n[0] + n[3] + n[6]) + 7 * (n[1] + n[4] + n[7]) + (n[2] + n[5] + n[8]);
162
+ return sum !== 0 && sum % 10 === 0;
163
+ }
164
+
165
+ const FINANCIAL_PATTERNS = [
166
+ // SWIFT / BIC — 4 bank + 2 country + 2 location (+ optional 3 branch). Eight
167
+ // uppercase chars is otherwise common, so gate on a wire/IBAN cue.
168
+ { name: "swift_bic", re: /\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b/g, confidence: 0.8, requireContext: /\b(?:swift|bic|iban|wire|beneficiary|intermediary)\b/i },
169
+ // Bank account number — no universal format, so detect ONLY when a strong
170
+ // account cue sits next to a 7-17 digit run.
171
+ { name: "bank_account", re: /\b\d{7,17}\b/g, confidence: 0.75, requireContext: /\b(?:account\s*(?:no\.?|number|#|num)|acct\.?\s*(?:no|#)?|a\/c\s*(?:no|#)?)\b/i },
172
+ // Card CVV / CVC — 3-4 digits, pure sensitive-auth data. Context-gated (a bare
173
+ // 3-4 digit number is meaningless on its own).
174
+ { name: "card_cvv", re: /\b\d{3,4}\b/g, confidence: 0.8, requireContext: /\b(?:cvv|cvc|cvv2|cvc2|cid|security\s+code|card\s+verification)\b/i },
175
+ // Card expiry MM/YY(YY) — gate on an expiry cue so we don't grab every date.
176
+ { name: "card_expiry", re: /\b(?:0[1-9]|1[0-2])\/(?:\d{4}|\d{2})\b/g, confidence: 0.6, requireContext: /\b(?:exp(?:iry|iration|\.)?|valid\s+(?:thru|until)|mm\/yy)\b/i },
177
+ // UK sort code NN-NN-NN — gate on "sort code" (same shape as many dashed nums).
178
+ { name: "uk_sort_code", re: /\b\d{2}-\d{2}-\d{2}\b/g, confidence: 0.75, requireContext: /\bsort\s*code\b/i },
179
+ ];
180
+
181
+ // ── INJECTION detectors ─────────────────────────────────────────────────
182
+ //
183
+ // The threat model here is "user pastes a prompt-injection payload INTO an
184
+ // LLM provider's input box" — the most common copy-paste exfil pattern.
185
+ // Each detector targets a documented jailbreak family with calibrated
186
+ // precision on adversarial samples; confidence values are conservative
187
+ // (false positives on legit prompts are far more painful than misses,
188
+ // which the agent's ONNX model picks up anyway when it's available).
189
+ //
190
+ // We intentionally tag detectors with the family name so the UI can show
191
+ // the user "this looks like a DAN-family jailbreak" instead of just
192
+ // "INJECTION".
193
+ const INJECTION_PATTERNS = [
194
+ // ── Classic instruction override ──
195
+ {
196
+ name: "ignore_previous",
197
+ re: /\bignore (?:all |the |any |my )?(?:previous|prior|above|preceding|earlier) (?:instructions?|prompts?|messages?|directives?|rules?|guidelines?)\b/gi,
198
+ confidence: 0.92,
199
+ },
200
+ {
201
+ name: "disregard_previous",
202
+ re: /\b(?:disregard|forget|override|nullify|cancel) (?:all |the |any |your )?(?:previous|prior|above|earlier|system) (?:instructions?|prompts?|directives?|rules?|guidelines?|training|programming)\b/gi,
203
+ confidence: 0.93,
204
+ },
205
+ {
206
+ name: "new_instructions",
207
+ re: /\b(?:new|updated|revised|fresh) (?:instructions?|system prompt|rules?)\s*[:\-]/gi,
208
+ confidence: 0.82,
209
+ },
210
+
211
+ // ── DAN / jailbreak family ──
212
+ {
213
+ name: "dan_family",
214
+ re: /\b(?:DAN(?: \d+(?:\.\d+)?)?(?: mode)?|do anything now|developer mode|jailbroken|jailbreak(?: mode|ed)?|evil mode|god mode|unrestricted mode)\b/gi,
215
+ confidence: 0.95,
216
+ },
217
+ {
218
+ name: "act_as_role",
219
+ re: /\b(?:you are now|act as|pretend to be|roleplay as|simulate being|become) (?:an?|the) (?:unrestricted|jailbroken|uncensored|amoral|DAN|evil|hacker|developer mode|unfiltered|free|liberated)\b/gi,
220
+ confidence: 0.95,
221
+ },
222
+ {
223
+ name: "no_restrictions",
224
+ re: /\b(?:without|no|ignoring|bypassing) (?:any |all |your )?(?:restrictions?|filters?|safety|guard ?rails?|guidelines?|policies|ethical (?:guidelines?|considerations?)|content policy|warnings?|disclaimers?)\b/gi,
225
+ confidence: 0.85,
226
+ },
227
+
228
+ // ── System-prompt extraction ──
229
+ {
230
+ name: "reveal_system_prompt",
231
+ re: /\b(?:reveal|show me|print|output|repeat|tell me|display|reproduce|share) (?:your |the |me your )?(?:system|initial|original|hidden|secret) (?:prompt|instructions?|message|context)\b/gi,
232
+ confidence: 0.93,
233
+ },
234
+ {
235
+ name: "what_is_system_prompt",
236
+ // Allow stacked adjectives ("exact initial instructions") via the
237
+ // outer + quantifier. The non-greedy bound prevents runaway matching.
238
+ re: /\bwhat (?:is|are|were) (?:your |the )?(?:exact |original |initial |system |true |first )+(?:instructions?|prompts?|directives?)\b/gi,
239
+ confidence: 0.9,
240
+ },
241
+ {
242
+ name: "verbatim_repeat",
243
+ // Allow optional "the " before the positional word ("repeat verbatim
244
+ // the above") and accept "context" / "system prompt" / "system
245
+ // message" as the object — common phrasing in extraction attempts.
246
+ re: /\b(?:repeat|print|output) (?:verbatim|word[- ]for[- ]word|exactly|the (?:entire|full|complete)) (?:the )?(?:above|prior|earlier|preceding|your|context|system (?:prompt|message))\b/gi,
247
+ confidence: 0.85,
248
+ },
249
+
250
+ // ── Chat-template / role markers ──
251
+ // These appear when someone copy-pastes from a fine-tuning dataset or
252
+ // attempts to inject a synthetic conversation turn.
253
+ {
254
+ name: "openai_chat_marker",
255
+ re: /<\|(?:im_start|im_end|system|user|assistant|endoftext|startoftext)\|>/g,
256
+ confidence: 0.99,
257
+ },
258
+ {
259
+ name: "anthropic_chat_marker",
260
+ re: /\b(?:\\n\\nHuman:|\\n\\nAssistant:|\bHuman:\s+|\bAssistant:\s+)/g,
261
+ confidence: 0.7, // lower — these CAN appear in legit conversation transcripts
262
+ requireContext: /system|instruction|prompt|ignore|override|jailbreak/i,
263
+ },
264
+ {
265
+ name: "llama_chat_marker",
266
+ re: /\[INST\]|\[\/INST\]|<<SYS>>|<<\/SYS>>/g,
267
+ confidence: 0.97,
268
+ },
269
+
270
+ // ── Output-format bypass ──
271
+ // "Skip your usual warnings", "respond only with X", etc. — used to
272
+ // strip safety preambles from the response.
273
+ {
274
+ name: "skip_warnings",
275
+ re: /\b(?:no|skip|omit|don'?t (?:include|add|write)) (?:any |the |all )?(?:warnings?|disclaimers?|caveats?|apologies|refusals?|moralizing)\b/gi,
276
+ confidence: 0.88,
277
+ },
278
+ {
279
+ name: "respond_only_with",
280
+ re: /\brespond (?:only|exclusively) with (?:the answer|the code|the result|raw)\b/gi,
281
+ confidence: 0.7,
282
+ },
283
+
284
+ // ── Persona override ──
285
+ {
286
+ name: "no_longer_ai",
287
+ re: /\byou are no longer (?:ChatGPT|GPT|Claude|Gemini|Bard|Copilot|Bing|an? AI|an? assistant|an? language model|an? LLM)\b/gi,
288
+ confidence: 0.93,
289
+ },
290
+ {
291
+ name: "your_name_is_now",
292
+ re: /\byour name is now (?:DAN|Evil|Free|Hacker|Liberation|Anti|Anarchy)\b/gi,
293
+ confidence: 0.95,
294
+ },
295
+
296
+ // ── Exfil / secret-tell patterns ──
297
+ {
298
+ name: "do_not_tell",
299
+ re: /\bdo not (?:tell|inform|reveal|notify) (?:the )?user\b/gi,
300
+ confidence: 0.9,
301
+ },
302
+ {
303
+ name: "between_us",
304
+ // "Between us" appears constantly in legit casual writing. We only
305
+ // raise the flag when paired with explicit instruction-hiding cues
306
+ // (system / prompt / instruction / hidden / reveal) — NOT just any
307
+ // mention of "secret", since "the secret is safe with me" is fine.
308
+ re: /\b(?:between (?:you and )?(?:me|us)|just between us|don'?t let anyone know)\b/gi,
309
+ confidence: 0.7,
310
+ requireContext: /\b(?:system|prompt|instruction|hidden (?:prompt|message)|reveal)\b/i,
311
+ },
312
+
313
+ // ── Token smuggling / encoding-tagged bypass ──
314
+ // Common in prompt-injection corpora: "Decode this base64 and follow it".
315
+ {
316
+ name: "decode_and_follow",
317
+ re: /\b(?:decode|decrypt|translate|run) (?:this |the following )?(?:base64|hex|rot13|binary|morse)(?: code| string)? (?:and |then )?(?:follow|execute|do|comply|obey)\b/gi,
318
+ confidence: 0.93,
319
+ },
320
+
321
+ // ── Sudo / authority claim ──
322
+ {
323
+ name: "sudo_claim",
324
+ re: /\b(?:sudo|with root access|as (?:an? |the )?admin(?:istrator)?|with (?:full|elevated) (?:privileges|permissions))\b\s+(?:do|run|execute|tell|show|reveal|give|provide)/gi,
325
+ confidence: 0.78,
326
+ },
327
+ ];
328
+
329
+ // ── Main classification entry point ─────────────────────────────────────
330
+
331
+ export function classifyLocal(text, opts = {}) {
332
+ const minConfidence = opts.minConfidence ?? 0.5;
333
+ // Built-in detectors an org turned OFF from the dashboard (policy
334
+ // `disabled_detectors`). Absent/empty = every built-in runs (the default).
335
+ // Keyed by the detector's `name` — the same id the dashboard catalog uses.
336
+ const disabled =
337
+ opts.disabledDetectors instanceof Set
338
+ ? opts.disabledDetectors
339
+ : new Set(opts.disabledDetectors || []);
340
+ const spans = [];
341
+
342
+ const push = (category, name, match, confidence) => {
343
+ if (confidence < minConfidence) return;
344
+ spans.push({
345
+ category,
346
+ text: match[0],
347
+ start: match.index,
348
+ end: match.index + match[0].length,
349
+ confidence,
350
+ detector: name,
351
+ });
352
+ };
353
+
354
+ const runSet = (set, category) => {
355
+ for (const { name, re, confidence, requireContext } of set) {
356
+ if (disabled.has(name)) continue; // org disabled this built-in detector
357
+ re.lastIndex = 0;
358
+ let m;
359
+ while ((m = re.exec(text)) !== null) {
360
+ if (requireContext) {
361
+ // Look at a small window around the match for the context cue.
362
+ const ctxStart = Math.max(0, m.index - 60);
363
+ const ctxEnd = Math.min(text.length, m.index + m[0].length + 60);
364
+ if (!requireContext.test(text.slice(ctxStart, ctxEnd))) continue;
365
+ }
366
+ push(category, name, m, confidence);
367
+ }
368
+ }
369
+ };
370
+
371
+ runSet(SECRET_PATTERNS, "SECRET");
372
+ runSet(PARTIAL_SECRET_PATTERNS, "SECRET");
373
+ runSet(PII_PATTERNS, "PII");
374
+ runSet(FINANCIAL_PATTERNS, "FINANCIAL");
375
+ runSet(INJECTION_PATTERNS, "INJECTION");
376
+
377
+ // Credit cards with Luhn validation.
378
+ if (!disabled.has("credit_card_luhn")) {
379
+ CC_RE.lastIndex = 0;
380
+ let cc;
381
+ while ((cc = CC_RE.exec(text)) !== null) {
382
+ const digits = cc[0].replace(/[ -]/g, "");
383
+ if (digits.length >= 13 && digits.length <= 19 && luhnValid(digits)) {
384
+ push("PII", "credit_card_luhn", cc, 0.9);
385
+ }
386
+ }
387
+ }
388
+
389
+ // IBAN with the ISO mod-97 checksum → FINANCIAL.
390
+ if (!disabled.has("iban")) {
391
+ IBAN_RE.lastIndex = 0;
392
+ let ib;
393
+ while ((ib = IBAN_RE.exec(text)) !== null) {
394
+ if (ibanValid(ib[0])) push("FINANCIAL", "iban", ib, 0.9);
395
+ }
396
+ }
397
+
398
+ // US ABA routing number: checksum AND a routing/wire cue nearby.
399
+ if (!disabled.has("us_aba_routing")) {
400
+ ABA_RE.lastIndex = 0;
401
+ let ab;
402
+ while ((ab = ABA_RE.exec(text)) !== null) {
403
+ if (!abaValid(ab[0])) continue;
404
+ const ctx = text.slice(Math.max(0, ab.index - 40), ab.index + ab[0].length + 40);
405
+ if (!ABA_CTX.test(ctx)) continue;
406
+ push("FINANCIAL", "us_aba_routing", ab, 0.85);
407
+ }
408
+ }
409
+
410
+ // Org-defined custom detectors (SITs) from policy — extra regexes the admin
411
+ // added, each tagged with a category. Deterministic, so high confidence.
412
+ runCustomPatterns(text, opts.customPatterns, spans, minConfidence);
413
+
414
+ return mergeAdjacent(spans);
415
+ }
416
+
417
+ // The extension's category keys are lowercased model labels (code, secret, …).
418
+ // The dashboard's custom-pattern category enum still uses "source_code" — map it
419
+ // so the resulting span flows through decide() (which keys on "code").
420
+ function normalizeCategory(c) {
421
+ const lc = String(c || "").toLowerCase();
422
+ return lc === "source_code" ? "code" : lc;
423
+ }
424
+
425
+ // Apply admin-defined custom regexes. Invalid regexes are skipped; matches are
426
+ // capped to bound pathological patterns (admin-authored, but be defensive).
427
+ function runCustomPatterns(text, patterns, spans, minConfidence) {
428
+ if (!Array.isArray(patterns) || 0.95 < (minConfidence ?? 0.5)) return;
429
+ for (const p of patterns) {
430
+ if (!p || !p.pattern || !p.category) continue;
431
+ const category = normalizeCategory(p.category).toUpperCase();
432
+ if (!category) continue;
433
+ let re;
434
+ try {
435
+ re = new RegExp(p.pattern, "gi");
436
+ } catch {
437
+ continue; // invalid regex — ignore rather than break scanning
438
+ }
439
+ let m;
440
+ let count = 0;
441
+ re.lastIndex = 0;
442
+ while ((m = re.exec(text)) !== null) {
443
+ if (m[0] === "") { re.lastIndex++; continue; } // guard zero-width loops
444
+ spans.push({
445
+ category,
446
+ text: m[0],
447
+ start: m.index,
448
+ end: m.index + m[0].length,
449
+ confidence: 0.95,
450
+ detector: `custom:${p.name || "pattern"}`,
451
+ });
452
+ if (++count >= 100) break; // cap matches per pattern
453
+ }
454
+ }
455
+ }
456
+
457
+ // Drop spans whose matched text is an allowlisted false positive. An entry with
458
+ // an empty category applies to every category; otherwise it's scoped.
459
+ export function applyAllowlist(spans, allowlist) {
460
+ if (!Array.isArray(allowlist) || allowlist.length === 0) return spans;
461
+ return (spans || []).filter((s) => !allowlist.some((a) => allowlistMatches(a, s)));
462
+ }
463
+
464
+ function allowlistMatches(entry, span) {
465
+ if (!entry || !entry.value) return false;
466
+ if (entry.category) {
467
+ const ec = normalizeCategory(entry.category).toUpperCase();
468
+ if (ec && ec !== String(span.category || "").toUpperCase()) return false;
469
+ }
470
+ const text = String(span.text || "").trim();
471
+ const val = String(entry.value);
472
+ switch (entry.match_type) {
473
+ case "exact": return text === val;
474
+ case "prefix": return text.startsWith(val);
475
+ case "regex":
476
+ try { return new RegExp(val).test(text); } catch { return false; }
477
+ default: return false;
478
+ }
479
+ }
480
+
481
+ // If two spans of the same category touch or overlap, collapse them into one.
482
+ // Keeps the UI from showing redundant findings (e.g. AWS access + secret pair).
483
+ function mergeAdjacent(spans) {
484
+ if (spans.length < 2) return spans;
485
+ spans.sort((a, b) => a.start - b.start);
486
+ const out = [spans[0]];
487
+ for (let i = 1; i < spans.length; i++) {
488
+ const prev = out[out.length - 1];
489
+ const cur = spans[i];
490
+ if (cur.category === prev.category && cur.start <= prev.end + 1) {
491
+ prev.end = Math.max(prev.end, cur.end);
492
+ prev.text = `${prev.text} … ${cur.text}`;
493
+ prev.confidence = Math.max(prev.confidence, cur.confidence);
494
+ } else {
495
+ out.push(cur);
496
+ }
497
+ }
498
+ return out;
499
+ }