@fixback/sdk-core 0.2.0 → 0.3.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/README.md +28 -9
- package/{src/annotation.ts → dist/annotation.d.ts} +36 -47
- package/dist/anonymous-id.d.ts +23 -0
- package/dist/anonymous-id.js +39 -0
- package/dist/anonymous-id.js.map +1 -0
- package/dist/auto-capture.d.ts +129 -0
- package/dist/auto-capture.js +210 -0
- package/dist/auto-capture.js.map +1 -0
- package/{src/backoff.ts → dist/backoff.d.ts} +17 -52
- package/dist/boot.d.ts +148 -0
- package/dist/boot.js +113 -0
- package/dist/boot.js.map +1 -0
- package/dist/breadcrumb.d.ts +133 -0
- package/dist/connect.d.ts +110 -0
- package/dist/connect.js +147 -0
- package/dist/connect.js.map +1 -0
- package/dist/env.d.ts +37 -0
- package/dist/env.js +83 -0
- package/dist/env.js.map +1 -0
- package/dist/fingerprint.d.ts +39 -0
- package/dist/fingerprint.js +10 -15
- package/dist/fingerprint.js.map +1 -1
- package/dist/http.d.ts +51 -0
- package/dist/http.js +42 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +115 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1057 -0
- package/dist/index.mjs.map +7 -0
- package/dist/options.d.ts +82 -0
- package/dist/options.js +22 -0
- package/dist/options.js.map +1 -0
- package/dist/release.d.ts +19 -0
- package/dist/release.js +36 -0
- package/dist/release.js.map +1 -0
- package/dist/scrub.d.ts +62 -0
- package/dist/stack.d.ts +51 -0
- package/dist/stack.js +97 -0
- package/dist/stack.js.map +1 -0
- package/dist/trace/buffer.d.ts +121 -0
- package/dist/trace/buffer.js +230 -0
- package/dist/trace/buffer.js.map +1 -0
- package/dist/trace/console-args.d.ts +60 -0
- package/dist/trace/console-args.js +189 -0
- package/dist/trace/console-args.js.map +1 -0
- package/dist/trace/console.d.ts +42 -0
- package/dist/trace/console.js +71 -0
- package/dist/trace/console.js.map +1 -0
- package/dist/trace/crumbs.d.ts +88 -0
- package/dist/trace/crumbs.js +164 -0
- package/dist/trace/crumbs.js.map +1 -0
- package/dist/trace/source.d.ts +30 -0
- package/dist/trace/source.js +59 -0
- package/dist/trace/source.js.map +1 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- package/dist/wire.d.ts +101 -0
- package/package.json +12 -8
- package/src/backoff.test.ts +0 -94
- package/src/breadcrumb.ts +0 -169
- package/src/fingerprint.test.ts +0 -96
- package/src/fingerprint.ts +0 -112
- package/src/index.ts +0 -63
- package/src/scrub.test.ts +0 -215
- package/src/scrub.ts +0 -226
- package/src/wire.ts +0 -116
package/src/fingerprint.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The cross-surface error **fingerprint** (research `sentry-error-capture-findings.md`
|
|
3
|
-
* §7.2; ADR-0027/0028) — the pure, runtime-agnostic key by which the same logical
|
|
4
|
-
* error groups together, computed the **same way** on every SDK.
|
|
5
|
-
*
|
|
6
|
-
* `computeFingerprint(type, value, stack)` hashes the error type, a normalised
|
|
7
|
-
* message, and a compact top-frames signature. It only has to be stable and
|
|
8
|
-
* well-distributed (the client key is a flood guard; the server does canonical
|
|
9
|
-
* cross-session clustering), so a dependency-free FNV-1a hash is right. The
|
|
10
|
-
* `normalize` shape and the frame limit are **starting points** from research
|
|
11
|
-
* (ticket #92), tunable — never a frozen magic set.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const UUID_RE =
|
|
15
|
-
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
16
|
-
const URL_RE = /\bhttps?:\/\/[^\s"')]+/gi;
|
|
17
|
-
const HEX_0X_RE = /\b0x[0-9a-f]+\b/gi;
|
|
18
|
-
const HEX_RUN_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
19
|
-
const DIGIT_RUN_RE = /\d{4,}/g;
|
|
20
|
-
|
|
21
|
-
/** How many top stack frames feed the fingerprint (research §7.2). */
|
|
22
|
-
const FINGERPRINT_FRAME_LIMIT = 5;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Collapse the volatile parts of an error message so a changing string doesn't
|
|
26
|
-
* split one bug: UUIDs, URLs, `0x…` and long hex runs, and long digit runs are
|
|
27
|
-
* replaced with stable placeholders. Short numbers and stable text are kept so
|
|
28
|
-
* genuinely distinct bugs stay distinct. A small, dependency-free regex set —
|
|
29
|
-
* tunable per ticket #92, never a frozen magic set.
|
|
30
|
-
*/
|
|
31
|
-
export function normalize(value: string): string {
|
|
32
|
-
if (typeof value !== "string" || value.length === 0) return "";
|
|
33
|
-
return value
|
|
34
|
-
.replace(UUID_RE, "<uuid>")
|
|
35
|
-
.replace(URL_RE, "<url>")
|
|
36
|
-
.replace(HEX_0X_RE, "<hex>")
|
|
37
|
-
.replace(HEX_RUN_RE, "<hex>")
|
|
38
|
-
.replace(DIGIT_RUN_RE, "<n>")
|
|
39
|
-
.trim();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A dependency-free FNV-1a hash rendered in base-36. It only has to be stable and
|
|
44
|
-
* well-distributed within one session (the client key is a flood guard; the server
|
|
45
|
-
* does canonical cross-session clustering), so a non-cryptographic hash is right.
|
|
46
|
-
*/
|
|
47
|
-
export function hashString(input: string): string {
|
|
48
|
-
let h = 0x811c9dc5;
|
|
49
|
-
for (let i = 0; i < input.length; i++) {
|
|
50
|
-
h ^= input.charCodeAt(i);
|
|
51
|
-
h = Math.imul(h, 0x01000193);
|
|
52
|
-
}
|
|
53
|
-
return (h >>> 0).toString(36);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Reduce a frame location to `basename:line:col`, dropping origin and query. */
|
|
57
|
-
function compactLocation(location: string): string {
|
|
58
|
-
const noQuery = location.replace(/\?[^:]*/, "");
|
|
59
|
-
const lastSlash = noQuery.lastIndexOf("/");
|
|
60
|
-
return lastSlash >= 0 ? noQuery.slice(lastSlash + 1) : noQuery;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Parse one stack line into a compact `function@basename:line:col` frame id. */
|
|
64
|
-
function parseFrame(line: string): string | null {
|
|
65
|
-
// V8: "at fn (loc)" | "at loc"
|
|
66
|
-
const v8Named = line.match(/^at\s+(.+?)\s+\((.+)\)$/);
|
|
67
|
-
if (v8Named) return `${v8Named[1] ?? ""}@${compactLocation(v8Named[2] ?? "")}`;
|
|
68
|
-
const v8Bare = line.match(/^at\s+(.+)$/);
|
|
69
|
-
if (v8Bare) return `@${compactLocation(v8Bare[1] ?? "")}`;
|
|
70
|
-
// Firefox / Safari: "fn@loc" | "@loc"
|
|
71
|
-
const at = line.indexOf("@");
|
|
72
|
-
if (at >= 0) {
|
|
73
|
-
const fn = line.slice(0, at);
|
|
74
|
-
return `${fn}@${compactLocation(line.slice(at + 1))}`;
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Extract a compact, stable signature of the top in-app frames of a stack: up to
|
|
81
|
-
* {@link FINGERPRINT_FRAME_LIMIT} frames as `function@basename:line:col`, origin
|
|
82
|
-
* and cache-busting query stripped so a per-deploy asset hash doesn't matter within
|
|
83
|
-
* a session. Returns `""` when there is no usable stack (message-only fallback).
|
|
84
|
-
*/
|
|
85
|
-
export function extractTopFrames(
|
|
86
|
-
stack: string | undefined,
|
|
87
|
-
limit = FINGERPRINT_FRAME_LIMIT,
|
|
88
|
-
): string {
|
|
89
|
-
if (typeof stack !== "string" || stack.length === 0) return "";
|
|
90
|
-
const frames: string[] = [];
|
|
91
|
-
for (const raw of stack.split("\n")) {
|
|
92
|
-
const frame = parseFrame(raw.trim());
|
|
93
|
-
if (frame) {
|
|
94
|
-
frames.push(frame);
|
|
95
|
-
if (frames.length >= limit) break;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return frames.join(" < ");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* The per-session fingerprint (research §7.2):
|
|
103
|
-
* `hash(errorType + "|" + normalize(value) + "|" + topFrames)`. Stack frames
|
|
104
|
-
* dominate when present; otherwise it falls back to type + normalized value.
|
|
105
|
-
*/
|
|
106
|
-
export function computeFingerprint(
|
|
107
|
-
type: string,
|
|
108
|
-
value: string,
|
|
109
|
-
stack?: string,
|
|
110
|
-
): string {
|
|
111
|
-
return hashString(`${type}|${normalize(value)}|${extractTopFrames(stack)}`);
|
|
112
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@fixback/sdk-core` — the runtime-agnostic core shared by the Fixback capture
|
|
3
|
-
* SDKs (ADR-0028). Pure fingerprinting, scrubbing, and backoff logic plus the
|
|
4
|
-
* shared ingest wire shapes — no DOM, no Node built-ins, no rrweb.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
// Fingerprint (ADR-0027) — the cross-surface grouping key.
|
|
8
|
-
export { computeFingerprint, extractTopFrames, hashString, normalize } from "./fingerprint";
|
|
9
|
-
|
|
10
|
-
// Scrub choke point (spec 0003 §C).
|
|
11
|
-
export {
|
|
12
|
-
applyDefaultScrub,
|
|
13
|
-
type BeforeSend,
|
|
14
|
-
type BeforeSendOptions,
|
|
15
|
-
redactPii,
|
|
16
|
-
runBeforeSend,
|
|
17
|
-
scrubUrl,
|
|
18
|
-
} from "./scrub";
|
|
19
|
-
|
|
20
|
-
// Backoff (spec 0003 §E/§H) — honouring ingest's 429 / Retry-After.
|
|
21
|
-
export {
|
|
22
|
-
AutoReportBackoff,
|
|
23
|
-
type Clock,
|
|
24
|
-
DEFAULT_RETRY_AFTER_SECONDS,
|
|
25
|
-
parseRetryAfter,
|
|
26
|
-
} from "./backoff";
|
|
27
|
-
|
|
28
|
-
// Ingest wire shapes.
|
|
29
|
-
export type {
|
|
30
|
-
CaptureEnvironment,
|
|
31
|
-
CapturedFrame,
|
|
32
|
-
FeedbackSource,
|
|
33
|
-
Platform,
|
|
34
|
-
ReportContent,
|
|
35
|
-
} from "./wire";
|
|
36
|
-
|
|
37
|
-
// Annotation wire types (spec 0003 §D).
|
|
38
|
-
export type {
|
|
39
|
-
Annotation,
|
|
40
|
-
ArrowMark,
|
|
41
|
-
BoxMark,
|
|
42
|
-
DrawTool,
|
|
43
|
-
ElementRect,
|
|
44
|
-
Mark,
|
|
45
|
-
PenMark,
|
|
46
|
-
Point,
|
|
47
|
-
Rect,
|
|
48
|
-
SelectedElement,
|
|
49
|
-
TextMark,
|
|
50
|
-
} from "./annotation";
|
|
51
|
-
|
|
52
|
-
// Breadcrumb / trace wire types (spec #122).
|
|
53
|
-
export type {
|
|
54
|
-
Breadcrumb,
|
|
55
|
-
BreadcrumbCategory,
|
|
56
|
-
BreadcrumbData,
|
|
57
|
-
BreadcrumbLevel,
|
|
58
|
-
ConsoleArg,
|
|
59
|
-
ConsoleArgType,
|
|
60
|
-
NetworkApi,
|
|
61
|
-
NetworkOutcome,
|
|
62
|
-
SourceLocation,
|
|
63
|
-
} from "./breadcrumb";
|
package/src/scrub.test.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { Breadcrumb } from "./breadcrumb";
|
|
4
|
-
import type { ReportContent } from "./wire";
|
|
5
|
-
import { applyDefaultScrub, redactPii, runBeforeSend, scrubUrl } from "./scrub";
|
|
6
|
-
|
|
7
|
-
describe("scrubUrl", () => {
|
|
8
|
-
it("strips the query string entirely", () => {
|
|
9
|
-
expect(scrubUrl("https://acme.app/checkout?token=abc123&next=/pay")).toBe(
|
|
10
|
-
"https://acme.app/checkout",
|
|
11
|
-
);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("strips userinfo credentials", () => {
|
|
15
|
-
expect(scrubUrl("https://user:s3cret@acme.app/data")).toBe(
|
|
16
|
-
"https://acme.app/data",
|
|
17
|
-
);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("drops a token-bearing fragment but keeps a plain hash route", () => {
|
|
21
|
-
expect(scrubUrl("https://acme.app/#access_token=xyz&type=bearer")).toBe(
|
|
22
|
-
"https://acme.app/",
|
|
23
|
-
);
|
|
24
|
-
expect(scrubUrl("https://acme.app/dashboard#/checkout")).toBe(
|
|
25
|
-
"https://acme.app/dashboard#/checkout",
|
|
26
|
-
);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("leaves a clean URL and relative paths untouched", () => {
|
|
30
|
-
expect(scrubUrl("https://acme.app/checkout")).toBe(
|
|
31
|
-
"https://acme.app/checkout",
|
|
32
|
-
);
|
|
33
|
-
expect(scrubUrl("/orders/42")).toBe("/orders/42");
|
|
34
|
-
expect(scrubUrl("/search?q=shoes")).toBe("/search");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("redacts an email in a path segment (#139)", () => {
|
|
38
|
-
expect(scrubUrl("https://acme.app/users/jane.doe@acme.app/orders")).toBe(
|
|
39
|
-
"https://acme.app/users/[redacted-email]/orders",
|
|
40
|
-
);
|
|
41
|
-
// Relative paths are swept too.
|
|
42
|
-
expect(scrubUrl("/u/jane@acme.app")).toBe("/u/[redacted-email]");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("redacts a long digit run in a path segment but keeps short ids (#139)", () => {
|
|
46
|
-
expect(scrubUrl("https://acme.app/card/4111111111111111/pay")).toBe(
|
|
47
|
-
"https://acme.app/card/[redacted-number]/pay",
|
|
48
|
-
);
|
|
49
|
-
// A short id (a page number, a small resource id) is left alone.
|
|
50
|
-
expect(scrubUrl("https://acme.app/orders/42")).toBe(
|
|
51
|
-
"https://acme.app/orders/42",
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("never redacts the authority — a host or port with digits is preserved (#139)", () => {
|
|
56
|
-
expect(scrubUrl("https://api2.acme.app/data")).toBe(
|
|
57
|
-
"https://api2.acme.app/data",
|
|
58
|
-
);
|
|
59
|
-
// A host that is itself a long digit run is authority, not a path segment.
|
|
60
|
-
expect(scrubUrl("https://12345678.acme.app/data")).toBe(
|
|
61
|
-
"https://12345678.acme.app/data",
|
|
62
|
-
);
|
|
63
|
-
expect(scrubUrl("https://acme.app:8080/orders/99999999")).toBe(
|
|
64
|
-
"https://acme.app:8080/orders/[redacted-number]",
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("scrubs both the query string and path PII together (#139)", () => {
|
|
69
|
-
expect(
|
|
70
|
-
scrubUrl("https://acme.app/users/jane@acme.app/44444444?token=abc"),
|
|
71
|
-
).toBe("https://acme.app/users/[redacted-email]/[redacted-number]");
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("redactPii", () => {
|
|
76
|
-
it("redacts email addresses", () => {
|
|
77
|
-
expect(redactPii("contact jane.doe@acme.app about it")).toBe(
|
|
78
|
-
"contact [redacted-email] about it",
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("redacts long digit runs but keeps short ones", () => {
|
|
83
|
-
expect(redactPii("card 4111111111111111 failed")).toBe(
|
|
84
|
-
"card [redacted-number] failed",
|
|
85
|
-
);
|
|
86
|
-
// A short run (an HTTP status, a small count) is left alone.
|
|
87
|
-
expect(redactPii("returned 404 in 12ms")).toBe("returned 404 in 12ms");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("redacts bearer / token secrets", () => {
|
|
91
|
-
expect(redactPii("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6")).toBe(
|
|
92
|
-
"Authorization: Bearer [redacted]",
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const CRUMB_WITH_TOKENS: Breadcrumb = {
|
|
98
|
-
category: "fetch",
|
|
99
|
-
message: "GET https://acme.app/me?token=secret sent to jane@acme.app",
|
|
100
|
-
timestamp: 10,
|
|
101
|
-
data: { method: "GET", url: "https://acme.app/me?token=secret", status: 200 },
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
describe("applyDefaultScrub", () => {
|
|
105
|
-
it("strips the page URL and scrubs crumb URLs + PII, but keeps the comment", () => {
|
|
106
|
-
const draft: ReportContent = {
|
|
107
|
-
comment: "email me at jane@acme.app — 4111111111111111",
|
|
108
|
-
url: "https://acme.app/checkout?token=abc",
|
|
109
|
-
trace: [CRUMB_WITH_TOKENS],
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const scrubbed = applyDefaultScrub(draft);
|
|
113
|
-
|
|
114
|
-
expect(scrubbed.url).toBe("https://acme.app/checkout");
|
|
115
|
-
expect(scrubbed.trace?.[0]?.data?.url).toBe("https://acme.app/me");
|
|
116
|
-
expect(scrubbed.trace?.[0]?.message).toBe(
|
|
117
|
-
"GET https://acme.app/me sent to [redacted-email]",
|
|
118
|
-
);
|
|
119
|
-
// The Reporter's own comment is authored on purpose — never scrubbed.
|
|
120
|
-
expect(scrubbed.comment).toBe("email me at jane@acme.app — 4111111111111111");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("does not mutate the input draft", () => {
|
|
124
|
-
const draft: ReportContent = {
|
|
125
|
-
url: "https://acme.app/x?token=abc",
|
|
126
|
-
trace: [CRUMB_WITH_TOKENS],
|
|
127
|
-
};
|
|
128
|
-
applyDefaultScrub(draft);
|
|
129
|
-
expect(draft.url).toBe("https://acme.app/x?token=abc");
|
|
130
|
-
expect(draft.trace?.[0]?.data?.url).toBe("https://acme.app/me?token=secret");
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("applyDefaultScrub — structured console args (#122)", () => {
|
|
135
|
-
const CONSOLE_WITH_PII: Breadcrumb = {
|
|
136
|
-
category: "console",
|
|
137
|
-
level: "error",
|
|
138
|
-
message: "checkout failed",
|
|
139
|
-
timestamp: 20,
|
|
140
|
-
args: [
|
|
141
|
-
{ t: "string", v: "user jane@acme.app card 4111111111111111" },
|
|
142
|
-
{
|
|
143
|
-
t: "json",
|
|
144
|
-
v: { note: "call 5551234567", ref: { url: "https://acme.app/x?token=zzz" } },
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
t: "error",
|
|
148
|
-
v: {
|
|
149
|
-
name: "TypeError",
|
|
150
|
-
message: "failed for jane@acme.app",
|
|
151
|
-
stack: "at pay (https://acme.app/pay.js?token=q:4:2)",
|
|
152
|
-
},
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
it("redacts PII and strips URL query strings inside a console entry's args", () => {
|
|
158
|
-
const scrubbed = applyDefaultScrub({ trace: [CONSOLE_WITH_PII] });
|
|
159
|
-
const serialized = JSON.stringify(scrubbed.trace);
|
|
160
|
-
// Every free-text carrier inside the args is swept — string, json leaves, error.
|
|
161
|
-
expect(serialized).not.toContain("jane@acme.app");
|
|
162
|
-
expect(serialized).not.toContain("4111111111111111");
|
|
163
|
-
expect(serialized).not.toContain("5551234567");
|
|
164
|
-
expect(serialized).not.toContain("token=zzz");
|
|
165
|
-
expect(serialized).not.toContain("token=q");
|
|
166
|
-
expect(serialized).toContain("[redacted-email]");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("does not mutate the input args", () => {
|
|
170
|
-
const draft = { trace: [CONSOLE_WITH_PII] } as ReportContent;
|
|
171
|
-
applyDefaultScrub(draft);
|
|
172
|
-
expect((draft.trace?.[0]?.args?.[0] as { v: string }).v).toContain(
|
|
173
|
-
"jane@acme.app",
|
|
174
|
-
);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
describe("runBeforeSend", () => {
|
|
179
|
-
const draft: ReportContent = {
|
|
180
|
-
url: "https://acme.app/x?token=abc",
|
|
181
|
-
trace: [CRUMB_WITH_TOKENS],
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
it("applies the default scrubbers by default", () => {
|
|
185
|
-
const out = runBeforeSend(draft);
|
|
186
|
-
expect(out?.url).toBe("https://acme.app/x");
|
|
187
|
-
expect(out?.trace?.[0]?.data?.url).toBe("https://acme.app/me");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("relaxes the default scrubbers when scrub is false", () => {
|
|
191
|
-
const out = runBeforeSend(draft, { scrub: false });
|
|
192
|
-
expect(out?.url).toBe("https://acme.app/x?token=abc");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("lets a hook mutate the (already scrubbed) draft", () => {
|
|
196
|
-
const hook = vi.fn((d: ReportContent) => ({ ...d, comment: "added" }));
|
|
197
|
-
const out = runBeforeSend(draft, { hook });
|
|
198
|
-
// The hook sees the scrubbed draft, and its mutation survives.
|
|
199
|
-
expect(hook.mock.calls[0]?.[0].url).toBe("https://acme.app/x");
|
|
200
|
-
expect(out?.comment).toBe("added");
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("drops the whole report when the hook returns null", () => {
|
|
204
|
-
expect(runBeforeSend(draft, { hook: () => null })).toBeNull();
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("treats a throwing hook as a no-op, keeping the scrubbed draft", () => {
|
|
208
|
-
const out = runBeforeSend(draft, {
|
|
209
|
-
hook: () => {
|
|
210
|
-
throw new Error("boom");
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
expect(out?.url).toBe("https://acme.app/x");
|
|
214
|
-
});
|
|
215
|
-
});
|
package/src/scrub.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The single client-side **scrub choke point** every report passes through
|
|
3
|
-
* before transport (spec 0003 §C; research `sentry-error-capture-findings.md`
|
|
4
|
-
* §7.4) — the SDK's `beforeSend` equivalent, shared runtime-agnostically across
|
|
5
|
-
* surfaces (ADR-0028).
|
|
6
|
-
*
|
|
7
|
-
* Masking is the SDK's job, done before anything leaves the process. The
|
|
8
|
-
* screenshot is masked at capture, and breadcrumbs never record a value or a
|
|
9
|
-
* body at the source; `runBeforeSend` is the **last** gate over the assembled
|
|
10
|
-
* report. Its default scrubbers are **on**: they strip credentials, query
|
|
11
|
-
* strings, and bearer tokens from URLs, and redact obvious PII (emails, long
|
|
12
|
-
* digit runs, bearer tokens) from crumb and error text. The result is then handed
|
|
13
|
-
* to an optional per-project hook that can mutate it further or drop the whole
|
|
14
|
-
* report by returning `null`.
|
|
15
|
-
*
|
|
16
|
-
* The hook is **synchronous and network-free** by contract, and both manual
|
|
17
|
-
* (overlay) and automatic (error-capture) reports run through the very same
|
|
18
|
-
* choke point. A project relaxes the defaults with `scrub: false`, or reshapes
|
|
19
|
-
* the draft in its own hook — never a silent raw send.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type { Breadcrumb, BreadcrumbData, ConsoleArg } from "./breadcrumb";
|
|
23
|
-
import type { ReportContent } from "./wire";
|
|
24
|
-
|
|
25
|
-
/** The per-project client scrub hook. Return `null` to drop the whole report. */
|
|
26
|
-
export type BeforeSend = (draft: ReportContent) => ReportContent | null;
|
|
27
|
-
|
|
28
|
-
/** Options for {@link runBeforeSend}. */
|
|
29
|
-
export interface BeforeSendOptions {
|
|
30
|
-
/** The per-project hook, run **after** the default scrubbers. */
|
|
31
|
-
readonly hook?: BeforeSend | null;
|
|
32
|
-
/** Run the built-in default scrubbers first. Defaults to `true`. */
|
|
33
|
-
readonly scrub?: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** A digit run at least this long is treated as sensitive (phone, card, id). */
|
|
37
|
-
const MIN_DIGIT_RUN = 7;
|
|
38
|
-
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
39
|
-
const DIGIT_RUN_RE = new RegExp(`\\d{${MIN_DIGIT_RUN},}`, "g");
|
|
40
|
-
const BEARER_RE = /\b(bearer|token)\s+[\w.\-~+/]+=*/gi;
|
|
41
|
-
/** An http(s) URL embedded in free text (e.g. a console log line). */
|
|
42
|
-
const URL_IN_TEXT_RE = /\bhttps?:\/\/[^\s"'<>]+/gi;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Redact obvious PII from free text: email addresses, `Bearer <token>` /
|
|
46
|
-
* `token <value>` pairs, and long digit runs. Conservative by design — it keeps
|
|
47
|
-
* the shape of the message readable while removing the sensitive spans.
|
|
48
|
-
*/
|
|
49
|
-
export function redactPii(text: string): string {
|
|
50
|
-
if (typeof text !== "string" || text.length === 0) return text;
|
|
51
|
-
return text
|
|
52
|
-
.replace(EMAIL_RE, "[redacted-email]")
|
|
53
|
-
.replace(BEARER_RE, (_match, keyword: string) => `${keyword} [redacted]`)
|
|
54
|
-
.replace(DIGIT_RUN_RE, "[redacted-number]");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Redact PII from the **path** portion of a URL (spec #122 §E, ticket #139):
|
|
59
|
-
* emails and long digit runs in path (or fragment-route) segments — a network
|
|
60
|
-
* list shows URLs prominently, so an identifier baked into a path
|
|
61
|
-
* (`/users/jane@acme.app`, `/card/4111111111111111`) must not leak. Deliberately
|
|
62
|
-
* narrower than {@link redactPii}: no bearer/token rule (URL credentials are
|
|
63
|
-
* already stripped by {@link scrubUrl}) and it is only ever applied to the
|
|
64
|
-
* post-authority remainder, so a host or port with digits is never touched.
|
|
65
|
-
*/
|
|
66
|
-
function redactPathPii(pathAndBeyond: string): string {
|
|
67
|
-
return pathAndBeyond
|
|
68
|
-
.replace(EMAIL_RE, "[redacted-email]")
|
|
69
|
-
.replace(DIGIT_RUN_RE, "[redacted-number]");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Strip the sensitive parts of a URL: userinfo credentials
|
|
74
|
-
* (`scheme://user:pass@host`), the entire query string, a token-bearing
|
|
75
|
-
* fragment (one that carries `key=value`), and PII (emails, long digit runs) in
|
|
76
|
-
* the **path segments** (#139). Plain hash routes (`#/checkout`) are kept. Works
|
|
77
|
-
* on absolute and relative URLs alike, with no dependency and no throw. The host
|
|
78
|
-
* (authority) is never redacted — only the path and any surviving fragment route.
|
|
79
|
-
*/
|
|
80
|
-
export function scrubUrl(url: string): string {
|
|
81
|
-
if (typeof url !== "string" || url.length === 0) return url;
|
|
82
|
-
let out = url;
|
|
83
|
-
// Drop userinfo credentials: scheme://user:pass@host → scheme://host
|
|
84
|
-
out = out.replace(/(^[a-z][a-z0-9+.-]*:\/\/)[^/@?#]*@/i, "$1");
|
|
85
|
-
// Drop the query string entirely (everything from '?' up to a '#').
|
|
86
|
-
out = out.replace(/\?[^#]*/, "");
|
|
87
|
-
// Drop a token-bearing fragment (it carries '='); keep plain hash routes.
|
|
88
|
-
out = out.replace(/#.*$/, (fragment) =>
|
|
89
|
-
fragment.includes("=") ? "" : fragment,
|
|
90
|
-
);
|
|
91
|
-
// Redact PII in the path (and any kept fragment route), never in the authority.
|
|
92
|
-
// Split off `scheme://authority` (or a protocol-relative `//authority`) so a
|
|
93
|
-
// host/port that contains digits is preserved; the remainder is the path onward.
|
|
94
|
-
const absolute = out.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)([\s\S]*)$/i);
|
|
95
|
-
if (absolute) return absolute[1] + redactPathPii(absolute[2] ?? "");
|
|
96
|
-
const protocolRelative = out.match(/^(\/\/[^/?#]*)([\s\S]*)$/);
|
|
97
|
-
if (protocolRelative) {
|
|
98
|
-
return protocolRelative[1] + redactPathPii(protocolRelative[2] ?? "");
|
|
99
|
-
}
|
|
100
|
-
// A relative URL is all path — redact the whole thing.
|
|
101
|
-
return redactPathPii(out);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
type MutableBreadcrumbData = { -readonly [K in keyof BreadcrumbData]: BreadcrumbData[K] };
|
|
105
|
-
type MutableBreadcrumb = { -readonly [K in keyof Breadcrumb]: Breadcrumb[K] };
|
|
106
|
-
type MutableContent = { -readonly [K in keyof ReportContent]: ReportContent[K] };
|
|
107
|
-
|
|
108
|
-
function scrubCrumbData(data: BreadcrumbData): BreadcrumbData {
|
|
109
|
-
const next: MutableBreadcrumbData = { ...data };
|
|
110
|
-
if (typeof next.url === "string") next.url = scrubUrl(next.url);
|
|
111
|
-
if (typeof next.from === "string") next.from = scrubUrl(next.from);
|
|
112
|
-
if (typeof next.to === "string") next.to = scrubUrl(next.to);
|
|
113
|
-
return next;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Scrub a crumb's free-text message: strip URL query strings, then redact PII. */
|
|
117
|
-
function scrubMessage(message: string): string {
|
|
118
|
-
return redactPii(message.replace(URL_IN_TEXT_RE, (url) => scrubUrl(url)));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Scrub the string leaves of a structured console-arg value (spec #122 §C/§E) — the
|
|
123
|
-
* value a `json` arg carries. Walks arrays and plain objects, applying the same
|
|
124
|
-
* URL-strip + PII redaction as a crumb message to every nested string, so a value
|
|
125
|
-
* logged through `console.*` is swept just like the flattened preview.
|
|
126
|
-
*/
|
|
127
|
-
function scrubArgValue(value: unknown): unknown {
|
|
128
|
-
if (typeof value === "string") return scrubMessage(value);
|
|
129
|
-
if (Array.isArray(value)) return value.map(scrubArgValue);
|
|
130
|
-
if (value && typeof value === "object") {
|
|
131
|
-
const out: Record<string, unknown> = {};
|
|
132
|
-
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
133
|
-
out[key] = scrubArgValue(v);
|
|
134
|
-
}
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
return value;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Scrub one structured console argument (spec #122 §C): redact PII / strip URL query
|
|
142
|
-
* strings from a `string` value, from every string leaf of a `json` value, and from an
|
|
143
|
-
* `error` value's `message` and `stack`. Other tags (`number`/`bool`/`null`) carry no
|
|
144
|
-
* free text and pass through unchanged.
|
|
145
|
-
*/
|
|
146
|
-
function scrubConsoleArg(arg: ConsoleArg): ConsoleArg {
|
|
147
|
-
switch (arg.t) {
|
|
148
|
-
case "string":
|
|
149
|
-
return typeof arg.v === "string" ? { t: "string", v: scrubMessage(arg.v) } : arg;
|
|
150
|
-
case "json":
|
|
151
|
-
return { t: "json", v: scrubArgValue(arg.v) };
|
|
152
|
-
case "error": {
|
|
153
|
-
if (!arg.v || typeof arg.v !== "object") return arg;
|
|
154
|
-
const v = arg.v as { message?: unknown; stack?: unknown };
|
|
155
|
-
const next: Record<string, unknown> = { ...v };
|
|
156
|
-
if (typeof next.message === "string") next.message = scrubMessage(next.message);
|
|
157
|
-
if (typeof next.stack === "string") next.stack = scrubMessage(next.stack);
|
|
158
|
-
return { t: "error", v: next };
|
|
159
|
-
}
|
|
160
|
-
default:
|
|
161
|
-
return arg;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function scrubCrumb(crumb: Breadcrumb): Breadcrumb {
|
|
166
|
-
const next: MutableBreadcrumb = { ...crumb };
|
|
167
|
-
if (typeof next.message === "string") next.message = scrubMessage(next.message);
|
|
168
|
-
if (next.data) next.data = scrubCrumbData(next.data);
|
|
169
|
-
// A rich network crumb (#139) carries its scrubbed URL at the top level — sweep it
|
|
170
|
-
// again here (idempotent) so the choke point holds whether the URL rode in `data`
|
|
171
|
-
// (a legacy thin crumb) or top-level, and path-PII redaction is never skipped.
|
|
172
|
-
if (typeof next.url === "string") next.url = scrubUrl(next.url);
|
|
173
|
-
// A console crumb's structured args carry the same free text as its preview — sweep
|
|
174
|
-
// them too, so PII redaction holds whether a value is read from `message` or `args`.
|
|
175
|
-
if (Array.isArray(next.args)) next.args = next.args.map(scrubConsoleArg);
|
|
176
|
-
return next;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Apply the built-in default scrubbers to a report draft: strip the page URL,
|
|
181
|
-
* and scrub every crumb's URLs and redact PII from its text. The Reporter's own
|
|
182
|
-
* `comment` is intentionally left untouched — it is authored on purpose, not
|
|
183
|
-
* scraped. The screenshot and input values are masked elsewhere (at capture and
|
|
184
|
-
* at crumb creation); this is the final URL/PII sweep.
|
|
185
|
-
*/
|
|
186
|
-
export function applyDefaultScrub(draft: ReportContent): ReportContent {
|
|
187
|
-
const next: MutableContent = { ...draft };
|
|
188
|
-
if (typeof next.url === "string") next.url = scrubUrl(next.url);
|
|
189
|
-
if (next.trace && next.trace.length > 0) {
|
|
190
|
-
next.trace = next.trace.map(scrubCrumb);
|
|
191
|
-
}
|
|
192
|
-
// The structured stack frames (#117) carry script URLs — sweep them again here
|
|
193
|
-
// (idempotent, like the crumb URLs) so the choke point holds regardless of
|
|
194
|
-
// where the frames were parsed.
|
|
195
|
-
if (next.errorFrames && next.errorFrames.length > 0) {
|
|
196
|
-
next.errorFrames = next.errorFrames.map((frame) => ({
|
|
197
|
-
...frame,
|
|
198
|
-
file: scrubUrl(frame.file),
|
|
199
|
-
}));
|
|
200
|
-
}
|
|
201
|
-
return next;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Run the report draft through the client scrub choke point: the default
|
|
206
|
-
* scrubbers first (unless `scrub` is `false`), then the optional per-project
|
|
207
|
-
* hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
|
|
208
|
-
* the hook drops the report. A hook that throws is treated as a no-op — the
|
|
209
|
-
* already-scrubbed draft is kept, so a buggy hook never breaks the report path
|
|
210
|
-
* nor leaks unscrubbed data.
|
|
211
|
-
*/
|
|
212
|
-
export function runBeforeSend(
|
|
213
|
-
draft: ReportContent,
|
|
214
|
-
options: BeforeSendOptions = {},
|
|
215
|
-
): ReportContent | null {
|
|
216
|
-
const current =
|
|
217
|
-
options.scrub === false ? draft : applyDefaultScrub(draft);
|
|
218
|
-
const hook = options.hook;
|
|
219
|
-
if (!hook) return current;
|
|
220
|
-
try {
|
|
221
|
-
const result = hook(current);
|
|
222
|
-
return result ?? null;
|
|
223
|
-
} catch {
|
|
224
|
-
return current;
|
|
225
|
-
}
|
|
226
|
-
}
|