@privacyscrubber/sdk 2.4.7 → 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.7';
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.7"
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.7"
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.7',
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.7")
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.7"
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.7",
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.7';
42
+ return '2.4.9';
43
43
  })();
44
44
 
45
45
  /**
@@ -132,7 +132,7 @@ function validateLicenseKey(key) {
132
132
  * @param {Record<string, string>|string[]} currentTokenMap
133
133
  * @returns {object}
134
134
  */
135
- function buildAuditTelemetry(currentTokenMap = {}) {
135
+ function buildAuditTelemetry(currentTokenMap = {}, extraStats = {}) {
136
136
  const entities = {};
137
137
  let totalCount = 0;
138
138
 
@@ -142,8 +142,9 @@ function buildAuditTelemetry(currentTokenMap = {}) {
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 = {}) {
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 = {}) {
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 = {}) {
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/',
@@ -242,12 +263,33 @@ function buildAuditTelemetry(currentTokenMap = {}) {
242
263
  }
243
264
  ];
244
265
 
266
+ const rawLicenseTier = String(extraStats?.licenseTier || '').trim().toUpperCase();
267
+ const isOrganizationalScope = ['TEAMS', 'SDK', 'OEM', 'ENTERPRISE', 'ENTERPRISE_AIRGAP'].includes(rawLicenseTier);
268
+ const auditScope = isOrganizationalScope ? {
269
+ tier: rawLicenseTier,
270
+ isOrganizational: true,
271
+ badge: 'ORGANIZATIONAL MULTI-SEAT COMPLIANCE SCOPE',
272
+ coverage: 'Covers department, clinic, or firm personnel under unified ZTDS policy governance.',
273
+ upgradeRecommended: false,
274
+ upgradeUrl: null
275
+ } : {
276
+ tier: rawLicenseTier || 'SINGLE_OPERATOR',
277
+ isOrganizational: false,
278
+ badge: 'SINGLE-OPERATOR PERSONAL SCOPE',
279
+ coverage: 'Valid for individual workstation only. Multi-employee clinics, CPA firms, and departments require TEAMS ($99/mo flat, unlimited seats) for organizational audit coverage.',
280
+ upgradeRecommended: true,
281
+ upgradeUrl: 'https://privacyscrubber.com/pricing/?tier=teams&utm_source=audit_receipt&utm_medium=scope_upsell'
282
+ };
283
+
245
284
  return {
246
285
  totalCount,
286
+ totalDetected: totalCount,
247
287
  entities,
248
288
  types,
249
289
  riskLevel,
250
- frameworksList: Array.from(frameworksSet),
290
+ frameworksList,
291
+ triggeredFrameworks,
292
+ auditScope,
251
293
  statutoryCitations,
252
294
  ipHoldings,
253
295
  agenticProtocol,
@@ -301,6 +343,19 @@ function _resetCommunityNoticeForTesting() {
301
343
  communityNoticeLogged = false;
302
344
  }
303
345
 
346
+ const FREE_PROD_SPECIALIZED_CALL_LIMIT = 250;
347
+ let _freeProdSpecializedCalls = 0;
348
+ let _prodProfileFallbackNoticeLogged = false;
349
+
350
+ /**
351
+ * Resets the production specialized call quota state for testing purposes.
352
+ * @private
353
+ */
354
+ function _resetProdQuotaForTesting() {
355
+ _freeProdSpecializedCalls = 0;
356
+ _prodProfileFallbackNoticeLogged = false;
357
+ }
358
+
304
359
  let devSecretNoticeLogged = false;
305
360
 
306
361
  function emitDevSecretNoticeIfNeeded(isPro, tokenMap = {}, limit = 15000) {
@@ -481,6 +536,82 @@ We request approval to acquire a **Developer SDK License** (\$299/month flat or
481
536
  * auditReceipt: string
482
537
  * }}
483
538
  */
539
+ const SDK_VERTICAL_DETECTORS = [
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 },
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 },
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 },
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 },
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 },
545
+ { profile: 'hr', label: 'HR & Resumes', regex: /\b(?:Employee\s*ID|Performance\s+Improvement\s+Plan|Payroll\s+Register|Gross\s+Pay\b|Net\s+Pay\b|Candidate\s+Evaluation|Compensation\s+Band|Sozialversicherungsnummer|Personalnummer|EEID\s*:)\b/gi },
546
+ { profile: 'realestate', label: 'Real Estate', regex: /\b(?:Assessor\s+Parcel\s+Number|APN\s*:|MLS\s*#|Closing\s+Disclosure|Escrow\s+(?:Account|Number|Officer)|Property\s+Deed|Title\s+Insurance|Lease\s+Application)\b/gi },
547
+ { profile: 'dev', label: 'API & Dev Logs', regex: /\b(?:BEGIN\s+(?:RSA|OPENSSH|EC|PRIVATE)\s+PRIVATE\s+KEY|AWS_SECRET_ACCESS_KEY|DATABASE_URL\s*=|GITHUB_TOKEN\s*=|Bearer\s+eyJ[A-Za-z0-9_-]+)\b/g },
548
+ { profile: 'sales', label: 'Sales & CRM', regex: /\b(?:CRM\s+(?:Opportunity|Export)|HubSpot\b|Salesforce\b|MEDDPICC\b|Primary\s+Buyer\s*:|MSA\s+Redlines|Deal\s+Stage|Pipeline\s+Forecast|Opportunity\s+ID|Account\s+Executive)\b/gi },
549
+ { profile: 'engineering', label: 'DevOps & Infra', regex: /\b(?:Incident\s+Postmortem|Kubeconfig|Kubernetes\s+Cluster|Helm\s+Chart|Terraform\s+State|DMAIC\b|Bill\s+of\s+Materials|BOM\s+Spec|CNC\s+Machining|Shop\s+Floor\s+Batch)\b/gi },
550
+ { profile: 'insurance', label: 'Insurance & Claims', regex: /\b(?:Claim\s+Settlement|Policyholder\s*:|Policy\s*#?POL-|Claim\s+Number|Adjuster\s*:|Loss\s+Location|Loss\s+Run|Subrogation\b|FNOL\b|Deductible\s*:)\b/gi },
551
+ { profile: 'pharma', label: 'Pharma & Trials', regex: /\b(?:Clinical\s+Trial(?:\s+Protocol)?|NCT\d{8}\b|IRB\s+Protocol|FDA\s+1572|Adverse\s+Event|Pharmacovigilance|Principal\s+Investigator|Cohort\s+[A-Z]\b|Blinded\s+Study|CDISC\b|IND\s*(?:#|Protocol))\b/gi },
552
+ { profile: 'underwriting', label: 'Loan Underwriting & Income', regex: /\b(?:Loan\s+Underwriting|Mortgage\s+Appraisal|Loan\s+Application\s+#?LOS|FICO\s*:\s*\d{3}|Appraised\s+Value\s*:|80%\s+LTV|Monthly\s+Qualifying\s*:|YTD\s+Earnings|Borrower\s*:)\b/gi },
553
+ { profile: 'security', label: 'Security & SIEM', regex: /\b(?:SIEM\s+Alert|CVE-\d{4}-\d{4,7}\b|Threat\s*:\s*CRITICAL|Unauthorized\s+S3\s+bucket|SOC\s+Incident|Firewall\s+Rule\s+Violation|Intrusion\s+Detection)\b/gi },
554
+ { profile: 'support', label: 'Support & Tickets', regex: /\b(?:Support\s+Ticket|Ticket\s*#INC-|Zendesk\b|Intercom\b|Billing\s+Discrepancy|SLA\s+Escalation|CSAT\s+Score|Customer\s+Support\s+Escalation)\b/gi },
555
+ { profile: 'compliance', label: 'Compliance Audit', regex: /\b(?:Suspicious\s+Activity\s+Report|FinCEN\s+SAR|BSA\s+Officer|31\s+CFR\b|ROPA\s+Register|Data\s+Protection\s+Impact\s+Assessment|SOC\s*2\s+Type\s+II|ISO\s*27001\s+Audit|CMMC\s+Level|ITAR\s+Controlled)\b/gi },
556
+ { profile: 'ccpa', label: 'CCPA / CPRA Privacy', regex: /\b(?:CCPA\s*\/?\s*CPRA|1798\.105|Right\s+to\s+Delete|Opt-out\s+of\s+Sale|Ticket\s*#?DSAR-)\b/gi },
557
+ { profile: 'bizops', label: 'Biz Ops & M&A', regex: /\b(?:Vendor\s+Master\s+Agreement|Bill\s+of\s+Lading|Customs\s+Declaration|Purchase\s+Order\s*#?PO-|Supply\s+Chain\s+Manifest)\b/gi },
558
+ { profile: 'biotech', label: 'Biotech & Genomics', regex: /\b(?:Sequencing\s+Platform|Illumina\s+NovaSeq|Flowcell\s*:|FastQ\s+Sample|ClinVar\b|HGVS\s+Variant|Genomic\s+Sequencing|Bioinformatics\s+Intake|BRCA[12]\s+c\.)\b/gi },
559
+ { profile: 'automotive', label: 'Automotive & EV', regex: /\b(?:Connected\s+Vehicle\s+Telematics|ECU\s+Serial|Telematics\s+Device|ASIL-D\b|DTC\s+P\d{4}\b|CAN-Bus\s+Log)\b/gi },
560
+ { profile: 'energy', label: 'Energy & Smart Grid', regex: /\b(?:Critical\s+Infrastructure\s+SCADA|Substation\s+ID|Feeder\s+ID|Smart\s+Meter\s*:|ESI\s+ID\s*:|NERC\s+CIP|BES\s+Cyber\s+Asset)\b/gi },
561
+ { profile: 'hospitality', label: 'Hospitality & Travel', regex: /\b(?:Airline\s+Passenger\s+Dossier|PNR\s*:\s*[A-Z0-9]{6}|e-Ticket\s*:|Boarding\s+Pass\s*:|Known\s+Traveler\s+KTN|Guest\s+Folio|Marriott\s+Bonvoy|SkyMiles)\b/gi },
562
+ { profile: 'telecom', label: 'Telecom & Call Center', regex: /\b(?:Telecom\s+Subscriber\s+CDR|MSISDN\s*:|IMSI\s*:\s*\d{14,15}|IMEI\s*:\s*\d{14,16}|Cell\s+Tower\s+ID|SIP\s+Signaling)\b/gi },
563
+ { profile: 'academic', label: 'Academic & Research', regex: /\b(?:FERPA\s+Student\s+Record|Student\s+ID\s*:\s*#?STU-|Disciplinary\s+Record|IEP\s+Evaluation|IRB\s+Approval)\b/gi },
564
+ { profile: 'agents', label: 'AI Agents & Prompts', regex: /\b(?:Autonomous\s+Financial\s+Procurement\s+Agent|AgentID\s*:\s*agt_|Encrypted\s+Tool\s+Token|Agent\s+Memory\s*>)\b/gi }
565
+ ];
566
+
567
+ /**
568
+ * Automatically detects the most relevant industry compliance profile from text content.
569
+ * Executes in <0.05ms in local RAM with zero network egress.
570
+ *
571
+ * @param {string} text - Input document or prompt text.
572
+ * @returns {{
573
+ * detectedProfile: string,
574
+ * label: string,
575
+ * confidence: 'high'|'medium'|'low',
576
+ * score: number,
577
+ * alternatives: Array<{ profile: string, label: string, score: number }>
578
+ * }}
579
+ */
580
+ function detectProfile(text) {
581
+ if (!text || typeof text !== 'string') {
582
+ return { detectedProfile: 'general', label: 'General PII', confidence: 'low', score: 0, alternatives: [] };
583
+ }
584
+ const sampleSlice = text.length > 16384 ? text.slice(0, 16384) : text;
585
+ const matches = [];
586
+ for (let i = 0; i < SDK_VERTICAL_DETECTORS.length; i++) {
587
+ const c = SDK_VERTICAL_DETECTORS[i];
588
+ c.regex.lastIndex = 0;
589
+ const found = sampleSlice.match(c.regex);
590
+ if (found && found.length > 0) {
591
+ matches.push({
592
+ profile: c.profile,
593
+ label: c.label,
594
+ score: found.length,
595
+ priority: i
596
+ });
597
+ }
598
+ }
599
+ if (matches.length === 0) {
600
+ return { detectedProfile: 'general', label: 'General PII', confidence: 'low', score: 0, alternatives: [] };
601
+ }
602
+ matches.sort((a, b) => (b.score !== a.score) ? (b.score - a.score) : (a.priority - b.priority));
603
+ const primary = matches[0];
604
+ const alternatives = matches.slice(1, 4).map(({ profile, label, score }) => ({ profile, label, score }));
605
+ const confidence = primary.score >= 3 ? 'high' : (primary.score >= 2 ? 'medium' : 'low');
606
+ return {
607
+ detectedProfile: primary.profile,
608
+ label: primary.label,
609
+ confidence,
610
+ score: primary.score,
611
+ alternatives
612
+ };
613
+ }
614
+
484
615
  function sanitize(text, options = {}) {
485
616
  if (!text || typeof text !== 'string') {
486
617
  return {
@@ -499,7 +630,7 @@ function sanitize(text, options = {}) {
499
630
  }
500
631
 
501
632
  const {
502
- profile = 'General',
633
+ profile: rawProfileOption = 'General',
503
634
  licenseKey = (typeof process !== 'undefined' && (process.env?.PRIVACYSCRUBBER_KEY || process.env?.PRIVACYSCRUBBER_LICENSE_KEY)) || '',
504
635
  customRules = [],
505
636
  tokenLabelMap = {},
@@ -508,9 +639,17 @@ function sanitize(text, options = {}) {
508
639
  throwOnLimit = true,
509
640
  onLimit = 'throw',
510
641
  detectSecrets = false,
511
- ignoreList = null
642
+ ignoreList = null,
643
+ entities = options.entityTypes || options.enabledEntities || null,
644
+ highRecallNames = Boolean(options.highRecallNames || options.nameRecall === 'high')
512
645
  } = options;
513
646
 
647
+ const isAutoProfile = Boolean(
648
+ (typeof rawProfileOption === 'string' && rawProfileOption.trim().toLowerCase() === 'auto') ||
649
+ options.autoDetectProfile === true
650
+ );
651
+ const autoDetected = isAutoProfile ? detectProfile(text) : null;
652
+
514
653
  const licenseCheck = validateLicense(licenseKey);
515
654
  const rawTier = licenseCheck.valid ? (licenseCheck.tier || 'PRO') : 'FREE';
516
655
  const isExpiredKey = !licenseCheck.valid && typeof licenseCheck.error === 'string' && /expired/i.test(licenseCheck.error);
@@ -532,6 +671,21 @@ function sanitize(text, options = {}) {
532
671
  const isSdkTier = licenseCheck.valid && ['SDK', 'OEM', 'ENTERPRISE'].includes(rawTier);
533
672
  const isSeatTier = licenseCheck.valid && ['PRO', 'TEAMS'].includes(rawTier);
534
673
 
674
+ const isPro = isSdkTier;
675
+ const tier = isPro ? rawTier : (isSeatTier ? rawTier : 'FREE');
676
+
677
+ let autoSuggestedOnly = false;
678
+ let profile = rawProfileOption;
679
+ if (isAutoProfile && autoDetected) {
680
+ if (!isPro && text.length > 5000 && text.length <= 15000 && autoDetected.detectedProfile !== 'general') {
681
+ // Non-blocking PLG fallback: keep General for Free 5k-15k texts so auto-mode never breaks free calls
682
+ profile = 'General';
683
+ autoSuggestedOnly = true;
684
+ } else {
685
+ profile = autoDetected.detectedProfile;
686
+ }
687
+ }
688
+
535
689
  if (isSeatTier) {
536
690
  const keyPreview = licenseKey ? `${licenseKey.slice(0, 12)}...` : '';
537
691
  const errorMsg = `[PrivacyScrubber] License tier mismatch: ${rawTier} license (${keyPreview}) is licensed strictly for human seats (Web App & Chrome Extension). Backend automation, microservices, and CRM pipelines require a Developer SDK license ($299/mo). Upgrade: https://privacyscrubber.com/pricing?tier=sdk&src=seat_tier_mismatch`;
@@ -545,9 +699,6 @@ function sanitize(text, options = {}) {
545
699
  }
546
700
  }
547
701
 
548
- const isPro = isSdkTier;
549
- const tier = isPro ? rawTier : (isSeatTier ? rawTier : 'FREE');
550
-
551
702
  emitCommunityNoticeIfNeeded(isPro);
552
703
 
553
704
  const isSpecialized = profile && profile.toLowerCase() !== 'general';
@@ -581,8 +732,48 @@ function sanitize(text, options = {}) {
581
732
  });
582
733
 
583
734
  // Auto-inject DevOps Secrets rules when in Dev profile or when detectSecrets is requested
584
- const activeRules = [...safeCustomRules];
585
- if (profile.toLowerCase() === 'dev' || detectSecrets === true) {
735
+ const isProdEnv = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production';
736
+ const hasCustomEnterpriseConfig = isSpecialized || safeCustomRules.length > 0 || (Array.isArray(ignoreList) && ignoreList.length > 0);
737
+ let profileFallback = false;
738
+ let effectiveProfile = profile;
739
+ let effectiveCustomRules = safeCustomRules;
740
+ let effectiveIgnoreList = ignoreList;
741
+
742
+ if (!isPro && isProdEnv && hasCustomEnterpriseConfig) {
743
+ _freeProdSpecializedCalls++;
744
+ if (_freeProdSpecializedCalls > FREE_PROD_SPECIALIZED_CALL_LIMIT) {
745
+ profileFallback = true;
746
+ effectiveProfile = 'General';
747
+ effectiveCustomRules = [];
748
+ effectiveIgnoreList = null;
749
+ if (!_prodProfileFallbackNoticeLogged && typeof process !== 'undefined' && process.stderr && typeof process.stderr.write === 'function') {
750
+ _prodProfileFallbackNoticeLogged = true;
751
+ process.stderr.write(
752
+ `\n[@privacyscrubber/sdk] Free Production Quota (${FREE_PROD_SPECIALIZED_CALL_LIMIT} specialized calls) reached for profile '${profile}'.\n` +
753
+ `[@privacyscrubber/sdk] Gracefully falling back to 'General' profile (Core PII remains protected, 0 downtime).\n` +
754
+ `[@privacyscrubber/sdk] Unlock unlimited specialized profiles, custom rules & ignoreLists in production ($299/mo): https://privacyscrubber.com/pricing?tier=sdk&src=prod_profile_fallback (7-Day Trial: npx @privacyscrubber/sdk trial)\n\n`
755
+ );
756
+ }
757
+ }
758
+ }
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
+
775
+ const activeRules = [...effectiveCustomRules];
776
+ if ((effectiveProfile.toLowerCase() === 'dev' || detectSecrets === true) && (!normalizedEntities || normalizedEntities.includes('SECRET') || normalizedEntities.includes('CUSTOM'))) {
586
777
  DEVOPS_SECRETS_DETECTOR.forEach(rule => {
587
778
  activeRules.push({
588
779
  name: rule.name,
@@ -603,10 +794,12 @@ function sanitize(text, options = {}) {
603
794
  textToScrub,
604
795
  activeRules,
605
796
  tokenLabelMap,
606
- profile,
797
+ effectiveProfile,
607
798
  existingSessionMap,
608
799
  isPro,
609
- ignoreList
800
+ effectiveIgnoreList,
801
+ normalizedEntities,
802
+ Boolean(highRecallNames)
610
803
  );
611
804
 
612
805
  const endTime = (typeof performance !== 'undefined' && typeof performance.now === 'function')
@@ -614,7 +807,7 @@ function sanitize(text, options = {}) {
614
807
  : Date.now();
615
808
 
616
809
  const executionMs = Math.round((endTime - startTime) * 100) / 100;
617
- const telemetry = buildAuditTelemetry(rawResult.tokenMap || {});
810
+ const telemetry = buildAuditTelemetry(rawResult.tokenMap || {}, { licenseTier: tier });
618
811
  const auditReceipt = generateReceipt ? formatAuditReceipt(telemetry) : '';
619
812
 
620
813
  emitDevSecretNoticeIfNeeded(isPro, rawResult.tokenMap, limit);
@@ -642,6 +835,10 @@ function sanitize(text, options = {}) {
642
835
  limit: limit,
643
836
  wasTruncated: wasTruncated,
644
837
  truncated: wasTruncated,
838
+ profileFallback: profileFallback,
839
+ effectiveProfile: effectiveProfile,
840
+ specializedCallsUsed: _freeProdSpecializedCalls,
841
+ specializedCallsLimit: isPro ? Infinity : FREE_PROD_SPECIALIZED_CALL_LIMIT,
645
842
  ...(isSeatTier && {
646
843
  upgradeUrl: `https://privacyscrubber.com/pricing?tier=sdk&profile=${encodeURIComponent(String(profile || 'General').toLowerCase())}&src=seat_tier_mismatch`,
647
844
  trialUrl: `https://privacyscrubber.com/pricing?tier=sdk&trial=true&profile=${encodeURIComponent(String(profile || 'General').toLowerCase())}&src=seat_tier_mismatch`,
@@ -649,12 +846,20 @@ function sanitize(text, options = {}) {
649
846
  recommendation: `Your ${rawTier} license is valid for interactive browser seats only. Upgrade to Developer SDK ($299/mo) for unlimited backend server throughput: https://privacyscrubber.com/pricing?tier=sdk`
650
847
  }),
651
848
  ...(!isPro && !isSeatTier && {
652
- upgradeUrl: `https://privacyscrubber.com/pricing?tier=sdk&profile=${encodeURIComponent(String(profile || 'General').toLowerCase())}&src=dev_upsell`,
653
- trialUrl: `https://privacyscrubber.com/pricing?tier=sdk&trial=true&profile=${encodeURIComponent(String(profile || 'General').toLowerCase())}&src=dev_upsell`,
849
+ upgradeUrl: `https://privacyscrubber.com/pricing?tier=sdk&profile=${encodeURIComponent(String(profile || 'General').toLowerCase())}&src=${profileFallback ? 'prod_profile_fallback' : 'dev_upsell'}`,
850
+ trialUrl: `https://privacyscrubber.com/pricing?tier=sdk&trial=true&profile=${encodeURIComponent(String(profile || 'General').toLowerCase())}&src=${profileFallback ? 'prod_profile_fallback' : 'dev_upsell'}`,
654
851
  trialCommand: 'npx @privacyscrubber/sdk trial',
655
852
  recommendation: 'Upgrade to Developer SDK ($299/mo) for unlimited backend text processing, custom regex rules, and specialized compliance profiles.'
656
853
  })
657
854
  },
855
+ profileDetection: {
856
+ autoDetected: isAutoProfile,
857
+ detectedProfile: autoDetected ? autoDetected.detectedProfile : String(effectiveProfile || 'General').toLowerCase(),
858
+ label: autoDetected ? autoDetected.label : effectiveProfile,
859
+ confidence: autoDetected ? autoDetected.confidence : 'high',
860
+ suggestedOnly: autoSuggestedOnly,
861
+ alternatives: autoDetected ? autoDetected.alternatives : []
862
+ },
658
863
  telemetry: telemetry,
659
864
  auditReceipt: auditReceipt
660
865
  };
@@ -922,6 +1127,8 @@ class PrivacyScrubberEngine {
922
1127
  this.generateReceipt = config.generateReceipt !== false;
923
1128
  this.sessionMap = { ...(config.initialSessionMap || {}) };
924
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');
925
1132
  this.maxSessionTokens = config.maxSessionTokens || 10000;
926
1133
 
927
1134
  this.license = validateLicense(this.licenseKey);
@@ -963,7 +1170,9 @@ class PrivacyScrubberEngine {
963
1170
  generateReceipt: options.generateReceipt !== undefined ? options.generateReceipt : this.generateReceipt,
964
1171
  throwOnLimit: options.throwOnLimit !== undefined ? options.throwOnLimit : true,
965
1172
  detectSecrets: options.detectSecrets !== undefined ? options.detectSecrets : false,
966
- 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)
967
1176
  };
968
1177
 
969
1178
  const result = sanitize(text, mergedOptions);
@@ -2503,13 +2712,13 @@ function buildStreamReplacer(tokenMap = {}, options = {}) {
2503
2712
 
2504
2713
  for (const k of sortedKeys) {
2505
2714
  const val = tokenMap[k];
2506
- const inner = k.replace(/^\[|<|\{\{?|__|\]|>|\}\}?|__/g, '');
2715
+ const inner = k.replace(/^(?:\[|<|\{\{?|__|【|[|〔|「|『)|(?:\]|>|\}\}?|__|】|]|〕|」|』)$/g, '');
2507
2716
  const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
2508
2717
 
2509
2718
  if (match) {
2510
2719
  const label = escapeRegex(match[1]).replace(/[_-]/g, '\\\\?[_-]');
2511
2720
  const idx = match[2];
2512
- 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)';
2513
2722
  patterns.push(patternStr);
2514
2723
  const normKey = (match[1] + '_' + parseInt(idx, 10)).toUpperCase();
2515
2724
  lookup.set(normKey, val);
@@ -2539,8 +2748,8 @@ function buildStreamReplacer(tokenMap = {}, options = {}) {
2539
2748
  restored = lookup.get(coreMatch.toUpperCase());
2540
2749
  } else {
2541
2750
  const inner = coreMatch
2542
- .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, '')
2543
- .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, '')
2544
2753
  .trim();
2545
2754
  const m = inner.match(/^([A-Za-z_0-9]+?)[-_\\\s]*(\d+)$/);
2546
2755
  if (m) {
@@ -2653,18 +2862,37 @@ class StreamingDetokenizer {
2653
2862
  // Candidate unclosed token at tail of buffer (inspect tail window up to 160 chars)
2654
2863
  // Matches incomplete tokens starting with [, 【, [, <, {, __, %5B, \u005b, or HTML entity &#91; / &lbrack;
2655
2864
  const tailLen = 160;
2656
- const tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
2657
- const offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
2658
- 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);
2659
2869
 
2660
2870
  if (m) {
2661
2871
  const candidate = m[0];
2662
2872
  // Closed double-underscore (__NAME_1__) or JSON Unicode (\u005bNAME_1\u005d) tokens match character class and can be emitted immediately.
2663
- const isClosedUnderscoreOrUnicode = (candidate.length >= 4 && candidate.startsWith('__') && candidate.endsWith('__')) || /\\u005[Dd]/.test(candidate);
2664
- if (isClosedUnderscoreOrUnicode && this.replacer(candidate) !== candidate) {
2665
- const toEmit = this.pendingBuffer;
2666
- this.pendingBuffer = '';
2667
- 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
+ }
2668
2896
  }
2669
2897
  const splitIdx = offset + m.index;
2670
2898
  const safeToEmit = this.pendingBuffer.slice(0, splitIdx);
@@ -3881,6 +4109,7 @@ const {
3881
4109
 
3882
4110
  const _defaultExport = {
3883
4111
  SDK_VERSION,
4112
+ detectProfile,
3884
4113
  sanitize,
3885
4114
  sanitizeObject,
3886
4115
  scrub,
@@ -3933,6 +4162,8 @@ const _defaultExport = {
3933
4162
  scrubText,
3934
4163
  unscrubText,
3935
4164
  DEVOPS_SECRETS_DETECTOR,
4165
+ FREE_PROD_SPECIALIZED_CALL_LIMIT,
4166
+ _resetProdQuotaForTesting,
3936
4167
  stripExifMetadata,
3937
4168
  redactImageBoundingBoxes,
3938
4169
  generateSvgRedactionOverlay
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). */