@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,45 @@
1
+ // Merge detected spans from multiple layers (the in-browser ONNX model and the
2
+ // deterministic regex pack) into a single list.
3
+ //
4
+ // Rules:
5
+ // • Same-category spans that overlap collapse to the higher-confidence one,
6
+ // so a key both layers catch isn't flagged twice.
7
+ // • Spans of *different* categories are both kept, even when they overlap.
8
+ // The banner takes the strictest action across every detected category, and
9
+ // the user should see all of them. This is what lets the deterministic
10
+ // regex SECRET span survive when the model labels the same text CODE — e.g.
11
+ // an API key buried in a long paste that the model only sees as "code".
12
+ //
13
+ // Pure + dependency-free so it can be unit-tested off the service worker.
14
+ // A "degenerate" span carries <3 alphanumeric chars — e.g. a broken model
15
+ // WordPiece like "t" (from "st"). It's useless as evidence and must never win a
16
+ // merge over a real, meaningful span (e.g. the full "9000 jane st" address).
17
+ const spanChars = (s) => String((s && s.text) || "").replace(/[^\p{L}\p{N}]/gu, "").length;
18
+ const isDegenerate = (s) => spanChars(s) < 3;
19
+
20
+ export function mergeSpans(...lists) {
21
+ const out = [];
22
+ for (const list of lists) {
23
+ for (const s of list || []) {
24
+ const cat = String(s.category || "").toUpperCase();
25
+ const i = out.findIndex(
26
+ (o) =>
27
+ String(o.category || "").toUpperCase() === cat &&
28
+ s.start < o.end &&
29
+ s.end > o.start,
30
+ );
31
+ if (i === -1) {
32
+ out.push(s);
33
+ } else {
34
+ // Overlap, same category → keep the better representative. A meaningful
35
+ // span always beats a degenerate fragment; otherwise, higher confidence.
36
+ const inDeg = isDegenerate(out[i]);
37
+ const chDeg = isDegenerate(s);
38
+ if (inDeg && !chDeg) out[i] = s;
39
+ else if (!inDeg && chDeg) { /* keep the meaningful incumbent */ }
40
+ else if ((s.confidence ?? 0) > (out[i].confidence ?? 0)) out[i] = s;
41
+ }
42
+ }
43
+ }
44
+ return out;
45
+ }
@@ -0,0 +1,480 @@
1
+ // The canonical "what do we do with these detected spans" decision, shared by
2
+ // the service worker and unit-tested in isolation.
3
+ //
4
+ // Two independent controls decide whether a category is enforced:
5
+ // 1. settings.enabledCategories[CAT] — the per-category on/off toggle
6
+ // (user setting; the org can lock it). OFF here = never flagged.
7
+ // 2. policy.category_actions[cat] === "off" — the ORG turned this category
8
+ // off from the dashboard. OFF here = never flagged (silently allowed),
9
+ // even if the local toggle is on.
10
+ // A span survives only if BOTH say the category is on AND it clears the
11
+ // confidence floor. Surviving spans then get an action (block/warn/coach).
12
+ //
13
+ // Pure + dependency-free so it runs under node:test.
14
+
15
+ // Standalone dates/timestamps and OCR garbage are frequently mislabeled PII by
16
+ // the model (e.g. the clock baked into a screenshot). A PII span is "noise" if,
17
+ // once date/time words are stripped, nothing that resembles a real entity is
18
+ // left — no alphabetic token of 3+ letters and no run of 4+ digits. Structured
19
+ // PII (email, SSN, phone, cards) always keeps a real token or a long digit run,
20
+ // so this never drops legitimate detections — only bare dates and OCR garble.
21
+ const DATE_WORDS =
22
+ /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b|\b(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\b|\b(?:at|am|pm|gmt|utc|on|the|today|yesterday|tomorrow)\b/gi;
23
+
24
+ // INTENT categories (dual-head sequence head): whole-text properties, not
25
+ // discrete values. They must never resolve to `mask` (nothing to redact — a
26
+ // whole-text mask blanks the prompt), so actionForSpans downgrades mask→block
27
+ // for them. Kept in lowercase to match category_actions keys.
28
+ const INTENT_CATEGORIES = new Set(["injection", "code"]);
29
+
30
+ export function isNoisyPii(text) {
31
+ const raw = String(text || "");
32
+ const stripped = raw
33
+ .replace(/\b(?:19|20)\d{2}\b/g, " ") // 4-digit years (would otherwise look like a number)
34
+ .replace(/\d{1,2}:\d{2}(?::\d{2})?/g, " ") // clock times
35
+ .replace(DATE_WORDS, " ");
36
+ const hasWord = /[a-z]{3,}/i.test(stripped);
37
+ const hasNumber = /\d{4,}/.test(stripped); // SSN/phone tail/card group survive; bare dates don't
38
+ if (!hasWord && !hasNumber) return true; // bare date/timestamp
39
+
40
+ // OCR garbage guard: a screenshot that includes browser chrome (tab titles,
41
+ // bookmarks bar) OCRs into a soup of 1-2 char fragments and symbol runs —
42
+ // "e C ) lz) cost » (24) fA Marr f*®" — which the model can mislabel as PII.
43
+ // A GENUINE identifier always survives (isNoisyOcrText escapes on one).
44
+ return isNoisyOcrText(raw);
45
+ }
46
+
47
+ // Is `text` OCR "chrome soup"? A full-screen screenshot uploaded to an AI OCRs
48
+ // the tab titles + bookmarks bar + toolbar glyphs into a run of ≥4 tokens that is
49
+ // dominated by lone characters, symbol-bearing fragments (» ® © * parens), or
50
+ // short non-word/non-number junk. The model can mislabel that soup as PII / CODE /
51
+ // INJECTION. A GENUINE identifier (email/SSN/phone/card/address) means it is NOT
52
+ // soup, so real leaks buried in a screenshot are never suppressed by this.
53
+ export function isNoisyOcrText(text) {
54
+ const raw = String(text || "");
55
+ if (hasGenuineIdentifier(raw)) return false;
56
+ const toks = raw.trim().split(/\s+/).filter(Boolean);
57
+ if (toks.length < 4) return false;
58
+ // "fragments": lone single characters and tokens carrying stray symbols —
59
+ // real names/addresses/words don't produce these.
60
+ const fragments = toks.filter(
61
+ (t) => t.length === 1 || /[^\p{L}\p{N}.,'\-@]/u.test(t),
62
+ ).length;
63
+ // "junk": neither a 3+ letter word nor a 3+ digit number.
64
+ const junk = toks.filter(
65
+ (t) => !/^\p{L}{3,}$/u.test(t) && !/^\p{N}{3,}$/u.test(t),
66
+ ).length;
67
+ return (fragments >= 3 && fragments / toks.length >= 0.25) || junk / toks.length >= 0.6;
68
+ }
69
+
70
+ // Trim leading/trailing non-alphanumeric junk and collapse whitespace, so the
71
+ // evidence chip/preview shows clean text. Only ever used for display — never for
72
+ // matching or the approval hash.
73
+ export function cleanSpanText(text) {
74
+ return String(text || "")
75
+ .replace(/\s+/g, " ")
76
+ .split(" ")
77
+ .filter((t) => /[\p{L}\p{N}]/u.test(t)) // drop pure-symbol tokens (OCR noise: » ) *®)
78
+ .join(" ")
79
+ .replace(/^[^\p{L}\p{N}]+/u, "")
80
+ .replace(/[^\p{L}\p{N}]+$/u, "")
81
+ .trim();
82
+ }
83
+
84
+ // A GENUINE leak-risk identifier: email / SSN / card / IBAN / passport-or-license /
85
+ // phone or long digit run / street address. Used to gate PII findings from IMAGE OCR,
86
+ // where a screenshot of browser chrome / an app UI OCRs into a list of bare words that
87
+ // the model mislabels PII. Bare names/labels are low-risk anyway; a real identifier is
88
+ // the actual leak — and those survive OCR well (verified ~97% even on degraded scans).
89
+ const GENUINE_ID_RE = new RegExp(
90
+ [
91
+ /[\w.+-]+@[\w-]+\.\w+/, // email
92
+ /\b\d{3}[- .]?\d{2}[- .]?\d{4}\b/, // SSN
93
+ /\b(?:\d[ -]?){13,19}\b/, // card
94
+ /\b[a-z]{2}\d{2}[a-z0-9]{10,}\b/, // IBAN
95
+ /\b[a-z]{1,2}\d{6,9}\b/, // passport / license
96
+ /(?:\d[\s().\-]{0,2}){6,}\d/, // phone (7+ digits, allows "(415) 555-0192")
97
+ /\b\d{7,}\b/, // long account / id
98
+ /\b\d{1,5}\b[^,\n]{0,32}\b(?:street|st|avenue|ave|court|ct|road|rd|lane|ln|drive|dr|way|place|pl|blvd|terrace|ridge|circle|square|highway|hwy)\b/, // street address
99
+ ]
100
+ .map((r) => r.source)
101
+ .join("|"),
102
+ "i", // one flag for the whole set (street words + IBAN/passport are letter-agnostic in OCR)
103
+ );
104
+ export function hasGenuineIdentifier(text) {
105
+ return GENUINE_ID_RE.test(String(text || ""));
106
+ }
107
+
108
+ // Positive evidence that text is actually source code — real keywords, loaded
109
+ // operators, call/def syntax, markup tags, or control flow. Used to gate CODE
110
+ // findings from IMAGE OCR: a screenshot of browser chrome / an app UI OCRs into a
111
+ // soup of short words (tab titles, bookmark names, toolbar glyphs) the model can
112
+ // mislabel CODE. A real code screenshot carries this structure; browser chrome
113
+ // does not. Text/PDF/DOCX code is unaffected — this gate only applies to images.
114
+ const CODE_SIGNAL_RE = new RegExp(
115
+ [
116
+ /\b(?:function|const|let|var|def|class|import|export|return|public|private|protected|static|void|async|await|lambda|struct|interface|namespace|require|typedef|extends|implements|println|printf)\b/,
117
+ /=>|===|!==|\+=|-=|\*=|::|\|\||&&|->|<<|>>/, // multi-char / loaded operators
118
+ /\b[A-Za-z_$][\w$]*\s*\([^)]*\)\s*[{;]/, // call/def followed by a body or terminator
119
+ /<\/[a-z][\w-]*>|<[a-z][\w-]*(?:\s[^<>]*)?\/>/i, // html/xml/jsx tag
120
+ /\b(?:if|for|while|switch|catch)\s*\(/, // control flow with a paren
121
+ /#include\b|#!\s*\/|<\?php|\bSELECT\b[\s\S]{1,200}\bFROM\b/i, // include / shebang / php / sql
122
+ ]
123
+ .map((r) => r.source)
124
+ .join("|"),
125
+ "i",
126
+ );
127
+ export function hasCodeSignal(text) {
128
+ return CODE_SIGNAL_RE.test(String(text || ""));
129
+ }
130
+
131
+ // Positive evidence that a SECRET span carries an actual credential — a known
132
+ // key shape/prefix, a credential keyword next to a value, or a high-entropy
133
+ // token. The model's token head sometimes labels ordinary prose SECRET on long
134
+ // documents (e.g. "USD pp. Then beach hopping"); a real secret is never plain
135
+ // English words. Deterministic regex secrets (sk-…, ghp_…) bypass this — they
136
+ // carry deterministic=true and are already validated. Recall-safe: a genuine
137
+ // key/token/password always matches one of these.
138
+ const SECRET_PREFIX_RE = new RegExp(
139
+ [
140
+ /\b(?:sk|pk|rk)[-_](?:live|test|prod)[-_][A-Za-z0-9]{8,}/, // stripe-style
141
+ /\bsk-ant-[A-Za-z0-9_\-]{16,}/, // anthropic
142
+ /\bgh[posru]_[A-Za-z0-9]{20,}/, // github token
143
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/,
144
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/, // slack
145
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}/, // aws
146
+ /\bAIza[0-9A-Za-z_\-]{20,}/, // google api key
147
+ /\bya29\.[0-9A-Za-z_\-]{10,}/, // google oauth
148
+ /\bglpat-[0-9A-Za-z_\-]{16,}/, // gitlab
149
+ /\b(?:npm_|dop_v1_|shpat_|sq0csp-)[A-Za-z0-9_\-]{16,}/, // npm/digitalocean/shopify/square
150
+ /\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{4,}/, // JWT
151
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key
152
+ /\bwhsec_[A-Za-z0-9]{16,}/, // stripe webhook signing secret
153
+ ]
154
+ .map((r) => r.source)
155
+ .join("|"),
156
+ "i",
157
+ );
158
+ // A credential keyword immediately followed by a value ("password: hunter2",
159
+ // "api_key = ...", "Wifi Password 87654321").
160
+ const CRED_KEYWORD_RE =
161
+ /\b(?:pass(?:word|wd|phrase)?|secret|api[_\- ]?key|access[_\- ]?key|client[_\- ]?secret|private[_\- ]?key|auth[_\- ]?token|bearer)\b\s*[:=]?\s*\S{4,}/i;
162
+ export function hasSecretSignal(text) {
163
+ const raw = String(text || "");
164
+ if (SECRET_PREFIX_RE.test(raw) || CRED_KEYWORD_RE.test(raw)) return true;
165
+ // High-entropy token: a long no-space run that isn't natural language. Real
166
+ // English words are short and single-class; a 24+ char run, or a 20+ char run
167
+ // mixing letters and digits, or a 32+ hex string, is key-shaped.
168
+ for (const tok of raw.split(/[\s"'`,;:()<>{}[\]]+/)) {
169
+ if (!/^[A-Za-z0-9_\-+/=.]+$/.test(tok)) continue;
170
+ if (tok.length >= 24) return true;
171
+ if (tok.length >= 20 && /[A-Za-z]/.test(tok) && /[0-9]/.test(tok)) return true;
172
+ if (/^[0-9a-f]{32,}$/i.test(tok)) return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ // Positive evidence that an INSURANCE span is an actual data leak: a policy /
178
+ // member / group / claim number (a digit-bearing identifier), a genuine PII
179
+ // identifier, or any 4+ digit run. Abstract insurance talk ("third party
180
+ // liability", "collision damage waiver coverage") is a topic, not sensitive
181
+ // data, and the model mislabels it INSURANCE on long benign prose.
182
+ const INSURANCE_ID_RE = new RegExp(
183
+ [
184
+ /\b(?:policy|member|subscriber|group|claim|certificate|plan|rx|bin|pcn)\b[^\n]{0,12}?[A-Z]{0,4}\d{3,}/i,
185
+ /\b[A-Z]{2,4}[- ]?\d{6,}\b/, // insurer id like BX-44718 / ABCD123456
186
+ ]
187
+ .map((r) => r.source)
188
+ .join("|"),
189
+ "i",
190
+ );
191
+ // Someone reading an ID out loud: three or more number-words in a row
192
+ // ("begins eight-eight-two-two", "ends five-five-nine-oh"). Rare in ordinary
193
+ // prose, and it is how people paraphrase an identifier they don't want to paste
194
+ // verbatim — so it recovers the oblique member/claim IDs that carry no digits at
195
+ // all, without admitting insurance TOPIC vocabulary.
196
+ const SPELLED_NUMBER_RUN =
197
+ /\b(?:zero|oh|one|two|three|four|five|six|seven|eight|nine|ten)\b(?:[\s,-]+\b(?:zero|oh|one|two|three|four|five|six|seven|eight|nine|ten)\b){2,}/i;
198
+
199
+ export function hasInsuranceSignal(text) {
200
+ const raw = String(text || "");
201
+ // A bare 4-digit run used to qualify, which meant any year or figure did —
202
+ // government reports and news are full of them, and 17% of the INSURANCE false
203
+ // positives measured on real prose rode in on that rule alone. Require a real
204
+ // identifier, an insurance-ID shape, a run long enough not to be a year, or a
205
+ // spelled-out identifier.
206
+ return (
207
+ hasGenuineIdentifier(raw) ||
208
+ INSURANCE_ID_RE.test(raw) ||
209
+ /\b\d{6,}\b/.test(raw) ||
210
+ SPELLED_NUMBER_RUN.test(raw)
211
+ );
212
+ }
213
+
214
+ // Positive evidence that a FINANCIAL span is actual financial data: a card / IBAN /
215
+ // bank-account or routing number, or a money/account keyword next to a number. A
216
+ // bare PRICE ("$12 USD per foreign adult", "90 usd pp") is NOT sensitive financial
217
+ // data — it's a cost — yet the model mislabels prices, place names, and stray
218
+ // words ("foreign", "Rebeca") FINANCIAL on long prose. Recall-safe: real financial
219
+ // PII always carries an account/card number or a money keyword with a value.
220
+ const FINANCIAL_SIGNAL_RE = new RegExp(
221
+ [
222
+ /\b(?:\d[ -]?){13,19}\b/, // card
223
+ /\b[a-z]{2}\d{2}[a-z0-9]{10,}\b/i, // IBAN
224
+ /\b\d{9,}\b/, // long account / routing number
225
+ /\b(?:account|acct|iban|swift|bic|routing|sort\s*code|ach|wire|balance|salary|wage|payroll|invoice|statement|ledger|deposit|withdrawal|transaction)\b[^\n]{0,20}\d/i,
226
+ /\b(?:visa|mastercard|amex|discover|cvv|cvc)\b/i,
227
+ ]
228
+ .map((r) => r.source)
229
+ .join("|"),
230
+ "i",
231
+ );
232
+ export function hasFinancialSignal(text) {
233
+ const raw = String(text || "");
234
+ return hasGenuineIdentifier(raw) || FINANCIAL_SIGNAL_RE.test(raw);
235
+ }
236
+
237
+ // A model span with almost no substance is noise, not a finding. The token head
238
+ // emits ragged 1-3 character runs on long or non-English documents ("imp", "Ku",
239
+ // "Has", "hoe") that surface as meaningless evidence chips. Measured over a
240
+ // 12,000-row benign corpus, 21% of all false-positive chips were this shape.
241
+ // A genuine value always carries more than a few characters — and anything that
242
+ // looks like a real identifier escapes before the length test.
243
+ const MIN_SPAN_ALNUM = 5;
244
+ export function isDegenerateSpan(text) {
245
+ const raw = String(text || "");
246
+ if (hasGenuineIdentifier(raw)) return false;
247
+ return raw.replace(/[^\p{L}\p{N}]/gu, "").length < MIN_SPAN_ALNUM;
248
+ }
249
+
250
+ // Positive evidence that a HEALTH span is somebody's health DATA rather than a
251
+ // health TOPIC. Discussing healthcare policy, psychology, or a medical concept
252
+ // is not a leak; a patient record is. Every genuine health row in the corpus
253
+ // carries at least one of: a person marker ("Patient X", "my wife", "dx:"), a
254
+ // record identifier (MRN / DOB / chart), a drug with a dose, or a lab value with
255
+ // units. Topic prose in news and government reports carries none of them — which
256
+ // is why HEALTH was the single largest false-positive category (1,465 chips)
257
+ // before this gate existed.
258
+ const HEALTH_PERSON_RE =
259
+ /\b(?:patient|pt\b|my (?:wife|husband|mother|mom|father|dad|son|daughter|brother|sister|partner|kid)|his|her|their)\b|\bdx\s*[:=]|\bdiagnos(?:ed|is)\b|\bpresents with\b|\bchart\b|\breferral\b/i;
260
+ const HEALTH_RECORD_RE =
261
+ /\bmrn\s*[:#]?\s*[\w-]+|\bdob\s*[:#]?\s*[\d/.-]+|\b\d{1,3}[\s-]*(?:y\/o|yo|yrs?[\s-]*old|year[\s-]*old)\b|\bsoap note\b|\bop note\b|\bcase report\b/i;
262
+ const HEALTH_CLINICAL_RE = new RegExp(
263
+ [
264
+ /\b\d+(?:\.\d+)?\s*(?:mg|mcg|ml|iu|units?)\b/, // a drug with a dose
265
+ /\b(?:a1c|hba1c|egfr|bmi|ef|rf|psa|inr|cd4|ldl|hdl|tsh|crp|esr)\b\s*(?:of\s*)?[<>]?\s*\d/, // lab + value
266
+ /\bviral load\b|\bblood pressure\s*\d|\b\d{2,3}\/\d{2,3}\s*mmhg\b/, // vitals
267
+ /\bstage\s+(?:i{1,3}v?|\d)\b/, // cancer staging
268
+ ]
269
+ .map((r) => r.source)
270
+ .join("|"),
271
+ "i",
272
+ );
273
+ export function hasHealthSignal(text) {
274
+ const raw = String(text || "");
275
+ return (
276
+ hasGenuineIdentifier(raw) ||
277
+ HEALTH_RECORD_RE.test(raw) ||
278
+ HEALTH_CLINICAL_RE.test(raw) ||
279
+ HEALTH_PERSON_RE.test(raw)
280
+ );
281
+ }
282
+
283
+ // Context that legitimizes a SECRET / INSURANCE finding. The genuine-signal
284
+ // guards below drop a model span whose own text carries no syntactic signal —
285
+ // but a real secret can be OBLIQUE ("my aws key is A K I A 7…", a base64'd token,
286
+ // "the passphrase is my dog's name") where the signal is in the surrounding words,
287
+ // not the value. So a span is ALSO kept when its surrounding text contains
288
+ // credential / insurance context. Benign prose (a travel itinerary) has neither
289
+ // the signal nor the context, so it is still dropped; an adversarial obfuscated
290
+ // secret is kept. Validated recall-first against 121k labeled rows: the only
291
+ // SECRET rows this still drops are bare card numbers (caught by the deterministic
292
+ // card detector + the FINANCIAL category instead). Multilingual on purpose
293
+ // (contrase-, senha, passwort) so a foreign-language credential isn't missed.
294
+ const SECRET_CONTEXT_RE =
295
+ /\b(?:api|key|keys|token|tokens|secret|secrets|password|passwords|passwd|passcode|passphrase|credential|credentials|creds|auth|bearer|oauth|ssh|webhook|env|login|vpn|pin|wifi|wi-fi|ssid|router|root|aws|github|gitlab|stripe|slack|sendgrid|twilio|mongo|postgres|database|connection\s*string|signing|access\s*key|private\s*key|client\s*secret|contrase|senha|passwort|mot\s*de\s*passe)\b/i;
296
+ // NOTE: INSURANCE is gated on span-signal ONLY (a policy/member/claim number),
297
+ // NOT on surrounding context. Unlike a secret, insurance data is only sensitive
298
+ // when an actual identifier is present — "third party liability coverage" is a
299
+ // topic, not a leak, yet sits amid insurance words. Context-gating would keep
300
+ // that benign topic talk (common in rental/legal docs). The cost is reduced
301
+ // recall on obliquely-stated member IDs, a low-value category for this product.
302
+
303
+ // The span's own text plus a little of the text around it (model spans carry
304
+ // char offsets into the full scanned text). Context lets the guard see credential
305
+ // wording that sits next to an obfuscated value.
306
+ const CONTEXT_PAD = 160;
307
+ function spanContext(span, fullText) {
308
+ if (typeof fullText === "string" && fullText && Number.isFinite(span?.start)) {
309
+ const lo = Math.max(0, span.start - CONTEXT_PAD);
310
+ const hi = Math.min(fullText.length, (span.end ?? span.start) + CONTEXT_PAD);
311
+ return fullText.slice(lo, hi);
312
+ }
313
+ return String(span?.text || "");
314
+ }
315
+
316
+ // Is a MODEL PII span actually a fragment of ordinary prose, not an entity? On
317
+ // long documents the token head mislabels runs of plain words PII ("…the
318
+ // entrance. You must pre-purchase…", "30 minute", "90 usd pp Then beach
319
+ // hopping"). This runs on the SPAN text (not the whole document): the model
320
+ // labels function words / units as O when it is working correctly, so their
321
+ // presence INSIDE a PII span is itself the tell that the span is a misfire. A
322
+ // genuine PII value — name, email, phone, address, SSN — trips none of these,
323
+ // and any real identifier escapes immediately. Three orthogonal signatures, all
324
+ // of which a real entity lacks:
325
+ // A) crosses a sentence boundary (". " / "? " / "!" / "??") — entities don't
326
+ // B) contains a prose function/modal word (you/must/then/…) — names don't
327
+ // (the list excludes words that double as names: will, grace, may, june…)
328
+ // C) is only numbers + measurement/currency units (usd, pp, minute, km …)
329
+ // D) all-lowercase multi-word prose joined by a preposition, with NO capitalized
330
+ // name-token — "…stops for photos or wildlife sightings". A genuine PII entity
331
+ // the model spans is either capitalized (a name/place → not all-lowercase) or
332
+ // an identifier (escapes at the top). Prepositions only (NOT and/or, which
333
+ // join names: "john and mary"), so a lowercase multi-word name is untouched.
334
+ const PII_PROSE_WORDS =
335
+ /\b(?:you|your|youre|youll|yours|must|then|because|would|should|could|please|weve|were|dont|doesnt|wont|cannot|well|theyre|weve|lets|dont|arent|isnt|whats|heres|theres)\b/i;
336
+ const PII_UNIT_TOKEN =
337
+ /^(?:[$€£]?\d[\d.,:/%-]*|usd|eur|gbp|cad|pp|ea|min|mins|minute|minutes|hour|hours|hr|hrs|sec|secs|second|seconds|day|days|week|weeks|month|months|year|years|yr|yrs|am|pm|km|mi|mile|miles|kg|lb|lbs|ft|cm|mm|m|%|and|or|to|of|per|the|a|an)$/i;
338
+ const PII_PREPOSITION = /\b(?:for|with|at|on|in|of|to|from|by|about|into|over|near|per|the)\b/i;
339
+ export function isProsePii(text) {
340
+ const raw = String(text || "");
341
+ if (hasGenuineIdentifier(raw)) return false; // a real identifier is never prose
342
+ // A) an entity never spans a sentence break
343
+ if (/[.?!]["')\]]?\s+\S|[?!]{2,}/.test(raw.trim())) return true;
344
+ // B) a function/modal word never appears inside a name/email/address/id
345
+ if (PII_PROSE_WORDS.test(raw)) return true;
346
+ // C) numbers + units only, with at least one number and no name-like token
347
+ const toks = raw.trim().split(/\s+/).filter(Boolean);
348
+ if (toks.length && toks.every((t) => PII_UNIT_TOKEN.test(t)) && /\d/.test(raw)) return true;
349
+ // D) all-lowercase multi-word prose with a preposition and no capitalized name
350
+ if (toks.length >= 3 && !/\p{Lu}/u.test(raw) && PII_PREPOSITION.test(raw)) return true;
351
+ return false;
352
+ }
353
+
354
+ export function filterSpans(spans, settings, policy, fullText) {
355
+ const localFloor = settings?.confidenceThreshold || 0;
356
+ const globalThreshold = Math.max(localFloor, policy?.min_confidence || 0);
357
+ const catThresholds = policy?.dlp_category_thresholds || {};
358
+ const actions = policy?.category_actions || {};
359
+ const enabled = settings?.enabledCategories || {};
360
+ return (spans || []).filter((s) => {
361
+ const up = (s.category || "").toUpperCase();
362
+ const lo = (s.category || "").toLowerCase();
363
+ if (!enabled[up]) return false; // category toggled off locally
364
+ if (actions[lo] === "off") return false; // category turned off by org policy → allow
365
+ // Per-category confidence floor OVERRIDES the global one when set (mirrors
366
+ // the gateway dlp.py precedence); the local user floor still applies as a
367
+ // hard minimum so a lax org threshold can't drop below the user's setting.
368
+ const threshold =
369
+ lo in catThresholds ? Math.max(localFloor, catThresholds[lo]) : globalThreshold;
370
+ if ((s.confidence ?? 1) < threshold) return false;
371
+ // Genuine-signal guards for MODEL spans. On long benign documents the token
372
+ // head over-fires — labeling ordinary prose SECRET / CODE / INSURANCE
373
+ // (e.g. "USD pp. Then beach hopping" → SECRET, "third party liability" →
374
+ // INSURANCE, park hours → CODE). The image/OCR path already gates these
375
+ // (background.js classifyFileText); the typed-text path previously did not,
376
+ // so the same false positives reached the user on a paste.
377
+ //
378
+ // SECRET keeps a span when its own text has the signal OR its surrounding
379
+ // text has credential context — so an OBLIQUE secret ("my aws key is
380
+ // A K I A 7…", a base64'd token) survives while a benign itinerary (neither
381
+ // signal nor context) is dropped. CODE is a whole-text intent property, so its
382
+ // span text is the document — signal-only is enough. INSURANCE is signal-only
383
+ // (a policy/member/claim number): insurance topic words are common in benign
384
+ // rental/legal prose, so context-gating would keep that benign talk. PII is
385
+ // gated on the SPAN text (isProsePii): the model labels function words / units
386
+ // as O, so their presence INSIDE a PII span marks it a prose misfire — a
387
+ // genuine name/email/address/id trips none of the signatures, so recall on
388
+ // real PII is preserved. Deterministic regex hits (sk-…, ghp_…) carry
389
+ // deterministic=true and are already validated — never second-guess them.
390
+ if (!s.deterministic && s.text) {
391
+ // A near-empty span is noise in every category — check it once, first.
392
+ // Intent spans (injection/code) carry the whole text as their evidence, so
393
+ // they are exempt from the length test.
394
+ if (s.kind !== "intent" && isDegenerateSpan(s.text)) return false;
395
+ if (up === "SECRET" && !hasSecretSignal(s.text) && !SECRET_CONTEXT_RE.test(spanContext(s, fullText)))
396
+ return false;
397
+ if (up === "CODE" && !hasCodeSignal(s.text)) return false;
398
+ if (up === "INSURANCE" && !hasInsuranceSignal(s.text)) return false;
399
+ if (up === "FINANCIAL" && !hasFinancialSignal(s.text)) return false; // price/word ≠ financial data
400
+ if (up === "HEALTH" && !hasHealthSignal(s.text)) return false; // health TOPIC ≠ health DATA
401
+ if (lo === "pii" && isProsePii(s.text)) return false; // model tagged a prose fragment PII
402
+ }
403
+ if (lo === "pii" && isNoisyPii(s.text)) return false; // bare date/timestamp / OCR noise
404
+ return true;
405
+ });
406
+ }
407
+
408
+ // Decide the UI action across a set of (already-filtered) spans. The "worst"
409
+ // action wins: block > mask > warn > coach. A disallow_override category forces
410
+ // a hard block even if its action was only "warn". "mask" redacts the matched
411
+ // value from the outgoing request (enforced in netguard) and lets the rest send.
412
+ export function actionForSpans(spans, policy) {
413
+ const rank = { block: 4, mask: 3, warn: 2, coach: 1 };
414
+ let worst = "coach";
415
+ let requireJustification = false;
416
+ let disallowOverride = false;
417
+ let disallowApproval = false;
418
+ const reqJust = new Set((policy?.require_justification || []).map((s) => s.toLowerCase()));
419
+ const noOverride = new Set((policy?.disallow_override || []).map((s) => s.toLowerCase()));
420
+ const noApproval = new Set((policy?.disallow_approval || []).map((s) => s.toLowerCase()));
421
+ const actions = policy?.category_actions || {};
422
+ for (const s of spans || []) {
423
+ const cat = (s.category || "").toLowerCase();
424
+ let a = actions[cat] || "warn";
425
+ // mask-safety invariant: an intent category (injection/code) has no discrete
426
+ // value to redact — masking it would blank the whole prompt in netguard.
427
+ // Downgrade mask→block. Guarded by BOTH the category set and the kind tag
428
+ // the offscreen model attaches, so it holds even if one is missing.
429
+ if (a === "mask" && (INTENT_CATEGORIES.has(cat) || s.kind === "intent")) {
430
+ a = "block";
431
+ }
432
+ if ((rank[a] || 0) > (rank[worst] || 0)) worst = a;
433
+ if (reqJust.has(cat)) requireJustification = true;
434
+ if (noOverride.has(cat)) disallowOverride = true;
435
+ if (noApproval.has(cat)) disallowApproval = true;
436
+ }
437
+ // disallow_override forces a hard block (no "Send anyway") even if the action
438
+ // was only "warn"; disallow_approval additionally removes "Request approval".
439
+ if (disallowOverride) {
440
+ worst = "block";
441
+ requireJustification = false;
442
+ }
443
+ return { action: worst, requireJustification, disallowOverride, disallowApproval };
444
+ }
445
+
446
+ // Full decision: filter to the enforced spans, then pick the action. When no
447
+ // span survives, `spans` is empty and the caller allows the submission.
448
+ export function decide(spans, settings, policy, fullText) {
449
+ const filtered = filterSpans(spans, settings, policy, fullText)
450
+ .map((s) => ({ ...s, text: cleanSpanText(s.text) }))
451
+ // Strongest match first, so the banner + admin event lead with the most
452
+ // meaningful evidence rather than whatever the model happened to emit first.
453
+ .sort((a, b) => (b.confidence ?? 1) - (a.confidence ?? 1));
454
+ return { spans: filtered, ...actionForSpans(filtered, policy) };
455
+ }
456
+
457
+ // Observe (monitor) mode: the org scans + logs findings but never enforces. The
458
+ // content script records the would-be action and lets the prompt through.
459
+ export function isObserveMode(policy) {
460
+ return !!(policy && policy.observe);
461
+ }
462
+
463
+ // Fail-closed mode (org policy `fail_mode: "closed"`): when scanning is
464
+ // UNAVAILABLE (worker dead, model hung, classify timeout), block the send
465
+ // instead of failing open. The consumer default stays fail-open — a broken
466
+ // extension must never lock an unmanaged user out of a site — but a compliance
467
+ // org can choose "no unverified content leaves the browser" as posture.
468
+ export function isFailClosed(policy) {
469
+ return !!(policy && policy.fail_mode === "closed");
470
+ }
471
+
472
+ // What to do with a file we can't read (image, encrypted, legacy .doc, oversize).
473
+ // Admin-overridable via policy.upload_unscannable_action; otherwise the default
474
+ // follows the org's failure posture: warn normally, block under fail-closed
475
+ // (an unreadable file IS an unverified send).
476
+ export function unscannableAction(policy) {
477
+ const a = policy && policy.upload_unscannable_action;
478
+ if (a === "off" || a === "warn" || a === "block") return a;
479
+ return isFailClosed(policy) ? "block" : "warn";
480
+ }
@@ -0,0 +1,91 @@
1
+ // Pseudonymization — consistent, reversible placeholders for detected spans.
2
+ //
3
+ // Used for agent-driven submissions when the org policy sets
4
+ // `agents.pseudonymize`: instead of blocking or warning, every detected value
5
+ // is replaced with a stable token ("[PII-1]", "[SECRET-2]") so the same value
6
+ // maps to the same token for the life of the tab, and the mapping can be
7
+ // rehydrated locally later. The map never leaves the browser.
8
+ //
9
+ // Pure module (unit-tested). Storage of the per-tab map is the caller's job.
10
+
11
+ export const PLACEHOLDER_PREFIX = {
12
+ secret: "SECRET",
13
+ pii: "PII",
14
+ financial: "FIN",
15
+ health: "PHI",
16
+ insurance: "INS",
17
+ code: "CODE",
18
+ injection: "TEXT",
19
+ };
20
+
21
+ // A placeholder must stand in for a VALUE, never a sentence. Model spans can
22
+ // be broad (a whole clause tagged financial); those are left for the caller
23
+ // to mine for identifiers (see background.js) rather than replaced wholesale.
24
+ export const MAX_PLACEHOLDER_CHARS = 48;
25
+ export const MAX_PLACEHOLDER_WORDS = 6;
26
+ export function isValueLike(text) {
27
+ const t = String(text || "").trim();
28
+ if (!t) return false;
29
+ if (t.length > MAX_PLACEHOLDER_CHARS) return false;
30
+ if (t.split(/\s+/).length > MAX_PLACEHOLDER_WORDS) return false;
31
+ return true;
32
+ }
33
+
34
+ export function placeholderFor(category, n) {
35
+ const key = String(category || "").toLowerCase();
36
+ return `[${PLACEHOLDER_PREFIX[key] || key.toUpperCase() || "DATA"}-${n}]`;
37
+ }
38
+
39
+ /**
40
+ * Assign a `replacement` to every span with text. `state` = {map, counters}
41
+ * where map: Record<text, token> and counters: Record<category, n>. Mutates
42
+ * and returns state so a caller can persist it per tab.
43
+ */
44
+ export function assignPlaceholders(spans, state) {
45
+ const st = state && typeof state === "object" ? state : {};
46
+ st.map = st.map && typeof st.map === "object" ? st.map : {};
47
+ st.counters = st.counters && typeof st.counters === "object" ? st.counters : {};
48
+ const out = [];
49
+ for (const s of spans || []) {
50
+ const text = s && typeof s.text === "string" ? s.text : "";
51
+ if (!text.trim() || s.kind === "intent" || !isValueLike(text)) {
52
+ out.push(s);
53
+ continue;
54
+ }
55
+ let token = st.map[text];
56
+ if (!token) {
57
+ const cat = String(s.category || "data").toLowerCase();
58
+ st.counters[cat] = (st.counters[cat] || 0) + 1;
59
+ token = placeholderFor(cat, st.counters[cat]);
60
+ st.map[text] = token;
61
+ }
62
+ out.push({ ...s, replacement: token });
63
+ }
64
+ return { spans: out, state: st };
65
+ }
66
+
67
+ /** Replace every mapped value in `text` with its token (longest values first). */
68
+ export function pseudonymizeText(text, state) {
69
+ let out = String(text ?? "");
70
+ const map = (state && state.map) || {};
71
+ const values = Object.keys(map).sort((a, b) => b.length - a.length);
72
+ for (const v of values) if (v && out.includes(v)) out = out.split(v).join(map[v]);
73
+ return out;
74
+ }
75
+
76
+ /** Inverse: put the original values back (e.g. into a model's reply). */
77
+ export function rehydrateText(text, state) {
78
+ let out = String(text ?? "");
79
+ const map = (state && state.map) || {};
80
+ for (const [v, token] of Object.entries(map)) if (token && out.includes(token)) out = out.split(token).join(v);
81
+ return out;
82
+ }
83
+
84
+ /** Bound the per-tab map so a long session can't grow it without limit. */
85
+ export function trimState(state, max = 500) {
86
+ if (!state || !state.map) return state;
87
+ const entries = Object.entries(state.map);
88
+ if (entries.length <= max) return state;
89
+ state.map = Object.fromEntries(entries.slice(entries.length - max));
90
+ return state;
91
+ }