redac---n 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +323 -2
- package/THIRD_PARTY_NOTICES.md +59 -0
- package/dist/aho.d.ts +25 -0
- package/dist/aho.js +116 -0
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +29 -0
- package/dist/bip39-data.d.ts +2 -0
- package/dist/bip39-data.js +2 -0
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +40 -0
- package/dist/calls.d.ts +64 -0
- package/dist/calls.js +269 -0
- package/dist/charlm-data.d.ts +2 -0
- package/dist/charlm-data.js +3 -0
- package/dist/charlm.d.ts +7 -0
- package/dist/charlm.js +65 -0
- package/dist/checksums.d.ts +10 -0
- package/dist/checksums.js +98 -0
- package/dist/commands.d.ts +72 -0
- package/dist/commands.js +787 -0
- package/dist/context.d.ts +98 -0
- package/dist/context.js +469 -0
- package/dist/encoded.d.ts +11 -0
- package/dist/encoded.js +336 -0
- package/dist/environment.d.ts +10 -0
- package/dist/environment.js +62 -0
- package/dist/formats-data.d.ts +2 -0
- package/dist/formats-data.js +405 -0
- package/dist/formats.d.ts +5 -0
- package/dist/formats.js +470 -0
- package/dist/hmac.d.ts +3 -0
- package/dist/hmac.js +87 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +488 -0
- package/dist/json.d.ts +82 -0
- package/dist/json.js +392 -0
- package/dist/known.d.ts +34 -0
- package/dist/known.js +340 -0
- package/dist/lines.d.ts +28 -0
- package/dist/lines.js +69 -0
- package/dist/lookup.d.ts +5 -0
- package/dist/lookup.js +7 -0
- package/dist/model-data.d.ts +3 -0
- package/dist/model-data.js +4 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +19 -0
- package/dist/named.d.ts +20 -0
- package/dist/named.js +3257 -0
- package/dist/names.d.ts +106 -0
- package/dist/names.js +1517 -0
- package/dist/normalize.d.ts +25 -0
- package/dist/normalize.js +529 -0
- package/dist/pem.d.ts +32 -0
- package/dist/pem.js +269 -0
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +470 -0
- package/dist/public.d.ts +44 -0
- package/dist/public.js +362 -0
- package/dist/publickeys.d.ts +41 -0
- package/dist/publickeys.js +232 -0
- package/dist/random.d.ts +29 -0
- package/dist/random.js +165 -0
- package/dist/repeats.d.ts +21 -0
- package/dist/repeats.js +80 -0
- package/dist/rules.d.ts +42 -0
- package/dist/rules.js +58 -0
- package/dist/runs.d.ts +10 -0
- package/dist/runs.js +36 -0
- package/dist/secretfile.d.ts +52 -0
- package/dist/secretfile.js +722 -0
- package/dist/shell.d.ts +127 -0
- package/dist/shell.js +703 -0
- package/dist/spans.d.ts +61 -0
- package/dist/spans.js +287 -0
- package/dist/sql.d.ts +20 -0
- package/dist/sql.js +66 -0
- package/dist/stream.d.ts +46 -0
- package/dist/stream.js +150 -0
- package/dist/structure.d.ts +24 -0
- package/dist/structure.js +1625 -0
- package/dist/syntax.d.ts +156 -0
- package/dist/syntax.js +446 -0
- package/dist/transcript.d.ts +30 -0
- package/dist/transcript.js +713 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +1 -0
- package/dist/unnamed.d.ts +59 -0
- package/dist/unnamed.js +939 -0
- package/dist/urls.d.ts +70 -0
- package/dist/urls.js +303 -0
- package/dist/values.d.ts +178 -0
- package/dist/values.js +1621 -0
- package/dist/yaml.d.ts +40 -0
- package/dist/yaml.js +174 -0
- package/docs/creddata.svg +318 -0
- package/docs/guessing.md +139 -0
- package/docs/holdout.svg +85 -0
- package/docs/layers.md +80 -0
- package/docs/models.svg +81 -0
- package/docs/real-text.svg +112 -0
- package/package.json +57 -5
package/dist/named.js
ADDED
|
@@ -0,0 +1,3257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Values under a credential name, in the syntaxes tools print them: `NAME=value` (env, shell, ini,
|
|
3
|
+
* properties), `name: value` (YAML, headers), `"name": "value"` (JSON), `name = "value"` and
|
|
4
|
+
* `name: str = "value"` (code), `name => 'value'`, `--name value`, `<name>value</name>`,
|
|
5
|
+
* `name="value"` attributes, `NAME value` (redis.conf, Dockerfile ENV, tables), Kubernetes
|
|
6
|
+
* `- name:`/`value:` pairs, YAML block scalars and prose (`the password is …`).
|
|
7
|
+
*
|
|
8
|
+
* A value is hidden only if it's a literal secret: not a type, placeholder, reference or code (see
|
|
9
|
+
* values.ts). Generic names (`key`, `token`, `auth`) also need a value that looks generated, since
|
|
10
|
+
* `"key": "user.name"` and `token: bearer` are everywhere.
|
|
11
|
+
*/
|
|
12
|
+
import { callArguments, calls, closingBracket, literalEnd, literalAt, literalHead, } from "./calls.js";
|
|
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
|
+
import { lineEnd as lineEndAt, lineStart as lineStartAt, lowerBound, splitLines } from "./lines.js";
|
|
15
|
+
import { BIP39_WORDS } from "./bip39-data.js";
|
|
16
|
+
import { insertCells } from "./sql.js";
|
|
17
|
+
import { isUrl, queryParams, urls } from "./urls.js";
|
|
18
|
+
import { blockExtent, isBlockIndicator, yamlLines } from "./yaml.js";
|
|
19
|
+
import { naturalness } from "./charlm.js";
|
|
20
|
+
import { clientSdkAt, isPlaceholder } from "./public.js";
|
|
21
|
+
import { jsonDocuments } from "./json.js";
|
|
22
|
+
import { PROMPT, shellWordAt } from "./shell.js";
|
|
23
|
+
import { promptsForValue } from "./commands.js";
|
|
24
|
+
import { CREDENTIAL_QUALIFIERS, CREDENTIAL_WORD, phraseBefore, FOREIGN_PASSWORD_SPELLINGS, isCredentialName, PHRASE_STOP, isGenericName, readPhrase, isPasswordName, isPinName, nameClass, PASSWORD_SPELLINGS, readName, takesAnyWord, } from "./names.js";
|
|
25
|
+
import { ALGORITHM, CJK_COUNT, isBenignValue, isRandomToken, COMMON_WORDS, cleanValue, isBalanced, isCode, isGeneratedSegment, isPath, isGroupedDigits, isIdentifierShaped, isInert, hasThreeClasses, isMadePassword, isNameLike, isTypeExpression, isWordy, looksSecret, readsAsWords, } from "./values.js";
|
|
26
|
+
/** A name that could be a credential's: cheap lookahead so other names never start a match. */
|
|
27
|
+
const NAME = String.raw `((?=[\p{L}\p{N}_.$@-]{0,80}?(?:${CREDENTIAL_WORD}))[\p{L}_$@][\p{L}\p{N}_.$@-]{0,80})`;
|
|
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
|
+
// `\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");
|
|
32
|
+
const FLAG = /(?:^|[\s"'`(]|(?<=\\[nrt]))(--?[A-Za-z][\w-]{0,60})(?:[ \t]{1,4}|=)(?=[^\s-])/g;
|
|
33
|
+
/**
|
|
34
|
+
* An XML element's text, plain or in a CDATA section (Jenkins `credentials.xml`, Maven `settings.xml`, Android
|
|
35
|
+
* resources), as two groups from `n`: the section's content, or the plain text. elementText picks the one that matched.
|
|
36
|
+
*/
|
|
37
|
+
const XML_TEXT = (min) => String.raw `(?:<!\[CDATA\[([^\n]{${min},${MAX_VALUE}}?)\]\]>|([^<\n]{${min},${MAX_VALUE}}?))`;
|
|
38
|
+
const elementText = (m, n) => group(m, m[n] !== undefined ? n : n + 1);
|
|
39
|
+
const XML_ELEMENT = new RegExp(String.raw `<([\w:.-]{1,80})(?:\s[^<>]{0,200})?>${XML_TEXT(1)}<\/\1>`, "dg");
|
|
40
|
+
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
|
+
/** Prefixes tools put before a line: diff `+`/`-`, grep `path:12:`, `cat -n`/`nl` numbers, log timestamps. */
|
|
42
|
+
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
|
+
const YAML_LINE_START = new RegExp(String.raw `^[ \t]*${LP}(?:-[ \t]+)?["']?$`);
|
|
46
|
+
/** Keywords that declare a variable rather than qualify its name: `export TOKEN=…`, `let key = …`. */
|
|
47
|
+
const DECLARATION = /^(?:export|declare|local|readonly|typeset|const|let|var|val|final|static|private|protected|String|string|str|ENV|ARG|set|setenv)$/;
|
|
48
|
+
/** The last meaningful word of a name, for deciding how strict to be about its value. */
|
|
49
|
+
const lastWord = (name) => readName(name).last;
|
|
50
|
+
/**
|
|
51
|
+
* A length rule, as a form or a doc states one: a count or a range of characters (`8-64 characters`, `min. 12 chars`,
|
|
52
|
+
* `8–12 Zeichen`, `au moins 10 caractères`).
|
|
53
|
+
*/
|
|
54
|
+
const LENGTH_RULE = /^(?:(?:at least|min(?:imum)?\.?|max(?:imum)?\.?|up to|mindestens|maximal|au moins|al menos|pelo menos)[ \t]+)?\d{1,3}(?:[ \t]*(?:[-–]|to|bis|à|a)[ \t]*\d{1,3}|\+)?[ \t]*(?:characters?|chars?|digits?|letters?|symbols?|zeichen|stellen|caract[eè]res?|caratteri|tekens|znak(?:i|ów)|символ(?:а|ов)?)(?!\p{L})/iu;
|
|
55
|
+
/** A UI label or message rather than a password: a sentence, Title Case, a length rule, or the name repeated. */
|
|
56
|
+
function isLabel(value, name) {
|
|
57
|
+
if (LENGTH_RULE.test(value))
|
|
58
|
+
return true;
|
|
59
|
+
// Title Case words. (One capitalised word is a password as much as a label, `Sunshine`, `Großmutter`: a label of one
|
|
60
|
+
// word is "password" in some language, isPasswordLabel's. Words with no sentence's word among them are a passphrase:
|
|
61
|
+
// `Correct horse battery staple`.)
|
|
62
|
+
// (Or with another language's grammar among them, a translated label: `Bevestig mijn account`.)
|
|
63
|
+
if (/^\p{Lu}\p{Ll}+(?: \p{L}\p{Ll}*)+[.:!?]?$/u.test(value) &&
|
|
64
|
+
(!isPassphrase(value.split(" ")) || value.split(" ").some((w) => FOREIGN_FUNCTION_WORDS.has(w.toLowerCase()))))
|
|
65
|
+
return true;
|
|
66
|
+
// (Words in another language, a translated label: `Mot de passe oublié ?`, `Mindestens 8 Zeichen`. Not with a word
|
|
67
|
+
// that runs letters and digits together as no label's word does, a password of words: `héllo wörld 2291x`.)
|
|
68
|
+
if (/[^\x00-\x7f]/.test(value) &&
|
|
69
|
+
/^[\p{L}\p{M}\d .,!?'’()-]+$/u.test(value) &&
|
|
70
|
+
/ \p{L}/u.test(value) &&
|
|
71
|
+
!/\p{Ll}\d|\d\p{Ll}/u.test(value))
|
|
72
|
+
return true;
|
|
73
|
+
// A sentence; but a passphrase is words by design, and only its UI text says "enter" or "your".
|
|
74
|
+
const phrase = /PHRASE|MNEMONIC|SEED/.test(lastWord(name));
|
|
75
|
+
if (value.split(/\s+/).length >= 3 &&
|
|
76
|
+
(phrase
|
|
77
|
+
? /\b(enter|please|your|must|at least|invalid|incorrect|required|characters?)\b/i
|
|
78
|
+
: /\b(the|your|is|are|be|to|for|of|and|enter|please|must|at least|invalid|incorrect|required|characters?)\b/i).test(value))
|
|
79
|
+
return true;
|
|
80
|
+
const flat = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
81
|
+
// (Or the name's own end, as docs fill each option with its name: `--user user --key key --passphrase phrase`.)
|
|
82
|
+
return (flat(value) === flat(name) ||
|
|
83
|
+
(/^[\p{L} _.-]+$/u.test(value) &&
|
|
84
|
+
(flat(value).endsWith(flat(lastWord(name))) || (flat(value).length >= 4 && flat(name).endsWith(flat(value))))));
|
|
85
|
+
}
|
|
86
|
+
/** Names about how a credential is handled, not the credential: `update_password`, `password_mechanism`, `pin_mode`. */
|
|
87
|
+
const SETTING_OF_CREDENTIAL = /(?:^|[^a-z])(?:update|change|check|rotate|lock|expire|expiry|mechanism|method|mode|strategy|policy|type|format)(?:$|[^a-z])|(?:password|passwd)[_-]?(?:encryption|hash(?:ing)?[_-]?algorithm|algorithm|scheme)(?:$|[^a-z])/i;
|
|
88
|
+
/** Words near a PIN that make it a postal code. */
|
|
89
|
+
const POSTAL = /\b(?:city|state|address|street|road|district|postal|zip|country|landmark|locality)\b/i;
|
|
90
|
+
/**
|
|
91
|
+
* Whether `label` is the ID half of a `label:secret` value under credential name `name` (Dagster's `user:<token>`,
|
|
92
|
+
* a catalog's `<client id>:<secret>`): a name, a number, an email, a UUID, a hex ID or a short ID. Under a password's name the
|
|
93
|
+
* whole value is the password, and a label as random as its secret is part of it (`pR7kQ2mX9vL4nB8w:Ht6J…`).
|
|
94
|
+
*/
|
|
95
|
+
export function isPairLabel(name, label) {
|
|
96
|
+
if (takesAnyWord(name))
|
|
97
|
+
return false;
|
|
98
|
+
return (label.length <= 12 ||
|
|
99
|
+
readsAsWords(label) ||
|
|
100
|
+
/^\d+$|^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(label) ||
|
|
101
|
+
/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$|^[0-9a-f]{8,40}$/i.test(label));
|
|
102
|
+
}
|
|
103
|
+
/** Where the secret starts in a `label:token` value under a credential's name, or -1. */
|
|
104
|
+
function credentialPair(name, value) {
|
|
105
|
+
const m = /^([\w.@-]{1,64}):([A-Za-z0-9+/_~-]{16,4096}={0,2})$/.exec(value);
|
|
106
|
+
// (Not a digest's name, `sha256:<b64>`, as names.ts reads one, nor an encoding's or a version's: `hex:`, `v2:`.)
|
|
107
|
+
if (!m ||
|
|
108
|
+
isGenericName(name.replace(/^[-$@]+/, "")) ||
|
|
109
|
+
nameClass(m[1]) === "hash" ||
|
|
110
|
+
/^(?:sha3-\d+|xxh\d*|sri|v\d+|\d+|hex\w*|b64\w*|base64\w*|raw\w*|str\w*|utf-?8\w*)$/i.test(m[1]) ||
|
|
111
|
+
naturalness(m[2]) >= 0.5 ||
|
|
112
|
+
!isPairLabel(name, m[1]))
|
|
113
|
+
return -1;
|
|
114
|
+
return m[1].length + 1;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Cache keys, object keys, content addresses: a hash with words or paths around it (`linux-deps-<sha>`,
|
|
118
|
+
* `objects/<sha>`, `sha256:<hex>`, `v2-cache-<sha>`). Not a vendor's key, one short prefix and the hex (Firecrawl's
|
|
119
|
+
* `fc-<hex>`), nor a token with a hash beside it (a Rails cookie, `<base64>--<hmac>`): every other part is a number, a
|
|
120
|
+
* short word or one that reads as a name (`fabrikam`, `PaymentApiKey`; random letters score far lower).
|
|
121
|
+
*/
|
|
122
|
+
function isHashWithWords(value) {
|
|
123
|
+
if (!/[0-9a-f]{16,}/i.test(value) || !/[-_/.:|+~]/.test(value) || /^[a-z]{2,4}[-_][0-9a-f]{24,}$/i.test(value))
|
|
124
|
+
return false;
|
|
125
|
+
return value
|
|
126
|
+
.replace(/[0-9a-f]{16,}/gi, "")
|
|
127
|
+
.split(/[-_/.:@()=;?&|+~]+/)
|
|
128
|
+
.every((part) => part === "" ||
|
|
129
|
+
/^v?\d{1,8}$/i.test(part) ||
|
|
130
|
+
(part.length <= 24 &&
|
|
131
|
+
/^[A-Za-z]+\d{0,4}$/.test(part) &&
|
|
132
|
+
(part.length <= 4 || naturalness(part.toLowerCase()) > -0.5)));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether `pass` after the word `before` is the verb or the noun: after what a test run counts or an ordinal (`All tests
|
|
136
|
+
* pass:`, `First pass:`, `each pass`). After a thing it is a password's label (`wifi pass:`, `Redis pass:`, `His pass:`),
|
|
137
|
+
* after a type a variable (`char pass[]`).
|
|
138
|
+
*/
|
|
139
|
+
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);
|
|
141
|
+
}
|
|
142
|
+
/** 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
|
+
function isVerdictUnderPass(name, value) {
|
|
144
|
+
return /^pass$/i.test(name) && /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(value);
|
|
145
|
+
}
|
|
146
|
+
/** An entity key: `ORDER#2026-09-30#o_4411`, `USER#42`. */
|
|
147
|
+
const ENTITY_KEY = /^[A-Z][A-Z0-9_]{1,30}(?:#[\w.:@-]{1,60}){1,4}$/;
|
|
148
|
+
/** A partition key's name in a pair: `PK: 'CUSTOMER#c_8812'`, `"pk": …`, `partitionKey=`. */
|
|
149
|
+
const PARTITION_KEY = /(?:^|[\s{,"'])(?:PK|pk|partitionKey|hashKey)["']?[ \t]*[:=]/;
|
|
150
|
+
/**
|
|
151
|
+
* A value its name says is no credential's: a setting of how one is handled, a word or two in lower case (Ansible's
|
|
152
|
+
* `update_password: always`, `password_mechanism: sshpass`, `password_lock: false`) or a hashing scheme's name
|
|
153
|
+
* (PostgreSQL's `password_encryption = 'scram-sha-256'`); or a placeholder that names its own variable, with markup
|
|
154
|
+
* around it (`POSTGRES_PASSWORD=!!!SET POSTGRES_PASSWORD!!!`, `ELASTIC_PASSWORD=$ELASTIC_PASSWORD <1>`; not a token
|
|
155
|
+
* that starts with its name, `API_TOKEN_9fQ2mL…`, nor a password made of it, `password1!`, `password@123`).
|
|
156
|
+
*/
|
|
157
|
+
function isSettingOrSelfName(name, value) {
|
|
158
|
+
if (SETTING_OF_CREDENTIAL.test(name) &&
|
|
159
|
+
(/^[a-z]+(?:[_-][a-z]+)*$/.test(value) ||
|
|
160
|
+
/^(?:scram|sha|md5|argon2|bcrypt|scrypt|pbkdf2|crypt|ssha|hmac|plain)(?:[_-]?[a-z0-9]+)*$/i.test(value)))
|
|
161
|
+
return true;
|
|
162
|
+
if (name.length < 8 || !value.includes(name))
|
|
163
|
+
return false;
|
|
164
|
+
// (Markup only: a symbol, and fewer than four letters or digits besides the name, none after a name that starts the
|
|
165
|
+
// value: `password1!` is a password.)
|
|
166
|
+
const rest = value.split(name).join("");
|
|
167
|
+
const alnum = rest.match(/[A-Za-z0-9]/g)?.length ?? 0;
|
|
168
|
+
return /[^\w]/.test(rest) && alnum < 4 && !(value.startsWith(name) && alnum > 0);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether `value` under credential name `name` is a secret. Password-like names accept any literal
|
|
172
|
+
* that isn't a common word or label; key-like names need something that isn't a word or identifier.
|
|
173
|
+
*/
|
|
174
|
+
export function isNamedSecret(name, raw, literal = false) {
|
|
175
|
+
const value = cleanValue(raw);
|
|
176
|
+
// A shell's backticks run a command: `PASSWORD=\`openssl rand -hex 16\``.
|
|
177
|
+
if (/^`[a-z][\w.-]*\s[^`]*`$/.test(raw.trim()))
|
|
178
|
+
return false;
|
|
179
|
+
if (isVerdictUnderPass(name, value))
|
|
180
|
+
return false;
|
|
181
|
+
// A list of strings (`API_KEYS='["…", "…"]'`, `passwords:` over `- …` lines) is judged by its items: findNamed hides
|
|
182
|
+
// each that is a secret under the name.
|
|
183
|
+
const items = stringListItems(value);
|
|
184
|
+
if (items)
|
|
185
|
+
return items.some((item) => isNamedSecret(name, `"${item.value}"`, true));
|
|
186
|
+
// A JSON object or array as a string (`"SecretString": "{\"username\": …}"`): its own pairs are judged by name.
|
|
187
|
+
// (Starting as JSON does: `{"`, `{key:`, `[1`, `["`, `[{`; `[bZ0{6tfzE&n2q:]` is a password in brackets.)
|
|
188
|
+
if (/^\s*(?:\{.*\}|\[.*\])\s*$/s.test(value) &&
|
|
189
|
+
/[:,]/.test(value) &&
|
|
190
|
+
/^\s*(?:\{\s*(?:["'][^"']*["']|[A-Za-z_$][\w$-]*)\s*:|\[\s*(?:["'{[\d-]|true\b|false\b|null\b))/.test(value))
|
|
191
|
+
return false;
|
|
192
|
+
const last = lastWord(name);
|
|
193
|
+
// A PIN is a few digits (`pin: GPIO21` is a hardware pin; a postal PIN code has an address around it).
|
|
194
|
+
// (Grouped digits too: `4821-7730`.)
|
|
195
|
+
// (A passcode of letters is a password: judged as one below.)
|
|
196
|
+
if (isPinName(name) && (/^\d/.test(value) || !isPasswordName(name)))
|
|
197
|
+
return /^\d{4,8}$|^\d{2,8}(?:[- ]\d{2,8}){1,3}$/.test(value);
|
|
198
|
+
// (Under a password's name, `PurpleRain1984` unquoted in an INI file is a password, not a class name.)
|
|
199
|
+
const passwordName = takesAnyWord(name);
|
|
200
|
+
if (isSettingOrSelfName(name, value))
|
|
201
|
+
return false;
|
|
202
|
+
// (And digits with separators, a date or a phone number, are one: `DB_PASSWORD=21.05.1987`; an ISO date is a date, isGroupedDigits.)
|
|
203
|
+
if (passwordName && isGroupedDigits(value))
|
|
204
|
+
return true;
|
|
205
|
+
if (!(looksSecret(raw, literal) ||
|
|
206
|
+
(passwordName && /^[A-Z][A-Za-z0-9]{2,40}$/.test(value) && /\d/.test(value) && looksSecret(raw, true))) ||
|
|
207
|
+
isLabel(value, name))
|
|
208
|
+
return false;
|
|
209
|
+
if (/^(SEED|SALT)$/.test(last) && /^[\d.]+$/.test(value))
|
|
210
|
+
return false;
|
|
211
|
+
// A value under a signature's name is a signature (a payment callback's, a webhook delivery's, an RSA, ECDSA or
|
|
212
|
+
// Ed25519 one), public once sent; the secret that makes one is named a secret or a key. A `…Sig` constant of 32–64
|
|
213
|
+
// characters, a signing key's length, is more often an HMAC key. (Signed URLs are judged by their own rule.)
|
|
214
|
+
if (last === "SIGNATURE" || (last === "SIG" && value.length > 64))
|
|
215
|
+
return false;
|
|
216
|
+
if (/^(PREFIX|HINT|LAST4|SUFFIX)$/.test(last))
|
|
217
|
+
return false;
|
|
218
|
+
// A constant's name under a dictionary key (`brandKey=KSBrandID` in a plist log): letters in a name's case, in
|
|
219
|
+
// words (`zvCadApKxYVYHo` isn't), read apart where a word or an acronym starts (`KS Brand ID`).
|
|
220
|
+
if (!passwordName &&
|
|
221
|
+
/[a-z]Key$/.test(name) &&
|
|
222
|
+
/^[A-Za-z]{4,40}$/.test(value) &&
|
|
223
|
+
/[A-Z].*[A-Z]/.test(value) &&
|
|
224
|
+
readsAsWords(value) &&
|
|
225
|
+
naturalness(value.replace(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, " ")) > 0)
|
|
226
|
+
return false;
|
|
227
|
+
// Seeds, mnemonics and phrases worth hiding are wallet phrases (12+ words) or long random values;
|
|
228
|
+
// `level-seed=…`, `mnemonic: "MOVZX"`, `"phrase": "machine learning"` are not.
|
|
229
|
+
if (/^(SEED|MNEMONIC|PHRASE)$/.test(last) &&
|
|
230
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
231
|
+
return false; // a seed node's ID
|
|
232
|
+
if (/^(SEED|MNEMONIC|PHRASE)$/.test(last)) {
|
|
233
|
+
const n = value.trim().split(/\s+/).length;
|
|
234
|
+
return n >= 12 || (n === 1 && value.length >= 32 && naturalness(value) < 0.5);
|
|
235
|
+
}
|
|
236
|
+
if (passwordName) {
|
|
237
|
+
// A bare `pass` is also a verb and a test counter (`tests pass: AGPL`, `PASS =/2 binds variable`,
|
|
238
|
+
// `pass | 41/41`): one word with a digit or a symbol, and not a count.
|
|
239
|
+
if (/^[-$@]*pass$/i.test(name) &&
|
|
240
|
+
!/^\s*["'`]/.test(raw) &&
|
|
241
|
+
(/\s/.test(value) || /^[A-Z]{2,}$/.test(value) || /^\d+(?:[/.,]\d+)*%?$/.test(value)))
|
|
242
|
+
return false;
|
|
243
|
+
// A test runner's status, not a password: `PASS: tests/test_auth.py::test_login`, `PASS src/a.test.ts`.
|
|
244
|
+
if (/^PASS(?:ED)?$/.test(name) &&
|
|
245
|
+
/^[\w./:\[\]-]+$/.test(value) &&
|
|
246
|
+
(/test|spec|::|\/[\w-]+\.\w{1,4}$/i.test(value) ||
|
|
247
|
+
!(/[A-Z]/.test(value) && /[a-z]/.test(value) && /\d/.test(value))))
|
|
248
|
+
return false;
|
|
249
|
+
// A compound adjective about the password, not one: a sentence's word among its parts (isSentenceWord: `write-only`,
|
|
250
|
+
// `per-user`, `auto-generated`) or an adjective's ending on its last (`case-sensitive`, `app-specific`,
|
|
251
|
+
// `human-readable`, `system-wide`, `upper-case`). Two other words are a password: `blue-moon`, `dev-password`.
|
|
252
|
+
if (/^[a-z]{2,12}-[a-z]{2,12}$/.test(value) &&
|
|
253
|
+
value.split("-").every((p) => naturalness(p) > 0) &&
|
|
254
|
+
(value.split("-").some(isSentenceWord) || /(?:ive|able|ible|specific|wide|safe|free|case)$/.test(value)))
|
|
255
|
+
return false;
|
|
256
|
+
if (COMMON_WORDS.has(value.toLowerCase()) ||
|
|
257
|
+
(!/PHRASE/.test(last) && COMMON_WORDS.has(value.trim().split(/\s+/)[0].toLowerCase())))
|
|
258
|
+
return false;
|
|
259
|
+
// Lower-case words holding a sentence's word say where the password is or what it's like (`see vault entry`,
|
|
260
|
+
// `ask devops team`, `case sensitive`, `randomly generated`), as isPassphrase reads them. (A wallet's seed
|
|
261
|
+
// phrase, twelve words or more, is any words.)
|
|
262
|
+
if (/^[a-z]+(?:[ \t]+[a-z]+){1,10}[.!]?$/.test(value.trim()) && value.trim().split(/\s+/).some(isSentenceWord))
|
|
263
|
+
return false;
|
|
264
|
+
// (Or a password manager's name, where it's kept: `check 1password`, `in bitwarden`, `see the team's keepass`.)
|
|
265
|
+
if (/^[a-z' ]+$/.test(value.replace(/1password/gi, "onepassword").trim()) &&
|
|
266
|
+
/\b(?:1password|lastpass|bitwarden|keepass(?:xc)?|dashlane|keeper|keychain|vaultwarden)\b/i.test(value) &&
|
|
267
|
+
/\s/.test(value.trim()))
|
|
268
|
+
return false;
|
|
269
|
+
const maxWords = /PHRASE/.test(last) ? 24 : 6;
|
|
270
|
+
if (/\s/.test(value) && value.split(/\s+/).length > maxWords)
|
|
271
|
+
return false;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
// A URL under a credential name is a secret when its path carries a generated token (a webhook).
|
|
275
|
+
// (Its password is the URL reader's: structure.ts.)
|
|
276
|
+
if (isUrl(value))
|
|
277
|
+
return [...urls(value)].some((u) => [...u.segments, ...[...queryParams(value)].map((p) => p.value)].some((p) => isRandomToken(p.value)));
|
|
278
|
+
// `label:token` under a credential's name (Dagster's `user:<token>`, a catalog's `<client id>:<secret>`): a pair,
|
|
279
|
+
// not a cache key or a digest (`sha256:…`). The caller hides the part after the colon (credentialPair).
|
|
280
|
+
if (credentialPair(name, value) > 0)
|
|
281
|
+
return true;
|
|
282
|
+
if (isHashWithWords(value))
|
|
283
|
+
return false;
|
|
284
|
+
if (/^[-+]?\d*\.?\d+(?:e[-+]?\d+)?$/i.test(value))
|
|
285
|
+
return false; // numbers: ports, counts, floats, numeric session ids
|
|
286
|
+
// An Authorization value, a scheme and its credential, is judged by the credential (`Bearer <token>`); a scheme's
|
|
287
|
+
// word before more words is prose (`Bearer Token Usage [RFC6750].`).
|
|
288
|
+
const scheme = /^(?:Bearer|Basic|Token) (\S+)$/.exec(value);
|
|
289
|
+
if (scheme)
|
|
290
|
+
return isNamedSecret(name, /^\s*["'`]/.test(raw) ? `"${scheme[1]}"` : scheme[1], literal);
|
|
291
|
+
// Keys and tokens: generated values, not identifiers, words or sentences.
|
|
292
|
+
if (/\s/.test(value))
|
|
293
|
+
return false;
|
|
294
|
+
if (value.length < 8)
|
|
295
|
+
return false;
|
|
296
|
+
// A UUID under a credential name is a key (Heroku and others issue them), unless the name says ID.
|
|
297
|
+
// (A session's ID as a UUID or an object ID names a session, as Claude Code's `sessionId` does; a random one is its cookie.)
|
|
298
|
+
if (/session[_-]?id$|sessionId$|sess[_-]?id$/i.test(name) &&
|
|
299
|
+
(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) ||
|
|
300
|
+
/^[a-z]{1,8}_[A-Za-z0-9]{8,}$/.test(value)))
|
|
301
|
+
return false;
|
|
302
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
303
|
+
return !/ID$/.test(last) || /(?:^|[-_ ])secret[-_ ]?id$/i.test(name);
|
|
304
|
+
if (isIdentifierShaped(value))
|
|
305
|
+
return false;
|
|
306
|
+
// Words, and code made of them: `my-signing-config`, `env(DATABASE_URL)`, `CORP\svc_deploy`.
|
|
307
|
+
// (Mostly letters: on a value mostly of symbols the model reads nothing. And each word one: the model reads
|
|
308
|
+
// camel-case breaks and symbols as likely word ends, so `JujvAIngWfOqw` and `)AM{?NfoSuFiMi.s` would score as words.)
|
|
309
|
+
// (A name without symbols is one only outside a password rule's classes, as below: `WinterHarbor2024` is a password.)
|
|
310
|
+
if (naturalness(value) > 1.0 &&
|
|
311
|
+
(value.match(/[A-Za-z]/g)?.length ?? 0) >= value.length * 0.6 &&
|
|
312
|
+
readsAsWords(value) &&
|
|
313
|
+
(/[^A-Za-z0-9]/.test(value) || (isNameLike(value) && !hasThreeClasses(value))))
|
|
314
|
+
return false;
|
|
315
|
+
// (Letters alone can still be generated: `WnDKpEQCbOciskIKhAfdQxFmY` reads as no words.)
|
|
316
|
+
if (isWordy(value) &&
|
|
317
|
+
!hasThreeClasses(value) &&
|
|
318
|
+
value.length < 40 &&
|
|
319
|
+
!(/^[A-Za-z]{16,}$/.test(value) && naturalness(value) < 1.2))
|
|
320
|
+
return false;
|
|
321
|
+
// dotted.config.keys, kebab-names, a list of names (`bamf,bamf_id,bamf_secret`), a word in any script (`--rpc-secret
|
|
322
|
+
// bayrağını kullanın`, Turkish for "use the flag").
|
|
323
|
+
if (/^\p{Ll}+(?:[._,-]\p{Ll}+)*$/u.test(value))
|
|
324
|
+
return false;
|
|
325
|
+
if (isGenericName(name.replace(/^[-$@]+/, ""))) {
|
|
326
|
+
// Cache keys, object keys, content addresses: hashes with words or paths around them.
|
|
327
|
+
// (Under a generic name a hex run beside a separator is one, whatever else is in it.)
|
|
328
|
+
// (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.)
|
|
330
|
+
if (/[0-9a-f]{16,}/i.test(value) &&
|
|
331
|
+
(/[-_/.:]/.test(value) ||
|
|
332
|
+
(/^[0-9a-f]+$/i.test(value) &&
|
|
333
|
+
!(/^(?:secret|token|tokens)$/i.test(name.replace(/^[-$@]+/, "")) &&
|
|
334
|
+
(value.length === 32 || value.length === 40)))))
|
|
335
|
+
return false;
|
|
336
|
+
// A path or an object key: a path's characters only, with a `.`, `_` or `-` base64 never has (`uploads/<id>.png`,
|
|
337
|
+
// `_pending/<id>/transit`), or segments that are all names or words (`config/keys`, S3's `exports/2024/Q3`,
|
|
338
|
+
// `apis/v1beta1/Namespaces`). Not a key in base64 with a word-like piece, nor a password with symbols.
|
|
339
|
+
if (/\//.test(value) &&
|
|
340
|
+
(isPath(value) ||
|
|
341
|
+
(/^[\w./-]+$/.test(value) &&
|
|
342
|
+
(/[._-]/.test(value) ||
|
|
343
|
+
value
|
|
344
|
+
.split("/")
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.every((seg) => !isGeneratedSegment(seg) && (isNameLike(seg) || naturalness(seg.toLowerCase()) > 0.5))))))
|
|
347
|
+
return false;
|
|
348
|
+
// Words with a number between them, a product's, a step's or a section's name (`Windows10Home`, `Step2:Review`),
|
|
349
|
+
// read as words once the number is a break; not a password's word and year (`Summer2024!`), nor a word spelled
|
|
350
|
+
// with digits (`Pa55word`). And a translation looked up by its key, `$t('auth.Login2')`.
|
|
351
|
+
if ((/[A-Za-z]\d+(?:[A-Z]|[:._-][A-Za-z])/.test(value) &&
|
|
352
|
+
readsAsWords(value) &&
|
|
353
|
+
naturalness(value.replace(/\d+/g, "-")) > 1) ||
|
|
354
|
+
/^[$\w.]+\((["'])[\w.:-]+\1\)$/.test(value))
|
|
355
|
+
return false;
|
|
356
|
+
// `"key": "user.name"`, `token: refresh`: a bare generic name needs a generated-looking value: with a digit, or
|
|
357
|
+
// letters in no words (mixed case scores near words by its humps: `WnDKpEQCbOciskIKhAfdQxFmY`).
|
|
358
|
+
// (Or a made password, `CANYpfso8s%` as an express-session secret.)
|
|
359
|
+
return ((value.length >= 12 && naturalness(value) < 0.6 && /\d/.test(value)) ||
|
|
360
|
+
(value.length >= 20 && (naturalness(value) < 0.2 || (/^[A-Za-z_-]+$/.test(value) && !readsAsWords(value)))) ||
|
|
361
|
+
isMadePassword(value));
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
/** What ends a bare value in code (`,` `;` `)` `]` `}` quotes, blanks, a table's bar) and in a shell word (blanks, quotes, operators). */
|
|
366
|
+
const CODE_VALUE_END = /[ \t,;&)\]}"'`<|│┃║]/;
|
|
367
|
+
const SHELL_WORD_END = /[ \t;&|<>()"'`]/;
|
|
368
|
+
/** The word after a quote that never closes, to its line's end. */
|
|
369
|
+
const UNCLOSED_WORD = new RegExp(`^${unquotedValue(String.raw `[^\s"'\x60]`, 4, String.raw `[ \t]*\r?$`)}`, "m");
|
|
370
|
+
/** Reads the value starting at `at`: quoted, JSON-escaped-quoted, or bare up to a delimiter. */
|
|
371
|
+
function readValue(text, at, { toEol = false, shell = false, toBlank = false } = {}) {
|
|
372
|
+
const c = text[at];
|
|
373
|
+
// A triple-quoted string (Python, Kotlin and Swift raw strings, Java text blocks): to its closing triple.
|
|
374
|
+
if ((c === '"' || c === "'") && text.startsWith(c.repeat(3), at)) {
|
|
375
|
+
const close = text.slice(at + 3, at + 3 + MAX_VALUE).indexOf(c.repeat(3));
|
|
376
|
+
return close < 0 ? undefined : [at + 3, at + 3 + close];
|
|
377
|
+
}
|
|
378
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
379
|
+
const close = literalEnd(text, at, { shell });
|
|
380
|
+
// (A single-quoted shell word goes on past a quote written as the shell writes one in it: `'Kx9mQ2'\''pLw7v'` and
|
|
381
|
+
// `'Kx9mQ2'"'"'pLw7v'` are one value.)
|
|
382
|
+
if (close >= 0 && shell && c === "'" && /^(?:\\'|"'")?'/.test(text.slice(close + 1, close + 5))) {
|
|
383
|
+
const word = shellWordAt(text, at);
|
|
384
|
+
return [at + 1, word.end - (/["']$/.test(word.raw) ? 1 : 0)];
|
|
385
|
+
}
|
|
386
|
+
if (close >= 0)
|
|
387
|
+
return [at + 1, close];
|
|
388
|
+
// (A quote that never closes on its line opens no string: the value is the one word after it, `DB_PASSWORD='Kx9…`.)
|
|
389
|
+
const rest = UNCLOSED_WORD.exec(text.slice(at + 1, lineEndAt(text, at + 1, UNQUOTED_READ + 1) + 1));
|
|
390
|
+
return rest ? [at + 1, at + 1 + rest[0].length] : undefined;
|
|
391
|
+
}
|
|
392
|
+
// `\"value\"` inside a JSON string (a transcript holding code).
|
|
393
|
+
if (c === "\\" && (text[at + 1] === '"' || text[at + 1] === "'")) {
|
|
394
|
+
const q = `\\${text[at + 1]}`;
|
|
395
|
+
const end = text.indexOf(q, at + 2);
|
|
396
|
+
if (end < 0 || end - at > MAX_VALUE)
|
|
397
|
+
return undefined;
|
|
398
|
+
return [at + 2, end];
|
|
399
|
+
}
|
|
400
|
+
// Inside a JSON string (an odd count of unescaped quotes before it on its line), a run of backslashes before a quote,
|
|
401
|
+
// `n`, `r` or `t` ends the value: a quote, a line break or a tab at some level of encoding (`\"`, `\n`, `\\n` in text
|
|
402
|
+
// encoded twice). Another run is the value's own backslash (`Vq7m\\2Lx`). Elsewhere a backslash is part of it
|
|
403
|
+
// (`x9#Lq\tR2!` in a .env), except an escaped line break, which ends a line of escaped text (isEscapedBreak).
|
|
404
|
+
// (Counted when a backslash first needs it.)
|
|
405
|
+
let inStringAt;
|
|
406
|
+
const inString = () => (inStringAt ??=
|
|
407
|
+
(text
|
|
408
|
+
.slice(lineStartAt(text, at, 4000), at)
|
|
409
|
+
.replace(/\\./g, "")
|
|
410
|
+
.match(/"/g)?.length ?? 0) %
|
|
411
|
+
2 ===
|
|
412
|
+
1);
|
|
413
|
+
let i = at;
|
|
414
|
+
// (A bracket the value opened is its own: `%{K8S_TOKEN}`, `${DB_PASS}`, `Wint(er)2024`; another ends it. Inside a
|
|
415
|
+
// shell expansion the value starts with, or a template's `{{ }}`, everything is its own until they close:
|
|
416
|
+
// `${REDIS_PASSWORD:?set it first}`, `--key=/etc/certs/{{ .Values.keyFile | default "tls.key" }}`.)
|
|
417
|
+
let open = 0;
|
|
418
|
+
// (Where the outermost bracket open now opened.)
|
|
419
|
+
let opened = at;
|
|
420
|
+
let expansion = text.startsWith("${", at);
|
|
421
|
+
while (i < text.length && i - at < UNQUOTED_READ) {
|
|
422
|
+
const ch = text[i];
|
|
423
|
+
if (ch === "\n" || ch === "\r")
|
|
424
|
+
break;
|
|
425
|
+
if (ch === "\\" && inString()) {
|
|
426
|
+
let run = 1;
|
|
427
|
+
while (text[i + run] === "\\")
|
|
428
|
+
run++;
|
|
429
|
+
if (/["'nrt]/.test(text[i + run] ?? ""))
|
|
430
|
+
break;
|
|
431
|
+
i += run;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (ch === "\\" && isEscapedBreak(text, i))
|
|
435
|
+
break;
|
|
436
|
+
if (ch === "{" && text[i + 1] === "{")
|
|
437
|
+
expansion = true;
|
|
438
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
439
|
+
if (open++ === 0)
|
|
440
|
+
opened = i;
|
|
441
|
+
}
|
|
442
|
+
else if ((ch === ")" || ch === "]" || ch === "}") && open > 0)
|
|
443
|
+
open--;
|
|
444
|
+
else if (!toEol &&
|
|
445
|
+
!(expansion && open > 0) &&
|
|
446
|
+
(toBlank ? /[ \t]/ : shell ? SHELL_WORD_END : CODE_VALUE_END).test(ch)) {
|
|
447
|
+
// (A quote or a blank inside a bracket the value opened is the value's own, if the bracket closes on the line:
|
|
448
|
+
// `0x#{key.unpack('H*')[0]}`, `cfg["db"]["pw"]`, `Buffer.from(pw, "base64")`. Not in a logfmt line, whose
|
|
449
|
+
// values run to a blank whatever they hold.)
|
|
450
|
+
const close = open > 0 && !toBlank ? throughBrackets(text, opened, i) : i;
|
|
451
|
+
if (close === i)
|
|
452
|
+
break;
|
|
453
|
+
i = close;
|
|
454
|
+
open = 0;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
i++;
|
|
458
|
+
}
|
|
459
|
+
let end = i;
|
|
460
|
+
// Trailing spaces and a trailing comment of a whole-line value.
|
|
461
|
+
if (toEol) {
|
|
462
|
+
const comment = text.slice(at, end).search(/(?<![ \t])[ \t]+#/);
|
|
463
|
+
if (comment >= 0)
|
|
464
|
+
end = at + comment;
|
|
465
|
+
while (end > at && /[ \t]/.test(text[end - 1]))
|
|
466
|
+
end--;
|
|
467
|
+
}
|
|
468
|
+
return end > at ? [at, end] : undefined;
|
|
469
|
+
}
|
|
470
|
+
/** The opening of a heredoc: `<<EOF`, `<<-EOT`, `<<~SQL`, `<<'EOF'`. */
|
|
471
|
+
const HEREDOC = /^<<[-~]?[ \t]*(["']?)([A-Za-z_]\w*)\1[ \t]*\r?\n/;
|
|
472
|
+
/** Adds the value at `start`–`end` under credential name `name`, its kind read from the name. */
|
|
473
|
+
function namedFinding(out, start, end, name, rule, confidence = 0.95) {
|
|
474
|
+
const w = lastWord(name);
|
|
475
|
+
const kind = isPasswordName(name)
|
|
476
|
+
? "password"
|
|
477
|
+
: /TOKEN/.test(w)
|
|
478
|
+
? "token"
|
|
479
|
+
: /SECRET/.test(w)
|
|
480
|
+
? "secret"
|
|
481
|
+
: /KEY/.test(w)
|
|
482
|
+
? "key"
|
|
483
|
+
: "credential";
|
|
484
|
+
out.push({ start, end, kind, rule, confidence, name });
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Adds a string literal's content under `name` when the name it is judged under (`judgedAs`, by default the name
|
|
488
|
+
* itself) is a credential's and the value is a secret under it.
|
|
489
|
+
*/
|
|
490
|
+
function namedValue(out, name, value, rule, { judgedAs = name, confidence = 0.95 } = {}) {
|
|
491
|
+
if (!isCredentialName(judgedAs) || !isNamedSecret(judgedAs, `"${value.value}"`, true))
|
|
492
|
+
return;
|
|
493
|
+
namedFinding(out, value.start, value.end, name, rule, confidence);
|
|
494
|
+
}
|
|
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;
|
|
497
|
+
/** The rules, each a reader of one syntax; findNamed runs them all. */
|
|
498
|
+
const RULES = [
|
|
499
|
+
assignments,
|
|
500
|
+
flags,
|
|
501
|
+
xmlElements,
|
|
502
|
+
spacedPairs,
|
|
503
|
+
configList,
|
|
504
|
+
cDefines,
|
|
505
|
+
referenceDefaults,
|
|
506
|
+
envFallbacks,
|
|
507
|
+
conditionalBranches,
|
|
508
|
+
xmlNamedValues,
|
|
509
|
+
dotLeaders,
|
|
510
|
+
setterCalls,
|
|
511
|
+
positionalCalls,
|
|
512
|
+
pairCalls,
|
|
513
|
+
comparisons,
|
|
514
|
+
formFills,
|
|
515
|
+
terraformVariables,
|
|
516
|
+
diffArrows,
|
|
517
|
+
pipeTables,
|
|
518
|
+
replEchoes,
|
|
519
|
+
jsonValues,
|
|
520
|
+
userPassProse,
|
|
521
|
+
dslCalls,
|
|
522
|
+
declarations,
|
|
523
|
+
substitutions,
|
|
524
|
+
secretArguments,
|
|
525
|
+
htmlPairs,
|
|
526
|
+
foreignLabels,
|
|
527
|
+
wrappedValues,
|
|
528
|
+
columnTables,
|
|
529
|
+
javaProperties,
|
|
530
|
+
siblingPairs,
|
|
531
|
+
userInfo,
|
|
532
|
+
sqlRows,
|
|
533
|
+
prose,
|
|
534
|
+
labelledProse,
|
|
535
|
+
passwordShaped,
|
|
536
|
+
];
|
|
537
|
+
/** Values under a credential name. (Values public by design, client-side Google keys and DocSearch keys among them, are policy.ts's.) */
|
|
538
|
+
export function findNamed(text) {
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const rule of RULES)
|
|
541
|
+
rule(text, out);
|
|
542
|
+
const imports = goImports(text);
|
|
543
|
+
return out
|
|
544
|
+
.filter((f) => !explainedByNeighbours(text, f) && !imports.some(([start, end]) => f.start >= start && f.end <= end))
|
|
545
|
+
.flatMap((f) => listItems(text, f))
|
|
546
|
+
.flatMap((f) => urlTokens(text, f));
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Whether the text around a finding says its value is no secret, whichever reader found it (isNamedSecret judges the
|
|
550
|
+
* value under its name; this, what stands near them, within 200 characters before and 100 after).
|
|
551
|
+
*/
|
|
552
|
+
function explainedByNeighbours(text, f) {
|
|
553
|
+
const near = text.slice(Math.max(0, f.start - 200), f.end + 100);
|
|
554
|
+
const value = cleanValue(text.slice(f.start, f.end));
|
|
555
|
+
const name = f.name ?? "";
|
|
556
|
+
// A single-table database's key: an entity's name, then its parts (`ORDER#2026-09-30#o_4411`), beside the partition
|
|
557
|
+
// key it sorts under (`PK: 'CUSTOMER#c_8812'`). (`sk` alone can name a secret key: the shape and the `PK` decide.)
|
|
558
|
+
if (ENTITY_KEY.test(value) && PARTITION_KEY.test(near))
|
|
559
|
+
return true;
|
|
560
|
+
// A postal PIN code, with an address around it.
|
|
561
|
+
if (isPinName(name) && POSTAL.test(near))
|
|
562
|
+
return true;
|
|
563
|
+
// A translation file: several neighbouring values are phrases (`password: facal-faire` among
|
|
564
|
+
// `otp: aplacaid dearbhaidh dhà-cheumnaich`, `sign_in_token: cuir còd …`).
|
|
565
|
+
if (/[\s-]/.test(value) &&
|
|
566
|
+
/^[\p{L}\p{M}'’ -]+$/u.test(value) &&
|
|
567
|
+
(near.match(/^[ \t]*[\w.-]{1,60}:[ \t]+["']?\p{L}[\p{L}\p{M}'’,.-]*(?:[ \t]+[\p{L}\p{M}'’,.-]+){2,}["']?[ \t]*$/gmu)
|
|
568
|
+
?.length ?? 0) >= 3)
|
|
569
|
+
return true;
|
|
570
|
+
// A default: the password is the user's or the database's name (`POSTGRES_USER=app` / `POSTGRES_PASSWORD=app`), or
|
|
571
|
+
// the user's name with a password's word for the user's (a fixture's `containrrr-user` / `containrrr-pass`).
|
|
572
|
+
// (In the other pairs near it, under a whole name: `INFLUXDB=<value>` isn't a database named <value>.)
|
|
573
|
+
if (value && value.length <= 32) {
|
|
574
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
575
|
+
const stem = /^(.+?[-_.])(?:pass(?:word)?|passwd|pwd|pw)$/i.exec(value)?.[1];
|
|
576
|
+
const esc = stem ? `(?:${escape(value)}|${escape(stem)}user(?:name)?)` : escape(value);
|
|
577
|
+
const others = near.replace(new RegExp(String.raw `[\w.-]*${escape(name)}["']?[ \t]*[:=][^\n]*`, "g"), "");
|
|
578
|
+
if (new RegExp(String.raw `(?<![\w.-])(?:[\w.-]*[_.-])?(?:user(?:name)?|login|db|database|dbname|name|account)["']?[ \t]*[:=][ \t]*["'\`]?${esc}["'\`]?(?:[\s,;]|$)`, "i").test(others))
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
/** A list of strings a name says holds secrets (isNamedSecret) is its items: each that is a secret under the name is hidden, the list around them kept. */
|
|
584
|
+
function listItems(text, f) {
|
|
585
|
+
const items = stringListItems(text.slice(f.start, f.end));
|
|
586
|
+
if (!items)
|
|
587
|
+
return [f];
|
|
588
|
+
return items
|
|
589
|
+
.filter((item) => isNamedSecret(f.name ?? "", `"${item.value}"`, true))
|
|
590
|
+
.map((item) => ({ ...f, start: f.start + item.start, end: f.start + item.end }));
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* A URL a name says is a secret (`password: https://…/reset/<token>`, `SLACK_WEBHOOK=https://hooks…/<token>`) is one by
|
|
594
|
+
* the tokens in it (isNamedSecret): those are hidden, the URL around them is kept.
|
|
595
|
+
*/
|
|
596
|
+
function urlTokens(text, f) {
|
|
597
|
+
const value = text.slice(f.start, f.end);
|
|
598
|
+
if (!isUrl(value))
|
|
599
|
+
return [f];
|
|
600
|
+
const [url] = urls(value);
|
|
601
|
+
if (!url || url.end !== value.length)
|
|
602
|
+
return [f];
|
|
603
|
+
const parts = [url.password, ...url.segments, ...[...queryParams(value)].map((p) => p.value)].filter((p) => p && isRandomToken(p.value));
|
|
604
|
+
return parts.length ? parts.map((p) => ({ ...f, start: f.start + p.start, end: f.start + p.end })) : [f];
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
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.)
|
|
609
|
+
*/
|
|
610
|
+
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");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/** An INI or TOML section header line: `[database]`, `[profile "prod"]`. */
|
|
617
|
+
const INI_SECTION = /(?:^|\n)[ \t]*\[[\w. "'-]{1,80}\][ \t]*\r?(?=\n)/g;
|
|
618
|
+
/** A flag that sets an environment variable or a property, before `NAME=value`: `-e`, `--env`, `--build-arg`, `-D`, `--set`. */
|
|
619
|
+
const ENV_FLAG = /(?:^|\s)(?:-e|--env|--build-arg|-D|--set|--set-string|--var|-var|env)[ \t]+["']?$/;
|
|
620
|
+
/**
|
|
621
|
+
* `name = value` in the syntaxes ASSIGN reads. The value is a string literal (with its prefix or wrapper, in
|
|
622
|
+
* parentheses, or passed to a converter), a bare word, the rest of an env, YAML or INI line, a YAML block scalar or
|
|
623
|
+
* a heredoc.
|
|
624
|
+
*/
|
|
625
|
+
function assignments(text, out) {
|
|
626
|
+
let sections;
|
|
627
|
+
// Whether a section header is within 4,000 characters above `at`.
|
|
628
|
+
const underSection = (at) => {
|
|
629
|
+
sections ??= [...text.matchAll(INI_SECTION)].map((m) => m.index);
|
|
630
|
+
const i = lowerBound(sections, at) - 1;
|
|
631
|
+
return i >= 0 && at - sections[i] <= 4000;
|
|
632
|
+
};
|
|
633
|
+
ASSIGN.lastIndex = 0;
|
|
634
|
+
for (let m = ASSIGN.exec(text); m; m = ASSIGN.exec(text)) {
|
|
635
|
+
const name = m[2].replace(/^[$@]+/, "");
|
|
636
|
+
if (!isAssignedName(text, m, name))
|
|
637
|
+
continue;
|
|
638
|
+
const [opStart, opEnd] = m.indices[3];
|
|
639
|
+
let op = m[3];
|
|
640
|
+
let at = m.index + m[0].length;
|
|
641
|
+
const lineStart = lineStartAt(text, m.index);
|
|
642
|
+
const before = text.slice(lineStart, m.index);
|
|
643
|
+
// `DB_PASSWORD==x…`, `- POSTGRES_PASSWORD=>>y…`: an env-shaped name glued to its operator on an env line, or after
|
|
644
|
+
// an env flag, takes the first `=` as the operator; the value starts with whatever follows it. Any other
|
|
645
|
+
// operator followed by `=` or `>` isn't one.
|
|
646
|
+
// (So does any unquoted name that starts a line, as `.properties` and INI lines are read: `spring.datasource.password==x`.)
|
|
647
|
+
// (Not a pinned requirement, `django-secrets==0.1.dev3`: there `==` pins a version.)
|
|
648
|
+
const envGlued = m[1] === "" &&
|
|
649
|
+
opEnd === at &&
|
|
650
|
+
text[opStart] === "=" &&
|
|
651
|
+
((/^[A-Z][A-Z0-9_]*$/.test(name) && (ENV_LINE_START.test(before) || ENV_FLAG.test(before))) ||
|
|
652
|
+
/^[ \t]*$/.test(before)) &&
|
|
653
|
+
!isBenignValue(/^\S*/.exec(text.slice(opEnd))[0]);
|
|
654
|
+
if (/[=>]/.test(text[opEnd] ?? "") || (/^(?:==|===|=>)$/.test(op) && envGlued)) {
|
|
655
|
+
if (!envGlued)
|
|
656
|
+
continue;
|
|
657
|
+
at = opStart + 1;
|
|
658
|
+
op = "=";
|
|
659
|
+
}
|
|
660
|
+
// (No blank after the operator: `NAME=value`, not `name = value`.)
|
|
661
|
+
const tight = at === opEnd || op !== m[3];
|
|
662
|
+
// `password: str = "x"`, `apiKey: string = "x"`: skip a type annotation. (Unspaced, `password: str='x'`, only a
|
|
663
|
+
// built-in type's: `start='…'` and `MAC id="…"` aren't annotations.)
|
|
664
|
+
const annotation = op === ":" ? text.slice(at, at + 80) : "";
|
|
665
|
+
const unspaced = /^([^=\n]{1,60}?)=(?=["'])/.exec(annotation);
|
|
666
|
+
const typed = /^[A-Za-z_&*][\w.<>\[\]|?, &'*]{0,60}?[ \t]+=(?!=)[ \t]*/.exec(annotation)?.[0] ??
|
|
667
|
+
(unspaced && isTypeExpression(unspaced[1]) ? unspaced[0] : undefined);
|
|
668
|
+
if (typed)
|
|
669
|
+
at += typed.length;
|
|
670
|
+
// Aligned columns: `SecretID: <value>`.
|
|
671
|
+
while (text[at] === " " || text[at] === "\t")
|
|
672
|
+
at++;
|
|
673
|
+
// `MYSQL_USER=root MYSQL_PASSWORD= FILTER=x npm test`: an empty variable, the next one's pair after it.
|
|
674
|
+
if (op === "=" &&
|
|
675
|
+
at > opEnd &&
|
|
676
|
+
/^[A-Z][A-Z0-9_]*$/.test(name) &&
|
|
677
|
+
/^[A-Za-z_]\w*=(?!=)/.test(text.slice(at, at + 80)))
|
|
678
|
+
continue;
|
|
679
|
+
// `password: |` YAML block scalar, `password = <<EOT` heredoc: the lines below.
|
|
680
|
+
// (The indicator, and a comment after it however long: yaml.ts reads the line.)
|
|
681
|
+
const eol = lineEndAt(text, at);
|
|
682
|
+
if (op === ":" && /^[|>]/.test(text[at] ?? "") && isBlockIndicator(text.slice(at, eol)) && text[eol] === "\n") {
|
|
683
|
+
multiLineValue(out, name, blockExtent(text, lineStart), "named:block");
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (HEREDOC.test(text.slice(at, at + 100))) {
|
|
687
|
+
multiLineValue(out, name, heredoc(text, at), "named:heredoc");
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
// A string literal's prefix or wrapper: Python `b'…'`, `r'…'`, `f"…"`, C# `@"…"` and `$"…"`, Rust `r#"…"#`,
|
|
691
|
+
// `Some("…")`, `String::from("…")`.
|
|
692
|
+
const head = literalHead(text, at);
|
|
693
|
+
if (head && text[head.open] !== "`")
|
|
694
|
+
at = head.open;
|
|
695
|
+
const prefix = head?.prefix ?? "";
|
|
696
|
+
const unquoted = !/["'`]/.test(text[at] ?? "");
|
|
697
|
+
// (Not an object literal's entry: code before its `,`, a name, a constant or a bracketed expression, its brackets
|
|
698
|
+
// balanced as a random password's rarely are: ` password: userPassword,`, `Keys: []string{"a"},`, an interface's
|
|
699
|
+
// generic type, `password: Secret<String>;`.)
|
|
700
|
+
const entry = /^(.*?)[ \t]*[,;{][ \t]*\r?$/.exec(text.slice(at, lineEndAt(text, at)))?.[1];
|
|
701
|
+
// `"NAME=value with spaces"` (docker inspect, compose lists): the value runs to the quote that opened the pair,
|
|
702
|
+
// escaped as it was (`\"NAME=value\"` inside a JSON string).
|
|
703
|
+
const pairQuote = m[1] === "" && op === "=" && /["']/.test(text[m.index - 1] ?? "") ? m.index - 1 : -1;
|
|
704
|
+
// An entry of a call or a literal written over several lines, the line before opening a bracket or ending an entry
|
|
705
|
+
// (`login(\n password=user_input[CONF_PASSWORD],`, `{\n 'password': None},`), or its last entry (isLastEntry):
|
|
706
|
+
// code, its value read as code is, not a whole-line env, YAML or INI value nor a log's pair. (Its value may open a
|
|
707
|
+
// call of its own, its arguments on the lines below: `),\n MasterUserPassword=Join(`; a callee in words, as a
|
|
708
|
+
// password ending in `(` is not.)
|
|
709
|
+
const opensCall = /^([A-Za-z_$][\w$.]*)\([ \t]*\r?$/.exec(text.slice(at, lineEndAt(text, at)))?.[1];
|
|
710
|
+
const continued = pairQuote < 0 &&
|
|
711
|
+
(((entry !== undefined || (opensCall !== undefined && readsAsWords(opensCall))) &&
|
|
712
|
+
/[,([{][ \t]*\r?\n$/.test(text.slice(Math.max(0, lineStart - 400), lineStart))) ||
|
|
713
|
+
isLastEntry(text, at));
|
|
714
|
+
// A whole-line `NAME=value` (env, export) or YAML `key: value` runs to the end of the line, spaces included.
|
|
715
|
+
// (A sigil makes the name code's variable, not an environment's or an INI file's: Perl's `@key=map(…);`.)
|
|
716
|
+
const sigil = /^[$@]/.test(m[2]);
|
|
717
|
+
const envLine = op === "=" && m[1] === "" && tight && !continued && !sigil && ENV_LINE_START.test(before);
|
|
718
|
+
// (Not a line of several pairs, `"password": null, "user": "bob"`: a YAML value holds no `name: ` of its own, an
|
|
719
|
+
// object's line of code does.)
|
|
720
|
+
const yamlLine = op === ":" &&
|
|
721
|
+
!continued &&
|
|
722
|
+
YAML_LINE_START.test(before) &&
|
|
723
|
+
!/,[ \t]*(["']?)[A-Za-z_$][\w$.-]*\1[ \t]*:(?:[ \t]|$)/.test(text.slice(at, lineEndAt(text, at))) &&
|
|
724
|
+
!(entry !== undefined &&
|
|
725
|
+
(/^(?:[A-Za-z_$][\w$.]*|\d+|true|false|null)$/.test(entry) ||
|
|
726
|
+
(/^[\w$.&*[\]<>]*[([{<]/.test(entry) && /[)\]}>]$/.test(entry) && isBalanced(entry))));
|
|
727
|
+
// An INI or TOML line under a `[section]` header: `password = c@I|wr=vsY6h[`, `admin_passwd = correct horse battery 42`.
|
|
728
|
+
// (Not a call, `password = input("…")`; a number in parentheses is the value's own, `Planet(685)`.)
|
|
729
|
+
const iniLine = op === "=" &&
|
|
730
|
+
m[1] === "" &&
|
|
731
|
+
!continued &&
|
|
732
|
+
!sigil &&
|
|
733
|
+
/^[ \t]*$/.test(before) &&
|
|
734
|
+
!/^[A-Za-z_][\w.]*\((?![ \t]*-?\d+[ \t]*\))[^()\n]*\)[ \t]*\r?$/.test(text.slice(at, lineEndAt(text, at))) &&
|
|
735
|
+
underSection(m.index);
|
|
736
|
+
// `key: # a comment`: YAML has no value there.
|
|
737
|
+
if (yamlLine && text[at] === "#")
|
|
738
|
+
continue;
|
|
739
|
+
const wholeLine = (envLine || yamlLine || iniLine) && unquoted;
|
|
740
|
+
// Outside a call's parentheses (`env=dict(os.environ, PGPASSWORD=quote(pw))` passes an expression):
|
|
741
|
+
const inLiteral = (before.replace(/\\./g, "").match(/"/g)?.length ?? 0) % 2 === 1;
|
|
742
|
+
const openParens = inLiteral ? 0 : (before.match(/\(/g)?.length ?? 0) - (before.match(/\)/g)?.length ?? 0);
|
|
743
|
+
// `SMTP_PASSWORD=x` anywhere (`heroku config:set …`, `API_KEY=x npm start`) is an env var, so a literal; and
|
|
744
|
+
// `user=root password=x` in a log line or a CLI is data, as is a pair in a query string, `?user=a&pass=x`, or in an
|
|
745
|
+
// option's comma list of them, `mount -o username=a,password=x`. (Not a declaration's list, `let user=a,password=b`;
|
|
746
|
+
// nor a variable with a sigil, Perl's `my @key=map(…)`; nor a keyword argument on a call's own line, `login(` /
|
|
747
|
+
// ` PASSWORD=x,`: code, whose value ends at its comma.)
|
|
748
|
+
const envName = op === "=" && /^[A-Z][A-Z0-9_]*$/.test(name) && !sigil && tight && openParens <= 0 && !continued;
|
|
749
|
+
const pair = op === "=" && m[1] === "" && !sigil && tight && openParens <= 0;
|
|
750
|
+
const logPair = pair && /[ \t]$/.test(before);
|
|
751
|
+
const listPair = pair && /(?:[?&]|(?:^|[ \t])(?<!\b(?:let|var|const)[ \t]+)(?:[\w.-]+=[^\s=,]*,)+)$/.test(before);
|
|
752
|
+
const shellTest = /^===?$/.test(op) && /(?:^|\s)(?:\[\[?|test)\s/.test(before);
|
|
753
|
+
const literal = !typed &&
|
|
754
|
+
!continued &&
|
|
755
|
+
(envLine ||
|
|
756
|
+
yamlLine ||
|
|
757
|
+
iniLine ||
|
|
758
|
+
envName ||
|
|
759
|
+
logPair ||
|
|
760
|
+
listPair ||
|
|
761
|
+
shellTest ||
|
|
762
|
+
pairQuote >= 0 ||
|
|
763
|
+
(op === "=" && m[1] === "" && ENV_FLAG.test(before)));
|
|
764
|
+
// (A logfmt line, `level=info … password=V host=db`: its pairs are separated by blanks, a value runs to one.)
|
|
765
|
+
const logfmt = logPair && (text.slice(lineStart, lineEndAt(text, at)).match(/(?:^|[ \t])[\w.-]+=[^\s=]/g)?.length ?? 0) >= 3;
|
|
766
|
+
// A list of strings under the name, on its line or over the lines below (`"api_keys": [` then `"…",` lines,
|
|
767
|
+
// `passwords:` then `- …` lines): read whole, each item judged under the name (isNamedSecret) and hidden alone
|
|
768
|
+
// (findNamed). (No pairs inside to read.)
|
|
769
|
+
const list = listValue(text, at, op === ":" && YAML_LINE_START.test(before) ? before : undefined);
|
|
770
|
+
if (list) {
|
|
771
|
+
ASSIGN.lastIndex = Math.max(ASSIGN.lastIndex, list[1]);
|
|
772
|
+
if (isNamedSecret(name, text.slice(list[0], list[1]), true)) {
|
|
773
|
+
namedFinding(out, list[0], list[1], name, "named:assign");
|
|
774
|
+
out[out.length - 1].literal = true;
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
let span = assignedValue(text, at, {
|
|
779
|
+
wholeLine,
|
|
780
|
+
envLine,
|
|
781
|
+
shell: envLine || envName || pairQuote >= 0 || ENV_FLAG.test(before),
|
|
782
|
+
pairQuote,
|
|
783
|
+
logfmt,
|
|
784
|
+
});
|
|
785
|
+
if (!span)
|
|
786
|
+
continue;
|
|
787
|
+
// In a sed or perl substitution, `s/^DB_PASSWORD=.*/DB_PASSWORD=<value>/`, a value ends at the script's delimiter.
|
|
788
|
+
const sed = /(?:^|[\s'"])s([/|#,:@])(?:[^\n]*)$/.exec(before);
|
|
789
|
+
if (sed) {
|
|
790
|
+
const stop = text.slice(span[0], span[1]).search(new RegExp(`(?<!\\\\)\\${sed[1]}`));
|
|
791
|
+
if (stop >= 0)
|
|
792
|
+
span = [span[0], span[0] + stop];
|
|
793
|
+
}
|
|
794
|
+
// `Authorization: Bearer x`: the value is the token, not the scheme (so it's learned and fingerprinted alone).
|
|
795
|
+
if (/^(?:proxy-)?authorization$/i.test(name)) {
|
|
796
|
+
const scheme = /^(?:Bearer|Basic|Token|Bot|Digest|ApiKey|Api-Key|SSWS|OAuth|JWT|DPoP|Splunk|GenieKey|Key)[ \t]+/i.exec(text.slice(span[0], span[1]));
|
|
797
|
+
if (scheme)
|
|
798
|
+
span = [span[0] + scheme[0].length, span[1]];
|
|
799
|
+
}
|
|
800
|
+
// Inside a flow mapping an unquoted value runs to `,` or `}`, not to `&` (`{password: Tr0ub4dor&3x}`).
|
|
801
|
+
if (span[0] === at &&
|
|
802
|
+
text[span[1]] === "&" &&
|
|
803
|
+
/[{,][ \t]*$/.test(before) &&
|
|
804
|
+
(before.match(/\{/g)?.length ?? 0) > (before.match(/\}/g)?.length ?? 0)) {
|
|
805
|
+
span = [span[0], span[1] + /^[^\s,}]*/.exec(text.slice(span[1], span[1] + 4096))[0].length];
|
|
806
|
+
}
|
|
807
|
+
const raw = text.slice(span[0], span[1]);
|
|
808
|
+
// A quoted value was read without its quotes; let the value tests see them.
|
|
809
|
+
const quoted = span[0] > at;
|
|
810
|
+
// Inside a call's or a signature's parentheses, an unquoted name before `)`, `,`, `|` or `=` is a parameter's type
|
|
811
|
+
// or a variable: `decode(secret: Base32SecretKey): …`, Swift's and Kotlin's `login(password: pw)`.
|
|
812
|
+
if (!quoted &&
|
|
813
|
+
op === ":" &&
|
|
814
|
+
openParens > 0 &&
|
|
815
|
+
/^[A-Za-z_$][\w$.]*$/.test(raw) &&
|
|
816
|
+
/^[ \t]*[),|=]/.test(text.slice(span[1], span[1] + 3)))
|
|
817
|
+
continue;
|
|
818
|
+
// `readPassword("password: ")).thenReturn(…)`: the name was inside a string, and what follows its
|
|
819
|
+
// closing quote is code.
|
|
820
|
+
if (quoted && m[1] === "" && /["'`]/.test(text[m.index - 1] ?? "") && /^\)[).;]/.test(raw))
|
|
821
|
+
continue;
|
|
822
|
+
// A parameter's description, a sentence to the line's end (or its comment's) that names what the parameter holds,
|
|
823
|
+
// in a comment of any language or a line of output: `# keystore_password: Keystore password.`, `REM db_password =
|
|
824
|
+
// Primary password of the database.`, `<!-- password: Master password. -->`. (Not a password that opens a sentence,
|
|
825
|
+
// `# password: Sunshine is the default.`: the sentence names no credential.)
|
|
826
|
+
if (!quoted && /^\p{Lu}\p{Ll}+$/u.test(raw)) {
|
|
827
|
+
const rest = /^(?:[ \t]+[\p{L}'’(),-]+)+\.(?=[ \t]*(?:-->|\*\/)?[ \t]*\r?$)/u.exec(text.slice(span[1], lineEndAt(text, span[1])))?.[0];
|
|
828
|
+
const sentence = `${raw}${rest}`.trim();
|
|
829
|
+
const credential = lastWord(name).toLowerCase();
|
|
830
|
+
if (rest &&
|
|
831
|
+
isLabel(sentence, name) &&
|
|
832
|
+
sentence
|
|
833
|
+
.toLowerCase()
|
|
834
|
+
.split(/[^\p{L}]+/u)
|
|
835
|
+
.includes(credential))
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
// Resume after this value: a value isn't also the start of the next pair (`a=b=c…` stays linear). An
|
|
839
|
+
// object or a list is read inside instead: its pairs are values of their own (`auth: { user, pass: '…' }`).
|
|
840
|
+
ASSIGN.lastIndex = Math.max(ASSIGN.lastIndex, /^[{[]/.test(raw) ? span[0] + 1 : span[1]);
|
|
841
|
+
// (With its own quote: a shell's backticks run a command. A framework's bound attribute, Vue's `:key="…"` or
|
|
842
|
+
// `v-bind:key="…"`, Angular's `[key]="…"`, quotes an expression: its value is read as code, `$t('auth.Login2')`.)
|
|
843
|
+
const q = quoted && text[span[0] - 1] === "`" ? "`" : '"';
|
|
844
|
+
const bound = op === "=" && quoted && /(?:^|[\s<])(?::|v-bind:|\[)$/.test(text.slice(Math.max(0, m.index - 8), m.index));
|
|
845
|
+
if (!isNamedSecret(name, bound ? raw : quoted ? `${q}${raw}${q}` : raw, literal && !bound))
|
|
846
|
+
continue;
|
|
847
|
+
// In an interpolated string, the value's own (`f'user_id={cookie}'`, `$"Pwd={pw}"`) or the one the pair stands in
|
|
848
|
+
// (`$"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))
|
|
852
|
+
continue;
|
|
853
|
+
// (Basic auth's user info is a credential in both halves: Confluent's `basic.auth.user.info=<key>:<secret>`.)
|
|
854
|
+
const colon = /user[._-]?info|basic/i.test(name) ? -1 : credentialPair(name, raw);
|
|
855
|
+
namedFinding(out, colon > 0 ? span[0] + colon : span[0], span[1], name, "named:assign");
|
|
856
|
+
if (literal)
|
|
857
|
+
out[out.length - 1].literal = true;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/** An interpolated string opened and not closed (Python's `f"`, `rf'`, C#'s `$"`, `$@"`): what comes after is inside it. */
|
|
861
|
+
const INTERPOLATED_OPEN = /(?<![\w"'])(?:[rR]?[fF]|[fF][rR]|\$@?|@\$)(["'])(?:\\.|(?!\1)[^\\])*$/;
|
|
862
|
+
/**
|
|
863
|
+
* Whether the line `at` is on is the last entry of a literal or a call written over several lines: the entry before it
|
|
864
|
+
* ends with a comma, and the next line closes the bracket (` email: user.email,` / ` password: hash` / `},`). There a
|
|
865
|
+
* bare word is a variable, as before a comma. (A config file's braces, `authorization {` / ` password: hunter2` /
|
|
866
|
+
* `}`, separate no entries with commas.)
|
|
867
|
+
*/
|
|
868
|
+
function isLastEntry(text, at) {
|
|
869
|
+
const start = lineStartAt(text, at);
|
|
870
|
+
const end = lineEndAt(text, at);
|
|
871
|
+
return (/,[ \t]*\r?\n$/.test(text.slice(Math.max(0, start - 400), start)) &&
|
|
872
|
+
/^\r?\n[ \t]*[)\]}]/.test(text.slice(end, end + 202)));
|
|
873
|
+
}
|
|
874
|
+
/** JavaScript's `typeof` before a name and its object's path: `typeof this.`. */
|
|
875
|
+
const TYPEOF_BEFORE = /\btypeof[ \t]+[\w$.]*$/;
|
|
876
|
+
/** Whether ASSIGN's match names a credential's value, and not a namespace, a string compared, or a header read elsewhere. */
|
|
877
|
+
function isAssignedName(text, m, name) {
|
|
878
|
+
const [, quote, , op] = m;
|
|
879
|
+
// (A name a generator made is none: `sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-…` is two hashes, its `=` base64's.)
|
|
880
|
+
if ((!isCredentialName(name) && !(name === "PASS" && op === "=")) || isRandomToken(name))
|
|
881
|
+
return false;
|
|
882
|
+
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] ?? ""))
|
|
884
|
+
return false;
|
|
885
|
+
// A `section/name` key is judged whole: `keystore/release_password` is a password, `checksum/secrets` a digest.
|
|
886
|
+
const section = text[m.index - 1] === "/"
|
|
887
|
+
? /([\w.-]{1,60})\/$/.exec(text.slice(Math.max(0, m.index - 61), m.index))?.[1]
|
|
888
|
+
: undefined;
|
|
889
|
+
if (section && nameClass(`${section}/${name}`) !== "credential")
|
|
890
|
+
return false;
|
|
891
|
+
// `pw:api`, `key:value`, `redis:6379`: an unquoted name with a colon and no space is a namespace, not YAML; in
|
|
892
|
+
// backticks, `` `pwd`:/workspace ``, a command substitution. (Except a password in a comma list of fields:
|
|
893
|
+
// `Logon ID: 0x1a2b, Password:Rmtq@1234!`; and a colon that ends its line, a YAML key whose value is below it.)
|
|
894
|
+
const end = m.index + m[0].length;
|
|
895
|
+
if (op === ":" &&
|
|
896
|
+
(quote === "" || quote === "`") &&
|
|
897
|
+
!/[ \t]/.test(text[end - 1]) &&
|
|
898
|
+
!/["'`\r\n]/.test(text[end] ?? "") &&
|
|
899
|
+
!(/,[ \t]+$/.test(text.slice(Math.max(0, m.index - 4), m.index)) && isPasswordName(name)))
|
|
900
|
+
return false;
|
|
901
|
+
// `prop="password" :class=…`: a quoted attribute value, not a key.
|
|
902
|
+
if (quote && text[m.index - 1] === "=")
|
|
903
|
+
return false;
|
|
904
|
+
// `typeof this.password === 'function'`: the value's type compared, not the value.
|
|
905
|
+
if (/^===?$/.test(op) && TYPEOF_BEFORE.test(text.slice(Math.max(0, m.index - 60), m.index)))
|
|
906
|
+
return false;
|
|
907
|
+
// `"password" === e.type`: a string compared, not a variable (`$auth['password'] === 'x'` is one).
|
|
908
|
+
if (quote && /^===?$/.test(op) && !m[0].includes("]"))
|
|
909
|
+
return false;
|
|
910
|
+
// `Cookie:` and `Set-Cookie:` headers are read pair by pair (structure.ts), with analytics cookies kept.
|
|
911
|
+
if (/^(?:set-)?cookies?$/i.test(name) && op === ":")
|
|
912
|
+
return false;
|
|
913
|
+
// `$expected ? 'pass' : 'cancel'`, `type={shown ? 'text' : 'password'}`: a ternary's strings, not a key and its value.
|
|
914
|
+
if (quote && op === ":" && /\?[ \t]*$/.test(text.slice(Math.max(0, m.index - 4), m.index)))
|
|
915
|
+
return false;
|
|
916
|
+
// `tab ? ['username', 'password'] : ['mobile', 'captcha']`: a list's last string before a ternary's other branch.
|
|
917
|
+
// (Not a computed key, `{ ['password']: 'x' }`, its bracket right before it.)
|
|
918
|
+
if (quote &&
|
|
919
|
+
op === ":" &&
|
|
920
|
+
/\][ \t]*:[ \t]*$/.test(m[0]) &&
|
|
921
|
+
!/\[[ \t]*$/.test(text.slice(Math.max(0, m.index - 8), m.index)))
|
|
922
|
+
return false;
|
|
923
|
+
// A country's ISO code among others, `{"SK": "Slovakia", "PW": "Palau", …}`: a map of countries, not a secret key.
|
|
924
|
+
if (/^[A-Z]{2}$/.test(name) &&
|
|
925
|
+
(text.slice(Math.max(0, m.index - 300), m.index + 300).match(/["'][A-Z]{2}["'][ \t]*:/g)?.length ?? 0) >= 5)
|
|
926
|
+
return false;
|
|
927
|
+
// XAML's own attributes, `x:Key="Minus10Converter"`, `x:Name`: a resource's or an element's name.
|
|
928
|
+
if (text[m.index - 1] === ":" && /(?:^|[\s<])x$/.test(text.slice(Math.max(0, m.index - 3), m.index - 1)))
|
|
929
|
+
return false;
|
|
930
|
+
// Windows `SET /P NAME=prompt`: what follows the `=` is the prompt the value is read with.
|
|
931
|
+
if (op === "=" && /(?:^|[\s(&])set[ \t]+\/p[ \t]+$/i.test(text.slice(Math.max(0, m.index - 20), m.index)))
|
|
932
|
+
return false;
|
|
933
|
+
// `PublicKey(key_id=…, key="…")`: a public key's constructor takes its key.
|
|
934
|
+
if (isGenericName(name) && /public[_-]?key|pub[_-]?key/i.test(enclosingCall(text, m.index) ?? ""))
|
|
935
|
+
return false;
|
|
936
|
+
// `cache key=…`, `next page token: …`, `public key: …`: the word before a bare generic name decides.
|
|
937
|
+
// (Read as context.ts reads the same words: names.ts readPhrase. A declaration's keyword is no part of the phrase.)
|
|
938
|
+
if (isGenericName(name)) {
|
|
939
|
+
const before = phraseBefore(text.slice(Math.max(0, m.index - 64), m.index), 2).filter((w) => !DECLARATION.test(w));
|
|
940
|
+
if (before.length && readPhrase(before, name).class !== "credential")
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
/** The name of the call whose arguments `at` is among, on its line: `PublicKey` in `PublicKey(key_id=1, key="…")`. */
|
|
946
|
+
function enclosingCall(text, at) {
|
|
947
|
+
let depth = 0;
|
|
948
|
+
for (let i = at - 1; i >= Math.max(0, at - 300); i--) {
|
|
949
|
+
const c = text[i];
|
|
950
|
+
if (c === "\n")
|
|
951
|
+
return undefined;
|
|
952
|
+
if (c === ")")
|
|
953
|
+
depth++;
|
|
954
|
+
else if (c === "(") {
|
|
955
|
+
if (depth === 0)
|
|
956
|
+
return /([A-Za-z_$][\w$.]*)$/.exec(text.slice(Math.max(0, i - 80), i))?.[1];
|
|
957
|
+
depth--;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return undefined;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* The list of strings (stringListItems) an assignment's value at `at` is, when it is one: a flow list from its `[` to the
|
|
964
|
+
* bracket that closes it, however many lines that takes; or, after a YAML key whose line ends there (`yamlKey`, the
|
|
965
|
+
* line before the key), the `- …` lines below it, no less indented than the key, comments and blank lines among them.
|
|
966
|
+
*/
|
|
967
|
+
function listValue(text, at, yamlKey) {
|
|
968
|
+
if (text[at] === "[") {
|
|
969
|
+
const close = closingBracket(text, at + 1);
|
|
970
|
+
return close > 0 && stringListItems(text.slice(at, close + 1)) ? [at, close + 1] : undefined;
|
|
971
|
+
}
|
|
972
|
+
const eol = lineEndAt(text, at);
|
|
973
|
+
if (yamlKey === undefined || text[eol] !== "\n" || !/^[ \t]*(?:#.*)?\r?$/.test(text.slice(at, eol)))
|
|
974
|
+
return undefined;
|
|
975
|
+
const indent = /^[ \t]*/.exec(yamlKey)[0].length;
|
|
976
|
+
let end = -1;
|
|
977
|
+
for (let k = eol + 1; k < text.length && k - eol < MAX_VALUE;) {
|
|
978
|
+
const next = lineEndAt(text, k, MAX_VALUE);
|
|
979
|
+
const line = text.slice(k, next);
|
|
980
|
+
const item = /^([ \t]*)-[ \t]+(.*)/.exec(line);
|
|
981
|
+
// (A mapping or a collection as an item, `- name: x`, makes no list of strings: read no further.)
|
|
982
|
+
if (item && item[1].length >= indent && /^[^"']\S*:(?:[ \t]|\r?$)|^[[{&*!|>]/.test(item[2]))
|
|
983
|
+
return undefined;
|
|
984
|
+
if (item && item[1].length >= indent)
|
|
985
|
+
end = next;
|
|
986
|
+
else if (!/^[ \t]*(?:#.*)?\r?$/.test(line))
|
|
987
|
+
break;
|
|
988
|
+
k = next + 1;
|
|
989
|
+
}
|
|
990
|
+
if (end < 0)
|
|
991
|
+
return undefined;
|
|
992
|
+
const start = eol + 1 + /^\s*/.exec(text.slice(eol + 1, end))[0].length;
|
|
993
|
+
const span = [lineStartAt(text, start), end - (text[end - 1] === "\r" ? 1 : 0)];
|
|
994
|
+
return stringListItems(text.slice(span[0], span[1])) ? span : undefined;
|
|
995
|
+
}
|
|
996
|
+
/** Reads an assignment's value at `at` (after its operator, blanks and a literal's prefix). */
|
|
997
|
+
function assignedValue(text, at, { wholeLine, envLine, shell, pairQuote, logfmt = false, }) {
|
|
998
|
+
// `"NAME=value with spaces"`: to the pair's own closing quote, or a `\n` escape inside a JSON string. (Not a value
|
|
999
|
+
// quoted itself, `\"…\"` in a JSON string included.)
|
|
1000
|
+
if (pairQuote >= 0 && !/["'`]/.test(text[at] ?? "") && !(text[at] === "\\" && /["']/.test(text[at + 1] ?? ""))) {
|
|
1001
|
+
// (Escaped itself inside a JSON string, `\"NAME=value\"`, the pair closes at the same escape.)
|
|
1002
|
+
const close = text[pairQuote - 1] === "\\"
|
|
1003
|
+
? escapedClose(text, at, `\\${text[pairQuote]}`)
|
|
1004
|
+
: stringEnd(text, pairQuote, { from: at });
|
|
1005
|
+
if (close > at) {
|
|
1006
|
+
const esc = text.slice(at, close).search(/\\[nrt]/);
|
|
1007
|
+
const end = esc >= 0 ? at + esc : close;
|
|
1008
|
+
// A query string or a connection string in quotes (`'a=1&b=2'`, `"Pwd=x;User Id=u"`): each value ends at the
|
|
1009
|
+
// next `&name=` or `;name=`.
|
|
1010
|
+
const next = text.slice(at, end).search(/[&;][\w.-][\w. -]{0,59}=/);
|
|
1011
|
+
// (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]);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// A string in parentheses, as Black wraps a long one: `DATABASES_PASSWORD = (\n "…"\n)`.
|
|
1016
|
+
if (text[at] === "(") {
|
|
1017
|
+
const inner = callArguments(text, at + 1);
|
|
1018
|
+
if (inner?.length === 1 && inner[0].literal && !inner[0].keyword)
|
|
1019
|
+
return [inner[0].literal.start, inner[0].literal.end];
|
|
1020
|
+
}
|
|
1021
|
+
const converted = convertedLiteral(text, at);
|
|
1022
|
+
if (converted)
|
|
1023
|
+
return [converted.start, converted.end];
|
|
1024
|
+
// Any other call that is the whole value is code, whose value isn't in the text: `getpass("Password: ")`, Ansible's
|
|
1025
|
+
// `password_expire_warn=dict(type='int', no_log=False)` (which, cut at its comma, would read as `dict(type='int'`).
|
|
1026
|
+
// (A function's name, and the call ending the value: a password with brackets in it, `Pa(ss)w0rd`, goes on; and so
|
|
1027
|
+
// does a word and a number before them, as passwords are made: `Spring2024(Blue)`.)
|
|
1028
|
+
const call = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\(/.exec(text.slice(at, at + 64));
|
|
1029
|
+
if (call &&
|
|
1030
|
+
!/^[A-Z][a-z]+\d{2,}$/.test(call[1]) &&
|
|
1031
|
+
(/[._]/.test(call[1]) || (call[1].length >= 4 && naturalness(call[1].toLowerCase()) > 0.3))) {
|
|
1032
|
+
const close = closingBracket(text, at + call[0].length);
|
|
1033
|
+
// (Code's arguments, not a number alone: `Tiger(6859)`, `Summer(2024)` are passwords.)
|
|
1034
|
+
const args = close > 0 ? text.slice(at + call[0].length, close).trim() : "";
|
|
1035
|
+
if (close > 0 && !/^[\d.]+$/.test(args) && /^[ \t]*(?:[,;)\]}\r\n#]|$)/.test(text.slice(close + 1, close + 3)))
|
|
1036
|
+
return undefined;
|
|
1037
|
+
}
|
|
1038
|
+
const span = readValue(text, at, { toEol: wholeLine, shell, toBlank: logfmt });
|
|
1039
|
+
// A pair in helm's list of them, `--set a.password=x,b.c=d`: the value ends at the list's next unescaped comma. (A
|
|
1040
|
+
// quoted one was written whole: `--set adminPassword='p-NK,fX…'`.)
|
|
1041
|
+
if (span && !/["']/.test(text[span[0] - 1] ?? "") && SET_LIST.test(text.slice(lineStartAt(text, at, 4000), at))) {
|
|
1042
|
+
const comma = /(?<!\\),/.exec(text.slice(span[0], span[1]));
|
|
1043
|
+
if (comma)
|
|
1044
|
+
return [span[0], span[0] + comma.index];
|
|
1045
|
+
}
|
|
1046
|
+
// An unquoted env value runs to the end of the line only as words (a dotenv passphrase); on a shell line it is one
|
|
1047
|
+
// shell word (`PGPASSWORD=… psql -h db`, `export MYSQL_PWD=… && mysql`). A query string's value ends at its next
|
|
1048
|
+
// pair, every `&` starting one (`oauth_consumer_key=…&oauth_nonce=…`, a percent-encoded one decoded; a password's
|
|
1049
|
+
// own `&` starts none, `Tr0ub4dor&3horse`).
|
|
1050
|
+
if (span && envLine && wholeLine) {
|
|
1051
|
+
const value = text.slice(span[0], span[1]);
|
|
1052
|
+
if (/^[^&\s]+(?:&[A-Za-z_][\w.-]{1,60}=[^&\s]*)+$/.test(value))
|
|
1053
|
+
return [span[0], span[0] + value.indexOf("&")];
|
|
1054
|
+
return shellWordValue(text, span);
|
|
1055
|
+
}
|
|
1056
|
+
return span;
|
|
1057
|
+
}
|
|
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
|
+
}
|
|
1070
|
+
/** Inside a `--set` argument (helm, helmfile), after a key and its `=`: the pairs before it, then this one's key. */
|
|
1071
|
+
const SET_LIST = /--set(?:-string|-file|-json|-literal)?[ \t=]+["']?(?:[^\s"',]*,)*[^\s"',=]+=$/;
|
|
1072
|
+
/** Commands an environment variable is set for (`PGPASSWORD=x psql …`): what tells a command line from a passphrase. */
|
|
1073
|
+
const COMMAND_WORDS = /^(?:sudo|env|exec|time|nohup|psql|pg_dump|pg_restore|mysql|mysqldump|mongo|mongosh|redis-cli|sqlcmd|sqlplus|python[\d.]*|pip\d?|node|npm|npx|yarn|pnpm|bun|deno|ruby|bundle|rails|rake|php|java|go|cargo|dotnet|make|bash|sh|zsh|curl|wget|docker|docker-compose|kubectl|helm|terraform|ansible-playbook|aws|gcloud|az|git|ssh|scp|rsync|uv|poetry|pytest|uvicorn|gunicorn|flask|celery|manage\.py)$/;
|
|
1074
|
+
/**
|
|
1075
|
+
* The value of an unquoted `NAME=value` that starts an env line, when a command follows it on the line: its first word,
|
|
1076
|
+
* 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
|
|
1078
|
+
* are flags, `NAME=`s or tokens. Otherwise the line's words are the value (a dotenv passphrase, `correct horse battery staple`).
|
|
1079
|
+
*/
|
|
1080
|
+
function shellWordValue(text, span) {
|
|
1081
|
+
const words = [...text.slice(span[0], span[1]).matchAll(/\S+/g)];
|
|
1082
|
+
if (words.length < 2)
|
|
1083
|
+
return span;
|
|
1084
|
+
const first = words[0][0];
|
|
1085
|
+
const [next, ...later] = words.slice(1).map((w) => w[0]);
|
|
1086
|
+
// (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
|
+
// of plain words, `correct horse battery staple`.)
|
|
1088
|
+
const lineAt = lineStartAt(text, span[0]);
|
|
1089
|
+
// (The next line from its `\n`: a CRLF line's value ends before its `\r`.)
|
|
1090
|
+
const nextAt = text.indexOf("\n", span[1]) + 1;
|
|
1091
|
+
const envFile = [
|
|
1092
|
+
text.slice(lineStartAt(text, Math.max(0, lineAt - 1)), Math.max(0, lineAt - 1)),
|
|
1093
|
+
nextAt > 0 ? text.slice(nextAt, lineEndAt(text, nextAt)) : "",
|
|
1094
|
+
].some((l) => /^[ \t]*(?:export[ \t]+)?[A-Z_][A-Z0-9_]*=/.test(l));
|
|
1095
|
+
const program = /^[a-z][a-z0-9.+_-]{0,30}$/.test(next) && !FUNCTION_WORDS.has(next) && !envFile && !/^[A-Za-z][a-z]*$/.test(first);
|
|
1096
|
+
// (A generated token is a value whole, as a passphrase's words never are: `STRIPE_SECRET_KEY=<token> next dev`.)
|
|
1097
|
+
const command = /;$/.test(first) ||
|
|
1098
|
+
isRandomToken(first) ||
|
|
1099
|
+
/^(?:&&?|\|\|?|;)$/.test(next) ||
|
|
1100
|
+
COMMAND_WORDS.test(next) ||
|
|
1101
|
+
program ||
|
|
1102
|
+
/^(?:\.{0,2}\/|~\/)\S|\.(?:sh|py|js|ts|rb|pl)$/.test(next) ||
|
|
1103
|
+
[next, ...later].some((w) => /^--?[A-Za-z]/.test(w) || /^[A-Za-z_]\w*=/.test(w) || /^(?=\S*\d)(?=\S*[A-Za-z])\S{12,}$/.test(w));
|
|
1104
|
+
return command ? [span[0], span[0] + first.replace(/;$/, "").length] : span;
|
|
1105
|
+
}
|
|
1106
|
+
/** Where `\"` (or `\'`) closes a string that opened with it, inside a JSON string: on the same line, within 4,096 characters. */
|
|
1107
|
+
function escapedClose(text, at, quote) {
|
|
1108
|
+
const close = text.indexOf(quote, at);
|
|
1109
|
+
return close >= 0 && close - at < 4096 && !text.slice(at, close).includes("\n") ? close : -1;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* A value of several lines under `name`: one line is judged like any value; several (a key, a certificate bundle)
|
|
1113
|
+
* are hidden whole, unless each line is inert or code (a template's `{{ .Values.a }}` lines, a script's), a comment, or
|
|
1114
|
+
* brackets and punctuation alone (`}`, `);`).
|
|
1115
|
+
*/
|
|
1116
|
+
function multiLineValue(out, name, body, rule) {
|
|
1117
|
+
// (Judged by its first 8 KB, a key's or a certificate chain's size: past that, a block holds more than a value.)
|
|
1118
|
+
const head = body?.value.slice(0, 8192) ?? "";
|
|
1119
|
+
const lines = head
|
|
1120
|
+
.split("\n")
|
|
1121
|
+
.map((line) => line.trim())
|
|
1122
|
+
.filter(Boolean);
|
|
1123
|
+
if (!body ||
|
|
1124
|
+
isCode(head, true) ||
|
|
1125
|
+
lines.every((line) => isInert(line) || isCode(line, true) || /^(?:#|\/\/)/.test(line) || !/[\p{L}\p{N}]/u.test(line)) ||
|
|
1126
|
+
(lines.length === 1 && !isNamedSecret(name, head, true)))
|
|
1127
|
+
return;
|
|
1128
|
+
namedFinding(out, body.start, body.end, name, rule);
|
|
1129
|
+
}
|
|
1130
|
+
/** The body of a heredoc whose `<<` is at `at` (`<<EOF`, `<<-EOT`, `<<~SQL`, `<<'EOF'`), up to its delimiter line, trimmed. */
|
|
1131
|
+
function heredoc(text, at) {
|
|
1132
|
+
const open = HEREDOC.exec(text.slice(at, at + 100));
|
|
1133
|
+
if (!open)
|
|
1134
|
+
return undefined;
|
|
1135
|
+
const start = at + open[0].length;
|
|
1136
|
+
const close = new RegExp(String.raw `^[ \t]*${open[2]}[ \t]*\r?$`, "m").exec(text.slice(start, start + 100_000));
|
|
1137
|
+
if (!close)
|
|
1138
|
+
return undefined;
|
|
1139
|
+
const body = text.slice(start, start + close.index).replace(/\r?\n$/, "");
|
|
1140
|
+
return trimmed({ value: body, start, end: start + body.length });
|
|
1141
|
+
}
|
|
1142
|
+
/** A span without the blanks at either end. */
|
|
1143
|
+
function trimmed(span) {
|
|
1144
|
+
const value = span.value.trim();
|
|
1145
|
+
if (!value)
|
|
1146
|
+
return undefined;
|
|
1147
|
+
const start = span.start + span.value.indexOf(value);
|
|
1148
|
+
return { value, start, end: start + value.length };
|
|
1149
|
+
}
|
|
1150
|
+
/** `--password value`, `--api-key=value`, `-p value` style flags on a command line. */
|
|
1151
|
+
function flags(text, out) {
|
|
1152
|
+
for (const m of text.matchAll(FLAG)) {
|
|
1153
|
+
const name = m[1].replace(/^-+/, "");
|
|
1154
|
+
if (name.length < 2 || !isCredentialName(name))
|
|
1155
|
+
continue;
|
|
1156
|
+
const at = m.index + m[0].length;
|
|
1157
|
+
const span = readValue(text, at, { shell: true });
|
|
1158
|
+
if (!span)
|
|
1159
|
+
continue;
|
|
1160
|
+
let raw = text.slice(span[0], span[1]);
|
|
1161
|
+
const skip = (n) => {
|
|
1162
|
+
span[0] += n;
|
|
1163
|
+
raw = raw.slice(n);
|
|
1164
|
+
};
|
|
1165
|
+
// `--cookie "sessionid=…"`, `-b name=…`: the cookie's name stays, its value is the secret.
|
|
1166
|
+
if (/^(?:cookie|b)$/i.test(name) && /^[\w.-]{1,100}=/.test(raw))
|
|
1167
|
+
skip(raw.indexOf("=") + 1);
|
|
1168
|
+
// `--auth user:password`, `--user name:pw`: the user stays, the part after the colon is the secret.
|
|
1169
|
+
if (/^(?:auth|user|u|basic|credentials?)$/i.test(name) && /^[\w.@+-]{1,100}:[^\s:]+$/.test(raw))
|
|
1170
|
+
skip(raw.indexOf(":") + 1);
|
|
1171
|
+
// OpenSSL's pass phrase arguments say where it comes from: `pass:<pw>` is the phrase; `env:`, `file:`, `fd:`
|
|
1172
|
+
// and `stdin` name a place, not a secret.
|
|
1173
|
+
if (/^pass(?:in|out|word)?$|^passphrase$/i.test(name)) {
|
|
1174
|
+
if (/^(?:env|file|fd):\S|^stdin$/.test(raw))
|
|
1175
|
+
continue;
|
|
1176
|
+
if (/^pass:/.test(raw))
|
|
1177
|
+
skip(5);
|
|
1178
|
+
}
|
|
1179
|
+
// (`use the -user and -passwd options`: the flag named in a sentence.)
|
|
1180
|
+
if (span[0] === at && /^(?:options?|flags?|arguments?|args|parameters?|params|switch(?:es)?)$/i.test(raw))
|
|
1181
|
+
continue;
|
|
1182
|
+
// (An option its tool takes a value for only attached: the next word is the command's next argument, `mysql
|
|
1183
|
+
// --password mydb`, unless it looks like the password typed in the wrong place.)
|
|
1184
|
+
if (!m[0].endsWith("=") && promptsForValue(text, m.index + m[0].indexOf(m[1])) && !looksSecret(raw))
|
|
1185
|
+
continue;
|
|
1186
|
+
if (!isNamedSecret(name, span[0] > at ? `"${raw}"` : raw, true))
|
|
1187
|
+
continue;
|
|
1188
|
+
namedFinding(out, span[0], span[1], name, "named:flag");
|
|
1189
|
+
out[out.length - 1].literal = true;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/** `<password>value</password>`, `<ns:apiKey>value</ns:apiKey>`. */
|
|
1193
|
+
function xmlElements(text, out) {
|
|
1194
|
+
if (!text.includes("</"))
|
|
1195
|
+
return;
|
|
1196
|
+
for (const m of text.matchAll(XML_ELEMENT)) {
|
|
1197
|
+
const value = trimmed(elementText(m, 2));
|
|
1198
|
+
if (!value || !isCredentialName(m[1].replace(/^\w+:/, "")) || !isNamedSecret(m[1], `"${value.value}"`))
|
|
1199
|
+
continue;
|
|
1200
|
+
namedFinding(out, value.start, value.end, m[1], "named:xml");
|
|
1201
|
+
out[out.length - 1].literal = true;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Words that start a sentence about a credential after its name, not a value: `password reset link`, `Passwort wurde …`;
|
|
1206
|
+
* and a credential's noun, the name and it one phrase: `Bearer Token Usage [RFC6750].`, `Secret Key Rotation`.
|
|
1207
|
+
*/
|
|
1208
|
+
const PROSE_START = /^(tokens?|keys?|secrets?|passwords?|is|was|for|of|on|and|to|the|a|an|reset|change|changed|updated|rotated|set|required|must|should|will|can|field|policy|manager|hash|length|expired|expires|expiry|last|not|has|have|had|ist|est|es|é|è|är|jest|je|это|lautet|wurde|wird|muss|doit|debe|deve)$/i;
|
|
1209
|
+
/** `NAME value` with blanks between: redis.conf's `requirepass`, Dockerfile `ENV`, `set`, tables with column bars. */
|
|
1210
|
+
function spacedPairs(text, out) {
|
|
1211
|
+
for (const m of text.matchAll(SPACED)) {
|
|
1212
|
+
const name = m[2];
|
|
1213
|
+
if (!isCredentialName(name))
|
|
1214
|
+
continue;
|
|
1215
|
+
const at = m.index + m[0].length;
|
|
1216
|
+
let span = readValue(text, at, { toEol: !/^["'`]/.test(text[at]) });
|
|
1217
|
+
if (!span)
|
|
1218
|
+
continue;
|
|
1219
|
+
// A table's cell (the name's own cell ended at a bar) ends at a bar; a column's at two blanks. Elsewhere a bar is the
|
|
1220
|
+
// value's (`requirepass a|b`).
|
|
1221
|
+
const barred = /[│|┃║]/.test(m[3]);
|
|
1222
|
+
// (A tab after the name separates columns, `password\tKx9…\tuser\tadmin` (`psql -At`, `--output text`): the cell
|
|
1223
|
+
// ends at the next tab. With more cells after it, the row is a table's, which its header reads: `fly secrets list`'s
|
|
1224
|
+
// `NAME\tDIGEST\tCREATED AT`.)
|
|
1225
|
+
const tabbed = !barred && m[3].includes("\t");
|
|
1226
|
+
const cell = text
|
|
1227
|
+
.slice(span[0], span[1])
|
|
1228
|
+
.search(barred ? /[ \t]*[│|┃║]|[ \t]{2,}/ : tabbed ? /\t|[ \t]{2,}/ : /[ \t]{2,}/);
|
|
1229
|
+
if (cell > 0)
|
|
1230
|
+
span = [span[0], span[0] + cell];
|
|
1231
|
+
// A line that is a JSON string of its own (a notebook's output, `"NAME value\n",`) ends at its escape or quote.
|
|
1232
|
+
const jsonLine = m[1].endsWith('"');
|
|
1233
|
+
if (jsonLine && !/^[ \t]+$/.test(m[3]))
|
|
1234
|
+
continue;
|
|
1235
|
+
if (jsonLine) {
|
|
1236
|
+
const stop = text.slice(span[0], span[1]).search(/\\[nrt]|"/);
|
|
1237
|
+
if (stop > 0)
|
|
1238
|
+
span = [span[0], span[0] + stop];
|
|
1239
|
+
if (!/^(?:\\[nrt])*"[ \t]*[,\]]?[ \t]*\r?$/.test(text.slice(span[1], lineEndAt(text, span[1]))))
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
// A note in brackets and a colon go on the label, the value after them: `password (utf-8): …`, `Password (required): …`.
|
|
1243
|
+
const label = /^[([{][^)\]}\n]{0,60}[)\]}][ \t]*:[ \t]+(?=\S)/.exec(text.slice(span[0], span[1]));
|
|
1244
|
+
if (label)
|
|
1245
|
+
span = [span[0] + label[0].length, span[1]];
|
|
1246
|
+
const raw = text.slice(span[0], span[1]);
|
|
1247
|
+
// A line of an `ls` listing, `passwd- sudo.conf`. (A random token is no file's name: a cookie's value beside its
|
|
1248
|
+
// name, `__Secure-1PSID<tab>g.a000R9Eh…`.)
|
|
1249
|
+
if (isListingLine(text.slice(lineStartAt(text, m.index), lineEndAt(text, m.index))) && !isRandomToken(raw))
|
|
1250
|
+
continue;
|
|
1251
|
+
if (/^[\w-]+:\s/.test(raw))
|
|
1252
|
+
continue; // `PSK identity: None`: a label, then its own value
|
|
1253
|
+
if (/^--?[A-Za-z]|^(?:&&|\|\||[|;>&])/.test(raw))
|
|
1254
|
+
continue; // `htpasswd -nb admin pw`, `pwd && ls`: a command line
|
|
1255
|
+
if (/^(?:->|=>|::|\?\.|<-)/.test(raw))
|
|
1256
|
+
continue; // `LoginField.PASSWORD -> input.text`: a `when` arm, an operator
|
|
1257
|
+
// A note or a type in brackets first (`password (optional)`, `token [required]`, a docstring's `password (str|None):`),
|
|
1258
|
+
// a member (`.field`), code in braces (`{ get; set; }`). (A value in parentheses and nothing else, `password (12:30)`,
|
|
1259
|
+
// is the prose rule's, which reads one: `the passwd (M34d0w)`.)
|
|
1260
|
+
const note = /^[([{]([^)\]}\n]{0,60})[)\]}]/.exec(raw);
|
|
1261
|
+
if ((note && (readsAsWords(note[1]) || (note[0] === raw && raw[0] === "(" && !barred))) ||
|
|
1262
|
+
/^\.[A-Za-z_]|^\{[ \t]/.test(raw))
|
|
1263
|
+
continue;
|
|
1264
|
+
// More words after the cell (and its closing quote) on a line that isn't a table: a directive (`password requisite
|
|
1265
|
+
// pam_pwquality.so`). (Not when the cell was read as far as a value is, UNQUOTED_READ: the rest is its own; nor a
|
|
1266
|
+
// comment after the value, `requirepass Kx9mQ2vL # set by ops`, which readValue cut.)
|
|
1267
|
+
const after = span[0] > at ? span[1] + 1 : span[1];
|
|
1268
|
+
const rest = text.slice(after, lineEndAt(text, after));
|
|
1269
|
+
if (!jsonLine && !barred && span[1] - span[0] < UNQUOTED_READ && /\S/.test(rest) && !/^[ \t]+#/.test(rest))
|
|
1270
|
+
continue;
|
|
1271
|
+
// `requirepass value`, `ENV API_KEY value`, `│ password │ value │`: but not prose (`password reset link`).
|
|
1272
|
+
if (PROSE_START.test(raw) || (!barred && PROSE_START.test(raw.split(/[ \t]/)[0])))
|
|
1273
|
+
continue;
|
|
1274
|
+
if (/^\S+(?: \S+){2,}$/.test(raw) &&
|
|
1275
|
+
!barred &&
|
|
1276
|
+
!/\d/.test(raw) &&
|
|
1277
|
+
!(/^[a-z]+(?: [a-z]+)+$/.test(raw) && isPassphrase(raw.split(" "))))
|
|
1278
|
+
continue; // words: a sentence, not a value
|
|
1279
|
+
if (/^passwd$/i.test(name) && /^[a-z_][a-z0-9_-]{0,31}$/.test(raw))
|
|
1280
|
+
continue; // `passwd ubuntu`: a user whose password changes
|
|
1281
|
+
// A title, `Password Store`, `Password Manager`; and `pass init`, `pass insert`: the password store's commands.
|
|
1282
|
+
if (((/^[A-Z][a-z]+$/.test(name) && /^[A-Z][a-z]{2,}$/.test(raw)) || (name === "pass" && /^[a-z]{2,}$/.test(raw))) &&
|
|
1283
|
+
naturalness(raw.toLowerCase()) > 1)
|
|
1284
|
+
continue;
|
|
1285
|
+
if (isSentenceWord(raw))
|
|
1286
|
+
continue; // `can't fake a pass either.`: the sentence goes on
|
|
1287
|
+
// A call, its result read: Gradle's `storePassword file('keystore.pass').text.trim()`, `password System.getenv("PW")`.
|
|
1288
|
+
if (/^[A-Za-z_$][\w$.]*\([^()]*\)(?:\.[A-Za-z_$][\w$]*(?:\([^()]*\))?)*$/.test(raw))
|
|
1289
|
+
continue;
|
|
1290
|
+
if (!isNamedSecret(name, span[0] > at ? `"${raw}"` : raw, true))
|
|
1291
|
+
continue;
|
|
1292
|
+
namedFinding(out, span[0], span[1], name, "named:spaced", 0.85);
|
|
1293
|
+
out[out.length - 1].literal = true;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
/** Meltano's `config list`: `password [env: TARGET_SNOWFLAKE_PASSWORD] current value: 'pw' (from \`.env\`)`. */
|
|
1297
|
+
function configList(text, out) {
|
|
1298
|
+
if (!text.includes("current value:"))
|
|
1299
|
+
return;
|
|
1300
|
+
for (const m of text.matchAll(new RegExp(String.raw `^[ \t]*([\w.-]{1,80})[ \t]+\[env: [A-Z0-9_]{1,120}\][ \t]+current value:[ \t]+${quoted(2)}`, "dgm")))
|
|
1301
|
+
namedValue(out, m[1], group(m, 3), "named:config-list");
|
|
1302
|
+
}
|
|
1303
|
+
/** C and Arduino: `#define SECRET_PASS "…"`. */
|
|
1304
|
+
function cDefines(text, out) {
|
|
1305
|
+
if (!text.includes("#define"))
|
|
1306
|
+
return;
|
|
1307
|
+
for (const m of text.matchAll(new RegExp(String.raw `^[ \t]*#define[ \t]+([A-Za-z_]\w{1,80})[ \t]+${quoted(2, 1, '"')}`, "dgm")))
|
|
1308
|
+
namedValue(out, m[1], group(m, 3), "named:define");
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* A default inside a reference: Spring `${DB_PASSWORD:claims_dev_2024}`, shell `${DB_PASSWORD:-hunter2}` and
|
|
1312
|
+
* `${DB_PASSWORD:=hunter2}` (assigned when unset), and the same without the colon. (`:?message` is the error when
|
|
1313
|
+
* unset, `:+word` the value when set: neither is a default.)
|
|
1314
|
+
*/
|
|
1315
|
+
function referenceDefaults(text, out) {
|
|
1316
|
+
if (!text.includes("${"))
|
|
1317
|
+
return;
|
|
1318
|
+
for (const m of text.matchAll(/\$\{([A-Za-z_][\w.-]{0,80})(?::[-=]|:(?![-=?+])|[-=])([^}\s$]{4,4096})\}/dg)) {
|
|
1319
|
+
namedValue(out, m[1], group(m, 2), "named:env-default");
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/** JavaScript and Ruby fallbacks: `process.env.JWT_SECRET ?? "…"`, `ENV["X"] || "…"`, `ENV.fetch("X") { "…" }`. */
|
|
1323
|
+
function envFallbacks(text, out) {
|
|
1324
|
+
if (!/process\.env|ENV[[.]/.test(text))
|
|
1325
|
+
return;
|
|
1326
|
+
const lookup = String.raw `process\.env\.([A-Za-z_]\w{0,80})|process\.env\[["']([\w.-]{1,80})["']\]|ENV\[["']([\w.-]{1,80})["']\]`;
|
|
1327
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:(?:${lookup})[ \t]*(?:\?\?|\|\||\bor\b)|ENV\.fetch\(["']([\w.-]{1,80})["']\)[ \t]*\{)[ \t]*${quoted(5, 4, "\"'`")}`, "dg"))) {
|
|
1328
|
+
namedValue(out, m[1] ?? m[2] ?? m[3] ?? m[4], group(m, 6), "named:env-default");
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
/** A ternary's `?`: not a query string's (`?schema=public`), optional chaining's `?.` nor a fallback's `??`. */
|
|
1332
|
+
const TERNARY = String.raw `(?<!\?)\?(?![?.=]|[\w-]+=)`;
|
|
1333
|
+
/** A ternary, a Python conditional, or a fallback. */
|
|
1334
|
+
const CONDITIONAL = new RegExp(String.raw `${TERNARY}[^\n]*:|[ \t]if[ \t][^\n]*[ \t]else[ \t]|\|\||\?\?|[ \t]or[ \t]`);
|
|
1335
|
+
/** A credential's name and what gives it a value: `pw =`, `password:`, `setPassword(`. */
|
|
1336
|
+
const CONDITIONAL_HEAD = /(?<![\w$])([A-Za-z_$][\w$.]{0,80}?)(?:["']?[ \t]*(?:=|:)(?![=:])|[ \t]*\()[ \t]*/dg;
|
|
1337
|
+
/** What may follow a branch that ends the expression: its statement's or bracket's end, or a comment. */
|
|
1338
|
+
const BRANCH_END = String.raw `(?:[;,)\]}]|#|//|$)`;
|
|
1339
|
+
/** Where a literal branch stands in the expression after it. */
|
|
1340
|
+
const BRANCHES = [
|
|
1341
|
+
// A ternary's: `? "…" :` and, after its `?`, `: "…"` closing the expression.
|
|
1342
|
+
new RegExp(String.raw `${TERNARY}[ \t]*${quoted(1, 1, "\"'`")}[ \t]*:`, "dg"),
|
|
1343
|
+
new RegExp(String.raw `${TERNARY}[^\n]*?:[ \t]*${quoted(1, 1, "\"'`")}[ \t]*${BRANCH_END}`, "dg"),
|
|
1344
|
+
// A fallback's, after `or`, `||` or `??`: `os.getenv("PW") or "…"`.
|
|
1345
|
+
new RegExp(String.raw `(?:\|\||\?\?|[ \t]or)[ \t]+${quoted(1, 1, "\"'`")}[ \t]*${BRANCH_END}`, "dg"),
|
|
1346
|
+
// Python's: `"…" if … else …` and `… if … else "…"`.
|
|
1347
|
+
new RegExp(String.raw `^${quoted(1, 1, "\"'")}[ \t]+if[ \t]`, "dg"),
|
|
1348
|
+
new RegExp(String.raw `[ \t]if[ \t][^\n]*?[ \t]else[ \t]+${quoted(1, 1, "\"'")}[ \t]*${BRANCH_END}`, "dg"),
|
|
1349
|
+
];
|
|
1350
|
+
/**
|
|
1351
|
+
* A conditional's string branches under a credential's name, assigned or passed: `pw = cfg == null ? "…" : cfg.pw;`,
|
|
1352
|
+
* `setPassword(p == null ? "…" : p)`, Python's `password = os.environ.get("PW") if env else "…"`, a fallback's
|
|
1353
|
+
* `token = os.getenv("T") or "…"`. (The expression is code, which the readers of a value leave whole; a literal branch
|
|
1354
|
+
* is a value all the same.)
|
|
1355
|
+
*/
|
|
1356
|
+
function conditionalBranches(text, out) {
|
|
1357
|
+
// (A cheap gate on the whole text; CONDITIONAL itself only on an expression, at most 300 characters.)
|
|
1358
|
+
if (!/[?|]|[ \t](?:or|if)[ \t]/.test(text))
|
|
1359
|
+
return;
|
|
1360
|
+
CONDITIONAL_HEAD.lastIndex = 0;
|
|
1361
|
+
for (const m of text.matchAll(CONDITIONAL_HEAD)) {
|
|
1362
|
+
const name = m[1].split(".").at(-1);
|
|
1363
|
+
if (!isCredentialName(name))
|
|
1364
|
+
continue;
|
|
1365
|
+
// The expression: the rest of its line, up to a statement's `;`.
|
|
1366
|
+
const from = m.index + m[0].length;
|
|
1367
|
+
const line = text.slice(from, lineEndAt(text, from));
|
|
1368
|
+
const expr = line.slice(0, Math.min(300, /;/.exec(line)?.index ?? line.length));
|
|
1369
|
+
// (Quotes escaped are a string's inside a string, a level the JSON pass decodes and reads again.)
|
|
1370
|
+
if (!CONDITIONAL.test(expr) || /\\["']/.test(expr))
|
|
1371
|
+
continue;
|
|
1372
|
+
for (const branch of BRANCHES) {
|
|
1373
|
+
branch.lastIndex = 0;
|
|
1374
|
+
for (const b of expr.matchAll(branch)) {
|
|
1375
|
+
const [start, end] = b.indices[2];
|
|
1376
|
+
namedValue(out, name, { value: b[2], start: from + start, end: from + end }, "named:conditional");
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/** Android resources and similar: `<string name="api_secret">…</string>`, `<setting name="X"><value>…</value>`. */
|
|
1382
|
+
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
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
/** A dot-leader row: the label (group 1), then the rest of the line (group 2). */
|
|
1392
|
+
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");
|
|
1393
|
+
/** Dot-leader rows (`artisan config:show`, `wp config list`): `password ........ value`. */
|
|
1394
|
+
function dotLeaders(text, out) {
|
|
1395
|
+
if (!text.includes("..."))
|
|
1396
|
+
return;
|
|
1397
|
+
// (The label is words of its own, not a path's last segment or a test runner's `test <path> ... ok`.)
|
|
1398
|
+
for (const m of text.matchAll(DOT_LEADER)) {
|
|
1399
|
+
if (isCredentialName(m[1]) && isNamedSecret(m[1], m[2], true))
|
|
1400
|
+
namedFinding(out, m.indices[2][0], m.indices[2][1], m[1], "named:dot-leader");
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
const USER_INFO = new RegExp(String.raw `(?:^|[\s"'])((?:[\w.-]*[._-])?user[._-]?info|[\w.-]*basic[\w.-]*credentials?)[ \t]*[=:][ \t]*["']?[\w.@+-]{1,100}:${unquotedValue(String.raw `[^\s"']`, 6, String.raw `["']|\s|$`)}`, "dgim");
|
|
1404
|
+
/** `user:secret` under a name that says it's a user's credentials (`basic.auth.user.info`, `userinfo`): the part after the colon. */
|
|
1405
|
+
function userInfo(text, out) {
|
|
1406
|
+
if (!/user[._-]?info|basic/i.test(text))
|
|
1407
|
+
return;
|
|
1408
|
+
for (const m of text.matchAll(USER_INFO)) {
|
|
1409
|
+
if (looksSecret(`"${m[2]}"`, true) && !isInert(m[2]))
|
|
1410
|
+
namedFinding(out, m.indices[2][0], m.indices[2][1], m[1], "named:userinfo");
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Terraform variables and outputs: `variable "db_password" {` … `default = "…"` (or a heredoc) among the block's own
|
|
1415
|
+
* attributes, not a nested block's (`validation { … }`). `sensitive = true` says the value is secret whatever it's named;
|
|
1416
|
+
* an `output` block's value is judged only then.
|
|
1417
|
+
*/
|
|
1418
|
+
function terraformVariables(text, out) {
|
|
1419
|
+
if (!/\b(?:variable|output)\b/.test(text))
|
|
1420
|
+
return;
|
|
1421
|
+
// (Where the block last read ends: a block holds no other, so a run of unclosed `variable "a" {` costs each character once.)
|
|
1422
|
+
let readTo = 0;
|
|
1423
|
+
for (const m of text.matchAll(/\b(variable|output)[ \t]+"([\w-]{1,80})"[ \t]*\{/g)) {
|
|
1424
|
+
if (m.index < readTo)
|
|
1425
|
+
continue;
|
|
1426
|
+
const open = m.index + m[0].length;
|
|
1427
|
+
// (A heredoc can hold what the bracket matcher reads as an unclosed string: then the block ends at a `}` line.)
|
|
1428
|
+
const bracket = closingBracket(text, open);
|
|
1429
|
+
const line = text.indexOf("\n}", open);
|
|
1430
|
+
const block = topLevel(text, open, Math.min(bracket >= 0 ? bracket : line >= 0 ? line : text.length, open + 8000));
|
|
1431
|
+
readTo = open + block.length;
|
|
1432
|
+
const sensitive = /(?<![\w.-])sensitive[ \t]*=[ \t]*true\b/.test(block);
|
|
1433
|
+
const name = m[2];
|
|
1434
|
+
if (m[1] === "output" && !sensitive)
|
|
1435
|
+
continue;
|
|
1436
|
+
// (An attribute starts its line, or the block: not one in a comment, `# default = "changeme"`, `description = "…" # value = …`.)
|
|
1437
|
+
const attribute = /(?:^|\n)[ \t]*(default|value)[ \t]*=(?!=)[ \t]*/.exec(block);
|
|
1438
|
+
if (!attribute)
|
|
1439
|
+
continue;
|
|
1440
|
+
const at = open + attribute.index + attribute[0].length;
|
|
1441
|
+
if (HEREDOC.test(text.slice(at, at + 100))) {
|
|
1442
|
+
if (sensitive || isCredentialName(name))
|
|
1443
|
+
multiLineValue(out, sensitive ? "password" : name, heredoc(text, at), "named:tf-variable");
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
const value = text[at] === '"' ? literalAt(text, at) : undefined;
|
|
1447
|
+
if (value)
|
|
1448
|
+
namedValue(out, name, value, "named:tf-variable", { judgedAs: sensitive ? "password" : name });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* The text of a block, `text[start, end)`, with its nested blocks and its strings' contents blanked out (the same
|
|
1453
|
+
* length, so positions hold): its own attributes only, never words inside a `description`.
|
|
1454
|
+
*/
|
|
1455
|
+
function topLevel(text, start, end) {
|
|
1456
|
+
const out = [];
|
|
1457
|
+
for (let i = start; i < end; i++) {
|
|
1458
|
+
const close = text[i] === '"' ? stringEnd(text, i) : text[i] === "{" ? closingBracket(text, i + 1) : -1;
|
|
1459
|
+
if (close < 0 || close >= end) {
|
|
1460
|
+
out.push(text[i]);
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
out.push(text[i], " ".repeat(close - i - 1), text[close]);
|
|
1464
|
+
i = close;
|
|
1465
|
+
}
|
|
1466
|
+
return out.join("");
|
|
1467
|
+
}
|
|
1468
|
+
/** Plan diffs: `"CLIENT_SECRET" = "old" -> "new"`, `password: "old" => "new"`. */
|
|
1469
|
+
function diffArrows(text, out) {
|
|
1470
|
+
if (!/->|=>/.test(text))
|
|
1471
|
+
return;
|
|
1472
|
+
for (const m of text.matchAll(new RegExp(String.raw `["']?([A-Za-z_][\w.-]{0,80})["']?[ \t]*[=:][ \t]*(["'])[^"'\n]{0,${MAX_VALUE}}\2[ \t]*(?:->|=>)[ \t]*${quoted(3)}`, "dg")))
|
|
1473
|
+
namedValue(out, m[1], group(m, 4), "named:diff-arrow");
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* The fields a mark reaches into: a sensitive value's own, a Terraform state's resources and a plan's changes (`before`,
|
|
1477
|
+
* `after`). Not a state's `sensitive_attributes`, the paths of its secrets: `{"type": "get_attr", "value": "result"}`.
|
|
1478
|
+
*/
|
|
1479
|
+
const MARKED_FIELDS = /^(?:value|outputs|resources|instances|attributes|values|change|before|after)$/;
|
|
1480
|
+
/** A value key in JSON, quoted: what jsonValues needs in a text to have anything to read. */
|
|
1481
|
+
const PAIR_VALUE_KEY = new RegExp(`"${PAIR_VALUE.source.slice(1, -1)}"`, "i");
|
|
1482
|
+
/**
|
|
1483
|
+
* JSON documents in the text (API responses, `terraform output -json`, `aws … --output json`), walked
|
|
1484
|
+
* for values whose name is a sibling field (`{"name": "DB_PASSWORD", "value": …}`, `{"keyName": "key1",
|
|
1485
|
+
* "value": …}`) or which are marked sensitive (`{"sensitive": true, "value": …}`). Values directly under
|
|
1486
|
+
* a credential name are left to the assignment rule. Each value is where the parser read it, however the
|
|
1487
|
+
* source escaped it (`\/`, `é`).
|
|
1488
|
+
*/
|
|
1489
|
+
function jsonValues(text, out) {
|
|
1490
|
+
if (!PAIR_VALUE_KEY.test(text))
|
|
1491
|
+
return;
|
|
1492
|
+
for (const doc of jsonDocuments(text)) {
|
|
1493
|
+
const walk = (v, sensitive) => {
|
|
1494
|
+
if (v.kind === "array")
|
|
1495
|
+
for (const x of v.items)
|
|
1496
|
+
walk(x, sensitive);
|
|
1497
|
+
if (v.kind !== "object")
|
|
1498
|
+
return;
|
|
1499
|
+
const field = (k) => v.entries.find(([key]) => key === k)?.[1];
|
|
1500
|
+
const type = field("type");
|
|
1501
|
+
const sens = field("sensitive");
|
|
1502
|
+
const marked = sensitive ||
|
|
1503
|
+
(sens?.kind === "literal" && sens.value === true) ||
|
|
1504
|
+
(type?.kind === "string" &&
|
|
1505
|
+
/^(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];
|
|
1507
|
+
for (const [k, x] of v.entries) {
|
|
1508
|
+
if (x.kind === "string" && PAIR_VALUE.test(k) && !/^\s*\{/.test(x.value)) {
|
|
1509
|
+
const quoted = JSON.stringify(x.value);
|
|
1510
|
+
if ((sibling && isCredentialName(sibling.value) && isNamedSecret(sibling.value, quoted, true)) ||
|
|
1511
|
+
(marked && looksSecret(quoted, true)))
|
|
1512
|
+
namedFinding(out, x.start, x.end, sibling?.value ?? "secret", "named:json-sibling", 0.95);
|
|
1513
|
+
}
|
|
1514
|
+
else
|
|
1515
|
+
walk(x, marked && MARKED_FIELDS.test(k));
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
walk(doc, false);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/** Callees that look a key up, so their literal argument is the key, not a secret: `getToken("github")`. */
|
|
1522
|
+
const LOOKUP = /^(?:find|get|delete|remove|has|fetch|load|read|lookup|clear|forgot|show|list|describe|generate|create|new|is)(?:_|[A-Z])/;
|
|
1523
|
+
/**
|
|
1524
|
+
* A call named for a credential that is given one: `cfg.setPassword("…")`, `builder.apiKey("…")`,
|
|
1525
|
+
* `withToken("…")`, `u.check_password("…")`, `keyring.set_password(service, user, "…")`. The secret is the
|
|
1526
|
+
* last string literal among the arguments, or with keyword arguments the one named for a credential. A lookup
|
|
1527
|
+
* takes the key it looks up instead (`findPassword(service)`, `getToken("github")`); its literal fallback is the
|
|
1528
|
+
* secret itself (`get_secret("prod/db", default="…")`).
|
|
1529
|
+
*/
|
|
1530
|
+
const SETTER_CALL = new RegExp(String.raw `(?<![\w$])((?:[A-Za-z_$][\w$]{0,60}?)?(?:${CREDENTIAL_WORD})[\w$]{0,60})[ \t]{0,2}\(`, "gi");
|
|
1531
|
+
function setterCalls(text, out) {
|
|
1532
|
+
// (Any callee that holds a credential word, `set_db_password`, `withApiKey`; isCredentialName decides.)
|
|
1533
|
+
for (const { match, args } of calls(text, SETTER_CALL)) {
|
|
1534
|
+
const callee = match[1];
|
|
1535
|
+
const hasher = HASHER.exec(callee.split(".").at(-1));
|
|
1536
|
+
if (!hasher && LOOKUP.test(callee)) {
|
|
1537
|
+
for (const a of args)
|
|
1538
|
+
if (a.literal && a.keyword && /^(?:default|fallback|default_value|defaultValue)$/.test(a.keyword))
|
|
1539
|
+
namedValue(out, callee, a.literal, "named:fallback", { judgedAs: "password" });
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
// (`pass` alone is the verb: picobox's `@box.pass_("database_session")`. A hasher's name says what it's given, though
|
|
1543
|
+
// it reads as a hash's: `password_hash`.)
|
|
1544
|
+
if (!hasher && (!isCredentialName(callee) || /^pass_*$/i.test(callee)))
|
|
1545
|
+
continue;
|
|
1546
|
+
// (A keyword argument named for a credential, `password="…"`, or for the value the call sets, prefect's
|
|
1547
|
+
// `Secret(value="…")`, is the secret's slot, and the positional arguments are then a service's and a user's; a
|
|
1548
|
+
// hasher's first argument is read all the same, `make_password("…", salt="…")`. Other keywords are options:
|
|
1549
|
+
// `set_password("…", commit=True)` is given its secret first.)
|
|
1550
|
+
const slots = args.filter((a) => a.keyword && (isCredentialName(a.keyword) || a.keyword === "value"));
|
|
1551
|
+
for (const a of slots)
|
|
1552
|
+
if (a.literal)
|
|
1553
|
+
namedValue(out, hasher ? "password" : callee, a.literal, "named:setter");
|
|
1554
|
+
if (slots.length && !hasher?.[1])
|
|
1555
|
+
continue;
|
|
1556
|
+
// The secret is the last argument, after a service and a user (`keyring.set_password("pgcli", key, passwd)` passes
|
|
1557
|
+
// a variable); flags after it don't count (`setPassword("…", true)`). A credentials call takes a user, then the
|
|
1558
|
+
// secret: `setCredentials("admin", "…", "realm")`.
|
|
1559
|
+
const positional = args.filter((a) => !a.keyword && !/^(?:true|false|True|False|None|null|nil|undefined|-?\d+(?:\.\d+)?)$/.test(a.text));
|
|
1560
|
+
// A hasher or a checker takes the password first, its salt or its hash after; werkzeug's checker, last (HASHER).
|
|
1561
|
+
let secret = positional.at(-1);
|
|
1562
|
+
if (hasher?.[1])
|
|
1563
|
+
secret = positional[0];
|
|
1564
|
+
else if (!hasher && /credentials?$/i.test(callee) && positional.length >= 2)
|
|
1565
|
+
secret = positional[1];
|
|
1566
|
+
// (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");
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Calls that hash or check a password. Given first (the group): Django's `make_password("…", salt, hasher)` and
|
|
1573
|
+
* `check_password("…", encoded)`, bcrypt's `hashpw(b"…", salt)` and `checkpw`, PHP's `password_hash("…",
|
|
1574
|
+
* PASSWORD_DEFAULT)` and `password_verify`, werkzeug's `generate_password_hash`; given last, werkzeug's
|
|
1575
|
+
* `check_password_hash(hash, "…")`.
|
|
1576
|
+
*/
|
|
1577
|
+
const HASHER = /^((?:make|hash|check|verify)_?password|generate_password_hash|(?:hash|check)pw|password_(?:hash|verify))$|^check_password_hash$/i;
|
|
1578
|
+
/**
|
|
1579
|
+
* Positional credentials of connect and credential calls: `mysqli_connect(host, user, pw, db)`,
|
|
1580
|
+
* `DBI->connect(dsn, user, pw)`, `new NetworkCredential(user, pw)`, `DirectoryEntry(path, user, pw)`,
|
|
1581
|
+
* `smtp.login(user, pw)`, `pymysql.connect(host, user, pw, db)`, `ldap.simple_bind_s(dn, pw)`, `auth=(user, pw)`.
|
|
1582
|
+
*/
|
|
1583
|
+
function positionalCalls(text, out) {
|
|
1584
|
+
for (const { match, args } of calls(text, /\b(mysqli?_connect|mysqli|DBI->connect|NetworkCredential(?:\]::new)?|DirectoryEntry|(?:pymysql|MySQLdb)\.connect|(?:\.|->)login|simple_bind(?:_s)?|bind_s|new PDO|PDO|getConnection|FTP(?:_TLS)?|WiFi\.begin|HTTPBasicAuth|HTTPDigestAuth|BasicAuth|PasswordAuthentication|UsernamePasswordCredentials)[ \t]*\(|\bauth[ \t]*=[ \t]*\(/g)) {
|
|
1585
|
+
const callee = match[1] ?? "auth";
|
|
1586
|
+
// (The password is the third argument after a host or a DSN, the second after a user.)
|
|
1587
|
+
const at = /^(?:mysqli?_connect|mysqli|DBI->connect|DirectoryEntry|(?:pymysql|MySQLdb)\.connect|new PDO|PDO|getConnection|FTP(?:_TLS)?)$/.test(callee)
|
|
1588
|
+
? 2
|
|
1589
|
+
: 1;
|
|
1590
|
+
if (args.length <= at || ((/login$/.test(callee) || callee === "auth") && args.length !== 2))
|
|
1591
|
+
continue;
|
|
1592
|
+
// (`login(provider, mode)` signs in with an identity provider: `auth.login("github", "popup")`.)
|
|
1593
|
+
if (/login$/.test(callee) &&
|
|
1594
|
+
/^(?:github|google|facebook|twitter|apple|microsoft|azure|gitlab|bitbucket|discord|slack|okta|auth0|linkedin|oidc|saml|oauth2?|email|anonymous|guest)$/i.test(args[0].literal?.value ?? ""))
|
|
1595
|
+
continue;
|
|
1596
|
+
const lit = args[at].literal;
|
|
1597
|
+
if (lit && !args[at].keyword)
|
|
1598
|
+
namedValue(out, "password", lit, "named:positional");
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
/** The verbs of calls whose first argument names their second (case-insensitive in the first letter). */
|
|
1602
|
+
const PAIR_VERBS = [
|
|
1603
|
+
"set",
|
|
1604
|
+
"put",
|
|
1605
|
+
"add",
|
|
1606
|
+
"append",
|
|
1607
|
+
"insert",
|
|
1608
|
+
"store",
|
|
1609
|
+
"define",
|
|
1610
|
+
"with",
|
|
1611
|
+
"header",
|
|
1612
|
+
"option",
|
|
1613
|
+
"config",
|
|
1614
|
+
"cookie",
|
|
1615
|
+
"get",
|
|
1616
|
+
"fetch",
|
|
1617
|
+
];
|
|
1618
|
+
/**
|
|
1619
|
+
* A call whose first argument names its second: a setter (`os.Setenv("API_KEY", "…")`, `localStorage.setItem("token",
|
|
1620
|
+
* "…")`, `req.Header.Set("Authorization", "…")`, PHP's `define('DB_PASSWORD', '…')`, a session builder's
|
|
1621
|
+
* `.config("spark.…key", "…")`) or a lookup whose second is the default (`os.getenv("X", "…")`, `ENV.fetch("X", "…")`,
|
|
1622
|
+
* Laravel's `env('X', '…')`). Not any call: `__('Password', 'my-theme')` translates a label.
|
|
1623
|
+
*/
|
|
1624
|
+
const PAIR_CALL = new RegExp(String.raw `(?<![\w$])(?:(?:${PAIR_VERBS.map((v) => `[${v[0]}${v[0].toUpperCase()}]${v.slice(1)}`).join("|")})(?:[A-Z_][\w$]*)?|[sS]etenv|getenv|setdefault|env)[ \t]*\((?=\s*[fFbBrRuU@$]?["'\x60])`, "g");
|
|
1625
|
+
/** A Spark or Hadoop setting that holds a key: `fs.azure.account.key.<account>.dfs.core.windows.net`, `fs.s3a.secret.key`. */
|
|
1626
|
+
const KEY_SETTING = /^[\w.{}$-]{0,200}(?:account\.key|secret\.key|access\.key|sas\.token|sas|password|secret|token|credential)(?:\.[\w.{}$-]{0,200})?$/;
|
|
1627
|
+
function pairCalls(text, out) {
|
|
1628
|
+
for (const { match, args } of calls(text, PAIR_CALL)) {
|
|
1629
|
+
const name = args[0]?.literal?.value ?? "";
|
|
1630
|
+
const value = args[1]?.literal;
|
|
1631
|
+
// (The name is a setting's, words joined by `.`, `_`, `-` or `:`, `nconf.set('database:password', …)`; not a route,
|
|
1632
|
+
// `$router->get('/oauth/token', 'OAuth2Controller@token')`, nor a sentence.)
|
|
1633
|
+
if (!value || !/^[A-Za-z_][\w.:{}$-]{1,200}$/.test(name))
|
|
1634
|
+
continue;
|
|
1635
|
+
// A cookie set by name, `res.append('Set-Cookie', 'theme=dark')`, holds a secret only as a session's cookie,
|
|
1636
|
+
// which the structure layer reads as cookies are read.
|
|
1637
|
+
if (/^(?:set-)?cookie$/i.test(name))
|
|
1638
|
+
continue;
|
|
1639
|
+
// WordPress's keys and salts: 64 generated characters, spaces among them (`define('AUTH_SALT', 'k%Y …')`).
|
|
1640
|
+
if (/^define/.test(match[0]) && isCredentialName(name) && isWordpressKey(value.value)) {
|
|
1641
|
+
namedFinding(out, value.start, value.end, name, "named:define");
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
if (!isCredentialName(name) && KEY_SETTING.test(name) && !/\.id$|access\.key\.id/i.test(name))
|
|
1645
|
+
namedValue(out, name, value, "named:call-pair", { judgedAs: "key" });
|
|
1646
|
+
else
|
|
1647
|
+
namedValue(out, name, value, /^define/.test(match[0]) ? "named:define" : "named:call-pair");
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* A WordPress key or salt as its generator makes them: 32+ characters of three or more classes. (Not the sample's `put
|
|
1652
|
+
* your unique phrase here`, nor a cache's prefix, `WP_CACHE_KEY_SALT`'s `mysite_production_object_cache_v2`.)
|
|
1653
|
+
*/
|
|
1654
|
+
function isWordpressKey(value) {
|
|
1655
|
+
return value.length >= 32 && [/[a-z]/, /[A-Z]/, /\d/, /[^\w\s]/].filter((r) => r.test(value)).length >= 3;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* 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" = "…" ]`.
|
|
1660
|
+
*/
|
|
1661
|
+
function comparisons(text, out) {
|
|
1662
|
+
if (!/[cC]ompare|[!=]=|equals|cmp|Equal|\[\[?[ \t]+"?\$/.test(text))
|
|
1663
|
+
return;
|
|
1664
|
+
const variable = (a) => a && !a.literal ? /^(?:[\w.]*\.)?([A-Za-z_]\w{1,60})$/.exec(a.text)?.[1] : undefined;
|
|
1665
|
+
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)) {
|
|
1666
|
+
// (Two arguments compare each other, a variable and a literal in either order; one argument, with the receiver:
|
|
1667
|
+
// `password.equals("…")`.)
|
|
1668
|
+
let name;
|
|
1669
|
+
let lit;
|
|
1670
|
+
if (args.length === 2) {
|
|
1671
|
+
const first = variable(args[0]) !== undefined;
|
|
1672
|
+
name = variable(args[first ? 0 : 1]);
|
|
1673
|
+
lit = args[first ? 1 : 0].literal;
|
|
1674
|
+
}
|
|
1675
|
+
else if (args.length === 1 && match[1])
|
|
1676
|
+
[name, lit] = [match[1], args[0].literal];
|
|
1677
|
+
if (name && lit)
|
|
1678
|
+
namedValue(out, name, lit, "named:compare");
|
|
1679
|
+
}
|
|
1680
|
+
// `name == "…"`, `"…" === name`, `"…".equals(name)`, `assertEquals("…", cfg.getPassword())`, and `[ "$NAME" = "…" ]` in a shell test.
|
|
1681
|
+
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
|
+
const name = m[1] ?? m[6] ?? m[7];
|
|
1683
|
+
// (`typeof password === 'function'` compares the value's type.)
|
|
1684
|
+
if (m[1] !== undefined && TYPEOF_BEFORE.test(text.slice(Math.max(0, m.index - 60), m.index)))
|
|
1685
|
+
continue;
|
|
1686
|
+
const k = m[3] !== undefined ? 3 : m[5] !== undefined ? 5 : 9;
|
|
1687
|
+
namedValue(out, name, group(m, k), "named:compare");
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Browser automation filling a password field: `page.fill('#password', '…')`, `getByLabel('Password').fill('…')`,
|
|
1692
|
+
* `cy.get('input[name=password]').type('…')`, `find_element(By.ID, "password").send_keys("…")`.
|
|
1693
|
+
*/
|
|
1694
|
+
function formFills(text, out) {
|
|
1695
|
+
if (!/\.(?:fill|type|send_keys|sendKeys|setValue|pressSequentially)\(/.test(text))
|
|
1696
|
+
return;
|
|
1697
|
+
const fill = (selector, value) => {
|
|
1698
|
+
const name = selector === undefined ? undefined : fieldName(selector);
|
|
1699
|
+
if (!name || !value || !isCredentialName(name))
|
|
1700
|
+
return;
|
|
1701
|
+
// (Cypress's key sequences, `{selectall}{backspace}`, are keystrokes, not text: those before and after it are kept,
|
|
1702
|
+
// `'{selectall}Kx9mQ2vL{enter}'`.)
|
|
1703
|
+
if (!isNamedSecret(name, `"${value.value.replace(/\{[a-z]+\}/gi, "")}"`, true))
|
|
1704
|
+
return;
|
|
1705
|
+
const before = /^(?:\{[a-z]+\})*/i.exec(value.value)[0].length;
|
|
1706
|
+
const after = /(?:\{[a-z]+\})*$/i.exec(value.value)[0].length;
|
|
1707
|
+
namedFinding(out, value.start + before, value.end - after, name, "named:form-fill");
|
|
1708
|
+
};
|
|
1709
|
+
for (const { args } of calls(text, /\.(?:fill|type)\(/g))
|
|
1710
|
+
if (args.length === 2)
|
|
1711
|
+
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)
|
|
1715
|
+
fill(match[2], args[0].literal);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* The name of the field a selector or label points at: `#password`, `[name=api_key]` and `[type=password]` name it; a
|
|
1720
|
+
* label's words do (`Password`, `API token`); `#token-name` and `#password-hint` name a token's name and a password's hint.
|
|
1721
|
+
*/
|
|
1722
|
+
function fieldName(selector) {
|
|
1723
|
+
const attribute = /\[(?:name|id|data-testid|data-test|data-cy|placeholder|aria-label)[*^$]?=["']?([^"'\]]{1,80})["']?\]/i.exec(selector)?.[1];
|
|
1724
|
+
const id = /#([\w-]{1,80})(?![\w-])/.exec(selector)?.[1];
|
|
1725
|
+
// (A password input names its field whatever else the selector says: `input[type=password]`, `[name=pw][type="password"]`.)
|
|
1726
|
+
if (/\[type=\\?["']?password\\?["']?\]/i.test(selector))
|
|
1727
|
+
return "password";
|
|
1728
|
+
const name = attribute ??
|
|
1729
|
+
id ??
|
|
1730
|
+
(/^[\p{L} _-]{1,60}$/u.test(selector.trim())
|
|
1731
|
+
? selector.trim()
|
|
1732
|
+
: /^[\w-]{1,80}$/.test(selector)
|
|
1733
|
+
? selector
|
|
1734
|
+
: undefined);
|
|
1735
|
+
return name?.replace(/[\s-]+/g, "_");
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Tables with `|` bars: Markdown, psql, `aws … --output table`. Rows are split at the bars, so cells match their
|
|
1739
|
+
* header by index whatever the alignment. A cell is a secret under a credential column's header
|
|
1740
|
+
* (`| User | Password |`), or after a cell that is a credential's name (`| api_key | abc… |`,
|
|
1741
|
+
* `| Postgres | DB_PASSWORD | Harbor#2291x |`), unless the next column describes rather than holds values
|
|
1742
|
+
* (`| auth.password | Redis password | "" |`: name, description, value).
|
|
1743
|
+
*/
|
|
1744
|
+
function pipeTables(text, out) {
|
|
1745
|
+
if (!text.includes("|"))
|
|
1746
|
+
return;
|
|
1747
|
+
const { lines, starts } = splitLines(text);
|
|
1748
|
+
let header;
|
|
1749
|
+
// Where the rule under the header puts the bars, when the header's own bars stand there: psql's and mysql's
|
|
1750
|
+
// aligned columns, which a `|` inside a value can't move.
|
|
1751
|
+
let bars;
|
|
1752
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1753
|
+
const line = lines[i];
|
|
1754
|
+
// (A row starts with a bar, or sits under a header ruled off below it: psql's ` name | value`. A sentence
|
|
1755
|
+
// with a `|` in it, `grep -Ev 'token|password'`, is neither.)
|
|
1756
|
+
if (PIPE_RULE.test(line))
|
|
1757
|
+
continue; // a rule under the header, or a MySQL table's `+----+` rules
|
|
1758
|
+
if (!line.includes("|") ||
|
|
1759
|
+
line.length > MAX_VALUE ||
|
|
1760
|
+
!(/^[ \t]*\|/.test(line) || header || PIPE_RULE_START.test(lines[i + 1] ?? ""))) {
|
|
1761
|
+
header = bars = undefined;
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
let cells = pipeCells(line, starts[i]);
|
|
1765
|
+
// A table starts at a row a rule follows: its header. Its rows run to a line without a bar, whatever their
|
|
1766
|
+
// cell count (a value may hold a `|`). (In a table with a header, a rule with no row after it closes the table:
|
|
1767
|
+
// mysql's last `+----+`.)
|
|
1768
|
+
const rule = lines[i + 1] ?? "";
|
|
1769
|
+
if (PIPE_RULE_START.test(rule) && (!header || (lines[i + 2] ?? "").includes("|"))) {
|
|
1770
|
+
header = cells.map((c) => c.text);
|
|
1771
|
+
const at = [...rule.matchAll(/[|+]/g)].map((m) => m.index);
|
|
1772
|
+
bars = at.length && at.every((p) => line[p] === "|" || p === 0 || p >= line.length) ? at : undefined;
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
if (header && cells.length > header.length)
|
|
1776
|
+
cells = bars ? alignedCells(line, starts[i], bars) : joinedCells(text, cells, header);
|
|
1777
|
+
for (let k = 0; k < cells.length; k++) {
|
|
1778
|
+
const cell = cells[k];
|
|
1779
|
+
if (cell.text.length < 4)
|
|
1780
|
+
continue;
|
|
1781
|
+
const column = header?.[k];
|
|
1782
|
+
const before = cells[k - 1]?.text;
|
|
1783
|
+
// (A description column holds sentences about the value, not the value.)
|
|
1784
|
+
const describes = (n) => !!n && /^(?:description|desc|purpose|notes?|meaning|usage|used for|details|comment|explanation)$/i.test(n);
|
|
1785
|
+
// The name is the column's header, or a credential's name in the cell before, unless the column describes the
|
|
1786
|
+
// value or (with no header) a sentence sits there with more cells after it.
|
|
1787
|
+
const prose = /\s/.test(cell.text) && cells.length > k + 1 && !header;
|
|
1788
|
+
const rowName = before !== undefined &&
|
|
1789
|
+
/^[A-Za-z_][\w.-]{1,80}$/.test(before) &&
|
|
1790
|
+
isCredentialName(before) &&
|
|
1791
|
+
!describes(column) &&
|
|
1792
|
+
!prose;
|
|
1793
|
+
const name = column && isCredentialName(column) ? column : rowName ? before : undefined;
|
|
1794
|
+
// (A bare `key`, `secret` or `token` naming a row of settings is the setting's name, not a cache's or an object's
|
|
1795
|
+
// key: its value is judged as a credential's, `| key | <64 hex> |`. Another generic name keeps a generic
|
|
1796
|
+
// name's judgment: `| auth | <md5 hex> |` is a digest.)
|
|
1797
|
+
const judgedAs = name === before && /^(?:key|secret|token)$/i.test(name ?? "") ? `api_${name}` : name;
|
|
1798
|
+
if (!name || !isNamedSecret(judgedAs, `"${cell.text}"`, true))
|
|
1799
|
+
continue;
|
|
1800
|
+
namedFinding(out, cell.start, cell.end, name, "named:table-cell");
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/** A row of an aligned table read by its rule's bars, not its own: ` 2 | bob | Kx9|mQv2 ` is three cells. */
|
|
1805
|
+
function alignedCells(line, offset, bars) {
|
|
1806
|
+
const cells = [];
|
|
1807
|
+
const edges = [-1, ...bars, line.length];
|
|
1808
|
+
for (let k = 0; k + 1 < edges.length; k++) {
|
|
1809
|
+
const raw = line.slice(edges[k] + 1, edges[k + 1]);
|
|
1810
|
+
// (Before the outer bar of a `+----+` table there is nothing, and a cell there would shift every column; the empty
|
|
1811
|
+
// one after the last bar shifts none.)
|
|
1812
|
+
if (k === 0 && edges[1] === 0)
|
|
1813
|
+
continue;
|
|
1814
|
+
const value = raw.trim();
|
|
1815
|
+
const start = offset + edges[k] + 1 + raw.indexOf(value);
|
|
1816
|
+
cells.push({ text: value, start, end: start + value.length });
|
|
1817
|
+
}
|
|
1818
|
+
return cells;
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* A Markdown row with more cells than its header (a `|` its writer didn't escape): the extra bars are taken to be
|
|
1822
|
+
* inside the first credential column's value, whose cells are joined back into one.
|
|
1823
|
+
*/
|
|
1824
|
+
function joinedCells(text, cells, header) {
|
|
1825
|
+
const extra = cells.length - header.length;
|
|
1826
|
+
const k = header.findIndex((h) => isCredentialName(h));
|
|
1827
|
+
if (k < 0)
|
|
1828
|
+
return cells;
|
|
1829
|
+
const joined = {
|
|
1830
|
+
text: text.slice(cells[k].start, cells[k + extra].end),
|
|
1831
|
+
start: cells[k].start,
|
|
1832
|
+
end: cells[k + extra].end,
|
|
1833
|
+
};
|
|
1834
|
+
return [...cells.slice(0, k), joined, ...cells.slice(k + extra + 1)];
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* A REPL or notebook echoing a credential: `os.environ['SESSION_SECRET']` (or `settings.API_KEY`,
|
|
1838
|
+
* `process.env.TOKEN`) on one line and its value on the next: `Out[29]: '…'`, `'…'`, `=> "…"`.
|
|
1839
|
+
*/
|
|
1840
|
+
function replEchoes(text, out) {
|
|
1841
|
+
if (!/environ|getenv|process\.env|settings\.|config\.|ENV\[|\$env:/.test(text))
|
|
1842
|
+
return;
|
|
1843
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:environ(?:\.get)?[\[(][ \t]*["']|getenv\([ \t]*["']|process\.env\.|settings\.|config\.|ENV\[["']|\$env:)([A-Za-z_][\w]{0,80})["']?[\])]?[^\n]{0,40}\r?\n(?:Out\[\d+\]:[ \t]*|=>[ \t]*|>[ \t]*)?${quoted(2, 4)}[ \t]*\r?$`, "dgm")))
|
|
1844
|
+
namedValue(out, m[1], group(m, 3), "named:repl-echo");
|
|
1845
|
+
}
|
|
1846
|
+
const HTML_PAIR = new RegExp(String.raw `<(th|td|dt)\b[^>]{0,200}>[ \t]*([^<\n]{1,80}?)[ \t]*<\/\1>[ \t\r\n]{0,40}<(td|dd)\b[^>]{0,200}>[ \t]*([^<\n]{1,${MAX_VALUE}}?)[ \t]*<\/\3>`, "dgi");
|
|
1847
|
+
/** HTML tables and definition lists: `<th>password</th><td>…</td>`, `<dt>API key</dt><dd>…</dd>`. */
|
|
1848
|
+
function htmlPairs(text, out) {
|
|
1849
|
+
if (!/<t[hd]|<dt/i.test(text))
|
|
1850
|
+
return;
|
|
1851
|
+
for (const m of text.matchAll(HTML_PAIR))
|
|
1852
|
+
namedValue(out, m[2].replace(/[\s:]+$/, "").replace(/\s+/g, "_"), group(m, 4), "named:html-pair");
|
|
1853
|
+
}
|
|
1854
|
+
/** The verbs that give a value in a sentence, after the name (`le mot de passe est …`, `der Key lautet …`), and CJK's particles (`密码是 …`, `パスワードは …`). */
|
|
1855
|
+
const COPULAS = ["lautet", "ist", "est", "es", "é", "è", "är", "jest", "je", "это", "is", "on"].join("|");
|
|
1856
|
+
const CJK_COPULAS = ["是", "为", "為", "は", "는", "은"].join("|");
|
|
1857
|
+
/** "Key" and "token" in other languages, as labels: 密钥, 令牌, トークン, ключ, токен, clave. */
|
|
1858
|
+
const FOREIGN_KEY_WORDS = ["密钥", "令牌", "トークン", "ключ", "токен", "clave"];
|
|
1859
|
+
/** The labels a copula sentence names (FOREIGN_COPULA), each with its first letter in either case: `[Mm]ot de passe`. */
|
|
1860
|
+
const COPULA_LABELS = ["password", ...FOREIGN_PASSWORD_SPELLINGS, "ключ", "токен", "clave"]
|
|
1861
|
+
.filter((w) => /^[\p{Script=Latin}\p{Script=Cyrillic}]/u.test(w))
|
|
1862
|
+
.map((w) => `[${w[0].toUpperCase()}${w[0].toLowerCase()}]${w.slice(1)}`)
|
|
1863
|
+
.join("|");
|
|
1864
|
+
/** The scripts a sentence in another language runs on into without a blank: a value ends at one. */
|
|
1865
|
+
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, … */
|
|
1867
|
+
// 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");
|
|
1869
|
+
/**
|
|
1870
|
+
* The same in a sentence, with the language's copula: `le mot de passe du service est …`, `a senha dele é …`,
|
|
1871
|
+
* `der DATADOG_API_KEY lautet …`, `пароль сервиса — …`, `パスワードは … です`, `他的密码是 …`. Up to three words
|
|
1872
|
+
* may stand between the name and the verb (`du service`, `für den Dienst`, `actuel`).
|
|
1873
|
+
*/
|
|
1874
|
+
const FOREIGN_COPULA = new RegExp(String.raw `(?:(?<![\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}\p{N}_])(${COPULA_LABELS}|[A-Za-z][A-Za-z0-9]*[_-][\w.-]{1,60}|[A-Z][A-Z0-9]{2,60})((?:[ \t]+(?!(?:${COPULAS})[ \t])[\p{L}][\p{L}'-]{0,20}){0,3})(?:[ \t]+(?:${COPULAS}|—|–)|[ \t]*(?:${CJK_COPULAS}))|(密码|口令|密碼|密钥|令牌|パスワード|トークン|비밀번호|암호)()[ \t]*(?:是|为|為|は|는|은|이|가))[ \t]*[::]?[ \t]*(["'「『*\x60]{0,2})${unquotedValue(String.raw `[^\s"'」』,。、;!?()*\x60]`, 4, String.raw `\5?(?:[\s"'」』,。、;!?()*\x60]|[.,;!?](?:\s|$)|${CJK_SCRIPTS}|$)`)}\5?`, "dgu");
|
|
1875
|
+
const FOREIGN_WORDS = new RegExp(`^(?:password|${[...FOREIGN_PASSWORD_SPELLINGS, ...FOREIGN_KEY_WORDS].join("|")})$`, "iu");
|
|
1876
|
+
/** What FOREIGN and FOREIGN_COPULA need to match at all: a label's word, a copula, or a dash. */
|
|
1877
|
+
const FOREIGN_PREFILTER = new RegExp(`${[...FOREIGN_PASSWORD_SPELLINGS, ...FOREIGN_KEY_WORDS, "키", "秘钥", "キー", "clé", "schlüssel", "chave", CJK_COPULAS, "이", "가"].join("|")}|(?<!\\p{L})(?:${COPULAS})(?!\\p{L})|[—–]`, "iu");
|
|
1878
|
+
/**
|
|
1879
|
+
* Whether the value at `text[start, end)` runs on into a counter's word, its digits the count, as a sentence's
|
|
1880
|
+
* requirement does (`パスワード:英数字8文字以上`); not a count the sentence goes on to (`密码是 Xk9#mQ2p,共8位`).
|
|
1881
|
+
* (`春天4698@です` is a password.)
|
|
1882
|
+
*/
|
|
1883
|
+
function runsIntoCount(text, start, end) {
|
|
1884
|
+
const count = CJK_COUNT.exec(text.slice(start, end + 4));
|
|
1885
|
+
return count !== null && count.index < end - start;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* The name a label in another language is judged and reported under: a word for "key" or "token", which also means a
|
|
1889
|
+
* key in general, as a generated key's (`api_key`); any other, a password's.
|
|
1890
|
+
*/
|
|
1891
|
+
function foreignName(label) {
|
|
1892
|
+
return FOREIGN_KEY_WORDS.some((w) => w.toLowerCase() === label.toLowerCase()) ? "api_key" : "password";
|
|
1893
|
+
}
|
|
1894
|
+
function foreignLabels(text, out) {
|
|
1895
|
+
// (The labels' words, or a copula after an English name: what FOREIGN and FOREIGN_COPULA need to match at all.)
|
|
1896
|
+
if (!FOREIGN_PREFILTER.test(text))
|
|
1897
|
+
return;
|
|
1898
|
+
for (const m of text.matchAll(FOREIGN)) {
|
|
1899
|
+
const value = withoutUnopenedClosers(m[2]);
|
|
1900
|
+
const start = m.indices[2][0];
|
|
1901
|
+
if (!isProseValue(value) ||
|
|
1902
|
+
isCode(value, true) ||
|
|
1903
|
+
/^\d+(?:-\d+)?$/.test(value) ||
|
|
1904
|
+
runsIntoCount(text, start, start + value.length))
|
|
1905
|
+
continue;
|
|
1906
|
+
namedFinding(out, start, start + value.length, foreignName(m[1]), "named:foreign-label", 0.9);
|
|
1907
|
+
}
|
|
1908
|
+
// 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]) ||
|
|
1911
|
+
!isNamedSecret("api_key", `"${m[2]}"`, true))
|
|
1912
|
+
continue;
|
|
1913
|
+
namedFinding(out, m.indices[2][0], m.indices[2][1], "key", "named:foreign-label", 0.85);
|
|
1914
|
+
}
|
|
1915
|
+
for (const m of text.matchAll(FOREIGN_COPULA)) {
|
|
1916
|
+
const name = m[1] ?? m[3];
|
|
1917
|
+
const value = withoutUnopenedClosers(m[6]);
|
|
1918
|
+
// An English-looking name must be a credential's (`DATADOG_API_KEY`, `access_token`).
|
|
1919
|
+
const foreign = m[3] !== undefined || FOREIGN_WORDS.test(name);
|
|
1920
|
+
const copula = m[0]
|
|
1921
|
+
.slice((m[1] ?? m[3]).length + (m[2] ?? "").length)
|
|
1922
|
+
.trim()
|
|
1923
|
+
.split(/[ \t]/)[0];
|
|
1924
|
+
if (!foreign && !isCredentialName(name))
|
|
1925
|
+
continue;
|
|
1926
|
+
// (Dutch `is` and Finnish `on` only after their own labels: a sentence in English is prose's.)
|
|
1927
|
+
if (/^(?:is|on)$/i.test(copula) && !/^(?:wachtwoord|salasana)$/i.test(name))
|
|
1928
|
+
continue;
|
|
1929
|
+
// A dash after an English name documents the variable: `- SMTP_PASSWORD — app-specific password`.
|
|
1930
|
+
if (!foreign &&
|
|
1931
|
+
/^[—–]$/.test(copula) &&
|
|
1932
|
+
(/^[a-z]+(?:-[a-z]+)+$/.test(value) || !(/\d/.test(value) || /[^\w-]/.test(value))))
|
|
1933
|
+
continue;
|
|
1934
|
+
if (!isProseValue(value) || isCode(value, true))
|
|
1935
|
+
continue;
|
|
1936
|
+
// Judged as a password (or, under a word that also means "key" in general, as a generated key), and
|
|
1937
|
+
// not when a word for "public" stands beside the name (`la clave pública es …`, `публичный ключ — …`),
|
|
1938
|
+
// nor a URL, a version, or a value in the sentence's own language (`8-stellig`, `2FA-geschützt`, `英数字8`); a
|
|
1939
|
+
// password in another script is one (`さくら3594@`), text in it isn't (isForeignText, looksSecret's), and one in Latin
|
|
1940
|
+
// with its accents is one too, a word that runs on into digits or a symbol rather than words joined by hyphens
|
|
1941
|
+
// (`Kesä2024!x`).
|
|
1942
|
+
const start = m.indices[6][0];
|
|
1943
|
+
const end = start + value.length;
|
|
1944
|
+
const around = text.slice(Math.max(0, m.index - 20), start);
|
|
1945
|
+
if (/p[uú]blic|öffentlich|публичн|открыт|公开|公開|공개|pubblic/i.test(around) ||
|
|
1946
|
+
/^https?:\/\//.test(value) ||
|
|
1947
|
+
(/[^\x00-\x7f]/.test(value) &&
|
|
1948
|
+
!/(?!\p{Script=Latin})\p{L}/u.test(value) &&
|
|
1949
|
+
/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)+$/u.test(value)) ||
|
|
1950
|
+
runsIntoCount(text, start, end) ||
|
|
1951
|
+
/^v?\d+(?:\.\d+)+$/.test(value))
|
|
1952
|
+
continue;
|
|
1953
|
+
const judgedAs = foreign ? foreignName(name) : name;
|
|
1954
|
+
const accepted = foreign
|
|
1955
|
+
? looksSecret(value) && /\d/.test(value) && /\p{L}/u.test(value) && isNamedSecret(judgedAs, `"${value}"`, true)
|
|
1956
|
+
: isNamedSecret(name, `"${value}"`, true);
|
|
1957
|
+
if (!accepted)
|
|
1958
|
+
continue;
|
|
1959
|
+
namedFinding(out, start, withBangs(text, end), judgedAs, "named:foreign-label", 0.85);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* A value wrapped onto the next line, as terminals and log viewers do at 80 columns: `DB_PASSWORD=`
|
|
1964
|
+
* ending one line, the value alone on the next.
|
|
1965
|
+
*/
|
|
1966
|
+
function wrappedValues(text, out) {
|
|
1967
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?<![\w.$@-])([A-Za-z_][\w.-]{1,80})[ \t]*[=:][ \t]*\r?\n[ \t]*${unquotedValue(String.raw `[^\s=:]`, 4, String.raw `[ \t]*\r?$`)}`, "dgm"))) {
|
|
1968
|
+
if (isCredentialName(m[1]) && isNamedSecret(m[1], m[2], true))
|
|
1969
|
+
namedFinding(out, m.indices[2][0], m.indices[2][1], m[1], "named:wrapped");
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Declarations and assignments in the syntaxes ASSIGN's operators don't cover, each a name then a string literal: VB's
|
|
1974
|
+
* `Dim password As String = "…"` (and `Private Shared …`), T-SQL's `DECLARE @password NVARCHAR(100) = N'…'`, C++'s
|
|
1975
|
+
* `std::string password{"…"}` and `auto password = std::string{"…"}`, CMake's `set(DB_PASSWORD "…")`, AppleScript's
|
|
1976
|
+
* `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
|
|
1978
|
+
* set`, `aws configure set`, `reg add … /v Password /d …`).
|
|
1979
|
+
*/
|
|
1980
|
+
const DECLARATION_FORMS = [
|
|
1981
|
+
[
|
|
1982
|
+
/\bAs\b/i,
|
|
1983
|
+
String.raw `\b(?:Dim|Private|Public|Protected|Friend|Const|Static)(?:[ \t]+(?:Shared|ReadOnly|Const|Static))*[ \t]+(\w+)[ \t]+As[ \t]+[\w.]+(?:\(\))?[ \t]*=[ \t]*`,
|
|
1984
|
+
],
|
|
1985
|
+
[
|
|
1986
|
+
/string[ \t]*[{(][ \t]*["']/i,
|
|
1987
|
+
String.raw `\b(\w+)[ \t]*=[ \t]*(?:std::)?(?:string|String|wstring)[ \t]*[{(][ \t]*(?=["'])`,
|
|
1988
|
+
],
|
|
1989
|
+
[/DECLARE[ \t]+@/i, String.raw `\bDECLARE[ \t]+@(\w+)[ \t]+\w+(?:\([^)\n]*\))?[ \t]*=[ \t]*N?(?=')`],
|
|
1990
|
+
[/\w[ \t]*\{[ \t]*["']/, String.raw `\b(\w+)[ \t]*\{[ \t]*(?=["'])`],
|
|
1991
|
+
[/\bset\(/i, String.raw `\bset\([ \t]*(\w+)[ \t]+`],
|
|
1992
|
+
[/\bset[ \t]+\w+[ \t]+to[ \t]/i, String.raw `\bset[ \t]+(\w+)[ \t]+to[ \t]+`],
|
|
1993
|
+
[/<<?-[ \t]*["']/, String.raw `\b(\w+)[ \t]*<<?-[ \t]*(?=["'])`],
|
|
1994
|
+
[
|
|
1995
|
+
/\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)`,
|
|
1997
|
+
],
|
|
1998
|
+
[
|
|
1999
|
+
/\breg(?:\.exe)?[ \t]+add[ \t]/i,
|
|
2000
|
+
String.raw `\breg(?:\.exe)?[ \t]+add[ \t][^\n]*?\/v[ \t]+["']?([\w.-]+)["']?[ \t][^\n]*?\/d[ \t]+`,
|
|
2001
|
+
],
|
|
2002
|
+
];
|
|
2003
|
+
/**
|
|
2004
|
+
* The forms of DECLARATION_FORMS whose gate the text passes, as one regex (built once for each set of them): the
|
|
2005
|
+
* forms that open with a bare name (`\b(\w+)…`) would otherwise be tried at every word of every text.
|
|
2006
|
+
*/
|
|
2007
|
+
const declarationForms = new Map();
|
|
2008
|
+
function declarationRegex(text) {
|
|
2009
|
+
const forms = DECLARATION_FORMS.filter(([gate]) => gate.test(text)).map(([, form]) => form);
|
|
2010
|
+
if (!forms.length)
|
|
2011
|
+
return undefined;
|
|
2012
|
+
const key = forms.join("|");
|
|
2013
|
+
let re = declarationForms.get(key);
|
|
2014
|
+
if (!re)
|
|
2015
|
+
declarationForms.set(key, (re = new RegExp(key, "gim")));
|
|
2016
|
+
return re;
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
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)`).
|
|
2024
|
+
*/
|
|
2025
|
+
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"],
|
|
2029
|
+
// (An HMAC's key: first in Python's `hmac.new(key, msg, sha256)` and `hmac.digest(key, msg, "sha256")`, pycryptodome's
|
|
2030
|
+
// `HMAC.new(key, msg)` and Ruby's `OpenSSL::HMAC.new(key, digest)`; second after the algorithm in `createHmac(alg, key)`
|
|
2031
|
+
// 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"],
|
|
2034
|
+
];
|
|
2035
|
+
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
|
+
function secretArguments(text, out) {
|
|
2037
|
+
if (!/basic|jwt|hmac|signWith/i.test(text))
|
|
2038
|
+
return;
|
|
2039
|
+
for (const { match, args } of calls(text, SECRET_ARGUMENT_CALL)) {
|
|
2040
|
+
const callee = match[1];
|
|
2041
|
+
const rule = SECRET_ARGUMENTS.find(([pattern]) => pattern.test(callee));
|
|
2042
|
+
if (!rule)
|
|
2043
|
+
continue;
|
|
2044
|
+
const literal = args[rule[1]]?.literal;
|
|
2045
|
+
if (literal)
|
|
2046
|
+
namedValue(out, rule[2], literal, "named:secret-argument", { judgedAs: rule[2] });
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* A sed or perl substitution's replacement, `s|^DB_PASSWORD=.*|DB_PASSWORD=<value>|`: a `NAME=value` the script
|
|
2051
|
+
* writes into a file, the value to the script's delimiter.
|
|
2052
|
+
*/
|
|
2053
|
+
function substitutions(text, out) {
|
|
2054
|
+
if (!/(?:^|[\s'"])s[/|#,:@]/.test(text))
|
|
2055
|
+
return;
|
|
2056
|
+
for (const m of text.matchAll(/(?:^|[\s'"])s([/|#,:@])((?:\\.|(?!\1)[^\n\\])*)\1((?:\\.|(?!\1)[^\n\\])*)\1/dg)) {
|
|
2057
|
+
const [from] = m.indices[3];
|
|
2058
|
+
// (A replacement that starts with the pattern's group holding the name, `s/^\(API_KEY=\).*/\1<value>/`, `-E 's/(KEY=).*/\1v/'`,
|
|
2059
|
+
// blanks around its `=` written as the pattern's, `(KEY[ ]*=[ ]*)`, `(KEY\s*=\s*)`.)
|
|
2060
|
+
const group = /^\\1/.test(m[3])
|
|
2061
|
+
? /\\?\((?:\^)?([A-Za-z_][\w.-]{1,80})(?:[ \t*+?]|\[[ \t]+\]|\[\[:space:\]\]|\\s)*[=:](?:[ \t*+?]|\[[ \t]+\]|\[\[:space:\]\]|\\s)*\\?\)/.exec(m[2])
|
|
2062
|
+
: null;
|
|
2063
|
+
if (group) {
|
|
2064
|
+
const value = /^\\1["']?((?:\\.|[^\s"'\\]){3,4096})/.exec(m[3]);
|
|
2065
|
+
if (value)
|
|
2066
|
+
namedValue(out, group[1], { value: value[1], start: from + value[0].length - value[1].length, end: from + value[0].length }, "named:substitution");
|
|
2067
|
+
}
|
|
2068
|
+
// (A replacement may begin with a back-reference, `\1API_KEY=…`, and a value hold an escaped delimiter, `a\/b`.)
|
|
2069
|
+
for (const pair of m[3].matchAll(/(?:^|[^\w.-]|\\\d)([A-Za-z_][\w.-]{1,80})[ \t]*[=:][ \t]*["']?((?:\\.|[^\s"'\\]){3,4096})/dg)) {
|
|
2070
|
+
namedValue(out, pair[1], { value: pair[2], start: from + pair.indices[2][0], end: from + pair.indices[2][1] }, "named:substitution");
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
function declarations(text, out) {
|
|
2075
|
+
const forms = declarationRegex(text);
|
|
2076
|
+
if (!forms)
|
|
2077
|
+
return;
|
|
2078
|
+
for (const m of text.matchAll(forms)) {
|
|
2079
|
+
const name = m.slice(1).find((g) => g !== undefined);
|
|
2080
|
+
if (!isCredentialName(name))
|
|
2081
|
+
continue;
|
|
2082
|
+
const at = m.index + m[0].length;
|
|
2083
|
+
if (text[at] === '"' || text[at] === "'") {
|
|
2084
|
+
const close = literalEnd(text, at);
|
|
2085
|
+
if (close > at)
|
|
2086
|
+
namedValue(out, name, { value: text.slice(at + 1, close), start: at + 1, end: close }, "named:declaration");
|
|
2087
|
+
continue;
|
|
2088
|
+
}
|
|
2089
|
+
// (A command's value word, unquoted: to a blank, and CMake's to its call's `)`. VB's `Dim … As … =` takes an
|
|
2090
|
+
// expression, code, `txtPassword.Text`: only its literal is a value.)
|
|
2091
|
+
if (/\bAs\b[^=]*=[ \t]*$/i.test(m[0]))
|
|
2092
|
+
continue;
|
|
2093
|
+
const word = (/^set\(/i.test(m[0]) ? /^[^\s'"`;&|)]{3,4096}/ : /^[^\s'"`;&|]{3,4096}/).exec(text.slice(at, at + 4096));
|
|
2094
|
+
if (word)
|
|
2095
|
+
namedValue(out, name, { value: word[0], start: at, end: at + word[0].length }, "named:declaration");
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* A line that is a name and one string literal, the call syntax of Groovy's and Ruby's DSLs: Gradle's `storePassword
|
|
2100
|
+
* 'x'`, Chef's and Rails' `bind_password "x"`, `api_key 'x'`.
|
|
2101
|
+
*/
|
|
2102
|
+
function dslCalls(text, out) {
|
|
2103
|
+
for (const m of text.matchAll(/^[ \t]*([A-Za-z_][\w]{1,60})[ \t]+(?=["'])/gm)) {
|
|
2104
|
+
const at = m.index + m[0].length;
|
|
2105
|
+
const close = literalEnd(text, at);
|
|
2106
|
+
if (close < 0 || !/^[ \t]*[,;]?[ \t]*(?:\/\/.*|#.*)?\r?$/.test(text.slice(close + 1, lineEndAt(text, close + 1))))
|
|
2107
|
+
continue;
|
|
2108
|
+
namedValue(out, m[1], { value: text.slice(at + 1, close), start: at + 1, end: close }, "named:dsl-call");
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* Go's imports, a block (`import (` to its `)`) or a line (`import pwd "…"`), where a name before a string is a
|
|
2113
|
+
* package's alias: `pwd "github.com/acme/password-x9"`. Each as where it starts and ends.
|
|
2114
|
+
*/
|
|
2115
|
+
function goImports(text) {
|
|
2116
|
+
if (!text.includes("import"))
|
|
2117
|
+
return [];
|
|
2118
|
+
return [...text.matchAll(/^[ \t]*import[ \t]+(?:\([^)]*\)|[\w.]+[ \t]+"[^"\n]*")/gm)].map((m) => [
|
|
2119
|
+
m.index,
|
|
2120
|
+
m.index + m[0].length,
|
|
2121
|
+
]);
|
|
2122
|
+
}
|
|
2123
|
+
/** Credentials given as a pair in prose: `wifi: guest / Cherry-Jade-57*`, `user \`ro\` pass \`p@ss\``, `login admin/hunter22`. */
|
|
2124
|
+
function userPassProse(text, out) {
|
|
2125
|
+
if (!/pass|pw|creds?|login|wifi|wi-fi|account|u\/p/i.test(text))
|
|
2126
|
+
return;
|
|
2127
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:\b(?:password|passwd|pw|pwd|passcode|passphrase)|\buser(?:name)?:?[ \t]{1,3}[\x60'"]?[\w.@-]{1,60}[\x60'"]?,?[ \t]{1,3}pass:?)[ \t]{1,3}(?:is[ \t]{1,3})?([\x60'"])([^\x60'"\s]{4,${MAX_VALUE}})\1`, "dgi"))) {
|
|
2128
|
+
if (isProseValue(m[2]))
|
|
2129
|
+
namedFinding(out, m.indices[2][0], m.indices[2][1], "password", "named:prose-pair", 0.85);
|
|
2130
|
+
}
|
|
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];
|
|
2133
|
+
// (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]))
|
|
2135
|
+
continue;
|
|
2136
|
+
// `login page / dashboard`, `account settings/billing`: words, not a password. (Read in one case: each break of
|
|
2137
|
+
// `QwZxKvBnMp` would score as a camelCase word's start.)
|
|
2138
|
+
if (/^\p{L}+$/u.test(pw) && naturalness(pw.toLowerCase()) > 0)
|
|
2139
|
+
continue;
|
|
2140
|
+
namedFinding(out, m.indices[2][0], m.indices[2][1], "password", "named:prose-pair", 0.85);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* A line of an `ls` listing: file names in columns (`cron.daily passwd- sudo.conf`). A file named `passwd`
|
|
2145
|
+
* or `gpg-preset-passphrase` isn't a column of passwords, nor a name with its value beside it.
|
|
2146
|
+
*/
|
|
2147
|
+
function isListingLine(line) {
|
|
2148
|
+
const cells = line
|
|
2149
|
+
.trim()
|
|
2150
|
+
.split(/[ \t]{2,}|\t/)
|
|
2151
|
+
.filter(Boolean);
|
|
2152
|
+
if (cells.length < 2 || !cells.every((c) => /^[\w.@+-]{1,80}[/*@=|]?$/.test(c)))
|
|
2153
|
+
return false;
|
|
2154
|
+
// (A bin directory's commands, `chage gpasswd passwd su`: lower-case words, one a password tool's name.)
|
|
2155
|
+
if (cells.every((c) => /^[a-z][a-z0-9_.+-]*$/.test(c)) &&
|
|
2156
|
+
cells.some((c) => /^(?:g|ch|ht|smb|vnc|yp)?passwd$/.test(c)))
|
|
2157
|
+
return true;
|
|
2158
|
+
const marked = cells.filter((c) => /[.-]/.test(c)).length;
|
|
2159
|
+
return cells.length >= 3 ? marked >= 1 : marked === cells.length;
|
|
2160
|
+
}
|
|
2161
|
+
/** What columnTables needs in a text to have a column to read: a credential word (names.ts), or a cookie table's `remember_me`. */
|
|
2162
|
+
const CREDENTIAL_COLUMN = new RegExp(`${CREDENTIAL_WORD}|remember`, "i");
|
|
2163
|
+
/** What prose needs in a text to have a credential's name to read: any of names.ts's words (`psk`, `salt`, `unseal`), or `code` (`PIN code`). */
|
|
2164
|
+
const CREDENTIAL_SENTENCE = new RegExp(`${CREDENTIAL_WORD}|code`, "i");
|
|
2165
|
+
/** A header of a fixed-width table: column names apart by two or more blanks or a bar (`NAME DIGEST CREATED`, `┃ User ┃ Password ┃`). */
|
|
2166
|
+
const FIXED_WIDTH_HEADER = /^[ \t|│┃║]*[A-Za-z_][\w.-]*(?:(?:[ \t]{2,}|[ \t]*[|│┃║][ \t]*)[A-Za-z_][\w.-]*)+[ \t|│┃║]*\r?$/;
|
|
2167
|
+
/** A table's rule line: dashes, equals signs, pluses, bars and box drawing (`---- ----`, `+----+`, `├────┼────┤`, `╚═══╩═══╝`). */
|
|
2168
|
+
const TABLE_RULE = /^[ \t]*[-+=|\u2500-\u257f][-+=|\u2500-\u257f \t]*\r?$/;
|
|
2169
|
+
/** A fixed-width table's cells: runs of words with single blanks between, apart from the next by two blanks or a bar. */
|
|
2170
|
+
const TABLE_CELL = /[^\s|│┃║]+(?:[ \t][^\s|│┃║]+)*/g;
|
|
2171
|
+
/**
|
|
2172
|
+
* Tables with a credential column: `psql` output, pandas DataFrames, `docker ps`-style listings. The
|
|
2173
|
+
* header names the column; each row's cell under it is the value.
|
|
2174
|
+
*/
|
|
2175
|
+
function columnTables(text, out) {
|
|
2176
|
+
if (!CREDENTIAL_COLUMN.test(text))
|
|
2177
|
+
return;
|
|
2178
|
+
const { lines, starts: offsets } = splitLines(text);
|
|
2179
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
2180
|
+
const header = lines[i];
|
|
2181
|
+
// Delimited tables (CSV, TSV, `mysql --batch`): split by the delimiter, match cells by index.
|
|
2182
|
+
// A header: every cell a name (not a number, not empty, not indented code).
|
|
2183
|
+
// (Its cells read as the rows' are, quotes and all: `"name, full",password`; a blank after the delimiter is allowed.)
|
|
2184
|
+
const delim = header.length < 2000 ? (/[\t,;]/.exec(header)?.[0] ?? "") : "";
|
|
2185
|
+
const names = delim ? delimitedCells(header.replace(/\r$/, ""), delim).map((c) => c.text.trim()) : [];
|
|
2186
|
+
// (Names of a few words each: a wrapped sentence, `password, the authorization server MUST protect the
|
|
2187
|
+
// endpoint`, or a line of code, `from picobox import Stack, pass_`, has commas but no columns. Nor names inside a
|
|
2188
|
+
// bracket the line above opens, a parameter list or a destructuring: `({` / ` email, password` / `}: {…}) => {`.)
|
|
2189
|
+
if (names.length >= 2 &&
|
|
2190
|
+
names.every((n) => /^[\p{L}_][\p{L}\w ./,-]{0,60}$/u.test(n) && (n.match(/\p{L}+/gu)?.length ?? 0) <= 4) &&
|
|
2191
|
+
!/^[ \t]*(?:from|import|def|class|return|if|for|while|let|const|var|use)\b/.test(header) &&
|
|
2192
|
+
!/[([{][ \t]*\r?$/.test(lines[i - 1] ?? "")) {
|
|
2193
|
+
const credIdx = names.map((n, k) => (isCredentialName(n) ? k : -1)).filter((k) => k >= 0);
|
|
2194
|
+
// A name column and a value column (DevTools cookie tables, exports of settings): each row names its value.
|
|
2195
|
+
const nameIdx = names.findIndex((n) => /^(?:name|key|cookie|nombre|nom)$/i.test(n));
|
|
2196
|
+
const valueIdx = names.findIndex((n) => /^(?:value|wert|valeur|valor|值)$/i.test(n));
|
|
2197
|
+
if (!credIdx.length && nameIdx >= 0 && valueIdx >= 0) {
|
|
2198
|
+
let j = i + 1;
|
|
2199
|
+
for (; j < lines.length && lines[j].trim(); j++) {
|
|
2200
|
+
const cells = delimitedCells(lines[j].replace(/\r$/, ""), delim, offsets[j]);
|
|
2201
|
+
if (cells.length <= Math.max(nameIdx, valueIdx))
|
|
2202
|
+
continue;
|
|
2203
|
+
const name = cells[nameIdx].text.trim();
|
|
2204
|
+
const cell = cells[valueIdx];
|
|
2205
|
+
const value = cell.text.trim();
|
|
2206
|
+
// (A cookie named for a session, `connect.sid`, `PHPSESSID`, is judged as a session token.)
|
|
2207
|
+
const session = /sess|sid$/i.test(name) && !isCredentialName(name);
|
|
2208
|
+
if (!(isCredentialName(name) || /sess|sid$|token|auth|jwt|remember/i.test(name)) ||
|
|
2209
|
+
!isNamedSecret(session ? "session_token" : name, `"${value}"`, true))
|
|
2210
|
+
continue;
|
|
2211
|
+
const st = cell.start + cell.text.indexOf(value);
|
|
2212
|
+
namedFinding(out, st, st + value.length, name, "named:table-pairs", 0.85);
|
|
2213
|
+
}
|
|
2214
|
+
i = j - 1;
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
if (credIdx.length) {
|
|
2218
|
+
let j = i + 1;
|
|
2219
|
+
// (A wider row is skipped, not the end of the table; a blank line ends it. A narrower one has left its last
|
|
2220
|
+
// fields out, `bob,<password>` under `user,password,host`: its cells are still under their columns.)
|
|
2221
|
+
for (; j < lines.length && lines[j].trim(); j++) {
|
|
2222
|
+
const cells = delimitedCells(lines[j].replace(/\r$/, ""), delim, offsets[j]);
|
|
2223
|
+
if (cells.length > names.length || cells.length < 2)
|
|
2224
|
+
continue;
|
|
2225
|
+
for (const k of credIdx) {
|
|
2226
|
+
const cell = cells[k];
|
|
2227
|
+
const value = cell?.text.trim();
|
|
2228
|
+
if (!value || !isNamedSecret(names[k], `"${value}"`, true))
|
|
2229
|
+
continue;
|
|
2230
|
+
const st = cell.start + cell.text.indexOf(value);
|
|
2231
|
+
namedFinding(out, st, st + value.length, names[k], "named:table-delimited", 0.85);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
i = j - 1;
|
|
2235
|
+
continue;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
// A header is only column names, separated by two or more spaces or by box-drawing bars (`|` tables are pipeTables').
|
|
2239
|
+
if (header.length > 2000 || header.includes("|") || !FIXED_WIDTH_HEADER.test(header))
|
|
2240
|
+
continue;
|
|
2241
|
+
if (isListingLine(header))
|
|
2242
|
+
continue;
|
|
2243
|
+
const cols = [];
|
|
2244
|
+
for (const c of header.matchAll(/[A-Za-z_][\w.-]{0,60}/g))
|
|
2245
|
+
cols.push([c.index, c.index + c[0].length, c[0]]);
|
|
2246
|
+
// A declaration, not a header: a struct's field and its type (`Password string`, gofmt's alignment).
|
|
2247
|
+
if (cols.length >= 2 && cols.slice(1).every(([, , n]) => DECLARED_TYPE.test(n)))
|
|
2248
|
+
continue;
|
|
2249
|
+
const credCols = cols.filter(([, , n]) => isCredentialName(n));
|
|
2250
|
+
// A name/value table (`SELECT name, value FROM settings`, `vault kv get`'s `Key Value`): each row names its own value,
|
|
2251
|
+
// whatever the first column's header calls the names (`Header Value`, `SECRET VALUE`).
|
|
2252
|
+
const pairs = cols.length === 2 && /^(?:value|val|setting)$/i.test(cols[1][2]);
|
|
2253
|
+
if (pairs || !credCols.length) {
|
|
2254
|
+
// Its rows aren't headers of their own: skip past them.
|
|
2255
|
+
let j = i + 1;
|
|
2256
|
+
for (; j < lines.length && lines[j].trim(); j++) {
|
|
2257
|
+
// A header of its own with a credential column starts the next table.
|
|
2258
|
+
if (!pairs &&
|
|
2259
|
+
FIXED_WIDTH_HEADER.test(lines[j]) &&
|
|
2260
|
+
[...lines[j].matchAll(/[A-Za-z_][\w.-]{0,60}/g)].some((c) => isCredentialName(c[0])))
|
|
2261
|
+
break;
|
|
2262
|
+
if (!pairs)
|
|
2263
|
+
continue;
|
|
2264
|
+
// (A rule's dashes name no credential.)
|
|
2265
|
+
const cells = [...lines[j].matchAll(TABLE_CELL)];
|
|
2266
|
+
if (cells.length !== 2)
|
|
2267
|
+
continue;
|
|
2268
|
+
const [name, value] = [cells[0][0], cells[1][0]];
|
|
2269
|
+
if (!isCredentialName(name) || !isNamedSecret(name, `"${value}"`, true))
|
|
2270
|
+
continue;
|
|
2271
|
+
namedFinding(out, offsets[j] + cells[1].index, offsets[j] + cells[1].index + value.length, name, "named:table-pairs", 0.85);
|
|
2272
|
+
}
|
|
2273
|
+
i = j - 1;
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
let j = i + 1;
|
|
2277
|
+
const column = [];
|
|
2278
|
+
for (; j < lines.length; j++) {
|
|
2279
|
+
const row = lines[j];
|
|
2280
|
+
// (A blank line or a prompt ends the table: the next command's output is another's.)
|
|
2281
|
+
if (!row.trim() || PROMPT.test(row))
|
|
2282
|
+
break;
|
|
2283
|
+
if (TABLE_RULE.test(row))
|
|
2284
|
+
continue;
|
|
2285
|
+
if ((row.match(/[^\s|│┃║]+/g) ?? []).length < 2)
|
|
2286
|
+
break;
|
|
2287
|
+
// A row with a cell for each column (cells apart by two or more blanks) is read by index: a cell wider than its
|
|
2288
|
+
// header (`doctl`'s URI) shifts the others off their headers' columns.
|
|
2289
|
+
const cells = [...row.matchAll(TABLE_CELL)];
|
|
2290
|
+
if (cells.length === cols.length) {
|
|
2291
|
+
for (const [hs, , name] of credCols) {
|
|
2292
|
+
const cell = cells[cols.findIndex(([s]) => s === hs)];
|
|
2293
|
+
if (/[ \t]/.test(cell[0]) || !isNamedSecret(name, `"${cell[0]}"`, true))
|
|
2294
|
+
continue;
|
|
2295
|
+
const found = [];
|
|
2296
|
+
namedFinding(found, offsets[j] + cell.index, offsets[j] + cell.index + cell[0].length, name, "named:table-column", 0.85);
|
|
2297
|
+
column.push({ finding: found[0], cell: cell[0] });
|
|
2298
|
+
}
|
|
2299
|
+
continue;
|
|
2300
|
+
}
|
|
2301
|
+
for (const [hs, he, name] of credCols) {
|
|
2302
|
+
// The cell is the run of non-space characters overlapping the header's columns.
|
|
2303
|
+
for (const cell of row.matchAll(/[^\s|│┃║]+/g)) {
|
|
2304
|
+
const cs = cell.index;
|
|
2305
|
+
const ce = cs + cell[0].length;
|
|
2306
|
+
if (ce <= hs - 2 || cs >= he + 2)
|
|
2307
|
+
continue;
|
|
2308
|
+
if (!isNamedSecret(name, `"${cell[0]}"`, true))
|
|
2309
|
+
continue;
|
|
2310
|
+
const found = [];
|
|
2311
|
+
namedFinding(found, offsets[j] + cs, offsets[j] + ce, name, "named:table-column", 0.85);
|
|
2312
|
+
column.push({ finding: found[0], cell: cell[0] });
|
|
2313
|
+
break;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
// (Identifiers all down the column are a listing's file names or a struct's fields under a name that only looks
|
|
2318
|
+
// like a header: `ls /usr/bin` under `kpasswd`, gofmt's `clientSecret string` above `tokenURL string`. A header
|
|
2319
|
+
// that is a column's name, `PASSWORD`, `passwd`, heads a table of passwords, however plain they are.)
|
|
2320
|
+
const headed = credCols.every(([, , n]) => /^(?:pass(?:word|wd)?|pwd|pin|secrets?|tokens?|keys?|api[_-]?keys?|credentials?)$/i.test(n));
|
|
2321
|
+
if (headed ||
|
|
2322
|
+
!(column.length >= 2 && column.every(({ cell }) => /^[a-z_][a-z0-9_.+-]*$|^[a-z]+(?:[A-Z][a-z0-9]*)+$/.test(cell))))
|
|
2323
|
+
out.push(...column.map(({ finding }) => finding));
|
|
2324
|
+
// The rows just read aren't headers of their own: continue after them.
|
|
2325
|
+
i = Math.max(i, j - 1);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
/** A value key as a word (`value:`, `data =`, `content="…"`): what siblingPairs needs in a text to have anything to read. */
|
|
2329
|
+
const PAIR_VALUE_WORD = new RegExp(String.raw `\b${PAIR_VALUE.source.slice(1, -1)}\b`, "i");
|
|
2330
|
+
/** A type as a declaration writes it after a field's name (Go's built-in types, a package's type: `time.Duration`). */
|
|
2331
|
+
const DECLARED_TYPE = /^(?:string|bool|byte|rune|error|any|int(?:8|16|32|64)?|uint(?:8|16|32|64|ptr)?|float(?:32|64)|complex(?:64|128)|func|map|chan|interface|struct|[a-z][a-z0-9]*\.[A-Z]\w*)$/;
|
|
2332
|
+
/** Java system properties on a command line: `-Dspring.datasource.password=…`, `-Djavax.net.ssl.keyStorePassword=…`. */
|
|
2333
|
+
function javaProperties(text, out) {
|
|
2334
|
+
if (!text.includes("-D") && !text.includes("-P"))
|
|
2335
|
+
return;
|
|
2336
|
+
// (Gradle takes project properties the same way: `-PKEYSTORE_PASSWORD=…`.)
|
|
2337
|
+
for (const m of text.matchAll(/(?:^|[\s"'=])-[DP]([A-Za-z_][\w.-]{0,120})=(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"']{1,4096}))/dg)) {
|
|
2338
|
+
const k = m[2] !== undefined ? 2 : m[3] !== undefined ? 3 : 4;
|
|
2339
|
+
if (isCredentialName(m[1]) && isNamedSecret(m[1], k < 4 ? `"${m[k]}"` : m[k], true))
|
|
2340
|
+
namedFinding(out, m.indices[k][0], m.indices[k][1], m[1], "named:java-property");
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Pairs whose name is a sibling attribute of the value, not its key: Kubernetes env (`- name: DB_PASSWORD` /
|
|
2345
|
+
* `value: …`), HCL (`name = "/prod/db/password"` … `value = "…"`), JSON and CloudFormation (`{"Name": "API_KEY",
|
|
2346
|
+
* "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
|
|
2349
|
+
* the same way wherever it is written.)
|
|
2350
|
+
*/
|
|
2351
|
+
function siblingPairs(text, out) {
|
|
2352
|
+
if (!PAIR_VALUE_WORD.test(text))
|
|
2353
|
+
return;
|
|
2354
|
+
const attrs = [];
|
|
2355
|
+
let line = 0;
|
|
2356
|
+
let lineAt = 0;
|
|
2357
|
+
// (The next line break, and a line's indent, its blanks and list markers, each found once.)
|
|
2358
|
+
let next = text.indexOf("\n");
|
|
2359
|
+
let indent = -1;
|
|
2360
|
+
// YAML mapping lines as yaml.ts reads them (a plain value runs to the line's end or a comment, `,` `{` and blanks
|
|
2361
|
+
// included); other syntaxes' attributes as syntax.ts reads them.
|
|
2362
|
+
const yaml = /^[ \t-]*["']?[A-Za-z]\w*["']?[ \t]*:/m.test(text) ? yamlLines(text) : [];
|
|
2363
|
+
const mapping = new Set();
|
|
2364
|
+
yaml.forEach((l, i) => {
|
|
2365
|
+
if (!l.key || !l.value || (!PAIR_NAME.test(l.key) && !PAIR_VALUE.test(l.key)))
|
|
2366
|
+
return;
|
|
2367
|
+
mapping.add(l.start);
|
|
2368
|
+
attrs.push({ key: l.key, value: l.value.value, start: l.value.start, quoted: l.quoted, line: i, indent: l.indent });
|
|
2369
|
+
});
|
|
2370
|
+
for (const a of attributes(text)) {
|
|
2371
|
+
if (!PAIR_NAME.test(a.key) &&
|
|
2372
|
+
!PAIR_VALUE.test(a.key) &&
|
|
2373
|
+
!(/^type$/i.test(a.key) && /^password$/i.test(a.value.value)))
|
|
2374
|
+
continue;
|
|
2375
|
+
for (; next >= 0 && next < a.start; next = text.indexOf("\n", next + 1)) {
|
|
2376
|
+
line++;
|
|
2377
|
+
lineAt = next + 1;
|
|
2378
|
+
indent = -1;
|
|
2379
|
+
}
|
|
2380
|
+
if (indent < 0)
|
|
2381
|
+
for (indent = 0; lineAt + indent < a.start && /[ \t-]/.test(text[lineAt + indent]); indent++)
|
|
2382
|
+
;
|
|
2383
|
+
if (lineAt + indent === a.start && mapping.has(lineAt))
|
|
2384
|
+
continue;
|
|
2385
|
+
attrs.push({ key: a.key, value: a.value.value, start: a.value.start, quoted: a.quoted, line, indent });
|
|
2386
|
+
}
|
|
2387
|
+
attrs.sort((a, b) => a.start - b.start);
|
|
2388
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
2389
|
+
const named = attrs[i];
|
|
2390
|
+
// (An HTML input's `type="password"` names its value as `name=` does: `<input type="password" value="…">`. A `type`
|
|
2391
|
+
// is read only when it is `password`.)
|
|
2392
|
+
const passwordInput = /^type$/i.test(named.key);
|
|
2393
|
+
if (!PAIR_NAME.test(named.key) && !passwordInput)
|
|
2394
|
+
continue;
|
|
2395
|
+
const credential = passwordInput
|
|
2396
|
+
? "password"
|
|
2397
|
+
: [named.value, named.value.split(/[/.:]/).filter(Boolean).at(-1) ?? ""].find((n) => /^[A-Za-z_][\w.\/:-]{0,100}$/.test(n) && isCredentialName(n));
|
|
2398
|
+
if (!credential)
|
|
2399
|
+
continue;
|
|
2400
|
+
// The value attribute nearest it, before or after, in the same block.
|
|
2401
|
+
for (const j of [i + 1, i - 1, i + 2, i - 2, i + 3]) {
|
|
2402
|
+
const v = attrs[j];
|
|
2403
|
+
if (!v || !PAIR_VALUE.test(v.key) || Math.abs(v.line - named.line) > 6)
|
|
2404
|
+
continue;
|
|
2405
|
+
const [first, second] = v.start > named.start ? [named, v] : [v, named];
|
|
2406
|
+
const between = text.slice(first.start, second.start);
|
|
2407
|
+
// (A brace or a new list item between them starts another block: `- name: A` / `value: …` / `- name: B`.)
|
|
2408
|
+
if (/[{}]|\n[ \t]*- /.test(between.slice(first.value.length)) ||
|
|
2409
|
+
(second.line > first.line && second.indent < first.indent))
|
|
2410
|
+
continue;
|
|
2411
|
+
if (!v.value || !isNamedSecret(credential, v.quoted ? `"${v.value}"` : v.value, true))
|
|
2412
|
+
break;
|
|
2413
|
+
namedFinding(out, v.start, v.start + v.value.length, credential, "named:sibling-pair");
|
|
2414
|
+
break;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
/** Words that follow `password is` in sentences about passwords rather than ones that give one. */
|
|
2419
|
+
const PROSE_WORDS = new Set([
|
|
2420
|
+
"required",
|
|
2421
|
+
"incorrect",
|
|
2422
|
+
"invalid",
|
|
2423
|
+
"wrong",
|
|
2424
|
+
"correct",
|
|
2425
|
+
"missing",
|
|
2426
|
+
"empty",
|
|
2427
|
+
"blank",
|
|
2428
|
+
"set",
|
|
2429
|
+
"unset",
|
|
2430
|
+
"stored",
|
|
2431
|
+
"hashed",
|
|
2432
|
+
"encrypted",
|
|
2433
|
+
"saved",
|
|
2434
|
+
"changed",
|
|
2435
|
+
"reset",
|
|
2436
|
+
"expired",
|
|
2437
|
+
"too",
|
|
2438
|
+
"not",
|
|
2439
|
+
"the",
|
|
2440
|
+
"a",
|
|
2441
|
+
"an",
|
|
2442
|
+
"your",
|
|
2443
|
+
"my",
|
|
2444
|
+
"our",
|
|
2445
|
+
"their",
|
|
2446
|
+
"his",
|
|
2447
|
+
"her",
|
|
2448
|
+
"its",
|
|
2449
|
+
"this",
|
|
2450
|
+
"that",
|
|
2451
|
+
"same",
|
|
2452
|
+
"different",
|
|
2453
|
+
"still",
|
|
2454
|
+
"now",
|
|
2455
|
+
"also",
|
|
2456
|
+
"being",
|
|
2457
|
+
"been",
|
|
2458
|
+
"case",
|
|
2459
|
+
"sensitive",
|
|
2460
|
+
"mandatory",
|
|
2461
|
+
"weak",
|
|
2462
|
+
"strong",
|
|
2463
|
+
"long",
|
|
2464
|
+
"short",
|
|
2465
|
+
"okay",
|
|
2466
|
+
"fine",
|
|
2467
|
+
"good",
|
|
2468
|
+
"bad",
|
|
2469
|
+
"there",
|
|
2470
|
+
"here",
|
|
2471
|
+
"generated",
|
|
2472
|
+
"random",
|
|
2473
|
+
"provided",
|
|
2474
|
+
"given",
|
|
2475
|
+
"sent",
|
|
2476
|
+
"shown",
|
|
2477
|
+
"hidden",
|
|
2478
|
+
"visible",
|
|
2479
|
+
"used",
|
|
2480
|
+
"needed",
|
|
2481
|
+
"protected",
|
|
2482
|
+
"secure",
|
|
2483
|
+
"insecure",
|
|
2484
|
+
"valid",
|
|
2485
|
+
"unique",
|
|
2486
|
+
"only",
|
|
2487
|
+
"just",
|
|
2488
|
+
"always",
|
|
2489
|
+
"never",
|
|
2490
|
+
"usually",
|
|
2491
|
+
"probably",
|
|
2492
|
+
"somewhere",
|
|
2493
|
+
"written",
|
|
2494
|
+
"printed",
|
|
2495
|
+
"rotated",
|
|
2496
|
+
"revoked",
|
|
2497
|
+
"compromised",
|
|
2498
|
+
"leaked",
|
|
2499
|
+
"known",
|
|
2500
|
+
"unknown",
|
|
2501
|
+
"fake",
|
|
2502
|
+
"real",
|
|
2503
|
+
"temporary",
|
|
2504
|
+
"new",
|
|
2505
|
+
"old",
|
|
2506
|
+
"updated",
|
|
2507
|
+
"string",
|
|
2508
|
+
"number",
|
|
2509
|
+
"int",
|
|
2510
|
+
"boolean",
|
|
2511
|
+
"text",
|
|
2512
|
+
"bytes",
|
|
2513
|
+
"object",
|
|
2514
|
+
"any",
|
|
2515
|
+
"nullable",
|
|
2516
|
+
"ignored",
|
|
2517
|
+
"configured",
|
|
2518
|
+
"supported",
|
|
2519
|
+
"accepted",
|
|
2520
|
+
"rejected",
|
|
2521
|
+
"disabled",
|
|
2522
|
+
"enabled",
|
|
2523
|
+
"displayed",
|
|
2524
|
+
"masked",
|
|
2525
|
+
"redacted",
|
|
2526
|
+
"what",
|
|
2527
|
+
"which",
|
|
2528
|
+
"where",
|
|
2529
|
+
"when",
|
|
2530
|
+
"why",
|
|
2531
|
+
"how",
|
|
2532
|
+
"something",
|
|
2533
|
+
"anything",
|
|
2534
|
+
"nothing",
|
|
2535
|
+
"everything",
|
|
2536
|
+
"easy",
|
|
2537
|
+
"hard",
|
|
2538
|
+
"simple",
|
|
2539
|
+
"complex",
|
|
2540
|
+
"safe",
|
|
2541
|
+
"unsafe",
|
|
2542
|
+
"secret",
|
|
2543
|
+
"private",
|
|
2544
|
+
"public",
|
|
2545
|
+
"important",
|
|
2546
|
+
"bcrypt",
|
|
2547
|
+
"scrypt",
|
|
2548
|
+
"argon2",
|
|
2549
|
+
"argon2id",
|
|
2550
|
+
"pbkdf2",
|
|
2551
|
+
"md5",
|
|
2552
|
+
"sha1",
|
|
2553
|
+
"sha256",
|
|
2554
|
+
"plaintext",
|
|
2555
|
+
"salted",
|
|
2556
|
+
"optional",
|
|
2557
|
+
"immutable",
|
|
2558
|
+
"readonly",
|
|
2559
|
+
"in",
|
|
2560
|
+
"on",
|
|
2561
|
+
"at",
|
|
2562
|
+
"for",
|
|
2563
|
+
"of",
|
|
2564
|
+
"to",
|
|
2565
|
+
"from",
|
|
2566
|
+
"by",
|
|
2567
|
+
"with",
|
|
2568
|
+
"and",
|
|
2569
|
+
"or",
|
|
2570
|
+
"but",
|
|
2571
|
+
"if",
|
|
2572
|
+
"then",
|
|
2573
|
+
"so",
|
|
2574
|
+
"because",
|
|
2575
|
+
"available",
|
|
2576
|
+
"expiring",
|
|
2577
|
+
"defined",
|
|
2578
|
+
"specified",
|
|
2579
|
+
"read",
|
|
2580
|
+
"loaded",
|
|
2581
|
+
"passed",
|
|
2582
|
+
"included",
|
|
2583
|
+
"attached",
|
|
2584
|
+
"present",
|
|
2585
|
+
"absent",
|
|
2586
|
+
"null",
|
|
2587
|
+
"none",
|
|
2588
|
+
"undefined",
|
|
2589
|
+
"false",
|
|
2590
|
+
"true",
|
|
2591
|
+
]);
|
|
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");
|
|
2593
|
+
/** A credential named in a sentence: `my wifi password is …`, `API key: …`, `the token was …`. */
|
|
2594
|
+
function prose(text, out) {
|
|
2595
|
+
if (!CREDENTIAL_SENTENCE.test(text))
|
|
2596
|
+
return;
|
|
2597
|
+
// A passphrase or password quoted after a label: `Your passphrase (between double-quotes): "falcon tiger quartz"`.
|
|
2598
|
+
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
|
+
const value = m[3];
|
|
2600
|
+
if (/\$\{|\{\{|^<|%s/.test(value) ||
|
|
2601
|
+
isInert(value) ||
|
|
2602
|
+
isPlaceholder(value) ||
|
|
2603
|
+
!(/^[a-z]+(?:[ -][a-z]+){2,}$/.test(value) ||
|
|
2604
|
+
(looksSecret(value) && /\d/.test(value) && /[A-Za-z]/.test(value) && !/\s/.test(value))))
|
|
2605
|
+
continue;
|
|
2606
|
+
namedFinding(out, m.indices[3][0], m.indices[3][1], m[1], "named:prose", 0.85);
|
|
2607
|
+
}
|
|
2608
|
+
for (const m of text.matchAll(PROSE)) {
|
|
2609
|
+
// (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];
|
|
2612
|
+
const before = words.length > 1
|
|
2613
|
+
? words.at(-2)
|
|
2614
|
+
: (/(\p{L}+)[ \t]*$/u.exec(text.slice(Math.max(0, m.index - 30), m.index))?.[1] ?? "");
|
|
2615
|
+
if (/^pass$/i.test(lastWord(name)) && isPassInEnglish(before))
|
|
2616
|
+
continue;
|
|
2617
|
+
if (isVerdictUnderPass(name, withoutUnopenedClosers(m[4])))
|
|
2618
|
+
continue;
|
|
2619
|
+
const last = lastWord(name);
|
|
2620
|
+
// (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]);
|
|
2622
|
+
const value = withoutUnopenedClosers(text.slice(m.indices[4][0], end));
|
|
2623
|
+
if (!isCredentialName(name.replace(/\s+/g, "_")) || /cookie/i.test(name) || clientSdkAt(text, m.index))
|
|
2624
|
+
continue;
|
|
2625
|
+
// A signature's parameter and its type, `decode(secret: Base32SecretKey): …`: the line opens a bracket before the
|
|
2626
|
+
// name and the value closes it.
|
|
2627
|
+
if (/^[A-Za-z_$][\w$.<>[\]]*\)/.test(m[4]) && enclosingCall(text, m.index) !== undefined)
|
|
2628
|
+
continue;
|
|
2629
|
+
const bare = value.replace(/[.,;:!?]+$/, "").toLowerCase();
|
|
2630
|
+
// (A passphrase of words, cut at its first blank by the value's grammar: read whole, before any test of one word.)
|
|
2631
|
+
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);
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
const pin = isPinName(name.replace(/\s+/g, "_")) && /^\d{4,8}$/.test(value.replace(/[.,;:!?]+$/, ""));
|
|
2639
|
+
if (!pin &&
|
|
2640
|
+
(/\?$/.test(value) ||
|
|
2641
|
+
/^[a-z]+ed(?:\/[a-z]+ed)*$/.test(bare) ||
|
|
2642
|
+
/^[[(<{].*[\])>}]$/.test(value) ||
|
|
2643
|
+
!isProseValue(value)))
|
|
2644
|
+
continue;
|
|
2645
|
+
// (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,
|
|
2647
|
+
// `{c.Password}`, `v2-cache-{{`, and a symbol, `[:public]`, aren't values.)
|
|
2648
|
+
// (A reference, a member's path or a call, or a type is code wherever it's written:
|
|
2649
|
+
// `pass: process.env.SMTP_PASSWORD`, `password: this.data.password`, `password: SecretStr`; a bare name isn't,
|
|
2650
|
+
// `The password is: BlueSecret`. A keyword's expression is judged with the rest of the line, as a blank cut it:
|
|
2651
|
+
// `password: lambda: secrets["db"]`.)
|
|
2652
|
+
if (((/=/.test(m[2]) ||
|
|
2653
|
+
/^[);,]/.test(text.slice(end)) ||
|
|
2654
|
+
(/^[ \t]*\r?$/.test(text.slice(end, lineEndAt(text, end))) && isLastEntry(text, m.index))) &&
|
|
2655
|
+
isCode(value)) ||
|
|
2656
|
+
isCode(value, true) ||
|
|
2657
|
+
(/^(?:lambda|await|new|function|fn)\b/.test(value) &&
|
|
2658
|
+
isCode(text.slice(m.indices[4][0], lineEndAt(text, m.indices[4][0])), true)))
|
|
2659
|
+
continue;
|
|
2660
|
+
if (/\{\{|^\[?:[a-z_]\w*\]?$/.test(value) || (text[m.indices[4][0] - 1] === "{" && /\}$/.test(value)))
|
|
2661
|
+
continue;
|
|
2662
|
+
const start = m.indices[4][0];
|
|
2663
|
+
if (/^(PIN|SEED|MNEMONIC|PHRASE|SIG|SIGNATURE)$/.test(last) &&
|
|
2664
|
+
!isNamedSecret(name.replace(/\s+/g, "_"), `"${value}"`, true))
|
|
2665
|
+
continue;
|
|
2666
|
+
if (isHashWithWords(value))
|
|
2667
|
+
continue;
|
|
2668
|
+
if (takesAnyWord(name.replace(/\s+/g, "_"))) {
|
|
2669
|
+
if (/^[a-z]+(-[a-z]+)+$/.test(value) || isLabel(value, name) || isSettingOrSelfName(name, value))
|
|
2670
|
+
continue;
|
|
2671
|
+
// (`pass: 12/12`, `Pass: 98.5%`: a test count.)
|
|
2672
|
+
if (/^pass$/i.test(name) && /^\d+(?:[/.,]\d+)*%?[,;]?$/.test(value))
|
|
2673
|
+
continue;
|
|
2674
|
+
const after = text.slice(start + value.length, start + value.length + 40);
|
|
2675
|
+
// (A digit or a symbol makes it one; a letter outside ASCII doesn't: `confirm_password: Potwierdź moje konto`.)
|
|
2676
|
+
// (Or the object around it closes after it, `{user: svc, password: acez}`, and it's no test word or ordinary word.)
|
|
2677
|
+
const closed = /^[ \t]*[)\]}]/.test(after) && naturalness(value) < 0.5;
|
|
2678
|
+
if (!(m[3] ||
|
|
2679
|
+
closed ||
|
|
2680
|
+
/\d/.test(value) ||
|
|
2681
|
+
/[^\p{L}\p{N}_]/u.test(value) ||
|
|
2682
|
+
/^[.,;!?)\]]{0,3}[ \t]*(?:$|\r|\n|\\n|(?:and|but|so|btw|if|or|lol|thanks|thx|please|pls|then|for|ok)\b)/i.test(after)))
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
else if (!isNamedSecret(name.replace(/\s+/g, "_"), `"${value}"`, false) ||
|
|
2686
|
+
(value.length < 16 && !(value.length >= 10 && hasThreeClasses(value) && /[^A-Za-z0-9]/.test(value))))
|
|
2687
|
+
continue;
|
|
2688
|
+
namedFinding(out, start, withBangs(text, start + value.length), name, "named:prose", 0.85);
|
|
2689
|
+
}
|
|
2690
|
+
// 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)) {
|
|
2693
|
+
const k = m[2] !== undefined ? 2 : 4;
|
|
2694
|
+
const value = m[k];
|
|
2695
|
+
const noun = (m[1] ?? m[3]).toLowerCase();
|
|
2696
|
+
// (`it is …` only for a credential just rotated, whichever side of its noun the verb is: `rotated the password, new
|
|
2697
|
+
// one is …`, `ops reset the db password, it's … now`.)
|
|
2698
|
+
// (Or a quoted value that reads as a password: ``the password instead? It's `Apple2007!` ``.)
|
|
2699
|
+
if (k === 4 &&
|
|
2700
|
+
!/rotat|chang|reset|regenerat|replac|updat|new one/i.test(text.slice(lineStartAt(text, m.index), m.indices[4][0])) &&
|
|
2701
|
+
!(/["'`*]$/.test(text.slice(m.index, m.indices[4][0])) && /\d/.test(value) && /[^\w]/.test(value)))
|
|
2702
|
+
continue;
|
|
2703
|
+
// (A word a colon joins to the one before it is a key's or a format's, not a sentence's: pulumi's `db:pw`, the
|
|
2704
|
+
// `username:password` of a .pgpass header, whose next line `\s+` would read as the value. A noun that qualifies
|
|
2705
|
+
// another names no credential the sentence goes on to give: `the password policy changed …, it is now NIST800
|
|
2706
|
+
// compliant`.)
|
|
2707
|
+
if (!value ||
|
|
2708
|
+
QUALIFIED_NOUN.test(text.slice(m.indices[k - 1][1], m.indices[k][0])) ||
|
|
2709
|
+
/:\/\/|\\/.test(value) ||
|
|
2710
|
+
(k === 2 && text[m.indices[1][0] - 1] === ":") ||
|
|
2711
|
+
!isProseValue(value) ||
|
|
2712
|
+
isCode(value, true) ||
|
|
2713
|
+
!(/\d/.test(value) && /[A-Za-z]/.test(value)) ||
|
|
2714
|
+
// (A key's or a token's value as those are judged: `the SSH key was updated, it is id_ed25519 now` names a file.)
|
|
2715
|
+
(!/^pass|^pw/.test(noun) && !isNamedSecret(noun.replace(/\s+/g, "_"), `"${value}"`, true)))
|
|
2716
|
+
continue;
|
|
2717
|
+
namedFinding(out, m.indices[k][0], withBangs(text, m.indices[k][1]), noun, "named:prose", 0.8);
|
|
2718
|
+
}
|
|
2719
|
+
// A value in parentheses after its name: `rotate the passwd (M34d0w) before Friday`, `the DB_PASSWORD (Xk9…)`.
|
|
2720
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?<![\w.$-])([A-Za-z][\w-]{1,60})[ \t]+\((["'\x60]?)${unquotedValue(String.raw `[^\s()"'\x60]`, 4, String.raw `\2\)`)}`, "dg"))) {
|
|
2721
|
+
const [, name, , value] = m;
|
|
2722
|
+
// (`tests pass (40/40)`: the verb, and a count.)
|
|
2723
|
+
// (A number is a password under a password's name, six digits or more, or a PIN of four to eight.)
|
|
2724
|
+
const digits = /^\d+$/.test(value) &&
|
|
2725
|
+
((isPinName(name) && value.length >= 4 && value.length <= 8) || (isPasswordName(name) && value.length >= 6));
|
|
2726
|
+
// (Nor a module's path after a function's name: unittest's `test_set_password (auth_tests.test_models.UserTests)`.)
|
|
2727
|
+
if (/^pass(?:es|ed)?$/i.test(name) ||
|
|
2728
|
+
(!/\p{L}/u.test(value) && !digits) ||
|
|
2729
|
+
!isCredentialName(name) ||
|
|
2730
|
+
(!digits && !isProseValue(value)) ||
|
|
2731
|
+
isCode(value, true) ||
|
|
2732
|
+
/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*){2,}$/.test(value))
|
|
2733
|
+
continue;
|
|
2734
|
+
// (Under a password's name a word with a digit or a symbol, not a letter outside ASCII: Czech `heslo (volitelné)`,
|
|
2735
|
+
// "password (optional)"; under another, what that name takes.)
|
|
2736
|
+
const password = takesAnyWord(name);
|
|
2737
|
+
const accepted = digits ||
|
|
2738
|
+
(password
|
|
2739
|
+
? (/\d/.test(value) || /[^\p{L}\p{N}_]/u.test(value)) &&
|
|
2740
|
+
!isLabel(value, name) &&
|
|
2741
|
+
isNamedSecret("password", `"${value}"`, true)
|
|
2742
|
+
: isNamedSecret(name, `"${value}"`, true));
|
|
2743
|
+
if (ALGORITHM.test(value) || !accepted)
|
|
2744
|
+
continue;
|
|
2745
|
+
namedFinding(out, m.indices[3][0], m.indices[3][1], name, "named:prose", 0.8);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Where a value a sentence gives ends, with the `!`s right after it that its reader left to the sentence: a password's
|
|
2750
|
+
* own as often (`the pass is Xyzzy42!`), and an exclamation hidden with one costs nothing. (A `?` stays the sentence's:
|
|
2751
|
+
* `is it hunter2?` asks.)
|
|
2752
|
+
*/
|
|
2753
|
+
function withBangs(text, end) {
|
|
2754
|
+
return end + (/^!+(?=[\s"'\x60*)\]]|$)/.exec(text.slice(end, end + 16))?.[0].length ?? 0);
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Whether a value a sentence gives (`the password is …`, `pass (…)`, `**Password:** …`, `密码:…`) can be a secret: not a
|
|
2758
|
+
* word a sentence goes on with or a common one, code that prose names (PROSE_CODE), or anything else a literal secret
|
|
2759
|
+
* never looks like. Sentence punctuation after it isn't part of it. Each rule adds its own terms. (Values never secret
|
|
2760
|
+
* whatever names them, an algorithm's name, a range, a count, are policy.ts's, for every rule that reads by name.)
|
|
2761
|
+
*/
|
|
2762
|
+
function isProseValue(value) {
|
|
2763
|
+
// (Nor a required field's star: `password (required*)`.)
|
|
2764
|
+
const v = value.replace(/[.,;:!?)*]+$/, "");
|
|
2765
|
+
const lower = v.toLowerCase();
|
|
2766
|
+
return !PROSE_WORDS.has(lower) && !COMMON_WORDS.has(lower) && !PROSE_CODE.test(v) && looksSecret(`"${v}"`, true);
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Code where prose names a credential (a value prose delimits is otherwise a literal, brackets and all): a call
|
|
2770
|
+
* or a type cut at its bracket (`from_private_bytes(`, `&ChangePassword{`, `[parameters(`), an instance or shell
|
|
2771
|
+
* variable (`@user_password`, `${JWT_SECRET:?…`), a call's end (`bool):`), a reference type (`pwd: &Path`), an
|
|
2772
|
+
* interpolation's hole cut at its brace (`{result2`); and an issue's reference (`[GH-975]`) or a list of formats
|
|
2773
|
+
* (`(DER/PEM/P12)`) that a sentence about a password goes on to.
|
|
2774
|
+
*/
|
|
2775
|
+
const PROSE_CODE = /^(?:[@$&%]{0,2}\{?)[A-Za-z_][\w.]*[([{][\w"'., :=$({[]*[)\]}]?:?$|^[@$]{1,2}\{?[A-Za-z_]\w*[})]?$|^\$\{|^\[[A-Za-z_][\w.]*\(|^[\w.]+\):?$|^&(?:mut )?[A-Z][\w:]*(?:<[^>]*>)?$|^\{[A-Za-z_]\w*$|^\[?[A-Z][A-Z0-9]{1,9}-\d{1,6}\]?$|^\(?[A-Z0-9]{2,8}(?:\/[A-Z0-9]{2,8})+\)?$/;
|
|
2776
|
+
/** How far into a sentence a credential's noun reaches to the verb or the value that gives it (`the admin password for the billing team's production cluster is …`). */
|
|
2777
|
+
const SENTENCE_REACH = 200;
|
|
2778
|
+
/** Credential nouns that sentences and labels use. */
|
|
2779
|
+
const CRED_NOUN = String.raw `(?:pass(?:word|code|phrase)|pass phrase|(?:${CREDENTIAL_QUALIFIERS}|authentication|application)[ _-]?(?:key|token|secret)|token|secret|pin(?: code)?)`;
|
|
2780
|
+
/**
|
|
2781
|
+
* A credential as a sentence names it: a determiner, up to three qualifiers and the noun (`the root password`, `the
|
|
2782
|
+
* webhook secret`, `your new API key`), or a variable's name (`DB_PASSWORD`). The noun, or the name, is group 1.
|
|
2783
|
+
*/
|
|
2784
|
+
const CRED_PHRASE = String.raw `(?:(?:the|a|an|their|his|her|its|our|my|your|this|that)\s+)?(?:[\w-]+\s+){0,3}?(${CRED_NOUN}|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_(?:PASSWORD|PASSWD|PASS|PWD|SECRET|TOKEN|KEY)(?:_[A-Z0-9]+)*)`;
|
|
2785
|
+
const CRED_NOUN_IN = new RegExp(CRED_NOUN, "i");
|
|
2786
|
+
/**
|
|
2787
|
+
* A word after a credential's noun that makes the noun a qualifier and another word the subject (`password policy`,
|
|
2788
|
+
* `password manager`, `**Password reset:**`): what a sentence goes on to say is about that subject.
|
|
2789
|
+
*/
|
|
2790
|
+
const QUALIFIED_NOUN = /^[ \t]+(?:policy|policies|reset|field|requirements?|rules?|manager|strength|length|hint|prompt|change|expir\w*|history|complexity|recovery|rotation|storage|hash(?:ing)?|form|page|screen|dialog|input|protected|protection|less)\b/i;
|
|
2791
|
+
/**
|
|
2792
|
+
* Words that don't make a passphrase: the grammar a sentence goes on with (`hunter2 and the user is bob`, `is sometimes
|
|
2793
|
+
* passed into`, `is more than enough`, `each time he uses sudo`), and what one says about a password instead of
|
|
2794
|
+
* giving it (`see vault entry`, `case sensitive`, `blank`, `expires next week`).
|
|
2795
|
+
*/
|
|
2796
|
+
const FUNCTION_WORDS = new Set([
|
|
2797
|
+
"and",
|
|
2798
|
+
"or",
|
|
2799
|
+
"but",
|
|
2800
|
+
"nor",
|
|
2801
|
+
"the",
|
|
2802
|
+
"a",
|
|
2803
|
+
"an",
|
|
2804
|
+
"is",
|
|
2805
|
+
"was",
|
|
2806
|
+
"are",
|
|
2807
|
+
"were",
|
|
2808
|
+
"be",
|
|
2809
|
+
"been",
|
|
2810
|
+
"being",
|
|
2811
|
+
"has",
|
|
2812
|
+
"have",
|
|
2813
|
+
"had",
|
|
2814
|
+
"do",
|
|
2815
|
+
"does",
|
|
2816
|
+
"did",
|
|
2817
|
+
"to",
|
|
2818
|
+
"for",
|
|
2819
|
+
"of",
|
|
2820
|
+
"in",
|
|
2821
|
+
"on",
|
|
2822
|
+
"at",
|
|
2823
|
+
"by",
|
|
2824
|
+
"with",
|
|
2825
|
+
"via",
|
|
2826
|
+
"from",
|
|
2827
|
+
"into",
|
|
2828
|
+
"onto",
|
|
2829
|
+
"upon",
|
|
2830
|
+
"about",
|
|
2831
|
+
"over",
|
|
2832
|
+
"under",
|
|
2833
|
+
"than",
|
|
2834
|
+
"so",
|
|
2835
|
+
"if",
|
|
2836
|
+
"then",
|
|
2837
|
+
"that",
|
|
2838
|
+
"this",
|
|
2839
|
+
"these",
|
|
2840
|
+
"those",
|
|
2841
|
+
"it",
|
|
2842
|
+
"its",
|
|
2843
|
+
"as",
|
|
2844
|
+
"not",
|
|
2845
|
+
"no",
|
|
2846
|
+
"you",
|
|
2847
|
+
"we",
|
|
2848
|
+
"i",
|
|
2849
|
+
"he",
|
|
2850
|
+
"she",
|
|
2851
|
+
"they",
|
|
2852
|
+
"him",
|
|
2853
|
+
"her",
|
|
2854
|
+
"them",
|
|
2855
|
+
"me",
|
|
2856
|
+
"us",
|
|
2857
|
+
"my",
|
|
2858
|
+
"your",
|
|
2859
|
+
"our",
|
|
2860
|
+
"their",
|
|
2861
|
+
"his",
|
|
2862
|
+
"please",
|
|
2863
|
+
"thanks",
|
|
2864
|
+
"use",
|
|
2865
|
+
"uses",
|
|
2866
|
+
"set",
|
|
2867
|
+
"change",
|
|
2868
|
+
"now",
|
|
2869
|
+
"sometimes",
|
|
2870
|
+
"always",
|
|
2871
|
+
"never",
|
|
2872
|
+
"often",
|
|
2873
|
+
"also",
|
|
2874
|
+
"only",
|
|
2875
|
+
"just",
|
|
2876
|
+
"still",
|
|
2877
|
+
"here",
|
|
2878
|
+
"there",
|
|
2879
|
+
"see",
|
|
2880
|
+
"check",
|
|
2881
|
+
"ask",
|
|
2882
|
+
"refer",
|
|
2883
|
+
"look",
|
|
2884
|
+
"find",
|
|
2885
|
+
"get",
|
|
2886
|
+
"contact",
|
|
2887
|
+
"stored",
|
|
2888
|
+
"kept",
|
|
2889
|
+
"provided",
|
|
2890
|
+
"above",
|
|
2891
|
+
"below",
|
|
2892
|
+
"per",
|
|
2893
|
+
"blank",
|
|
2894
|
+
"empty",
|
|
2895
|
+
"unset",
|
|
2896
|
+
"random",
|
|
2897
|
+
"sensitive",
|
|
2898
|
+
"insensitive",
|
|
2899
|
+
"default",
|
|
2900
|
+
"expires",
|
|
2901
|
+
"changes",
|
|
2902
|
+
"required",
|
|
2903
|
+
"optional",
|
|
2904
|
+
"next",
|
|
2905
|
+
"week",
|
|
2906
|
+
"month",
|
|
2907
|
+
"day",
|
|
2908
|
+
"when",
|
|
2909
|
+
"where",
|
|
2910
|
+
"which",
|
|
2911
|
+
"who",
|
|
2912
|
+
"what",
|
|
2913
|
+
"how",
|
|
2914
|
+
"why",
|
|
2915
|
+
"should",
|
|
2916
|
+
"will",
|
|
2917
|
+
"would",
|
|
2918
|
+
"can",
|
|
2919
|
+
"could",
|
|
2920
|
+
"must",
|
|
2921
|
+
"may",
|
|
2922
|
+
"might",
|
|
2923
|
+
"very",
|
|
2924
|
+
"too",
|
|
2925
|
+
"quite",
|
|
2926
|
+
"rather",
|
|
2927
|
+
"really",
|
|
2928
|
+
"more",
|
|
2929
|
+
"most",
|
|
2930
|
+
"less",
|
|
2931
|
+
"least",
|
|
2932
|
+
"much",
|
|
2933
|
+
"many",
|
|
2934
|
+
"few",
|
|
2935
|
+
"enough",
|
|
2936
|
+
"each",
|
|
2937
|
+
"every",
|
|
2938
|
+
"all",
|
|
2939
|
+
"any",
|
|
2940
|
+
"some",
|
|
2941
|
+
"both",
|
|
2942
|
+
"either",
|
|
2943
|
+
"neither",
|
|
2944
|
+
"other",
|
|
2945
|
+
"another",
|
|
2946
|
+
"such",
|
|
2947
|
+
"same",
|
|
2948
|
+
"own",
|
|
2949
|
+
"else",
|
|
2950
|
+
"somewhere",
|
|
2951
|
+
"anywhere",
|
|
2952
|
+
"everywhere",
|
|
2953
|
+
"nowhere",
|
|
2954
|
+
]);
|
|
2955
|
+
/**
|
|
2956
|
+
* The grammar of the languages UI text is most often translated into (Dutch, German, French, Spanish, Portuguese,
|
|
2957
|
+
* Italian): articles, possessives, prepositions and conjunctions that are no English word nor BIP39's. Among a label's
|
|
2958
|
+
* capitalised words they make it a translation (`Bevestig mijn account`, `Confirmar minha conta`), not a passphrase.
|
|
2959
|
+
* (Only there: in lower case, `t la xk zmtwvhw`, a short one is as likely a passphrase's.)
|
|
2960
|
+
*/
|
|
2961
|
+
const FOREIGN_FUNCTION_WORDS = new Set([
|
|
2962
|
+
"mijn",
|
|
2963
|
+
"uw",
|
|
2964
|
+
"jouw",
|
|
2965
|
+
"het",
|
|
2966
|
+
"een",
|
|
2967
|
+
"voor",
|
|
2968
|
+
"niet",
|
|
2969
|
+
"naar",
|
|
2970
|
+
"bij",
|
|
2971
|
+
"wordt",
|
|
2972
|
+
"deze",
|
|
2973
|
+
"ons",
|
|
2974
|
+
"onze",
|
|
2975
|
+
"mein",
|
|
2976
|
+
"meine",
|
|
2977
|
+
"dein",
|
|
2978
|
+
"deine",
|
|
2979
|
+
"ihr",
|
|
2980
|
+
"ihre",
|
|
2981
|
+
"und",
|
|
2982
|
+
"der",
|
|
2983
|
+
"das",
|
|
2984
|
+
"ist",
|
|
2985
|
+
"nicht",
|
|
2986
|
+
"mit",
|
|
2987
|
+
"für",
|
|
2988
|
+
"zu",
|
|
2989
|
+
"ein",
|
|
2990
|
+
"eine",
|
|
2991
|
+
"einen",
|
|
2992
|
+
"bitte",
|
|
2993
|
+
"oder",
|
|
2994
|
+
"wird",
|
|
2995
|
+
"sie",
|
|
2996
|
+
"mon",
|
|
2997
|
+
"mes",
|
|
2998
|
+
"votre",
|
|
2999
|
+
"vos",
|
|
3000
|
+
"le",
|
|
3001
|
+
"la",
|
|
3002
|
+
"les",
|
|
3003
|
+
"des",
|
|
3004
|
+
"du",
|
|
3005
|
+
"et",
|
|
3006
|
+
"est",
|
|
3007
|
+
"avec",
|
|
3008
|
+
"une",
|
|
3009
|
+
"vous",
|
|
3010
|
+
"nous",
|
|
3011
|
+
"de",
|
|
3012
|
+
"au",
|
|
3013
|
+
"aux",
|
|
3014
|
+
"ce",
|
|
3015
|
+
"cette",
|
|
3016
|
+
"mi",
|
|
3017
|
+
"mis",
|
|
3018
|
+
"tu",
|
|
3019
|
+
"tus",
|
|
3020
|
+
"su",
|
|
3021
|
+
"sus",
|
|
3022
|
+
"el",
|
|
3023
|
+
"los",
|
|
3024
|
+
"las",
|
|
3025
|
+
"del",
|
|
3026
|
+
"para",
|
|
3027
|
+
"una",
|
|
3028
|
+
"es",
|
|
3029
|
+
"por",
|
|
3030
|
+
"minha",
|
|
3031
|
+
"meu",
|
|
3032
|
+
"sua",
|
|
3033
|
+
"seu",
|
|
3034
|
+
"você",
|
|
3035
|
+
"voce",
|
|
3036
|
+
"uma",
|
|
3037
|
+
"não",
|
|
3038
|
+
"nao",
|
|
3039
|
+
"da",
|
|
3040
|
+
"dos",
|
|
3041
|
+
"ao",
|
|
3042
|
+
"il",
|
|
3043
|
+
"gli",
|
|
3044
|
+
"della",
|
|
3045
|
+
"di",
|
|
3046
|
+
"è",
|
|
3047
|
+
"tuo",
|
|
3048
|
+
"tua",
|
|
3049
|
+
"mio",
|
|
3050
|
+
"mia",
|
|
3051
|
+
"non",
|
|
3052
|
+
]);
|
|
3053
|
+
/** nsswitch.conf's sources: `passwd: files cache ldap` lists where the system looks users up. */
|
|
3054
|
+
const NSS_SOURCES = new Set([
|
|
3055
|
+
"files",
|
|
3056
|
+
"systemd",
|
|
3057
|
+
"ldap",
|
|
3058
|
+
"sss",
|
|
3059
|
+
"nis",
|
|
3060
|
+
"nisplus",
|
|
3061
|
+
"db",
|
|
3062
|
+
"compat",
|
|
3063
|
+
"cache",
|
|
3064
|
+
"dns",
|
|
3065
|
+
"winbind",
|
|
3066
|
+
"altfiles",
|
|
3067
|
+
"mymachines",
|
|
3068
|
+
"resolve",
|
|
3069
|
+
"myhostname",
|
|
3070
|
+
"hesiod",
|
|
3071
|
+
"extrausers",
|
|
3072
|
+
"oslogin",
|
|
3073
|
+
]);
|
|
3074
|
+
/**
|
|
3075
|
+
* A word a sentence has and a passphrase doesn't: a function word, or a verb's or an adverb's form (`hashed`, `using`,
|
|
3076
|
+
* `effectively`) that isn't a word of BIP39's list, which passphrases are drawn from (`jelly`, `bring`, `speed`).
|
|
3077
|
+
*/
|
|
3078
|
+
export function isSentenceWord(word) {
|
|
3079
|
+
const w = word.toLowerCase().replace(/[.,;:!?]+$/, "");
|
|
3080
|
+
// (A participle or an adverb has a stem before its ending: `using`, `generated`, `randomly`; not `red`, `king`, `holy`.)
|
|
3081
|
+
return FUNCTION_WORDS.has(w) || (/^[a-z]{2,}(?:ed|ing)$|^[a-z]{3,}ly$/.test(w) && !bip39().has(w));
|
|
3082
|
+
}
|
|
3083
|
+
/** BIP39's word list as each word's index in it, built on first use. */
|
|
3084
|
+
let BIP39;
|
|
3085
|
+
export const bip39 = () => (BIP39 ??= new Map(BIP39_WORDS.split(" ").map((w, i) => [w, i])));
|
|
3086
|
+
/**
|
|
3087
|
+
* Whether words read as a passphrase (`correct horse battery staple`): three to twelve of them, none repeated, no
|
|
3088
|
+
* sentence's word, and not a list of nsswitch sources.
|
|
3089
|
+
*/
|
|
3090
|
+
function isPassphrase(words) {
|
|
3091
|
+
const lower = words.map((w) => w.toLowerCase());
|
|
3092
|
+
return (lower.length >= 3 &&
|
|
3093
|
+
lower.length <= 12 &&
|
|
3094
|
+
new Set(lower).size === lower.length &&
|
|
3095
|
+
!lower.every((w) => NSS_SOURCES.has(w)) &&
|
|
3096
|
+
!lower.some(isSentenceWord));
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
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.
|
|
3103
|
+
*/
|
|
3104
|
+
function passphraseEnd(text, start, end) {
|
|
3105
|
+
const q = text[start - 1];
|
|
3106
|
+
if (q === '"' || q === "'" || q === "`") {
|
|
3107
|
+
const close = text.indexOf(q, end);
|
|
3108
|
+
const lineEnd = text.indexOf("\n", end);
|
|
3109
|
+
return close > end &&
|
|
3110
|
+
close - start <= 200 &&
|
|
3111
|
+
(lineEnd < 0 || close < lineEnd) &&
|
|
3112
|
+
isPassphrase(text.slice(start, close).trim().split(/\s+/))
|
|
3113
|
+
? close
|
|
3114
|
+
: end;
|
|
3115
|
+
}
|
|
3116
|
+
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;
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* A value as prose writes it: no spaces, up to its closing quote or emphasis, sentence punctuation, a blank, a closing
|
|
3123
|
+
* tag (`is **X**.`, `<p>… is X.</p>`) or an escaped line break (`… if -force is specified\n\` in a string). Not one in
|
|
3124
|
+
* parentheses, `set the password (self.pw) first`, `the password (required*)`: that is prose's own reading of `name (value)`.
|
|
3125
|
+
*/
|
|
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}`;
|
|
3127
|
+
/**
|
|
3128
|
+
* Credentials named in prose with words in between, and in Markdown labels:
|
|
3129
|
+
* - `Our password for accessing the strategy documents is River@2025.`, `The API key utilized … was b2d1…`
|
|
3130
|
+
* - `…logged into the portal using the password River2025! to access…`, `Catherine's password, Elephant99#, is…`
|
|
3131
|
+
* - `- **Password:** m4P$9tR#7kLw`, `**Temporary Password:** Ocean@2025`, `| Password for Accessing Records | R@2025 |`
|
|
3132
|
+
* The value must read as one: a password with a digit, a symbol or mixed case; a key or token generated.
|
|
3133
|
+
*/
|
|
3134
|
+
function labelledProse(text, out) {
|
|
3135
|
+
if (!/pass|key|token|secret|pin\b/i.test(text))
|
|
3136
|
+
return;
|
|
3137
|
+
const accept = (noun, value) => {
|
|
3138
|
+
const v = value.replace(/[.,;:)]+$/, "");
|
|
3139
|
+
// (A question or a verb ends the sentence about it: `the password is reset?`, `was guessed/hacked`, `is POSTed`.)
|
|
3140
|
+
if (/\?$/.test(value) || /^[A-Za-z]+ed(?:\/[A-Za-z]+ed)*$/.test(v))
|
|
3141
|
+
return false;
|
|
3142
|
+
// (A PIN is a few digits, which no other value test takes: checked first.)
|
|
3143
|
+
if (isPinName(noun.replace(/\s+/g, "_")))
|
|
3144
|
+
return /^\d{4,8}$/.test(v);
|
|
3145
|
+
// (A secret's path or name in a store, not the secret: consul-template's `{{ with secret "kv-v2/data/app" }}`.)
|
|
3146
|
+
if (/^[a-z0-9_.-]+(?:\/[a-z0-9_.*-]+)+$/.test(v))
|
|
3147
|
+
return false;
|
|
3148
|
+
if (v.length < 4 || !isProseValue(v))
|
|
3149
|
+
return false;
|
|
3150
|
+
// (A password, or a secret a person set: `Set the webhook secret to Maple1319%`.)
|
|
3151
|
+
if (/pass|secret/i.test(noun))
|
|
3152
|
+
return (isNamedSecret("password", `"${v}"`, true) &&
|
|
3153
|
+
(/\d/.test(v) || /[^\w]/.test(v) || (/[a-z]/.test(v) && /[A-Z]/.test(v.slice(1)))));
|
|
3154
|
+
return v.length >= 12 && isNamedSecret(noun.replace(/\s+/g, "_"), `"${v}"`, true);
|
|
3155
|
+
};
|
|
3156
|
+
// The value is group 2; the noun ends at `nounEnd`.
|
|
3157
|
+
const add = (m, noun, nounEnd) => {
|
|
3158
|
+
const [start] = m.indices[2];
|
|
3159
|
+
// (The noun is the subject, not a qualifier: `password policy is …`, `password manager is …`, `**Password reset:**
|
|
3160
|
+
// <url>`; checked before a passphrase is read on.)
|
|
3161
|
+
if (QUALIFIED_NOUN.test(text.slice(nounEnd, start)))
|
|
3162
|
+
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);
|
|
3166
|
+
return;
|
|
3167
|
+
}
|
|
3168
|
+
const v = m[2].replace(/[.,;:)]+$/, "");
|
|
3169
|
+
if (/^https?:\/\//.test(v) || !accept(noun, v))
|
|
3170
|
+
return;
|
|
3171
|
+
namedFinding(out, start, withBangs(text, start + v.length), noun.replace(/\s+/g, "_"), "named:prose-label", 0.85);
|
|
3172
|
+
};
|
|
3173
|
+
// `<noun> … is|was <value>`: up to 60 characters of the same sentence between them.
|
|
3174
|
+
for (const m of text.matchAll(new RegExp(String.raw `\b(${CRED_NOUN})\b(?:[^.!?\n:=]{0,${SENTENCE_REACH}}?)\b(?:is|was|are|were|reads|remains|stays)\s+(?:now\s+|still\s+|set to\s+|changed to\s+)?${PROSE_VALUE}`, "dgi")))
|
|
3175
|
+
add(m, m[1], m.indices[1][1]);
|
|
3176
|
+
// `using|with|enter the (new) password <value>`.
|
|
3177
|
+
for (const m of text.matchAll(new RegExp(String.raw `\b(?:using|with|via|enter(?:ed)?|type[d]?|use|set|reset (?:it )?to|changed (?:it )?to|try)\s+(?:the |a |an |their |his |her |its |our |my |your )?(?:new |temporary |default |current |updated |initial )?(${CRED_NOUN})\s+${PROSE_VALUE}`, "dgi")))
|
|
3178
|
+
add(m, m[1], m.indices[1][1]);
|
|
3179
|
+
// `set|changed|update the webhook secret to <value>`, `rotate DB_PASSWORD to <value>`.
|
|
3180
|
+
for (const m of text.matchAll(new RegExp(String.raw `\b(?:set|sets|setting|change[ds]?|changing|reset(?:s|ting)?|update[ds]?|updating|rotate[ds]?|rotating)\s+${CRED_PHRASE}\s+(?:to|as)\s+(?:be\s+)?${PROSE_VALUE}`, "dgi")))
|
|
3181
|
+
add(m, m[1], m.indices[1][1]);
|
|
3182
|
+
// `use <value> as the (new) password`: the value before the noun.
|
|
3183
|
+
for (const m of text.matchAll(new RegExp(String.raw `\buse[ds]?\s+${PROSE_VALUE}\s+as\s+${CRED_PHRASE}`, "dgi"))) {
|
|
3184
|
+
const [start] = m.indices[1];
|
|
3185
|
+
const v = m[1].replace(/[.,;:)]+$/, "");
|
|
3186
|
+
if (accept(m[2], v))
|
|
3187
|
+
namedFinding(out, start, withBangs(text, start + v.length), m[2].replace(/\s+/g, "_"), "named:prose-label", 0.85);
|
|
3188
|
+
}
|
|
3189
|
+
// `X's password, <value>, …` (apposition).
|
|
3190
|
+
for (const m of text.matchAll(new RegExp(String.raw `['’]s\s+(?:new |temporary |current )?(${CRED_NOUN}),\s+${PROSE_VALUE}`, "dgi")))
|
|
3191
|
+
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"))) {
|
|
3194
|
+
const noun = labelNoun(m[1]);
|
|
3195
|
+
if (m[1].split(/\s+/).length <= 6)
|
|
3196
|
+
add(m, noun[0], m.indices[1][0] + noun.index + noun[0].length);
|
|
3197
|
+
}
|
|
3198
|
+
for (const m of text.matchAll(new RegExp(String.raw `^[ \t]*\|[ \t]*\**([^|\n*]{0,30}\b${CRED_NOUN}\b[^|\n*]{0,30}?)\**[ \t]*\|[ \t]*\x60?${unquotedValue(String.raw `[^|\s\x60]`, 4, String.raw `\x60?[ \t]*\|[ \t]*\r?$`)}`, "dgim"))) {
|
|
3199
|
+
const noun = labelNoun(m[1]);
|
|
3200
|
+
if (m[1].trim().split(/\s+/).length <= 6)
|
|
3201
|
+
add(m, noun[0], m.indices[1][0] + noun.index + noun[0].length);
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* The credential noun a label names its value by: its password's, secret's or PIN's when it has one (`API Key Password`
|
|
3206
|
+
* names a password), else its last (`Client ID and Secret Key`).
|
|
3207
|
+
*/
|
|
3208
|
+
function labelNoun(label) {
|
|
3209
|
+
const nouns = [...label.matchAll(new RegExp(String.raw `\b${CRED_NOUN}\b`, "gi"))];
|
|
3210
|
+
return (nouns.find((n) => /pass|secret|pin/i.test(n[0])) ?? nouns.at(-1) ?? CRED_NOUN_IN.exec(label));
|
|
3211
|
+
}
|
|
3212
|
+
/** "Password" in the languages agents' users write in (and its common inflections). */
|
|
3213
|
+
const PASSWORD_WORD = `(?:${PASSWORD_SPELLINGS.join("|")})`;
|
|
3214
|
+
/** A value no identifier, word or hash looks like: letters of both cases, a digit and a symbol, 6 to 64 characters. */
|
|
3215
|
+
const PASSWORD_SYMBOL = /[!@#$%^&*()+=?~[\]{}<>|]/;
|
|
3216
|
+
const PASSWORD_SHAPE = new RegExp(String.raw `^(?=\S*[a-z])(?=\S*[A-Z])(?=\S*\d)(?=\S*${PASSWORD_SYMBOL.source})[!-~]{6,64}$`);
|
|
3217
|
+
/**
|
|
3218
|
+
* A password-shaped value a few characters after a password word, in any language: `логин porfiri16, пароль
|
|
3219
|
+
* cOq&8SWy&5`, `Kullanıcı adı onat63, şifre &9OO0u!qxg`, `username debrajohnson and password _ZP8RqQT*2`,
|
|
3220
|
+
* `كلمة المرور الحالية للحساب: fOM4pSDb&6`. The shape carries it: case-mixed with a digit and a symbol.
|
|
3221
|
+
*/
|
|
3222
|
+
function passwordShaped(text, out) {
|
|
3223
|
+
if (!PASSWORD_SYMBOL.test(text))
|
|
3224
|
+
return;
|
|
3225
|
+
// (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"))) {
|
|
3228
|
+
// (Each opener with its own closer, so a quoted password may hold the others: `"Abc)1!"`, `«Mot(de)Passe1»`.)
|
|
3229
|
+
const k = [2, 3, 4, 5].find((i) => m[i] !== undefined);
|
|
3230
|
+
// (The sentence's punctuation and a bracket it closes, in either order: `(password: Xk9!mQ2z).`; a call's own
|
|
3231
|
+
// `)` 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
|
+
const [start] = m.indices[k];
|
|
3234
|
+
// (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
|
+
// code's or an encoding's; a pair or a flag with its value, `--pass="$P12Pass"`, or a list's first item,
|
|
3236
|
+
// `["Summer2024!",`, the readers' of those.)
|
|
3237
|
+
if (!PASSWORD_SHAPE.test(value) ||
|
|
3238
|
+
/:\/\/|^[\w.-]+@|^-{0,2}[A-Za-z_][\w.-]*=|<\/?[A-Za-z][\w:-]*>|\$\{|\{\{|::|\\["'nrt\\]|"\w*"[:,]|^\[["'\x60]/.test(value))
|
|
3239
|
+
continue;
|
|
3240
|
+
// (Unquoted, the code a value opens runs on to its brackets' close, and is judged whole with the keyword before
|
|
3241
|
+
// it: `password: await getSecret(names[0])`, `password: new Buffer(parts[1], "base64")`. Read only for a value
|
|
3242
|
+
// the checks above left: a line of unclosed brackets would have each match read to the bound.)
|
|
3243
|
+
const code = k === 5 &&
|
|
3244
|
+
isCode(/(?:\b(?:await|new)[ \t]+)?$/.exec(text.slice(Math.max(0, start - 12), start))[0] +
|
|
3245
|
+
text.slice(start, throughBrackets(text, start, start + m[k].length)), true);
|
|
3246
|
+
if (code || isCode(value, true)) {
|
|
3247
|
+
// (Code holds the password when a converter is given it: `New SecureString("Kx9mQv2Lp7Rt")`.)
|
|
3248
|
+
const literal = k === 5 ? convertedLiteral(text, start) : undefined;
|
|
3249
|
+
if (literal && isNamedSecret("password", `"${literal.value}"`, true))
|
|
3250
|
+
namedFinding(out, literal.start, literal.end, "password", "named:password-word", 0.85);
|
|
3251
|
+
continue;
|
|
3252
|
+
}
|
|
3253
|
+
if (!looksSecret(`"${value}"`, true))
|
|
3254
|
+
continue;
|
|
3255
|
+
namedFinding(out, start, start + value.length, "password", "named:password-word", 0.85);
|
|
3256
|
+
}
|
|
3257
|
+
}
|