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