redac---n 0.0.0-stage → 0.0.1

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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/THIRD_PARTY_NOTICES.md +59 -0
  4. package/dist/aho.d.ts +25 -0
  5. package/dist/aho.js +116 -0
  6. package/dist/base64.d.ts +10 -0
  7. package/dist/base64.js +29 -0
  8. package/dist/bip39-data.d.ts +2 -0
  9. package/dist/bip39-data.js +2 -0
  10. package/dist/cache.d.ts +14 -0
  11. package/dist/cache.js +40 -0
  12. package/dist/calls.d.ts +64 -0
  13. package/dist/calls.js +269 -0
  14. package/dist/charlm-data.d.ts +2 -0
  15. package/dist/charlm-data.js +3 -0
  16. package/dist/charlm.d.ts +7 -0
  17. package/dist/charlm.js +65 -0
  18. package/dist/checksums.d.ts +10 -0
  19. package/dist/checksums.js +98 -0
  20. package/dist/commands.d.ts +72 -0
  21. package/dist/commands.js +784 -0
  22. package/dist/context.d.ts +98 -0
  23. package/dist/context.js +469 -0
  24. package/dist/encoded.d.ts +11 -0
  25. package/dist/encoded.js +336 -0
  26. package/dist/environment.d.ts +10 -0
  27. package/dist/environment.js +62 -0
  28. package/dist/formats-data.d.ts +2 -0
  29. package/dist/formats-data.js +405 -0
  30. package/dist/formats.d.ts +5 -0
  31. package/dist/formats.js +470 -0
  32. package/dist/hmac.d.ts +3 -0
  33. package/dist/hmac.js +87 -0
  34. package/dist/index.d.ts +101 -0
  35. package/dist/index.js +484 -0
  36. package/dist/json.d.ts +82 -0
  37. package/dist/json.js +392 -0
  38. package/dist/known.d.ts +34 -0
  39. package/dist/known.js +340 -0
  40. package/dist/lines.d.ts +28 -0
  41. package/dist/lines.js +69 -0
  42. package/dist/lookup.d.ts +5 -0
  43. package/dist/lookup.js +7 -0
  44. package/dist/model-data.d.ts +3 -0
  45. package/dist/model-data.js +4 -0
  46. package/dist/model.d.ts +2 -0
  47. package/dist/model.js +19 -0
  48. package/dist/named.d.ts +20 -0
  49. package/dist/named.js +3257 -0
  50. package/dist/names.d.ts +94 -0
  51. package/dist/names.js +1449 -0
  52. package/dist/normalize.d.ts +25 -0
  53. package/dist/normalize.js +529 -0
  54. package/dist/pem.d.ts +32 -0
  55. package/dist/pem.js +269 -0
  56. package/dist/policy.d.ts +3 -0
  57. package/dist/policy.js +470 -0
  58. package/dist/public.d.ts +44 -0
  59. package/dist/public.js +362 -0
  60. package/dist/publickeys.d.ts +41 -0
  61. package/dist/publickeys.js +232 -0
  62. package/dist/random.d.ts +29 -0
  63. package/dist/random.js +165 -0
  64. package/dist/repeats.d.ts +21 -0
  65. package/dist/repeats.js +76 -0
  66. package/dist/rules.d.ts +42 -0
  67. package/dist/rules.js +58 -0
  68. package/dist/runs.d.ts +10 -0
  69. package/dist/runs.js +36 -0
  70. package/dist/secretfile.d.ts +52 -0
  71. package/dist/secretfile.js +676 -0
  72. package/dist/shell.d.ts +127 -0
  73. package/dist/shell.js +703 -0
  74. package/dist/spans.d.ts +53 -0
  75. package/dist/spans.js +272 -0
  76. package/dist/sql.d.ts +20 -0
  77. package/dist/sql.js +66 -0
  78. package/dist/stream.d.ts +46 -0
  79. package/dist/stream.js +150 -0
  80. package/dist/structure.d.ts +24 -0
  81. package/dist/structure.js +1614 -0
  82. package/dist/syntax.d.ts +156 -0
  83. package/dist/syntax.js +446 -0
  84. package/dist/transcript.d.ts +30 -0
  85. package/dist/transcript.js +709 -0
  86. package/dist/types.d.ts +81 -0
  87. package/dist/types.js +1 -0
  88. package/dist/unnamed.d.ts +59 -0
  89. package/dist/unnamed.js +939 -0
  90. package/dist/urls.d.ts +70 -0
  91. package/dist/urls.js +303 -0
  92. package/dist/values.d.ts +178 -0
  93. package/dist/values.js +1621 -0
  94. package/dist/yaml.d.ts +40 -0
  95. package/dist/yaml.js +174 -0
  96. package/docs/creddata.svg +318 -0
  97. package/docs/guessing.md +139 -0
  98. package/docs/holdout.svg +85 -0
  99. package/docs/layers.md +80 -0
  100. package/docs/models.svg +81 -0
  101. package/docs/real-text.svg +112 -0
  102. package/package.json +57 -5
@@ -0,0 +1,784 @@
1
+ /**
2
+ * Where a text came from, told by the command that printed it or the path it was read from: a secret file
3
+ * (`.env`, `id_rsa`, `cat .env`, `grep KEY .env.local`, `sops -d`), or a command whose output is a credential
4
+ * (`gh auth token`, `openssl rand -hex 32`, `echo $DB_PASSWORD`). Commands are read through the wrappers that run
5
+ * them (`bash -lc`, `docker exec`, `kubectl exec`, `ssh`).
6
+ */
7
+ import { lookup } from "./lookup.js";
8
+ import { isCredentialName } from "./names.js";
9
+ import { lastText, lineEnd, lowerBound } from "./lines.js";
10
+ import { CONTRACTION } from "./syntax.js";
11
+ import { allRedirects, programName, PROMPT, redirectsStdout, shellCommands, simpleCommand, } from "./shell.js";
12
+ const EXAMPLE_FILE = /\.(example|sample|template|defaults?|dist|tmpl|tpl)$|^(example|sample|template)[._-]|\.(example|sample|template)\./i;
13
+ const SECRET_FILE = [
14
+ /^\.env([.-].+)?$/,
15
+ /^[^.].*\.env$/,
16
+ /^\.envrc$/,
17
+ /^\.dev\.vars$/,
18
+ /^\.secrets?$/,
19
+ /^id_(rsa|dsa|ecdsa|ed25519)(_sk)?$/,
20
+ /\.(key|p12|pfx|jks|keystore|ppk|keytab|pem)$/i,
21
+ /^credentials(\.(json|ya?ml|toml|ini|csv))?$/,
22
+ /^\.(git-credentials|netrc|npmrc|pypirc|vault-token|pgpass|my\.cnf|s3cfg|boto|dockercfg|htpasswd|terraformrc)$/,
23
+ // Password and PIN files: Ansible's vault_pass, `db_password.txt`, `~/.pin`, Docker and Compose secret files, pppd's.
24
+ /^\.?vault[_-]?pass(?:word)?(?:\.txt)?$/i,
25
+ /(?:^|[_.-])(?:pass(?:word|wd|phrase|code)?|secret|token|pin(?:[_-]?code)?)(?:\.txt)?$/i,
26
+ /^(?:pap|chap)-secrets(?:[-_.][\w-]+)?$/,
27
+ /^(_netrc|rclone\.conf|cookies\.txt|keystore\.properties|wp-config\.php|\.?htpasswd|redis\.conf|sentinel\.conf|keyfile|userlist\.txt)$/i,
28
+ /(^|[-_.])secrets?(\.[\w-]{1,40})?\.(json|ya?ml|toml|env|ini|properties)$/i,
29
+ /\.(tfvars|tfstate)(\.json|\.backup)?$/,
30
+ /^kubeconfig(\..+)?$|\.kubeconfig$/,
31
+ /^(service[-_]?account|client[-_]secret|application_default_credentials).*\.json$/i,
32
+ /^(auth|mcp-auth|hosts)\.(json|yml)$/,
33
+ /^(?:auth|creds?|login|userpass|user-pass)(?:[_-]file)?\.txt$/i,
34
+ /^\.credentials\.json$/,
35
+ /realm\.properties$/,
36
+ ];
37
+ const SECRET_DIR = /(^|\/)\.(aws|ssh|gnupg|docker|kube|azure)\/|(^|\/)\.config\/(gcloud|gh|rclone|sops)\/|(^|\/)\.?secrets\//;
38
+ // (Also a key's public half by its name, `cache-pub-key.pem`, `server.pub.pem`, `public.key`; and a certificate's,
39
+ // `fullchain.pem`, `cert.pem`, but not a private key's container named for its certificate, `cert.key`, `server.crt.p12`,
40
+ // `cert-key.pem`.
41
+ // A private key in a certificate's file anyway is found by its DER.)
42
+ const NOT_SECRET_PATH = /\.pub$|(?:^|[/._-])(?:pub|public|pubkey)(?=[._-][^/]*$)|(?:^|[/._-])(?:cert|certs|certificate|crt|chain|fullchain|cacert|ca-bundle)(?=[._-][^/]*$)(?![^/]*(?:\.(?:key|p12|pfx|jks|keystore|ppk)|[-_.]key\.pem)$)|known_hosts|authorized_keys|(?:^|\/)\.ssh\/config$|\.(md|ts|tsx|js|py|go|rs|rb|java)$|(?:^|\/)etc\/(?:passwd|group)-?$/;
43
+ /** Whether a path names a file whose whole content is secret material (`.env`, `id_rsa`, `credentials.json`). */
44
+ export function isSecretFile(path) {
45
+ path = path.replace(/\\/g, "/"); // Windows paths
46
+ const name = path.slice(path.lastIndexOf("/") + 1);
47
+ if (!name || EXAMPLE_FILE.test(name) || NOT_SECRET_PATH.test(path))
48
+ return false;
49
+ return SECRET_FILE.some((r) => r.test(name)) || SECRET_DIR.test(path);
50
+ }
51
+ /**
52
+ * Whether a value is the name of a file that is a secret file or its template, not a password: `.env.example`, `.env.local`,
53
+ * `.npmrc` (a dotfile, or a template's name, in lower case; `Env.Local9` is a password).
54
+ */
55
+ export function isConfigFileName(value) {
56
+ return (/^\.?[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)*$/.test(value) &&
57
+ ((value.startsWith(".") && isSecretFile(value)) || (value.includes(".") && EXAMPLE_FILE.test(value))));
58
+ }
59
+ /** Whether a path names a file that holds nothing but secrets, by its name or its directory's: `config/secrets.staging.yml`, `credentials.json`, `secrets/db`; not `.env`, which holds settings too. */
60
+ export function holdsOnlySecrets(path) {
61
+ return (isSecretFile(path) &&
62
+ (/secret|credential|^auth\.json$|^\.(?:netrc|_netrc|pgpass|vault-token)$/i.test(path.slice(path.lastIndexOf("/") + 1)) ||
63
+ /(^|\/)\.?secrets\//.test(path)));
64
+ }
65
+ const CONTENT_COMMANDS = /^(cat|tac|head|tail|less|more|bat|batcat|nl|sed|awk|grep|egrep|fgrep|zgrep|rg|ag|ack|Select-String|sls|cut|sort|uniq|strings|xxd|od|base64|tr|column|jq|yq|type|Get-Content|gc|diff|sops|op|dotenv|printenv|env|set|export|declare)$/;
66
+ /**
67
+ * Commands whose whole output is a credential, by tool: each read from the program on (a commit message that mentions
68
+ * `gh auth token` isn't one).
69
+ */
70
+ const SECRET_TOOLS = new RegExp(`^(?:${[
71
+ // Cloud and platform CLIs.
72
+ /gh\s+auth\s+token\b/,
73
+ /heroku\s+auth:token\b/,
74
+ /heroku\s+config:get\b/,
75
+ /fly\s+tokens\s+create\b/,
76
+ /netlify\s+env:get\b/,
77
+ /vercel\s+env\s+pull\b/,
78
+ /supabase\s+.*token\b(?!-)/,
79
+ /firebase\s+login:ci\b/,
80
+ /yc\s+iam\s+create-token\b/,
81
+ /npm\s+token\s+create\b/,
82
+ /gcloud\s+auth\s+(?:application-default\s+)?print-(?:access|identity)-token\b/,
83
+ /gcloud\s+secrets\s+versions\s+access\b/,
84
+ /aws\s+ecr(?:-public)?\s+get-login-password\b/,
85
+ /aws\s+codeartifact\s+get-authorization-token\b/,
86
+ /aws\s+iam\s+create-access-key\b/,
87
+ /aws\s+ssm\s+get-parameters?\b.*--with-decryption\b.*--output\s+text\b/,
88
+ // (Or its default JSON output, `--query SecretString`'s `"…"`: findPrintedSecret reads a JSON string as its value.)
89
+ /aws\s+secretsmanager\s+get-secret-value\b.*(?:--output\s+text\b|--query\s+["']?SecretString\b)/,
90
+ /aws\s+configure\s+get\s+\S*(?:secret|token|key)\b/,
91
+ /az\s+account\s+get-access-token\b/,
92
+ /az\s+storage\s+account\s+keys\s+list\b/,
93
+ /az\s+keyvault\s+secret\s+show\b.*(?:(?:-o|--output)\s+tsv\b|--query\s+["']?value\b)/,
94
+ /az\s+\S+\s+.*\b(?:keys|credential)\s+list\b/,
95
+ /kubectl\b.*\bget\s+secrets?\b.*-o\s*=?\s*(?:jsonpath|go-template)/,
96
+ /kubectl\s+create\s+token\b/,
97
+ /terraform\b.*\boutput\s+(?:-raw|-json)\s+\S*(?:[Pp]ass|[Ss]ecret|[Tt]oken|[Kk]ey|[Cc]red)/,
98
+ /wp\s+config\s+get\s+\S*(?:PASSWORD|KEY|SALT|SECRET|TOKEN)\b/i,
99
+ // Secret managers and password stores.
100
+ /op\s+read\b/,
101
+ /op\s+item\s+get\b.*--(?:fields|reveal)\b/,
102
+ /op\s+signin\b.*--raw\b/,
103
+ /doppler\s+secrets\s+get\b/,
104
+ /vault\s+(?:kv\s+get|read|print\s+token)\b.*-field\b/,
105
+ /vault\s+write\b.*-field\b/,
106
+ /security\s+find-(?:generic|internet)-password\b.*\s-w\b/,
107
+ /pass\s+(?:show\s+)?(?!(?:ls|list|version|init|help|git|tree|find|grep|edit|insert|generate|rm|mv|cp|otp|show)\b|-)[\w/.-]+\s*$/,
108
+ /bw\s+get\s+(?:password|totp)\b/,
109
+ /gopass\s+(?:show|cat)\b/,
110
+ /secret-tool\s+lookup\b/,
111
+ /lpass\s+show\b.*--password\b/,
112
+ /keepassxc-cli\s+show\b.*(?:-a|--attributes)\s+Password\b/,
113
+ // Generators.
114
+ /openssl\s+rand\b/,
115
+ /pwgen\b/,
116
+ /wg\s+(?:genkey|genpsk)\b/,
117
+ /wg\s+show\b.*\bprivate-key\b/,
118
+ /rails\s+secret\b/,
119
+ /rake\s+secret\b/,
120
+ ]
121
+ .map((r) => r.source)
122
+ .join("|")})`);
123
+ /** What a command reads or calls that is a secret, wherever it sits in the command: a random device, a language's generator. */
124
+ const SECRET_SOURCES = /\/dev\/u?random\b|get_random_secret_key|secrets\.token_(?:hex|urlsafe|bytes)|SecureRandom\.(?:hex|base64|urlsafe_base64)|crypto\.randomBytes/;
125
+ /** A shell's name: `bash`, `sh`, `zsh`. */
126
+ export const SHELL = /^(?:ba|z|da|k)?sh$/;
127
+ /** Commands whose whole output is a set of secrets, read like a secret file; each read from the program on. */
128
+ const SECRET_DUMPS = new RegExp(String.raw `^(?:\bdoppler\s+secrets(?:\s+download)?\b(?![^\n]*\b(?:set|delete)\b)|\bsops\s+(?:-d|--decrypt)\b|\bansible-vault\s+(?:view|decrypt)\b|\b(?:bin\/)?rails\s+credentials:show\b|\binfisical\s+export\b|\bchamber\s+(?:export|read)\b|\bheroku\s+config(?:\s|$)(?!:)|\bvault\s+kv\s+get\b|\bop\s+inject\b|\baws\s+sts\s+(?:get-session-token|assume-role\S*|get-federation-token)\b|\baws\s+configure\s+export-credentials\b|\baz\s+ad\s+sp\s+(?:create-for-rbac|credential\s+reset)\b|\bkubectl\s+config\s+view\b[^\n]*--raw\b|\bterraform\s+output\s+-json\b|\bgit\s+credential\s+fill\b|\b(?:gh|glab)\s+auth\s+status\b[^\n]*(?:--show-token|\s-t\b))`);
129
+ /**
130
+ * A line written for PowerShell or cmd, where a backslash separates a path (`.\.env`, `C:\Users\me\.aws\credentials`,
131
+ * `$env:USERPROFILE\.ssh\id_rsa`): read without POSIX escapes.
132
+ */
133
+ const WINDOWS_LINE = /(?:^|[\s'"=(])(?:[A-Za-z]:\\|\.{1,2}\\[\w.$-]|~\\|\$(?:HOME|env:\w+)\\)|^[ \t]*(?:Get-\w+|Select-String|gc|type|pwsh|powershell|cmd)(?:\.exe)?[ \t]/i;
134
+ /**
135
+ * The simple commands a command line runs, in order, read through what runs a command of its own: a shell (`bash -lc
136
+ * "cat .env"`, `sudo sh -c '…'`, a heredoc or here-string fed to one: `ssh host <<'EOF'`), PowerShell (`pwsh -Command
137
+ * …`) and cmd (`cmd /c …`), a container (`docker exec app cat .env`, `docker compose exec`, `kubectl exec pod -- cat
138
+ * .env`), a remote host (`ssh host -- cat .env`), and substitutions (`echo "$(cat .env)"`), nested ones too. A prompt
139
+ * before the line (a pasted session's `$ `, a notebook's `!`, a trace's `+ `, a quote's `> `) is left out. Positions are in `text`, however deep the
140
+ * command was; an unwrapped command takes the wrapper's place in its pipeline and its redirections as a group's.
141
+ */
142
+ export function commandsRun(text, { at = 0, oneLine = false, lines, depth = 0, } = {}) {
143
+ const out = [];
144
+ for (const c of commandLine(text, at, { oneLine, lines })) {
145
+ if (depth < 4) {
146
+ for (const s of c.words.flatMap((w) => w.substitutions))
147
+ out.push(...commandsRun(text.slice(s.start, s.end), { depth: depth + 1 }).map((r) => remap(r, textSource(text, s.start, s.end), text, c)));
148
+ // A process substitution the command's input is redirected from is piped into it (`tee out.log < <(cat .env)`
149
+ // runs as `cat .env | tee out.log`); one its output goes to is run by it, as a word's is.
150
+ for (const r of c.redirects) {
151
+ for (const s of r.target?.substitutions ?? []) {
152
+ const feeds = r.op === "<";
153
+ const run = commandsRun(text.slice(s.start, s.end), { depth: depth + 1 }).map((x) => remap(x, textSource(text, s.start, s.end), text, feeds ? undefined : c));
154
+ if (feeds && run.length)
155
+ run[run.length - 1].then = "|";
156
+ out.push(...run);
157
+ }
158
+ }
159
+ }
160
+ const inner = depth < 4 ? innerCommand(c, text) : undefined;
161
+ const run = inner ? commandsRun(inner.text, { depth: depth + 1 }).map((r) => remap(r, inner, text)) : [];
162
+ if (!run.length) {
163
+ out.push(c);
164
+ continue;
165
+ }
166
+ const wrapper = { kind: programName(simpleCommand(c.words)[0]), redirects: c.redirects };
167
+ for (const r of run)
168
+ r.groups = [...r.groups, wrapper, ...c.groups];
169
+ run[run.length - 1].then = c.then;
170
+ out.push(...run);
171
+ }
172
+ return out;
173
+ }
174
+ /**
175
+ * A line's prose before the command it shows: words and ordinary punctuation, with a contraction in them (`Here's`,
176
+ * `You'll`, `Don't`; a list's `- ` before them), up to the inline code that holds the command (the backtick, group 1)
177
+ * or the colon after which it follows. (Case-insensitive, as CONTRACTION is: `DON'T`.)
178
+ */
179
+ const PROSE_BEFORE_COMMAND = new RegExp(String.raw `^[ \t]*(?:[-*>][ \t]+)?[A-Za-z][A-Za-z ,.!?()-]{0,80}?${CONTRACTION.source}[A-Za-z0-9 ,.!?()-]{0,120}?(?:(\x60)|:[ \t]+(?=\S))`, "i");
180
+ /**
181
+ * The commands of the command line at `at` as written, in its dialect (POSIX, or Windows' backslashes), from where
182
+ * the command starts: past a prompt; on a line of prose, at its inline code (`` You'll need `mysql …` ``) or after its
183
+ * colon (`Here's how: mysql …`), since a contraction's apostrophe is no quote a shell would close.
184
+ */
185
+ function commandLine(text, at, { oneLine = false, lines } = {}) {
186
+ // (Or a trace's `+ ` and a quote's `> `, before a command that a log or a message shows.)
187
+ const head = text.slice(at, at + 2400);
188
+ const prompt = PROMPT.exec(head) ?? /^[ \t]*(?:\++|>) (\S[^\n]{0,2000})/.exec(head);
189
+ const prose = prompt ? undefined : PROSE_BEFORE_COMMAND.exec(head);
190
+ const from = prompt
191
+ ? at + prompt[0].length - prompt[1].length
192
+ : prose
193
+ ? at + prose[0].length - (prose[1] ? 1 : 0)
194
+ : at;
195
+ const backslash = !WINDOWS_LINE.test(text.slice(from, lineEnd(text, from, 4000)));
196
+ return shellCommands(text, 0, { at: from, oneLine, lines, backslash });
197
+ }
198
+ /** A command read out of `source`'s string, with its positions where the source puts them in `text`. */
199
+ function remap(c, source, text, host) {
200
+ const { at, endAt } = source;
201
+ const word = (w) => {
202
+ const [start, end] = [at(w.start), endAt(w.end)];
203
+ return {
204
+ ...w,
205
+ start,
206
+ end,
207
+ raw: text.slice(start, end),
208
+ map: w.map.map(at),
209
+ ends: w.ends.map(endAt),
210
+ substitutions: w.substitutions.map((s) => ({ start: at(s.start), end: endAt(s.end) })),
211
+ };
212
+ };
213
+ const redirect = (r) => ({
214
+ ...r,
215
+ start: at(r.start),
216
+ end: endAt(r.end),
217
+ ...(r.target ? { target: word(r.target) } : {}),
218
+ });
219
+ const [start, end] = [at(c.start), endAt(c.end)];
220
+ return {
221
+ ...c,
222
+ text: text.slice(start, end),
223
+ start,
224
+ end,
225
+ words: c.words.map(word),
226
+ redirects: c.redirects.map(redirect),
227
+ heredocs: c.heredocs.map((h) => ({ ...h, start: at(h.start), end: endAt(h.end) })),
228
+ groups: c.groups.map((g) => ({ ...g, redirects: g.redirects.map(redirect) })),
229
+ ...(c.host ? { host: remap(c.host, source, text) } : host ? { host } : {}),
230
+ };
231
+ }
232
+ const wordSource = (w) => ({
233
+ text: w.text,
234
+ at: (p) => (p < w.map.length ? w.map[p] : w.end),
235
+ endAt: (p) => (p > 0 ? w.ends[p - 1] : w.start),
236
+ });
237
+ const textSource = (text, start, end) => ({
238
+ text: text.slice(start, end),
239
+ at: (p) => p + start,
240
+ endAt: (p) => p + start,
241
+ });
242
+ /** The options of a CLI before its subcommand that take a value: `git -C app show`, `docker --context prod exec`. */
243
+ const GLOBAL_OPTIONS = lookup({
244
+ git: /^(?:-C|-c|--git-dir|--work-tree|--namespace|--exec-path|--config-env)$/,
245
+ docker: /^(?:-c|--context|-H|--host|--config|-l|--log-level|--tlscacert|--tlscert|--tlskey)$/,
246
+ podman: /^(?:-c|--connection|--url|--identity|--log-level|--root|--runroot)$/,
247
+ nerdctl: /^(?:-n|--namespace|-H|--host|--address)$/,
248
+ kubectl: /^(?:-n|--namespace|--context|--kubeconfig|--cluster|--user|-s|--server|--token)$/,
249
+ oc: /^(?:-n|--namespace|--context|--kubeconfig|--cluster|--user|-s|--server|--token)$/,
250
+ });
251
+ /** A CLI's words from its subcommand on, past its global options and their values and a management group (`docker container exec`). */
252
+ function subcommand(words) {
253
+ const program = programName(words[0]);
254
+ let k = 1;
255
+ while (k < words.length && words[k].text.startsWith("-"))
256
+ k += !words[k].text.includes("=") && GLOBAL_OPTIONS[program]?.test(words[k].text) ? 2 : 1;
257
+ if (/^(?:docker|podman|nerdctl)$/.test(program) && words[k]?.text === "container")
258
+ k++;
259
+ return words.slice(k);
260
+ }
261
+ /** The command a wrapper runs, as a string in `text`, or undefined if `c` runs none. */
262
+ function innerCommand(c, text) {
263
+ const words = simpleCommand(c.words);
264
+ const program = programName(words[0]);
265
+ // The rest of the command from `rest[k]`: one word is the command it quotes, several words are as written. With none,
266
+ // the script a heredoc or a here-string feeds it (`ssh host <<'EOF'`, `docker exec -i app sh <<EOF`).
267
+ // (The tool's name, then the text from `start` on, positions mapped back into `text`.)
268
+ const entrypoint = (tool, start) => {
269
+ const head = `${tool} `;
270
+ return {
271
+ text: head + text.slice(start, c.end),
272
+ at: (p) => (p < head.length ? start : p - head.length + start),
273
+ endAt: (p) => (p <= head.length ? start : p - head.length + start),
274
+ };
275
+ };
276
+ const from = (rest, k) => {
277
+ const runs = rest.slice(k);
278
+ // (A shell with no command string reads its script from stdin: `docker exec -i app sh <<EOF`.)
279
+ if (!runs.length || (SHELL.test(programName(runs[0])) && !runs.some((w) => /^-\w*c\w*$/.test(w.text))))
280
+ return script();
281
+ return runs.length === 1 ? wordSource(runs[0]) : textSource(text, runs[0].start, c.end);
282
+ };
283
+ const script = () => {
284
+ const heredoc = c.heredocs[0];
285
+ if (heredoc)
286
+ return textSource(text, heredoc.start, heredoc.end);
287
+ const herestring = c.redirects.find((r) => r.op === "<<<")?.target;
288
+ return herestring && wordSource(herestring);
289
+ };
290
+ if (SHELL.test(program)) {
291
+ // Its options, `-c` among them (`-lc`, `-o pipefail -c`), then the command string; with no `-c` and no script
292
+ // file after the options, a script on stdin.
293
+ for (let k = 1; k < words.length && words[k].text.startsWith("-"); k++) {
294
+ if (VALUE_OPTIONS.shell.test(words[k].text))
295
+ k++;
296
+ else if (/^-\w*c\w*$/.test(words[k].text))
297
+ return words[k + 1] && wordSource(words[k + 1]);
298
+ }
299
+ return afterOptions(words, 1, VALUE_OPTIONS.shell) < words.length ? undefined : script();
300
+ }
301
+ if (/^(?:pwsh|powershell)(?:\.exe)?$/i.test(program)) {
302
+ const k = words.findIndex((w) => /^-(?:c|command)$/i.test(w.text));
303
+ return k > 0 ? from(words, k + 1) : undefined;
304
+ }
305
+ if (/^cmd(?:\.exe)?$/i.test(program)) {
306
+ const k = words.findIndex((w) => /^\/[ck]$/i.test(w.text));
307
+ return k > 0 ? from(words, k + 1) : undefined;
308
+ }
309
+ const sub = subcommand(words);
310
+ if (/^(?:kubectl|oc)$/.test(program) && sub[0]?.text === "exec") {
311
+ const dashes = sub.findIndex((w) => w.text === "--");
312
+ return dashes > 0 ? from(sub, dashes + 1) : undefined;
313
+ }
314
+ // `docker exec`, `docker compose exec`, `docker-compose exec`: options (with the value of those that take one), then
315
+ // the container or the service, then its command.
316
+ const exec = program === "docker-compose"
317
+ ? words.slice(afterOptions(words, 1, VALUE_OPTIONS["docker compose"]))
318
+ : /^(?:docker|podman|nerdctl)$/.test(program)
319
+ ? sub[0]?.text === "compose"
320
+ ? sub.slice(afterOptions(sub, 1, VALUE_OPTIONS["docker compose"]))
321
+ : sub
322
+ : undefined;
323
+ if (exec?.[0]?.text === "exec")
324
+ return from(exec, afterOptions(exec, 1, VALUE_OPTIONS["docker exec"]) + 1);
325
+ // `docker run [options] IMAGE [COMMAND…]`, `kubectl run NAME --image=IMAGE [--command] -- …`: the command after the
326
+ // image; with none, the arguments go to the image's entrypoint, a tool's for a tool's image (`curlimages/curl -b …`).
327
+ if (/^(?:docker|podman|nerdctl)$/.test(program) && sub[0]?.text === "run") {
328
+ const k = afterOptions(sub, 1, VALUE_OPTIONS["docker run"]);
329
+ const tool = ENTRYPOINT_TOOLS[imageName(sub[k]?.text ?? "")];
330
+ if (!tool || (k + 1 < sub.length && !sub[k + 1].text.startsWith("-")))
331
+ return from(sub, k + 1);
332
+ return k + 1 < sub.length ? entrypoint(tool, sub[k + 1].start) : undefined;
333
+ }
334
+ if (/^(?:kubectl|oc)$/.test(program) && sub[0]?.text === "run") {
335
+ const dashes = sub.findIndex((w) => w.text === "--");
336
+ if (dashes < 0 || dashes + 1 >= sub.length)
337
+ return undefined;
338
+ const image = sub.find((w) => w.text.startsWith("--image="))?.text.slice(8) ?? "";
339
+ const tool = ENTRYPOINT_TOOLS[imageName(image)];
340
+ return tool && !sub.some((w) => w.text === "--command")
341
+ ? entrypoint(tool, sub[dashes + 1].start)
342
+ : from(sub, dashes + 1);
343
+ }
344
+ if (program === "ssh") {
345
+ // The host, then the command, after a `--` if there is one.
346
+ let k = afterOptions(words, 1, VALUE_OPTIONS.ssh) + 1;
347
+ if (words[k]?.text === "--")
348
+ k++;
349
+ return from(words, k);
350
+ }
351
+ return undefined;
352
+ }
353
+ /**
354
+ * The options that take their value as the next word, by program or program and subcommand: `docker run -e X`,
355
+ * `docker compose -f x.yml`, `docker exec --detach-keys k`, `ssh -i key`, `bash -o pipefail`. One table, so every
356
+ * reader that steps over a command's options finds its operands at the same word.
357
+ */
358
+ const VALUE_OPTIONS = lookup({
359
+ // (Every option of `docker run` that takes a value, as its reference lists them.)
360
+ "docker run": /^(?:-[acehlmpuvw]|--(?:add-host|annotation|attach|blkio-weight(?:-device)?|cap-add|cap-drop|cgroup-parent|cgroupns|cidfile|cpu-period|cpu-quota|cpu-rt-period|cpu-rt-runtime|cpu-shares|cpus|cpuset-cpus|cpuset-mems|detach-keys|device|device-cgroup-rule|device-(?:read|write)-(?:bps|iops)|dns|dns-option|dns-search|domainname|entrypoint|env|env-file|expose|gpus|group-add|health-(?:cmd|interval|retries|start-period|start-interval|timeout)|hostname|ip|ip6|ipc|isolation|kernel-memory|label|label-file|link|link-local-ip|log-driver|log-opt|mac-address|memory|memory-reservation|memory-swap|memory-swappiness|mount|name|net|net-alias|network|network-alias|oom-score-adj|pid|pids-limit|platform|publish|pull|restart|runtime|security-opt|shm-size|stop-signal|stop-timeout|storage-opt|sysctl|tmpfs|ulimit|user|userns|uts|volume|volume-driver|volumes-from|workdir))$/,
361
+ "docker exec": /^(?:-[uewU]|--user|--env|--workdir|--env-file|--index|--detach-keys)$/,
362
+ "docker compose": /^(?:-f|--file|-p|--project-name|--profile|--env-file|--project-directory|--ansi|--progress|--parallel)$/,
363
+ ssh: /^-[iplJFoLRDbcemSwWBEOQ]$/,
364
+ shell: /^-[a-bd-z]*o$|^--(?:rcfile|init-file)$/,
365
+ // (grep's, and ripgrep's, ag's and ack's alike: context, a count, a pattern or the file of them, which files to read.)
366
+ grep: /^-[ABCDdefm]$|^--(?:after-context|before-context|context|max-count|regexp|file|include|exclude|exclude-dir|label|devices|directories|binary-files)$/,
367
+ });
368
+ /** Where a command's operands start: past its options from `k`, and the values of those `options` takes as a next word. */
369
+ function afterOptions(words, k, options) {
370
+ while (k < words.length && words[k].text.startsWith("-") && words[k].text !== "--")
371
+ k += !words[k].text.includes("=") && options?.test(words[k].text) ? 2 : 1;
372
+ return k;
373
+ }
374
+ /** Images whose entrypoint is a tool, by the image's name (`curlimages/curl:8.5.0` → `curl`). */
375
+ const ENTRYPOINT_TOOLS = lookup({
376
+ curl: "curl",
377
+ kubectl: "kubectl",
378
+ "aws-cli": "aws",
379
+ httpie: "http",
380
+ "redis-cli": "redis-cli",
381
+ });
382
+ /** An image reference's name: its last path segment, without the tag or digest. */
383
+ function imageName(image) {
384
+ return (image
385
+ .replace(/[@:][^/]*$/, "")
386
+ .split("/")
387
+ .at(-1) ?? "");
388
+ }
389
+ /** Programs whose output is their arguments, so a substitution in them prints what it runs: `echo "$(cat .env)"`. */
390
+ const ECHOES = /^(?:echo|printf|Write-Output|Write-Host)$/;
391
+ /** Whether a command prints the contents of a secret file (`cat .env`, `grep KEY .env.local`, `cat < .env`, `git show HEAD:.env`). */
392
+ export function printsSecretFile(command) {
393
+ return printsWhatItReads(command, readsSecretFile, isSecretFile) || printsIndirectly(command);
394
+ }
395
+ /**
396
+ * A file printed by a name the pipeline reader doesn't see: `find … -exec cat {}`, `xargs cat`, a printer given a
397
+ * variable or a substitution (`cat "$f"`, `cat $(find …)`, `echo "$line"`), a script's `open(…).read()`.
398
+ */
399
+ const PRINTER = /-exec[ \t]+(?:cat|tac|head|tail|less|more|bat|nl)\b|\bxargs[ \t]+(?:-\S+[ \t]+)*(?:cat|tac|head|tail|less|more|bat|nl)\b|(?:^|[\s;|&(])(?:cat|tac|head|tail|less|more|bat|nl|echo|printf|print)\b[^;|&\n]*\$[{(\w"]|\.read\(\)|readlines\(\)/;
400
+ /**
401
+ * A secret file printed by a route the pipeline reader doesn't follow: named to `find … -exec cat {}`, `xargs cat`, a
402
+ * loop's variable, a substitution, a script's `open('.env').read()`, a `while read` loop's `< .env`. Taken as printed
403
+ * when the command line names the file, has a printer, and ends in no stage that prints none of its input and no
404
+ * redirect to a file.
405
+ */
406
+ function printsIndirectly(command) {
407
+ if (command.length > 8192 || !PRINTER.test(command))
408
+ return false;
409
+ // (A file named as a path, `.env`, `config/secrets.yml`, `$HOME/.env`: a bare word or a variable, `token`, `$TOKEN`, is
410
+ // no file's name here.)
411
+ const named = command
412
+ .split(/[\s'"`;|&()<>{}=,]+/)
413
+ .some((w) => /[./]/.test(w) && isSecretFile(w.replace(/^\.\//, "").replace(/[*?]+$/, "")));
414
+ if (!named)
415
+ return false;
416
+ const run = commandsRun(command);
417
+ const last = run.at(-1);
418
+ return !!last && passesThrough(last) && stdoutFile(last) === undefined;
419
+ }
420
+ /** The value of an option given as `-d X`, `-dX`, `--delimiter X` or `--delimiter=X`; `short` is `-d`, `long` is `--delimiter`. */
421
+ function optionValue(args, short, long) {
422
+ for (let k = 0; k < args.length; k++) {
423
+ const a = args[k];
424
+ if (a === short || a === long)
425
+ return args[k + 1];
426
+ if (a.startsWith(long + "="))
427
+ return a.slice(long.length + 1);
428
+ if (a.startsWith(short))
429
+ return a.slice(short.length);
430
+ }
431
+ return undefined;
432
+ }
433
+ /** Whether a command rewrites its files and prints nothing of them: `sed -i`, `sed -i.bak`, `sed -ni`, `sed --in-place`, `awk -i inplace`. */
434
+ function editsInPlace(words) {
435
+ const program = programName(words[0]);
436
+ const args = words.slice(1).map((w) => w.text);
437
+ if (program === "sed")
438
+ return args.some((a) => /^-[a-zA-Z]*i|^--in-place/.test(a));
439
+ return program === "awk" && args.some((a, k) => a === "-i" && args[k + 1] === "inplace");
440
+ }
441
+ /** What separates a name from its value in a settings file: `NAME=value`, `name: value`. */
442
+ const NAME_SEPARATOR = /^[=:]$/;
443
+ /**
444
+ * Whether a command cuts each line of a settings file down to its name, so what it prints holds no value: `cut -d= -f1`,
445
+ * `awk -F= '{print $1}'`, or a `sed` that deletes from the `=` to the line's end.
446
+ */
447
+ function printsNamesOnly(words) {
448
+ const program = programName(words[0]);
449
+ const args = words.slice(1).map((w) => w.text);
450
+ if (program === "cut")
451
+ return (NAME_SEPARATOR.test(optionValue(args, "-d", "--delimiter") ?? "") && optionValue(args, "-f", "--fields") === "1");
452
+ if (program === "awk")
453
+ return (NAME_SEPARATOR.test(optionValue(args, "-F", "--field-separator") ?? "") &&
454
+ args.some((a) => /^\{\s*print\s+\$1\s*\}$/.test(a)));
455
+ if (program === "sed")
456
+ return args.some((a) => /^s(.)\[?[=:]\]?\.\*\$?\1\1[gp]?$/.test(a));
457
+ return false;
458
+ }
459
+ /**
460
+ * Whether a command reads a secret file but cuts what it prints to the names (`cat .env | cut -d= -f1`): its output
461
+ * holds a value only where the cut didn't happen, a `NAME=value` line in it.
462
+ */
463
+ export function printsSecretFileNames(command) {
464
+ const runs = commandsRun(command);
465
+ return (runs.some((c) => printsNamesOnly(simpleCommand(c.words))) &&
466
+ runs.some((c) => c.words.some((w) => isSecretFile(w.text))));
467
+ }
468
+ /** Whether one command reads a secret file's content: `cat .env`, `grep KEY .env`, `git show HEAD:.env`, `env`, `cat < .env`. */
469
+ function readsSecretFile(c) {
470
+ const words = simpleCommand(c.words);
471
+ const program = programName(words[0]);
472
+ const rest = words.slice(1).map((w) => w.text);
473
+ if (SECRET_DUMPS.test(words.map((w) => w.text).join(" ")))
474
+ return true;
475
+ // `git show HEAD:.env`, `git --no-pager cat-file -p main:config/.env`: a file at a revision.
476
+ if (program === "git") {
477
+ const sub = subcommand(words).map((w) => w.text);
478
+ return (/^(?:show|cat-file)$/.test(sub[0] ?? "") &&
479
+ sub.some((t) => /^[^:\s]*:./.test(t) && isSecretFile(t.slice(t.indexOf(":") + 1))));
480
+ }
481
+ // (xargs hands what it reads to the command it runs as words, so echo, its default command, prints it: `xargs -a .env
482
+ // echo`, `xargs < .env`; `xargs -a hosts.txt ping` prints no file.)
483
+ const xargs = xargsInput(c, words);
484
+ if (xargs !== undefined && (program === "xargs" || ECHOES.test(program)))
485
+ return isSecretFile(xargs);
486
+ if (!CONTENT_COMMANDS.test(program) || printsNamesOnly(words) || editsInPlace(words))
487
+ return false;
488
+ // (`env` here runs no command: simpleCommand reads past it to the one it runs. `env -0` prints the environment too;
489
+ // `printenv NAME` prints one variable, judged by its name: printsSecret.)
490
+ if (program === "env" ||
491
+ (/^(printenv|export|declare|set)$/.test(program) &&
492
+ (rest.length === 0 ||
493
+ (program === "printenv" && rest.every((t) => t.startsWith("-"))) ||
494
+ (program === "declare" && rest[0] === "-p") ||
495
+ (program === "export" && rest[0] === "-p"))))
496
+ return true;
497
+ // (A redirection's target is read when it is `<`'s: `cat < .env`; `echo x > .env` prints nothing.)
498
+ if (allRedirects(c).some((r) => r.op === "<" && r.target && isSecretFile(r.target.text)))
499
+ return true;
500
+ // (Counting, listing and testing print none of what grep reads: `grep -c`, `grep -l`, `grep -q`.)
501
+ if (/^(?:grep|egrep|fgrep|rg|ag|ack|zgrep)$/.test(program) && rest.some((t) => countsMatches(program, t)))
502
+ return false;
503
+ const files = /^(?:grep|egrep|fgrep|rg|ag|ack|zgrep|Select-String|sls)$/.test(program)
504
+ ? searchedFiles(rest)
505
+ : rest.filter((t) => !t.startsWith("-"));
506
+ return files.some((t) => isSecretFile(t));
507
+ }
508
+ /**
509
+ * The files a search reads (grep, ripgrep, Select-String), from its words after the program: its operands but the
510
+ * pattern, which is the first unless an option gives it (`grep -rn password src/`, `grep -e KEY .env`, `grep -ePASS
511
+ * .env`, `Select-String -Pattern KEY -Path .env`). An option's value is no operand: `grep -m 1 token src/`.
512
+ */
513
+ function searchedFiles(rest) {
514
+ const operands = [];
515
+ let pattern = false;
516
+ for (let k = 0; k < rest.length; k++) {
517
+ const t = rest[k];
518
+ if (/^-[ef]|^--(?:regexp|file)(?:=|$)/.test(t) || /^-Pattern$/i.test(t))
519
+ pattern = true;
520
+ if (VALUE_OPTIONS.grep.test(t) || /^-(?:Pattern|Context)$/i.test(t))
521
+ k++;
522
+ else if (!t.startsWith("-"))
523
+ operands.push(t);
524
+ }
525
+ return operands.slice(pattern ? 0 : 1);
526
+ }
527
+ /**
528
+ * The file a command's xargs (a wrapper before `words`, its simple command, or that command) reads its words from: its
529
+ * `-a FILE` (`-aFILE`, `--arg-file=FILE`), or else its input's `< FILE`.
530
+ */
531
+ function xargsInput(c, words) {
532
+ const k = c.words.findIndex((w) => programName(w) === "xargs");
533
+ if (k < 0 || k > c.words.length - words.length)
534
+ return undefined;
535
+ for (let i = k + 1; i < c.words.length && c.words[i].text.startsWith("-"); i++) {
536
+ const w = c.words[i].text;
537
+ if (w === "-a" || w === "--arg-file")
538
+ return c.words[i + 1]?.text;
539
+ if (w.startsWith("--arg-file="))
540
+ return w.slice(11);
541
+ if (w.startsWith("-a"))
542
+ return w.slice(2);
543
+ }
544
+ return allRedirects(c).find((r) => r.op === "<")?.target?.text;
545
+ }
546
+ /**
547
+ * Whether what a command reads (`reads`: a secret file, a credential) reaches what it prints: in some pipeline of it, the
548
+ * stage that reads it, then no stage that prints none of its input (`| wc -l`, `| sha256sum`, `| wl-copy`, `grep -c`;
549
+ * any other program is taken to print what it reads: `| nl`, `| xargs`, `| python3 -c …`), and the last stage's output
550
+ * not redirected to a file (`> /tmp/backup`), unless a later command reads that copy (`> /tmp/x; cat /tmp/x`). Run by
551
+ * a substitution, it is printed only by a command that prints its arguments (`echo "$(cat .env)"`; not `export $(grep
552
+ * … .env)`) or reads it as a file (`diff <(…)`).
553
+ */
554
+ function printsWhatItReads(command, reads, source) {
555
+ const run = commandsRun(command);
556
+ // The files a pipeline wrote what it read to, or that `cp`/`mv` made of a `source`: a later command that reads one
557
+ // reads it too.
558
+ const copies = new Set();
559
+ const readsCopy = (c) => copies.size > 0 &&
560
+ CONTENT_COMMANDS.test(programName(simpleCommand(c.words)[0])) &&
561
+ [
562
+ ...simpleCommand(c.words)
563
+ .slice(1)
564
+ .map((w) => w.text),
565
+ ...allRedirects(c)
566
+ .filter((r) => r.op === "<")
567
+ .map((r) => r.target?.text ?? ""),
568
+ ].some((t) => copies.has(t));
569
+ // (Pipelines are read among the commands that share a host: the line's, or one substitution's.)
570
+ let pipeline = [];
571
+ for (let k = 0; k < run.length; k++) {
572
+ const c = run[k];
573
+ const words = simpleCommand(c.words).map((w) => w.text);
574
+ if (source &&
575
+ /^(?:cp|mv|install|ln)$/.test(programName(simpleCommand(c.words)[0])) &&
576
+ words.slice(1, -1).some((t) => !t.startsWith("-") && (source(t) || copies.has(t))))
577
+ copies.add(words.at(-1));
578
+ pipeline.push(c);
579
+ if ((c.then === "|" || c.then === "|&") && run[k + 1]?.host === c.host)
580
+ continue;
581
+ const stages = pipeline;
582
+ pipeline = [];
583
+ const at = stages.findIndex((s) => reads(s) || readsCopy(s));
584
+ if (at < 0)
585
+ continue;
586
+ const host = stages[at].host && programName(simpleCommand(stages[at].host.words)[0]);
587
+ // (`export`, `declare`, `set`, `env` given words assign them; they print the environment only alone.)
588
+ // (A content command reads a process substitution as a file, `diff <(…) x`; what `$(…)` gives it is an argument,
589
+ // like `sed "s/^KEY=.*/KEY=$(openssl rand -hex 32)/" .env`'s script.)
590
+ const readAsFile = command.slice(Math.max(0, stages[at].start - 2), stages[at].start) === "<(";
591
+ if (host &&
592
+ !ECHOES.test(host) &&
593
+ !(CONTENT_COMMANDS.test(host) && !/^(?:export|declare|set|env|printenv)$/.test(host) && readAsFile))
594
+ continue;
595
+ if (!stages.slice(at + 1).every(passesThrough))
596
+ continue;
597
+ const file = stdoutFile(stages.at(-1));
598
+ if (file === undefined)
599
+ return true;
600
+ copies.add(file);
601
+ }
602
+ return false;
603
+ }
604
+ /** Devices that are what's shown: a tool's captured output holds stdout and stderr, and a terminal shows both. */
605
+ const SHOWN = /^\/dev\/(?:stdout|stderr|tty|fd\/[12])$/;
606
+ /** The file a command's output goes to, not to what's shown: `> out`, `>> log`, `&> /dev/null`, `>& out` (not `2>`, `>&2`, `> /dev/stderr`). */
607
+ function stdoutFile(c) {
608
+ const r = allRedirects(c).find((r) => redirectsStdout(r) && !SHOWN.test(r.target?.text ?? ""));
609
+ return r ? (r.target?.text ?? "") : undefined;
610
+ }
611
+ /**
612
+ * Whether a grep's option prints a count, a file list or nothing instead of the lines: `-c`, `-l`, `-L`, `-q`,
613
+ * `--count`. (ripgrep's `-L` follows links and prints the lines.)
614
+ */
615
+ function countsMatches(program, option) {
616
+ return ((program === "rg" ? /^-[a-zA-Z]*[clq][a-zA-Z]*$/ : /^-[a-zA-Z]*[clLq][a-zA-Z]*$/).test(option) ||
617
+ /^--(?:count|files-with(?:out)?-matches|quiet|silent)$/.test(option));
618
+ }
619
+ /**
620
+ * Programs that print none of their input: counts, checksums, a clipboard's tools, tests, compressors and encrypters
621
+ * (their output is the input made unreadable). Any other stage of a pipeline is taken to pass on what it reads (`nl`,
622
+ * `xargs`, `perl -pe`, `python3 -c …`, `bat`): what it prints can't be known.
623
+ */
624
+ const PRINTS_NONE_OF_INPUT = /^(?:wc|md5sum|sha\d*sum|b2sum|shasum|cksum|sum|pbcopy|xclip|xsel|wl-copy|clip|clip\.exe|true|false|test|\[|cmp|gzip|bzip2|xz|zstd|lz4|age|ssh-add)$/;
625
+ /**
626
+ * A stage that takes its input as a secret to store or use, printing only its own messages: `docker login
627
+ * --password-stdin`, `gh auth login --with-token`, `gh secret set`, `kubectl create secret … --from-file=/dev/stdin`,
628
+ * `vault kv put … -`, `gpg -c`, `openssl enc`, `tar`. (Not one that decrypts, `openssl enc -d`, nor gpg's `--clearsign`,
629
+ * which prints its input signed but in the clear; nor `vault login -`, which prints the token it logged in with.)
630
+ */
631
+ function consumesInput(command) {
632
+ const program = programName(command[0]);
633
+ const words = command.map((w) => w.text);
634
+ const sub = words[1] ?? "";
635
+ return (words.some((w) => /^--(?:password|token|identity-token)-stdin$|^--(?:from-file|data-file|body-file)=(?:-|\/dev\/stdin)$/.test(w)) ||
636
+ (program === "gh" && ((sub === "auth" && words.includes("--with-token")) || sub === "secret")) ||
637
+ (program === "kubectl" && /^(?:create|apply|replace)$/.test(sub)) ||
638
+ (program === "vault" && /^(?:kv|write)$/.test(sub)) ||
639
+ (program === "openssl" &&
640
+ /^(?:enc|dgst|pkeyutl|rsautl|smime|cms)$/.test(sub) &&
641
+ !words.some((w) => /^-(?:d|decrypt)$/.test(w))) ||
642
+ (program === "gpg" && words.some((w) => /^-[a-z]*[ec][a-z]*$|^--(?:encrypt|symmetric|sign)$/.test(w))) ||
643
+ program === "tar");
644
+ }
645
+ /** Whether a command prints a credential: any of its pipelines (`openssl rand -hex 32 && echo ok`) whose stage that makes one reaches its output. */
646
+ export function printsSecret(command) {
647
+ return printsWhatItReads(command, printsCredential);
648
+ }
649
+ function passesThrough(c) {
650
+ const words = simpleCommand(c.words);
651
+ const program = programName(words[0]);
652
+ // (A grep passes the lines it matches; counting, listing or testing them passes none: `grep -c`, `-l`, `-q`.)
653
+ if (/^(?:grep|egrep|fgrep|rg|ag|ack)$/.test(program))
654
+ return !words.slice(1).some((w) => countsMatches(program, w.text));
655
+ // (Nor a stage that prints the public key of the private key it reads: `wg pubkey`, `openssl pkey -pubout`.)
656
+ const publicOfInput = (program === "wg" && words[1]?.text === "pubkey") ||
657
+ (program === "openssl" && words.some((w) => w.text === "-pubout"));
658
+ return !printsNamesOnly(words) && !PRINTS_NONE_OF_INPUT.test(program) && !consumesInput(words) && !publicOfInput;
659
+ }
660
+ /** How much of a command SECRET_TOOLS reads: its patterns have several `.*`, quadratic on a very long line. */
661
+ const MAX_TOOL_LINE = 4000;
662
+ /**
663
+ * Whether one command prints a credential: a tool that prints one (SECRET_TOOLS), a secret source it reads or calls
664
+ * (`head -c 32 /dev/urandom`, `python -c 'print(secrets.token_hex())'`), or a credential variable printed on its own
665
+ * (`echo $DB_PASSWORD`, `printf 'Token: %s\n' "$TOKEN"`, `printenv OPENAI_API_KEY`).
666
+ */
667
+ function printsCredential(c) {
668
+ const words = simpleCommand(c.words);
669
+ // (Read from the program's own name on: `/usr/bin/gh auth token` is `gh auth token`.)
670
+ const line = [programName(words[0]), ...words.slice(1).map((w) => w.text)].join(" ").slice(0, MAX_TOOL_LINE);
671
+ if (SECRET_TOOLS.test(line) || SECRET_SOURCES.test(line))
672
+ return true;
673
+ const program = programName(words[0]);
674
+ const args = words.slice(1);
675
+ if (program === "printenv")
676
+ return args.length === 1 && isCredentialName(args[0].text);
677
+ if (program !== "echo" && program !== "printf")
678
+ return false;
679
+ // (printf prints its format and its operands alike, `printf "Token: %s\n" "$TOKEN"`, `printf "$TOKEN"`; but `-v NAME`
680
+ // assigns what it would print and prints nothing.)
681
+ const values = program === "echo"
682
+ ? args.filter((w) => !/^-[neE]+$/.test(w.text))
683
+ : args[0]?.text === "-v"
684
+ ? []
685
+ : args.filter((w) => w.text !== "--");
686
+ // (A credential's variable printed alone, `echo $DB_PASSWORD`, or inside its words, `echo "token: $GITHUB_TOKEN"`.)
687
+ return values.some((v) => !v.literalEnd && [...v.text.matchAll(/\$\{?([A-Za-z_]\w*)\}?/g)].some((m) => isCredentialName(m[1])));
688
+ }
689
+ /** Tools by name, and how each takes a password (structure.ts reads them). */
690
+ const TOOLS = [
691
+ [
692
+ /^(?:docker|podman|helm|oras|crane|skopeo|buildah|nerdctl|regctl|cosign)$/,
693
+ { flags: new Set(["-p", "--password"]), next: "always", attached: false, subcommand: "login" },
694
+ ],
695
+ [/^sshpass$/, { flags: new Set(["-p"]), next: "always", attached: true, wraps: true }],
696
+ [/^(?:redis|valkey|keydb)-cli$/, { flags: new Set(["-a", "--pass"]), next: "always", attached: true }],
697
+ [
698
+ /^ldap(?:search|modify|add|delete|passwd|whoami|compare|modrdn)$/,
699
+ { flags: new Set(["-w"]), next: "always", attached: true },
700
+ ],
701
+ [/^(?:smbclient|svn|rpcclient|net)$/, { flags: new Set(["--password"]), next: "always", attached: false }],
702
+ [
703
+ /^mongo(?:sh|dump|restore|export|import|stat|top)?$/,
704
+ { flags: new Set(["-p", "--password"]), next: "unless an option", attached: false },
705
+ ],
706
+ [/^influx$/, { flags: new Set(["-p", "--password", "-password"]), next: "always", attached: false }],
707
+ [/^(?:sqlcmd|bcp|sqlpackage)$/, { flags: new Set(["-P"]), next: "always", attached: false }],
708
+ [/^(?:supabase|altool)$/, { flags: new Set(["-p", "--password"]), next: "always", attached: false }],
709
+ [/^mosquitto_(?:pub|sub|rr)$/, { flags: new Set(["-P", "--pw"]), next: "always", attached: false }],
710
+ [/^beeline$/, { flags: new Set(["-p"]), next: "always", attached: false }],
711
+ [/^az$/, { flags: new Set(["-p", "--password"]), next: "always", attached: false, subcommand: "login" }],
712
+ [/^gh$/, { flags: new Set(["-b", "--body"]), next: "always", attached: false, subcommand: "secret" }],
713
+ [/^(?:zip|unzip|zipcloak)$/, { flags: new Set(["-P", "--password"]), next: "always", attached: false }],
714
+ // (`7z x f.7z -pPW`: attached only; `-p` alone prompts.)
715
+ [/^(?:7z|7za|7zr)$/, { flags: new Set(["-p"]), next: "never", attached: true }],
716
+ [
717
+ /^keytool$/,
718
+ {
719
+ flags: new Set([
720
+ "-storepass",
721
+ "-keypass",
722
+ "-srcstorepass",
723
+ "-deststorepass",
724
+ "-srckeypass",
725
+ "-destkeypass",
726
+ "-new",
727
+ ]),
728
+ next: "always",
729
+ attached: false,
730
+ },
731
+ ],
732
+ // MySQL's and MariaDB's clients, which share one option parser. (`-p` alone prompts; the password is attached, `-pPW`,
733
+ // or given as `--password=PW`.)
734
+ [
735
+ /^(?:mysql(?:admin|binlog|check|dump|import|pump|show|slap|sh|_upgrade|_secure_installation)?|mariadb(?:-(?:admin|binlog|check|dump|import|show|slap|upgrade|secure-installation))?)$/,
736
+ { flags: new Set(["--password"]), next: "never", attached: true },
737
+ ],
738
+ ];
739
+ /** How the tool a program names takes a password, if it takes one by an option. */
740
+ export function passwordOptions(program) {
741
+ return TOOLS.find(([re]) => re.test(program))?.[1];
742
+ }
743
+ /**
744
+ * The simple command (shell.ts simpleCommand: past wrappers and assignments) whose words hold `at`, and the word at
745
+ * `at`: among the commands its line runs (`mysql` in `docker exec db mysql --password …`), or else among the line's
746
+ * commands as written (`docker` in `docker exec -u www-data:www-data app …`, which runs `app …`'s command).
747
+ */
748
+ export function commandAt(text, at) {
749
+ for (const commands of lineCommands(text, at)) {
750
+ for (const c of commands) {
751
+ const word = c.words.find((w) => w.start <= at && at < w.end);
752
+ if (word)
753
+ return { command: simpleCommand(c.words), word };
754
+ }
755
+ }
756
+ return undefined;
757
+ }
758
+ /**
759
+ * The commands that the line holding `at` runs, and the line's commands as written: read once per line of the last
760
+ * text asked about (its line starts found once too, so a long line's many options cost one reading).
761
+ */
762
+ const readLines = lastText((text) => {
763
+ const starts = [0];
764
+ for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1))
765
+ starts.push(i + 1);
766
+ return { starts, lines: new Map() };
767
+ });
768
+ function lineCommands(text, at) {
769
+ const { starts, lines } = readLines(text);
770
+ const start = starts[lowerBound(starts, at + 1) - 1];
771
+ let both = lines.get(start);
772
+ if (!both)
773
+ lines.set(start, (both = [commandsRun(text, { at: start, oneLine: true }), commandLine(text, start, { oneLine: true })]));
774
+ return both;
775
+ }
776
+ /**
777
+ * Whether the word after the option at `at` is not its value, because the option takes one only attached:
778
+ * `mysql -u root --password mydb` prompts, and `mydb` is the database.
779
+ */
780
+ export function promptsForValue(text, at) {
781
+ const found = commandAt(text, at);
782
+ const spec = found?.command.length ? passwordOptions(programName(found.command[0])) : undefined;
783
+ return spec?.next === "never" && spec.flags.has(found.word.text);
784
+ }