@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 ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release: the shared recorder redaction engine.
6
+
7
+ - Policy-driven rule compilation (`compileRules`) with built-in safe defaults
8
+ mirroring the vitrinka server's ingest engine: auth-bearing header names
9
+ (plus the `…api-key`/`…token` suffix rule), sensitive body keys (normalized
10
+ matching, so `card_number` ≡ `cardNumber`), and URL parameter scrubbing for
11
+ query AND fragment, split on both `&` and `;`.
12
+ - Token-based key matching on top of the defaults (`X-Dev-Auth-Secret`,
13
+ `otpCode`, `user_password_hash` are caught without being listed).
14
+ - Surface transforms: `redactHeaders` (with value/budget caps), `redactBody`
15
+ (JSON structural with recursion guard, form-encoded, multipart/form-data,
16
+ truncated-JSON key fallback), `redactUrl`, `redactText`, `redactAndCap`
17
+ (shape-aware redact/cap ordering), `maskDirectives` (rrweb-style DOM
18
+ recorders), `pixelPolicy` (screenshot recorders).
19
+ - Workspace policy extensions: `extraHeaders`, `extraBodyKeys`, `patterns`
20
+ (each compiled in its own try/catch; a bad pattern is skipped, never fatal),
21
+ `maskAllText`, `fullFidelity` passthrough.
22
+ - Language-agnostic `spec/REDACTION-SPEC.md` + `spec/vectors.json`
23
+ conformance suite for ports to other recorder platforms.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @vitrinka/redact
2
+
3
+ The vitrinka recorder redaction engine: safe-by-default scrubbing of
4
+ auth-bearing headers, sensitive body keys, and URL query/fragment secrets in
5
+ recorded sessions — extensible per workspace via the redaction policy the
6
+ vitrinka server serves, and shared by every vitrinka capture client
7
+ ([`@vitrinka/expo`](../expo) and the [browser extension](../../apps/extension)).
8
+
9
+ The vitrinka server applies the same policy again at ingest as the
10
+ load-bearing backstop. This engine is defense in depth: with it, secrets never
11
+ leave the device at all, and recorded payloads are smaller on the wire.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import {
17
+ compileRules,
18
+ redactHeaders,
19
+ redactBody,
20
+ redactUrl,
21
+ redactAndCap,
22
+ maskDirectives,
23
+ pixelPolicy,
24
+ } from '@vitrinka/redact';
25
+
26
+ // Fetch the workspace policy at session start (GET /api/v1/recorder/policy).
27
+ // FAIL CLOSED: on any failure, compile null — the built-in safe defaults.
28
+ const rules = compileRules(policyOrNull);
29
+
30
+ redactHeaders(rules, { Authorization: 'Bearer …' });
31
+ // → { Authorization: '[redacted]' }
32
+
33
+ redactBody(rules, '{"password":"hunter2"}', 'application/json');
34
+ // → '{"password":"[redacted]"}'
35
+
36
+ redactUrl(rules, 'https://app.example.com/cb#access_token=…');
37
+ // → 'https://app.example.com/cb#access_token=[redacted]'
38
+
39
+ redactAndCap(rules, body, 64 * 1024, contentType); // redact-then-cap, shape-aware
40
+
41
+ maskDirectives(rules); // rrweb-style DOM recorders: maskAllInputs/maskAllText
42
+ pixelPolicy(rules); // screenshot recorders: 'none' | 'blur'
43
+ ```
44
+
45
+ `compileRules` caches by policy identity — call it per event for free.
46
+ `fullFidelity: true` in the policy (the self-host escape hatch, env-gated
47
+ server-side) turns every transform into a pass-through; clients only honor
48
+ the field, never default to it.
49
+
50
+ ## Porting to other platforms
51
+
52
+ The engine is specified language-agnostically in
53
+ [`spec/REDACTION-SPEC.md`](spec/REDACTION-SPEC.md), with a portable
54
+ conformance suite in [`spec/vectors.json`](spec/vectors.json) (ported from the
55
+ server engine's table tests). A recorder for any UI technology — Flutter,
56
+ native iOS/Android, desktop — implements the spec's surface transforms and
57
+ must pass every vector. This TypeScript implementation is the reference.
58
+
59
+ ## License
60
+
61
+ [Elastic License 2.0](../../LICENSE).
@@ -0,0 +1,10 @@
1
+ /**
2
+ * IIFE entry for build-free consumers (the browser extension's service worker
3
+ * loads the built file via importScripts). Attaches the whole engine as
4
+ * `globalThis.VitrinkaRedact`. Built with `bun run build:iife`; the extension
5
+ * checks the artifact in and CI diffs it against a fresh build.
6
+ */
7
+ import * as engine from './index';
8
+ declare global {
9
+ var VitrinkaRedact: typeof engine;
10
+ }
package/build/iife.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * IIFE entry for build-free consumers (the browser extension's service worker
3
+ * loads the built file via importScripts). Attaches the whole engine as
4
+ * `globalThis.VitrinkaRedact`. Built with `bun run build:iife`; the extension
5
+ * checks the artifact in and CI diffs it against a fresh build.
6
+ */
7
+ import * as engine from './index';
8
+ globalThis.VitrinkaRedact = engine;
@@ -0,0 +1,171 @@
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 declare const REDACTED = "[redacted]";
41
+ /**
42
+ * The workspace-configurable redaction policy, exactly as the server serves
43
+ * it. The zero/absent policy is the safe default.
44
+ */
45
+ export interface RedactionPolicy {
46
+ /** Additional header names whose values are scrubbed. */
47
+ extraHeaders?: string[];
48
+ /** Additional JSON/form body keys to scrub recursively. */
49
+ extraBodyKeys?: string[];
50
+ /**
51
+ * Extra regex patterns; every match in recorded string values is replaced.
52
+ * The server pre-filters these to a backtracking-safe subset before serving
53
+ * them to clients; each is still compiled in its own try/catch here.
54
+ */
55
+ patterns?: string[];
56
+ /** Mask ALL text in DOM recordings; screenshot recorders degrade pixels. */
57
+ maskAllText?: boolean;
58
+ /**
59
+ * Disables redaction entirely — the self-host escape hatch. Only ever
60
+ * honored when the server explicitly served it; clients never default to it.
61
+ */
62
+ fullFidelity?: boolean;
63
+ }
64
+ /** A compiled policy — build once per policy via {@link compileRules}. */
65
+ export interface RuleSet {
66
+ readonly full: boolean;
67
+ readonly maskAllText: boolean;
68
+ /** Normalized header names (defaults + policy extras). */
69
+ readonly headers: ReadonlySet<string>;
70
+ /** Normalized body-key names (defaults + policy extras). */
71
+ readonly bodyKeys: ReadonlySet<string>;
72
+ readonly patterns: readonly RegExp[];
73
+ }
74
+ /** Canonicalize a header/body-key name: lowercase, `-` and `_` removed. */
75
+ export declare function norm(s: string): string;
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 declare function compileRules(policy?: RedactionPolicy | null): RuleSet;
82
+ /**
83
+ * Token-wise secret detection: TOKEN matching, not substring, so `author`,
84
+ * `authorId`, `shippingAddress` and `pinned` stay intact while
85
+ * `Authorization`, `accessToken` and `X-Dev-Auth-Secret` are caught.
86
+ */
87
+ export declare function isSecretKey(key: string): boolean;
88
+ /**
89
+ * Is this header's value scrubbed? Listed (defaults + policy), an
90
+ * api-key/token-suffixed variant (the server's suffix rule), or token-secret.
91
+ */
92
+ export declare function sensitiveHeader(rules: RuleSet, name: string): boolean;
93
+ /** Is this JSON/form key's value scrubbed? Listed (normalized) or token-secret. */
94
+ export declare function sensitiveBodyKey(rules: RuleSet, name: string): boolean;
95
+ /**
96
+ * Is this URL query/fragment parameter's value scrubbed? Every sensitive body
97
+ * key, plus the api-key/token suffix rule (?access_token=…, ?sas_token=…).
98
+ */
99
+ export declare function sensitiveParam(rules: RuleSet, name: string): boolean;
100
+ /**
101
+ * Redact sensitive-keyed pairs in one raw query/fragment/form string,
102
+ * splitting on BOTH separators (`&` and `;`). Never URLSearchParams (it
103
+ * treats `;` as a literal, hiding a trailing `;access_token=…` inside the
104
+ * previous value) and never the platform URL parser (React Native's is
105
+ * incomplete). A pair with a benign name still loses its value when the
106
+ * DECODED value carries an embedded secret pair (`?next=%2Fcb%3Ftoken%3D…`).
107
+ * A fully benign string comes back byte-identical.
108
+ */
109
+ export declare function scrubPairs(rules: RuleSet, sensitive: (rules: RuleSet, name: string) => boolean, raw: string): string;
110
+ /**
111
+ * Redact sensitive query/fragment parameter values in one URL — OAuth/OIDC
112
+ * callbacks (?access_token=…), magic links (?token=…), SAS URLs. The fragment
113
+ * scrubs too (implicit-grant OAuth returns tokens as `#access_token=…`).
114
+ * String-split rather than URL-parsed: React Native's URL is incomplete, and
115
+ * an unparseable URL must never be a free pass. Benign URLs pass through
116
+ * byte-identical; the extra patterns run over the result.
117
+ */
118
+ export declare function redactUrl(rules: RuleSet, raw: string): string;
119
+ /**
120
+ * Scrub one request/response body. JSON parses + scrubs recursively (shape
121
+ * preserved so a timeline still shows which fields were sent); a JSON-looking
122
+ * body that fails to parse — or is too big to parse safely — gets the
123
+ * key-pair fallback; form-encoded and multipart bodies scrub key-wise;
124
+ * anything else gets the free-text scanner + patterns. Never throws — capture
125
+ * must not break the recorded app.
126
+ */
127
+ export declare function redactBody(rules: RuleSet, body: string, contentType?: string): string;
128
+ /**
129
+ * Redact a free-text capture (console line, unknown-shape body, URL-bearing
130
+ * log). JSON is walked structurally; anything else gets the text scanner.
131
+ * Never throws.
132
+ */
133
+ export declare function redactText(rules: RuleSet, text: string | undefined): string | undefined;
134
+ /**
135
+ * Redact, then cap to `cap` characters — with the ORDER chosen by shape:
136
+ * JSON redacts WHOLE then caps (capping first would slice it into invalid
137
+ * JSON, downgrading exactly the payloads most likely to carry credentials);
138
+ * anything else caps first (slicing text is harmless and bounds masking
139
+ * cost), then the truncation fallback still guards the sliced tail.
140
+ */
141
+ export declare function redactAndCap(rules: RuleSet, body: string, cap: number, contentType?: string): string | undefined;
142
+ /** Size caps for a captured header map (one giant cookie must not bloat events). */
143
+ export interface HeaderCaps {
144
+ /** Max characters kept of one header value (default 1024). */
145
+ maxValueLen?: number;
146
+ /** Total budget in characters across the whole map (default 8192). */
147
+ budget?: number;
148
+ }
149
+ /**
150
+ * Scrub + cap a captured header map: sensitive names lose their value, the
151
+ * rest pass the extra patterns; values are bounded individually and by a
152
+ * total budget (the map gains a `…: (truncated)` marker when it overflows).
153
+ */
154
+ export declare function redactHeaders(rules: RuleSet, headers: Record<string, unknown> | undefined | null, caps?: HeaderCaps): Record<string, string> | undefined;
155
+ /** rrweb-style DOM-capture masking options derived from the rules. */
156
+ export interface MaskDirectives {
157
+ /** Fail-closed default: input VALUES are always masked unless fullFidelity. */
158
+ maskAllInputs: boolean;
159
+ /** Mask every text node (policy `maskAllText`). */
160
+ maskAllText: boolean;
161
+ /** Older rrweb spelling of maskAllText; set to `*` when it applies. */
162
+ maskTextSelector?: string;
163
+ }
164
+ /** DOM-capture masking for rrweb-style recorders. */
165
+ export declare function maskDirectives(rules: RuleSet): MaskDirectives;
166
+ /**
167
+ * What a PIXEL capture surface (screenshots — real rendered text, no DOM to
168
+ * mask) must do under these rules: `blur` = degrade resolution until text is
169
+ * unreadable while layout survives; `none` = capture normally.
170
+ */
171
+ export declare function pixelPolicy(rules: RuleSet): 'none' | 'blur';