@debugbundle/sdk-react-native 3.0.1 → 3.1.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/CHANGELOG.md +16 -0
- package/DebugBundleReactNative.podspec +2 -2
- package/MIGRATION-3.1.md +116 -0
- package/README.md +37 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/debugbundle/reactnative/DebugBundleReactNativeModule.java +34 -3
- package/app.plugin.js +1 -1
- package/dist/NativeDebugBundleReactNative.d.ts +1 -0
- package/dist/NativeDebugBundleReactNative.d.ts.map +1 -1
- package/dist/before-send.d.ts.map +1 -1
- package/dist/before-send.js +9 -131
- package/dist/canonical-schema.d.ts +15 -0
- package/dist/canonical-schema.d.ts.map +1 -0
- package/dist/canonical-schema.js +139 -0
- package/dist/client-values.d.ts +17 -0
- package/dist/client-values.d.ts.map +1 -0
- package/dist/client-values.js +185 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +102 -205
- package/dist/diagnostic-budget.d.ts +16 -0
- package/dist/diagnostic-budget.d.ts.map +1 -0
- package/dist/diagnostic-budget.js +120 -0
- package/dist/event-schema-data.d.ts +2 -0
- package/dist/event-schema-data.d.ts.map +1 -0
- package/dist/event-schema-data.js +49 -0
- package/dist/evidence-capabilities.d.ts +5 -0
- package/dist/evidence-capabilities.d.ts.map +1 -0
- package/dist/evidence-capabilities.js +21 -0
- package/dist/evidence-controller.d.ts +24 -0
- package/dist/evidence-controller.d.ts.map +1 -0
- package/dist/evidence-controller.js +113 -0
- package/dist/evidence-types.d.ts +33 -0
- package/dist/evidence-types.d.ts.map +1 -0
- package/dist/evidence-types.js +1 -0
- package/dist/evidence-wire.d.ts +7 -0
- package/dist/evidence-wire.d.ts.map +1 -0
- package/dist/evidence-wire.js +132 -0
- package/dist/exception-evidence.d.ts +25 -0
- package/dist/exception-evidence.d.ts.map +1 -0
- package/dist/exception-evidence.js +175 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/owned-json.d.ts +11 -0
- package/dist/owned-json.d.ts.map +1 -0
- package/dist/owned-json.js +85 -0
- package/dist/privacy-keys.d.ts +2 -0
- package/dist/privacy-keys.d.ts.map +1 -1
- package/dist/privacy-keys.js +10 -2
- package/dist/privacy-telemetry.js +7 -7
- package/dist/privacy-text.d.ts +2 -2
- package/dist/privacy-text.d.ts.map +1 -1
- package/dist/privacy-text.js +35 -33
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +9 -7
- package/dist/safe-error-fields.d.ts +4 -0
- package/dist/safe-error-fields.d.ts.map +1 -0
- package/dist/safe-error-fields.js +54 -0
- package/dist/types.d.ts +21 -6
- package/dist/types.d.ts.map +1 -1
- package/ios/DebugBundleReactNative.swift +58 -4
- package/ios/DebugBundleReactNativeBridge.mm +4 -0
- package/package.json +3 -2
- package/src/NativeDebugBundleReactNative.ts +1 -0
- package/src/before-send.ts +10 -174
- package/src/canonical-schema.ts +108 -0
- package/src/client-values.ts +196 -0
- package/src/client.ts +92 -213
- package/src/diagnostic-budget.ts +90 -0
- package/src/event-schema-data.ts +49 -0
- package/src/evidence-capabilities.ts +19 -0
- package/src/evidence-controller.ts +89 -0
- package/src/evidence-types.ts +14 -0
- package/src/evidence-wire.ts +100 -0
- package/src/exception-evidence.ts +112 -0
- package/src/index.ts +6 -0
- package/src/owned-json.ts +60 -0
- package/src/privacy-keys.ts +12 -2
- package/src/privacy-telemetry.ts +9 -9
- package/src/privacy-text.ts +32 -31
- package/src/redaction.ts +8 -7
- package/src/safe-error-fields.ts +46 -0
- package/src/types.ts +23 -5
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { errorField, safeErrorStack, nativeBrand } from "./safe-error-fields.js";
|
|
2
|
+
import { sanitizeValue } from "./redaction.js";
|
|
3
|
+
import { utf8Bytes } from "./owned-json.js";
|
|
4
|
+
|
|
5
|
+
type LegacyError = { name: string; message: string; stack: string; traceAvailable?: boolean };
|
|
6
|
+
type Frame = { function: string | null; file: string | null; line: number | null; column: number | null; classification: "unknown" | "framework" | "sdk" };
|
|
7
|
+
type Node = { id: number; name: string; message: string; stack: string | null; trace_origin: string; parsing_status: string;
|
|
8
|
+
frames: Frame[]; relationships: Array<{ kind: "cause"; target_id: number }> };
|
|
9
|
+
const text = (value: unknown, fallback: string): string => typeof value === "string" && value ? value : fallback;
|
|
10
|
+
export function snapshotError(error: unknown, fields: string[]): LegacyError {
|
|
11
|
+
let name = "Error", message = "Unknown error", stack: unknown;
|
|
12
|
+
try {
|
|
13
|
+
if (typeof error === "string") message = error;
|
|
14
|
+
else if (error !== null && typeof error === "object") {
|
|
15
|
+
name = text(errorField(error, "name"), name); message = text(errorField(error, "message"), message); stack = safeErrorStack(error);
|
|
16
|
+
}
|
|
17
|
+
} catch { /* Legacy outer evidence is best effort; no application accessor is required. */ }
|
|
18
|
+
return {
|
|
19
|
+
name: text(sanitizeValue(name, { redactFields: fields }), "Error"),
|
|
20
|
+
message: text(sanitizeValue(message, { redactFields: fields }), "Unknown error"),
|
|
21
|
+
stack: text(sanitizeValue(text(stack, `${name}: ${message}`), { redactFields: fields, maxStringLength: 8192 }), `${name}: ${message}`),
|
|
22
|
+
traceAvailable: typeof stack === "string" && stack.length > 0
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clip(text: string, maximum: number): string {
|
|
27
|
+
let result = "", bytes = 0;
|
|
28
|
+
for (const character of text.slice(0, maximum)) {
|
|
29
|
+
const cost = utf8Bytes(character); if (bytes + cost > maximum) break;
|
|
30
|
+
result += character; bytes += cost;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
export function parseMobileStack(stack: string, maximum = 32): { frames: Frame[]; status: string; truncated: boolean } {
|
|
35
|
+
const frames: Frame[] = [];
|
|
36
|
+
const lines = stack.slice(0, 16_384).split(/\r?\n/, 129);
|
|
37
|
+
let partial = false, truncated = stack.length > 16_384 || lines.length > 128;
|
|
38
|
+
for (const input of lines.slice(0, 128)) {
|
|
39
|
+
if (frames.length >= maximum) { truncated = true; break; }
|
|
40
|
+
if (input.length > 2048) truncated = true;
|
|
41
|
+
const line = input.trim().slice(0, 2048);
|
|
42
|
+
const named = /^at\s+(.+?)\s*\((?:address at )?(.+?):(\d+)(?::(\d+))?\)$/.exec(line);
|
|
43
|
+
const anonymous = named ? null : /^at\s+(.+?):(\d+)(?::(\d+))?$/.exec(line);
|
|
44
|
+
const webkit = named || anonymous ? null : /^(.*?)@(.+?):(\d+)(?::(\d+))?$/.exec(line);
|
|
45
|
+
const match = named ?? anonymous ?? webkit;
|
|
46
|
+
if (!match) { if (/^at\s|@\S+:\d/.test(line)) partial = true; continue; }
|
|
47
|
+
let file = named || webkit ? match[2]! : match[1]!;
|
|
48
|
+
if (/^https?:\/\//i.test(file)) {
|
|
49
|
+
try { const url = new URL(file); file = `${url.protocol}//${url.host}${url.pathname}`; } catch { partial = true; continue; }
|
|
50
|
+
} else file = file.split(/[?#]/, 1)[0]!;
|
|
51
|
+
const row = Number(named || webkit ? match[3] : match[2]);
|
|
52
|
+
const rawColumn = named || webkit ? match[4] : match[3];
|
|
53
|
+
const column = rawColumn === undefined ? null : Number(rawColumn);
|
|
54
|
+
if (!Number.isSafeInteger(row) || row <= 0 || column !== null && (!Number.isSafeInteger(column) || column < 0)) { partial = true; continue; }
|
|
55
|
+
const fn = named || webkit ? clip(match[1]!.trim(), 256) || null : null;
|
|
56
|
+
frames.push({ function: fn, file: clip(file, 2048), line: row, column,
|
|
57
|
+
classification: /node_modules[/\\]@debugbundle[/\\]/.test(file) ? "sdk" : /node_modules[/\\]/.test(file) ? "framework" : "unknown" });
|
|
58
|
+
}
|
|
59
|
+
return { frames, status: truncated ? "truncated" : !frames.length ? "unparsed" : partial ? "partial" : "parsed", truncated };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stackLinks(stack: string): string {
|
|
63
|
+
return stack.replace(/https?:\/\/[^\s)]+/gi, source => {
|
|
64
|
+
const coordinate = /(:\d+(?::\d+)?)$/.exec(source)?.[1] ?? "";
|
|
65
|
+
const location = coordinate ? source.slice(0, -coordinate.length) : source;
|
|
66
|
+
try { const url = new URL(location); return `${url.protocol}//${url.host}${url.pathname}${coordinate}`; } catch { return "[unavailable URL]"; }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** New cause traversal requires the engine's native Error brand. Hermes/JSC
|
|
71
|
+
* without it retain the outer error and explicitly report unsupported traversal.
|
|
72
|
+
* Portable JS cannot prove aggregate arrays are not proxies: no iterator is run. */
|
|
73
|
+
export function snapshotExceptionEvidence(error: unknown, legacy: LegacyError, fields: string[], fatal = false,
|
|
74
|
+
brand = nativeBrand): Record<string, unknown> | undefined {
|
|
75
|
+
const nodes: Node[] = [], seen = new Map<object, number>(), active = new Set<object>();
|
|
76
|
+
let unsupported = brand === undefined, unsafe = false, truncated = false, totalFrames = 0;
|
|
77
|
+
const visit = (value: unknown, depth: number, root = false): number | null => {
|
|
78
|
+
if (!root && (!brand || !brand(value))) { unsupported = true; return null; }
|
|
79
|
+
if (typeof value === "object" && value !== null && active.has(value)) { truncated = true; return null; }
|
|
80
|
+
if (depth > (fatal ? 1 : 4) || nodes.length >= (fatal ? 2 : 8)) { truncated = true; return null; }
|
|
81
|
+
if (!root && seen.has(value as object)) return seen.get(value as object)!;
|
|
82
|
+
const snapshot = root ? legacy : snapshotError(value, fields);
|
|
83
|
+
const name = clip(snapshot.name, 256), message = clip(snapshot.message, fatal ? 512 : 2048);
|
|
84
|
+
const stack = stackLinks(clip(snapshot.stack, fatal ? 512 : 16_384));
|
|
85
|
+
const parsed = parseMobileStack(stack, fatal ? Math.min(4, 8 - totalFrames) : 32), frames = parsed.frames;
|
|
86
|
+
totalFrames += frames.length; truncated ||= parsed.truncated;
|
|
87
|
+
const trace = snapshot.traceAvailable !== false;
|
|
88
|
+
const id = nodes.length;
|
|
89
|
+
const node: Node = { id, name, message, stack: trace ? stack : null, frames: trace ? frames : [],
|
|
90
|
+
trace_origin: trace ? "runtime_reported" : "unavailable", parsing_status: trace ? parsed.status : "unavailable", relationships: [] };
|
|
91
|
+
nodes.push(node);
|
|
92
|
+
if (!brand || !brand(value)) { unsupported = true; return id; }
|
|
93
|
+
seen.set(value as object, id); active.add(value as object);
|
|
94
|
+
const cause = Object.getOwnPropertyDescriptor(value, "cause");
|
|
95
|
+
if (cause && !("value" in cause)) unsafe = true;
|
|
96
|
+
else if (cause?.value !== undefined) { const target = visit(cause.value, depth + 1); if (target !== null) node.relationships.push({ kind: "cause", target_id: target }); }
|
|
97
|
+
const aggregate = Object.getOwnPropertyDescriptor(value, "errors");
|
|
98
|
+
if (aggregate) { if (!("value" in aggregate)) unsafe = true; else unsupported = true; }
|
|
99
|
+
active.delete(value as object); return id;
|
|
100
|
+
};
|
|
101
|
+
try { visit(error, 0, true); } catch { unsafe = true; }
|
|
102
|
+
if (!nodes.length) return undefined;
|
|
103
|
+
const graph = { version: 1, runtime: "react_native", root_id: 0, nodes, truncated,
|
|
104
|
+
unavailable_reason: unsafe ? "unsafe" : truncated ? "budget_exceeded" : unsupported ? "unsupported" : null };
|
|
105
|
+
const maximum = fatal ? 3584 : 31_000;
|
|
106
|
+
if (utf8Bytes(JSON.stringify(graph)) > maximum) {
|
|
107
|
+
graph.truncated = true; graph.unavailable_reason = "budget_exceeded";
|
|
108
|
+
for (const node of nodes) { node.stack = null; node.frames = node.frames.slice(0, fatal ? 2 : 4); node.parsing_status = "truncated"; }
|
|
109
|
+
}
|
|
110
|
+
if (utf8Bytes(JSON.stringify(graph)) > maximum) for (const node of nodes) node.frames = [];
|
|
111
|
+
return utf8Bytes(JSON.stringify(graph)) <= maximum ? graph : undefined;
|
|
112
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,10 +12,15 @@ import type {
|
|
|
12
12
|
DebugBundleResponseInfo,
|
|
13
13
|
DebugBundleStatus
|
|
14
14
|
} from "./types.js";
|
|
15
|
+
import type { EvidenceCapabilities, OperationScope, RecoveryObservation } from "./evidence-types.js";
|
|
15
16
|
|
|
16
17
|
const singleton = new DebugBundleReactNativeClient();
|
|
17
18
|
|
|
18
19
|
export const DebugBundle = {
|
|
20
|
+
beginOperation(): OperationScope { return singleton.beginOperation(); },
|
|
21
|
+
withOperationContext<T>(operation: (scope: OperationScope) => T): T { return singleton.withOperationContext(operation); },
|
|
22
|
+
captureRecovery(observation: RecoveryObservation): void { singleton.captureRecovery(observation); },
|
|
23
|
+
get evidenceCapabilities(): EvidenceCapabilities { return singleton.evidenceCapabilities; },
|
|
19
24
|
init(config: DebugBundleConfig): void {
|
|
20
25
|
singleton.init(config);
|
|
21
26
|
if (config.captureErrors !== false) {
|
|
@@ -71,6 +76,7 @@ export const DebugBundle = {
|
|
|
71
76
|
export { createDebugBundleClient, DebugBundleReactNativeClient };
|
|
72
77
|
export { captureDebugBundleConsole };
|
|
73
78
|
export { installDebugBundleErrorHandlers };
|
|
79
|
+
export type { EvidenceCapabilities, EvidenceFeature, EvidenceScope, OperationScope, RecoveryObservation } from "./evidence-types.js";
|
|
74
80
|
export type {
|
|
75
81
|
DebugBundleCaptureContext,
|
|
76
82
|
DebugBundleBeforeSend,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type OwnedJSON = null | boolean | number | string | OwnedJSON[] | { [key: string]: OwnedJSON };
|
|
2
|
+
|
|
3
|
+
/** Exhausted ownership cannot safely fall back to pre-hook application data. */
|
|
4
|
+
export class EventValidationUnavailable extends Error {}
|
|
5
|
+
|
|
6
|
+
export function utf8Bytes(value: string): number {
|
|
7
|
+
let bytes = 0;
|
|
8
|
+
for (let index = 0; index < value.length; index++) {
|
|
9
|
+
const code = value.charCodeAt(index);
|
|
10
|
+
if (code < 0x80) bytes++;
|
|
11
|
+
else if (code < 0x800) bytes += 2;
|
|
12
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
|
|
13
|
+
value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) { bytes += 4; index++; }
|
|
14
|
+
else bytes += 3;
|
|
15
|
+
}
|
|
16
|
+
return bytes;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Bounded plain JSON copy; no toJSON, property getters, foreign iterators or formatters. */
|
|
20
|
+
export function ownJSON(input: unknown): OwnedJSON {
|
|
21
|
+
let nodes = 4096, remaining = 8 * 1024 * 1024;
|
|
22
|
+
const seen = new Set<object>();
|
|
23
|
+
const use = (amount: number): void => { remaining -= amount; if (remaining < 0) throw new EventValidationUnavailable(); };
|
|
24
|
+
const visit = (value: unknown, depth: number): OwnedJSON => {
|
|
25
|
+
if (--nodes < 0 || depth > 16) throw new EventValidationUnavailable();
|
|
26
|
+
if (value === null || typeof value === "boolean") { use(5); return value; }
|
|
27
|
+
if (typeof value === "number" && Number.isFinite(value)) { use(24); return value; }
|
|
28
|
+
if (typeof value === "string") { if (value.length > remaining) throw new EventValidationUnavailable(); use(utf8Bytes(value) + 2); return value; }
|
|
29
|
+
if (typeof value !== "object" || value === null || seen.has(value)) throw new TypeError("invalid JSON");
|
|
30
|
+
seen.add(value);
|
|
31
|
+
try {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
if (value.length > 256) throw new EventValidationUnavailable();
|
|
34
|
+
use(value.length + 2);
|
|
35
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
36
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
37
|
+
if (!descriptor || !("value" in descriptor)) throw new TypeError("invalid JSON");
|
|
38
|
+
return visit(descriptor.value, depth + 1);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError("invalid JSON");
|
|
42
|
+
const keys = Object.keys(value);
|
|
43
|
+
if (keys.length > 256) throw new EventValidationUnavailable();
|
|
44
|
+
const output: Record<string, OwnedJSON> = Object.create(null);
|
|
45
|
+
for (const key of keys) {
|
|
46
|
+
if (key.length > remaining) throw new EventValidationUnavailable();
|
|
47
|
+
use(utf8Bytes(key) + 4);
|
|
48
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
49
|
+
if (!descriptor || !("value" in descriptor)) throw new TypeError("invalid JSON");
|
|
50
|
+
output[key] = visit(descriptor.value, depth + 1);
|
|
51
|
+
}
|
|
52
|
+
return output;
|
|
53
|
+
} finally { seen.delete(value); }
|
|
54
|
+
};
|
|
55
|
+
return visit(input, 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function jsonObject(value: unknown): Record<string, OwnedJSON> | null {
|
|
59
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, OwnedJSON> : null;
|
|
60
|
+
}
|
package/src/privacy-keys.ts
CHANGED
|
@@ -19,12 +19,22 @@ function splitKeyIntoSegments(key: string): string[] {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function isSensitiveKey(key: string, sensitiveKeys: readonly string[]): boolean {
|
|
22
|
+
return createSensitiveKeyMatcher(sensitiveKeys)(key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Operation-owned lookup: bounded caller policy never enters a global cache. */
|
|
26
|
+
export function createSensitiveKeyMatcher(sensitiveKeys: readonly string[]): (key: string) => boolean {
|
|
27
|
+
const exact = new Set(sensitiveKeys);
|
|
28
|
+
const canonicalSensitiveKeys = new Set(sensitiveKeys.map(canonicalizeSensitiveKey));
|
|
29
|
+
return key => matchesSensitiveKey(key, exact, canonicalSensitiveKeys);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function matchesSensitiveKey(key: string, exact: ReadonlySet<string>, canonicalSensitiveKeys: ReadonlySet<string>): boolean {
|
|
22
33
|
const normalized = key.trim().toLowerCase();
|
|
23
|
-
if (
|
|
34
|
+
if (exact.has(normalized)) {
|
|
24
35
|
return true;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
const canonicalSensitiveKeys = new Set(sensitiveKeys.map(canonicalizeSensitiveKey));
|
|
28
38
|
const segments = splitKeyIntoSegments(key);
|
|
29
39
|
for (let start = 0; start < segments.length; start += 1) {
|
|
30
40
|
let combined = "";
|
package/src/privacy-telemetry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
2
|
-
import { DEFAULT_SENSITIVE_KEYS,
|
|
3
|
-
import {
|
|
2
|
+
import { DEFAULT_SENSITIVE_KEYS, createSensitiveKeyMatcher } from "./privacy-keys.js";
|
|
3
|
+
import { createCredentialTextScrubber } from "./privacy-text.js";
|
|
4
4
|
|
|
5
5
|
const REDACTED = "[REDACTED]";
|
|
6
6
|
const MAX_DEPTH = 16;
|
|
@@ -23,8 +23,8 @@ interface WorkState {
|
|
|
23
23
|
textBytes: number;
|
|
24
24
|
redactionCount: number;
|
|
25
25
|
seen: WeakSet<object>;
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
sensitiveKey: (key: string) => boolean;
|
|
27
|
+
scrubText: (value: string) => string;
|
|
28
28
|
maxTotalBytes: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -72,7 +72,7 @@ function sanitizeString(value: string, state: WorkState, allowStructured: boolea
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const result =
|
|
75
|
+
const result = state.scrubText(value);
|
|
76
76
|
if (result !== value) state.redactionCount += 1;
|
|
77
77
|
if ((value.startsWith("{") || value.startsWith("[")) && result === value &&
|
|
78
78
|
/(?:password|token|secret|authorization|cookie)["']?\s*[:=]/i.test(value)) {
|
|
@@ -114,14 +114,14 @@ function sanitizeValue(value: unknown, state: WorkState, depth: number, allowStr
|
|
|
114
114
|
}
|
|
115
115
|
state.textBytes += byteLength(key);
|
|
116
116
|
if (state.textBytes > state.maxTotalBytes) throw new BudgetExceeded();
|
|
117
|
-
const safeKey =
|
|
117
|
+
const safeKey = state.scrubText(key);
|
|
118
118
|
if (safeKey !== key) {
|
|
119
119
|
state.redactionCount += 1;
|
|
120
120
|
continue;
|
|
121
121
|
}
|
|
122
122
|
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
123
123
|
if (descriptor === undefined || !("value" in descriptor)) throw new TypeError("unsafe_input");
|
|
124
|
-
const safeValue =
|
|
124
|
+
const safeValue = state.sensitiveKey(key)
|
|
125
125
|
? mask(state)
|
|
126
126
|
: sanitizeValue(descriptor.value, state, depth + 1, allowStructured);
|
|
127
127
|
Object.defineProperty(result, key, {
|
|
@@ -153,8 +153,8 @@ export function sanitizeTelemetry(value: unknown, options: TelemetrySanitization
|
|
|
153
153
|
textBytes: 0,
|
|
154
154
|
redactionCount: 0,
|
|
155
155
|
seen: new WeakSet<object>(),
|
|
156
|
-
|
|
157
|
-
extra,
|
|
156
|
+
sensitiveKey: createSensitiveKeyMatcher([...DEFAULT_SENSITIVE_KEYS, ...extra]),
|
|
157
|
+
scrubText: createCredentialTextScrubber(extra),
|
|
158
158
|
maxTotalBytes
|
|
159
159
|
};
|
|
160
160
|
const sanitized = sanitizeValue(value, state, 0, true);
|
package/src/privacy-text.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_SENSITIVE_KEYS,
|
|
1
|
+
import { DEFAULT_SENSITIVE_KEYS, createSensitiveKeyMatcher } from "./privacy-keys.js";
|
|
2
2
|
|
|
3
3
|
const REDACTED = "[REDACTED]";
|
|
4
4
|
const BUILTIN_LABELS = [...DEFAULT_SENSITIVE_KEYS, "client_secret", "x_api_key",
|
|
@@ -34,7 +34,7 @@ function isValidCard(value: string): boolean {
|
|
|
34
34
|
return sum % 10 === 0;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function scrubUrl(raw: string,
|
|
37
|
+
function scrubUrl(raw: string, scrub: (value: string, scanUrls?: boolean) => string, sensitive: (key: string) => boolean): string {
|
|
38
38
|
try {
|
|
39
39
|
const url = new URL(raw);
|
|
40
40
|
if (url.username || url.password) {
|
|
@@ -43,8 +43,8 @@ function scrubUrl(raw: string, keys: readonly string[]): string {
|
|
|
43
43
|
}
|
|
44
44
|
const nextQuery = new URLSearchParams();
|
|
45
45
|
for (const [key, value] of url.searchParams) {
|
|
46
|
-
if (key.length > 128 ||
|
|
47
|
-
nextQuery.append(key,
|
|
46
|
+
if (key.length > 128 || scrub(key, false) !== key) continue;
|
|
47
|
+
nextQuery.append(key, sensitive(key) || scrub(value, false) !== value ? REDACTED : value);
|
|
48
48
|
}
|
|
49
49
|
url.search = nextQuery.toString();
|
|
50
50
|
url.hash = "";
|
|
@@ -55,32 +55,33 @@ function scrubUrl(raw: string, keys: readonly string[]): string {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
export function
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
output = decodeURIComponent(output);
|
|
66
|
-
} catch {
|
|
67
|
-
return REDACTED;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
output = output.replace(PEM, REDACTED).replace(HEADER, "$1: [REDACTED]");
|
|
71
|
-
output = output.replace(BEARER, "$1 [REDACTED]").replace(DEBUGBUNDLE_TOKEN, REDACTED);
|
|
72
|
-
output = output.replace(ASSIGNMENT, "$1$2[REDACTED]");
|
|
73
|
-
if (additionalKeys.length > 0) {
|
|
74
|
-
for (const key of additionalKeys) {
|
|
75
|
-
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
76
|
-
const named = new RegExp(`\\b(${escaped})\\b(["']?\\s*[:=]\\s*)(?:"[^"]*"|'[^']*'|[^\\s&,;]+)`, "gi");
|
|
77
|
-
output = output.replace(named, "$1$2[REDACTED]");
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
output = output.replace(CARD, (candidate) => isValidCard(candidate) ? REDACTED : candidate);
|
|
81
|
-
if (!scanUrls) return output;
|
|
82
|
-
return output.replace(URL_TEXT, (matched) => {
|
|
83
|
-
const trailing = matched.match(/[).,;]+$/)?.[0] ?? "";
|
|
84
|
-
return scrubUrl(matched.slice(0, matched.length - trailing.length), keys) + trailing;
|
|
58
|
+
/** Compile each caller rule once per bounded protection operation, not per scalar. */
|
|
59
|
+
export function createCredentialTextScrubber(additionalKeys: readonly string[]): (input: string, scanUrls?: boolean) => string {
|
|
60
|
+
const sensitive = createSensitiveKeyMatcher([...DEFAULT_SENSITIVE_KEYS, ...additionalKeys]);
|
|
61
|
+
const patterns = additionalKeys.map(key => {
|
|
62
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
return new RegExp(`\\b(${escaped})\\b(["']?\\s*[:=]\\s*)(?:"[^"]*"|'[^']*'|[^\\s&,;]+)`, "gi");
|
|
85
64
|
});
|
|
65
|
+
const scrub = (input: string, scanUrls = true): string => {
|
|
66
|
+
if (PEM_START.test(input) && !PEM_COMPLETE.test(input)) return REDACTED;
|
|
67
|
+
let output = input;
|
|
68
|
+
if (/(?:password|token|secret|authorization|cookie)%3[ad]/i.test(output)) {
|
|
69
|
+
try {
|
|
70
|
+
output = decodeURIComponent(output);
|
|
71
|
+
} catch {
|
|
72
|
+
return REDACTED;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
output = output.replace(PEM, REDACTED).replace(HEADER, "$1: [REDACTED]");
|
|
76
|
+
output = output.replace(BEARER, "$1 [REDACTED]").replace(DEBUGBUNDLE_TOKEN, REDACTED);
|
|
77
|
+
output = output.replace(ASSIGNMENT, "$1$2[REDACTED]");
|
|
78
|
+
for (const pattern of patterns) output = output.replace(pattern, "$1$2[REDACTED]");
|
|
79
|
+
output = output.replace(CARD, (candidate) => isValidCard(candidate) ? REDACTED : candidate);
|
|
80
|
+
if (!scanUrls) return output;
|
|
81
|
+
return output.replace(URL_TEXT, (matched) => {
|
|
82
|
+
const trailing = matched.match(/[).,;]+$/)?.[0] ?? "";
|
|
83
|
+
return scrubUrl(matched.slice(0, matched.length - trailing.length), scrub, sensitive) + trailing;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
return scrub;
|
|
86
87
|
}
|
package/src/redaction.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_SENSITIVE_KEYS, isSensitiveKey as privacySensitiveKey } from "./privacy-keys.js";
|
|
2
2
|
import { sanitizeTelemetry } from "./privacy-telemetry.js";
|
|
3
|
+
import { errorField, safeErrorStack } from "./safe-error-fields.js";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_REDACT_FIELDS = [
|
|
5
6
|
...DEFAULT_SENSITIVE_KEYS
|
|
@@ -93,9 +94,6 @@ function sanitize(
|
|
|
93
94
|
if (typeof value === "function" || typeof value === "symbol") {
|
|
94
95
|
return `[${typeof value}]`;
|
|
95
96
|
}
|
|
96
|
-
if (value instanceof Error) {
|
|
97
|
-
return sanitizeError(value, options, depth, visited);
|
|
98
|
-
}
|
|
99
97
|
if (depth >= options.maxDepth) {
|
|
100
98
|
return "[Truncated: depth]";
|
|
101
99
|
}
|
|
@@ -104,6 +102,7 @@ function sanitize(
|
|
|
104
102
|
return "[Circular]";
|
|
105
103
|
}
|
|
106
104
|
visited.add(value);
|
|
105
|
+
if (value instanceof Error) return sanitizeError(value, options, depth, visited);
|
|
107
106
|
if (Array.isArray(value)) {
|
|
108
107
|
return value
|
|
109
108
|
.slice(0, options.maxArrayLength)
|
|
@@ -124,11 +123,13 @@ function sanitizeError(
|
|
|
124
123
|
depth: number,
|
|
125
124
|
visited: WeakSet<object>
|
|
126
125
|
): Record<string, unknown> {
|
|
126
|
+
const name = errorField(error, "name"), message = errorField(error, "message");
|
|
127
|
+
const stack = safeErrorStack(error), cause = Object.getOwnPropertyDescriptor(error, "cause");
|
|
127
128
|
return {
|
|
128
|
-
name: truncate(
|
|
129
|
-
message: truncate(
|
|
130
|
-
stack: typeof
|
|
131
|
-
cause:
|
|
129
|
+
name: truncate(typeof name === "string" && name ? name : "Error", options.maxStringLength, options.redactFields),
|
|
130
|
+
message: truncate(typeof message === "string" ? message : "", options.maxStringLength, options.redactFields),
|
|
131
|
+
stack: typeof stack === "string" ? truncate(stack, options.maxStringLength * 4, options.redactFields) : null,
|
|
132
|
+
cause: cause && "value" in cause ? sanitize(cause.value, options, depth + 1, visited, "cause") : null
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
135
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Read primitive native error fields without calling application accessors. */
|
|
2
|
+
const nativePrototypes = new Set([Error.prototype, TypeError.prototype, RangeError.prototype, ReferenceError.prototype,
|
|
3
|
+
SyntaxError.prototype, URIError.prototype, EvalError.prototype, ...(typeof AggregateError === "function" ? [AggregateError.prototype] : [])]);
|
|
4
|
+
const brandDescriptor = Object.getOwnPropertyDescriptor(Error, "isError");
|
|
5
|
+
export const nativeBrand = brandDescriptor && "value" in brandDescriptor && typeof brandDescriptor.value === "function"
|
|
6
|
+
? brandDescriptor.value as (value: unknown) => boolean : undefined;
|
|
7
|
+
let nativeStackGetter: (() => unknown) | undefined;
|
|
8
|
+
|
|
9
|
+
export function errorField(value: object, key: string): unknown {
|
|
10
|
+
const own = Object.getOwnPropertyDescriptor(value, key);
|
|
11
|
+
if (own) return "value" in own ? own.value : undefined;
|
|
12
|
+
let prototype = Object.getPrototypeOf(value);
|
|
13
|
+
for (let depth = 0; prototype && depth < 4; depth++) {
|
|
14
|
+
if (!nativePrototypes.has(prototype)) return undefined;
|
|
15
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
|
|
16
|
+
if (descriptor) return "value" in descriptor ? descriptor.value : undefined;
|
|
17
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export function safeErrorStack(value: object): unknown {
|
|
22
|
+
let prototype = Object.getPrototypeOf(value);
|
|
23
|
+
for (let depth = 0; prototype && depth < 4; depth++) {
|
|
24
|
+
if (!nativePrototypes.has(prototype) && prototype !== Object.prototype) return undefined;
|
|
25
|
+
for (const key of ["name", "message"]) {
|
|
26
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
|
|
27
|
+
if (descriptor && (!("value" in descriptor) || typeof descriptor.value !== "string" || descriptor.value.length > 8192)) return undefined;
|
|
28
|
+
}
|
|
29
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
30
|
+
}
|
|
31
|
+
for (const key of ["name", "message"]) {
|
|
32
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
33
|
+
if (descriptor && (!("value" in descriptor) || typeof descriptor.value !== "string" || descriptor.value.length > 8192)) return undefined;
|
|
34
|
+
}
|
|
35
|
+
const formatter = Object.getOwnPropertyDescriptor(Error, "prepareStackTrace");
|
|
36
|
+
const limit = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit");
|
|
37
|
+
if (formatter && (!("value" in formatter) || formatter.value !== undefined) ||
|
|
38
|
+
limit && (!("value" in limit) || typeof limit.value !== "number" || limit.value < 0 || limit.value > 64)) return undefined;
|
|
39
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, "stack");
|
|
40
|
+
if (descriptor && "value" in descriptor) return descriptor.value;
|
|
41
|
+
if (descriptor?.get && nativeBrand?.(value)) {
|
|
42
|
+
nativeStackGetter ??= Object.getOwnPropertyDescriptor(new Error(), "stack")?.get;
|
|
43
|
+
if (descriptor.get === nativeStackGetter) return descriptor.get.call(value);
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface DebugBundleConfig {
|
|
|
22
22
|
maxEventsPerSession?: number;
|
|
23
23
|
maxBreadcrumbs?: number;
|
|
24
24
|
captureErrors?: boolean;
|
|
25
|
+
captureExceptionEvidence?: boolean;
|
|
26
|
+
captureRecoveryObservations?: boolean;
|
|
27
|
+
evidenceCapabilities?: EvidenceCapabilities;
|
|
25
28
|
captureUnhandledRejections?: boolean;
|
|
26
29
|
captureScreens?: boolean;
|
|
27
30
|
captureActions?: boolean;
|
|
@@ -60,6 +63,9 @@ export interface ResolvedDebugBundleConfig {
|
|
|
60
63
|
maxEventsPerSession: number;
|
|
61
64
|
maxBreadcrumbs: number;
|
|
62
65
|
captureErrors: boolean;
|
|
66
|
+
captureExceptionEvidence: boolean;
|
|
67
|
+
captureRecoveryObservations: boolean;
|
|
68
|
+
evidenceCapabilities: EvidenceCapabilities | null;
|
|
63
69
|
captureUnhandledRejections: boolean;
|
|
64
70
|
captureScreens: boolean;
|
|
65
71
|
captureActions: boolean;
|
|
@@ -104,6 +110,8 @@ export interface DebugBundleResponseInfo {
|
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
export interface DebugBundleCaptureContext {
|
|
113
|
+
failureRef?: string;
|
|
114
|
+
evidenceScope?: EvidenceScope;
|
|
107
115
|
[key: string]: unknown;
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -112,26 +120,29 @@ export interface DebugBundleProbeOptions {
|
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
export interface DebugBundleEventEnvelope {
|
|
115
|
-
schema_version:
|
|
123
|
+
schema_version: string;
|
|
116
124
|
event_id: string;
|
|
117
125
|
event_type:
|
|
118
126
|
| "frontend_exception"
|
|
127
|
+
| "backend_exception"
|
|
128
|
+
| "diagnostic_context"
|
|
129
|
+
| "deploy_metadata"
|
|
119
130
|
| "frontend_breadcrumb"
|
|
120
131
|
| "log_event"
|
|
121
132
|
| "request_event"
|
|
122
133
|
| "error_suppressed"
|
|
123
134
|
| "probe_event";
|
|
124
|
-
sdk_name:
|
|
135
|
+
sdk_name: string;
|
|
125
136
|
sdk_version: string;
|
|
126
137
|
service: {
|
|
127
138
|
name: string;
|
|
128
139
|
environment: string;
|
|
129
|
-
runtime
|
|
130
|
-
framework
|
|
140
|
+
runtime?: string | null;
|
|
141
|
+
framework?: string | null;
|
|
131
142
|
};
|
|
132
143
|
occurred_at: string;
|
|
133
144
|
correlation?: {
|
|
134
|
-
trace_id
|
|
145
|
+
trace_id?: string | null;
|
|
135
146
|
request_id?: string | null;
|
|
136
147
|
session_id?: string | null;
|
|
137
148
|
user_id_hash?: string | null;
|
|
@@ -143,6 +154,10 @@ export interface DebugBundleEventEnvelope {
|
|
|
143
154
|
export interface DebugBundleClient {
|
|
144
155
|
readonly status: DebugBundleStatus;
|
|
145
156
|
readonly lastEventAt: number | null;
|
|
157
|
+
readonly evidenceCapabilities?: EvidenceCapabilities;
|
|
158
|
+
beginOperation?(): OperationScope;
|
|
159
|
+
withOperationContext?<T>(operation: (scope: OperationScope) => T): T;
|
|
160
|
+
captureRecovery?(observation: RecoveryObservation): void;
|
|
146
161
|
init(config: DebugBundleConfig): void;
|
|
147
162
|
captureException(error: unknown, context?: DebugBundleCaptureContext): void;
|
|
148
163
|
captureError(error: unknown, context?: DebugBundleCaptureContext): void;
|
|
@@ -164,6 +179,7 @@ export interface DebugBundleClient {
|
|
|
164
179
|
export interface NativeDebugBundleModule {
|
|
165
180
|
initialize(config: NativeDebugBundleConfig): Promise<NativeDebugBundleState> | NativeDebugBundleState;
|
|
166
181
|
enqueueCanonicalEvent?(event: DebugBundleEventEnvelope): Promise<boolean | void> | boolean | void;
|
|
182
|
+
enqueueCanonicalEventJson?(event: string): Promise<boolean | void> | boolean | void;
|
|
167
183
|
/** Compatibility fallback implemented by native binaries released before the canonical bridge. */
|
|
168
184
|
enqueueEvent(event: DebugBundleEventEnvelope): Promise<void> | void;
|
|
169
185
|
captureProbe?(
|
|
@@ -185,6 +201,8 @@ export interface NativeDebugBundleState {
|
|
|
185
201
|
status: DebugBundleStatus;
|
|
186
202
|
lastEventAt?: number | null;
|
|
187
203
|
device?: Record<string, unknown> | null;
|
|
204
|
+
evidenceCapabilities?: unknown;
|
|
188
205
|
nativeModuleAvailable?: boolean;
|
|
189
206
|
degradedReason?: string | null;
|
|
190
207
|
}
|
|
208
|
+
import type { EvidenceCapabilities, EvidenceScope, OperationScope, RecoveryObservation } from "./evidence-types.js";
|