redac---n 0.0.3 → 0.0.5

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.
Files changed (48) hide show
  1. package/README.md +11 -10
  2. package/dist/calls.d.ts +8 -0
  3. package/dist/calls.js +60 -3
  4. package/dist/checksums.js +4 -3
  5. package/dist/commands.d.ts +5 -0
  6. package/dist/commands.js +93 -14
  7. package/dist/context.d.ts +5 -2
  8. package/dist/context.js +36 -9
  9. package/dist/encoded.js +13 -6
  10. package/dist/environment.d.ts +5 -3
  11. package/dist/environment.js +38 -14
  12. package/dist/formats.js +15 -0
  13. package/dist/index.js +10 -4
  14. package/dist/json.js +29 -5
  15. package/dist/known.d.ts +7 -7
  16. package/dist/known.js +33 -25
  17. package/dist/named.d.ts +6 -0
  18. package/dist/named.js +421 -125
  19. package/dist/names.d.ts +5 -1
  20. package/dist/names.js +143 -18
  21. package/dist/normalize.d.ts +4 -1
  22. package/dist/normalize.js +37 -11
  23. package/dist/pem.d.ts +2 -0
  24. package/dist/pem.js +12 -0
  25. package/dist/policy.js +12 -5
  26. package/dist/public.js +35 -13
  27. package/dist/secretfile.d.ts +5 -0
  28. package/dist/secretfile.js +49 -20
  29. package/dist/shell.d.ts +12 -3
  30. package/dist/shell.js +51 -7
  31. package/dist/spans.d.ts +4 -3
  32. package/dist/spans.js +79 -8
  33. package/dist/sql.d.ts +12 -8
  34. package/dist/sql.js +94 -16
  35. package/dist/stream.d.ts +2 -1
  36. package/dist/stream.js +15 -3
  37. package/dist/structure.js +210 -61
  38. package/dist/syntax.d.ts +5 -2
  39. package/dist/syntax.js +7 -4
  40. package/dist/transcript.js +116 -28
  41. package/dist/unnamed.js +128 -20
  42. package/dist/urls.d.ts +8 -3
  43. package/dist/urls.js +16 -7
  44. package/dist/values.d.ts +2 -1
  45. package/dist/values.js +70 -25
  46. package/docs/holdout.svg +8 -8
  47. package/docs/layers.md +1 -1
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -32,15 +32,15 @@ came from, and see what would be sent. The library runs in the page, so nothing
32
32
 
33
33
  ![Secrets caught against look-alike values destroyed, redac---n and seven open-source scanners](docs/holdout.svg)
34
34
 
35
- On a held-out set of realistic agent output (1,112 cases, never used for tuning), redac---n catches **93.0%** of
36
- secrets and destroys **3.0%** of the values that look like secrets but aren't. The best combination of existing
35
+ On a held-out set of realistic agent output (1,112 cases, never used for tuning), redac---n catches **92.6%** of
36
+ secrets and destroys **2.9%** of the values that look like secrets but aren't. The best combination of existing
37
37
  scanners we found catches 70.6% and destroys 2.2%; all seven together catch 84.2% but destroy 28.6% of the
38
38
  look-alikes.
39
39
 
40
40
  | | secrets caught | look-alikes destroyed |
41
41
  |---|---:|---:|
42
- | **redac---n** (default) | **93.0%** | **3.0%** |
43
- | redac---n, `guessing: "off"` | 90.9% | 1.1% |
42
+ | **redac---n** (default) | **92.6%** | **2.9%** |
43
+ | redac---n, `guessing: "off"` | 90.5% | 0.9% |
44
44
  | gitleaks + Betterleaks + Kingfisher | 70.6% | 2.2% |
45
45
  | all seven scanners combined | 84.2% | 28.6% |
46
46
  | CredSweeper, the best single scanner | 70.1% | 15.2% |
@@ -81,7 +81,7 @@ and then became development data. Secrets caught / look-alikes destroyed at that
81
81
 
82
82
  | set | redac---n | `guessing: "off"` | gitleaks + Betterleaks + Kingfisher | best single other scanner |
83
83
  |---|---:|---:|---:|---:|
84
- | Held-out 1: 1,112 cases over 104 kinds of output | 93.0 / 3.0 | 90.9 / 1.1 | 70.6 / 2.2 | CredSweeper 70.1 / 15.2 |
84
+ | Held-out 1: 1,112 cases over 104 kinds of output | 92.6 / 2.9 | 90.5 / 0.9 | 70.6 / 2.2 | CredSweeper 70.1 / 15.2 |
85
85
  | Held-out 2: 848 cases from blind template writers | 86.8 / 5.7 | 78.3 / 3.7 | 60.3 / 5.1 | CredSweeper 65.7 / 20.1 |
86
86
  | Held-out 3: 450 cases, mobile, ML, IoT, enterprise stacks, other CI vendors | 85.5 / 7.4 | 78.5 / 3.2 | 64.0 / 4.8 | CredSweeper 69.4 / 21.0 |
87
87
  | Held-out 4: 478 everyday agent sessions, issue and PR threads | 90.4 / 1.9 | 86.7 / 0.7 | 66.2 / 3.6 | CredSweeper 74.1 / 20.4 |
@@ -89,6 +89,7 @@ and then became development data. Secrets caught / look-alikes destroyed at that
89
89
  | Held-out 6: 112 cases from regional clouds and payment APIs | 93.2 / 9.3 | 78.9 / 5.8 | 61.7 / 3.1 | CredSweeper 73.7 / 18.1 |
90
90
  | Held-out 7: 305 cases from databases, queues, observability, build tooling | 93.4 / 8.5 | 85.1 / 3.8 | 69.6 / 3.1 | CredSweeper 67.5 / 19.8 |
91
91
  | Held-out 8: 261 cases from data and analytics tooling | 89.0 / 1.5 | 83.7 / 0.8 | 67.9 / 1.8 | CredSweeper 72.4 / 16.7 |
92
+ | Held-out 9: 300 cases from the AI and agent ecosystem, JVM and .NET business apps, and SaaS integrations | 94.0 / 7.9 | 89.8 / 4.0 | not run | not run |
92
93
  | [SlotGuard](https://github.com/illinoisdata/SlotGuard): 200 agent sessions, 852 planted values (652 credentials, and 200 email addresses this library leaves alone) | 62.6 / 0.0 | 53.1 / 0.0 | 29.2 / 0.0 | CredSweeper 46.9 / 0.0 |
93
94
  | [ProwlBench](https://github.com/Lercas/prowlbench): 7,491 secrets in code, tickets, logs and chat in eight languages; 7,375 "secret-free" snippets, some with values under credential names that this library hides by design | 89.4 / 12.5 | 78.6 / 7.8 | 59.1 / 8.7 | detect-secrets 63.8 / 19.1 |
94
95
 
@@ -96,9 +97,9 @@ Other measurements; where a set was first scored blind, that score comes first:
96
97
 
97
98
  | set | result |
98
99
  |---|---|
99
- | Development sets (4,250 cases, 6,424 secrets, 18,402 look-alikes; held-out 2–8 among them) | 95.4% caught, 1.4% destroyed; 95.4% and 1.4% when each case is wrapped in an Anthropic or OpenAI transcript and passed to `redactValue` |
100
- | [CredData](https://github.com/Samsung/CredData) test split (real lines from public repositories) | 82.8% caught and 3.2% of non-secret lines hit, blind; now 83.5% and 3.5%. On the dev split it was tuned on, 91.1% and 1.8%: the gap shows how much of the tuning doesn't carry over to new repositories |
101
- | Five more ordinary-text sets (new repositories, trajectory shards, donated agent sessions from [Trace Commons](https://huggingface.co/datasets/trace-commons/agent-traces), agent pull requests from [AIDev](https://huggingface.co/datasets/hao-li/AIDev), repositories that handle credentials) | 5.5, 4.1, 5.6, 22.5 and 17.0 redactions per million characters, blind (the last two hold AWS signatures in presigned URLs and recorded OAuth test runs with real tokens) |
100
+ | Development sets (4,550 cases, 6,942 secrets, 18,932 look-alikes; held-out 2–9 among them) | 95.6% caught, 1.4% destroyed; 95.5% and 1.4% when each case is wrapped in an Anthropic or OpenAI transcript and passed to `redactValue` |
101
+ | [CredData](https://github.com/Samsung/CredData) test split (real lines from public repositories) | 82.8% caught and 3.2% of non-secret lines hit, blind; now 83.9% and 3.5%. On the dev split it was tuned on, 91.4% and 1.8%: the gap shows how much of the tuning doesn't carry over to new repositories |
102
+ | Six more ordinary-text sets (new repositories, trajectory shards, donated agent sessions from [Trace Commons](https://huggingface.co/datasets/trace-commons/agent-traces), agent pull requests from [AIDev](https://huggingface.co/datasets/hao-li/AIDev), repositories that handle credentials) | 5.5, 4.1, 5.6, 22.5, 17.0 and 4.8 redactions per million characters, blind (the last two hold AWS signatures in presigned URLs and recorded OAuth test runs with real tokens) |
102
103
  | [GitLab's secret-detection rules](https://gitlab.com/gitlab-org/security-products/secret-detection/secret-detection-rules), each rule's examples | 81.4% of 2,038 caught and 23% of 168 negatives hit, blind; now 88.0% and 15% |
103
104
  | Credential-handling code with no credentials ([ICSME 2026](https://doi.org/10.5281/zenodo.22942243), 5,903 snippets) | 4.6% of snippets hit, blind; now 2.0%, mostly passwords written into the code |
104
105
  | [levainbot/fp-corpus](https://github.com/levainbot/fp-corpus): log and build output, 85 planted values | 79 caught and 3 of 116 clean sections hit, blind; now 81 (the four missed: an email address, two card numbers and one key) and 4 of 116 |
@@ -229,7 +230,7 @@ measures what each adds and what each costs.
229
230
 
230
231
  1. **Values you gave it**, in every encoding output turns them into: JSON-, URL-, shell-, SQL- and HTML-escaped,
231
232
  hex, base64 at any offset, and cut short.
232
- 2. **536 vendor token formats**: 137 maintained here and 399 from
233
+ 2. **542 vendor token formats**: 143 maintained here and 399 from
233
234
  [Betterleaks](https://github.com/betterleaks/betterleaks)' catalog (some vendors are in both).
234
235
  3. **Structure:** private keys (by their DER, under any PEM label), URL passwords, Authorization headers, cookies,
235
236
  connection strings, signed URLs, Kubernetes Secrets, CI masks, sign-in links, `.netrc`, and passwords given to
@@ -296,7 +297,7 @@ it as a `known` value: known values are hidden whatever they look like, as long
296
297
 
297
298
  - **Behaviour tests** (`test/`, by topic): a few cases per rule or decision, cross-products of credential names,
298
299
  syntaxes and value shapes, and checks that long and repetitive inputs take linear time.
299
- - **The quality gate** (`pnpm check`) scores every development set and the 132 regression sets
300
+ - **The quality gate** (`pnpm check`) scores every development set and the 138 regression sets
300
301
  (`eval/corpora/regress/README.md`: probes of each rule's edges) against a committed baseline
301
302
  (`eval/baseline.json.gz`), and fails on any case that newly leaks, is newly destroyed or is newly slow.
302
303
  `scripts/snap.ts` does the same with a sample of ordinary text from the external corpora added.
package/dist/calls.d.ts CHANGED
@@ -53,6 +53,14 @@ export interface LiteralHead {
53
53
  export declare function literalHead(text: string, at: number, { keyword }?: {
54
54
  keyword?: boolean | undefined;
55
55
  }): LiteralHead | undefined;
56
+ /**
57
+ * The literal a converter at `at` is given, the value it makes (CONVERTER), and where the converter's call closes.
58
+ * (Not a lookup's argument, a secret's name: Prefect's `Secret.load("db-password")`, `SecretStr.get(…)`; nor an
59
+ * encoding's name.)
60
+ */
61
+ export declare function convertedLiteral(text: string, at: number): (Literal & {
62
+ close: number;
63
+ }) | undefined;
56
64
  /** The string literal starting at `at` (after blanks), read as an argument would be: `"…"`, `b'…'`, `Some("…")`. */
57
65
  export declare function literalAt(text: string, at: number): Literal | undefined;
58
66
  export interface Call {
package/dist/calls.js CHANGED
@@ -67,6 +67,9 @@ function matchBrackets(text, slashComments) {
67
67
  else if (c === "'" && isContraction(text, i)) {
68
68
  // (Prose's apostrophe, `don't connect(…) doesn't`, opens nothing.)
69
69
  }
70
+ else if (c === "`" && text.startsWith("``", i + 1)) {
71
+ i = fenceEnd(text, i);
72
+ }
70
73
  else if (c === '"' || c === "'" || c === "`") {
71
74
  // (A string that doesn't close ends every call around it: none of them is closed.)
72
75
  const stop = literalEnd(text, i, { max: MAX_CALL });
@@ -88,6 +91,16 @@ function matchBrackets(text, slashComments) {
88
91
  }
89
92
  return close;
90
93
  }
94
+ /**
95
+ * The last backtick of the run of three or more that starts at `at`: a Markdown code fence (or inline code's), which opens
96
+ * no template literal. Read as one, a fence would hide everything up to the next one, the code it shows included.
97
+ */
98
+ function fenceEnd(text, at) {
99
+ let i = at;
100
+ while (text[i + 1] === "`")
101
+ i++;
102
+ return i;
103
+ }
91
104
  /** The arguments between `at` and the bracket at `close` that closes the call: separated by commas outside brackets and strings. */
92
105
  function splitArguments(text, at, close, slashComments) {
93
106
  const args = [];
@@ -222,10 +235,17 @@ export function literalEnd(text, at, { max = MAX_VALUE, shell = false } = {}) {
222
235
  */
223
236
  const LITERAL_HEAD = new RegExp(String.raw `^(?:([A-Za-z_$][\w$]*)[ \t]*(?:=(?![=>])|:(?!:))[ \t]*)?((?:Some|String::from|SecretStr|Secret)\([ \t]*)?(${STRING_PREFIX})?(["'\x60])`);
224
237
  /**
225
- * What may follow a literal and leave it the value: a Rust raw string's closing `#`s, the wrapper's `)`, a conversion
226
- * (`.to_string()`, `.encode()`).
238
+ * A method that converts the value it is called on and takes no other: `.to_string()`, `.encode("utf-8")`,
239
+ * `.getBytes(StandardCharsets.UTF_8)`, `.toCharArray()`, `.trim()`. Its argument, if any, is a constant (an encoding,
240
+ * a number, a name in capitals or with a dot), not a variable a method would combine the value with: `.concat(x)`,
241
+ * `", ".join(parts)`.
242
+ */
243
+ const CONVERSION = String.raw `\.[A-Za-z_]\w{0,30}\([ \t]*(?:"[\w.-]{0,20}"|'[\w.-]{0,20}'|\d{1,5}|[A-Z][A-Z0-9_]{1,30}|[A-Za-z_]\w{0,30}(?:(?:\.|::)\w{1,30}){1,3})?[ \t]*\)`;
244
+ /**
245
+ * What may follow a literal and leave it the value: a Rust raw string's closing `#`s, the wrapper's `)`, and up to
246
+ * three conversions (CONVERSION).
227
247
  */
228
- const LITERAL_TAIL = /^#*(?:[ \t]*\))?(?:\.(?:to_string|into|to_owned|encode|strip)\(\))?[ \t]*$/;
248
+ const LITERAL_TAIL = new RegExp(String.raw `^#*(?:[ \t]*\))?(?:CONVERSION){0,3}[ \t]*$`.replace("CONVERSION", CONVERSION));
229
249
  const KEYWORD = /^([A-Za-z_$][\w$]*)[ \t]*(?:=(?![=>])|:(?!:))/;
230
250
  /** The literal head starting at `at`, or undefined. With `keyword`, a keyword before it is read too. */
231
251
  export function literalHead(text, at, { keyword = false } = {}) {
@@ -250,8 +270,45 @@ function argument(text, start, end) {
250
270
  const lit = head && literalOf(text, head);
251
271
  if (lit && LITERAL_TAIL.test(text.slice(lit.end + 1, end)))
252
272
  arg.literal = lit;
273
+ else if (!head) {
274
+ // A converter given the literal, the value it makes: `Buffer.from("…", "utf8")`, `Secret::new("…")`.
275
+ const converted = convertedLiteral(text, start + (keyword?.[0].length ?? 0));
276
+ if (converted && converted.close < end && LITERAL_TAIL.test(text.slice(converted.close + 1, end)))
277
+ arg.literal = converted;
278
+ }
253
279
  return arg;
254
280
  }
281
+ /** An encoding's name, which a converter takes beside the value it converts: `Buffer.from(key, "base64")`. */
282
+ const ENCODING = /^(?:utf-?8|utf-?16(?:le|be)?|latin-?1|ascii|iso-?8859-?\d+|cp\d+|base64|hex|binary|ucs-?2)$/i;
283
+ /**
284
+ * A converter's callee, up to its `(`: one that makes a value of the string it is given, `hexStringToUint8Array(`,
285
+ * `bytes.fromhex(`, `Buffer.from(`, `new TextEncoder().encode(`, `Secret::new(`, `SecretString::from(`, Go's `[]byte(`;
286
+ * or a digest that is given a password to hash, `MD5(`, `crypt(`, `hashlib.sha256(`, whose input is the password still.
287
+ */
288
+ const DIGEST = /^(?:[A-Za-z_]\w{0,30}(?:\.|::))?(?:md5|sha(?:1|2|224|256|384|512)?|crypt|digest|hashpw)[ \t]*\(/i;
289
+ const CONVERTER = /^(?:new[ \t]+)?(?:\[\])?(?:[\w.]|::|\(\)){0,60}(?:hex|Hex|b64|base64|Base64|decode|Decode|fromhex|unhexlify|Buffer\.from|SecretStr|Secret|SecureString|getBytes|encode|bytes?|String|str|Uint8Array|parse|Parse)(?:[\w.]|::){0,40}\(/;
290
+ /**
291
+ * The literal a converter at `at` is given, the value it makes (CONVERTER), and where the converter's call closes.
292
+ * (Not a lookup's argument, a secret's name: Prefect's `Secret.load("db-password")`, `SecretStr.get(…)`; nor an
293
+ * encoding's name.)
294
+ */
295
+ export function convertedLiteral(text, at) {
296
+ const head = text.slice(at, at + 120);
297
+ const converter = CONVERTER.exec(head) ?? DIGEST.exec(head);
298
+ if (!converter ||
299
+ /(?:^(?:new[ \t]+)?|\.|::)(?:load|get|fetch|read|lookup|retrieve|resolve|access)\w*\($/i.test(converter[0]))
300
+ return undefined;
301
+ const open = at + converter[0].length;
302
+ // (It is given the value alone, or with an encoding or a constant: a constructor given two strings,
303
+ // `new ClientSecretKeyVaultCredential("<client id>", "<secret>")`, makes no value of the first.)
304
+ const args = callArguments(text, open);
305
+ const literal = args?.[0]?.literal;
306
+ if (args?.slice(1).some((a) => a.literal && !ENCODING.test(a.literal.value)))
307
+ return undefined;
308
+ return literal && literal.value.length >= 4 && !ENCODING.test(literal.value)
309
+ ? { ...literal, close: closingBracket(text, open) }
310
+ : undefined;
311
+ }
255
312
  /** The string literal starting at `at` (after blanks), read as an argument would be: `"…"`, `b'…'`, `Some("…")`. */
256
313
  export function literalAt(text, at) {
257
314
  const start = at + /^[ \t]*/.exec(text.slice(at, at + 200))[0].length;
package/dist/checksums.js CHANGED
@@ -40,10 +40,11 @@ function base58Check(t, alphabet) {
40
40
  return check.slice(0, 4).every((b, i) => b === bytes[payload.length + i]) ? payload : undefined;
41
41
  }
42
42
  /**
43
- * The human-readable parts that name a secret, not an address: Nostr's `nsec` and `ncryptsec`, and the `sk`, `sec` and
44
- * `priv` that chains and tools give a private key (`sk1…`, `priv1…`), whose checksum would verify as well.
43
+ * The human-readable parts that name a secret, not an address: Nostr's `nsec` and `ncryptsec`, the `sk` and `sec` that
44
+ * chains and tools give a private key (`sk1…`), and any that says private, secret or seed (`priv1…`, Sui's
45
+ * `suiprivkey1…`), whose checksum would verify as well.
45
46
  */
46
- const SECRET_HRP = /^(?:nsec|ncryptsec|sk|sec|priv)$/;
47
+ const SECRET_HRP = /^(?:nsec|ncryptsec|sk|sec)$|priv|secret|seed/;
47
48
  /** A bech32 or bech32m address (`bc1…`, `cosmos1…`, `npub1…`) whose checksum verifies; never a secret's prefix (`nsec1`). All lower case or all upper case (BIP-173); not mixed. */
48
49
  export function isBech32Address(address) {
49
50
  const t = address === address.toUpperCase() ? address.toLowerCase() : address;
@@ -35,6 +35,11 @@ export declare function printsSecretFile(command: string): boolean;
35
35
  * holds a value only where the cut didn't happen, a `NAME=value` line in it.
36
36
  */
37
37
  export declare function printsSecretFileNames(command: string): boolean;
38
+ /**
39
+ * Whether what a command prints goes into the next command's input: by its own `|`, or by a group's around it, whatever
40
+ * follows it inside the group (`{ echo A=1; echo B=2; } | tee .env`).
41
+ */
42
+ export declare function pipes(c: ShellCommand): boolean;
38
43
  /** Whether a command prints a credential: any of its pipelines (`openssl rand -hex 32 && echo ok`) whose stage that makes one reaches its output. */
39
44
  export declare function printsSecret(command: string): boolean;
40
45
  /** How a command-line tool takes a password. */
package/dist/commands.js CHANGED
@@ -64,7 +64,7 @@ export function holdsOnlySecrets(path) {
64
64
  (/secret|credential|^auth\.json$|^\.(?:netrc|_netrc|pgpass|vault-token)$/i.test(path.slice(path.lastIndexOf("/") + 1)) ||
65
65
  /(^|\/)\.?secrets\//.test(path)));
66
66
  }
67
- const CONTENT_COMMANDS = /^(cat|tac|head|tail|less|more|bat|batcat|nl|sed|awk|grep|egrep|fgrep|zgrep|rg|ag|ack|Select-String|sls|cut|sort|uniq|strings|xxd|od|base64|tr|column|jq|yq|type|Get-Content|gc|diff|sops|op|dotenv|printenv|env|set|export|declare)$/;
67
+ const CONTENT_COMMANDS = /^(cat|tac|head|tail|less|more|bat|batcat|nl|sed|awk|grep|egrep|fgrep|zgrep|rg|ag|ack|Select-String|sls|cut|sort|uniq|strings|xxd|od|base64|tr|column|jq|yq|type|Get-Content|gc|findstr|diff|sops|op|dotenv|printenv|env|set|export|declare)$/;
68
68
  /**
69
69
  * Commands whose whole output is a credential, by tool: each read from the program on (a commit message that mentions
70
70
  * `gh auth token` isn't one).
@@ -112,6 +112,8 @@ const SECRET_TOOLS = new RegExp(`^(?:${[
112
112
  /secret-tool\s+lookup\b/,
113
113
  /lpass\s+show\b.*--password\b/,
114
114
  /keepassxc-cli\s+show\b.*(?:-a|--attributes)\s+Password\b/,
115
+ // (PowerShell's SecretManagement; without `-AsPlainText` it prints `System.Security.SecureString`.)
116
+ /Get-Secret\b.*-AsPlainText\b/,
115
117
  // Generators.
116
118
  /openssl\s+rand\b/,
117
119
  /pwgen\b/,
@@ -123,16 +125,22 @@ const SECRET_TOOLS = new RegExp(`^(?:${[
123
125
  .map((r) => r.source)
124
126
  .join("|")})`);
125
127
  /** What a command reads or calls that is a secret, wherever it sits in the command: a random device, a language's generator. */
126
- const SECRET_SOURCES = /\/dev\/u?random\b|get_random_secret_key|secrets\.token_(?:hex|urlsafe|bytes)|SecureRandom\.(?:hex|base64|urlsafe_base64)|crypto\.randomBytes/;
128
+ const SECRET_SOURCES = /\/dev\/u?random\b|get_random_secret_key|secrets\.token_(?:hex|urlsafe|bytes)|SecureRandom\.(?:hex|base64|urlsafe_base64)|crypto(?:["']\))?\.randomBytes/;
129
+ /**
130
+ * Byte generators that make IDs, salts and test data as often as keys (`user = os.urandom(8).hex()`, a patch that
131
+ * changes `os.urandom(3)`): a secret only printed as made, `print(os.urandom(32).hex())`, `echo
132
+ * bin2hex(random_bytes(32));`.
133
+ */
134
+ const PRINTED_SOURCE = /\b(?:print|puts|echo)\b[ \t(]*(?:[\w.]+\()*(?:os\.urandom\(|random_bytes\(|openssl_random_pseudo_bytes\()/;
127
135
  /** A shell's name: `bash`, `sh`, `zsh`. */
128
136
  export const SHELL = /^(?:ba|z|da|k)?sh$/;
129
137
  /** Commands whose whole output is a set of secrets, read like a secret file; each read from the program on. */
130
138
  const SECRET_DUMPS = new RegExp(String.raw `^(?:\bdoppler\s+secrets(?:\s+download)?\b(?![^\n]*\b(?:set|delete)\b)|\bsops\s+(?:-d|--decrypt)\b|\bansible-vault\s+(?:view|decrypt)\b|\b(?:bin\/)?rails\s+credentials:show\b|\binfisical\s+export\b|\bchamber\s+(?:export|read)\b|\bheroku\s+config(?:\s|$)(?!:)|\bvault\s+kv\s+get\b|\bop\s+inject\b|\baws\s+sts\s+(?:get-session-token|assume-role\S*|get-federation-token)\b|\baws\s+configure\s+export-credentials\b|\baz\s+ad\s+sp\s+(?:create-for-rbac|credential\s+reset)\b|\bkubectl\s+config\s+view\b[^\n]*--raw\b|\bterraform\s+output\s+-json\b|\bgit\s+credential\s+fill\b|\b(?:gh|glab)\s+auth\s+status\b[^\n]*(?:--show-token|\s-t\b))`);
131
139
  /**
132
140
  * A line written for PowerShell or cmd, where a backslash separates a path (`.\.env`, `C:\Users\me\.aws\credentials`,
133
- * `$env:USERPROFILE\.ssh\id_rsa`): read without POSIX escapes.
141
+ * `$env:USERPROFILE\.ssh\id_rsa`, `%USERPROFILE%\.ssh`): read without POSIX escapes.
134
142
  */
135
- const WINDOWS_LINE = /(?:^|[\s'"=(])(?:[A-Za-z]:\\|\.{1,2}\\[\w.$-]|~\\|\$(?:HOME|env:\w+)\\)|^[ \t]*(?:Get-\w+|Select-String|gc|type|pwsh|powershell|cmd)(?:\.exe)?[ \t]/i;
143
+ const WINDOWS_LINE = /(?:^|[\s'"=(])(?:[A-Za-z]:\\|\.{1,2}\\[\w.$-]|~\\|\$(?:HOME|env:\w+)\\|%\w+%\\)|^[ \t]*(?:Get-\w+|Select-String|gc|type|findstr|pwsh|powershell|cmd)(?:\.exe)?[ \t]/i;
136
144
  /**
137
145
  * The simple commands a command line runs, in order, read through what runs a command of its own: a shell (`bash -lc
138
146
  * "cat .env"`, `sudo sh -c '…'`, a heredoc or here-string fed to one: `ssh host <<'EOF'`), PowerShell (`pwsh -Command
@@ -165,7 +173,7 @@ export function commandsRun(text, { at = 0, oneLine = false, lines, depth = 0, }
165
173
  out.push(c);
166
174
  continue;
167
175
  }
168
- const wrapper = { kind: programName(simpleCommand(c.words)[0]), redirects: c.redirects };
176
+ const wrapper = { kind: programName(simpleCommand(c.words)[0]), redirects: c.redirects, then: c.then };
169
177
  for (const r of run)
170
178
  r.groups = [...r.groups, wrapper, ...c.groups];
171
179
  run[run.length - 1].then = c.then;
@@ -194,7 +202,9 @@ function commandLine(text, at, { oneLine = false, lines } = {}) {
194
202
  : prose
195
203
  ? at + prose[0].length - (prose[1] ? 1 : 0)
196
204
  : at;
197
- const backslash = !WINDOWS_LINE.test(text.slice(from, lineEnd(text, from, 4000)));
205
+ // (After PowerShell's prompt or cmd's, `PS C:\app> `, `C:\app>`, the line is theirs.)
206
+ const backslash = !(prompt && /^[ \t]*(?:PS\b|[A-Za-z]:\\)/.test(prompt[0])) &&
207
+ !WINDOWS_LINE.test(text.slice(from, lineEnd(text, from, 4000)));
198
208
  return shellCommands(text, 0, { at: from, oneLine, lines, backslash });
199
209
  }
200
210
  /** A command read out of `source`'s string, with its positions where the source puts them in `text`. */
@@ -308,6 +318,14 @@ function innerCommand(c, text) {
308
318
  const k = words.findIndex((w) => /^\/[ck]$/i.test(w.text));
309
319
  return k > 0 ? from(words, k + 1) : undefined;
310
320
  }
321
+ // `su - app -c 'cat .env'`, and runuser's twin of it, `runuser -l app -c …` (no `-u`, so no wrapper): the command
322
+ // string `-c` (`--command`) gives, as a shell's.
323
+ const runuser = programName(c.words.find((w) => !/^[A-Za-z_]\w*=/.test(w.text))) === "runuser";
324
+ if (program === "su" || runuser) {
325
+ const own = program === "su" ? words : c.words;
326
+ const k = own.findIndex((w) => /^(?:-c|--command)$/.test(w.text));
327
+ return k > 0 ? own[k + 1] && wordSource(own[k + 1]) : undefined;
328
+ }
311
329
  const sub = subcommand(words);
312
330
  if (/^(?:kubectl|oc)$/.test(program) && sub[0]?.text === "exec") {
313
331
  const dashes = sub.findIndex((w) => w.text === "--");
@@ -392,13 +410,17 @@ function imageName(image) {
392
410
  const ECHOES = /^(?:echo|printf|Write-Output|Write-Host)$/;
393
411
  /** Whether a command prints the contents of a secret file (`cat .env`, `grep KEY .env.local`, `cat < .env`, `git show HEAD:.env`). */
394
412
  export function printsSecretFile(command) {
413
+ // (A member of what a command gives prints that member, not the file or the listing: `(Get-Item Env:HOM*).Name`.)
414
+ if (EXPRESSION.test(command) &&
415
+ /\)(?:\.[A-Za-z_]\w*(?:\(\))?)*\.(?!value\b)[A-Za-z_]\w*(?:\(\))?[ \t]*$/i.test(command))
416
+ return false;
395
417
  return printsWhatItReads(command, readsSecretFile, isSecretFile) || printsIndirectly(command);
396
418
  }
397
419
  /**
398
420
  * A file printed by a name the pipeline reader doesn't see: `find … -exec cat {}`, `xargs cat`, a printer given a
399
421
  * variable or a substitution (`cat "$f"`, `cat $(find …)`, `echo "$line"`), a script's `open(…).read()`.
400
422
  */
401
- const PRINTER = /-exec[ \t]+(?:cat|tac|head|tail|less|more|bat|nl)\b|\bxargs[ \t]+(?:-\S+[ \t]+)*(?:cat|tac|head|tail|less|more|bat|nl)\b|(?:^|[\s;|&(])(?:cat|tac|head|tail|less|more|bat|nl|echo|printf|print)\b[^;|&\n]*\$[{(\w"]|\.read\(\)|readlines\(\)/;
423
+ const PRINTER = /(?:-exec|\bxargs\b[^|&\n]*?|\bfd(?:find)?\b[^|;&\n]*[ \t]-[xX])[ \t'"]+(?:cat|tac|head|tail|less|more|bat|nl|sort|strings|awk|(?:e?grep|rg)\b(?![^;|&\n]*[ \t]-[A-Za-z]*[lLcq])|sed\b(?![^;|&\n]*[ \t]-(?:[A-Za-z]*i|-in-place)))\b|(?:^|[\s;|&(])(?:cat|tac|head|tail|less|more|bat|nl|echo|printf|print)\b[^;|&\n]*\$[{(\w"]|\.read\(\)|readlines\(\)|\b(?:print|puts|console\.log|echo)\b[^;\n]{0,80}?(?:readFileSync\(|\.read_text\(|\bFile\.read\(|file_get_contents\()/;
402
424
  /**
403
425
  * A secret file printed by a route the pipeline reader doesn't follow: named to `find … -exec cat {}`, `xargs cat`, a
404
426
  * loop's variable, a substitution, a script's `open('.env').read()`, a `while read` loop's `< .env`. Taken as printed
@@ -485,6 +507,13 @@ function readsSecretFile(c) {
485
507
  const xargs = xargsInput(c, words);
486
508
  if (xargs !== undefined && (program === "xargs" || ECHOES.test(program)))
487
509
  return isSecretFile(xargs);
510
+ // (PowerShell's `Get-ChildItem Env:` and `Get-Item Env:NAME` print the variables as a table of names and values: all
511
+ // of them, or those a wildcard matches; one variable named in full is judged by its name, `Env:AZURE_TENANT_ID`.)
512
+ const drive = /^(?:Get-ChildItem|gci|dir|ls|Get-Item|gi)$/.test(program)
513
+ ? rest.find((t) => /^env:/i.test(t))
514
+ : undefined;
515
+ if (drive !== undefined)
516
+ return !/^env:\\?\w+$/i.test(drive) || isCredentialName(drive.replace(/^env:\\?/i, ""));
488
517
  if (!CONTENT_COMMANDS.test(program) || printsNamesOnly(words) || editsInPlace(words))
489
518
  return false;
490
519
  // (`env` here runs no command: simpleCommand reads past it to the one it runs. `env -0` prints the environment too;
@@ -502,9 +531,12 @@ function readsSecretFile(c) {
502
531
  // (Counting, listing and testing print none of what grep reads: `grep -c`, `grep -l`, `grep -q`.)
503
532
  if (/^(?:grep|egrep|fgrep|rg|ag|ack|zgrep)$/.test(program) && rest.some((t) => countsMatches(program, t)))
504
533
  return false;
534
+ // (A word with backslashes is read as written, a Windows path whatever else the line holds: PowerShell's `cat
535
+ // apps\web\.env.local`.)
536
+ const args = words.slice(1).map((w) => (/\\/.test(w.raw) && !/['"$`]/.test(w.raw) ? w.raw : w.text));
505
537
  const files = /^(?:grep|egrep|fgrep|rg|ag|ack|zgrep|Select-String|sls)$/.test(program)
506
- ? searchedFiles(rest)
507
- : rest.filter((t) => !t.startsWith("-"));
538
+ ? searchedFiles(args)
539
+ : args.filter((t) => !t.startsWith("-"));
508
540
  return files.some((t) => isSecretFile(t));
509
541
  }
510
542
  /**
@@ -603,6 +635,13 @@ function printsWhatItReads(command, reads, source) {
603
635
  }
604
636
  return false;
605
637
  }
638
+ /**
639
+ * Whether what a command prints goes into the next command's input: by its own `|`, or by a group's around it, whatever
640
+ * follows it inside the group (`{ echo A=1; echo B=2; } | tee .env`).
641
+ */
642
+ export function pipes(c) {
643
+ return [c.then, ...c.groups.map((g) => g.then)].some((then) => then === "|" || then === "|&");
644
+ }
606
645
  /** Devices that are what's shown: a tool's captured output holds stdout and stderr, and a terminal shows both. */
607
646
  const SHOWN = /^\/dev\/(?:stdout|stderr|tty|fd\/[12])$/;
608
647
  /** The file a command's output goes to, not to what's shown: `> out`, `>> log`, `&> /dev/null`, `>& out` (not `2>`, `>&2`, `> /dev/stderr`). */
@@ -646,7 +685,34 @@ function consumesInput(command) {
646
685
  }
647
686
  /** Whether a command prints a credential: any of its pipelines (`openssl rand -hex 32 && echo ok`) whose stage that makes one reaches its output. */
648
687
  export function printsSecret(command) {
649
- return printsWhatItReads(command, printsCredential);
688
+ return printsExpression(command) || printsWhatItReads(command, printsCredential);
689
+ }
690
+ /**
691
+ * A command line that is one expression, which PowerShell prints (as a REPL does; a POSIX shell runs a variable's value
692
+ * as a command and prints it in its error): a variable, `$env:NAME`, its members (`$cred.GetNetworkCredential().Password`),
693
+ * or a command's in parentheses (`(Get-Item Env:TOKEN).Value`).
694
+ */
695
+ const EXPRESSION = /^[ \t]*(?:\$\{?(?:env:)?[A-Za-z_]\w*\}?|\([^()\n]{1,200}\))(?:\.[A-Za-z_]\w*(?:\(\))?)*[ \t]*$/i;
696
+ /**
697
+ * Whether a command line is an expression (EXPRESSION) that prints a credential: by its last member's name, or with
698
+ * none, or a `Value` (an entry's), by its variables' (`$cred.UserName` is a name; `$env:TOKEN`, `(Get-Item
699
+ * Env:TOKEN).Value` are the token).
700
+ */
701
+ function printsExpression(command) {
702
+ if (!EXPRESSION.test(command))
703
+ return false;
704
+ const member = /\.([A-Za-z_]\w*)(?:\(\))?[ \t]*$/.exec(command)?.[1];
705
+ // (A dictionary's keys are its names, `$hash.Keys`, `(Get-Command x).Parameters.Keys`, and an entry's `$_.Key`.)
706
+ if (member !== undefined && !/^value$/i.test(member))
707
+ return !/^(?:all)?keys?$/i.test(member) && isCredentialName(member);
708
+ return variablesIn(command).some(isCredentialName);
709
+ }
710
+ /**
711
+ * The variables a text reads, by their names, in any shell's syntax: `$NAME`, `${NAME}`, PowerShell's `$env:NAME`,
712
+ * `${env:NAME}` and its drive's `Env:NAME`, cmd's `%NAME%`.
713
+ */
714
+ function variablesIn(text) {
715
+ return [...text.matchAll(/\$\{?(?:env:)?([A-Za-z_]\w*)|%([A-Za-z_]\w*)%|\benv:([A-Za-z_]\w*)/gi)].map((m) => m[1] ?? m[2] ?? m[3]);
650
716
  }
651
717
  function passesThrough(c) {
652
718
  const words = simpleCommand(c.words);
@@ -670,13 +736,17 @@ function printsCredential(c) {
670
736
  const words = simpleCommand(c.words);
671
737
  // (Read from the program's own name on: `/usr/bin/gh auth token` is `gh auth token`.)
672
738
  const line = [programName(words[0]), ...words.slice(1).map((w) => w.text)].join(" ").slice(0, MAX_TOOL_LINE);
673
- if (SECRET_TOOLS.test(line) || SECRET_SOURCES.test(line))
739
+ if (SECRET_TOOLS.test(line) || SECRET_SOURCES.test(line) || PRINTED_SOURCE.test(line))
674
740
  return true;
675
741
  const program = programName(words[0]);
676
742
  const args = words.slice(1);
677
743
  if (program === "printenv")
678
744
  return args.length === 1 && isCredentialName(args[0].text);
679
- if (program !== "echo" && program !== "printf")
745
+ // (A script given on the command line that prints a credential's variable: `python3 -c "print(os.environ['TOKEN'])"`,
746
+ // `node -e "console.log(process.env.TOKEN)"`.)
747
+ if (INTERPRETER.test(program))
748
+ return [...line.matchAll(PRINTED_ENV_READ)].some((m) => isCredentialName(m[1] ?? m[2]));
749
+ if (program !== "echo" && program !== "printf" && !/^write-(?:host|output)$/i.test(program))
680
750
  return false;
681
751
  // (printf prints its format and its operands alike, `printf "Token: %s\n" "$TOKEN"`, `printf "$TOKEN"`; but `-v NAME`
682
752
  // assigns what it would print and prints nothing.)
@@ -685,9 +755,18 @@ function printsCredential(c) {
685
755
  : args[0]?.text === "-v"
686
756
  ? []
687
757
  : args.filter((w) => w.text !== "--");
688
- // (A credential's variable printed alone, `echo $DB_PASSWORD`, or inside its words, `echo "token: $GITHUB_TOKEN"`.)
689
- return values.some((v) => !v.literalEnd && [...v.text.matchAll(/\$\{?([A-Za-z_]\w*)\}?/g)].some((m) => isCredentialName(m[1])));
758
+ // (A credential's variable printed alone, `echo $DB_PASSWORD`, `echo %DB_PASSWORD%`, or inside its words, `echo
759
+ // "token: $GITHUB_TOKEN"`.)
760
+ return values.some((v) => !v.literalEnd && variablesIn(v.text).some(isCredentialName));
690
761
  }
762
+ /** Interpreters that run a script given on their command line. */
763
+ const INTERPRETER = /^(?:python[\d.]*|node|deno|bun|ruby|perl|php)$/;
764
+ /**
765
+ * A read of an environment variable in those scripts, by its name, that their print takes: `print(os.environ['TOKEN'])`,
766
+ * `console.log("token", process.env.TOKEN)`. (A script that reads one to use it prints what it does with it: a login
767
+ * script's `type(process.env.PW)` and its `console.log("submitted")`.)
768
+ */
769
+ const PRINTED_ENV_READ = /\b(?:print|puts|console\.log|echo|say|process\.stdout\.write)\b[^;\n]{0,80}?(?:(?:os\.environ(?:\.get)?|os\.getenv|\bENV(?:\.fetch)?|\bgetenv|\$_ENV|\$ENV)\s*[[({]\s*["']([A-Za-z_]\w*)["']|process\.env\.([A-Za-z_]\w*))/g;
691
770
  /** Tools by name, and how each takes a password (structure.ts reads them). */
692
771
  const TOOLS = [
693
772
  [
package/dist/context.d.ts CHANGED
@@ -34,7 +34,7 @@ export declare class Doc {
34
34
  * as pem.ts reads a block (to its END, or where its material stops when output is cut short: `head -3 cert.pem`).
35
35
  */
36
36
  inPublicArmor(at: number): boolean;
37
- /** The cell of an SQL `INSERT` row whose value holds `[start, end)`, read as sql.ts reads rows. */
37
+ /** The cell of an SQL row (`INSERT`, `COPY`) whose value holds `[start, end)`, read as sql.ts reads rows. */
38
38
  sqlCell(start: number, end: number): SqlCell | undefined;
39
39
  /**
40
40
  * The first line of the comma-separated block that line `i` is in: the lines above it with as many cells, up to
@@ -64,7 +64,10 @@ export interface Context {
64
64
  * on the line: "your new API key for the git server: <t>".
65
65
  */
66
66
  hashPhrase: boolean;
67
- /** The previous URL path segment, for `/hooks/<token>`, `/webhook/<token>`. */
67
+ /**
68
+ * The previous URL path segment, for `/hooks/<token>`, `/webhook/<token>`; in a URL, or a request's target after its
69
+ * method (`POST /hooks/<token> HTTP/1.1`).
70
+ */
68
71
  pathParent: string;
69
72
  /** A phrase that hands over a value: "here's your key", "Token created:", "use this one". */
70
73
  handover: boolean;
package/dist/context.js CHANGED
@@ -7,7 +7,7 @@ import { lastText, lowerBound } from "./lines.js";
7
7
  import { isGenericName, mentionsCredential, nameClass, phraseBefore, readPhrase } from "./names.js";
8
8
  import { inJwk } from "./json.js";
9
9
  import { publicBlocks } from "./publickeys.js";
10
- import { insertCells } from "./sql.js";
10
+ import { sqlCells } from "./sql.js";
11
11
  import { backslashesBefore, delimitedCells, isEscapedBreak, PAIR_VALUE, PIPE_RULE, pipeCells, siblingName, STRING_PREFIX, } from "./syntax.js";
12
12
  import { PARAM_NAME, PARAM_START, urlAround } from "./urls.js";
13
13
  /** How far a line is read either side of a token, and the longest line read whole for its structure (tables, blobs). */
@@ -90,11 +90,11 @@ export class Doc {
90
90
  const k = lowerBound(this.armor.begins, at + 1) - 1;
91
91
  return k >= 0 && at < this.armor.ends[k];
92
92
  }
93
- /** The cell of an SQL `INSERT` row whose value holds `[start, end)`, read as sql.ts reads rows. */
93
+ /** The cell of an SQL row (`INSERT`, `COPY`) whose value holds `[start, end)`, read as sql.ts reads rows. */
94
94
  sqlCell(start, end) {
95
95
  if (!this.cells) {
96
96
  // (Values in the order they appear, so their starts are sorted.)
97
- this.cells = [...insertCells(this.text)].sort((a, b) => a.value.start - b.value.start);
97
+ this.cells = [...sqlCells(this.text)].sort((a, b) => a.value.start - b.value.start);
98
98
  this.cellStarts = this.cells.map((c) => c.value.start);
99
99
  }
100
100
  const cell = this.cells[lowerBound(this.cellStarts, start + 1) - 1];
@@ -156,7 +156,15 @@ const XML_ATTR = /\b([\w:.-]{1,80})=["']$/;
156
156
  const CALL = new RegExp(String.raw `(?<![\w$.])([A-Za-z_$][\w$]{0,60}(?:\.[A-Za-z_$][\w$]{0,60}){0,4})[ \t]{0,2}\((?:[^()]{0,200}?,)?[ \t]{0,4}(?:[A-Za-z_]\w{0,30}[ \t]{0,2}=[ \t]{0,2})?(?:(?:${STRING_PREFIX})?["'\x60])?$`);
157
157
  const PROSE = /\b([A-Za-z][\w-]{0,30})(?:[ \t]{1,3}(?:is|was|=|:)[ \t]{0,3}|:[ \t]{1,3}|[ \t]{1,3}(?:of|for)[ \t][^:]{0,40}:[ \t]{0,3})["'`*]{0,2}$/;
158
158
  const TABLE = /^[ \t│|]{0,10}([A-Za-z_][\w.-]{0,80})(?:[ \t]{2,80}|[ \t]{0,80}[│|][ \t]{0,10})$/;
159
- const HASH_PREFIX = /(?:^|[\s;])bh=$|pin-sha256="?$|(?<![A-Za-z0-9])(?:sha(?:1|224|256|384|512)|md5|blake2b?|blake3|crc32|h1|integrity|xxh\d*)[:=\-][ \t]?$|@sha256:$|\bsha(?:1|256|512)sum\b.{0,40}$|\b(?:commit|tree|parent|object|blob|merge)[ \t]$|\bcherry picked from commit $|\bindex $|(?:^|\n)Index: [^\s=:]{1,100}$/i;
159
+ /** A hash algorithm's name: `sha256`, `sha-256` (HTTP's), `md5`, `blake3`. */
160
+ const HASH_ALGORITHM = String.raw `(?:sha-?(?:1|224|256|384|512)|md5|blake2b?|blake3|crc32|xxh\d*)`;
161
+ /**
162
+ * A hash's or a digest's name right before the token. (HTTP's digest fields, RFC 9530, write the digest as a structured
163
+ * field's byte sequence, `Content-Digest: sha-256=:<base64>:`; tools write the algorithm or the encoding in brackets
164
+ * after a label, `Hash (sha256): `, `Checksum (base64): `; BSD's checksum tools the file's name after the algorithm,
165
+ * `SHA256 (app.tar.gz) = `.)
166
+ */
167
+ const HASH_PREFIX = new RegExp(String.raw `(?:^|[\s;])bh=$|pin-sha256="?$|(?<![A-Za-z0-9])(?:${HASH_ALGORITHM}|h1|integrity)[:=\-]:?[ \t]?$|(?<![A-Za-z0-9])(?:hash|checksum|digest|${HASH_ALGORITHM})[ \t]*\([^()\n]{1,100}\)[ \t]?[:=][ \t]?$|@sha256:$|\bsha(?:1|256|512)sum\b.{0,40}$|\b(?:commit|tree|parent|object|blob|merge)[ \t]$|\bcherry picked from commit $|\bindex $|(?:^|\n)Index: [^\s=:]{1,100}$`, "i");
160
168
  /** The text before the last `name` in `left` (none when the name came from elsewhere, a sibling or a header). */
161
169
  function textBefore(left, name) {
162
170
  const at = left.lastIndexOf(name);
@@ -194,6 +202,18 @@ function isBlobLine(doc, start, end, i) {
194
202
  }
195
203
  return true;
196
204
  }
205
+ /** A line holding one token alone, or as a list's item: `- Kx9mQ2…`. */
206
+ const LONE_TOKEN_LINE = /^[ \t]*(?:[-*•][ \t]+)?["'`]?[A-Za-z0-9+/=_.-]{16,}["'`]?[ \t]*\r?$/;
207
+ /**
208
+ * The line above line `i`, or above the lines of lone tokens line `i` ends (keys handed over one per line: "Here are
209
+ * your new API keys:" over three of them).
210
+ */
211
+ function listHead(doc, i, above) {
212
+ let k = i - 1;
213
+ while (k > 0 && k >= i - 20 && LONE_TOKEN_LINE.test(doc.line(k)))
214
+ k--;
215
+ return k < i - 1 ? doc.line(k) : above;
216
+ }
197
217
  /**
198
218
  * The name the list that line `i` is an item of sits under (`api_keys:` above `- …` lines, `"keys": [` above `"…",`
199
219
  * lines), read over its other items, blank lines and comments; undefined if none. (A line that only names: `Your API
@@ -300,8 +320,10 @@ function nameFromLeft(left, url) {
300
320
  const trimmed = left.trimStart();
301
321
  if (trimmed === "" || /^[-*•>+][ \t]*["'`]?$/.test(trimmed) || /^["'`]$/.test(trimmed))
302
322
  return { name: "", syntax: /^[-*•]/.test(trimmed) ? "list" : "bare" };
303
- // `Authorization: Bearer <token>`: the scheme word sits between name and value.
304
- if ((m = /^[ \t]{0,8}([A-Za-z][\w-]{1,60}):[ \t]{1,4}(?:[A-Za-z][\w-]{0,20}[ \t]{1,3})?$/.exec(left)))
323
+ // `Authorization: Bearer <token>`: the scheme word sits between name and value; or parameters and version tags do,
324
+ // `webhook-signature: v1,<sig>`, `Stripe-Signature: t=…,v1=<sig>`.
325
+ if ((m =
326
+ /^[ \t]{0,8}([A-Za-z][\w-]{1,60}):[ \t]{1,4}(?:[A-Za-z][\w-]{0,20}[ \t]{1,3}|(?:[\w-]{1,20}(?:=[^\s,;]{0,40})?[,;][ \t]*)+(?:[\w-]{1,20}=)?)?$/.exec(left)))
305
327
  return { name: m[1], syntax: /^[A-Z]/.test(m[1]) || /-/.test(m[1]) ? "header" : "assign" };
306
328
  if ((m = FLAG.exec(left)))
307
329
  return { name: m[1].replace(/^-+/, ""), syntax: "flag" };
@@ -358,6 +380,9 @@ export function contextOf(doc, start, end) {
358
380
  // An item of a list under a name (`api_keys:` then `- <key>` lines): the list's name.
359
381
  if (!name && /^[ \t]*(?:[-*•][ \t]*["'`]?|["'`])$/.test(left))
360
382
  name = listName(doc, i) ?? "";
383
+ // A request's target after its method, as access logs and HTTP dumps print it (`"POST /hooks/<token> HTTP/1.1"`): a
384
+ // URL's path, not a file's.
385
+ const requestPath = !inUrl && /\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)[ \t]+\/[^\s"]*$/.test(left);
361
386
  const fsPath = !inUrl &&
362
387
  (/(?:^|[\s"'`=:(,\[])(?:~|\.{1,2}|[A-Za-z]:)?(?:[\\/][\w.@+-]{1,80}){1,30}[\\/]$/.test(left) ||
363
388
  (/[\\/]$/.test(left) && /^[\\/][\w.-]/.test(right)));
@@ -436,8 +461,10 @@ export function contextOf(doc, start, end) {
436
461
  const before = phraseBefore(textBefore(left, name), 4);
437
462
  ({ name, class: cls } = readPhrase(before, name));
438
463
  }
439
- const pathParent = inUrl ? (/\/([^/\s?#]{1,60})\/$/.exec(left)?.[1] ?? "") : "";
440
- const leftAndAbove = identifierWords(`${above}\n${left}`);
464
+ const pathParent = inUrl || requestPath ? (/\/([^/\s?#]{1,60})\/$/.exec(left)?.[1] ?? "") : "";
465
+ // (A query parameter's credential words are its own name's: `?api_key=…&profile=<id>` says nothing of the profile.)
466
+ const ownLeft = syntax === "query" && name ? left.replace(/[?&][^?&\s"'=]+=[^&\s"']*(?=&[^&]*$)/g, "") : left;
467
+ const leftAndAbove = identifierWords(`${above}\n${ownLeft}`);
441
468
  return {
442
469
  name,
443
470
  nameClass: cls,
@@ -452,7 +479,7 @@ export function contextOf(doc, start, end) {
452
479
  pathParent,
453
480
  sqlCell,
454
481
  handover: HANDOVER.test(identifierWords(left.slice(-200))) ||
455
- (left.trim() === "" && HANDOVER.test(identifierWords(above.slice(-200)))),
482
+ (left.trim() === "" && HANDOVER.test(identifierWords(listHead(doc, i, above).slice(-200)))),
456
483
  inUrl,
457
484
  fsPath,
458
485
  armored: armoredAt(doc, start, left),
package/dist/encoded.js CHANGED
@@ -8,6 +8,7 @@ import { naturalness } from "./charlm.js";
8
8
  import { lineEnd, lineStart } from "./lines.js";
9
9
  import { asciiSet, longRuns } from "./runs.js";
10
10
  import { keepReason } from "./policy.js";
11
+ import { isJsonDocument } from "./secretfile.js";
11
12
  import { hasThreeClasses } from "./values.js";
12
13
  const UTF8 = new TextDecoder("utf-8");
13
14
  /** The share of characters that must be printable for bytes to count as text, and of zero bytes for UTF-16. */
@@ -132,6 +133,10 @@ function isGeneratedToken(token) {
132
133
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token) ||
133
134
  /^[\w.-]+@[\w.-]+$|^https?:/.test(token))
134
135
  return false;
136
+ // (Nor a JSON object or array, which its quotes and braces give three classes: a JWT's header on a line of its own,
137
+ // `cut -d. -f1` of a token, is `{"alg":"HS256","typ":"JWT"}`.)
138
+ if (isJsonDocument(token))
139
+ return false;
135
140
  if (keepReason(token, { start: 0, end: token.length, kind: "random token", rule: "unnamed", confidence: 0.8 }) !==
136
141
  undefined)
137
142
  return false;
@@ -245,10 +250,11 @@ function isLineAlone(text, start, end) {
245
250
  */
246
251
  export function findHexDumps(text, inner) {
247
252
  const out = [];
248
- if (!/[0-9a-f]{4}/i.test(text))
253
+ if (!/[0-9a-f]{4}|[0-9a-f]{2}[ \t][0-9a-f]{2}[ \t][0-9a-f]{2}/i.test(text))
249
254
  return out;
250
- // Offset-prefixed dumps: runs of lines `<offset>[:] <hex groups> [ascii]`.
251
- for (const m of text.matchAll(/(?:^[0-9a-f]{6,8}:?(?:[ \t]{1,4}[0-9a-f]{2,4}){1,16}[^\n]{0,80}(?:\n|$)){1,100000}/gim)) {
255
+ // Offset-prefixed dumps: runs of lines `<offset>[:] <hex groups> [ascii]`. (Groups of any whole number of bytes up to
256
+ // 16, and any number of them with an ASCII column of any width: `xxd -g 8`, `xxd -c 32 -g1`.)
257
+ for (const m of text.matchAll(/(?:^[0-9a-f]{6,8}:?(?:[ \t]{1,4}(?:[0-9a-f]{2}){1,16})[^\n]*(?:\n|$)){1,100000}/gim)) {
252
258
  const words = [];
253
259
  let bytes = "";
254
260
  const lines = m[0].split("\n").filter((l) => l);
@@ -260,7 +266,7 @@ export function findHexDumps(text, inner) {
260
266
  const body = line.replace(/^[0-9a-f]{6,8}:?[ \t]+/i, "");
261
267
  let digits = 0;
262
268
  for (const t of body.split(/[ \t]+/)) {
263
- if (!/^(?:[0-9a-f]{2}|[0-9a-f]{4}|[0-9a-f]{8})$/i.test(t) || digits + t.length > step * 2)
269
+ if (!/^(?:[0-9a-f]{2}){1,16}$/i.test(t) || digits + t.length > step * 2)
264
270
  break;
265
271
  if (t.length === 4)
266
272
  words.push(t.slice(2) + t.slice(0, 2));
@@ -306,14 +312,15 @@ export function findHexDumps(text, inner) {
306
312
  runStart = -1;
307
313
  lines = 0;
308
314
  };
309
- for (const m of text.matchAll(/^(?:[0-9a-f]{2}){1,100}\r?$/gim)) {
315
+ // (Also bytes apart, with no offsets: `od -An -tx1`'s ` 44 42 5f …`.)
316
+ for (const m of text.matchAll(/^[ \t]*(?:[0-9a-f]{2}[ \t]?){1,100}\r?$/gim)) {
310
317
  if (runStart >= 0 && m.index === runEnd + 1) {
311
318
  runEnd = m.index + m[0].length;
312
319
  lines++;
313
320
  }
314
321
  else {
315
322
  flush();
316
- if (m[0].trimEnd().length < 16)
323
+ if (m[0].replace(/\s+/g, "").length < 16)
317
324
  continue;
318
325
  runStart = m.index;
319
326
  runEnd = m.index + m[0].length;