@chatpanel/pii 0.2.9 → 0.2.11

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/index.js CHANGED
@@ -9,9 +9,11 @@
9
9
  // 'chatpanel-pii/pii-detect.js' local NER / LLM entity detection
10
10
  // 'chatpanel-pii/pipeline.js' pure turn orchestration + tier/scope gating
11
11
  // 'chatpanel-pii/tool-rank.js' deterministic tool narrowing (auto mode)
12
+ // 'chatpanel-pii/sanitize.js' Unicode de-steganography (strip invisible/format chars)
12
13
 
13
14
  export * from './pii-redact.js';
14
15
  export * from './pii-detect.js';
15
16
  export * from './pipeline.js';
16
17
  export * from './tool-rank.js';
17
18
  export * from './tool-harness.js';
19
+ export * from './sanitize.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/pii",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "The canonical ChatPanel privacy engine — reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -10,7 +10,8 @@
10
10
  "./pii-detect.js": "./pii-detect.js",
11
11
  "./pipeline.js": "./pipeline.js",
12
12
  "./tool-rank.js": "./tool-rank.js",
13
- "./tool-harness.js": "./tool-harness.js"
13
+ "./tool-harness.js": "./tool-harness.js",
14
+ "./sanitize.js": "./sanitize.js"
14
15
  },
15
16
  "files": [
16
17
  "index.js",
@@ -19,6 +20,7 @@
19
20
  "pipeline.js",
20
21
  "tool-rank.js",
21
22
  "tool-harness.js",
23
+ "sanitize.js",
22
24
  "LICENSE",
23
25
  "README.md"
24
26
  ],
package/pii-redact.js CHANGED
@@ -72,6 +72,42 @@ function escapeRegex(s) {
72
72
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
73
  }
74
74
 
75
+ // Apply many find/replace rules in a SINGLE left-to-right pass over the source.
76
+ // Each rule is { re: <global RegExp>, repl: (match) => string }. Unlike running
77
+ // rule[0].replace then rule[1].replace then …, text emitted by one rule is NEVER
78
+ // re-scanned by a later rule — so substitutions can't cascade (e.g. a pseudonym
79
+ // that happens to equal another entry's input). On a tie at the same position the
80
+ // earlier rule wins (rules carry priority by their order in the array).
81
+ function applyRulesOnce(text, rules) {
82
+ if (!rules || rules.length === 0) return text;
83
+ let out = '';
84
+ let pos = 0;
85
+ const n = text.length;
86
+ while (pos <= n) {
87
+ let best = null;
88
+ let bestRule = null;
89
+ for (const rule of rules) {
90
+ rule.re.lastIndex = pos;
91
+ const m = rule.re.exec(text);
92
+ if (m && (best === null || m.index < best.index)) {
93
+ best = m;
94
+ bestRule = rule;
95
+ if (m.index === pos) break; // nothing can start earlier than the cursor
96
+ }
97
+ }
98
+ if (!best) { out += text.slice(pos); break; }
99
+ out += text.slice(pos, best.index);
100
+ if (best[0].length === 0) { // pathological empty match — emit a char, advance
101
+ out += text[best.index] ?? '';
102
+ pos = best.index + 1;
103
+ } else {
104
+ out += bestRule.repl(best);
105
+ pos = best.index + best[0].length;
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
75
111
  function luhnValid(digits) {
76
112
  let sum = 0;
77
113
  let alt = false;
@@ -135,25 +171,33 @@ export function redactText(text, vault, {
135
171
  // An entry with `alias` PSEUDONYMIZES: permanent substitution (the model and
136
172
  // the user's transcript both see the alias, never reversed). Otherwise it
137
173
  // REDACTS to a reversible [[TYPE_n]] placeholder restored in the user's view.
174
+ // All entries are applied in ONE pass (applyRulesOnce): an alias produced by
175
+ // one entry must not be re-matched by a later entry, or substitutions cascade
176
+ // (e.g. value 'Arnav'→alias 'John' then 'John' caught by a later 'John' rule).
177
+ const dictRules = [];
138
178
  for (const d of dictionary || []) {
139
179
  if (!d) continue;
180
+ let re;
140
181
  try {
141
- const re = d.pattern
182
+ re = d.pattern
142
183
  ? new RegExp(d.pattern, d.flags && /g/.test(d.flags) ? d.flags : `${d.flags || ''}g`)
143
184
  : (d.value ? new RegExp(`(?<![\\w])${escapeRegex(d.value)}(?![\\w])`, 'gi') : null);
144
- if (!re) continue;
145
- if (d.alias != null && d.alias !== '') {
146
- out = out.replace(re, () => d.alias); // pseudonymize: model + reply see the alias…
147
- // …but record alias→original so LOCAL tool args (history/meeting search) map
148
- // back to the real value. Local lookups must hit real data; only the model is blinded.
149
- if (d.value) v.aliases.set(d.alias, d.value);
150
- } else {
151
- out = out.replace(re, (m) => tokenFor(v, d.type || (d.pattern ? 'PII' : 'TERM'), d.pattern ? m : d.value));
152
- }
153
185
  } catch {
154
- /* a bad user regex must never break redaction */
186
+ re = null; // a bad user regex must never break redaction
187
+ }
188
+ if (!re) continue;
189
+ if (d.alias != null && d.alias !== '') {
190
+ // pseudonymize: model + reply see the alias…
191
+ // …but record alias→original so LOCAL tool args (history/meeting search) map
192
+ // back to the real value. Local lookups must hit real data; only the model is blinded.
193
+ if (d.value) v.aliases.set(d.alias, d.value);
194
+ dictRules.push({ re, repl: () => d.alias });
195
+ } else {
196
+ const type = d.type || (d.pattern ? 'PII' : 'TERM');
197
+ dictRules.push({ re, repl: (m) => tokenFor(v, type, d.pattern ? m[0] : d.value) });
155
198
  }
156
199
  }
200
+ out = applyRulesOnce(out, dictRules);
157
201
 
158
202
  // 2) Known entities (full tier) — longest value first so "Alex Rivera" wins
159
203
  // before a bare "Alex". Restores to the canonical entity value.
@@ -188,9 +232,15 @@ export function restoreText(text, vault) {
188
232
  export function restoreWithAliases(text, vault) {
189
233
  let out = restoreText(text, vault);
190
234
  if (vault?.aliases?.size) {
191
- for (const [alias, real] of vault.aliases) {
192
- if (!alias) continue;
193
- out = out.replace(new RegExp(`(?<![\\w])${escapeRegex(alias)}(?![\\w])`, 'g'), () => real);
235
+ // ONE pass over every alias at once. Looping `replace` per alias re-scans the
236
+ // output and cascades when one alias's real value equals another alias (e.g.
237
+ // 'Twinkle'→'John' then 'John'→'Arnav'): the model's "Twinkle" would walk the
238
+ // chain to "Arnav". A single alternation replaces each span exactly once.
239
+ // Longest alias first so a multi-word pseudonym wins over its prefix.
240
+ const aliases = [...vault.aliases.keys()].filter(Boolean).sort((a, b) => b.length - a.length);
241
+ if (aliases.length) {
242
+ const re = new RegExp(`(?<![\\w])(?:${aliases.map(escapeRegex).join('|')})(?![\\w])`, 'g');
243
+ out = out.replace(re, (m) => (vault.aliases.has(m) ? vault.aliases.get(m) : m));
194
244
  }
195
245
  }
196
246
  return out;
package/sanitize.js ADDED
@@ -0,0 +1,133 @@
1
+ // Unicode de-steganography for text that flows through the privacy boundary.
2
+ //
3
+ // Invisible and format-control characters are a single vector with three abuses,
4
+ // all relevant to a redaction product:
5
+ //
6
+ // 1. Redaction bypass - splitting a value with zero-width chars (j<ZWSP>o<ZWSP>hn@x.com)
7
+ // hides it from the regex/NER detector, then the model reassembles the real
8
+ // value. The deterministic engine works on text, so the smuggled PII leaks.
9
+ // 2. Hidden prompt injection - Unicode Tag characters (U+E0000-E007F) render as
10
+ // nothing but encode a full ASCII instruction the model reads ("ASCII smuggling").
11
+ // 3. Fingerprinting / watermarking - steganographic markers injected into a prompt
12
+ // (e.g. a client classifying a custom gateway and encoding a bit into invisible
13
+ // punctuation). A privacy proxy should scrub these - and never emit its own.
14
+ //
15
+ // We strip the channels that have no legitimate place in plain prompt text, while
16
+ // PRESERVING the few legitimate uses (emoji ZWJ/variation sequences, normal accents).
17
+ //
18
+ // The patterns are BUILT FROM NUMERIC CODE POINTS below - there are deliberately no
19
+ // literal invisible characters anywhere in this source (auditable, and fitting for a
20
+ // de-steg module). Pure + dependency-free ESM. Call it BEFORE detection so obfuscated
21
+ // PII becomes matchable, and on model output before restoration so a token can't be
22
+ // split/spoofed with invisibles.
23
+
24
+ // Code-point ranges (inclusive) per category, by their abuse.
25
+ const RANGES = {
26
+ // Unicode Tag block - the ASCII-smuggling channel.
27
+ tags: [[0xE0000, 0xE007F]],
28
+ // Bidi controls - reorder/override visible text to hide reversed instructions.
29
+ bidi: [[0x061C, 0x061C], [0x200E, 0x200F], [0x202A, 0x202E], [0x2066, 0x2069]],
30
+ // Zero-width & assorted invisible format chars: soft hyphen, Hangul/Mongolian
31
+ // fillers, ZWSP, word/invisible joiners, deprecated format controls, BOM/ZWNBSP,
32
+ // interlinear annotation, object replacement.
33
+ zeroWidth: [
34
+ [0x00AD, 0x00AD], [0x115F, 0x1160], [0x180E, 0x180E], [0x200B, 0x200B],
35
+ [0x2060, 0x2064], [0x206A, 0x206F], [0x3164, 0x3164], [0xFEFF, 0xFEFF],
36
+ [0xFFA0, 0xFFA0], [0xFFF9, 0xFFFB], [0xFFFC, 0xFFFC],
37
+ ],
38
+ // Supplementary variation selectors - the byte-smuggling range. Never legit in text.
39
+ supVS: [[0xE0100, 0xE01EF]],
40
+ // Line/paragraph separators - converted to '\n' (kill parser tricks, keep the break).
41
+ lineSep: [[0x2028, 0x2029]],
42
+ // ZWJ/ZWNJ + BMP variation selectors - legit ONLY next to an emoji base, so these
43
+ // are stripped contextually (see ANOMALOUS_JOIN_VS), not unconditionally.
44
+ joinVS: [[0x200C, 0x200D], [0xFE00, 0xFE0F]],
45
+ };
46
+
47
+ const u = (cp) => `\\u{${cp.toString(16).toUpperCase()}}`;
48
+ const cls = (ranges) => ranges.map(([a, b]) => (a === b ? u(a) : `${u(a)}-${u(b)}`)).join('');
49
+
50
+ const TAGS = new RegExp(`[${cls(RANGES.tags)}]`, 'gu');
51
+ const BIDI = new RegExp(`[${cls(RANGES.bidi)}]`, 'gu');
52
+ const ZERO_WIDTH = new RegExp(`[${cls(RANGES.zeroWidth)}]`, 'gu');
53
+ const SUP_VS = new RegExp(`[${cls(RANGES.supVS)}]`, 'gu');
54
+ const LINE_SEP = new RegExp(`[${cls(RANGES.lineSep)}]`, 'gu');
55
+ // Strip ZWJ/ZWNJ/VS only when NOT preceded by an emoji base (so emoji sequences and
56
+ // regional-indicator flags survive); supplementary VS are always stripped.
57
+ const ANOMALOUS_JOIN_VS = new RegExp(
58
+ `(?<![\\p{Extended_Pictographic}${u(0x1F1E6)}-${u(0x1F1FF)}])[${cls(RANGES.joinVS)}]|[${cls(RANGES.supVS)}]`,
59
+ 'gu',
60
+ );
61
+ // Runs of combining marks (Zalgo / bit-stuffing). A real stacked diacritic is 1-3
62
+ // marks; anything past the cap is signalling, not language.
63
+ const COMBINING_RUN = /\p{M}+/gu;
64
+
65
+ // Cheap boolean for hot paths / UI ("does this contain anything hidden?"). Excludes
66
+ // the context-dependent joinVS so legitimate emoji aren't flagged - sanitizeUnicode()
67
+ // stays the source of truth for those.
68
+ const ANY_HIDDEN = new RegExp(
69
+ `[${cls(RANGES.bidi)}${cls(RANGES.zeroWidth)}]|[${cls(RANGES.tags)}]|[${cls(RANGES.supVS)}]`,
70
+ 'u',
71
+ );
72
+
73
+ export function hasHiddenChars(text) {
74
+ return typeof text === 'string' && ANY_HIDDEN.test(text);
75
+ }
76
+
77
+ // sanitizeUnicode(text, opts) -> { clean, removed, findings }
78
+ // clean - text with the smuggling channels stripped/normalized
79
+ // removed - total count of stripped/collapsed characters (0 = nothing hidden)
80
+ // findings - per-category counts (only non-zero keys), for transparent reporting
81
+ //
82
+ // opts.normalize: 'NFC' (default, appearance-preserving) | 'NFKC' (also folds
83
+ // fullwidth/homoglyph compatibility forms - stronger for detection, but rewrites
84
+ // some visible glyphs) | 'none'.
85
+ // opts.collapseCombiningOver: max combining marks kept per run (default 4).
86
+ export function sanitizeUnicode(text, { normalize = 'NFC', collapseCombiningOver = 4 } = {}) {
87
+ if (typeof text !== 'string' || text === '') return { clean: text ?? '', removed: 0, findings: {} };
88
+ let s = text;
89
+ const findings = {};
90
+
91
+ // Strip one category, counting by code point (spread iterates code points, so a
92
+ // supplementary char like a Tag counts as 1, not 2 UTF-16 units).
93
+ const strip = (re, key) => {
94
+ let n = 0;
95
+ s = s.replace(re, (m) => { n += [...m].length; return ''; });
96
+ if (n) findings[key] = n;
97
+ };
98
+
99
+ let lineSep = 0;
100
+ s = s.replace(LINE_SEP, () => { lineSep++; return '\n'; });
101
+ if (lineSep) findings.lineSep = lineSep;
102
+
103
+ strip(TAGS, 'tags');
104
+ strip(BIDI, 'bidi');
105
+ strip(ZERO_WIDTH, 'zeroWidth');
106
+ strip(ANOMALOUS_JOIN_VS, 'joinersVS');
107
+
108
+ // Compose canonically so split/decomposed forms can't dodge the detector.
109
+ if (normalize && normalize !== 'none') {
110
+ try { s = s.normalize(normalize); } catch { /* invalid form name -> skip */ }
111
+ }
112
+
113
+ let combining = 0;
114
+ s = s.replace(COMBINING_RUN, (run) => {
115
+ const marks = [...run];
116
+ if (marks.length <= collapseCombiningOver) return run;
117
+ combining += marks.length - collapseCombiningOver;
118
+ return marks.slice(0, collapseCombiningOver).join('');
119
+ });
120
+ if (combining) findings.combining = combining;
121
+
122
+ const removed = (findings.tags || 0) + (findings.bidi || 0) + (findings.zeroWidth || 0)
123
+ + (findings.joinersVS || 0) + (findings.combining || 0);
124
+ return { clean: s, removed, findings };
125
+ }
126
+
127
+ // Convenience for the common "just give me clean text" caller.
128
+ export function stripHidden(text, opts) {
129
+ return sanitizeUnicode(text, opts).clean;
130
+ }
131
+
132
+ // Exposed for tests / external auditing.
133
+ export const SANITIZE_RANGES = RANGES;