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,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a secret file holds, read as that file: every value under a credential's name, a file that is one token, a
|
|
3
|
+
* `.pgpass` line's password, a value no setting has under a vendor's name; a credential command's output; the
|
|
4
|
+
* output under a prompt in a pasted session; and the places a secret file's content sits inside other text (a
|
|
5
|
+
* diff's hunks, a heredoc that writes one, `echo … > ~/.vault_pass`).
|
|
6
|
+
*/
|
|
7
|
+
import { commandsRun, holdsOnlySecrets, isConfigFileName, isSecretFile, printsSecret, printsSecretFile, } from "./commands.js";
|
|
8
|
+
import { allRedirects, LineIndex, programName, PROMPT, simpleCommand, wordSpan } from "./shell.js";
|
|
9
|
+
import { naturalness } from "./charlm.js";
|
|
10
|
+
import { isPlaceholder } from "./public.js";
|
|
11
|
+
import { hasCredentialWord, isCredentialName, isSettingName, isVendorName, namesPasswordOrPin, namesSession, words, } from "./names.js";
|
|
12
|
+
import { lowerBound } from "./lines.js";
|
|
13
|
+
import { COMMON_WORDS, hasThreeClasses, isBenignValue, isCode, isGeneratedSegment, isIdentifierShaped, isInert, isMadePassword, isPath, isPodName, isRandomToken, isWordy, readsAsWords, } from "./values.js";
|
|
14
|
+
import { isSentenceWord } from "./named.js";
|
|
15
|
+
import { PGPASS } from "./normalize.js";
|
|
16
|
+
import { isPasswordlessUrl, leadingUrl } from "./urls.js";
|
|
17
|
+
import { stringListItems } from "./syntax.js";
|
|
18
|
+
import { publicBlocks } from "./publickeys.js";
|
|
19
|
+
import { isSessionCookie } from "./structure.js";
|
|
20
|
+
/**
|
|
21
|
+
* Under a name the name rules didn't take, in a secret file: a value no setting has, under a vendor's bare word
|
|
22
|
+
* (`STRIPE=<uuid>`, `OPENAI=Tiger1103#x`) or a name with a credential word that the in-text classifier reads otherwise
|
|
23
|
+
* (`BASIC_AUTH=Harbor2291!`, `MY_SECRET_THING=…`) or a session's (`INSTAGRAM_SESSION_ID`: names.ts namesSession): the
|
|
24
|
+
* file says its values are secrets. Not under a name a setting has (`APP_URL`, `SSH_AUTH_SOCK`), nor a value that reads
|
|
25
|
+
* as a setting: words, numbers, versions, paths, hosts and URLs (`DEBUG=true`, `REGION=us-east-1`). These findings
|
|
26
|
+
* still pass the decision stage (policy.ts), which keeps publishable keys and other public values.
|
|
27
|
+
*/
|
|
28
|
+
function isOpaqueSetting(name, v, tokenUnderAnyName) {
|
|
29
|
+
if (!mayHoldOpaque(name, tokenUnderAnyName) || isPodName(v))
|
|
30
|
+
return false;
|
|
31
|
+
// (Under a name that says nothing, `admin:` in `config/secrets.staging.yml`, only a generated token or a made password
|
|
32
|
+
// is one, and only in a file that holds nothing else: `.env` has settings and public IDs (`GPG_KEY=<fingerprint>`).
|
|
33
|
+
// Not one a word and a hyphen lead, `gradle-<hash>`: a cache's key.)
|
|
34
|
+
if (!(isBareVendorWord(name) || hasCredentialWord(name) || namesSession(name)))
|
|
35
|
+
return (isRandomToken(v, MIN_UNNAMED_TOKEN) || isMadePassword(v)) && !isSettingShaped(v) && !/^[a-z]{2,}-/i.test(v);
|
|
36
|
+
if (/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(v))
|
|
37
|
+
return true;
|
|
38
|
+
// (Letters alone are a word, `livemode`, unless random, `XGxAdcVIefCUMAVsDhkYhErsQwPz`. Digits with symbols are a
|
|
39
|
+
// password, `90210!@#`: a number a setting holds is setting-shaped.)
|
|
40
|
+
return !isSettingShaped(v) && (/[\d\W]/.test(v.replace(/[-_.]/g, "")) || isRandomToken(v));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether isOpaqueSetting can take a value under `name`, by the name alone: a session's, whose `ID` is no setting's;
|
|
44
|
+
* or not a setting's, and either a vendor's or credential's word, or any name in a file that holds only secrets.
|
|
45
|
+
*/
|
|
46
|
+
function mayHoldOpaque(name, tokenUnderAnyName) {
|
|
47
|
+
return (namesSession(name) ||
|
|
48
|
+
(!isSettingName(name) && (tokenUnderAnyName || isBareVendorWord(name) || hasCredentialWord(name))));
|
|
49
|
+
}
|
|
50
|
+
/** The shortest generated token taken as a secret under a name that says nothing of one: a 128-bit hex ID is 32; a 96-bit one, 24. */
|
|
51
|
+
const MIN_UNNAMED_TOKEN = 24;
|
|
52
|
+
/** One upper-case word: `STRIPE`, `OPENAI`. */
|
|
53
|
+
function isBareVendorWord(name) {
|
|
54
|
+
return /^[A-Z][A-Z0-9]{2,30}$/.test(name);
|
|
55
|
+
}
|
|
56
|
+
/** A host's name, and its port: `db.internal:5432`, `abc123def456.ngrok.io`. */
|
|
57
|
+
const HOST = /^[\w-]+(?:\.[a-z][\w-]*)+(?::\d+)?$/;
|
|
58
|
+
/**
|
|
59
|
+
* What settings hold: words, numbers, versions, hosts, emails, URLs without credentials, and a JSON object, whose
|
|
60
|
+
* pairs jsonPairs reads (`CORS={"origins":[…]}`). (Paths are inert, isNotSecret's.)
|
|
61
|
+
*/
|
|
62
|
+
function isSettingShaped(v) {
|
|
63
|
+
return (v.length < 8 ||
|
|
64
|
+
/\s/.test(v) ||
|
|
65
|
+
isPasswordlessUrl(v) ||
|
|
66
|
+
/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(v) ||
|
|
67
|
+
/^v?\d+(?:[.:-]\d+)*$/.test(v) ||
|
|
68
|
+
HOST.test(v) ||
|
|
69
|
+
isWordySetting(v) ||
|
|
70
|
+
isIdentifierShaped(v) ||
|
|
71
|
+
isBenignValue(v) ||
|
|
72
|
+
(v.startsWith("{") && isJsonDocument(v)));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Words and numbers as a setting or a name joins them (values.ts isWordy: `acme-prod-uploads-2024`,
|
|
76
|
+
* `refs/heads/main`), not a password made of them, which has a password rule's three classes (`Maple-Orbit-42`,
|
|
77
|
+
* `Summer2024!`).
|
|
78
|
+
*/
|
|
79
|
+
function isWordySetting(v) {
|
|
80
|
+
return isWordy(v) && !hasThreeClasses(v);
|
|
81
|
+
}
|
|
82
|
+
/** Words in sentence punctuation, no digits or symbols: `The PORT key is duplicated`, `ASCII text, with CRLF line terminators`. */
|
|
83
|
+
const PROSE_WORD = /^[\p{L}][\p{L}_'’.,:;!?()-]*$/u;
|
|
84
|
+
/** The fewest words of a sentence. (Two are a name and its qualifier, `Admin password`; a passphrase has none of a sentence's words.) */
|
|
85
|
+
const MIN_SENTENCE_WORDS = 3;
|
|
86
|
+
/** A sentence: a message or a report line, not a passphrase, which is words with none of a sentence's (`correct horse battery staple`). */
|
|
87
|
+
function isProse(value) {
|
|
88
|
+
const words = value.trim().split(/\s+/);
|
|
89
|
+
return words.length >= MIN_SENTENCE_WORDS && words.every((w) => PROSE_WORD.test(w)) && words.some(isSentenceWord);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A URL with a scheme that is no credential whole: no query or fragment and no path segment a generator made
|
|
93
|
+
* (`https://packages.microsoft.com/keys/microsoft.asc`). A password in it, or a token in its path, is hidden on its own,
|
|
94
|
+
* the URL around it kept: structure.ts reads a URL's password, the name layer a generated segment under a credential's
|
|
95
|
+
* name (named.ts).
|
|
96
|
+
*/
|
|
97
|
+
function isPlainUrl(value) {
|
|
98
|
+
const url = leadingUrl(value);
|
|
99
|
+
if (!url || url.end !== value.length || /[?#]/.test(value))
|
|
100
|
+
return false;
|
|
101
|
+
return url.segments.every((s) => !isGeneratedSegment(value.slice(s.start, s.end)));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A duration (`24h`, `90s`, `1h30m`) or a list of words (`["ci" "default"]`, a token's policies): a setting a secret
|
|
105
|
+
* manager's listing shows. (Words of letters: `["hunter2hunter2", "letmein2024"]` are passwords. Two or three letters,
|
|
106
|
+
* `ci`, `qa`, are an abbreviation the model reads no word in.)
|
|
107
|
+
*/
|
|
108
|
+
function isSettingList(value) {
|
|
109
|
+
return (/^(?:\d+(?:\.\d+)?(?:ns|us|ms|s|m|h|d|w))+$/.test(value) ||
|
|
110
|
+
(/^\[/.test(value) &&
|
|
111
|
+
(stringListItems(value)?.every((item) => /^[a-z][a-z_-]{0,30}$/.test(item.value) && (item.value.length <= 3 || readsAsWords(item.value))) ??
|
|
112
|
+
false)));
|
|
113
|
+
}
|
|
114
|
+
/** A value a secret file holds that is no secret: an inert one (values.ts), a plain URL, a sentence, a file's name, a setting. */
|
|
115
|
+
function isNotSecret(value) {
|
|
116
|
+
return isInert(value) || isPlainUrl(value) || isProse(value) || isConfigFileName(value) || isSettingList(value);
|
|
117
|
+
}
|
|
118
|
+
/** Where group `g` of a match made with the `d` flag starts and ends. */
|
|
119
|
+
function at(m, g) {
|
|
120
|
+
return m.indices[g];
|
|
121
|
+
}
|
|
122
|
+
/** A number that is a setting (a port, a timeout), unless the name says password or PIN (or, for 4 digits or more, secret). */
|
|
123
|
+
function isSettingNumber(name, value) {
|
|
124
|
+
if (!/^\d{1,19}$/.test(value))
|
|
125
|
+
return false;
|
|
126
|
+
// (By the name's words: `spinner_delay` and `shipping_days` hold no PIN.)
|
|
127
|
+
return value.length <= 3 ? !namesPasswordOrPin(name) : !(namesPasswordOrPin(name) || words(name).includes("SECRET"));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A file that is one line with no name in it is its value: a password file, a passphrase with spaces; or
|
|
131
|
+
* `user:password`, as a credentials file holds it. (Not a `NAME=value` line, a diff's or an `export`'s
|
|
132
|
+
* included, nor a URL: those are read by their parts. A comment is `#` and a blank, a section `[…]`, an object
|
|
133
|
+
* `{…}`, markup `<…>`. A name before `=` or `:` is a name when it reads as one: `db_password=`, `API_KEY:`,
|
|
134
|
+
* `dbPassword=`; `Hx7m=Q2pL` and `kX9:mQ2#pLw7` are one value. Brackets around one token are part of it:
|
|
135
|
+
* `{Kd8pQ2zX9mL}`.)
|
|
136
|
+
*/
|
|
137
|
+
function wholeFile(text, out) {
|
|
138
|
+
const only = text.trim();
|
|
139
|
+
if (!only || only.includes("\n"))
|
|
140
|
+
return;
|
|
141
|
+
const start = text.indexOf(only); // (the first non-blank character: `only` is `text` trimmed)
|
|
142
|
+
if (isOneValueLine(only)) {
|
|
143
|
+
out.push({ start, end: start + only.length, kind: "secret", rule: "file:whole", confidence: 0.95 });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const pair = /^[\w.@-]{1,64}:(\S{4,4096})$/d.exec(only);
|
|
147
|
+
// (A `.pgpass` line's password is pgpassLines', which the whole line gives way to: spans.ts preferNarrower.)
|
|
148
|
+
if (pair && !/:\/\//.test(only) && !isInert(pair[1]) && !isPlaceholder(pair[1]) && !/^\d{1,5}$/.test(pair[1])) {
|
|
149
|
+
out.push({
|
|
150
|
+
start: start + at(pair, 1)[0],
|
|
151
|
+
end: start + at(pair, 1)[1],
|
|
152
|
+
kind: "password",
|
|
153
|
+
rule: "file:whole",
|
|
154
|
+
confidence: 0.95,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The keyword a shell's listing of its variables puts before each: `export NAME=…` (`export -p` in sh and zsh), bash's
|
|
160
|
+
* `declare -x NAME="…"` (its `declare -p` and `export -p`), zsh's `typeset -x`.
|
|
161
|
+
*/
|
|
162
|
+
const DECLARATION = /(?:export|(?:declare|typeset)[ \t]+-[\w-]+)[ \t]+/;
|
|
163
|
+
/** The name a line starts by assigning, a diff's or a declaration's included. */
|
|
164
|
+
const DECLARED_NAME = new RegExp(String.raw `^[+ -]?(?:${DECLARATION.source})?([\w.-]+)[=:]`);
|
|
165
|
+
/** A line that is one value: not `NAME=value` or `NAME: value`, a URL, a comment, a section, a document, a netrc entry or a reference. */
|
|
166
|
+
function isOneValueLine(line) {
|
|
167
|
+
const named = DECLARED_NAME.exec(line)?.[1];
|
|
168
|
+
const nameShaped = named !== undefined && /^(?:[a-z_][a-z0-9_.-]*|[A-Z_][A-Z0-9_.-]*|[a-z]+(?:[A-Z][a-z]+)+)$/.test(named);
|
|
169
|
+
if (line.length > 1000 || /[=:]\s|:\/\//.test(line) || nameShaped)
|
|
170
|
+
return false;
|
|
171
|
+
// One token in brackets is the value, brackets and all: `{Kd8pQ2zX9mL}`.
|
|
172
|
+
if (/^(?:\[[^\s\]]*\]|\{[^\s}]*\}|<[^\s>]*>)$/.test(line) && !/[:,=]/.test(line))
|
|
173
|
+
return true;
|
|
174
|
+
// (A netrc entry is read by its keywords, spacedKeywords: `machine github.com login x password ${GITHUB_TOKEN}`.)
|
|
175
|
+
const structured = /^#[ \t!#]|^\[[^\]]*\]$|^\{[\s\S]*\}$|^<[\s\S]*>$|^["{[][\s\S]*[:,]|^(?:machine[ \t]+\S+|default)[ \t]+(?:login|password|account)[ \t]/.test(line);
|
|
176
|
+
// (Nor a keyword and a reference: `requirepass ${REDIS_PASSWORD}`, its value read elsewhere.)
|
|
177
|
+
const keywordReference = /^[\w.-]+[ \t]+(?:\$\{[^{}\n]+\}|\$[A-Za-z_]\w*)$/.test(line);
|
|
178
|
+
return (!structured &&
|
|
179
|
+
!keywordReference &&
|
|
180
|
+
!isNotSecret(line) &&
|
|
181
|
+
!(line.includes("=") && isInert(line.slice(line.lastIndexOf("=") + 1))));
|
|
182
|
+
}
|
|
183
|
+
/** `.pgpass` lines: the last field. */
|
|
184
|
+
function pgpassLines(text, out) {
|
|
185
|
+
for (const m of text.matchAll(new RegExp(PGPASS.source, "gmd")))
|
|
186
|
+
out.push({ start: at(m, 1)[0], end: at(m, 1)[1], kind: "password", rule: "file:named", confidence: 1 });
|
|
187
|
+
}
|
|
188
|
+
/** A `NAME=value` or `name: value` line (assignments): its head (a line number, a declaration, a list's `- `), name, separator and value. */
|
|
189
|
+
const ASSIGNMENT_LINE = new RegExp(String.raw `^([ \t]{0,40}(?:\d{1,7}(?:[\t→:|]|[ \t]{1,4})[ \t]*)?(?:${DECLARATION.source}|-[ \t]+|\+[ \t]*)?["']?)([\w.\-/@]{1,200})(["']?[ \t]{0,20}(?:=|:(?!\/\/))[ \t]{0,20})([^\r\n]{1,4000})\r?$`, "dgm");
|
|
190
|
+
/**
|
|
191
|
+
* `NAME=value`, `name: value`, `"name": "value",` lines, and `+` lines of a patch that adds the file: the value under
|
|
192
|
+
* a credential's name, or an opaque one under any name that says nothing of a secret (isOpaqueSetting). (Published
|
|
193
|
+
* examples, `AKIAIOSFODNN7EXAMPLE`, are placeholders even in a secret file.)
|
|
194
|
+
*/
|
|
195
|
+
function assignments(text, out, tokenUnderAnyName) {
|
|
196
|
+
for (const m of text.matchAll(ASSIGNMENT_LINE)) {
|
|
197
|
+
if (PGPASS.test(m[0]))
|
|
198
|
+
continue;
|
|
199
|
+
const name = m[2];
|
|
200
|
+
// Trailing blanks and a ` # comment` are cut in code: a regex for them backtracks on long blank runs.
|
|
201
|
+
const raw = m[4].trimEnd();
|
|
202
|
+
// A quoted value keeps its quotes, a JSON line its trailing comma, a `.env` line its ` # comment`; a line of
|
|
203
|
+
// several JSON pairs is left to jsonPairs.
|
|
204
|
+
// (Its quote inside written as YAML and SQL write it, doubled, `'it''s'`, or as a shell does, `'it'\''s'`.)
|
|
205
|
+
const quoted = raw.match(/^(["'`])((?:\1\\\1\1|\1\1|\\.|(?!\1|\\).|\\(?=\1(?:[,;]|[ \t]+#.*)?$))*)\1(?:[,;]|[ \t]+#.*)?$/);
|
|
206
|
+
if (!quoted && /^["'`]/.test(raw))
|
|
207
|
+
continue;
|
|
208
|
+
let value = quoted ? quoted[2] : raw;
|
|
209
|
+
// An unquoted JSON value (`"token": true, "x": 1`) ends at its delimiter.
|
|
210
|
+
if (!quoted && m[1].endsWith('"'))
|
|
211
|
+
value = value.replace(/[ \t]*[,}\]][\s\S]*$/, "");
|
|
212
|
+
const comment = quoted ? -1 : value.search(/[ \t]#/);
|
|
213
|
+
if (comment >= 0)
|
|
214
|
+
value = value.slice(0, comment).trimEnd();
|
|
215
|
+
// (A vendor's name alone holds the vendor's key, or where the vendor is: `NGROK=abc123def456.ngrok.io` is a host.)
|
|
216
|
+
const credential = (isCredentialName(name) && !(isVendorName(name) && HOST.test(value))) || /^pass$/i.test(name);
|
|
217
|
+
// (The name first: most of a large file's lines, a state file's `"name": "svc-0"`, are settings by it alone, and the
|
|
218
|
+
// value's checks cost more.)
|
|
219
|
+
if (!credential && !mayHoldOpaque(name, tokenUnderAnyName))
|
|
220
|
+
continue;
|
|
221
|
+
// (A reference in code, `password: process.env.DB_PASSWORD,` in a line `grep` found in source.)
|
|
222
|
+
if (!value ||
|
|
223
|
+
isNotSecret(value) ||
|
|
224
|
+
isCode(value, true) ||
|
|
225
|
+
(value.length >= 8 && !/^\d+$/.test(value) && isPlaceholder(value)) ||
|
|
226
|
+
/^[|>][-+]?$|^[{[]$|^(true|false|null)$/i.test(value) ||
|
|
227
|
+
isSettingNumber(name, value))
|
|
228
|
+
continue;
|
|
229
|
+
if (!credential && !isOpaqueSetting(name, value, tokenUnderAnyName))
|
|
230
|
+
continue;
|
|
231
|
+
const start = at(m, 4)[0] + (quoted ? 1 : 0);
|
|
232
|
+
out.push({
|
|
233
|
+
start,
|
|
234
|
+
end: start + value.length,
|
|
235
|
+
kind: "secret",
|
|
236
|
+
rule: credential ? "file:named" : "file:opaque",
|
|
237
|
+
name,
|
|
238
|
+
confidence: credential ? 1 : 0.9,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** A table's own word in a value's place, not a value: an empty cell's (`-`, `n/a`, `null`) or a header's (`value`, `type`). */
|
|
243
|
+
const TABLE_WORD = /^(?:true|false|null|n\/a|-+|values?|data|type|description)$/i;
|
|
244
|
+
/** Table rows, as secret managers print them: `client_secret abc123` (vault kv get), `│ KEY │ value │` (doppler). */
|
|
245
|
+
function tableRows(text, out) {
|
|
246
|
+
for (const m of text.matchAll(/^(?:[ \t]*[│|┃║][ \t]*|[ \t]*)([\w.\-/@]{1,200})(?:[ \t]{2,}|[ \t]*[│|┃║][ \t]*)([^\s│|┃║](?:[^│|┃║\r\n]{0,4000}?[^\s│|┃║])?)[ \t]*(?:[│|┃║][^\r\n]*)?\r?$/dgm)) {
|
|
247
|
+
const [, name, value] = m;
|
|
248
|
+
if (!isCredentialName(name) ||
|
|
249
|
+
isNotSecret(value) ||
|
|
250
|
+
isPlaceholder(value) ||
|
|
251
|
+
TABLE_WORD.test(value) ||
|
|
252
|
+
isSettingNumber(name, value))
|
|
253
|
+
continue;
|
|
254
|
+
out.push({ start: at(m, 2)[0], end: at(m, 2)[1], kind: "secret", rule: "file:table", confidence: 1 });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* JSON pairs anywhere, minified files included: `{"client_secret":"…","refresh_token":"…"}`, `"pin":4821`. Read as pairs,
|
|
259
|
+
* not parsed: a secret file shown by a viewer has its line numbers in front (` 3→ "token": "…",`), and one cut
|
|
260
|
+
* short (`head -c`) isn't a whole document.
|
|
261
|
+
*/
|
|
262
|
+
function jsonPairs(text, out) {
|
|
263
|
+
for (const m of text.matchAll(/"([\w.\-]{1,100})"[ \t]*:[ \t]*(?:"((?:[^"\\\n]|\\.){1,4096})"|(-?\d[\d.]{0,40})(?=[ \t]*(?:[,}\]\r\n]|$)))/dg)) {
|
|
264
|
+
const name = m[1];
|
|
265
|
+
const g = m[2] !== undefined ? 2 : 3;
|
|
266
|
+
const value = m[g];
|
|
267
|
+
if (!isCredentialName(name) ||
|
|
268
|
+
isNotSecret(value) ||
|
|
269
|
+
(value.length >= 8 && !/^\d+$/.test(value) && isPlaceholder(value)) ||
|
|
270
|
+
/^(true|false|null)$/i.test(value) ||
|
|
271
|
+
isSettingNumber(name, value))
|
|
272
|
+
continue;
|
|
273
|
+
out.push({ start: at(m, g)[0], end: at(m, g)[1], kind: "secret", rule: "file:json", confidence: 1 });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* netrc and space-separated configs: `machine h login u password p`, `requirepass p`. The keyword starts a line or
|
|
278
|
+
* follows another `keyword value` pair; comments and prose (`# the secret is rotated monthly`) are skipped. A shell
|
|
279
|
+
* expansion is read whole, blanks and all (`requirepass ${REDIS_PASSWORD:?set it first}`: a reference, kept).
|
|
280
|
+
*/
|
|
281
|
+
function spacedKeywords(text, out) {
|
|
282
|
+
for (const m of text.matchAll(/(?:^[ \t]*|(?:^|[ \t])(?:machine|login|default|account)[ \t]+\S{1,200}[ \t]+)(?:password|passwd|account|secret|token|requirepass|masterauth|rootpw|adminpass)[ \t]+(?:"([^"\n]+)"|'([^'\n]+)'|(\$\{[^{}\n]+\}|[^\s"',;]{1,4096}))/dgim)) {
|
|
283
|
+
const g = m[1] !== undefined ? 1 : m[2] !== undefined ? 2 : 3;
|
|
284
|
+
const v = m[g];
|
|
285
|
+
// (Nor a table's word: `secret value` is a header, as tableRows reads it.)
|
|
286
|
+
if (v.length < 4 || isInert(v) || COMMON_WORDS.has(v.toLowerCase()) || TABLE_WORD.test(v))
|
|
287
|
+
continue;
|
|
288
|
+
out.push({ start: at(m, g)[0], end: at(m, g)[1], kind: "password", rule: "file:spaced", confidence: 0.95 });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** pgbouncer's userlist: `"user" "password"`. */
|
|
292
|
+
function userlist(text, out) {
|
|
293
|
+
for (const m of text.matchAll(/^"[^"\n]{1,100}"[ \t]+"([^"\n]{4,500})"/dgm))
|
|
294
|
+
out.push({ start: at(m, 1)[0], end: at(m, 1)[1], kind: "password", rule: "file:userlist", confidence: 0.9 });
|
|
295
|
+
}
|
|
296
|
+
/** Netscape cookies.txt: the value is the seventh tab-separated field, judged as structure.ts judges a cookie jar's (a session's, not a preference's). */
|
|
297
|
+
function cookiesTxt(text, out) {
|
|
298
|
+
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]{4,4096})$/dgm)) {
|
|
299
|
+
if (isSessionCookie(m[1], m[2]))
|
|
300
|
+
out.push({ start: at(m, 2)[0], end: at(m, 2)[1], kind: "cookie", rule: "file:cookies", confidence: 0.95 });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** htpasswd: `user:<hash>`, a crypt hash's `$id$` (`$apr1$…`, `$2y$…`) or `{SSHA}`. (Not `admin:$ADMIN_PASSWORD`: a variable.) */
|
|
304
|
+
function htpasswd(text, out) {
|
|
305
|
+
for (const m of text.matchAll(/^[\w.@-]{1,100}:(\$[\w.+=-]{1,30}\$[\w$./+=-]{8,300}|\{SSHA\}[\w+/=]{10,200})[ \t]*$/dgm))
|
|
306
|
+
out.push({ start: at(m, 1)[0], end: at(m, 1)[1], kind: "password hash", rule: "file:hash", confidence: 1 });
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Redis's ACL rules, `user worker on >password ~jobs:* +@all` (redis.conf, an aclfile): a password given or taken away,
|
|
310
|
+
* `>pw` and `<pw`, and one given or taken away as its SHA-256, `#<hash>` and `!<hash>`.
|
|
311
|
+
*/
|
|
312
|
+
function redisUsers(text, out) {
|
|
313
|
+
for (const line of text.matchAll(/^[ \t]*user[ \t]+\S+[ \t]+[^\r\n]+/gm))
|
|
314
|
+
for (const m of line[0].matchAll(/[ \t](?:[<>](\S+)|[#!]([0-9a-f]{64})(?![^ \t]))/dg)) {
|
|
315
|
+
const g = m[1] !== undefined ? 1 : 2;
|
|
316
|
+
const [start, end] = at(m, g);
|
|
317
|
+
out.push({
|
|
318
|
+
start: line.index + start,
|
|
319
|
+
end: line.index + end,
|
|
320
|
+
kind: g === 1 ? "password" : "password hash",
|
|
321
|
+
rule: "file:redis-acl",
|
|
322
|
+
confidence: 1,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* A string literal of escaped bytes: key material as code writes binary, Symfony's vault key (`prod.decrypt.private.php`
|
|
328
|
+
* holds `return "\x8E\x1Fq\xC4…";`), a Python or C byte string. Read from 16 escaped bytes, half the string or more: fewer
|
|
329
|
+
* are a setting's control characters (`PS1="\x1b[32m$ "`).
|
|
330
|
+
*/
|
|
331
|
+
function escapedBytes(text, out) {
|
|
332
|
+
if (!/\\(?:x[0-9A-Fa-f]{2}|[0-7]{3})/.test(text))
|
|
333
|
+
return;
|
|
334
|
+
for (const m of text.matchAll(/"((?:\\.|[^"\\\r\n]){16,8192})"|'((?:\\.|[^'\\\r\n]){16,8192})'/dg)) {
|
|
335
|
+
const g = m[1] !== undefined ? 1 : 2;
|
|
336
|
+
const escaped = m[g].match(/\\(?:x[0-9A-Fa-f]{2}|[0-7]{3})/g)?.length ?? 0;
|
|
337
|
+
const bytes = m[g].replace(/\\(?:x[0-9A-Fa-f]{2}|[0-7]{3}|.)/g, ".").length;
|
|
338
|
+
if (escaped < 16 || escaped * 2 < bytes)
|
|
339
|
+
continue;
|
|
340
|
+
out.push({ start: at(m, g)[0], end: at(m, g)[1], kind: "secret", rule: "file:bytes", confidence: 0.95 });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Lines that are one token (a bare token file, `cut -d= -f2` output), after any line number a file viewer puts in
|
|
345
|
+
* front (`cat -n`, an agent's Read tool ` 3→`). Not a NAME, an assignment, a comment, a section or a rule, nor
|
|
346
|
+
* a line inside a certificate or public-key block stored next to a private key: those are public.
|
|
347
|
+
*/
|
|
348
|
+
function bareTokens(text, out) {
|
|
349
|
+
const publicBlocks = certificateBlocks(text);
|
|
350
|
+
let block = 0;
|
|
351
|
+
for (const m of text.matchAll(/^[ \t]*(?:\d{1,7}[\t→][ \t]*)?([A-Za-z0-9+/=_.~!@#$%^&*-]{8,})[ \t]*\r?$/dgm)) {
|
|
352
|
+
while (block < publicBlocks.length && publicBlocks[block][1] <= m.index)
|
|
353
|
+
block++;
|
|
354
|
+
if (block < publicBlocks.length && m.index >= publicBlocks[block][0])
|
|
355
|
+
continue;
|
|
356
|
+
const t = m[1];
|
|
357
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) &&
|
|
358
|
+
t === t.toUpperCase() &&
|
|
359
|
+
t
|
|
360
|
+
.split("_")
|
|
361
|
+
.filter(Boolean)
|
|
362
|
+
.every((w) => readsAsWords(w.toLowerCase()) || /^\d+$/.test(w)))
|
|
363
|
+
continue;
|
|
364
|
+
// (A name and `=` or `:`; not padded base64, whose `=` pads: `Zm9vYmFyYg==`.)
|
|
365
|
+
if (/^[A-Za-z_][\w.-]{0,80}[ \t]*(?::|=(?!=$))/.test(t))
|
|
366
|
+
continue;
|
|
367
|
+
// (A comment, `#…`, or a rule: `-----+-----` under psql's header, `+----+` around MySQL's, `|---|` in Markdown.)
|
|
368
|
+
if (isInert(t) || (isWordySetting(t) && naturalness(t) > 0.5) || /^#|^-{3}|^=+$|^[+|][-+=|]+$/.test(t))
|
|
369
|
+
continue;
|
|
370
|
+
out.push({ start: at(m, 1)[0], end: at(m, 1)[1], kind: "secret", rule: "file:bare", confidence: 0.9 });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** The shapes of line a secret file holds, each read where it is. */
|
|
374
|
+
const SECRET_FILE_READERS = [
|
|
375
|
+
wholeFile,
|
|
376
|
+
pgpassLines,
|
|
377
|
+
assignments,
|
|
378
|
+
tableRows,
|
|
379
|
+
jsonPairs,
|
|
380
|
+
spacedKeywords,
|
|
381
|
+
userlist,
|
|
382
|
+
cookiesTxt,
|
|
383
|
+
htpasswd,
|
|
384
|
+
redisUsers,
|
|
385
|
+
escapedBytes,
|
|
386
|
+
bareTokens,
|
|
387
|
+
];
|
|
388
|
+
/**
|
|
389
|
+
* In a secret file, every value under a credential name is hidden whatever it looks like, and so is a
|
|
390
|
+
* line that is a single token (a bare token file, `cut -d= -f2` output); see isOpaqueSetting for other names.
|
|
391
|
+
* `path` is the file's, when known: a file that holds only secrets (`secrets.yml`) hides a generated token under any name.
|
|
392
|
+
*/
|
|
393
|
+
export function findInSecretFile(text, { part = false, path = "" } = {}) {
|
|
394
|
+
const out = [];
|
|
395
|
+
const secretsOnly = holdsOnlySecrets(path);
|
|
396
|
+
// (`part`: lines added to a file, `echo x >> .env`, aren't the whole file.)
|
|
397
|
+
for (const read of SECRET_FILE_READERS)
|
|
398
|
+
if (!(part && read === wholeFile))
|
|
399
|
+
read(text, out, secretsOnly);
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
/** Spans of public armored blocks (certificates, public keys, parameters, signatures), in order. */
|
|
403
|
+
const certificateBlocks = (text) => publicBlocks(text).map((b) => [b.start, b.end]);
|
|
404
|
+
/**
|
|
405
|
+
* The first line with something on it, its blanks trimmed (`start`, `value`), and where the line ends (`end`). Scanned,
|
|
406
|
+
* not matched: a regex for the trailing blanks backtracks quadratically on a long run of them.
|
|
407
|
+
*/
|
|
408
|
+
function firstLine(text) {
|
|
409
|
+
let start = 0;
|
|
410
|
+
while (start < text.length && " \t\r\n".includes(text[start]))
|
|
411
|
+
start++;
|
|
412
|
+
let end = start;
|
|
413
|
+
while (end < text.length && !"\r\n\u2028\u2029".includes(text[end]))
|
|
414
|
+
end++;
|
|
415
|
+
let stop = end;
|
|
416
|
+
while (stop > start && (text[stop - 1] === " " || text[stop - 1] === "\t"))
|
|
417
|
+
stop--;
|
|
418
|
+
return {
|
|
419
|
+
start,
|
|
420
|
+
value: text.slice(start, stop),
|
|
421
|
+
end: end + (text[end] === "\r" && (end + 1 === text.length || "\r\n\u2028\u2029".includes(text[end + 1])) ? 1 : 0),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The credential in the output of a command that prints one: its first line, whole (a passphrase of words, a PIN, a
|
|
426
|
+
* password that starts with `[`), unless the line is a message (`Error: …`), a record's field (`ID: …`), a reference a
|
|
427
|
+
* variable holds instead (`op://vault/item/field`), a plain URL or address, or a JSON document whole (its pairs'
|
|
428
|
+
* readers judge it); and later lines that are one token, as `pass show` prints its metadata under the password.
|
|
429
|
+
* `--output text` rows of AWS's.
|
|
430
|
+
*/
|
|
431
|
+
export function findPrintedSecret(text) {
|
|
432
|
+
const out = [];
|
|
433
|
+
const add = (start, end) => out.push({ start, end, kind: "credential", rule: "command:prints-secret", confidence: 1 });
|
|
434
|
+
// `--output text` rows: SSM's value follows its type, Secrets Manager's is the fourth field.
|
|
435
|
+
for (const m of text.matchAll(/^(?:PARAMETER\t[^\n]*?\t(?:SecureString|String|StringList)\t([^\t\n]+)\t\d+|arn:aws:secretsmanager:[^\t\n]+\t[^\t\n]*\t[^\t\n]*\t([^\t\n]+)(?:\t[^\n]*)?)\r?$/dgm)) {
|
|
436
|
+
const [start, end] = at(m, m[1] !== undefined ? 1 : 2);
|
|
437
|
+
add(start, end);
|
|
438
|
+
}
|
|
439
|
+
if (out.length)
|
|
440
|
+
return out;
|
|
441
|
+
// (A path is where a credential is, not one: `GOOGLE_APPLICATION_CREDENTIALS=/workspace/secrets/sa.json`.)
|
|
442
|
+
const notSecret = (line) => isInert(line) ||
|
|
443
|
+
isPath(line.replace(/^[A-Za-z_][\w.-]*=/, "")) ||
|
|
444
|
+
/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(line) ||
|
|
445
|
+
isPasswordlessUrl(line) ||
|
|
446
|
+
isJsonDocument(line);
|
|
447
|
+
const first = firstLine(text);
|
|
448
|
+
const firstEnd = first.end;
|
|
449
|
+
// (A value printed as a JSON string is judged as what it holds, and hidden inside its quotes.)
|
|
450
|
+
const firstValue = jsonString(first.value) ?? first.value;
|
|
451
|
+
const quote = firstValue === first.value ? 0 : 1;
|
|
452
|
+
if (firstValue && !notSecret(firstValue) && !/^[{[]$/.test(firstValue) && readsAsValue(firstValue))
|
|
453
|
+
add(first.start + quote, first.start + first.value.length - quote);
|
|
454
|
+
for (const m of text.slice(firstEnd).matchAll(/^[ \t]*(\S{6,})[ \t]*\r?$/dgm)) {
|
|
455
|
+
const value = jsonString(m[1]) ?? m[1];
|
|
456
|
+
const q = value === m[1] ? 0 : 1;
|
|
457
|
+
// (A message's tokens are not values; `[`/`{` start a JSON document's lines.)
|
|
458
|
+
if (isMessageToken(value) || /^[{[]/.test(value) || notSecret(value))
|
|
459
|
+
continue;
|
|
460
|
+
add(firstEnd + at(m, 1)[0] + q, firstEnd + at(m, 1)[1] - q);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The shape of a CLI's message rather than a value: it opens with a message's word (`no oauth token found`, `Error …`,
|
|
466
|
+
* `Please run …`, `Warning token expires in 3 days`), says something isn't (`You are not logged into …`, `token does
|
|
467
|
+
* not exist`), or ends on what happened (`Permission denied`, `An error occurred`). A message word elsewhere in the
|
|
468
|
+
* line is just a word: `correct horse no battery staple` is a passphrase.
|
|
469
|
+
*/
|
|
470
|
+
const MESSAGE_SHAPE = /^(?:no|not|error|errors|warning|fatal|usage|note|hint|failed|failure|invalid|unable|cannot|can't|couldn't|please|run|missing|unauthorized|forbidden)\b|\b(?:is|are|was|were|do|does|did|has|have|can|could|will|must)(?:n't| not)\b|\b(?:found|denied|expired|occurred|exists?|failed)$/i;
|
|
471
|
+
/**
|
|
472
|
+
* One token that is a message's, not a value: a label (`Error:`), a status word with its stop (`Done.`, `Saved!`), or
|
|
473
|
+
* a word with a message's shape (`Unauthorized`, `expired`). A letters-only word otherwise is a value, with a password's
|
|
474
|
+
* own stop too (`monkeykingdom`, `sunshine!`, `letmein.`).
|
|
475
|
+
*/
|
|
476
|
+
const isMessageToken = (token) => /^\p{L}+:$/u.test(token) ||
|
|
477
|
+
/^(?:done|ok|okay|success|succeeded|saved|copied|deleted|removed|updated|created|finished|complete|completed|ready|aborted|cancell?ed|warning)[.!]$/i.test(token) ||
|
|
478
|
+
MESSAGE_SHAPE.test(token);
|
|
479
|
+
/**
|
|
480
|
+
* Whether a credential command's first line reads as the value it prints, not a sentence about it: one token, or a
|
|
481
|
+
* passphrase's words (up to 24, no sentence punctuation, not a message's shape). A record's field, `op item get`'s
|
|
482
|
+
* `ID: …`, has a label's colon: its fields are read by name.
|
|
483
|
+
*/
|
|
484
|
+
function readsAsValue(line) {
|
|
485
|
+
if (!/\s/.test(line))
|
|
486
|
+
return !isMessageToken(line);
|
|
487
|
+
const words = line.split(/\s+/);
|
|
488
|
+
return words.length <= 24 && words.every((w) => /^[\p{L}\p{N}-]+$/u.test(w)) && !MESSAGE_SHAPE.test(line);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Whether a line is a JSON document whole: an object, an array, or a string that holds one (output encoded once more,
|
|
492
|
+
* `--query SecretString`'s `"{\"password\": …}"`). A string that holds anything else is a value (jsonString).
|
|
493
|
+
*/
|
|
494
|
+
function isJsonDocument(line) {
|
|
495
|
+
if (!/^(?:[{[].*[}\]]|".*")$/.test(line))
|
|
496
|
+
return false;
|
|
497
|
+
try {
|
|
498
|
+
const v = JSON.parse(line);
|
|
499
|
+
return typeof v !== "string" || isJsonDocument(v.trim());
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** What a line that is one JSON string holds (`az … --query value` prints its value so, `"…"`), or undefined. */
|
|
506
|
+
function jsonString(line) {
|
|
507
|
+
if (!/^".+"$/.test(line))
|
|
508
|
+
return undefined;
|
|
509
|
+
try {
|
|
510
|
+
const v = JSON.parse(line);
|
|
511
|
+
return typeof v === "string" ? v : undefined;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Prompt lines: a terminal prompt (shell.ts PROMPT) at a line's start, before a command. */
|
|
518
|
+
const PROMPT_LINES = new RegExp(PROMPT.source, "gm");
|
|
519
|
+
/** In a document, a code fence closes the session it shows: what follows isn't the last command's output. */
|
|
520
|
+
const FENCE = /^[ \t]*(?:```|~~~)/m;
|
|
521
|
+
/**
|
|
522
|
+
* The commands a notebook cell runs with a bare `!` (`!cat .env`, `!pip install …`): a `!` line is a prompt only before
|
|
523
|
+
* one of them, as a value (`!qaz2wsx`) or a MySQL option file's `!include` isn't.
|
|
524
|
+
*/
|
|
525
|
+
const NOTEBOOK_COMMAND = /^!(?:cat|head|tail|less|more|grep|ls|echo|printf|env|printenv|export|pip3?|python3?|conda|git|curl|wget|cd|sudo|bash|sh|source|type|sops|op|docker|kubectl|gcloud|aws|az|npm|apt(?:-get)?|which|find|wc|unzip|mkdir|rm|cp|mv)\b/;
|
|
526
|
+
/** The prompt lines of a text: where each starts and ends, and its command. */
|
|
527
|
+
function promptLines(text) {
|
|
528
|
+
return [...text.matchAll(PROMPT_LINES)]
|
|
529
|
+
.filter((m) => !/^[ \t]*!/.test(m[0]) || NOTEBOOK_COMMAND.test(m[0].trimStart()))
|
|
530
|
+
.map((m) => ({ start: m.index, end: m.index + m[0].length, command: m[1] }));
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Where the first prompt line inside a command's output starts (a later command), or its length if none. (Not a `!`
|
|
534
|
+
* line: in a command's output that is a value or an option file's directive, not a notebook's command.)
|
|
535
|
+
*/
|
|
536
|
+
export function firstPrompt(text) {
|
|
537
|
+
return (promptLines(text).find((p) => p.start > 0 && !/^[ \t]*!/.test(text.slice(p.start, p.end)))?.start ?? text.length);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Command transcripts inside a text (a notebook's `!cat .env`, a pasted terminal session's
|
|
541
|
+
* `$ cat ~/.aws/credentials`): the output under a prompt that prints a secret file, up to the next
|
|
542
|
+
* prompt or a closing fence, is treated as that file.
|
|
543
|
+
*/
|
|
544
|
+
export function findEmbeddedOutputs(text) {
|
|
545
|
+
const out = [];
|
|
546
|
+
for (const { command, start, end } of commandOutputs(text)) {
|
|
547
|
+
const credential = printsSecret(command);
|
|
548
|
+
if (!credential && !printsSecretFile(command))
|
|
549
|
+
continue;
|
|
550
|
+
const body = text.slice(start, end);
|
|
551
|
+
for (const f of credential ? findPrintedSecret(body) : findInSecretFile(body))
|
|
552
|
+
out.push({ ...f, start: f.start + start, end: f.end + start });
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
/** Each prompt's command and its output: the lines after it, up to the next prompt or a code fence. */
|
|
557
|
+
function commandOutputs(text) {
|
|
558
|
+
const prompts = promptLines(text);
|
|
559
|
+
return prompts.map((p, i) => {
|
|
560
|
+
const start = Math.min(text.length, p.end + 1);
|
|
561
|
+
let end = i + 1 < prompts.length ? prompts[i + 1].start : text.length;
|
|
562
|
+
const fence = text.slice(start, end).search(FENCE);
|
|
563
|
+
if (fence >= 0)
|
|
564
|
+
end = start + fence;
|
|
565
|
+
return { command: p.command.slice(0, 2000), prompt: p.start, start, end: Math.max(start, end) };
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Where a secret file's content sits inside a text, as [start, end) spans, in order and apart:
|
|
570
|
+
* - a diff of one (`git diff` of `.env.production`): its hunks, once normalization has removed their markers;
|
|
571
|
+
* - what a command line writes into one (commands.ts commandsRun reads it, through `sudo sh -c`, `ssh`, a prompt): a
|
|
572
|
+
* heredoc's body (`cat > .env <<'EOF'`, `sudo tee .env > /dev/null <<EOF`, `cat <<EOF | tee .env`), a here-string
|
|
573
|
+
* (`tee .env <<< "…"`), or `echo`'s and `printf`'s operands (`echo 'Harbor#2291x' > ~/.vault_pass`,
|
|
574
|
+
* `{ echo A=1; echo B=2; } > .env`).
|
|
575
|
+
* Not a SOPS file, encrypted by design. A command line inside an earlier heredoc's body is that body's text.
|
|
576
|
+
*/
|
|
577
|
+
export function secretFileSections(text) {
|
|
578
|
+
return readWrites(text).sections;
|
|
579
|
+
}
|
|
580
|
+
/** The secret files' sections of a text (secretFileSections), and the heredocs open at its end that write one. */
|
|
581
|
+
function readWrites(text, inBody = false) {
|
|
582
|
+
const sections = [];
|
|
583
|
+
// (Where a `sops:` line starts, found once: a heredoc that never ends reaches the text's end, and slicing to it per line is quadratic.)
|
|
584
|
+
let sops;
|
|
585
|
+
const holdsSops = ({ start, end }) => {
|
|
586
|
+
sops ??= [...text.matchAll(/^sops:/gm)].map((m) => m.index);
|
|
587
|
+
return text.startsWith("sops:", start) || sops[lowerBound(sops, start)] < end;
|
|
588
|
+
};
|
|
589
|
+
const add = (section) => {
|
|
590
|
+
if (section.end > section.start && !holdsSops(section))
|
|
591
|
+
sections.push(section);
|
|
592
|
+
};
|
|
593
|
+
let diff;
|
|
594
|
+
if (/^\+\+\+ /m.test(text)) {
|
|
595
|
+
// A diff's file headers and the lines that end a file's hunks, in order: each section is read once. The file is
|
|
596
|
+
// named on either side: `+++ b/.env`, or `--- a/.env` above `+++ /dev/null` when the diff deletes it.
|
|
597
|
+
const marks = [...text.matchAll(/^(?:\+\+\+ (?:b\/)?(\S+)[^\n]*\n|diff --git |--- (?:a\/)?(\S+)|Index: )/gm)];
|
|
598
|
+
marks.forEach((h, k) => {
|
|
599
|
+
const file = h[1] === "/dev/null" ? marks[k - 1]?.[2] : h[1];
|
|
600
|
+
if (file === undefined || !isSecretFile(file))
|
|
601
|
+
return;
|
|
602
|
+
const from = h.index + h[0].length;
|
|
603
|
+
const to = marks[k + 1]?.index ?? text.length;
|
|
604
|
+
// (Only once its markers are gone.)
|
|
605
|
+
if (!/^[+-]/m.test(text.slice(from, to).replace(/^@@[^\n]*\n/gm, "")))
|
|
606
|
+
add({ start: from, end: to, part: false });
|
|
607
|
+
if (to === text.length)
|
|
608
|
+
diff = file;
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
let open = [];
|
|
612
|
+
if (/<<|\b(?:echo|printf|tee)\b/.test(text)) {
|
|
613
|
+
const lines = /<</.test(text) ? new LineIndex(text) : undefined;
|
|
614
|
+
let body = 0;
|
|
615
|
+
for (const line of text.matchAll(/^[^\n]*(?:<<|\b(?:echo|printf|tee)\b)[^\n]*$/gm)) {
|
|
616
|
+
if (line.index < body)
|
|
617
|
+
continue;
|
|
618
|
+
const commands = commandsRun(text, { at: line.index, oneLine: true, lines });
|
|
619
|
+
const writes = writtenToSecretFile(commands);
|
|
620
|
+
for (const w of writes.sections)
|
|
621
|
+
add(w);
|
|
622
|
+
const heredocs = commands.flatMap((c) => c.heredocs);
|
|
623
|
+
// (A finished heredoc's body is text, whatever the heredoc writes to. In a text that isn't a body itself it is
|
|
624
|
+
// also read on its own, a heredoc in it that doesn't end ending with it: a script a heredoc writes may write a
|
|
625
|
+
// secret file. A body in that script is the script's text. A heredoc that never ends is read as text.)
|
|
626
|
+
for (const h of heredocs.filter((h) => h.terminated)) {
|
|
627
|
+
body = Math.max(body, h.end);
|
|
628
|
+
if (!inBody)
|
|
629
|
+
for (const s of readWrites(text.slice(h.start, h.end), true).sections)
|
|
630
|
+
add({ ...s, start: s.start + h.start, end: s.end + h.start });
|
|
631
|
+
}
|
|
632
|
+
if (heredocs.some((h) => !h.terminated && writes.files.has(h)))
|
|
633
|
+
open = heredocs.filter((h) => !h.terminated).map((h) => ({ heredoc: h, file: writes.files.get(h) }));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// (In order and apart: a section inside or overlapping the one kept before it is dropped.)
|
|
637
|
+
const apart = [];
|
|
638
|
+
for (const section of sections.sort((a, b) => a.start - b.start))
|
|
639
|
+
if (section.start >= (apart.at(-1)?.end ?? 0))
|
|
640
|
+
apart.push(section);
|
|
641
|
+
return { sections: apart, open, diff };
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* The scopes still open at the end of a text, as the lines that open them, in order: a secret file's diff that runs to
|
|
645
|
+
* the end (its `+++` line and a hunk header), heredocs that write one and haven't ended (their command lines, made minimal, with any
|
|
646
|
+
* heredoc before them that is open too), or else the last prompt's command line, while no code fence has closed its
|
|
647
|
+
* output. Text that continues this one (a stream's next chunk) is read as it would be with these lines in front of it.
|
|
648
|
+
*/
|
|
649
|
+
export function openScopes(text) {
|
|
650
|
+
const { open, diff } = readWrites(text);
|
|
651
|
+
const out = [];
|
|
652
|
+
// (With a hunk header that runs on, so normalization takes the markers off the hunk lines that follow.)
|
|
653
|
+
if (diff)
|
|
654
|
+
out.push(`+++ b/${diff}\n@@ -1,1000000 +1,1000000 @@\n`);
|
|
655
|
+
if (open.length) {
|
|
656
|
+
const last = open.findLastIndex((o) => o.file !== undefined);
|
|
657
|
+
out.push(`${open
|
|
658
|
+
.slice(0, last + 1)
|
|
659
|
+
.map(({ heredoc: h, file }) => `cat${file ? ` > '${file}'` : ""} <<${h.strip ? "-" : ""}'${h.delimiter}'`)
|
|
660
|
+
.join("; ")}\n`);
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
const last = commandOutputs(text).at(-1);
|
|
664
|
+
if (last && last.end === text.length)
|
|
665
|
+
out.push(`$ ${last.command}\n`);
|
|
666
|
+
}
|
|
667
|
+
return out;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* What the pipelines of a command line write into a secret file: their heredocs' bodies and here-strings, or what
|
|
671
|
+
* they echo; and the file each heredoc writes.
|
|
672
|
+
*/
|
|
673
|
+
function writtenToSecretFile(commands) {
|
|
674
|
+
const sections = [];
|
|
675
|
+
const files = new Map();
|
|
676
|
+
let pipeline = [];
|
|
677
|
+
for (let k = 0; k < commands.length; k++) {
|
|
678
|
+
const c = commands[k];
|
|
679
|
+
pipeline.push(c);
|
|
680
|
+
if ((c.then === "|" || c.then === "|&") && commands[k + 1]?.host === c.host)
|
|
681
|
+
continue;
|
|
682
|
+
// Where it writes: a redirection's file (`>`, `>>`, `>|`, `&>`, `>&`), its groups' included, and `tee`'s.
|
|
683
|
+
// (A descriptor, `2>&1`, `>&-`, is no secret file's name.)
|
|
684
|
+
const targets = pipeline.flatMap((p) => {
|
|
685
|
+
const words = simpleCommand(p.words);
|
|
686
|
+
const tee = programName(words[0]) === "tee" ? words.slice(1) : [];
|
|
687
|
+
const append = tee.some((w) => /^-\w*a|^--append$/.test(w.text));
|
|
688
|
+
const redirected = allRedirects(p).filter((r) => r.target && /^(?:>|>>|>\||&>|&>>|>&)$/.test(r.op));
|
|
689
|
+
return [
|
|
690
|
+
...redirected.map((r) => ({ file: r.target.text, append: r.op.endsWith(">>") })),
|
|
691
|
+
...tee.filter((w) => !w.text.startsWith("-")).map((w) => ({ file: w.text, append })),
|
|
692
|
+
];
|
|
693
|
+
});
|
|
694
|
+
const written = targets.filter(({ file }) => isSecretFile(file.replace(/^~[/\\]/, "")));
|
|
695
|
+
if (written.length) {
|
|
696
|
+
// (One of a group's several writes is part of the file: `{ echo A=1; echo B=2; } > .env`.)
|
|
697
|
+
const grouped = pipeline[0].groups.some((g) => g.redirects.length && commands.filter((x) => x.groups.includes(g)).length > 1);
|
|
698
|
+
const part = grouped || written.every((f) => f.append);
|
|
699
|
+
// What: the pipeline's heredocs and here-strings, or the first stage's echo or printf operands (printf's after its format).
|
|
700
|
+
for (const p of pipeline) {
|
|
701
|
+
for (const h of p.heredocs) {
|
|
702
|
+
sections.push({ start: h.start, end: h.end, part });
|
|
703
|
+
files.set(h, written[0].file);
|
|
704
|
+
}
|
|
705
|
+
for (const r of p.redirects)
|
|
706
|
+
if (r.op === "<<<" && r.target)
|
|
707
|
+
sections.push({ ...wordSpan(r.target), part });
|
|
708
|
+
}
|
|
709
|
+
const first = simpleCommand(pipeline[0].words);
|
|
710
|
+
const program = programName(first[0]);
|
|
711
|
+
if (program === "echo" || program === "printf") {
|
|
712
|
+
const args = first.slice(1).filter((w) => !/^-[neE]+$/.test(w.text));
|
|
713
|
+
const operands = program === "printf" && args.length > 1 ? args.slice(1) : args;
|
|
714
|
+
for (const w of operands)
|
|
715
|
+
if (w.text)
|
|
716
|
+
sections.push({ ...wordSpan(w), part });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
pipeline = [];
|
|
720
|
+
}
|
|
721
|
+
return { sections, files };
|
|
722
|
+
}
|