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