@swfte/nexus-sdk 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/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +414 -0
- package/ai.d.ts +84 -0
- package/index.d.ts +433 -0
- package/otel.d.ts +80 -0
- package/package.json +93 -0
- package/policy.d.ts +141 -0
- package/src/ai.cjs +334 -0
- package/src/ai.js +39 -0
- package/src/core.cjs +2411 -0
- package/src/health.cjs +172 -0
- package/src/index.cjs +53 -0
- package/src/index.js +151 -0
- package/src/otel/bridge.cjs +257 -0
- package/src/otel/classify.cjs +166 -0
- package/src/otel/index.cjs +84 -0
- package/src/otel/index.js +39 -0
- package/src/otel/semconv.cjs +650 -0
- package/src/policy/engine.cjs +368 -0
- package/src/policy/envelope.cjs +256 -0
- package/src/policy/index.cjs +224 -0
- package/src/policy/rules.cjs +442 -0
- package/src/pricing.cjs +188 -0
- package/src/provenance.cjs +304 -0
- package/src/redact.cjs +734 -0
package/src/redact.cjs
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* In-process scrubbing, before anything is queued.
|
|
4
|
+
*
|
|
5
|
+
* A port of the Python SDK's `nexus/redact.py`, kept deliberately close to it: the two SDKs write
|
|
6
|
+
* into one ledger, and `tier: 'full'` has to mean the same thing in both or a customer who read one
|
|
7
|
+
* README and deployed the other gets a surprise made of their users' data.
|
|
8
|
+
*
|
|
9
|
+
* Obfuscation happens at the source, never at the backend — the one thing from Datadog's design
|
|
10
|
+
* worth copying without modification. Once a secret has left the process it has left the process;
|
|
11
|
+
* "we redact on ingest" is a promise about somebody else's infrastructure that a security review
|
|
12
|
+
* cannot verify.
|
|
13
|
+
*
|
|
14
|
+
* This is a deliberately smaller redactor than the wrapper's `nexus_devtools/redact.py`, and the
|
|
15
|
+
* difference is a design decision rather than an omission. That one runs on a developer's laptop
|
|
16
|
+
* against arbitrary shell output and can afford entropy heuristics and a wide pattern set. This one
|
|
17
|
+
* runs on the request path of a production service, where the cost is paid per call by somebody
|
|
18
|
+
* else's end users — so it is a bounded set of anchored patterns plus key-name matching, compiled
|
|
19
|
+
* once, with no entropy scan over unbounded text.
|
|
20
|
+
*
|
|
21
|
+
* The compensating control is that the **default tier is `metadata_only`**: at T0 no free text is
|
|
22
|
+
* transmitted at all, so redaction is a second line of defence rather than the only one.
|
|
23
|
+
*
|
|
24
|
+
* **Redaction failing must fail closed on the field, not on the event** (case 5.8). Dropping the
|
|
25
|
+
* whole event because one string could not be scrubbed also drops the governance record, which is
|
|
26
|
+
* exactly the record you most want when something odd is happening.
|
|
27
|
+
*
|
|
28
|
+
* ── What is different from the Python, and why ────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* Three things could not be transliterated, and each is a correctness issue rather than a style
|
|
31
|
+
* one. They are called out where they occur, but in summary:
|
|
32
|
+
*
|
|
33
|
+
* 1. **Unicode classes.** Python's `re.UNICODE` makes `\w` and `[^\W\d_]` mean "any script".
|
|
34
|
+
* JavaScript's `\w` is ASCII-only even with the `u` flag, so `[^\W\d_]` would have silently
|
|
35
|
+
* become "ASCII letter" and `jane@example.中国` would have stopped being an email address — the
|
|
36
|
+
* exact defect the Python file's comment says it fixed. Written with `\p{L}` instead.
|
|
37
|
+
* 2. **The IBAN checksum.** Python builds one arbitrarily large integer and takes `% 97`.
|
|
38
|
+
* JavaScript numbers lose integer precision past 2^53, so a 34-character IBAN would produce a
|
|
39
|
+
* rounded value and a checksum that passes or fails at random. Computed incrementally instead,
|
|
40
|
+
* which is exact.
|
|
41
|
+
* 3. **Regex state.** A `g`-flagged regex in JavaScript carries a mutable `lastIndex`, so calling
|
|
42
|
+
* `.test()` on a shared one gives a different answer every other call. Every anchored predicate
|
|
43
|
+
* here uses a non-global regex, and every scanner goes through `String.replace`, which resets it.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const MASK = '[REDACTED]';
|
|
47
|
+
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
49
|
+
// credentials
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Opening markers of secrets whose pattern has **no bounded length**, so a match can require
|
|
54
|
+
* arbitrarily many characters to complete. Everything else this module hunts is short and either
|
|
55
|
+
* matches inside the scan window or starts past the emitted prefix and is truncated away. See
|
|
56
|
+
* {@link scanWindow}.
|
|
57
|
+
*/
|
|
58
|
+
const UNTERMINATED_MARKERS = ['-----BEGIN'];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A `name =` / `name:` opener, used to answer the question the marker list cannot: *did the emitted
|
|
62
|
+
* prefix stop in the middle of somebody's secret?*
|
|
63
|
+
*
|
|
64
|
+
* A 9 kB `password = "…"` runs past the scan window, never meets its closing quote, matches no
|
|
65
|
+
* pattern, and would ship its first 280 characters. This is a post-condition on the output: the
|
|
66
|
+
* last assignment visible in the emitted text is looked up in {@link isSensitiveKey} and, if the
|
|
67
|
+
* mask does not follow it, the value ran past what we could scan. It needs no list of secret
|
|
68
|
+
* shapes, so a vendor format nobody has invented yet is covered on the day it appears.
|
|
69
|
+
*/
|
|
70
|
+
const ASSIGN_OPENER_RE = /([A-Za-z_][A-Za-z0-9_\-]{0,63})\s*[:=]/g;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Anchored, high-precision patterns. Order matters only in that longer forms come first.
|
|
74
|
+
*
|
|
75
|
+
* The set was chosen from the LLM-vendor list, which is the wrong axis: this SDK watches agents
|
|
76
|
+
* *calling tools*, and the credential an agent hands a tool is a payments key or a package-registry
|
|
77
|
+
* token far more often than it is a model key. Each of the second group was measured leaking
|
|
78
|
+
* through the Python `redact()` before being added there.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT here: bare 32-hex. It is the shape of an Azure key and also the shape of every
|
|
81
|
+
* MD5 in every log line this SDK will ever see, and a rule that masks content hashes deletes the
|
|
82
|
+
* record it exists to protect. `sk_test_` IS included alongside `sk_live_`: a test key is still a
|
|
83
|
+
* credential, and telling them apart is the customer's job, not ours.
|
|
84
|
+
*/
|
|
85
|
+
const PATTERNS = [
|
|
86
|
+
/\bsk-ant-[A-Za-z0-9_-]{16,}/gi, // Anthropic
|
|
87
|
+
/\bsk-proj-[A-Za-z0-9_-]{16,}/gi, // OpenAI project keys
|
|
88
|
+
/\bsk-[A-Za-z0-9]{20,}/gi, // OpenAI classic
|
|
89
|
+
/\bAIza[0-9A-Za-z_-]{30,}/gi, // Google API
|
|
90
|
+
/\bAKIA[0-9A-Z]{16}\b/gi, // AWS access key id
|
|
91
|
+
/\bASIA[0-9A-Z]{16}\b/gi, // AWS temporary key id
|
|
92
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/gi, // GitHub
|
|
93
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/gi, // Slack
|
|
94
|
+
/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/gi, // JWT
|
|
95
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi,
|
|
96
|
+
/\bhf_[A-Za-z0-9]{20,}/gi, // HuggingFace
|
|
97
|
+
/\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/gi, // Stripe secret/restricted
|
|
98
|
+
/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/gi, // SendGrid
|
|
99
|
+
/\bnpm_[A-Za-z0-9]{30,}/gi, // npm automation token
|
|
100
|
+
/\bpypi-[A-Za-z0-9_-]{32,}/gi, // PyPI API token
|
|
101
|
+
/\bglpat-[A-Za-z0-9_-]{20}/gi, // GitLab PAT
|
|
102
|
+
/\bdop_v1_[a-f0-9]{64}/gi, // DigitalOcean
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The words that make a *name* sensitive, written once.
|
|
107
|
+
*
|
|
108
|
+
* Two rules consume this alternation — the `key=value` rule that runs over prose, and the key-name
|
|
109
|
+
* rule that runs over mapping keys — and they are deliberately two separately compiled patterns
|
|
110
|
+
* rather than one built out of the other. Sharing the vocabulary keeps them from drifting; building
|
|
111
|
+
* one from the other makes one of them a no-op, which is a defect the Python file carries a long
|
|
112
|
+
* comment about having shipped.
|
|
113
|
+
*
|
|
114
|
+
* These are safe as substrings — no ordinary English or field-name word contains them — so they
|
|
115
|
+
* live here rather than in the segment list below. The test for membership is not "is this secret"
|
|
116
|
+
* but "can this word appear inside an innocent name": `cvv` cannot, `pin` can (`shipping`), and
|
|
117
|
+
* that is the whole basis for the split.
|
|
118
|
+
*/
|
|
119
|
+
const SENSITIVE_WORDS =
|
|
120
|
+
'api[_\\-]?key|secret|token|password|passwd|passphrase|credential|' +
|
|
121
|
+
'authorization|session[_\\-]?id|private[_\\-]?key|access[_\\-]?key|cookie|' +
|
|
122
|
+
'mnemonic|totp|cvv|cvc|mfa[_\\-]?code|recovery[_\\-]?code|seed[_\\-]?phrase';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Words sensitive only as a **whole segment** of a name.
|
|
126
|
+
*
|
|
127
|
+
* `auth` as a substring also condemns `author` and `authored_by` — which are not secrets, and are
|
|
128
|
+
* precisely the fields a governance ledger exists to carry. A rule that masks the author of a
|
|
129
|
+
* deploy has stopped protecting anything and started deleting the record. Likewise `pin` inside
|
|
130
|
+
* `shipping`, `otp` inside `dotproduct`, `dob` inside `dobson`.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately absent, having been considered and rejected upstream: `pass` (`pass_rate`,
|
|
133
|
+
* `first_pass` are metrics, and `password`/`passwd`/`passphrase` already cover the credential),
|
|
134
|
+
* `salt`, `nonce` and `sig` (public by construction — masking a signature deletes the governance
|
|
135
|
+
* record without protecting anything), and `refresh` (`refresh_token` is already caught by `token`;
|
|
136
|
+
* `refresh` alone is an interval).
|
|
137
|
+
*/
|
|
138
|
+
const SEGMENT_WORDS = 'auth|pwd|pin|otp|ssn|dob|jwt|bearer';
|
|
139
|
+
|
|
140
|
+
const SEGMENT_IN_NAME = '(?:[A-Za-z0-9]+[_\\-])*(?:' + SEGMENT_WORDS + ')(?:[_\\-][A-Za-z0-9]+)*';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `KEY=value` / `"key": "value"` / `--token value` / `Authorization: Bearer …`
|
|
144
|
+
*
|
|
145
|
+
* Prose, so `auth` stays in: `auth = hunter2hunter2` in a log line is a credential whatever the
|
|
146
|
+
* surrounding sentence is, and the value group's four-character floor keeps it off ordinary text.
|
|
147
|
+
* Both vocabularies are spelled with the scope each one requires, in BOTH rules — splicing the
|
|
148
|
+
* segment words in as bare substrings here makes `pin` mask `shipping: 12345` and `otp` mask
|
|
149
|
+
* `dotproduct = 0.9871`.
|
|
150
|
+
*
|
|
151
|
+
* Three capture groups, and the count is load-bearing: the replacement keeps 1 and 2 and masks 3.
|
|
152
|
+
*/
|
|
153
|
+
const SENSITIVE_KEY = new RegExp(
|
|
154
|
+
'\\b(' + SEGMENT_IN_NAME + '|[A-Za-z0-9_\\-]*(?:' + SENSITIVE_WORDS + ')[A-Za-z0-9_\\-]*)' +
|
|
155
|
+
'(\\s*[:=]\\s*|\\s+)' +
|
|
156
|
+
'("[^"]{4,}"|\'[^\']{4,}\'|[^\\s,;\'"})]{4,})',
|
|
157
|
+
'gi');
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A mapping key whose *name* alone condemns its value. Anchored end to end over a character class
|
|
161
|
+
* that includes `.`, so `x-api-key`, `AWS_SECRET_ACCESS_KEY` and `db_password` match while
|
|
162
|
+
* `headers` does not — that one is recursed into instead.
|
|
163
|
+
*
|
|
164
|
+
* Non-global on purpose: it is used with `.test()`, and a `g` flag would make it stateful.
|
|
165
|
+
*/
|
|
166
|
+
const SENSITIVE_NAME = new RegExp(
|
|
167
|
+
'^[A-Za-z0-9_\\-.]*(?:' + SENSITIVE_WORDS + ')[A-Za-z0-9_\\-.]*$', 'i');
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The segment-scoped rule: the word must occupy whole `_`/`-`/`.`-delimited segments of the name.
|
|
171
|
+
* `auth`, `auth_token` and `req.auth` match; `authored_by` does not.
|
|
172
|
+
*/
|
|
173
|
+
const SENSITIVE_SEGMENT = new RegExp(
|
|
174
|
+
'^(?:[A-Za-z0-9]+[_\\-.])*(?:' + SEGMENT_WORDS + ')(?:[_\\-.][A-Za-z0-9]+)*$', 'i');
|
|
175
|
+
|
|
176
|
+
const BEARER = /\b(bearer|basic|token)\s+([A-Za-z0-9._\-+/=]{8,})/gi;
|
|
177
|
+
|
|
178
|
+
/** userinfo in a URL: `https://user:pass@host/…` */
|
|
179
|
+
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s:]+):([^/@\s]+)@/gi;
|
|
180
|
+
|
|
181
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
182
|
+
// personal data — the *second* line of defence, never the first
|
|
183
|
+
//
|
|
184
|
+
// The first line is the tier: at `metadata_only` no free text reaches the wire at all, so none of
|
|
185
|
+
// the patterns below has to be right for the default configuration to be safe. They earn their
|
|
186
|
+
// place at T1/T2, where a customer has consciously opted into content and still should not ship a
|
|
187
|
+
// card number by accident.
|
|
188
|
+
//
|
|
189
|
+
// The set is small and anchored, for the reason in the module header: this runs on somebody else's
|
|
190
|
+
// request path. It is also, by construction, incomplete — `John Doe` is not recognisable by any
|
|
191
|
+
// regular expression, and a field that names a patient will always leak at T2. That is an argument
|
|
192
|
+
// for the tier gate being the primary control, not for a longer pattern list here.
|
|
193
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Unicode dashes. A card number pasted out of a word processor, a PDF or a CRM arrives with U+2011
|
|
197
|
+
* (non-breaking hyphen) or U+2013 (en dash) where the author typed a hyphen, and to the reader it
|
|
198
|
+
* is identical. Written as a character class rather than by normalising the input, because
|
|
199
|
+
* normalising would change the very text we are about to emit — NFKC is not length-preserving, so
|
|
200
|
+
* every match offset would then be an offset into a string the caller never gave us.
|
|
201
|
+
*/
|
|
202
|
+
const DASHES = '\\-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uff0d';
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Email.
|
|
206
|
+
*
|
|
207
|
+
* **This is the rule the unicode difference bites hardest.** Python spells the domain and TLD
|
|
208
|
+
* `[^\W\d_]` under `re.UNICODE`, which means "a letter in any script" and is what lets
|
|
209
|
+
* `jane@example.中国` — an internationalised domain, ordinary in half the world — be recognised.
|
|
210
|
+
* JavaScript's `\w` stays ASCII even under the `u` flag, so the literal transliteration would have
|
|
211
|
+
* quietly narrowed the rule back to ASCII-only and reintroduced the exact defect Python's comment
|
|
212
|
+
* records fixing. `\p{L}` is the honest equivalent.
|
|
213
|
+
*
|
|
214
|
+
* The boundaries are lookarounds rather than `\b` for the same reason: `\b` is ASCII-defined in
|
|
215
|
+
* JavaScript, so it would fire in the middle of a non-ASCII TLD.
|
|
216
|
+
*/
|
|
217
|
+
const EMAIL = /(?<![A-Za-z0-9._%+\-])[A-Za-z0-9._%+\-]+@\p{L}(?:[\p{L}\p{N}_.\-]*[\p{L}\p{N}_])?\.\p{L}{2,24}(?!\p{L})/gu;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* US SSN and the national-ID shapes that share its form. Applied before the phone rules, which
|
|
221
|
+
* overlap it; the more specific rule must win.
|
|
222
|
+
*
|
|
223
|
+
* The separator is any of space / dot / dash, but **the same one twice** — the backreference is
|
|
224
|
+
* what keeps this off `123 45-6789`-shaped coincidences and, more importantly, off arbitrary digit
|
|
225
|
+
* soup. A bare `123456789` is deliberately NOT matched: nine digits with no separator is also every
|
|
226
|
+
* order id, every epoch-ish counter and every autoincrement primary key in the corpus, and a rule
|
|
227
|
+
* that masks all of them stops being a redactor and starts being a censor.
|
|
228
|
+
*/
|
|
229
|
+
const NATIONAL_ID = new RegExp(
|
|
230
|
+
'(?<![\\d' + DASHES + '])\\d{3}([ .' + DASHES + '])\\d{2}\\1\\d{4}(?![\\d' + DASHES + '])', 'g');
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* IBAN. Gated on the ISO 7064 mod-97 checksum for the same reason {@link CARD} is gated on Luhn:
|
|
234
|
+
* the shape alone (two letters, two digits, alphanumerics) matches product SKUs and git refs.
|
|
235
|
+
*/
|
|
236
|
+
const IBAN = /\b[A-Za-z]{2}\d{2}[A-Za-z0-9]{11,30}\b/g;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* E.164 and the written NANP forms. The lookarounds keep it off version strings and durations.
|
|
240
|
+
*
|
|
241
|
+
* An extension has to be *consumed*, not merely tolerated: the trailing `(?![\w\-])` is what keeps
|
|
242
|
+
* this rule off version strings, so it cannot simply be relaxed. Without the extension branch,
|
|
243
|
+
* `415.555.2671x22` was not a phone number — and a direct line with an extension is more
|
|
244
|
+
* identifying than one without, not less.
|
|
245
|
+
*/
|
|
246
|
+
const PHONE = new RegExp(
|
|
247
|
+
'(?<![\\w.\\-])(?:\\+\\d{1,3}[ .\\-]?)?(?:\\(\\d{3}\\)[ .\\-]?|\\d{3}[ .\\-])\\d{3}[ .\\-]\\d{4}' +
|
|
248
|
+
'(?:[ ]?(?:x|ext\\.?|extension)[ ]?\\d{1,6})?(?![\\w\\-])' +
|
|
249
|
+
'|(?<![\\w.\\-])\\+\\d{9,15}(?![\\w\\-])', 'g');
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Payment-card candidates. Shape alone over-matches every long integer in a log line, so a
|
|
253
|
+
* candidate is masked only once it passes Luhn — precision bought for a few microseconds.
|
|
254
|
+
*/
|
|
255
|
+
const CARD = new RegExp(
|
|
256
|
+
'(?<![\\d' + DASHES + '])(?:\\d[ .' + DASHES + ']?){12,18}\\d(?![\\d' + DASHES + '])', 'g');
|
|
257
|
+
|
|
258
|
+
function luhn(digits) {
|
|
259
|
+
let total = 0;
|
|
260
|
+
let alt = false;
|
|
261
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
262
|
+
let d = digits.charCodeAt(i) - 48;
|
|
263
|
+
if (alt) {
|
|
264
|
+
d *= 2;
|
|
265
|
+
if (d > 9) d -= 9;
|
|
266
|
+
}
|
|
267
|
+
total += d;
|
|
268
|
+
alt = !alt;
|
|
269
|
+
}
|
|
270
|
+
return total % 10 === 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function maskCard(m) {
|
|
274
|
+
const digits = m.replace(/\D/g, '');
|
|
275
|
+
return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? MASK : m;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* ISO 7064 mod-97: rotate the first four characters to the end, letters as A=10..Z=35, `% 97 == 1`.
|
|
280
|
+
*
|
|
281
|
+
* Same role as {@link luhn} — it turns a shape that over-matches into a rule precise enough to run
|
|
282
|
+
* on somebody else's request path. `GB29NWBK60161331926819` passes; `DE49PRODUCTSKU12345` does not,
|
|
283
|
+
* and neither does a git ref that happens to start with two letters and two digits.
|
|
284
|
+
*
|
|
285
|
+
* **Computed incrementally rather than as one integer, and that is not a micro-optimisation.**
|
|
286
|
+
* Python can take `% 97` of an arbitrarily large int exactly. A 34-character IBAN expands to a
|
|
287
|
+
* ~40-digit number, which in JavaScript is far past `Number.MAX_SAFE_INTEGER` — the literal port
|
|
288
|
+
* would round it and then decide the checksum from whatever the rounding produced, passing and
|
|
289
|
+
* failing effectively at random. Reducing digit by digit is exact and needs no BigInt.
|
|
290
|
+
*/
|
|
291
|
+
function ibanOk(s) {
|
|
292
|
+
const up = String(s).toUpperCase();
|
|
293
|
+
const rotated = up.slice(4) + up.slice(0, 4);
|
|
294
|
+
let remainder = 0;
|
|
295
|
+
for (let i = 0; i < rotated.length; i += 1) {
|
|
296
|
+
const code = rotated.charCodeAt(i);
|
|
297
|
+
let part;
|
|
298
|
+
if (code >= 65 && code <= 90) part = String(code - 55); // A..Z -> 10..35
|
|
299
|
+
else if (code >= 48 && code <= 57) part = String(code - 48); // 0..9
|
|
300
|
+
else return false;
|
|
301
|
+
for (let j = 0; j < part.length; j += 1) {
|
|
302
|
+
remainder = (remainder * 10 + (part.charCodeAt(j) - 48)) % 97;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return remainder === 1;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function maskIban(m) {
|
|
309
|
+
return ibanOk(m) ? MASK : m;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `[pattern, replacement]`. IBAN runs before {@link CARD}: an IBAN's digit run can be long enough
|
|
314
|
+
* for the card rule to consider it, and the more specific rule must win — the same ordering
|
|
315
|
+
* argument as {@link NATIONAL_ID} before {@link PHONE}.
|
|
316
|
+
*/
|
|
317
|
+
const PII_RULES = [
|
|
318
|
+
[EMAIL, () => MASK],
|
|
319
|
+
[NATIONAL_ID, () => MASK],
|
|
320
|
+
[IBAN, maskIban],
|
|
321
|
+
[CARD, maskCard],
|
|
322
|
+
[PHONE, () => MASK],
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Return `{ text, redacted }`. Never throws — see case 5.8.
|
|
327
|
+
*
|
|
328
|
+
* On failure the whole string becomes the mask rather than propagating: the caller keeps its event,
|
|
329
|
+
* this one field becomes unreadable. That is the correct direction, because the alternative is
|
|
330
|
+
* dropping the governance record at exactly the moment something odd is happening.
|
|
331
|
+
*/
|
|
332
|
+
function redact(text) {
|
|
333
|
+
if (text === null || text === undefined || text === '') {
|
|
334
|
+
return { text: text === 0 ? '0' : (text || ''), redacted: false };
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
let out = String(text);
|
|
338
|
+
const before = out;
|
|
339
|
+
for (const pat of PATTERNS) out = out.replace(pat, MASK);
|
|
340
|
+
out = out.replace(URL_USERINFO, (_m, scheme, user) => scheme + user + ':' + MASK + '@');
|
|
341
|
+
out = out.replace(BEARER, (_m, kind) => kind + ' ' + MASK);
|
|
342
|
+
out = out.replace(SENSITIVE_KEY, (_m, name, sep) => name + sep + MASK);
|
|
343
|
+
for (const [pat, repl] of PII_RULES) out = out.replace(pat, repl);
|
|
344
|
+
return { text: out, redacted: out !== before };
|
|
345
|
+
} catch (_err) {
|
|
346
|
+
return { text: MASK, redacted: true };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
351
|
+
// URLs
|
|
352
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A path segment that carries a secret rather than naming a resource. Two shapes:
|
|
356
|
+
*
|
|
357
|
+
* - twelve or more characters containing **both** a letter and a digit — `TOKEN123ABCDEF`,
|
|
358
|
+
* `ORD-2026-0012`, a base62 invite code. Requiring both is what keeps `messages`, `completions`
|
|
359
|
+
* and `pancreatic-carcinoma` out of it; requiring twelve is what keeps `v1` and `2026-08-27` out.
|
|
360
|
+
* - thirty-two or more characters of anything path-safe — the length alone is the tell.
|
|
361
|
+
*
|
|
362
|
+
* Numeric-only segments are deliberately kept. `/users/12345` is a resource id and it is what makes
|
|
363
|
+
* a path groupable.
|
|
364
|
+
*/
|
|
365
|
+
const PATH_TOKEN =
|
|
366
|
+
/^(?=[A-Za-z0-9._~-]{12,}$)(?=[^A-Za-z]*[A-Za-z])(?=[^0-9]*[0-9])[A-Za-z0-9._~-]+$|^[A-Za-z0-9._~-]{32,}$/;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The path with token-shaped segments masked, then run through {@link redact}.
|
|
370
|
+
*
|
|
371
|
+
* Both steps are needed and neither subsumes the other. `redact` catches a JWT or an API key sitting
|
|
372
|
+
* in a path, because those have recognisable shapes; it cannot catch `/reset/TOKEN123ABCDEF`,
|
|
373
|
+
* because a random opaque string has no shape to recognise. The segment rule catches exactly that,
|
|
374
|
+
* by position and entropy-ish shape. Single-use credentials live in paths far more often than in
|
|
375
|
+
* queries — `/reset/<token>`, `/invite/<token>`, `/verify/<token>` are the standard shape of every
|
|
376
|
+
* password-reset email ever sent.
|
|
377
|
+
*/
|
|
378
|
+
function safePath(path) {
|
|
379
|
+
return redact(String(path).split('/')
|
|
380
|
+
.map((seg) => (PATH_TOKEN.test(seg) ? MASK : seg))
|
|
381
|
+
.join('/')).text;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* scheme + host + path. Query, fragment and userinfo are dropped.
|
|
386
|
+
*
|
|
387
|
+
* A URL stops being metadata the moment it has a query string: `?q=…` is free text of exactly the
|
|
388
|
+
* class the tier gate just refused to store, and magic links carry single-use tokens that no
|
|
389
|
+
* pattern above can recognise.
|
|
390
|
+
*/
|
|
391
|
+
function safeUrl(u) {
|
|
392
|
+
try {
|
|
393
|
+
const parsed = new URL(String(u));
|
|
394
|
+
const host = parsed.port ? parsed.hostname + ':' + parsed.port : parsed.hostname;
|
|
395
|
+
return parsed.protocol + '//' + host + safePath(parsed.pathname);
|
|
396
|
+
} catch (_err) {
|
|
397
|
+
return MASK;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
402
|
+
// key names
|
|
403
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Key names whose numeric value describes the *operation* rather than the subject of it. These are
|
|
407
|
+
* the numbers telemetry exists to carry, and they are what makes the tier gate affordable — without
|
|
408
|
+
* them `metadata_only` would emit no measurements at all and nobody would leave it on.
|
|
409
|
+
*
|
|
410
|
+
* An allowlist, not a denylist, and that direction is the whole point. A denylist of sensitive
|
|
411
|
+
* numeric names has to anticipate `ssn`, `mrn`, `iban`, `lat`, `salary` and whatever the next
|
|
412
|
+
* customer's schema calls them; it fails open on the first one nobody thought of. This fails
|
|
413
|
+
* closed: an unrecognised numeric key is masked, and the cost of being wrong is one masked metric
|
|
414
|
+
* that somebody notices and adds here.
|
|
415
|
+
*/
|
|
416
|
+
const STRUCTURAL_NUMBER_KEYS = new Set([
|
|
417
|
+
'attempt', 'attempts', 'batch', 'capacity', 'code', 'concurrency', 'count', 'depth',
|
|
418
|
+
'dropped', 'duration', 'elapsed', 'errors', 'exit_code', 'failures', 'hits', 'index',
|
|
419
|
+
'iteration', 'length', 'limit', 'lines', 'misses', 'offset', 'page', 'pending', 'port',
|
|
420
|
+
'position', 'priority', 'queued', 'retries', 'rows', 'size', 'status', 'status_code',
|
|
421
|
+
'step', 'timeout', 'tokens', 'total', 'version', 'weight',
|
|
422
|
+
]);
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Suffixes that make a numeric key structural whatever its stem: `upload_bytes`,
|
|
426
|
+
* `render_duration_ms`, `retry_count`. Matching on the suffix rather than the whole name is what
|
|
427
|
+
* keeps the allowlist from needing an entry per metric a customer invents.
|
|
428
|
+
*/
|
|
429
|
+
const STRUCTURAL_NUMBER_SUFFIXES = [
|
|
430
|
+
'_bytes', '_count', '_duration', '_elapsed', '_index', '_len', '_length', '_limit', '_ms',
|
|
431
|
+
'_ns', '_offset', '_pct', '_percent', '_rate', '_ratio', '_retries', '_rows', '_s',
|
|
432
|
+
'_seconds', '_size', '_status', '_total', '_us',
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const endsWithStructuralSuffix = (k) => STRUCTURAL_NUMBER_SUFFIXES.some((s) => k.endsWith(s));
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Whether a numeric value under this key is a measurement rather than a fact about a person.
|
|
439
|
+
*
|
|
440
|
+
* Deliberately conservative: it decides on the *name*, which is the only evidence available, and a
|
|
441
|
+
* name is weak evidence. `lat`/`lon`, `ssn`, `card` and `balance` are absent and therefore masked
|
|
442
|
+
* below `full`; `duration_ms` and `rows` are present and survive.
|
|
443
|
+
*/
|
|
444
|
+
function isStructuralNumberKey(key) {
|
|
445
|
+
const k = String(key).trim().toLowerCase();
|
|
446
|
+
return STRUCTURAL_NUMBER_KEYS.has(k) || endsWithStructuralSuffix(k);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The same question for the elements of a *list* under this key — and a stricter answer.
|
|
451
|
+
*
|
|
452
|
+
* A list element has no name of its own, so it can only inherit its parent key's verdict. But the
|
|
453
|
+
* two halves of the allowlist do not survive that inheritance equally, and treating them as if they
|
|
454
|
+
* did is how `{ rows: [123456789, 4111111111111111] }` would ship at `metadata_only`:
|
|
455
|
+
*
|
|
456
|
+
* - The **suffix** half names a *unit* — `_ms`, `_bytes`, `_pct`. A unit means the same thing
|
|
457
|
+
* whether the key holds one measurement or a hundred, so `durations_ms: [12, 15, 402]` is exactly
|
|
458
|
+
* as structural as `duration_ms: 12`. These inherit.
|
|
459
|
+
* - The **bare-name** half names a *quantity* — `count`, `rows`, `batch`, `depth`. Under a scalar
|
|
460
|
+
* that is the tally; under a list it is the things being tallied, which is the opposite of
|
|
461
|
+
* structural. These do not inherit.
|
|
462
|
+
*/
|
|
463
|
+
function isStructuralNumberKeyForSequence(key) {
|
|
464
|
+
return endsWithStructuralSuffix(String(key).trim().toLowerCase());
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Codepoint ranges for the scripts whose letterforms are confusable with ASCII: Greek and Cyrillic.
|
|
469
|
+
* Named as *classes* rather than as a list of homoglyph characters, because a list of characters is
|
|
470
|
+
* a list and the attacker picks the character.
|
|
471
|
+
*/
|
|
472
|
+
const CONFUSABLE_RANGES = [[0x0370, 0x03ff], [0x0400, 0x052f], [0x1f00, 0x1fff]];
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Whether `name` mixes ASCII letters with Cyrillic or Greek ones.
|
|
476
|
+
*
|
|
477
|
+
* `аpi_key` with a Cyrillic `а` renders identically to `api_key` and matches neither pattern, so the
|
|
478
|
+
* value would ship. NFKC does not help: Cyrillic `а` and Latin `a` are different letters in
|
|
479
|
+
* different scripts, not compatibility variants of one letter, and Unicode is right about that.
|
|
480
|
+
*
|
|
481
|
+
* So this asks a different question — not "is this character a homoglyph" but "is this name written
|
|
482
|
+
* in one script". A field name that mixes ASCII with Cyrillic is mojibake or an attack; it is not a
|
|
483
|
+
* name a developer typed on purpose. Fails closed on the mix.
|
|
484
|
+
*
|
|
485
|
+
* Only these two scripts are named, and only against ASCII. A name written wholly in Cyrillic,
|
|
486
|
+
* Greek, Japanese or Arabic is somebody's ordinary field name and is left entirely alone — the rule
|
|
487
|
+
* is about the *mixture*, which is the thing that has no innocent reading.
|
|
488
|
+
*/
|
|
489
|
+
function hasConfusableScript(name) {
|
|
490
|
+
let hasAscii = false;
|
|
491
|
+
let hasConfusable = false;
|
|
492
|
+
for (const ch of String(name)) {
|
|
493
|
+
// `\p{L}` rather than a case-comparison trick: the question is "is this a letter in any
|
|
494
|
+
// script", and only a unicode property escape answers it for Greek and Cyrillic alike.
|
|
495
|
+
if (!/\p{L}/u.test(ch)) continue;
|
|
496
|
+
const o = ch.codePointAt(0);
|
|
497
|
+
if (o < 128) hasAscii = true;
|
|
498
|
+
else if (CONFUSABLE_RANGES.some(([lo, hi]) => o >= lo && o <= hi)) hasConfusable = true;
|
|
499
|
+
if (hasAscii && hasConfusable) return true;
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Does this mapping key's *name* alone condemn its value?
|
|
506
|
+
*
|
|
507
|
+
* Fails closed: an unrepresentable key is treated as sensitive. Masking a harmless value costs a
|
|
508
|
+
* field; not masking a harmful one costs a credential.
|
|
509
|
+
*
|
|
510
|
+
* **The name is normalised before it is matched, and that is a bug fix rather than a courtesy.**
|
|
511
|
+
* Both patterns are anchored over a character class that excludes whitespace, so `"API_KEY "` — one
|
|
512
|
+
* trailing space — fails the match outright and ships the value. Keys arrive with stray whitespace
|
|
513
|
+
* constantly: parsed headers, CSV columns, YAML round-trips, hand-written JSON. NFKC additionally
|
|
514
|
+
* folds the compatibility forms (fullwidth `api_key` is the same key wearing different
|
|
515
|
+
* codepoints).
|
|
516
|
+
*/
|
|
517
|
+
function isSensitiveKey(key) {
|
|
518
|
+
try {
|
|
519
|
+
const name = String(key).normalize('NFKC').trim();
|
|
520
|
+
if (hasConfusableScript(name)) return true;
|
|
521
|
+
return SENSITIVE_NAME.test(name) || SENSITIVE_SEGMENT.test(name);
|
|
522
|
+
} catch (_err) {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
528
|
+
// the bounded scan
|
|
529
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* How much text is scanned beyond the emitted limit.
|
|
533
|
+
*
|
|
534
|
+
* Redacting a whole input and then keeping 256 characters makes every pattern walk a megabyte to
|
|
535
|
+
* produce a tweet. Scanning a window slightly wider than the limit keeps the cost constant while
|
|
536
|
+
* leaving room for a secret that straddles the cut — truncating first, exactly at the limit, would
|
|
537
|
+
* slice a key in half and ship the surviving prefix.
|
|
538
|
+
*/
|
|
539
|
+
const SCAN_OVERSCAN = 4096;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Ceiling on how much text is scanned when the caller asked for no truncation at all. Some bound
|
|
543
|
+
* has to exist or the absence of a limit is an unbounded regex walk on the caller's request thread;
|
|
544
|
+
* 64 KiB is far above any free-text field a sane caller emits and far below the point where the
|
|
545
|
+
* walk is felt.
|
|
546
|
+
*/
|
|
547
|
+
const FULL_SCAN_CEILING = 65536;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Redact with a bounded scan. Returns `{ text, redacted, unfinished }`.
|
|
551
|
+
*
|
|
552
|
+
* The single implementation of two rules, so that no caller can hold a different opinion about
|
|
553
|
+
* either one:
|
|
554
|
+
*
|
|
555
|
+
* - **Scan a bounded window, not the whole input.**
|
|
556
|
+
* - **Never emit the prefix of something you could not finish scanning.** The overscan covers a
|
|
557
|
+
* secret straddling the cut. It cannot cover one *longer than the window*, because a PEM needs
|
|
558
|
+
* its `-----END` terminator before the pattern fires — so the first `limit` characters, which are
|
|
559
|
+
* the start of the key, would ship untouched. `unfinished` reports that, and every caller turns
|
|
560
|
+
* it into a mask.
|
|
561
|
+
*
|
|
562
|
+
* `unfinished` is a post-condition on the *output* rather than a guess about the input, so it
|
|
563
|
+
* generalises to the next unterminated pattern instead of enumerating this one. Two questions are
|
|
564
|
+
* asked of the emitted text: does an unbounded-length opener survive in it, and does the last
|
|
565
|
+
* assignment in it name something sensitive with no mask after it — meaning the value is still
|
|
566
|
+
* running when the output stops. Both are linear in the emitted prefix, which is already bounded.
|
|
567
|
+
*/
|
|
568
|
+
function scanWindow(text, limit) {
|
|
569
|
+
const s = String(text);
|
|
570
|
+
const window = limit === null || limit === undefined ? FULL_SCAN_CEILING : limit + SCAN_OVERSCAN;
|
|
571
|
+
const truncated = s.length > window;
|
|
572
|
+
const r = redact(s.slice(0, window));
|
|
573
|
+
let scrubbed = r.text;
|
|
574
|
+
if (limit !== null && limit !== undefined) scrubbed = scrubbed.slice(0, limit);
|
|
575
|
+
|
|
576
|
+
let unfinished = UNTERMINATED_MARKERS.some((m) => scrubbed.includes(m));
|
|
577
|
+
if (!unfinished && truncated) {
|
|
578
|
+
let last = null;
|
|
579
|
+
// `matchAll` rather than `exec` in a loop: the shared pattern is `g`-flagged, and `exec`
|
|
580
|
+
// would mutate its `lastIndex` across calls — the JavaScript hazard with no Python analogue.
|
|
581
|
+
for (const m of scrubbed.matchAll(ASSIGN_OPENER_RE)) last = m;
|
|
582
|
+
if (last !== null && isSensitiveKey(last[1])) {
|
|
583
|
+
unfinished = !scrubbed.slice(last.index + last[1].length).includes(MASK);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return { text: scrubbed, redacted: r.redacted, unfinished };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
590
|
+
// the tier ladder's leaves
|
|
591
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The three tiers, defined here rather than in `core.cjs`.
|
|
595
|
+
*
|
|
596
|
+
* They used to live in the core, which meant this module could not name them without a circular
|
|
597
|
+
* require. Since the redactor is the thing that *acts* on a tier, it is the honest owner of the
|
|
598
|
+
* vocabulary, and the core imports it from here.
|
|
599
|
+
*/
|
|
600
|
+
const TIER_METADATA_ONLY = 'metadata_only';
|
|
601
|
+
const TIER_HASHED = 'hashed';
|
|
602
|
+
const TIER_FULL = 'full';
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The single gate every free-text field passes through when a caller wants one string back.
|
|
606
|
+
*
|
|
607
|
+
* It lives in one function rather than at the call sites because that is precisely how these fields
|
|
608
|
+
* leaked in the wrapper: one shell command was redacted on the `tool_action` path and shipped raw
|
|
609
|
+
* on three others. A gate here cannot be forgotten by the next call site.
|
|
610
|
+
*
|
|
611
|
+
* **Three tiers, three branches.** At T0 this returns `null`, and the envelope builder drops keys
|
|
612
|
+
* whose value is `null`, so the field is *absent* rather than blanked — a reader can tell "no
|
|
613
|
+
* content was captured" from "the content was empty". Returning `null` also means a new tier, or a
|
|
614
|
+
* typo, degrades to silence rather than to egress, which is the only failure direction worth having.
|
|
615
|
+
*/
|
|
616
|
+
function wireText(text, tier, limit) {
|
|
617
|
+
if (text === null || text === undefined) return null;
|
|
618
|
+
const s = String(text);
|
|
619
|
+
if (!s) return s;
|
|
620
|
+
if (tier !== TIER_FULL && tier !== TIER_HASHED) return null;
|
|
621
|
+
const r = scanWindow(s, limit === undefined ? 512 : limit);
|
|
622
|
+
return r.unfinished ? MASK : r.text;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Structured redaction — the common miss.
|
|
627
|
+
*
|
|
628
|
+
* Tool arguments and structured outputs are objects, not prompt strings, and a redactor that only
|
|
629
|
+
* walks free text passes an API key straight through when it arrives as
|
|
630
|
+
* `{ headers: { Authorization: 'Bearer …' } }`. Bounded in width and depth so a pathological
|
|
631
|
+
* payload cannot turn scrubbing into the slow part of a request.
|
|
632
|
+
*
|
|
633
|
+
* Three rules worth stating, each of which was a defect somewhere before it was a rule:
|
|
634
|
+
*
|
|
635
|
+
* - A sensitive **name** masks the whole value whatever its type. `{ api_key: 12345678 }` and
|
|
636
|
+
* `{ auth: { token: '…' } }` are both condemned by the name; descending into them to scrub the
|
|
637
|
+
* leaves would emit the shape of a credential, and a credential's shape is a credential. This
|
|
638
|
+
* over-approximates — a field named `token_count` masks a harmless integer — and the trade is
|
|
639
|
+
* deliberate: a false positive costs one number, a false negative costs a secret.
|
|
640
|
+
* - **A number's type says nothing about its sensitivity.** `{ ssn: 123456789, card:
|
|
641
|
+
* 4111111111111111 }` would otherwise pass every tier unchanged while the same values as strings
|
|
642
|
+
* were correctly dropped — a type-confusion hole, not a policy decision. So numbers go through
|
|
643
|
+
* the structural-name allowlist below `full`.
|
|
644
|
+
* - **Depth is bounded.** Without it the walk is capped only by the stack.
|
|
645
|
+
*
|
|
646
|
+
* At T0 string values are dropped rather than blanked, for the reason in {@link wireText}.
|
|
647
|
+
* Booleans and `null` survive every tier — a flag is not content.
|
|
648
|
+
*/
|
|
649
|
+
function scrubMapping(data, tier, opts) {
|
|
650
|
+
const o = opts || {};
|
|
651
|
+
const maxKeys = o.maxKeys === undefined ? 32 : o.maxKeys;
|
|
652
|
+
const limit = o.limit === undefined ? 256 : o.limit;
|
|
653
|
+
const maxDepth = o.maxDepth === undefined ? 6 : o.maxDepth;
|
|
654
|
+
|
|
655
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || maxDepth <= 0) return {};
|
|
656
|
+
const out = {};
|
|
657
|
+
for (const k of Object.keys(data).slice(0, maxKeys)) {
|
|
658
|
+
const key = String(k).slice(0, 64);
|
|
659
|
+
const v = data[k];
|
|
660
|
+
if (isSensitiveKey(key)) { out[key] = MASK; continue; }
|
|
661
|
+
|
|
662
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
663
|
+
out[key] = scrubMapping(v, tier, { maxKeys, limit, maxDepth: maxDepth - 1 });
|
|
664
|
+
} else if (Array.isArray(v)) {
|
|
665
|
+
// The key's verdict travels with its elements, but only the half of it that survives the
|
|
666
|
+
// trip — see `isStructuralNumberKeyForSequence`. Without any inheritance a list under
|
|
667
|
+
// `durations_ms` would have every measurement masked below `full`, and the allowlist exists
|
|
668
|
+
// precisely so `metadata_only` still carries numbers somebody will look at.
|
|
669
|
+
out[key] = v.slice(0, 16).map((i) => scrubItem(i, tier, maxKeys, limit, maxDepth - 1,
|
|
670
|
+
isStructuralNumberKeyForSequence(key)));
|
|
671
|
+
} else if (v === null || v === undefined || typeof v === 'boolean') {
|
|
672
|
+
out[key] = v === undefined ? null : v;
|
|
673
|
+
} else if (typeof v === 'number' || typeof v === 'bigint') {
|
|
674
|
+
out[key] = (tier === TIER_FULL || isStructuralNumberKey(key)) ? v : MASK;
|
|
675
|
+
} else {
|
|
676
|
+
const scrubbed = wireText(String(v), tier, limit);
|
|
677
|
+
if (scrubbed !== null) out[key] = scrubbed;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* One element of a list.
|
|
685
|
+
*
|
|
686
|
+
* The container types recurse into each other, and the depth budget is what stops it. Fixing a
|
|
687
|
+
* nesting hole at the level it was found — one more type check for the two-deep case — only moves
|
|
688
|
+
* it to three deep: `{ batch: [[{ api_key: '…' }]] }` is not exotic, it is a batch insert, a
|
|
689
|
+
* JSON-RPC batch, a paged tool result.
|
|
690
|
+
*
|
|
691
|
+
* A list element has no key of its own to judge, so it inherits its parent key's verdict for
|
|
692
|
+
* numbers. Without that, `{ rows: [123456789, 4111111111111111] }` comes through `metadata_only` —
|
|
693
|
+
* the tier that promises no content — completely unchanged.
|
|
694
|
+
*/
|
|
695
|
+
function scrubItem(item, tier, maxKeys, limit, maxDepth, numbersOk) {
|
|
696
|
+
if (maxDepth <= 0) return MASK;
|
|
697
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
698
|
+
return scrubMapping(item, tier, { maxKeys, limit, maxDepth });
|
|
699
|
+
}
|
|
700
|
+
if (Array.isArray(item)) {
|
|
701
|
+
return item.slice(0, 16).map((i) => scrubItem(i, tier, maxKeys, limit, maxDepth - 1, numbersOk));
|
|
702
|
+
}
|
|
703
|
+
if (item === null || item === undefined || typeof item === 'boolean') {
|
|
704
|
+
return item === undefined ? null : item;
|
|
705
|
+
}
|
|
706
|
+
if (typeof item === 'number' || typeof item === 'bigint') {
|
|
707
|
+
return (tier === TIER_FULL || numbersOk) ? item : MASK;
|
|
708
|
+
}
|
|
709
|
+
return wireText(String(item), tier, limit);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
module.exports = {
|
|
713
|
+
MASK,
|
|
714
|
+
TIER_METADATA_ONLY,
|
|
715
|
+
TIER_HASHED,
|
|
716
|
+
TIER_FULL,
|
|
717
|
+
redact,
|
|
718
|
+
scanWindow,
|
|
719
|
+
wireText,
|
|
720
|
+
scrubMapping,
|
|
721
|
+
safeUrl,
|
|
722
|
+
safePath,
|
|
723
|
+
isSensitiveKey,
|
|
724
|
+
isStructuralNumberKey,
|
|
725
|
+
isStructuralNumberKeyForSequence,
|
|
726
|
+
hasConfusableScript,
|
|
727
|
+
luhn,
|
|
728
|
+
ibanOk,
|
|
729
|
+
// Exported for tests that need to assert on the vocabulary rather than on one example.
|
|
730
|
+
SENSITIVE_WORDS,
|
|
731
|
+
SEGMENT_WORDS,
|
|
732
|
+
STRUCTURAL_NUMBER_KEYS,
|
|
733
|
+
STRUCTURAL_NUMBER_SUFFIXES,
|
|
734
|
+
};
|