redac---n 0.0.4 → 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 +12 -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/dist/stream.d.ts CHANGED
@@ -25,7 +25,8 @@ export declare class RedactionStream {
25
25
  /** Values hidden earlier in the stream, looked for again however far back they were named, as `redact()` sees the whole text. */
26
26
  private remembered;
27
27
  private readonly redactor;
28
- private readonly options;
28
+ /** The options each scan runs with: the hint's command dropped once a prompt is released (see advance). */
29
+ private options;
29
30
  constructor(redactor: Redactor, options: DetectOptions & {
30
31
  holdback?: number;
31
32
  });
package/dist/stream.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { occurrences, repeatable } from "./repeats.js";
2
- import { openScopes } from "./secretfile.js";
2
+ import { endsInBlock } from "./pem.js";
3
+ import { firstPrompt, openScopes } from "./secretfile.js";
3
4
  import { openManifestData } from "./structure.js";
4
5
  import { needsNormalizing, normalize } from "./normalize.js";
5
6
  import { merge, secretValue } from "./spans.js";
@@ -30,6 +31,7 @@ export class RedactionStream {
30
31
  /** Values hidden earlier in the stream, looked for again however far back they were named, as `redact()` sees the whole text. */
31
32
  remembered = new Map();
32
33
  redactor;
34
+ /** The options each scan runs with: the hint's command dropped once a prompt is released (see advance). */
33
35
  options;
34
36
  constructor(redactor, options) {
35
37
  this.redactor = redactor;
@@ -75,9 +77,13 @@ export class RedactionStream {
75
77
  flush(all) {
76
78
  const head = this.head();
77
79
  const left = head.length;
78
- // (Up to the buffer's last line break, but at the end: no detector sees a line cut short that the whole text wouldn't.)
80
+ // (Up to the buffer's last line break, but at the end: no detector sees a line cut short that the whole text wouldn't.
81
+ // Unless that break is inside a private key whose END line has no break after it, `…-----END RSA PRIVATE KEY----- {"a":…`:
82
+ // the key is read to its END, and what follows on that line, cut short, is held back with it.)
79
83
  const lineEnd = all ? -1 : this.buffer.lastIndexOf("\n");
80
- const whole = head + (lineEnd < 0 ? this.buffer : this.buffer.slice(0, lineEnd + 1));
84
+ let whole = head + (lineEnd < 0 ? this.buffer : this.buffer.slice(0, lineEnd + 1));
85
+ if (lineEnd >= 0 && endsInBlock(whole))
86
+ whole = head + this.buffer;
81
87
  // An unfinished line, and a Secret's data whose `kind` hasn't come yet, are held until they are whole.
82
88
  let cut = this.releasePoint(all);
83
89
  if (cut <= 0 || (!all && this.buffer.length < MAX_HELD && openManifestData(whole)?.pending)) {
@@ -132,6 +138,12 @@ export class RedactionStream {
132
138
  const raw = this.head() + out;
133
139
  const seen = needsNormalizing(raw) ? normalize(raw).text : raw;
134
140
  this.scopes = [...openScopes(seen), ...(openManifestData(seen)?.lines ?? [])];
141
+ // A command's output runs to the next prompt in it, as `redact()` reads the whole text (secretfile.ts firstPrompt):
142
+ // once one is released, what comes after is another command's, and the hint's command no longer says what it is,
143
+ // however far behind the prompt has scrolled.
144
+ const hint = this.options.hint;
145
+ if (hint?.command !== undefined && firstPrompt(seen) < seen.length)
146
+ this.options = { ...this.options, hint: { ...hint, command: undefined } };
135
147
  // The context starts at a line's start, as the whole text's lines do (a piece of a line would read as one).
136
148
  const joined = this.context + out;
137
149
  this.context = joined.slice(-CONTEXT);
package/dist/structure.js CHANGED
@@ -10,7 +10,7 @@ import { lineEnd, lineStart } from "./lines.js";
10
10
  import { armoredBlocks, pemTail } from "./pem.js";
11
11
  import { inJwk, jsonDocuments } from "./json.js";
12
12
  import { blockLines, siblingValue, yamlLines } from "./yaml.js";
13
- import { isUrl, queryParams, readUserinfo, URL_SCHEME, urls } from "./urls.js";
13
+ import { isUrl, PARAM_START, queryParams, readUserinfo, URL_SCHEME, urls } from "./urls.js";
14
14
  import { naturalness } from "./charlm.js";
15
15
  import { bip39, isNamedSecret } from "./named.js";
16
16
  import { FILE_EXTENSION, isCredentialName, isSettingName, nameClass, namesPasswordOrPin } from "./names.js";
@@ -58,10 +58,13 @@ function armoredBase64(body) {
58
58
  .join("")
59
59
  .replace(/[^A-Za-z0-9+/=]/g, "");
60
60
  }
61
- /** PuTTY private keys: the `Private-Lines:` section. OpenSSL `-text` dumps: `priv:` and prime sections. */
61
+ /**
62
+ * PuTTY private keys: the `Private-Lines:` section, indented too (a .ppk pasted in a Markdown code block) and with
63
+ * trailing blanks. OpenSSL `-text` dumps: `priv:` and prime sections.
64
+ */
62
65
  function otherPrivateKeys(text, out) {
63
66
  if (text.includes("Private-Lines:")) {
64
- for (const m of text.matchAll(/Private-Lines: \d{1,3}\r?\n((?:[A-Za-z0-9+/=]{4,80}(?:\r?\n|$)){1,200})/g)) {
67
+ for (const m of text.matchAll(/Private-Lines: \d{1,3}[ \t]*\r?\n[ \t]*((?:[A-Za-z0-9+/=]{4,80}[ \t]*(?:\r?\n[ \t]*|$)){1,200})/g)) {
65
68
  const start = m.index + m[0].length - m[1].length;
66
69
  out.push({
67
70
  start,
@@ -94,6 +97,24 @@ function otherPrivateKeys(text, out) {
94
97
  out.push({ start, end, kind: "private key", rule: "structure:jwk", confidence: 0.95 });
95
98
  }
96
99
  }
100
+ // The same members in YAML (a Helm values file's `jwks:` list), unquoted, in a mapping that has a `kty`.
101
+ if (/^[ \t]*(?:-[ \t]+)?kty:/m.test(text)) {
102
+ const lines = yamlLines(text);
103
+ lines.forEach((l, i) => {
104
+ const v = l.value?.value ?? "";
105
+ if (!l.key || !/^(?:d|p|q|dp|dq|qi|k)$/.test(l.key) || !/^[A-Za-z0-9_-]{16,}$/.test(v) || isWordsValue(v))
106
+ return;
107
+ if (siblingValue(lines, i, "kty") === undefined)
108
+ return;
109
+ out.push({
110
+ start: l.value.start,
111
+ end: l.value.end,
112
+ kind: "private key",
113
+ rule: "structure:jwk",
114
+ confidence: 0.95,
115
+ });
116
+ });
117
+ }
97
118
  }
98
119
  /** A name in docs' brackets: `<password>`, `[YOUR_PASSWORD]`, `{db pass}`; not a password left in them, `<Harbor2291x>`. */
99
120
  function isBracketedName(value) {
@@ -128,11 +149,12 @@ export function isLiteralPassword(value, quoted = false) {
128
149
  if (/^\{[^{}\n]{1,200}\}$|^\\\([^()\n]{1,200}\)$/.test(value) && /[A-Za-z_]/.test(value))
129
150
  return false;
130
151
  // `$DB_PASSWORD`-style names, a letter in each part, not all-caps passwords like `WINTER2024PASS`, `SUMMER_2024`
131
- // or `WINTERPASS` (a placeholder word in capitals is read below). (Templates with holes, `${DB_PASS}`, `{{ pw }}`,
132
- // `<password>`, are placeholders above when nothing secret is around their holes.) A `$(…)` even in single quotes:
133
- // a Makefile's `-p'$(DB_PASSWORD)'` is make's variable, expanded before the shell runs.
152
+ // or `WINTERPASS` (a placeholder word in capitals is read below). A `$` and a name is a variable when the name reads
153
+ // as one (`$dbPassword`), not a generated password that starts with a symbol (`$Zq8mK2vXp4`). (Templates with holes,
154
+ // `${DB_PASS}`, `{{ pw }}`, `<password>`, are placeholders above when nothing secret is around their holes.) A
155
+ // `$(…)` even in single quotes: a Makefile's `-p'$(DB_PASSWORD)'` is make's variable, expanded before the shell runs.
134
156
  if (/^[A-Z]+(?:_[A-Z0-9]*[A-Z][A-Z0-9]*)+$|^_[A-Z0-9_]+$/.test(value) ||
135
- (!quoted && /^\$[A-Za-z_]\w*$/.test(value)) ||
157
+ (!quoted && /^\$[A-Za-z_]\w*$/.test(value) && isNameLike(value.slice(1))) ||
136
158
  /^\$\(.*\)$/.test(value) ||
137
159
  (/^[a-z_][A-Za-z_]{2,}\(.*\)$/.test(value) && readsAsWords(value.slice(0, value.indexOf("(")))))
138
160
  return false;
@@ -257,9 +279,11 @@ function authorization(text, out) {
257
279
  continue;
258
280
  if (/^aws4-hmac/i.test(scheme ?? ""))
259
281
  continue; // handled by the Signature= rule
260
- // Closing quotes, brackets and sentence punctuation aren't part of the value.
261
- let value = m[2].replace(/[`'")\].,;:]+$/, "");
262
- let start = m.indices[2][0];
282
+ // Quotes and brackets around it and sentence punctuation after it aren't part of the value, as the judge above
283
+ // reads it: Go prints a header as `Authorization:[<value>]`, Markdown as Authorization: `<value>`.
284
+ const opening = /^[`'"(\[]*/.exec(m[2])[0].length;
285
+ let value = m[2].slice(opening).replace(/[`'")\].,;:]+$/, "");
286
+ let start = m.indices[2][0] + opening;
263
287
  if (/^basic$/i.test(scheme ?? "") && isPlaceholderBasic(value))
264
288
  continue;
265
289
  // Azure's `SharedKey <account>:<signature>`: the account is a name.
@@ -347,7 +371,7 @@ export function isPlaceholderBasic(b64) {
347
371
  /** Signatures and tokens in signed URLs (S3/GCS presigned, Azure SAS, CloudFront) and credentials in query strings and form bodies. */
348
372
  function signedUrls(text, out) {
349
373
  const NAMES = String.raw `X-Amz-Signature|X-Amz-Security-Token|X-Goog-Signature|X-Goog-Credential|sig|Signature|access_token|id_token|refresh_token|auth_token|token|apikey|api_key|key|client_secret|password|code|appid|app_key|private_token|access_key|sasl_password|db_password|secret|passwd|pwd`;
350
- if (!new RegExp(String.raw `[?&](?:${NAMES})=`, "i").test(text))
374
+ if (!new RegExp(String.raw `${PARAM_START}(?:${NAMES})=`, "i").test(text))
351
375
  return;
352
376
  for (const { name: n, value } of queryParams(text, NAMES)) {
353
377
  const name = n.toLowerCase();
@@ -387,11 +411,16 @@ export function isSessionCookie(name, value) {
387
411
  // Analytics and preference cookies: `_ga=GA1.1.35…`, `_gid`, `_fbp`, consent, locale.
388
412
  return !(ANALYTICS_COOKIE.test(name) || /^(?:GA|GS)\d\.\d\.|^fb\.\d\.|^-?[\d.]+$/.test(value));
389
413
  }
390
- /** Google's app passwords, as Google writes them: `Your app password for your device: qwhz kfmt vbnr xplo`. */
414
+ /**
415
+ * Google's app passwords, as Google writes them: `Your app password for your device: qwhz kfmt vbnr xplo`. (Sixteen
416
+ * random letters: four words of four letters are the sentence going on, `app password: this will work with most…`.)
417
+ */
391
418
  function appPasswords(text, out) {
392
419
  if (!/app[ -]?password/i.test(text))
393
420
  return;
394
421
  for (const m of text.matchAll(/app[ -]?password\b[^\n:]{0,60}:[ \t]*["'`*]{0,2}([a-z]{4} [a-z]{4} [a-z]{4} [a-z]{4})\b/gi)) {
422
+ if (m[1].split(" ").every((word) => readsAsWords(word)))
423
+ continue;
395
424
  const start = m.index + m[0].length - m[1].length;
396
425
  out.push({ start, end: start + m[1].length, kind: "password", rule: "structure:app-password", confidence: 0.95 });
397
426
  }
@@ -481,7 +510,9 @@ function connectionStrings(text, out) {
481
510
  }
482
511
  /**
483
512
  * Base64 that decodes to something public or trivial: a certificate, a public key; a word, a boolean, a number or a
484
- * URL without credentials, unless the key says password or PIN (`admin-pin: NDgyMTc3MzA=`).
513
+ * URL without credentials, unless the key says password or PIN (`admin-pin: NDgyMTc3MzA=`). (Three words or more
514
+ * joined are a passphrase but under a setting's key, as isHarmlessText judges them written plain: under `redis` or
515
+ * `admin`, `lamp-otter-marble-quill` is one; `sslmode: require` is a setting.)
485
516
  */
486
517
  function harmlessBase64(v, key = "") {
487
518
  const d = /^[A-Za-z0-9+/]+={0,2}$/.test(v) && v.length % 4 === 0 ? base64Binary(v) : undefined;
@@ -491,7 +522,11 @@ function harmlessBase64(v, key = "") {
491
522
  return true;
492
523
  const t = d.trim();
493
524
  return (!namesPasswordOrPin(key) &&
494
- ((d.length < 40 && (isWordsValue(t) || /^(?:true|false|\d+)$/.test(t) || HOST_NAME.test(t))) || isPlainUrl(t, key)));
525
+ ((d.length < 40 &&
526
+ ((isWordsValue(t) && (isSettingName(key) || t.split(/[_-]/).length < 3)) ||
527
+ /^(?:true|false|\d+)$/.test(t) ||
528
+ HOST_NAME.test(t))) ||
529
+ isPlainUrl(t, key)));
495
530
  }
496
531
  /**
497
532
  * A URL that carries no credential: no password, no path segment or parameter value that a generator made, and no
@@ -712,14 +747,17 @@ function toolPasswords(words, add) {
712
747
  addWord(rest[at + 2], rest[at + 2].text);
713
748
  return;
714
749
  }
715
- // `vault login <token>`, `vault login -method=userpass username=u password=pw`.
750
+ // `vault login <token>`, `vault login -method=userpass username=u password=pw`. The token is the one word without
751
+ // `=`: more such words are the sentence a changelog goes on with (`fix regression in vault login command that caused
752
+ // login to fail`) or a usage line (`vault login TOKEN [CONFIG K=V...]`), and a bracket opens a link or a usage's slot.
716
753
  if (name === "vault" && rest[0]?.text === "login") {
754
+ const operands = rest.slice(1).filter((w) => !w.text.startsWith("-") && !w.text.includes("="));
755
+ if (operands.length === 1 && !/^[[<(]/.test(operands[0].text))
756
+ addWord(operands[0], operands[0].text);
717
757
  for (const w of rest.slice(1)) {
718
758
  const pair = /^(?:token|password)=(.+)$/.exec(w.text);
719
759
  if (pair)
720
760
  addWord(w, pair[1]);
721
- else if (!w.text.startsWith("-") && !w.text.includes("="))
722
- addWord(w, w.text);
723
761
  }
724
762
  return;
725
763
  }
@@ -778,9 +816,9 @@ function toolPasswords(words, add) {
778
816
  addWord(pw, pw.text);
779
817
  return;
780
818
  }
781
- if (/^(?:http|https|httpie)$/.test(name)) {
782
- // HTTPie: `-a user:password` (the part after the colon, unless it's the docs' `username:password`; `-a user`
783
- // alone prompts), or with `-A bearer` the whole `-a` argument.
819
+ if (/^(?:http|https|httpie|xh|xhs|hey)$/.test(name)) {
820
+ // HTTPie and its clone xh: `-a user:password` (the part after the colon, unless it's the docs' `username:password`;
821
+ // `-a user` alone prompts), or with `-A bearer` the whole `-a` argument. hey takes `-a user:password` too.
784
822
  const bearer = rest.some((w, i) => /^(?:-A|--auth-type)$/.test(w.text) && /^bearer$/i.test(rest[i + 1]?.text ?? "")) ||
785
823
  rest.some((w) => /^--auth-type=bearer$/i.test(w.text));
786
824
  for (let i = 0; i < rest.length; i++) {
@@ -872,7 +910,10 @@ function stdinFeeds(text) {
872
910
  const herestring = c.redirects.find((r) => r.op === "<<<")?.target;
873
911
  if (herestring)
874
912
  out.push({ value: herestring, reader: c });
875
- const words = simpleCommand(c.words);
913
+ // (The echo may come after a line's own prefix, which a shell doesn't run: a workflow's `- run: echo …`, a
914
+ // Dockerfile's `RUN echo …`.)
915
+ const all = simpleCommand(c.words);
916
+ const words = all.slice(Math.max(0, all.findIndex((w) => /^(?:echo|printf)$/.test(programName(w)))));
876
917
  const program = programName(words[0]);
877
918
  if (c.then !== "|" || !/^(?:echo|printf)$/.test(program) || !commands[k + 1])
878
919
  return;
@@ -889,16 +930,21 @@ function stdinFeeds(text) {
889
930
  }
890
931
  /** Commands that read a password or a token from stdin, by their words (and `sudo`'s options before them). */
891
932
  const STDIN_SECRET = /^(?:sudo(?:\s+-[A-Za-z]*S[A-Za-z]*)(?:\s|$)|(?:sudo(?:\s+-\S+)*\s+)?(?:(?:docker|podman|nerdctl|helm|oras|crane|skopeo|buildah)\s+(?:registry\s+)?login\b.*--password-stdin|gh\s+auth\s+login\b.*--with-token|gh\s+secret\s+set\b|(?:podman|docker)\s+secret\s+create\b|(?:npx\s+)?wrangler\s+secret\s+put\b|vercel\s+env\s+add\b|kubectl\s+create\s+secret\b|fly(?:ctl)?\s+secrets\s+(?:set|import)\b|netlify\s+env:set\b|pulumi\s+config\s+set\b.*--secret\b|vault\s+(?:login|kv\s+put|write)\b|chpasswd\b|passwd\b|openssl\b.*-pass(?:in|out)?\s+stdin))/;
933
+ /** Windows' commands that run against another computer as another user, `/s srv /u CORP\bob /p pw`. */
934
+ const REMOTE_USER_TOOLS = /^(?:schtasks|setx|taskkill|tasklist|systeminfo|getmac|bootcfg|gpresult|openfiles|eventcreate|eventquery|eventtriggers|driverquery|waitfor)(?:\.exe)?$/i;
892
935
  /**
893
936
  * Windows commands' passwords: switches (`cmdkey /pass:pw`, `wmic /password:pw`, `winrs -p:pw`, `schtasks /rp pw`,
894
- * `psexec -p pw`, `osql -P pw`) and positional ones (`net use Z: \\srv\share pw /user:CORP\alice`, `net user svc pw /add`).
895
- * (`*` asks for the password at a prompt: passwordAdder adds no run of `*`.)
937
+ * `psexec -p pw`, `osql -P pw`, and the remote user's `/u CORP\bob /p pw` that schtasks, setx, taskkill, tasklist,
938
+ * systeminfo, getmac, bootcfg, gpresult and their kin share) and positional ones (`net use Z: \\srv\share pw
939
+ * /user:CORP\alice`, `net user svc pw /add`). (`*` asks for the password at a prompt: passwordAdder adds no run of `*`.)
896
940
  */
897
941
  function windowsPasswords(text, add) {
898
- if (!/\b(?:cmdkey|schtasks|psexec|net|wmic|winrs|osql|sqlcmd|runas)\b|\/(?:pass|password|pwd):/i.test(text))
942
+ if (!/\b(?:cmdkey|schtasks|psexec|net|wmic|winrs|osql|sqlcmd|runas)\b|\/(?:pass|password|pwd):|[ \t]\/[uU][ \t]/i.test(text))
899
943
  return;
900
944
  for (const m of text.matchAll(/^[^\n]*$/gm)) {
901
- const ws = shellWords(m[0], m.index, { backslash: false });
945
+ // (Past a cmd or PowerShell prompt, `C:\Users\bob> ` or `PS C:\> `, whose `>` is no redirect.)
946
+ const prompt = /^[ \t]*(?:PS )?[A-Za-z]:\\[^>\n<|]{0,200}>[ \t]*/.exec(m[0])?.[0].length ?? 0;
947
+ const ws = shellWords(m[0].slice(prompt), m.index + prompt, { backslash: false });
902
948
  const command = ws.findIndex((w) => /^(?:cmdkey|schtasks|psexec(?:64)?|net|wmic|winrs|osql|sqlcmd)(?:\.exe)?$/i.test(w.text));
903
949
  for (let k = 0; k < ws.length; k++) {
904
950
  const w = ws[k];
@@ -907,11 +953,27 @@ function windowsPasswords(text, add) {
907
953
  if (glued && (w.text[0] === "/" || /^winrs/i.test(ws[command]?.text ?? "")))
908
954
  addTail(add, w, glued[1], w.quoted);
909
955
  }
956
+ // A switch's value is the next word of the same command, written as a value: not past an operator (`/P | sort`,
957
+ // `/p; then`) nor a syntax's placeholder (`/p [password]`, `/p <password>`).
958
+ const value = (k) => {
959
+ const [w, v] = [ws[k], ws[k + 1]];
960
+ return v && /^[ \t]*$/.test(m[0].slice(w.end - m.index, v.start - m.index)) && !/^[[<|;&>]/.test(v.text)
961
+ ? v
962
+ : undefined;
963
+ };
964
+ // The remote user's password, `/u CORP\bob /p pw`, on the commands that take them (not in prose: `use the /u and /p
965
+ // fields`).
966
+ const remote = ws.findIndex((w) => REMOTE_USER_TOOLS.test(w.text));
967
+ for (let k = Math.max(2, remote + 3); remote >= 0 && k + 1 < ws.length; k++)
968
+ if (/^\/p$/i.test(ws[k].text) && /^\/u$/i.test(ws[k - 2].text) && value(k - 2) && value(k)) {
969
+ const pw = value(k);
970
+ addTail(add, pw, pw.text, pw.quoted);
971
+ }
910
972
  if (command < 0)
911
973
  continue;
912
974
  const tool = ws[command].text.toLowerCase().replace(/\.exe$/, "");
913
975
  const rest = ws.slice(command + 1);
914
- // A switch followed by its value: `/rp pw` (schtasks), `-p pw` (psexec), `-P pw` (osql, sqlcmd).
976
+ // A switch followed by its value: `/rp pw` (schtasks' run-as user's), `-p pw` (psexec), `-P pw` (osql, sqlcmd).
915
977
  const flag = tool === "schtasks"
916
978
  ? /^\/rp$/i
917
979
  : /^psexec/.test(tool)
@@ -919,9 +981,11 @@ function windowsPasswords(text, add) {
919
981
  : /^(?:osql|sqlcmd)$/.test(tool)
920
982
  ? /^-P$/
921
983
  : undefined;
922
- for (let k = 0; flag && k + 1 < rest.length; k++)
923
- if (flag.test(rest[k].text))
924
- addTail(add, rest[k + 1], rest[k + 1].text, rest[k + 1].quoted);
984
+ for (let k = command + 1; flag && k + 1 < ws.length; k++) {
985
+ const pw = flag.test(ws[k].text) ? value(k) : undefined;
986
+ if (pw)
987
+ addTail(add, pw, pw.text, pw.quoted);
988
+ }
925
989
  // `net use [drive] \\share [password] /user:…` and `net user name [password] /add`: the positional word after the share or the name.
926
990
  if (tool === "net" && /^(?:use|user)$/i.test(rest[0]?.text ?? "")) {
927
991
  const positional = rest.slice(1).filter((w) => !/^\//.test(w.text));
@@ -934,23 +998,25 @@ function windowsPasswords(text, add) {
934
998
  }
935
999
  /**
936
1000
  * Network devices' configuration lines that end in a key: Cisco IOS (`enable secret pw`, `username u privilege 15 secret 0 pw`,
937
- * `crypto isakmp key pw address 0.0.0.0`, `tacacs-server key pw`, `ip ospf authentication-key pw`,
938
- * `ip ospf message-digest-key 1 md5 pw`, `snmp-server community s RW`) and Junos (`… authentication-key "pw"`,
939
- * `plain-text-password-value pw`). The keyword may carry an encryption type: 0 and 7 are readable, 5, 8 and 9 are
940
- * hashes, kept as hashes are.
1001
+ * `crypto isakmp key pw address 0.0.0.0`, an IKEv2 keyring's `pre-shared-key local pw`, `tacacs-server key pw`,
1002
+ * `ip ospf authentication-key pw`, `ip ospf message-digest-key 1 md5 pw`, `snmp-server community s view v RW`) and
1003
+ * Junos (`… authentication-key "pw"`, `plain-text-password-value pw`, and `show configuration`'s
1004
+ * `pre-shared-key ascii-text "$9$…"; ## SECRET-DATA`). The keyword may carry an encryption type: 0 and 7 are readable,
1005
+ * 5, 8 and 9 are hashes, kept as hashes are. (The key's span is where the match put it, not counted back from
1006
+ * the line's end, which trailing blanks and a CRLF's `\r` move.)
941
1007
  */
942
1008
  function networkConfigKeys(text, add) {
943
1009
  if (!/\b(?:secret|password|key|key-string|community|plain-text-password-value|psk)\b/.test(text))
944
1010
  return;
945
- const IOS = /^[ \t]*(?:set[ \t]+)?(?:(?:enable|username|crypto|tacacs-server|radius-server|ip|ipv6|ntp|key-string|neighbor|ppp|standby|vrrp|router|aaa|snmp-server|system|protocols|security|interfaces)\b[^\n]*?[ \t](?:secret|password|key|key-string|pre-shared-key|authentication-key|md5|community|plain-text-password-value|psk)|wpa-psk|key-string)[ \t]+(?:(?:ascii-text|hexadecimal|ascii|hex)[ \t]+)?(?:(\d)[ \t]+)?(?:"([^"\n]{3,200})"|(\S{3,200}))((?:[ \t]+(?:address|level|RO|RW|ro|rw)\b[^\n]*)?)[ \t]*;?\r?$/gm;
1011
+ const IOS = /^[ \t]*(?:set[ \t]+)?(?:(?:enable|username|crypto|tacacs-server|radius-server|ip|ipv6|ntp|key-string|neighbor|ppp|standby|vrrp|router|aaa|snmp-server|system|protocols|security|interfaces)\b[^\n]*?[ \t](?:secret|password|key|key-string|pre-shared-key|authentication-key|md5|community|plain-text-password-value|psk)|wpa-psk|key-string|pre-shared-key)[ \t]+(?:(?:local|remote)[ \t]+)?(?:(?:ascii-text|hexadecimal|ascii|hex)[ \t]+)?(?:(\d)[ \t]+)?(?:"([^"\n]{3,200})"|(\S{3,200}))((?:[ \t]+(?:address|view|level|RO|RW|ro|rw)\b[^\r\n]*)?)[ \t]*;?(?:[ \t]*## SECRET-DATA)?\r?$/dgm;
946
1012
  for (const m of text.matchAll(IOS)) {
947
1013
  if (m[1] && /[589]/.test(m[1]))
948
1014
  continue;
949
1015
  const pw = m[2] ?? m[3];
950
1016
  if (/^(?:public|private|\d+)$/i.test(pw) && !m[2])
951
1017
  continue;
952
- const end = m.index + m[0].replace(/[ \t]*;?\r?$/, "").length - m[4].length - (m[2] ? 1 : 0);
953
- add(end - pw.length, pw, "structure:cli-password", !!m[2]);
1018
+ const start = (m.indices[2] ?? m.indices[3])[0];
1019
+ add(start, pw, "structure:cli-password", !!m[2]);
954
1020
  }
955
1021
  }
956
1022
  /**
@@ -962,9 +1028,27 @@ function oracleLogins(text, add) {
962
1028
  if (!/oracle|expdp|impdp|\bexp\b|\bimp\b|rman|sqlldr|dgmgrl|tkprof|\bconn(?:ect)?\b/i.test(text))
963
1029
  return;
964
1030
  // (Oracle's tools as a command, at a line's start or after a prompt or an operator: `dvc exp run` runs dvc.)
965
- const LOGIN = /(?:jdbc:oracle:(?:thin|oci):|(?:^|[;&|(]|\$ )[ \t]*(?:sudo[ \t]+)?(?:expdp|impdp|exp|imp|rman|sqlldr|dgmgrl|tkprof)\b[^\n]{0,400}?[ \t](?:(?:target|auxiliary|userid|catalog)[ \t=]+)?["']?|^[ \t]*(?:SQL>[ \t]*)?conn(?:ect)?[ \t]+["']?)([A-Za-z_][\w$#]{0,30})\/((?:[^\s\/@'"]|@(?=[^\s\/@'"]*@)){3,})(?=@|["']?[ \t]*(?:as[ \t]+sys\w*)?[ \t]*;?\r?$)/gim;
1031
+ const LOGIN = /(?:jdbc:oracle:(?:thin|oci):|(?:^|[;&|(]|\$ )[ \t]*(?:sudo[ \t]+)?(?:expdp|impdp|exp|imp|rman|sqlldr|dgmgrl|tkprof)\b[^\n]{0,400}?[ \t](?:(?:target|auxiliary|userid|catalog)[ \t=]+)?["']?|^[ \t]*(?:SQL>[ \t]*)?conn(?:ect)?[ \t]+["']?)([A-Za-z_][\w$#]{0,30})\/((?:[^\s\/@'"]|@(?=[^\s\/@'"]*@)){3,})(?=@|["']?(?:[ \t]|;?\r?$))/gim;
966
1032
  for (const m of text.matchAll(LOGIN))
967
- add(m.index + m[0].length - m[2].length, m[2], "structure:cli-password");
1033
+ if (endsLogin(text, m.index + m[0].length, m[2]) && !isUserAsPassword(m[1], m[2]))
1034
+ add(m.index + m[0].length - m[2].length, m[2], "structure:cli-password");
1035
+ }
1036
+ /**
1037
+ * Whether an Oracle login's password is its user's name (`impdp scott/scott`, `TCL/TCL`): a schema's owner a guide
1038
+ * logs in as, which tells nothing the name doesn't (as url-password's `tally:tally@`).
1039
+ */
1040
+ function isUserAsPassword(user, password) {
1041
+ return user.toLowerCase() === password.toLowerCase() && !isRandomToken(password);
1042
+ }
1043
+ /**
1044
+ * Whether an Oracle login's `user/password` ends at `end`: at its `@db`, the line's end or an operator, or before the
1045
+ * command's next word (`expdp system/pw directory=DP`, `sqlplus scott/pw @run.sql`, `rman target sys/pw nocatalog`).
1046
+ * Before a next word, plain words are a sentence's `input/output` (`Connect input/output ports to the board`).
1047
+ */
1048
+ function endsLogin(text, end, password) {
1049
+ const after = /^["']?([ \t]*)/.exec(text.slice(end, end + 2));
1050
+ const rest = text.slice(end + after[0].length, end + after[0].length + 8);
1051
+ return !after[1] || /^(?:@|;|[\r\n]|$|as[ \t]+sys)/i.test(rest) || !isWordsValue(password);
968
1052
  }
969
1053
  /**
970
1054
  * How command-line and config rules add a password: unless it's a reference (`$PASS`, `${PASS}`, `{{ pw }}`, a
@@ -993,7 +1077,7 @@ const USER_PASSWORD = /^(?:curl|curlie|xh|xhs|http|https|httpie|wget|grpcurl|hey
993
1077
  * `docker login -p PASS`, `sshpass -p PASS`, `redis-cli -a PASS`, `ldapsearch -w PASS`, `htpasswd -b file user PASS`.
994
1078
  */
995
1079
  function cliPasswords(text, out) {
996
- if (!/[\s"']-[upaPwUbN]|--(?:user|pass|auth|pw|body)|\/p:|password[ \t]|sshpass|htpasswd|mkpasswd|rabbitmqctl|steamcmd|sqlplus|poetry|wandb|beeline|keytool|vault[ \t]+login|pulumi|openssl[ \t]+passwd|-a |\||<<</i.test(text))
1080
+ if (!/[\s"']-[upaPwUbN]|--(?:[a-z]+-)?(?:user|pass|auth|pw|body)|\/p:|password[ \t]|sshpass|htpasswd|mkpasswd|rabbitmqctl|steamcmd|sqlplus|poetry|wandb|beeline|keytool|vault[ \t]+login|pulumi|openssl[ \t]+passwd|-a |\||<<</i.test(text))
997
1081
  return;
998
1082
  const add = passwordAdder(out);
999
1083
  // The password options of programs outside the tool table: `hydra -p`, `xfreerdp /p:`, `unrar x -p…`, `ssh-keygen -N`,
@@ -1013,8 +1097,11 @@ function cliPasswords(text, out) {
1013
1097
  const name = programName(command[0]);
1014
1098
  const word = /^password/.test(option);
1015
1099
  // (The program is the command's first word, or a word before the option that names one of these, past prose
1016
- // the line starts with: `Brute-force it: hydra -l admin -p …`.)
1017
- const names = command.filter((w, i) => i === 0 || (w.end <= at && !/[/\\]/.test(w.text))).map(programName);
1100
+ // the line starts with: `Brute-force it: hydra -l admin -p …`. Not an option's value, which names something else:
1101
+ // `dvc stage add -n hydra -p params.yaml:` names a stage.)
1102
+ const names = command
1103
+ .filter((w, i) => i === 0 || (w.end <= at && !/[/\\]/.test(w.text) && !command[i - 1].text.startsWith("-")))
1104
+ .map(programName);
1018
1105
  // (rar's `-p` takes its password attached, `-pPW`; alone it asks for one, and the archive's name follows,
1019
1106
  // `rar a -p backup.rar`. A password written apart anyway, `-p Summer2024`, is still the one its writer meant.)
1020
1107
  const takes = word ||
@@ -1076,9 +1163,10 @@ function cliPasswords(text, out) {
1076
1163
  add(m.index + m[0].length - m[1].length - m[2].length, m[2], "structure:cli-password");
1077
1164
  }
1078
1165
  // `sqlplus user/password@db` (the last `@` ends a password that has one, `scott/pa@ss@orcl`).
1079
- for (const m of text.matchAll(/(?:^|[;&|(\s]|\$ )sqlplus[ \t]+(?:-\w+[ \t]+)*["']?[\w.$#-]{1,60}\/((?:[^\s@'"]|@(?=[^\s@'"]*@)){3,4096})(?=@|[ \t]+as[ \t]+sys|["']?[ \t]*$|["']?[ \t]*[;&|\n])/gm)) {
1080
- const pw = m[1] ?? m[2];
1081
- add(m.index + m[0].length - pw.length, pw, "structure:cli-password");
1166
+ for (const m of text.matchAll(/(?:^|[;&|(\s]|\$ )sqlplus[ \t]+(?:-\w+[ \t]+)*["']?([\w.$#-]{1,60})\/((?:[^\s@'"]|@(?=[^\s@'"]*@)){3,4096})(?=@|["']?(?:[ \t]|$|[;&|\n]))/gm)) {
1167
+ const pw = m[2];
1168
+ if (endsLogin(text, m.index + m[0].length, pw) && !isUserAsPassword(m[1], pw))
1169
+ add(m.index + m[0].length - pw.length, pw, "structure:cli-password");
1082
1170
  }
1083
1171
  // A password written unquoted with a shell's operators in it (`-pXk9(mP2&vQ7`) runs to the next blank, as the person
1084
1172
  // who wrote it reads it; the shell would have cut it at the operator. (Not past a quote: `"$(… pw)"` closes there.)
@@ -1328,40 +1416,63 @@ function netrcLike(text, out) {
1328
1416
  }
1329
1417
  /**
1330
1418
  * Passwords in SQL statements: MySQL/Oracle `IDENTIFIED [WITH plugin] BY '…'`, `SET PASSWORD [FOR u] = '…'`,
1331
- * Postgres `[WITH] PASSWORD '…'`, T-SQL `WITH PASSWORD = '…'`.
1419
+ * Postgres `[WITH] PASSWORD '…'`, T-SQL `WITH PASSWORD = '…'` (or `N'…'`, the Unicode string SSMS scripts write).
1332
1420
  */
1333
1421
  function sqlPasswords(text, out) {
1334
1422
  if (!/IDENTIFIED|PASSWORD/i.test(text))
1335
1423
  return;
1424
+ // A string literal, its quote doubled or escaped by a backslash (MySQL's default, Postgres' `E'…'`): `'Hx7m\'Q2pL'`.
1425
+ // (A backslash alone may end it: `'C:\'`.)
1426
+ const single = String.raw `'((?:\\[^\n]|[^'\\\n]|'')+\\?)'`;
1427
+ const double = String.raw `"((?:\\[^\n]|[^"\\\n])+\\?)"`;
1336
1428
  const forms = [
1337
- /\bIDENTIFIED\s+(?:WITH\s+['"`]?\w+['"`]?\s+)?BY\s+(?!PASSWORD\b)(?:'((?:[^'\n]|'')+)'|"([^"\n]+)")/gi,
1429
+ new RegExp(String.raw `\bIDENTIFIED\s+(?:WITH\s+['"${"`"}]?\w+['"${"`"}]?\s+)?BY\s+(?!PASSWORD\b)(?:N?${single}|${double})`, "gi"),
1338
1430
  // Unquoted (Oracle) only in a statement that creates or changes a user: prose says "identified by" too. (Up to a
1339
1431
  // statement's end, or a quote that closes the string a program holds it in: `"CREATE USER app IDENTIFIED BY …"`.
1340
1432
  // Not MySQL's `IDENTIFIED BY RANDOM PASSWORD`, which makes one.)
1341
- /\b(?:CREATE|ALTER)\s+(?:USER|ROLE)\b[^;\n]{0,200}?\bIDENTIFIED\s+BY\s+(?!PASSWORD\b|VALUES\b|RANDOM\s+PASSWORD\b)([A-Za-z0-9_$#!@%^&*+=.-]{4,128})(?=\s*(?:[;"'`]|$|\s))/gim,
1342
- /\bSET\s+PASSWORD\s+(?:FOR\s+\S+\s*)?=\s*(?:PASSWORD\s*\(\s*)?'((?:[^'\n]|'')+)'/gi,
1343
- /\b(?:CREATE|ALTER)\s+(?:USER|ROLE|LOGIN)\b[^;\n]{0,200}?\bPASSWORD\s*=?\s*'((?:[^'\n]|'')+)'/gi,
1433
+ /\b(?:CREATE(?:\s+OR\s+REPLACE)?|ALTER)\s+(?:USER|ROLE)\b[^;\n]{0,200}?\bIDENTIFIED\s+BY\s+(?!PASSWORD\b|VALUES\b|RANDOM\s+PASSWORD\b)([A-Za-z0-9_$#!@%^&*+=.-]{4,128})(?=\s*(?:[;"'`]|$|\s))/gim,
1434
+ new RegExp(String.raw `\bSET\s+PASSWORD\s+(?:FOR\s+\S+\s*)?=\s*(?:PASSWORD\s*\(\s*)?N?${single}`, "gi"),
1435
+ new RegExp(String.raw `\b(?:CREATE(?:\s+OR\s+REPLACE)?|ALTER)\s+(?:USER|ROLE|LOGIN)\b[^;\n]{0,200}?\bPASSWORD\s*=?\s*[NE]?${single}`, "gi"),
1344
1436
  // MySQL's current password, which a password change authenticates with: `IDENTIFIED BY 'new' REPLACE 'old'`.
1345
- /\bIDENTIFIED\s+BY\s+'(?:[^'\n]|'')+'\s+REPLACE\s+'((?:[^'\n]|'')+)'/gi,
1437
+ new RegExp(String.raw `\bIDENTIFIED\s+BY\s+'(?:\\[^\n]|[^'\\\n]|'')+\\?'\s+REPLACE\s+N?${single}`, "gi"),
1346
1438
  ];
1347
- for (const re of forms) {
1439
+ for (const [k, re] of forms.entries()) {
1348
1440
  for (const m of text.matchAll(re)) {
1349
1441
  const pw = m[1] ?? m[2];
1442
+ // The quoted `IDENTIFIED BY '…'` in a statement that makes or changes a user, grants to one or links to a
1443
+ // database as one (from its start, the last `;` before it): prose says a record "is identified by 'order_id'".
1444
+ if (k === 0 && !inUserStatement(text, m.index))
1445
+ continue;
1350
1446
  // (A bind parameter, `:pw` or `?`, is never quoted and can't start the unquoted form: these are all literals.
1351
- // MySQL's `*<40 hex>` is a hash, passwordHashes's.)
1352
- if (pw.length < 3 || isInert(pw) || isPlaceholder(pw) || /^\*[0-9A-F]{40}$/.test(pw))
1447
+ // MySQL's `*<40 hex>` is a hash, passwordHashes's. Nor dynamic SQL's string built around a variable, `'… PASSWORD =
1448
+ // N''' + @pwd + ''';'`: its quotes are doubled in the string that holds the statement.)
1449
+ if (pw.length < 3 ||
1450
+ isInert(pw) ||
1451
+ isPlaceholder(pw) ||
1452
+ /^\*[0-9A-F]{40}$/.test(pw) ||
1453
+ /^''[ \t]*\+|\+[ \t]*''$/.test(pw))
1353
1454
  continue;
1354
1455
  const start = m.index + m[0].lastIndexOf(pw);
1355
1456
  out.push({ start, end: start + pw.length, kind: "password", rule: "structure:sql-password", confidence: 0.95 });
1356
1457
  }
1357
1458
  }
1358
1459
  }
1460
+ /**
1461
+ * Whether `at` is in a SQL statement that makes or changes a user or a role, grants to one, or links to a database as
1462
+ * one: `CREATE USER`, `ALTER ROLE`, `GRANT … TO`, `CREATE DATABASE LINK … CONNECT TO`, `SET PASSWORD`. (Read back to
1463
+ * the statement's start, the last `;`, at most 600 characters: a statement written over several lines too.)
1464
+ */
1465
+ function inUserStatement(text, at) {
1466
+ const before = text.slice(Math.max(0, at - 600), at);
1467
+ const statement = before.slice(before.lastIndexOf(";") + 1);
1468
+ return /\b(?:(?:CREATE(?:\s+OR\s+REPLACE)?|ALTER)\s+(?:USER|ROLE|LOGIN)|GRANT\b|CONNECT\s+TO|SET\s+PASSWORD)\b/i.test(statement);
1469
+ }
1359
1470
  /**
1360
1471
  * Redis sessions: `AUTH [user] pw`, `CONFIG SET requirepass pw`, and replies that pair a credential name
1361
1472
  * with its value (`1) "requirepass"` / `2) "pw"`, `GET api_token` then `"pw"`).
1362
1473
  */
1363
1474
  function redisSessions(text, out) {
1364
- if (!/\bAUTH\b|requirepass|masterauth|\d\) "|> ?GET /i.test(text))
1475
+ if (!/\bAUTH\b|requirepass|masterauth|\d\) "|> ?GET |SETUSER|^[ \t]*user[ \t]/im.test(text))
1365
1476
  return;
1366
1477
  const add = (start, value, rule) => {
1367
1478
  if (value.length < 4 || isInert(value))
@@ -1380,6 +1491,23 @@ function redisSessions(text, out) {
1380
1491
  const v = m[1] ?? m[2] ?? m[3];
1381
1492
  add(m.index + m[0].length - v.length - (m[3] === undefined ? 1 : 0), v, "structure:redis-auth");
1382
1493
  }
1494
+ // ACL rules, `ACL SETUSER bob on >pw ~* +@all` and an ACL file's `user bob on >pw …`: a password added (`>pw`) or
1495
+ // removed (`<pw`). (A `#<hex>` is its hash, passwordHashes's.) Not a usage's slot, `SETUSER <username> <attribute>`,
1496
+ // nor a template's or a doc's placeholder, `>REMITFLOW_APP_PASSWORD_PLACEHOLDER`, `>PASSWD`; and a Markdown code
1497
+ // span's closing backtick is the page's: `` `ACL SETUSER default >newpass` ``.
1498
+ for (const m of text.matchAll(/(?:\bSETUSER|^[ \t]*user)[ \t]+\S+([^\n]*)/gim)) {
1499
+ const rules = m[1];
1500
+ if (!/SETUSER/i.test(m[0]) && !/[ \t](?:on|off)(?=[ \t]|$)/.test(rules))
1501
+ continue;
1502
+ const base = m.index + m[0].length - rules.length;
1503
+ for (const r of rules.matchAll(/(?<=[ \t])[<>](\S+)/g)) {
1504
+ if (/^<[^<>]+>$/.test(r[0]))
1505
+ continue;
1506
+ const pw = /`$/.test(r[1]) && /`/.test(text.slice(lineStart(text, m.index), base + r.index)) ? r[1].slice(0, -1) : r[1];
1507
+ if (isLiteralPassword(pw))
1508
+ add(base + r.index + 1, pw, "structure:redis-acl");
1509
+ }
1510
+ }
1383
1511
  // Array replies: a credential-named element followed by its value.
1384
1512
  for (const m of text.matchAll(/^[ \t]*\d{1,4}\) "([\w.:-]{1,80})"[ \t]*\r?\n[ \t]*\d{1,4}\) "((?:[^"\\\n]|\\.)+)"/gm)) {
1385
1513
  if (!isCredentialName(m[1]))
@@ -1429,7 +1557,7 @@ function passwordHashes(text, out) {
1429
1557
  for (const m of text.matchAll(PASSWORD_HASH_IN_TEXT))
1430
1558
  add(m.index, m[0], "structure:password-hash");
1431
1559
  // MySQL `*<40 hex>` and Redis ACL `#<64 hex>` only where a password is being talked about.
1432
- for (const m of text.matchAll(/^[^\n]*(?:password|passwd|authentication_string|\buser\s+\S+\s+on\b)[^\n]*$/gim)) {
1560
+ for (const m of text.matchAll(/^[^\n]*(?:password|passwd|authentication_string|(?:\buser|\bSETUSER)\s+\S+\s+(?:on|off)\b)[^\n]*$/gim)) {
1433
1561
  for (const h of m[0].matchAll(/(?<![\w*#])(?:\*[0-9A-F]{40}|#[0-9a-f]{64})\b/g))
1434
1562
  add(m.index + h.index + 1, h[0].slice(1), "structure:hash-field");
1435
1563
  }
@@ -1487,8 +1615,23 @@ export function findStructure(text) {
1487
1615
  vpnSecrets(text, out);
1488
1616
  passwordHashes(text, out);
1489
1617
  configPasswords(text, out);
1490
- return out;
1618
+ // The stand-ins every common-password list starts with (`hunter2`, `test123`, `mypassword`) after a config's or a
1619
+ // statement's password keyword are docs', as under a password's name (values.ts isTestValue): SQL's `PASSWORD '…'`,
1620
+ // a netrc's `password …`, Redis' `AUTH …`, HAProxy's, a VPN's PSK. (Not a command line's option, `-p`, `pass:`, nor
1621
+ // a URL's password field, which no name labels.)
1622
+ return out.filter((f) => !STAND_IN_RULES.has(f.rule) || !isTestValue(f.value ?? text.slice(f.start, f.end)));
1491
1623
  }
1624
+ /** The rules whose password follows a keyword that names it (findStructure keeps a stand-in there). */
1625
+ const STAND_IN_RULES = new Set([
1626
+ "structure:sql-password",
1627
+ "structure:netrc",
1628
+ "structure:redis-auth",
1629
+ "structure:redis-reply",
1630
+ "structure:redis-acl",
1631
+ "structure:haproxy",
1632
+ "structure:ipsec",
1633
+ "structure:openvpn",
1634
+ ]);
1492
1635
  /**
1493
1636
  * A BIP-39 seed phrase: 12 to 24 words of the English list whose checksum verifies (1 in 16 to 1 in 256 for
1494
1637
  * words drawn at random), on one line or numbered one per line, with or without a name. A wallet's whole
@@ -1525,9 +1668,11 @@ function mnemonics(text, out) {
1525
1668
  const gap = last < 0 ? "" : text.slice(last, at);
1526
1669
  // (A line's indentation is one separator, not twelve. Code's joints and operators separate no phrase's words:
1527
1670
  // `chunk.item.type == "…"`, `model_dump`, `ignore_index=True`.)
1528
- if (gap.replace(/\s+/g, " ").length > MAX_PHRASE_GAP || /[_=()]|\.(?=[A-Za-z])/.test(gap))
1671
+ // (A list's numbering is no joint: `1) salt\n2) pear`, `(1) salt (2) pear`.)
1672
+ const joints = gap.replace(/(?:^|\s)\(?\d{1,2}[.)]/g, " ");
1673
+ if (gap.replace(/\s+/g, " ").length > MAX_PHRASE_GAP || /[_=()]|\.(?=[A-Za-z])/.test(joints))
1529
1674
  flush();
1530
- words.push({ word: w, start: at, end, lineBreakBefore: /\n/.test(gap) });
1675
+ words.push({ word: w, start: at, end, lineBreakBefore: /\n/.test(gap), opensValue: /["'`“‘:[]/.test(gap) });
1531
1676
  }
1532
1677
  else
1533
1678
  flush();
@@ -1544,15 +1689,19 @@ const MAX_PHRASE_GAP = 12;
1544
1689
  const LABEL_WORDS = new Set(["seed", "phrase", "secret", "key", "word", "list", "private"]);
1545
1690
  /**
1546
1691
  * Windows of 12–24 words with a valid checksum (published phrases aside), longest first, each word in one at most.
1547
- * A window starts a run or a line, or follows a label of list words on its line (`Seed phrase: slow core …`).
1548
- * In a run longer than 48 words (a word list, or several phrases on their own lines) only whole lines count.
1692
+ * A window starts a run or a line, or follows a label of list words on its line (`Seed phrase: slow core …`), or
1693
+ * opens a quoted value after up to two list words that start its line (a command's: `echo "salt pear …" | wallet
1694
+ * import`). In a run longer than 48 words (a word list, or several phrases on their own lines) only whole lines count.
1549
1695
  */
1550
1696
  function findPhrases(words, list, out) {
1551
1697
  const long = words.length > 48;
1698
+ let lineStart = 0;
1552
1699
  for (let i = 0; i < words.length; i++) {
1553
1700
  const startsLine = i === 0 || words[i].lineBreakBefore;
1701
+ if (startsLine)
1702
+ lineStart = i;
1554
1703
  // (A label may follow other list words on its line: `Copy this seed phrase: slow core …`.)
1555
- if (!startsLine && (long || !LABEL_WORDS.has(words[i - 1].word)))
1704
+ if (!startsLine && (long || (!LABEL_WORDS.has(words[i - 1].word) && !(words[i].opensValue && i - lineStart <= 2))))
1556
1705
  continue;
1557
1706
  for (const n of [24, 21, 18, 15, 12]) {
1558
1707
  if (i + n > words.length)
package/dist/syntax.d.ts CHANGED
@@ -72,8 +72,11 @@ export declare function throughBrackets(text: string, start: number, end: number
72
72
  */
73
73
  export declare function continuesToken(text: string, i: number, dash?: boolean): boolean;
74
74
  /**
75
- * A string literal's prefix: Python's `b r u f` (two of them: `rb`), C#'s `@` and `$`, Rust's `r#`. The readers of
76
- * calls', assignments' and names' literals find where one opens with it and a quote.
75
+ * A string literal's prefix: Python's `b r u f` (two of them: `rb`), C#'s `@` and `$`, Rust's `r#`, C's and C++'s `L`
76
+ * and `u8` (before a double quote), SQL's `N'…'` (T-SQL's Unicode string, as SSMS scripts every one), `E'…'`
77
+ * (Postgres' escaped string, as pg_dump writes one) and MySQL's character set, `_utf8mb4'…'` (before a single quote,
78
+ * as SQL quotes a string: a password may start `N"`). The readers of calls', assignments' and names' literals
79
+ * find where one opens with it and a quote.
77
80
  */
78
81
  export declare const STRING_PREFIX: string;
79
82
  /** A contraction's apostrophe (`Here's`, `don't`, `you'll`), which prose writes and no quote is: test its source, or replace it. */
package/dist/syntax.js CHANGED
@@ -189,10 +189,13 @@ export function continuesToken(text, i, dash = false) {
189
189
  return !escape || backslashesBefore(text, i + 1 - escape[0].length) % 2 === 1;
190
190
  }
191
191
  /**
192
- * A string literal's prefix: Python's `b r u f` (two of them: `rb`), C#'s `@` and `$`, Rust's `r#`. The readers of
193
- * calls', assignments' and names' literals find where one opens with it and a quote.
192
+ * A string literal's prefix: Python's `b r u f` (two of them: `rb`), C#'s `@` and `$`, Rust's `r#`, C's and C++'s `L`
193
+ * and `u8` (before a double quote), SQL's `N'…'` (T-SQL's Unicode string, as SSMS scripts every one), `E'…'`
194
+ * (Postgres' escaped string, as pg_dump writes one) and MySQL's character set, `_utf8mb4'…'` (before a single quote,
195
+ * as SQL quotes a string: a password may start `N"`). The readers of calls', assignments' and names' literals
196
+ * find where one opens with it and a quote.
194
197
  */
195
- export const STRING_PREFIX = String.raw `r#+|[rRbBuUfF]{1,2}|@\$?|\$@?`;
198
+ export const STRING_PREFIX = String.raw `r#+|(?:u8|L)(?=")|[rRbBuUfF]{1,2}|(?:[NE]|_[A-Za-z][A-Za-z0-9]{1,15})(?=')|@\$?|\$@?`;
196
199
  /** A contraction's apostrophe (`Here's`, `don't`, `you'll`), which prose writes and no quote is: test its source, or replace it. */
197
200
  export const CONTRACTION = /(?<=[A-Za-z])['’](?=(?:s|t|ll|re|ve|d|m)\b)/gi;
198
201
  /**
@@ -201,7 +204,7 @@ export const CONTRACTION = /(?<=[A-Za-z])['’](?=(?:s|t|ll|re|ve|d|m)\b)/gi;
201
204
  */
202
205
  export function isContraction(text, i) {
203
206
  const word = /[A-Za-z]+$/.exec(text.slice(Math.max(0, i - 20), i))?.[0];
204
- return !!word && !/^[rRbBuUfF]{1,2}$/.test(word) && /^(?:s|t|ll|re|ve|d|m)\b/i.test(text.slice(i + 1, i + 4));
207
+ return (!!word && !/^(?:[rRbBuUfF]{1,2}|[NE])$/.test(word) && /^(?:s|t|ll|re|ve|d|m)\b/i.test(text.slice(i + 1, i + 4)));
205
208
  }
206
209
  /** How many backslashes run right before `at`: an odd count escapes the character at `at`. */
207
210
  export function backslashesBefore(text, at) {