@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.
@@ -0,0 +1,176 @@
1
+ # vitrinka recorder redaction — engine specification
2
+
3
+ This document defines the redaction semantics every vitrinka capture client
4
+ must implement, in any language. The TypeScript engine in this package is the
5
+ reference implementation; `vectors.json` (same directory) is the conformance
6
+ suite — **an implementation is conformant when it passes every vector**. The
7
+ vitrinka server applies the same semantics at ingest as the load-bearing
8
+ backstop, so client-side redaction is defense in depth: a conformant client
9
+ never lets a secret leave the device, and a broken one still cannot store one.
10
+
11
+ An implementation MAY redact **more** than required (superset matching is
12
+ explicitly encouraged), except where a vector's `exact` field demands
13
+ byte-identical output — those cases pin down *over*-redaction bugs (benign
14
+ data must survive).
15
+
16
+ ## The policy document
17
+
18
+ Clients fetch the workspace policy at session start from
19
+ `GET /api/v1/recorder/policy` (same PAT-gated auth as the ingest surface):
20
+
21
+ ```jsonc
22
+ {
23
+ "extraHeaders": ["X-Tenant-Secret"], // extra header names to scrub
24
+ "extraBodyKeys": ["internalId"], // extra body keys, matched normalized
25
+ "patterns": ["vk_[A-Za-z0-9]+"], // regexes; every match → "[redacted]"
26
+ "maskAllText": false, // DOM recorders mask ALL text;
27
+ // pixel recorders degrade to blur
28
+ "fullFidelity": false // self-host escape hatch — no-op
29
+ }
30
+ ```
31
+
32
+ Rules:
33
+
34
+ - **Fail closed.** A failed/slow/absent fetch means the built-in defaults —
35
+ never capture-everything. The fetch must never reject; defaults apply until
36
+ it resolves. Capture may start before it resolves only in states that are
37
+ already safe (DOM recording starts with inputs masked).
38
+ - `patterns` served to clients are pre-filtered server-side to a
39
+ backtracking-safe subset, but each pattern still compiles inside its own
40
+ try/catch: a bad pattern is skipped (and logged), the rest keep working.
41
+ - `fullFidelity: true` disables all scrubbing. Clients only ever *honor* the
42
+ field (the server refuses to serve it unless the deployment allows it);
43
+ clients never default to it.
44
+
45
+ ## Normalization
46
+
47
+ `norm(name)` = lowercase, then remove every `-` and `_`. All name matching
48
+ below is on normalized names, so `card_number` ≡ `cardNumber` ≡ `Card-Number`.
49
+
50
+ ## Default rule set
51
+
52
+ Header names (values replaced with `[redacted]`): `authorization`,
53
+ `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`,
54
+ `x-csrf-token`, `x-amz-security-token`, plus any name whose normalized form
55
+ **ends with** `apikey` or `token`, plus `extraHeaders`.
56
+
57
+ Body keys (values replaced wholesale, recursively): `password`, `passwd`,
58
+ `secret`, `token`, `access_token`, `refresh_token`, `id_token`,
59
+ `authorization`, `api_key`, `apikey`, `client_secret`, `card`, `card_number`,
60
+ `cvv`, `cvc`, `pin`, `ssn`, plus `extraBodyKeys`.
61
+
62
+ URL parameters: every sensitive body key, plus the `apikey`/`token` suffix
63
+ rule (`?access_token=…`, `?sas_token=…`, `?my_api_key=…`).
64
+
65
+ The reference engine additionally **token-matches** names (splitting
66
+ camelCase/snake/kebab into words) against a secret vocabulary (`auth`, `otp`,
67
+ `pin`, `cvv`, `iban`, `ssn`, `credential`, …) and secret phrases
68
+ (`privatekey`, `sessionid`, `cardnumber`, …), so `X-Dev-Auth-Secret` and
69
+ `otpCode` are caught without being listed. Ports SHOULD implement this; the
70
+ vectors only require the normalized-set matching above.
71
+
72
+ ## Surfaces
73
+
74
+ Every capture surface routes through the matching transform. The replacement
75
+ marker is always the string `[redacted]` — a marker, not deletion, so replay
76
+ still shows the field existed.
77
+
78
+ ### Headers
79
+
80
+ Sensitive names lose their value; benign values pass through the extra
81
+ patterns. Values are capped (reference: 1024 chars/value, 8 KiB budget per
82
+ map, overflow marked with a `"…": "(truncated)"` entry).
83
+
84
+ ### Bodies
85
+
86
+ Dispatch on shape and declared content type:
87
+
88
+ 1. **JSON** (first non-space char `{`/`[`), within the structural size limit:
89
+ parse, walk recursively (bounded depth; beyond it, subtree → `[redacted]`).
90
+ Sensitive-keyed values replaced wholesale; string values under URL-ish keys
91
+ (`url`, `href`, `uri`, `location`) get URL scrubbing; every other string
92
+ passes the extra patterns.
93
+ 2. **JSON that fails to parse or exceeds the parse limit** (truncated at a
94
+ client cap — the cap must NEVER become a bypass): a key-pair fallback regex
95
+ rewrites `"<sensitive-key>": <value>` pairs, tolerating a missing closing
96
+ quote; then patterns.
97
+ 3. **`application/x-www-form-urlencoded`**: split pairs on **both `&` and
98
+ `;`** and scrub key-wise, THEN run the free-text scan over the result.
99
+ Never a platform query parser — `;` handling is the known bypass class
100
+ (see below). The content-type path must be a SUPERSET of the shapeless
101
+ free-text path, never a replacement: a benign-keyed value can carry a raw
102
+ credential the key scrub cannot see (`client_assertion=eyJ…`).
103
+ 4. **`multipart/form-data`**: parse parts by the boundary; a part whose
104
+ `Content-Disposition` form name is sensitive loses its value; other part
105
+ values (file parts included) get the free-text scan (a part value can be a
106
+ header line, a bare JWT, or embedded JSON carrying sensitive keys);
107
+ rebuild with the same boundary. If the body won't parse (truncated, no
108
+ boundary): FAIL CLOSED — omit the body with a marker. Free-text scanning
109
+ cannot see multipart structure (a `name="password"` part's raw value sits
110
+ on its own line), so a partial scan would leak exactly the fields the key
111
+ scrub exists for.
112
+ 5. **Anything else**: free-text scanning (reference behavior, see below) and
113
+ the extra patterns. At minimum the patterns must run.
114
+
115
+ **Order with capping**: redact JSON *before* any size cap (capping first
116
+ makes it invalid JSON and silently downgrades it to the weaker fallback);
117
+ cap free text first, then scrub the slice.
118
+
119
+ ### URLs
120
+
121
+ Scrub the query AND the fragment (implicit-grant OAuth returns tokens as
122
+ `#access_token=…`). Split pairs on **both `&` and `;`** — never
123
+ `URLSearchParams`/`url.ParseQuery`, which mishandle `;` and turn one
124
+ semicolon into a full bypass. Do not trust platform URL parsers to accept
125
+ every captured URL: an unparseable URL still scrubs everything after the
126
+ first `?`/`#` (the reference implementation is string-split end to end).
127
+ A fully benign URL comes back **byte-identical** (no re-encoding churn).
128
+
129
+ A benign-named parameter whose *decoded* value embeds a secret pair
130
+ (`?next=%2Fcb%3Ftoken%3D…`) also loses its value (reference behavior;
131
+ decoding for inspection fails closed, escape-by-escape, bounded double-decode).
132
+ Hash-route fragments nest a query inside one pair (`#/reset?token=…` yields
133
+ the key `/reset?token`): the part after the last `?` in a pair's key MUST be
134
+ inspected as a name too, or set-matched keys (including policy extras) evade
135
+ the scrub. Truncated-JSON fallback keys MUST be classified with JSON string
136
+ escapes decoded (`pass\\u0077ord` ≡ `password`).
137
+
138
+ ### Free text (reference engine; ports SHOULD follow)
139
+
140
+ Header-shaped lines (`Key: value`) with sensitive names lose the rest of the
141
+ line; `Authorization`-family names mask mid-line too; standalone scheme
142
+ credentials (`Bearer …`, `Basic …`, `Digest realm=…`) and bare JWTs
143
+ (`eyJ…`) are masked; generic `key=value` / `"key": value` pairs with
144
+ sensitive keys lose their values. Then patterns.
145
+
146
+ ### DOM capture (rrweb-style recorders)
147
+
148
+ `maskDirectives(rules)`:
149
+
150
+ - default: `maskAllInputs: true` — input values never recorded. This is the
151
+ fail-closed *starting* state: recording must begin masked even if policy
152
+ delivery races.
153
+ - `maskAllText` ⇒ `maskAllText: true` + `maskTextSelector: "*"`.
154
+ - `fullFidelity` ⇒ both off.
155
+
156
+ ### Pixel capture (screenshot recorders)
157
+
158
+ Screenshots carry real rendered text — there is no DOM to mask. Under
159
+ `maskAllText` (without `fullFidelity`) `pixelPolicy(rules)` = `blur`: degrade
160
+ resolution until text is unreadable while layout survives. Otherwise `none`.
161
+
162
+ ## Event shape invariants
163
+
164
+ - Recorded event kinds never change (`net` for Expo, `request` for the
165
+ extension — stored sessions carry them forever).
166
+ - Existing body caps (64 KiB in both kit clients) stay as they are.
167
+
168
+ ## Conformance
169
+
170
+ Run every case in `vectors.json`. Case surfaces map to:
171
+ `headerName` → the sensitive-header predicate; `headers` → the header map
172
+ transform; `body` → the body transform (with `contentType`); `url` → the URL
173
+ transform. Assertions: `exact` (byte-identical output), `mustNotContain`,
174
+ `mustContain`, `outputIsJson`, `redactedKeys`/`keptEntries`/
175
+ `valueMustNotContain` for header maps. The `policy` field, when present, is
176
+ the policy to compile for that case.
@@ -0,0 +1,294 @@
1
+ {
2
+ "version": 1,
3
+ "description": "Language-agnostic conformance vectors for the vitrinka recorder redaction engine, ported from the server engine's table tests (vitrinka internal/redact/redact_test.go). Any implementation, in any language, must pass every case. Semantics: REDACTED = \"[redacted]\"; an implementation MAY redact more than required (superset matching), except where `exact` demands byte-identical output.",
4
+ "cases": [
5
+ {
6
+ "name": "default sensitive header names",
7
+ "surface": "headerName",
8
+ "sensitive": true,
9
+ "inputs": [
10
+ "Authorization", "authorization", "AUTHORIZATION",
11
+ "Proxy-Authorization", "Cookie", "Set-Cookie",
12
+ "X-Api-Key", "x-api-key", "X_API_KEY",
13
+ "X-Auth-Token", "X-CSRF-Token", "X-Amz-Security-Token",
14
+ "X-Access-Token", "X-Github-Token", "Api-Key", "ApiKey", "My-Service-Api-Key"
15
+ ]
16
+ },
17
+ {
18
+ "name": "benign header names stay benign",
19
+ "surface": "headerName",
20
+ "sensitive": false,
21
+ "inputs": ["Content-Type", "Accept", "X-Request-Id", "User-Agent", "ETag"]
22
+ },
23
+ {
24
+ "name": "header map: sensitive values redacted, benign kept",
25
+ "surface": "headers",
26
+ "policy": { "extraHeaders": ["X-Tenant-Secret"] },
27
+ "input": {
28
+ "Authorization": "Bearer sk-live-12345",
29
+ "Cookie": "session=abcdef",
30
+ "X-Tenant-Secret": "shh",
31
+ "Content-Type": "application/json"
32
+ },
33
+ "redactedKeys": ["Authorization", "Cookie", "X-Tenant-Secret"],
34
+ "keptEntries": { "Content-Type": "application/json" }
35
+ },
36
+ {
37
+ "name": "JSON body: nested objects and arrays scrub key-wise",
38
+ "surface": "body",
39
+ "contentType": "application/json",
40
+ "input": "{\"user\":{\"name\":\"lukas\",\"password\":\"hunter2\",\"cards\":[{\"card_number\":\"4111111111111111\",\"cvv\":\"123\",\"label\":\"main\"}]},\"items\":[{\"token\":\"tok_x\"},{\"note\":\"ok\"}],\"accessToken\":\"at\"}",
41
+ "mustNotContain": ["hunter2", "4111111111111111", "\"123\"", "tok_x", "\"at\""],
42
+ "mustContain": ["lukas", "main", "ok"],
43
+ "outputIsJson": true
44
+ },
45
+ {
46
+ "name": "form-encoded body scrubs key-wise",
47
+ "surface": "body",
48
+ "contentType": "application/x-www-form-urlencoded; charset=utf-8",
49
+ "input": "username=lukas&password=hunter2&remember=1",
50
+ "mustNotContain": ["hunter2"],
51
+ "mustContain": ["username=lukas", "remember=1"]
52
+ },
53
+ {
54
+ "name": "semicolon separator never bypasses: query",
55
+ "surface": "url",
56
+ "input": "https://app.example.com/cb?a=1;access_token=SEMI-SECRET",
57
+ "mustNotContain": ["SEMI-SECRET"],
58
+ "mustContain": ["a=1"]
59
+ },
60
+ {
61
+ "name": "semicolon separator never bypasses: query, secret first",
62
+ "surface": "url",
63
+ "input": "https://app.example.com/cb?access_token=SEMI-SECRET;x=1",
64
+ "mustNotContain": ["SEMI-SECRET"]
65
+ },
66
+ {
67
+ "name": "semicolon separator never bypasses: mixed separators",
68
+ "surface": "url",
69
+ "input": "https://app.example.com/cb?a=1;access_token=SEMI-SECRET&b=2",
70
+ "mustNotContain": ["SEMI-SECRET"],
71
+ "mustContain": ["a=1"]
72
+ },
73
+ {
74
+ "name": "semicolon separator never bypasses: fragment",
75
+ "surface": "url",
76
+ "input": "https://app.example.com/cb#access_token=FRAG-SECRET;type=bearer",
77
+ "mustNotContain": ["FRAG-SECRET"],
78
+ "mustContain": ["type=bearer"]
79
+ },
80
+ {
81
+ "name": "semicolon separator never bypasses: form body",
82
+ "surface": "body",
83
+ "contentType": "application/x-www-form-urlencoded",
84
+ "input": "y=1;password=FORM-SECRET",
85
+ "mustNotContain": ["FORM-SECRET"],
86
+ "mustContain": ["y=1"]
87
+ },
88
+ {
89
+ "name": "benign semicolon query stays byte-identical",
90
+ "surface": "url",
91
+ "input": "https://app.example.com/list?a=1;b=2",
92
+ "exact": "https://app.example.com/list?a=1;b=2"
93
+ },
94
+ {
95
+ "name": "unparseable URL still scrubs query",
96
+ "surface": "url",
97
+ "input": "https://app.example.com/a\u0000b?token=CTRL-SECRET&ok=1",
98
+ "mustNotContain": ["CTRL-SECRET"],
99
+ "mustContain": ["ok=1"]
100
+ },
101
+ {
102
+ "name": "unparseable URL still scrubs fragment",
103
+ "surface": "url",
104
+ "input": "https://app.example.com/a\u0000b#access_token=CTRL-FRAG",
105
+ "mustNotContain": ["CTRL-FRAG"]
106
+ },
107
+ {
108
+ "name": "unparseable URL still scrubs semicolon pairs",
109
+ "surface": "url",
110
+ "input": "https://app.example.com/a\u0000b?x=1;token=CTRL-SEMI",
111
+ "mustNotContain": ["CTRL-SEMI"],
112
+ "mustContain": ["x=1"]
113
+ },
114
+ {
115
+ "name": "plain prose body left alone",
116
+ "surface": "body",
117
+ "contentType": "text/plain",
118
+ "input": "plain text mentioning password words but not a form",
119
+ "exact": "plain text mentioning password words but not a form"
120
+ },
121
+ {
122
+ "name": "multipart/form-data: sensitive field loses its value, benign parts survive",
123
+ "surface": "body",
124
+ "contentType": "multipart/form-data; boundary=b123",
125
+ "input": "--b123\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\nlukas\r\n--b123\r\nContent-Disposition: form-data; name=\"password\"\r\n\r\nMULTI-SECRET\r\n--b123\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\nContent-Type: image/png\r\n\r\nPNGBYTES\r\n--b123--\r\n",
126
+ "mustNotContain": ["MULTI-SECRET"],
127
+ "mustContain": ["[redacted]", "lukas", "PNGBYTES", "--b123"]
128
+ },
129
+ {
130
+ "name": "multipart/form-data: policy extra body keys apply (normalized match)",
131
+ "surface": "body",
132
+ "policy": { "extraBodyKeys": ["internalId"] },
133
+ "contentType": "multipart/form-data; boundary=b123",
134
+ "input": "--b123\r\nContent-Disposition: form-data; name=\"internal_id\"\r\n\r\nEXTRA-SECRET\r\n--b123--\r\n",
135
+ "mustNotContain": ["EXTRA-SECRET"]
136
+ },
137
+ {
138
+ "name": "truncated multipart FAILS CLOSED — the body is omitted, never half-scanned",
139
+ "surface": "body",
140
+ "contentType": "multipart/form-data; boundary=b123",
141
+ "input": "--b123\r\nContent-Disposition: form-data; name=\"password\"\r\n\r\nTRUNC-SECRET\r\n--b123\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\nContent-Type: image/png\r\n\r\nPNGBYTES\r\n--b1",
142
+ "mustNotContain": ["TRUNC-SECRET", "PNGBYTES"]
143
+ },
144
+ {
145
+ "name": "hash-route fragment: nested query key is inspected (set-only key)",
146
+ "surface": "url",
147
+ "policy": { "extraBodyKeys": ["internalId"] },
148
+ "input": "https://app.example.com/#/reset?internal_id=NESTED-SECRET",
149
+ "mustNotContain": ["NESTED-SECRET"]
150
+ },
151
+ {
152
+ "name": "hash-route fragment: nested magic-link token is scrubbed",
153
+ "surface": "url",
154
+ "input": "https://app.example.com/#/reset?token=FRAG-ROUTE-SECRET",
155
+ "mustNotContain": ["FRAG-ROUTE-SECRET"]
156
+ },
157
+ {
158
+ "name": "truncated JSON: escaped key spellings cannot evade the fallback",
159
+ "surface": "body",
160
+ "contentType": "application/json",
161
+ "input": "{\"note\":\"x\",\"pass\\u0077ord\":\"ESCAPED-LEAK\",\"blob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa",
162
+ "mustNotContain": ["ESCAPED-LEAK"]
163
+ },
164
+ {
165
+ "name": "form body: benign-keyed JWT credential is still text-scanned (CT path is a superset)",
166
+ "surface": "body",
167
+ "contentType": "application/x-www-form-urlencoded",
168
+ "input": "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&client_assertion=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.SFlKc2VjcmV0U2ln",
169
+ "mustNotContain": ["eyJhbGciOiJIUzI1NiJ9"],
170
+ "mustContain": ["grant_type="]
171
+ },
172
+ {
173
+ "name": "multipart: benign part carrying a Bearer header line is text-scanned",
174
+ "surface": "body",
175
+ "contentType": "multipart/form-data; boundary=b123",
176
+ "input": "--b123\r\nContent-Disposition: form-data; name=\"payload\"\r\n\r\nAuthorization: Bearer sk_live_ABC123SECRET\r\n--b123--\r\n",
177
+ "mustNotContain": ["sk_live_ABC123SECRET"]
178
+ },
179
+ {
180
+ "name": "multipart: benign part carrying embedded JSON with a sensitive key is scanned",
181
+ "surface": "body",
182
+ "contentType": "multipart/form-data; boundary=b123",
183
+ "input": "--b123\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\r\n{\"access_token\":\"sk_live_SECRET\"}\r\n--b123--\r\n",
184
+ "mustNotContain": ["sk_live_SECRET"]
185
+ },
186
+ {
187
+ "name": "truncated JSON still scrubs known keys (cap is never a bypass)",
188
+ "surface": "body",
189
+ "contentType": "application/json",
190
+ "input": "{\"note\":\"x\",\"password\":\"hunter2\",\"refresh_token\":\"rt-abc\",\"blob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
191
+ "mustNotContain": ["hunter2", "rt-abc"]
192
+ },
193
+ {
194
+ "name": "URL: OAuth code callback keeps state, loses access_token",
195
+ "surface": "url",
196
+ "input": "https://app.example.com/cb?code=abc&access_token=AT-1&state=xyz",
197
+ "mustNotContain": ["AT-1"],
198
+ "mustContain": ["state=xyz", "/cb"]
199
+ },
200
+ {
201
+ "name": "URL: SAS token suffix rule",
202
+ "surface": "url",
203
+ "input": "https://acct.blob.core.windows.net/c/b.png?sv=1&sig=SAS&sas_token=SAS2",
204
+ "mustNotContain": ["SAS2"],
205
+ "mustContain": ["sv=1"]
206
+ },
207
+ {
208
+ "name": "URL: magic link token",
209
+ "surface": "url",
210
+ "input": "https://app.example.com/magic?token=MAGIC",
211
+ "mustNotContain": ["MAGIC"]
212
+ },
213
+ {
214
+ "name": "URL: implicit-grant fragment token",
215
+ "surface": "url",
216
+ "input": "https://app.example.com/cb#access_token=FRAG&type=bearer",
217
+ "mustNotContain": ["FRAG"],
218
+ "mustContain": ["type=bearer"]
219
+ },
220
+ {
221
+ "name": "benign URL stays byte-identical: encoded query",
222
+ "surface": "url",
223
+ "input": "https://api.example.com/v1/items?page=2&q=caf%C3%A9",
224
+ "exact": "https://api.example.com/v1/items?page=2&q=caf%C3%A9"
225
+ },
226
+ {
227
+ "name": "benign URL stays byte-identical: relative path",
228
+ "surface": "url",
229
+ "input": "/relative/path?ok=1",
230
+ "exact": "/relative/path?ok=1"
231
+ },
232
+ {
233
+ "name": "benign URL stays byte-identical: SPA fragment route",
234
+ "surface": "url",
235
+ "input": "https://app.example.com/#/spa/route",
236
+ "exact": "https://app.example.com/#/spa/route"
237
+ },
238
+ {
239
+ "name": "url-keyed strings inside JSON bodies get URL scrubbing",
240
+ "surface": "body",
241
+ "contentType": "application/json",
242
+ "input": "{\"callback\":{\"href\":\"https://x.example/cb?api_key=KEY-1\"}}",
243
+ "mustNotContain": ["KEY-1"]
244
+ },
245
+ {
246
+ "name": "policy extensions: extra body key (normalized) + inline pattern",
247
+ "surface": "body",
248
+ "policy": { "extraBodyKeys": ["internalId"], "patterns": ["vk[a-z]?_[A-Za-z0-9]{8,}"] },
249
+ "contentType": "application/json",
250
+ "input": "{\"internal_id\":\"sek\",\"note\":\"key vkb_abcdefgh12 inline\"}",
251
+ "mustNotContain": ["sek", "vkb_abcdefgh12"]
252
+ },
253
+ {
254
+ "name": "policy patterns run over benign header values",
255
+ "surface": "headers",
256
+ "policy": { "patterns": ["vk[a-z]?_[A-Za-z0-9]{8,}"] },
257
+ "input": { "X-Debug": "saw vkb_abcdefgh12 here" },
258
+ "redactedKeys": [],
259
+ "valueMustNotContain": { "X-Debug": ["vkb_abcdefgh12"] }
260
+ },
261
+ {
262
+ "name": "a bad regex pattern is skipped, defaults still apply",
263
+ "surface": "body",
264
+ "policy": { "patterns": ["("] },
265
+ "contentType": "application/json",
266
+ "input": "{\"password\":\"hunter2\",\"note\":\"ok\"}",
267
+ "mustNotContain": ["hunter2"],
268
+ "mustContain": ["ok"]
269
+ },
270
+ {
271
+ "name": "fullFidelity: headers pass through untouched",
272
+ "surface": "headers",
273
+ "policy": { "fullFidelity": true },
274
+ "input": { "Authorization": "Bearer x" },
275
+ "redactedKeys": [],
276
+ "keptEntries": { "Authorization": "Bearer x" }
277
+ },
278
+ {
279
+ "name": "fullFidelity: body passes through untouched",
280
+ "surface": "body",
281
+ "policy": { "fullFidelity": true },
282
+ "contentType": "application/json",
283
+ "input": "{\"password\":\"hunter2\"}",
284
+ "exact": "{\"password\":\"hunter2\"}"
285
+ },
286
+ {
287
+ "name": "fullFidelity: URL passes through untouched",
288
+ "surface": "url",
289
+ "policy": { "fullFidelity": true },
290
+ "input": "https://app.example.com/cb?access_token=AT-1",
291
+ "exact": "https://app.example.com/cb?access_token=AT-1"
292
+ }
293
+ ]
294
+ }