@vitrinka/redact 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/CHANGELOG.md +23 -0
- package/README.md +61 -0
- package/build/iife.d.ts +10 -0
- package/build/iife.js +8 -0
- package/build/index.d.ts +171 -0
- package/build/index.js +634 -0
- package/package.json +47 -0
- package/spec/REDACTION-SPEC.md +176 -0
- package/spec/vectors.json +294 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitrinka/redact — the recorder redaction engine.
|
|
3
|
+
*
|
|
4
|
+
* Safe-by-default, capture-side scrubbing of auth-bearing headers, sensitive
|
|
5
|
+
* body keys, and URL query/fragment secrets, extensible per workspace via the
|
|
6
|
+
* redaction policy the vitrinka server serves at
|
|
7
|
+
* `GET /api/v1/recorder/policy`. The server re-applies the same policy at
|
|
8
|
+
* ingest (its `internal/redact` engine) as the load-bearing backstop — this
|
|
9
|
+
* engine is defense in depth: secrets never transit, stored payloads shrink.
|
|
10
|
+
*
|
|
11
|
+
* The engine is a PLATFORM, not a client: it owns no I/O and no capture. A
|
|
12
|
+
* recorder (browser extension, Expo, or a future Flutter/Swift/Kotlin port)
|
|
13
|
+
* compiles the policy into a {@link RuleSet} once, then routes each capture
|
|
14
|
+
* surface through the matching transform:
|
|
15
|
+
*
|
|
16
|
+
* headers → {@link redactHeaders}
|
|
17
|
+
* bodies → {@link redactBody} / {@link redactAndCap}
|
|
18
|
+
* URLs → {@link redactUrl}
|
|
19
|
+
* free text → {@link redactText}
|
|
20
|
+
* DOM capture → {@link maskDirectives} (rrweb-style recorders)
|
|
21
|
+
* pixels → {@link pixelPolicy} (screenshot recorders)
|
|
22
|
+
*
|
|
23
|
+
* Conformance is defined by `spec/REDACTION-SPEC.md` + `spec/vectors.json`
|
|
24
|
+
* (ported from the server engine's table tests): any implementation, in any
|
|
25
|
+
* language, must pass the vectors. This TS implementation is the reference.
|
|
26
|
+
*
|
|
27
|
+
* Matching is DELIBERATELY a superset of the server's: the server matches
|
|
28
|
+
* normalized names (lowercase, `-`/`_` stripped) against its default sets;
|
|
29
|
+
* this engine additionally token-matches key names (so `X-Dev-Auth-Secret`,
|
|
30
|
+
* `otpCode` and `user_password_hash` hit without being listed). Over-redacting
|
|
31
|
+
* a value the server would keep is acceptable; the reverse is not.
|
|
32
|
+
*
|
|
33
|
+
* FAIL CLOSED: `compileRules(null)` is the full default rule set. A client
|
|
34
|
+
* whose policy fetch fails must use it — never capture-everything. Only an
|
|
35
|
+
* explicit `fullFidelity: true` from the server (self-host escape hatch,
|
|
36
|
+
* env-gated there) disables scrubbing.
|
|
37
|
+
*/
|
|
38
|
+
/** Replaces every scrubbed value — a marker, not "", so a replayed session
|
|
39
|
+
* still shows that a header/field WAS present. */
|
|
40
|
+
export const REDACTED = '[redacted]';
|
|
41
|
+
/** Always-scrubbed header names, normalized — mirrors the server engine. */
|
|
42
|
+
const DEFAULT_HEADERS = [
|
|
43
|
+
'authorization',
|
|
44
|
+
'proxyauthorization',
|
|
45
|
+
'cookie',
|
|
46
|
+
'setcookie',
|
|
47
|
+
'xapikey',
|
|
48
|
+
'xauthtoken',
|
|
49
|
+
'xcsrftoken',
|
|
50
|
+
'xamzsecuritytoken',
|
|
51
|
+
];
|
|
52
|
+
/** Always-scrubbed body keys, normalized — mirrors the server engine. */
|
|
53
|
+
const DEFAULT_BODY_KEYS = [
|
|
54
|
+
'password',
|
|
55
|
+
'passwd',
|
|
56
|
+
'secret',
|
|
57
|
+
'token',
|
|
58
|
+
'accesstoken',
|
|
59
|
+
'refreshtoken',
|
|
60
|
+
'idtoken',
|
|
61
|
+
'authorization',
|
|
62
|
+
'apikey',
|
|
63
|
+
'clientsecret',
|
|
64
|
+
'card',
|
|
65
|
+
'cardnumber',
|
|
66
|
+
'cvv',
|
|
67
|
+
'cvc',
|
|
68
|
+
'pin',
|
|
69
|
+
'ssn',
|
|
70
|
+
];
|
|
71
|
+
/** Canonicalize a header/body-key name: lowercase, `-` and `_` removed. */
|
|
72
|
+
export function norm(s) {
|
|
73
|
+
return String(s).toLowerCase().replace(/[-_]/g, '');
|
|
74
|
+
}
|
|
75
|
+
let cache = null;
|
|
76
|
+
/**
|
|
77
|
+
* Compile a policy (or null/undefined = the safe defaults) into a RuleSet.
|
|
78
|
+
* Cached by policy identity, so calling per event is free. Bad regexes are
|
|
79
|
+
* skipped individually — the server backstop still applies them at ingest.
|
|
80
|
+
*/
|
|
81
|
+
export function compileRules(policy) {
|
|
82
|
+
const src = JSON.stringify(policy ?? null);
|
|
83
|
+
if (cache?.src === src)
|
|
84
|
+
return cache.rules;
|
|
85
|
+
const headers = new Set(DEFAULT_HEADERS);
|
|
86
|
+
const bodyKeys = new Set(DEFAULT_BODY_KEYS);
|
|
87
|
+
const patterns = [];
|
|
88
|
+
for (const h of policy?.extraHeaders ?? []) {
|
|
89
|
+
const n = norm(h);
|
|
90
|
+
if (n)
|
|
91
|
+
headers.add(n);
|
|
92
|
+
}
|
|
93
|
+
for (const k of policy?.extraBodyKeys ?? []) {
|
|
94
|
+
const n = norm(k);
|
|
95
|
+
if (n)
|
|
96
|
+
bodyKeys.add(n);
|
|
97
|
+
}
|
|
98
|
+
for (const p of policy?.patterns ?? []) {
|
|
99
|
+
try {
|
|
100
|
+
patterns.push(new RegExp(p, 'g'));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
// Server-validated as RE2; an engine-specific miss must not kill capture.
|
|
104
|
+
console.warn('vitrinka redact: unsupported pattern skipped', p, e);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const rules = {
|
|
108
|
+
full: policy?.fullFidelity === true,
|
|
109
|
+
maskAllText: policy?.maskAllText === true,
|
|
110
|
+
headers,
|
|
111
|
+
bodyKeys,
|
|
112
|
+
patterns,
|
|
113
|
+
};
|
|
114
|
+
cache = { src, rules };
|
|
115
|
+
return rules;
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Key classification — token matching on top of the normalized sets
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
/** Exact key TOKENS that mark a secret (compared per word, case-folded). */
|
|
121
|
+
const SECRET_TOKENS = new Set([
|
|
122
|
+
'password',
|
|
123
|
+
'passwd',
|
|
124
|
+
'pwd',
|
|
125
|
+
'secret',
|
|
126
|
+
'token',
|
|
127
|
+
'auth',
|
|
128
|
+
'authorization',
|
|
129
|
+
'otp',
|
|
130
|
+
'pin',
|
|
131
|
+
'apikey',
|
|
132
|
+
'credential',
|
|
133
|
+
'credentials',
|
|
134
|
+
'cookie',
|
|
135
|
+
'cvv',
|
|
136
|
+
'cvc',
|
|
137
|
+
'iban',
|
|
138
|
+
'ssn',
|
|
139
|
+
]);
|
|
140
|
+
/** Multi-word names that only read as secrets when joined. */
|
|
141
|
+
const SECRET_PHRASES = [
|
|
142
|
+
'apikey',
|
|
143
|
+
'privatekey',
|
|
144
|
+
'secretkey',
|
|
145
|
+
'sessionid',
|
|
146
|
+
'cardnumber',
|
|
147
|
+
'birthnumber',
|
|
148
|
+
'rodnecislo',
|
|
149
|
+
'accesstoken',
|
|
150
|
+
'refreshtoken',
|
|
151
|
+
'idtoken',
|
|
152
|
+
];
|
|
153
|
+
/** Split a key into lowercase word tokens: camelCase, snake_case, kebab-case, dots. */
|
|
154
|
+
function tokenize(key) {
|
|
155
|
+
return key
|
|
156
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
157
|
+
.split(/[^A-Za-z0-9]+/)
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.map((t) => t.toLowerCase());
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Token-wise secret detection: TOKEN matching, not substring, so `author`,
|
|
163
|
+
* `authorId`, `shippingAddress` and `pinned` stay intact while
|
|
164
|
+
* `Authorization`, `accessToken` and `X-Dev-Auth-Secret` are caught.
|
|
165
|
+
*/
|
|
166
|
+
export function isSecretKey(key) {
|
|
167
|
+
const tokens = tokenize(key);
|
|
168
|
+
if (tokens.some((t) => SECRET_TOKENS.has(t)))
|
|
169
|
+
return true;
|
|
170
|
+
// Phrases match a run of CONSECUTIVE tokens, never a raw substring of the
|
|
171
|
+
// joined key: `joined.includes('privatekey')` also matched
|
|
172
|
+
// `privateKeyboardEnabled` and `apiKeyboardLayout`.
|
|
173
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
174
|
+
let run = '';
|
|
175
|
+
for (let j = i; j < tokens.length; j++) {
|
|
176
|
+
run += tokens[j];
|
|
177
|
+
if (SECRET_PHRASES.includes(run))
|
|
178
|
+
return true;
|
|
179
|
+
if (run.length > 24)
|
|
180
|
+
break; // no phrase is longer
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Is this header's value scrubbed? Listed (defaults + policy), an
|
|
187
|
+
* api-key/token-suffixed variant (the server's suffix rule), or token-secret.
|
|
188
|
+
*/
|
|
189
|
+
export function sensitiveHeader(rules, name) {
|
|
190
|
+
const n = norm(name);
|
|
191
|
+
return (rules.headers.has(n) || n.endsWith('apikey') || n.endsWith('token') || isSecretKey(name));
|
|
192
|
+
}
|
|
193
|
+
/** Is this JSON/form key's value scrubbed? Listed (normalized) or token-secret. */
|
|
194
|
+
export function sensitiveBodyKey(rules, name) {
|
|
195
|
+
return rules.bodyKeys.has(norm(name)) || isSecretKey(name);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Is this URL query/fragment parameter's value scrubbed? Every sensitive body
|
|
199
|
+
* key, plus the api-key/token suffix rule (?access_token=…, ?sas_token=…).
|
|
200
|
+
*/
|
|
201
|
+
export function sensitiveParam(rules, name) {
|
|
202
|
+
if (sensitiveBodyKey(rules, name))
|
|
203
|
+
return true;
|
|
204
|
+
const n = norm(name);
|
|
205
|
+
return n.endsWith('apikey') || n.endsWith('token');
|
|
206
|
+
}
|
|
207
|
+
/** Apply the policy's extra regex patterns to one string value. */
|
|
208
|
+
function maskPatterns(rules, s) {
|
|
209
|
+
let out = s;
|
|
210
|
+
for (const rx of rules.patterns) {
|
|
211
|
+
rx.lastIndex = 0;
|
|
212
|
+
out = out.replace(rx, REDACTED);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Percent-decoding for INSPECTION (never for output), failing closed
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
/** Does this text contain a `secretkey=value` pair? Used for encoded values. */
|
|
220
|
+
function containsSecretPair(rules, text) {
|
|
221
|
+
for (const m of text.matchAll(/([A-Za-z][A-Za-z0-9_.\-[\]]{0,60})\s*[:=]/g)) {
|
|
222
|
+
if (sensitiveBodyKey(rules, m[1]))
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Decode for INSPECTION, failing closed. `decodeURIComponent` is
|
|
229
|
+
* all-or-nothing: one malformed escape anywhere (`?next=token%3Dhunter2%ZZ`)
|
|
230
|
+
* threw, and the value was then scanned in its still-encoded form where
|
|
231
|
+
* `containsSecretPair` cannot see the encoded `=`. Decode escape-by-escape so
|
|
232
|
+
* a bad tail cannot hide a good prefix, and repeat a bounded number of times
|
|
233
|
+
* so a double-encoded `token%253D…` is seen.
|
|
234
|
+
*/
|
|
235
|
+
function safeDecode(s) {
|
|
236
|
+
let out = s;
|
|
237
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
238
|
+
const next = decodeEscapes(out);
|
|
239
|
+
if (next === out)
|
|
240
|
+
break;
|
|
241
|
+
out = next;
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/** Percent-decode what we can; leave individually-malformed escapes as-is. */
|
|
246
|
+
function decodeEscapes(s) {
|
|
247
|
+
if (!s.includes('%'))
|
|
248
|
+
return s;
|
|
249
|
+
try {
|
|
250
|
+
return decodeURIComponent(s);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Per-run best effort, then per-ESCAPE: a run containing malformed UTF-8
|
|
254
|
+
// (e.g. `%3Dhunter2%E0%80`) used to be returned whole, keeping the valid
|
|
255
|
+
// `%3D` encoded so the structural scan could not see it. ASCII-range
|
|
256
|
+
// escapes decode individually; non-ASCII bytes that cannot stand alone
|
|
257
|
+
// are left as-is.
|
|
258
|
+
return s.replace(/(%[0-9A-Fa-f]{2})+/g, (seq) => {
|
|
259
|
+
try {
|
|
260
|
+
return decodeURIComponent(seq);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return seq.replace(/%([0-9A-Fa-f]{2})/g, (esc, hex) => {
|
|
264
|
+
const code = Number.parseInt(hex, 16);
|
|
265
|
+
return code < 0x80 ? String.fromCharCode(code) : esc;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// URLs — query AND fragment, split on BOTH `&` and `;`
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
/**
|
|
275
|
+
* Redact sensitive-keyed pairs in one raw query/fragment/form string,
|
|
276
|
+
* splitting on BOTH separators (`&` and `;`). Never URLSearchParams (it
|
|
277
|
+
* treats `;` as a literal, hiding a trailing `;access_token=…` inside the
|
|
278
|
+
* previous value) and never the platform URL parser (React Native's is
|
|
279
|
+
* incomplete). A pair with a benign name still loses its value when the
|
|
280
|
+
* DECODED value carries an embedded secret pair (`?next=%2Fcb%3Ftoken%3D…`).
|
|
281
|
+
* A fully benign string comes back byte-identical.
|
|
282
|
+
*/
|
|
283
|
+
export function scrubPairs(rules, sensitive, raw) {
|
|
284
|
+
let changed = false;
|
|
285
|
+
const out = raw
|
|
286
|
+
.split(/[&;]/)
|
|
287
|
+
.map((p) => {
|
|
288
|
+
if (p === '')
|
|
289
|
+
return p;
|
|
290
|
+
const i = p.indexOf('=');
|
|
291
|
+
const key = i >= 0 ? p.slice(0, i) : p;
|
|
292
|
+
const name = safeDecode(key.replace(/\+/g, ' '));
|
|
293
|
+
// Hash-route fragments nest a query inside one "pair":
|
|
294
|
+
// `#/reset?token=SECRET` arrives as key `/reset?token`. Token matching
|
|
295
|
+
// catches suffix-shaped names, but set-only keys (`card`, policy
|
|
296
|
+
// extraBodyKeys) match on the WHOLE normalized name — inspect the part
|
|
297
|
+
// after the last `?` too, or nested keys evade the equality matchers.
|
|
298
|
+
const q = name.lastIndexOf('?');
|
|
299
|
+
const nested = q >= 0 ? name.slice(q + 1) : null;
|
|
300
|
+
if (sensitive(rules, name) || (nested !== null && sensitive(rules, nested))) {
|
|
301
|
+
changed = true;
|
|
302
|
+
return `${key}=${REDACTED}`;
|
|
303
|
+
}
|
|
304
|
+
if (i >= 0 && containsSecretPair(rules, safeDecode(p.slice(i + 1)))) {
|
|
305
|
+
changed = true;
|
|
306
|
+
return `${key}=${REDACTED}`;
|
|
307
|
+
}
|
|
308
|
+
return p; // preserve benign pairs' original encoding
|
|
309
|
+
});
|
|
310
|
+
return changed ? out.join('&') : raw;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Redact sensitive query/fragment parameter values in one URL — OAuth/OIDC
|
|
314
|
+
* callbacks (?access_token=…), magic links (?token=…), SAS URLs. The fragment
|
|
315
|
+
* scrubs too (implicit-grant OAuth returns tokens as `#access_token=…`).
|
|
316
|
+
* String-split rather than URL-parsed: React Native's URL is incomplete, and
|
|
317
|
+
* an unparseable URL must never be a free pass. Benign URLs pass through
|
|
318
|
+
* byte-identical; the extra patterns run over the result.
|
|
319
|
+
*/
|
|
320
|
+
export function redactUrl(rules, raw) {
|
|
321
|
+
if (rules.full || !raw)
|
|
322
|
+
return raw;
|
|
323
|
+
const i = raw.search(/[?#]/);
|
|
324
|
+
if (i < 0)
|
|
325
|
+
return maskPatterns(rules, raw);
|
|
326
|
+
// Everything after the first ?/# is pair territory; the # split keeps a
|
|
327
|
+
// fragment's pairs keyed correctly (and `#/spa/route` fragments, with no
|
|
328
|
+
// `=`, come back untouched).
|
|
329
|
+
const scrubbed = raw
|
|
330
|
+
.slice(i + 1)
|
|
331
|
+
.split('#')
|
|
332
|
+
.map((seg) => scrubPairs(rules, sensitiveParam, seg))
|
|
333
|
+
.join('#');
|
|
334
|
+
return maskPatterns(rules, raw.slice(0, i + 1) + scrubbed);
|
|
335
|
+
}
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Free text — header lines, auth schemes, JWTs, key=value pairs
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
/**
|
|
340
|
+
* Generic `key=value` / `"key": "value"` scanner for non-JSON text. The key is
|
|
341
|
+
* handed to the SAME predicates as the structural path, so the two can never
|
|
342
|
+
* drift apart. Quantifiers are bounded — an unbounded key-prefix class
|
|
343
|
+
* backtracks quadratically. The VALUE groups are unbounded on purpose: a
|
|
344
|
+
* `{1,4096}` bound matched only a prefix, leaving the tail of a longer
|
|
345
|
+
* credential recorded verbatim (negated classes with `*` are linear).
|
|
346
|
+
*/
|
|
347
|
+
const KV_PAIR = /([A-Za-z][A-Za-z0-9_.\-[\]]{0,60})("?\s*[:=]\s*(?!\/\/))("?)([^&?"'\s,}]*)\3/g;
|
|
348
|
+
/**
|
|
349
|
+
* Query parameters get their OWN pass, before the generic scanner. Without it
|
|
350
|
+
* a URL's scheme matched the generic pattern first (`https:` as key), which
|
|
351
|
+
* consumed the query string so its real parameters were never examined.
|
|
352
|
+
*/
|
|
353
|
+
const QUERY_PARAM = /([?&])([^=&#\s]{1,120})=([^&#\s]*)/g;
|
|
354
|
+
/**
|
|
355
|
+
* Header-shaped lines (`Key: value`) mask their ENTIRE value: for a header
|
|
356
|
+
* the secret is the rest of the line, not a whitespace-free token.
|
|
357
|
+
*/
|
|
358
|
+
const HEADER_LINE = /^([A-Za-z][A-Za-z0-9_.-]{0,60})[ \t]*:[ \t]*(.+)$/gm;
|
|
359
|
+
/** Beyond this, only the structural JSON path is worth running. */
|
|
360
|
+
const MASK_TEXT_LIMIT = 128 * 1024;
|
|
361
|
+
function maskText(rules, text) {
|
|
362
|
+
if (text.length > MASK_TEXT_LIMIT)
|
|
363
|
+
return `[body omitted: ${text.length} bytes, unmaskable]`;
|
|
364
|
+
const scrubbed = text
|
|
365
|
+
// Secret HEADERS mask their whole value first — running this before the
|
|
366
|
+
// generic pass avoids matching only `Bearer` in `authorization: Bearer
|
|
367
|
+
// <token>` and only the first pair of `Cookie: a=1; b=2`.
|
|
368
|
+
.replace(HEADER_LINE, (match, key) => sensitiveHeader(rules, key) ? `${key}: ${REDACTED}` : match)
|
|
369
|
+
// An auth header appearing MID-LINE (not at line start) loses everything
|
|
370
|
+
// after the key — without wiping ANY line containing e.g. "Digest".
|
|
371
|
+
.replace(/\b((?:proxy-)?authorization|www-authenticate)(\s*[:=]\s*)[^\r\n]+/gi, (_m, key, sep) => `${key}${sep}${REDACTED}`)
|
|
372
|
+
// A scheme CHALLENGE/CREDENTIAL standing on its own. The lookahead
|
|
373
|
+
// requires the first token after the scheme to be a `param=`, which is
|
|
374
|
+
// what makes this a credential rather than prose — `Digest mismatch for
|
|
375
|
+
// asset …` does not match, `Digest realm="api", nonce="abc"` does.
|
|
376
|
+
.replace(/\b(Digest|Negotiate|NTLM)\s+(?=[A-Za-z][A-Za-z-]{0,30}\s*=)[^\r\n]+/gi, (_m, scheme) => `${scheme} ${REDACTED}`)
|
|
377
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${REDACTED}`)
|
|
378
|
+
.replace(/\beyJ[A-Za-z0-9._-]{10,}/g, REDACTED) // bare JWT
|
|
379
|
+
.replace(QUERY_PARAM, (match, lead, key, value) => {
|
|
380
|
+
if (sensitiveParam(rules, safeDecode(key)))
|
|
381
|
+
return `${lead}${key}=${REDACTED}`;
|
|
382
|
+
// An ordinary key can CARRY an encoded URL/form payload that holds a
|
|
383
|
+
// secret: `?next=%2Fcb%3Ftoken%3Dsupersecret`.
|
|
384
|
+
if (containsSecretPair(rules, safeDecode(value)))
|
|
385
|
+
return `${lead}${key}=${REDACTED}`;
|
|
386
|
+
return match;
|
|
387
|
+
})
|
|
388
|
+
.replace(KV_PAIR, (match, key, sep, quote, value) => {
|
|
389
|
+
if (value === REDACTED)
|
|
390
|
+
return match; // already masked by an earlier pass
|
|
391
|
+
return sensitiveBodyKey(rules, key) ? `${key}${sep}${quote}${REDACTED}${quote}` : match;
|
|
392
|
+
});
|
|
393
|
+
return maskPatterns(rules, scrubbed);
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Bodies — JSON structural, form-encoded, multipart, truncation fallback
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
/** URL-valued payload keys get query/fragment scrubbing, not just patterns. */
|
|
399
|
+
function urlKey(k) {
|
|
400
|
+
switch (norm(k)) {
|
|
401
|
+
case 'url':
|
|
402
|
+
case 'href':
|
|
403
|
+
case 'uri':
|
|
404
|
+
case 'location':
|
|
405
|
+
return true;
|
|
406
|
+
default:
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const MAX_SCRUB_DEPTH = 64; // recursion guard for hostile deeply-nested bodies
|
|
411
|
+
function scrubTree(rules, v, depth) {
|
|
412
|
+
if (depth > MAX_SCRUB_DEPTH)
|
|
413
|
+
return REDACTED;
|
|
414
|
+
if (Array.isArray(v))
|
|
415
|
+
return v.map((x) => scrubTree(rules, x, depth + 1));
|
|
416
|
+
if (v !== null && typeof v === 'object') {
|
|
417
|
+
const out = {};
|
|
418
|
+
for (const [k, mv] of Object.entries(v)) {
|
|
419
|
+
if (sensitiveBodyKey(rules, k))
|
|
420
|
+
out[k] = REDACTED;
|
|
421
|
+
else if (typeof mv === 'string' && urlKey(k))
|
|
422
|
+
out[k] = redactUrl(rules, mv);
|
|
423
|
+
else
|
|
424
|
+
out[k] = scrubTree(rules, mv, depth + 1);
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
return typeof v === 'string' ? maskPatterns(rules, v) : v;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Key-pair fallback for a body that LOOKS like JSON but fails to parse
|
|
432
|
+
* (typically truncated at a client's byte cap) — the cap must never become a
|
|
433
|
+
* redaction bypass. The string alternative accepts a missing closing quote
|
|
434
|
+
* (truncation); the bare alternative stops at JSON structure.
|
|
435
|
+
*/
|
|
436
|
+
/** Decode JSON string escapes in a KEY for classification (`password`
|
|
437
|
+
* ≡ `password` to a real parser, so it must be to the fallback too). */
|
|
438
|
+
function decodeJsonKey(key) {
|
|
439
|
+
if (!key.includes('\\'))
|
|
440
|
+
return key;
|
|
441
|
+
return key.replace(/\\u([0-9A-Fa-f]{4})|\\(.)/g, (_m, hex, ch) => hex !== undefined ? String.fromCharCode(Number.parseInt(hex, 16)) : ch);
|
|
442
|
+
}
|
|
443
|
+
function scrubTruncatedJson(rules, body) {
|
|
444
|
+
// The bare-value alternative stops at JSON STRUCTURE (`{`, `[`, `"` as well
|
|
445
|
+
// as `,}]`): a benign key must consume only its scalar value, never a
|
|
446
|
+
// nested object — `"nested":{"access_token":…}` would otherwise ride inside
|
|
447
|
+
// the benign match and the sensitive pair would never be scanned. The key
|
|
448
|
+
// class admits JSON escapes, DECODED before classification — a real parser
|
|
449
|
+
// sees `password` as `password`, so the fallback must too.
|
|
450
|
+
return body.replace(/("((?:[A-Za-z0-9_-]|\\u[0-9A-Fa-f]{4}|\\.)+)"\s*:\s*)("(?:[^"\\]|\\.)*"?|[^,{}[\]"\r\n]*)/g, (m, pre, key) => sensitiveBodyKey(rules, decodeJsonKey(key)) ? `${pre}"${REDACTED}"` : m);
|
|
451
|
+
}
|
|
452
|
+
function looksLikeJson(text) {
|
|
453
|
+
const t = text.trimStart();
|
|
454
|
+
return t.startsWith('{') || t.startsWith('[');
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Scrub a multipart/form-data body: every form field whose name is a
|
|
458
|
+
* sensitive body key loses its value; other part values (including file
|
|
459
|
+
* parts) get the FULL free-text scan — a part value can be a header line, a
|
|
460
|
+
* bare JWT, or embedded JSON carrying sensitive keys. Rebuilt with the SAME
|
|
461
|
+
* boundary. Returns null — caller falls back to free-text masking over the
|
|
462
|
+
* whole body — when the content-type carries no boundary or the structure
|
|
463
|
+
* won't parse (typically truncated at the client cap): never emit a
|
|
464
|
+
* half-scrubbed reconstruction.
|
|
465
|
+
*/
|
|
466
|
+
function scrubMultipart(rules, body, contentType) {
|
|
467
|
+
const bm = /boundary="?([^";]+)"?/i.exec(contentType);
|
|
468
|
+
if (!bm)
|
|
469
|
+
return null;
|
|
470
|
+
const delim = `--${bm[1]}`;
|
|
471
|
+
const segs = body.split(delim);
|
|
472
|
+
// Shape: "" ‖ "\r\n<headers>\r\n\r\n<value>\r\n" × N ‖ "--…" (closing).
|
|
473
|
+
if (segs.length < 3 || segs[0] !== '')
|
|
474
|
+
return null;
|
|
475
|
+
if (!segs[segs.length - 1].startsWith('--'))
|
|
476
|
+
return null; // truncated
|
|
477
|
+
const out = [segs[0]];
|
|
478
|
+
for (let i = 1; i < segs.length - 1; i++) {
|
|
479
|
+
const seg = segs[i];
|
|
480
|
+
if (!seg.startsWith('\r\n') || !seg.endsWith('\r\n'))
|
|
481
|
+
return null;
|
|
482
|
+
const hb = seg.indexOf('\r\n\r\n');
|
|
483
|
+
if (hb < 0)
|
|
484
|
+
return null;
|
|
485
|
+
const partHeaders = seg.slice(2, hb);
|
|
486
|
+
const value = seg.slice(hb + 4, -2);
|
|
487
|
+
const nm = /content-disposition:[^\r\n]*;\s*name="([^"]*)"/i.exec(partHeaders);
|
|
488
|
+
const name = nm?.[1] ?? '';
|
|
489
|
+
// Benign parts get the FULL text scanner, not patterns alone — a part
|
|
490
|
+
// value can be a header line (`Authorization: Bearer …`), a bare JWT, or
|
|
491
|
+
// an embedded JSON body carrying sensitive keys.
|
|
492
|
+
const scrubbed = name !== '' && sensitiveBodyKey(rules, name) ? REDACTED : maskText(rules, value);
|
|
493
|
+
out.push(`\r\n${partHeaders}\r\n\r\n${scrubbed}\r\n`);
|
|
494
|
+
}
|
|
495
|
+
out.push(segs[segs.length - 1]);
|
|
496
|
+
return out.join(delim);
|
|
497
|
+
}
|
|
498
|
+
/** Bodies above this skip the structural JSON path (parse + deep-copy cost). */
|
|
499
|
+
const JSON_STRUCTURAL_LIMIT = 256 * 1024;
|
|
500
|
+
/**
|
|
501
|
+
* Scrub one request/response body. JSON parses + scrubs recursively (shape
|
|
502
|
+
* preserved so a timeline still shows which fields were sent); a JSON-looking
|
|
503
|
+
* body that fails to parse — or is too big to parse safely — gets the
|
|
504
|
+
* key-pair fallback; form-encoded and multipart bodies scrub key-wise;
|
|
505
|
+
* anything else gets the free-text scanner + patterns. Never throws — capture
|
|
506
|
+
* must not break the recorded app.
|
|
507
|
+
*/
|
|
508
|
+
export function redactBody(rules, body, contentType) {
|
|
509
|
+
if (rules.full || !body)
|
|
510
|
+
return body;
|
|
511
|
+
try {
|
|
512
|
+
if (looksLikeJson(body)) {
|
|
513
|
+
if (body.length <= JSON_STRUCTURAL_LIMIT) {
|
|
514
|
+
try {
|
|
515
|
+
return JSON.stringify(scrubTree(rules, JSON.parse(body), 0));
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// truncated/malformed — fall through to the key-pair fallback
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return maskPatterns(rules, scrubTruncatedJson(rules, body));
|
|
522
|
+
}
|
|
523
|
+
const ct = (contentType ?? '').toLowerCase();
|
|
524
|
+
if (ct.includes('x-www-form-urlencoded')) {
|
|
525
|
+
// Key-wise scrub FIRST, then the full free-text scanner — the CT path
|
|
526
|
+
// must be a SUPERSET of the shapeless-text path, never a replacement:
|
|
527
|
+
// a benign-keyed value can carry a raw credential the key scrub cannot
|
|
528
|
+
// see (`client_assertion=eyJ…` — RFC 7523 / private_key_jwt).
|
|
529
|
+
return maskText(rules, scrubPairs(rules, sensitiveBodyKey, body));
|
|
530
|
+
}
|
|
531
|
+
if (ct.includes('multipart/form-data')) {
|
|
532
|
+
const scrubbed = scrubMultipart(rules, body, contentType ?? '');
|
|
533
|
+
if (scrubbed !== null)
|
|
534
|
+
return scrubbed;
|
|
535
|
+
// Unparseable (truncated at a cap, missing boundary): FAIL CLOSED and
|
|
536
|
+
// omit the body. The free-text scanner cannot see multipart structure —
|
|
537
|
+
// a leading `name="password"` part's raw value sits on its own line
|
|
538
|
+
// where no key=value rule fires — so emitting a scan of it would leak
|
|
539
|
+
// exactly the fields the key scrub exists for.
|
|
540
|
+
return `[multipart body omitted: unparseable (${body.length} chars)]`;
|
|
541
|
+
}
|
|
542
|
+
return maskText(rules, body);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return REDACTED; // never let a redaction failure leak the raw payload
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Redact a free-text capture (console line, unknown-shape body, URL-bearing
|
|
550
|
+
* log). JSON is walked structurally; anything else gets the text scanner.
|
|
551
|
+
* Never throws.
|
|
552
|
+
*/
|
|
553
|
+
export function redactText(rules, text) {
|
|
554
|
+
if (text === undefined)
|
|
555
|
+
return undefined;
|
|
556
|
+
if (rules.full)
|
|
557
|
+
return text;
|
|
558
|
+
if (looksLikeJson(text)) {
|
|
559
|
+
try {
|
|
560
|
+
return JSON.stringify(scrubTree(rules, JSON.parse(text), 0));
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// malformed (or truncated) JSON — fall through to text masking
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
return maskText(rules, text);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return REDACTED;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Redact, then cap to `cap` characters — with the ORDER chosen by shape:
|
|
575
|
+
* JSON redacts WHOLE then caps (capping first would slice it into invalid
|
|
576
|
+
* JSON, downgrading exactly the payloads most likely to carry credentials);
|
|
577
|
+
* anything else caps first (slicing text is harmless and bounds masking
|
|
578
|
+
* cost), then the truncation fallback still guards the sliced tail.
|
|
579
|
+
*/
|
|
580
|
+
export function redactAndCap(rules, body, cap, contentType) {
|
|
581
|
+
if (rules.full)
|
|
582
|
+
return body.length > cap ? `${body.slice(0, cap)}…[truncated]` : body;
|
|
583
|
+
if (looksLikeJson(body) && body.length <= JSON_STRUCTURAL_LIMIT) {
|
|
584
|
+
const clean = redactBody(rules, body, contentType);
|
|
585
|
+
return clean.length > cap ? `${clean.slice(0, cap)}…[truncated]` : clean;
|
|
586
|
+
}
|
|
587
|
+
const sliced = body.length > cap ? `${body.slice(0, cap)}…[truncated]` : body;
|
|
588
|
+
return redactBody(rules, sliced, contentType);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Scrub + cap a captured header map: sensitive names lose their value, the
|
|
592
|
+
* rest pass the extra patterns; values are bounded individually and by a
|
|
593
|
+
* total budget (the map gains a `…: (truncated)` marker when it overflows).
|
|
594
|
+
*/
|
|
595
|
+
export function redactHeaders(rules, headers, caps) {
|
|
596
|
+
if (!headers)
|
|
597
|
+
return undefined;
|
|
598
|
+
const maxValueLen = caps?.maxValueLen ?? 1024;
|
|
599
|
+
let budget = caps?.budget ?? 8192;
|
|
600
|
+
const out = {};
|
|
601
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
602
|
+
const raw = rules.full
|
|
603
|
+
? String(v)
|
|
604
|
+
: sensitiveHeader(rules, k)
|
|
605
|
+
? REDACTED
|
|
606
|
+
: maskPatterns(rules, String(v));
|
|
607
|
+
const val = raw.slice(0, maxValueLen);
|
|
608
|
+
budget -= k.length + val.length;
|
|
609
|
+
if (budget < 0) {
|
|
610
|
+
out['…'] = '(truncated)';
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
out[k] = val;
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
/** DOM-capture masking for rrweb-style recorders. */
|
|
618
|
+
export function maskDirectives(rules) {
|
|
619
|
+
if (rules.full)
|
|
620
|
+
return { maskAllInputs: false, maskAllText: false };
|
|
621
|
+
return {
|
|
622
|
+
maskAllInputs: true,
|
|
623
|
+
maskAllText: rules.maskAllText,
|
|
624
|
+
...(rules.maskAllText ? { maskTextSelector: '*' } : {}),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* What a PIXEL capture surface (screenshots — real rendered text, no DOM to
|
|
629
|
+
* mask) must do under these rules: `blur` = degrade resolution until text is
|
|
630
|
+
* unreadable while layout survives; `none` = capture normally.
|
|
631
|
+
*/
|
|
632
|
+
export function pixelPolicy(rules) {
|
|
633
|
+
return !rules.full && rules.maskAllText ? 'blur' : 'none';
|
|
634
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vitrinka/redact",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "vitrinka recorder redaction engine — safe-by-default scrubbing of auth headers, sensitive body keys, and URL secrets, driven by the workspace redaction policy. Shared by every vitrinka capture client.",
|
|
5
|
+
"license": "Elastic-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/henderson-tech/vitrinka-kit.git",
|
|
9
|
+
"directory": "packages/redact"
|
|
10
|
+
},
|
|
11
|
+
"source": "src/index.ts",
|
|
12
|
+
"main": "build/index.js",
|
|
13
|
+
"types": "build/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./build/index.d.ts",
|
|
17
|
+
"default": "./build/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./spec/vectors.json": "./spec/vectors.json",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"build",
|
|
24
|
+
"spec",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.build.json",
|
|
31
|
+
"build:iife": "bun build src/iife.ts --format=iife --outfile dist/redact.iife.js --banner '// GENERATED from packages/redact — do not edit; rebuild with: bun run --filter @vitrinka/redact build:iife'",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "bun test src",
|
|
34
|
+
"prepublishOnly": "tsc -p tsconfig.build.json"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "^1.2.0",
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"typescript": "~6.0.3"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"vitrinka",
|
|
43
|
+
"redaction",
|
|
44
|
+
"security",
|
|
45
|
+
"session-recording"
|
|
46
|
+
]
|
|
47
|
+
}
|