@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.
- package/README.md +67 -0
- package/dist/actor.js +106 -0
- package/dist/agent-policy.js +109 -0
- package/dist/attribution.js +52 -0
- package/dist/index.js +45 -0
- package/dist/local-classifier.js +499 -0
- package/dist/medusa-engine.js +720 -0
- package/dist/merge-spans.js +45 -0
- package/dist/policy-decision.js +480 -0
- package/dist/pseudonymize.js +91 -0
- package/dist/receipts.js +173 -0
- package/dist/webmcp-inventory.js +64 -0
- package/package.json +46 -0
|
@@ -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
|
+
}
|