@privacyscrubber/sdk 2.2.1 → 2.2.2
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 +59 -1
- package/cli.js +166 -19
- package/index.d.ts +67 -6
- package/index.js +444 -67
- package/package.json +1 -1
- package/ps-license-manager.js +1 -1
- package/ps-pii-engine.cjs +1 -1
- package/scrubber-core.cjs +1 -1
package/index.js
CHANGED
|
@@ -43,7 +43,12 @@ export const DEVOPS_SECRETS_DETECTOR = [
|
|
|
43
43
|
{ name: 'Stripe API Key', regex: /\b(?:[rs]k)_(?:test|live)_[a-zA-Z0-9]{24,}\b/g, label: 'STRIPE_KEY' },
|
|
44
44
|
{ name: 'OpenAI Project API Key', regex: /\b(?:sk|pk)-(?:proj-)?[a-zA-Z0-9_-]{16,}\b/gi, label: 'OPENAI_KEY' },
|
|
45
45
|
{ name: 'Database Connection URI', regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s"']+/gi, label: 'DB_URI' },
|
|
46
|
-
{ name: '
|
|
46
|
+
{ name: 'Embedded Credentials URI', regex: /\b(?:https?|s?ftp):\/\/[a-zA-Z0-9_.%+-]+:[^\s/@:"']+@[a-zA-Z0-9_.-]+(?::\d+)?(?:\/[^\s"']*)?/gi, label: 'SECRET' },
|
|
47
|
+
{ name: 'URL Query Parameter Secret', regex: /(?<=[?&](?:api_key|apikey|access_token|client_secret|auth_token|token|secret|password|private_key)=)[^\s"'\x26#)>]{8,}/gi, label: 'SECRET' },
|
|
48
|
+
{ name: 'Bearer / Auth Header Token', regex: /(?<=\b(?:Bearer|Token)\s+)[a-zA-Z0-9_\-\.~+/=]{16,}\b/gi, label: 'SECRET' },
|
|
49
|
+
{ name: 'Database/API Secret', regex: /\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\s*[:=]\s*[^ \t\r\n"']{8,}\b/gi, label: 'SECRET' },
|
|
50
|
+
{ name: 'Private Cryptographic Key (PEM Block)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]{10,8192}?-----END (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, label: 'SECRET' },
|
|
51
|
+
{ name: 'Private Cryptographic Key (Header)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, label: 'SECRET' }
|
|
47
52
|
];
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -198,6 +203,160 @@ export function _resetCommunityNoticeForTesting() {
|
|
|
198
203
|
communityNoticeLogged = false;
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
let devSecretNoticeLogged = false;
|
|
207
|
+
|
|
208
|
+
function emitDevSecretNoticeIfNeeded(isPro, tokenMap = {}, limit = 15000) {
|
|
209
|
+
if (devSecretNoticeLogged || isPro) return;
|
|
210
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') return;
|
|
211
|
+
|
|
212
|
+
const tokens = Object.keys(tokenMap || {});
|
|
213
|
+
const secretTokens = tokens.filter(t => {
|
|
214
|
+
const upper = t.toUpperCase();
|
|
215
|
+
return ['API_KEY', 'SECRET', 'PASSWORD', 'KEY', 'AWS_KEY', 'STRIPE_KEY', 'JWT_TOKEN', 'API_TOKEN', 'TOKEN'].some(k => upper.includes(k));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (secretTokens.length > 0) {
|
|
219
|
+
devSecretNoticeLogged = true;
|
|
220
|
+
const count = secretTokens.length;
|
|
221
|
+
const secretSample = secretTokens.slice(0, 2).join(', ');
|
|
222
|
+
if (typeof process !== 'undefined' && process.stderr && typeof process.stderr.write === 'function') {
|
|
223
|
+
process.stderr.write(
|
|
224
|
+
`\n[PrivacyScrubber] Intercepted ${count} API secret(s) before LLM egress: ${secretSample}\n` +
|
|
225
|
+
`[PrivacyScrubber] Protected in local RAM (0 byte network egress). Free Tier active (${limit.toLocaleString()} char cap).\n` +
|
|
226
|
+
`[PrivacyScrubber] Developer SDK ($199/mo flat) for unlimited backend nodes, CI/CD gates & ETL pipelines: https://privacyscrubber.com/pricing?tier=sdk&src=cli_leak_guard\n\n`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resets the dev secret notice state for testing purposes.
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
export function _resetDevSecretNoticeForTesting() {
|
|
237
|
+
devSecretNoticeLogged = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generates an executive CISO & Legal Procurement Memo for enterprise approval of @privacyscrubber/sdk.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} [options={}] - Custom memo configuration options.
|
|
244
|
+
* @param {string} [options.appName='Internal AI Applications & LLM Pipelines'] - Target system name.
|
|
245
|
+
* @param {string} [options.developer='Engineering / AI Platform Team'] - Authoring team name.
|
|
246
|
+
* @param {'markdown'|'text'} [options.format='markdown'] - Output format ('markdown' or 'text').
|
|
247
|
+
* @param {number} [options.teamSize=10] - Size of engineering team.
|
|
248
|
+
* @param {string} [options.estimatedVolume='50M - 500M characters/month'] - Projected token volume.
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
export function generateProcurementMemo(options = {}) {
|
|
252
|
+
const {
|
|
253
|
+
appName = 'Internal AI Applications & LLM Pipelines',
|
|
254
|
+
developer = 'Engineering / AI Platform Team',
|
|
255
|
+
format = 'markdown',
|
|
256
|
+
teamSize = 10,
|
|
257
|
+
estimatedVolume = '50M - 500M characters/month'
|
|
258
|
+
} = options;
|
|
259
|
+
|
|
260
|
+
if (format === 'text') {
|
|
261
|
+
return `MEMORANDUM: ADOPTION OF PRIVACYSCRUBBER DEVELOPER SDK (ZTDS STANDARD)
|
|
262
|
+
|
|
263
|
+
TO: Chief Information Security Officer (CISO), Data Protection Officer (DPO), VP Engineering
|
|
264
|
+
FROM: ${developer}
|
|
265
|
+
SUBJECT: Approval Request: Zero-Trust Data Sanitization (@privacyscrubber/sdk)
|
|
266
|
+
TARGET SYSTEM: ${appName}
|
|
267
|
+
ESTIMATED VOLUME: ${estimatedVolume}
|
|
268
|
+
|
|
269
|
+
1. EXECUTIVE SUMMARY
|
|
270
|
+
We propose standardizing on the PrivacyScrubber Developer SDK (@privacyscrubber/sdk) for in-memory Zero-Trust Data Sanitization across our AI ingestion pipelines. The SDK intercepts and redacts PII, regulatory identifiers, and cloud secrets locally in RAM before any prompt leaves our infrastructure.
|
|
271
|
+
|
|
272
|
+
2. ZERO-TRUST ARCHITECTURAL INVARIANTS
|
|
273
|
+
* Zero Network Egress: 100% local in-memory processing. Zero external API calls, zero telemetry egress, zero third-party sub-processors.
|
|
274
|
+
* In-Memory Volatility: All masking maps remain in volatile process RAM and are discarded after request completion.
|
|
275
|
+
* Sub-Millisecond Latency: Executes in <0.1ms per prompt in local RAM, eliminating latency overhead.
|
|
276
|
+
|
|
277
|
+
3. COMPLIANCE & REGULATORY COVERAGE
|
|
278
|
+
Pre-configured for 25 specialized industry profiles and compliance frameworks:
|
|
279
|
+
* GDPR (Art. 4, 9, 32) & CCPA/CPRA: Ephemeral tokenization.
|
|
280
|
+
* HIPAA (§164.514 Safe Harbor): MRNs, medical record codes, patient identifiers.
|
|
281
|
+
* SOC 2 Type II & ISO 27001 (A.8.11): Zero persistent unencrypted PII.
|
|
282
|
+
* PCI DSS v4.0: PAN, CVV, IBAN, bank ledger sanitization.
|
|
283
|
+
* NIST SP 800-53: Cloud API keys, JWT tokens, AWS/Stripe secret leak protection.
|
|
284
|
+
|
|
285
|
+
4. TOTAL COST OF OWNERSHIP (TCO) COMPARISON
|
|
286
|
+
* Cloud DLP Proxies (AWS Macie, GCP DLP, Nightfall):
|
|
287
|
+
- Cost: $1,500 - $4,000 / month (usage-based billing).
|
|
288
|
+
- Latency: +200ms - 400ms added network roundtrip per AI prompt.
|
|
289
|
+
- Sub-processor Risk: Requires vendor DPA, external network egress.
|
|
290
|
+
* PrivacyScrubber Developer SDK:
|
|
291
|
+
- Cost: $199 / month flat (or $1,990 / year) for unlimited nodes, microservices, and prompts.
|
|
292
|
+
- Latency: <0.1ms (in-process RAM).
|
|
293
|
+
- Sub-processor Risk: Zero (pure in-memory software library).
|
|
294
|
+
|
|
295
|
+
5. ACTION REQUESTED
|
|
296
|
+
Approve commercial license upgrade for ${developer} (${teamSize} engineers, unlimited backend nodes):
|
|
297
|
+
* License Tier: Developer SDK ($199/month flat or $1,990/year)
|
|
298
|
+
* Direct Procurement & License Activation: https://privacyscrubber.com/pricing?tier=sdk&src=ciso_memo
|
|
299
|
+
* Enterprise / Invoicing Contact: enterprise@privacyscrubber.com
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return `# Internal Memo: Adoption of PrivacyScrubber Developer SDK (ZTDS Standard)
|
|
304
|
+
|
|
305
|
+
| **To** | Chief Information Security Officer (CISO), Data Protection Officer (DPO), VP Engineering |
|
|
306
|
+
| :--- | :--- |
|
|
307
|
+
| **From** | ${developer} |
|
|
308
|
+
| **Date** | ${new Date().toISOString().split('T')[0]} |
|
|
309
|
+
| **Subject** | Architectural Approval & License Procurement for Zero-Trust AI Sanitization |
|
|
310
|
+
| **Target System** | ${appName} |
|
|
311
|
+
| **Estimated Scope** | ~${teamSize} engineers / ${estimatedVolume} |
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
### 1. Executive Summary
|
|
316
|
+
We recommend adopting the **PrivacyScrubber Developer SDK** (\`@privacyscrubber/sdk\`) to enforce **Zero-Trust Data Sanitization (ZTDS)** across all internal AI pipelines, LangChain agents, microservices, and developer toolchains.
|
|
317
|
+
|
|
318
|
+
The SDK redacts PII, regulatory identifiers, and developer secrets **in-process before external API egress to OpenAI, Anthropic, or external LLM providers**, and deterministically restores original values upon response return.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
### 2. Architectural Invariants (Zero-Trust Data Sanitization)
|
|
323
|
+
* **Zero Remote Network Egress:** 100% of sanitization and token restoration executes in local Node.js process RAM. No prompt data or telemetry ever leaves our VPC/infrastructure.
|
|
324
|
+
* **Ephemeral In-Memory Tokens:** Masking mappings (\`[EMAIL_1]\`, \`[NAME_1]\`, \`[SECRET_1]\`) are volatile and strictly scoped to request execution context.
|
|
325
|
+
* **Ultra-Low Latency:** Micro-benchmarks verify <0.1ms execution overhead per prompt, compared to 200–400ms for network-based cloud DLP proxies.
|
|
326
|
+
* **Full Reversibility:** Bidirectional round-trip reconstruction ensures downstream application logic receives intact responses without exposing sensitive data to model providers.
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
### 3. Compliance Framework Mapping
|
|
331
|
+
The SDK enforces out-of-the-box sanitization matching mandatory compliance controls:
|
|
332
|
+
* **GDPR (Regulation (EU) 2016/679) & CCPA/CPRA:** Eliminates cross-border PII transfer risk to third-party AI models.
|
|
333
|
+
* **HIPAA (§164.514 Safe Harbor):** Automated scrubbing of Medical Record Numbers (MRNs), patient identifiers, and health insurance IDs.
|
|
334
|
+
* **SOC 2 Type II & ISO 27001 (Control A.8.11):** Enforces data masking and prevents unauthorized logging of confidential client data.
|
|
335
|
+
* **PCI DSS v4.0:** Sanitization of payment cards, IBANs, and financial ledger data.
|
|
336
|
+
* **NIST SP 800-53:** DevOps secret interception for AWS keys, JWT tokens, database URIs, and private API keys.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
### 4. Financial & Architectural Comparison (TCO)
|
|
341
|
+
|
|
342
|
+
| Evaluation Factor | Traditional Cloud DLP (AWS/GCP/Nightfall) | PrivacyScrubber Developer SDK |
|
|
343
|
+
| :--- | :--- | :--- |
|
|
344
|
+
| **Pricing Model** | \$1,500 – \$4,000 / month (metered per GB/token) | **\$199 / month flat** (unlimited nodes & tokens) |
|
|
345
|
+
| **Network Egress** | Requires outbound network calls to DLP vendor | **0 bytes** (100% local Node.js process memory) |
|
|
346
|
+
| **Added Latency** | +200ms – +450ms network round-trip | **<0.1ms** (in-memory regular expressions) |
|
|
347
|
+
| **Vendor Sub-processors** | Third-party cloud vendor added to vendor list | **None** (zero-network software library) |
|
|
348
|
+
| **Deployment** | Complex API gateways and proxy configurations | \`npm install @privacyscrubber/sdk\` in 3 minutes |
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
### 5. Procurement Recommendation
|
|
353
|
+
We request approval to acquire a **Developer SDK License** (\$199/month flat or \$1,990/year) to unlock unlimited throughput across all microservices and CI/CD pipelines.
|
|
354
|
+
|
|
355
|
+
* **Direct Procurement Portal:** [https://privacyscrubber.com/pricing?tier=sdk&src=ciso_memo](https://privacyscrubber.com/pricing?tier=sdk&src=ciso_memo)
|
|
356
|
+
* **Procurement / Invoicing Contact:** \`enterprise@privacyscrubber.com\`
|
|
357
|
+
`;
|
|
358
|
+
}
|
|
359
|
+
|
|
201
360
|
/**
|
|
202
361
|
* Sanitizes input text, masking detected PII & secrets with reversible tokens.
|
|
203
362
|
*
|
|
@@ -210,6 +369,7 @@ export function _resetCommunityNoticeForTesting() {
|
|
|
210
369
|
* @param {Record<string, string>} [options.existingSessionMap={}] - In-memory session map to preserve token IDs across turns.
|
|
211
370
|
* @param {boolean} [options.generateReceipt=true] - Generate a CISO Markdown audit receipt.
|
|
212
371
|
* @param {boolean} [options.throwOnLimit=true] - Throw PrivacyScrubberLicenseError when free tier limit is hit.
|
|
372
|
+
* @param {'throw'|'truncate'} [options.onLimit='throw'] - Action when character limit exceeded ('throw' or 'truncate').
|
|
213
373
|
* @param {boolean} [options.detectSecrets=false] - Auto-detect cloud credentials and API keys.
|
|
214
374
|
* @param {Set<string>|Array<string>} [options.ignoreList=null] - Values to preserve in cleartext.
|
|
215
375
|
* @returns {{
|
|
@@ -217,7 +377,7 @@ export function _resetCommunityNoticeForTesting() {
|
|
|
217
377
|
* tokenMap: Record<string, string>,
|
|
218
378
|
* count: number,
|
|
219
379
|
* executionMs: number,
|
|
220
|
-
* license: { isPro: boolean, tier: 'PRO'|'TEAMS'|'FREE', limit: number, upgradeUrl?: string },
|
|
380
|
+
* license: { isPro: boolean, tier: 'PRO'|'TEAMS'|'FREE', limit: number, wasTruncated: boolean, upgradeUrl?: string, recommendation?: string },
|
|
221
381
|
* telemetry: object,
|
|
222
382
|
* auditReceipt: string
|
|
223
383
|
* }}
|
|
@@ -229,7 +389,7 @@ export function sanitize(text, options = {}) {
|
|
|
229
389
|
tokenMap: {},
|
|
230
390
|
count: 0,
|
|
231
391
|
executionMs: 0,
|
|
232
|
-
license: { isPro: false, tier: 'FREE', limit: 15000 },
|
|
392
|
+
license: { isPro: false, tier: 'FREE', limit: 15000, wasTruncated: false },
|
|
233
393
|
telemetry: buildAuditTelemetry({}),
|
|
234
394
|
auditReceipt: formatAuditReceipt(buildAuditTelemetry({}))
|
|
235
395
|
};
|
|
@@ -243,6 +403,7 @@ export function sanitize(text, options = {}) {
|
|
|
243
403
|
existingSessionMap = {},
|
|
244
404
|
generateReceipt = true,
|
|
245
405
|
throwOnLimit = true,
|
|
406
|
+
onLimit = 'throw',
|
|
246
407
|
detectSecrets = false,
|
|
247
408
|
ignoreList = null
|
|
248
409
|
} = options;
|
|
@@ -256,9 +417,12 @@ export function sanitize(text, options = {}) {
|
|
|
256
417
|
const isSpecialized = profile && profile.toLowerCase() !== 'general';
|
|
257
418
|
const limit = isPro ? Infinity : (isSpecialized ? 5000 : 15000);
|
|
258
419
|
|
|
420
|
+
const shouldThrow = throwOnLimit && onLimit !== 'truncate';
|
|
421
|
+
let wasTruncated = false;
|
|
422
|
+
|
|
259
423
|
if (!isPro && text.length > limit) {
|
|
260
|
-
|
|
261
|
-
|
|
424
|
+
if (shouldThrow) {
|
|
425
|
+
const errorMsg = `[PrivacyScrubber] Free Tier character limit exceeded (${limit.toLocaleString()} chars for profile '${profile}', received ${text.length.toLocaleString()} chars). Upgrade to PRO, TEAMS, or Developer SDK for unlimited throughput: https://privacyscrubber.com/pricing?utm_source=npm_sdk&utm_medium=sdk_error&utm_campaign=dev_upsell`;
|
|
262
426
|
throw new PrivacyScrubberLicenseError(errorMsg, {
|
|
263
427
|
tier: 'FREE',
|
|
264
428
|
currentLength: text.length,
|
|
@@ -266,6 +430,7 @@ export function sanitize(text, options = {}) {
|
|
|
266
430
|
profile: profile
|
|
267
431
|
});
|
|
268
432
|
}
|
|
433
|
+
wasTruncated = true;
|
|
269
434
|
}
|
|
270
435
|
|
|
271
436
|
// Auto-inject DevOps Secrets rules when in Dev profile or when detectSecrets is requested
|
|
@@ -305,8 +470,15 @@ export function sanitize(text, options = {}) {
|
|
|
305
470
|
const telemetry = buildAuditTelemetry(rawResult.tokenMap || {});
|
|
306
471
|
const auditReceipt = generateReceipt ? formatAuditReceipt(telemetry) : '';
|
|
307
472
|
|
|
473
|
+
emitDevSecretNoticeIfNeeded(isPro, rawResult.tokenMap, limit);
|
|
474
|
+
|
|
475
|
+
let finalScrubbedText = rawResult.scrubbedText;
|
|
476
|
+
if (wasTruncated && (onLimit === 'truncate' || throwOnLimit === false)) {
|
|
477
|
+
finalScrubbedText += `\n\n<!-- [PrivacyScrubber] Input truncated at free tier limit (${limit.toLocaleString()} chars). For unlimited throughput: https://privacyscrubber.com/pricing?tier=sdk&src=truncation -->`;
|
|
478
|
+
}
|
|
479
|
+
|
|
308
480
|
return {
|
|
309
|
-
scrubbedText:
|
|
481
|
+
scrubbedText: finalScrubbedText,
|
|
310
482
|
tokenMap: rawResult.tokenMap || {},
|
|
311
483
|
count: rawResult.count || 0,
|
|
312
484
|
executionMs: executionMs,
|
|
@@ -314,6 +486,7 @@ export function sanitize(text, options = {}) {
|
|
|
314
486
|
isPro: isPro,
|
|
315
487
|
tier: tier,
|
|
316
488
|
limit: limit,
|
|
489
|
+
wasTruncated: wasTruncated,
|
|
317
490
|
...(!isPro && {
|
|
318
491
|
upgradeUrl: 'https://privacyscrubber.com/pricing?utm_source=npm_sdk&utm_medium=sdk_telemetry&utm_campaign=dev_upsell',
|
|
319
492
|
recommendation: 'Upgrade to PRO, TEAMS, or Developer SDK for unlimited text processing, custom regex rules, and specialized compliance profiles.'
|
|
@@ -463,12 +636,34 @@ export class PrivacyScrubberEngine {
|
|
|
463
636
|
clearIgnoreList() {
|
|
464
637
|
this.ignoreList.clear();
|
|
465
638
|
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Creates a streaming token restorer bound to this engine's session map.
|
|
642
|
+
* @param {Record<string, string>} [tokenMap=null]
|
|
643
|
+
* @param {object} [options={}]
|
|
644
|
+
*/
|
|
645
|
+
createStreamRestorer(tokenMap = null, options = {}) {
|
|
646
|
+
const activeMap = tokenMap || this.sessionMap;
|
|
647
|
+
return createStreamTokenRestorer(activeMap, options);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Wraps an async iterable LLM stream with real-time de-tokenization.
|
|
652
|
+
* @param {AsyncIterable<any>} stream
|
|
653
|
+
* @param {Record<string, string>} [tokenMap=null]
|
|
654
|
+
* @param {object} [options={}]
|
|
655
|
+
*/
|
|
656
|
+
async* wrapStream(stream, tokenMap = null, options = {}) {
|
|
657
|
+
const activeMap = tokenMap || this.sessionMap;
|
|
658
|
+
yield* wrapAiStream(stream, activeMap, options);
|
|
659
|
+
}
|
|
466
660
|
}
|
|
467
661
|
|
|
468
662
|
/**
|
|
469
663
|
* Transparent Zero-Trust Wrapper for OpenAI Client.
|
|
470
664
|
* Automatically sanitizes outgoing messages before transmission to OpenAI,
|
|
471
665
|
* and restores completion tokens in the assistant response before returning!
|
|
666
|
+
* Seamlessly de-tokenizes streaming responses in real-time when stream: true.
|
|
472
667
|
*
|
|
473
668
|
* @param {object} openaiClient - Instance of OpenAI SDK (e.g. new OpenAI()).
|
|
474
669
|
* @param {object} [options={}] - PrivacyScrubber configuration.
|
|
@@ -500,6 +695,12 @@ export function wrapOpenAI(openaiClient, options = {}) {
|
|
|
500
695
|
const modifiedParams = { ...params, messages: sanitizedMessages };
|
|
501
696
|
const response = await originalCreate(modifiedParams, requestOptions);
|
|
502
697
|
|
|
698
|
+
// Streaming support: transparently de-tokenize async iterable chunks
|
|
699
|
+
if (params.stream) {
|
|
700
|
+
const detok = new StreamingDetokenizer(sessionTokenMap);
|
|
701
|
+
return detok.wrapStream(response);
|
|
702
|
+
}
|
|
703
|
+
|
|
503
704
|
if (response && Array.isArray(response.choices)) {
|
|
504
705
|
response.choices.forEach(choice => {
|
|
505
706
|
if (choice.message && typeof choice.message.content === 'string') {
|
|
@@ -526,88 +727,246 @@ export function createLangChainTransform(options = {}) {
|
|
|
526
727
|
}
|
|
527
728
|
|
|
528
729
|
/**
|
|
529
|
-
*
|
|
530
|
-
* Solves the broken chunk boundary problem where tokens like [EMAIL_1] are split across multiple chunks.
|
|
531
|
-
*
|
|
532
|
-
* Works with:
|
|
533
|
-
* - Vercel AI SDK (streamText / createDataStreamResponse)
|
|
534
|
-
* - OpenAI streaming (chat.completions.create({ stream: true }))
|
|
535
|
-
* - LangChain streaming
|
|
536
|
-
* - Native Web Streams (ReadableStream.pipeThrough)
|
|
537
|
-
* - Async generators (for await ... of)
|
|
538
|
-
*
|
|
539
|
-
* @param {Record<string, string>} tokenMap - Volatile token mapping object.
|
|
540
|
-
* @param {object} [options] - Configuration options.
|
|
541
|
-
* @returns {TransformStream & { process: (chunk: string) => string, flush: () => string, wrapStream: (asyncIterable: AsyncIterable<string>) => AsyncGenerator<string> }}
|
|
730
|
+
* Helper to escape special regex characters.
|
|
542
731
|
*/
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
732
|
+
function escapeRegex(s) {
|
|
733
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
734
|
+
}
|
|
546
735
|
|
|
547
|
-
|
|
736
|
+
/**
|
|
737
|
+
* Builds an ultra-fast streaming token replacement function.
|
|
738
|
+
* Preserves all whitespace, indentation, newlines, and code blocks (does NOT trim).
|
|
739
|
+
* Handles descending key ordering (e.g. [NAME_10] before [NAME_1]), case insensitivity,
|
|
740
|
+
* alternative delimiters (<...>, {{...}}, __...__), and possessive suffixes.
|
|
741
|
+
*
|
|
742
|
+
* @param {Record<string, string>} tokenMap
|
|
743
|
+
* @returns {(text: string) => string}
|
|
744
|
+
*/
|
|
745
|
+
export function buildStreamReplacer(tokenMap = {}) {
|
|
746
|
+
if (!tokenMap || typeof tokenMap !== 'object' || Object.keys(tokenMap).length === 0) {
|
|
747
|
+
return (text) => text || '';
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const rules = [];
|
|
751
|
+
const sortedKeys = Object.keys(tokenMap).sort((a, b) => b.length - a.length);
|
|
752
|
+
|
|
753
|
+
for (const k of sortedKeys) {
|
|
754
|
+
const val = tokenMap[k];
|
|
755
|
+
const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
|
|
756
|
+
const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
|
|
757
|
+
|
|
758
|
+
if (match) {
|
|
759
|
+
const label = escapeRegex(match[1]);
|
|
760
|
+
const idx = match[2];
|
|
761
|
+
const patternStr = '(?:\\[\\s*' + label + '[-_\\s]*0*' + idx + '\\s*\\]|<\\s*' + label + '[-_\\s]*0*' + idx + '\\s*>|\\{\\{\\s*' + label + '[-_\\s]*0*' + idx + '\\s*\\}\\}|__\\s*' + label + '[-_\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)(?:\'s|’s|s)?';
|
|
762
|
+
rules.push({
|
|
763
|
+
regex: new RegExp(patternStr, 'gi'),
|
|
764
|
+
val,
|
|
765
|
+
k
|
|
766
|
+
});
|
|
767
|
+
} else {
|
|
768
|
+
const safe = escapeRegex(k);
|
|
769
|
+
rules.push({
|
|
770
|
+
regex: new RegExp(safe + '(?:\'s|’s|s)?', 'gi'),
|
|
771
|
+
val,
|
|
772
|
+
k
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return function replaceTokens(text) {
|
|
548
778
|
if (!text) return '';
|
|
549
779
|
let result = text;
|
|
550
|
-
for (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
780
|
+
for (let i = 0; i < rules.length; i++) {
|
|
781
|
+
const rule = rules[i];
|
|
782
|
+
rule.regex.lastIndex = 0;
|
|
783
|
+
result = result.replace(rule.regex, (m) => {
|
|
784
|
+
const isPossessive = /('s|’s)$/i.test(m);
|
|
785
|
+
return rule.val + (isPossessive ? "'s" : '');
|
|
786
|
+
});
|
|
554
787
|
}
|
|
555
788
|
return result;
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Streaming Token Detokenizer with sliding window buffer for LLM chunk boundary splits.
|
|
794
|
+
* Seamlessly restores split tokens (e.g. [EM + AIL_1]), preserves multi-byte UTF-8,
|
|
795
|
+
* and maintains zero-latency streaming throughput.
|
|
796
|
+
*/
|
|
797
|
+
export class StreamingDetokenizer {
|
|
798
|
+
constructor(tokenMap = {}, options = {}) {
|
|
799
|
+
this.tokenMap = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
|
|
800
|
+
this.options = options || {};
|
|
801
|
+
this.replacer = buildStreamReplacer(this.tokenMap);
|
|
802
|
+
this.pendingBuffer = '';
|
|
803
|
+
this.hasTokens = Object.keys(this.tokenMap).length > 0;
|
|
804
|
+
this.decoder = new TextDecoder('utf-8', { fatal: false });
|
|
556
805
|
}
|
|
557
806
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
return directRestore(safeToEmit);
|
|
574
|
-
}
|
|
575
|
-
return '';
|
|
576
|
-
}
|
|
807
|
+
/**
|
|
808
|
+
* Feeds an incoming chunk (string, Uint8Array, or Buffer) and returns de-tokenized text.
|
|
809
|
+
* Buffers any unclosed token candidates at chunk boundaries until closed or flushed.
|
|
810
|
+
* @param {string | Uint8Array | Buffer} chunk
|
|
811
|
+
* @returns {string}
|
|
812
|
+
*/
|
|
813
|
+
feed(chunk) {
|
|
814
|
+
if (chunk === undefined || chunk === null) return '';
|
|
815
|
+
let text = '';
|
|
816
|
+
if (typeof chunk === 'string') {
|
|
817
|
+
text = chunk;
|
|
818
|
+
} else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
|
|
819
|
+
text = this.decoder.decode(chunk, { stream: true });
|
|
820
|
+
} else {
|
|
821
|
+
text = String(chunk);
|
|
577
822
|
}
|
|
578
823
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
824
|
+
if (!text) return '';
|
|
825
|
+
if (!this.hasTokens) return text;
|
|
826
|
+
|
|
827
|
+
this.pendingBuffer += text;
|
|
828
|
+
|
|
829
|
+
// Candidate unclosed token at tail of buffer (up to 64 chars)
|
|
830
|
+
// Matches incomplete tokens starting with [, <, {{, or __
|
|
831
|
+
const m = this.pendingBuffer.match(/(?:\[|<|\{\{|__)[A-Za-z0-9_\-А-Яа-яЁё ]{0,64}$/);
|
|
832
|
+
if (m) {
|
|
833
|
+
const safeToEmit = this.pendingBuffer.slice(0, m.index);
|
|
834
|
+
this.pendingBuffer = this.pendingBuffer.slice(m.index);
|
|
835
|
+
return safeToEmit ? this.replacer(safeToEmit) : '';
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const toEmit = this.pendingBuffer;
|
|
839
|
+
this.pendingBuffer = '';
|
|
840
|
+
return this.replacer(toEmit);
|
|
582
841
|
}
|
|
583
842
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
return
|
|
843
|
+
/**
|
|
844
|
+
* Alias for feed() to maintain backwards compatibility with process() callers.
|
|
845
|
+
*/
|
|
846
|
+
process(chunk) {
|
|
847
|
+
return this.feed(chunk);
|
|
589
848
|
}
|
|
590
849
|
|
|
591
|
-
|
|
850
|
+
/**
|
|
851
|
+
* Flushes remaining buffered tokens and finalizes UTF-8 stream decoder.
|
|
852
|
+
* @returns {string}
|
|
853
|
+
*/
|
|
854
|
+
flush() {
|
|
855
|
+
let text = '';
|
|
856
|
+
try {
|
|
857
|
+
text = this.decoder.decode(new Uint8Array(), { stream: false });
|
|
858
|
+
} catch (_) {}
|
|
859
|
+
|
|
860
|
+
if (text) {
|
|
861
|
+
this.pendingBuffer += text;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (!this.pendingBuffer) return '';
|
|
865
|
+
const remaining = this.pendingBuffer;
|
|
866
|
+
this.pendingBuffer = '';
|
|
867
|
+
return this.replacer(remaining);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Returns a native Node.js Transform stream instance for .pipe() / pipeline().
|
|
872
|
+
* @returns {Transform}
|
|
873
|
+
*/
|
|
874
|
+
toNodeTransform() {
|
|
875
|
+
const self = this;
|
|
876
|
+
return new Transform({
|
|
877
|
+
transform(chunk, encoding, callback) {
|
|
878
|
+
try {
|
|
879
|
+
const out = self.feed(chunk);
|
|
880
|
+
if (out) this.push(out);
|
|
881
|
+
callback();
|
|
882
|
+
} catch (err) {
|
|
883
|
+
callback(err);
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
flush(callback) {
|
|
887
|
+
try {
|
|
888
|
+
const out = self.flush();
|
|
889
|
+
if (out) this.push(out);
|
|
890
|
+
callback();
|
|
891
|
+
} catch (err) {
|
|
892
|
+
callback(err);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Wraps an async iterable (e.g. LLM streaming response) yielding restored chunks.
|
|
900
|
+
* @param {AsyncIterable<any>} asyncIterable
|
|
901
|
+
* @returns {AsyncGenerator<any>}
|
|
902
|
+
*/
|
|
903
|
+
async* wrapStream(asyncIterable) {
|
|
592
904
|
for await (const chunk of asyncIterable) {
|
|
593
|
-
|
|
594
|
-
|
|
905
|
+
if (chunk === null || chunk === undefined) continue;
|
|
906
|
+
// Handle OpenAI chunk object
|
|
907
|
+
if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]?.delta?.content !== undefined) {
|
|
908
|
+
const delta = chunk.choices[0].delta.content;
|
|
909
|
+
if (typeof delta === 'string') {
|
|
910
|
+
const restored = this.feed(delta);
|
|
911
|
+
if (restored) {
|
|
912
|
+
chunk.choices[0].delta.content = restored;
|
|
913
|
+
yield chunk;
|
|
914
|
+
}
|
|
915
|
+
} else {
|
|
916
|
+
yield chunk;
|
|
917
|
+
}
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let text = '';
|
|
922
|
+
if (typeof chunk === 'string') {
|
|
923
|
+
text = chunk;
|
|
924
|
+
} else if (typeof chunk?.text === 'string') {
|
|
925
|
+
text = chunk.text;
|
|
926
|
+
} else if (typeof chunk?.content === 'string') {
|
|
927
|
+
text = chunk.content;
|
|
928
|
+
} else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
|
|
929
|
+
text = chunk;
|
|
930
|
+
} else {
|
|
931
|
+
text = String(chunk);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const emitted = this.feed(text);
|
|
595
935
|
if (emitted) yield emitted;
|
|
596
936
|
}
|
|
597
|
-
|
|
937
|
+
|
|
938
|
+
const finalChunk = this.flush();
|
|
598
939
|
if (finalChunk) yield finalChunk;
|
|
599
940
|
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Creates a zero-latency streaming token restorer for LLM Server-Sent Events (SSE) and Web Streams.
|
|
945
|
+
* Solves the broken chunk boundary problem where tokens like [EMAIL_1] are split across multiple chunks.
|
|
946
|
+
*
|
|
947
|
+
* Works with:
|
|
948
|
+
* - Vercel AI SDK (streamText / createDataStreamResponse)
|
|
949
|
+
* - OpenAI streaming (chat.completions.create({ stream: true }))
|
|
950
|
+
* - LangChain streaming
|
|
951
|
+
* - Native Web Streams (ReadableStream.pipeThrough)
|
|
952
|
+
* - Async generators (for await ... of)
|
|
953
|
+
*
|
|
954
|
+
* @param {Record<string, string>} tokenMap - Volatile token mapping object.
|
|
955
|
+
* @param {object} [options] - Configuration options.
|
|
956
|
+
* @returns {TransformStream & { process: (chunk: string) => string, feed: (chunk: string) => string, flush: () => string, wrapStream: (asyncIterable: AsyncIterable<any>) => AsyncGenerator<any>, toNodeTransform: () => Transform, detokenizer: StreamingDetokenizer }}
|
|
957
|
+
*/
|
|
958
|
+
export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
|
|
959
|
+
const detok = new StreamingDetokenizer(tokenMap, options);
|
|
600
960
|
|
|
601
961
|
let stream;
|
|
602
962
|
if (typeof TransformStream !== 'undefined') {
|
|
603
963
|
stream = new TransformStream({
|
|
604
964
|
transform(chunk, controller) {
|
|
605
|
-
const
|
|
606
|
-
const out = processChunk(text);
|
|
965
|
+
const out = detok.feed(chunk);
|
|
607
966
|
if (out) controller.enqueue(out);
|
|
608
967
|
},
|
|
609
968
|
flush(controller) {
|
|
610
|
-
const out =
|
|
969
|
+
const out = detok.flush();
|
|
611
970
|
if (out) controller.enqueue(out);
|
|
612
971
|
}
|
|
613
972
|
});
|
|
@@ -615,24 +974,38 @@ export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
|
|
|
615
974
|
stream = {};
|
|
616
975
|
}
|
|
617
976
|
|
|
618
|
-
stream.process =
|
|
619
|
-
stream.
|
|
620
|
-
stream.
|
|
977
|
+
stream.process = (chunk) => detok.feed(chunk);
|
|
978
|
+
stream.feed = (chunk) => detok.feed(chunk);
|
|
979
|
+
stream.flush = () => detok.flush();
|
|
980
|
+
stream.wrapStream = (asyncIterable) => detok.wrapStream(asyncIterable);
|
|
981
|
+
stream.toNodeTransform = () => detok.toNodeTransform();
|
|
982
|
+
stream.detokenizer = detok;
|
|
621
983
|
|
|
622
984
|
return stream;
|
|
623
985
|
}
|
|
624
986
|
|
|
987
|
+
/**
|
|
988
|
+
* Creates a Node.js native Transform stream for backend streaming pipelines (stream.pipeline / .pipe()).
|
|
989
|
+
* @param {Record<string, string>} tokenMap - Volatile token mapping object.
|
|
990
|
+
* @param {object} [options] - Configuration options.
|
|
991
|
+
* @returns {Transform}
|
|
992
|
+
*/
|
|
993
|
+
export function createNodeStreamTokenRestorer(tokenMap = {}, options = {}) {
|
|
994
|
+
const detok = new StreamingDetokenizer(tokenMap, options);
|
|
995
|
+
return detok.toNodeTransform();
|
|
996
|
+
}
|
|
997
|
+
|
|
625
998
|
/**
|
|
626
999
|
* Wraps an async iterable LLM stream (e.g. from Vercel AI SDK textStream or OpenAI stream)
|
|
627
1000
|
* and yields de-tokenized text chunks in real-time, seamlessly stitching split tokens across chunks.
|
|
628
1001
|
*
|
|
629
1002
|
* @param {AsyncIterable<string|object>} stream - The incoming LLM stream.
|
|
630
1003
|
* @param {Record<string, string>} tokenMap - Volatile token map.
|
|
631
|
-
* @returns {AsyncGenerator<
|
|
1004
|
+
* @returns {AsyncGenerator<any>}
|
|
632
1005
|
*/
|
|
633
1006
|
export async function* wrapAiStream(stream, tokenMap = {}) {
|
|
634
|
-
const
|
|
635
|
-
yield*
|
|
1007
|
+
const detok = new StreamingDetokenizer(tokenMap);
|
|
1008
|
+
yield* detok.wrapStream(stream);
|
|
636
1009
|
}
|
|
637
1010
|
|
|
638
1011
|
/**
|
|
@@ -1156,11 +1529,15 @@ export default {
|
|
|
1156
1529
|
buildAuditTelemetry,
|
|
1157
1530
|
formatAuditReceipt,
|
|
1158
1531
|
generateAuditReport,
|
|
1532
|
+
generateProcurementMemo,
|
|
1159
1533
|
PrivacyScrubberEngine,
|
|
1160
1534
|
PrivacyScrubberLicenseError,
|
|
1161
1535
|
wrapOpenAI,
|
|
1162
1536
|
createLangChainTransform,
|
|
1537
|
+
StreamingDetokenizer,
|
|
1163
1538
|
createStreamTokenRestorer,
|
|
1539
|
+
createNodeStreamTokenRestorer,
|
|
1540
|
+
buildStreamReplacer,
|
|
1164
1541
|
wrapAiStream,
|
|
1165
1542
|
withPrivacyScrubber,
|
|
1166
1543
|
createExpressMiddleware,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@privacyscrubber/sdk",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "Zero-dependency, in-memory PII & secrets redaction SDK for Node.js, Next.js, OpenAI, and LangChain. 100% client-side & server-side zero-trust prompt anonymization under 1ms.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|