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/dist/named.js CHANGED
@@ -9,12 +9,12 @@
9
9
  * values.ts). Generic names (`key`, `token`, `auth`) also need a value that looks generated, since
10
10
  * `"key": "user.name"` and `token: bearer` are everywhere.
11
11
  */
12
- import { callArguments, calls, closingBracket, literalEnd, literalAt, literalHead, } from "./calls.js";
12
+ import { callArguments, calls, closingBracket, convertedLiteral, literalEnd, literalAt, literalHead, } from "./calls.js";
13
13
  import { attributes, delimitedCells, group, isEscapedBreak, MAX_VALUE, PAIR_NAME, UNQUOTED_READ, PAIR_VALUE, PIPE_RULE, PIPE_RULE_START, pipeCells, quoted, stringEnd, stringListItems, throughBrackets, unquotedValue, withoutUnopenedClosers, } from "./syntax.js";
14
14
  import { lineEnd as lineEndAt, lineStart as lineStartAt, lowerBound, splitLines } from "./lines.js";
15
15
  import { BIP39_WORDS } from "./bip39-data.js";
16
- import { insertCells } from "./sql.js";
17
- import { isUrl, queryParams, urls } from "./urls.js";
16
+ import { sqlCells } from "./sql.js";
17
+ import { isUrl, PARAM_NAME, PARAM_START, queryParams, urlAround, urls } from "./urls.js";
18
18
  import { blockExtent, isBlockIndicator, yamlLines } from "./yaml.js";
19
19
  import { naturalness } from "./charlm.js";
20
20
  import { clientSdkAt, isPlaceholder } from "./public.js";
@@ -27,21 +27,26 @@ import { ALGORITHM, CJK_COUNT, isBenignValue, isRandomToken, COMMON_WORDS, clean
27
27
  const NAME = String.raw `((?=[\p{L}\p{N}_.$@-]{0,80}?(?:${CREDENTIAL_WORD}))[\p{L}_$@][\p{L}\p{N}_.$@-]{0,80})`;
28
28
  // A name doesn't start inside a word or right after a backslash, except an escape before it in a JSON string (`\n`,
29
29
  // `\r`, `\t`: a line of an encoded file).
30
- // (`keystore/release_password="…"` in a Godot preset: a `section/name` key at the start of a line.)
31
- const ASSIGN = new RegExp(String.raw `(?:(?<=\\[nrt])|(?<=(?:^|\n)[ \t]*[\w.-]{1,60}\/)|(?<![\p{L}\p{N}_.$@/-]|\$\{|\\))(\\?["'${"`"}]?)${NAME}\1(?:\[\])?\]?[ \t]{0,40}(=>|:=|\?=|\|\|=|===?|=|:(?!:))[ \t]{0,40}`, "dgiu");
30
+ // (`keystore/release_password="…"` in a Godot preset: a `section/name` key at the start of a line. A form body's key
31
+ // in brackets, percent-encoded as browsers and HTTP clients send one: `user%5Bpassword%5D=…`.)
32
+ const ASSIGN = new RegExp(String.raw `(?:(?<=\\[nrt])|(?<=(?:^|\n)[ \t]*[\w.-]{1,60}\/)|(?<=%5B)|(?<![\p{L}\p{N}_.$@/-]|\$\{|\\))(\\?["'${"`"}]?)${NAME}\1(?:\[\]|%5B%5D)?(?:\]|%5D)?[ \t]{0,40}(=>|:=|\?=|\|\|=|===?|=|:(?!:))[ \t]{0,40}`, "dgiu");
32
33
  const FLAG = /(?:^|[\s"'`(]|(?<=\\[nrt]))(--?[A-Za-z][\w-]{0,60})(?:[ \t]{1,4}|=)(?=[^\s-])/g;
33
34
  /**
34
35
  * An XML element's text, plain or in a CDATA section (Jenkins `credentials.xml`, Maven `settings.xml`, Android
35
36
  * resources), as two groups from `n`: the section's content, or the plain text. elementText picks the one that matched.
37
+ * (Pretty-printed, the text may stand on a line of its own between the tags.)
36
38
  */
37
- const XML_TEXT = (min) => String.raw `(?:<!\[CDATA\[([^\n]{${min},${MAX_VALUE}}?)\]\]>|([^<\n]{${min},${MAX_VALUE}}?))`;
39
+ const XML_TEXT = (min) => String.raw `(?:[ \t]*\r?\n[ \t]*)?(?:<!\[CDATA\[([^\n]{${min},${MAX_VALUE}}?)\]\]>|([^<\n]{${min},${MAX_VALUE}}?))(?:[ \t]*\r?\n[ \t]*)?`;
38
40
  const elementText = (m, n) => group(m, m[n] !== undefined ? n : n + 1);
39
41
  const XML_ELEMENT = new RegExp(String.raw `<([\w:.-]{1,80})(?:\s[^<>]{0,200})?>${XML_TEXT(1)}<\/\1>`, "dg");
40
42
  const SPACED = /^([ \t║┃│|]{0,20}"?(?:ENV[ \t]+|set[ \t]+|setenv[ \t]+|SetEnv[ \t]+|config set[ \t]+|ARG[ \t]+)?)([A-Za-z_][\w.-]{1,80})([ \t]*[│|┃║][ \t]*|[ \t]+)(?=[^\s=:│|┃║])/gm;
41
43
  /** Prefixes tools put before a line: diff `+`/`-`, grep `path:12:`, `cat -n`/`nl` numbers, log timestamps. */
42
44
  const LP = String.raw `(?:[+-](?=[ \t\w])|\d{1,7}[:|\t→-]|[\w./-]{1,200}:\d{1,7}[:-]|\d{4}-\d\d-\d\dT[\d:.]+Z?[ \t])?[ \t]*`;
43
- /** What may come before `NAME=` on an env line, and before `key:` on a YAML line. */
44
- const ENV_LINE_START = new RegExp(String.raw `^[ \t]*${LP}(?:export[ \t]+|declare[ \t]+-x[ \t]+|set[ \t]+-gx[ \t]+|\+|-[ \t]+|#[ \t]*|ENV[ \t]+|ARG[ \t]+)?$`);
45
+ /**
46
+ * What may come before `NAME=` on an env line, and before `key:` on a YAML line. (On a `.properties` line, the `%` of a
47
+ * Quarkus or MicroProfile profile, `%dev.quarkus.datasource.password=…`: the name is read from after it.)
48
+ */
49
+ const ENV_LINE_START = new RegExp(String.raw `^[ \t]*${LP}(?:export[ \t]+|declare[ \t]+-x[ \t]+|set[ \t]+-gx[ \t]+|\+|-[ \t]+|#[ \t]*|ENV[ \t]+|ARG[ \t]+|%)?$`);
45
50
  const YAML_LINE_START = new RegExp(String.raw `^[ \t]*${LP}(?:-[ \t]+)?["']?$`);
46
51
  /** Keywords that declare a variable rather than qualify its name: `export TOKEN=…`, `let key = …`. */
47
52
  const DECLARATION = /^(?:export|declare|local|readonly|typeset|const|let|var|val|final|static|private|protected|String|string|str|ENV|ARG|set|setenv)$/;
@@ -137,7 +142,7 @@ function isHashWithWords(value) {
137
142
  * after a type a variable (`char pass[]`).
138
143
  */
139
144
  function isPassInEnglish(before) {
140
- return /^(?:tests|cases|checks|builds|specs|assertions|suites|jobs|steps|runs|lints|linters|examples|scenarios|validations|benchmarks|things|all|first|second|third|final|last|next|one|two|three|each|every|both|single|double|another|initial|this|that)$/i.test(before);
145
+ return /^(?:tests|cases|checks|builds|specs|assertions|suites|jobs|steps|runs|lints|linters|examples|scenarios|validations|benchmarks|things|all|first|second|third|final|last|next|one|two|three|each|every|both|single|double|another|initial|this|that|\d+(?:st|nd|rd|th)|unexpected|expected)$/i.test(before);
141
146
  }
142
147
  /** Under a bare `pass`, a snake_case identifier: a test's or a rule's name in a verdict list (`- pass: expired_command`), not a password. */
143
148
  function isVerdictUnderPass(name, value) {
@@ -167,6 +172,14 @@ function isSettingOrSelfName(name, value) {
167
172
  const alnum = rest.match(/[A-Za-z0-9]/g)?.length ?? 0;
168
173
  return /[^\w]/.test(rest) && alnum < 4 && !(value.startsWith(name) && alnum > 0);
169
174
  }
175
+ /** An unquoted value that is its own name numbered is another variable of the same kind: `local_tree_pass = local_tree_pass2`, `token = token_2`. */
176
+ function isNumberedSelf(name, value) {
177
+ return (name.length >= 4 &&
178
+ value.toLowerCase().startsWith(name.toLowerCase()) &&
179
+ /^_?\d{1,2}$/.test(value.slice(name.length)));
180
+ }
181
+ /** An operator after an unquoted word: the word is an operand, the value an expression (`test1_pass = stages1 == stages2`, `token = prefix + suffix`). */
182
+ const OPERATOR_AFTER = /^[ \t]+(?:[=!]==?|&&|\|\||\?\?|[-+*/%]|and|or|if|is|in)[ \t]/;
170
183
  /**
171
184
  * Whether `value` under credential name `name` is a secret. Password-like names accept any literal
172
185
  * that isn't a common word or label; key-like names need something that isn't a word or identifier.
@@ -183,6 +196,10 @@ export function isNamedSecret(name, raw, literal = false) {
183
196
  const items = stringListItems(value);
184
197
  if (items)
185
198
  return items.some((item) => isNamedSecret(name, `"${item.value}"`, true));
199
+ // Words in Markdown's bold are docs' placeholder, judged as the words: `<value>**mysql metastore DB password**</value>`.
200
+ const emphasized = /^(\*\*|__)([^*_]*\s[^*_]*)\1$/.exec(value);
201
+ if (emphasized)
202
+ return isNamedSecret(name, `"${emphasized[2]}"`, literal);
186
203
  // A JSON object or array as a string (`"SecretString": "{\"username\": …}"`): its own pairs are judged by name.
187
204
  // (Starting as JSON does: `{"`, `{key:`, `[1`, `["`, `[{`; `[bZ0{6tfzE&n2q:]` is a password in brackets.)
188
205
  if (/^\s*(?:\{.*\}|\[.*\])\s*$/s.test(value) &&
@@ -190,6 +207,8 @@ export function isNamedSecret(name, raw, literal = false) {
190
207
  /^\s*(?:\{\s*(?:["'][^"']*["']|[A-Za-z_$][\w$-]*)\s*:|\[\s*(?:["'{[\d-]|true\b|false\b|null\b))/.test(value))
191
208
  return false;
192
209
  const last = lastWord(name);
210
+ if (!/^\s*["'`]/.test(raw) && isNumberedSelf(name, value))
211
+ return false;
193
212
  // A PIN is a few digits (`pin: GPIO21` is a hardware pin; a postal PIN code has an address around it).
194
213
  // (Grouped digits too: `4821-7730`.)
195
214
  // (A passcode of letters is a password: judged as one below.)
@@ -240,11 +259,14 @@ export function isNamedSecret(name, raw, literal = false) {
240
259
  !/^\s*["'`]/.test(raw) &&
241
260
  (/\s/.test(value) || /^[A-Z]{2,}$/.test(value) || /^\d+(?:[/.,]\d+)*%?$/.test(value)))
242
261
  return false;
243
- // A test runner's status, not a password: `PASS: tests/test_auth.py::test_login`, `PASS src/a.test.ts`.
262
+ // A test runner's status, not a password: `PASS: tests/test_auth.py::test_login`, `PASS src/a.test.ts`, a
263
+ // test's short or word-like name (`PASS: regress1`, `PASS: x86_64`). Random letters and digits of eight or more
264
+ // are an env file's password (`PASS=zq8lw2mxr5`), as under any other spelling of the name.
244
265
  if (/^PASS(?:ED)?$/.test(name) &&
245
266
  /^[\w./:\[\]-]+$/.test(value) &&
246
267
  (/test|spec|::|\/[\w-]+\.\w{1,4}$/i.test(value) ||
247
- !(/[A-Z]/.test(value) && /[a-z]/.test(value) && /\d/.test(value))))
268
+ (!(/[A-Z]/.test(value) && /[a-z]/.test(value) && /\d/.test(value)) &&
269
+ (value.length < 8 || naturalness(value) > -0.5))))
248
270
  return false;
249
271
  // A compound adjective about the password, not one: a sentence's word among its parts (isSentenceWord: `write-only`,
250
272
  // `per-user`, `auto-generated`) or an adjective's ending on its last (`case-sensitive`, `app-specific`,
@@ -326,12 +348,13 @@ export function isNamedSecret(name, raw, literal = false) {
326
348
  // Cache keys, object keys, content addresses: hashes with words or paths around them.
327
349
  // (Under a generic name a hex run beside a separator is one, whatever else is in it.)
328
350
  // (Pure hex of 32 or 40 under a bare `secret` or `token` is one: Facebook app secrets, webhook secrets;
329
- // 64 is a SHA-256, as Laravel Sanctum stores its tokens.)
351
+ // 64 is a SHA-256, as Laravel Sanctum stores its tokens. Under `secret`, any of 20 to 40: Pusher's app secrets are 20.)
352
+ const bare = name.replace(/^[-$@]+/, "");
330
353
  if (/[0-9a-f]{16,}/i.test(value) &&
331
354
  (/[-_/.:]/.test(value) ||
332
355
  (/^[0-9a-f]+$/i.test(value) &&
333
- !(/^(?:secret|token|tokens)$/i.test(name.replace(/^[-$@]+/, "")) &&
334
- (value.length === 32 || value.length === 40)))))
356
+ !((/^tokens?$/i.test(bare) && (value.length === 32 || value.length === 40)) ||
357
+ (/^secret$/i.test(bare) && value.length >= 20 && value.length <= 40)))))
335
358
  return false;
336
359
  // A path or an object key: a path's characters only, with a `.`, `_` or `-` base64 never has (`uploads/<id>.png`,
337
360
  // `_pending/<id>/transit`), or segments that are all names or words (`config/keys`, S3's `exports/2024/Q3`,
@@ -367,6 +390,16 @@ const CODE_VALUE_END = /[ \t,;&)\]}"'`<|│┃║]/;
367
390
  const SHELL_WORD_END = /[ \t;&|<>()"'`]/;
368
391
  /** The word after a quote that never closes, to its line's end. */
369
392
  const UNCLOSED_WORD = new RegExp(`^${unquotedValue(String.raw `[^\s"'\x60]`, 4, String.raw `[ \t]*\r?$`)}`, "m");
393
+ /** An HTML tag, opening, closing or empty, whole: `<br>`, `<br />`, `</td>`, `<span class="x">`. */
394
+ const MARKUP_TAG = /^<\/?[A-Za-z][\w-]{0,30}(?:[ \t]+[^<>\n]{0,160})?\/?>/;
395
+ /**
396
+ * Whether markup starts at `i`, after a value read from `from`: a whole tag, or the close of a comment opened before it
397
+ * on the line. (Markup after an unquoted value is the page's: `Password: Kx9…<br />`, `<!-- DB_PASSWORD=Kx9…-->`.)
398
+ */
399
+ function markupAt(text, i, from) {
400
+ return ((text[i] === "<" && MARKUP_TAG.test(text.slice(i, i + 200))) ||
401
+ (text.startsWith("-->", i) && text.lastIndexOf("<!--", i) >= lineStartAt(text, from)));
402
+ }
370
403
  /** Reads the value starting at `at`: quoted, JSON-escaped-quoted, or bare up to a delimiter. */
371
404
  function readValue(text, at, { toEol = false, shell = false, toBlank = false } = {}) {
372
405
  const c = text[at];
@@ -433,6 +466,10 @@ function readValue(text, at, { toEol = false, shell = false, toBlank = false } =
433
466
  }
434
467
  if (ch === "\\" && isEscapedBreak(text, i))
435
468
  break;
469
+ // (A comment's close after the value is the page's: `<!-- DB_PASSWORD=Kx9…-->`. A tag may stand for a line break
470
+ // between a label and its value, `Secret key<br>Kx9…`: the readers of labels read on through it.)
471
+ if (i > at && open === 0 && text.startsWith("-->", i) && markupAt(text, i, at))
472
+ break;
436
473
  if (ch === "{" && text[i + 1] === "{")
437
474
  expansion = true;
438
475
  if (ch === "(" || ch === "[" || ch === "{") {
@@ -492,10 +529,41 @@ function namedValue(out, name, value, rule, { judgedAs = name, confidence = 0.95
492
529
  return;
493
530
  namedFinding(out, value.start, value.end, name, rule, confidence);
494
531
  }
495
- /** An encoding's name, which a converter takes beside the value it converts: `Buffer.from(key, "base64")`. */
496
- const ENCODING = /^(?:utf-?8|utf-?16(?:le|be)?|latin-?1|ascii|iso-?8859-?\d+|cp\d+|base64|hex|binary|ucs-?2)$/i;
532
+ /** A plural credential's name (`passwords`, `secrets`, `api_keys`): an object under it maps names to credentials. */
533
+ const isPluralCredential = (name) => /[^s]s$/i.test(name) && isCredentialName(name);
534
+ /**
535
+ * Values in a JSON object under a plural credential's name, keyed by whose or which they are (`{"securityKeys":
536
+ * {"S0_Legacy": …}}`, `{"secrets": {"prod": …}}`): each is judged under the parent's name, as a list's items are under
537
+ * the list's. Only when every value in it is a string and a secret under that name: an object of settings,
538
+ * `"secrets": {"enabled": "true"}`, holds other things; nor a lower-case word, a setting's choice more often than a
539
+ * password.
540
+ */
541
+ function parentMaps(text, out) {
542
+ if (!text.includes("{") || !/(?:words|wds|secrets|credentials|tokens|keys|phrases)"[ \t]*:[ \t]*\{/i.test(text))
543
+ return;
544
+ for (const doc of jsonDocuments(text)) {
545
+ const walk = (v) => {
546
+ if (v.kind === "array")
547
+ v.items.forEach(walk);
548
+ if (v.kind !== "object")
549
+ return;
550
+ for (const [k, x] of v.entries) {
551
+ if (x.kind !== "object" || !isPluralCredential(k) || !x.entries.every(([, y]) => y.kind === "string")) {
552
+ walk(x);
553
+ continue;
554
+ }
555
+ const values = x.entries.filter(([key]) => !isCredentialName(key)).map(([, y]) => y);
556
+ if (values.length && values.every((y) => !/^\p{Ll}+$/u.test(y.value) && isNamedSecret(k, `"${y.value}"`, true)))
557
+ for (const y of values)
558
+ namedFinding(out, y.start, y.end, k, "named:parent-map", 0.85);
559
+ }
560
+ };
561
+ walk(doc);
562
+ }
563
+ }
497
564
  /** The rules, each a reader of one syntax; findNamed runs them all. */
498
565
  const RULES = [
566
+ parentMaps,
499
567
  assignments,
500
568
  flags,
501
569
  xmlElements,
@@ -573,6 +641,11 @@ function explainedByNeighbours(text, f) {
573
641
  if (value && value.length <= 32) {
574
642
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
575
643
  const stem = /^(.+?[-_.])(?:pass(?:word)?|passwd|pwd|pw)$/i.exec(value)?.[1];
644
+ // (Only where the value is written again near it, or there is a stem: the regular expressions below, built for
645
+ // each name and value, are too slow to build for every value of a long config.)
646
+ const lower = near.toLowerCase();
647
+ if (!stem && lower.indexOf(value.toLowerCase()) === lower.lastIndexOf(value.toLowerCase()))
648
+ return false;
576
649
  const esc = stem ? `(?:${escape(value)}|${escape(stem)}user(?:name)?)` : escape(value);
577
650
  const others = near.replace(new RegExp(String.raw `[\w.-]*${escape(name)}["']?[ \t]*[:=][^\n]*`, "g"), "");
578
651
  if (new RegExp(String.raw `(?<![\w.-])(?:[\w.-]*[_.-])?(?:user(?:name)?|login|db|database|dbname|name|account)["']?[ \t]*[:=][ \t]*["'\`]?${esc}["'\`]?(?:[\s,;]|$)`, "i").test(others))
@@ -604,17 +677,66 @@ function urlTokens(text, f) {
604
677
  return parts.length ? parts.map((p) => ({ ...f, start: f.start + p.start, end: f.start + p.end })) : [f];
605
678
  }
606
679
  /**
607
- * SQL rows, each value under its column's name: `INSERT INTO users (email, password) VALUES ('ann@x.io', 'V'), (…)`.
608
- * (`UPDATE … SET password = 'V'` is an assignment; a psql grid, a table.)
680
+ * SQL rows, each value under its column's name: `INSERT INTO users (email, password) VALUES ('ann@x.io', 'V'), (…)`,
681
+ * and pg_dump's `COPY users (email, password) FROM stdin;` rows. A key/value table's value is under the name its row
682
+ * gives it, as a JSON object's `{"name": …, "value": …}` is: `INSERT INTO wp_options (option_name, option_value) VALUES
683
+ * ('smtp_password', 'V')` (keyValueName). (`UPDATE … SET password = 'V'` is an assignment; a psql grid, a table.)
609
684
  */
610
685
  function sqlRows(text, out) {
611
- for (const { column, value } of insertCells(text, (_, columns) => columns.some(isCredentialName))) {
612
- if (value.literal && isCredentialName(column) && isNamedSecret(column, `'${value.literal.value}'`, true))
613
- namedFinding(out, value.literal.start, value.literal.end, column, "named:sql-row");
686
+ for (const { column, value, columns, row } of sqlCells(text, (_, columns) => columns.some(isCredentialName) || columns.some((c) => keyColumnOf(c, columns) >= 0))) {
687
+ if (!value.literal)
688
+ continue;
689
+ const keyColumn = isCredentialName(column) ? -1 : keyColumnOf(column, columns);
690
+ const name = keyColumn >= 0 ? row[keyColumn]?.literal?.value : column;
691
+ if (name &&
692
+ /^[\w.:/-]{1,100}$/.test(name) &&
693
+ isCredentialName(name) &&
694
+ isNamedSecret(name, `'${value.literal.value}'`, true))
695
+ namedFinding(out, value.literal.start, value.literal.end, name, "named:sql-row");
614
696
  }
615
697
  }
698
+ /**
699
+ * The column that names a key/value table's value column: `value` is named by `name`, `key` or another PAIR_NAME,
700
+ * `option_value` by `option_name`, `meta_value` by `meta_key`, `setting_value` by `setting_key`. -1 for none, and for a
701
+ * column that holds no value.
702
+ */
703
+ function keyColumnOf(column, columns) {
704
+ const stem = /^(.*?)[_.]?(?:value|val)$/i.exec(column)?.[1];
705
+ if (stem === undefined || !(stem || PAIR_VALUE.test(column)))
706
+ return -1;
707
+ return columns.findIndex((c) => (stem ? new RegExp(`^${stem}[_.]?(?:name|key)$`, "i").test(c) : PAIR_NAME.test(c)));
708
+ }
616
709
  /** An INI or TOML section header line: `[database]`, `[profile "prod"]`. */
617
710
  const INI_SECTION = /(?:^|\n)[ \t]*\[[\w. "'-]{1,80}\][ \t]*\r?(?=\n)/g;
711
+ /** A config file's line: `host = db.internal`, `port = 5432`, a value with no quote, bracket or `;` of code's. */
712
+ const CONFIG_PAIR = /^[ \t]*[A-Za-z_][\w.-]{0,60}[ \t]*=[ \t]*[^\s"'`([{;][^"'`()[\]{};]*\r?$/;
713
+ /**
714
+ * Whether the line at `lineStart` is in a config file with no `[section]` (a `.conf`, a `.properties`): the lines
715
+ * around it, up to a blank line, are `name = value` pairs, one at least besides it, and none holds code (a quote, a
716
+ * bracket, a `;`, a keyword). Its value is then a literal, brackets and all: `password = Winter(2025)!!`.
717
+ */
718
+ function isConfigBlock(text, lineStart) {
719
+ const around = [];
720
+ for (let at = lineStart, n = 0; at > 0 && n < 6; n++) {
721
+ const start = lineStartAt(text, at - 1);
722
+ const line = text.slice(start, at - 1);
723
+ if (!line.trim())
724
+ break;
725
+ around.push(line);
726
+ at = start;
727
+ }
728
+ for (let at = lineEndAt(text, lineStart) + 1, n = 0; at < text.length && n < 6; n++) {
729
+ const line = text.slice(at, lineEndAt(text, at));
730
+ if (!line.trim())
731
+ break;
732
+ around.push(line);
733
+ at += line.length + 1;
734
+ }
735
+ // (Comments are the file's own.)
736
+ around.splice(0, around.length, ...around.filter((l) => !/^[ \t]*[#;]/.test(l)));
737
+ return (around.some((l) => CONFIG_PAIR.test(l)) &&
738
+ around.every((l) => CONFIG_PAIR.test(l) && !/^[ \t]*(?:export|const|let|var|my|local|set)\b/.test(l)));
739
+ }
618
740
  /** A flag that sets an environment variable or a property, before `NAME=value`: `-e`, `--env`, `--build-arg`, `-D`, `--set`. */
619
741
  const ENV_FLAG = /(?:^|\s)(?:-e|--env|--build-arg|-D|--set|--set-string|--var|-var|env)[ \t]+["']?$/;
620
742
  /**
@@ -732,7 +854,7 @@ function assignments(text, out) {
732
854
  !sigil &&
733
855
  /^[ \t]*$/.test(before) &&
734
856
  !/^[A-Za-z_][\w.]*\((?![ \t]*-?\d+[ \t]*\))[^()\n]*\)[ \t]*\r?$/.test(text.slice(at, lineEndAt(text, at))) &&
735
- underSection(m.index);
857
+ (underSection(m.index) || isConfigBlock(text, lineStart));
736
858
  // `key: # a comment`: YAML has no value there.
737
859
  if (yamlLine && text[at] === "#")
738
860
  continue;
@@ -807,6 +929,14 @@ function assignments(text, out) {
807
929
  const raw = text.slice(span[0], span[1]);
808
930
  // A quoted value was read without its quotes; let the value tests see them.
809
931
  const quoted = span[0] > at;
932
+ // An unquoted word an operator follows is an operand, the value an expression's (OPERATOR_AFTER). (Code spaces its
933
+ // `=`; a shell's assignment doesn't, and its `&&` runs the next command: `DB_PASSWORD=Xk9… && mysql`.)
934
+ if (!quoted &&
935
+ op === "=" &&
936
+ !tight &&
937
+ /^[A-Za-z_$][\w$.]*$/.test(raw) &&
938
+ OPERATOR_AFTER.test(text.slice(span[1], span[1] + 8)))
939
+ continue;
810
940
  // Inside a call's or a signature's parentheses, an unquoted name before `)`, `,`, `|` or `=` is a parameter's type
811
941
  // or a variable: `decode(secret: Base32SecretKey): …`, Swift's and Kotlin's `login(password: pw)`.
812
942
  if (!quoted &&
@@ -846,9 +976,7 @@ function assignments(text, out) {
846
976
  continue;
847
977
  // In an interpolated string, the value's own (`f'user_id={cookie}'`, `$"Pwd={pw}"`) or the one the pair stands in
848
978
  // (`$"Pwd={pw};User={u}"`, `f"host={h} password={pw}xyz"`), the holes are code: the literal part must hold the secret.
849
- if ((/[fF$]/.test(prefix) || INTERPOLATED_OPEN.test(before)) &&
850
- /\{[^{}]*\}/.test(raw) &&
851
- !isNamedSecret(name, `"${raw.replace(/\{[^{}]*\}/g, "")}"`, literal))
979
+ if ((/[fF$]/.test(prefix) || INTERPOLATED_OPEN.test(before)) && holesOnly(name, raw, literal))
852
980
  continue;
853
981
  // (Basic auth's user info is a credential in both halves: Confluent's `basic.auth.user.info=<key>:<secret>`.)
854
982
  const colon = /user[._-]?info|basic/i.test(name) ? -1 : credentialPair(name, raw);
@@ -857,6 +985,44 @@ function assignments(text, out) {
857
985
  out[out.length - 1].literal = true;
858
986
  }
859
987
  }
988
+ /**
989
+ * The key of the object or mapping the entry at `at` sits in: before the nearest `{` it is inside (`mixpanel: {`,
990
+ * `"mixpanel": {`), or a YAML line indented less with nothing after its key (`mixpanel:`). Read within 2,000
991
+ * characters; undefined if none.
992
+ */
993
+ export function enclosingKey(text, at) {
994
+ const from = Math.max(0, at - 2000);
995
+ let depth = 0;
996
+ for (let i = at - 1; i >= from; i--) {
997
+ const c = text[i];
998
+ if (c === "}" || c === "]" || c === ")")
999
+ depth++;
1000
+ else if (c === "(" || c === "[") {
1001
+ if (depth-- === 0)
1002
+ return undefined;
1003
+ }
1004
+ else if (c === "{") {
1005
+ if (depth-- === 0)
1006
+ return /(?:^|[\s,{(])["']?([A-Za-z_$][\w$-]{0,60})["']?[ \t]*[:=][ \t]*$/.exec(text.slice(from, i))?.[1];
1007
+ }
1008
+ }
1009
+ // A YAML mapping: the first line above indented less, a key alone on it.
1010
+ const lineStart = lineStartAt(text, at);
1011
+ const indent = /^[ \t]*/.exec(text.slice(lineStart, at))[0].length;
1012
+ if (!/^[ \t]*$/.test(text.slice(lineStart, lineStart + indent)) || indent === 0)
1013
+ return undefined;
1014
+ for (let k = lineStart; k > from;) {
1015
+ const start = lineStartAt(text, k - 1);
1016
+ const line = text.slice(start, k - 1);
1017
+ k = start;
1018
+ if (!line.trim() || /^[ \t]*#/.test(line))
1019
+ continue;
1020
+ if (/^[ \t]*/.exec(line)[0].length >= indent)
1021
+ continue;
1022
+ return /^[ \t]*(?:-[ \t]+)?["']?([A-Za-z_][\w-]{0,60})["']?:[ \t]*\r?$/.exec(line)?.[1];
1023
+ }
1024
+ return undefined;
1025
+ }
860
1026
  /** An interpolated string opened and not closed (Python's `f"`, `rf'`, C#'s `$"`, `$@"`): what comes after is inside it. */
861
1027
  const INTERPOLATED_OPEN = /(?<![\w"'])(?:[rR]?[fF]|[fF][rR]|\$@?|@\$)(["'])(?:\\.|(?!\1)[^\\])*$/;
862
1028
  /**
@@ -880,7 +1046,7 @@ function isAssignedName(text, m, name) {
880
1046
  if ((!isCredentialName(name) && !(name === "PASS" && op === "=")) || isRandomToken(name))
881
1047
  return false;
882
1048
  if (/^pass$/i.test(name) &&
883
- isPassInEnglish(/(\p{L}+)[ \t]*["']?$/u.exec(text.slice(Math.max(0, m.index - 30), m.index + m[1].length))?.[1] ?? ""))
1049
+ isPassInEnglish(/([\p{L}\d]*\p{L})[ \t]*["']?$/u.exec(text.slice(Math.max(0, m.index - 30), m.index + m[1].length))?.[1] ?? ""))
884
1050
  return false;
885
1051
  // A `section/name` key is judged whole: `keystore/release_password` is a password, `checksum/secrets` a digest.
886
1052
  const section = text[m.index - 1] === "/"
@@ -995,6 +1161,15 @@ function listValue(text, at, yamlKey) {
995
1161
  }
996
1162
  /** Reads an assignment's value at `at` (after its operator, blanks and a literal's prefix). */
997
1163
  function assignedValue(text, at, { wholeLine, envLine, shell, pairQuote, logfmt = false, }) {
1164
+ // A URL's query parameter: its value ends where the URL's own reading of it does (urls.ts queryParams), at an angle
1165
+ // bracket, a quote or the next pair (`<a href=https://x.io/cb?token=…>reset</a>`, a Link header's `<…?access_token=…>`).
1166
+ // (Not a template's or a variable's, read whole below: `?token={{ .Token }}`, `?key=${API_KEY}`.)
1167
+ if (!/^[{$%]/.test(text[at] ?? "") &&
1168
+ QUERY_NAME_BEFORE.test(text.slice(Math.max(0, at - 80), at)) &&
1169
+ urlAround(text, at) >= 0) {
1170
+ const value = withoutUnopenedClosers(/^[^&#\s"'<>`\\]*/.exec(text.slice(at, at + MAX_VALUE))[0], ")");
1171
+ return value ? [at, at + value.length] : undefined;
1172
+ }
998
1173
  // `"NAME=value with spaces"`: to the pair's own closing quote, or a `\n` escape inside a JSON string. (Not a value
999
1174
  // quoted itself, `\"…\"` in a JSON string included.)
1000
1175
  if (pairQuote >= 0 && !/["'`]/.test(text[at] ?? "") && !(text[at] === "\\" && /["']/.test(text[at + 1] ?? ""))) {
@@ -1007,9 +1182,12 @@ function assignedValue(text, at, { wholeLine, envLine, shell, pairQuote, logfmt
1007
1182
  const end = esc >= 0 ? at + esc : close;
1008
1183
  // A query string or a connection string in quotes (`'a=1&b=2'`, `"Pwd=x;User Id=u"`): each value ends at the
1009
1184
  // next `&name=` or `;name=`.
1185
+ // (Not inside a bracket the value opened: `"DB_PASSWORD=@Microsoft.KeyVault(VaultName=kv;SecretName=db)"`.)
1010
1186
  const next = text.slice(at, end).search(/[&;][\w.-][\w. -]{0,59}=/);
1011
1187
  // (A shell command in quotes: `'PGPASSWORD=x psql -h db'`, the value is its first word.)
1012
- return next >= 0 ? [at, at + next] : shellWordValue(text, [at, end]);
1188
+ return next >= 0 && throughBrackets(text, at, at + next) === at + next
1189
+ ? [at, at + next]
1190
+ : shellWordValue(text, [at, end]);
1013
1191
  }
1014
1192
  }
1015
1193
  // A string in parentheses, as Black wraps a long one: `DATABASES_PASSWORD = (\n "…"\n)`.
@@ -1055,18 +1233,8 @@ function assignedValue(text, at, { wholeLine, envLine, shell, pairQuote, logfmt
1055
1233
  }
1056
1234
  return span;
1057
1235
  }
1058
- /**
1059
- * The literal a converter at `at` is given, the value it makes: `hexStringToUint8Array("…")`, `SecretStr("…")`,
1060
- * `bytes.fromhex("…")`, `SecureString("…")`. (Not a lookup's argument, a secret's name: Prefect's
1061
- * `Secret.load("db-password")`, `SecretStr.get(…)`; nor an encoding's name.)
1062
- */
1063
- function convertedLiteral(text, at) {
1064
- const converter = /^[\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}\(/.exec(text.slice(at, at + 120));
1065
- if (!converter || /\.(?:load|get|fetch|read|lookup|retrieve|resolve|access)\w*\($/i.test(converter[0]))
1066
- return undefined;
1067
- const literal = callArguments(text, at + converter[0].length)?.[0]?.literal;
1068
- return literal && literal.value.length >= 4 && !ENCODING.test(literal.value) ? literal : undefined;
1069
- }
1236
+ /** A query parameter's start and name, and its `=`, ending a text. */
1237
+ const QUERY_NAME_BEFORE = new RegExp(String.raw `${PARAM_START}${PARAM_NAME}=$`);
1070
1238
  /** Inside a `--set` argument (helm, helmfile), after a key and its `=`: the pairs before it, then this one's key. */
1071
1239
  const SET_LIST = /--set(?:-string|-file|-json|-literal)?[ \t=]+["']?(?:[^\s"',]*,)*[^\s"',=]+=$/;
1072
1240
  /** Commands an environment variable is set for (`PGPASSWORD=x psql …`): what tells a command line from a passphrase. */
@@ -1074,15 +1242,19 @@ const COMMAND_WORDS = /^(?:sudo|env|exec|time|nohup|psql|pg_dump|pg_restore|mysq
1074
1242
  /**
1075
1243
  * The value of an unquoted `NAME=value` that starts an env line, when a command follows it on the line: its first word,
1076
1244
  * up to a blank (read as a line, a password may hold `&`, `;` and `<` themselves: `Tr0ub4dor&3horse`). A command follows when the next word is an operator
1077
- * (`&&`, `;`, `|`) or the first ends with `;` or is a generated token, a command or a path comes next, or later words
1245
+ * (`&&`, `;`, `|`, a redirection) or the first ends with `;` or is a generated token, a command or a path comes next, or later words
1078
1246
  * are flags, `NAME=`s or tokens. Otherwise the line's words are the value (a dotenv passphrase, `correct horse battery staple`).
1079
1247
  */
1080
1248
  function shellWordValue(text, span) {
1081
1249
  const words = [...text.slice(span[0], span[1]).matchAll(/\S+/g)];
1082
1250
  if (words.length < 2)
1083
1251
  return span;
1084
- const first = words[0][0];
1085
- const [next, ...later] = words.slice(1).map((w) => w[0]);
1252
+ // (A shell expansion is one word with the blanks inside its brackets: `${DB_PASSWORD:-$(openssl rand -hex 16)}`.)
1253
+ const firstEnd = throughBrackets(text, span[0], span[0] + words[0][0].length);
1254
+ if (firstEnd >= span[1])
1255
+ return [span[0], firstEnd];
1256
+ const first = text.slice(span[0], firstEnd);
1257
+ const [next, ...later] = words.filter((w) => span[0] + w.index >= firstEnd).map((w) => w[0]);
1086
1258
  // (Or any program's name, `gh`, `vercel`, `mvn`, on a line of its own: not an env file's next line, nor a passphrase
1087
1259
  // of plain words, `correct horse battery staple`.)
1088
1260
  const lineAt = lineStartAt(text, span[0]);
@@ -1097,6 +1269,8 @@ function shellWordValue(text, span) {
1097
1269
  const command = /;$/.test(first) ||
1098
1270
  isRandomToken(first) ||
1099
1271
  /^(?:&&?|\|\|?|;)$/.test(next) ||
1272
+ // (A redirection: `> out.txt`, `>> log`, `2>&1`, `< in.sql`.)
1273
+ /^\d?(?:<|>>?)(?:&\d|\S*)$/.test(next) ||
1100
1274
  COMMAND_WORDS.test(next) ||
1101
1275
  program ||
1102
1276
  /^(?:\.{0,2}\/|~\/)\S|\.(?:sh|py|js|ts|rb|pl)$/.test(next) ||
@@ -1254,6 +1428,16 @@ function spacedPairs(text, out) {
1254
1428
  continue; // `htpasswd -nb admin pw`, `pwd && ls`: a command line
1255
1429
  if (/^(?:->|=>|::|\?\.|<-)/.test(raw))
1256
1430
  continue; // `LoginField.PASSWORD -> input.text`: a `when` arm, an operator
1431
+ // A declaration, the name its type's: `PasswordAuthentication auth = new PasswordAuthentication(…);`. (The variable's
1432
+ // value is the assignment's to read.)
1433
+ if (/^[A-Za-z_$][\w$]*(?:[ \t]+=[ \t]|[ \t]*=(?!=).*;[ \t]*$)/.test(raw))
1434
+ continue;
1435
+ if (/^\.{3,}[ \t]/.test(raw))
1436
+ continue; // `password ........ value`: a dot leader, whose value dotLeaders reads
1437
+ // A keyword of code after the name: a membership test, `CONF_PASSWORD in ota_conf`; a loop's or a condition's
1438
+ // modifier, Ruby's `Thread.pass while @thread.alive?`.
1439
+ if (/^(?:in|not in|instanceof|while|until|if|unless|or|and)[ \t]/.test(raw))
1440
+ continue;
1257
1441
  // A note or a type in brackets first (`password (optional)`, `token [required]`, a docstring's `password (str|None):`),
1258
1442
  // a member (`.field`), code in braces (`{ get; set; }`). (A value in parentheses and nothing else, `password (12:30)`,
1259
1443
  // is the prose rule's, which reads one: `the passwd (M34d0w)`.)
@@ -1378,15 +1562,31 @@ function conditionalBranches(text, out) {
1378
1562
  }
1379
1563
  }
1380
1564
  }
1381
- /** Android resources and similar: `<string name="api_secret">…</string>`, `<setting name="X"><value>…</value>`. */
1565
+ /**
1566
+ * An element named by its attribute, whatever its tag: Android resources' `<string name="api_secret">…</string>`,
1567
+ * `<setting name="X"><value>…</value>`, Hibernate's `<property name="hibernate.connection.password">…`, Spring's `<prop
1568
+ * key="…">`, Java's XML properties' `<entry key="…">`, Tomcat's `<Parameter name="…">`.
1569
+ */
1570
+ const XML_NAMED = new RegExp(String.raw `<([A-Za-z][\w:.-]{0,40})\b[^>\n]{0,100}?\b(?:name|key|Name|Key)=(["'])([\w.:-]{1,80})\2[^>\n]{0,200}>[ \t\r\n]{0,40}(?:<value>)?${XML_TEXT(4)}(?:<\/value>)?[ \t\r\n]{0,40}<\/\1>`, "dg");
1571
+ /** An element named by a `<name>` or `<key>` child before its `<value>`: Hadoop's `<property><name>…</name><value>…</value>`. */
1572
+ const XML_NAME_CHILD = new RegExp(String.raw `<(name|key)>[ \t]*([\w.:-]{1,120})[ \t]*<\/\1>[ \t\r\n]{0,200}<value>${XML_TEXT(4)}<\/value>`, "dg");
1573
+ /** Elements whose name is an attribute's or a child's, not their tag. */
1382
1574
  function xmlNamedValues(text, out) {
1383
- if (!text.includes('name="'))
1384
- return;
1385
- for (const m of text.matchAll(new RegExp(String.raw `<(string|item|setting|entry|add)\b[^>\n]{0,100}?\bname="([\w.-]{1,80})"[^>\n]{0,200}>[ \t\r\n]{0,40}(?:<value>)?${XML_TEXT(4)}(?:<\/value>)?[ \t\r\n]{0,40}<\/\1>`, "dg"))) {
1386
- const value = trimmed(elementText(m, 3));
1387
- if (value)
1388
- namedValue(out, m[2], value, "named:xml-name");
1389
- }
1575
+ if (/(?:name|key|Name|Key)=["']/.test(text))
1576
+ for (const m of text.matchAll(XML_NAMED)) {
1577
+ // (An HTML anchor's name is a place in the page, not a setting's: `<a name="example-jwe-password">`.)
1578
+ if (/^a$/i.test(m[1]))
1579
+ continue;
1580
+ const value = trimmed(elementText(m, 4));
1581
+ if (value)
1582
+ namedValue(out, m[3], value, "named:xml-name");
1583
+ }
1584
+ if (text.includes("<value>"))
1585
+ for (const m of text.matchAll(XML_NAME_CHILD)) {
1586
+ const value = trimmed(elementText(m, 3));
1587
+ if (value)
1588
+ namedValue(out, m[2], value, "named:xml-name");
1589
+ }
1390
1590
  }
1391
1591
  /** A dot-leader row: the label (group 1), then the rest of the line (group 2). */
1392
1592
  const DOT_LEADER = new RegExp(String.raw `^(?![ \t]*test[ \t]).{0,300}?(?<![\w:./-])([A-Za-z_][\w-]{0,80})[ \t]\.{3,300}[ \t]{1,5}(?=\S)${unquotedValue("[^\\n]", 1, String.raw `[ \t]*\r?$`)}`, "dgm");
@@ -1490,7 +1690,7 @@ function jsonValues(text, out) {
1490
1690
  if (!PAIR_VALUE_KEY.test(text))
1491
1691
  return;
1492
1692
  for (const doc of jsonDocuments(text)) {
1493
- const walk = (v, sensitive) => {
1693
+ const walk = (v, sensitive, parent) => {
1494
1694
  if (v.kind === "array")
1495
1695
  for (const x of v.items)
1496
1696
  walk(x, sensitive);
@@ -1503,7 +1703,9 @@ function jsonValues(text, out) {
1503
1703
  (sens?.kind === "literal" && sens.value === true) ||
1504
1704
  (type?.kind === "string" &&
1505
1705
  /^(random_password|tls_private_key|random_string|aws_iam_access_key)$/.test(type.value));
1506
- const sibling = v.entries.find(([key, x]) => PAIR_NAME.test(key) && x.kind === "string")?.[1];
1706
+ // (With no name beside it, the value's object is named by its key: Spring's actuator `/env`, `"DB_PASSWORD": {
1707
+ // "value": …, "origin": … }`, a `terraform output -json` output's.)
1708
+ const sibling = v.entries.find(([key, x]) => PAIR_NAME.test(key) && x.kind === "string")?.[1] ?? (parent === undefined ? undefined : { value: parent });
1507
1709
  for (const [k, x] of v.entries) {
1508
1710
  if (x.kind === "string" && PAIR_VALUE.test(k) && !/^\s*\{/.test(x.value)) {
1509
1711
  const quoted = JSON.stringify(x.value);
@@ -1512,7 +1714,7 @@ function jsonValues(text, out) {
1512
1714
  namedFinding(out, x.start, x.end, sibling?.value ?? "secret", "named:json-sibling", 0.95);
1513
1715
  }
1514
1716
  else
1515
- walk(x, marked && MARKED_FIELDS.test(k));
1717
+ walk(x, marked && MARKED_FIELDS.test(k), k);
1516
1718
  }
1517
1719
  };
1518
1720
  walk(doc, false);
@@ -1548,9 +1750,14 @@ function setterCalls(text, out) {
1548
1750
  // hasher's first argument is read all the same, `make_password("…", salt="…")`. Other keywords are options:
1549
1751
  // `set_password("…", commit=True)` is given its secret first.)
1550
1752
  const slots = args.filter((a) => a.keyword && (isCredentialName(a.keyword) || a.keyword === "value"));
1753
+ const judged = hasher ? "password" : callee;
1754
+ // (An f-string's holes are code, `token=f"test_{uuid4()}"`: its literal part must hold the secret.)
1755
+ const given = (a) => a?.literal &&
1756
+ !(/^(?:[\w$]+[ \t]*[=:][ \t]*)?(?:[rR]?[fF][rR]?|\$@?|@\$)["']/.test(a.text) &&
1757
+ holesOnly(judged, a.literal.value, true));
1551
1758
  for (const a of slots)
1552
- if (a.literal)
1553
- namedValue(out, hasher ? "password" : callee, a.literal, "named:setter");
1759
+ if (given(a))
1760
+ namedValue(out, judged, a.literal, "named:setter");
1554
1761
  if (slots.length && !hasher?.[1])
1555
1762
  continue;
1556
1763
  // The secret is the last argument, after a service and a user (`keyring.set_password("pgcli", key, passwd)` passes
@@ -1564,10 +1771,14 @@ function setterCalls(text, out) {
1564
1771
  else if (!hasher && /credentials?$/i.test(callee) && positional.length >= 2)
1565
1772
  secret = positional[1];
1566
1773
  // (Judged as the password a hasher is given, whatever its name reads as.)
1567
- if (secret?.literal)
1568
- namedValue(out, hasher ? "password" : callee, secret.literal, "named:setter");
1774
+ if (given(secret))
1775
+ namedValue(out, judged, secret.literal, "named:setter");
1569
1776
  }
1570
1777
  }
1778
+ /** An interpolated string's literal part holds no secret, its holes being code: `f"test_token_{uuid4()}"`, `$"key-{id}"`. */
1779
+ function holesOnly(name, raw, literal) {
1780
+ return /\{[^{}]*\}/.test(raw) && !isNamedSecret(name, `"${raw.replace(/\{[^{}]*\}/g, "")}"`, literal);
1781
+ }
1571
1782
  /**
1572
1783
  * Calls that hash or check a password. Given first (the group): Django's `make_password("…", salt, hasher)` and
1573
1784
  * `check_password("…", encoded)`, bcrypt's `hashpw(b"…", salt)` and `checkpw`, PHP's `password_hash("…",
@@ -1656,10 +1867,12 @@ function isWordpressKey(value) {
1656
1867
  }
1657
1868
  /**
1658
1869
  * A credential compared with a literal: `secure_compare(password, "…")`, `hmac.compare_digest(token, "…")`,
1659
- * `password.equals("…")`, `"…".equals(password)`, `if password == "…"`, a shell's `[ "$DB_PASSWORD" = "…" ]`.
1870
+ * `password.equals("…")`, `"…".equals(password)`, `if password == "…"`, a shell's `[ "$DB_PASSWORD" = "…" ]`; and a
1871
+ * test's chained assertion that it is one (`expect(cfg.password).toEqual("…")`,
1872
+ * `assertThat(cfg.getPassword()).isEqualTo("…")`).
1660
1873
  */
1661
1874
  function comparisons(text, out) {
1662
- if (!/[cC]ompare|[!=]=|equals|cmp|Equal|\[\[?[ \t]+"?\$/.test(text))
1875
+ if (!/[cC]ompare|[!=]=|equals|cmp|Equal|toBe\(|\.equal\(|\[\[?[ \t]+"?\$/.test(text))
1663
1876
  return;
1664
1877
  const variable = (a) => a && !a.literal ? /^(?:[\w.]*\.)?([A-Za-z_]\w{1,60})$/.exec(a.text)?.[1] : undefined;
1665
1878
  for (const { match, args } of calls(text, /(?<![\w$])(?:([A-Za-z_][\w$]{0,60})\.)?(secure_compare|compare_digest|timingSafeEqual|constant_time_compare|ConstantTimeCompare|safe_str_cmp|equals(?:IgnoreCase)?)[ \t]*\(/g)) {
@@ -1677,6 +1890,9 @@ function comparisons(text, out) {
1677
1890
  if (name && lit)
1678
1891
  namedValue(out, name, lit, "named:compare");
1679
1892
  }
1893
+ for (const { match, args } of calls(text, /(?<![\w$])(?:expect|assertThat|assert_that)\([ \t]*(?:[\w.]*\.)?(?:get(?=[A-Z]))?([A-Za-z_]\w{1,60})(?:\(\))?[ \t]*\)[ \t]*\.(?:to\.)?(?:toBe|toEqual|toStrictEqual|isEqualTo|equal|eq)\(/g))
1894
+ if (args.length === 1 && args[0].literal)
1895
+ namedValue(out, match[1], args[0].literal, "named:compare");
1680
1896
  // `name == "…"`, `"…" === name`, `"…".equals(name)`, `assertEquals("…", cfg.getPassword())`, and `[ "$NAME" = "…" ]` in a shell test.
1681
1897
  for (const m of text.matchAll(new RegExp(String.raw `(?<![\w.'"])\$?([A-Za-z_]\w{1,60})[ \t]*(?:===?|!==?)[ \t]*${quoted(2, 4, "\"'`")}|${quoted(4, 4, "\"'`")}(?:[ \t]*(?:===?|!==?)[ \t]*|\.equals(?:IgnoreCase)?\([ \t]*|[ \t]*,[ \t]*(?=[\w.]*get))(?:[\w.]*\.)?(?:get)?([A-Za-z_]\w{1,60})\b(?![.[])|\[\[?[ \t]+"?\$\{?([A-Za-z_]\w{1,60})\}?"?[ \t]+!?==?[ \t]+${quoted(8, 4)}`, "dg"))) {
1682
1898
  const name = m[1] ?? m[6] ?? m[7];
@@ -1706,12 +1922,16 @@ function formFills(text, out) {
1706
1922
  const after = /(?:\{[a-z]+\})*$/i.exec(value.value)[0].length;
1707
1923
  namedFinding(out, value.start + before, value.end - after, name, "named:form-fill");
1708
1924
  };
1925
+ // (After the text, an options object, `{ log: false }`, `{ delay: 100 }`, which says nothing about it.)
1926
+ const extras = (rest) => rest.every((a) => /^\{[\s\S]*\}$/.test(a.text));
1709
1927
  for (const { args } of calls(text, /\.(?:fill|type)\(/g))
1710
- if (args.length === 2)
1928
+ if (args.length >= 2 && args[1].literal && extras(args.slice(2)))
1711
1929
  fill(args[0].literal?.value, args[1].literal);
1712
- // The selector of the call the value is chained to: its last argument, `getByLabel('Password')`, `find_element(By.ID, "password")`.
1713
- for (const { match, args } of calls(text, new RegExp(String.raw `${quoted(1, 0)}[ \t]*\)[ \t]*\.(?:fill|type|send_keys|sendKeys|setValue|pressSequentially)\(`, "g"))) {
1714
- if (args.length === 1)
1930
+ // The selector of the call the value is chained to: its last literal argument, `getByLabel('Password')`,
1931
+ // `find_element(By.ID, "password")`, `findElement(By.id("password"))`, options after it (`getByLabel('Password', { exact: true })`) and calls between
1932
+ // (`.first()`, `.clear()`, `.nth(0)`).
1933
+ for (const { match, args } of calls(text, new RegExp(String.raw `${quoted(1, 0)}[ \t]*(?:,[ \t]*\{[^{}\n]{0,200}\}[ \t]*)?\)(?:[ \t]*\))?(?:[ \t]*\.[A-Za-z_]\w{0,30}\([^()"'\n]{0,40}\))*[ \t]*\.(?:fill|type|send_keys|sendKeys|setValue|pressSequentially)\(`, "g"))) {
1934
+ if (args.length >= 1 && extras(args.slice(1)))
1715
1935
  fill(match[2], args[0].literal);
1716
1936
  }
1717
1937
  }
@@ -1863,9 +2083,12 @@ const COPULA_LABELS = ["password", ...FOREIGN_PASSWORD_SPELLINGS, "ключ", "
1863
2083
  .join("|");
1864
2084
  /** The scripts a sentence in another language runs on into without a blank: a value ends at one. */
1865
2085
  const CJK_SCRIPTS = String.raw `[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]`;
1866
- /** Credential labels in other languages: 密码, パスワード, 비밀번호, пароль, contraseña, mot de passe, Passwort, … */
2086
+ /**
2087
+ * Credential labels in other languages: 密码, パスワード, 비밀번호, пароль, contraseña, mot de passe, Passwort, … and the
2088
+ * word that says whose it is before a CJK label (`root密码`).
2089
+ */
1867
2090
  // Labels in alphabets written with spaces start a word (`enclave` isn't `clave`); CJK labels follow other characters directly.
1868
- const FOREIGN = new RegExp(String.raw `(?<![\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}\p{N}_])(${[...FOREIGN_PASSWORD_SPELLINGS, ...FOREIGN_KEY_WORDS].join("|")})[ \t]*[::=][ \t]*["'「]?${unquotedValue(String.raw `[^\s"'」,。、;!?()]`, 4, String.raw `[\s"'」,。、;!?()]|${CJK_SCRIPTS}|$`)}`, "dgiu");
2091
+ const FOREIGN = new RegExp(String.raw `(?:(?<![\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}\p{N}_])|(?=${CJK_SCRIPTS}))(${[...FOREIGN_PASSWORD_SPELLINGS, ...FOREIGN_KEY_WORDS].join("|")})[ \t]*[::=][ \t]*["'「]?${unquotedValue(String.raw `[^\s"'」,。、;!?()]`, 4, String.raw `[\s"'」,。、;!?()]|${CJK_SCRIPTS}|$`)}`, "dgiu");
1869
2092
  /**
1870
2093
  * The same in a sentence, with the language's copula: `le mot de passe du service est …`, `a senha dele é …`,
1871
2094
  * `der DATADOG_API_KEY lautet …`, `пароль сервиса — …`, `パスワードは … です`, `他的密码是 …`. Up to three words
@@ -1906,8 +2129,8 @@ function foreignLabels(text, out) {
1906
2129
  namedFinding(out, start, start + value.length, foreignName(m[1]), "named:foreign-label", 0.9);
1907
2130
  }
1908
2131
  // A key's row in a table labelled in another language: `| REST API 키 | … |`, `| 密钥 | … |`.
1909
- for (const m of text.matchAll(new RegExp(String.raw `[│|][ \t]*([^│|\n]{0,40}?(?:키|密钥|秘钥|キー|ключ|clé|schlüssel|chave|clave))[ \t]*[│|][ \t]*([^\s│|]{8,${MAX_VALUE}})[ \t]*[│|]`, "dgi"))) {
1910
- if (/p[uú]blic|공개|公开|公開|öffentlich|публичн|javascript|js\b|native|네이티브|client/i.test(m[1]) ||
2132
+ for (const m of text.matchAll(new RegExp(String.raw `[│|][ \t]*([^│|\n]{0,40}?(?:키|密钥|秘钥|キー|(?<!\p{L})(?:ключ|clé|schlüssel|chave|clave)))[ \t]*[│|][ \t]*([^\s│|]{8,${MAX_VALUE}})[ \t]*[│|]`, "dgiu"))) {
2133
+ if (/p[uú]bli[cq]|공개|公开|公開|öffentlich|публичн|javascript|js\b|native|네이티브|client/i.test(m[1]) ||
1911
2134
  !isNamedSecret("api_key", `"${m[2]}"`, true))
1912
2135
  continue;
1913
2136
  namedFinding(out, m.indices[2][0], m.indices[2][1], "key", "named:foreign-label", 0.85);
@@ -1974,7 +2197,7 @@ function wrappedValues(text, out) {
1974
2197
  * `Dim password As String = "…"` (and `Private Shared …`), T-SQL's `DECLARE @password NVARCHAR(100) = N'…'`, C++'s
1975
2198
  * `std::string password{"…"}` and `auto password = std::string{"…"}`, CMake's `set(DB_PASSWORD "…")`, AppleScript's
1976
2199
  * `set thePassword to "…"`, R's `password <- "…"`; and a name and its value as two words of a command that sets one
1977
- * (`setx DB_PASSWORD …`, fish's `set -gx`, `launchctl setenv`, `npm config set`, `dotnet user-secrets set`, `wp config
2200
+ * (`setx DB_PASSWORD …` and `setx /M …`, fish's `set -gx` and `set --export`, `launchctl setenv`, `npm config set`, `dotnet user-secrets set`, `wp config
1978
2201
  * set`, `aws configure set`, `reg add … /v Password /d …`).
1979
2202
  */
1980
2203
  const DECLARATION_FORMS = [
@@ -1993,7 +2216,7 @@ const DECLARATION_FORMS = [
1993
2216
  [/<<?-[ \t]*["']/, String.raw `\b(\w+)[ \t]*<<?-[ \t]*(?=["'])`],
1994
2217
  [
1995
2218
  /\bset(?:x|env)?\b/i,
1996
- String.raw `(?:^|[;&|][ \t]*|\$[ \t]+)(?:setx|set(?:[ \t]+-[a-zA-Z]+)+|launchctl[ \t]+setenv|aws[ \t]+configure[ \t]+set|(?:npm|yarn|pnpm|git|wp|dotnet[ \t]+user-secrets|heroku)[ \t]+(?:config[ \t]+)?set)[ \t]+["']?([\w.:/@-]+?)["']?[ \t]+(?=\S)`,
2219
+ String.raw `(?:^|[;&|][ \t]*|\$[ \t]+)(?:setx(?:[ \t]+\/[A-Za-z]+)*|set(?:[ \t]+--?[a-zA-Z][a-zA-Z-]*)+|launchctl[ \t]+setenv|aws[ \t]+configure[ \t]+set|(?:npm|yarn|pnpm|git|wp|dotnet[ \t]+user-secrets|heroku)[ \t]+(?:config[ \t]+)?set)[ \t]+["']?([\w.:/@-]+?)["']?[ \t]+(?=\S)`,
1997
2220
  ],
1998
2221
  [
1999
2222
  /\breg(?:\.exe)?[ \t]+add[ \t]/i,
@@ -2016,21 +2239,28 @@ function declarationRegex(text) {
2016
2239
  return re;
2017
2240
  }
2018
2241
  /**
2019
- * Calls whose signature takes a secret, with no credential word in the callee: the argument at its place, judged as
2020
- * what it is. Basic auth's password (Go's `req.SetBasicAuth(user, pw)`, Ruby's and Rust's `basic_auth(user, pw)`,
2021
- * OkHttp's `Credentials.basic(user, pw)`, Python's `HTTPBasicAuth(user, pw)`), a JWT's signing secret
2022
- * (`jwt.sign(payload, secret)`, `jwt.encode(payload, key, …)`), an HMAC's key (`hmac.new(key, …)`,
2023
- * `crypto.createHmac("sha256", key)`, `OpenSSL::HMAC.hexdigest("SHA256", key, data)`).
2242
+ * Calls whose signature takes a secret, with no credential word in the callee: the argument at its place (the first
2243
+ * literal of the places listed), judged as what it is. Basic auth's password (Go's `req.SetBasicAuth(user, pw)`, Ruby's
2244
+ * and Rust's `basic_auth(user, pw)`, OkHttp's `Credentials.basic(user, pw)`, Jersey's
2245
+ * `HttpAuthenticationFeature.basic(user, pw)`, Python's `HTTPBasicAuth(user, pw)`), a JWT's signing secret
2246
+ * (`jwt.sign(payload, secret)`, `jwt.encode(payload, key, …)`, jjwt's `signWith(key)` and its older
2247
+ * `signWith(SignatureAlgorithm.HS512, secret)`), an HMAC's key (`hmac.new(key, …)`, `crypto.createHmac("sha256", key)`,
2248
+ * `OpenSSL::HMAC.hexdigest("SHA256", key, data)`).
2024
2249
  */
2025
2250
  const SECRET_ARGUMENTS = [
2026
- [/(?:SetBasicAuth|basic_auth|BasicAuth|HTTPBasicAuth|HttpBasicAuth|Credentials\.basic|basicAuth)$/, 1, "password"],
2027
- [/\bjwt\.(?:sign|encode|verify|decode)$|\bJWT\.(?:encode|decode)$|\bJwts?\.(?:sign|encode)$/i, 1, "secret_key"],
2028
- [/\bsignWith$/, 0, "secret_key"],
2251
+ [
2252
+ /(?:SetBasicAuth|basic_auth|BasicAuth|HTTPBasicAuth|HttpBasicAuth|(?:Credentials|[A-Za-z]*Auth[A-Za-z]*)\.basic|basicAuth)$/,
2253
+ [1],
2254
+ "password",
2255
+ ],
2256
+ [/\bjwt\.(?:sign|encode|verify|decode)$|\bJWT\.(?:encode|decode)$|\bJwts?\.(?:sign|encode)$/i, [1], "secret_key"],
2257
+ // (jjwt's `signWith(key)`, and before 0.10 `signWith(SignatureAlgorithm.HS512, secret)`: the algorithm first.)
2258
+ [/\bsignWith$/, [0, 1], "secret_key"],
2029
2259
  // (An HMAC's key: first in Python's `hmac.new(key, msg, sha256)` and `hmac.digest(key, msg, "sha256")`, pycryptodome's
2030
2260
  // `HMAC.new(key, msg)` and Ruby's `OpenSSL::HMAC.new(key, digest)`; second after the algorithm in `createHmac(alg, key)`
2031
2261
  // and Ruby's `HMAC.hexdigest(alg, key, data)`.)
2032
- [/\bhmac\.(?:new|digest|HMAC)$|\bHMAC\.new$/, 0, "secret_key"],
2033
- [/\bcreateHmac$|\bHMAC\.(?:hexdigest|digest)$/, 1, "secret_key"],
2262
+ [/\bhmac\.(?:new|digest|HMAC)$|\bHMAC\.new$/, [0], "secret_key"],
2263
+ [/\bcreateHmac$|\bHMAC\.(?:hexdigest|digest)$/, [1], "secret_key"],
2034
2264
  ];
2035
2265
  const SECRET_ARGUMENT_CALL = /(?<![\w$])((?:[A-Za-z_$][\w$]*\.){0,3}(?:SetBasicAuth|basic_auth|BasicAuth|HTTPBasicAuth|HttpBasicAuth|basic|basicAuth|sign|signWith|encode|decode|verify|new|createHmac|hexdigest|digest|HMAC))[ \t]*\(/g;
2036
2266
  function secretArguments(text, out) {
@@ -2041,7 +2271,7 @@ function secretArguments(text, out) {
2041
2271
  const rule = SECRET_ARGUMENTS.find(([pattern]) => pattern.test(callee));
2042
2272
  if (!rule)
2043
2273
  continue;
2044
- const literal = args[rule[1]]?.literal;
2274
+ const literal = rule[1].map((k) => args[k]?.literal).find((l) => l);
2045
2275
  if (literal)
2046
2276
  namedValue(out, rule[2], literal, "named:secret-argument", { judgedAs: rule[2] });
2047
2277
  }
@@ -2128,16 +2358,20 @@ function userPassProse(text, out) {
2128
2358
  if (isProseValue(m[2]))
2129
2359
  namedFinding(out, m.indices[2][0], m.indices[2][1], "password", "named:prose-pair", 0.85);
2130
2360
  }
2131
- for (const m of text.matchAll(new RegExp(String.raw `(?<![/\w-])(?:creds?|credentials|login|logins|wifi|wi-fi|account|user\/pass|u\/p)[ \t]{0,3}[:=-]?[ \t]{1,3}[\x60'"]?([\w.@+-]{1,60})[\x60'"]?[ \t]{0,3}\/[ \t]{0,3}[\x60'"]?${unquotedValue(String.raw `[^\s\x60'"/]`, 4, String.raw `[\x60'"]?(?=[.,;!)]*(?:\s|$))`)}[\x60'"]?`, "dgi"))) {
2132
- const pw = m[2];
2361
+ for (const m of text.matchAll(new RegExp(String.raw `(?<![/\w-])(creds?|credentials|login|logins|wifi|wi-fi|account|user\/pass|u\/p)[ \t]{0,3}[:=-]?[ \t]{1,3}[\x60'"]?([\w.@+-]{1,60})[\x60'"]?[ \t]{0,3}\/[ \t]{0,3}[\x60'"]?${unquotedValue(String.raw `[^\s\x60'"/]`, 4, String.raw `[\x60'"]?(?=[.,;!)]*(?:\s|$))`)}[\x60'"]?`, "dgi"))) {
2362
+ const pw = m[3];
2133
2363
  // (A module path's segment and its version, go.sum's `…/credentials v1.20.6/go.mod`, are no user and password.)
2134
- if (!isProseValue(pw) || /^\d+$/.test(pw) || /^(?:https?|www)\b/.test(pw) || /^v?\d+(?:\.\d+)+/.test(m[1]))
2364
+ if (!isProseValue(pw) || /^\d+$/.test(pw) || /^(?:https?|www)\b/.test(pw) || /^v?\d+(?:\.\d+)+/.test(m[2]))
2135
2365
  continue;
2136
2366
  // `login page / dashboard`, `account settings/billing`: words, not a password. (Read in one case: each break of
2137
2367
  // `QwZxKvBnMp` would score as a camelCase word's start.)
2138
2368
  if (/^\p{L}+$/u.test(pw) && naturalness(pw.toLowerCase()) > 0)
2139
2369
  continue;
2140
- namedFinding(out, m.indices[2][0], m.indices[2][1], "password", "named:prose-pair", 0.85);
2370
+ // An account's or a login's path names an organization's service, `account: acme/payments-api`, `login:
2371
+ // dev/staging-env`; a pair given as credentials or a wifi's may be a passphrase of such words.
2372
+ if (/^(?:account|logins?)$/i.test(m[1]) && /^[a-z]+(?:[-_.][a-z]+)+$/.test(pw))
2373
+ continue;
2374
+ namedFinding(out, m.indices[3][0], m.indices[3][1], "password", "named:prose-pair", 0.85);
2141
2375
  }
2142
2376
  }
2143
2377
  /**
@@ -2164,6 +2398,8 @@ const CREDENTIAL_COLUMN = new RegExp(`${CREDENTIAL_WORD}|remember`, "i");
2164
2398
  const CREDENTIAL_SENTENCE = new RegExp(`${CREDENTIAL_WORD}|code`, "i");
2165
2399
  /** A header of a fixed-width table: column names apart by two or more blanks or a bar (`NAME DIGEST CREATED`, `┃ User ┃ Password ┃`). */
2166
2400
  const FIXED_WIDTH_HEADER = /^[ \t|│┃║]*[A-Za-z_][\w.-]*(?:(?:[ \t]{2,}|[ \t]*[|│┃║][ \t]*)[A-Za-z_][\w.-]*)+[ \t|│┃║]*\r?$/;
2401
+ /** A config file's directive aligned to its value: `rootdn "cn=Manager,dc=example,dc=com"`, `directory /var/lib/ldap`. */
2402
+ const CONFIG_DIRECTIVE = /^[ \t]*[A-Za-z_][\w.-]*(?:\t|[ ]{2,})[ \t]*[^\s|│┃║]/;
2167
2403
  /** A table's rule line: dashes, equals signs, pluses, bars and box drawing (`---- ----`, `+----+`, `├────┼────┤`, `╚═══╩═══╝`). */
2168
2404
  const TABLE_RULE = /^[ \t]*[-+=|\u2500-\u257f][-+=|\u2500-\u257f \t]*\r?$/;
2169
2405
  /** A fixed-width table's cells: runs of words with single blanks between, apart from the next by two blanks or a bar. */
@@ -2240,6 +2476,11 @@ function columnTables(text, out) {
2240
2476
  continue;
2241
2477
  if (isListingLine(header))
2242
2478
  continue;
2479
+ // A config file's directive and its value, not a header: slapd.conf's `rootpw secret` under `rootdn "cn=Manager,…"`,
2480
+ // a line of the same `name value` shape just above it. (Its credential's value is named.ts's to read, the lines
2481
+ // below it are the file's other directives.)
2482
+ if (CONFIG_DIRECTIVE.test(lines[i - 1] ?? "") && !FIXED_WIDTH_HEADER.test(lines[i - 1]))
2483
+ continue;
2243
2484
  const cols = [];
2244
2485
  for (const c of header.matchAll(/[A-Za-z_][\w.-]{0,60}/g))
2245
2486
  cols.push([c.index, c.index + c[0].length, c[0]]);
@@ -2344,8 +2585,9 @@ function javaProperties(text, out) {
2344
2585
  * Pairs whose name is a sibling attribute of the value, not its key: Kubernetes env (`- name: DB_PASSWORD` /
2345
2586
  * `value: …`), HCL (`name = "/prod/db/password"` … `value = "…"`), JSON and CloudFormation (`{"Name": "API_KEY",
2346
2587
  * "Value": "…"}`), HTML (`<input name="password" value="…">`). Siblings sit on one line, or in one block: later lines
2347
- * indented at least as far, with nothing between them that closes the block. A name that is a path or a dotted key
2348
- * names its value by its last part (`/prod/db/password`). (One reader for every syntax, so a value's name is found
2588
+ * indented at least as far, with nothing between them that closes the block. A name that is a path, a dotted key or
2589
+ * a form's nested field names its value by its last part (`/prod/db/password`, Rails' and PHP's `user[password]`).
2590
+ * (One reader for every syntax, so a value's name is found
2349
2591
  * the same way wherever it is written.)
2350
2592
  */
2351
2593
  function siblingPairs(text, out) {
@@ -2382,7 +2624,8 @@ function siblingPairs(text, out) {
2382
2624
  ;
2383
2625
  if (lineAt + indent === a.start && mapping.has(lineAt))
2384
2626
  continue;
2385
- attrs.push({ key: a.key, value: a.value.value, start: a.value.start, quoted: a.quoted, line, indent });
2627
+ const expression = a.operator === "=" && !a.quoted && /[ \t]/.test(text[a.value.start - 1]);
2628
+ attrs.push({ key: a.key, value: a.value.value, start: a.value.start, quoted: a.quoted, expression, line, indent });
2386
2629
  }
2387
2630
  attrs.sort((a, b) => a.start - b.start);
2388
2631
  for (let i = 0; i < attrs.length; i++) {
@@ -2394,7 +2637,12 @@ function siblingPairs(text, out) {
2394
2637
  continue;
2395
2638
  const credential = passwordInput
2396
2639
  ? "password"
2397
- : [named.value, named.value.split(/[/.:]/).filter(Boolean).at(-1) ?? ""].find((n) => /^[A-Za-z_][\w.\/:-]{0,100}$/.test(n) && isCredentialName(n));
2640
+ : [
2641
+ named.value,
2642
+ /^[A-Za-z_][\w-]*(?:\[[\w-]*\])*\[([\w-]+)\]$/.exec(named.value)?.[1] ??
2643
+ named.value.split(/[/.:]/).filter(Boolean).at(-1) ??
2644
+ "",
2645
+ ].find((n) => /^[A-Za-z_][\w.\/:-]{0,100}$/.test(n) && isCredentialName(n));
2398
2646
  if (!credential)
2399
2647
  continue;
2400
2648
  // The value attribute nearest it, before or after, in the same block.
@@ -2408,7 +2656,7 @@ function siblingPairs(text, out) {
2408
2656
  if (/[{}]|\n[ \t]*- /.test(between.slice(first.value.length)) ||
2409
2657
  (second.line > first.line && second.indent < first.indent))
2410
2658
  continue;
2411
- if (!v.value || !isNamedSecret(credential, v.quoted ? `"${v.value}"` : v.value, true))
2659
+ if (!v.value || !isNamedSecret(credential, v.quoted ? `"${v.value}"` : v.value, !v.expression))
2412
2660
  break;
2413
2661
  namedFinding(out, v.start, v.start + v.value.length, credential, "named:sibling-pair");
2414
2662
  break;
@@ -2589,7 +2837,17 @@ const PROSE_WORDS = new Set([
2589
2837
  "false",
2590
2838
  "true",
2591
2839
  ]);
2592
- const PROSE = new RegExp(String.raw `(?<![/.])\b([A-Za-z][\w-]{0,40}(?:[ \t][A-Za-z][\w-]{0,40})?)('s[ \t]{1,3}|[ \t]{1,3}(?:is|was|=|got (?:reset|changed|rotated|set) to|(?:has been |was )?(?:reset|changed|rotated|set|updated) to)(?:[ \t]{1,3}(?:now|still|currently|actually|just|set to))?[ \t]{0,3}:?[ \t]{1,3}|:[ \t]{1,3})(["'\x60*]{0,2})${unquotedValue(String.raw `(?:(?!\\[nrt])[^\s"'\x60])`, 4, String.raw `\3(?=[.,;!?)\]]{0,3}(?:[\s"'\x60]|\\n|$|<\/[a-zA-Z]))`)}\3?`, "dg");
2840
+ /**
2841
+ * The punctuation outside ASCII that ends a value in a sentence as a blank does: dashes, an ellipsis, and CJK's and
2842
+ * fullwidth punctuation (`Qm7vLx2kP9—don't share it`, `Qm7vLx2kP9,user is bob`).
2843
+ */
2844
+ const SENTENCE_STOPS = "—–…,。、;:!?";
2845
+ /**
2846
+ * A credential named in a sentence, and its value: the name (with, before a colon, whose it is: `Password for the
2847
+ * admin user:`), the verb or colon, and the value. (The verb phrases first: in `was reset to`, `was` alone would take
2848
+ * `reset` for the value.)
2849
+ */
2850
+ const PROSE = new RegExp(String.raw `(?<![/.])\b([A-Za-z][\w-]{0,40}(?:[ \t][A-Za-z][\w-]{0,40})?(?:[ \t]+(?:for|of)(?:[ \t]+[\w.@'-]{1,40}){1,4}(?=:))?)('s[ \t]{1,3}|[ \t]{1,3}(?:(?:has been |was )?(?:reset|changed|rotated|set|updated) to|got (?:reset|changed|rotated|set) to|is|was|=)(?:[ \t]{1,3}(?:now|still|currently|actually|just|set to))?[ \t]{0,3}:?[ \t]{1,3}|:[ \t]{1,3})(["'\x60*]{0,2})${unquotedValue(String.raw `(?:(?!\\[nrt])[^\s"'\x60${SENTENCE_STOPS}])`, 4, String.raw `\3(?=[.,;!?)\]]{0,3}(?:[\s"'\x60${SENTENCE_STOPS}]|\\n|$|<\/[a-zA-Z]))`)}\3?`, "dg");
2593
2851
  /** A credential named in a sentence: `my wifi password is …`, `API key: …`, `the token was …`. */
2594
2852
  function prose(text, out) {
2595
2853
  if (!CREDENTIAL_SENTENCE.test(text))
@@ -2597,6 +2855,11 @@ function prose(text, out) {
2597
2855
  // A passphrase or password quoted after a label: `Your passphrase (between double-quotes): "falcon tiger quartz"`.
2598
2856
  for (const m of text.matchAll(/\b(passphrase|pass phrase|mnemonic|recovery phrase|seed phrase)\b[^:\n"]{0,40}:[ \t]*(["'])([^"'\n]{8,200})\2/dgi)) {
2599
2857
  const value = m[3];
2858
+ // (A rule for one is no passphrase: a sentence's words among the phrase's, `"at least four words long"`, unless
2859
+ // every word is a BIP39 mnemonic's, whose list has `you` and `must` too.)
2860
+ const words = value.split(/[ -]/);
2861
+ if (words.some(isSentenceWord) && !words.every((w) => bip39().has(w)))
2862
+ continue;
2600
2863
  if (/\$\{|\{\{|^<|%s/.test(value) ||
2601
2864
  isInert(value) ||
2602
2865
  isPlaceholder(value) ||
@@ -2607,18 +2870,24 @@ function prose(text, out) {
2607
2870
  }
2608
2871
  for (const m of text.matchAll(PROSE)) {
2609
2872
  // (A preposition or a determiner before the name is the sentence's: `Cache restored from key: …` names a `key`.)
2610
- const words = m[1].split(/\s+/);
2611
- const name = words.length > 1 && PHRASE_STOP.test(words[0]) ? words[1] : m[1];
2873
+ // (Whose it is, `for the CI bot`, says nothing of what it is: the name is the noun before it.)
2874
+ const label = m[1].replace(/[ \t]+(?:for|of)[ \t].*$/, "");
2875
+ const words = label.split(/\s+/);
2876
+ const name = words.length > 1 && PHRASE_STOP.test(words[0]) ? words[1] : label;
2612
2877
  const before = words.length > 1
2613
2878
  ? words.at(-2)
2614
- : (/(\p{L}+)[ \t]*$/u.exec(text.slice(Math.max(0, m.index - 30), m.index))?.[1] ?? "");
2879
+ : (/([\p{L}\d]*\p{L})[ \t]*$/u.exec(text.slice(Math.max(0, m.index - 30), m.index))?.[1] ?? "");
2615
2880
  if (/^pass$/i.test(lastWord(name)) && isPassInEnglish(before))
2616
2881
  continue;
2617
2882
  if (isVerdictUnderPass(name, withoutUnopenedClosers(m[4])))
2618
2883
  continue;
2619
2884
  const last = lastWord(name);
2620
2885
  // (Cut at a quote inside its own brackets, the value runs on to their close: `Option<&'static str>`.)
2621
- const end = throughBrackets(text, m.indices[4][0], m.indices[4][1]);
2886
+ let end = throughBrackets(text, m.indices[4][0], m.indices[4][1]);
2887
+ // (Or ends where markup starts: `<td>Password: Kx9…<br>more</td>`.)
2888
+ for (let i = m.indices[4][0] + 1; i < end; i++)
2889
+ if (markupAt(text, i, m.indices[4][0]))
2890
+ end = i;
2622
2891
  const value = withoutUnopenedClosers(text.slice(m.indices[4][0], end));
2623
2892
  if (!isCredentialName(name.replace(/\s+/g, "_")) || /cookie/i.test(name) || clientSdkAt(text, m.index))
2624
2893
  continue;
@@ -2629,10 +2898,10 @@ function prose(text, out) {
2629
2898
  const bare = value.replace(/[.,;:!?]+$/, "").toLowerCase();
2630
2899
  // (A passphrase of words, cut at its first blank by the value's grammar: read whole, before any test of one word.)
2631
2900
  const phrase = takesAnyWord(name.replace(/\s+/g, "_"))
2632
- ? passphraseEnd(text, m.indices[4][0], m.indices[4][0] + value.length)
2633
- : 0;
2634
- if (phrase > m.indices[4][0] + value.length) {
2635
- namedFinding(out, m.indices[4][0], phrase, name, "named:prose", 0.85);
2901
+ ? passphraseSpan(text, m.indices[4][0], m.indices[4][0] + value.length)
2902
+ : undefined;
2903
+ if (phrase) {
2904
+ namedFinding(out, phrase[0], phrase[1], name, "named:prose", 0.85);
2636
2905
  continue;
2637
2906
  }
2638
2907
  const pin = isPinName(name.replace(/\s+/g, "_")) && /^\d{4,8}$/.test(value.replace(/[.,;:!?]+$/, ""));
@@ -2643,14 +2912,14 @@ function prose(text, out) {
2643
2912
  !isProseValue(value)))
2644
2913
  continue;
2645
2914
  // (After `=` the sentence is code: `user_password = Arcfour(key).decrypt(…)`; and so it is when code's punctuation
2646
- // follows, `password: proxyPass);`, or the value ends a literal's last entry (isLastEntry). A template's hole,
2915
+ // follows, `password: proxyPass);`, `{ confirmPassword: PwdConfirmationMode.Strict })`, or the value ends a literal's last entry (isLastEntry). A template's hole,
2647
2916
  // `{c.Password}`, `v2-cache-{{`, and a symbol, `[:public]`, aren't values.)
2648
2917
  // (A reference, a member's path or a call, or a type is code wherever it's written:
2649
2918
  // `pass: process.env.SMTP_PASSWORD`, `password: this.data.password`, `password: SecretStr`; a bare name isn't,
2650
2919
  // `The password is: BlueSecret`. A keyword's expression is judged with the rest of the line, as a blank cut it:
2651
2920
  // `password: lambda: secrets["db"]`.)
2652
2921
  if (((/=/.test(m[2]) ||
2653
- /^[);,]/.test(text.slice(end)) ||
2922
+ /^(?:[);,]|[ \t]+})/.test(text.slice(end)) ||
2654
2923
  (/^[ \t]*\r?$/.test(text.slice(end, lineEndAt(text, end))) && isLastEntry(text, m.index))) &&
2655
2924
  isCode(value)) ||
2656
2925
  isCode(value, true) ||
@@ -2659,6 +2928,9 @@ function prose(text, out) {
2659
2928
  continue;
2660
2929
  if (/\{\{|^\[?:[a-z_]\w*\]?$/.test(value) || (text[m.indices[4][0] - 1] === "{" && /\}$/.test(value)))
2661
2930
  continue;
2931
+ // (After `=`, an operand or the name's own sibling variable: `test1_pass = stages1 == stages2`, `x_pass = x_pass2`.)
2932
+ if (/=/.test(m[2]) && !m[3] && (OPERATOR_AFTER.test(text.slice(end, end + 8)) || isNumberedSelf(name, value)))
2933
+ continue;
2662
2934
  const start = m.indices[4][0];
2663
2935
  if (/^(PIN|SEED|MNEMONIC|PHRASE|SIG|SIGNATURE)$/.test(last) &&
2664
2936
  !isNamedSecret(name.replace(/\s+/g, "_"), `"${value}"`, true))
@@ -2688,8 +2960,9 @@ function prose(text, out) {
2688
2960
  namedFinding(out, start, withBangs(text, start + value.length), name, "named:prose", 0.85);
2689
2961
  }
2690
2962
  // Listed in a sentence: `user is ro_analytics, password R0ck3t#46, host …`; and the new value of a credential
2691
- // the sentence named: `ops rotated the db password yesterday, new one is Thund3r&55`.
2692
- for (const m of text.matchAll(/(?:^|[,;:(]\s*|\b(?:and|with)\s+)(password|passwd|pwd|pw|passphrase)\s+([^\s,;()"'`]{6,64}?)(?=[,;)]|\s+(?:and|host|user|db|port|for|account|role|warehouse|database|schema|on|at|in|via|to)\b|[.!]?\s*$)|\b(password|passwd|token|secret|api key|key)\b[^.\n]{0,80}?\b(?:new one|the new one|it)(?:'s| is| is now|’s)\s+["'`*]{0,2}([^\s"'`*,;]{6,64}?)["'`*]{0,2}(?=[.,;!?]?(?:\s|$))/dgim)) {
2963
+ // the sentence named: `ops rotated the db password yesterday, new one is Thund3r&55`, or the one before it: `I changed
2964
+ // the password. The new one is Thund3r&55`.
2965
+ for (const m of text.matchAll(/(?:^|[,;:(]\s*|\b(?:and|with)\s+)(password|passwd|pwd|pw|passphrase)\s+([^\s,;()"'`]{6,64}?)(?=[,;)]|\s+(?:and|host|user|db|port|for|account|role|warehouse|database|schema|on|at|in|via|to)\b|[.!]?\s*$)|\b(password|passwd|token|secret|api key|key)\b(?:[^.\n]{0,80}?|[^.!?\n]{0,80}[.!][ \t]+[^.!?\n]{0,20}?)\b(?:new one|the new one|it)(?:'s| is| is now|’s)\s+["'`*]{0,2}([^\s"'`*,;]{6,64}?)["'`*]{0,2}(?=[.,;!?]?(?:\s|$))/dgim)) {
2693
2966
  const k = m[2] !== undefined ? 2 : 4;
2694
2967
  const value = m[k];
2695
2968
  const noun = (m[1] ?? m[3]).toLowerCase();
@@ -2763,6 +3036,9 @@ function isProseValue(value) {
2763
3036
  // (Nor a required field's star: `password (required*)`.)
2764
3037
  const v = value.replace(/[.,;:!?)*]+$/, "");
2765
3038
  const lower = v.toLowerCase();
3039
+ // (A reference whose own bracket closes it is one before that's cut: `@Microsoft.KeyVault(SecretName=db)`.)
3040
+ if (isInert(value))
3041
+ return false;
2766
3042
  return !PROSE_WORDS.has(lower) && !COMMON_WORDS.has(lower) && !PROSE_CODE.test(v) && looksSecret(`"${v}"`, true);
2767
3043
  }
2768
3044
  /**
@@ -3095,35 +3371,43 @@ function isPassphrase(words) {
3095
3371
  !lower.every((w) => NSS_SOURCES.has(w)) &&
3096
3372
  !lower.some(isSentenceWord));
3097
3373
  }
3374
+ /** The quotes that close each of the paired ones typing and chat clients put around a phrase: `“…”`, `‘…’`, `«…»`, `„…“`. */
3375
+ const PAIRED_QUOTES = { "“": "”", "‘": "’", "«": "»", "„": "“" };
3098
3376
  /**
3099
- * Where a value under a password's or a passphrase's name ends, when its reader cut it at a blank: at its closing quote
3100
- * when quoted (`is "correct horse battery staple"`), or, unquoted, after the lower-case words that run to its line's end
3101
- * (`The wifi password is correct horse battery staple`; not `hunter2 and the user is bob`). Either way the words must be
3102
- * a passphrase's.
3377
+ * Where a passphrase under a password's or a passphrase's name is, when its reader cut the value at a blank: to its
3378
+ * closing quote when quoted (`is "correct horse battery staple"`, `is “lamp otter river stone”`, the paired quote read
3379
+ * as the value's first character), or, unquoted, the lower-case words that run to its line's end or to where the
3380
+ * sentence goes on after a comma, a dash or a bracket (`The wifi password is correct horse battery staple`, `is lamp
3381
+ * otter river stone, don't share it`; not `hunter2 and the user is bob`). Either way the words must be a
3382
+ * passphrase's. Undefined when there is none longer than the word read.
3103
3383
  */
3104
- function passphraseEnd(text, start, end) {
3384
+ function passphraseSpan(text, start, end) {
3105
3385
  const q = text[start - 1];
3106
- if (q === '"' || q === "'" || q === "`") {
3107
- const close = text.indexOf(q, end);
3386
+ const paired = PAIRED_QUOTES[text[start]];
3387
+ if (q === '"' || q === "'" || q === "`" || paired) {
3388
+ const from = paired ? start + 1 : start;
3389
+ const close = text.indexOf(paired ?? q, end);
3108
3390
  const lineEnd = text.indexOf("\n", end);
3109
3391
  return close > end &&
3110
3392
  close - start <= 200 &&
3111
3393
  (lineEnd < 0 || close < lineEnd) &&
3112
- isPassphrase(text.slice(start, close).trim().split(/\s+/))
3113
- ? close
3114
- : end;
3394
+ isPassphrase(text.slice(from, close).trim().split(/\s+/))
3395
+ ? [from, close]
3396
+ : undefined;
3115
3397
  }
3116
3398
  if (!/^[a-z]{2,20}$/.test(text.slice(start, end)))
3117
- return end;
3118
- const rest = /^(?:[ \t]+[a-z]{2,20}){2,11}(?=[.!]?[ \t]*(?:\r?\n|$))/.exec(text.slice(end, end + 300));
3119
- return rest && isPassphrase([text.slice(start, end), ...rest[0].trim().split(/\s+/)]) ? end + rest[0].length : end;
3399
+ return undefined;
3400
+ const rest = /^(?:[ \t]+[a-z]{2,20}){2,11}(?=[.!]?[ \t]*(?:\r?\n|$)|[ \t]*[,;(—–]|[ \t]+-[ \t]|[ \t]+:\))/.exec(text.slice(end, end + 300));
3401
+ return rest && isPassphrase([text.slice(start, end), ...rest[0].trim().split(/\s+/)])
3402
+ ? [start, end + rest[0].length]
3403
+ : undefined;
3120
3404
  }
3121
3405
  /**
3122
3406
  * A value as prose writes it: no spaces, up to its closing quote or emphasis, sentence punctuation, a blank, a closing
3123
3407
  * tag (`is **X**.`, `<p>… is X.</p>`) or an escaped line break (`… if -force is specified\n\` in a string). Not one in
3124
3408
  * parentheses, `set the password (self.pw) first`, `the password (required*)`: that is prose's own reading of `name (value)`.
3125
3409
  */
3126
- const PROSE_VALUE = String.raw `["'\x60*]{0,2}(?!\()${unquotedValue(String.raw `[^\s"'\x60]`, 4, String.raw `["'\x60*]{0,2}[.,;:!?]?(?:\s|$|<\/[a-zA-Z]|\\[nrt])`)}["'\x60*]{0,2}`;
3410
+ const PROSE_VALUE = String.raw `["'\x60*]{0,2}(?!\()${unquotedValue(String.raw `[^\s"'\x60${SENTENCE_STOPS}]`, 4, String.raw `["'\x60*]{0,2}[.,;:!?]?(?:[\s${SENTENCE_STOPS}]|$|<\/[a-zA-Z]|\\[nrt])`)}["'\x60*]{0,2}`;
3127
3411
  /**
3128
3412
  * Credentials named in prose with words in between, and in Markdown labels:
3129
3413
  * - `Our password for accessing the strategy documents is River@2025.`, `The API key utilized … was b2d1…`
@@ -3160,12 +3444,13 @@ function labelledProse(text, out) {
3160
3444
  // <url>`; checked before a passphrase is read on.)
3161
3445
  if (QUALIFIED_NOUN.test(text.slice(nounEnd, start)))
3162
3446
  return;
3163
- const phrase = /pass|phrase/i.test(noun) ? passphraseEnd(text, start, start + m[2].length) : start;
3164
- if (phrase > start + m[2].length) {
3165
- namedFinding(out, start, phrase, noun.replace(/\s+/g, "_"), "named:prose-label", 0.85);
3447
+ const phrase = /pass|phrase/i.test(noun) ? passphraseSpan(text, start, start + m[2].length) : undefined;
3448
+ if (phrase) {
3449
+ namedFinding(out, phrase[0], phrase[1], noun.replace(/\s+/g, "_"), "named:prose-label", 0.85);
3166
3450
  return;
3167
3451
  }
3168
- const v = m[2].replace(/[.,;:)]+$/, "");
3452
+ // (A string's escaped line break ends it, though the value's reading ran on through it: `"The secret is foo\n"`.)
3453
+ const v = m[2].replace(/(?:\\[nrt])+$/, "").replace(/[.,;:)]+$/, "");
3169
3454
  if (/^https?:\/\//.test(v) || !accept(noun, v))
3170
3455
  return;
3171
3456
  namedFinding(out, start, withBangs(text, start + v.length), noun.replace(/\s+/g, "_"), "named:prose-label", 0.85);
@@ -3189,8 +3474,8 @@ function labelledProse(text, out) {
3189
3474
  // `X's password, <value>, …` (apposition).
3190
3475
  for (const m of text.matchAll(new RegExp(String.raw `['’]s\s+(?:new |temporary |current )?(${CRED_NOUN}),\s+${PROSE_VALUE}`, "dgi")))
3191
3476
  add(m, m[1], m.indices[1][1]);
3192
- // Markdown: `**Password:** v`, `**Password for Access**: v`, `| API Key | v |`.
3193
- for (const m of text.matchAll(new RegExp(String.raw `\*\*([^*\n]{0,30}\b${CRED_NOUN}\b[^*\n]{0,30}?)[::]?\*\*[ \t]*[::]?[ \t]+${PROSE_VALUE}`, "dgi"))) {
3477
+ // Markdown: `**Password:** v`, `**Password for Access**: v`, `__Password:__ v`, `_Password:_ v`, `| API Key | v |`.
3478
+ for (const m of text.matchAll(new RegExp(String.raw `(?:\*\*|(?<!\w)__?)([^*\n]{0,30}(?<![A-Za-z\d])${CRED_NOUN}(?![A-Za-z\d])[^*\n]{0,30}?)[::]?(?:\*\*|__?(?!\w))[ \t]*[::]?[ \t]+${PROSE_VALUE}`, "dgi"))) {
3194
3479
  const noun = labelNoun(m[1]);
3195
3480
  if (m[1].split(/\s+/).length <= 6)
3196
3481
  add(m, noun[0], m.indices[1][0] + noun.index + noun[0].length);
@@ -3223,14 +3508,25 @@ function passwordShaped(text, out) {
3223
3508
  if (!PASSWORD_SYMBOL.test(text))
3224
3509
  return;
3225
3510
  // (A quoted value is read to its closing quote, and an unquoted one to a blank, its sentence's punctuation cut off
3226
- // after it but a password's own `!` and `?` kept: `Password "Abc1!" is too short`, `the password is Xk9mPq2&Rt7vLw4!`.)
3227
- for (const m of text.matchAll(new RegExp(String.raw `(?<![\p{L}\d_:/@.-])${PASSWORD_WORD}(?![\p{L}\d_-])[^\n.;!?=]{0,${SENTENCE_REACH}}?[ \t::](?:(["'\x60])([^\s"'\x60]{1,64})\1|«([^\s»]{1,64})»|\(([^\s)]{1,64})\)|([^\s"'\x60«][!-~]{5,63}))(?=\s|$|[、。,])`, "dgiu"))) {
3511
+ // after it but a password's own `!` and `?` kept: `Password "Abc1!" is too short`, `the password is Xk9mPq2&Rt7vLw4!`.
3512
+ // A word ending in a colon is a label's, whose it is, never the value: `Password for jenkins: Xk9mPq2&Rt7vLw4!`.)
3513
+ for (const m of text.matchAll(new RegExp(String.raw `(?<![\p{L}\d_:/@.-])${PASSWORD_WORD}(?![\p{L}\d_-])[^\n.;!?=]{0,${SENTENCE_REACH}}?[ \t::](?:(["'\x60])([^\s"'\x60]{1,64})\1|«([^\s»]{1,64})»|\(([^\s)]{1,64})\)|([^\s"'\x60«][!-~]{4,62}[!-9;-~]))(?=\s|$|[、。,])`, "dgiu"))) {
3228
3514
  // (Each opener with its own closer, so a quoted password may hold the others: `"Abc)1!"`, `«Mot(de)Passe1»`.)
3229
3515
  const k = [2, 3, 4, 5].find((i) => m[i] !== undefined);
3230
3516
  // (The sentence's punctuation and a bracket it closes, in either order: `(password: Xk9!mQ2z).`; a call's own
3231
3517
  // `)` stays. Nor quotes at its end that it didn't open: a string's around it, `"$(openssl passwd -6 …)"`.)
3232
- const value = withoutUnopenedClosers(m[k].replace(/(?<=^[^"'\x60]*)["'\x60]+$/, "").replace(/[.,;:]+$/, "")).replace(/[.,;:]+$/, "");
3233
3518
  const [start] = m.indices[k];
3519
+ // (Unquoted, it ends where markup starts: `<td>Password: Kx9!…<br />`.)
3520
+ let read = m[k];
3521
+ if (k === 5)
3522
+ for (let i = 1; i < read.length; i++)
3523
+ if (markupAt(text, start + i, start))
3524
+ read = read.slice(0, i);
3525
+ const value = withoutUnopenedClosers(read.replace(/(?<=^[^"'\x60]*)["'\x60]+$/, "").replace(/[.,;:]+$/, "")).replace(/[.,;:]+$/, "");
3526
+ // (Nor a part of a reference the colon stood inside: `password: arn:aws:secretsmanager:…:secret:rds!db-…`.)
3527
+ const word = /[^\s"'\x60]*$/.exec(text.slice(Math.max(0, start - 200), start))[0];
3528
+ if (k === 5 && word && isInert(word + value))
3529
+ continue;
3234
3530
  // (A bracket is a symbol a password may hold; a tag, a template's hole, a path of names or an escape is markup's,
3235
3531
  // code's or an encoding's; a pair or a flag with its value, `--pass="$P12Pass"`, or a list's first item,
3236
3532
  // `["Summer2024!",`, the readers' of those.)