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.
- package/README.md +11 -10
- package/dist/calls.d.ts +8 -0
- package/dist/calls.js +60 -3
- package/dist/checksums.js +4 -3
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +93 -14
- package/dist/context.d.ts +5 -2
- package/dist/context.js +36 -9
- package/dist/encoded.js +13 -6
- package/dist/environment.d.ts +5 -3
- package/dist/environment.js +38 -14
- package/dist/formats.js +15 -0
- package/dist/index.js +10 -4
- package/dist/json.js +29 -5
- package/dist/known.d.ts +7 -7
- package/dist/known.js +33 -25
- package/dist/named.d.ts +6 -0
- package/dist/named.js +421 -125
- package/dist/names.d.ts +5 -1
- package/dist/names.js +143 -18
- package/dist/normalize.d.ts +4 -1
- package/dist/normalize.js +37 -11
- package/dist/pem.d.ts +2 -0
- package/dist/pem.js +12 -0
- package/dist/policy.js +12 -5
- package/dist/public.js +35 -13
- package/dist/secretfile.d.ts +5 -0
- package/dist/secretfile.js +49 -20
- package/dist/shell.d.ts +12 -3
- package/dist/shell.js +51 -7
- package/dist/spans.d.ts +4 -3
- package/dist/spans.js +79 -8
- package/dist/sql.d.ts +12 -8
- package/dist/sql.js +94 -16
- package/dist/stream.d.ts +2 -1
- package/dist/stream.js +15 -3
- package/dist/structure.js +210 -61
- package/dist/syntax.d.ts +5 -2
- package/dist/syntax.js +7 -4
- package/dist/transcript.js +116 -28
- package/dist/unnamed.js +128 -20
- package/dist/urls.d.ts +8 -3
- package/dist/urls.js +16 -7
- package/dist/values.d.ts +2 -1
- package/dist/values.js +70 -25
- package/docs/holdout.svg +8 -8
- package/docs/layers.md +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
!(/^
|
|
334
|
-
(value.length
|
|
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
|
-
/**
|
|
496
|
-
const
|
|
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
|
-
*
|
|
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
|
|
612
|
-
if (value.literal
|
|
613
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
* (`&&`, `;`,
|
|
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
|
-
|
|
1085
|
-
const [
|
|
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
|
-
/**
|
|
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 (
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
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
|
|
1553
|
-
namedValue(out,
|
|
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
|
|
1568
|
-
namedValue(out,
|
|
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
|
|
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')`,
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
-
/**
|
|
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}?(
|
|
1910
|
-
if (/p[uú]
|
|
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
|
|
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
|
|
2020
|
-
* what it is. Basic auth's password (Go's `req.SetBasicAuth(user, pw)`, Ruby's
|
|
2021
|
-
*
|
|
2022
|
-
*
|
|
2023
|
-
* `
|
|
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
|
-
[
|
|
2027
|
-
|
|
2028
|
-
|
|
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 =
|
|
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-])(
|
|
2132
|
-
const pw = m[
|
|
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[
|
|
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
|
-
|
|
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
|
|
2348
|
-
* names its value by its last part (`/prod/db/password
|
|
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
|
-
|
|
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
|
-
: [
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2611
|
-
const
|
|
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}
|
|
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
|
-
|
|
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
|
-
?
|
|
2633
|
-
:
|
|
2634
|
-
if (phrase
|
|
2635
|
-
namedFinding(out,
|
|
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
|
-
|
|
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
|
|
3100
|
-
* when quoted (`is "correct horse battery staple"`
|
|
3101
|
-
*
|
|
3102
|
-
* a
|
|
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
|
|
3384
|
+
function passphraseSpan(text, start, end) {
|
|
3105
3385
|
const q = text[start - 1];
|
|
3106
|
-
|
|
3107
|
-
|
|
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(
|
|
3113
|
-
? close
|
|
3114
|
-
:
|
|
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
|
|
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+/)])
|
|
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}[.,;:!?]?(
|
|
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) ?
|
|
3164
|
-
if (phrase
|
|
3165
|
-
namedFinding(out,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.)
|