repro-nest 0.0.214 → 0.0.215
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/dist/index.d.ts +55 -4
- package/dist/index.js +250 -25
- package/docs/tracing.md +69 -3
- package/package.json +1 -1
- package/src/index.ts +370 -32
package/dist/index.d.ts
CHANGED
|
@@ -48,17 +48,28 @@ export type TraceEventForFilter = {
|
|
|
48
48
|
eventType: TraceEventPhase;
|
|
49
49
|
functionType?: string | null;
|
|
50
50
|
fn?: string;
|
|
51
|
+
wrapperClass?: string | null;
|
|
51
52
|
file?: string | null;
|
|
52
53
|
depth?: number;
|
|
53
54
|
library?: string | null;
|
|
54
55
|
};
|
|
55
56
|
export type HeaderRule = string | RegExp;
|
|
56
57
|
export type HeaderCaptureOptions = {
|
|
57
|
-
/** When true, sensitive headers such as Authorization are kept; default
|
|
58
|
+
/** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
|
|
58
59
|
allowSensitiveHeaders?: boolean;
|
|
59
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Header names (string or RegExp) to mask.
|
|
62
|
+
* `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
|
|
63
|
+
*/
|
|
64
|
+
maskHeaders?: HeaderRule | HeaderRule[];
|
|
65
|
+
/** @deprecated Alias for {@link maskHeaders}. */
|
|
60
66
|
dropHeaders?: HeaderRule | HeaderRule[];
|
|
61
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
|
|
69
|
+
* `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
|
|
70
|
+
*/
|
|
71
|
+
unmaskHeaders?: HeaderRule | HeaderRule[];
|
|
72
|
+
/** @deprecated Alias for {@link unmaskHeaders}. */
|
|
62
73
|
keepHeaders?: HeaderRule | HeaderRule[];
|
|
63
74
|
};
|
|
64
75
|
/** Lightweight helper to disable every trace emitted from specific files. */
|
|
@@ -78,6 +89,17 @@ export type DisableFunctionTraceRule = {
|
|
|
78
89
|
fn?: TraceRulePattern;
|
|
79
90
|
/** Function name (e.g. `"findOne"`, `/^UserService\./`). */
|
|
80
91
|
functionName?: TraceRulePattern;
|
|
92
|
+
/** Shortcut for {@link wrapperClass}. */
|
|
93
|
+
wrapper?: TraceRulePattern;
|
|
94
|
+
/**
|
|
95
|
+
* Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
|
|
96
|
+
* Useful when multiple functions share the same method name.
|
|
97
|
+
*/
|
|
98
|
+
wrapperClass?: TraceRulePattern;
|
|
99
|
+
/** Alias for {@link wrapperClass}. */
|
|
100
|
+
className?: TraceRulePattern;
|
|
101
|
+
/** Alias for {@link wrapperClass}. */
|
|
102
|
+
owner?: TraceRulePattern;
|
|
81
103
|
/** Absolute file path where the function was defined. */
|
|
82
104
|
file?: TraceRulePattern;
|
|
83
105
|
/** Shortcut for {@link library}. */
|
|
@@ -95,6 +117,33 @@ export type DisableFunctionTraceRule = {
|
|
|
95
117
|
};
|
|
96
118
|
export type DisableFunctionTracePredicate = (event: TraceEventForFilter) => boolean;
|
|
97
119
|
export type DisableFunctionTraceConfig = DisableFunctionTraceRule | DisableFunctionTracePredicate;
|
|
120
|
+
export type ReproMaskTarget = 'request.headers' | 'request.body' | 'request.params' | 'request.query' | 'response.body' | 'trace.args' | 'trace.returnValue' | 'trace.error';
|
|
121
|
+
export type ReproMaskWhen = DisableFunctionTraceRule & {
|
|
122
|
+
/** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
|
|
123
|
+
method?: TraceRulePattern;
|
|
124
|
+
/** Match request path without query string (e.g. `"/api/auth/login"`). */
|
|
125
|
+
path?: TraceRulePattern;
|
|
126
|
+
/** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
|
|
127
|
+
key?: TraceRulePattern;
|
|
128
|
+
};
|
|
129
|
+
export type ReproMaskRule = {
|
|
130
|
+
when?: ReproMaskWhen;
|
|
131
|
+
target: ReproMaskTarget | ReproMaskTarget[];
|
|
132
|
+
/**
|
|
133
|
+
* Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
|
|
134
|
+
* Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
|
|
135
|
+
*/
|
|
136
|
+
paths?: string | string[];
|
|
137
|
+
/** Key name patterns to mask anywhere in the payload. */
|
|
138
|
+
keys?: TraceRulePattern;
|
|
139
|
+
/** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
|
|
140
|
+
replacement?: any;
|
|
141
|
+
};
|
|
142
|
+
export type ReproMaskingConfig = {
|
|
143
|
+
/** Default replacement value (defaults to `"[REDACTED]"`). */
|
|
144
|
+
replacement?: any;
|
|
145
|
+
rules?: ReproMaskRule[] | null;
|
|
146
|
+
};
|
|
98
147
|
export declare function setDisabledFunctionTraces(rules?: DisableFunctionTraceConfig[] | null): void;
|
|
99
148
|
export declare function setDisabledFunctionTypes(patterns?: TraceRulePattern | null): void;
|
|
100
149
|
export declare function setDisabledTraceFiles(config?: DisableTraceFileConfig | DisableTraceFileConfig[] | null): void;
|
|
@@ -146,8 +195,10 @@ export type ReproMiddlewareConfig = {
|
|
|
146
195
|
tenantId: string;
|
|
147
196
|
appSecret: string;
|
|
148
197
|
apiBase: string;
|
|
149
|
-
/** Configure header capture/
|
|
198
|
+
/** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
|
|
150
199
|
captureHeaders?: boolean | HeaderCaptureOptions;
|
|
200
|
+
/** Optional masking rules for request/response payloads and function traces. */
|
|
201
|
+
masking?: ReproMaskingConfig;
|
|
151
202
|
};
|
|
152
203
|
export declare function reproMiddleware(cfg: ReproMiddlewareConfig): (req: Request, res: Response, next: NextFunction) => void;
|
|
153
204
|
export declare function reproMongoosePlugin(cfg: {
|
package/dist/index.js
CHANGED
|
@@ -298,10 +298,31 @@ function inferLibraryNameFromFile(file) {
|
|
|
298
298
|
}
|
|
299
299
|
return segments[0] || null;
|
|
300
300
|
}
|
|
301
|
+
function inferWrapperClassFromFn(fn) {
|
|
302
|
+
if (!fn)
|
|
303
|
+
return null;
|
|
304
|
+
const raw = String(fn);
|
|
305
|
+
if (!raw)
|
|
306
|
+
return null;
|
|
307
|
+
const hashIdx = raw.indexOf('#');
|
|
308
|
+
if (hashIdx > 0) {
|
|
309
|
+
const left = raw.slice(0, hashIdx).trim();
|
|
310
|
+
return left || null;
|
|
311
|
+
}
|
|
312
|
+
const dotIdx = raw.lastIndexOf('.');
|
|
313
|
+
if (dotIdx > 0) {
|
|
314
|
+
const left = raw.slice(0, dotIdx).trim();
|
|
315
|
+
return left || null;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
301
319
|
function matchesRule(rule, event) {
|
|
302
320
|
const namePattern = rule.fn ?? rule.functionName;
|
|
303
321
|
if (!matchesPattern(event.fn, namePattern))
|
|
304
322
|
return false;
|
|
323
|
+
const wrapperPattern = rule.wrapper ?? rule.wrapperClass ?? rule.className ?? rule.owner;
|
|
324
|
+
if (!matchesPattern(event.wrapperClass, wrapperPattern))
|
|
325
|
+
return false;
|
|
305
326
|
if (!matchesPattern(event.file, rule.file))
|
|
306
327
|
return false;
|
|
307
328
|
const libPattern = rule.lib ?? rule.library;
|
|
@@ -1197,6 +1218,7 @@ const DEFAULT_SENSITIVE_HEADERS = [
|
|
|
1197
1218
|
'cookie',
|
|
1198
1219
|
'set-cookie',
|
|
1199
1220
|
];
|
|
1221
|
+
const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
|
|
1200
1222
|
function normalizeHeaderRules(rules) {
|
|
1201
1223
|
return normalizePatternArray(rules || []);
|
|
1202
1224
|
}
|
|
@@ -1218,14 +1240,14 @@ function matchesHeaderRule(name, rules) {
|
|
|
1218
1240
|
}
|
|
1219
1241
|
function normalizeHeaderCaptureConfig(raw) {
|
|
1220
1242
|
if (raw === false) {
|
|
1221
|
-
return { enabled: false, allowSensitive: false,
|
|
1243
|
+
return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
|
|
1222
1244
|
}
|
|
1223
1245
|
const opts = raw && raw !== true ? raw : {};
|
|
1224
1246
|
return {
|
|
1225
1247
|
enabled: true,
|
|
1226
1248
|
allowSensitive: opts.allowSensitiveHeaders === true,
|
|
1227
|
-
|
|
1228
|
-
|
|
1249
|
+
mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
|
|
1250
|
+
unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
|
|
1229
1251
|
};
|
|
1230
1252
|
}
|
|
1231
1253
|
function sanitizeHeaderValue(value) {
|
|
@@ -1248,24 +1270,209 @@ function sanitizeHeaders(headers, rawCfg) {
|
|
|
1248
1270
|
const cfg = normalizeHeaderCaptureConfig(rawCfg);
|
|
1249
1271
|
if (!cfg.enabled)
|
|
1250
1272
|
return {};
|
|
1251
|
-
const dropList = cfg.allowSensitive ? cfg.drop : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.drop];
|
|
1252
1273
|
const out = {};
|
|
1253
1274
|
for (const [rawKey, rawVal] of Object.entries(headers || {})) {
|
|
1254
1275
|
const key = String(rawKey || '').toLowerCase();
|
|
1255
1276
|
if (!key)
|
|
1256
1277
|
continue;
|
|
1257
|
-
const shouldDrop = matchesHeaderRule(key, dropList);
|
|
1258
|
-
const keep = matchesHeaderRule(key, cfg.keep);
|
|
1259
|
-
if (shouldDrop && !keep)
|
|
1260
|
-
continue;
|
|
1261
1278
|
const sanitizedValue = sanitizeHeaderValue(rawVal);
|
|
1262
1279
|
if (sanitizedValue !== undefined) {
|
|
1263
1280
|
out[key] = sanitizedValue;
|
|
1264
1281
|
}
|
|
1265
1282
|
}
|
|
1283
|
+
const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
|
|
1284
|
+
Object.keys(out).forEach((key) => {
|
|
1285
|
+
if (!matchesHeaderRule(key, maskList))
|
|
1286
|
+
return;
|
|
1287
|
+
if (matchesHeaderRule(key, cfg.unmask))
|
|
1288
|
+
return;
|
|
1289
|
+
const current = out[key];
|
|
1290
|
+
if (Array.isArray(current)) {
|
|
1291
|
+
out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
out[key] = DEFAULT_MASK_REPLACEMENT;
|
|
1295
|
+
});
|
|
1266
1296
|
return out;
|
|
1267
1297
|
}
|
|
1298
|
+
function normalizeMaskTargets(target) {
|
|
1299
|
+
const out = Array.isArray(target) ? target : [target];
|
|
1300
|
+
return out.filter((t) => typeof t === 'string' && t.length > 0);
|
|
1301
|
+
}
|
|
1302
|
+
function parseMaskPath(raw) {
|
|
1303
|
+
if (!raw)
|
|
1304
|
+
return [];
|
|
1305
|
+
let path = String(raw).trim();
|
|
1306
|
+
if (!path)
|
|
1307
|
+
return [];
|
|
1308
|
+
if (path.startsWith('$.'))
|
|
1309
|
+
path = path.slice(2);
|
|
1310
|
+
if (path.startsWith('.'))
|
|
1311
|
+
path = path.slice(1);
|
|
1312
|
+
path = path.replace(/\[(\d+|\*)\]/g, '.$1');
|
|
1313
|
+
return path.split('.').map(s => s.trim()).filter(Boolean);
|
|
1314
|
+
}
|
|
1315
|
+
function normalizeMaskPaths(paths) {
|
|
1316
|
+
if (!paths)
|
|
1317
|
+
return [];
|
|
1318
|
+
const list = Array.isArray(paths) ? paths : [paths];
|
|
1319
|
+
return list
|
|
1320
|
+
.map(p => parseMaskPath(p))
|
|
1321
|
+
.filter(parts => parts.length > 0);
|
|
1322
|
+
}
|
|
1323
|
+
function normalizeMaskingConfig(raw) {
|
|
1324
|
+
const rules = raw?.rules;
|
|
1325
|
+
if (!Array.isArray(rules) || rules.length === 0)
|
|
1326
|
+
return null;
|
|
1327
|
+
const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
|
|
1328
|
+
const normalized = [];
|
|
1329
|
+
for (const rule of rules) {
|
|
1330
|
+
if (!rule)
|
|
1331
|
+
continue;
|
|
1332
|
+
const targets = normalizeMaskTargets(rule.target);
|
|
1333
|
+
if (!targets.length)
|
|
1334
|
+
continue;
|
|
1335
|
+
const paths = normalizeMaskPaths(rule.paths);
|
|
1336
|
+
const keys = rule.keys ?? undefined;
|
|
1337
|
+
if (!paths.length && !keys)
|
|
1338
|
+
continue;
|
|
1339
|
+
normalized.push({
|
|
1340
|
+
when: rule.when,
|
|
1341
|
+
targets,
|
|
1342
|
+
paths,
|
|
1343
|
+
keys,
|
|
1344
|
+
replacement: rule.replacement ?? replacement,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
return normalized.length ? { replacement, rules: normalized } : null;
|
|
1348
|
+
}
|
|
1349
|
+
function maskWhenRequiresTrace(when) {
|
|
1350
|
+
return Boolean(when.fn ||
|
|
1351
|
+
when.functionName ||
|
|
1352
|
+
when.wrapper ||
|
|
1353
|
+
when.wrapperClass ||
|
|
1354
|
+
when.className ||
|
|
1355
|
+
when.owner ||
|
|
1356
|
+
when.file ||
|
|
1357
|
+
when.lib ||
|
|
1358
|
+
when.library ||
|
|
1359
|
+
when.type ||
|
|
1360
|
+
when.functionType ||
|
|
1361
|
+
when.event ||
|
|
1362
|
+
when.eventType);
|
|
1363
|
+
}
|
|
1364
|
+
function matchesMaskWhen(when, req, trace) {
|
|
1365
|
+
if (!when)
|
|
1366
|
+
return true;
|
|
1367
|
+
if (!matchesPattern(req.method, when.method))
|
|
1368
|
+
return false;
|
|
1369
|
+
if (!matchesPattern(req.path, when.path))
|
|
1370
|
+
return false;
|
|
1371
|
+
if (!matchesPattern(req.key, when.key))
|
|
1372
|
+
return false;
|
|
1373
|
+
if (maskWhenRequiresTrace(when)) {
|
|
1374
|
+
if (!trace)
|
|
1375
|
+
return false;
|
|
1376
|
+
if (!matchesRule(when, trace))
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
function maskKeysInPlace(node, keys, replacement) {
|
|
1382
|
+
if (!node)
|
|
1383
|
+
return;
|
|
1384
|
+
if (Array.isArray(node)) {
|
|
1385
|
+
node.forEach(item => maskKeysInPlace(item, keys, replacement));
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (typeof node !== 'object')
|
|
1389
|
+
return;
|
|
1390
|
+
Object.keys(node).forEach((key) => {
|
|
1391
|
+
if (matchesPattern(key, keys, false)) {
|
|
1392
|
+
try {
|
|
1393
|
+
node[key] = replacement;
|
|
1394
|
+
}
|
|
1395
|
+
catch { }
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
maskKeysInPlace(node[key], keys, replacement);
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
function maskPathInPlace(node, pathParts, replacement, depth = 0) {
|
|
1402
|
+
if (!node)
|
|
1403
|
+
return;
|
|
1404
|
+
if (depth >= pathParts.length)
|
|
1405
|
+
return;
|
|
1406
|
+
const part = pathParts[depth];
|
|
1407
|
+
const isLast = depth === pathParts.length - 1;
|
|
1408
|
+
const applyAt = (container, key) => {
|
|
1409
|
+
if (!container)
|
|
1410
|
+
return;
|
|
1411
|
+
try {
|
|
1412
|
+
container[key] = replacement;
|
|
1413
|
+
}
|
|
1414
|
+
catch { }
|
|
1415
|
+
};
|
|
1416
|
+
if (part === '*') {
|
|
1417
|
+
if (Array.isArray(node)) {
|
|
1418
|
+
for (let i = 0; i < node.length; i++) {
|
|
1419
|
+
if (isLast)
|
|
1420
|
+
applyAt(node, i);
|
|
1421
|
+
else
|
|
1422
|
+
maskPathInPlace(node[i], pathParts, replacement, depth + 1);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
else if (typeof node === 'object') {
|
|
1426
|
+
for (const key of Object.keys(node)) {
|
|
1427
|
+
if (isLast)
|
|
1428
|
+
applyAt(node, key);
|
|
1429
|
+
else
|
|
1430
|
+
maskPathInPlace(node[key], pathParts, replacement, depth + 1);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const index = Number(part);
|
|
1436
|
+
const isIndex = Number.isInteger(index) && String(index) === part;
|
|
1437
|
+
if (Array.isArray(node) && isIndex) {
|
|
1438
|
+
if (index < 0 || index >= node.length)
|
|
1439
|
+
return;
|
|
1440
|
+
if (isLast)
|
|
1441
|
+
applyAt(node, index);
|
|
1442
|
+
else
|
|
1443
|
+
maskPathInPlace(node[index], pathParts, replacement, depth + 1);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (typeof node !== 'object')
|
|
1447
|
+
return;
|
|
1448
|
+
if (!Object.prototype.hasOwnProperty.call(node, part))
|
|
1449
|
+
return;
|
|
1450
|
+
if (isLast)
|
|
1451
|
+
applyAt(node, part);
|
|
1452
|
+
else
|
|
1453
|
+
maskPathInPlace(node[part], pathParts, replacement, depth + 1);
|
|
1454
|
+
}
|
|
1455
|
+
function applyMasking(target, value, req, trace, masking) {
|
|
1456
|
+
if (!masking || !masking.rules.length)
|
|
1457
|
+
return value;
|
|
1458
|
+
if (value === undefined)
|
|
1459
|
+
return value;
|
|
1460
|
+
for (const rule of masking.rules) {
|
|
1461
|
+
if (!rule.targets.includes(target))
|
|
1462
|
+
continue;
|
|
1463
|
+
if (!matchesMaskWhen(rule.when, req, trace))
|
|
1464
|
+
continue;
|
|
1465
|
+
const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
|
|
1466
|
+
if (rule.keys)
|
|
1467
|
+
maskKeysInPlace(value, rule.keys, replacement);
|
|
1468
|
+
if (rule.paths.length) {
|
|
1469
|
+
rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
return value;
|
|
1473
|
+
}
|
|
1268
1474
|
function reproMiddleware(cfg) {
|
|
1475
|
+
const masking = normalizeMaskingConfig(cfg.masking);
|
|
1269
1476
|
return function (req, res, next) {
|
|
1270
1477
|
const sid = req.headers['x-bug-session-id'] || '';
|
|
1271
1478
|
const aid = req.headers['x-bug-action-id'] || '';
|
|
@@ -1278,8 +1485,10 @@ function reproMiddleware(cfg) {
|
|
|
1278
1485
|
const rid = nextRequestId(requestEpochMs);
|
|
1279
1486
|
const t0 = requestStartRaw;
|
|
1280
1487
|
const url = req.originalUrl || req.url || '/';
|
|
1488
|
+
const urlPathOnly = (url || '/').split('?')[0] || '/';
|
|
1281
1489
|
const path = url; // back-compat
|
|
1282
1490
|
const key = normalizeRouteKey(req.method, url);
|
|
1491
|
+
const maskReq = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
|
|
1283
1492
|
const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
|
|
1284
1493
|
beginSessionRequest(sid);
|
|
1285
1494
|
// ---- response body capture (unchanged) ----
|
|
@@ -1439,14 +1648,24 @@ function reproMiddleware(cfg) {
|
|
|
1439
1648
|
if (ev.functionType !== undefined) {
|
|
1440
1649
|
evt.functionType = ev.functionType;
|
|
1441
1650
|
}
|
|
1651
|
+
const candidate = {
|
|
1652
|
+
type: evt.type,
|
|
1653
|
+
eventType: evt.type,
|
|
1654
|
+
functionType: evt.functionType ?? null,
|
|
1655
|
+
fn: evt.fn,
|
|
1656
|
+
wrapperClass: inferWrapperClassFromFn(evt.fn),
|
|
1657
|
+
file: evt.file ?? null,
|
|
1658
|
+
depth: evt.depth,
|
|
1659
|
+
library: inferLibraryNameFromFile(evt.file),
|
|
1660
|
+
};
|
|
1442
1661
|
if (ev.args !== undefined) {
|
|
1443
|
-
evt.args = sanitizeTraceArgs(ev.args);
|
|
1662
|
+
evt.args = applyMasking('trace.args', sanitizeTraceArgs(ev.args), maskReq, candidate, masking);
|
|
1444
1663
|
}
|
|
1445
1664
|
if (ev.returnValue !== undefined) {
|
|
1446
|
-
evt.returnValue = sanitizeTraceValue(ev.returnValue);
|
|
1665
|
+
evt.returnValue = applyMasking('trace.returnValue', sanitizeTraceValue(ev.returnValue), maskReq, candidate, masking);
|
|
1447
1666
|
}
|
|
1448
1667
|
if (ev.error !== undefined) {
|
|
1449
|
-
evt.error = sanitizeTraceValue(ev.error);
|
|
1668
|
+
evt.error = applyMasking('trace.error', sanitizeTraceValue(ev.error), maskReq, candidate, masking);
|
|
1450
1669
|
}
|
|
1451
1670
|
if (ev.threw !== undefined) {
|
|
1452
1671
|
evt.threw = Boolean(ev.threw);
|
|
@@ -1454,15 +1673,6 @@ function reproMiddleware(cfg) {
|
|
|
1454
1673
|
if (ev.unawaited !== undefined) {
|
|
1455
1674
|
evt.unawaited = ev.unawaited === true;
|
|
1456
1675
|
}
|
|
1457
|
-
const candidate = {
|
|
1458
|
-
type: evt.type,
|
|
1459
|
-
eventType: evt.type,
|
|
1460
|
-
functionType: evt.functionType ?? null,
|
|
1461
|
-
fn: evt.fn,
|
|
1462
|
-
file: evt.file ?? null,
|
|
1463
|
-
depth: evt.depth,
|
|
1464
|
-
library: inferLibraryNameFromFile(evt.file),
|
|
1465
|
-
};
|
|
1466
1676
|
const dropEvent = shouldDropTraceEvent(candidate);
|
|
1467
1677
|
const spanKey = normalizeSpanId(evt.spanId);
|
|
1468
1678
|
if (evt.type === 'enter') {
|
|
@@ -1546,9 +1756,24 @@ function reproMiddleware(cfg) {
|
|
|
1546
1756
|
?? firstAppTrace
|
|
1547
1757
|
?? { fn: null, file: null, line: null, functionType: null };
|
|
1548
1758
|
const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
1759
|
+
const endpointTraceCtx = (() => {
|
|
1760
|
+
if (!chosenEndpoint?.fn && !chosenEndpoint?.file)
|
|
1761
|
+
return null;
|
|
1762
|
+
return {
|
|
1763
|
+
type: 'enter',
|
|
1764
|
+
eventType: 'enter',
|
|
1765
|
+
fn: chosenEndpoint.fn ?? undefined,
|
|
1766
|
+
wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
|
|
1767
|
+
file: chosenEndpoint.file ?? null,
|
|
1768
|
+
functionType: chosenEndpoint.functionType ?? null,
|
|
1769
|
+
library: inferLibraryNameFromFile(chosenEndpoint.file),
|
|
1770
|
+
};
|
|
1771
|
+
})();
|
|
1772
|
+
const requestBody = applyMasking('request.body', sanitizeRequestSnapshot(req.body), maskReq, endpointTraceCtx, masking);
|
|
1773
|
+
const requestParams = applyMasking('request.params', sanitizeRequestSnapshot(req.params), maskReq, endpointTraceCtx, masking);
|
|
1774
|
+
const requestQuery = applyMasking('request.query', sanitizeRequestSnapshot(req.query), maskReq, endpointTraceCtx, masking);
|
|
1775
|
+
const maskedHeaders = applyMasking('request.headers', requestHeaders, maskReq, endpointTraceCtx, masking);
|
|
1776
|
+
const responseBody = applyMasking('response.body', capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody), maskReq, endpointTraceCtx, masking);
|
|
1552
1777
|
const requestPayload = {
|
|
1553
1778
|
rid,
|
|
1554
1779
|
method: req.method,
|
|
@@ -1556,9 +1781,9 @@ function reproMiddleware(cfg) {
|
|
|
1556
1781
|
path,
|
|
1557
1782
|
status: res.statusCode,
|
|
1558
1783
|
durMs: Date.now() - t0,
|
|
1559
|
-
headers:
|
|
1784
|
+
headers: maskedHeaders,
|
|
1560
1785
|
key,
|
|
1561
|
-
respBody:
|
|
1786
|
+
respBody: responseBody,
|
|
1562
1787
|
trace: traceBatches.length ? undefined : '[]',
|
|
1563
1788
|
};
|
|
1564
1789
|
if (requestBody !== undefined)
|
package/docs/tracing.md
CHANGED
|
@@ -30,7 +30,7 @@ or getters. Provide a string, regular expression, or array of them and every
|
|
|
30
30
|
matching trace event will be ignored, regardless of library or filename.
|
|
31
31
|
|
|
32
32
|
```ts
|
|
33
|
-
import { setDisabledFunctionTypes } from '
|
|
33
|
+
import { setDisabledFunctionTypes } from 'repro-nest';
|
|
34
34
|
|
|
35
35
|
setDisabledFunctionTypes(['constructor']);
|
|
36
36
|
```
|
|
@@ -50,6 +50,7 @@ captured in the session payload.
|
|
|
50
50
|
| Property | Description |
|
|
51
51
|
| --------------- | ------------------------------------------------------------------------------------------------- |
|
|
52
52
|
| `fn`/`functionName` | Match against the instrumented function name (substring or RegExp). |
|
|
53
|
+
| `wrapper`/`wrapperClass`/`className`/`owner` | Match the wrapper/owner inferred from the function name (e.g. `"UserService"` in `"UserService.create"`). |
|
|
53
54
|
| `file` | Match the absolute source filename, useful for filtering entire modules. |
|
|
54
55
|
| `lib`/`library` | Match the npm package inferred from the file path (e.g. `"mongoose"`). |
|
|
55
56
|
| `type`/`functionType` | Match the detected function kind (e.g. `"constructor"`, `"method"`, `"arrow"`). |
|
|
@@ -64,7 +65,7 @@ Provide a function that receives the raw trace event and returns `true` when it
|
|
|
64
65
|
should be discarded. Use this form for complex, stateful, or cross-field logic.
|
|
65
66
|
|
|
66
67
|
```ts
|
|
67
|
-
import { setDisabledFunctionTraces } from '
|
|
68
|
+
import { setDisabledFunctionTraces } from 'repro-nest';
|
|
68
69
|
|
|
69
70
|
setDisabledFunctionTraces([
|
|
70
71
|
(event) => event.library === 'mongoose' && event.functionType === 'constructor',
|
|
@@ -74,6 +75,71 @@ setDisabledFunctionTraces([
|
|
|
74
75
|
Pass `null` or an empty array to `initReproTracing` or `setDisabledFunctionTraces`
|
|
75
76
|
to remove previously configured rules.
|
|
76
77
|
|
|
78
|
+
## Payload masking (`reproMiddleware`)
|
|
79
|
+
|
|
80
|
+
`reproMiddleware` can mask request/response payloads, request headers, and traced
|
|
81
|
+
function inputs/outputs before they are persisted.
|
|
82
|
+
|
|
83
|
+
### Targets
|
|
84
|
+
|
|
85
|
+
- `request.headers`
|
|
86
|
+
- `request.body`
|
|
87
|
+
- `request.params`
|
|
88
|
+
- `request.query`
|
|
89
|
+
- `response.body`
|
|
90
|
+
- `trace.args`
|
|
91
|
+
- `trace.returnValue`
|
|
92
|
+
- `trace.error`
|
|
93
|
+
|
|
94
|
+
### Rules
|
|
95
|
+
|
|
96
|
+
- `when.method` / `when.path` / `when.key` scope rules by endpoint (`key` is `"METHOD /path"` without query string).
|
|
97
|
+
- For function-specific rules, `when` supports the same fields as `disableFunctionTraces` (`fn`, `wrapperClass`, `file`, etc). This is useful when multiple functions share the same name.
|
|
98
|
+
- `paths` uses dot/bracket syntax and supports `*`, `[0]`, `[*]` (example: `"items[*].token"` or `"0.password"` for trace args arrays).
|
|
99
|
+
- `keys` masks matching key names anywhere in the payload (string/RegExp/array).
|
|
100
|
+
- `replacement` overrides the default replacement value (defaults to `"[REDACTED]"`).
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { reproMiddleware } from 'repro-nest';
|
|
104
|
+
|
|
105
|
+
app.use(reproMiddleware({
|
|
106
|
+
appId,
|
|
107
|
+
tenantId,
|
|
108
|
+
appSecret,
|
|
109
|
+
apiBase,
|
|
110
|
+
masking: {
|
|
111
|
+
rules: [
|
|
112
|
+
{
|
|
113
|
+
when: { key: 'POST /api/auth/login' },
|
|
114
|
+
target: 'request.body',
|
|
115
|
+
paths: ['password'],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
when: { wrapperClass: 'AuthService', functionName: 'login', file: '/app/src/auth/auth.service.ts' },
|
|
119
|
+
target: ['trace.args', 'trace.returnValue', 'trace.error'],
|
|
120
|
+
keys: [/token/i],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
target: 'request.headers',
|
|
124
|
+
keys: [/authorization/i, /cookie/i],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Header capture/masking (`captureHeaders`)
|
|
132
|
+
|
|
133
|
+
Headers are captured by default with sensitive values masked. Configure `captureHeaders`
|
|
134
|
+
to change the behavior:
|
|
135
|
+
|
|
136
|
+
- `captureHeaders: false` disables header capture entirely.
|
|
137
|
+
- `captureHeaders: true` (or omitted) captures headers and masks sensitive ones.
|
|
138
|
+
- `captureHeaders.allowSensitiveHeaders: true` keeps default sensitive headers unmasked (use with care).
|
|
139
|
+
- `captureHeaders.maskHeaders` adds additional header names to mask.
|
|
140
|
+
- `captureHeaders.unmaskHeaders` keeps specific header names unmasked (overrides defaults and `maskHeaders`).
|
|
141
|
+
- `captureHeaders.dropHeaders` / `captureHeaders.keepHeaders` are legacy aliases for `maskHeaders` / `unmaskHeaders`.
|
|
142
|
+
|
|
77
143
|
## `logFunctionCalls`
|
|
78
144
|
|
|
79
145
|
Set to `true` to enable verbose console logging of function entry/exit events at
|
|
@@ -81,7 +147,7 @@ runtime, or to `false` to silence them. The value is forwarded to
|
|
|
81
147
|
`setReproTraceLogsEnabled`, so you can also toggle logging later in the process:
|
|
82
148
|
|
|
83
149
|
```ts
|
|
84
|
-
import { enableReproTraceLogs, disableReproTraceLogs } from '
|
|
150
|
+
import { enableReproTraceLogs, disableReproTraceLogs } from 'repro-nest';
|
|
85
151
|
|
|
86
152
|
enableReproTraceLogs();
|
|
87
153
|
// ...
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -236,6 +236,7 @@ export type TraceEventForFilter = {
|
|
|
236
236
|
eventType: TraceEventPhase;
|
|
237
237
|
functionType?: string | null;
|
|
238
238
|
fn?: string;
|
|
239
|
+
wrapperClass?: string | null;
|
|
239
240
|
file?: string | null;
|
|
240
241
|
depth?: number;
|
|
241
242
|
library?: string | null;
|
|
@@ -267,19 +268,29 @@ type EndpointTraceInfo = {
|
|
|
267
268
|
|
|
268
269
|
export type HeaderRule = string | RegExp;
|
|
269
270
|
export type HeaderCaptureOptions = {
|
|
270
|
-
/** When true, sensitive headers such as Authorization are kept; default
|
|
271
|
+
/** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
|
|
271
272
|
allowSensitiveHeaders?: boolean;
|
|
272
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* Header names (string or RegExp) to mask.
|
|
275
|
+
* `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
|
|
276
|
+
*/
|
|
277
|
+
maskHeaders?: HeaderRule | HeaderRule[];
|
|
278
|
+
/** @deprecated Alias for {@link maskHeaders}. */
|
|
273
279
|
dropHeaders?: HeaderRule | HeaderRule[];
|
|
274
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
|
|
282
|
+
* `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
|
|
283
|
+
*/
|
|
284
|
+
unmaskHeaders?: HeaderRule | HeaderRule[];
|
|
285
|
+
/** @deprecated Alias for {@link unmaskHeaders}. */
|
|
275
286
|
keepHeaders?: HeaderRule | HeaderRule[];
|
|
276
287
|
};
|
|
277
288
|
|
|
278
289
|
type NormalizedHeaderCapture = {
|
|
279
290
|
enabled: boolean;
|
|
280
291
|
allowSensitive: boolean;
|
|
281
|
-
|
|
282
|
-
|
|
292
|
+
mask: HeaderRule[];
|
|
293
|
+
unmask: HeaderRule[];
|
|
283
294
|
};
|
|
284
295
|
|
|
285
296
|
/** Lightweight helper to disable every trace emitted from specific files. */
|
|
@@ -301,6 +312,17 @@ export type DisableFunctionTraceRule = {
|
|
|
301
312
|
fn?: TraceRulePattern;
|
|
302
313
|
/** Function name (e.g. `"findOne"`, `/^UserService\./`). */
|
|
303
314
|
functionName?: TraceRulePattern;
|
|
315
|
+
/** Shortcut for {@link wrapperClass}. */
|
|
316
|
+
wrapper?: TraceRulePattern;
|
|
317
|
+
/**
|
|
318
|
+
* Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
|
|
319
|
+
* Useful when multiple functions share the same method name.
|
|
320
|
+
*/
|
|
321
|
+
wrapperClass?: TraceRulePattern;
|
|
322
|
+
/** Alias for {@link wrapperClass}. */
|
|
323
|
+
className?: TraceRulePattern;
|
|
324
|
+
/** Alias for {@link wrapperClass}. */
|
|
325
|
+
owner?: TraceRulePattern;
|
|
304
326
|
/** Absolute file path where the function was defined. */
|
|
305
327
|
file?: TraceRulePattern;
|
|
306
328
|
/** Shortcut for {@link library}. */
|
|
@@ -323,6 +345,45 @@ export type DisableFunctionTraceConfig =
|
|
|
323
345
|
| DisableFunctionTraceRule
|
|
324
346
|
| DisableFunctionTracePredicate;
|
|
325
347
|
|
|
348
|
+
export type ReproMaskTarget =
|
|
349
|
+
| 'request.headers'
|
|
350
|
+
| 'request.body'
|
|
351
|
+
| 'request.params'
|
|
352
|
+
| 'request.query'
|
|
353
|
+
| 'response.body'
|
|
354
|
+
| 'trace.args'
|
|
355
|
+
| 'trace.returnValue'
|
|
356
|
+
| 'trace.error';
|
|
357
|
+
|
|
358
|
+
export type ReproMaskWhen = DisableFunctionTraceRule & {
|
|
359
|
+
/** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
|
|
360
|
+
method?: TraceRulePattern;
|
|
361
|
+
/** Match request path without query string (e.g. `"/api/auth/login"`). */
|
|
362
|
+
path?: TraceRulePattern;
|
|
363
|
+
/** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
|
|
364
|
+
key?: TraceRulePattern;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export type ReproMaskRule = {
|
|
368
|
+
when?: ReproMaskWhen;
|
|
369
|
+
target: ReproMaskTarget | ReproMaskTarget[];
|
|
370
|
+
/**
|
|
371
|
+
* Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
|
|
372
|
+
* Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
|
|
373
|
+
*/
|
|
374
|
+
paths?: string | string[];
|
|
375
|
+
/** Key name patterns to mask anywhere in the payload. */
|
|
376
|
+
keys?: TraceRulePattern;
|
|
377
|
+
/** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
|
|
378
|
+
replacement?: any;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
export type ReproMaskingConfig = {
|
|
382
|
+
/** Default replacement value (defaults to `"[REDACTED]"`). */
|
|
383
|
+
replacement?: any;
|
|
384
|
+
rules?: ReproMaskRule[] | null;
|
|
385
|
+
};
|
|
386
|
+
|
|
326
387
|
const DEFAULT_INTERCEPTOR_TRACE_RULES: DisableFunctionTraceConfig[] = [
|
|
327
388
|
{ fn: /switchToHttp$/i },
|
|
328
389
|
{ fn: /intercept$/i },
|
|
@@ -403,10 +464,30 @@ function inferLibraryNameFromFile(file?: string | null): string | null {
|
|
|
403
464
|
return segments[0] || null;
|
|
404
465
|
}
|
|
405
466
|
|
|
467
|
+
function inferWrapperClassFromFn(fn?: string | null): string | null {
|
|
468
|
+
if (!fn) return null;
|
|
469
|
+
const raw = String(fn);
|
|
470
|
+
if (!raw) return null;
|
|
471
|
+
const hashIdx = raw.indexOf('#');
|
|
472
|
+
if (hashIdx > 0) {
|
|
473
|
+
const left = raw.slice(0, hashIdx).trim();
|
|
474
|
+
return left || null;
|
|
475
|
+
}
|
|
476
|
+
const dotIdx = raw.lastIndexOf('.');
|
|
477
|
+
if (dotIdx > 0) {
|
|
478
|
+
const left = raw.slice(0, dotIdx).trim();
|
|
479
|
+
return left || null;
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
406
484
|
function matchesRule(rule: DisableFunctionTraceRule, event: TraceEventForFilter): boolean {
|
|
407
485
|
const namePattern = rule.fn ?? rule.functionName;
|
|
408
486
|
if (!matchesPattern(event.fn, namePattern)) return false;
|
|
409
487
|
|
|
488
|
+
const wrapperPattern = rule.wrapper ?? rule.wrapperClass ?? rule.className ?? rule.owner;
|
|
489
|
+
if (!matchesPattern(event.wrapperClass, wrapperPattern)) return false;
|
|
490
|
+
|
|
410
491
|
if (!matchesPattern(event.file, rule.file)) return false;
|
|
411
492
|
|
|
412
493
|
const libPattern = rule.lib ?? rule.library;
|
|
@@ -1421,6 +1502,8 @@ const DEFAULT_SENSITIVE_HEADERS: Array<string | RegExp> = [
|
|
|
1421
1502
|
'set-cookie',
|
|
1422
1503
|
];
|
|
1423
1504
|
|
|
1505
|
+
const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
|
|
1506
|
+
|
|
1424
1507
|
function normalizeHeaderRules(rules?: HeaderRule | HeaderRule[] | null): HeaderRule[] {
|
|
1425
1508
|
return normalizePatternArray<HeaderRule>(rules || []);
|
|
1426
1509
|
}
|
|
@@ -1438,14 +1521,14 @@ function matchesHeaderRule(name: string, rules: HeaderRule[]): boolean {
|
|
|
1438
1521
|
|
|
1439
1522
|
function normalizeHeaderCaptureConfig(raw?: boolean | HeaderCaptureOptions): NormalizedHeaderCapture {
|
|
1440
1523
|
if (raw === false) {
|
|
1441
|
-
return { enabled: false, allowSensitive: false,
|
|
1524
|
+
return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
|
|
1442
1525
|
}
|
|
1443
1526
|
const opts: HeaderCaptureOptions = raw && raw !== true ? raw : {};
|
|
1444
1527
|
return {
|
|
1445
1528
|
enabled: true,
|
|
1446
1529
|
allowSensitive: opts.allowSensitiveHeaders === true,
|
|
1447
|
-
|
|
1448
|
-
|
|
1530
|
+
mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
|
|
1531
|
+
unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
|
|
1449
1532
|
};
|
|
1450
1533
|
}
|
|
1451
1534
|
|
|
@@ -1466,24 +1549,210 @@ function sanitizeHeaders(headers: any, rawCfg?: boolean | HeaderCaptureOptions)
|
|
|
1466
1549
|
const cfg = normalizeHeaderCaptureConfig(rawCfg);
|
|
1467
1550
|
if (!cfg.enabled) return {};
|
|
1468
1551
|
|
|
1469
|
-
const dropList = cfg.allowSensitive ? cfg.drop : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.drop];
|
|
1470
1552
|
const out: Record<string, any> = {};
|
|
1471
1553
|
|
|
1472
1554
|
for (const [rawKey, rawVal] of Object.entries(headers || {})) {
|
|
1473
1555
|
const key = String(rawKey || '').toLowerCase();
|
|
1474
1556
|
if (!key) continue;
|
|
1475
|
-
const shouldDrop = matchesHeaderRule(key, dropList);
|
|
1476
|
-
const keep = matchesHeaderRule(key, cfg.keep);
|
|
1477
|
-
if (shouldDrop && !keep) continue;
|
|
1478
|
-
|
|
1479
1557
|
const sanitizedValue = sanitizeHeaderValue(rawVal);
|
|
1480
1558
|
if (sanitizedValue !== undefined) {
|
|
1481
1559
|
out[key] = sanitizedValue;
|
|
1482
1560
|
}
|
|
1483
1561
|
}
|
|
1562
|
+
|
|
1563
|
+
const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
|
|
1564
|
+
Object.keys(out).forEach((key) => {
|
|
1565
|
+
if (!matchesHeaderRule(key, maskList)) return;
|
|
1566
|
+
if (matchesHeaderRule(key, cfg.unmask)) return;
|
|
1567
|
+
const current = out[key];
|
|
1568
|
+
if (Array.isArray(current)) {
|
|
1569
|
+
out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
out[key] = DEFAULT_MASK_REPLACEMENT;
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1484
1575
|
return out;
|
|
1485
1576
|
}
|
|
1486
1577
|
|
|
1578
|
+
// ===================================================================
|
|
1579
|
+
// Masking (request/response payloads + trace args/returns/errors)
|
|
1580
|
+
// ===================================================================
|
|
1581
|
+
type NormalizedMaskRule = {
|
|
1582
|
+
when?: ReproMaskWhen;
|
|
1583
|
+
targets: ReproMaskTarget[];
|
|
1584
|
+
paths: string[][];
|
|
1585
|
+
keys?: TraceRulePattern;
|
|
1586
|
+
replacement: any;
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
type NormalizedMaskingConfig = {
|
|
1590
|
+
replacement: any;
|
|
1591
|
+
rules: NormalizedMaskRule[];
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
type MaskRequestContext = {
|
|
1595
|
+
method: string;
|
|
1596
|
+
path: string;
|
|
1597
|
+
key: string;
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
|
|
1601
|
+
const out = Array.isArray(target) ? target : [target];
|
|
1602
|
+
return out.filter((t): t is ReproMaskTarget => typeof t === 'string' && t.length > 0);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function parseMaskPath(raw: string): string[] {
|
|
1606
|
+
if (!raw) return [];
|
|
1607
|
+
let path = String(raw).trim();
|
|
1608
|
+
if (!path) return [];
|
|
1609
|
+
if (path.startsWith('$.')) path = path.slice(2);
|
|
1610
|
+
if (path.startsWith('.')) path = path.slice(1);
|
|
1611
|
+
path = path.replace(/\[(\d+|\*)\]/g, '.$1');
|
|
1612
|
+
return path.split('.').map(s => s.trim()).filter(Boolean);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function normalizeMaskPaths(paths?: string | string[]): string[][] {
|
|
1616
|
+
if (!paths) return [];
|
|
1617
|
+
const list = Array.isArray(paths) ? paths : [paths];
|
|
1618
|
+
return list
|
|
1619
|
+
.map(p => parseMaskPath(p))
|
|
1620
|
+
.filter(parts => parts.length > 0);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function normalizeMaskingConfig(raw?: ReproMaskingConfig): NormalizedMaskingConfig | null {
|
|
1624
|
+
const rules = raw?.rules;
|
|
1625
|
+
if (!Array.isArray(rules) || rules.length === 0) return null;
|
|
1626
|
+
const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
|
|
1627
|
+
const normalized: NormalizedMaskRule[] = [];
|
|
1628
|
+
for (const rule of rules) {
|
|
1629
|
+
if (!rule) continue;
|
|
1630
|
+
const targets = normalizeMaskTargets(rule.target);
|
|
1631
|
+
if (!targets.length) continue;
|
|
1632
|
+
const paths = normalizeMaskPaths(rule.paths);
|
|
1633
|
+
const keys = rule.keys ?? undefined;
|
|
1634
|
+
if (!paths.length && !keys) continue;
|
|
1635
|
+
normalized.push({
|
|
1636
|
+
when: rule.when,
|
|
1637
|
+
targets,
|
|
1638
|
+
paths,
|
|
1639
|
+
keys,
|
|
1640
|
+
replacement: rule.replacement ?? replacement,
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
return normalized.length ? { replacement, rules: normalized } : null;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function maskWhenRequiresTrace(when: ReproMaskWhen): boolean {
|
|
1647
|
+
return Boolean(
|
|
1648
|
+
when.fn ||
|
|
1649
|
+
when.functionName ||
|
|
1650
|
+
when.wrapper ||
|
|
1651
|
+
when.wrapperClass ||
|
|
1652
|
+
when.className ||
|
|
1653
|
+
when.owner ||
|
|
1654
|
+
when.file ||
|
|
1655
|
+
when.lib ||
|
|
1656
|
+
when.library ||
|
|
1657
|
+
when.type ||
|
|
1658
|
+
when.functionType ||
|
|
1659
|
+
when.event ||
|
|
1660
|
+
when.eventType
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function matchesMaskWhen(when: ReproMaskWhen | undefined, req: MaskRequestContext, trace: TraceEventForFilter | null): boolean {
|
|
1665
|
+
if (!when) return true;
|
|
1666
|
+
if (!matchesPattern(req.method, when.method)) return false;
|
|
1667
|
+
if (!matchesPattern(req.path, when.path)) return false;
|
|
1668
|
+
if (!matchesPattern(req.key, when.key)) return false;
|
|
1669
|
+
|
|
1670
|
+
if (maskWhenRequiresTrace(when)) {
|
|
1671
|
+
if (!trace) return false;
|
|
1672
|
+
if (!matchesRule(when, trace)) return false;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
return true;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function maskKeysInPlace(node: any, keys: TraceRulePattern, replacement: any) {
|
|
1679
|
+
if (!node) return;
|
|
1680
|
+
if (Array.isArray(node)) {
|
|
1681
|
+
node.forEach(item => maskKeysInPlace(item, keys, replacement));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
if (typeof node !== 'object') return;
|
|
1685
|
+
Object.keys(node).forEach((key) => {
|
|
1686
|
+
if (matchesPattern(key, keys, false)) {
|
|
1687
|
+
try { node[key] = replacement; } catch {}
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
maskKeysInPlace(node[key], keys, replacement);
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function maskPathInPlace(node: any, pathParts: string[], replacement: any, depth: number = 0) {
|
|
1695
|
+
if (!node) return;
|
|
1696
|
+
if (depth >= pathParts.length) return;
|
|
1697
|
+
const part = pathParts[depth];
|
|
1698
|
+
const isLast = depth === pathParts.length - 1;
|
|
1699
|
+
|
|
1700
|
+
const applyAt = (container: any, key: string | number) => {
|
|
1701
|
+
if (!container) return;
|
|
1702
|
+
try { container[key] = replacement; } catch {}
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
if (part === '*') {
|
|
1706
|
+
if (Array.isArray(node)) {
|
|
1707
|
+
for (let i = 0; i < node.length; i++) {
|
|
1708
|
+
if (isLast) applyAt(node, i);
|
|
1709
|
+
else maskPathInPlace(node[i], pathParts, replacement, depth + 1);
|
|
1710
|
+
}
|
|
1711
|
+
} else if (typeof node === 'object') {
|
|
1712
|
+
for (const key of Object.keys(node)) {
|
|
1713
|
+
if (isLast) applyAt(node, key);
|
|
1714
|
+
else maskPathInPlace(node[key], pathParts, replacement, depth + 1);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const index = Number(part);
|
|
1721
|
+
const isIndex = Number.isInteger(index) && String(index) === part;
|
|
1722
|
+
if (Array.isArray(node) && isIndex) {
|
|
1723
|
+
if (index < 0 || index >= node.length) return;
|
|
1724
|
+
if (isLast) applyAt(node, index);
|
|
1725
|
+
else maskPathInPlace(node[index], pathParts, replacement, depth + 1);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (typeof node !== 'object') return;
|
|
1730
|
+
if (!Object.prototype.hasOwnProperty.call(node, part)) return;
|
|
1731
|
+
if (isLast) applyAt(node, part);
|
|
1732
|
+
else maskPathInPlace(node[part], pathParts, replacement, depth + 1);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function applyMasking(
|
|
1736
|
+
target: ReproMaskTarget,
|
|
1737
|
+
value: any,
|
|
1738
|
+
req: MaskRequestContext,
|
|
1739
|
+
trace: TraceEventForFilter | null,
|
|
1740
|
+
masking: NormalizedMaskingConfig | null
|
|
1741
|
+
) {
|
|
1742
|
+
if (!masking || !masking.rules.length) return value;
|
|
1743
|
+
if (value === undefined) return value;
|
|
1744
|
+
for (const rule of masking.rules) {
|
|
1745
|
+
if (!rule.targets.includes(target)) continue;
|
|
1746
|
+
if (!matchesMaskWhen(rule.when, req, trace)) continue;
|
|
1747
|
+
const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
|
|
1748
|
+
if (rule.keys) maskKeysInPlace(value, rule.keys, replacement);
|
|
1749
|
+
if (rule.paths.length) {
|
|
1750
|
+
rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return value;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1487
1756
|
// ===================================================================
|
|
1488
1757
|
// reproMiddleware — unchanged behavior + passive per-request trace
|
|
1489
1758
|
// ===================================================================
|
|
@@ -1492,11 +1761,14 @@ export type ReproMiddlewareConfig = {
|
|
|
1492
1761
|
tenantId: string;
|
|
1493
1762
|
appSecret: string;
|
|
1494
1763
|
apiBase: string;
|
|
1495
|
-
/** Configure header capture/
|
|
1764
|
+
/** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
|
|
1496
1765
|
captureHeaders?: boolean | HeaderCaptureOptions;
|
|
1766
|
+
/** Optional masking rules for request/response payloads and function traces. */
|
|
1767
|
+
masking?: ReproMaskingConfig;
|
|
1497
1768
|
};
|
|
1498
1769
|
|
|
1499
1770
|
export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
1771
|
+
const masking = normalizeMaskingConfig(cfg.masking);
|
|
1500
1772
|
return function (req: Request, res: Response, next: NextFunction) {
|
|
1501
1773
|
const sid = (req.headers['x-bug-session-id'] as string) || '';
|
|
1502
1774
|
const aid = (req.headers['x-bug-action-id'] as string) || '';
|
|
@@ -1509,8 +1781,10 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
1509
1781
|
const rid = nextRequestId(requestEpochMs);
|
|
1510
1782
|
const t0 = requestStartRaw;
|
|
1511
1783
|
const url = (req as any).originalUrl || req.url || '/';
|
|
1784
|
+
const urlPathOnly = (url || '/').split('?')[0] || '/';
|
|
1512
1785
|
const path = url; // back-compat
|
|
1513
1786
|
const key = normalizeRouteKey(req.method, url);
|
|
1787
|
+
const maskReq: MaskRequestContext = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
|
|
1514
1788
|
const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
|
|
1515
1789
|
beginSessionRequest(sid);
|
|
1516
1790
|
|
|
@@ -1651,14 +1925,43 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
1651
1925
|
evt.functionType = ev.functionType;
|
|
1652
1926
|
}
|
|
1653
1927
|
|
|
1928
|
+
const candidate: TraceEventForFilter = {
|
|
1929
|
+
type: evt.type,
|
|
1930
|
+
eventType: evt.type,
|
|
1931
|
+
functionType: evt.functionType ?? null,
|
|
1932
|
+
fn: evt.fn,
|
|
1933
|
+
wrapperClass: inferWrapperClassFromFn(evt.fn),
|
|
1934
|
+
file: evt.file ?? null,
|
|
1935
|
+
depth: evt.depth,
|
|
1936
|
+
library: inferLibraryNameFromFile(evt.file),
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1654
1939
|
if (ev.args !== undefined) {
|
|
1655
|
-
evt.args =
|
|
1940
|
+
evt.args = applyMasking(
|
|
1941
|
+
'trace.args',
|
|
1942
|
+
sanitizeTraceArgs(ev.args),
|
|
1943
|
+
maskReq,
|
|
1944
|
+
candidate,
|
|
1945
|
+
masking
|
|
1946
|
+
);
|
|
1656
1947
|
}
|
|
1657
1948
|
if (ev.returnValue !== undefined) {
|
|
1658
|
-
evt.returnValue =
|
|
1949
|
+
evt.returnValue = applyMasking(
|
|
1950
|
+
'trace.returnValue',
|
|
1951
|
+
sanitizeTraceValue(ev.returnValue),
|
|
1952
|
+
maskReq,
|
|
1953
|
+
candidate,
|
|
1954
|
+
masking
|
|
1955
|
+
);
|
|
1659
1956
|
}
|
|
1660
1957
|
if (ev.error !== undefined) {
|
|
1661
|
-
evt.error =
|
|
1958
|
+
evt.error = applyMasking(
|
|
1959
|
+
'trace.error',
|
|
1960
|
+
sanitizeTraceValue(ev.error),
|
|
1961
|
+
maskReq,
|
|
1962
|
+
candidate,
|
|
1963
|
+
masking
|
|
1964
|
+
);
|
|
1662
1965
|
}
|
|
1663
1966
|
if (ev.threw !== undefined) {
|
|
1664
1967
|
evt.threw = Boolean(ev.threw);
|
|
@@ -1667,16 +1970,6 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
1667
1970
|
evt.unawaited = ev.unawaited === true;
|
|
1668
1971
|
}
|
|
1669
1972
|
|
|
1670
|
-
const candidate: TraceEventForFilter = {
|
|
1671
|
-
type: evt.type,
|
|
1672
|
-
eventType: evt.type,
|
|
1673
|
-
functionType: evt.functionType ?? null,
|
|
1674
|
-
fn: evt.fn,
|
|
1675
|
-
file: evt.file ?? null,
|
|
1676
|
-
depth: evt.depth,
|
|
1677
|
-
library: inferLibraryNameFromFile(evt.file),
|
|
1678
|
-
};
|
|
1679
|
-
|
|
1680
1973
|
const dropEvent = shouldDropTraceEvent(candidate);
|
|
1681
1974
|
const spanKey = normalizeSpanId(evt.spanId);
|
|
1682
1975
|
if (evt.type === 'enter') {
|
|
@@ -1759,9 +2052,54 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
1759
2052
|
?? firstAppTrace
|
|
1760
2053
|
?? { fn: null, file: null, line: null, functionType: null };
|
|
1761
2054
|
const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
|
|
1762
|
-
const
|
|
1763
|
-
|
|
1764
|
-
|
|
2055
|
+
const endpointTraceCtx: TraceEventForFilter | null = (() => {
|
|
2056
|
+
if (!chosenEndpoint?.fn && !chosenEndpoint?.file) return null;
|
|
2057
|
+
return {
|
|
2058
|
+
type: 'enter',
|
|
2059
|
+
eventType: 'enter',
|
|
2060
|
+
fn: chosenEndpoint.fn ?? undefined,
|
|
2061
|
+
wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
|
|
2062
|
+
file: chosenEndpoint.file ?? null,
|
|
2063
|
+
functionType: chosenEndpoint.functionType ?? null,
|
|
2064
|
+
library: inferLibraryNameFromFile(chosenEndpoint.file),
|
|
2065
|
+
};
|
|
2066
|
+
})();
|
|
2067
|
+
|
|
2068
|
+
const requestBody = applyMasking(
|
|
2069
|
+
'request.body',
|
|
2070
|
+
sanitizeRequestSnapshot((req as any).body),
|
|
2071
|
+
maskReq,
|
|
2072
|
+
endpointTraceCtx,
|
|
2073
|
+
masking
|
|
2074
|
+
);
|
|
2075
|
+
const requestParams = applyMasking(
|
|
2076
|
+
'request.params',
|
|
2077
|
+
sanitizeRequestSnapshot((req as any).params),
|
|
2078
|
+
maskReq,
|
|
2079
|
+
endpointTraceCtx,
|
|
2080
|
+
masking
|
|
2081
|
+
);
|
|
2082
|
+
const requestQuery = applyMasking(
|
|
2083
|
+
'request.query',
|
|
2084
|
+
sanitizeRequestSnapshot((req as any).query),
|
|
2085
|
+
maskReq,
|
|
2086
|
+
endpointTraceCtx,
|
|
2087
|
+
masking
|
|
2088
|
+
);
|
|
2089
|
+
const maskedHeaders = applyMasking(
|
|
2090
|
+
'request.headers',
|
|
2091
|
+
requestHeaders,
|
|
2092
|
+
maskReq,
|
|
2093
|
+
endpointTraceCtx,
|
|
2094
|
+
masking
|
|
2095
|
+
);
|
|
2096
|
+
const responseBody = applyMasking(
|
|
2097
|
+
'response.body',
|
|
2098
|
+
capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody),
|
|
2099
|
+
maskReq,
|
|
2100
|
+
endpointTraceCtx,
|
|
2101
|
+
masking
|
|
2102
|
+
);
|
|
1765
2103
|
|
|
1766
2104
|
const requestPayload: Record<string, any> = {
|
|
1767
2105
|
rid,
|
|
@@ -1770,9 +2108,9 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
1770
2108
|
path,
|
|
1771
2109
|
status: res.statusCode,
|
|
1772
2110
|
durMs: Date.now() - t0,
|
|
1773
|
-
headers:
|
|
2111
|
+
headers: maskedHeaders,
|
|
1774
2112
|
key,
|
|
1775
|
-
respBody:
|
|
2113
|
+
respBody: responseBody,
|
|
1776
2114
|
trace: traceBatches.length ? undefined : '[]',
|
|
1777
2115
|
};
|
|
1778
2116
|
if (requestBody !== undefined) requestPayload.body = requestBody;
|