@basein/runner 0.3.24 → 0.3.26
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +80 -0
- package/dist/replay/bundle.js +455 -24
- package/dist/replay/controller.d.ts +64 -1
- package/dist/replay/controller.js +238 -38
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +15 -0
- package/dist/replay/plan.js +48 -30
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/docs/calculatedReplay.md +32 -0
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/record/redact.js
CHANGED
|
@@ -45,13 +45,23 @@
|
|
|
45
45
|
* `token: newToken`). A separator never spans a line break: an empty
|
|
46
46
|
* `DB_PASSWORD=` must not take the next line with it.
|
|
47
47
|
*
|
|
48
|
+
* A VALUE IN QUOTES IS READ TO ITS CLOSING QUOTE, spaces, `&`, `;` and all: a
|
|
49
|
+
* passphrase has words in it. Nor is it code for what it starts with or holds:
|
|
50
|
+
* a password may start with `$`, `!` or `(` and have a bracket in it
|
|
51
|
+
* (`"p@ss(w0rd)1"`), and went out as it was while that made a value look like
|
|
52
|
+
* code. Only a reference — `${VAR}`, `$(cmd)`, `<placeholder>`, `{{ template }}`
|
|
53
|
+
* — exempts it. Unquoted, an expression still shows by its shape.
|
|
54
|
+
*
|
|
48
55
|
* JSON TEXT IS REDACTED AS JSON. A serialized tool payload goes through
|
|
49
56
|
* {@link redactJsonText}, which parses it and runs the rules on each string
|
|
50
57
|
* leaf — the same pass its producer's {@link redact} made — so the result is
|
|
51
58
|
* 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).
|
|
53
|
-
*
|
|
54
|
-
*
|
|
59
|
+
* exactly (live replay serializes a result the same way and compares). A
|
|
60
|
+
* string leaf is text, whatever it holds: a JSON document in one — a file's
|
|
61
|
+
* content, what an MCP tool returns in `content[].text` — gets the text rules
|
|
62
|
+
* where it stands, and is never parsed and written back. The text rules read
|
|
63
|
+
* JSON text, plain and JSON-escaped (a document held in a string, a preview of
|
|
64
|
+
* one cut off before it parses): they never start a match inside an escape
|
|
55
65
|
* sequence and never replace a quote or a backslash, so an escaped document
|
|
56
66
|
* stays parseable.
|
|
57
67
|
*
|
|
@@ -278,14 +288,109 @@ export function isSecretEnvName(name) {
|
|
|
278
288
|
return false;
|
|
279
289
|
}
|
|
280
290
|
// ── free-text values ────────────────────────────────────────────────────────
|
|
281
|
-
/** A
|
|
282
|
-
|
|
283
|
-
|
|
291
|
+
/** A boolean-ish setting: never a credential, whatever the name beside it. */
|
|
292
|
+
const FLAG_LITERAL = /^(?:true|false|yes|no|on|off|enabled|disabled|null|none)$/i;
|
|
293
|
+
/** A type in a declaration: `password: string` declares a field, it does not set one. */
|
|
294
|
+
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;
|
|
295
|
+
/** Name segments a variable *for* a secret carries: `newToken`, `apiKey`, `secret_value`. */
|
|
296
|
+
const SECRET_WORDS = new Set([
|
|
297
|
+
"token", "secret", "password", "passwd", "passphrase", "pass", "pwd", "key", "auth",
|
|
298
|
+
"credential", "credentials", "cookie", "session", "jwt", "bearer", "dsn",
|
|
299
|
+
]);
|
|
300
|
+
/**
|
|
301
|
+
* A value that stands for a secret kept somewhere else, written whole:
|
|
302
|
+
* `${DB_PASSWORD}`, `$(cat ~/.token)`, `%DB_PASSWORD%`, `<your-password>`,
|
|
303
|
+
* `{{ vault_password }}`, `{password}`.
|
|
304
|
+
*
|
|
305
|
+
* The whole value has to be the reference. The test used to be its first
|
|
306
|
+
* character — `$`, `%`, `{` or `<` — and a literal password that only starts
|
|
307
|
+
* like a reference was sent as it was: `$up3rS3cret`, `%2FAbCdEf…`, `<3Kittens!`.
|
|
308
|
+
*/
|
|
309
|
+
const REFERENCE = new RegExp(`^(?:${[
|
|
310
|
+
/\$\{(?:[^{}]|\{[^{}]*\})*\}/, // ${VAR}, ${VAR:-${OTHER}}, ${{ secrets.X }}
|
|
311
|
+
/\$\(.*\)/, // $(command)
|
|
312
|
+
/`[^`]*`/, // `command`
|
|
313
|
+
/\$[0-9@*#?!$-]/, // $1, $@
|
|
314
|
+
/%(?:[0-9]|[A-Za-z_][A-Za-z0-9_]*%)/, // %VAR%, %1
|
|
315
|
+
/<[^<>]+>/, // <placeholder>
|
|
316
|
+
/\{\{.*\}\}/, // {{ template }}
|
|
317
|
+
/\{[A-Za-z_][\w.]*\}/, // {placeholder}
|
|
318
|
+
]
|
|
319
|
+
.map((r) => r.source)
|
|
320
|
+
.join("|")})$`);
|
|
321
|
+
/**
|
|
322
|
+
* True for a {@link REFERENCE}, and for `$NAME`: a `$` and one whole name
|
|
323
|
+
* (`PGPASSWORD=$pw psql`, `token=$tok`, `X-Api-Key: $API_KEY`).
|
|
324
|
+
*
|
|
325
|
+
* A name that mixes upper and lower case around a digit is the exception: it
|
|
326
|
+
* is what a generated password looks like when its first character is a `$`
|
|
327
|
+
* — `$up3rS3cret`, `$ecretPass1` — where a variable is `$DB_PASSWORD`,
|
|
328
|
+
* `$tok`, `$apiKey` or `$VAR1`. Nothing else about the text tells the two
|
|
329
|
+
* apart, and the first is the one that must not be sent.
|
|
330
|
+
*/
|
|
331
|
+
function isReference(value) {
|
|
332
|
+
// One quoting inside the other is a shell's way to write it: `'export API_KEY='"$API_KEY"'; run'`.
|
|
333
|
+
const wrap = /^(\\?["']).*\1$/.exec(value);
|
|
334
|
+
const inner = wrap ? value.slice(wrap[1].length, -wrap[1].length) : value;
|
|
335
|
+
if (REFERENCE.test(inner))
|
|
284
336
|
return true;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
337
|
+
const ident = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(inner)?.[1];
|
|
338
|
+
return ident !== undefined && !(/\d/.test(ident) && /[a-z]/.test(ident) && /[A-Z]/.test(ident));
|
|
339
|
+
}
|
|
340
|
+
/** What opens a reference that may have a space in it, and what closes it. */
|
|
341
|
+
const OPENERS = [
|
|
342
|
+
["$(", ")"],
|
|
343
|
+
["${", "}"],
|
|
344
|
+
["{{", "}}"],
|
|
345
|
+
["`", "`"],
|
|
346
|
+
["<", ">"],
|
|
347
|
+
];
|
|
348
|
+
/** What follows a value on its line, as far as a reference could reasonably run. */
|
|
349
|
+
const LINE_REST = /[^\r\n\\]{0,200}/y;
|
|
350
|
+
/**
|
|
351
|
+
* An unquoted value ends at the first space, so a reference with a space in it
|
|
352
|
+
* arrives cut: `$(cat` of `$(cat ~/.token)`, `<your` of `<your api key>`. It
|
|
353
|
+
* is one all the same when what it opened is closed further along the line.
|
|
354
|
+
* `<3Kittens!` opens nothing that closes, and is a password.
|
|
355
|
+
*/
|
|
356
|
+
function opensReference(value, text, end) {
|
|
357
|
+
for (const [open, close] of OPENERS) {
|
|
358
|
+
if (!value.startsWith(open))
|
|
359
|
+
continue;
|
|
360
|
+
// Closed inside the value with more after it: `${PREFIX}s3cret` is a literal in part.
|
|
361
|
+
if (value.includes(close, open.length))
|
|
362
|
+
return false;
|
|
363
|
+
LINE_REST.lastIndex = end;
|
|
364
|
+
return (LINE_REST.exec(text)?.[0] ?? "").includes(close);
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
/** A call or an index, from its first character: `os.getenv(`, `buildAuth(user);`, `tokens[0]`. */
|
|
369
|
+
const CALL_OR_INDEX = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*[([]/;
|
|
370
|
+
/**
|
|
371
|
+
* A value that is visibly code or a reference, not a literal secret.
|
|
372
|
+
*
|
|
373
|
+
* In quotes only a reference is. `"!Sup3rS3cret"`, `'@dm1nP@ssw0rd'` and
|
|
374
|
+
* `"(Secret)1!"` are strings, whatever they start with or hold; each was sent
|
|
375
|
+
* as it was while a first character or a bracket anywhere made a value code.
|
|
376
|
+
* Unquoted, an expression shows by its shape: it opens with an operator or a
|
|
377
|
+
* bracket, or it is a call or an index. A bracket that closes in the middle of
|
|
378
|
+
* a word is neither: `p@ss(w0rd)1`, `abc(def)123456`.
|
|
379
|
+
*
|
|
380
|
+
* @param text the text the value was found in, and `end` where its match
|
|
381
|
+
* ends there: an unquoted reference may go on past its value.
|
|
382
|
+
*/
|
|
383
|
+
function looksLikeCode(value, quoted, text, end) {
|
|
384
|
+
if (isReference(quoted ? value : value.replace(/[,;]$/, "")))
|
|
385
|
+
return true;
|
|
386
|
+
if (!quoted) {
|
|
387
|
+
if (opensReference(value, text, end))
|
|
388
|
+
return true;
|
|
389
|
+
if (/^[([{`@&*!=#]/.test(value))
|
|
390
|
+
return true; // expressions, YAML tags and anchors, comments
|
|
391
|
+
if (CALL_OR_INDEX.test(value) && !/[)\]][\w$]/.test(value))
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
289
394
|
if (/^(true|false|null|none|nil|undefined|required|optional|enabled|disabled|yes|no|on|off|\*+|x+|\.{3}|…)[,;]?$/i.test(value)) {
|
|
290
395
|
return true;
|
|
291
396
|
}
|
|
@@ -295,15 +400,6 @@ function looksLikeCode(value) {
|
|
|
295
400
|
return true; // `password: password,`
|
|
296
401
|
return false;
|
|
297
402
|
}
|
|
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
403
|
/**
|
|
308
404
|
* A bare identifier that refers to the secret instead of spelling it out: a
|
|
309
405
|
* type (`password: string`), the key's own word (`self.password = password`),
|
|
@@ -358,6 +454,95 @@ const QUOTE = `((?:\\\\["'])|["']|)`;
|
|
|
358
454
|
* as part of the word: `…\nsk-proj-…` inside a JSON document held in a string.
|
|
359
455
|
*/
|
|
360
456
|
const B = `(?:(?<=\\\\[nrt])|\\b)`;
|
|
457
|
+
/**
|
|
458
|
+
* What stands between `"…"`: any character, or a backslash and the one it
|
|
459
|
+
* escapes — an escaped `\n` or `\/` is part of the value, as a .env file's
|
|
460
|
+
* `KEY="line1\nline2"` has it. In JSON text a plain `"` is only ever a
|
|
461
|
+
* delimiter, and never followed by a `\n`, so nothing is taken across a line.
|
|
462
|
+
*/
|
|
463
|
+
const IN_DOUBLE_QUOTES = `(?:[^"\\\\\\r\\n]|\\\\[^\\r\\n])`;
|
|
464
|
+
/**
|
|
465
|
+
* Between `'…'`. A JSON `\n` or `\r` ends it: a JSON string holds a single
|
|
466
|
+
* quote as it is, so an unclosed one would take the lines after it.
|
|
467
|
+
*/
|
|
468
|
+
const IN_SINGLE_QUOTES = `(?:[^'\\\\\\r\\n]|\\\\[^nr\\r\\n])`;
|
|
469
|
+
/**
|
|
470
|
+
* Between `\"…\"`, the quotes of a JSON document held in a string. There a
|
|
471
|
+
* backslash of the value's own is `\\`, and a quote of its own `\\\"`; a `\"`
|
|
472
|
+
* on its own is the closing quote.
|
|
473
|
+
*/
|
|
474
|
+
const IN_ESCAPED_QUOTES = `(?:[^"\\\\\\r\\n]|\\\\\\\\(?:\\\\["\\\\]|[^"\\\\\\r\\n])|\\\\[/bftu])`;
|
|
475
|
+
/**
|
|
476
|
+
* A value in quotes, read to its closing quote: a passphrase has spaces in
|
|
477
|
+
* it, and `&`, `;` or `)` inside quotes are part of the password, not the end
|
|
478
|
+
* of it. `SMTP_PASSWORD="correct horse battery staple"` used to lose its first
|
|
479
|
+
* word and send the other three.
|
|
480
|
+
*
|
|
481
|
+
* One alternative for each way the quotes are written, each a (quote, value)
|
|
482
|
+
* pair of groups with the closing quote left in the text.
|
|
483
|
+
*
|
|
484
|
+
* Not a value: what follows a quote that CLOSES a string — `"API_KEY=" + key`
|
|
485
|
+
* in source, `["API_KEY=", 123, "x"]` in JSON. So a quoted value here does not
|
|
486
|
+
* start with a space or with `, : ] }`; one that does is left to the form the
|
|
487
|
+
* rule had before, which reads what follows the quote up to the next space.
|
|
488
|
+
*/
|
|
489
|
+
function quotedForms(min) {
|
|
490
|
+
const opens = `(?![\\s,:\\]}])`;
|
|
491
|
+
return [
|
|
492
|
+
`(")${opens}(${IN_DOUBLE_QUOTES}{${min},})(?=")`,
|
|
493
|
+
`(')${opens}(${IN_SINGLE_QUOTES}{${min},})(?=')`,
|
|
494
|
+
`(\\\\")${opens}(${IN_ESCAPED_QUOTES}{${min},})(?=\\\\")`,
|
|
495
|
+
].join("|");
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The end of a line after a value, with nothing between them but blanks and a
|
|
499
|
+
* `#` comment. In JSON text the line ends at a `\n` escape, and a file's last
|
|
500
|
+
* line at the quote that closes the string holding it.
|
|
501
|
+
*/
|
|
502
|
+
const LINE_END = /[ \t]*(?:#[^\r\n"'\\]*)?(?:$|[\r\n"']|\\[nr"])/y;
|
|
503
|
+
/** Where a shell stops reading an unquoted word. */
|
|
504
|
+
const SHELL_SEPARATOR = /[;&|)`]/;
|
|
505
|
+
/**
|
|
506
|
+
* Words run together, each one capitalised: `JavaScript`, `TypeScript`. After
|
|
507
|
+
* `Basic` that is a heading, not a credential. Base64 of eight characters or
|
|
508
|
+
* more is almost never made of capitalised words, so telling them apart costs
|
|
509
|
+
* no credential in practice.
|
|
510
|
+
*/
|
|
511
|
+
const CAPITALISED_WORDS = /^(?:[A-Z][a-z]+){2,}$/;
|
|
512
|
+
/**
|
|
513
|
+
* Words joined by `-`, each perhaps numbered, or a short number between them:
|
|
514
|
+
* `jenkins-build-agent-number-forty-two`, `ci-runner-eu-west-1`. A UUID's
|
|
515
|
+
* groups of hex mix letters and digits as no word does, and a key of 32 hex
|
|
516
|
+
* characters has no `-` at all.
|
|
517
|
+
*/
|
|
518
|
+
const HYPHENATED_WORDS = /^(?:[A-Za-z]+\d{0,3}|\d{1,3})(?:-(?:[A-Za-z]+\d{0,3}|\d{1,3}))+$/;
|
|
519
|
+
/**
|
|
520
|
+
* The parameter that carries the signature of a signed URL: an Azure SAS
|
|
521
|
+
* (`sig`), S3 and Google Cloud Storage presigned URLs (`X-Amz-Signature`,
|
|
522
|
+
* `X-Goog-Signature`), CloudFront (`Signature`). Whoever has it has the file.
|
|
523
|
+
*
|
|
524
|
+
* Secret in a query string only, so not one of {@link isSecretKey}'s names:
|
|
525
|
+
* as a key, `signature` is an ordinary argument of a code tool (a function's
|
|
526
|
+
* signature), and `sig` a signal's name.
|
|
527
|
+
*/
|
|
528
|
+
const SIGNED_URL_PARAM = /^(?:sig|signature|x-amz-signature|x-goog-signature)$/i;
|
|
529
|
+
/** Where a match ends, and in what text: `replace()` passes both after a rule's own groups. */
|
|
530
|
+
function matchEnd(match, rest) {
|
|
531
|
+
return { text: rest[rest.length - 1], end: rest[rest.length - 2] + match.length };
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* What a rule built on {@link quotedForms} read: the one (quote, value) pair
|
|
535
|
+
* among its alternatives that took part in the match. `rest` is everything
|
|
536
|
+
* `replace()` passes from the first of those pairs on.
|
|
537
|
+
*/
|
|
538
|
+
function matchedValue(match, rest) {
|
|
539
|
+
for (let i = 0; i + 3 < rest.length; i += 2) {
|
|
540
|
+
const value = rest[i + 1];
|
|
541
|
+
if (typeof value === "string")
|
|
542
|
+
return { quote: rest[i] ?? "", value, ...matchEnd(match, rest) };
|
|
543
|
+
}
|
|
544
|
+
return { quote: "", value: "", ...matchEnd(match, rest) };
|
|
545
|
+
}
|
|
361
546
|
/**
|
|
362
547
|
* Value shapes that are secret wherever they appear, including inside prose.
|
|
363
548
|
* Order matters: specific token shapes first, so an assignment rule after them
|
|
@@ -378,7 +563,12 @@ const SECRET_SHAPES = [
|
|
|
378
563
|
// which is most of the cost on a result made of many short strings.
|
|
379
564
|
{
|
|
380
565
|
re: new RegExp(`${B}(?:${[
|
|
381
|
-
|
|
566
|
+
// JWT. Not after an earlier `eyJ` of the same run of token characters:
|
|
567
|
+
// a `-` is one of them and a word boundary too, so in `eyJaaaa-eyJaaaa-…`
|
|
568
|
+
// every `eyJ` began a scan to the end of the run — 256 KiB of it took
|
|
569
|
+
// 8 s. A later one could only match where the first did: a first
|
|
570
|
+
// segment ends at the run's end whichever `eyJ` it began at.
|
|
571
|
+
new RegExp(`\\beyJ(?<!${B}eyJ[A-Za-z0-9_-]*?-eyJ)[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]*`),
|
|
382
572
|
/\bsk-[A-Za-z0-9_-]{16,}/, // OpenAI / Anthropic style
|
|
383
573
|
/\b[rs]k_(?:live|test)_[A-Za-z0-9]{10,}/, // Stripe secret / restricted
|
|
384
574
|
/\bwhsec_[A-Za-z0-9]{20,}/, // Stripe webhook secret
|
|
@@ -398,22 +588,79 @@ const SECRET_SHAPES = [
|
|
|
398
588
|
.join("|")})`, "g"),
|
|
399
589
|
replace: REDACTED,
|
|
400
590
|
},
|
|
401
|
-
// The password half of URL userinfo: postgres://admin:S3cret@host → admin:[redacted]@host
|
|
591
|
+
// The password half of URL userinfo: postgres://admin:S3cret@host → admin:[redacted]@host,
|
|
592
|
+
// and with no user name before it: redis://:S3cret@host. A password that is a
|
|
593
|
+
// variable is not one: `redis://:$REDIS_PASSWORD@redis:6379`,
|
|
594
|
+
// `postgres://${DB_USER}:${DB_PASSWORD}@db/app` — a compose file's, a
|
|
595
|
+
// script's — go as they are.
|
|
596
|
+
//
|
|
597
|
+
// Found from its `://`, and the scheme checked behind that. Read forwards, a
|
|
598
|
+
// scheme began after every `.`, `+` and `-` of a run of its own characters
|
|
599
|
+
// and ran to the end of it: `a.a.a.a…` cost 23 s for 256 KiB.
|
|
402
600
|
{
|
|
403
601
|
hint: (t) => t.includes("://"),
|
|
404
|
-
re:
|
|
405
|
-
replace: `$
|
|
602
|
+
re: /(:\/\/(?<=\b[a-z][a-z0-9+.-]*:\/\/)[^\s:/@"'\\]*:)([^\s@/"'\\]+)@/gi,
|
|
603
|
+
replace: (m, head, password) => password === REDACTED || isReference(password) ? m : `${head}${REDACTED}@`,
|
|
406
604
|
},
|
|
407
|
-
//
|
|
605
|
+
// User-info that is only a token: https://<token>@github.com/owner/repo.git, a
|
|
606
|
+
// Sentry DSN, and the user half of `https://<token>:x-oauth-basic@…`, whose
|
|
607
|
+
// password half the rule above has just taken. A user name is short and a
|
|
608
|
+
// word; a token is 32 characters or more, or 20 with a digit and both cases.
|
|
609
|
+
// Words joined by `-` are a name however long they run
|
|
610
|
+
// (`jenkins-build-agent-number-forty-two`); 32 hex characters, or a UUID,
|
|
611
|
+
// are still a token.
|
|
612
|
+
{
|
|
613
|
+
hint: (t) => t.includes("://") && t.includes("@"),
|
|
614
|
+
re: /(:\/\/(?<=\b[a-z][a-z0-9+.-]*:\/\/))([A-Za-z0-9_-]{20,})(?=(?::[^\s@/"'\\]*)?@[^\s@/"'\\]+)/gi,
|
|
615
|
+
replace: (m, head, user) => (user.length >= 32 && !HYPHENATED_WORDS.test(user)) || (/\d/.test(user) && /[a-z]/.test(user) && /[A-Z]/.test(user))
|
|
616
|
+
? `${head}${REDACTED}`
|
|
617
|
+
: m,
|
|
618
|
+
},
|
|
619
|
+
// HTTP Basic credentials. Prose ("Basic authentication", "Basic JavaScript") is left alone.
|
|
408
620
|
{
|
|
409
621
|
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,
|
|
622
|
+
replace: (m, cred) => /[0-9+/=]/.test(cred) || (/[A-Z]/.test(cred.slice(1)) && !CAPITALISED_WORDS.test(cred)) ? `Basic ${REDACTED}` : m,
|
|
411
623
|
},
|
|
412
624
|
// curl -u user:password / --user user:password.
|
|
625
|
+
//
|
|
626
|
+
// Between the flag and the user name: blanks and `=` up to a quote, or up to
|
|
627
|
+
// the last blank, or one `=`. It was `[\s=]+`, and a user name may hold `=`
|
|
628
|
+
// too, so the two shared a run of `=` in every way there is to split it:
|
|
629
|
+
// ` -u ====…` cost 43 s for 256 KiB. These three match the same text, each
|
|
630
|
+
// in one way.
|
|
631
|
+
//
|
|
632
|
+
// What `-u` is given may be a URL instead (`redis-cli -u redis://:pw@host`),
|
|
633
|
+
// whose password the URL rule has read already: `//` after the colon is not
|
|
634
|
+
// a password. Nor is a variable (`curl -u admin:$ADMIN_PASSWORD`).
|
|
413
635
|
{
|
|
414
636
|
hint: (t) => t.includes("-u") || t.includes("--user"),
|
|
415
|
-
re: /((?:^|\s)(?:-u|--user)[\s=]+["']
|
|
416
|
-
replace: `$
|
|
637
|
+
re: /((?:^|\s)(?:-u|--user)(?:[\s=]+["']|[\s=]*\s|=)[^\s:"'\\]+:)(?!\/\/)([^\s"'\\]+)/g,
|
|
638
|
+
replace: (m, head, password) => (isReference(password) ? m : `${head}${REDACTED}`),
|
|
639
|
+
},
|
|
640
|
+
// `docker login -u me -p <password>` and `sshpass -p <password>`, the value
|
|
641
|
+
// after a blank or none (`-phunter2222`, `-p'two words'`). For most
|
|
642
|
+
// commands `-p` is a port, a path or "parents" (`docker run -p 8080:80`,
|
|
643
|
+
// `mkdir -p`), so it is read after these two only, and not past a `;`, `&`
|
|
644
|
+
// or `|` that ends the command it is in.
|
|
645
|
+
{
|
|
646
|
+
hint: (t) => t.includes("-p") && (t.includes("docker") || t.includes("sshpass")),
|
|
647
|
+
re: new RegExp(`(\\b(?:docker[ \\t]+login\\b[^\\r\\n;&|]{0,200}?|sshpass)[ \\t]+-p[ \\t]*)(?:${quotedForms(1)}|()([^\\s"'\\\\]+))`, "g"),
|
|
648
|
+
replace: (m, head, ...rest) => {
|
|
649
|
+
const { quote, value, text, end } = matchedValue(m, rest);
|
|
650
|
+
if (value === REDACTED || isReference(value) || (!quote && opensReference(value, text, end)))
|
|
651
|
+
return m;
|
|
652
|
+
return `${head}${quote}${REDACTED}`;
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
// Cookies given to curl on the command line: `curl -b 'sid=…'`, `--cookie "sid=…; theme=dark"`.
|
|
656
|
+
// A value with no `=` in it is the name of a cookie file, not a cookie.
|
|
657
|
+
{
|
|
658
|
+
hint: (t) => t.includes("-b") || t.includes("--cookie"),
|
|
659
|
+
re: new RegExp(`((?:\\bcurl\\b[^\\r\\n;&|]{0,300}?[ \\t]-b[ \\t]+|[ \\t]--cookie[ \\t=]+))(?:${quotedForms(1)}|()([^\\s"'\\\\]+))`, "g"),
|
|
660
|
+
replace: (m, head, ...rest) => {
|
|
661
|
+
const { quote, value } = matchedValue(m, rest);
|
|
662
|
+
return value.includes("=") ? `${head}${quote}${REDACTED}` : m;
|
|
663
|
+
},
|
|
417
664
|
},
|
|
418
665
|
// Authorization with a scheme the shapes above do not know (Token, Digest, …).
|
|
419
666
|
{
|
|
@@ -426,75 +673,161 @@ const SECRET_SHAPES = [
|
|
|
426
673
|
re: /((?:^|[;"'\s])(?:AccountKey|SharedAccessKey|SharedAccessSignature|Password|Pwd)=)([^;"'\s\\]+)/gi,
|
|
427
674
|
replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
|
|
428
675
|
},
|
|
429
|
-
// A secret in a query string: ?access_token=…&page=2,
|
|
676
|
+
// A secret in a query string or a URL fragment: ?access_token=…&page=2,
|
|
677
|
+
// &api_key=…, #access_token=… (where an OAuth implicit grant puts it), and
|
|
678
|
+
// the signature of a signed URL.
|
|
430
679
|
{
|
|
431
|
-
hint: (t) => t.includes("=") && (t.includes("?") || t.includes("&")),
|
|
432
|
-
re: /([
|
|
433
|
-
replace: (m, sep, name, value) => isSecretKey(name)
|
|
680
|
+
hint: (t) => t.includes("=") && (t.includes("?") || t.includes("&") || t.includes("#")),
|
|
681
|
+
re: /([?&#])([A-Za-z_][A-Za-z0-9_.-]*)=([^&#\s"'\\]{4,})/g,
|
|
682
|
+
replace: (m, sep, name, value) => (isSecretKey(name) || SIGNED_URL_PARAM.test(name)) && value !== REDACTED && !isReference(value)
|
|
683
|
+
? `${sep}${name}=${REDACTED}`
|
|
684
|
+
: m,
|
|
434
685
|
},
|
|
435
|
-
// Command-line flags: --password=…, --api-key=…, --token
|
|
686
|
+
// Command-line flags: --password=…, --api-key=…, --token …, the value bare
|
|
687
|
+
// or in quotes, the name's words joined by `-` or `_` (`--api_key`).
|
|
436
688
|
{
|
|
437
689
|
hint: (t) => t.includes("-"),
|
|
438
|
-
re:
|
|
439
|
-
replace: (m, head,
|
|
440
|
-
|
|
441
|
-
|
|
690
|
+
re: new RegExp(`(\\s--?(?:[a-z0-9]+[-_])*(?:password|passwd|passphrase|token|api[-_]?key|secret|access[-_]?key|auth[-_]?token)(?:=|[ \\t]+))(?:${quotedForms(1)}|()([^\\s"'\\\\-][^\\s"'\\\\]*))`, "gi"),
|
|
691
|
+
replace: (m, head, ...rest) => {
|
|
692
|
+
const { quote, value, text, end } = matchedValue(m, rest);
|
|
693
|
+
if (value === REDACTED)
|
|
694
|
+
return m;
|
|
695
|
+
if (!value.startsWith(REDACTED)) {
|
|
696
|
+
const flag = head.trim().replace(/^-+|=$/g, "");
|
|
697
|
+
if (isReference(value) || (!quote && opensReference(value, text, end)))
|
|
698
|
+
return m;
|
|
699
|
+
if (/\s$/.test(head) && value.length < 6)
|
|
700
|
+
return m;
|
|
701
|
+
// Help text names a type or the flag itself: `--token string Auth token`, `--password PASSWORD`.
|
|
702
|
+
if (!quote && (TYPE_NAME.test(value) || (/^[A-Z][A-Z_]*$/.test(value) && namesItself(flag, value))))
|
|
703
|
+
return m;
|
|
704
|
+
}
|
|
705
|
+
return `${head}${quote}${REDACTED}`;
|
|
706
|
+
},
|
|
442
707
|
},
|
|
443
708
|
// Cookie headers carry sessions: `Cookie: sid=…; theme=dark` loses all of it.
|
|
709
|
+
//
|
|
710
|
+
// Not after an earlier `Cookie:` of the same stretch of line: that one took
|
|
711
|
+
// the rest of it if an `=` follows, and if none does, none follows this one
|
|
712
|
+
// either. Without the check every `Cookie:` of a line with no `=` read to
|
|
713
|
+
// the line's end — 25 s for 256 KiB of them.
|
|
714
|
+
//
|
|
715
|
+
// And the value does not start with a blank, which the blanks of the head
|
|
716
|
+
// have taken: `Cookie:` and 256 KiB of spaces shared the run between the
|
|
717
|
+
// head and the value in every way there is to split it — a minute.
|
|
444
718
|
{
|
|
445
|
-
re: new RegExp(`${B}((?:Set-)?Cookie[ \\t]*:[ \\t]*)([^\\r\\n"'\\\\]*=[^\\r\\n"'\\\\]*)`, "gi"),
|
|
719
|
+
re: new RegExp(`${B}(?=(?:Set-)?Cookie[ \\t]*:)(?<!${B}Cookie[ \\t]*:[^\\r\\n"'\\\\]*?)((?:Set-)?Cookie[ \\t]*:[ \\t]*)(?![ \\t])([^\\r\\n"'\\\\]*=[^\\r\\n"'\\\\]*)`, "gi"),
|
|
446
720
|
replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
|
|
447
721
|
},
|
|
448
722
|
// Header form with a hyphenated name: `X-Api-Key: …`, `X-Auth-Token: …`, `-H 'Private-Token: …'`.
|
|
449
723
|
{
|
|
450
724
|
hint: (t) => t.includes(":") && t.includes("-"),
|
|
451
725
|
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) =>
|
|
726
|
+
replace: (m, pre, name, sep, quote, value, ...rest) => {
|
|
727
|
+
if (!isSecretKey(name) || value === REDACTED)
|
|
728
|
+
return m;
|
|
729
|
+
const { text, end } = matchEnd(m, rest);
|
|
730
|
+
if (!value.startsWith(REDACTED) && looksLikeCode(value, quote !== "", text, end))
|
|
731
|
+
return m;
|
|
732
|
+
return `${pre}${name}${sep}${quote}${REDACTED}`;
|
|
733
|
+
},
|
|
453
734
|
},
|
|
454
735
|
// Shell and dotenv form: `export GITHUB_TOKEN=…`, `PGPASSWORD=… psql`, `API_KEY = "…"`.
|
|
736
|
+
//
|
|
737
|
+
// Unquoted, the value ends where a shell would end it — at a space or at
|
|
738
|
+
// `; & | )` or a backtick — because a command may go on from there:
|
|
739
|
+
// `FOO_TOKEN=abc123&& make deploy`. Whether such a character is part of the
|
|
740
|
+
// password instead (`DB_PASSWORD=S3cr&tP@ssw0rd!` in a .env file) is for the
|
|
741
|
+
// next rule to say: it knows whether the assignment is the whole line.
|
|
455
742
|
{
|
|
456
743
|
hint: (t) => t.includes("="),
|
|
457
|
-
re: new RegExp(`(${WORD_START})((?:export[ \\t]+)?)([A-Z][A-Z0-9_]*)([ \\t]*=(?!=)[ \\t]*)
|
|
458
|
-
replace: (m, pre, exp, name, sep,
|
|
459
|
-
|
|
460
|
-
|
|
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))
|
|
744
|
+
re: new RegExp(`(${WORD_START})((?:export[ \\t]+)?)([A-Z][A-Z0-9_]*)([ \\t]*=(?!=)[ \\t]*)(?:${quotedForms(3)}|${QUOTE}([^\\s"'\\\\;&|\`)]{3,}))`, "g"),
|
|
745
|
+
replace: (m, pre, exp, name, sep, ...rest) => {
|
|
746
|
+
const { quote, value, text, end } = matchedValue(m, rest);
|
|
747
|
+
if (!isSecretEnvName(name) || value === REDACTED)
|
|
469
748
|
return m;
|
|
749
|
+
// What an earlier rule left of a value is still the value: `GITHUB_TOKEN=[redacted]-suffix`.
|
|
750
|
+
if (!value.startsWith(REDACTED)) {
|
|
751
|
+
// A flag is not a credential: `SKIP_AUTH=true`, `REQUIRE_OTP=off`.
|
|
752
|
+
if (FLAG_LITERAL.test(value))
|
|
753
|
+
return m;
|
|
754
|
+
// `NAME=value` with no spaces is how env files and shells write it; with
|
|
755
|
+
// spaces it may be code, so a call or a reference is left alone.
|
|
756
|
+
if (sep.trim() !== sep && (looksLikeCode(value, quote !== "", text, end) || (!quote && namesItself(name, value)))) {
|
|
757
|
+
return m;
|
|
758
|
+
}
|
|
759
|
+
if (isReference(value) || (!quote && opensReference(value, text, end)))
|
|
760
|
+
return m;
|
|
761
|
+
}
|
|
470
762
|
return `${pre}${exp}${name}${sep}${quote}${REDACTED}`;
|
|
471
763
|
},
|
|
472
764
|
},
|
|
473
765
|
// Env/ini/YAML/properties lines with any-case names: `password: …`, `aws_secret_access_key = …`,
|
|
474
|
-
// `spring.datasource.password=…`, indented or `export`ed.
|
|
766
|
+
// `spring.datasource.password=…`, indented or `export`ed — and an .npmrc line,
|
|
767
|
+
// whose name comes after the registry it is for: `//registry.example/:_authToken=…`.
|
|
475
768
|
{
|
|
476
769
|
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]*)
|
|
478
|
-
replace: (m, pre, name, sep,
|
|
479
|
-
|
|
480
|
-
|
|
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)
|
|
770
|
+
re: new RegExp(`(${LINE_START}(?:[ \\t]|\\\\t)*(?:-[ \\t]+)?(?:export[ \\t]+)?(?://[^\\s"'\\\\=]*/:)?)([A-Za-z_][A-Za-z0-9_.-]*)([ \\t]*[=:][ \\t]*)(?:${quotedForms(4)}|${QUOTE}(${V}{4,}))`, "g"),
|
|
771
|
+
replace: (m, pre, name, sep, ...rest) => {
|
|
772
|
+
const { quote, value, text, end } = matchedValue(m, rest);
|
|
773
|
+
if ((!isSecretKey(name) && !isSecretEnvName(name)) || value === REDACTED)
|
|
487
774
|
return m;
|
|
775
|
+
if (!value.startsWith(REDACTED)) {
|
|
776
|
+
if (looksLikeCode(value, quote !== "", text, end))
|
|
777
|
+
return m;
|
|
778
|
+
// `password: string`, `token: newToken`, `api_key = apiKey`: code naming the secret.
|
|
779
|
+
if (!quote && namesItself(name, value))
|
|
780
|
+
return m;
|
|
781
|
+
if (sep.includes(":") && value.length < 6 && !quote)
|
|
782
|
+
return m;
|
|
783
|
+
}
|
|
784
|
+
else if (!quote && sep.includes("=")) {
|
|
785
|
+
// The shell rule took the head of this value and stopped at `&`, `;`,
|
|
786
|
+
// `|`, `)` or a backtick. On a line that is this assignment and nothing
|
|
787
|
+
// more the rest is the rest of the password (`DB_PASSWORD=S3cr&tP@ssw0rd!`),
|
|
788
|
+
// and all of it goes. Where the line goes on it is a command
|
|
789
|
+
// (`FOO_TOKEN=[redacted]&& make deploy`), and from that character on it stays.
|
|
790
|
+
LINE_END.lastIndex = end;
|
|
791
|
+
if (!LINE_END.test(text)) {
|
|
792
|
+
const cut = value.search(SHELL_SEPARATOR);
|
|
793
|
+
const own = cut < 0 ? value : value.slice(0, cut);
|
|
794
|
+
return own === REDACTED ? m : `${pre}${name}${sep}${REDACTED}${value.slice(own.length)}`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
488
797
|
return `${pre}${name}${sep}${quote}${REDACTED}`;
|
|
489
798
|
},
|
|
490
799
|
},
|
|
491
800
|
// JSON form inside text: "password": "hunter2" (also with JSON-escaped quotes).
|
|
801
|
+
//
|
|
802
|
+
// The value runs to its closing quote, escapes and all. It used to end at
|
|
803
|
+
// the first backslash, so a value holding `\/`, `\u0026` or `\n` was not
|
|
804
|
+
// matched at all, and one holding `\"` kept everything after it. The form
|
|
805
|
+
// of before comes last, for a value whose quotes are not a pair
|
|
806
|
+
// (`\"password\": "…"`): what it matched it still does.
|
|
492
807
|
{
|
|
493
808
|
hint: (t) => t.includes('"') && t.includes(":"),
|
|
494
|
-
re:
|
|
495
|
-
replace: (m, q1, name, q2, sep,
|
|
496
|
-
|
|
497
|
-
: m
|
|
809
|
+
re: new RegExp(`(\\\\?")([A-Za-z_][A-Za-z0-9_.-]*)(\\\\?")(\\s*:\\s*)(?:(")(${IN_DOUBLE_QUOTES}{4,})(?=")|(\\\\")(${IN_ESCAPED_QUOTES}{4,})(?=\\\\")|(\\\\?")([^"\\\\\\r\\n]{4,})(?=\\\\?"))`, "g"),
|
|
810
|
+
replace: (m, q1, name, q2, sep, ...rest) => {
|
|
811
|
+
const { quote, value } = matchedValue(m, rest);
|
|
812
|
+
return isSecretKey(name) && value !== REDACTED && !isReference(value) ? `${q1}${name}${q2}${sep}${quote}${REDACTED}` : m;
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
// A number under a key that names the secret itself, in JSON text: a
|
|
816
|
+
// one-time code (`"otp": 482913`), a PIN — which the same object passed as an
|
|
817
|
+
// object loses by its key. A whole number of four digits or more, under a
|
|
818
|
+
// strong key only: a count under `tokens` and an id under `sessionId` stay,
|
|
819
|
+
// and so do `"token": 0` and a ratio. Written back as a string in the key's
|
|
820
|
+
// own quotes, so the document still parses.
|
|
821
|
+
{
|
|
822
|
+
hint: (t) => t.includes('"') && t.includes(":"),
|
|
823
|
+
re: /(\\?")([A-Za-z_][A-Za-z0-9_.-]*)(\\?")(\s*:\s*)(\d{4,})(?![\w.])/g,
|
|
824
|
+
replace: (m, q1, name, q2, sep) => secretKeyStrength(name) === "strong" ? `${q1}${name}${q2}${sep}${q1}${REDACTED}${q1}` : m,
|
|
825
|
+
},
|
|
826
|
+
// The strings of a list under a secret key, in JSON text: `"tokens":
|
|
827
|
+
// ["tok_…"]`, `"set-cookie": ["sid=…; Path=/"]`.
|
|
828
|
+
{
|
|
829
|
+
hint: (t) => t.includes("[") && t.includes('"') && t.includes(":"),
|
|
830
|
+
apply: redactSecretLists,
|
|
498
831
|
},
|
|
499
832
|
];
|
|
500
833
|
/**
|
|
@@ -546,8 +879,13 @@ function redactPass(text) {
|
|
|
546
879
|
}
|
|
547
880
|
return out;
|
|
548
881
|
}
|
|
549
|
-
|
|
550
|
-
|
|
882
|
+
/**
|
|
883
|
+
* `-----BEGIN RSA PRIVATE KEY-----`, and PGP armor, which says one word more:
|
|
884
|
+
* `-----BEGIN PGP PRIVATE KEY BLOCK-----`. The END line has to be of the same
|
|
885
|
+
* kind, so a block does not end at the END line of a key of another.
|
|
886
|
+
*/
|
|
887
|
+
const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY( BLOCK)?-----/g;
|
|
888
|
+
const PEM_END = /-----END ([A-Z0-9 ]*)PRIVATE KEY( BLOCK)?-----/g;
|
|
551
889
|
/**
|
|
552
890
|
* What a key cut off before its END line is made of: base64, its headers
|
|
553
891
|
* (`Proc-Type: 4,ENCRYPTED`) and line breaks, raw or JSON-escaped. A backslash
|
|
@@ -569,10 +907,11 @@ function redactPem(text) {
|
|
|
569
907
|
const ends = new Map();
|
|
570
908
|
PEM_END.lastIndex = 0;
|
|
571
909
|
for (let m = PEM_END.exec(text); m; m = PEM_END.exec(text)) {
|
|
572
|
-
|
|
910
|
+
const kind = m[1] + (m[2] ?? "");
|
|
911
|
+
let list = ends.get(kind);
|
|
573
912
|
if (!list)
|
|
574
|
-
ends.set(
|
|
575
|
-
list.push(m.index);
|
|
913
|
+
ends.set(kind, (list = []));
|
|
914
|
+
list.push({ at: m.index, to: m.index + m[0].length });
|
|
576
915
|
}
|
|
577
916
|
const cursor = new Map();
|
|
578
917
|
let quote = -2; // the first `"` at or after the last position asked about; -1 = none
|
|
@@ -580,20 +919,20 @@ function redactPem(text) {
|
|
|
580
919
|
let last = 0;
|
|
581
920
|
PEM_BEGIN.lastIndex = 0;
|
|
582
921
|
for (let m = PEM_BEGIN.exec(text); m; m = PEM_BEGIN.exec(text)) {
|
|
583
|
-
const
|
|
922
|
+
const kind = m[1] + (m[2] ?? "");
|
|
584
923
|
const after = m.index + m[0].length;
|
|
585
924
|
let stop = -1;
|
|
586
|
-
const list = ends.get(
|
|
925
|
+
const list = ends.get(kind);
|
|
587
926
|
if (list) {
|
|
588
|
-
let i = cursor.get(
|
|
589
|
-
while (i < list.length && list[i] < after)
|
|
927
|
+
let i = cursor.get(kind) ?? 0;
|
|
928
|
+
while (i < list.length && list[i].at < after)
|
|
590
929
|
i += 1;
|
|
591
|
-
cursor.set(
|
|
930
|
+
cursor.set(kind, i);
|
|
592
931
|
if (i < list.length) {
|
|
593
932
|
if (quote !== -1 && quote < after)
|
|
594
933
|
quote = text.indexOf('"', after);
|
|
595
|
-
if (quote === -1 || quote >= list[i])
|
|
596
|
-
stop = list[i]
|
|
934
|
+
if (quote === -1 || quote >= list[i].at)
|
|
935
|
+
stop = list[i].to;
|
|
597
936
|
}
|
|
598
937
|
}
|
|
599
938
|
if (stop < 0) {
|
|
@@ -607,8 +946,107 @@ function redactPem(text) {
|
|
|
607
946
|
}
|
|
608
947
|
return last === 0 ? text : out + text.slice(last);
|
|
609
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* A member whose value is a list, `"tokens": [`, with its key's quotes plain
|
|
951
|
+
* or JSON-escaped (`\"tokens\": [`, a document held in a string). Between the
|
|
952
|
+
* key and the bracket: blanks, and in escaped text a `\n` or a `\t`.
|
|
953
|
+
*/
|
|
954
|
+
const LIST_HEAD = /(\\?")([A-Za-z_][A-Za-z0-9_.-]*)\1(?:\s|\\[nrt])*:(?:\s|\\[nrt])*\[/g;
|
|
955
|
+
/** What may stand between the items of a list. */
|
|
956
|
+
const LIST_SPACE = /(?:\s|\\[nrt])*/y;
|
|
957
|
+
/** An item in plain quotes, and its text. */
|
|
958
|
+
const LIST_STRING = /"((?:[^"\\\r\n]|\\[^\r\n])*)"/y;
|
|
959
|
+
/** An item in JSON-escaped quotes, and its text. */
|
|
960
|
+
const LIST_ESCAPED_STRING = new RegExp(`\\\\"(${IN_ESCAPED_QUOTES}*)\\\\"`, "y");
|
|
961
|
+
/** An item that is a number, or `true`, `false` or `null`. */
|
|
962
|
+
const LIST_OTHER = /(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)(?![\w.])/y;
|
|
963
|
+
/**
|
|
964
|
+
* The items of every list under a secret key, as `[redacted]` each in its own
|
|
965
|
+
* quotes: a string always, a whole number of four digits or more under a
|
|
966
|
+
* strong key — what the same list passed as an object loses by its key. The
|
|
967
|
+
* list keeps its shape and its spacing, and the text around it every byte.
|
|
968
|
+
*
|
|
969
|
+
* Not a regex over the list: read item by item, each from where the last one
|
|
970
|
+
* ended, so a list is read once however long it is. An item that is not a
|
|
971
|
+
* string, a number or a literal — an object, a list — ends the reading: an
|
|
972
|
+
* object in the list is read by its own keys, by the rules above.
|
|
973
|
+
*/
|
|
974
|
+
function redactSecretLists(text) {
|
|
975
|
+
let out = "";
|
|
976
|
+
let last = 0;
|
|
977
|
+
let changed = false;
|
|
978
|
+
LIST_HEAD.lastIndex = 0;
|
|
979
|
+
for (let m = LIST_HEAD.exec(text); m; m = LIST_HEAD.exec(text)) {
|
|
980
|
+
const strength = secretKeyStrength(m[2]);
|
|
981
|
+
if (!strength)
|
|
982
|
+
continue;
|
|
983
|
+
const quote = m[1];
|
|
984
|
+
const item = quote.length === 2 ? LIST_ESCAPED_STRING : LIST_STRING;
|
|
985
|
+
let at = m.index + m[0].length;
|
|
986
|
+
for (;;) {
|
|
987
|
+
LIST_SPACE.lastIndex = at;
|
|
988
|
+
LIST_SPACE.exec(text);
|
|
989
|
+
at = LIST_SPACE.lastIndex;
|
|
990
|
+
item.lastIndex = at;
|
|
991
|
+
const s = item.exec(text);
|
|
992
|
+
if (s) {
|
|
993
|
+
const value = s[1];
|
|
994
|
+
const from = at + quote.length;
|
|
995
|
+
if (value !== "" && value !== REDACTED && !isReference(value)) {
|
|
996
|
+
out += text.slice(last, from) + REDACTED;
|
|
997
|
+
last = from + value.length;
|
|
998
|
+
changed = true;
|
|
999
|
+
}
|
|
1000
|
+
at = item.lastIndex;
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
LIST_OTHER.lastIndex = at;
|
|
1004
|
+
const o = LIST_OTHER.exec(text);
|
|
1005
|
+
if (!o)
|
|
1006
|
+
break;
|
|
1007
|
+
if (strength === "strong" && /^\d{4,}$/.test(o[0])) {
|
|
1008
|
+
out += `${text.slice(last, at)}${quote}${REDACTED}${quote}`;
|
|
1009
|
+
last = at + o[0].length;
|
|
1010
|
+
changed = true;
|
|
1011
|
+
}
|
|
1012
|
+
at = LIST_OTHER.lastIndex;
|
|
1013
|
+
}
|
|
1014
|
+
LIST_SPACE.lastIndex = at;
|
|
1015
|
+
LIST_SPACE.exec(text);
|
|
1016
|
+
at = LIST_SPACE.lastIndex;
|
|
1017
|
+
if (text[at] !== ",")
|
|
1018
|
+
break;
|
|
1019
|
+
at += 1;
|
|
1020
|
+
}
|
|
1021
|
+
// The next list starts after this one: no item is read twice.
|
|
1022
|
+
if (LIST_HEAD.lastIndex < at)
|
|
1023
|
+
LIST_HEAD.lastIndex = at;
|
|
1024
|
+
}
|
|
1025
|
+
return changed ? out + text.slice(last) : text;
|
|
1026
|
+
}
|
|
610
1027
|
/** Deep enough for any producer's output ({@link redact} stops at 24) inside a wrapper. */
|
|
611
1028
|
const JSON_TEXT_MAX_DEPTH = 64;
|
|
1029
|
+
/**
|
|
1030
|
+
* Whether a string can be a JSON object or array, by its first and last
|
|
1031
|
+
* characters alone. A log line (`[INFO] …`) or a preview cut off before its
|
|
1032
|
+
* end is spared a parse that would read all of it to fail at the last character.
|
|
1033
|
+
*/
|
|
1034
|
+
function mayHoldJson(text) {
|
|
1035
|
+
let first = 0;
|
|
1036
|
+
while (first < text.length && isJsonSpace(text.charCodeAt(first)))
|
|
1037
|
+
first += 1;
|
|
1038
|
+
const open = text.charCodeAt(first);
|
|
1039
|
+
if (open !== 0x7b && open !== 0x5b)
|
|
1040
|
+
return false; // { [
|
|
1041
|
+
let last = text.length - 1;
|
|
1042
|
+
while (last > first && isJsonSpace(text.charCodeAt(last)))
|
|
1043
|
+
last -= 1;
|
|
1044
|
+
return text.charCodeAt(last) === open + 2; // } ]
|
|
1045
|
+
}
|
|
1046
|
+
/** The four characters JSON allows between its tokens. */
|
|
1047
|
+
function isJsonSpace(code) {
|
|
1048
|
+
return code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09;
|
|
1049
|
+
}
|
|
612
1050
|
/**
|
|
613
1051
|
* Redact a string that holds JSON — a serialized tool input, result or error —
|
|
614
1052
|
* and return JSON, always.
|
|
@@ -621,7 +1059,11 @@ const JSON_TEXT_MAX_DEPTH = 64;
|
|
|
621
1059
|
* producers redact first — the original bytes come back untouched: live replay
|
|
622
1060
|
* serializes a result the same way and compares against what was recorded.
|
|
623
1061
|
* Text that is not JSON (a truncated preview, a producer's plain error) gets
|
|
624
|
-
* {@link redactText}: there is no structure to keep.
|
|
1062
|
+
* {@link redactText}: there is no structure to keep. Unless the rules gave it
|
|
1063
|
+
* one: a private key written over several lines can be all that kept a
|
|
1064
|
+
* document from parsing, and with the key gone it is redacted as the JSON it
|
|
1065
|
+
* now is — or the next pass over it (the recorder's, after its producer's)
|
|
1066
|
+
* would be the one to do that, and move its bytes.
|
|
625
1067
|
*/
|
|
626
1068
|
export function redactJsonText(text) {
|
|
627
1069
|
let parsed;
|
|
@@ -629,7 +1071,16 @@ export function redactJsonText(text) {
|
|
|
629
1071
|
parsed = JSON.parse(text);
|
|
630
1072
|
}
|
|
631
1073
|
catch {
|
|
632
|
-
|
|
1074
|
+
const plain = redactText(text);
|
|
1075
|
+
if (plain === text || !mayHoldJson(plain))
|
|
1076
|
+
return plain;
|
|
1077
|
+
try {
|
|
1078
|
+
parsed = JSON.parse(plain);
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return plain;
|
|
1082
|
+
}
|
|
1083
|
+
text = plain;
|
|
633
1084
|
}
|
|
634
1085
|
let cleaned;
|
|
635
1086
|
try {
|
|
@@ -690,6 +1141,14 @@ function walk(value, depth, maxDepth, seen) {
|
|
|
690
1141
|
// Cut first: shapes are scanned in linear time, but twenty-odd passes
|
|
691
1142
|
// over a 20 MB leaf is still a visible pause on a hook's path, and no
|
|
692
1143
|
// consumer sends anything close to this size.
|
|
1144
|
+
// A string is text, whatever it holds. A JSON document in one — a file a
|
|
1145
|
+
// Write puts on disk, what an MCP tool returns in `content[].text` — is
|
|
1146
|
+
// not parsed and written back: that lost its formatting and its big
|
|
1147
|
+
// numbers, and rule 2 emptied a config's `env` block (`"browser": true`
|
|
1148
|
+
// in an .eslintrc became "[redacted]"), and the plan wrote that file.
|
|
1149
|
+
// The text rules read the JSON in it where it is: a value under a secret
|
|
1150
|
+
// key, a number under one (a one-time code), the strings of a list
|
|
1151
|
+
// (`"tokens": [...]`, a `set-cookie` array).
|
|
693
1152
|
return redactText(value.length > REDACT_SCAN_MAX_CHARS ? truncateString(value, REDACT_SCAN_MAX_CHARS) : value);
|
|
694
1153
|
case "number":
|
|
695
1154
|
return Number.isFinite(value) ? value : String(value);
|