drengr-js 0.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/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +24 -0
- package/dist/cjs/capture.js +378 -0
- package/dist/cjs/index.js +184 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/redact.js +351 -0
- package/dist/cjs/sink.js +276 -0
- package/dist/esm/capture.d.ts +49 -0
- package/dist/esm/capture.js +371 -0
- package/dist/esm/index.d.ts +61 -0
- package/dist/esm/index.js +181 -0
- package/dist/esm/redact.d.ts +18 -0
- package/dist/esm/redact.js +341 -0
- package/dist/esm/sink.d.ts +71 -0
- package/dist/esm/sink.js +271 -0
- package/package.json +48 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Drengr Analytics for JavaScript runtimes — Web, React Native, Electron.
|
|
4
|
+
* One call captures every fetch/XHR exchange (secret/PII redaction applied
|
|
5
|
+
* in-process before anything leaves the runtime) and ships it to your org's
|
|
6
|
+
* ingest endpoint under a publishable key.
|
|
7
|
+
*
|
|
8
|
+
* import { Drengr } from 'drengr-js';
|
|
9
|
+
* Drengr.start({
|
|
10
|
+
* ingestUrl: 'https://<ref>.supabase.co/functions/v1/ingest',
|
|
11
|
+
* publishableKey: 'drengr_pk_…',
|
|
12
|
+
* appPackage: 'com.example.app',
|
|
13
|
+
* });
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.SDK_VERSION = exports.Drengr = exports.redact = exports.IngestSink = void 0;
|
|
17
|
+
const capture_js_1 = require("./capture.js");
|
|
18
|
+
const sink_js_1 = require("./sink.js");
|
|
19
|
+
Object.defineProperty(exports, "IngestSink", { enumerable: true, get: function () { return sink_js_1.IngestSink; } });
|
|
20
|
+
exports.redact = require("./redact.js");
|
|
21
|
+
const INSTALL_KEY = 'drengr_install_id';
|
|
22
|
+
const OPTOUT_KEY = 'drengr_opt_out';
|
|
23
|
+
let sink = null;
|
|
24
|
+
let storageRef = null;
|
|
25
|
+
function runtimeOs() {
|
|
26
|
+
try {
|
|
27
|
+
const nav = globalThis.navigator;
|
|
28
|
+
if (nav?.product === 'ReactNative')
|
|
29
|
+
return 'react-native';
|
|
30
|
+
const proc = globalThis.process;
|
|
31
|
+
if (proc?.versions?.electron)
|
|
32
|
+
return 'electron';
|
|
33
|
+
if (nav?.userAgent)
|
|
34
|
+
return 'web';
|
|
35
|
+
if (proc?.versions?.node)
|
|
36
|
+
return 'node';
|
|
37
|
+
}
|
|
38
|
+
catch { /* fall through */ }
|
|
39
|
+
return 'js';
|
|
40
|
+
}
|
|
41
|
+
function isThenable(v) {
|
|
42
|
+
return typeof v === 'object' && v !== null && typeof v.then === 'function';
|
|
43
|
+
}
|
|
44
|
+
function genId() {
|
|
45
|
+
try {
|
|
46
|
+
const c = globalThis.crypto;
|
|
47
|
+
if (c?.randomUUID)
|
|
48
|
+
return c.randomUUID();
|
|
49
|
+
}
|
|
50
|
+
catch { /* fall through */ }
|
|
51
|
+
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
52
|
+
}
|
|
53
|
+
exports.Drengr = {
|
|
54
|
+
/** Install capture + delivery. Subsequent calls are ignored (stop() first). */
|
|
55
|
+
start(options) {
|
|
56
|
+
if ((0, capture_js_1.isInstalled)())
|
|
57
|
+
return;
|
|
58
|
+
const storage = options.storage ?? (0, sink_js_1.defaultStorage)();
|
|
59
|
+
storageRef = storage; // so optOut()/optIn() can persist the choice after start()
|
|
60
|
+
// A mutable context: on async storage (React Native's AsyncStorage) the
|
|
61
|
+
// install_id resolves after start() returns, so the sink reads it lazily and
|
|
62
|
+
// we backfill here before the first flush.
|
|
63
|
+
const context = {
|
|
64
|
+
app_package: options.appPackage,
|
|
65
|
+
os: runtimeOs(),
|
|
66
|
+
install_id: '', // filled sync (localStorage) or async (AsyncStorage) below
|
|
67
|
+
session_id: `s-${Date.now()}`,
|
|
68
|
+
sdk_version: exports.SDK_VERSION,
|
|
69
|
+
...options.context,
|
|
70
|
+
};
|
|
71
|
+
const s = new sink_js_1.IngestSink({
|
|
72
|
+
url: options.ingestUrl,
|
|
73
|
+
publishableKey: options.publishableKey,
|
|
74
|
+
storage,
|
|
75
|
+
context,
|
|
76
|
+
});
|
|
77
|
+
sink = s;
|
|
78
|
+
// CONSENT-SAFE START. Sync storage (localStorage): read the opt-out NOW and
|
|
79
|
+
// fold it into startPaused so an opted-out user is paused BEFORE capture goes
|
|
80
|
+
// live — reading it a microtask later let same-tick requests leak. Async
|
|
81
|
+
// storage (RN AsyncStorage): can't read synchronously, so start PAUSED and
|
|
82
|
+
// enable only after the async read resolves (never if opted out).
|
|
83
|
+
const asyncStore = isThenable(storage.getItem(INSTALL_KEY));
|
|
84
|
+
const syncOptOut = !asyncStore && storage.getItem(OPTOUT_KEY) === '1';
|
|
85
|
+
const startPaused = options.enabled === false || asyncStore || syncOptOut;
|
|
86
|
+
// Always exclude our OWN ingest host: on React Native fetch is XHR-backed, so
|
|
87
|
+
// the pre-patch fetch the sink uses still routes through the patched XHR and
|
|
88
|
+
// would self-capture every delivery. Ignoring the host closes that loop.
|
|
89
|
+
const ignoreHosts = new Set((options.ignoreHosts ?? []).map((h) => h.toLowerCase()));
|
|
90
|
+
try {
|
|
91
|
+
const ih = new URL(options.ingestUrl).host;
|
|
92
|
+
if (ih)
|
|
93
|
+
ignoreHosts.add(ih.toLowerCase());
|
|
94
|
+
}
|
|
95
|
+
catch { /* malformed ingest URL: nothing to add */ }
|
|
96
|
+
(0, capture_js_1.install)({
|
|
97
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
98
|
+
captureWhen: options.captureWhen,
|
|
99
|
+
ignoreHosts,
|
|
100
|
+
redactHeaderNames: options.redactHeaders
|
|
101
|
+
? new Set(options.redactHeaders.map((h) => h.toLowerCase()))
|
|
102
|
+
: undefined,
|
|
103
|
+
onEvent: (e) => {
|
|
104
|
+
try {
|
|
105
|
+
options.onEvent?.(e);
|
|
106
|
+
}
|
|
107
|
+
catch { /* app callback must not break delivery */ }
|
|
108
|
+
s.addNetwork(e);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
if (startPaused)
|
|
112
|
+
(0, capture_js_1.setEnabled)(false);
|
|
113
|
+
// Resolve install_id + opt-out across BOTH sync and async storage.
|
|
114
|
+
void Promise.resolve(storage.getItem(INSTALL_KEY)).then((existing) => {
|
|
115
|
+
let id = typeof existing === 'string' && existing ? existing : '';
|
|
116
|
+
if (!id) {
|
|
117
|
+
id = genId();
|
|
118
|
+
try {
|
|
119
|
+
void storage.setItem(INSTALL_KEY, id);
|
|
120
|
+
}
|
|
121
|
+
catch { /* memory-only ok */ }
|
|
122
|
+
}
|
|
123
|
+
context.install_id = id;
|
|
124
|
+
});
|
|
125
|
+
void Promise.resolve(storage.getItem(OPTOUT_KEY)).then((v) => {
|
|
126
|
+
if (v === '1') {
|
|
127
|
+
(0, capture_js_1.setEnabled)(false); // opted out — stay paused regardless
|
|
128
|
+
}
|
|
129
|
+
else if (options.enabled !== false && !syncOptOut) {
|
|
130
|
+
(0, capture_js_1.setEnabled)(true); // not opted out and not explicitly disabled — resume
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
/** Pause/resume capture (consent gate). Delivery of already-captured events continues. */
|
|
135
|
+
setEnabled(v) {
|
|
136
|
+
(0, capture_js_1.setEnabled)(v);
|
|
137
|
+
},
|
|
138
|
+
/** Persistently opt this install OUT of capture (GDPR). Unlike setEnabled(false),
|
|
139
|
+
* this survives restart: it writes the opt-out flag to storage AND pauses now, so
|
|
140
|
+
* start() reads it and stays paused on the next launch. */
|
|
141
|
+
optOut() {
|
|
142
|
+
(0, capture_js_1.setEnabled)(false);
|
|
143
|
+
try {
|
|
144
|
+
void storageRef?.setItem(OPTOUT_KEY, '1');
|
|
145
|
+
}
|
|
146
|
+
catch { /* memory-only ok */ }
|
|
147
|
+
},
|
|
148
|
+
/** Reverse optOut(): clear the persisted flag and resume capture. */
|
|
149
|
+
optIn() {
|
|
150
|
+
try {
|
|
151
|
+
void storageRef?.removeItem(OPTOUT_KEY);
|
|
152
|
+
}
|
|
153
|
+
catch { /* memory-only ok */ }
|
|
154
|
+
(0, capture_js_1.setEnabled)(true);
|
|
155
|
+
},
|
|
156
|
+
/** Flush the queue now (e.g. before navigation). Best-effort. */
|
|
157
|
+
flush() {
|
|
158
|
+
return sink?.flush() ?? Promise.resolve();
|
|
159
|
+
},
|
|
160
|
+
/** Sets external_id (your own stable, non-PII user id — not an email) on the
|
|
161
|
+
* session and all events hereafter; emits one identify event. traits are
|
|
162
|
+
* redacted before delivery. Fail-open: no-op if start() hasn't run or
|
|
163
|
+
* externalId is empty. */
|
|
164
|
+
identify(externalId, traits) {
|
|
165
|
+
try {
|
|
166
|
+
sink?.identify(externalId, traits);
|
|
167
|
+
}
|
|
168
|
+
catch { /* fail-open */ }
|
|
169
|
+
},
|
|
170
|
+
/** Tags the session with an experiment variant, attached to all events
|
|
171
|
+
* hereafter as `experiments`. Pass a null/empty variant to clear the key. */
|
|
172
|
+
setExperiment(key, variant) {
|
|
173
|
+
try {
|
|
174
|
+
sink?.setExperiment(key, variant);
|
|
175
|
+
}
|
|
176
|
+
catch { /* fail-open */ }
|
|
177
|
+
},
|
|
178
|
+
/** Uninstall capture, restoring the runtime's own fetch/XHR. */
|
|
179
|
+
stop() {
|
|
180
|
+
(0, capture_js_1.uninstall)();
|
|
181
|
+
sink = null;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
exports.SDK_VERSION = '0.1.0';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Secret + PII redaction for captured events (port of redact.dart): structural
|
|
3
|
+
// key-masking + value-level scrubbing; best-effort, never throws (input unchanged).
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.sensitiveHeaders = exports.redactMask = void 0;
|
|
6
|
+
exports.isSensitiveName = isSensitiveName;
|
|
7
|
+
exports.redactHeaders = redactHeaders;
|
|
8
|
+
exports.scrubValues = scrubValues;
|
|
9
|
+
exports.redactUrl = redactUrl;
|
|
10
|
+
exports.scrubNamedValues = scrubNamedValues;
|
|
11
|
+
exports.redactBody = redactBody;
|
|
12
|
+
exports.projectBody = projectBody;
|
|
13
|
+
exports.redactMask = '[REDACTED]';
|
|
14
|
+
/** Header names (lowercase) whose values are always masked. */
|
|
15
|
+
exports.sensitiveHeaders = new Set([
|
|
16
|
+
'authorization',
|
|
17
|
+
'proxy-authorization',
|
|
18
|
+
'cookie',
|
|
19
|
+
'set-cookie',
|
|
20
|
+
'x-auth-token',
|
|
21
|
+
'x-api-key',
|
|
22
|
+
'x-access-token',
|
|
23
|
+
'x-session-token',
|
|
24
|
+
'x-secret',
|
|
25
|
+
'www-authenticate',
|
|
26
|
+
'proxy-authenticate',
|
|
27
|
+
'x-csrf-token',
|
|
28
|
+
'x-xsrf-token',
|
|
29
|
+
]);
|
|
30
|
+
/** Whole-name matches only — short tokens here so `pin` doesn't hit `shipping`. */
|
|
31
|
+
const sensitiveExact = new Set([
|
|
32
|
+
'password', 'passwd', 'pwd', 'pass', 'passphrase', 'secret', 'token',
|
|
33
|
+
'authorization', 'pin', 'cvv', 'cvc', 'csc', 'cvv2', 'ssn', 'sin',
|
|
34
|
+
'otp', 'totp', 'iban',
|
|
35
|
+
// De-masked (3-tier policy): 'auth', 'pan', 'sig' — see the Dart source note.
|
|
36
|
+
]);
|
|
37
|
+
/** Longer fragments safe to match as substrings of a normalized name. */
|
|
38
|
+
const sensitiveFragments = [
|
|
39
|
+
'token', 'secret', 'password', 'passphrase', 'apikey', 'apisecret',
|
|
40
|
+
'accesstoken', 'refreshtoken', 'idtoken', 'oauthtoken', 'privatekey',
|
|
41
|
+
'secretkey', 'sessiontoken', 'cardnumber', 'cardno', 'ccnumber',
|
|
42
|
+
'creditcard', 'accountnumber', 'routingnumber', 'sortcode',
|
|
43
|
+
// Rare-substring tokens as fragments to catch compounds (card_cvv, user_ssn); pin/pass/sin stay whole-name-only.
|
|
44
|
+
'cvv', 'cvc', 'cvv2', 'ssn', 'otp', 'totp', 'passphrase',
|
|
45
|
+
// Personal data (PII), redacted by default — 0-code means 0-code PII safety.
|
|
46
|
+
'email', 'phone', 'firstname', 'lastname', 'fullname', 'username',
|
|
47
|
+
'recipientname', 'customername', 'sendername', 'passport', 'nationality',
|
|
48
|
+
'address', 'birthdate', 'dateofbirth', 'promocode', 'promotioncode',
|
|
49
|
+
'messagetext', 'giftmessage',
|
|
50
|
+
// De-masked: 'sessionid' (correlation key, not a credential), 'signature'.
|
|
51
|
+
];
|
|
52
|
+
/** Whether a header/query/field name denotes a secret. */
|
|
53
|
+
function isSensitiveName(name) {
|
|
54
|
+
const n = name.toLowerCase().replace(/[_\-$@.\s]/g, '');
|
|
55
|
+
if (sensitiveExact.has(n))
|
|
56
|
+
return true;
|
|
57
|
+
for (const f of sensitiveFragments) {
|
|
58
|
+
if (n.includes(f))
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/** Mask the values of sensitive headers; preserve every key. */
|
|
64
|
+
function redactHeaders(headers, extra = new Set()) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
67
|
+
const lk = k.toLowerCase();
|
|
68
|
+
out[k] = exports.sensitiveHeaders.has(lk) || extra.has(lk) ? exports.redactMask : v;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
// --- value-level scrubbers (run over any text or URL) ---
|
|
73
|
+
// 13+ digits with optional single space/dash separators between digits.
|
|
74
|
+
const digitRun = /[0-9](?:[ -]?[0-9]){11,}/g;
|
|
75
|
+
const jwtRe = /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]*/g;
|
|
76
|
+
const bearerRe = /[Bb]earer\s+[A-Za-z0-9\-._~+/]+=*/g;
|
|
77
|
+
const cookieLineRe = /^(set-cookie|cookie)\s*:\s*.*$/gim;
|
|
78
|
+
// Free-text PII by VALUE PATTERN (audit blocker #1). Phone needs separators so bare id/timestamp runs aren't hit.
|
|
79
|
+
const emailRe = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
80
|
+
const ssnRe = /\b\d{3}-\d{2}-\d{4}\b/g;
|
|
81
|
+
const phoneRe = /(?:\+\d{1,3}[ .-]?)?\(?\d{3}\)?[ .-]\d{3}[ .-]\d{4}\b/g;
|
|
82
|
+
// Well-known opaque SECRETS by unambiguous vendor prefix — catches a key under a
|
|
83
|
+
// benign field name (name-masking misses). Zero-FP by anchoring on the prefix.
|
|
84
|
+
const secretTokenRe = /\b(?:(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|gh[opusr]_[A-Za-z0-9]{36,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
|
|
85
|
+
// PEM private key block (catastrophic if shipped).
|
|
86
|
+
const pemRe = /-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g;
|
|
87
|
+
function luhn(digits) {
|
|
88
|
+
if (digits.length < 13)
|
|
89
|
+
return false;
|
|
90
|
+
let sum = 0;
|
|
91
|
+
let alt = false;
|
|
92
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
93
|
+
let n = digits.charCodeAt(i) - 48;
|
|
94
|
+
if (n < 0 || n > 9)
|
|
95
|
+
return false;
|
|
96
|
+
if (alt) {
|
|
97
|
+
n *= 2;
|
|
98
|
+
if (n > 9)
|
|
99
|
+
n -= 9;
|
|
100
|
+
}
|
|
101
|
+
sum += n;
|
|
102
|
+
alt = !alt;
|
|
103
|
+
}
|
|
104
|
+
return sum % 10 === 0;
|
|
105
|
+
}
|
|
106
|
+
/** Redact card numbers, JWTs, bearer tokens, cookie lines, and PII anywhere in a string. */
|
|
107
|
+
function scrubValues(s) {
|
|
108
|
+
let out = s.replace(digitRun, (m) => {
|
|
109
|
+
const digits = m.replace(/[ -]/g, '');
|
|
110
|
+
if (digits.length > 40)
|
|
111
|
+
return '[REDACTED-PAN]'; // suspicious + bounds work
|
|
112
|
+
for (let len = 13; len <= 19 && len <= digits.length; len++) {
|
|
113
|
+
for (let i = 0; i + len <= digits.length; i++) {
|
|
114
|
+
if (luhn(digits.substring(i, i + len)))
|
|
115
|
+
return '[REDACTED-PAN]';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return m;
|
|
119
|
+
});
|
|
120
|
+
out = out.replace(jwtRe, '[REDACTED-JWT]');
|
|
121
|
+
out = out.replace(bearerRe, `Bearer ${exports.redactMask}`);
|
|
122
|
+
out = out.replace(cookieLineRe, (_m, p1) => `${p1}: ${exports.redactMask}`);
|
|
123
|
+
out = out.replace(emailRe, '[REDACTED-EMAIL]');
|
|
124
|
+
out = out.replace(ssnRe, '[REDACTED-SSN]');
|
|
125
|
+
out = out.replace(phoneRe, '[REDACTED-PHONE]');
|
|
126
|
+
out = out.replace(secretTokenRe, '[REDACTED-SECRET]');
|
|
127
|
+
out = out.replace(pemRe, '[REDACTED-KEY]');
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/** Mask sensitive query/fragment params and scrub secrets in the path. */
|
|
131
|
+
function redactUrl(url) {
|
|
132
|
+
try {
|
|
133
|
+
let result = url;
|
|
134
|
+
let u = null;
|
|
135
|
+
let relative = false;
|
|
136
|
+
try {
|
|
137
|
+
u = new URL(url);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Relative URL: parse against a dummy base so query/fragment masking runs, then strip it — else params leak.
|
|
141
|
+
try {
|
|
142
|
+
u = new URL(url, 'http://drengr.invalid');
|
|
143
|
+
relative = true;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
u = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (u) {
|
|
150
|
+
if (u.search.length > 1) {
|
|
151
|
+
const params = new URLSearchParams(u.search);
|
|
152
|
+
const masked = new URLSearchParams();
|
|
153
|
+
for (const [k, v] of params) {
|
|
154
|
+
masked.append(k, isSensitiveName(k) ? exports.redactMask : v);
|
|
155
|
+
}
|
|
156
|
+
u.search = masked.toString();
|
|
157
|
+
}
|
|
158
|
+
const frag = u.hash.startsWith('#') ? u.hash.slice(1) : u.hash;
|
|
159
|
+
if (frag.includes('=')) {
|
|
160
|
+
const newFrag = frag
|
|
161
|
+
.split('&')
|
|
162
|
+
.map((pair) => {
|
|
163
|
+
const i = pair.indexOf('=');
|
|
164
|
+
if (i > 0 && isSensitiveName(pair.substring(0, i))) {
|
|
165
|
+
return `${pair.substring(0, i)}=${exports.redactMask}`;
|
|
166
|
+
}
|
|
167
|
+
return pair;
|
|
168
|
+
})
|
|
169
|
+
.join('&');
|
|
170
|
+
u.hash = `#${newFrag}`;
|
|
171
|
+
}
|
|
172
|
+
result = relative ? u.toString().slice('http://drengr.invalid'.length) : u.toString();
|
|
173
|
+
}
|
|
174
|
+
return scrubValues(result);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return url;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Mask a value whenever its adjacent NAME is sensitive — for bodies key-masking
|
|
181
|
+
// can't reach (JSON truncated past the cap, XML/SOAP) and inline literals in a
|
|
182
|
+
// parsed JSON string (GraphQL `query`). Value stops at any backslash/quote so a
|
|
183
|
+
// JSON-wrapped literal matches the INNERMOST name:"value" pair. Bounded → no ReDoS.
|
|
184
|
+
// escNamed runs FIRST (anchored on the escaped quote) so a plain-quote wrapper
|
|
185
|
+
// can't shadow the first inner literal.
|
|
186
|
+
const escNamedRe = /([A-Za-z][A-Za-z0-9_.\-]{0,63})(\s*[:=]\s*)\\"[^"\\]{0,8192}\\"/g;
|
|
187
|
+
const dqNamedRe = /(["']?)([A-Za-z][A-Za-z0-9_.\-]{0,63})\1(\s*[:=]\s*\\?")[^"\\]{0,8192}(\\?")/g;
|
|
188
|
+
const sqNamedRe = /(["']?)([A-Za-z][A-Za-z0-9_.\-]{0,63})\1(\s*[:=]\s*\\?')[^'\\]{0,8192}(\\?')/g;
|
|
189
|
+
// XML element text: <name>value</name>
|
|
190
|
+
const xmlElemRe = /<([A-Za-z][A-Za-z0-9_.\-:]{0,63})>[^<]{0,8192}<\/\1\s*>/g;
|
|
191
|
+
// Bare numeric/bool under a quoted JSON key ("cvv":123) — value-scrubbing skips short digit runs, so it'd leak.
|
|
192
|
+
const jsonNumRe = /("[A-Za-z][A-Za-z0-9_.\-]{0,63}"\s*:\s*)(-?\d[\d.eE+\-]{0,40}|true|false)/g;
|
|
193
|
+
/** Mask values whose adjacent name is sensitive (see note above). Best-effort. */
|
|
194
|
+
function scrubNamedValues(s) {
|
|
195
|
+
let out = s.replace(escNamedRe, (m, name, sep) => isSensitiveName(name) ? `${name}${sep}\\"${exports.redactMask}\\"` : m);
|
|
196
|
+
out = out.replace(dqNamedRe, (m, q, name, sep, close) => isSensitiveName(name) ? `${q}${name}${q}${sep}${exports.redactMask}${close}` : m);
|
|
197
|
+
out = out.replace(sqNamedRe, (m, q, name, sep, close) => isSensitiveName(name) ? `${q}${name}${q}${sep}${exports.redactMask}${close}` : m);
|
|
198
|
+
out = out.replace(xmlElemRe, (m, name) => isSensitiveName(name) ? `<${name}>${exports.redactMask}</${name}>` : m);
|
|
199
|
+
out = out.replace(jsonNumRe, (m, head, _val) => {
|
|
200
|
+
const name = head.slice(1, head.indexOf('"', 1));
|
|
201
|
+
return isSensitiveName(name) ? `${head}${exports.redactMask}` : m;
|
|
202
|
+
});
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/** Redact a body string: structurally (JSON keys / form fields) then by value. */
|
|
206
|
+
function redactBody(body) {
|
|
207
|
+
try {
|
|
208
|
+
const decoded = tryJson(body);
|
|
209
|
+
let out;
|
|
210
|
+
if (decoded !== undefined)
|
|
211
|
+
out = scrubValues(JSON.stringify(redactJson(decoded)));
|
|
212
|
+
else if (looksFormEncoded(body))
|
|
213
|
+
out = scrubValues(redactFormEncoded(body));
|
|
214
|
+
else
|
|
215
|
+
out = scrubValues(body);
|
|
216
|
+
// Net values sensitive by NAME that survived structural + value passes. See scrubNamedValues.
|
|
217
|
+
return scrubNamedValues(out);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return body;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function tryJson(body) {
|
|
224
|
+
const t = body.trimStart();
|
|
225
|
+
if (t.length === 0 || (t[0] !== '{' && t[0] !== '['))
|
|
226
|
+
return undefined;
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(body);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function redactJson(v) {
|
|
235
|
+
if (Array.isArray(v))
|
|
236
|
+
return v.map(redactJson);
|
|
237
|
+
if (v !== null && typeof v === 'object') {
|
|
238
|
+
const out = {};
|
|
239
|
+
for (const [k, val] of Object.entries(v)) {
|
|
240
|
+
out[k] = isSensitiveName(k) ? exports.redactMask : redactJson(val);
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
return v;
|
|
245
|
+
}
|
|
246
|
+
function looksFormEncoded(body) {
|
|
247
|
+
if (!body.includes('=') || body.includes('\n') || body.includes(' '))
|
|
248
|
+
return false;
|
|
249
|
+
return /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/.test(body);
|
|
250
|
+
}
|
|
251
|
+
function redactFormEncoded(body) {
|
|
252
|
+
return body
|
|
253
|
+
.split('&')
|
|
254
|
+
.map((pair) => {
|
|
255
|
+
const i = pair.indexOf('=');
|
|
256
|
+
if (i <= 0)
|
|
257
|
+
return pair;
|
|
258
|
+
const key = pair.substring(0, i);
|
|
259
|
+
let name = key;
|
|
260
|
+
try {
|
|
261
|
+
name = decodeURIComponent(key.replace(/\+/g, ' '));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* keep raw */
|
|
265
|
+
}
|
|
266
|
+
if (isSensitiveName(name))
|
|
267
|
+
return `${key}=${exports.redactMask}`;
|
|
268
|
+
// Scrub the DECODED value — else an encoded value slips the outer scrubValues and projectBody ships the secret.
|
|
269
|
+
let val = pair.substring(i + 1);
|
|
270
|
+
try {
|
|
271
|
+
val = decodeURIComponent(val.replace(/\+/g, ' '));
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
/* keep raw */
|
|
275
|
+
}
|
|
276
|
+
return `${key}=${scrubValues(val)}`;
|
|
277
|
+
})
|
|
278
|
+
.join('&');
|
|
279
|
+
}
|
|
280
|
+
// --- safe projection (the annotatable DTO shipped to the server) ---
|
|
281
|
+
const projMaxKeys = 512;
|
|
282
|
+
const projMaxDepth = 12;
|
|
283
|
+
const projMaxStrLen = 1024;
|
|
284
|
+
/** Project an already-redacted body into `dotted.path → scalar`, keeping only
|
|
285
|
+
* analytics scalars (num/bool/short non-mask strings). Null when nothing safe remains. */
|
|
286
|
+
function projectBody(body) {
|
|
287
|
+
if (!body)
|
|
288
|
+
return null;
|
|
289
|
+
const decoded = tryJson(body) ?? tryForm(body);
|
|
290
|
+
if (decoded === undefined || decoded === null)
|
|
291
|
+
return null;
|
|
292
|
+
const out = {};
|
|
293
|
+
try {
|
|
294
|
+
flatten('', decoded, out, 0);
|
|
295
|
+
if (Object.keys(out).length === 0)
|
|
296
|
+
return null;
|
|
297
|
+
return JSON.stringify(out);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function tryForm(body) {
|
|
304
|
+
if (!looksFormEncoded(body))
|
|
305
|
+
return undefined;
|
|
306
|
+
const map = {};
|
|
307
|
+
for (const pair of body.split('&')) {
|
|
308
|
+
const i = pair.indexOf('=');
|
|
309
|
+
if (i <= 0)
|
|
310
|
+
continue;
|
|
311
|
+
let k = pair.substring(0, i);
|
|
312
|
+
let v = pair.substring(i + 1);
|
|
313
|
+
try {
|
|
314
|
+
k = decodeURIComponent(k.replace(/\+/g, ' '));
|
|
315
|
+
}
|
|
316
|
+
catch { /* keep raw */ }
|
|
317
|
+
try {
|
|
318
|
+
v = decodeURIComponent(v.replace(/\+/g, ' '));
|
|
319
|
+
}
|
|
320
|
+
catch { /* keep raw */ }
|
|
321
|
+
map[k] = v;
|
|
322
|
+
}
|
|
323
|
+
return Object.keys(map).length === 0 ? undefined : map;
|
|
324
|
+
}
|
|
325
|
+
function flatten(prefix, v, out, depth) {
|
|
326
|
+
if (Object.keys(out).length >= projMaxKeys || depth > projMaxDepth)
|
|
327
|
+
return;
|
|
328
|
+
if (Array.isArray(v)) {
|
|
329
|
+
for (let i = 0; i < v.length && Object.keys(out).length < projMaxKeys; i++) {
|
|
330
|
+
flatten(prefix === '' ? `${i}` : `${prefix}.${i}`, v[i], out, depth + 1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else if (v !== null && typeof v === 'object') {
|
|
334
|
+
for (const [k, val] of Object.entries(v)) {
|
|
335
|
+
if (Object.keys(out).length < projMaxKeys) {
|
|
336
|
+
flatten(prefix === '' ? k : `${prefix}.${k}`, val, out, depth + 1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else if (typeof v === 'string') {
|
|
341
|
+
if (v.length === 0 || v.length > projMaxStrLen)
|
|
342
|
+
return;
|
|
343
|
+
if (v.startsWith('[REDACTED'))
|
|
344
|
+
return; // redactor dropped it — no signal
|
|
345
|
+
out[prefix] = v;
|
|
346
|
+
}
|
|
347
|
+
else if (typeof v === 'number' || typeof v === 'boolean') {
|
|
348
|
+
out[prefix] = v;
|
|
349
|
+
}
|
|
350
|
+
// null / other types: skip
|
|
351
|
+
}
|