@st-gr/sail-proxy 0.9.3 → 0.9.4

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.
Files changed (79) hide show
  1. package/bundled/gateway/node_modules/.package-lock.json +17 -17
  2. package/bundled/gateway/node_modules/form-data/CHANGELOG.md +29 -2
  3. package/bundled/gateway/node_modules/form-data/README.md +4 -4
  4. package/bundled/gateway/node_modules/form-data/lib/form_data.js +14 -2
  5. package/bundled/gateway/node_modules/form-data/package.json +7 -7
  6. package/bundled/gateway/node_modules/qs/CHANGELOG.md +16 -0
  7. package/bundled/gateway/node_modules/qs/README.md +1 -1
  8. package/bundled/gateway/node_modules/qs/dist/qs.js +15 -15
  9. package/bundled/gateway/node_modules/qs/eslint.config.mjs +1 -0
  10. package/bundled/gateway/node_modules/qs/lib/parse.js +54 -24
  11. package/bundled/gateway/node_modules/qs/lib/stringify.js +11 -4
  12. package/bundled/gateway/node_modules/qs/package.json +3 -3
  13. package/bundled/gateway/node_modules/qs/test/parse.js +135 -0
  14. package/bundled/gateway/node_modules/qs/test/stringify.js +138 -0
  15. package/bundled/gateway/node_modules/qs/test/utils.js +31 -3
  16. package/bundled/gateway/node_modules/semver/classes/range.js +17 -4
  17. package/bundled/gateway/node_modules/semver/package.json +2 -2
  18. package/bundled/gateway/node_modules/side-channel/CHANGELOG.md +10 -0
  19. package/bundled/gateway/node_modules/side-channel/README.md +1 -1
  20. package/bundled/gateway/node_modules/side-channel/index.js +5 -2
  21. package/bundled/gateway/node_modules/side-channel/package.json +10 -10
  22. package/bundled/gateway/node_modules/side-channel/test/index.js +16 -0
  23. package/bundled/gateway/package-lock.json +22 -22
  24. package/bundled/gateway/package.json +13 -5
  25. package/bundled/gateway/services/gateway/src/controllers/anthropicController.js +1 -1
  26. package/bundled/gateway/services/gateway/src/controllers/awsBedrockController.js +5 -1
  27. package/bundled/gateway/services/gateway/src/index.js +14 -0
  28. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/detectors/index.js +23 -1
  29. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/detectors/regexDetectors.js +149 -10
  30. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/entityToggles.d.ts +24 -0
  31. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/entityToggles.js +54 -0
  32. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/index.d.ts +2 -1
  33. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/index.js +441 -92
  34. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacementMap.d.ts +24 -0
  35. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacementMap.js +93 -4
  36. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacer.d.ts +22 -0
  37. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacer.js +77 -0
  38. package/bundled/gateway/services/gateway/src/plugins/pseudonymization/streamBuffer.js +26 -8
  39. package/bundled/gateway/services/gateway/src/services/awsBedrockService.js +118 -46
  40. package/bundled/gateway/services/gateway/src/services/betaFlagQuarantine.d.ts +18 -0
  41. package/bundled/gateway/services/gateway/src/services/betaFlagQuarantine.js +111 -0
  42. package/bundled/gateway/services/gateway/src/services/configService.d.ts +8 -0
  43. package/bundled/gateway/services/gateway/src/services/configService.js +61 -53
  44. package/bundled/gateway/services/gateway/src/utils/bedrockStreamParser.d.ts +9 -0
  45. package/bundled/gateway/services/gateway/src/utils/bedrockStreamParser.js +69 -3
  46. package/bundled/gateway/services/gateway/src/utils/betaFeatureFilter.d.ts +31 -0
  47. package/bundled/gateway/services/gateway/src/utils/betaFeatureFilter.js +58 -0
  48. package/bundled/gateway/services/gateway/src/utils/payloadLogger.d.ts +6 -0
  49. package/bundled/gateway/services/gateway/src/utils/payloadLogger.js +11 -7
  50. package/bundled/gateway/services/gateway/src/utils/pluginCacheSelector.d.ts +15 -0
  51. package/bundled/gateway/services/gateway/src/utils/pluginCacheSelector.js +24 -0
  52. package/bundled/gateway/services/gateway/src/utils/sseWriter.js +4 -2
  53. package/bundled/gateway/services/gateway/src/utils/upstreamErrorBody.d.ts +10 -0
  54. package/bundled/gateway/services/gateway/src/utils/upstreamErrorBody.js +71 -0
  55. package/bundled/ollama/node_modules/.package-lock.json +14 -14
  56. package/bundled/ollama/node_modules/form-data/CHANGELOG.md +29 -2
  57. package/bundled/ollama/node_modules/form-data/README.md +4 -4
  58. package/bundled/ollama/node_modules/form-data/lib/form_data.js +14 -2
  59. package/bundled/ollama/node_modules/form-data/package.json +7 -7
  60. package/bundled/ollama/node_modules/qs/CHANGELOG.md +16 -0
  61. package/bundled/ollama/node_modules/qs/README.md +1 -1
  62. package/bundled/ollama/node_modules/qs/dist/qs.js +15 -15
  63. package/bundled/ollama/node_modules/qs/eslint.config.mjs +1 -0
  64. package/bundled/ollama/node_modules/qs/lib/parse.js +54 -24
  65. package/bundled/ollama/node_modules/qs/lib/stringify.js +11 -4
  66. package/bundled/ollama/node_modules/qs/package.json +3 -3
  67. package/bundled/ollama/node_modules/qs/test/parse.js +135 -0
  68. package/bundled/ollama/node_modules/qs/test/stringify.js +138 -0
  69. package/bundled/ollama/node_modules/qs/test/utils.js +31 -3
  70. package/bundled/ollama/node_modules/side-channel/CHANGELOG.md +10 -0
  71. package/bundled/ollama/node_modules/side-channel/README.md +1 -1
  72. package/bundled/ollama/node_modules/side-channel/index.js +5 -2
  73. package/bundled/ollama/node_modules/side-channel/package.json +10 -10
  74. package/bundled/ollama/node_modules/side-channel/test/index.js +16 -0
  75. package/bundled/ollama/package-lock.json +15 -15
  76. package/bundled/ollama/package.json +13 -5
  77. package/dist/templates/api_config.template.json +266 -18
  78. package/node_modules/@sap-llm-gateway/service-key-parser/package.json +1 -1
  79. package/package.json +2 -2
@@ -16,11 +16,14 @@
16
16
  *
17
17
  * Strategies: before + after + stream
18
18
  *
19
- * @see pseudonymization-proxy-prompt.md - Full specification
19
+ * @see ../pseudonymization.md - Usage, activation methods, and category configuration
20
+ * @see ../../../../../docs/security/pseudonymization-security-assessment.md - Security assessment
20
21
  */
22
+ const logger_1 = require("@libs/logger");
21
23
  const replacementMap_1 = require("./replacementMap");
22
24
  const detectors_1 = require("./detectors");
23
25
  const replacer_1 = require("./replacer");
26
+ const entityToggles_1 = require("./entityToggles");
24
27
  const unmasker_1 = require("./unmasker");
25
28
  const streamBuffer_1 = require("./streamBuffer");
26
29
  const entityCache_1 = require("./entityCache");
@@ -75,6 +78,12 @@ function extractResponseText(response) {
75
78
  if (response?.final_result?.choices?.[0]?.delta?.content) {
76
79
  return response.final_result.choices[0].delta.content;
77
80
  }
81
+ // Anthropic native streaming: content_block_delta with text_delta
82
+ if (response?.type === 'content_block_delta'
83
+ && response?.delta?.type === 'text_delta'
84
+ && typeof response.delta.text === 'string') {
85
+ return response.delta.text;
86
+ }
78
87
  // Anthropic format
79
88
  if (response?.content && Array.isArray(response.content)) {
80
89
  for (const block of response.content) {
@@ -101,6 +110,11 @@ function setResponseText(response, newText) {
101
110
  else if (response?.final_result?.choices?.[0]?.delta?.content !== undefined) {
102
111
  response.final_result.choices[0].delta.content = newText;
103
112
  }
113
+ else if (response?.type === 'content_block_delta'
114
+ && response?.delta?.type === 'text_delta'
115
+ && response.delta.text !== undefined) {
116
+ response.delta.text = newText;
117
+ }
104
118
  else if (response?.content && Array.isArray(response.content)) {
105
119
  for (const block of response.content) {
106
120
  if (block.type === 'text') {
@@ -135,6 +149,10 @@ const DEFAULT_MASKING_CONFIG = {
135
149
  { type: 'profile-url' },
136
150
  { type: 'profile-address' },
137
151
  { type: 'profile-username-password' },
152
+ { type: 'profile-nationalid' },
153
+ { type: 'profile-passport' },
154
+ { type: 'profile-driverlicense' },
155
+ { type: 'profile-pronouns-gender' },
138
156
  { type: 'profile-nationality' },
139
157
  { type: 'profile-ethnicity' },
140
158
  { type: 'profile-gender' },
@@ -146,6 +164,38 @@ const DEFAULT_MASKING_CONFIG = {
146
164
  { type: 'profile-location' },
147
165
  ],
148
166
  };
167
+ // All category types the pseudonymizationPlugin understands, for toggle validation.
168
+ const KNOWN_ENTITY_TYPES = (0, entityToggles_1.buildKnownEntityTypes)(DEFAULT_MASKING_CONFIG.entities);
169
+ /**
170
+ * Resolve the DEFAULT entity set for this request by layering api_config.json
171
+ * toggles over DEFAULT_MASKING_CONFIG: global (api_config.pseudonymization.entities)
172
+ * → per-endpoint (defaultHooks[endpoint].pseudonymization.entities) → per-model
173
+ * (model_list_changes[model].pseudonymization.entities); later layers win.
174
+ * Reads dynamic config per request (hot in distributed mode; standalone requires
175
+ * a restart, as for all plugin config). Falls back to the code defaults on any error.
176
+ */
177
+ function resolveDefaultEntities(req) {
178
+ try {
179
+ const configService = require('../../services/configService').default || require('../../services/configService');
180
+ const apiConfig = configService.getConfig()?.api_config;
181
+ if (!apiConfig)
182
+ return DEFAULT_MASKING_CONFIG.entities;
183
+ let entities = (0, entityToggles_1.applyEntityToggles)(DEFAULT_MASKING_CONFIG.entities, apiConfig.pseudonymization?.entities, KNOWN_ENTITY_TYPES);
184
+ const endpoint = req?.__endpoint;
185
+ if (endpoint) {
186
+ entities = (0, entityToggles_1.applyEntityToggles)(entities, apiConfig.defaultHooks?.[endpoint]?.pseudonymization?.entities, KNOWN_ENTITY_TYPES);
187
+ }
188
+ const modelName = req?.body?.model;
189
+ if (modelName) {
190
+ const substituted = configService.getSubstitutedModel('anthropic', modelName) || modelName;
191
+ entities = (0, entityToggles_1.applyEntityToggles)(entities, apiConfig.model_list_changes?.[substituted]?.pseudonymization?.entities, KNOWN_ENTITY_TYPES);
192
+ }
193
+ return entities;
194
+ }
195
+ catch {
196
+ return DEFAULT_MASKING_CONFIG.entities;
197
+ }
198
+ }
149
199
  /**
150
200
  * Scan messages for triggerword, strip it, and return masking config if found
151
201
  */
@@ -197,7 +247,7 @@ function scanAndStripTriggerword(req) {
197
247
  }
198
248
  if (!found)
199
249
  return null;
200
- return { ...DEFAULT_MASKING_CONFIG, method: found };
250
+ return { ...DEFAULT_MASKING_CONFIG, entities: resolveDefaultEntities(req), method: found };
201
251
  }
202
252
  /**
203
253
  * Check if the model has pseudonymization forced on via api_config.json.
@@ -217,7 +267,7 @@ function getModelForcedConfig(req) {
217
267
  if (modelConfig?.pseudonymization?.enabled) {
218
268
  const method = modelConfig.pseudonymization.method || 'pseudonymization';
219
269
  const allowBypass = modelConfig.pseudonymization.allow_user_bypass === true;
220
- return { config: { ...DEFAULT_MASKING_CONFIG, method }, allowBypass, source: 'model' };
270
+ return { config: { ...DEFAULT_MASKING_CONFIG, entities: resolveDefaultEntities(req), method }, allowBypass, source: 'model' };
221
271
  }
222
272
  }
223
273
  // Per-endpoint force flag (defaultHooks[endpoint].pseudonymization.enabled)
@@ -227,7 +277,7 @@ function getModelForcedConfig(req) {
227
277
  if (endpointConfig?.pseudonymization?.enabled) {
228
278
  const method = endpointConfig.pseudonymization.method || 'pseudonymization';
229
279
  const allowBypass = endpointConfig.pseudonymization.allow_user_bypass === true;
230
- return { config: { ...DEFAULT_MASKING_CONFIG, method }, allowBypass, source: 'endpoint' };
280
+ return { config: { ...DEFAULT_MASKING_CONFIG, entities: resolveDefaultEntities(req), method }, allowBypass, source: 'endpoint' };
231
281
  }
232
282
  }
233
283
  return null;
@@ -299,6 +349,27 @@ async function beforeHandler({ req, res, utils }) {
299
349
  const allEntities = [];
300
350
  const maskedInputs = [];
301
351
  const messages = req.body.messages || [];
352
+ // Residue guard: the incoming request may contain literal MASKED_* tokens from
353
+ // earlier turns/sessions. With content-derived (hash) placeholders these resolve
354
+ // on their own whenever the underlying value is present in this request (the same
355
+ // value re-mints the identical token). Legacy NUMERIC residue (from the old
356
+ // counter scheme) can never be re-minted; reserve those numbers so anonymization
357
+ // counters cannot collide with them, and log the residue for visibility.
358
+ try {
359
+ const rawBody = JSON.stringify(req.body);
360
+ const residue = new Set(rawBody.match(/MASKED_[A-Z_]+_[0-9a-f]+|(?:https?:\/\/)?masked-url-\d+\.invalid/g) || []);
361
+ if (residue.size > 0) {
362
+ for (const token of residue) {
363
+ const m = token.match(/^(MASKED_[A-Z_]+)_(\d+)$/);
364
+ // Cap the reservation so a digits-only hash id cannot blow up the counters.
365
+ if (m && m[2].length <= 6)
366
+ map.reserveMinCount(m[1], parseInt(m[2], 10));
367
+ }
368
+ const sample = Array.from(residue).slice(0, 10).join(', ');
369
+ logger.info(`Request contains ${residue.size} masked token(s) carried in from earlier turns (hash-stable tokens resolve automatically when their value is present): ${sample}${residue.size > 10 ? ', …' : ''}`);
370
+ }
371
+ }
372
+ catch { /* non-fatal: residue guard is best-effort */ }
302
373
  // Process system messages (Anthropic format)
303
374
  if (Array.isArray(req.body.system)) {
304
375
  for (let i = 0; i < req.body.system.length; i++) {
@@ -344,6 +415,45 @@ async function beforeHandler({ req, res, utils }) {
344
415
  }
345
416
  }
346
417
  }
418
+ // Value-consistency propagation: mask every remaining exact occurrence of each
419
+ // detected value across the request, including nodes where no detector fired
420
+ // (e.g. the same secret riding through in an `Authorization: Bearer` header).
421
+ // Prevents the model from seeing the raw value alongside its mask.
422
+ const propagated = (0, replacer_1.propagateMaskedValues)(req.body, map);
423
+ // Tell the model to treat placeholder tokens as opaque and copy them verbatim.
424
+ // A garbled id cannot be unmasked (the value is unrecoverable), so copy fidelity
425
+ // is part of the design; observed in practice: models occasionally rewrite ids
426
+ // when reproducing them in generated files unless explicitly instructed not to.
427
+ if (maskingConfig.method === 'pseudonymization' && map.size > 0) {
428
+ const copyNote = 'Some values in this conversation are pseudonymized as placeholder tokens of the form '
429
+ + 'MASKED_<TYPE>_<id>. Treat each token as an opaque identifier: when you refer to a masked value, '
430
+ + 'copy its token EXACTLY, character for character. Never invent, alter, shorten, or merge token ids. '
431
+ + 'Only use MASKED_* tokens that appear verbatim in this conversation — NEVER invent, guess, or '
432
+ + 'construct a new MASKED_* token or id under any circumstances; a token you did not see in the input '
433
+ + 'is meaningless and will break the user\'s workflow. If a value you need appears unmasked in the '
434
+ + 'conversation, use that unmasked value as-is; do not create a token for it. '
435
+ + 'Masked URLs appear as hosts like https://masked-url-<id>.invalid — you MAY append paths and query '
436
+ + 'parameters to such a host to form new URLs, but never invent a masked-url host that is not present '
437
+ + 'in the conversation.';
438
+ if (Array.isArray(req.body.system)) {
439
+ req.body.system.push({ type: 'text', text: copyNote });
440
+ }
441
+ else if (typeof req.body.system === 'string') {
442
+ req.body.system = `${req.body.system}\n\n${copyNote}`;
443
+ }
444
+ else if (req.body.system === undefined || req.body.system === null) {
445
+ req.body.system = copyNote;
446
+ }
447
+ }
448
+ // Capture the masking_info debug opt-in before the config is stripped. The
449
+ // full masked-input diagnostic is off by default for pseudonymization (it
450
+ // bloats every client response); attach it only when explicitly requested via
451
+ // `masking.debug: true` or the out-of-band `x-sail-proxy-masking-debug: on`
452
+ // header (same channel pattern as the bypass header).
453
+ const maskingDebug = req.body?.masking?.debug === true
454
+ || (typeof req.headers?.['x-sail-proxy-masking-debug'] === 'string'
455
+ && req.headers['x-sail-proxy-masking-debug'].toLowerCase() === 'on');
456
+ req.__pseudonymizationDebug = maskingDebug;
347
457
  // Remove the masking config from the body before forwarding upstream
348
458
  delete req.body.masking;
349
459
  // Store state on request for after/stream handlers
@@ -362,7 +472,7 @@ async function beforeHandler({ req, res, utils }) {
362
472
  if (maskingConfig.method === 'pseudonymization' && res) {
363
473
  installSseUnmaskInterceptor(req, res, map);
364
474
  }
365
- logger.info(`Masked ${allEntities.length} entities (${map.size} unique) across ${messages.length} messages`);
475
+ logger.info(`Masked ${allEntities.length} entities (${map.size} unique) across ${messages.length} messages; propagated ${propagated} additional occurrence(s)`);
366
476
  // Async: store detected entities in learned cache (non-blocking)
367
477
  if (allEntities.length > 0) {
368
478
  const toCache = allEntities
@@ -417,7 +527,11 @@ async function afterHandler({ req, upstreamResponse, utils }) {
417
527
  logger.debug(`Unmasked response text (${responseText.length} → ${unmasked.length} chars)`);
418
528
  }
419
529
  unmaskToolBlocks(upstreamResponse, map);
420
- return attachMaskingInfo(upstreamResponse, state);
530
+ // Residue audit (the non-streaming path had none — this is the incident path).
531
+ // Scan the fully-unmasked client-facing body for surviving MASKED_* tokens and
532
+ // log per-token detail plus one aggregated, grep-stable counter line.
533
+ auditResponseResidue(upstreamResponse, map, req?.debugRequestId || 'unknown', logger);
534
+ return maybeAttachMaskingInfo(upstreamResponse, state, req);
421
535
  }
422
536
  catch (error) {
423
537
  logger.error(`Error in pseudonymization after handler: ${error.message}`, { stack: error.stack });
@@ -430,119 +544,292 @@ async function afterHandler({ req, upstreamResponse, utils }) {
430
544
  * directly without going through the after-plugin chain per chunk (e.g., AWS Bedrock
431
545
  * native streaming via BedrockStreamParser).
432
546
  *
433
- * Each event is an `event: <name>\ndata: <json>\n\n` block written via a single res.write.
434
- * We parse, mutate input_json_delta.partial_json and tool_calls.function.arguments deltas
435
- * through per-block StreamUnmaskBuffer instances, and re-serialize.
547
+ * The patched write re-frames arbitrary write boundaries into complete SSE blocks
548
+ * (`[event: <name>\n]data: <json>\n\n`), holding a partial tail across writes, so it
549
+ * also covers pipelines that pipe multiple or partial events per write (e.g. the
550
+ * converse-stream direct passthrough). Assistant text (text_delta), tool input
551
+ * (input_json_delta), and OpenAI tool_calls argument deltas are unmasked through
552
+ * per-content-block StreamUnmaskBuffer instances; retained fragments are flushed on
553
+ * content_block_stop / message_stop / finish_reason / res.end.
554
+ *
555
+ * Diagnostics: when a completed block still contains MASKED_* placeholders, an error
556
+ * (token was in the map — a genuine unmask miss) or warning (token unknown — residue
557
+ * leaked into the conversation by an earlier session) is logged, so leaks are never
558
+ * silent again.
436
559
  */
437
560
  function installSseUnmaskInterceptor(req, res, map) {
438
561
  if (res.__pseudoSseInterceptorInstalled)
439
562
  return;
563
+ if (typeof res.write !== 'function' || typeof res.end !== 'function')
564
+ return;
440
565
  res.__pseudoSseInterceptorInstalled = true;
441
- const toolBuffers = new Map();
566
+ // Patch res.json so NON-streaming responses that bypass res.write are still unmasked.
567
+ // The awsBedrockResponseCache plugin serves non-streaming cache hits via res.status().json()
568
+ // and returns { stop: true }, which short-circuits the after-handler AND never touches
569
+ // res.write — so without this, cached placeholders reach the client masked. The cache stores
570
+ // the masked upstream response (pseudonymization unmasks at the wire, after caching); for an
571
+ // identical request the placeholder numbers are deterministic, so this request's map resolves
572
+ // them. Tokens not in the map (residue) are left untouched by unmaskText.
573
+ const logger = (0, logger_1.getDefaultLogger)();
574
+ if (typeof res.json === 'function') {
575
+ const originalJson = res.json.bind(res);
576
+ res.json = function patchedJson(body) {
577
+ try {
578
+ if (body && typeof body === 'object' && map.reverse.size > 0) {
579
+ const serialized = JSON.stringify(body);
580
+ if (serialized.includes('MASKED_')) {
581
+ const unmasked = (0, unmasker_1.unmaskText)(serialized, map);
582
+ if (unmasked !== serialized) {
583
+ body = JSON.parse(unmasked);
584
+ logger.debug('Pseudonymization', `Unmasked non-streaming JSON response body (requestId=${req?.debugRequestId || 'unknown'})`);
585
+ }
586
+ }
587
+ }
588
+ }
589
+ catch { /* fall through with original body */ }
590
+ return originalJson(body);
591
+ };
592
+ }
593
+ const requestId = req?.debugRequestId || 'unknown';
594
+ const buffers = new Map();
442
595
  const getBuf = (key) => {
443
- let b = toolBuffers.get(key);
596
+ let b = buffers.get(key);
444
597
  if (!b) {
445
598
  b = new streamBuffer_1.StreamUnmaskBuffer(map);
446
- toolBuffers.set(key, b);
599
+ buffers.set(key, b);
447
600
  }
448
601
  return b;
449
602
  };
450
- const originalWrite = res.write.bind(res);
451
- // Patch res.write. The Express Response.write signature has overloads; we accept any.
452
- res.write = function patchedWrite(chunk, ...args) {
453
- try {
454
- const str = typeof chunk === 'string' ? chunk : (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : null);
455
- if (!str)
456
- return originalWrite(chunk, ...args);
457
- // Two SSE formats are emitted by this gateway:
458
- // 1) "event: <name>\ndata: <json>\n\n" (writeEventStream)
459
- // 2) "data: <json>\n\n" (writeChunk — used by BedrockStreamParser)
460
- let eventName = null;
461
- let jsonStr = null;
462
- const m1 = str.match(/^event: ([^\n]+)\ndata: (.+)\n\n$/s);
463
- if (m1) {
464
- eventName = m1[1];
465
- jsonStr = m1[2];
603
+ // Per-block emitted content, used only for the leak audit below.
604
+ const emitted = new Map();
605
+ const track = (key, s) => {
606
+ if (s)
607
+ emitted.set(key, (emitted.get(key) || '') + s);
608
+ };
609
+ const auditBlock = (key) => {
610
+ const content = emitted.get(key);
611
+ emitted.delete(key);
612
+ if (!content)
613
+ return;
614
+ for (const token of new Set(content.match(/MASKED_[A-Z_]+_[0-9a-f]+|(?:https?:\/\/)?masked-url-\d+\.invalid/g) || [])) {
615
+ if (map.reverse.has(token)) {
616
+ logger.error('Pseudonymization', `Unmask miss on streaming block ${key}: ${token} was in the replacement map but reached the client masked (requestId=${requestId})`);
466
617
  }
467
618
  else {
468
- const m2 = str.match(/^data: (.+)\n\n$/s);
469
- if (m2) {
470
- jsonStr = m2[1];
471
- }
619
+ logger.warn('Pseudonymization', `Unresolvable masked residue in streaming block ${key}: ${token} is not in this request's map — its value is absent from this request (leaked by an earlier session) (requestId=${requestId})`);
472
620
  }
473
- if (!jsonStr)
474
- return originalWrite(chunk, ...args);
475
- let event;
476
- try {
477
- event = JSON.parse(jsonStr);
621
+ }
622
+ };
623
+ const originalWrite = res.write.bind(res);
624
+ const originalEnd = res.end.bind(res);
625
+ // Partial SSE block carried across write() calls (re-framing buffer).
626
+ let pending = '';
627
+ // Process one complete SSE block; returns the (possibly rewritten) block.
628
+ const processBlock = (block) => {
629
+ // Two SSE formats are emitted by this gateway:
630
+ // 1) "event: <name>\ndata: <json>\n\n" (writeEventStream)
631
+ // 2) "data: <json>\n\n" (writeChunk — used by BedrockStreamParser)
632
+ let eventName = null;
633
+ let jsonStr = null;
634
+ const m1 = block.match(/^event: ([^\n]+)\ndata: (.+)\n\n$/s);
635
+ if (m1) {
636
+ eventName = m1[1];
637
+ jsonStr = m1[2];
638
+ }
639
+ else {
640
+ const m2 = block.match(/^data: (.+)\n\n$/s);
641
+ if (m2) {
642
+ jsonStr = m2[1];
478
643
  }
479
- catch {
480
- return originalWrite(chunk, ...args);
644
+ }
645
+ if (!jsonStr)
646
+ return block;
647
+ let event;
648
+ try {
649
+ event = JSON.parse(jsonStr);
650
+ }
651
+ catch {
652
+ return block;
653
+ }
654
+ let modified = false;
655
+ let syntheticPrefix = '';
656
+ // Serialize a synthetic delta in the same format as the surrounding stream,
657
+ // emitted BEFORE the current event for correct ordering.
658
+ const synth = (idx, delta) => {
659
+ const ev = { type: 'content_block_delta', index: idx, delta };
660
+ const prefix = eventName ? 'event: content_block_delta\n' : '';
661
+ return `${prefix}data: ${JSON.stringify(ev)}\n\n`;
662
+ };
663
+ // Anthropic content_block_delta with text_delta (assistant prose)
664
+ if (event.type === 'content_block_delta'
665
+ && event.delta?.type === 'text_delta'
666
+ && typeof event.delta.text === 'string') {
667
+ const idx = event.index ?? 0;
668
+ const buf = getBuf(`text:${idx}`);
669
+ event.delta.text = buf.append(event.delta.text);
670
+ track(`text:${idx}`, event.delta.text);
671
+ modified = true;
672
+ }
673
+ // Anthropic content_block_delta with input_json_delta (tool call input)
674
+ if (event.type === 'content_block_delta'
675
+ && event.delta?.type === 'input_json_delta'
676
+ && typeof event.delta.partial_json === 'string') {
677
+ const idx = event.index ?? 0;
678
+ const buf = getBuf(`tool_use:${idx}`);
679
+ event.delta.partial_json = buf.append(event.delta.partial_json);
680
+ track(`tool_use:${idx}`, event.delta.partial_json);
681
+ modified = true;
682
+ }
683
+ // Anthropic content_block_stop — flush retained suffixes of this block's buffers
684
+ // as synthetic deltas emitted before the stop, then audit the block for leaks.
685
+ if (event.type === 'content_block_stop') {
686
+ const idx = event.index ?? 0;
687
+ const textBuf = buffers.get(`text:${idx}`);
688
+ if (textBuf) {
689
+ const remainder = textBuf.flush();
690
+ if (remainder) {
691
+ syntheticPrefix += synth(idx, { type: 'text_delta', text: remainder });
692
+ track(`text:${idx}`, remainder);
693
+ }
694
+ buffers.delete(`text:${idx}`);
481
695
  }
482
- let modified = false;
483
- const extraWrites = [];
484
- // Anthropic content_block_delta with input_json_delta
485
- if (event.type === 'content_block_delta'
486
- && event.delta?.type === 'input_json_delta'
487
- && typeof event.delta.partial_json === 'string') {
488
- const idx = event.index ?? 0;
489
- const buf = getBuf(`tool_use:${idx}`);
490
- event.delta.partial_json = buf.append(event.delta.partial_json);
491
- modified = true;
492
- }
493
- // Anthropic content_block_stop — flush any retained suffix as a synthetic delta
494
- // emitted before the stop.
495
- if (event.type === 'content_block_stop') {
496
- const idx = event.index ?? 0;
497
- const buf = toolBuffers.get(`tool_use:${idx}`);
498
- if (buf) {
499
- const remainder = buf.flush();
500
- if (remainder) {
501
- const synthetic = {
502
- type: 'content_block_delta',
503
- index: idx,
504
- delta: { type: 'input_json_delta', partial_json: remainder },
505
- };
506
- // Match the source format (event: prefix only when present in the original write)
507
- const prefix = eventName ? `event: content_block_delta\n` : '';
508
- extraWrites.push(`${prefix}data: ${JSON.stringify(synthetic)}\n\n`);
509
- }
510
- toolBuffers.delete(`tool_use:${idx}`);
696
+ const toolBuf = buffers.get(`tool_use:${idx}`);
697
+ if (toolBuf) {
698
+ const remainder = toolBuf.flush();
699
+ if (remainder) {
700
+ syntheticPrefix += synth(idx, { type: 'input_json_delta', partial_json: remainder });
701
+ track(`tool_use:${idx}`, remainder);
511
702
  }
703
+ buffers.delete(`tool_use:${idx}`);
512
704
  }
513
- // OpenAI streaming via SSE (less common with this controller, but handle just in case)
514
- if (Array.isArray(event?.choices?.[0]?.delta?.tool_calls)) {
515
- const finishReason = event?.choices?.[0]?.finish_reason;
516
- for (const call of event.choices[0].delta.tool_calls) {
517
- if (typeof call?.function?.arguments === 'string') {
518
- const idx = call.index ?? 0;
519
- const buf = getBuf(`oai_tool_call:${idx}`);
520
- if (finishReason) {
521
- call.function.arguments = buf.append(call.function.arguments) + buf.flush();
522
- toolBuffers.delete(`oai_tool_call:${idx}`);
523
- }
524
- else {
525
- call.function.arguments = buf.append(call.function.arguments);
526
- }
527
- modified = true;
705
+ auditBlock(`text:${idx}`);
706
+ auditBlock(`tool_use:${idx}`);
707
+ }
708
+ // Anthropic message_stop — safety flush of ALL remaining buffers (Anthropic streams
709
+ // have no choices[].finish_reason, so anything not flushed by a stop lands here).
710
+ if (event.type === 'message_stop') {
711
+ for (const [key, buf] of Array.from(buffers.entries())) {
712
+ const remainder = buf.flush();
713
+ if (remainder) {
714
+ const idx = parseInt(key.split(':')[1], 10) || 0;
715
+ const deltaType = key.startsWith('text:') ? 'text_delta' : 'input_json_delta';
716
+ syntheticPrefix += synth(idx, deltaType === 'text_delta'
717
+ ? { type: 'text_delta', text: remainder }
718
+ : { type: 'input_json_delta', partial_json: remainder });
719
+ track(key, remainder);
720
+ }
721
+ buffers.delete(key);
722
+ }
723
+ for (const key of Array.from(emitted.keys()))
724
+ auditBlock(key);
725
+ }
726
+ // OpenAI streaming via SSE (less common with this controller, but handle just in case)
727
+ if (Array.isArray(event?.choices?.[0]?.delta?.tool_calls)) {
728
+ const finishReason = event?.choices?.[0]?.finish_reason;
729
+ for (const call of event.choices[0].delta.tool_calls) {
730
+ if (typeof call?.function?.arguments === 'string') {
731
+ const idx = call.index ?? 0;
732
+ const key = `oai_tool_call:${idx}`;
733
+ const buf = getBuf(key);
734
+ if (finishReason) {
735
+ call.function.arguments = buf.append(call.function.arguments) + buf.flush();
736
+ track(key, call.function.arguments);
737
+ buffers.delete(key);
738
+ auditBlock(key);
739
+ }
740
+ else {
741
+ call.function.arguments = buf.append(call.function.arguments);
742
+ track(key, call.function.arguments);
528
743
  }
744
+ modified = true;
529
745
  }
530
746
  }
531
- // Emit any synthetic events first (they come before the original event for correct ordering)
532
- for (const extra of extraWrites)
533
- originalWrite(extra);
534
- if (modified) {
535
- const newStr = eventName
536
- ? `event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`
537
- : `data: ${JSON.stringify(event)}\n\n`;
538
- return originalWrite(newStr, ...args);
747
+ }
748
+ if (modified) {
749
+ const newStr = eventName
750
+ ? `event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`
751
+ : `data: ${JSON.stringify(event)}\n\n`;
752
+ return syntheticPrefix + newStr;
753
+ }
754
+ return syntheticPrefix + block;
755
+ };
756
+ // Final safety net: unmask any COMPLETE placeholder still present in outgoing bytes.
757
+ // The structured per-block handling above only unmasks events it recognizes as
758
+ // content_block_delta text_delta / input_json_delta; text emitted through other
759
+ // shapes (notably the BedrockStreamParser raw-text fallback, which writes bare
760
+ // `data: <text>` on a parse miss) would otherwise reach the client masked. This is
761
+ // idempotent on already-unmasked output (no reverse-map keys remain to match) and
762
+ // only touches whole tokens, so it never disturbs the buffer's partial-retention.
763
+ const safetyNetUnmask = (s) => {
764
+ if (map.reverse.size === 0)
765
+ return s;
766
+ if (!s.includes('MASKED_') && !s.includes('masked-url-'))
767
+ return s;
768
+ return (0, unmasker_1.unmaskText)(s, map);
769
+ };
770
+ // Split accumulated data into complete SSE blocks, keep the partial tail pending.
771
+ const processIncoming = (str) => {
772
+ pending += str;
773
+ let out = '';
774
+ let sep;
775
+ while ((sep = pending.indexOf('\n\n')) !== -1) {
776
+ const block = pending.slice(0, sep + 2);
777
+ pending = pending.slice(sep + 2);
778
+ out += processBlock(block);
779
+ }
780
+ return safetyNetUnmask(out);
781
+ };
782
+ // Patch res.write. The Express Response.write signature has overloads; we accept any.
783
+ res.write = function patchedWrite(chunk, ...args) {
784
+ try {
785
+ const str = typeof chunk === 'string' ? chunk : (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : null);
786
+ if (str === null)
787
+ return originalWrite(chunk, ...args);
788
+ const out = processIncoming(str);
789
+ // Strip a callback argument if present — it must fire even when we buffer.
790
+ const cb = typeof args[args.length - 1] === 'function' ? args.pop() : undefined;
791
+ if (out) {
792
+ const result = originalWrite(out, ...args);
793
+ if (cb)
794
+ cb();
795
+ return result;
539
796
  }
540
- return originalWrite(chunk, ...args);
797
+ // Entire chunk retained as a partial block — report success, emit on next write/end.
798
+ if (cb)
799
+ cb();
800
+ return true;
541
801
  }
542
802
  catch {
543
803
  return originalWrite(chunk, ...args);
544
804
  }
545
805
  };
806
+ // Patch res.end so a trailing partial block (and any retained buffer content) is not lost.
807
+ res.end = function patchedEnd(chunk, ...args) {
808
+ try {
809
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
810
+ const out = processIncoming(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
811
+ if (out)
812
+ originalWrite(out);
813
+ chunk = undefined;
814
+ }
815
+ if (pending) {
816
+ // Incomplete final block — pass through (best effort), still safety-net unmasked.
817
+ originalWrite(safetyNetUnmask(pending));
818
+ pending = '';
819
+ }
820
+ // Streams that ended without content_block_stop/message_stop: audit what remains.
821
+ for (const [key, buf] of Array.from(buffers.entries())) {
822
+ const remainder = buf.flush();
823
+ if (remainder)
824
+ track(key, remainder);
825
+ buffers.delete(key);
826
+ }
827
+ for (const key of Array.from(emitted.keys()))
828
+ auditBlock(key);
829
+ }
830
+ catch { /* fall through to originalEnd */ }
831
+ return chunk !== undefined ? originalEnd(chunk, ...args) : originalEnd(...args);
832
+ };
546
833
  }
547
834
  /**
548
835
  * Walk a non-streaming response and unmask placeholders inside tool_use / tool_calls blocks.
@@ -638,15 +925,30 @@ function handleStreamingChunk(req, chunk, map, state, logger) {
638
925
  const idx = chunk.index ?? 0;
639
926
  const key = `tool_use:${idx}`;
640
927
  const buf = toolBuffers.get(key);
928
+ let toolFlushed = false;
641
929
  if (buf) {
642
930
  const remainder = buf.flush();
643
931
  if (remainder) {
644
932
  if (!chunk.delta)
645
933
  chunk.delta = { type: 'input_json_delta', partial_json: '' };
646
934
  chunk.delta.partial_json = (chunk.delta.partial_json || '') + remainder;
935
+ toolFlushed = true;
647
936
  }
648
937
  toolBuffers.delete(key);
649
938
  }
939
+ // Text blocks: Anthropic streams never set choices[].finish_reason, so the shared
940
+ // text buffer's retained tail would otherwise be stranded. Flush it here, unless
941
+ // this stop already carries a tool remainder (don't mix delta types).
942
+ if (!toolFlushed) {
943
+ const textRemainder = buffer.flush();
944
+ if (textRemainder) {
945
+ if (!chunk.delta)
946
+ chunk.delta = { type: 'text_delta', text: '' };
947
+ if (chunk.delta.type === 'text_delta') {
948
+ chunk.delta.text = (chunk.delta.text || '') + textRemainder;
949
+ }
950
+ }
951
+ }
650
952
  }
651
953
  // ─── OpenAI streaming: choices[0].delta.tool_calls[].function.arguments ─
652
954
  const oaiToolCalls = chunk?.choices?.[0]?.delta?.tool_calls;
@@ -709,6 +1011,53 @@ function attachMaskingInfo(response, state) {
709
1011
  }
710
1012
  return response;
711
1013
  }
1014
+ /**
1015
+ * Attach masking_info, but for pseudonymization only when the request opted into the
1016
+ * debug diagnostic (`masking.debug: true` or `x-sail-proxy-masking-debug: on`).
1017
+ * Anonymization keeps its documented behavior of always attaching it.
1018
+ */
1019
+ function maybeAttachMaskingInfo(response, state, req) {
1020
+ if (state.config.method === 'pseudonymization' && !req?.__pseudonymizationDebug) {
1021
+ return response;
1022
+ }
1023
+ return attachMaskingInfo(response, state);
1024
+ }
1025
+ // Placeholder-residue pattern (hash-hex ids and masked-url hosts), shared by the
1026
+ // streaming and non-streaming leak audits.
1027
+ const RESIDUE_REGEX = /MASKED_[A-Z_]+_[0-9a-f]+|(?:https?:\/\/)?masked-url-\d+\.invalid/g;
1028
+ /**
1029
+ * Scan a fully-unmasked non-streaming response for surviving MASKED_* tokens.
1030
+ * Logs per-token detail (ERROR if the token was in the map — a genuine unmask miss;
1031
+ * WARN if it is unknown residue from an earlier session) plus one aggregated,
1032
+ * grep-stable counter line so leak rates can be monitored.
1033
+ */
1034
+ function auditResponseResidue(response, map, requestId, logger) {
1035
+ let serialized;
1036
+ try {
1037
+ serialized = JSON.stringify(response) || '';
1038
+ }
1039
+ catch {
1040
+ return;
1041
+ }
1042
+ if (!serialized.includes('MASKED_') && !serialized.includes('masked-url-'))
1043
+ return;
1044
+ const tokens = new Set(serialized.match(RESIDUE_REGEX) || []);
1045
+ if (tokens.size === 0)
1046
+ return;
1047
+ const byType = {};
1048
+ for (const token of tokens) {
1049
+ const type = token.match(/^(MASKED_[A-Z_]+)_/)?.[1] || 'MASKED_URL';
1050
+ byType[type] = (byType[type] || 0) + 1;
1051
+ if (map.reverse.has(token)) {
1052
+ logger.error('Pseudonymization', `Unmask miss on response: ${token} was in the replacement map but reached the client masked (requestId=${requestId})`);
1053
+ }
1054
+ else {
1055
+ logger.warn('Pseudonymization', `Unresolvable masked residue in response: ${token} is not in this request's map — its value is absent from this request (leaked by an earlier session) (requestId=${requestId})`);
1056
+ }
1057
+ }
1058
+ const byTypeStr = Object.entries(byType).map(([t, n]) => `${t}:${n}`).join(',');
1059
+ logger.warn('Pseudonymization', `pseudonymization_residue_unresolved_total=${tokens.size} by_type=${byTypeStr} requestId=${requestId}`);
1060
+ }
712
1061
  // ─────────────────────────────────────────────────────────────────────────────
713
1062
  // STREAM HANDLER: Raw buffer processing (operates before SSE parsing)
714
1063
  // ─────────────────────────────────────────────────────────────────────────────