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
|
@@ -0,0 +1,1625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets recognizable by the structure around them rather than their own format: private key
|
|
3
|
+
* blocks, passwords in URLs, Authorization headers, cookies, passwords in connection strings, and
|
|
4
|
+
* signatures in signed URLs. Each is a secret wherever it appears.
|
|
5
|
+
*/
|
|
6
|
+
import { base64Binary } from "./base64.js";
|
|
7
|
+
import { isDerPrivateKey, isPublicPem } from "./publickeys.js";
|
|
8
|
+
import { sha256 } from "./hmac.js";
|
|
9
|
+
import { lineEnd, lineStart } from "./lines.js";
|
|
10
|
+
import { armoredBlocks, pemTail } from "./pem.js";
|
|
11
|
+
import { inJwk, jsonDocuments } from "./json.js";
|
|
12
|
+
import { blockLines, siblingValue, yamlLines } from "./yaml.js";
|
|
13
|
+
import { isUrl, queryParams, readUserinfo, URL_SCHEME, urls } from "./urls.js";
|
|
14
|
+
import { naturalness } from "./charlm.js";
|
|
15
|
+
import { bip39, isNamedSecret } from "./named.js";
|
|
16
|
+
import { FILE_EXTENSION, isCredentialName, isSettingName, nameClass, namesPasswordOrPin } from "./names.js";
|
|
17
|
+
import { ANALYTICS_COOKIE, COMMON_WORDS, isCode, isIdentifierShaped, isInert, isNameLike, isRandomToken, isTestValue, isWordsValue, PASSWORD_HASH_IN_TEXT, readsAsWords, } from "./values.js";
|
|
18
|
+
import { isPlaceholder } from "./public.js";
|
|
19
|
+
import { isWrapper, programName, shellWords, simpleCommand, wordSpan, } from "./shell.js";
|
|
20
|
+
import { commandAt, commandsRun, passwordOptions, SHELL } from "./commands.js";
|
|
21
|
+
import { stringEnd } from "./syntax.js";
|
|
22
|
+
/** The labels of private key blocks, and of the OpenVPN static key. */
|
|
23
|
+
const PRIVATE_LABEL = /^(?:[A-Z0-9]+ ){0,4}PRIVATE KEY(?: BLOCK)?$|^OpenVPN Static key V1$/;
|
|
24
|
+
/** An END line of a private key's armor, PEM's or RFC 4716's. */
|
|
25
|
+
const PRIVATE_END = /-{4,5} ?END (?:[A-Z0-9]+ ){0,4}PRIVATE KEY(?: BLOCK)? ?-{4,5}/g;
|
|
26
|
+
/**
|
|
27
|
+
* Private key blocks (pem.ts reads them): from BEGIN to END, or to the last line of material when output stops early,
|
|
28
|
+
* and from the material above END when it starts late. A BEGIN with no material after it (a string constant in code)
|
|
29
|
+
* is not a key.
|
|
30
|
+
*/
|
|
31
|
+
function privateKeys(text, out) {
|
|
32
|
+
if (!text.includes("PRIVATE KEY") && !text.includes("BEGIN "))
|
|
33
|
+
return;
|
|
34
|
+
// (A private key's DER under another label, `PUBLIC KEY` or `CERTIFICATE`, is a private key: its body says so.)
|
|
35
|
+
const blocks = armoredBlocks(text)
|
|
36
|
+
.filter((b) => b.material >= 64 &&
|
|
37
|
+
(PRIVATE_LABEL.test(b.label) || isDerPrivateKey(armoredBase64(text.slice(b.labelEnd, b.end)))))
|
|
38
|
+
.map(({ start, end }) => ({ start, end }));
|
|
39
|
+
// The end of a key whose BEGIN line was cut off (`tail -5 server.key`), in order with the blocks read whole.
|
|
40
|
+
let k = 0;
|
|
41
|
+
for (const m of text.matchAll(PRIVATE_END)) {
|
|
42
|
+
while (k < blocks.length && blocks[k].end < m.index)
|
|
43
|
+
k++;
|
|
44
|
+
if (k < blocks.length && blocks[k].start <= m.index)
|
|
45
|
+
continue;
|
|
46
|
+
const { start, material } = pemTail(text, m.index);
|
|
47
|
+
if (material >= 64)
|
|
48
|
+
blocks.splice(k, 0, { start, end: m.index + m[0].length });
|
|
49
|
+
}
|
|
50
|
+
for (const b of blocks)
|
|
51
|
+
out.push({ ...b, kind: "private key", rule: "structure:private-key", confidence: 1 });
|
|
52
|
+
}
|
|
53
|
+
/** The base64 of an armored block's body: its material lines' characters, past any `Name: value` headers. */
|
|
54
|
+
function armoredBase64(body) {
|
|
55
|
+
return body
|
|
56
|
+
.split(/\r?\n|\\r\\{1,2}n|\\{1,2}n/)
|
|
57
|
+
.filter((line) => !line.includes(":"))
|
|
58
|
+
.join("")
|
|
59
|
+
.replace(/[^A-Za-z0-9+/=]/g, "");
|
|
60
|
+
}
|
|
61
|
+
/** PuTTY private keys: the `Private-Lines:` section. OpenSSL `-text` dumps: `priv:` and prime sections. */
|
|
62
|
+
function otherPrivateKeys(text, out) {
|
|
63
|
+
if (text.includes("Private-Lines:")) {
|
|
64
|
+
for (const m of text.matchAll(/Private-Lines: \d{1,3}\r?\n((?:[A-Za-z0-9+/=]{4,80}(?:\r?\n|$)){1,200})/g)) {
|
|
65
|
+
const start = m.index + m[0].length - m[1].length;
|
|
66
|
+
out.push({
|
|
67
|
+
start,
|
|
68
|
+
end: start + m[1].trimEnd().length,
|
|
69
|
+
kind: "private key",
|
|
70
|
+
rule: "structure:putty-key",
|
|
71
|
+
confidence: 1,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (/priv(?:ate)?|prime|exponent|coefficient/i.test(text)) {
|
|
76
|
+
for (const m of text.matchAll(/^[ \t]{0,20}(?:priv(?:ate)?(?:[- ]?key)?|privateExponent|prime[12]|exponent[12]|coefficient|d)[ \t]{0,20}:[ \t]{0,20}\r?\n((?:[ \t]{1,20}(?:[0-9a-f]{2}:){1,40}[0-9a-f]{0,2}[ \t]{0,20}(?:\r?\n|$)){1,200})/gim)) {
|
|
77
|
+
const start = m.index + m[0].length - m[1].length;
|
|
78
|
+
out.push({
|
|
79
|
+
start,
|
|
80
|
+
end: start + m[1].trimEnd().length,
|
|
81
|
+
kind: "private key",
|
|
82
|
+
rule: "structure:key-dump",
|
|
83
|
+
confidence: 1,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// A JWK private key: `"d": "<base64url>"` in an object with a `kty`, in JSON or a JS or Python literal
|
|
88
|
+
// (`{ kty: "OKP", d: "…" }`): key material by its place, unless it is words (a docs' `"d": "private-exponent"`).
|
|
89
|
+
if (/\bkty["']?[ \t]*:/.test(text)) {
|
|
90
|
+
for (const m of text.matchAll(/(?<![\w$])["']?(?:d|p|q|dp|dq|qi|k)["']?[ \t]*:[ \t]*["']([A-Za-z0-9_-]{16,})["']/dg)) {
|
|
91
|
+
const [start, end] = m.indices[1];
|
|
92
|
+
if (!inJwk(text, start, end) || isWordsValue(m[1]))
|
|
93
|
+
continue;
|
|
94
|
+
out.push({ start, end, kind: "private key", rule: "structure:jwk", confidence: 0.95 });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** A name in docs' brackets: `<password>`, `[YOUR_PASSWORD]`, `{db pass}`; not a password left in them, `<Harbor2291x>`. */
|
|
99
|
+
function isBracketedName(value) {
|
|
100
|
+
const inner = /^[[{(<]+([\w -]{1,40})[\]})>]+$/.exec(value)?.[1];
|
|
101
|
+
return (inner !== undefined &&
|
|
102
|
+
inner
|
|
103
|
+
.split(/[ -]+/)
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.every((w) => isNameLike(w)));
|
|
106
|
+
}
|
|
107
|
+
/** A password the way docs write one: `password`, `[password]`, `<password>`, `thepassword`, `password_test`. */
|
|
108
|
+
function isDocsPassword(value) {
|
|
109
|
+
// (Every word a password word or a docs' word: `thepassword`, `my_secret`, `password_test`; not a passphrase that
|
|
110
|
+
// ends in one, `Lunar-Tiger-Velvet-Pass`, `FluffyBunnyPassword`.)
|
|
111
|
+
if (isBracketedName(value) || /^(?:pass|password|passwd|pwd|secret|changeme|test|foo|bar)$/i.test(value))
|
|
112
|
+
return true;
|
|
113
|
+
const words = value.split(/[_-]|(?<=[a-z])(?=[A-Z])/).map((w) => w.toLowerCase());
|
|
114
|
+
const PW = /^(?:the|my|your|our|some|other|test|real|really|dummy|fake|super)?(?:pass(?:word|wd)?|pwd|secret)$/;
|
|
115
|
+
return (words.some((w) => PW.test(w)) &&
|
|
116
|
+
words.every((w) => PW.test(w) ||
|
|
117
|
+
/^(?:the|my|your|some|other|test|tests|real|really|dummy|fake|super|secret|user|admin|example|sample|db|database|app|api|root)$/.test(w)));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether a URL's password field (or a command line's password) holds a password rather than a variable, format
|
|
121
|
+
* specifier or placeholder. `quoted`: the value sat in single quotes, where a shell expands nothing (`-p'$mIWy…'`
|
|
122
|
+
* is a literal).
|
|
123
|
+
*/
|
|
124
|
+
export function isLiteralPassword(value, quoted = false) {
|
|
125
|
+
if (!value || value.length < 3 || (!quoted && isInert(value)) || isPlaceholder(value) || isUrl(value))
|
|
126
|
+
return false;
|
|
127
|
+
// A hole of an interpolated string, whole: `{quote_plus(password)}`, Swift's `\(password)`, `{Uri.EscapeDataString(pw)}`.
|
|
128
|
+
if (/^\{[^{}\n]{1,200}\}$|^\\\([^()\n]{1,200}\)$/.test(value) && /[A-Za-z_]/.test(value))
|
|
129
|
+
return false;
|
|
130
|
+
// `$DB_PASSWORD`-style names, a letter in each part, not all-caps passwords like `WINTER2024PASS`, `SUMMER_2024`
|
|
131
|
+
// or `WINTERPASS` (a placeholder word in capitals is read below). (Templates with holes, `${DB_PASS}`, `{{ pw }}`,
|
|
132
|
+
// `<password>`, are placeholders above when nothing secret is around their holes.) A `$(…)` even in single quotes:
|
|
133
|
+
// a Makefile's `-p'$(DB_PASSWORD)'` is make's variable, expanded before the shell runs.
|
|
134
|
+
if (/^[A-Z]+(?:_[A-Z0-9]*[A-Z][A-Z0-9]*)+$|^_[A-Z0-9_]+$/.test(value) ||
|
|
135
|
+
(!quoted && /^\$[A-Za-z_]\w*$/.test(value)) ||
|
|
136
|
+
/^\$\(.*\)$/.test(value) ||
|
|
137
|
+
(/^[a-z_][A-Za-z_]{2,}\(.*\)$/.test(value) && readsAsWords(value.slice(0, value.indexOf("(")))))
|
|
138
|
+
return false;
|
|
139
|
+
// Default credentials docs and images use (`postgres`, `guest`, `rabbitmq`), and docs' placeholders (isDocsPassword).
|
|
140
|
+
if (/^(?:user|username|token|credentials|pw|example|postgres|root|admin|guest|mysql|redis|rabbitmq|baz|qux)$/i.test(value))
|
|
141
|
+
return false;
|
|
142
|
+
return !isDocsPassword(value);
|
|
143
|
+
}
|
|
144
|
+
/** A Go MySQL DSN's user and its `:`, after a scheme or not (`mysql://root:`, `root:`). */
|
|
145
|
+
const DSN_USER = new RegExp(String.raw `(?<![\w/:@.-])(?:${URL_SCHEME}:\/\/)?([\w.-]{1,64}):(?!\/\/)`);
|
|
146
|
+
function urlCredentials(text, out) {
|
|
147
|
+
// Go's MySQL DSN: `root:pw@tcp(127.0.0.1:3306)/db`, `user:pw@unix(/tmp/mysql.sock)/db`, after a scheme or not
|
|
148
|
+
// (`mysql://root:@tcp(…)`, whose password is empty). The userinfo reads as a URL's, with `tcp(` as the host.
|
|
149
|
+
// (From the start of the word, or the end of the DSN before it in the word: its userinfo is read whole, however long,
|
|
150
|
+
// and each stretch once.)
|
|
151
|
+
let previous = 0;
|
|
152
|
+
for (const m of text.matchAll(/@(?:tcp|unix)\(/g)) {
|
|
153
|
+
let from = m.index;
|
|
154
|
+
while (from > previous && !/[\s'"`]/.test(text[from - 1]))
|
|
155
|
+
from--;
|
|
156
|
+
previous = m.index + m[0].length;
|
|
157
|
+
const user = DSN_USER.exec(text.slice(from, m.index));
|
|
158
|
+
if (!user)
|
|
159
|
+
continue;
|
|
160
|
+
const at = from + user.index + user[0].length - user[1].length - 1;
|
|
161
|
+
const info = readUserinfo(text.slice(at, m.index + 4), 0, /\(/);
|
|
162
|
+
if (!info || info.passwordStart < 0 || at + info.end !== m.index)
|
|
163
|
+
continue;
|
|
164
|
+
const pw = text.slice(at + info.passwordStart, m.index);
|
|
165
|
+
if (pw.length >= 3 && isLiteralPassword(pw))
|
|
166
|
+
out.push({
|
|
167
|
+
start: at + info.passwordStart,
|
|
168
|
+
end: m.index,
|
|
169
|
+
kind: "password",
|
|
170
|
+
rule: "structure:url-password",
|
|
171
|
+
confidence: 0.98,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (!text.includes("://"))
|
|
175
|
+
return;
|
|
176
|
+
for (const url of urls(text)) {
|
|
177
|
+
const { user, password: pw, host } = url;
|
|
178
|
+
if (!user || !host)
|
|
179
|
+
continue;
|
|
180
|
+
// A token as the whole user part: `https://<token>@github.com`, Azure DevOps PATs, `https://x-access-token:<token>@`,
|
|
181
|
+
// whatever the host (`@${GIT_HOST}`). (With a password, the user is a name unless the password is empty, as curl's
|
|
182
|
+
// `-u <token>:` writes it, or a fixed word: `<token>:x-oauth-basic@`.)
|
|
183
|
+
if (/^[A-Za-z0-9_-]{20,}$/.test(user.value) &&
|
|
184
|
+
(!pw?.value || pw.value === user.value || FIXED_PASSWORD.test(pw.value))) {
|
|
185
|
+
// Sentry DSNs carry a public key by design (`<key>@o123.ingest.sentry.io`).
|
|
186
|
+
const hostname = /^[\w.-]{0,200}/.exec(host.value)[0];
|
|
187
|
+
const sentry = /(?:^|\.)(?:sentry\.io|ingest\.[\w.]+|sentry\.[\w.]+)$/.test(hostname);
|
|
188
|
+
if (!sentry && isRandomToken(user.value, 20) && !isIdentifierShaped(user.value))
|
|
189
|
+
out.push({
|
|
190
|
+
start: user.start,
|
|
191
|
+
end: user.end,
|
|
192
|
+
kind: "token",
|
|
193
|
+
rule: "structure:url-user-token",
|
|
194
|
+
confidence: 0.9,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// A password (urls.ts reads where it ends: it may hold `@` and `/`, the user may be a service account's email), and
|
|
198
|
+
// the host may be empty (`snowflake://USER:pw@/DB?account=…`), the password a docs' `<password>` filled in
|
|
199
|
+
// (`mongodb+srv://admin:<Harbor2291x>@…`).
|
|
200
|
+
if (!pw)
|
|
201
|
+
continue;
|
|
202
|
+
// (A password that is the user name, `tally:tally@`, tells nothing the name doesn't, unless both are a token.)
|
|
203
|
+
if (!isLiteralPassword(pw.value) ||
|
|
204
|
+
FIXED_PASSWORD.test(pw.value) ||
|
|
205
|
+
(pw.value === user.value && !isRandomToken(pw.value)))
|
|
206
|
+
continue;
|
|
207
|
+
out.push({ start: pw.start, end: pw.end, kind: "password", rule: "structure:url-password", confidence: 0.98 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Passwords that forges and registries take beside a token in the user part: `<token>:x-oauth-basic@github.com`. */
|
|
211
|
+
const FIXED_PASSWORD = /^(?:x-oauth-basic|x-access-token|oauth2|x-token-auth|token)$/i;
|
|
212
|
+
/** Authorization schemes whose name says what the credential is, for its placeholder. */
|
|
213
|
+
const AUTH_SCHEMES = /^(?:Bearer|Basic|Token|Bot|Digest|ApiKey|Api-Key|SSWS|OAuth|JWT|DPoP|Splunk|GenieKey|Klaviyo-API-Key|Key|sso-key|Negotiate|NTLM|SharedKey|Client-ID)$/i;
|
|
214
|
+
/**
|
|
215
|
+
* What an Authorization header holds in code, configs and docs rather than a credential, by what it is. (Quotes,
|
|
216
|
+
* brackets and sentence punctuation around the value aside.)
|
|
217
|
+
*/
|
|
218
|
+
const NOT_AN_AUTHORIZATION = [
|
|
219
|
+
// `authorization: "https://…/authorize"` in an OAuth provider's config.
|
|
220
|
+
["an endpoint", (v) => /^https?:\/\//.test(v)],
|
|
221
|
+
// `authorization: config.authHeader`, Swift's `authorization: Mastodon.API.OAuth.Authorization`.
|
|
222
|
+
["a member or type path", (v) => /^[A-Za-z_$][\w$]{0,40}(?:\.[A-Za-z_$][\w$]{0,40}){1,6}$/.test(v) && isNameLike(v)],
|
|
223
|
+
["too short", (v) => v.length < 6],
|
|
224
|
+
["inert", isInert],
|
|
225
|
+
["a test value", isTestValue],
|
|
226
|
+
["identifier-shaped", isIdentifierShaped],
|
|
227
|
+
["code", (v) => /[[\]()<>{}]/.test(v)],
|
|
228
|
+
["a placeholder", isPlaceholder],
|
|
229
|
+
["a constant's name", (v) => /^[A-Z][A-Z_]{2,}$|^[A-Z][A-Z0-9]*_[A-Z0-9_]+$/.test(v)],
|
|
230
|
+
["a template's hole", (v) => /^\$[A-Za-z_]\w*$|\$\{\w[\w:-]*\}|\{\{[^{}]*\}\}|^%[sdv]$|^<[^<>]*>$/.test(v)],
|
|
231
|
+
// `"Bearer " + token`: a name and the `+` that joins it (not base64's own last `+`).
|
|
232
|
+
["a concatenation", (v) => /^[A-Za-z_$][\w$.]*\+$/.test(v) && isNameLike(v.slice(0, -1))],
|
|
233
|
+
// `Authorization: required`, `Bearer realm`.
|
|
234
|
+
["words", isWordsValue],
|
|
235
|
+
];
|
|
236
|
+
function isAuthorizationLiteral(raw) {
|
|
237
|
+
const value = raw.replace(/^[`'"(\[]+|[`'")\].,;:]+$/g, "");
|
|
238
|
+
return !NOT_AN_AUTHORIZATION.some(([, test]) => test(value));
|
|
239
|
+
}
|
|
240
|
+
/** The value after a scheme in an Authorization header or a bare `Bearer`/`Basic` token. */
|
|
241
|
+
function authorization(text, out) {
|
|
242
|
+
if (!/auth|bearer|basic|token |bot |apikey|ssws|Signature=/i.test(text))
|
|
243
|
+
return;
|
|
244
|
+
// `Authorization: Bearer x`, `"authorization": "Token x"`, `-H 'Authorization: Basic x'`, `Proxy-Authorization:`: as RFC 7235
|
|
245
|
+
// has it, a scheme (any token: `Negotiate`, `NTLM`, `Zoho-oauthtoken`, `SharedKey`) and its credential, or a
|
|
246
|
+
// credential alone (`authorization: <token>` in a config).
|
|
247
|
+
// (A path's `::` is no separator: `windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW`.)
|
|
248
|
+
for (const m of text.matchAll(/(?:\b|(?<=\\[nrt]))(?<!::)(?:Proxy-)?Authorization["']?[ \t]{0,10}(?:=|,|:(?!:))[ \t]{0,10}["']?([!#$%&'*+.^_`|~0-9A-Za-z-]{1,40}[ \t]{1,10}(?=[^\s"',;\\]))?([^\s"',;\\]{1,4096})/dgi)) {
|
|
249
|
+
const scheme = m[1]?.trim();
|
|
250
|
+
const end = m.index + m[0].length;
|
|
251
|
+
// OAuth 1 and MAC headers are `k="v"` and `k=v` lists (`k=\"v\"` inside a JSON string); their secret parts are
|
|
252
|
+
// named and caught by name: a `k=` is theirs when a quoted value follows it. Base64 ending in `=` padding is a
|
|
253
|
+
// credential, the quote after it the string's end (`-H 'Authorization: Basic ZGV2OlhrOWYycFE='`).
|
|
254
|
+
if (/^[\w-]+=[^=]/.test(m[2]) ||
|
|
255
|
+
(/^[\w-]+=$/.test(m[2]) && /^\\?["'][^\s"'\\,;)\]}]/.test(text.slice(end, end + 3))) ||
|
|
256
|
+
!isAuthorizationLiteral(m[2]))
|
|
257
|
+
continue;
|
|
258
|
+
if (/^aws4-hmac/i.test(scheme ?? ""))
|
|
259
|
+
continue; // handled by the Signature= rule
|
|
260
|
+
// Closing quotes, brackets and sentence punctuation aren't part of the value.
|
|
261
|
+
let value = m[2].replace(/[`'")\].,;:]+$/, "");
|
|
262
|
+
let start = m.indices[2][0];
|
|
263
|
+
if (/^basic$/i.test(scheme ?? "") && isPlaceholderBasic(value))
|
|
264
|
+
continue;
|
|
265
|
+
// Azure's `SharedKey <account>:<signature>`: the account is a name.
|
|
266
|
+
const account = /^SharedKey(?:Lite)?$/i.test(scheme ?? "") ? /^[\w-]{1,64}:/.exec(value)?.[0] : undefined;
|
|
267
|
+
if (account) {
|
|
268
|
+
start += account.length;
|
|
269
|
+
value = value.slice(account.length);
|
|
270
|
+
}
|
|
271
|
+
out.push({
|
|
272
|
+
start,
|
|
273
|
+
end: start + value.length,
|
|
274
|
+
kind: scheme && AUTH_SCHEMES.test(scheme) ? `${scheme.toLowerCase()} credential` : "authorization",
|
|
275
|
+
rule: "structure:authorization",
|
|
276
|
+
confidence: 0.97,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
for (const m of text.matchAll(/\b(Bearer|Basic|Token|Bot)[ \t]{1,10}([A-Za-z0-9._~+/-]{16,4096}={0,2})/g)) {
|
|
280
|
+
if (!isAuthorizationLiteral(m[2]) || naturalness(m[2]) > 0.8)
|
|
281
|
+
continue;
|
|
282
|
+
if (m[1] === "Basic" && isPlaceholderBasic(m[2]))
|
|
283
|
+
continue;
|
|
284
|
+
const start = m.index + m[0].length - m[2].length;
|
|
285
|
+
out.push({
|
|
286
|
+
start,
|
|
287
|
+
end: start + m[2].length,
|
|
288
|
+
kind: `${m[1].toLowerCase()} token`,
|
|
289
|
+
rule: "structure:bearer",
|
|
290
|
+
confidence: m[1] === "Token" || m[1] === "Bot" ? 0.8 : 0.95,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// AWS SigV4's request signature, in the header and the query. (The credential scope's key ID is an AKIA… format.)
|
|
294
|
+
for (const m of text.matchAll(/(?<![\w-])Signature=([a-f0-9]{64})\b/g)) {
|
|
295
|
+
const start = m.index + m[0].length - m[1].length;
|
|
296
|
+
out.push({ start, end: start + m[1].length, kind: "request signature", rule: "structure:sigv4", confidence: 0.9 });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** SMTP `AUTH PLAIN <base64>` and the base64 lines after `AUTH LOGIN`, as a mail client's debug output shows them. */
|
|
300
|
+
function smtpAuth(text, out) {
|
|
301
|
+
// (Or the password's prompt alone: a log excerpt that starts there, `grep -A1 '334 UGFzc3dvcmQ6' maillog`.)
|
|
302
|
+
if (!/AUTH[ \t]+(?:PLAIN|LOGIN)|334[ \t]+UGFzc3dvcmQ6/i.test(text))
|
|
303
|
+
return;
|
|
304
|
+
for (const m of text.matchAll(/AUTH[ \t]+PLAIN[ \t]+([A-Za-z0-9+/]{8,4096}={0,2})/gi)) {
|
|
305
|
+
const start = m.index + m[0].length - m[1].length;
|
|
306
|
+
out.push({
|
|
307
|
+
start,
|
|
308
|
+
end: start + m[1].length,
|
|
309
|
+
kind: "SMTP credentials",
|
|
310
|
+
rule: "structure:smtp-auth",
|
|
311
|
+
confidence: 0.95,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// The base64 line answering `334 UGFzc3dvcmQ6` ("Password:"), or `334` after a bare `AUTH PLAIN`, with any
|
|
315
|
+
// `C: `, `-> ` or timestamp prefix (swaks, a client's debug log).
|
|
316
|
+
for (const m of text.matchAll(/(?:334[ \t]+UGFzc3dvcmQ6|AUTH[ \t]+PLAIN[ \t]*\r?\n[^\n]{0,40}334[ \t]*)[^\n]{0,400}\r?\n[^\n]{0,40}?(?:->|C:|>)?[ \t]*([A-Za-z0-9+/]{4,4096}={0,2})[ \t]*\r?$/gim)) {
|
|
317
|
+
const start = m.index + m[0].lastIndexOf(m[1]);
|
|
318
|
+
out.push({
|
|
319
|
+
start,
|
|
320
|
+
end: start + m[1].length,
|
|
321
|
+
kind: "SMTP credentials",
|
|
322
|
+
rule: "structure:smtp-auth",
|
|
323
|
+
confidence: 0.95,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** What `vault operator init` prints: unseal and recovery keys, and the initial root token. */
|
|
328
|
+
function vaultInit(text, out) {
|
|
329
|
+
if (!/Unseal Key|Recovery Key|Initial Root Token/.test(text))
|
|
330
|
+
return;
|
|
331
|
+
for (const m of text.matchAll(/^[ \t]*(?:Unseal Key \d+|Recovery Key \d+|Initial Root Token):[ \t]*(\S{8,})[ \t]*\r?$/gm)) {
|
|
332
|
+
const start = m.index + m[0].indexOf(m[1], m[0].indexOf(":"));
|
|
333
|
+
out.push({ start, end: start + m[1].length, kind: "Vault key", rule: "structure:vault-init", confidence: 1 });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** HTTP Basic credentials whose password is a docs placeholder (`user:password`, `username:[password]`). */
|
|
337
|
+
export function isPlaceholderBasic(b64) {
|
|
338
|
+
const decoded = base64Binary(b64);
|
|
339
|
+
if (decoded === undefined)
|
|
340
|
+
return false;
|
|
341
|
+
const colon = decoded.indexOf(":");
|
|
342
|
+
// (`dGVzdA==` is `test`: a test word with no password at all.)
|
|
343
|
+
if (colon < 0)
|
|
344
|
+
return /^(?:test|user|admin|foo|bar|example|demo|username|password)$/i.test(decoded);
|
|
345
|
+
return /^[\x20-\x7e]+$/.test(decoded) && isDocsPassword(decoded.slice(colon + 1));
|
|
346
|
+
}
|
|
347
|
+
/** Signatures and tokens in signed URLs (S3/GCS presigned, Azure SAS, CloudFront) and credentials in query strings and form bodies. */
|
|
348
|
+
function signedUrls(text, out) {
|
|
349
|
+
const NAMES = String.raw `X-Amz-Signature|X-Amz-Security-Token|X-Goog-Signature|X-Goog-Credential|sig|Signature|access_token|id_token|refresh_token|auth_token|token|apikey|api_key|key|client_secret|password|code|appid|app_key|private_token|access_key|sasl_password|db_password|secret|passwd|pwd`;
|
|
350
|
+
if (!new RegExp(String.raw `[?&](?:${NAMES})=`, "i").test(text))
|
|
351
|
+
return;
|
|
352
|
+
for (const { name: n, value } of queryParams(text, NAMES)) {
|
|
353
|
+
const name = n.toLowerCase();
|
|
354
|
+
const v = value.value;
|
|
355
|
+
// (Judged decoded: `%24%7BAPI_KEY%7D` is `${API_KEY}`. `refresh_token=refresh_token`,
|
|
356
|
+
// `client_secret=my_client_secret`: placeholders made of words.)
|
|
357
|
+
const decoded = decodeURIComponentSafe(v);
|
|
358
|
+
if (v.length < 8 || isInert(decoded) || isPlaceholder(decoded) || isWordsValue(v))
|
|
359
|
+
continue;
|
|
360
|
+
// `key=`, `code=`, `token=` and `appid=` are often not secrets (`?key=value`, `?code=200`, a page's
|
|
361
|
+
// `?token=next-page-2`, `?appid=WeatherDashboard`): require randomness.
|
|
362
|
+
if ((name === "key" || name === "code" || name === "token" || name === "appid") && !isRandomToken(v))
|
|
363
|
+
continue;
|
|
364
|
+
out.push({
|
|
365
|
+
start: value.start,
|
|
366
|
+
end: value.end,
|
|
367
|
+
kind: "URL credential",
|
|
368
|
+
rule: "structure:url-param",
|
|
369
|
+
confidence: 0.9,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function decodeURIComponentSafe(v) {
|
|
374
|
+
try {
|
|
375
|
+
return decodeURIComponent(v);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return v;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Whether a cookie holds something worth hiding: not an attribute, a flag, a number, or an analytics or preference cookie. */
|
|
382
|
+
export function isSessionCookie(name, value) {
|
|
383
|
+
if (/^(domain|path|expires|max-age|samesite|secure|httponly|priority|partitioned|version|comment)$/i.test(name))
|
|
384
|
+
return false;
|
|
385
|
+
if (value.length < 8 || isInert(value) || isWordsValue(value))
|
|
386
|
+
return false;
|
|
387
|
+
// Analytics and preference cookies: `_ga=GA1.1.35…`, `_gid`, `_fbp`, consent, locale.
|
|
388
|
+
return !(ANALYTICS_COOKIE.test(name) || /^(?:GA|GS)\d\.\d\.|^fb\.\d\.|^-?[\d.]+$/.test(value));
|
|
389
|
+
}
|
|
390
|
+
/** Google's app passwords, as Google writes them: `Your app password for your device: qwhz kfmt vbnr xplo`. */
|
|
391
|
+
function appPasswords(text, out) {
|
|
392
|
+
if (!/app[ -]?password/i.test(text))
|
|
393
|
+
return;
|
|
394
|
+
for (const m of text.matchAll(/app[ -]?password\b[^\n:]{0,60}:[ \t]*["'`*]{0,2}([a-z]{4} [a-z]{4} [a-z]{4} [a-z]{4})\b/gi)) {
|
|
395
|
+
const start = m.index + m[0].length - m[1].length;
|
|
396
|
+
out.push({ start, end: start + m[1].length, kind: "password", rule: "structure:app-password", confidence: 0.95 });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The session cookies' values in a cookie string (`a=1; session="…"`), placed at `base`. A value is quoted or bare, with
|
|
401
|
+
* no blank, quote, `;` or backslash (none is a cookie's: a log line's escaped `\r\n` ends it).
|
|
402
|
+
*/
|
|
403
|
+
function* cookiePairs(s, base) {
|
|
404
|
+
for (const c of s.matchAll(/(?:^|;[ \t]{0,5})([^=;\s]{1,100})=("?)([^;"'\s\\]{1,4096})\2/dg)) {
|
|
405
|
+
if (isSessionCookie(c[1], c[3]))
|
|
406
|
+
yield { start: base + c.indices[3][0], end: base + c.indices[3][1] };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Cookie values in Cookie and Set-Cookie headers (bare, quoted, or in a headers object), and in Netscape cookie jars. */
|
|
410
|
+
function cookies(text, out) {
|
|
411
|
+
// A cookie written as a Set-Cookie line outside a header: `user_session=…; Path=/; HttpOnly` (its attributes say so).
|
|
412
|
+
if (/;\s*(?:Path|Domain|Expires|Max-Age|HttpOnly|Secure|SameSite)\b/i.test(text)) {
|
|
413
|
+
for (const m of text.matchAll(/(?<![\w.-])([A-Za-z_][\w.-]{1,60})=([^;\s"'|]{6,4096});[ \t]*(?:Path|Domain|Expires|Max-Age|HttpOnly|Secure|SameSite)\b/gi)) {
|
|
414
|
+
// (A session's cookie by its name, as isSessionCookie judges one; not a CSRF token, nor an email.)
|
|
415
|
+
if (!/sess|sid$|token|auth|jwt|remember|login/i.test(m[1]) ||
|
|
416
|
+
/csrf|xsrf/i.test(m[1]) ||
|
|
417
|
+
!isSessionCookie(m[1], m[2]) ||
|
|
418
|
+
/@/.test(m[2]))
|
|
419
|
+
continue;
|
|
420
|
+
const start = m.index + m[1].length + 1;
|
|
421
|
+
out.push({ start, end: start + m[2].length, kind: "cookie", rule: "structure:cookie", confidence: 0.9 });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (/cookie/i.test(text)) {
|
|
425
|
+
// (Real Cookie headers run past 8 KB.)
|
|
426
|
+
// (Also a request-headers table copied from devtools: `cookie` then a tab or a run of spaces.)
|
|
427
|
+
for (const m of text.matchAll(/^((?:[^\n]{0,200}?[ \t'"{(,])?[ \t]{0,10}(?:[<>*] )?(?:set-)?cookie["']?[ \t]{0,3}[:=][ \t]{0,10}["']?|[ \t]*(?:set-)?cookie(?:\t|[ \t]{2,})[ \t]*)([^\n]+)/gim)) {
|
|
428
|
+
for (const c of cookiePairs(m[2], m.index + m[1].length))
|
|
429
|
+
out.push({ ...c, kind: "cookie", rule: "structure:cookie", confidence: 0.9 });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// `curl -b 'a=1; session=…'`, `--cookie "…"`: curl's options (ldapsearch's `-b` is a base DN).
|
|
433
|
+
if (/(?:^|\s)(?:-b|--cookie)[ \t]/.test(text)) {
|
|
434
|
+
for (const m of text.matchAll(/(?:^|\s)(?:-b|--cookie)[ \t]+(['"])([^'"\n]{3,})\1/g)) {
|
|
435
|
+
const program = commandAt(text, m.index + m[0].search(/-/))?.command[0];
|
|
436
|
+
if (!program || programName(program) !== "curl")
|
|
437
|
+
continue;
|
|
438
|
+
for (const c of cookiePairs(m[2], m.index + m[0].length - 1 - m[2].length))
|
|
439
|
+
out.push({ ...c, kind: "cookie", rule: "structure:cookie", confidence: 0.9 });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// `domain TRUE / FALSE expiry name value`, tab-separated (`curl -c`, `wget --save-cookies`, browser exports).
|
|
443
|
+
if (/\t(?:TRUE|FALSE)\t/.test(text)) {
|
|
444
|
+
for (const m of text.matchAll(/^(?:#HttpOnly_)?[^\s#][^\t\n]*\t(?:TRUE|FALSE)\t[^\t\n]*\t(?:TRUE|FALSE)\t\d+\t([^\t\n]+)\t([^\t\n\r]{1,4096})/gm)) {
|
|
445
|
+
if (!isSessionCookie(m[1], m[2]))
|
|
446
|
+
continue;
|
|
447
|
+
const start = m.index + m[0].length - m[2].length;
|
|
448
|
+
out.push({ start, end: start + m[2].length, kind: "cookie", rule: "structure:cookie-jar", confidence: 0.9 });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** `Password=…;` in ADO.NET and ODBC connection strings, `AccountKey=`, `SharedAccessKey=`, JDBC `password=`. */
|
|
453
|
+
function connectionStrings(text, out) {
|
|
454
|
+
if (!/pass|pwd|accountkey|sharedaccess|secret/i.test(text))
|
|
455
|
+
return;
|
|
456
|
+
for (const m of text.matchAll(/(?:^|[;"'\s?&{])((?:User )?Password|Pwd|AccountKey|SharedAccessKey|SharedAccessSignature|SharedSecret|ClientSecret|Client Secret)[ \t]{0,3}=[ \t]{0,3}(?:\{(?!\{)([^}\n]+)\}|'([^'\n]+)'|"([^"\n]+)"|([^;"'\s](?:[^;"'\n]{0,4096}[^;"'\s])?))/dgi)) {
|
|
457
|
+
// (The value inside its braces or quotes, or bare.)
|
|
458
|
+
const g = m[2] !== undefined ? 2 : m[3] !== undefined ? 3 : m[4] !== undefined ? 4 : 5;
|
|
459
|
+
const bare = g === 5;
|
|
460
|
+
let value = m[g];
|
|
461
|
+
const start = m.indices[g][0];
|
|
462
|
+
// In a URL query (`?password=x&y=z`) `&` ends the value; in `Password=Wi&nter;` it's part of it.
|
|
463
|
+
if (/^[?&]/.test(m[0]) && value.includes("&"))
|
|
464
|
+
value = value.slice(0, value.indexOf("&"));
|
|
465
|
+
// The `Key=Value;` shape is a literal context; a `{…}` or quoted value is a literal whatever it holds.
|
|
466
|
+
if (value.length < 4 || isInert(value) || isPlaceholder(value) || (bare && isCode(value, true)))
|
|
467
|
+
continue;
|
|
468
|
+
// Only in something shaped like a connection string: two other `Key=Value` pairs separated by `;` on the line. (A
|
|
469
|
+
// value may hold `=`: base64's padding, `Pwd=Xk9fPq2LmZ=`.)
|
|
470
|
+
const line = text.slice(lineStart(text, m.index), lineEnd(text, m.index + m[0].length));
|
|
471
|
+
if ((line.match(/(?:^|;)[ \t]{0,3}["']?[A-Za-z][\w .-]{0,40}=[^;\n]{0,300}(?=;|["']|$)/g) ?? []).length < 3)
|
|
472
|
+
continue;
|
|
473
|
+
out.push({
|
|
474
|
+
start,
|
|
475
|
+
end: start + value.length,
|
|
476
|
+
kind: "password",
|
|
477
|
+
rule: "structure:connection-string",
|
|
478
|
+
confidence: 0.95,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Base64 that decodes to something public or trivial: a certificate, a public key; a word, a boolean, a number or a
|
|
484
|
+
* URL without credentials, unless the key says password or PIN (`admin-pin: NDgyMTc3MzA=`).
|
|
485
|
+
*/
|
|
486
|
+
function harmlessBase64(v, key = "") {
|
|
487
|
+
const d = /^[A-Za-z0-9+/]+={0,2}$/.test(v) && v.length % 4 === 0 ? base64Binary(v) : undefined;
|
|
488
|
+
if (d === undefined)
|
|
489
|
+
return false;
|
|
490
|
+
if (isPublicPem(d) || /^ssh-(?:rsa|ed25519) /.test(d))
|
|
491
|
+
return true;
|
|
492
|
+
const t = d.trim();
|
|
493
|
+
return (!namesPasswordOrPin(key) &&
|
|
494
|
+
((d.length < 40 && (isWordsValue(t) || /^(?:true|false|\d+)$/.test(t) || HOST_NAME.test(t))) || isPlainUrl(t, key)));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* A URL that carries no credential: no password, no path segment or parameter value that a generator made, and no
|
|
498
|
+
* key that says the URL is a secret itself (`webhook-secret: https://hooks.zapier.com/hooks/catch/23808906/7c06jvh/`).
|
|
499
|
+
*/
|
|
500
|
+
function isPlainUrl(t, key) {
|
|
501
|
+
if (isCredentialName(key))
|
|
502
|
+
return false;
|
|
503
|
+
const [url] = urls(t);
|
|
504
|
+
if (!url || url.start !== 0 || url.end !== t.length || url.password)
|
|
505
|
+
return false;
|
|
506
|
+
return (url.segments.every((s) => !isRandomToken(s.value)) &&
|
|
507
|
+
[...queryParams(t)].every((p) => !isRandomToken(p.value.value)));
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Every value under `data:`/`stringData:` of a Kubernetes Secret, whatever its key is called, in YAML or in JSON. The
|
|
511
|
+
* Secret is the mapping the `data` key is in, by its own `kind` (`kubectl get cm,secret -o yaml` lists ConfigMaps too).
|
|
512
|
+
*/
|
|
513
|
+
function kubeSecrets(text, out) {
|
|
514
|
+
for (const { key, value } of secretDataEntries(text)) {
|
|
515
|
+
if (isKubeSecretKey(key) && isKubeSecretValue(value.value, key))
|
|
516
|
+
out.push({
|
|
517
|
+
start: value.start,
|
|
518
|
+
end: value.end,
|
|
519
|
+
kind: "Kubernetes secret",
|
|
520
|
+
rule: "structure:kube-secret",
|
|
521
|
+
confidence: 0.95,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* A manifest's `data:` or `stringData:` mapping still open at the end of a text (no line has come back to its column),
|
|
527
|
+
* by the lines that open it: its own and its `kind` (a Secret's, or a ConfigMap's), at its column. `pending`: its
|
|
528
|
+
* `kind` hasn't come yet (kubectl prints `data:` first), so what follows can't be judged until it does.
|
|
529
|
+
*/
|
|
530
|
+
export function openManifestData(text) {
|
|
531
|
+
if (!/^[ \t-]*(?:data|stringData):[ \t]*$/m.test(text))
|
|
532
|
+
return undefined;
|
|
533
|
+
const lines = yamlLines(text);
|
|
534
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
535
|
+
const data = lines[i];
|
|
536
|
+
if (data.blank)
|
|
537
|
+
continue;
|
|
538
|
+
if ((data.key !== "data" && data.key !== "stringData") || data.value || data.block) {
|
|
539
|
+
// (A line inside the mapping: keep looking up for its `data:`.)
|
|
540
|
+
if (data.indent > 0)
|
|
541
|
+
continue;
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
if (lines.slice(i + 1).some((l) => !l.blank && l.indent <= data.indent))
|
|
545
|
+
return undefined;
|
|
546
|
+
const kind = siblingValue(lines, i, "kind");
|
|
547
|
+
const line = text.slice(data.start, data.end);
|
|
548
|
+
return {
|
|
549
|
+
lines: [`${kind === undefined ? "" : `${" ".repeat(data.indent)}kind: ${kind}\n`}${line}\n`],
|
|
550
|
+
pending: kind === undefined,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
/** The entries of every Kubernetes Secret's `data` and `stringData`: each key, and its value where it is. */
|
|
556
|
+
function* secretDataEntries(text) {
|
|
557
|
+
// (Gated on the word alone: the readers decide on the parsed `kind`, quoted or not, `kind: "Secret"`.)
|
|
558
|
+
if (!/\bSecret\b/.test(text))
|
|
559
|
+
return;
|
|
560
|
+
const lines = yamlLines(text);
|
|
561
|
+
for (let i = 0; i < lines.length; i++) {
|
|
562
|
+
const data = lines[i];
|
|
563
|
+
if ((data.key !== "data" && data.key !== "stringData") ||
|
|
564
|
+
data.value ||
|
|
565
|
+
data.block ||
|
|
566
|
+
siblingValue(lines, i, "kind") !== "Secret")
|
|
567
|
+
continue;
|
|
568
|
+
// The mapping's own entries, at its first entry's indent: a line deeper is a block scalar's or a flow
|
|
569
|
+
// collection's (the config file under `config.yaml: |`), and a line without a key (`- item`) is none.
|
|
570
|
+
let indent = -1;
|
|
571
|
+
for (let j = i + 1; j < lines.length && (lines[j].blank || lines[j].indent > data.indent); j++) {
|
|
572
|
+
const { key, value, block, blank } = lines[j];
|
|
573
|
+
if (blank)
|
|
574
|
+
continue;
|
|
575
|
+
if (indent < 0)
|
|
576
|
+
indent = lines[j].indent;
|
|
577
|
+
if (lines[j].indent !== indent || !key)
|
|
578
|
+
continue;
|
|
579
|
+
// A `key: |` block scalar: one line is a value like any other; several (a config file) are scanned as text
|
|
580
|
+
// by the other detectors.
|
|
581
|
+
const body = block ? blockLines(text, lines[j].start) : [];
|
|
582
|
+
if (body.length === 1)
|
|
583
|
+
yield { key, value: body[0] };
|
|
584
|
+
else if (value)
|
|
585
|
+
yield { key, value };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// JSON: `"data": { "k": "v", … }` in an object whose `"kind"` is `"Secret"` (`kubectl -o json` puts `data` before `kind`).
|
|
589
|
+
if (!text.includes('"kind"'))
|
|
590
|
+
return;
|
|
591
|
+
const entries = [];
|
|
592
|
+
const walk = (v) => {
|
|
593
|
+
if (v.kind === "array")
|
|
594
|
+
v.items.forEach(walk);
|
|
595
|
+
if (v.kind !== "object")
|
|
596
|
+
return;
|
|
597
|
+
const secret = v.entries.some(([k, x]) => k === "kind" && x.kind === "string" && x.value === "Secret");
|
|
598
|
+
for (const [k, x] of v.entries) {
|
|
599
|
+
if (secret && (k === "data" || k === "stringData") && x.kind === "object") {
|
|
600
|
+
for (const [key, value] of x.entries)
|
|
601
|
+
if (value.kind === "string")
|
|
602
|
+
entries.push({ key, value });
|
|
603
|
+
}
|
|
604
|
+
else
|
|
605
|
+
walk(x);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
for (const doc of jsonDocuments(text))
|
|
609
|
+
walk(doc);
|
|
610
|
+
yield* entries;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Every value of a Secret is sensitive, except ones whose key says they locate something or are public. (A URL is
|
|
614
|
+
* judged by its value and its key, isPlainUrl's: one in a Secret is often there because it carries a token.)
|
|
615
|
+
*/
|
|
616
|
+
function isKubeSecretKey(key) {
|
|
617
|
+
// Certificates and public keys stored next to the key: `tls.crt`, `ca.crt`, `ca-bundle.pem`.
|
|
618
|
+
if (/\.(?:crt|cer|pub)$|^(?:ca|ca[-_.]bundle(?:\.pem)?|tls\.crt|cert|certificate)$/i.test(key))
|
|
619
|
+
return false;
|
|
620
|
+
// (A file's name is judged by its stem: what the file holds is mounted as the key, `password.txt`; `checksums.txt` is
|
|
621
|
+
// still a hash's.)
|
|
622
|
+
const stem = key.replace(FILE_EXTENSION, "");
|
|
623
|
+
const cls = nameClass(stem);
|
|
624
|
+
return !(/^(?:user(?:name)?|login|host(?:name)?|port|database|db|dbname|region|namespace|email|bucket|cluster|server|address|account)$/i.test(stem) ||
|
|
625
|
+
cls === "public" ||
|
|
626
|
+
cls === "hash");
|
|
627
|
+
}
|
|
628
|
+
function isKubeSecretValue(value, key = "") {
|
|
629
|
+
// (A flow collection written over several lines, `config.json: {`, leaves its key's line the bracket alone.)
|
|
630
|
+
return (!isInert(value) &&
|
|
631
|
+
!/^[{[]$/.test(value) &&
|
|
632
|
+
!isPlainUrl(value, key) &&
|
|
633
|
+
!harmlessBase64(value, key) &&
|
|
634
|
+
!isHarmlessText(value, key));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* A host as a setting gives one (`mycluster.example.com`, `db.internal:5432`): lowercase labels, then a short TLD or an
|
|
638
|
+
* internal zone's name; not a dotted token (`AbCdEf123456.zzSecret`, `qwerty.secretkeyvalue`).
|
|
639
|
+
*/
|
|
640
|
+
// (Its top-level domain a country's or a common generic one, or a cluster's: `hunter2.mypass` is no host.)
|
|
641
|
+
const HOST_NAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2}|com|net|org|edu|gov|mil|int|info|biz|dev|app|cloud|tech|site|online|xyz|internal|local|localdomain|lan|corp|home|svc|cluster|example)(?::\d{1,5})?$/;
|
|
642
|
+
/**
|
|
643
|
+
* A Secret's field written as text that holds nothing secret, under a key that names no credential: a word under a
|
|
644
|
+
* setting's key, a boolean or a number (a JSON line's, with its comma), a host, an API version, a Helm template's expression (`type: git`,
|
|
645
|
+
* `"insecure": false,`, `server: mycluster.example.com`, `apiVersion: client.authentication.k8s.io/v1beta1`).
|
|
646
|
+
*/
|
|
647
|
+
function isHarmlessText(value, key) {
|
|
648
|
+
const t = value.trim().replace(/^"|",?$|,$/g, "");
|
|
649
|
+
if (isCredentialName(key))
|
|
650
|
+
return false;
|
|
651
|
+
// (A word only under a setting's key, `type: git`: under `value` or `key1` a word is as likely a weak password.)
|
|
652
|
+
return (/^\{\{.*\}\}$/.test(t) ||
|
|
653
|
+
/^(?:true|false|null|-?\d+(?:\.\d+)?)$/.test(t) ||
|
|
654
|
+
HOST_NAME.test(t) ||
|
|
655
|
+
/^[a-z0-9.-]+\/v\d\w*$/.test(t) ||
|
|
656
|
+
(t.length < 40 && isWordsValue(t) && isSettingName(key)));
|
|
657
|
+
}
|
|
658
|
+
/** PowerShell: `ConvertTo-SecureString "p@ss" -AsPlainText`, the plain password itself. */
|
|
659
|
+
function powershellSecureStrings(text, out) {
|
|
660
|
+
if (!/ConvertTo-SecureString/i.test(text))
|
|
661
|
+
return;
|
|
662
|
+
// `ConvertTo-SecureString "pw" -AsPlainText`, flags in any order before `-String "pw"`, and `"pw" | ConvertTo-SecureString -AsPlainText`.
|
|
663
|
+
// (A PowerShell string: double-quoted, escaped by a backtick or a doubled quote; single-quoted, by a doubled quote.)
|
|
664
|
+
const literal = String.raw `(?:"((?:""|\x60[^\n]|[^"\x60\n])+)"|'((?:''|[^'\n])+)')`;
|
|
665
|
+
const forms = [
|
|
666
|
+
new RegExp(String.raw `ConvertTo-SecureString[ \t]+(?:-(?:AsPlainText|Force)[ \t]+){0,2}(?:-String[ \t]+)?${literal}(?=[^\n]{0,80}-AsPlainText|(?<=AsPlainText[^\n]{0,80}))`, "gid"),
|
|
667
|
+
new RegExp(String.raw `${literal}[ \t]*\|[ \t]*ConvertTo-SecureString\b(?=[^\n]{0,80}-AsPlainText)`, "gid"),
|
|
668
|
+
];
|
|
669
|
+
for (const m of forms.flatMap((re) => [...text.matchAll(re)])) {
|
|
670
|
+
const group = m[1] === undefined ? 2 : 1;
|
|
671
|
+
if (isInert(m[group]))
|
|
672
|
+
continue;
|
|
673
|
+
const [start, end] = m.indices[group];
|
|
674
|
+
out.push({ start, end, kind: "password", rule: "structure:securestring", confidence: 0.95 });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The passwords one command's words give its tool (commands.ts passwordOptions): `docker login -p pw`, `redis-cli -apw`,
|
|
679
|
+
* `mysql --password=pw`, HTTPie's `-a user:pw` (or the whole `-a` with `-A bearer`), smbclient's `-U user%pw`,
|
|
680
|
+
* `rabbitmqctl add_user name pw`, `htpasswd -b file user pw`. A wrapper's own options are read with its spec, and
|
|
681
|
+
* the command it runs with that command's.
|
|
682
|
+
*/
|
|
683
|
+
function commandPasswords(words, add) {
|
|
684
|
+
// (A wrapper's command is read in turn, in a loop: `sshpass -p x sshpass -p y …` nests as deep as it's written.)
|
|
685
|
+
for (let next = words; next;)
|
|
686
|
+
next = toolPasswords(next, add);
|
|
687
|
+
}
|
|
688
|
+
/** The passwords one command gives its tool (see commandPasswords); the words of the command it wraps, if it wraps one. */
|
|
689
|
+
function toolPasswords(words, add) {
|
|
690
|
+
// The command starts at its first word that names a tool or a wrapper: past a prompt, a timestamp or a CI log's
|
|
691
|
+
// prefix (`$ `, `2025-…Z ##[group]Run`, `xcrun`); a path names one only as the first word (`find /var/lib/mysql`
|
|
692
|
+
// runs find), or after a process listing's columns (` 73104 /usr/bin/redis-cli -a …`). Then past wrappers and
|
|
693
|
+
// assignments (shell.ts simpleCommand).
|
|
694
|
+
const names = (w, i) => (i === 0 || !/[/\\]/.test(w.text) || isProcessColumns(words.slice(0, i))) &&
|
|
695
|
+
(isWrapper(w.text) ||
|
|
696
|
+
/^(?:rabbitmqctl|htpasswd|https?|httpie|steamcmd|vault|lftp|pulumi|openssl|mkpasswd)$/.test(programName(w)) ||
|
|
697
|
+
USER_PASSWORD.test(programName(w)) ||
|
|
698
|
+
!!passwordOptions(programName(w)));
|
|
699
|
+
const k = words.findIndex(names);
|
|
700
|
+
if (k < 0)
|
|
701
|
+
return;
|
|
702
|
+
// (A wrapper alone, `sudo`, runs nothing: its name and its words are empty.)
|
|
703
|
+
const command = simpleCommand(words.slice(k));
|
|
704
|
+
const name = programName(command[0]);
|
|
705
|
+
const rest = command.slice(1);
|
|
706
|
+
// (`value` is the end of the word's text: `pw` of `--password='pw'`.)
|
|
707
|
+
const addWord = (w, value) => addTail(add, w, value, w.literalEnd);
|
|
708
|
+
// `steamcmd +login <user> <password>`.
|
|
709
|
+
if (name === "steamcmd") {
|
|
710
|
+
const at = rest.findIndex((w) => w.text === "+login");
|
|
711
|
+
if (at >= 0 && at + 2 < rest.length && !rest[at + 2].text.startsWith("+"))
|
|
712
|
+
addWord(rest[at + 2], rest[at + 2].text);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// `vault login <token>`, `vault login -method=userpass username=u password=pw`.
|
|
716
|
+
if (name === "vault" && rest[0]?.text === "login") {
|
|
717
|
+
for (const w of rest.slice(1)) {
|
|
718
|
+
const pair = /^(?:token|password)=(.+)$/.exec(w.text);
|
|
719
|
+
if (pair)
|
|
720
|
+
addWord(w, pair[1]);
|
|
721
|
+
else if (!w.text.startsWith("-") && !w.text.includes("="))
|
|
722
|
+
addWord(w, w.text);
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
// lftp: `-u user,password`.
|
|
727
|
+
if (name === "lftp") {
|
|
728
|
+
const at = rest.findIndex((w) => w.text === "-u" || w.text === "--user");
|
|
729
|
+
const pair = rest[at + 1];
|
|
730
|
+
if (at >= 0 && pair?.text.includes(","))
|
|
731
|
+
addWord(pair, pair.text.slice(pair.text.indexOf(",") + 1));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
// `pulumi config set --secret <key> <value>`, the value the flag makes a secret (without one, it's read at a prompt
|
|
735
|
+
// or from stdin), past `--path` and options with a value: `-s dev`, `--config-file f`.
|
|
736
|
+
if (name === "pulumi") {
|
|
737
|
+
const set = rest.findIndex((w, i) => w.text === "set" && rest[i - 1]?.text === "config");
|
|
738
|
+
if (set < 0 || !rest.some((w) => w.text === "--secret"))
|
|
739
|
+
return;
|
|
740
|
+
const operands = rest
|
|
741
|
+
.slice(set + 1)
|
|
742
|
+
.filter((w, i, after) => !w.text.startsWith("-") && !/^(?:-s|--stack|--config-file|--type)$/.test(after[i - 1]?.text ?? ""));
|
|
743
|
+
if (operands[1])
|
|
744
|
+
addWord(operands[1], operands[1].text);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// `openssl passwd -6 <password>…`, which hashes the passwords it's given (`-salt` and `-in` take a value).
|
|
748
|
+
if (name === "openssl") {
|
|
749
|
+
if (rest[0]?.text !== "passwd")
|
|
750
|
+
return;
|
|
751
|
+
for (const [i, w] of rest.entries())
|
|
752
|
+
if (i > 0 && !w.text.startsWith("-") && !/^-(?:salt|in)$/.test(rest[i - 1].text))
|
|
753
|
+
addWord(w, w.text);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// `mkpasswd -m sha-512 <password> [<salt>]`, which hashes the password it's given (`-m`, `-S`, `-R`, `-P` take a value).
|
|
757
|
+
if (name === "mkpasswd") {
|
|
758
|
+
const pw = rest.find((w, i) => !w.text.startsWith("-") && !/^(?:-[mSRP]|--(?:method|salt|rounds|password-fd))$/.test(rest[i - 1]?.text ?? ""));
|
|
759
|
+
if (pw)
|
|
760
|
+
addWord(pw, pw.text);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (name === "rabbitmqctl") {
|
|
764
|
+
const at = rest.findIndex((w) => w.text === "add_user" || w.text === "change_password");
|
|
765
|
+
if (at >= 0 && at + 2 < rest.length)
|
|
766
|
+
addWord(rest[at + 2], rest[at + 2].text);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (name === "htpasswd") {
|
|
770
|
+
// `htpasswd -b[c] <file> <user> <password>`; with -n there is no file. (`-C <cost>` and `-r <rounds>` take a value.)
|
|
771
|
+
const flags = rest
|
|
772
|
+
.filter((w) => /^-[a-zA-Z]+$/.test(w.text))
|
|
773
|
+
.map((w) => w.text.slice(1))
|
|
774
|
+
.join("");
|
|
775
|
+
const positional = rest.filter((w, i) => !/^-[a-zA-Z]+$/.test(w.text) && !/^-[a-zA-Z]*[Cr]$/.test(rest[i - 1]?.text ?? ""));
|
|
776
|
+
const pw = positional[flags.includes("n") ? 1 : 2];
|
|
777
|
+
if (flags.includes("b") && pw)
|
|
778
|
+
addWord(pw, pw.text);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (/^(?:http|https|httpie)$/.test(name)) {
|
|
782
|
+
// HTTPie: `-a user:password` (the part after the colon, unless it's the docs' `username:password`; `-a user`
|
|
783
|
+
// alone prompts), or with `-A bearer` the whole `-a` argument.
|
|
784
|
+
const bearer = rest.some((w, i) => /^(?:-A|--auth-type)$/.test(w.text) && /^bearer$/i.test(rest[i + 1]?.text ?? "")) ||
|
|
785
|
+
rest.some((w) => /^--auth-type=bearer$/i.test(w.text));
|
|
786
|
+
for (let i = 0; i < rest.length; i++) {
|
|
787
|
+
const eq = /^--auth=/.exec(rest[i].text);
|
|
788
|
+
const word = eq ? rest[i] : /^(?:-a|--auth)$/.test(rest[i].text) ? rest[i + 1] : undefined;
|
|
789
|
+
const arg = word && (eq ? word.text.slice(eq[0].length) : word.text);
|
|
790
|
+
if (!word || !arg)
|
|
791
|
+
continue;
|
|
792
|
+
if (bearer)
|
|
793
|
+
addWord(word, arg);
|
|
794
|
+
else if (arg.includes(":") && isLiteralPassword(arg.slice(arg.indexOf(":") + 1)))
|
|
795
|
+
addWord(word, arg.slice(arg.indexOf(":") + 1));
|
|
796
|
+
}
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// `gh secret set <NAME> --body <value>`: a secret, unless its name says it's an ID (`AZURE_TENANT_ID`, `CLOUDFLARE_ACCOUNT_ID`).
|
|
800
|
+
if (name === "gh" && rest[0]?.text === "secret" && rest[1]?.text === "set") {
|
|
801
|
+
const secret = rest.slice(2).find((w) => !w.text.startsWith("-"))?.text ?? "";
|
|
802
|
+
if (["identifier", "public"].includes(nameClass(secret)))
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// curl and its kin: `-u user:password`, `--user=user:password`, `--proxy-user …` (the part after the first colon).
|
|
806
|
+
if (USER_PASSWORD.test(name)) {
|
|
807
|
+
for (let i = 0; i < rest.length; i++) {
|
|
808
|
+
const eq = /^--(?:proxy-)?user=/.exec(rest[i].text);
|
|
809
|
+
const word = eq ? rest[i] : /^(?:-u|-U|--user|--proxy-user)$/.test(rest[i].text) ? rest[i + 1] : undefined;
|
|
810
|
+
const pair = word && (eq ? word.text.slice(eq[0].length) : word.text);
|
|
811
|
+
if (pair?.includes(":"))
|
|
812
|
+
addWord(word, pair.slice(pair.indexOf(":") + 1));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const spec = passwordOptions(name);
|
|
816
|
+
if (!spec || (spec.subcommand && !rest.slice(0, 3).some((w) => w.text === spec.subcommand)))
|
|
817
|
+
return;
|
|
818
|
+
for (let i = 0; i < rest.length; i++) {
|
|
819
|
+
const w = rest[i].text;
|
|
820
|
+
// A wrapper's command starts at its first word that isn't an option or an option's value.
|
|
821
|
+
if (spec.wraps && !w.startsWith("-") && !spec.flags.has(rest[i - 1]?.text ?? ""))
|
|
822
|
+
return rest.slice(i);
|
|
823
|
+
const eq = w.indexOf("=");
|
|
824
|
+
if (eq > 0 && spec.flags.has(w.slice(0, eq)))
|
|
825
|
+
addWord(rest[i], w.slice(eq + 1));
|
|
826
|
+
else if (spec.flags.has(w) &&
|
|
827
|
+
i + 1 < rest.length &&
|
|
828
|
+
(spec.next === "always" || (spec.next === "unless an option" && !rest[i + 1].raw.startsWith("-"))))
|
|
829
|
+
addWord(rest[i + 1], rest[i + 1].text);
|
|
830
|
+
// A one-letter option with its value attached: `sshpass -pPW`, `redis-cli -aPW`, `mysql -pPW`.
|
|
831
|
+
else if (spec.attached &&
|
|
832
|
+
/^-[A-Za-z]./.test(w) &&
|
|
833
|
+
(spec.flags.has(w.slice(0, 2)) || (w[1] === "p" && /^mysql|^mariadb/.test(name))))
|
|
834
|
+
addWord(rest[i], w.slice(2));
|
|
835
|
+
// smbclient, net, rpcclient: `-U user%password`, and attached, `-Uuser%password`, `--user=user%password`.
|
|
836
|
+
else if (w === "-U" && /^(?:smbclient|net|rpcclient)$/.test(name) && rest[i + 1]?.text.includes("%"))
|
|
837
|
+
addWord(rest[i + 1], rest[i + 1].text.slice(rest[i + 1].text.indexOf("%") + 1));
|
|
838
|
+
else if (/^(?:-U|--user=)[^%]*%/.test(w) && /^(?:smbclient|net|rpcclient)$/.test(name))
|
|
839
|
+
addWord(rest[i], w.slice(w.indexOf("%") + 1));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* A process listing's columns before its command (`ps -eo pid,args`, `ps aux`, `pgrep -a`): a PID, and maybe a user
|
|
844
|
+
* first, then CPU and memory figures, sizes, a terminal, a state, a start time and a CPU time (`root 73104 0.0 0.1 …
|
|
845
|
+
* pts/0 Ss+ Oct10 0:00`).
|
|
846
|
+
*/
|
|
847
|
+
function isProcessColumns(words) {
|
|
848
|
+
return (words.some((w) => /^\d+$/.test(w.text)) &&
|
|
849
|
+
words.every((w, j) => /^(?:\d[\d.:,]*[KMG]?|\?|(?:pts|tty)\/?\w*|[RSDTtZIWXP][<NLsl+]*|[A-Z][a-z]{2}\d{1,2})$/.test(w.text) ||
|
|
850
|
+
(j === 0 && /^[a-z_][\w.-]*\+?$/.test(w.text))));
|
|
851
|
+
}
|
|
852
|
+
/** Adds `value`, the end of a shell word's text, as a command-line password, at the span the word wrote it in. */
|
|
853
|
+
function addTail(add, word, value, quoted, rule = "structure:cli-password") {
|
|
854
|
+
const { start, end } = wordSpan(word, word.text.length - value.length);
|
|
855
|
+
add(start, value, rule, quoted, end);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* What a command line feeds a command on stdin: `echo`'s or `printf`'s one operand piped into it (printf's after its
|
|
859
|
+
* format: `printf '%s' pw | …`), or a here-string (`… <<< "pw"`). Each value's word, and the command that reads it.
|
|
860
|
+
*/
|
|
861
|
+
function stdinFeeds(text) {
|
|
862
|
+
const out = [];
|
|
863
|
+
if (!/\||<<</.test(text))
|
|
864
|
+
return out;
|
|
865
|
+
for (const line of text.matchAll(/^[^\n]*$/gm)) {
|
|
866
|
+
// (A here-string, or an echo or printf before a pipe: found with one indexOf, not a pattern that backtracks along the line.)
|
|
867
|
+
const echo = /\b(?:echo|printf)\b/.exec(line[0]);
|
|
868
|
+
if (!line[0].includes("<<<") && !(echo && line[0].includes("|", echo.index)))
|
|
869
|
+
continue;
|
|
870
|
+
const commands = commandsRun(text, { at: line.index, oneLine: true });
|
|
871
|
+
commands.forEach((c, k) => {
|
|
872
|
+
const herestring = c.redirects.find((r) => r.op === "<<<")?.target;
|
|
873
|
+
if (herestring)
|
|
874
|
+
out.push({ value: herestring, reader: c });
|
|
875
|
+
const words = simpleCommand(c.words);
|
|
876
|
+
const program = programName(words[0]);
|
|
877
|
+
if (c.then !== "|" || !/^(?:echo|printf)$/.test(program) || !commands[k + 1])
|
|
878
|
+
return;
|
|
879
|
+
// (A format alone with `%s` in it prints no value. Nor its options: echo's `-n`, printf's `--` before its format.)
|
|
880
|
+
const args = words
|
|
881
|
+
.slice(1)
|
|
882
|
+
.filter((w) => !/^-[neE]+$/.test(w.text) && !(program === "printf" && w.text === "--"));
|
|
883
|
+
const operands = program === "printf" ? (args.length > 1 ? args.slice(1) : args.filter((w) => !/%[sd]/.test(w.text))) : args;
|
|
884
|
+
if (operands.length === 1)
|
|
885
|
+
out.push({ value: operands[0], reader: commands[k + 1] });
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
/** Commands that read a password or a token from stdin, by their words (and `sudo`'s options before them). */
|
|
891
|
+
const STDIN_SECRET = /^(?:sudo(?:\s+-[A-Za-z]*S[A-Za-z]*)(?:\s|$)|(?:sudo(?:\s+-\S+)*\s+)?(?:(?:docker|podman|nerdctl|helm|oras|crane|skopeo|buildah)\s+(?:registry\s+)?login\b.*--password-stdin|gh\s+auth\s+login\b.*--with-token|gh\s+secret\s+set\b|(?:podman|docker)\s+secret\s+create\b|(?:npx\s+)?wrangler\s+secret\s+put\b|vercel\s+env\s+add\b|kubectl\s+create\s+secret\b|fly(?:ctl)?\s+secrets\s+(?:set|import)\b|netlify\s+env:set\b|pulumi\s+config\s+set\b.*--secret\b|vault\s+(?:login|kv\s+put|write)\b|chpasswd\b|passwd\b|openssl\b.*-pass(?:in|out)?\s+stdin))/;
|
|
892
|
+
/**
|
|
893
|
+
* Windows commands' passwords: switches (`cmdkey /pass:pw`, `wmic /password:pw`, `winrs -p:pw`, `schtasks /rp pw`,
|
|
894
|
+
* `psexec -p pw`, `osql -P pw`) and positional ones (`net use Z: \\srv\share pw /user:CORP\alice`, `net user svc pw /add`).
|
|
895
|
+
* (`*` asks for the password at a prompt: passwordAdder adds no run of `*`.)
|
|
896
|
+
*/
|
|
897
|
+
function windowsPasswords(text, add) {
|
|
898
|
+
if (!/\b(?:cmdkey|schtasks|psexec|net|wmic|winrs|osql|sqlcmd|runas)\b|\/(?:pass|password|pwd):/i.test(text))
|
|
899
|
+
return;
|
|
900
|
+
for (const m of text.matchAll(/^[^\n]*$/gm)) {
|
|
901
|
+
const ws = shellWords(m[0], m.index, { backslash: false });
|
|
902
|
+
const command = ws.findIndex((w) => /^(?:cmdkey|schtasks|psexec(?:64)?|net|wmic|winrs|osql|sqlcmd)(?:\.exe)?$/i.test(w.text));
|
|
903
|
+
for (let k = 0; k < ws.length; k++) {
|
|
904
|
+
const w = ws[k];
|
|
905
|
+
// `/pass:pw`, `/password:pw`, `/pwd:pw` on any command; `-p:pw` (winrs).
|
|
906
|
+
const glued = /^(?:\/(?:pass|password|pwd)|-p):(.+)$/i.exec(w.text);
|
|
907
|
+
if (glued && (w.text[0] === "/" || /^winrs/i.test(ws[command]?.text ?? "")))
|
|
908
|
+
addTail(add, w, glued[1], w.quoted);
|
|
909
|
+
}
|
|
910
|
+
if (command < 0)
|
|
911
|
+
continue;
|
|
912
|
+
const tool = ws[command].text.toLowerCase().replace(/\.exe$/, "");
|
|
913
|
+
const rest = ws.slice(command + 1);
|
|
914
|
+
// A switch followed by its value: `/rp pw` (schtasks), `-p pw` (psexec), `-P pw` (osql, sqlcmd).
|
|
915
|
+
const flag = tool === "schtasks"
|
|
916
|
+
? /^\/rp$/i
|
|
917
|
+
: /^psexec/.test(tool)
|
|
918
|
+
? /^-p$/
|
|
919
|
+
: /^(?:osql|sqlcmd)$/.test(tool)
|
|
920
|
+
? /^-P$/
|
|
921
|
+
: undefined;
|
|
922
|
+
for (let k = 0; flag && k + 1 < rest.length; k++)
|
|
923
|
+
if (flag.test(rest[k].text))
|
|
924
|
+
addTail(add, rest[k + 1], rest[k + 1].text, rest[k + 1].quoted);
|
|
925
|
+
// `net use [drive] \\share [password] /user:…` and `net user name [password] /add`: the positional word after the share or the name.
|
|
926
|
+
if (tool === "net" && /^(?:use|user)$/i.test(rest[0]?.text ?? "")) {
|
|
927
|
+
const positional = rest.slice(1).filter((w) => !/^\//.test(w.text));
|
|
928
|
+
const skip = rest[0].text.toLowerCase() === "use" ? positional.findIndex((w) => /^\\\\/.test(w.text)) + 1 : 1;
|
|
929
|
+
const pw = skip > 0 ? positional[skip] : undefined;
|
|
930
|
+
if (pw && !/^[A-Za-z]:$/.test(pw.text))
|
|
931
|
+
addTail(add, pw, pw.text, pw.quoted);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Network devices' configuration lines that end in a key: Cisco IOS (`enable secret pw`, `username u privilege 15 secret 0 pw`,
|
|
937
|
+
* `crypto isakmp key pw address 0.0.0.0`, `tacacs-server key pw`, `ip ospf authentication-key pw`,
|
|
938
|
+
* `ip ospf message-digest-key 1 md5 pw`, `snmp-server community s RW`) and Junos (`… authentication-key "pw"`,
|
|
939
|
+
* `plain-text-password-value pw`). The keyword may carry an encryption type: 0 and 7 are readable, 5, 8 and 9 are
|
|
940
|
+
* hashes, kept as hashes are.
|
|
941
|
+
*/
|
|
942
|
+
function networkConfigKeys(text, add) {
|
|
943
|
+
if (!/\b(?:secret|password|key|key-string|community|plain-text-password-value|psk)\b/.test(text))
|
|
944
|
+
return;
|
|
945
|
+
const IOS = /^[ \t]*(?:set[ \t]+)?(?:(?:enable|username|crypto|tacacs-server|radius-server|ip|ipv6|ntp|key-string|neighbor|ppp|standby|vrrp|router|aaa|snmp-server|system|protocols|security|interfaces)\b[^\n]*?[ \t](?:secret|password|key|key-string|pre-shared-key|authentication-key|md5|community|plain-text-password-value|psk)|wpa-psk|key-string)[ \t]+(?:(?:ascii-text|hexadecimal|ascii|hex)[ \t]+)?(?:(\d)[ \t]+)?(?:"([^"\n]{3,200})"|(\S{3,200}))((?:[ \t]+(?:address|level|RO|RW|ro|rw)\b[^\n]*)?)[ \t]*;?\r?$/gm;
|
|
946
|
+
for (const m of text.matchAll(IOS)) {
|
|
947
|
+
if (m[1] && /[589]/.test(m[1]))
|
|
948
|
+
continue;
|
|
949
|
+
const pw = m[2] ?? m[3];
|
|
950
|
+
if (/^(?:public|private|\d+)$/i.test(pw) && !m[2])
|
|
951
|
+
continue;
|
|
952
|
+
const end = m.index + m[0].replace(/[ \t]*;?\r?$/, "").length - m[4].length - (m[2] ? 1 : 0);
|
|
953
|
+
add(end - pw.length, pw, "structure:cli-password", !!m[2]);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Oracle's `user/password@db` logins: `jdbc:oracle:thin:admin/pw@host:1521:SID`, `expdp system/pw@ORCL`,
|
|
958
|
+
* `rman target sys/pw@db`, `sqlldr userid=u/pw@db`, `SQL> connect scott/pw@orcl`, `CONN u/pw`, quoted `"system/pw@ORCL"`.
|
|
959
|
+
* (`sqlplus user/pw` is read with the other command lines.) The last `@` ends the password: `scott/pa@ss@orcl`.
|
|
960
|
+
*/
|
|
961
|
+
function oracleLogins(text, add) {
|
|
962
|
+
if (!/oracle|expdp|impdp|\bexp\b|\bimp\b|rman|sqlldr|dgmgrl|tkprof|\bconn(?:ect)?\b/i.test(text))
|
|
963
|
+
return;
|
|
964
|
+
// (Oracle's tools as a command, at a line's start or after a prompt or an operator: `dvc exp run` runs dvc.)
|
|
965
|
+
const LOGIN = /(?:jdbc:oracle:(?:thin|oci):|(?:^|[;&|(]|\$ )[ \t]*(?:sudo[ \t]+)?(?:expdp|impdp|exp|imp|rman|sqlldr|dgmgrl|tkprof)\b[^\n]{0,400}?[ \t](?:(?:target|auxiliary|userid|catalog)[ \t=]+)?["']?|^[ \t]*(?:SQL>[ \t]*)?conn(?:ect)?[ \t]+["']?)([A-Za-z_][\w$#]{0,30})\/((?:[^\s\/@'"]|@(?=[^\s\/@'"]*@)){3,})(?=@|["']?[ \t]*(?:as[ \t]+sys\w*)?[ \t]*;?\r?$)/gim;
|
|
966
|
+
for (const m of text.matchAll(LOGIN))
|
|
967
|
+
add(m.index + m[0].length - m[2].length, m[2], "structure:cli-password");
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* How command-line and config rules add a password: unless it's a reference (`$PASS`, `${PASS}`, `{{ pw }}`, a
|
|
971
|
+
* `<placeholder>`, `%VAR%`), a docs' placeholder or a prompt's `*`. `quoted`: inside single quotes, where a shell
|
|
972
|
+
* expands nothing: `-u 'admin:$q…'` is a literal. (In a shell, `$name` unquoted is expanded whatever the name.)
|
|
973
|
+
*/
|
|
974
|
+
function passwordAdder(out) {
|
|
975
|
+
return (start, value, rule, quoted = false, end = start + value.length) => {
|
|
976
|
+
if (/^\*+$/.test(value) || !isLiteralPassword(value, quoted))
|
|
977
|
+
return;
|
|
978
|
+
out.push({
|
|
979
|
+
start,
|
|
980
|
+
end,
|
|
981
|
+
kind: "password",
|
|
982
|
+
rule,
|
|
983
|
+
confidence: 0.92,
|
|
984
|
+
literal: true,
|
|
985
|
+
...(end - start !== value.length ? { value } : {}),
|
|
986
|
+
});
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
/** Programs whose `-u`, `--user` or `--proxy-user` is `user:password`: curl and its kin, HTTP clients, wget's `--user`. */
|
|
990
|
+
const USER_PASSWORD = /^(?:curl|curlie|xh|xhs|http|https|httpie|wget|grpcurl|hey|siege)$/;
|
|
991
|
+
/**
|
|
992
|
+
* Passwords given to command-line clients: `curl -u user:pass`, `mysql -pPASS` / `-p'PASS'`,
|
|
993
|
+
* `docker login -p PASS`, `sshpass -p PASS`, `redis-cli -a PASS`, `ldapsearch -w PASS`, `htpasswd -b file user PASS`.
|
|
994
|
+
*/
|
|
995
|
+
function cliPasswords(text, out) {
|
|
996
|
+
if (!/[\s"']-[upaPwUbN]|--(?:user|pass|auth|pw|body)|\/p:|password[ \t]|sshpass|htpasswd|mkpasswd|rabbitmqctl|steamcmd|sqlplus|poetry|wandb|beeline|keytool|vault[ \t]+login|pulumi|openssl[ \t]+passwd|-a |\||<<</i.test(text))
|
|
997
|
+
return;
|
|
998
|
+
const add = passwordAdder(out);
|
|
999
|
+
// The password options of programs outside the tool table: `hydra -p`, `xfreerdp /p:`, `unrar x -p…`, `ssh-keygen -N`,
|
|
1000
|
+
// and a `password <v>` word pair on any command line (`nmcli … password <v>`, `mysqladmin … password <v>`). (`-p` is
|
|
1001
|
+
// a port, a path or a profile to most programs: only these say it is a password.)
|
|
1002
|
+
// (Bare, or quoted as a shell quotes it: `-p 'Password123!'`, `/p:"Summer2024!"`; in a line's inline code too,
|
|
1003
|
+
// `` Run `hydra -p pw` ``.)
|
|
1004
|
+
for (const m of text.matchAll(/(?:^|[ \t])(-[pNA]|\/p:|password[ \t]+)([ \t]?)(?:'([^'\n]{6,64})'|"([^"\n]{6,64})"|([^\s'"`]{6,64}))(?=[\s;|&)`]|$)/g)) {
|
|
1005
|
+
const [, option, blank] = m;
|
|
1006
|
+
const pw = m[3] ?? m[4] ?? m[5];
|
|
1007
|
+
const bare = m[5] !== undefined;
|
|
1008
|
+
const at = m.index + m[0].length - pw.length - (bare ? 0 : 1);
|
|
1009
|
+
const command = commandAt(text, at)?.command;
|
|
1010
|
+
// (A word list is no password: `nxc smb hosts -u users.txt -p passwords.txt`.)
|
|
1011
|
+
if (pw.startsWith("-") || !command?.length || /\.(?:txt|lst|list|dic|csv)$/i.test(pw))
|
|
1012
|
+
continue;
|
|
1013
|
+
const name = programName(command[0]);
|
|
1014
|
+
const word = /^password/.test(option);
|
|
1015
|
+
// (The program is the command's first word, or a word before the option that names one of these, past prose
|
|
1016
|
+
// the line starts with: `Brute-force it: hydra -l admin -p …`.)
|
|
1017
|
+
const names = command.filter((w, i) => i === 0 || (w.end <= at && !/[/\\]/.test(w.text))).map(programName);
|
|
1018
|
+
// (rar's `-p` takes its password attached, `-pPW`; alone it asks for one, and the archive's name follows,
|
|
1019
|
+
// `rar a -p backup.rar`. A password written apart anyway, `-p Summer2024`, is still the one its writer meant.)
|
|
1020
|
+
const takes = word ||
|
|
1021
|
+
names.some((name) => (option === "/p:" && /^(?:(?:x|w|s)?freerdp3?|rdesktop)$/.test(name)) ||
|
|
1022
|
+
(option === "-p" &&
|
|
1023
|
+
(/^(?:hydra|medusa|twine|evil-winrm|crackmapexec|netexec|nxc|rpcclient|smbmap|enum4linux(?:-ng)?|useradd|usermod)$|^impacket-/.test(name) ||
|
|
1024
|
+
(/^(?:unrar|rar)$/.test(name) && !(blank && /\.(?:rar|r\d\d|zip|7z|exe|sfx)$/i.test(pw))))) ||
|
|
1025
|
+
(option === "-N" && name === "ssh-keygen") ||
|
|
1026
|
+
(option === "-A" && /^snmp/.test(name)));
|
|
1027
|
+
if (!takes)
|
|
1028
|
+
continue;
|
|
1029
|
+
// A program's own password option or word takes any value, and quoted, all its quotes hold: `-p 'Xk9(mP2/vQ7'`.
|
|
1030
|
+
// Otherwise a path or a call is no password (`-p /tmp/pwlist`, `password hash(pw1)`), and a sentence's
|
|
1031
|
+
// punctuation is none of one (`hydra -p Winter2024.`); after a `password` word on any other command, only a
|
|
1032
|
+
// password's shape is one: letters with a digit, or of both cases with a symbol, `Zebra-Lamp-Cloud`; not prose,
|
|
1033
|
+
// `authentication`, `re-use`, nor code that holds one, `os.environ.PW1`.
|
|
1034
|
+
const own = !word || /^(?:nmcli|mysqladmin)$/.test(name);
|
|
1035
|
+
const whole = own && !bare;
|
|
1036
|
+
if (!whole && /[()/]/.test(pw))
|
|
1037
|
+
continue;
|
|
1038
|
+
const value = whole ? pw : pw.replace(/[.,;:]+$/, "");
|
|
1039
|
+
if (!own &&
|
|
1040
|
+
(isCode(value, true) ||
|
|
1041
|
+
!((/[A-Za-z]/.test(value) && /\d/.test(value)) ||
|
|
1042
|
+
(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[\W_]/.test(value)))))
|
|
1043
|
+
continue;
|
|
1044
|
+
add(at, value, "structure:cli-password");
|
|
1045
|
+
}
|
|
1046
|
+
// `wandb login <key>`: a key as a command's argument.
|
|
1047
|
+
for (const m of text.matchAll(/\bwandb[ \t]+login(?:[ \t]+--?[\w-]+)*[ \t]+([0-9a-f]{40}|local-[0-9a-f]{40,})\b/g))
|
|
1048
|
+
add(m.index + m[0].length - m[1].length, m[1], "structure:cli-key");
|
|
1049
|
+
// curl -u user:password, --user user:password: only the part after the first colon.
|
|
1050
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:^|\s)(?:-u|--user|--proxy-user|-U)(?:[ \t]{1,4}|=|(?=[\w.@+-]{1,100}:))` +
|
|
1051
|
+
String.raw `(?:'([^':\n]{0,100}):([^'\n]+)'|"([^":\n]{0,100}):([^"\n]+)"|([^\s:'"]{0,100}):(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'"]{1,4096})))`, "g"))) {
|
|
1052
|
+
// `user:pass`, `'user:pass'`, or `user:'pass'` with only the password quoted.
|
|
1053
|
+
const pw = m[2] ?? m[4] ?? m[6] ?? m[7] ?? m[8];
|
|
1054
|
+
// The tools that document `user:password` say so (USER_PASSWORD); for any other program, or a sentence, both halves
|
|
1055
|
+
// must read as a pair: a user's name, and no name or number (`docker run -u www-data:www-data`, `chown 1000:1000`,
|
|
1056
|
+
// `date -u +%Y-%m-%dT%H:%M:%SZ` aren't). (The command the option is in: an earlier `chown app:app … &&` says
|
|
1057
|
+
// nothing about a later `curl -u`.)
|
|
1058
|
+
const user = m[1] ?? m[3] ?? m[5] ?? "";
|
|
1059
|
+
// (A URL, `redis-cli -u redis://:pw@host:6379/2`, is the URL reader's: its scheme is no user.)
|
|
1060
|
+
if (pw.startsWith("//"))
|
|
1061
|
+
continue;
|
|
1062
|
+
const program = commandAt(text, m.index + m[0].search(/-/))?.command[0];
|
|
1063
|
+
if (!(program && USER_PASSWORD.test(programName(program))) &&
|
|
1064
|
+
(!/^[\w.@-]{1,100}$/.test(user) || /^(?:[a-z_][a-z0-9_.-]{0,31}|\d{1,10})$/.test(pw)))
|
|
1065
|
+
continue;
|
|
1066
|
+
add(m.index + m[0].length - pw.length - (m[8] === undefined ? 1 : 0), pw, "structure:cli-user-pass", m[2] !== undefined || m[6] !== undefined);
|
|
1067
|
+
}
|
|
1068
|
+
// A password piped into a command that reads it from stdin: `echo pw | docker login --password-stdin`, or given it as
|
|
1069
|
+
// a here-string: `vault login - <<< "pw"`.
|
|
1070
|
+
for (const { value, reader } of stdinFeeds(text)) {
|
|
1071
|
+
if (STDIN_SECRET.test(reader.words.map((w) => w.text).join(" ")))
|
|
1072
|
+
addTail(add, value, value.text, value.literalEnd, "structure:piped-password");
|
|
1073
|
+
}
|
|
1074
|
+
// `poetry config http-basic.<repo> <user> <password>`.
|
|
1075
|
+
for (const m of text.matchAll(/\bpoetry[ \t]+config[ \t]+http-basic\.[\w.-]+[ \t]+["']?[^\s'"]{1,100}["']?[ \t]+(["']?)([^\s'"]{3,4096})\1/g)) {
|
|
1076
|
+
add(m.index + m[0].length - m[1].length - m[2].length, m[2], "structure:cli-password");
|
|
1077
|
+
}
|
|
1078
|
+
// `sqlplus user/password@db` (the last `@` ends a password that has one, `scott/pa@ss@orcl`).
|
|
1079
|
+
for (const m of text.matchAll(/(?:^|[;&|(\s]|\$ )sqlplus[ \t]+(?:-\w+[ \t]+)*["']?[\w.$#-]{1,60}\/((?:[^\s@'"]|@(?=[^\s@'"]*@)){3,4096})(?=@|[ \t]+as[ \t]+sys|["']?[ \t]*$|["']?[ \t]*[;&|\n])/gm)) {
|
|
1080
|
+
const pw = m[1] ?? m[2];
|
|
1081
|
+
add(m.index + m[0].length - pw.length, pw, "structure:cli-password");
|
|
1082
|
+
}
|
|
1083
|
+
// A password written unquoted with a shell's operators in it (`-pXk9(mP2&vQ7`) runs to the next blank, as the person
|
|
1084
|
+
// who wrote it reads it; the shell would have cut it at the operator. (Not past a quote: `"$(… pw)"` closes there.)
|
|
1085
|
+
const toBlank = (start, value, rule, quoted, end = start + value.length) => {
|
|
1086
|
+
const rest = /^[()&|;<>]+[^\s()&|;<>"'`]\S*/.exec(text.slice(end, end + 4096))?.[0] ?? "";
|
|
1087
|
+
add(start, value + rest, rule, quoted, end + rest.length);
|
|
1088
|
+
};
|
|
1089
|
+
for (const line of text.matchAll(/^[^\n]*$/gm)) {
|
|
1090
|
+
if (!/-|rabbitmqctl|steamcmd|vault[ \t]+login|passwd/.test(line[0]))
|
|
1091
|
+
continue;
|
|
1092
|
+
// (Read as the commands it runs: past a prompt, through wrappers, in PowerShell's dialect where it's written in it.)
|
|
1093
|
+
for (const c of commandsRun(text, { at: line.index, oneLine: true }))
|
|
1094
|
+
commandPasswords(c.words, toBlank);
|
|
1095
|
+
}
|
|
1096
|
+
// The same commands written as lists of words; a shell's `-c` script in one, as the command line it is.
|
|
1097
|
+
if (/\[[ \t\r\n]*["']/.test(text))
|
|
1098
|
+
for (const m of text.matchAll(/\[[ \t\r\n]*(?=["'])/g)) {
|
|
1099
|
+
const words = listWords(text, m.index + m[0].length);
|
|
1100
|
+
if (!words || words.length < 2)
|
|
1101
|
+
continue;
|
|
1102
|
+
commandPasswords(words, add);
|
|
1103
|
+
const flag = SHELL.test(programName(words[0])) ? words.findIndex((w) => /^-\w*c\w*$/.test(w.text)) : -1;
|
|
1104
|
+
const script = flag > 0 ? words[flag + 1] : undefined;
|
|
1105
|
+
if (script)
|
|
1106
|
+
for (const c of commandsRun(script.text))
|
|
1107
|
+
commandPasswords(c.words.map((w) => inWord(script, w)), add);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/** A word of a command read out of `outer`'s text, with its positions where `outer`'s map puts them. */
|
|
1111
|
+
function inWord(outer, w) {
|
|
1112
|
+
const at = (p) => (p < outer.map.length ? outer.map[p] : outer.map[outer.map.length - 1] + 1);
|
|
1113
|
+
return {
|
|
1114
|
+
...w,
|
|
1115
|
+
start: at(w.start),
|
|
1116
|
+
end: at(w.end - 1) + 1,
|
|
1117
|
+
map: w.map.map(at),
|
|
1118
|
+
ends: w.ends.map((e) => at(e - 1) + 1),
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* An exec array's words, from the first item at `at` to its `]`: a command written as a list (`["docker", "login", "-p",
|
|
1123
|
+
* "pw"]` in JSON, a Dockerfile's `CMD`, compose's `command:`, Python's `subprocess.run([…])`, Node's `spawn(…, […])`),
|
|
1124
|
+
* read as a command line's words are (commandPasswords), each literal's text mapped to where it's written. An item
|
|
1125
|
+
* that isn't a literal (`pw`, `os.environ["PW"]`) is a reference, a `$` word no password rule takes.
|
|
1126
|
+
*/
|
|
1127
|
+
function listWords(text, at) {
|
|
1128
|
+
const words = [];
|
|
1129
|
+
let i = at;
|
|
1130
|
+
while (words.length < 200) {
|
|
1131
|
+
const q = text[i];
|
|
1132
|
+
const start = i;
|
|
1133
|
+
if (q === '"' || q === "'") {
|
|
1134
|
+
const close = stringEnd(text, i);
|
|
1135
|
+
if (close < 0)
|
|
1136
|
+
return undefined;
|
|
1137
|
+
// (An escaped character is written as its whole escape, `\"`. The word is what its quotes hold: a value's span
|
|
1138
|
+
// is never widened over them, so `"-a", "pa\"ss"` keeps its quotes.)
|
|
1139
|
+
let value = "";
|
|
1140
|
+
const map = [];
|
|
1141
|
+
const ends = [];
|
|
1142
|
+
for (let j = i + 1; j < close; j++) {
|
|
1143
|
+
map.push(j);
|
|
1144
|
+
if (text[j] === "\\")
|
|
1145
|
+
j++;
|
|
1146
|
+
value += text[j];
|
|
1147
|
+
ends.push(j + 1);
|
|
1148
|
+
}
|
|
1149
|
+
words.push({
|
|
1150
|
+
text: value,
|
|
1151
|
+
start: start + 1,
|
|
1152
|
+
end: close,
|
|
1153
|
+
raw: text.slice(start + 1, close),
|
|
1154
|
+
quoted: true,
|
|
1155
|
+
literalEnd: q === "'",
|
|
1156
|
+
map,
|
|
1157
|
+
ends,
|
|
1158
|
+
substitutions: [],
|
|
1159
|
+
});
|
|
1160
|
+
i = close + 1;
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
1163
|
+
const item = /^[^,\]\n]{1,200}/.exec(text.slice(i, i + 201))?.[0].trimEnd();
|
|
1164
|
+
if (!item)
|
|
1165
|
+
return undefined;
|
|
1166
|
+
const end = start + item.length;
|
|
1167
|
+
words.push({
|
|
1168
|
+
text: "$ref",
|
|
1169
|
+
start,
|
|
1170
|
+
end,
|
|
1171
|
+
raw: item,
|
|
1172
|
+
quoted: false,
|
|
1173
|
+
literalEnd: false,
|
|
1174
|
+
map: [start, start, start, start],
|
|
1175
|
+
ends: [end, end, end, end],
|
|
1176
|
+
substitutions: [],
|
|
1177
|
+
});
|
|
1178
|
+
i += item.length;
|
|
1179
|
+
}
|
|
1180
|
+
while (text[i] === " " || text[i] === "\t" || text[i] === "\r" || text[i] === "\n")
|
|
1181
|
+
i++;
|
|
1182
|
+
if (text[i] === "]")
|
|
1183
|
+
return words;
|
|
1184
|
+
if (text[i] !== ",")
|
|
1185
|
+
return undefined;
|
|
1186
|
+
i++;
|
|
1187
|
+
while (text[i] === " " || text[i] === "\t" || text[i] === "\r" || text[i] === "\n")
|
|
1188
|
+
i++;
|
|
1189
|
+
}
|
|
1190
|
+
return undefined;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Values a CI system was told to mask: GitHub Actions `::add-mask::<value>`, Azure `##vso[task.setsecret]<value>`,
|
|
1194
|
+
* Buildkite `buildkite-agent redactor add` (fed on stdin).
|
|
1195
|
+
*/
|
|
1196
|
+
function ciMasks(text, out) {
|
|
1197
|
+
// Buildkite's `buildkite-agent redactor add`, fed on stdin.
|
|
1198
|
+
if (text.includes("redactor add")) {
|
|
1199
|
+
for (const { value, reader } of stdinFeeds(text)) {
|
|
1200
|
+
if (!/^buildkite-agent\s+redactor\s+add\b/.test(simpleCommand(reader.words)
|
|
1201
|
+
.map((w) => w.text)
|
|
1202
|
+
.join(" ")) ||
|
|
1203
|
+
value.text.length < 4 ||
|
|
1204
|
+
isInert(value.text))
|
|
1205
|
+
continue;
|
|
1206
|
+
out.push({
|
|
1207
|
+
...wordSpan(value),
|
|
1208
|
+
kind: "masked CI value",
|
|
1209
|
+
rule: "structure:ci-mask",
|
|
1210
|
+
confidence: 1,
|
|
1211
|
+
...(wordSpan(value).end - wordSpan(value).start !== value.text.length ? { value: value.text } : {}),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (!/::add-mask::|##vso\[task\.setsecret\]|##vso\[task\.setvariable[^\]\n]{0,200}issecret=true/i.test(text))
|
|
1216
|
+
return;
|
|
1217
|
+
// The value is the rest of the line (a passphrase has spaces), up to the quote that opened an `echo "…"`,
|
|
1218
|
+
// or a JSON escape when the line sits inside a JSON string.
|
|
1219
|
+
for (const m of text.matchAll(/(?:::add-mask::|##vso\[task\.setsecret\]|##vso\[task\.setvariable[^\]\n]{0,200}issecret=true[^\]\n]{0,100}\])([^\n]{1,4096})/gi)) {
|
|
1220
|
+
let value = m[1].replace(/\\[nrt"][\s\S]*$/, "");
|
|
1221
|
+
const quote = /(["'])[^"'\n]*$/.exec(text.slice(Math.max(0, m.index - 200), m.index))?.[1];
|
|
1222
|
+
if (quote && value.includes(quote))
|
|
1223
|
+
value = value.slice(0, value.indexOf(quote));
|
|
1224
|
+
value = value.replace(/[ \t\r]+$/, "");
|
|
1225
|
+
if (!value || isInert(value))
|
|
1226
|
+
continue;
|
|
1227
|
+
const start = m.index + m[0].length - m[1].length;
|
|
1228
|
+
out.push({ start, end: start + value.length, kind: "masked CI value", rule: "structure:ci-mask", confidence: 1 });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/** Path segments of one-time links: `/reset/<token>`, `/auth/magic-link/<token>`, `/verify-email/<token>`. */
|
|
1232
|
+
const SIGN_IN_SEGMENT = /^(?:(?:password|email|account|user|magic)[-_]?)?(?:reset|verify|verification|confirm|confirmation|magic|login|signin|sign[-_]in|activate|activation|invite|invitation|unlock|recover|recovery|otp|claim|passwordless|onetime|one[-_]time)(?:[-_]?(?:password|email|account|links?|tokens?|login))?$/i;
|
|
1233
|
+
/** A bundler's content-hashed asset: `app-Kx81kP0qLm2Vb7Rt.js`, `chunk.3f9a2c1e.css`, `Inter-Bold-Zk3mP9qL.woff2`. Named for its hash, served to everyone. */
|
|
1234
|
+
const HASHED_ASSET = /^[A-Za-z_][\w]*[-.][\w-]{6,}\.(?:m?js|cjs|css|map|json|woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico|mp[34]|webm|wasm)$/i;
|
|
1235
|
+
/** A path segment that is a one-time token: random, and not a UUID (a record's id as often as a token) or an asset's name. */
|
|
1236
|
+
function isSignInToken(v) {
|
|
1237
|
+
return isRandomToken(v) && !/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(v) && !HASHED_ASSET.test(v);
|
|
1238
|
+
}
|
|
1239
|
+
/** Query parameters that carry a one-time login or an access grant. */
|
|
1240
|
+
const SIGN_IN_PARAM = /^(?:magic|otp|ticket|oobcode|login_token|reset_token|invite_token|verification_token|confirmation_token|reset_password_token|unlock_token|invitation_token|access_token|id_token|refresh_token|auth|sso_token)$/i;
|
|
1241
|
+
/**
|
|
1242
|
+
* Reset, magic sign-in and verification links, which log in whoever opens them: a random token in the
|
|
1243
|
+
* path after `/reset/`, `/magic-link/`, `/verify/`, or in a query parameter like `?magic=`.
|
|
1244
|
+
*/
|
|
1245
|
+
function signInLinks(text, out) {
|
|
1246
|
+
if (!/https?:\/\/|\/(?:reset|verify|magic|confirm|invite|activate|login|unlock)|[?&](?:magic|otp|ticket|oobcode|[a-z]+_token|auth)=/i.test(text))
|
|
1247
|
+
return;
|
|
1248
|
+
for (const { scheme, segments } of urls(text, { paths: true })) {
|
|
1249
|
+
if (scheme && !/^https?$/i.test(scheme))
|
|
1250
|
+
continue;
|
|
1251
|
+
// The token follows the sign-in segment (and a second word that qualifies it: `/password/reset/<token>`), right
|
|
1252
|
+
// after it or after an id (Django's `/reset/<uidb64>/<token>/`). Laravel's reset tokens are 64 hex; under a
|
|
1253
|
+
// repository named `login`, `/login/commit/<sha>` has a word there.
|
|
1254
|
+
let at = segments.findIndex((seg) => SIGN_IN_SEGMENT.test(seg.value));
|
|
1255
|
+
if (at < 0)
|
|
1256
|
+
continue;
|
|
1257
|
+
const next = segments[at + 1]?.value ?? "";
|
|
1258
|
+
if (SIGN_IN_SEGMENT.test(next) || /^(?:password|email|account|user)s?$/i.test(next))
|
|
1259
|
+
at++;
|
|
1260
|
+
// (A token right after it is taken first, read as an id or not.)
|
|
1261
|
+
const id = segments[at + 1] &&
|
|
1262
|
+
/^[A-Za-z0-9_=-]{1,24}$/.test(segments[at + 1].value) &&
|
|
1263
|
+
!readsAsWords(segments[at + 1].value);
|
|
1264
|
+
const token = segments.slice(at + 1, at + (id ? 3 : 2)).find((seg) => isSignInToken(seg.value));
|
|
1265
|
+
if (token)
|
|
1266
|
+
out.push({
|
|
1267
|
+
start: token.start,
|
|
1268
|
+
end: token.end,
|
|
1269
|
+
kind: "sign-in link token",
|
|
1270
|
+
rule: "structure:sign-in-link",
|
|
1271
|
+
confidence: 0.9,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
for (const { name, value } of queryParams(text)) {
|
|
1275
|
+
if (!SIGN_IN_PARAM.test(name) || !isRandomToken(value.value) || isInert(value.value))
|
|
1276
|
+
continue;
|
|
1277
|
+
out.push({
|
|
1278
|
+
start: value.start,
|
|
1279
|
+
end: value.end,
|
|
1280
|
+
kind: "sign-in link token",
|
|
1281
|
+
rule: "structure:sign-in-link",
|
|
1282
|
+
confidence: 0.9,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* A credential flag and its value as separate items of a list: `- "--requirepass"` then `- "value"` in a
|
|
1288
|
+
* compose `command:`, `["--password", "value"]` in an exec array or a JSON args list.
|
|
1289
|
+
*/
|
|
1290
|
+
function flagListItems(text, out) {
|
|
1291
|
+
if (!/(?:pass|token|secret|key|auth)/i.test(text) || !/["'\s]-{1,2}[A-Za-z]/.test(text))
|
|
1292
|
+
return;
|
|
1293
|
+
// `["--password", "x"]`, `- "--requirepass"` then `- "x"`, and unquoted YAML list items `- --password` / `- x`.
|
|
1294
|
+
for (const m of text.matchAll(
|
|
1295
|
+
// (A flag starts a word: not `-password` inside `/run/secrets/db-password`.)
|
|
1296
|
+
/(?<![\w/.=-])(["']?)(--?[A-Za-z][\w-]{0,60})\1[ \t]*(?:,[ \t\r\n]{0,80}(["'])([^"'\n]{3,})\3|\r?\n[ \t]*-[ \t]+(?:(["'])([^"'\n]{3,})\5|([^\s"'#-][^\n#]{2,4095}?))[ \t]*(?=\r?\n|$))/g)) {
|
|
1297
|
+
const flag = m[2].replace(/^-+/, "");
|
|
1298
|
+
const value = m[4] ?? m[6] ?? m[7];
|
|
1299
|
+
// (Judged as every name rule judges a value: `--partition-key` names an identifier, `--auth oidc-device-flow` a mode.)
|
|
1300
|
+
if (!isCredentialName(flag) || /^-/.test(value) || !isNamedSecret(flag, `"${value}"`, true))
|
|
1301
|
+
continue;
|
|
1302
|
+
const start = m.index + m[0].lastIndexOf(value);
|
|
1303
|
+
out.push({ start, end: start + value.length, kind: "credential", rule: "structure:flag-list", confidence: 0.9 });
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/** `machine host login user password value` (netrc), also inside JSON strings and logs. */
|
|
1307
|
+
function netrcLike(text, out) {
|
|
1308
|
+
if (!/\bpassword\b/.test(text) || !/\b(?:login|machine)\b/.test(text))
|
|
1309
|
+
return;
|
|
1310
|
+
// `machine <host> login <user> password <pw>` (or `default …`, or `login <user> password <pw>`): the
|
|
1311
|
+
// tokens follow one another, as netrc writes them; prose that mentions a login and a password doesn't.
|
|
1312
|
+
// Between tokens: blanks, a line break, a `\n` escape, or the end of one string of a JSON lines array and the next.
|
|
1313
|
+
const SEP = String.raw `(?:[ \t]+|[ \t]*(?:\\n|\r?\n)(?:["'],?[ \t\r\n]{0,40}["'])?[ \t]*)`;
|
|
1314
|
+
// (Or `machine <host> password <pw> login <user>`: netrc tokens may come in any order. A word that joins the two is
|
|
1315
|
+
// no user: `"login or password incorrect"`.)
|
|
1316
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:\b(?:machine${SEP}[^\s,;:]{1,200}|default)${SEP}|(?:^|\\n|["'])[ \t]*)(?:login${SEP}(?!(?:and|or|with|via|using|by|&)${SEP})[^\s,;:]{1,200}${SEP}(?:account${SEP}\S{1,200}${SEP})?)?password[ \t]+([^\s"'\\]{3,500})`, "gm"))) {
|
|
1317
|
+
// Without a login before it, a password must follow `machine`/`default` and be followed by `login`.
|
|
1318
|
+
if (!/\blogin\b/.test(m[0]) &&
|
|
1319
|
+
!/^(?:[ \t]+|[ \t]*(?:\\n|\r?\n)(?:["'],?[ \t\r\n]{0,40}["'])?[ \t]*)login\b/.test(text.slice(m.index + m[0].length, m.index + m[0].length + 60)))
|
|
1320
|
+
continue;
|
|
1321
|
+
if (!/\b(?:machine|default)\b/.test(m[0]) && !/\blogin\b/.test(m[0]))
|
|
1322
|
+
continue;
|
|
1323
|
+
if (isInert(m[1]) || COMMON_WORDS.has(m[1].toLowerCase()))
|
|
1324
|
+
continue;
|
|
1325
|
+
const start = m.index + m[0].length - m[1].length;
|
|
1326
|
+
out.push({ start, end: start + m[1].length, kind: "password", rule: "structure:netrc", confidence: 0.9 });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Passwords in SQL statements: MySQL/Oracle `IDENTIFIED [WITH plugin] BY '…'`, `SET PASSWORD [FOR u] = '…'`,
|
|
1331
|
+
* Postgres `[WITH] PASSWORD '…'`, T-SQL `WITH PASSWORD = '…'`.
|
|
1332
|
+
*/
|
|
1333
|
+
function sqlPasswords(text, out) {
|
|
1334
|
+
if (!/IDENTIFIED|PASSWORD/i.test(text))
|
|
1335
|
+
return;
|
|
1336
|
+
const forms = [
|
|
1337
|
+
/\bIDENTIFIED\s+(?:WITH\s+['"`]?\w+['"`]?\s+)?BY\s+(?!PASSWORD\b)(?:'((?:[^'\n]|'')+)'|"([^"\n]+)")/gi,
|
|
1338
|
+
// Unquoted (Oracle) only in a statement that creates or changes a user: prose says "identified by" too. (Up to a
|
|
1339
|
+
// statement's end, or a quote that closes the string a program holds it in: `"CREATE USER app IDENTIFIED BY …"`.
|
|
1340
|
+
// Not MySQL's `IDENTIFIED BY RANDOM PASSWORD`, which makes one.)
|
|
1341
|
+
/\b(?:CREATE|ALTER)\s+(?:USER|ROLE)\b[^;\n]{0,200}?\bIDENTIFIED\s+BY\s+(?!PASSWORD\b|VALUES\b|RANDOM\s+PASSWORD\b)([A-Za-z0-9_$#!@%^&*+=.-]{4,128})(?=\s*(?:[;"'`]|$|\s))/gim,
|
|
1342
|
+
/\bSET\s+PASSWORD\s+(?:FOR\s+\S+\s*)?=\s*(?:PASSWORD\s*\(\s*)?'((?:[^'\n]|'')+)'/gi,
|
|
1343
|
+
/\b(?:CREATE|ALTER)\s+(?:USER|ROLE|LOGIN)\b[^;\n]{0,200}?\bPASSWORD\s*=?\s*'((?:[^'\n]|'')+)'/gi,
|
|
1344
|
+
// MySQL's current password, which a password change authenticates with: `IDENTIFIED BY 'new' REPLACE 'old'`.
|
|
1345
|
+
/\bIDENTIFIED\s+BY\s+'(?:[^'\n]|'')+'\s+REPLACE\s+'((?:[^'\n]|'')+)'/gi,
|
|
1346
|
+
];
|
|
1347
|
+
for (const re of forms) {
|
|
1348
|
+
for (const m of text.matchAll(re)) {
|
|
1349
|
+
const pw = m[1] ?? m[2];
|
|
1350
|
+
// (A bind parameter, `:pw` or `?`, is never quoted and can't start the unquoted form: these are all literals.
|
|
1351
|
+
// MySQL's `*<40 hex>` is a hash, passwordHashes's.)
|
|
1352
|
+
if (pw.length < 3 || isInert(pw) || isPlaceholder(pw) || /^\*[0-9A-F]{40}$/.test(pw))
|
|
1353
|
+
continue;
|
|
1354
|
+
const start = m.index + m[0].lastIndexOf(pw);
|
|
1355
|
+
out.push({ start, end: start + pw.length, kind: "password", rule: "structure:sql-password", confidence: 0.95 });
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Redis sessions: `AUTH [user] pw`, `CONFIG SET requirepass pw`, and replies that pair a credential name
|
|
1361
|
+
* with its value (`1) "requirepass"` / `2) "pw"`, `GET api_token` then `"pw"`).
|
|
1362
|
+
*/
|
|
1363
|
+
function redisSessions(text, out) {
|
|
1364
|
+
if (!/\bAUTH\b|requirepass|masterauth|\d\) "|> ?GET /i.test(text))
|
|
1365
|
+
return;
|
|
1366
|
+
const add = (start, value, rule) => {
|
|
1367
|
+
if (value.length < 4 || isInert(value))
|
|
1368
|
+
return;
|
|
1369
|
+
out.push({ start, end: start + value.length, kind: "password", rule, confidence: 0.9 });
|
|
1370
|
+
};
|
|
1371
|
+
const value = String.raw `(?:"((?:[^"\\\n]|\\.)+)"|'([^'\n]+)'|([^\s"']{1,4096}))`;
|
|
1372
|
+
// `127.0.0.1:6379> AUTH pw`, `redis-cli … AUTH user pw`, `> CONFIG SET requirepass "pw"`.
|
|
1373
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:(?:^|\s)[\w.-]*:\d{2,5}(?:\[\d+\])?>[ \t]*|\bredis-cli\b[^\n]{0,400}?[ \t])(?:AUTH|auth)[ \t]+(?:[^\s"']{1,100}[ \t]+)?` +
|
|
1374
|
+
value +
|
|
1375
|
+
String.raw `[ \t]*$`, "gm"))) {
|
|
1376
|
+
const v = m[1] ?? m[2] ?? m[3];
|
|
1377
|
+
add(m.index + m[0].trimEnd().length - v.length - (m[3] === undefined ? 1 : 0), v, "structure:redis-auth");
|
|
1378
|
+
}
|
|
1379
|
+
for (const m of text.matchAll(new RegExp(String.raw `\bCONFIG[ \t]+SET[ \t]+(?:requirepass|masterauth|masteruser)[ \t]+` + value, "gi"))) {
|
|
1380
|
+
const v = m[1] ?? m[2] ?? m[3];
|
|
1381
|
+
add(m.index + m[0].length - v.length - (m[3] === undefined ? 1 : 0), v, "structure:redis-auth");
|
|
1382
|
+
}
|
|
1383
|
+
// Array replies: a credential-named element followed by its value.
|
|
1384
|
+
for (const m of text.matchAll(/^[ \t]*\d{1,4}\) "([\w.:-]{1,80})"[ \t]*\r?\n[ \t]*\d{1,4}\) "((?:[^"\\\n]|\\.)+)"/gm)) {
|
|
1385
|
+
if (!isCredentialName(m[1]))
|
|
1386
|
+
continue;
|
|
1387
|
+
add(m.index + m[0].length - 1 - m[2].length, m[2], "structure:redis-reply");
|
|
1388
|
+
}
|
|
1389
|
+
// `GET <credential key>` and its bulk-string reply on the next line.
|
|
1390
|
+
for (const m of text.matchAll(/>[ \t]*GET[ \t]+([\w.:-]{1,120})[ \t]*\r?\n[ \t]*"((?:[^"\\\n]|\\.)+)"/gi)) {
|
|
1391
|
+
if (!isCredentialName(m[1].split(/[:.]/).at(-1)))
|
|
1392
|
+
continue;
|
|
1393
|
+
add(m.index + m[0].length - 1 - m[2].length, m[2], "structure:redis-reply");
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/** IPsec `ipsec.secrets` (`: PSK "…"`) and OpenVPN inline `<auth-user-pass>` blocks (user, then password). */
|
|
1397
|
+
function vpnSecrets(text, out) {
|
|
1398
|
+
if (text.includes("PSK") || text.includes("XAUTH") || text.includes("EAP")) {
|
|
1399
|
+
// Selectors (IPs, `%any`, hostnames, `@ids`) before the colon; an unquoted value ends the line.
|
|
1400
|
+
for (const m of text.matchAll(/^[ \t]*(?:[\w.:%@/-]{1,100}[ \t]+){0,4}[\w.:%@/-]{0,100}[ \t]*:[ \t]*(?:PSK|XAUTH|EAP)[ \t]+(?:"([^"\n]+)"|'([^'\n]+)'|(\S{4,4096})[ \t]*$)/gm)) {
|
|
1401
|
+
if (/^[ \t]*[A-Z][a-z]+(?:[ \t]+[a-z]+)*[ \t]*:/.test(m[0]))
|
|
1402
|
+
continue; // `Mode: PSK x`, `Authentication method: PSK …`: prose
|
|
1403
|
+
const v = m[1] ?? m[2] ?? m[3];
|
|
1404
|
+
if (isInert(v))
|
|
1405
|
+
continue;
|
|
1406
|
+
const start = m.index + m[0].length - v.length - (m[3] === undefined ? 1 : 0);
|
|
1407
|
+
out.push({ start, end: start + v.length, kind: "pre-shared key", rule: "structure:ipsec", confidence: 0.95 });
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
if (text.includes("<auth-user-pass>")) {
|
|
1411
|
+
for (const m of text.matchAll(/<auth-user-pass>[ \t]*\r?\n[^\n]*\r?\n[ \t]*([^\s<][^\n]{0,4096}?)[ \t]*\r?\n[ \t]*<\/auth-user-pass>/g)) {
|
|
1412
|
+
if (isInert(m[1]))
|
|
1413
|
+
continue;
|
|
1414
|
+
const start = m.index + m[0].indexOf(m[1], m[0].indexOf("\n", m[0].indexOf("\n") + 1));
|
|
1415
|
+
out.push({ start, end: start + m[1].length, kind: "password", rule: "structure:openvpn", confidence: 0.95 });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Password hashes: by their format (values.ts PASSWORD_HASHES: bcrypt, argon2, crypt(3) and its `$$`-escaped Compose
|
|
1421
|
+
* form, Django and passlib, SCRAM, LDAP `{SSHA}`, Postgres `md5…`, MySQL `*…`), and by their place: MySQL's and Redis
|
|
1422
|
+
* ACL's hex where a password is talked about, values under hash-named password fields. Not a usable password, but
|
|
1423
|
+
* crackable offline, so not for an untrusted provider either.
|
|
1424
|
+
*/
|
|
1425
|
+
function passwordHashes(text, out) {
|
|
1426
|
+
if (!/\$|\{S?S?HA|\{MD5|\{CRYPT|\{PBKDF2|\{ARGON2|\{BCRYPT|md5[0-9a-f]|\*[0-9A-F]{40}|#[0-9a-f]{64}|SCRAM-|storedKey|serverKey|password[_-]?(?:hash|sha|double|digest)|hashed_?password/i.test(text))
|
|
1427
|
+
return;
|
|
1428
|
+
const add = (start, value, rule) => out.push({ start, end: start + value.length, kind: "password hash", rule, confidence: 0.95 });
|
|
1429
|
+
for (const m of text.matchAll(PASSWORD_HASH_IN_TEXT))
|
|
1430
|
+
add(m.index, m[0], "structure:password-hash");
|
|
1431
|
+
// MySQL `*<40 hex>` and Redis ACL `#<64 hex>` only where a password is being talked about.
|
|
1432
|
+
for (const m of text.matchAll(/^[^\n]*(?:password|passwd|authentication_string|\buser\s+\S+\s+on\b)[^\n]*$/gim)) {
|
|
1433
|
+
for (const h of m[0].matchAll(/(?<![\w*#])(?:\*[0-9A-F]{40}|#[0-9a-f]{64})\b/g))
|
|
1434
|
+
add(m.index + h.index + 1, h[0].slice(1), "structure:hash-field");
|
|
1435
|
+
}
|
|
1436
|
+
// Hash-named fields: `"password_hash": "…"`, `<password_sha256_hex>…</…>`, SCRAM `storedKey`/`serverKey`.
|
|
1437
|
+
for (const m of text.matchAll(/(?:["'<]|\b)(password[_-]?(?:hash|sha\d+(?:_hex)?|double_sha1_hex|digest)|passwordHash|hashed_?password|storedKey|serverKey)["'>]?[ \t]*(?:[:=]>?[ \t]*["']?|(?<=>))([A-Za-z0-9+/=$.:_-]{16,512})/gi)) {
|
|
1438
|
+
if (isInert(m[2]))
|
|
1439
|
+
continue;
|
|
1440
|
+
add(m.index + m[0].length - m[2].length, m[2], "structure:hash-field");
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/** VPN and server configs: HAProxy `stats auth user:pw`, `user u insecure-password pw`; OpenSSL `-passout pass:pw`. */
|
|
1444
|
+
function configPasswords(text, out) {
|
|
1445
|
+
const add = (start, value, rule) => {
|
|
1446
|
+
if (value.length < 3 || isInert(value))
|
|
1447
|
+
return;
|
|
1448
|
+
out.push({ start, end: start + value.length, kind: "password", rule, confidence: 0.92, literal: true });
|
|
1449
|
+
};
|
|
1450
|
+
for (const m of text.matchAll(/\bstats[ \t]+auth[ \t]+[^:\s]{1,100}:(\S{1,4096})|\buser[ \t]+\S{1,100}[ \t]+insecure-password[ \t]+(\S{1,4096})/g)) {
|
|
1451
|
+
const v = m[1] ?? m[2];
|
|
1452
|
+
add(m.index + m[0].length - v.length, v, "structure:haproxy");
|
|
1453
|
+
}
|
|
1454
|
+
for (const m of text.matchAll(
|
|
1455
|
+
// (Quoted, the value may hold blanks: `-passout "pass:correct horse"`.)
|
|
1456
|
+
/(?:^|\s)-(?:pass(?:in|out|word)?|password|passphrase|storepass|keypass|srcstorepass|deststorepass)[ \t]+(?:(['"])pass:([^'"\n]{1,4096})\1|pass:([^\s'"]{1,4096}))/gm)) {
|
|
1457
|
+
const pw = m[2] ?? m[3];
|
|
1458
|
+
add(m.index + m[0].length - pw.length - (m[1] ? 1 : 0), pw, "structure:openssl-pass");
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
/** Everything in this module. */
|
|
1462
|
+
export function findStructure(text) {
|
|
1463
|
+
const out = [];
|
|
1464
|
+
privateKeys(text, out);
|
|
1465
|
+
otherPrivateKeys(text, out);
|
|
1466
|
+
urlCredentials(text, out);
|
|
1467
|
+
mnemonics(text, out);
|
|
1468
|
+
appPasswords(text, out);
|
|
1469
|
+
authorization(text, out);
|
|
1470
|
+
signedUrls(text, out);
|
|
1471
|
+
cookies(text, out);
|
|
1472
|
+
connectionStrings(text, out);
|
|
1473
|
+
kubeSecrets(text, out);
|
|
1474
|
+
powershellSecureStrings(text, out);
|
|
1475
|
+
cliPasswords(text, out);
|
|
1476
|
+
windowsPasswords(text, passwordAdder(out));
|
|
1477
|
+
networkConfigKeys(text, passwordAdder(out));
|
|
1478
|
+
oracleLogins(text, passwordAdder(out));
|
|
1479
|
+
ciMasks(text, out);
|
|
1480
|
+
smtpAuth(text, out);
|
|
1481
|
+
vaultInit(text, out);
|
|
1482
|
+
signInLinks(text, out);
|
|
1483
|
+
flagListItems(text, out);
|
|
1484
|
+
netrcLike(text, out);
|
|
1485
|
+
sqlPasswords(text, out);
|
|
1486
|
+
redisSessions(text, out);
|
|
1487
|
+
vpnSecrets(text, out);
|
|
1488
|
+
passwordHashes(text, out);
|
|
1489
|
+
configPasswords(text, out);
|
|
1490
|
+
return out;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* A BIP-39 seed phrase: 12 to 24 words of the English list whose checksum verifies (1 in 16 to 1 in 256 for
|
|
1494
|
+
* words drawn at random), on one line or numbered one per line, with or without a name. A wallet's whole
|
|
1495
|
+
* key; never kept for its context (policy.ts). Published phrases are (isPublishedPhrase).
|
|
1496
|
+
*/
|
|
1497
|
+
function mnemonics(text, out) {
|
|
1498
|
+
if (!/[A-Za-z]{3,8}[^A-Za-z]+[A-Za-z]{3,8}/.test(text))
|
|
1499
|
+
return;
|
|
1500
|
+
const list = bip39();
|
|
1501
|
+
// Runs of list words in any case, separated by anything without letters: blanks, commas, quotes, numbering
|
|
1502
|
+
// (`1. slow 2. core`), bullets, line breaks (`['slow', 'core', …]`, `Slow Core Trial`, one word per line).
|
|
1503
|
+
const words = [];
|
|
1504
|
+
const flush = () => {
|
|
1505
|
+
if (words.length >= 12)
|
|
1506
|
+
findPhrases(words, list, out);
|
|
1507
|
+
words.length = 0;
|
|
1508
|
+
};
|
|
1509
|
+
let last = -1;
|
|
1510
|
+
const isLetter = (i) => {
|
|
1511
|
+
const folded = text.charCodeAt(i) | 32;
|
|
1512
|
+
return folded >= 97 && folded <= 122;
|
|
1513
|
+
};
|
|
1514
|
+
for (let at = 0; at < text.length;) {
|
|
1515
|
+
if (!isLetter(at)) {
|
|
1516
|
+
at++;
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
let end = at + 1;
|
|
1520
|
+
while (end < text.length && isLetter(end))
|
|
1521
|
+
end++;
|
|
1522
|
+
// (Only a word of the list's lengths is looked up; any other, and a gap too long, ends the run.)
|
|
1523
|
+
const w = end - at >= BIP39_WORD_LENGTHS[0] && end - at <= BIP39_WORD_LENGTHS[1] ? text.slice(at, end).toLowerCase() : "";
|
|
1524
|
+
if (list.has(w)) {
|
|
1525
|
+
const gap = last < 0 ? "" : text.slice(last, at);
|
|
1526
|
+
// (A line's indentation is one separator, not twelve. Code's joints and operators separate no phrase's words:
|
|
1527
|
+
// `chunk.item.type == "…"`, `model_dump`, `ignore_index=True`.)
|
|
1528
|
+
if (gap.replace(/\s+/g, " ").length > MAX_PHRASE_GAP || /[_=()]|\.(?=[A-Za-z])/.test(gap))
|
|
1529
|
+
flush();
|
|
1530
|
+
words.push({ word: w, start: at, end, lineBreakBefore: /\n/.test(gap) });
|
|
1531
|
+
}
|
|
1532
|
+
else
|
|
1533
|
+
flush();
|
|
1534
|
+
last = end;
|
|
1535
|
+
at = end;
|
|
1536
|
+
}
|
|
1537
|
+
flush();
|
|
1538
|
+
}
|
|
1539
|
+
/** The shortest and longest word of BIP-39's English list. */
|
|
1540
|
+
const BIP39_WORD_LENGTHS = [3, 8];
|
|
1541
|
+
/** The most characters between two words of a phrase, blanks counted once. */
|
|
1542
|
+
const MAX_PHRASE_GAP = 12;
|
|
1543
|
+
/** List words a label before a phrase is made of: `Seed phrase:`, `secret key:`, `private key words:`. */
|
|
1544
|
+
const LABEL_WORDS = new Set(["seed", "phrase", "secret", "key", "word", "list", "private"]);
|
|
1545
|
+
/**
|
|
1546
|
+
* Windows of 12–24 words with a valid checksum (published phrases aside), longest first, each word in one at most.
|
|
1547
|
+
* A window starts a run or a line, or follows a label of list words on its line (`Seed phrase: slow core …`).
|
|
1548
|
+
* In a run longer than 48 words (a word list, or several phrases on their own lines) only whole lines count.
|
|
1549
|
+
*/
|
|
1550
|
+
function findPhrases(words, list, out) {
|
|
1551
|
+
const long = words.length > 48;
|
|
1552
|
+
for (let i = 0; i < words.length; i++) {
|
|
1553
|
+
const startsLine = i === 0 || words[i].lineBreakBefore;
|
|
1554
|
+
// (A label may follow other list words on its line: `Copy this seed phrase: slow core …`.)
|
|
1555
|
+
if (!startsLine && (long || !LABEL_WORDS.has(words[i - 1].word)))
|
|
1556
|
+
continue;
|
|
1557
|
+
for (const n of [24, 21, 18, 15, 12]) {
|
|
1558
|
+
if (i + n > words.length)
|
|
1559
|
+
continue;
|
|
1560
|
+
const endsLine = i + n === words.length || words[i + n].lineBreakBefore;
|
|
1561
|
+
if (long && !(startsLine && endsLine && words.slice(i + 1, i + n).every((w) => !w.lineBreakBefore)))
|
|
1562
|
+
continue;
|
|
1563
|
+
// (Ending at the run's end or a line's, unless the run is short: a 13th word after a phrase is allowed.)
|
|
1564
|
+
if (!endsLine && words.length > 30)
|
|
1565
|
+
continue;
|
|
1566
|
+
const run = words.slice(i, i + n).map((w) => w.word);
|
|
1567
|
+
// (A phrase's words are mostly different; markup repeating its tag names isn't one.)
|
|
1568
|
+
if (new Set(run).size < n * 0.75)
|
|
1569
|
+
continue;
|
|
1570
|
+
const entropy = phraseEntropy(run.map((w) => list.get(w)));
|
|
1571
|
+
if (!entropy)
|
|
1572
|
+
continue;
|
|
1573
|
+
if (isPublishedPhrase(run, entropy))
|
|
1574
|
+
break;
|
|
1575
|
+
out.push({
|
|
1576
|
+
start: words[i].start,
|
|
1577
|
+
end: words[i + n - 1].end,
|
|
1578
|
+
kind: "seed phrase",
|
|
1579
|
+
rule: "structure:mnemonic",
|
|
1580
|
+
confidence: 0.99,
|
|
1581
|
+
});
|
|
1582
|
+
i += n - 1;
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/** The entropy bytes 11-bit word indices encode, if their BIP-39 checksum (the first ENT/32 bits of SHA-256(entropy)) verifies. */
|
|
1588
|
+
function phraseEntropy(indices) {
|
|
1589
|
+
const bits = indices.map((x) => x.toString(2).padStart(11, "0")).join("");
|
|
1590
|
+
const cs = bits.length / 33;
|
|
1591
|
+
const ent = bits.slice(0, bits.length - cs);
|
|
1592
|
+
const bytes = new Uint8Array(ent.length / 8);
|
|
1593
|
+
for (let k = 0; k < bytes.length; k++)
|
|
1594
|
+
bytes[k] = parseInt(ent.slice(k * 8, k * 8 + 8), 2);
|
|
1595
|
+
const hashBits = sha256(bytes)[0].toString(2).padStart(8, "0");
|
|
1596
|
+
return hashBits.slice(0, cs) === bits.slice(bits.length - cs) ? bytes : undefined;
|
|
1597
|
+
}
|
|
1598
|
+
/** Whether a text is a published BIP-39 phrase (see isPublishedPhrase): 12 to 24 list words whose checksum verifies. */
|
|
1599
|
+
export function isPublishedMnemonic(text) {
|
|
1600
|
+
const list = bip39();
|
|
1601
|
+
const words = text
|
|
1602
|
+
.toLowerCase()
|
|
1603
|
+
.split(/[^a-z]+/)
|
|
1604
|
+
.filter(Boolean);
|
|
1605
|
+
if (words.length < 12 || words.length > 24 || words.length % 3 || !words.every((w) => list.has(w)))
|
|
1606
|
+
return false;
|
|
1607
|
+
const entropy = phraseEntropy(words.map((w) => list.get(w)));
|
|
1608
|
+
return !!entropy && isPublishedPhrase(words, entropy);
|
|
1609
|
+
}
|
|
1610
|
+
/** The first words of development wallets' published phrases: Truffle's, Ganache's, ethers' docs', Osmosis' localnet, Hardhat's. */
|
|
1611
|
+
const PUBLISHED_PHRASES = [
|
|
1612
|
+
"candy maple cake sugar",
|
|
1613
|
+
"myth like bonus scare",
|
|
1614
|
+
"announce room limb pattern",
|
|
1615
|
+
"notice oak worry limit",
|
|
1616
|
+
"test test test test",
|
|
1617
|
+
];
|
|
1618
|
+
/**
|
|
1619
|
+
* A phrase published on purpose: a test vector, whose entropy is one or two repeated bytes (BIP-39's `abandon … about`,
|
|
1620
|
+
* `legal winner … yellow`, `letter advice … above`, `zoo … wrong`), or a development wallet's (`candy maple cake
|
|
1621
|
+
* sugar …`). A random phrase starts like one with odds below 2^-40.
|
|
1622
|
+
*/
|
|
1623
|
+
function isPublishedPhrase(words, entropy) {
|
|
1624
|
+
return new Set(entropy).size <= 2 || PUBLISHED_PHRASES.some((p) => words.join(" ").startsWith(p));
|
|
1625
|
+
}
|