@capxul/observability 4.1.4 → 4.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -17
- package/dist/capxul-source.json +11 -0
- package/dist/engineering.d.mts +1 -1
- package/dist/engineering.mjs +90 -25
- package/dist/index.d.mts +6 -9
- package/dist/index.mjs +72 -90
- package/dist/src-CWfRYsBI.mjs +302 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,23 +26,28 @@ optional and snake_case; the camelCase spellings are retired.
|
|
|
26
26
|
One boundary, two named postures, both built on the same sanitizer, token
|
|
27
27
|
guard and missing-key opt-out:
|
|
28
28
|
|
|
29
|
-
| | `utilityAppAnalytics` | `productAppAnalytics`
|
|
30
|
-
| --------------- | ------------------------------------------ |
|
|
31
|
-
| For | capability-URL apps (approval links, docs) | the Capxul product web app
|
|
32
|
-
| Persistence | off | **on** — J1 spans an OTP reload
|
|
33
|
-
| Person profiles | identified-only, nothing identifies | identified-only, identify-driven
|
|
34
|
-
| Autocapture | off | off
|
|
35
|
-
| Session replay | off | **on in every environment**,
|
|
36
|
-
| Exceptions | off | on
|
|
37
|
-
|
|
38
|
-
Both stamp `capxul_env` and `producer: "browser"` through `before_send
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
29
|
+
| | `utilityAppAnalytics` | `productAppAnalytics` |
|
|
30
|
+
| --------------- | ------------------------------------------ | ----------------------------------------------- |
|
|
31
|
+
| For | capability-URL apps (approval links, docs) | the Capxul product web app |
|
|
32
|
+
| Persistence | off | **on** — J1 spans an OTP reload |
|
|
33
|
+
| Person profiles | identified-only, nothing identifies | identified-only, identify-driven |
|
|
34
|
+
| Autocapture | off | off |
|
|
35
|
+
| Session replay | off | **on in every environment**, credentials masked |
|
|
36
|
+
| Exceptions | off | on |
|
|
37
|
+
|
|
38
|
+
Both stamp `capxul_env` and `producer: "browser"` through `before_send`.
|
|
39
|
+
Utility analytics removes URL queries, fragments, and promoted campaign/search
|
|
40
|
+
values. Product analytics preserves safe URL components and masks credentials.
|
|
41
|
+
Both profiles preserve the PostHog routing token required for transport.
|
|
42
|
+
`createPostHogBrowserPageviewTracker` reports pathname-only initial loads and
|
|
43
|
+
SPA route changes. Vendor initialization and pageview capture failures degrade
|
|
44
|
+
to disabled analytics.
|
|
45
|
+
|
|
46
|
+
[The redaction rule](../../docs/rules/redaction.md) defines the data classes.
|
|
47
|
+
Product replay masks password, OTP, and credential controls. Ordinary input
|
|
48
|
+
remains visible. Network copies mask credential fields, headers, and URL parts
|
|
49
|
+
while retaining safe codes, public chain evidence, and replay identifiers.
|
|
50
|
+
Existing event schemas and the four auth-event email hashes remain unchanged.
|
|
46
51
|
|
|
47
52
|
### Import the standard `posthog-js` build
|
|
48
53
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"commit": "fe4f1c05f1de962a7a76825816bdff2beaf5fc88",
|
|
4
|
+
"tree": "45b0c3cfdd39d23a4cfb1c1bcc047521f16bc12d",
|
|
5
|
+
"branch": "codex/programme-rc",
|
|
6
|
+
"repository": "https://github.com/Xelmar-tech/infrastructure",
|
|
7
|
+
"lockfileSha256": "5870f53b72706d103220c1bcf733cb4fb71196aea32a9cfbdc277d73cc959264"
|
|
8
|
+
},
|
|
9
|
+
"name": "@capxul/observability",
|
|
10
|
+
"version": "4.2.0-rc.2"
|
|
11
|
+
}
|
package/dist/engineering.d.mts
CHANGED
|
@@ -12,7 +12,7 @@ interface EngineeringTelemetryConfig {
|
|
|
12
12
|
readonly sdkVersion: string;
|
|
13
13
|
readonly serviceName?: string;
|
|
14
14
|
}
|
|
15
|
-
declare const traceHeaderFilter: (
|
|
15
|
+
declare const traceHeaderFilter: (_name: string) => boolean;
|
|
16
16
|
declare const postHogOtlpEndpoints: (host: string) => {
|
|
17
17
|
readonly logs: `${string}/i/v1/logs`;
|
|
18
18
|
readonly traces: `${string}/i/v1/traces`;
|
package/dist/engineering.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as redactUrlSecrets, n as isCredentialField, r as redactSecrets } from "./src-CWfRYsBI.mjs";
|
|
2
|
+
import { Cause, Effect, Exit, FiberSet, Layer, Stream, Tracer } from "effect";
|
|
2
3
|
import { FetchHttpClient, Headers, HttpClient } from "effect/unstable/http";
|
|
3
4
|
import { OtlpExporter, OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
|
|
4
5
|
//#region src/engineering.ts
|
|
@@ -8,21 +9,8 @@ const ENGINEERING_CAPXUL_ENVS = [
|
|
|
8
9
|
"production",
|
|
9
10
|
"local"
|
|
10
11
|
];
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
"content-type",
|
|
14
|
-
"traceparent",
|
|
15
|
-
"tracestate",
|
|
16
|
-
"x-request-id"
|
|
17
|
-
];
|
|
18
|
-
const ENGINEERING_REDACTED_HEADER_NAMES = Object.freeze([
|
|
19
|
-
"authorization",
|
|
20
|
-
"cookie",
|
|
21
|
-
"set-cookie",
|
|
22
|
-
"x-api-key",
|
|
23
|
-
/auth|email|key|otp|secret|session|token|wallet/i
|
|
24
|
-
]);
|
|
25
|
-
const traceHeaderFilter = (name) => SAFE_TRACED_HEADER_NAMES.includes(name.toLowerCase());
|
|
12
|
+
const traceHeaderFilter = (_name) => true;
|
|
13
|
+
const credentialHeaderPattern = Object.assign(/./u, { test: (name) => isCredentialField(name, "header") });
|
|
26
14
|
const postHogOtlpEndpoints = (host) => {
|
|
27
15
|
const base = host.replace(/\/+$/, "");
|
|
28
16
|
return {
|
|
@@ -54,23 +42,79 @@ const validateEngineeringTelemetryConfig = (config) => {
|
|
|
54
42
|
headers: Object.freeze({ authorization: headerEntries[0][1] })
|
|
55
43
|
};
|
|
56
44
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
45
|
+
const failureText = (failure, key, limit, source) => {
|
|
46
|
+
if (failure === null || typeof failure !== "object") return void 0;
|
|
47
|
+
try {
|
|
48
|
+
let owner = failure;
|
|
49
|
+
let descriptor;
|
|
50
|
+
for (let depth = 0; owner !== null && depth < 4; depth += 1) {
|
|
51
|
+
descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
|
52
|
+
if (descriptor !== void 0 || key !== "name") break;
|
|
53
|
+
owner = Object.getPrototypeOf(owner);
|
|
54
|
+
}
|
|
55
|
+
const value = descriptor?.value;
|
|
56
|
+
return typeof value === "string" || key === "code" && typeof value === "number" ? redactSecrets(String(value), source).slice(0, limit) : void 0;
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const exportFailure = (failure, source) => {
|
|
62
|
+
const message = failureText(failure, "message", 2048, source) ?? (typeof failure === "string" ? redactSecrets(failure, source).slice(0, 2048) : "Engineering operation failed");
|
|
63
|
+
const code = failureText(failure, "code", 128);
|
|
64
|
+
const error = new Error((code === void 0 ? message : `${message} [code=${code}]`).slice(0, 2048));
|
|
65
|
+
error.name = failureText(failure, "_tag", 128) ?? failureText(failure, "name", 128) ?? "Error";
|
|
66
|
+
error.stack = failureText(failure, "stack", 8192, source) ?? `${error.name}: ${error.message}`;
|
|
67
|
+
return error;
|
|
68
|
+
};
|
|
69
|
+
const exportFailureExit = (cause, source) => Exit.failCause(Cause.fromReasons(cause.reasons.filter((reason) => !Cause.isInterruptReason(reason)).slice(0, 8).map((reason) => Cause.isFailReason(reason) ? Cause.makeFailReason(exportFailure(reason.error, source)) : Cause.makeDieReason(exportFailure(reason.defect, source)))));
|
|
70
|
+
const BROWSER_KEEPALIVE_BYTES = 65536;
|
|
71
|
+
let unfinishedBrowserExportBytes = 0;
|
|
72
|
+
const hasBrowserDocument = () => typeof window !== "undefined" && typeof document !== "undefined";
|
|
73
|
+
const browserExportClient = (client) => HttpClient.transform(client, (response, request) => Effect.acquireUseRelease(Effect.sync(() => {
|
|
74
|
+
const bytes = "contentLength" in request.body ? request.body.contentLength : void 0;
|
|
75
|
+
if (request.body._tag === "Stream" || request.body._tag === "FormData" || bytes === void 0 || !Number.isSafeInteger(bytes) || bytes < 0 || unfinishedBrowserExportBytes + bytes > BROWSER_KEEPALIVE_BYTES) return;
|
|
76
|
+
unfinishedBrowserExportBytes += bytes;
|
|
77
|
+
return bytes;
|
|
78
|
+
}), (bytes) => response.pipe(Effect.provideService(FetchHttpClient.RequestInit, { keepalive: bytes !== void 0 }), Effect.tap((result) => bytes === void 0 ? Effect.void : Stream.runDrain(result.stream).pipe(Effect.catch((error) => error.reason._tag === "EmptyBodyError" ? Effect.void : Effect.fail(error))))), (bytes) => Effect.sync(() => {
|
|
79
|
+
if (bytes !== void 0) unfinishedBrowserExportBytes -= bytes;
|
|
80
|
+
})));
|
|
81
|
+
const spanUpstream = (span) => {
|
|
82
|
+
let current = span;
|
|
83
|
+
while (current?._tag === "Span") {
|
|
84
|
+
const upstream = current.attributes.get("chain.upstream");
|
|
85
|
+
if (upstream === "alchemy" || upstream === "infura") return upstream;
|
|
86
|
+
current = current.parent._tag === "Some" ? current.parent.value : void 0;
|
|
62
87
|
}
|
|
63
88
|
};
|
|
64
|
-
const REDACTED_SPAN_FAILURE = Exit.fail(new RedactedEngineeringSpanFailure());
|
|
65
89
|
/** Preserve domain exits while preventing the OTLP serializer from seeing raw causes. */
|
|
66
90
|
const makeLeakSafeEngineeringTracer = (delegate) => Tracer.make({
|
|
67
91
|
span(options) {
|
|
68
92
|
const span = delegate.span(options);
|
|
69
93
|
const wrapped = Object.create(span);
|
|
94
|
+
Object.defineProperty(wrapped, "attribute", { value: (name, value) => {
|
|
95
|
+
const source = spanUpstream(span);
|
|
96
|
+
const header = /^http\.(?:request|response)\.header\.(.+)$/u.exec(name)?.[1];
|
|
97
|
+
if (header !== void 0 && isCredentialField(header, "header")) span.attribute(name, "[REDACTED]");
|
|
98
|
+
else if (typeof value === "string" && name === "url.query") span.attribute(name, redactUrlSecrets(`?${value}`).slice(1, 2049));
|
|
99
|
+
else if (typeof value === "string" && name === "url.path") {
|
|
100
|
+
const fullUrl = span.attributes.get("url.full");
|
|
101
|
+
const path = typeof fullUrl === "string" ? /^https?:\/\/[^/]+([^?#]*)/u.exec(fullUrl)?.[1] ?? value : value;
|
|
102
|
+
span.attribute(name, redactUrlSecrets(path, source).slice(0, 2048));
|
|
103
|
+
} else if (typeof value === "string" && (name === "url.full" || header === "location" || header === "referer" || header === "referrer")) span.attribute(name, redactUrlSecrets(value, source).slice(0, 2048));
|
|
104
|
+
else if (header !== void 0 && typeof value === "string") span.attribute(name, redactSecrets(value, source).slice(0, 2048));
|
|
105
|
+
else span.attribute(name, value);
|
|
106
|
+
} });
|
|
70
107
|
Object.defineProperty(wrapped, "end", {
|
|
71
108
|
configurable: false,
|
|
72
109
|
enumerable: false,
|
|
73
|
-
value: (endTime, exit) =>
|
|
110
|
+
value: (endTime, exit) => {
|
|
111
|
+
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
|
|
112
|
+
const first = exit.cause.reasons.find((reason) => !Cause.isInterruptReason(reason));
|
|
113
|
+
const code = failureText(first === void 0 ? void 0 : Cause.isFailReason(first) ? first.error : first.defect, "code", 128);
|
|
114
|
+
if (code !== void 0) span.attribute("error.code", code);
|
|
115
|
+
span.end(endTime, exportFailureExit(exit.cause, spanUpstream(span)));
|
|
116
|
+
} else span.end(endTime, exit);
|
|
117
|
+
},
|
|
74
118
|
writable: false
|
|
75
119
|
});
|
|
76
120
|
return wrapped;
|
|
@@ -101,8 +145,29 @@ const makeEngineeringTelemetryLayer = (config) => {
|
|
|
101
145
|
resource,
|
|
102
146
|
mergeWithExisting: true
|
|
103
147
|
});
|
|
104
|
-
const headerPolicy = Layer.merge(Layer.succeed(HttpClient.TracerHeaderFilter, traceHeaderFilter), Layer.succeed(Headers.CurrentRedactedNames,
|
|
105
|
-
|
|
148
|
+
const headerPolicy = Layer.merge(Layer.succeed(HttpClient.TracerHeaderFilter, traceHeaderFilter), Layer.succeed(Headers.CurrentRedactedNames, [credentialHeaderPattern]));
|
|
149
|
+
const browserLifecycle = Layer.effectDiscard(Effect.gen(function* () {
|
|
150
|
+
if (validated.producer !== "browser" || !hasBrowserDocument()) return;
|
|
151
|
+
const browserWindow = window;
|
|
152
|
+
const browserDocument = document;
|
|
153
|
+
const flusher = yield* OtlpExporter.Flusher;
|
|
154
|
+
const run = yield* FiberSet.makeRuntime();
|
|
155
|
+
const flush = () => {
|
|
156
|
+
run(flusher.flush);
|
|
157
|
+
};
|
|
158
|
+
const visibilityChanged = () => {
|
|
159
|
+
if (browserDocument.visibilityState === "hidden") flush();
|
|
160
|
+
};
|
|
161
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
162
|
+
browserDocument.addEventListener("visibilitychange", visibilityChanged);
|
|
163
|
+
browserWindow.addEventListener("pagehide", flush);
|
|
164
|
+
}), () => Effect.sync(() => {
|
|
165
|
+
browserDocument.removeEventListener("visibilitychange", visibilityChanged);
|
|
166
|
+
browserWindow.removeEventListener("pagehide", flush);
|
|
167
|
+
}));
|
|
168
|
+
}));
|
|
169
|
+
const transport = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (client) => validated.producer === "browser" && hasBrowserDocument() ? browserExportClient(client) : client)).pipe(Layer.provide(FetchHttpClient.layer));
|
|
170
|
+
return browserLifecycle.pipe(Layer.provideMerge(Layer.mergeAll(tracing, logging, headerPolicy)), Layer.provide(OtlpSerialization.layerJson), Layer.provide(transport));
|
|
106
171
|
};
|
|
107
172
|
//#endregion
|
|
108
173
|
export { ENGINEERING_CAPXUL_ENVS, makeEngineeringTelemetryLayer, makeLeakSafeEngineeringTracer, postHogOtlpEndpoints, traceHeaderFilter };
|
package/dist/index.d.mts
CHANGED
|
@@ -3181,12 +3181,12 @@ interface PostHogBrowserCapture {
|
|
|
3181
3181
|
* The callback mutates and returns the supplied event, matching PostHog's
|
|
3182
3182
|
* `before_send` contract while preserving the event's full structural type.
|
|
3183
3183
|
*/
|
|
3184
|
-
declare function sanitizePostHogBrowserEvent<T extends PostHogBrowserCapture | null>(event: T): T;
|
|
3184
|
+
declare function sanitizePostHogBrowserEvent<T extends PostHogBrowserCapture | null>(event: T, urlPolicy?: "path-only" | "diagnostic"): T;
|
|
3185
3185
|
/**
|
|
3186
3186
|
* Drop local events before transport. Sanitize and stamp every other browser
|
|
3187
3187
|
* event with `capxul_env` and `producer: "browser"`.
|
|
3188
3188
|
*/
|
|
3189
|
-
declare function createCapxulBeforeSend(capxulEnv: CapxulEnv): <T extends PostHogBrowserCapture | null>(event: T) => T | null;
|
|
3189
|
+
declare function createCapxulBeforeSend(capxulEnv: CapxulEnv, urlPolicy?: "path-only" | "diagnostic"): <T extends PostHogBrowserCapture | null>(event: T) => T | null;
|
|
3190
3190
|
type PostHogBeforeSend = ReturnType<typeof createCapxulBeforeSend>;
|
|
3191
3191
|
/**
|
|
3192
3192
|
* ADR-0016 posture, unchanged: capability-URL utility apps (approval links,
|
|
@@ -3227,7 +3227,7 @@ type PostHogBrowserOptions = PostHogUtilityAppOptions;
|
|
|
3227
3227
|
* funnel lies.
|
|
3228
3228
|
* - identify-driven person profiles (ADR-0020 A2 wires the calls at L1).
|
|
3229
3229
|
* - session replay in EVERY environment. Ordinary text and input values remain
|
|
3230
|
-
* visible.
|
|
3230
|
+
* visible. Password, OTP, and credential controls are masked. Network
|
|
3231
3231
|
* timing plus request/response headers and bodies are recorded, so a failed
|
|
3232
3232
|
* call shows its status and body in the replay. Canvas stays off. The
|
|
3233
3233
|
* recorder script loads from PostHog, so external dependency loading is on.
|
|
@@ -3298,17 +3298,14 @@ declare function utilityAppAnalytics(input: CapxulBrowserAnalyticsInput): PostHo
|
|
|
3298
3298
|
* generic over the real request type and returns the same type it was given.
|
|
3299
3299
|
*/
|
|
3300
3300
|
interface CapturedNetworkRequestLike {
|
|
3301
|
+
readonly name?: string | undefined;
|
|
3302
|
+
readonly url?: string | undefined;
|
|
3301
3303
|
readonly requestHeaders?: Record<string, string> | undefined;
|
|
3302
3304
|
readonly responseHeaders?: Record<string, string> | undefined;
|
|
3303
3305
|
readonly requestBody?: unknown;
|
|
3304
3306
|
readonly responseBody?: unknown;
|
|
3305
3307
|
}
|
|
3306
|
-
/**
|
|
3307
|
-
* Replay keeps the request line, status, timing, headers, and bodies, so a
|
|
3308
|
-
* failed call carries its evidence. Credential headers and secret material
|
|
3309
|
-
* never leave the browser: bearer tokens, cookies, API keys, and the OTP a
|
|
3310
|
-
* user just typed are masked before the recording is sent.
|
|
3311
|
-
*/
|
|
3308
|
+
/** Keep request evidence while masking credential values at the browser boundary. */
|
|
3312
3309
|
declare function maskCapturedNetworkRequest<T extends CapturedNetworkRequestLike>(request: T): T;
|
|
3313
3310
|
declare function productAppAnalytics(input: CapxulBrowserAnalyticsInput): PostHogProductAppOptions;
|
|
3314
3311
|
type PostHogBrowserInitialize<Options, T> = (key: string, options: Options) => T;
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { i as redactUrlSecrets, n as isCredentialField, r as redactSecrets, t as CAPXUL_ERROR_CODES } from "./src-CWfRYsBI.mjs";
|
|
1
2
|
import { Effect, Schema, SchemaGetter } from "effect";
|
|
2
3
|
//#region ../errors/src/chain-cause.ts
|
|
3
4
|
/**
|
|
@@ -35,39 +36,6 @@ const CHAIN_UPSTREAMS = [
|
|
|
35
36
|
"gateway"
|
|
36
37
|
];
|
|
37
38
|
//#endregion
|
|
38
|
-
//#region ../errors/src/errors.ts
|
|
39
|
-
const CAPXUL_ERROR_CODES = [
|
|
40
|
-
"NOT_AUTHENTICATED",
|
|
41
|
-
"EMAIL_DELIVERY_FAILED",
|
|
42
|
-
"PROFILE_NOT_FOUND",
|
|
43
|
-
"SMART_ACCOUNT_MISSING",
|
|
44
|
-
"PLAYER_NOT_FOUND",
|
|
45
|
-
"ACCOUNT_NOT_FOUND",
|
|
46
|
-
"ACCOUNT_CLAIMED_BY_OTHER_SIGNER",
|
|
47
|
-
"PROVIDER_ERROR",
|
|
48
|
-
"CAPABILITY_UNAVAILABLE",
|
|
49
|
-
"INVALID_INPUT",
|
|
50
|
-
"ENV_MISSING",
|
|
51
|
-
"NOT_IMPLEMENTED",
|
|
52
|
-
"VERIFICATION_REQUIRED",
|
|
53
|
-
"INSUFFICIENT_BALANCE",
|
|
54
|
-
"INVALID_RECIPIENT",
|
|
55
|
-
"ROLE_PERMISSION_DENIED",
|
|
56
|
-
"TRANSACTION_FAILED",
|
|
57
|
-
"RATE_LIMITED",
|
|
58
|
-
"NETWORK_ERROR",
|
|
59
|
-
"UNKNOWN",
|
|
60
|
-
"OTP_EXPIRED",
|
|
61
|
-
"SIGNER_REJECTED",
|
|
62
|
-
"CANCELLED",
|
|
63
|
-
"WRONG_STATE",
|
|
64
|
-
"STALE_EPOCH",
|
|
65
|
-
"SUPERSEDED",
|
|
66
|
-
"WORK_DIED",
|
|
67
|
-
"ACTOR_STOPPED"
|
|
68
|
-
];
|
|
69
|
-
new Set(CAPXUL_ERROR_CODES);
|
|
70
|
-
//#endregion
|
|
71
39
|
//#region ../types/src/index.ts
|
|
72
40
|
const EVM_ADDRESS_RE = /^0x[0-9a-f]{40}$/i;
|
|
73
41
|
const BYTES32_RE = /^0x[0-9a-f]{64}$/i;
|
|
@@ -1775,22 +1743,24 @@ function redactTelemetryProps(name, props, options = {}) {
|
|
|
1775
1743
|
* The callback mutates and returns the supplied event, matching PostHog's
|
|
1776
1744
|
* `before_send` contract while preserving the event's full structural type.
|
|
1777
1745
|
*/
|
|
1778
|
-
function sanitizePostHogBrowserEvent(event) {
|
|
1746
|
+
function sanitizePostHogBrowserEvent(event, urlPolicy = "path-only") {
|
|
1779
1747
|
if (event === null) return event;
|
|
1748
|
+
const transportToken = event.properties.token;
|
|
1780
1749
|
const seen = /* @__PURE__ */ new WeakSet();
|
|
1781
|
-
sanitizePostHogBrowserValue(event.properties, seen);
|
|
1782
|
-
if (
|
|
1783
|
-
if (event.$
|
|
1750
|
+
sanitizePostHogBrowserValue(event.properties, seen, urlPolicy);
|
|
1751
|
+
if (transportToken !== void 0) event.properties.token = transportToken;
|
|
1752
|
+
if (event.$set !== void 0) sanitizePostHogBrowserValue(event.$set, seen, urlPolicy);
|
|
1753
|
+
if (event.$set_once !== void 0) sanitizePostHogBrowserValue(event.$set_once, seen, urlPolicy);
|
|
1784
1754
|
return event;
|
|
1785
1755
|
}
|
|
1786
1756
|
/**
|
|
1787
1757
|
* Drop local events before transport. Sanitize and stamp every other browser
|
|
1788
1758
|
* event with `capxul_env` and `producer: "browser"`.
|
|
1789
1759
|
*/
|
|
1790
|
-
function createCapxulBeforeSend(capxulEnv) {
|
|
1760
|
+
function createCapxulBeforeSend(capxulEnv, urlPolicy = "path-only") {
|
|
1791
1761
|
return (event) => {
|
|
1792
1762
|
if (capxulEnv === "local") return null;
|
|
1793
|
-
const sanitized = sanitizePostHogBrowserEvent(event);
|
|
1763
|
+
const sanitized = sanitizePostHogBrowserEvent(event, urlPolicy);
|
|
1794
1764
|
if (sanitized === null) return sanitized;
|
|
1795
1765
|
sanitized.properties.capxul_env = capxulEnv;
|
|
1796
1766
|
sanitized.properties.producer ??= "browser";
|
|
@@ -1821,67 +1791,64 @@ function utilityAppAnalytics(input) {
|
|
|
1821
1791
|
before_send: createCapxulBeforeSend(input.capxulEnv)
|
|
1822
1792
|
};
|
|
1823
1793
|
}
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
"x-auth-token"
|
|
1833
|
-
]);
|
|
1834
|
-
/** Any JSON field whose name says "credential", however it is spelled. */
|
|
1835
|
-
const CREDENTIAL_FIELD_NAME = /(?:otp|code|pass(?:word|wd|phrase)?|token|secret|credential|api[_-]?key|private[_-]?key|authorization|cookie|session)/iu;
|
|
1836
|
-
/** Secret material in captured text: bearer tokens, JWTs, keys, and credential fields. */
|
|
1837
|
-
const NETWORK_TEXT_MASKS = [
|
|
1838
|
-
[/\bBearer\s+[^\s,;"]+/giu, "Bearer [REDACTED]"],
|
|
1839
|
-
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]"],
|
|
1840
|
-
[/\b0x[a-fA-F0-9]{40,}\b/gu, "[REDACTED]"],
|
|
1841
|
-
[/\b([A-Za-z_][A-Za-z0-9_.-]*=)[^&\s"]+/gu, "$1[REDACTED]"]
|
|
1842
|
-
];
|
|
1843
|
-
/** `"name": "value"` JSON pairs; the name decides whether the value is masked. */
|
|
1844
|
-
const JSON_STRING_FIELD = /("([^"\\]*)"\s*:\s*")((?:[^"\\]|\\.)*)(")/gu;
|
|
1845
|
-
const QUERY_PAIR = /\b([A-Za-z_][A-Za-z0-9_.-]*=)[^&\s"]+/gu;
|
|
1846
|
-
function maskNetworkText(text) {
|
|
1847
|
-
return NETWORK_TEXT_MASKS.slice(0, 3).reduce((current, [pattern, replacement]) => current.replace(pattern, replacement), text).replace(JSON_STRING_FIELD, (whole, open, name, _value, close) => CREDENTIAL_FIELD_NAME.test(name) ? `${open}[REDACTED]${close}` : whole).replace(QUERY_PAIR, (whole, name) => CREDENTIAL_FIELD_NAME.test(name) ? `${name}[REDACTED]` : whole);
|
|
1794
|
+
/** In unstructured bodies, preserve text around explicitly named credentials. */
|
|
1795
|
+
const NETWORK_FIELD = /((?:"?)([A-Za-z_][A-Za-z0-9_.-]*)(?:"?)\s*[:=]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,;&}]+)/gu;
|
|
1796
|
+
function maskNetworkText(text, context) {
|
|
1797
|
+
return redactSecrets(text).replace(NETWORK_FIELD, (whole, prefix, name, value) => {
|
|
1798
|
+
if (!isCredentialField(name, context)) return whole;
|
|
1799
|
+
const quote = value.startsWith("\"") ? "\"" : value.startsWith("'") ? "'" : "";
|
|
1800
|
+
return `${prefix}${quote}[REDACTED]${quote}`;
|
|
1801
|
+
});
|
|
1848
1802
|
}
|
|
1849
1803
|
function maskNetworkHeaders(headers) {
|
|
1850
1804
|
if (headers === void 0) return void 0;
|
|
1851
1805
|
const masked = {};
|
|
1852
|
-
for (const [name, value] of Object.entries(headers)) masked[name] =
|
|
1806
|
+
for (const [name, value] of Object.entries(headers)) masked[name] = isCredentialField(name, "header") ? "[REDACTED]" : /^(?:location|content-location|referer)$/iu.test(name) ? redactUrlSecrets(value) : maskNetworkText(value);
|
|
1853
1807
|
return masked;
|
|
1854
1808
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
if (typeof value === "string") return maskNetworkText(value);
|
|
1809
|
+
function maskJsonValue(value, depth = 0, context) {
|
|
1810
|
+
if (typeof value === "function" || typeof value === "symbol") return "[UNREADABLE]";
|
|
1811
|
+
if (typeof value === "string") return maskNetworkText(value, context);
|
|
1858
1812
|
if (typeof value !== "object" || value === null) return value;
|
|
1859
1813
|
if (depth >= 16) return "[REDACTED]";
|
|
1860
|
-
if (Array.isArray(value)) return value.map((item) => maskJsonValue(item, depth + 1));
|
|
1861
|
-
const masked =
|
|
1862
|
-
for (const [key,
|
|
1814
|
+
if (Array.isArray(value)) return value.map((item) => maskJsonValue(item, depth + 1, context));
|
|
1815
|
+
const masked = Object.create(null);
|
|
1816
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
1817
|
+
if (!descriptor.enumerable) continue;
|
|
1818
|
+
masked[key] = isCredentialField(key, context) ? "[REDACTED]" : !("value" in descriptor) ? "[UNREADABLE]" : typeof descriptor.value === "string" && isPostHogUrlProperty(key) ? redactUrlSecrets(descriptor.value) : maskJsonValue(descriptor.value, depth + 1, context);
|
|
1819
|
+
}
|
|
1863
1820
|
return masked;
|
|
1864
1821
|
}
|
|
1865
|
-
function maskNetworkBody(body) {
|
|
1866
|
-
if (typeof body !== "string") return body;
|
|
1822
|
+
function maskNetworkBody(body, context) {
|
|
1867
1823
|
try {
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1824
|
+
if (typeof body !== "string") return maskJsonValue(body, 0, context);
|
|
1825
|
+
let parsed;
|
|
1826
|
+
try {
|
|
1827
|
+
parsed = JSON.parse(body);
|
|
1828
|
+
} catch {
|
|
1829
|
+
if (/^[^=&\s]+=[\s\S]*$/u.test(body)) return new URLSearchParams(Array.from(new URLSearchParams(body), ([name, value]) => [name, isCredentialField(name, context) ? "[REDACTED]" : maskNetworkText(value)])).toString();
|
|
1830
|
+
return maskNetworkText(body, context);
|
|
1831
|
+
}
|
|
1832
|
+
return JSON.stringify(maskJsonValue(parsed, 0, context));
|
|
1833
|
+
} catch {
|
|
1834
|
+
return "[UNREADABLE]";
|
|
1835
|
+
}
|
|
1872
1836
|
}
|
|
1873
|
-
/**
|
|
1874
|
-
* Replay keeps the request line, status, timing, headers, and bodies, so a
|
|
1875
|
-
* failed call carries its evidence. Credential headers and secret material
|
|
1876
|
-
* never leave the browser: bearer tokens, cookies, API keys, and the OTP a
|
|
1877
|
-
* user just typed are masked before the recording is sent.
|
|
1878
|
-
*/
|
|
1837
|
+
/** Keep request evidence while masking credential values at the browser boundary. */
|
|
1879
1838
|
function maskCapturedNetworkRequest(request) {
|
|
1839
|
+
const requestUrl = request.name ?? request.url ?? "";
|
|
1840
|
+
let context;
|
|
1841
|
+
try {
|
|
1842
|
+
const path = new URL(requestUrl, "https://capxul.invalid").pathname;
|
|
1843
|
+
if (/\/(?:auth|oauth|sign-in|signin|login|verify-otp|verify-email)(?:\/|$)/iu.test(path)) context = "authentication";
|
|
1844
|
+
} catch {}
|
|
1880
1845
|
return {
|
|
1881
1846
|
...request,
|
|
1847
|
+
...request.name === void 0 ? {} : { name: redactUrlSecrets(request.name) },
|
|
1848
|
+
...request.url === void 0 ? {} : { url: redactUrlSecrets(request.url) },
|
|
1882
1849
|
requestHeaders: maskNetworkHeaders(request.requestHeaders),
|
|
1883
1850
|
responseHeaders: maskNetworkHeaders(request.responseHeaders),
|
|
1884
|
-
requestBody: maskNetworkBody(request.requestBody),
|
|
1851
|
+
requestBody: maskNetworkBody(request.requestBody, context),
|
|
1885
1852
|
responseBody: maskNetworkBody(request.responseBody)
|
|
1886
1853
|
};
|
|
1887
1854
|
}
|
|
@@ -1911,13 +1878,20 @@ function productAppAnalytics(input) {
|
|
|
1911
1878
|
save_referrer: false,
|
|
1912
1879
|
session_recording: {
|
|
1913
1880
|
maskAllInputs: true,
|
|
1914
|
-
maskInputFn: (text, element) =>
|
|
1881
|
+
maskInputFn: (text, element) => {
|
|
1882
|
+
const autocomplete = element?.getAttribute("autocomplete")?.toLowerCase().split(/\s+/u) ?? [];
|
|
1883
|
+
return element?.getAttribute("type")?.toLowerCase() === "password" || autocomplete.some((value) => [
|
|
1884
|
+
"one-time-code",
|
|
1885
|
+
"current-password",
|
|
1886
|
+
"new-password"
|
|
1887
|
+
].includes(value)) || isCredentialField(element?.getAttribute("name") ?? "") || isCredentialField(element?.getAttribute("id") ?? "") ? "*".repeat(text.length) : maskNetworkText(text);
|
|
1888
|
+
},
|
|
1915
1889
|
captureCanvas: { recordCanvas: false },
|
|
1916
1890
|
recordHeaders: true,
|
|
1917
1891
|
recordBody: true,
|
|
1918
1892
|
maskCapturedNetworkRequestFn: maskCapturedNetworkRequest
|
|
1919
1893
|
},
|
|
1920
|
-
before_send: createCapxulBeforeSend(input.capxulEnv)
|
|
1894
|
+
before_send: createCapxulBeforeSend(input.capxulEnv, "diagnostic")
|
|
1921
1895
|
};
|
|
1922
1896
|
}
|
|
1923
1897
|
/**
|
|
@@ -1974,11 +1948,15 @@ function createPostHogBrowserPageviewTracker(client) {
|
|
|
1974
1948
|
} catch {}
|
|
1975
1949
|
};
|
|
1976
1950
|
}
|
|
1977
|
-
function sanitizePostHogBrowserValue(value, seen) {
|
|
1951
|
+
function sanitizePostHogBrowserValue(value, seen, urlPolicy) {
|
|
1978
1952
|
if (typeof value !== "object" || value === null || seen.has(value)) return;
|
|
1979
1953
|
seen.add(value);
|
|
1980
1954
|
if (Array.isArray(value)) {
|
|
1981
|
-
for (
|
|
1955
|
+
for (let index = 0; index < value.length; index++) {
|
|
1956
|
+
const item = value[index];
|
|
1957
|
+
if (typeof item === "string") value[index] = maskNetworkText(item);
|
|
1958
|
+
else sanitizePostHogBrowserValue(item, seen, urlPolicy);
|
|
1959
|
+
}
|
|
1982
1960
|
return;
|
|
1983
1961
|
}
|
|
1984
1962
|
const properties = value;
|
|
@@ -1987,11 +1965,15 @@ function sanitizePostHogBrowserValue(value, seen) {
|
|
|
1987
1965
|
delete properties[key];
|
|
1988
1966
|
continue;
|
|
1989
1967
|
}
|
|
1990
|
-
if (
|
|
1991
|
-
properties[key] =
|
|
1968
|
+
if (isCredentialField(key)) {
|
|
1969
|
+
properties[key] = "[REDACTED]";
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
if (typeof nestedValue === "string") {
|
|
1973
|
+
properties[key] = isPostHogUrlProperty(key) ? urlPolicy === "path-only" ? stripUrlQueryAndFragment(nestedValue) : redactUrlSecrets(nestedValue) : maskNetworkText(nestedValue);
|
|
1992
1974
|
continue;
|
|
1993
1975
|
}
|
|
1994
|
-
sanitizePostHogBrowserValue(nestedValue, seen);
|
|
1976
|
+
sanitizePostHogBrowserValue(nestedValue, seen, urlPolicy);
|
|
1995
1977
|
}
|
|
1996
1978
|
}
|
|
1997
1979
|
const POSTHOG_QUERY_DERIVED_PROPERTY_NAMES = [
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
//#region ../errors/src/secret-material.ts
|
|
2
|
+
const CREDENTIAL_FIELDS = /* @__PURE__ */ new Set([
|
|
3
|
+
"password",
|
|
4
|
+
"passwd",
|
|
5
|
+
"pwd",
|
|
6
|
+
"pass",
|
|
7
|
+
"passphrase",
|
|
8
|
+
"currentpassword",
|
|
9
|
+
"newpassword",
|
|
10
|
+
"confirmpassword",
|
|
11
|
+
"passwordconfirmation",
|
|
12
|
+
"passwordhash",
|
|
13
|
+
"hashedpassword",
|
|
14
|
+
"pin",
|
|
15
|
+
"privatekey",
|
|
16
|
+
"walletprivatekey",
|
|
17
|
+
"secretkey",
|
|
18
|
+
"signingkey",
|
|
19
|
+
"signingsecret",
|
|
20
|
+
"seed",
|
|
21
|
+
"seedphrase",
|
|
22
|
+
"mnemonic",
|
|
23
|
+
"recoveryphrase",
|
|
24
|
+
"recovery",
|
|
25
|
+
"recoverycode",
|
|
26
|
+
"recoverycodes",
|
|
27
|
+
"keyshare",
|
|
28
|
+
"signingmaterial",
|
|
29
|
+
"recoverymaterial",
|
|
30
|
+
"privatekeybytes",
|
|
31
|
+
"signingprivatekey",
|
|
32
|
+
"encryptionkey",
|
|
33
|
+
"decryptionkey",
|
|
34
|
+
"authtoken",
|
|
35
|
+
"apitoken",
|
|
36
|
+
"bearertoken",
|
|
37
|
+
"oauthtoken",
|
|
38
|
+
"csrftoken",
|
|
39
|
+
"clientassertion",
|
|
40
|
+
"token",
|
|
41
|
+
"accesstoken",
|
|
42
|
+
"refreshtoken",
|
|
43
|
+
"idtoken",
|
|
44
|
+
"sessiontoken",
|
|
45
|
+
"session",
|
|
46
|
+
"sessionkey",
|
|
47
|
+
"sessionsecret",
|
|
48
|
+
"authsessionid",
|
|
49
|
+
"sessioncredential",
|
|
50
|
+
"sessioncredentials",
|
|
51
|
+
"sessioncookie",
|
|
52
|
+
"auth",
|
|
53
|
+
"authentication",
|
|
54
|
+
"authorization",
|
|
55
|
+
"proxyauthorization",
|
|
56
|
+
"cookie",
|
|
57
|
+
"cookies",
|
|
58
|
+
"setcookie",
|
|
59
|
+
"apikey",
|
|
60
|
+
"xapikey",
|
|
61
|
+
"xauthtoken",
|
|
62
|
+
"clientsecret",
|
|
63
|
+
"secret",
|
|
64
|
+
"secrets",
|
|
65
|
+
"credential",
|
|
66
|
+
"credentials",
|
|
67
|
+
"signature",
|
|
68
|
+
"otp",
|
|
69
|
+
"otpcode",
|
|
70
|
+
"totpsecret",
|
|
71
|
+
"onetimecode",
|
|
72
|
+
"onetimepassword",
|
|
73
|
+
"verificationcode",
|
|
74
|
+
"authcode",
|
|
75
|
+
"authorizationcode",
|
|
76
|
+
"oauthcode",
|
|
77
|
+
"codeverifier"
|
|
78
|
+
]);
|
|
79
|
+
/** Match credential fields by meaning, without hiding token addresses or replay IDs. */
|
|
80
|
+
function isCredentialField(name, context) {
|
|
81
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9]/gu, "");
|
|
82
|
+
return CREDENTIAL_FIELDS.has(normalized) || /(?:password(?:hash)?|passwd|passphrase|privatekey|signingkey|apikey|clientsecret|accesstoken|refreshtoken|sessiontoken)$/u.test(normalized) || context === "authentication" && (normalized === "code" || normalized === "sessionid") || context === "header" && /(?:token|secret|apikey|privatekey|signingkey|password|authorization|cookie|session)$/u.test(normalized);
|
|
83
|
+
}
|
|
84
|
+
function decodeUrlComponent(text) {
|
|
85
|
+
try {
|
|
86
|
+
return decodeURIComponent(text);
|
|
87
|
+
} catch {
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Preserve URL diagnostics while removing credentials from their declared components. */
|
|
92
|
+
function redactUrlSecrets(value, source) {
|
|
93
|
+
try {
|
|
94
|
+
const absolute = /^[a-z][a-z0-9+.-]*:/iu.test(value);
|
|
95
|
+
const protocolRelative = value.startsWith("//");
|
|
96
|
+
const url = new URL(value, "https://redaction.invalid");
|
|
97
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return maskText(value);
|
|
98
|
+
let changed = url.username.length > 0 || url.password.length > 0;
|
|
99
|
+
const maskParams = (params) => new URLSearchParams(Array.from(params, ([name, entry]) => {
|
|
100
|
+
const safeName = maskText(name);
|
|
101
|
+
const safeEntry = isCredentialField(name) || /^(?:code|draft|__posthog)$/iu.test(name) ? "[REDACTED]" : maskText(entry);
|
|
102
|
+
changed ||= safeName !== name || safeEntry !== entry;
|
|
103
|
+
return [safeName, safeEntry];
|
|
104
|
+
})).toString();
|
|
105
|
+
const provider = source !== void 0 || /(?:^|\.)(?:alchemy\.com|alchemyapi\.io|infura\.io)$/iu.test(url.hostname);
|
|
106
|
+
const segments = (absolute || protocolRelative ? url.pathname : value.split(/[?#]/u)[0] ?? "").split("/");
|
|
107
|
+
const path = segments.map((segment, index) => {
|
|
108
|
+
const decoded = decodeUrlComponent(segment);
|
|
109
|
+
const masked = provider && /^v[23]$/u.test(decodeUrlComponent(segments[index - 1] ?? "")) && decoded.length > 0 ? "[REDACTED]" : maskText(decoded);
|
|
110
|
+
changed ||= masked !== decoded;
|
|
111
|
+
return masked === decoded ? segment : encodeURIComponent(masked);
|
|
112
|
+
}).join("/");
|
|
113
|
+
const query = maskParams(url.searchParams);
|
|
114
|
+
const fragment = url.hash.slice(1);
|
|
115
|
+
const decodedFragment = decodeUrlComponent(fragment);
|
|
116
|
+
const maskedFragment = maskText(decodedFragment);
|
|
117
|
+
changed ||= maskedFragment !== decodedFragment;
|
|
118
|
+
const fragmentQuery = fragment.indexOf("?");
|
|
119
|
+
const fragmentPath = fragment.slice(0, fragmentQuery);
|
|
120
|
+
const maskedFragmentPath = maskText(decodeUrlComponent(fragmentPath));
|
|
121
|
+
changed ||= maskedFragmentPath !== decodeUrlComponent(fragmentPath);
|
|
122
|
+
const hash = fragmentQuery >= 0 ? `${maskedFragmentPath === decodeUrlComponent(fragmentPath) ? fragmentPath : encodeURIComponent(maskedFragmentPath)}?${maskParams(new URLSearchParams(fragment.slice(fragmentQuery + 1)))}` : fragment.includes("=") ? maskParams(new URLSearchParams(fragment)) : maskedFragment === decodedFragment ? fragment : encodeURIComponent(maskedFragment);
|
|
123
|
+
const origin = absolute ? url.origin : protocolRelative ? `//${url.host}` : "";
|
|
124
|
+
if (!changed) return value;
|
|
125
|
+
return `${origin}${path}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
|
|
126
|
+
} catch {
|
|
127
|
+
return maskText(value.replace(/(https?:\/\/)[^/\s@]+@/giu, "$1[REDACTED]@").replace(/([?&#])([^=&#]+)=([^&#]*)/gu, (whole, separator, name) => isCredentialField(decodeUrlComponent(name)) || /^(?:code|draft|__posthog)$/iu.test(decodeUrlComponent(name)) ? `${separator}${name}=[REDACTED]` : whole));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const JSON_FIELD = /("(?:[^"\\]|\\.)*")(\s*:\s*)("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/gu;
|
|
131
|
+
const PROSE_FIELD = /(?=(\b([A-Za-z][A-Za-z0-9_-]*(?:[ \t]+[A-Za-z][A-Za-z0-9_-]*){0,3})\s*[:=]\s*((?:Bearer|Basic)[ \t]+[^\s,;"']+|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,;&"']+)))/gu;
|
|
132
|
+
const EMBEDDED_URL = /(https?:\/\/[^\s"<>]*)/giu;
|
|
133
|
+
function isSecretLabel(name) {
|
|
134
|
+
return isCredentialField(name) || /^request[ _-]?body$/iu.test(name);
|
|
135
|
+
}
|
|
136
|
+
const JSON_ENCODING_MAX = 8;
|
|
137
|
+
const JSON_NESTING_MAX = 64;
|
|
138
|
+
const JSON_KEY = /("(?:[^"\\]|\\.)*")\s*:/gu;
|
|
139
|
+
const JSON_CONTAINER_START = /(?:\{\s*["}]|\[\s*[[\]{}"0-9tfn-])/uy;
|
|
140
|
+
function hasSecretJsonKey(value) {
|
|
141
|
+
for (const match of value.matchAll(JSON_KEY)) try {
|
|
142
|
+
if (match[1] !== void 0 && isSecretLabel(JSON.parse(match[1]))) return true;
|
|
143
|
+
} catch {}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
/** Project a complete fragment once. Never retry all of its nested substrings. */
|
|
147
|
+
function projectJsonFragment(value, encodingDepth, overDepth) {
|
|
148
|
+
if (overDepth || encodingDepth >= JSON_ENCODING_MAX) return "[REDACTED]";
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(value);
|
|
152
|
+
} catch {
|
|
153
|
+
return hasSecretJsonKey(value) ? "[REDACTED]" : value;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
let changed = false;
|
|
157
|
+
const masked = JSON.stringify(parsed, (name, nested) => {
|
|
158
|
+
if (isSecretLabel(name)) {
|
|
159
|
+
changed ||= nested !== "[REDACTED]";
|
|
160
|
+
return "[REDACTED]";
|
|
161
|
+
}
|
|
162
|
+
if (typeof nested === "string") {
|
|
163
|
+
const projected = maskJsonFragments(nested, encodingDepth + 1);
|
|
164
|
+
changed ||= projected !== nested;
|
|
165
|
+
return projected;
|
|
166
|
+
}
|
|
167
|
+
return nested;
|
|
168
|
+
});
|
|
169
|
+
return changed ? masked : value;
|
|
170
|
+
} catch {
|
|
171
|
+
return "[REDACTED]";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Scan balanced object/array fragments once, respecting JSON quotes and escapes. */
|
|
175
|
+
function maskJsonFragments(value, encodingDepth = 0) {
|
|
176
|
+
if (value.trimStart().startsWith("\"")) try {
|
|
177
|
+
const decoded = JSON.parse(value);
|
|
178
|
+
if (typeof decoded === "string") {
|
|
179
|
+
if (encodingDepth >= JSON_ENCODING_MAX) return "\"[REDACTED]\"";
|
|
180
|
+
const masked = maskJsonFragments(decoded, encodingDepth + 1);
|
|
181
|
+
return masked === decoded ? value : JSON.stringify(masked);
|
|
182
|
+
}
|
|
183
|
+
} catch {}
|
|
184
|
+
let output = "";
|
|
185
|
+
let cursor = 0;
|
|
186
|
+
let start = -1;
|
|
187
|
+
let depth = 0;
|
|
188
|
+
let overDepth = false;
|
|
189
|
+
let quoted = false;
|
|
190
|
+
let escaped = false;
|
|
191
|
+
for (let index = 0; index < value.length; index++) {
|
|
192
|
+
const character = value[index];
|
|
193
|
+
if (start < 0) {
|
|
194
|
+
JSON_CONTAINER_START.lastIndex = index;
|
|
195
|
+
if (!JSON_CONTAINER_START.test(value)) continue;
|
|
196
|
+
start = index;
|
|
197
|
+
depth = 1;
|
|
198
|
+
overDepth = false;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (quoted) {
|
|
202
|
+
if (escaped) escaped = false;
|
|
203
|
+
else if (character === "\\") escaped = true;
|
|
204
|
+
else if (character === "\"") quoted = false;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (character === "\"") quoted = true;
|
|
208
|
+
else if (character === "{" || character === "[") {
|
|
209
|
+
depth++;
|
|
210
|
+
overDepth ||= depth > JSON_NESTING_MAX;
|
|
211
|
+
} else if (character === "}" || character === "]") {
|
|
212
|
+
depth--;
|
|
213
|
+
if (depth !== 0) continue;
|
|
214
|
+
output += value.slice(cursor, start) + projectJsonFragment(value.slice(start, index + 1), encodingDepth, overDepth);
|
|
215
|
+
cursor = index + 1;
|
|
216
|
+
start = -1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (start >= 0) {
|
|
220
|
+
const fragment = value.slice(start);
|
|
221
|
+
output += value.slice(cursor, start) + (overDepth || hasSecretJsonKey(fragment) ? "[REDACTED]" : fragment);
|
|
222
|
+
cursor = value.length;
|
|
223
|
+
}
|
|
224
|
+
return output + value.slice(cursor);
|
|
225
|
+
}
|
|
226
|
+
function maskLabelledText(value) {
|
|
227
|
+
const jsonMasked = maskJsonFragments(value).replace(JSON_FIELD, (whole, quotedName, separator) => {
|
|
228
|
+
try {
|
|
229
|
+
return isSecretLabel(JSON.parse(quotedName)) ? `${quotedName}${separator}"[REDACTED]"` : whole;
|
|
230
|
+
} catch {
|
|
231
|
+
return whole;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
let output = "";
|
|
235
|
+
let cursor = 0;
|
|
236
|
+
for (const match of jsonMasked.matchAll(PROSE_FIELD)) {
|
|
237
|
+
const [, whole, label, entry] = match;
|
|
238
|
+
if (whole === void 0 || label === void 0 || entry === void 0 || match.index < cursor || /^Bearer[ \t]+/iu.test(entry)) continue;
|
|
239
|
+
const words = label.split(/[ \t]+/u);
|
|
240
|
+
for (let index = 0; index < words.length; index++) {
|
|
241
|
+
const name = words.slice(index).join(" ");
|
|
242
|
+
if (isSecretLabel(name)) {
|
|
243
|
+
const prefix = words.slice(0, index).join(" ");
|
|
244
|
+
output += jsonMasked.slice(cursor, match.index) + `${prefix ? `${prefix} ` : ""}${name}=[REDACTED]`;
|
|
245
|
+
cursor = match.index + whole.length;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return output + jsonMasked.slice(cursor);
|
|
251
|
+
}
|
|
252
|
+
const TOKEN_MASKS = [
|
|
253
|
+
[/\bBearer\s+(?!(?:realm|error|error_description|scope|authorization_uri|resource|claims)\s*=)[^\s,;"']+/giu, "Bearer [REDACTED]"],
|
|
254
|
+
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]"],
|
|
255
|
+
[/(^|[^a-fA-F0-9])(?<!0[xX])[a-fA-F0-9]{64}(?=$|[^a-fA-F0-9])/gu, "$1[REDACTED]"],
|
|
256
|
+
[/(?:(?:sk|pk|rk)_(?:live|test)|(?:phc|phx|ghp|gho|ghu|ghs|ghr|whsec)_|github_pat_|xox[baprs]-|AKIA[0-9A-Z]{16}|AIza)[A-Za-z0-9_-]*/giu, "[REDACTED]"]
|
|
257
|
+
];
|
|
258
|
+
/** URL components and malformed URLs use this non-recursive masking layer. */
|
|
259
|
+
function maskText(value) {
|
|
260
|
+
let text = maskLabelledText(value);
|
|
261
|
+
for (const [pattern, replacement] of TOKEN_MASKS) text = text.replace(pattern, replacement);
|
|
262
|
+
return text;
|
|
263
|
+
}
|
|
264
|
+
/** Mask credential fields and embedded URLs while preserving surrounding evidence. */
|
|
265
|
+
function redactSecrets(value, source) {
|
|
266
|
+
return maskLabelledText(value).split(EMBEDDED_URL).map((part, index) => index % 2 === 1 ? redactUrlSecrets(part, source).replaceAll("%5BREDACTED%5D", "[REDACTED]") : maskText(part)).join("");
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region ../errors/src/errors.ts
|
|
270
|
+
const CAPXUL_ERROR_CODES = [
|
|
271
|
+
"NOT_AUTHENTICATED",
|
|
272
|
+
"EMAIL_DELIVERY_FAILED",
|
|
273
|
+
"PROFILE_NOT_FOUND",
|
|
274
|
+
"SMART_ACCOUNT_MISSING",
|
|
275
|
+
"PLAYER_NOT_FOUND",
|
|
276
|
+
"ACCOUNT_NOT_FOUND",
|
|
277
|
+
"ACCOUNT_CLAIMED_BY_OTHER_SIGNER",
|
|
278
|
+
"PROVIDER_ERROR",
|
|
279
|
+
"CAPABILITY_UNAVAILABLE",
|
|
280
|
+
"INVALID_INPUT",
|
|
281
|
+
"ENV_MISSING",
|
|
282
|
+
"NOT_IMPLEMENTED",
|
|
283
|
+
"VERIFICATION_REQUIRED",
|
|
284
|
+
"INSUFFICIENT_BALANCE",
|
|
285
|
+
"INVALID_RECIPIENT",
|
|
286
|
+
"ROLE_PERMISSION_DENIED",
|
|
287
|
+
"TRANSACTION_FAILED",
|
|
288
|
+
"RATE_LIMITED",
|
|
289
|
+
"NETWORK_ERROR",
|
|
290
|
+
"UNKNOWN",
|
|
291
|
+
"OTP_EXPIRED",
|
|
292
|
+
"SIGNER_REJECTED",
|
|
293
|
+
"CANCELLED",
|
|
294
|
+
"WRONG_STATE",
|
|
295
|
+
"STALE_EPOCH",
|
|
296
|
+
"SUPERSEDED",
|
|
297
|
+
"WORK_DIED",
|
|
298
|
+
"ACTOR_STOPPED"
|
|
299
|
+
];
|
|
300
|
+
new Set(CAPXUL_ERROR_CODES);
|
|
301
|
+
//#endregion
|
|
302
|
+
export { redactUrlSecrets as i, isCredentialField as n, redactSecrets as r, CAPXUL_ERROR_CODES as t };
|