@privacyscrubber/sdk 2.4.8 → 2.4.9

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 CHANGED
@@ -207,7 +207,13 @@ const { scrubbedText, tokenMap } = sanitize(rawCrmPayload, {
207
207
  ],
208
208
 
209
209
  // Exclude specific domain terms or false-positive nouns (case-insensitive; also prevents multi-word NAME matches containing these terms):
210
- ignoreList: ['Materialmangel', 'Störgrund', 'Prüfstand', 'Linie 3']
210
+ ignoreList: ['Materialmangel', 'Störgrund', 'Prüfstand', 'Linie 3'],
211
+
212
+ // Optional: Entity-type allowlist filter (e.g., ['NAME'] or ['PERSON'] to mask ONLY person names while keeping batch numbers, order IDs, and phone/date fields readable):
213
+ // entities: ['NAME'],
214
+
215
+ // Optional: Enable higher-recall person-name detection in free text:
216
+ // highRecallNames: true
211
217
  });
212
218
  ```
213
219
 
package/cli.js CHANGED
@@ -32,7 +32,7 @@ for (const a of rawArgs) {
32
32
  }
33
33
  }
34
34
 
35
- let sdkVersion = '2.4.8';
35
+ let sdkVersion = '2.4.9';
36
36
  try {
37
37
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
38
38
  sdkVersion = pkg.version || sdkVersion;
@@ -59,7 +59,7 @@ if FASTAPI_AVAILABLE:
59
59
  app = FastAPI(
60
60
  title="PrivacyScrubber ZTDS Sidecar Proxy",
61
61
  description="Zero-Trust Data Sanitization & In-Memory PII Shield for LLM Pipelines",
62
- version="2.4.8"
62
+ version="2.4.9"
63
63
  )
64
64
 
65
65
  @app.on_event("startup")
@@ -24,7 +24,7 @@ from privacyscrubber.exceptions import (
24
24
  PrivacyScrubberLicenseError,
25
25
  )
26
26
 
27
- __version__ = "2.4.8"
27
+ __version__ = "2.4.9"
28
28
  __author__ = "Ilya Sibiryakov (BrandMeWeb)"
29
29
  __license__ = "Proprietary"
30
30
 
@@ -115,7 +115,7 @@ rl.on('line', (line) => {
115
115
  sendResponse(id, {
116
116
  pong: true,
117
117
  engine: 'PrivacyScrubber ZTDS',
118
- version: sdk.SDK_VERSION || '2.4.8',
118
+ version: sdk.SDK_VERSION || '2.4.9',
119
119
  uptime: process.uptime()
120
120
  });
121
121
  break;
@@ -50,7 +50,7 @@ def main() -> None:
50
50
  args = parser.parse_args()
51
51
 
52
52
  if args.version:
53
- print("PrivacyScrubber ZTDS Python SDK v2.4.8")
53
+ print("PrivacyScrubber ZTDS Python SDK v2.4.9")
54
54
  sys.exit(0)
55
55
 
56
56
  if not args.command:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "privacyscrubber"
7
- version = "2.4.8"
7
+ version = "2.4.9"
8
8
  description = "Zero-Trust Data Sanitization & In-Memory PII Shield for Python AI Pipelines (ZTDS RFC v1.0)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -13,7 +13,7 @@ with open(os.path.join(here, "README.md"), encoding="utf-8") as f:
13
13
 
14
14
  setup(
15
15
  name="privacyscrubber",
16
- version="2.4.8",
16
+ version="2.4.9",
17
17
  description="Zero-Trust Data Sanitization & In-Memory PII Shield for Python AI Pipelines (ZTDS RFC v1.0)",
18
18
  long_description=long_description,
19
19
  long_description_content_type="text/markdown",
package/index.cjs CHANGED
@@ -39,7 +39,7 @@ const SDK_VERSION = (() => {
39
39
  return pkgJson.version;
40
40
  }
41
41
  } catch (_) {}
42
- return '2.4.8';
42
+ return '2.4.9';
43
43
  })();
44
44
 
45
45
  /**
@@ -142,8 +142,9 @@ function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
142
142
  if (!t) continue;
143
143
  const tokenStr = typeof t === 'string' ? t : ((t && (t.token || t.mask)) ? String(t.token || t.mask) : '');
144
144
  if (!tokenStr) continue;
145
- const match = tokenStr.match(/\[([A-Z0-9_]+)_\d+\]/);
146
- const baseType = match ? match[1] : (tokenStr.replace(/\[|\]/g, '').replace(/_[0-9]+$/, '') || 'CUSTOM');
145
+ const match = tokenStr.match(/\[([A-Za-z0-9_]+)_\d+\]/);
146
+ const rawBase = match ? match[1] : tokenStr.replace(/\[|\]|<|>|\{|\}|__/g, '').replace(/_[0-9]+$/, '');
147
+ const baseType = (rawBase.replace(/[^A-Za-z0-9_]/g, '') || 'CUSTOM').toUpperCase();
147
148
  entities[baseType] = (entities[baseType] || 0) + 1;
148
149
  totalCount++;
149
150
  }
@@ -153,7 +154,7 @@ function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
153
154
  let riskLevel = 'LOW EXPOSURE';
154
155
 
155
156
  if (totalCount > 0) {
156
- const hasHighRisk = types.some(t => ['ID', 'SSN', 'CREDIT_CARD', 'PASSPORT', 'BANK', 'API_KEY', 'SECRET', 'PASSWORD', 'MRN', 'KEY', 'AWS_KEY', 'STRIPE_KEY', 'JWT_TOKEN', 'API_TOKEN'].includes(t));
157
+ const hasHighRisk = types.some(t => ['ID', 'SSN', 'CREDIT_CARD', 'PASSPORT', 'BANK', 'API_KEY', 'SECRET', 'PASSWORD', 'MRN', 'KEY', 'PHI', 'PRIVILEGE', 'AWS_KEY', 'STRIPE_KEY', 'JWT_TOKEN', 'API_TOKEN'].includes(t));
157
158
  if (hasHighRisk || types.length >= 3 || totalCount >= 10) {
158
159
  riskLevel = 'CRITICAL (HIGH EXPOSURE)';
159
160
  } else if (types.length >= 2 || totalCount >= 3) {
@@ -178,10 +179,10 @@ function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
178
179
  frameworksSet.add('PCI DSS v4.0');
179
180
  frameworksSet.add('IRC § 7216 & GLBA');
180
181
  }
181
- if (types.some(t => ['MRN', 'HEALTH', 'MEDICAL', 'PATIENT', 'DIAGNOSIS', 'NPI', 'DEA'].includes(t))) {
182
+ if (types.some(t => ['PHI', 'MRN', 'HEALTH', 'MEDICAL', 'PATIENT', 'DIAGNOSIS', 'NPI', 'DEA'].includes(t))) {
182
183
  frameworksSet.add('HIPAA §164.514');
183
184
  }
184
- if (types.some(t => ['CASE_NUMBER', 'DOCKET', 'PRIVILEGED', 'LEGAL', 'ATTORNEY', 'COURT', 'CONTRACT', 'NDA'].includes(t))) {
185
+ if (types.some(t => ['PRIVILEGE', 'CASE_NUMBER', 'DOCKET', 'PRIVILEGED', 'LEGAL', 'ATTORNEY', 'COURT', 'CONTRACT', 'NDA'].includes(t))) {
185
186
  frameworksSet.add('FRE 502 (Privilege)');
186
187
  }
187
188
  if (types.some(t => ['API_KEY', 'SECRET', 'PASSWORD', 'TOKEN', 'KEY', 'AWS_KEY', 'JWT_TOKEN', 'API_TOKEN', 'CREDENTIAL'].includes(t))) {
@@ -191,6 +192,26 @@ function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
191
192
  frameworksSet.add('EU AI Act (Art. 50)');
192
193
  }
193
194
 
195
+ const frameworksList = Array.from(frameworksSet);
196
+ const getFrameworkSlug = (name) => {
197
+ const lower = name.toLowerCase();
198
+ if (lower.includes('gdpr')) return 'gdpr';
199
+ if (lower.includes('ccpa')) return 'ccpa';
200
+ if (lower.includes('soc')) return 'soc2';
201
+ if (lower.includes('iso')) return 'iso27001';
202
+ if (lower.includes('pci')) return 'pci';
203
+ if (lower.includes('hipaa')) return 'hipaa';
204
+ if (lower.includes('nist')) return 'nist';
205
+ if (lower.includes('irc') || lower.includes('7216')) return 'irc7216';
206
+ if (lower.includes('fre') || lower.includes('502')) return 'fre502';
207
+ if (lower.includes('ai act')) return 'euaiact';
208
+ return lower.replace(/[^a-z0-9]/g, '');
209
+ };
210
+ const triggeredFrameworks = frameworksList.map(name => ({
211
+ name,
212
+ id: getFrameworkSlug(name)
213
+ }));
214
+
194
215
  const ipHoldings = {
195
216
  standard: 'IETF draft-sibiryakov-ztds-protocol-01',
196
217
  standardUrl: 'https://datatracker.ietf.org/doc/draft-sibiryakov-ztds-protocol/',
@@ -262,10 +283,12 @@ function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
262
283
 
263
284
  return {
264
285
  totalCount,
286
+ totalDetected: totalCount,
265
287
  entities,
266
288
  types,
267
289
  riskLevel,
268
- frameworksList: Array.from(frameworksSet),
290
+ frameworksList,
291
+ triggeredFrameworks,
269
292
  auditScope,
270
293
  statutoryCitations,
271
294
  ipHoldings,
@@ -515,7 +538,7 @@ We request approval to acquire a **Developer SDK License** (\$299/month flat or
515
538
  */
516
539
  const SDK_VERTICAL_DETECTORS = [
517
540
  { profile: 'medical', label: 'Medical PHI (HIPAA)', regex: /\b(?:Patient\s*(?:Name|ID|DOB|Record)|MRN\b|Medical\s+Record|Attending\s+Physician|Chief\s+Complaint|Diagnosis\s*:|ICD-10|HIPAA|Clinical\s+Intake|Discharge\s+Summary|MedPod|NPI\s*(?:#|No|:)?\s*\d{10}|Medicare\s*(?:ID|Beneficiary|Claim)|Medicaid\s*ID|Prescription|Rx\s*#|Blood\s+Type|Vital\s+Signs|Radiology|Lab\s+Results|EHR\b|EMR\b)\b/gi },
518
- { profile: 'accounting', label: 'Tax & Accounting', regex: /\b(?:Form\s*1040|Form\s*W-2|Form\s*1099|Adjusted\s+Gross\s+Income|Employer\s+Identification\s+Number|Schedule\s+C\b|Steuer-ID|USt-IdNr|Tax\s+Return|General\s+Ledger|Withholding\s+Allowance|Total\s+Tax\s+Liability)\b/gi },
541
+ { profile: 'accounting', label: 'Tax & Accounting', regex: /\b(?:Form\s*1040|Form\s*W-2|Form\s*1099|Adjusted\s+Gross\s+Income|Assessable\s+Income|Employer\s+Identification\s+Number|Schedule\s+C\b|Steuer-ID|USt-IdNr|Tax\s+Return|General\s+Ledger|Withholding\s+Allowance|Total\s+Tax\s+Liability|ABN\b|TFN\b|Tax\s+File\s+Number|Australian\s+Business\s+Number|Business\s+Activity\s+Statement|PAYG\s+(?:Withholding|Instalment)|Franking\s+Credits?)\b/gi },
519
542
  { profile: 'wealthmgmt', label: 'Wealth Management', regex: /\b(?:Revocable\s+Living\s+Trust|Irrevocable\s+(?:Generation-Skipping\s+)?Trust|Grantor\s*:|Settlor\s*:|Trustee\s*:|Beneficiary\s+Designation|Roth\s+IRA|Asset\s+Allocation|FBO\b|CUSIP\s*:|Grantor\s+Retained\s+Annuity\s+Trust|AUM\s*:)\b/gi },
520
543
  { profile: 'legal', label: 'Legal & Contracts', regex: /\b(?:Non-Disclosure\s+Agreement|Attorney-Client\s+Privilege|Docket\s+No|Hereinafter\s+referred|Indemnification\s+Clause|Governing\s+Law\s+and\s+Jurisdiction|Plaintiff\b|Defendant\b|Subpoena\b|Deposition\b|State\s+Bar\s*#)\b/gi },
521
544
  { profile: 'finance', label: 'Finance & Banking', regex: /\b(?:Wire\s+Transfer\s+Authorization|SWIFT\s*\/?\s*BIC\s*:|IBAN\s*:\s*[A-Z]{2}\d{2}|ABA\s+Routing|ACH\s+Transfer|PCI-DSS|Primary\s+Account\s+Number)\b/gi },
@@ -616,7 +639,9 @@ function sanitize(text, options = {}) {
616
639
  throwOnLimit = true,
617
640
  onLimit = 'throw',
618
641
  detectSecrets = false,
619
- ignoreList = null
642
+ ignoreList = null,
643
+ entities = options.entityTypes || options.enabledEntities || null,
644
+ highRecallNames = Boolean(options.highRecallNames || options.nameRecall === 'high')
620
645
  } = options;
621
646
 
622
647
  const isAutoProfile = Boolean(
@@ -732,8 +757,23 @@ function sanitize(text, options = {}) {
732
757
  }
733
758
  }
734
759
 
760
+ let normalizedEntities = null;
761
+ if (entities) {
762
+ const rawArr = entities instanceof Set ? Array.from(entities) : (Array.isArray(entities) ? entities : [entities]);
763
+ normalizedEntities = rawArr.filter(Boolean).map(e => {
764
+ const u = String(e).trim().toUpperCase();
765
+ if (u === 'PERSON' || u === 'PERSONS' || u === 'PEOPLE' || u === 'NAMES') return 'NAME';
766
+ if (u === 'EMAILS') return 'EMAIL';
767
+ if (u === 'PHONES' || u === 'TELEPHONE') return 'PHONE';
768
+ if (u === 'IDS' || u === 'IDENTIFIER' || u === 'IDENTIFIERS') return 'ID';
769
+ if (u === 'ADDRESSES' || u === 'LOCATION' || u === 'LOCATIONS') return 'ADDRESS';
770
+ return u;
771
+ });
772
+ if (normalizedEntities.length === 0) normalizedEntities = null;
773
+ }
774
+
735
775
  const activeRules = [...effectiveCustomRules];
736
- if (effectiveProfile.toLowerCase() === 'dev' || detectSecrets === true) {
776
+ if ((effectiveProfile.toLowerCase() === 'dev' || detectSecrets === true) && (!normalizedEntities || normalizedEntities.includes('SECRET') || normalizedEntities.includes('CUSTOM'))) {
737
777
  DEVOPS_SECRETS_DETECTOR.forEach(rule => {
738
778
  activeRules.push({
739
779
  name: rule.name,
@@ -757,7 +797,9 @@ function sanitize(text, options = {}) {
757
797
  effectiveProfile,
758
798
  existingSessionMap,
759
799
  isPro,
760
- effectiveIgnoreList
800
+ effectiveIgnoreList,
801
+ normalizedEntities,
802
+ Boolean(highRecallNames)
761
803
  );
762
804
 
763
805
  const endTime = (typeof performance !== 'undefined' && typeof performance.now === 'function')
@@ -1085,6 +1127,8 @@ class PrivacyScrubberEngine {
1085
1127
  this.generateReceipt = config.generateReceipt !== false;
1086
1128
  this.sessionMap = { ...(config.initialSessionMap || {}) };
1087
1129
  this.ignoreList = new Set(config.ignoreList || []);
1130
+ this.entities = config.entities || config.entityTypes || config.enabledEntities || null;
1131
+ this.highRecallNames = Boolean(config.highRecallNames || config.nameRecall === 'high');
1088
1132
  this.maxSessionTokens = config.maxSessionTokens || 10000;
1089
1133
 
1090
1134
  this.license = validateLicense(this.licenseKey);
@@ -1126,7 +1170,9 @@ class PrivacyScrubberEngine {
1126
1170
  generateReceipt: options.generateReceipt !== undefined ? options.generateReceipt : this.generateReceipt,
1127
1171
  throwOnLimit: options.throwOnLimit !== undefined ? options.throwOnLimit : true,
1128
1172
  detectSecrets: options.detectSecrets !== undefined ? options.detectSecrets : false,
1129
- ignoreList: mergedIgnoreList.size > 0 ? mergedIgnoreList : null
1173
+ ignoreList: mergedIgnoreList.size > 0 ? mergedIgnoreList : null,
1174
+ entities: options.entities || options.entityTypes || options.enabledEntities || this.entities,
1175
+ highRecallNames: options.highRecallNames !== undefined ? Boolean(options.highRecallNames) : (options.nameRecall === 'high' ? true : this.highRecallNames)
1130
1176
  };
1131
1177
 
1132
1178
  const result = sanitize(text, mergedOptions);
@@ -2666,13 +2712,13 @@ function buildStreamReplacer(tokenMap = {}, options = {}) {
2666
2712
 
2667
2713
  for (const k of sortedKeys) {
2668
2714
  const val = tokenMap[k];
2669
- const inner = k.replace(/^\[|<|\{\{?|__|\]|>|\}\}?|__/g, '');
2715
+ const inner = k.replace(/^(?:\[|<|\{\{?|__|【|[|〔|「|『)|(?:\]|>|\}\}?|__|】|]|〕|」|』)$/g, '');
2670
2716
  const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
2671
2717
 
2672
2718
  if (match) {
2673
2719
  const label = escapeRegex(match[1]).replace(/[_-]/g, '\\\\?[_-]');
2674
2720
  const idx = match[2];
2675
- const patternStr = '(?:(?:\\[|\\\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?lbrack;|【|[|〔|「|『)\\s*[*`~]*' + label + '[-_\\\\\\s]*0*' + idx + '[*`~]*[.,;:!?]?\\s*(?:\\]|\\\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?rbrack;|】|]|〕|」|』)|<\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*>|\\{\\{?\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*\\}\\}?|__\\s*' + label + '[-_\\\\\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)';
2721
+ const patternStr = '(?:(?:\\[|\\\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?(?:lbrack|lsqb);|【|[|〔|「|『)\\s*[*`~]*' + label + '[-_\\\\\\s]*0*' + idx + '[*`~]*[.,;:!?]?\\s*(?:\\]|\\\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?(?:rbrack|rsqb);|】|]|〕|」|』)|<\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*>|\\{\\{?\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*\\}\\}?|__\\s*' + label + '[-_\\\\\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)';
2676
2722
  patterns.push(patternStr);
2677
2723
  const normKey = (match[1] + '_' + parseInt(idx, 10)).toUpperCase();
2678
2724
  lookup.set(normKey, val);
@@ -2702,8 +2748,8 @@ function buildStreamReplacer(tokenMap = {}, options = {}) {
2702
2748
  restored = lookup.get(coreMatch.toUpperCase());
2703
2749
  } else {
2704
2750
  const inner = coreMatch
2705
- .replace(/^(?:\[|\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?lbrack;|【|[|〔|「|『|<|\{\{?|__)|(?:\]|\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?rbrack;|】|]|〕|」|』|>|\}\}?|__)$/gi, '')
2706
- .replace(/^[*`~\s]+|[.,;:!?*`~\s]+$/g, '')
2751
+ .replace(/^(?:\[|\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?(?:lbrack|lsqb);|【|[|〔|「|『|<|\{\{?|__)|(?:\]|\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?(?:rbrack|rsqb);|】|]|〕|」|』|>|\}\}?|__)$/gi, '')
2752
+ .replace(/^[*`_~\s]+|[.,;:!?*`_~\s]+$/g, '')
2707
2753
  .trim();
2708
2754
  const m = inner.match(/^([A-Za-z_0-9]+?)[-_\\\s]*(\d+)$/);
2709
2755
  if (m) {
@@ -2816,18 +2862,37 @@ class StreamingDetokenizer {
2816
2862
  // Candidate unclosed token at tail of buffer (inspect tail window up to 160 chars)
2817
2863
  // Matches incomplete tokens starting with [, 【, [, <, {, __, %5B, \u005b, or HTML entity &#91; / &lbrack;
2818
2864
  const tailLen = 160;
2819
- const tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2820
- const offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2821
- const m = tail.match(/(?:(?:\[|【|[|〔|「|『|<|\{\{?|__?|%5[Bb]|\\u005[Bb]|&(?:amp;)?(?:#0*91;|#[xX]0*5[Bb];|lbrack;))[A-Za-z0-9_\-\u0410-\u044F\u0401\u0451 *`~.\\]{0,128}(?:%5?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?3?|[xX]0*5?[Dd]?)?|r(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)?|%[0-9A-Fa-f]?|\\(?:u(?:0(?:0(?:5)?)?)?)?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?1?|[xX]0*5?)?|l(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)$/);
2865
+ const tailRegex = /(?:(?:\[|【|[|〔|「|『|<|\{\{?|__|%5[Bb]|\\u005[Bb]|&(?:amp;)?(?:#0*91;|#[xX]0*5[Bb];|lbrack;))[A-Za-z0-9_\-\u0410-\u044F\u0401\u0451 *`~.\\]{0,128}(?:%5?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?3?|[xX]0*5?[Dd]?)?|r(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)?|%[0-9A-Fa-f]?|\\(?:u(?:0(?:0(?:5)?)?)?)?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?1?|[xX]0*5?)?|l(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?|_)$/;
2866
+ let tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2867
+ let offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2868
+ let m = tail.match(tailRegex);
2822
2869
 
2823
2870
  if (m) {
2824
2871
  const candidate = m[0];
2825
2872
  // Closed double-underscore (__NAME_1__) or JSON Unicode (\u005bNAME_1\u005d) tokens match character class and can be emitted immediately.
2826
- const isClosedUnderscoreOrUnicode = (candidate.length >= 4 && candidate.startsWith('__') && candidate.endsWith('__')) || /\\u005[Dd]/.test(candidate);
2827
- if (isClosedUnderscoreOrUnicode && this.replacer(candidate) !== candidate) {
2828
- const toEmit = this.pendingBuffer;
2829
- this.pendingBuffer = '';
2830
- return this.replacer(toEmit);
2873
+ const uClose = candidate.startsWith('__') ? candidate.indexOf('__', 2) : -1;
2874
+ const uniMatch = candidate.match(/\\u005[Dd]/);
2875
+ const closeLen = uClose !== -1 ? uClose + 2 : (uniMatch ? uniMatch.index + 6 : -1);
2876
+ if (closeLen !== -1) {
2877
+ const closedToken = candidate.slice(0, closeLen);
2878
+ if (this.replacer(closedToken) !== closedToken) {
2879
+ const splitAfterToken = offset + m.index + closeLen;
2880
+ const prefixEmitted = this.replacer(this.pendingBuffer.slice(0, splitAfterToken));
2881
+ this.pendingBuffer = this.pendingBuffer.slice(splitAfterToken);
2882
+ if (!this.pendingBuffer) return prefixEmitted;
2883
+ tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2884
+ offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2885
+ m = tail.match(tailRegex);
2886
+ if (!m) {
2887
+ const rest = this.replacer(this.pendingBuffer);
2888
+ this.pendingBuffer = '';
2889
+ return prefixEmitted + rest;
2890
+ }
2891
+ const sIdx = offset + m.index;
2892
+ const safe = this.pendingBuffer.slice(0, sIdx);
2893
+ this.pendingBuffer = this.pendingBuffer.slice(sIdx);
2894
+ return prefixEmitted + (safe ? this.replacer(safe) : '');
2895
+ }
2831
2896
  }
2832
2897
  const splitIdx = offset + m.index;
2833
2898
  const safeToEmit = this.pendingBuffer.slice(0, splitIdx);
package/index.d.ts CHANGED
@@ -74,6 +74,14 @@ export interface SanitizeOptions {
74
74
  returnFullSessionMap?: boolean;
75
75
  /** Set or array of strings or exact terms to preserve unmasked in cleartext. */
76
76
  ignoreList?: string[] | Set<string>;
77
+ /** Optional entity-type allowlist filter (e.g. ['NAME'] or ['PERSON'] to mask only person names while keeping batch/order numbers, dates, and IDs readable). */
78
+ entities?: string[];
79
+ /** Alias for entities. */
80
+ entityTypes?: string[];
81
+ /** Enable higher-recall person-name detection in free text (Default: false). */
82
+ highRecallNames?: boolean;
83
+ /** Alias for highRecallNames ('default' | 'high'). */
84
+ nameRecall?: 'default' | 'high';
77
85
  /** Whether to generate a CISO Markdown audit receipt (Default: true). */
78
86
  generateReceipt?: boolean;
79
87
  /** Throw PrivacyScrubberLicenseError when free tier limits are reached (Default: true). */
package/index.js CHANGED
@@ -41,7 +41,7 @@ export const SDK_VERSION = (() => {
41
41
  return pkgJson.version;
42
42
  }
43
43
  } catch (_) {}
44
- return '2.4.8';
44
+ return '2.4.9';
45
45
  })();
46
46
 
47
47
  /**
@@ -144,8 +144,9 @@ export function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
144
144
  if (!t) continue;
145
145
  const tokenStr = typeof t === 'string' ? t : ((t && (t.token || t.mask)) ? String(t.token || t.mask) : '');
146
146
  if (!tokenStr) continue;
147
- const match = tokenStr.match(/\[([A-Z0-9_]+)_\d+\]/);
148
- const baseType = match ? match[1] : (tokenStr.replace(/\[|\]/g, '').replace(/_[0-9]+$/, '') || 'CUSTOM');
147
+ const match = tokenStr.match(/\[([A-Za-z0-9_]+)_\d+\]/);
148
+ const rawBase = match ? match[1] : tokenStr.replace(/\[|\]|<|>|\{|\}|__/g, '').replace(/_[0-9]+$/, '');
149
+ const baseType = (rawBase.replace(/[^A-Za-z0-9_]/g, '') || 'CUSTOM').toUpperCase();
149
150
  entities[baseType] = (entities[baseType] || 0) + 1;
150
151
  totalCount++;
151
152
  }
@@ -155,7 +156,7 @@ export function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
155
156
  let riskLevel = 'LOW EXPOSURE';
156
157
 
157
158
  if (totalCount > 0) {
158
- const hasHighRisk = types.some(t => ['ID', 'SSN', 'CREDIT_CARD', 'PASSPORT', 'BANK', 'API_KEY', 'SECRET', 'PASSWORD', 'MRN', 'KEY', 'AWS_KEY', 'STRIPE_KEY', 'JWT_TOKEN', 'API_TOKEN'].includes(t));
159
+ const hasHighRisk = types.some(t => ['ID', 'SSN', 'CREDIT_CARD', 'PASSPORT', 'BANK', 'API_KEY', 'SECRET', 'PASSWORD', 'MRN', 'KEY', 'PHI', 'PRIVILEGE', 'AWS_KEY', 'STRIPE_KEY', 'JWT_TOKEN', 'API_TOKEN'].includes(t));
159
160
  if (hasHighRisk || types.length >= 3 || totalCount >= 10) {
160
161
  riskLevel = 'CRITICAL (HIGH EXPOSURE)';
161
162
  } else if (types.length >= 2 || totalCount >= 3) {
@@ -180,10 +181,10 @@ export function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
180
181
  frameworksSet.add('PCI DSS v4.0');
181
182
  frameworksSet.add('IRC § 7216 & GLBA');
182
183
  }
183
- if (types.some(t => ['MRN', 'HEALTH', 'MEDICAL', 'PATIENT', 'DIAGNOSIS', 'NPI', 'DEA'].includes(t))) {
184
+ if (types.some(t => ['PHI', 'MRN', 'HEALTH', 'MEDICAL', 'PATIENT', 'DIAGNOSIS', 'NPI', 'DEA'].includes(t))) {
184
185
  frameworksSet.add('HIPAA §164.514');
185
186
  }
186
- if (types.some(t => ['CASE_NUMBER', 'DOCKET', 'PRIVILEGED', 'LEGAL', 'ATTORNEY', 'COURT', 'CONTRACT', 'NDA'].includes(t))) {
187
+ if (types.some(t => ['PRIVILEGE', 'CASE_NUMBER', 'DOCKET', 'PRIVILEGED', 'LEGAL', 'ATTORNEY', 'COURT', 'CONTRACT', 'NDA'].includes(t))) {
187
188
  frameworksSet.add('FRE 502 (Privilege)');
188
189
  }
189
190
  if (types.some(t => ['API_KEY', 'SECRET', 'PASSWORD', 'TOKEN', 'KEY', 'AWS_KEY', 'JWT_TOKEN', 'API_TOKEN', 'CREDENTIAL'].includes(t))) {
@@ -193,6 +194,26 @@ export function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
193
194
  frameworksSet.add('EU AI Act (Art. 50)');
194
195
  }
195
196
 
197
+ const frameworksList = Array.from(frameworksSet);
198
+ const getFrameworkSlug = (name) => {
199
+ const lower = name.toLowerCase();
200
+ if (lower.includes('gdpr')) return 'gdpr';
201
+ if (lower.includes('ccpa')) return 'ccpa';
202
+ if (lower.includes('soc')) return 'soc2';
203
+ if (lower.includes('iso')) return 'iso27001';
204
+ if (lower.includes('pci')) return 'pci';
205
+ if (lower.includes('hipaa')) return 'hipaa';
206
+ if (lower.includes('nist')) return 'nist';
207
+ if (lower.includes('irc') || lower.includes('7216')) return 'irc7216';
208
+ if (lower.includes('fre') || lower.includes('502')) return 'fre502';
209
+ if (lower.includes('ai act')) return 'euaiact';
210
+ return lower.replace(/[^a-z0-9]/g, '');
211
+ };
212
+ const triggeredFrameworks = frameworksList.map(name => ({
213
+ name,
214
+ id: getFrameworkSlug(name)
215
+ }));
216
+
196
217
  const ipHoldings = {
197
218
  standard: 'IETF draft-sibiryakov-ztds-protocol-01',
198
219
  standardUrl: 'https://datatracker.ietf.org/doc/draft-sibiryakov-ztds-protocol/',
@@ -264,10 +285,12 @@ export function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
264
285
 
265
286
  return {
266
287
  totalCount,
288
+ totalDetected: totalCount,
267
289
  entities,
268
290
  types,
269
291
  riskLevel,
270
- frameworksList: Array.from(frameworksSet),
292
+ frameworksList,
293
+ triggeredFrameworks,
271
294
  auditScope,
272
295
  statutoryCitations,
273
296
  ipHoldings,
@@ -517,7 +540,7 @@ We request approval to acquire a **Developer SDK License** (\$299/month flat or
517
540
  */
518
541
  const SDK_VERTICAL_DETECTORS = [
519
542
  { profile: 'medical', label: 'Medical PHI (HIPAA)', regex: /\b(?:Patient\s*(?:Name|ID|DOB|Record)|MRN\b|Medical\s+Record|Attending\s+Physician|Chief\s+Complaint|Diagnosis\s*:|ICD-10|HIPAA|Clinical\s+Intake|Discharge\s+Summary|MedPod|NPI\s*(?:#|No|:)?\s*\d{10}|Medicare\s*(?:ID|Beneficiary|Claim)|Medicaid\s*ID|Prescription|Rx\s*#|Blood\s+Type|Vital\s+Signs|Radiology|Lab\s+Results|EHR\b|EMR\b)\b/gi },
520
- { profile: 'accounting', label: 'Tax & Accounting', regex: /\b(?:Form\s*1040|Form\s*W-2|Form\s*1099|Adjusted\s+Gross\s+Income|Employer\s+Identification\s+Number|Schedule\s+C\b|Steuer-ID|USt-IdNr|Tax\s+Return|General\s+Ledger|Withholding\s+Allowance|Total\s+Tax\s+Liability)\b/gi },
543
+ { profile: 'accounting', label: 'Tax & Accounting', regex: /\b(?:Form\s*1040|Form\s*W-2|Form\s*1099|Adjusted\s+Gross\s+Income|Assessable\s+Income|Employer\s+Identification\s+Number|Schedule\s+C\b|Steuer-ID|USt-IdNr|Tax\s+Return|General\s+Ledger|Withholding\s+Allowance|Total\s+Tax\s+Liability|ABN\b|TFN\b|Tax\s+File\s+Number|Australian\s+Business\s+Number|Business\s+Activity\s+Statement|PAYG\s+(?:Withholding|Instalment)|Franking\s+Credits?)\b/gi },
521
544
  { profile: 'wealthmgmt', label: 'Wealth Management', regex: /\b(?:Revocable\s+Living\s+Trust|Irrevocable\s+(?:Generation-Skipping\s+)?Trust|Grantor\s*:|Settlor\s*:|Trustee\s*:|Beneficiary\s+Designation|Roth\s+IRA|Asset\s+Allocation|FBO\b|CUSIP\s*:|Grantor\s+Retained\s+Annuity\s+Trust|AUM\s*:)\b/gi },
522
545
  { profile: 'legal', label: 'Legal & Contracts', regex: /\b(?:Non-Disclosure\s+Agreement|Attorney-Client\s+Privilege|Docket\s+No|Hereinafter\s+referred|Indemnification\s+Clause|Governing\s+Law\s+and\s+Jurisdiction|Plaintiff\b|Defendant\b|Subpoena\b|Deposition\b|State\s+Bar\s*#)\b/gi },
523
546
  { profile: 'finance', label: 'Finance & Banking', regex: /\b(?:Wire\s+Transfer\s+Authorization|SWIFT\s*\/?\s*BIC\s*:|IBAN\s*:\s*[A-Z]{2}\d{2}|ABA\s+Routing|ACH\s+Transfer|PCI-DSS|Primary\s+Account\s+Number)\b/gi },
@@ -618,7 +641,9 @@ export function sanitize(text, options = {}) {
618
641
  throwOnLimit = true,
619
642
  onLimit = 'throw',
620
643
  detectSecrets = false,
621
- ignoreList = null
644
+ ignoreList = null,
645
+ entities = options.entityTypes || options.enabledEntities || null,
646
+ highRecallNames = Boolean(options.highRecallNames || options.nameRecall === 'high')
622
647
  } = options;
623
648
 
624
649
  const isAutoProfile = Boolean(
@@ -734,8 +759,23 @@ export function sanitize(text, options = {}) {
734
759
  }
735
760
  }
736
761
 
762
+ let normalizedEntities = null;
763
+ if (entities) {
764
+ const rawArr = entities instanceof Set ? Array.from(entities) : (Array.isArray(entities) ? entities : [entities]);
765
+ normalizedEntities = rawArr.filter(Boolean).map(e => {
766
+ const u = String(e).trim().toUpperCase();
767
+ if (u === 'PERSON' || u === 'PERSONS' || u === 'PEOPLE' || u === 'NAMES') return 'NAME';
768
+ if (u === 'EMAILS') return 'EMAIL';
769
+ if (u === 'PHONES' || u === 'TELEPHONE') return 'PHONE';
770
+ if (u === 'IDS' || u === 'IDENTIFIER' || u === 'IDENTIFIERS') return 'ID';
771
+ if (u === 'ADDRESSES' || u === 'LOCATION' || u === 'LOCATIONS') return 'ADDRESS';
772
+ return u;
773
+ });
774
+ if (normalizedEntities.length === 0) normalizedEntities = null;
775
+ }
776
+
737
777
  const activeRules = [...effectiveCustomRules];
738
- if (effectiveProfile.toLowerCase() === 'dev' || detectSecrets === true) {
778
+ if ((effectiveProfile.toLowerCase() === 'dev' || detectSecrets === true) && (!normalizedEntities || normalizedEntities.includes('SECRET') || normalizedEntities.includes('CUSTOM'))) {
739
779
  DEVOPS_SECRETS_DETECTOR.forEach(rule => {
740
780
  activeRules.push({
741
781
  name: rule.name,
@@ -759,7 +799,9 @@ export function sanitize(text, options = {}) {
759
799
  effectiveProfile,
760
800
  existingSessionMap,
761
801
  isPro,
762
- effectiveIgnoreList
802
+ effectiveIgnoreList,
803
+ normalizedEntities,
804
+ Boolean(highRecallNames)
763
805
  );
764
806
 
765
807
  const endTime = (typeof performance !== 'undefined' && typeof performance.now === 'function')
@@ -1087,6 +1129,8 @@ export class PrivacyScrubberEngine {
1087
1129
  this.generateReceipt = config.generateReceipt !== false;
1088
1130
  this.sessionMap = { ...(config.initialSessionMap || {}) };
1089
1131
  this.ignoreList = new Set(config.ignoreList || []);
1132
+ this.entities = config.entities || config.entityTypes || config.enabledEntities || null;
1133
+ this.highRecallNames = Boolean(config.highRecallNames || config.nameRecall === 'high');
1090
1134
  this.maxSessionTokens = config.maxSessionTokens || 10000;
1091
1135
 
1092
1136
  this.license = validateLicense(this.licenseKey);
@@ -1128,7 +1172,9 @@ export class PrivacyScrubberEngine {
1128
1172
  generateReceipt: options.generateReceipt !== undefined ? options.generateReceipt : this.generateReceipt,
1129
1173
  throwOnLimit: options.throwOnLimit !== undefined ? options.throwOnLimit : true,
1130
1174
  detectSecrets: options.detectSecrets !== undefined ? options.detectSecrets : false,
1131
- ignoreList: mergedIgnoreList.size > 0 ? mergedIgnoreList : null
1175
+ ignoreList: mergedIgnoreList.size > 0 ? mergedIgnoreList : null,
1176
+ entities: options.entities || options.entityTypes || options.enabledEntities || this.entities,
1177
+ highRecallNames: options.highRecallNames !== undefined ? Boolean(options.highRecallNames) : (options.nameRecall === 'high' ? true : this.highRecallNames)
1132
1178
  };
1133
1179
 
1134
1180
  const result = sanitize(text, mergedOptions);
@@ -2668,13 +2714,13 @@ export function buildStreamReplacer(tokenMap = {}, options = {}) {
2668
2714
 
2669
2715
  for (const k of sortedKeys) {
2670
2716
  const val = tokenMap[k];
2671
- const inner = k.replace(/^\[|<|\{\{?|__|\]|>|\}\}?|__/g, '');
2717
+ const inner = k.replace(/^(?:\[|<|\{\{?|__|【|[|〔|「|『)|(?:\]|>|\}\}?|__|】|]|〕|」|』)$/g, '');
2672
2718
  const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
2673
2719
 
2674
2720
  if (match) {
2675
2721
  const label = escapeRegex(match[1]).replace(/[_-]/g, '\\\\?[_-]');
2676
2722
  const idx = match[2];
2677
- const patternStr = '(?:(?:\\[|\\\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?lbrack;|【|[|〔|「|『)\\s*[*`~]*' + label + '[-_\\\\\\s]*0*' + idx + '[*`~]*[.,;:!?]?\\s*(?:\\]|\\\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?rbrack;|】|]|〕|」|』)|<\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*>|\\{\\{?\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*\\}\\}?|__\\s*' + label + '[-_\\\\\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)';
2723
+ const patternStr = '(?:(?:\\[|\\\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?(?:lbrack|lsqb);|【|[|〔|「|『)\\s*[*`~]*' + label + '[-_\\\\\\s]*0*' + idx + '[*`~]*[.,;:!?]?\\s*(?:\\]|\\\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?(?:rbrack|rsqb);|】|]|〕|」|』)|<\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*>|\\{\\{?\\s*' + label + '[-_\\\\\\s]*0*' + idx + '[.,;:!?]?\\s*\\}\\}?|__\\s*' + label + '[-_\\\\\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)';
2678
2724
  patterns.push(patternStr);
2679
2725
  const normKey = (match[1] + '_' + parseInt(idx, 10)).toUpperCase();
2680
2726
  lookup.set(normKey, val);
@@ -2704,8 +2750,8 @@ export function buildStreamReplacer(tokenMap = {}, options = {}) {
2704
2750
  restored = lookup.get(coreMatch.toUpperCase());
2705
2751
  } else {
2706
2752
  const inner = coreMatch
2707
- .replace(/^(?:\[|\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?lbrack;|【|[|〔|「|『|<|\{\{?|__)|(?:\]|\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?rbrack;|】|]|〕|」|』|>|\}\}?|__)$/gi, '')
2708
- .replace(/^[*`~\s]+|[.,;:!?*`~\s]+$/g, '')
2753
+ .replace(/^(?:\[|\\u005[Bb]|%5[Bb]|&(?:amp;)?#0*91;|&(?:amp;)?#[xX]0*5[Bb];|&(?:amp;)?(?:lbrack|lsqb);|【|[|〔|「|『|<|\{\{?|__)|(?:\]|\\u005[Dd]|%5[Dd]|&(?:amp;)?#0*93;|&(?:amp;)?#[xX]0*5[Dd];|&(?:amp;)?(?:rbrack|rsqb);|】|]|〕|」|』|>|\}\}?|__)$/gi, '')
2754
+ .replace(/^[*`_~\s]+|[.,;:!?*`_~\s]+$/g, '')
2709
2755
  .trim();
2710
2756
  const m = inner.match(/^([A-Za-z_0-9]+?)[-_\\\s]*(\d+)$/);
2711
2757
  if (m) {
@@ -2818,18 +2864,37 @@ export class StreamingDetokenizer {
2818
2864
  // Candidate unclosed token at tail of buffer (inspect tail window up to 160 chars)
2819
2865
  // Matches incomplete tokens starting with [, 【, [, <, {, __, %5B, \u005b, or HTML entity &#91; / &lbrack;
2820
2866
  const tailLen = 160;
2821
- const tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2822
- const offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2823
- const m = tail.match(/(?:(?:\[|【|[|〔|「|『|<|\{\{?|__?|%5[Bb]|\\u005[Bb]|&(?:amp;)?(?:#0*91;|#[xX]0*5[Bb];|lbrack;))[A-Za-z0-9_\-\u0410-\u044F\u0401\u0451 *`~.\\]{0,128}(?:%5?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?3?|[xX]0*5?[Dd]?)?|r(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)?|%[0-9A-Fa-f]?|\\(?:u(?:0(?:0(?:5)?)?)?)?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?1?|[xX]0*5?)?|l(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)$/);
2867
+ const tailRegex = /(?:(?:\[|【|[|〔|「|『|<|\{\{?|__|%5[Bb]|\\u005[Bb]|&(?:amp;)?(?:#0*91;|#[xX]0*5[Bb];|lbrack;))[A-Za-z0-9_\-\u0410-\u044F\u0401\u0451 *`~.\\]{0,128}(?:%5?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?3?|[xX]0*5?[Dd]?)?|r(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)?|%[0-9A-Fa-f]?|\\(?:u(?:0(?:0(?:5)?)?)?)?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?1?|[xX]0*5?)?|l(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?|_)$/;
2868
+ let tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2869
+ let offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2870
+ let m = tail.match(tailRegex);
2824
2871
 
2825
2872
  if (m) {
2826
2873
  const candidate = m[0];
2827
2874
  // Closed double-underscore (__NAME_1__) or JSON Unicode (\u005bNAME_1\u005d) tokens match character class and can be emitted immediately.
2828
- const isClosedUnderscoreOrUnicode = (candidate.length >= 4 && candidate.startsWith('__') && candidate.endsWith('__')) || /\\u005[Dd]/.test(candidate);
2829
- if (isClosedUnderscoreOrUnicode && this.replacer(candidate) !== candidate) {
2830
- const toEmit = this.pendingBuffer;
2831
- this.pendingBuffer = '';
2832
- return this.replacer(toEmit);
2875
+ const uClose = candidate.startsWith('__') ? candidate.indexOf('__', 2) : -1;
2876
+ const uniMatch = candidate.match(/\\u005[Dd]/);
2877
+ const closeLen = uClose !== -1 ? uClose + 2 : (uniMatch ? uniMatch.index + 6 : -1);
2878
+ if (closeLen !== -1) {
2879
+ const closedToken = candidate.slice(0, closeLen);
2880
+ if (this.replacer(closedToken) !== closedToken) {
2881
+ const splitAfterToken = offset + m.index + closeLen;
2882
+ const prefixEmitted = this.replacer(this.pendingBuffer.slice(0, splitAfterToken));
2883
+ this.pendingBuffer = this.pendingBuffer.slice(splitAfterToken);
2884
+ if (!this.pendingBuffer) return prefixEmitted;
2885
+ tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2886
+ offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2887
+ m = tail.match(tailRegex);
2888
+ if (!m) {
2889
+ const rest = this.replacer(this.pendingBuffer);
2890
+ this.pendingBuffer = '';
2891
+ return prefixEmitted + rest;
2892
+ }
2893
+ const sIdx = offset + m.index;
2894
+ const safe = this.pendingBuffer.slice(0, sIdx);
2895
+ this.pendingBuffer = this.pendingBuffer.slice(sIdx);
2896
+ return prefixEmitted + (safe ? this.replacer(safe) : '');
2897
+ }
2833
2898
  }
2834
2899
  const splitIdx = offset + m.index;
2835
2900
  const safeToEmit = this.pendingBuffer.slice(0, splitIdx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@privacyscrubber/sdk",
3
- "version": "2.4.8",
3
+ "version": "2.4.9",
4
4
  "description": "In-process consumer privacy defense and pre-emptive PII interception SDK for Node.js, Next.js, and AI pipelines. Zero data loss, zero third parties, sub-millisecond in-memory execution.",
5
5
  "main": "index.cjs",
6
6
  "module": "index.js",