@basein/runner 0.2.11 → 0.3.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/README.md +540 -350
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2803 -306
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +371 -20
- package/dist/replay/controller.js +1185 -111
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -271
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/record/redact.js
CHANGED
|
@@ -4,47 +4,649 @@
|
|
|
4
4
|
* The proxy sits on the credential path for every wrapped server (§9), and every
|
|
5
5
|
* argument and result it sees crosses a process boundary and then the network.
|
|
6
6
|
* So redaction runs on the recording copy of both arguments *and* results,
|
|
7
|
-
* before the step report is even queued
|
|
7
|
+
* before the step report is even queued — and once more, as the last line of
|
|
8
|
+
* defence, on every free-text field `RemoteRecorder` sends (the prompt, the
|
|
9
|
+
* answer, the reasoning, error messages), because those never pass through a
|
|
10
|
+
* structured payload at all.
|
|
8
11
|
*
|
|
9
12
|
* Three rules, in the order they fire:
|
|
10
|
-
* 1. **By key.** A value under a key
|
|
11
|
-
* wholesale, whatever its type. `authorization: { scheme, token }`
|
|
12
|
-
* both halves, because the object as a whole is the secret.
|
|
13
|
+
* 1. **By key.** A value under a key that names a secret ({@link isSecretKey})
|
|
14
|
+
* is replaced wholesale, whatever its type. `authorization: { scheme, token }`
|
|
15
|
+
* loses both halves, because the object as a whole is the secret.
|
|
13
16
|
* 2. **`env` blocks.** An object under an `env` key has *every* value replaced.
|
|
14
17
|
* MCP server configs put credentials there under arbitrary names, so no key
|
|
15
18
|
* pattern would catch them.
|
|
16
|
-
* 3. **By shape.** A string that looks like a bearer token, a
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* 3. **By shape.** A string that looks like a credential — a bearer token, a
|
|
20
|
+
* JWT, a provider key, a PEM private key, a password in a URL, a
|
|
21
|
+
* `DB_PASSWORD=…` line of a `.env` file, an `X-Api-Key:` header — is
|
|
22
|
+
* replaced wherever it appears, including inside free text, where key
|
|
23
|
+
* matching cannot reach. Reading `.env` or `~/.aws/credentials` is a routine
|
|
24
|
+
* thing for an agent to do; its result is recorded like any other.
|
|
25
|
+
*
|
|
26
|
+
* KEYS ARE MATCHED ON WHOLE NAME SEGMENTS, not substrings. The old rule was one
|
|
27
|
+
* unanchored alternation, so `author`, `max_tokens`, `nextPageToken` and
|
|
28
|
+
* `tokenizer` were all "secrets" — the recording lost real arguments (numbers
|
|
29
|
+
* became the string "[redacted]") and a scenario built from it had nothing to
|
|
30
|
+
* replay. Names are split on camelCase, snake_case, kebab-case and dots, and a
|
|
31
|
+
* segment has to *be* the secret word (`token`, `accessToken`, `x-api-key`), not
|
|
32
|
+
* merely contain it. The names 0.2.12's substring rule caught that do hold
|
|
33
|
+
* credentials are still caught: plurals (`tokens`, `api_keys`), an `auth` that
|
|
34
|
+
* ends the name (`basic_auth`, `X-Upstream-Auth`), `oauth`, `authentication`,
|
|
35
|
+
* `auth_code` — and a number under the secret itself (`token: 482913`, an OTP).
|
|
36
|
+
*
|
|
37
|
+
* TEXT RULES ARE ANCHORED TO WHERE SECRETS ARE WRITTEN, not to every
|
|
38
|
+
* `name = value` in sight. A recorded Read of source code must survive: its
|
|
39
|
+
* bytes are what a scenario's output logic is authored against, and
|
|
40
|
+
* `const token = await getToken()` is not a secret. So assignments are only
|
|
41
|
+
* redacted in env/ini/YAML line form, shell `NAME=value` form, header form and
|
|
42
|
+
* JSON `"key": "value"` form, and never when the value is visibly code (a call,
|
|
43
|
+
* a member access, a `${reference}`, a type in a declaration such as
|
|
44
|
+
* `password: string`, or a variable named for the same thing such as
|
|
45
|
+
* `token: newToken`). A separator never spans a line break: an empty
|
|
46
|
+
* `DB_PASSWORD=` must not take the next line with it.
|
|
47
|
+
*
|
|
48
|
+
* JSON TEXT IS REDACTED AS JSON. A serialized tool payload goes through
|
|
49
|
+
* {@link redactJsonText}, which parses it and runs the rules on each string
|
|
50
|
+
* leaf — the same pass its producer's {@link redact} made — so the result is
|
|
51
|
+
* always valid JSON and, when nothing new was found, the producer's bytes
|
|
52
|
+
* exactly (live replay serializes a result the same way and compares). The
|
|
53
|
+
* rules still tolerate JSON-escaped text (a string leaf that holds a JSON
|
|
54
|
+
* document, a preview of one): they never start a match inside an escape
|
|
55
|
+
* sequence and never replace a quote or a backslash, so an escaped document
|
|
56
|
+
* stays parseable.
|
|
19
57
|
*
|
|
20
58
|
* Redaction never throws and never mutates its input: a cycle, a `BigInt`, a
|
|
21
59
|
* getter that explodes — each degrades to a placeholder rather than losing the
|
|
22
60
|
* step or, worse, taking the session down.
|
|
23
61
|
*/
|
|
62
|
+
import { truncateString } from "./truncate.js";
|
|
24
63
|
export const REDACTED = "[redacted]";
|
|
25
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The substring rule this module used before key segments. Kept exported for
|
|
66
|
+
* anything that imported it, and applied again under `BIR_REDACT_LEGACY_KEYS=1`.
|
|
67
|
+
*
|
|
68
|
+
* @deprecated over-matches ordinary names (`author`, `max_tokens`); use {@link isSecretKey}.
|
|
69
|
+
*/
|
|
26
70
|
export const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|apikey|authorization|auth|cookie|session[_-]?id|credential|private[_-]?key|access[_-]?key|client[_-]?secret)/i;
|
|
27
71
|
/** Keys whose *children* are all secret (values only — the names stay). */
|
|
28
72
|
const ENV_KEY = /^env(ironment)?$/i;
|
|
29
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* A string leaf longer than this is cut before its shapes are scanned. Every
|
|
75
|
+
* consumer caps a recorded payload far below it (64 KiB), so the cut is never
|
|
76
|
+
* visible in what is sent — it only stops a 20 MB tool result from costing a
|
|
77
|
+
* few hundred milliseconds of regex work on a hook's path.
|
|
78
|
+
*/
|
|
79
|
+
export const REDACT_SCAN_MAX_CHARS = 256 * 1024;
|
|
80
|
+
// ── keys ──────────────────────────────────────────────────────────────────
|
|
81
|
+
/** Split a name into lower-case segments: camelCase, snake_case, kebab-case, dots. */
|
|
82
|
+
function segments(name) {
|
|
83
|
+
return name
|
|
84
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
85
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.split(/[\s_\-.:/]+/)
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A last segment that makes a secret-sounding name a *description* of the
|
|
92
|
+
* secret rather than the secret: `tokenCount`, `secretName`, `passwordFile`,
|
|
93
|
+
* `api_key_env`. Their values are numbers, names and paths.
|
|
94
|
+
*/
|
|
95
|
+
const NEUTRAL_TAILS = new Set([
|
|
96
|
+
"count", "counts", "limit", "limits", "usage", "used", "budget", "type", "types",
|
|
97
|
+
"length", "len", "size", "estimate", "total", "index", "name", "names", "domain",
|
|
98
|
+
"path", "paths", "file", "files", "dir", "url", "uri", "endpoint", "enabled",
|
|
99
|
+
"disabled", "required", "provider", "method", "mode", "format", "env", "var",
|
|
100
|
+
"variable", "policy", "ttl", "expiry", "lifetime", "rotation", "hint", "label",
|
|
101
|
+
]);
|
|
102
|
+
/**
|
|
103
|
+
* A `token` (or `tokens`) after one of these is a pagination cursor or a
|
|
104
|
+
* counter, not a credential: `page_token`, `max_tokens`, `cached_tokens`.
|
|
105
|
+
*/
|
|
106
|
+
const NON_SECRET_TOKEN_QUALIFIERS = new Set([
|
|
107
|
+
"page", "next", "continuation", "pagination", "sync", "cursor", "max", "min",
|
|
108
|
+
"num", "input", "output", "prompt", "completion", "total", "reasoning", "cache",
|
|
109
|
+
"cached",
|
|
110
|
+
]);
|
|
111
|
+
/** The same words run together into one segment: `maxtokens`, `cachedtokens`. */
|
|
112
|
+
const NON_SECRET_TOKENS_COMPOUND = new RegExp(`^(?:${[...NON_SECRET_TOKEN_QUALIFIERS].join("|")})tokens$`);
|
|
113
|
+
/** `<qualifier> key` pairs that are credentials: `api key`, `private key`, … */
|
|
114
|
+
const KEY_QUALIFIERS = new Set([
|
|
115
|
+
"api", "access", "private", "secret", "encryption", "signing", "master",
|
|
116
|
+
"account", "client", "app", "license", "subscription", "service", "shared",
|
|
117
|
+
"session", "auth",
|
|
118
|
+
]);
|
|
119
|
+
/** Keys that are secret only as the whole name. */
|
|
120
|
+
const WHOLE_KEYS = new Set(["auth", "pass", "pwd", "dsn", "jwt", "bearer", "otp"]);
|
|
121
|
+
/**
|
|
122
|
+
* What may follow `oauth` / `authentication` / `auth code` and make the name a
|
|
123
|
+
* description rather than the secret, besides {@link NEUTRAL_TAILS}:
|
|
124
|
+
* `oauth_scopes`, `authenticationStatus`, the `Authentication-Results` mail
|
|
125
|
+
* header. Only for these words: `tokenStatus` could still hold a token.
|
|
126
|
+
*/
|
|
127
|
+
const AUTH_DESCRIPTOR_TAILS = new Set([
|
|
128
|
+
"scope", "scopes", "status", "result", "results", "scheme", "schemes", "flow", "error", "errors",
|
|
129
|
+
]);
|
|
130
|
+
/** A one-time code, as the last segment: `otp`, `mfa_otp`, `totp`, `passcode`. */
|
|
131
|
+
const ONE_TIME_CODES = new Set(["otp", "totp", "hotp", "passcode"]);
|
|
132
|
+
/**
|
|
133
|
+
* Verdicts by key. A 50k-row result repeats the same handful of keys 50k
|
|
134
|
+
* times, and splitting each one into segments again made redaction several
|
|
135
|
+
* times slower than the payload is worth. Bounded, and simply cleared when full.
|
|
136
|
+
*/
|
|
137
|
+
const keyVerdicts = new Map();
|
|
138
|
+
const KEY_CACHE_MAX = 5_000;
|
|
139
|
+
/**
|
|
140
|
+
* `BIR_REDACT_LEGACY_KEYS=1` also applies the old substring rule, for anyone
|
|
141
|
+
* who preferred over-redaction (`author`, `max_tokens` lost) to the new
|
|
142
|
+
* segment matching. Never less redaction than today's rules.
|
|
143
|
+
*/
|
|
144
|
+
const LEGACY_KEYS = process.env.BIR_REDACT_LEGACY_KEYS === "1";
|
|
145
|
+
export function secretKeyStrength(name) {
|
|
146
|
+
const cached = keyVerdicts.get(name);
|
|
147
|
+
if (cached !== undefined)
|
|
148
|
+
return cached ?? undefined;
|
|
149
|
+
const verdict = judgeKey(name) ?? (LEGACY_KEYS && SECRET_KEY.test(name) ? "strong" : undefined);
|
|
150
|
+
if (keyVerdicts.size >= KEY_CACHE_MAX)
|
|
151
|
+
keyVerdicts.clear();
|
|
152
|
+
keyVerdicts.set(name, verdict ?? null);
|
|
153
|
+
return verdict;
|
|
154
|
+
}
|
|
155
|
+
function judgeKey(name) {
|
|
156
|
+
const key = name.trim();
|
|
157
|
+
if (!key)
|
|
158
|
+
return undefined;
|
|
159
|
+
const lower = key.toLowerCase();
|
|
160
|
+
const segs = segments(key);
|
|
161
|
+
if (segs.length === 0)
|
|
162
|
+
return undefined;
|
|
163
|
+
const last = segs[segs.length - 1];
|
|
164
|
+
const neutral = segs.length > 1 && NEUTRAL_TAILS.has(last);
|
|
165
|
+
if (WHOLE_KEYS.has(lower))
|
|
166
|
+
return "strong";
|
|
167
|
+
// Substring on purpose: `pgpassword`, `dbPasswd`, `keyPassphrase`.
|
|
168
|
+
if (/pass(word|wd|phrase)/.test(lower))
|
|
169
|
+
return neutral ? undefined : "strong";
|
|
170
|
+
if (neutral)
|
|
171
|
+
return undefined;
|
|
172
|
+
for (let i = 0; i < segs.length; i++) {
|
|
173
|
+
const s = segs[i];
|
|
174
|
+
const prev = i > 0 ? segs[i - 1] : "";
|
|
175
|
+
const next = segs[i + 1] ?? "";
|
|
176
|
+
// The secret itself is the last segment (`mfa_token`, `api_key`); a
|
|
177
|
+
// segment further in is something about it (`token_id`), and keeps numbers.
|
|
178
|
+
const itself = i === segs.length - 1 ? "strong" : "weak";
|
|
179
|
+
if (s === "secret" || s === "secrets" || (s.length > 6 && s.endsWith("secret")))
|
|
180
|
+
return "strong";
|
|
181
|
+
if (s === "credential" || s === "credentials" || /[a-z]credentials?$/.test(s))
|
|
182
|
+
return "strong";
|
|
183
|
+
if (s === "authorization" || s === "cookie" || s === "cookies")
|
|
184
|
+
return "strong";
|
|
185
|
+
if (s === "connectionstring" || (s === "connection" && next === "string"))
|
|
186
|
+
return "strong";
|
|
187
|
+
if (s === "conn" && next === "str")
|
|
188
|
+
return "strong";
|
|
189
|
+
if (s === "private" && (next === "key" || next === "keys"))
|
|
190
|
+
return "strong";
|
|
191
|
+
if (s === "privatekey" || s === "secretkey" || s === "privatekeys" || s === "secretkeys")
|
|
192
|
+
return "strong";
|
|
193
|
+
if (s === "token" || (s.length > 5 && s.endsWith("token"))) {
|
|
194
|
+
if (s === "token" && NON_SECRET_TOKEN_QUALIFIERS.has(prev))
|
|
195
|
+
continue;
|
|
196
|
+
if (/^(page|next|continuation|pagination|sync|max|min)token$/.test(s))
|
|
197
|
+
continue;
|
|
198
|
+
return itself;
|
|
199
|
+
}
|
|
200
|
+
// A list of them — `tokens`, `refreshTokens`, `api_keys` — is as secret as
|
|
201
|
+
// one, but a number there is a count (`tokens: 1234` in an LLM's usage).
|
|
202
|
+
if (s === "tokens" || (s.length > 6 && s.endsWith("tokens"))) {
|
|
203
|
+
if (s === "tokens" && NON_SECRET_TOKEN_QUALIFIERS.has(prev))
|
|
204
|
+
continue;
|
|
205
|
+
if (NON_SECRET_TOKENS_COMPOUND.test(s))
|
|
206
|
+
continue;
|
|
207
|
+
return "weak";
|
|
208
|
+
}
|
|
209
|
+
if (s === "key" && KEY_QUALIFIERS.has(prev))
|
|
210
|
+
return prev === "session" ? "weak" : itself;
|
|
211
|
+
if (s === "keys" && KEY_QUALIFIERS.has(prev))
|
|
212
|
+
return "weak";
|
|
213
|
+
if (/^(api|access|auth|encryption|signing|master|account|client|app|license|subscription)key$/.test(s))
|
|
214
|
+
return itself;
|
|
215
|
+
if (/^(api|access|auth|encryption|signing|master|account|client|app|license|subscription|session)keys?$/.test(s)) {
|
|
216
|
+
return "weak";
|
|
217
|
+
}
|
|
218
|
+
if (s === "session" && (next === "id" || next === "key"))
|
|
219
|
+
return "weak";
|
|
220
|
+
if (s === "sessionid" || s === "sessid" || s === "phpsessid")
|
|
221
|
+
return "weak";
|
|
222
|
+
if (s === "auth") {
|
|
223
|
+
// The credential an `auth` names: `basic_auth`, `httpAuth`, `X-Upstream-Auth`.
|
|
224
|
+
// `authMethod`, `auth_type` and `oauth_provider` never get here (a neutral tail).
|
|
225
|
+
if (i === segs.length - 1 || prev === "x" || prev === "proxy" || next === "header" || next === "key" || next === "value") {
|
|
226
|
+
return "weak";
|
|
227
|
+
}
|
|
228
|
+
if (next === "code" && !isDescriptor(segs[i + 2] ?? ""))
|
|
229
|
+
return "strong";
|
|
230
|
+
}
|
|
231
|
+
if ((s === "oauth" || s === "authentication") && !isDescriptor(next))
|
|
232
|
+
return "weak";
|
|
233
|
+
if (s === "authcode" && !isDescriptor(next))
|
|
234
|
+
return "strong";
|
|
235
|
+
if (ONE_TIME_CODES.has(s) && i === segs.length - 1)
|
|
236
|
+
return "strong";
|
|
237
|
+
if (s === "code" && (prev === "mfa" || prev === "otp" || prev === "totp" || prev === "2fa"))
|
|
238
|
+
return itself;
|
|
239
|
+
if (s === "jwt" || s === "bearer")
|
|
240
|
+
return "weak";
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
/** `oauth_provider_id`, `authenticationStatus`: the name describes the credential, it does not hold it. */
|
|
245
|
+
function isDescriptor(segment) {
|
|
246
|
+
return NEUTRAL_TAILS.has(segment) || AUTH_DESCRIPTOR_TAILS.has(segment);
|
|
247
|
+
}
|
|
248
|
+
/** True when a value under `name` is a secret (see {@link secretKeyStrength}). */
|
|
249
|
+
export function isSecretKey(name) {
|
|
250
|
+
return secretKeyStrength(name) !== undefined;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The same question for an UPPER_SNAKE environment variable name, where the
|
|
254
|
+
* words run together (`PGPASSWORD`, `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`)
|
|
255
|
+
* and substring matching is the convention rather than the mistake — minus
|
|
256
|
+
* the counters (`MAX_TOKENS`, `TOKENIZERS_PARALLELISM`) and file pointers
|
|
257
|
+
* (`PASSWORD_FILE`, the Docker secrets convention).
|
|
258
|
+
*/
|
|
259
|
+
export function isSecretEnvName(name) {
|
|
260
|
+
const n = name.toUpperCase();
|
|
261
|
+
if (/_(FILE|PATH|DIR|NAME|URL|URI|ENABLED|TYPE|COUNT|LIMIT|LENGTH|SIZE|TTL|EXPIRY)$/.test(n))
|
|
262
|
+
return false;
|
|
263
|
+
// Settings *about* the secret — `CREDENTIALS_CHECK_MS = 2_000`, `TOKEN_HEADER`.
|
|
264
|
+
if (/_(MS|SECONDS|SECS|TIMEOUT|INTERVAL|RETRIES|ATTEMPTS|MAX|MIN|DAYS|HOURS|MINUTES|BYTES|CHARS|VERSION|HEADER|PREFIX|FIELD|PARAM|ALGORITHM|ISSUER|AUDIENCE|SCOPES?|EXPIRES(_IN)?|LIFETIME)$/.test(n)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (/PASS(WORD|WD|PHRASE)|SECRET|CREDENTIAL|PRIVATE_?KEY|API_?KEY|ACCESS_?KEY|AUTH_?KEY|COOKIE|CONNECTION_?STRING/.test(n)) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (/(^|_)(PWD|DSN|PASS|SESSION_?ID|SESSION_?KEY|JWT|AUTH_?CODE|OTP|MFA_?CODE)(_|$)/.test(n))
|
|
271
|
+
return true;
|
|
272
|
+
// `BASIC_AUTH=user:pass`, `HTTP_AUTH=…`, `GITHUB_OAUTH=…`. A flag such as
|
|
273
|
+
// `SKIP_AUTH=true` is left alone by the rules that read these lines.
|
|
274
|
+
if (/(^|_)O?AUTH$/.test(n))
|
|
275
|
+
return true;
|
|
276
|
+
if (/TOKEN(?!S|IZ)/.test(n))
|
|
277
|
+
return !/(^|_)(PAGE|NEXT|MAX|MIN|CONTINUATION)_?TOKEN/.test(n);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
// ── free-text values ────────────────────────────────────────────────────────
|
|
281
|
+
/** A value that is visibly code or a reference, not a literal secret. */
|
|
282
|
+
function looksLikeCode(value) {
|
|
283
|
+
if (value.startsWith(REDACTED))
|
|
284
|
+
return true;
|
|
285
|
+
if (/^[$%{<([`@&*!=#]/.test(value))
|
|
286
|
+
return true; // ${VAR}, %VAR%, <placeholder>, expressions
|
|
287
|
+
if (/[(){}[\]]/.test(value))
|
|
288
|
+
return true; // calls, indexing, literals
|
|
289
|
+
if (/^(true|false|null|none|nil|undefined|required|optional|enabled|disabled|yes|no|on|off|\*+|x+|\.{3}|…)[,;]?$/i.test(value)) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+[,;]?$/.test(value))
|
|
293
|
+
return true; // user.password, process.env.X
|
|
294
|
+
if (/^[A-Za-z_$][\w$]*[,;]$/.test(value))
|
|
295
|
+
return true; // `password: password,`
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
/** A boolean-ish setting: never a credential, whatever the name beside it. */
|
|
299
|
+
const FLAG_LITERAL = /^(?:true|false|yes|no|on|off|enabled|disabled|null|none)$/i;
|
|
300
|
+
/** A type in a declaration: `password: string` declares a field, it does not set one. */
|
|
301
|
+
const TYPE_NAME = /^(?:string|number|boolean|bigint|symbol|object|any|unknown|never|void|str|int|float|bool|bytes|dict|list|char|varchar|text|uuid)$/i;
|
|
302
|
+
/** Name segments a variable *for* a secret carries: `newToken`, `apiKey`, `secret_value`. */
|
|
303
|
+
const SECRET_WORDS = new Set([
|
|
304
|
+
"token", "secret", "password", "passwd", "passphrase", "pass", "pwd", "key", "auth",
|
|
305
|
+
"credential", "credentials", "cookie", "session", "jwt", "bearer", "dsn",
|
|
306
|
+
]);
|
|
307
|
+
/**
|
|
308
|
+
* A bare identifier that refers to the secret instead of spelling it out: a
|
|
309
|
+
* type (`password: string`), the key's own word (`self.password = password`),
|
|
310
|
+
* or a compound variable name carrying it (`token: newToken`,
|
|
311
|
+
* `api_key = apiKey`, `secret = secret_value`). Only lowerCamel or snake_case
|
|
312
|
+
* names without a digit count as compound, so `password: hunter`,
|
|
313
|
+
* `password: MySecret1` and `api_key: abcdef` are still literals.
|
|
314
|
+
*/
|
|
315
|
+
function namesItself(name, value) {
|
|
316
|
+
if (TYPE_NAME.test(value))
|
|
317
|
+
return true;
|
|
318
|
+
if (!/^[A-Za-z_$][A-Za-z_$]*$/.test(value))
|
|
319
|
+
return false;
|
|
320
|
+
const segs = segments(name);
|
|
321
|
+
const flat = value.toLowerCase().replace(/[_$]/g, "");
|
|
322
|
+
if (flat === segs.join("") || flat === segs[segs.length - 1])
|
|
323
|
+
return true;
|
|
324
|
+
if (!/^[a-z][a-z$]*(?:[A-Z][a-z$]*|_[a-z$]+)+$/.test(value))
|
|
325
|
+
return false;
|
|
326
|
+
return segs.some((s) => SECRET_WORDS.has(s) && flat.includes(s));
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A value shaped like an opaque credential rather than a word of prose or an
|
|
330
|
+
* identifier. Random tokens of any useful length carry a digit or a symbol
|
|
331
|
+
* with near certainty; `tokens`, `authentication` and `authHeader` do not.
|
|
332
|
+
*/
|
|
333
|
+
function looksLikeCredential(value) {
|
|
334
|
+
if (value.length < 8)
|
|
335
|
+
return false;
|
|
336
|
+
if (value.startsWith(REDACTED))
|
|
337
|
+
return false;
|
|
338
|
+
return /\d/.test(value) || /[-._~+/=]/.test(value) || value.length >= 32;
|
|
339
|
+
}
|
|
340
|
+
/** Characters a value may not contain: whitespace, quotes and a JSON escape. */
|
|
341
|
+
const V = `[^\\s"'\\\\]`;
|
|
342
|
+
/**
|
|
343
|
+
* A line start in plain text, or in JSON text where newlines are `\n` escapes
|
|
344
|
+
* and a file's first line starts right after the string's opening quote.
|
|
345
|
+
*/
|
|
346
|
+
const LINE_START = `(?:^|[\\r\\n"]|\\\\[nr])`;
|
|
347
|
+
/** Before a shell word: whitespace, an operator, a quote, or a JSON escape. */
|
|
348
|
+
const WORD_START = `(?:^|[\\s;&|(\`"':]|\\\\[nrt])`;
|
|
349
|
+
/**
|
|
350
|
+
* An opening quote, JSON-escaped or not, or none. A backslash is taken only
|
|
351
|
+
* together with the quote it escapes: `(\\\\?["']?)` used to take the lone
|
|
352
|
+
* backslash of a `\n` after an empty `KEY=`, and the value after it, which
|
|
353
|
+
* left `KEY=\[redacted]` — an invalid escape in a JSON document.
|
|
354
|
+
*/
|
|
355
|
+
const QUOTE = `((?:\\\\["'])|["']|)`;
|
|
356
|
+
/**
|
|
357
|
+
* `\b`, or just after a JSON `\n`/`\r`/`\t` escape, where `\b` sees the `n`
|
|
358
|
+
* as part of the word: `…\nsk-proj-…` inside a JSON document held in a string.
|
|
359
|
+
*/
|
|
360
|
+
const B = `(?:(?<=\\\\[nrt])|\\b)`;
|
|
361
|
+
/**
|
|
362
|
+
* Value shapes that are secret wherever they appear, including inside prose.
|
|
363
|
+
* Order matters: specific token shapes first, so an assignment rule after them
|
|
364
|
+
* sees `[redacted]` and leaves it alone.
|
|
365
|
+
*/
|
|
30
366
|
const SECRET_SHAPES = [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
367
|
+
// A PEM private key, whole — or cut off by truncation before its END line.
|
|
368
|
+
{
|
|
369
|
+
hint: (t) => t.includes("PRIVATE KEY"),
|
|
370
|
+
apply: redactPem,
|
|
371
|
+
},
|
|
372
|
+
// Authorization header value. Prose ("Bearer tokens expire") is left alone.
|
|
373
|
+
{
|
|
374
|
+
re: new RegExp(`${B}Bearer[ \\t]+([A-Za-z0-9\\-._~+/]+=*)`, "gi"),
|
|
375
|
+
replace: (m, tok) => (looksLikeCredential(tok) ? REDACTED : m),
|
|
376
|
+
},
|
|
377
|
+
// Provider token shapes, as one alternation: one scan instead of fifteen,
|
|
378
|
+
// which is most of the cost on a result made of many short strings.
|
|
379
|
+
{
|
|
380
|
+
re: new RegExp(`${B}(?:${[
|
|
381
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/, // JWT
|
|
382
|
+
/\bsk-[A-Za-z0-9_-]{16,}/, // OpenAI / Anthropic style
|
|
383
|
+
/\b[rs]k_(?:live|test)_[A-Za-z0-9]{10,}/, // Stripe secret / restricted
|
|
384
|
+
/\bwhsec_[A-Za-z0-9]{20,}/, // Stripe webhook secret
|
|
385
|
+
/\bgh[pousr]_[A-Za-z0-9]{16,}/, // GitHub tokens
|
|
386
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
|
|
387
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/, // GitLab PAT
|
|
388
|
+
/\bnpm_[A-Za-z0-9]{36}\b/, // npm
|
|
389
|
+
/\bhf_[A-Za-z0-9]{30,}/, // Hugging Face
|
|
390
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack tokens
|
|
391
|
+
/\bxapp-[A-Za-z0-9-]{10,}/, // Slack app token
|
|
392
|
+
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key id
|
|
393
|
+
/\bAIza[0-9A-Za-z_-]{20,}/, // Google API key
|
|
394
|
+
/\bya29\.[A-Za-z0-9_-]{20,}/, // Google OAuth access token
|
|
395
|
+
/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/, // SendGrid
|
|
396
|
+
]
|
|
397
|
+
.map((r) => r.source.replace(/^\\b/, ""))
|
|
398
|
+
.join("|")})`, "g"),
|
|
399
|
+
replace: REDACTED,
|
|
400
|
+
},
|
|
401
|
+
// The password half of URL userinfo: postgres://admin:S3cret@host → admin:[redacted]@host.
|
|
402
|
+
{
|
|
403
|
+
hint: (t) => t.includes("://"),
|
|
404
|
+
re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@"'\\]+:)[^\s@/"'\\]+@/gi,
|
|
405
|
+
replace: `$1${REDACTED}@`,
|
|
406
|
+
},
|
|
407
|
+
// HTTP Basic credentials. Prose ("Basic authentication") is left alone.
|
|
408
|
+
{
|
|
409
|
+
re: new RegExp(`${B}Basic[ \\t]+([A-Za-z0-9+/]{8,}={0,2})(?![A-Za-z0-9+/=])`, "g"),
|
|
410
|
+
replace: (m, cred) => /[0-9+/=]/.test(cred) || /[A-Z]/.test(cred.slice(1)) ? `Basic ${REDACTED}` : m,
|
|
411
|
+
},
|
|
412
|
+
// curl -u user:password / --user user:password.
|
|
413
|
+
{
|
|
414
|
+
hint: (t) => t.includes("-u") || t.includes("--user"),
|
|
415
|
+
re: /((?:^|\s)(?:-u|--user)[\s=]+["']?[^\s:"'\\]+:)[^\s"'\\]+/g,
|
|
416
|
+
replace: `$1${REDACTED}`,
|
|
417
|
+
},
|
|
418
|
+
// Authorization with a scheme the shapes above do not know (Token, Digest, …).
|
|
419
|
+
{
|
|
420
|
+
re: new RegExp(`${B}((?:Proxy-)?Authorization[ \\t]*[:=][ \\t]*(?:\\\\?["'])?(?:[A-Za-z][A-Za-z0-9-]*[ \\t]+)?)([^\\s"'\\\\,;]{8,})`, "gi"),
|
|
421
|
+
replace: (m, head, value) => (looksLikeCredential(value) ? `${head}${REDACTED}` : m),
|
|
422
|
+
},
|
|
423
|
+
// Azure-style connection strings: …;AccountKey=…; and Password=… inside them.
|
|
424
|
+
{
|
|
425
|
+
hint: (t) => t.includes("="),
|
|
426
|
+
re: /((?:^|[;"'\s])(?:AccountKey|SharedAccessKey|SharedAccessSignature|Password|Pwd)=)([^;"'\s\\]+)/gi,
|
|
427
|
+
replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
|
|
428
|
+
},
|
|
429
|
+
// A secret in a query string: ?access_token=…&page=2, &api_key=….
|
|
430
|
+
{
|
|
431
|
+
hint: (t) => t.includes("=") && (t.includes("?") || t.includes("&")),
|
|
432
|
+
re: /([?&])([A-Za-z_][A-Za-z0-9_.-]*)=([^&#\s"'\\]{4,})/g,
|
|
433
|
+
replace: (m, sep, name, value) => isSecretKey(name) && !value.startsWith(REDACTED) && !/^[$%{<]/.test(value) ? `${sep}${name}=${REDACTED}` : m,
|
|
434
|
+
},
|
|
435
|
+
// Command-line flags: --password=…, --api-key=…, --token …
|
|
436
|
+
{
|
|
437
|
+
hint: (t) => t.includes("-"),
|
|
438
|
+
re: /(\s--?(?:[a-z0-9]+-)*(?:password|passwd|passphrase|token|api-?key|secret|access-?key|auth-?token)(?:=|[ \t]+))([^\s"'\\-][^\s"'\\]*)/gi,
|
|
439
|
+
replace: (m, head, value) => value.startsWith(REDACTED) || /^[$%{<]/.test(value) || (/\s$/.test(head) && value.length < 6)
|
|
440
|
+
? m
|
|
441
|
+
: `${head}${REDACTED}`,
|
|
442
|
+
},
|
|
443
|
+
// Cookie headers carry sessions: `Cookie: sid=…; theme=dark` loses all of it.
|
|
444
|
+
{
|
|
445
|
+
re: new RegExp(`${B}((?:Set-)?Cookie[ \\t]*:[ \\t]*)([^\\r\\n"'\\\\]*=[^\\r\\n"'\\\\]*)`, "gi"),
|
|
446
|
+
replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
|
|
447
|
+
},
|
|
448
|
+
// Header form with a hyphenated name: `X-Api-Key: …`, `X-Auth-Token: …`, `-H 'Private-Token: …'`.
|
|
449
|
+
{
|
|
450
|
+
hint: (t) => t.includes(":") && t.includes("-"),
|
|
451
|
+
re: new RegExp(`(${WORD_START})((?:[A-Za-z0-9]+-)+[A-Za-z0-9]+)([ \\t]*:[ \\t]*)${QUOTE}(${V}{6,})`, "g"),
|
|
452
|
+
replace: (m, pre, name, sep, quote, value) => isSecretKey(name) && !looksLikeCode(value) ? `${pre}${name}${sep}${quote}${REDACTED}` : m,
|
|
453
|
+
},
|
|
454
|
+
// Shell and dotenv form: `export GITHUB_TOKEN=…`, `PGPASSWORD=… psql`, `API_KEY = "…"`.
|
|
455
|
+
{
|
|
456
|
+
hint: (t) => t.includes("="),
|
|
457
|
+
re: new RegExp(`(${WORD_START})((?:export[ \\t]+)?)([A-Z][A-Z0-9_]*)([ \\t]*=(?!=)[ \\t]*)${QUOTE}([^\\s"'\\\\;&|\`)]{3,})`, "g"),
|
|
458
|
+
replace: (m, pre, exp, name, sep, quote, value) => {
|
|
459
|
+
if (!isSecretEnvName(name) || value.startsWith(REDACTED))
|
|
460
|
+
return m;
|
|
461
|
+
// A flag is not a credential: `SKIP_AUTH=true`, `REQUIRE_OTP=off`.
|
|
462
|
+
if (FLAG_LITERAL.test(value))
|
|
463
|
+
return m;
|
|
464
|
+
// `NAME=value` with no spaces is how env files and shells write it; with
|
|
465
|
+
// spaces it may be code, so a call or a reference is left alone.
|
|
466
|
+
if (sep.trim() !== sep && (looksLikeCode(value) || (!quote && namesItself(name, value))))
|
|
467
|
+
return m;
|
|
468
|
+
if (/^[$%{<]/.test(value))
|
|
469
|
+
return m;
|
|
470
|
+
return `${pre}${exp}${name}${sep}${quote}${REDACTED}`;
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
// Env/ini/YAML/properties lines with any-case names: `password: …`, `aws_secret_access_key = …`,
|
|
474
|
+
// `spring.datasource.password=…`, indented or `export`ed.
|
|
475
|
+
{
|
|
476
|
+
hint: (t) => t.includes("=") || t.includes(":"),
|
|
477
|
+
re: new RegExp(`(${LINE_START}(?:[ \\t]|\\\\t)*(?:-[ \\t]+)?(?:export[ \\t]+)?)([A-Za-z_][A-Za-z0-9_.-]*)([ \\t]*[=:][ \\t]*)${QUOTE}(${V}{4,})`, "g"),
|
|
478
|
+
replace: (m, pre, name, sep, quote, value) => {
|
|
479
|
+
if (!isSecretKey(name) && !isSecretEnvName(name))
|
|
480
|
+
return m;
|
|
481
|
+
if (looksLikeCode(value))
|
|
482
|
+
return m;
|
|
483
|
+
// `password: string`, `token: newToken`, `api_key = apiKey`: code naming the secret.
|
|
484
|
+
if (!quote && namesItself(name, value))
|
|
485
|
+
return m;
|
|
486
|
+
if (sep.includes(":") && value.length < 6 && !quote)
|
|
487
|
+
return m;
|
|
488
|
+
return `${pre}${name}${sep}${quote}${REDACTED}`;
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
// JSON form inside text: "password": "hunter2" (also with JSON-escaped quotes).
|
|
492
|
+
{
|
|
493
|
+
hint: (t) => t.includes('"') && t.includes(":"),
|
|
494
|
+
re: /(\\?")([A-Za-z_][A-Za-z0-9_.-]*)(\\?")(\s*:\s*)(\\?")([^"\\\r\n]{4,})(?=\\?")/g,
|
|
495
|
+
replace: (m, q1, name, q2, sep, q3, value) => isSecretKey(name) && !/^[$%{<]/.test(value) && !value.startsWith(REDACTED)
|
|
496
|
+
? `${q1}${name}${q2}${sep}${q3}${REDACTED}`
|
|
497
|
+
: m,
|
|
498
|
+
},
|
|
38
499
|
];
|
|
39
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* Can any rule match? Every shape above needs one of these characters or
|
|
502
|
+
* words, so a leaf without them — most of a large result: names, ids, prose
|
|
503
|
+
* without punctuation — skips twenty-odd regex passes.
|
|
504
|
+
*/
|
|
505
|
+
const MAYBE_SECRET = /[=:\-_.@]|AKIA|AIza|Bearer|Basic/i;
|
|
506
|
+
/** Most passes {@link redactText} makes; the second one almost never changes anything. */
|
|
507
|
+
const MAX_PASSES = 3;
|
|
508
|
+
/**
|
|
509
|
+
* Replace every secret-shaped run inside a string.
|
|
510
|
+
*
|
|
511
|
+
* Idempotent: redacting the result again changes nothing. That is what lets a
|
|
512
|
+
* payload be redacted by its producer and again at the recorder without its
|
|
513
|
+
* bytes moving (live replay compares against them). One pass is not always a
|
|
514
|
+
* fixed point — a match one rule declined (`Authorization: Authorization: …`)
|
|
515
|
+
* can hide a secret that only shows once a later rule has run — so a pass that
|
|
516
|
+
* changed something is followed by another, until one changes nothing.
|
|
517
|
+
*/
|
|
40
518
|
export function redactText(text) {
|
|
41
519
|
let out = text;
|
|
520
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
521
|
+
const next = redactPass(out);
|
|
522
|
+
if (next === out)
|
|
523
|
+
break;
|
|
524
|
+
out = next;
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
function redactPass(text) {
|
|
529
|
+
if (text.length < 8 || !MAYBE_SECRET.test(text))
|
|
530
|
+
return text;
|
|
531
|
+
let out = text;
|
|
42
532
|
for (const shape of SECRET_SHAPES) {
|
|
43
|
-
shape.
|
|
44
|
-
|
|
533
|
+
if (shape.hint && !shape.hint(out))
|
|
534
|
+
continue;
|
|
535
|
+
if (shape.apply) {
|
|
536
|
+
out = shape.apply(out);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!shape.re || shape.replace === undefined)
|
|
540
|
+
continue;
|
|
541
|
+
shape.re.lastIndex = 0;
|
|
542
|
+
out =
|
|
543
|
+
typeof shape.replace === "string"
|
|
544
|
+
? out.replace(shape.re, shape.replace)
|
|
545
|
+
: out.replace(shape.re, shape.replace);
|
|
45
546
|
}
|
|
46
547
|
return out;
|
|
47
548
|
}
|
|
549
|
+
const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/g;
|
|
550
|
+
const PEM_END = /-----END ([A-Z0-9 ]*)PRIVATE KEY-----/g;
|
|
551
|
+
/**
|
|
552
|
+
* What a key cut off before its END line is made of: base64, its headers
|
|
553
|
+
* (`Proc-Type: 4,ENCRYPTED`) and line breaks, raw or JSON-escaped. A backslash
|
|
554
|
+
* only as part of `\n`/`\r`/`\t`, so an escaped quote (`\"`) ends it rather
|
|
555
|
+
* than being eaten — which used to leave a JSON document with a dangling `\`.
|
|
556
|
+
*/
|
|
557
|
+
const PEM_BODY = /(?:[A-Za-z0-9+/=\s:,.-]|\\[nrt])*/y;
|
|
558
|
+
/**
|
|
559
|
+
* Every PEM private key, whole or truncated, as `[redacted]`.
|
|
560
|
+
*
|
|
561
|
+
* Not one regex: `BEGIN…[^"]*?…END` rescanned to the end of the text for each
|
|
562
|
+
* BEGIN line that had no END, so a log repeating the header was quadratic
|
|
563
|
+
* (60 ms for 64 KiB, about a second for a leaf of the size {@link redact}
|
|
564
|
+
* scans). Here the END lines are found once, and each BEGIN looks up the next
|
|
565
|
+
* one of its kind — linear however the text is built. As before, a key never
|
|
566
|
+
* spans a `"`: in JSON text that is the end of the string it lives in.
|
|
567
|
+
*/
|
|
568
|
+
function redactPem(text) {
|
|
569
|
+
const ends = new Map();
|
|
570
|
+
PEM_END.lastIndex = 0;
|
|
571
|
+
for (let m = PEM_END.exec(text); m; m = PEM_END.exec(text)) {
|
|
572
|
+
let list = ends.get(m[1]);
|
|
573
|
+
if (!list)
|
|
574
|
+
ends.set(m[1], (list = []));
|
|
575
|
+
list.push(m.index);
|
|
576
|
+
}
|
|
577
|
+
const cursor = new Map();
|
|
578
|
+
let quote = -2; // the first `"` at or after the last position asked about; -1 = none
|
|
579
|
+
let out = "";
|
|
580
|
+
let last = 0;
|
|
581
|
+
PEM_BEGIN.lastIndex = 0;
|
|
582
|
+
for (let m = PEM_BEGIN.exec(text); m; m = PEM_BEGIN.exec(text)) {
|
|
583
|
+
const label = m[1];
|
|
584
|
+
const after = m.index + m[0].length;
|
|
585
|
+
let stop = -1;
|
|
586
|
+
const list = ends.get(label);
|
|
587
|
+
if (list) {
|
|
588
|
+
let i = cursor.get(label) ?? 0;
|
|
589
|
+
while (i < list.length && list[i] < after)
|
|
590
|
+
i += 1;
|
|
591
|
+
cursor.set(label, i);
|
|
592
|
+
if (i < list.length) {
|
|
593
|
+
if (quote !== -1 && quote < after)
|
|
594
|
+
quote = text.indexOf('"', after);
|
|
595
|
+
if (quote === -1 || quote >= list[i])
|
|
596
|
+
stop = list[i] + `-----END ${label}PRIVATE KEY-----`.length;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (stop < 0) {
|
|
600
|
+
PEM_BODY.lastIndex = after;
|
|
601
|
+
PEM_BODY.exec(text);
|
|
602
|
+
stop = Math.max(after, PEM_BODY.lastIndex);
|
|
603
|
+
}
|
|
604
|
+
out += text.slice(last, m.index) + REDACTED;
|
|
605
|
+
last = stop;
|
|
606
|
+
PEM_BEGIN.lastIndex = stop;
|
|
607
|
+
}
|
|
608
|
+
return last === 0 ? text : out + text.slice(last);
|
|
609
|
+
}
|
|
610
|
+
/** Deep enough for any producer's output ({@link redact} stops at 24) inside a wrapper. */
|
|
611
|
+
const JSON_TEXT_MAX_DEPTH = 64;
|
|
612
|
+
/**
|
|
613
|
+
* Redact a string that holds JSON — a serialized tool input, result or error —
|
|
614
|
+
* and return JSON, always.
|
|
615
|
+
*
|
|
616
|
+
* Running {@link redactText} over the serialized text could not promise that:
|
|
617
|
+
* a rule that met `\n` after an empty `KEY=` wrote `KEY=\[redacted]`, and the
|
|
618
|
+
* service stored a payload no parser accepts. So the text is parsed and every
|
|
619
|
+
* string leaf gets the rules as plain text, exactly as its producer's
|
|
620
|
+
* {@link redact} did. When that finds nothing new — the normal case, since
|
|
621
|
+
* producers redact first — the original bytes come back untouched: live replay
|
|
622
|
+
* serializes a result the same way and compares against what was recorded.
|
|
623
|
+
* Text that is not JSON (a truncated preview, a producer's plain error) gets
|
|
624
|
+
* {@link redactText}: there is no structure to keep.
|
|
625
|
+
*/
|
|
626
|
+
export function redactJsonText(text) {
|
|
627
|
+
let parsed;
|
|
628
|
+
try {
|
|
629
|
+
parsed = JSON.parse(text);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return redactText(text);
|
|
633
|
+
}
|
|
634
|
+
let cleaned;
|
|
635
|
+
try {
|
|
636
|
+
cleaned = JSON.stringify(redact(parsed, JSON_TEXT_MAX_DEPTH));
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return JSON.stringify({ truncated: true, reason: "unserializable" });
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
if (cleaned === JSON.stringify(parsed))
|
|
643
|
+
return text;
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
/* too deep to compare: send the redacted form */
|
|
647
|
+
}
|
|
648
|
+
return cleaned;
|
|
649
|
+
}
|
|
48
650
|
/**
|
|
49
651
|
* Deep-clone `value` with secrets removed. The result is always JSON-safe, so
|
|
50
652
|
* the caller can serialise it without a second failure mode.
|
|
@@ -53,16 +655,42 @@ export function redactText(text) {
|
|
|
53
655
|
* @param maxDepth guard against pathological nesting (default 24).
|
|
54
656
|
*/
|
|
55
657
|
export function redact(value, maxDepth = 24) {
|
|
56
|
-
return walk(value,
|
|
658
|
+
return walk(value, 0, maxDepth, new WeakSet());
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Set an own property, including one named `__proto__`: JSON.parse makes that
|
|
662
|
+
* an ordinary key, and a plain assignment would set the copy's prototype
|
|
663
|
+
* instead — the key would vanish from what is recorded.
|
|
664
|
+
*/
|
|
665
|
+
function put(target, key, value) {
|
|
666
|
+
if (key === "__proto__") {
|
|
667
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
target[key] = value;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** The value that replaces `v` under a secret key of strength `strength`. */
|
|
674
|
+
function underSecretKey(v, strength) {
|
|
675
|
+
// A flag or a counter under a weak key (`sessionId: 42`, `tokens: 1234`) is
|
|
676
|
+
// not a credential; a number under `password` or `mfa_token` might be one (a
|
|
677
|
+
// PIN, a one-time code), so strong keys redact everything but a flag.
|
|
678
|
+
if (strength === "weak" && (typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)))) {
|
|
679
|
+
return v;
|
|
680
|
+
}
|
|
681
|
+
if (typeof v === "boolean")
|
|
682
|
+
return v;
|
|
683
|
+
return REDACTED;
|
|
57
684
|
}
|
|
58
|
-
function walk(value,
|
|
59
|
-
if (key !== undefined && SECRET_KEY.test(key))
|
|
60
|
-
return REDACTED;
|
|
685
|
+
function walk(value, depth, maxDepth, seen) {
|
|
61
686
|
if (value === null || value === undefined)
|
|
62
687
|
return value ?? null;
|
|
63
688
|
switch (typeof value) {
|
|
64
689
|
case "string":
|
|
65
|
-
|
|
690
|
+
// Cut first: shapes are scanned in linear time, but twenty-odd passes
|
|
691
|
+
// over a 20 MB leaf is still a visible pause on a hook's path, and no
|
|
692
|
+
// consumer sends anything close to this size.
|
|
693
|
+
return redactText(value.length > REDACT_SCAN_MAX_CHARS ? truncateString(value, REDACT_SCAN_MAX_CHARS) : value);
|
|
66
694
|
case "number":
|
|
67
695
|
return Number.isFinite(value) ? value : String(value);
|
|
68
696
|
case "boolean":
|
|
@@ -83,7 +711,7 @@ function walk(value, key, depth, maxDepth, seen) {
|
|
|
83
711
|
seen.add(obj);
|
|
84
712
|
try {
|
|
85
713
|
if (Array.isArray(obj)) {
|
|
86
|
-
return obj.map((item) => walk(item,
|
|
714
|
+
return obj.map((item) => walk(item, depth + 1, maxDepth, seen));
|
|
87
715
|
}
|
|
88
716
|
if (obj instanceof Date)
|
|
89
717
|
return obj.toISOString();
|
|
@@ -91,18 +719,19 @@ function walk(value, key, depth, maxDepth, seen) {
|
|
|
91
719
|
return { name: obj.name, message: redactText(obj.message) };
|
|
92
720
|
const out = {};
|
|
93
721
|
for (const [k, v] of Object.entries(obj)) {
|
|
94
|
-
|
|
95
|
-
|
|
722
|
+
const strength = secretKeyStrength(k);
|
|
723
|
+
if (strength) {
|
|
724
|
+
put(out, k, underSecretKey(v, strength));
|
|
96
725
|
}
|
|
97
726
|
else if (ENV_KEY.test(k) && v && typeof v === "object" && !Array.isArray(v)) {
|
|
98
727
|
// Rule 2: an env block's names are useful, its values never are.
|
|
99
728
|
const env = {};
|
|
100
729
|
for (const name of Object.keys(v))
|
|
101
|
-
env
|
|
102
|
-
out
|
|
730
|
+
put(env, name, REDACTED);
|
|
731
|
+
put(out, k, env);
|
|
103
732
|
}
|
|
104
733
|
else {
|
|
105
|
-
out
|
|
734
|
+
put(out, k, walk(v, depth + 1, maxDepth, seen));
|
|
106
735
|
}
|
|
107
736
|
}
|
|
108
737
|
return out;
|