@debugbundle/sdk-react-native 1.3.0 → 3.0.0

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/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getNativeModule, degradedNativeState, safeNativeCall } from "./native.js";
2
2
  import { applyBeforeSend } from "./before-send.js";
3
3
  import { defaultRedactFields, DEFAULT_HEADER_ALLOWLIST, sanitizeHeaders, sanitizeValue } from "./redaction.js";
4
+ import { sanitizeTelemetry } from "./privacy-telemetry.js";
4
5
  import type {
5
6
  DebugBundleCaptureContext,
6
7
  DebugBundleClient,
@@ -18,7 +19,14 @@ import type {
18
19
  } from "./types.js";
19
20
 
20
21
  const SDK_NAME = "@debugbundle/sdk-react-native" as const;
21
- const DEFAULT_SDK_VERSION = "1.3.0";
22
+ const DEFAULT_SDK_VERSION = "3.0.0";
23
+ const MAX_PENDING_NATIVE_CALLS = 256;
24
+ const MAX_PENDING_LOW_PRIORITY_CALLS = 224;
25
+ const MAX_PENDING_NATIVE_BYTES = 4 * 1024 * 1024;
26
+ const MAX_PENDING_LOW_PRIORITY_BYTES = 3 * 1024 * 1024;
27
+ const MAX_NATIVE_EVENT_BYTES = 64 * 1024;
28
+ const MAX_CONTEXT_FIELDS = 50;
29
+ const MAX_CONTEXT_KEY_LENGTH = 128;
22
30
  const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
23
31
  debug: 10,
24
32
  info: 20,
@@ -27,6 +35,39 @@ const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
27
35
  critical: 50
28
36
  };
29
37
 
38
+ function protectEventFields(
39
+ event: DebugBundleEventEnvelope,
40
+ additionalKeys: string[]
41
+ ): DebugBundleEventEnvelope | null {
42
+ // Preserve typed identities only when their scalar values pass the mandatory policy.
43
+ for (const value of [event.schema_version, event.sdk_name, event.sdk_version,
44
+ ...Object.values(event.correlation ?? {})]) {
45
+ if (typeof value !== "string") continue;
46
+ const checked = sanitizeTelemetry(value, { additionalKeys });
47
+ if (!checked.ok || checked.value !== value) return null;
48
+ }
49
+ const result = sanitizeTelemetry({
50
+ service: event.service,
51
+ payload: event.payload,
52
+ ...(event.context ? { context: event.context } : {})
53
+ }, { additionalKeys });
54
+ if (!result.ok || !result.value || typeof result.value !== "object" || Array.isArray(result.value)) {
55
+ return null;
56
+ }
57
+ const safeFields = result.value as Record<string, unknown>;
58
+ if (!safeFields.service || !safeFields.payload ||
59
+ typeof safeFields.service !== "object" || Array.isArray(safeFields.service) ||
60
+ typeof safeFields.payload !== "object" || Array.isArray(safeFields.payload)) {
61
+ return null;
62
+ }
63
+ return {
64
+ ...event,
65
+ service: safeFields.service as DebugBundleEventEnvelope["service"],
66
+ payload: safeFields.payload as Record<string, unknown>,
67
+ ...(event.context ? { context: safeFields.context as Record<string, unknown> } : {})
68
+ };
69
+ }
70
+
30
71
  export class DebugBundleReactNativeClient implements DebugBundleClient {
31
72
  private config: ResolvedDebugBundleConfig;
32
73
  private nativeModule: NativeDebugBundleModule | null = null;
@@ -35,6 +76,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
35
76
  private breadcrumbs: Array<Record<string, unknown>> = [];
36
77
  private probeBuffers = new Map<string, Array<Record<string, unknown>>>();
37
78
  private jsProbeActivationExpiresAt = 0;
79
+ private pendingNativeCalls = 0;
80
+ private pendingNativeBytes = 0;
38
81
 
39
82
  constructor(config: DebugBundleConfig = {}) {
40
83
  this.config = resolveConfig(config);
@@ -82,6 +125,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
82
125
  }
83
126
 
84
127
  captureException(error: unknown, context: DebugBundleCaptureContext = {}): void {
128
+ if (!this.config.captureErrors || !this.canCapture("frontend_exception", false) ||
129
+ !this.hasNativeCapacity(true)) return;
85
130
  const mergedContext = this.mergeContext(context);
86
131
  const sanitizedError = asRecord(sanitizeValue(error, { redactFields: this.config.redactFields }));
87
132
  this.enqueue("frontend_exception", {
@@ -100,15 +145,22 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
100
145
  }
101
146
 
102
147
  captureLog(message: string, level: DebugBundleLogLevel = "warning", context: DebugBundleCaptureContext = {}): void {
148
+ if (!this.isLogEnabled(level)) return;
149
+ const mergedContext = this.mergeContext(context);
103
150
  this.enqueue("log_event", {
104
151
  level,
105
152
  message: sanitizeValue(message, { redactFields: this.config.redactFields }),
106
153
  attributes: {
107
- ...asRecord(sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields })),
154
+ ...asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
108
155
  source: "javascript"
109
156
  }
110
- }, stringValue(context.trace_id), true, undefined,
111
- this.config.captureLogs && LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel]);
157
+ }, stringValue(mergedContext.trace_id), true);
158
+ }
159
+
160
+ isLogEnabled(level: DebugBundleLogLevel): boolean {
161
+ const rank = LOG_LEVELS[level];
162
+ return rank !== undefined && this.config.captureLogs && rank >= LOG_LEVELS[this.config.logLevel] &&
163
+ this.canCapture("log_event", true) && this.hasNativeCapacity(rank >= LOG_LEVELS.error);
112
164
  }
113
165
 
114
166
  captureRequest(
@@ -116,6 +168,7 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
116
168
  response: DebugBundleResponseInfo,
117
169
  context: DebugBundleCaptureContext = {}
118
170
  ): void {
171
+ if (!this.config.captureNetwork || !this.canCapture("request_event", true) || !this.hasNativeCapacity(false)) return;
119
172
  const mergedContext = this.mergeContext(context);
120
173
  const traceId = request.traceId ?? stringValue(mergedContext.trace_id);
121
174
  const parsedUrl = parseRequestUrl(request.url);
@@ -133,16 +186,13 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
133
186
  }),
134
187
  duration_ms: response.durationMillis ?? 0
135
188
  };
136
- if (this.config.captureNetwork) {
137
- this.recordBreadcrumb("network_request", payload);
138
- }
189
+ this.recordBreadcrumb("network_request", payload);
139
190
  this.enqueue(
140
191
  "request_event",
141
192
  payload,
142
193
  traceId,
143
194
  true,
144
- asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
145
- this.config.captureNetwork
195
+ asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields }))
146
196
  );
147
197
  }
148
198
 
@@ -155,43 +205,61 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
155
205
  }
156
206
 
157
207
  setContext(key: string, value: unknown): void {
158
- this.context[key] = sanitizeValue(value, { redactFields: this.config.redactFields });
159
- if (this.nativeModule?.setContextValue) {
160
- void safeNativeCall(() => this.nativeModule!.setContextValue!(key, { value: this.context[key] }), undefined);
161
- } else if (this.nativeModule?.setContext) {
162
- void safeNativeCall(() => this.nativeModule!.setContext!(key, this.context[key]), undefined);
208
+ if (typeof key !== "string" || !key || key.length > MAX_CONTEXT_KEY_LENGTH ||
209
+ !this.hasNativeCapacity(false) ||
210
+ !Object.hasOwn(this.context, key) && Object.keys(this.context).length >= MAX_CONTEXT_FIELDS) return;
211
+ try {
212
+ // Include the field name in mandatory privacy before local or native ownership.
213
+ const fields = asRecord(sanitizeValue({ [key]: value }, { redactFields: this.config.redactFields }));
214
+ if (!Object.hasOwn(fields, key)) return;
215
+ const entry = { value: fields[key] };
216
+ this.context = { ...this.context, [key]: entry.value };
217
+ const nativeModule = this.nativeModule;
218
+ if (nativeModule?.setContextValue) {
219
+ this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContextValue!(key, entry));
220
+ } else if (nativeModule?.setContext) {
221
+ this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContext!(key, entry.value));
222
+ }
223
+ } catch {
224
+ // Context accessors and optional native integrations cannot escape into the host.
163
225
  }
164
226
  }
165
227
 
166
228
  probe(label: string, data: unknown | (() => unknown), options: DebugBundleProbeOptions = {}): void {
167
- if (!label || this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) {
168
- return;
169
- }
170
- const nativeProbeActive = this.nativeModule?.isProbeActive?.(label) ?? false;
171
- if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) {
172
- return;
173
- }
174
- const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
175
- const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
176
- const occurredAt = new Date().toISOString();
177
- const entry = {
178
- label,
179
- data: sanitizedData,
180
- timestamp: occurredAt,
181
- activation_id: null
182
- };
183
- const entries = this.probeBuffers.get(label) ?? [];
184
- entries.push(entry);
185
- this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
186
- if (this.nativeModule?.captureProbe) {
187
- void safeNativeCall(
188
- () => this.nativeModule!.captureProbe!(label, sanitizedData, occurredAt),
189
- false
190
- );
229
+ if (!label || label.length > MAX_CONTEXT_KEY_LENGTH || !this.hasNativeCapacity(false) ||
230
+ this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) return;
231
+ try {
232
+ const nativeModule = this.nativeModule;
233
+ const nativeProbeActive = nativeModule?.isProbeActive?.(label) ?? false;
234
+ if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) return;
235
+ const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
236
+ const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
237
+ const occurredAt = new Date().toISOString();
238
+ const entry = { label, data: sanitizedData, timestamp: occurredAt, activation_id: null };
239
+ const entries = this.probeBuffers.get(label) ?? [];
240
+ entries.push(entry);
241
+ this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
242
+ if (nativeModule?.captureProbe) {
243
+ this.enqueueNativeAuxiliary({ label, data: sanitizedData, occurredAt },
244
+ () => nativeModule.captureProbe!(label, sanitizedData, occurredAt));
245
+ }
246
+ } catch {
247
+ // Probe options, native activation readers and data access cannot throw into the host.
191
248
  }
192
249
  }
193
250
 
251
+ private enqueueNativeAuxiliary(payload: unknown, operation: () => unknown): void {
252
+ if (!this.hasNativeCapacity(false)) return;
253
+ const bytes = utf8ByteLength(JSON.stringify(payload));
254
+ if (bytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + bytes > MAX_PENDING_LOW_PRIORITY_BYTES) return;
255
+ this.pendingNativeCalls += 1;
256
+ this.pendingNativeBytes += bytes;
257
+ void safeNativeCall(operation, undefined).finally(() => this.releaseNativeCapacity(bytes));
258
+ }
259
+
194
260
  async flush(): Promise<void> {
261
+ // Hook preparation queued by earlier capture calls must reach the bridge first.
262
+ await Promise.resolve();
195
263
  if (!this.nativeModule) {
196
264
  return;
197
265
  }
@@ -246,43 +314,111 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
246
314
  context?: Record<string, unknown>,
247
315
  localPolicyAllows = true
248
316
  ): void {
317
+ if (!localPolicyAllows) return;
249
318
  if (!this.canCapture(eventType, countTowardSession)) {
250
319
  return;
251
320
  }
252
- const authoredEvent: DebugBundleEventEnvelope = {
253
- schema_version: "2026-03-01",
254
- event_id: generateId(),
255
- event_type: eventType,
256
- sdk_name: SDK_NAME,
257
- sdk_version: this.config.sdkVersion,
258
- service: {
259
- name: this.config.service,
260
- environment: this.config.environment,
261
- runtime: "react-native",
262
- framework: "react-native"
263
- },
264
- occurred_at: new Date().toISOString(),
265
- ...(traceId ? { correlation: { trace_id: traceId } } : {}),
266
- ...(context && Object.keys(context).length > 0 ? { context } : {}),
267
- payload: this.nativeState.device
268
- ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
269
- : payload
270
- };
271
- const event = applyBeforeSend(authoredEvent, this.config.beforeSend);
272
- if (!event || !localPolicyAllows) {
273
- return;
274
- }
275
- if (!this.nativeModule) {
276
- this.nativeState = degradedNativeState("native_module_unavailable");
277
- return;
278
- }
279
- if (this.nativeModule.enqueueCanonicalEvent) {
280
- void safeNativeCall(() => this.nativeModule!.enqueueCanonicalEvent!(event), undefined);
281
- } else {
282
- void safeNativeCall(() => this.nativeModule!.enqueueEvent(toLegacyNativeEvent(event)), undefined);
321
+ const highPriority = eventType === "frontend_exception" ||
322
+ eventType === "log_event" && (payload.level === "error" || payload.level === "critical");
323
+ if (!this.hasNativeCapacity(highPriority)) return;
324
+ this.pendingNativeCalls += 1;
325
+ let handedOff = false;
326
+ let reservedBytes = 0;
327
+ try {
328
+ const authoredEvent: DebugBundleEventEnvelope = {
329
+ schema_version: "2026-03-01",
330
+ event_id: generateId(),
331
+ event_type: eventType,
332
+ sdk_name: SDK_NAME,
333
+ sdk_version: this.config.sdkVersion,
334
+ service: {
335
+ name: this.config.service,
336
+ environment: this.config.environment,
337
+ runtime: "react-native",
338
+ framework: "react-native"
339
+ },
340
+ occurred_at: new Date().toISOString(),
341
+ ...(traceId ? { correlation: { trace_id: traceId } } : {}),
342
+ ...(context && Object.keys(context).length > 0 ? { context } : {}),
343
+ payload: this.nativeState.device
344
+ ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
345
+ : payload
346
+ };
347
+ const initial = protectEventFields(authoredEvent, this.config.redactFields);
348
+ if (!initial) return;
349
+ const config = this.config;
350
+ const nativeModule = this.nativeModule;
351
+ const initialBytes = utf8ByteLength(JSON.stringify(initial));
352
+ const byteLimit = highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
353
+ if (initialBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + initialBytes > byteLimit) return;
354
+ this.pendingNativeBytes += initialBytes;
355
+ reservedBytes = initialBytes;
356
+ if (!nativeModule) {
357
+ this.nativeState = degradedNativeState("native_module_unavailable");
358
+ return;
359
+ }
360
+ handedOff = true;
361
+ this.sendAdmittedEvent(initial, config, nativeModule, reservedBytes);
362
+ } catch {
363
+ this.nativeState = degradedNativeState("event_preparation_failed");
364
+ } finally {
365
+ if (!handedOff) this.releaseNativeCapacity(reservedBytes);
283
366
  }
284
367
  }
285
368
 
369
+ private sendAdmittedEvent(
370
+ initial: DebugBundleEventEnvelope,
371
+ config: ResolvedDebugBundleConfig,
372
+ nativeModule: NativeDebugBundleModule,
373
+ reservedBytes: number
374
+ ): void {
375
+ // This activation receives only a protected snapshot: deferred closures must
376
+ // not retain the raw capture payload/context through their lexical scope.
377
+ const prepareAndSend = async (): Promise<void> => {
378
+ if (this.config !== config) return;
379
+ const safeEvent = this.prepareAdmittedEvent(initial, config, nativeModule.enqueueCanonicalEvent !== undefined);
380
+ if (safeEvent === null) return;
381
+ const finalPriority = safeEvent.event_type === "frontend_exception" ||
382
+ safeEvent.event_type === "log_event" && ["error", "critical"].includes(String(safeEvent.payload.level));
383
+ const eventBytes = utf8ByteLength(JSON.stringify(safeEvent));
384
+ const finalLimit = finalPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
385
+ // The callback closure can retain the protected original until the bridge
386
+ // settles. Charge both it and the final snapshot instead of forgetting it.
387
+ if (eventBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + eventBytes > finalLimit) return;
388
+ this.pendingNativeBytes += eventBytes;
389
+ reservedBytes += eventBytes;
390
+ if (nativeModule.enqueueCanonicalEvent) await nativeModule.enqueueCanonicalEvent(safeEvent);
391
+ else await nativeModule.enqueueEvent(safeEvent);
392
+ };
393
+ const operation = config.beforeSend === null
394
+ ? safeNativeCall(prepareAndSend, undefined)
395
+ : Promise.resolve().then(() => safeNativeCall(prepareAndSend, undefined));
396
+ void operation.finally(() => { this.releaseNativeCapacity(reservedBytes); });
397
+ }
398
+
399
+ private prepareAdmittedEvent(initial: DebugBundleEventEnvelope, config: ResolvedDebugBundleConfig, canonical: boolean): DebugBundleEventEnvelope | null {
400
+ // Exit this synchronous activation before awaiting the bridge. An application
401
+ // replacement may be huge before projection and must not survive suspension.
402
+ const event = applyBeforeSend(initial, config.beforeSend);
403
+ if (!event || this.config !== config) return null;
404
+ const safeEvent = protectEventFields(event, config.redactFields);
405
+ if (!safeEvent) return null;
406
+ if (safeEvent.event_type === "log_event" &&
407
+ (!config.captureLogs || !(LOG_LEVELS[safeEvent.payload.level as DebugBundleLogLevel] >= LOG_LEVELS[config.logLevel]))) return null;
408
+ if (safeEvent.event_type === "frontend_exception" && !config.captureErrors) return null;
409
+ return canonical ? safeEvent : toLegacyNativeEvent(safeEvent);
410
+ }
411
+
412
+ private hasNativeCapacity(highPriority: boolean): boolean {
413
+ return this.pendingNativeCalls < (highPriority ? MAX_PENDING_NATIVE_CALLS : MAX_PENDING_LOW_PRIORITY_CALLS) &&
414
+ this.pendingNativeBytes < (highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES);
415
+ }
416
+
417
+ private releaseNativeCapacity(bytes: number): void {
418
+ this.pendingNativeCalls -= 1;
419
+ this.pendingNativeBytes -= bytes;
420
+ }
421
+
286
422
  private canCapture(eventType: DebugBundleEventEnvelope["event_type"], countTowardSession: boolean): boolean {
287
423
  if (!this.config.enabled || !this.config.projectToken) {
288
424
  return false;
@@ -293,7 +429,12 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
293
429
  }
294
430
 
295
431
  private mergeContext(context: DebugBundleCaptureContext): Record<string, unknown> {
296
- return { ...this.context, ...context };
432
+ try {
433
+ return { ...this.context, ...context };
434
+ } catch {
435
+ // A hostile caller accessor cannot interrupt error reporting or application work.
436
+ return { ...this.context };
437
+ }
297
438
  }
298
439
 
299
440
  private snapshotProbeData(): Record<string, unknown> {
@@ -312,6 +453,21 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
312
453
 
313
454
  }
314
455
 
456
+ function utf8ByteLength(value: string): number {
457
+ let bytes = 0;
458
+ for (let index = 0; index < value.length; index += 1) {
459
+ const code = value.charCodeAt(index);
460
+ if (code < 0x80) bytes += 1;
461
+ else if (code < 0x800) bytes += 2;
462
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
463
+ value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
464
+ bytes += 4;
465
+ index += 1;
466
+ } else bytes += 3;
467
+ }
468
+ return bytes;
469
+ }
470
+
315
471
  export function createDebugBundleClient(config: DebugBundleConfig = {}): DebugBundleReactNativeClient {
316
472
  return new DebugBundleReactNativeClient(config);
317
473
  }
package/src/console.ts CHANGED
@@ -2,7 +2,9 @@ import type { DebugBundleClient, DebugBundleLogLevel } from "./types.js";
2
2
 
3
3
  let restoreConsole: (() => void) | null = null;
4
4
 
5
- export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog">): () => void {
5
+ export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog"> & {
6
+ isLogEnabled?: (level: DebugBundleLogLevel) => boolean;
7
+ }): () => void {
6
8
  if (restoreConsole) {
7
9
  return restoreConsole;
8
10
  }
@@ -30,7 +32,10 @@ export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captu
30
32
  }
31
33
  inCapture = true;
32
34
  try {
35
+ if (client.isLogEnabled?.(level) === false) return;
33
36
  client.captureLog(args.map(String).join(" "), level, { source: "console" });
37
+ } catch {
38
+ // Console instrumentation must not change host logging behavior.
34
39
  } finally {
35
40
  inCapture = false;
36
41
  }
@@ -0,0 +1,39 @@
1
+ export const DEFAULT_SENSITIVE_KEYS = [
2
+ "password", "secret", "token", "api_key", "apikey", "access_token", "refresh_token",
3
+ "private_key", "authorization", "bearer", "cookie", "session_id", "passwd", "ssn",
4
+ "credit_card", "card_number", "cvv", "cvc", "pin", "expiry", "phone", "otp",
5
+ "verification_code"
6
+ ] as const;
7
+
8
+ function canonicalizeSensitiveKey(value: string): string {
9
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
10
+ }
11
+
12
+ function splitKeyIntoSegments(key: string): string[] {
13
+ return key
14
+ .trim()
15
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
16
+ .toLowerCase()
17
+ .split(/[^a-z0-9]+/)
18
+ .filter((segment) => segment.length > 0);
19
+ }
20
+
21
+ export function isSensitiveKey(key: string, sensitiveKeys: readonly string[]): boolean {
22
+ const normalized = key.trim().toLowerCase();
23
+ if (sensitiveKeys.includes(normalized)) {
24
+ return true;
25
+ }
26
+
27
+ const canonicalSensitiveKeys = new Set(sensitiveKeys.map(canonicalizeSensitiveKey));
28
+ const segments = splitKeyIntoSegments(key);
29
+ for (let start = 0; start < segments.length; start += 1) {
30
+ let combined = "";
31
+ for (let end = start; end < segments.length; end += 1) {
32
+ combined += segments[end];
33
+ if (canonicalSensitiveKeys.has(combined)) {
34
+ return true;
35
+ }
36
+ }
37
+ }
38
+ return false;
39
+ }
@@ -0,0 +1,168 @@
1
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
2
+ import { DEFAULT_SENSITIVE_KEYS, isSensitiveKey } from "./privacy-keys.js";
3
+ import { scrubCredentialText } from "./privacy-text.js";
4
+
5
+ const REDACTED = "[REDACTED]";
6
+ const MAX_DEPTH = 16;
7
+ const MAX_NODES = 4096;
8
+ const MAX_ENTRIES = 256;
9
+ const MAX_STRING_BYTES = 16 * 1024;
10
+ const MAX_TOTAL_BYTES = 256 * 1024;
11
+
12
+ export interface TelemetrySanitizationOptions {
13
+ additionalKeys?: readonly string[];
14
+ maxTotalBytes?: number;
15
+ }
16
+
17
+ export type TelemetrySanitizationResult =
18
+ | { ok: true; value: JsonValue; redactionCount: number }
19
+ | { ok: false; reason: "unsafe_input" | "budget_exceeded" };
20
+
21
+ interface WorkState {
22
+ nodes: number;
23
+ textBytes: number;
24
+ redactionCount: number;
25
+ seen: WeakSet<object>;
26
+ keys: string[];
27
+ extra: readonly string[];
28
+ maxTotalBytes: number;
29
+ }
30
+
31
+ class BudgetExceeded extends Error {}
32
+
33
+ function byteLength(value: string): number {
34
+ let bytes = 0;
35
+ for (let index = 0; index < value.length; index += 1) {
36
+ const code = value.charCodeAt(index);
37
+ if (code < 0x80) bytes += 1;
38
+ else if (code < 0x800) bytes += 2;
39
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
40
+ value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
41
+ bytes += 4;
42
+ index += 1;
43
+ } else bytes += 3;
44
+ }
45
+ return bytes;
46
+ }
47
+
48
+ function mask(state: WorkState): string {
49
+ state.redactionCount += 1;
50
+ return REDACTED;
51
+ }
52
+
53
+ function sanitizeString(value: string, state: WorkState, allowStructured: boolean): JsonValue {
54
+ if (value.length > MAX_STRING_BYTES) return mask(state);
55
+ const length = byteLength(value);
56
+ if (length > MAX_STRING_BYTES) return mask(state);
57
+ state.textBytes += length;
58
+ if (state.textBytes > state.maxTotalBytes) throw new BudgetExceeded();
59
+
60
+ if (allowStructured && (value.startsWith("{") || value.startsWith("["))) {
61
+ try {
62
+ const parsed: unknown = JSON.parse(value);
63
+ if (typeof parsed === "object" && parsed !== null) {
64
+ const nested = sanitizeValue(parsed, state, 0, false);
65
+ const encoded = JSON.stringify(nested);
66
+ if (encoded !== value) state.redactionCount += 1;
67
+ return encoded;
68
+ }
69
+ } catch (error) {
70
+ if (error instanceof BudgetExceeded) throw error;
71
+ // Malformed structured content with a recognizable sensitive label is withheld below.
72
+ }
73
+ }
74
+
75
+ const result = scrubCredentialText(value, state.extra);
76
+ if (result !== value) state.redactionCount += 1;
77
+ if ((value.startsWith("{") || value.startsWith("[")) && result === value &&
78
+ /(?:password|token|secret|authorization|cookie)["']?\s*[:=]/i.test(value)) {
79
+ return mask(state);
80
+ }
81
+ return result;
82
+ }
83
+
84
+ function sanitizeValue(value: unknown, state: WorkState, depth: number, allowStructured: boolean): JsonValue {
85
+ state.nodes += 1;
86
+ if (state.nodes > MAX_NODES) throw new BudgetExceeded();
87
+ if (depth > MAX_DEPTH) return mask(state);
88
+ if (typeof value === "string") return sanitizeString(value, state, allowStructured);
89
+ if (value === null || typeof value === "boolean") return value;
90
+ if (typeof value === "number" && Number.isFinite(value)) return value;
91
+ if (typeof value !== "object") throw new TypeError("unsafe_input");
92
+
93
+ if (state.seen.has(value)) return "[Circular]";
94
+ state.seen.add(value);
95
+ try {
96
+ if (Array.isArray(value)) {
97
+ if (value.length > MAX_ENTRIES) return mask(state);
98
+ return Array.from({ length: value.length }, (_, index) => {
99
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
100
+ if (descriptor === undefined || !("value" in descriptor)) throw new TypeError("unsafe_input");
101
+ return sanitizeValue(descriptor.value, state, depth + 1, allowStructured);
102
+ });
103
+ }
104
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
105
+ throw new TypeError("unsafe_input");
106
+ }
107
+ const keys = Object.keys(value);
108
+ if (keys.length > MAX_ENTRIES) return mask(state);
109
+ const result: Record<string, JsonValue> = {};
110
+ for (const key of keys) {
111
+ if (key.length > 128) {
112
+ state.redactionCount += 1;
113
+ continue;
114
+ }
115
+ state.textBytes += byteLength(key);
116
+ if (state.textBytes > state.maxTotalBytes) throw new BudgetExceeded();
117
+ const safeKey = scrubCredentialText(key, state.extra);
118
+ if (safeKey !== key) {
119
+ state.redactionCount += 1;
120
+ continue;
121
+ }
122
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
123
+ if (descriptor === undefined || !("value" in descriptor)) throw new TypeError("unsafe_input");
124
+ const safeValue = isSensitiveKey(key, state.keys)
125
+ ? mask(state)
126
+ : sanitizeValue(descriptor.value, state, depth + 1, allowStructured);
127
+ Object.defineProperty(result, key, {
128
+ value: safeValue,
129
+ enumerable: true,
130
+ configurable: true,
131
+ writable: true
132
+ });
133
+ }
134
+ return result;
135
+ } finally {
136
+ state.seen.delete(value);
137
+ }
138
+ }
139
+
140
+ /** Mandatory telemetry policy; unlike the legacy `redact` helper, custom keys are additive. */
141
+ export function sanitizeTelemetry(value: unknown, options: TelemetrySanitizationOptions = {}): TelemetrySanitizationResult {
142
+ try {
143
+ const extra = options.additionalKeys ?? [];
144
+ if (extra.length > 128 || extra.some((key) => typeof key !== "string" || key.length > 64 || !key.trim())) {
145
+ return { ok: false, reason: "unsafe_input" };
146
+ }
147
+ const maxTotalBytes = options.maxTotalBytes ?? MAX_TOTAL_BYTES;
148
+ if (!Number.isInteger(maxTotalBytes) || maxTotalBytes < 1 || maxTotalBytes > 512 * 1024) {
149
+ return { ok: false, reason: "unsafe_input" };
150
+ }
151
+ const state: WorkState = {
152
+ nodes: 0,
153
+ textBytes: 0,
154
+ redactionCount: 0,
155
+ seen: new WeakSet<object>(),
156
+ keys: [...DEFAULT_SENSITIVE_KEYS, ...extra],
157
+ extra,
158
+ maxTotalBytes
159
+ };
160
+ const sanitized = sanitizeValue(value, state, 0, true);
161
+ if (byteLength(JSON.stringify(sanitized)) > maxTotalBytes) {
162
+ return { ok: false, reason: "budget_exceeded" };
163
+ }
164
+ return { ok: true, value: sanitized, redactionCount: state.redactionCount };
165
+ } catch (error) {
166
+ return { ok: false, reason: error instanceof BudgetExceeded ? "budget_exceeded" : "unsafe_input" };
167
+ }
168
+ }