@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/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
+ }