@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,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
|
+
}
|