@piagent/platform 1.2.0 → 1.2.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.
- package/CHANGELOG.md +133 -1
- package/README.md +1 -1
- package/docs/command-reference-vietnamese.md +8 -3
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +49 -3
- package/docs/operator-manual-vietnamese.md +20 -8
- package/docs/publishing-for-teams.md +1 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +4 -3
- package/docs/team-onboarding.md +3 -3
- package/docs/usage-observability.md +29 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/mcp-approval.html +54 -2
- package/docs-site/content/quickstart.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +55 -3
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +2 -2
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/package.json +1 -1
- package/packages/piagent-core/capabilities/capability-core.js +41 -4
- package/packages/piagent-core/capabilities/capability-sources.js +73 -12
- package/packages/piagent-core/extensions/piagent-guard.ts +139 -25
- package/packages/piagent-core/extensions/policy-core.js +1586 -98
- package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
- package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
- package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
- package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
- package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
- package/packages/piagent-core/package.json +1 -1
- package/scripts/configure-model-scope.sh +19 -3
- package/scripts/configure-subagents.sh +17 -4
- package/scripts/init-project.sh +15 -8
- package/scripts/mcp-manage.mjs +111 -22
- package/scripts/pi-model-catalog.sh +11 -1
- package/scripts/pi-session-stats.sh +27 -1
- package/scripts/pi-usage-history.mjs +567 -0
- package/scripts/quality-benchmark.sh +37 -9
- package/scripts/setup.sh +21 -13
- package/scripts/uninstall-global.sh +10 -4
- package/scripts/verify-local.sh +19 -0
- package/scripts/verify-release-identity.mjs +29 -0
|
@@ -6,6 +6,36 @@ const SEARCH_GLOB_OPTIONS = new Set(["-g", "--glob", "--iglob", "--include", "--
|
|
|
6
6
|
const SEARCH_PATTERN_OPTIONS = new Set(["-e", "--regexp", "--pattern"]);
|
|
7
7
|
const ASSIGNMENT_BUILTINS = new Set(["export", "readonly", "declare", "typeset", "local"]);
|
|
8
8
|
const SHELL_COMMANDS = new Set(["bash", "sh", "zsh"]);
|
|
9
|
+
|
|
10
|
+
// Commands whose non-flag operands are files by definition, rather than by
|
|
11
|
+
// convention. The distinction matters because this set decides where a word
|
|
12
|
+
// nobody can resolve is refused: `cat $F` names a file whatever `F` holds, so
|
|
13
|
+
// not knowing it is worth a question, while `git commit -m $MSG` names a
|
|
14
|
+
// message and asking about it would be noise with no answer behind it.
|
|
15
|
+
const FILE_OPERAND_COMMANDS = new Set([
|
|
16
|
+
"cat", "tac", "head", "tail", "less", "more", "nl", "od", "xxd", "strings", "wc",
|
|
17
|
+
"base64", "md5sum", "shasum", "sha256sum", "cksum", "file", "stat", "readlink",
|
|
18
|
+
"cp", "mv", "ln", "touch", "install", "shred", "truncate", "chmod", "chown", "chgrp",
|
|
19
|
+
"source", ".", "open"
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Interpreters that take a program on the command line, and the flags that
|
|
23
|
+
// introduce it. What follows such a flag is source code in another language, so
|
|
24
|
+
// none of it tokenises as a shell path — `node -e "readFileSync('.env')"` is one
|
|
25
|
+
// opaque argument to the shell parser. The filenames live in the string literals
|
|
26
|
+
// inside it, which is where this looks.
|
|
27
|
+
const INLINE_CODE_FLAGS = new Map([
|
|
28
|
+
["node", new Set(["-e", "--eval", "-p", "--print"])],
|
|
29
|
+
["nodejs", new Set(["-e", "--eval", "-p", "--print"])],
|
|
30
|
+
["deno", new Set(["eval"])],
|
|
31
|
+
["bun", new Set(["-e", "--eval"])],
|
|
32
|
+
["python", new Set(["-c"])],
|
|
33
|
+
["python2", new Set(["-c"])],
|
|
34
|
+
["python3", new Set(["-c"])],
|
|
35
|
+
["ruby", new Set(["-e"])],
|
|
36
|
+
["perl", new Set(["-e", "-E"])],
|
|
37
|
+
["php", new Set(["-r"])]
|
|
38
|
+
]);
|
|
9
39
|
const SIMPLE_WRAPPERS = new Set(["sudo", "nohup", "time", "nice", "ionice", "command"]);
|
|
10
40
|
|
|
11
41
|
function escapeRegex(value) {
|
|
@@ -96,6 +126,12 @@ export function splitShellSegments(command) {
|
|
|
96
126
|
let current = "";
|
|
97
127
|
let quote;
|
|
98
128
|
let escaped = false;
|
|
129
|
+
// A separator inside `$( )` or backticks belongs to the substitution, not to
|
|
130
|
+
// the command line around it. Splitting there cut `cat $(echo x; echo .env)`
|
|
131
|
+
// into two halves, and the half holding the path was re-read as a command of
|
|
132
|
+
// its own — so the path was never a path to anything here.
|
|
133
|
+
let substitutionDepth = 0;
|
|
134
|
+
let inBackticks = false;
|
|
99
135
|
for (let index = 0; index < command.length; index += 1) {
|
|
100
136
|
const char = command[index];
|
|
101
137
|
const next = command[index + 1];
|
|
@@ -119,9 +155,34 @@ export function splitShellSegments(command) {
|
|
|
119
155
|
current += char;
|
|
120
156
|
continue;
|
|
121
157
|
}
|
|
158
|
+
if (!quote) {
|
|
159
|
+
if (char === "$" && next === "(") {
|
|
160
|
+
substitutionDepth += 1;
|
|
161
|
+
current += char;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (char === ")" && substitutionDepth > 0) {
|
|
165
|
+
substitutionDepth -= 1;
|
|
166
|
+
current += char;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (char === "`") {
|
|
170
|
+
inBackticks = !inBackticks;
|
|
171
|
+
current += char;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
122
175
|
const previous = command[index - 1];
|
|
123
176
|
const ampersandIsSeparator = char === "&" && next !== "&" && previous !== ">" && previous !== "<";
|
|
124
|
-
|
|
177
|
+
// `>|` is one operator: the noclobber override. Without the same lookbehind
|
|
178
|
+
// `&` already had, the `|` split the line and the redirection target became
|
|
179
|
+
// a command name, so `printf x >| .env` truncated a protected file against a
|
|
180
|
+
// candidate list that never contained it.
|
|
181
|
+
const pipeIsSeparator = char === "|" && previous !== ">";
|
|
182
|
+
const inSubstitution = substitutionDepth > 0 || inBackticks;
|
|
183
|
+
if (!quote && !inSubstitution
|
|
184
|
+
&& (char === "\n" || char === ";" || pipeIsSeparator || ampersandIsSeparator
|
|
185
|
+
|| (char === "&" && next === "&") || (char === "|" && next === "|"))) {
|
|
125
186
|
const segment = current.trim();
|
|
126
187
|
if (segment) segments.push(segment);
|
|
127
188
|
current = "";
|
|
@@ -135,6 +196,39 @@ export function splitShellSegments(command) {
|
|
|
135
196
|
return segments.length ? segments : [command.trim()].filter(Boolean);
|
|
136
197
|
}
|
|
137
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Reads one `$'...'` ANSI-C quote and returns what the shell would pass along.
|
|
201
|
+
*
|
|
202
|
+
* Treating it as an ordinary single quote is not enough: the escapes inside are
|
|
203
|
+
* the point of the syntax, and leaving them raw means `$'\x2eenv'` is read as
|
|
204
|
+
* the literal `x2eenv` while the shell opens `.env`. The same trick reaches
|
|
205
|
+
* `rm -rf $'\x2f'`, which nothing recognised as root.
|
|
206
|
+
*
|
|
207
|
+
* A backslash escapes the next character, the closing quote included, so `\'`
|
|
208
|
+
* does not end the quote.
|
|
209
|
+
*
|
|
210
|
+
* @param {string[]|string} chars the segment, indexed the same way the caller
|
|
211
|
+
* indexes it, so the returned endIndex can be assigned straight back
|
|
212
|
+
* @param {number} openIndex index of the `$`
|
|
213
|
+
* @returns {{value: string, endIndex: number}} decoded content, and the index of
|
|
214
|
+
* the closing quote (or the last character when the quote never closes)
|
|
215
|
+
*/
|
|
216
|
+
function readAnsiCQuote(chars, openIndex) {
|
|
217
|
+
let raw = "";
|
|
218
|
+
let index = openIndex + 2;
|
|
219
|
+
for (; index < chars.length; index += 1) {
|
|
220
|
+
const char = chars[index];
|
|
221
|
+
if (char === "\\" && index + 1 < chars.length) {
|
|
222
|
+
raw += char + chars[index + 1];
|
|
223
|
+
index += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (char === "'") break;
|
|
227
|
+
raw += char;
|
|
228
|
+
}
|
|
229
|
+
return { value: decodeShellDataEscapes(raw), endIndex: Math.min(index, chars.length - 1) };
|
|
230
|
+
}
|
|
231
|
+
|
|
138
232
|
function shellWordTokens(segment) {
|
|
139
233
|
const tokens = [];
|
|
140
234
|
let current = "";
|
|
@@ -143,17 +237,37 @@ function shellWordTokens(segment) {
|
|
|
143
237
|
let activeGlob = false;
|
|
144
238
|
let unquotedVariable = false;
|
|
145
239
|
let variableActive = false;
|
|
240
|
+
// A space inside `$( )`, `${ }` or backticks is part of one word, not a break
|
|
241
|
+
// between two. Splitting there tore `.en$(echo v)` into `.en$(echo` and `v)`,
|
|
242
|
+
// so the filename being assembled was never a single token to reason about.
|
|
243
|
+
let substitutionDepth = 0;
|
|
244
|
+
let inBackticks = false;
|
|
245
|
+
// Set only for a brace the shell would expand. Quoting suspends the expansion
|
|
246
|
+
// (`"{a,b}"` is one literal word), and `${NAME}` is a parameter reference
|
|
247
|
+
// rather than a list, so neither marks the token.
|
|
248
|
+
let braceActive = false;
|
|
249
|
+
// One flag per character of `value`: `1` where the character is text rather
|
|
250
|
+
// than brace syntax -- quoted, escaped, part of `${...}`, or inside a
|
|
251
|
+
// substitution. A single flag for the whole token cannot describe
|
|
252
|
+
// `"{.env,}"{,}`, where one group is literal and the next one expands.
|
|
253
|
+
let literalMask = "";
|
|
146
254
|
const flush = () => {
|
|
147
255
|
if (!current) return;
|
|
148
|
-
tokens.push({ value: current, activeGlob, unquotedVariable, variableActive });
|
|
256
|
+
tokens.push({ value: current, activeGlob, unquotedVariable, variableActive, braceActive, literalMask });
|
|
149
257
|
current = "";
|
|
258
|
+
literalMask = "";
|
|
150
259
|
activeGlob = false;
|
|
151
260
|
unquotedVariable = false;
|
|
152
261
|
variableActive = false;
|
|
262
|
+
braceActive = false;
|
|
153
263
|
};
|
|
154
|
-
|
|
264
|
+
const chars = [...segment.trim()];
|
|
265
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
266
|
+
const char = chars[index];
|
|
155
267
|
if (escaped) {
|
|
156
268
|
current += char;
|
|
269
|
+
// `\{a,b\}` is a filename, not a list.
|
|
270
|
+
literalMask += "1";
|
|
157
271
|
escaped = false;
|
|
158
272
|
continue;
|
|
159
273
|
}
|
|
@@ -161,6 +275,19 @@ function shellWordTokens(segment) {
|
|
|
161
275
|
escaped = true;
|
|
162
276
|
continue;
|
|
163
277
|
}
|
|
278
|
+
// `$'...'` is ANSI-C quoting: the dollar opens the quote rather than naming
|
|
279
|
+
// a variable, and the escapes inside it are decoded before the word is
|
|
280
|
+
// passed on. Reading it as a variable left the dollar glued to the value, so
|
|
281
|
+
// `cat $'.env'` produced the candidate `$.env`; reading it as a plain single
|
|
282
|
+
// quote dropped the backslashes, so `cat $'\x2eenv'` produced `x2eenv`.
|
|
283
|
+
// Either way the shell opened `.env` and nothing here saw a protected path.
|
|
284
|
+
if (char === "$" && chars[index + 1] === "'" && !quote) {
|
|
285
|
+
const ansiC = readAnsiCQuote(chars, index);
|
|
286
|
+
current += ansiC.value;
|
|
287
|
+
literalMask += "1".repeat(ansiC.value.length);
|
|
288
|
+
index = ansiC.endIndex;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
164
291
|
if ((char === "'" || char === "\"") && !quote) {
|
|
165
292
|
quote = char;
|
|
166
293
|
continue;
|
|
@@ -169,16 +296,41 @@ function shellWordTokens(segment) {
|
|
|
169
296
|
quote = undefined;
|
|
170
297
|
continue;
|
|
171
298
|
}
|
|
172
|
-
|
|
299
|
+
// The depth this character sits at before it can close the substitution it
|
|
300
|
+
// belongs to. Reading the depth afterwards left the `}` of `${...}` looking
|
|
301
|
+
// like brace syntax, so `{cat,${X:-.env}}` closed on the wrong brace and
|
|
302
|
+
// expanded to `cat}` and `${X:-.env}` instead of `cat` and `.env`.
|
|
303
|
+
const enclosingDepth = substitutionDepth;
|
|
304
|
+
if (!quote) {
|
|
305
|
+
if (char === "$" && (chars[index + 1] === "(" || chars[index + 1] === "{")) substitutionDepth += 1;
|
|
306
|
+
else if ((char === ")" || char === "}") && substitutionDepth > 0) substitutionDepth -= 1;
|
|
307
|
+
else if (char === "`") inBackticks = !inBackticks;
|
|
308
|
+
}
|
|
309
|
+
const insideSubstitution = enclosingDepth > 0 || substitutionDepth > 0;
|
|
310
|
+
if (!quote && substitutionDepth === 0 && !inBackticks && /\s/.test(char)) {
|
|
173
311
|
flush();
|
|
174
312
|
continue;
|
|
175
313
|
}
|
|
176
314
|
if (!quote && /[*?{\[]/.test(char)) activeGlob = true;
|
|
177
|
-
|
|
315
|
+
// A brace the shell would expand, which is any brace not opening a parameter
|
|
316
|
+
// reference and not inside one. The dollar is counted rather than looked for
|
|
317
|
+
// behind the brace, because a dollar can be there without opening anything:
|
|
318
|
+
// in `\${a,b}` it is literal text and the shell still expands the group.
|
|
319
|
+
//
|
|
320
|
+
// No decision can currently tell the two readings apart -- the literal
|
|
321
|
+
// dollar is glued onto every alternative, so `\${a,.env}` produces `$a` and
|
|
322
|
+
// `$.env` and neither is a path or a command. This is the correct reading
|
|
323
|
+
// rather than a fix for anything, and it is written this way because the
|
|
324
|
+
// depth is already known here and looking backwards was never right.
|
|
325
|
+
if (!quote && char === "{" && enclosingDepth === 0 && !inBackticks) {
|
|
326
|
+
braceActive = true;
|
|
327
|
+
}
|
|
328
|
+
if ((char === "$" || char === "`") && quote !== "'") {
|
|
178
329
|
variableActive = true;
|
|
179
330
|
if (!quote) unquotedVariable = true;
|
|
180
331
|
}
|
|
181
332
|
current += char;
|
|
333
|
+
literalMask += (quote || insideSubstitution || inBackticks) ? "1" : "0";
|
|
182
334
|
}
|
|
183
335
|
flush();
|
|
184
336
|
return tokens;
|
|
@@ -188,6 +340,323 @@ export function shellWords(segment) {
|
|
|
188
340
|
return shellWordTokens(segment).map((token) => token.value);
|
|
189
341
|
}
|
|
190
342
|
|
|
343
|
+
const BRACE_RANGE = /^(-?\d+|[A-Za-z])\.\.(-?\d+|[A-Za-z])(?:\.\.(-?\d+))?$/;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The members of a `{a..z}` or `{1..9}` range, or undefined when the body is
|
|
347
|
+
* not one.
|
|
348
|
+
*
|
|
349
|
+
* Ranges were left literal on the reasoning that they enumerate counters rather
|
|
350
|
+
* than spell names. They spell names perfectly well: `{n..n}` is one letter, so
|
|
351
|
+
* `r{m..m}` is `rm`, `fi{n..n}d` is `find` and `.e{n..n}v` is `.env`. A range
|
|
352
|
+
* with a single member is the same trick the empty alternative was.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} body
|
|
355
|
+
* @returns {string[]|undefined}
|
|
356
|
+
*/
|
|
357
|
+
function braceRangeAlternatives(body) {
|
|
358
|
+
const match = BRACE_RANGE.exec(body);
|
|
359
|
+
if (!match) return undefined;
|
|
360
|
+
const [, from, to, step] = match;
|
|
361
|
+
const increment = Math.abs(Number(step ?? 1)) || 1;
|
|
362
|
+
// The letter ranges are the ones that spell things. A digit range enumerates
|
|
363
|
+
// counters, and a protected pattern that would match what it produces matches
|
|
364
|
+
// the unexpanded word too, since the glob covers the braces -- so no decision
|
|
365
|
+
// here turns on the numeric branch. It is expanded anyway, because a word
|
|
366
|
+
// list that disagrees with the shell is the thing this whole file is for.
|
|
367
|
+
const numeric = /^-?\d+$/.test(from) && /^-?\d+$/.test(to);
|
|
368
|
+
if (!numeric && (from.length !== 1 || to.length !== 1 || /^-?\d+$/.test(from) || /^-?\d+$/.test(to))) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const start = numeric ? Number(from) : from.codePointAt(0);
|
|
372
|
+
const end = numeric ? Number(to) : to.codePointAt(0);
|
|
373
|
+
// Bash keeps the zero padding when either endpoint is written with it.
|
|
374
|
+
const width = numeric && (/^-?0\d/.test(from) || /^-?0\d/.test(to))
|
|
375
|
+
? Math.max(from.replace("-", "").length, to.replace("-", "").length)
|
|
376
|
+
: 0;
|
|
377
|
+
const members = [];
|
|
378
|
+
const direction = start <= end ? 1 : -1;
|
|
379
|
+
for (let value = start; direction > 0 ? value <= end : value >= end; value += direction * increment) {
|
|
380
|
+
if (members.length >= MAX_BRACE_RESULTS) break;
|
|
381
|
+
if (!numeric) {
|
|
382
|
+
members.push(String.fromCodePoint(value));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const text = String(Math.abs(value)).padStart(width, "0");
|
|
386
|
+
members.push(value < 0 ? `-${text}` : text);
|
|
387
|
+
}
|
|
388
|
+
return members;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The alternatives of one brace group, split on the commas at its own level.
|
|
393
|
+
* Returns the mask slices alongside, so a nested group keeps knowing which of
|
|
394
|
+
* its characters are syntax.
|
|
395
|
+
*/
|
|
396
|
+
function splitBraceAlternatives(body, mask) {
|
|
397
|
+
const parts = [];
|
|
398
|
+
let current = "";
|
|
399
|
+
let currentMask = "";
|
|
400
|
+
let level = 0;
|
|
401
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
402
|
+
const char = body[index];
|
|
403
|
+
const literal = mask[index] === "1";
|
|
404
|
+
if (!literal && char === "{") level += 1;
|
|
405
|
+
else if (!literal && char === "}") level -= 1;
|
|
406
|
+
else if (!literal && char === "," && level === 0) {
|
|
407
|
+
parts.push({ value: current, mask: currentMask });
|
|
408
|
+
current = "";
|
|
409
|
+
currentMask = "";
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
current += char;
|
|
413
|
+
currentMask += mask[index];
|
|
414
|
+
}
|
|
415
|
+
parts.push({ value: current, mask: currentMask });
|
|
416
|
+
return parts;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const MAX_BRACE_RESULTS = 64;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Brace expansion, which the shell performs before every other expansion.
|
|
423
|
+
*
|
|
424
|
+
* Nothing here modelled it, so a word was read as the single literal the author
|
|
425
|
+
* typed. `rm -rf {/,}` is `rm -rf /` by the time it runs, and `cat {.env,}`
|
|
426
|
+
* opens `.env`; both read as ordinary words holding no root and no protected
|
|
427
|
+
* path. The trailing empty alternative is the whole trick — it makes a one-word
|
|
428
|
+
* expansion out of a form that does not look like one.
|
|
429
|
+
*
|
|
430
|
+
* A group without a top-level comma is left alone, because the shell leaves it
|
|
431
|
+
* alone: `{/}` and `{a}` are literal. Ranges are expanded: they look like they
|
|
432
|
+
* only enumerate counters, but `{n..n}` is a single letter with the braces
|
|
433
|
+
* taken off, which is how `r{m..m}` spells `rm` and `.e{n..n}v` spells `.env`.
|
|
434
|
+
*
|
|
435
|
+
* @param {string} word
|
|
436
|
+
* @returns {string[]} every word the shell would produce, the input itself when
|
|
437
|
+
* there is no expansion to do
|
|
438
|
+
*/
|
|
439
|
+
function expandShellBraces(word, mask = "0".repeat(word.length), depth = 0) {
|
|
440
|
+
if (depth >= 6 || !word.includes("{")) return [{ value: word, mask }];
|
|
441
|
+
const literalAt = (index) => mask[index] === "1";
|
|
442
|
+
let open = -1;
|
|
443
|
+
let level = 0;
|
|
444
|
+
for (let index = 0; index < word.length; index += 1) {
|
|
445
|
+
const char = word[index];
|
|
446
|
+
if (literalAt(index)) continue;
|
|
447
|
+
if (char === "{") {
|
|
448
|
+
if (level === 0) open = index;
|
|
449
|
+
level += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (char !== "}" || level === 0) continue;
|
|
453
|
+
level -= 1;
|
|
454
|
+
if (level > 0) continue;
|
|
455
|
+
const body = word.slice(open + 1, index);
|
|
456
|
+
const bodyMask = mask.slice(open + 1, index);
|
|
457
|
+
const range = braceRangeAlternatives(body);
|
|
458
|
+
const alternatives = range
|
|
459
|
+
? range.map((value) => ({ value, mask: "0".repeat(value.length) }))
|
|
460
|
+
: splitBraceAlternatives(body, bodyMask);
|
|
461
|
+
// A comma body needs two alternatives to be an expansion at all -- `{a}` is
|
|
462
|
+
// literal to the shell. A *range* does not: `{m..m}` is one member and the
|
|
463
|
+
// braces still come off, which is the whole of `r{m..m}`.
|
|
464
|
+
if (!range && alternatives.length < 2) {
|
|
465
|
+
open = -1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const prefix = word.slice(0, open);
|
|
469
|
+
const prefixMask = mask.slice(0, open);
|
|
470
|
+
const suffix = word.slice(index + 1);
|
|
471
|
+
const suffixMask = mask.slice(index + 1);
|
|
472
|
+
const results = [];
|
|
473
|
+
for (const alternative of alternatives) {
|
|
474
|
+
const expandedWord = `${prefix}${alternative.value}${suffix}`;
|
|
475
|
+
const expandedMask = `${prefixMask}${alternative.mask}${suffixMask}`;
|
|
476
|
+
for (const expanded of expandShellBraces(expandedWord, expandedMask, depth + 1)) {
|
|
477
|
+
if (results.length >= MAX_BRACE_RESULTS) return results;
|
|
478
|
+
results.push(expanded);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return results;
|
|
482
|
+
}
|
|
483
|
+
return [{ value: word, mask }];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* True when the word would be matched against filenames rather than used as
|
|
488
|
+
* one, judged per character so quoting counts where the shell counts it.
|
|
489
|
+
*
|
|
490
|
+
* The tokenizer decides this for a word as typed. After brace expansion the
|
|
491
|
+
* answer can differ for each word that comes out -- `{cat,.env*}` produces a
|
|
492
|
+
* command and a pattern from one token, and carrying the parent token's verdict
|
|
493
|
+
* onto both got one of them wrong.
|
|
494
|
+
*
|
|
495
|
+
* @param {string} value
|
|
496
|
+
* @param {string} mask
|
|
497
|
+
* @returns {boolean}
|
|
498
|
+
*/
|
|
499
|
+
/**
|
|
500
|
+
* The single file a redirection would open, or nothing when there is no single
|
|
501
|
+
* file for it to name.
|
|
502
|
+
*
|
|
503
|
+
* Bash writes only where the target expands to exactly one word, and calls
|
|
504
|
+
* everything else an ambiguous redirect and does nothing. `> {.env,x}`,
|
|
505
|
+
* `> "{.env,}"{,}` and `> .en{v,w}*` each expand to two words and open no file
|
|
506
|
+
* at all, so reporting a protected path for them blocked a command that writes
|
|
507
|
+
* nothing. `> {.env,}` is the other way round -- the empty alternative is
|
|
508
|
+
* dropped, one word is left, and it really does truncate `.env`.
|
|
509
|
+
*
|
|
510
|
+
* Expansion happens here rather than at the candidate, so it happens with the
|
|
511
|
+
* record of which characters were quoted, and in the order the shell uses:
|
|
512
|
+
* braces first, substitutions after.
|
|
513
|
+
*
|
|
514
|
+
* Whether to expand at all is the mask's decision and nothing else's. The
|
|
515
|
+
* scanner used to hand over a separate `braceActive` flag beside it, and a flag
|
|
516
|
+
* for a whole word is the thing that went wrong here in the first place: it can
|
|
517
|
+
* only say "expand it all" or "expand none of it", and it can disagree with the
|
|
518
|
+
* record next to it. The mask answers per character, so it answers both.
|
|
519
|
+
*
|
|
520
|
+
* @param {{value: string, literalMask?: string}} redirect
|
|
521
|
+
* @returns {{value: string, mask: string}[]}
|
|
522
|
+
*/
|
|
523
|
+
function redirectionTargetWords(redirect) {
|
|
524
|
+
// The scanner writes one mask character per target character, so the fallback
|
|
525
|
+
// is a defensive default rather than a reachable branch. Unmasked is the safe
|
|
526
|
+
// direction of the two: it expands, and expanding offers more to check.
|
|
527
|
+
const mask = redirect.literalMask?.length === redirect.value.length
|
|
528
|
+
? redirect.literalMask
|
|
529
|
+
: "0".repeat(redirect.value.length);
|
|
530
|
+
const words = expandShellBraces(redirect.value, mask).filter((entry) => entry.value);
|
|
531
|
+
if (words.length === 1) return words;
|
|
532
|
+
// Two words are only two words while both survive. An unset variable expands
|
|
533
|
+
// to nothing, so `> {$X,.env}` leaves one word and truncates `.env`, and an
|
|
534
|
+
// unmatched glob does the same under `nullglob`. Where a word can still
|
|
535
|
+
// vanish the count is not knowable here, so every word stays a candidate.
|
|
536
|
+
const collapsible = words.some((entry) => /\$|`/.test(entry.value) || wordHasActiveGlob(entry.value, entry.mask));
|
|
537
|
+
return collapsible ? words : [];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function wordHasActiveGlob(value, mask) {
|
|
541
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
542
|
+
if (mask[index] === "1") continue;
|
|
543
|
+
if (/[*?[]/.test(value[index])) return true;
|
|
544
|
+
}
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The word list the shell builds for a segment, brace expansion included.
|
|
550
|
+
*
|
|
551
|
+
* Applying the expansion inside the destructive checks closed those checks and
|
|
552
|
+
* nothing else, which is not what the shell does -- it expands once, before it
|
|
553
|
+
* decides what the command is, and every reader downstream sees the result. So
|
|
554
|
+
* `git reset --har{d,}` sailed past a legacy pattern, `docker volume pr{une,}`
|
|
555
|
+
* past an exec-policy rule, and `{bash,} -c '...'` past the nested-interpreter
|
|
556
|
+
* scan, each of them reading the text as typed. This is the one place the word
|
|
557
|
+
* list is built, so a reader cannot be left behind again.
|
|
558
|
+
*
|
|
559
|
+
* `text` is the segment rewritten from those words, for the matchers that
|
|
560
|
+
* compare against the command as a string. It is the raw segment when there is
|
|
561
|
+
* no expansion to do, so quoting is preserved in the ordinary case.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} segment
|
|
564
|
+
* @returns {{words: string[], text: string, tokens: ReturnType<typeof shellWordTokens>}}
|
|
565
|
+
*/
|
|
566
|
+
function expandedSegment(segment, assignments = new Map()) {
|
|
567
|
+
const rawTokens = shellWordTokens(segment);
|
|
568
|
+
if (!rawTokens.some((token) => token.braceActive)) {
|
|
569
|
+
return resolveExpandedTokens(segment, rawTokens, assignments, segment);
|
|
570
|
+
}
|
|
571
|
+
// Expanded into *tokens*, not just into a word list. Taking the command name
|
|
572
|
+
// from the expanded words and the arguments from the raw ones read
|
|
573
|
+
// `{cat,.env}` as a `cat` with no operands at all, because both the command
|
|
574
|
+
// and its argument come out of that single word. Everything downstream takes
|
|
575
|
+
// command and arguments from this one stream.
|
|
576
|
+
//
|
|
577
|
+
// An empty alternative expands to nothing rather than to an empty argument --
|
|
578
|
+
// `rm {-rf,} /` reaches `rm` as two words, not three.
|
|
579
|
+
const tokens = rawTokens.flatMap((token) => {
|
|
580
|
+
if (!token.braceActive) return [token];
|
|
581
|
+
const expansions = expandShellBraces(token.value, token.literalMask).filter((entry) => entry.value);
|
|
582
|
+
if (expansions.length === 1 && expansions[0].value === token.value) return [token];
|
|
583
|
+
// The mask comes out of the expansion with the word, because one token can
|
|
584
|
+
// produce words that differ in what they are: `{cat,.env*}` is a command and
|
|
585
|
+
// a pattern, and deciding either from the parent's flags gets one of them
|
|
586
|
+
// wrong. `braceActive` is cleared below for every token alike.
|
|
587
|
+
return expansions.map((entry) => ({
|
|
588
|
+
...token,
|
|
589
|
+
value: entry.value,
|
|
590
|
+
literalMask: entry.mask,
|
|
591
|
+
activeGlob: wordHasActiveGlob(entry.value, entry.mask)
|
|
592
|
+
}));
|
|
593
|
+
});
|
|
594
|
+
// `braceActive` is cleared on every token, expanded or not. The expansion has
|
|
595
|
+
// already happened here, with the mask; a later reader that expanded again
|
|
596
|
+
// would be doing it without one, and `{.env","x}` -- whose comma is quoted,
|
|
597
|
+
// so the shell leaves the whole word alone -- came back as `.env`.
|
|
598
|
+
// `braceActive` is cleared on every token below, expanded or not.
|
|
599
|
+
return resolveExpandedTokens(segment, tokens, assignments, undefined);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The rest of the expansion order, applied to an already brace-expanded stream.
|
|
604
|
+
*
|
|
605
|
+
* The shell expands braces, then parameters, then command substitutions, and
|
|
606
|
+
* only then decides what the command is. This module did the first of those
|
|
607
|
+
* here and left the other two to whichever reader happened to want them, which
|
|
608
|
+
* meant one reader had them and eleven did not: `rm -rf $(printf /)` was
|
|
609
|
+
* refused because the destructive check resolved its own target, while
|
|
610
|
+
* `r$(printf %s m) -rf /` -- the same removal with the name assembled the same
|
|
611
|
+
* way -- was permitted by every check in the file, and `echo .env | xa$(printf
|
|
612
|
+
* %s rgs) cat` opened a protected file because the consumer's name was read as
|
|
613
|
+
* written. Resolving here means a reader cannot be left behind: there is one
|
|
614
|
+
* word list, and it is the one the shell would build.
|
|
615
|
+
*
|
|
616
|
+
* A word is resolved only where resolving it is faithful. Single quotes suspend
|
|
617
|
+
* substitution, a body this cannot read exactly gives up rather than guess, and
|
|
618
|
+
* an unresolved word is passed through untouched with `resolved: false` so the
|
|
619
|
+
* checks that ask about it still see an expansion they could not evaluate.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} segment
|
|
622
|
+
* @param {ReturnType<typeof shellWordTokens>} tokens
|
|
623
|
+
* @param {Map<string, string>} assignments
|
|
624
|
+
* @param {string | undefined} rawText text to keep when nothing resolves, so
|
|
625
|
+
* quoting survives for the matchers that compare against the command string
|
|
626
|
+
* @returns {{words: string[], text: string, braceText: string,
|
|
627
|
+
* tokens: ReturnType<typeof shellWordTokens>}}
|
|
628
|
+
*/
|
|
629
|
+
function resolveExpandedTokens(segment, tokens, assignments, rawText) {
|
|
630
|
+
const substituting = segmentHasUnquotedSubstitution(segment);
|
|
631
|
+
const evaluable = substituting && substitutionsAreLiteral(segment);
|
|
632
|
+
// The text as it stands after brace expansion and before anything is
|
|
633
|
+
// resolved. The nested scan reads substitution bodies and interpreter
|
|
634
|
+
// payloads out of it, and resolving first takes them away: rewrite
|
|
635
|
+
// `printf data > $(printf %s .env)` to `printf data > .env` and there is no
|
|
636
|
+
// body left to descend into, and the descent is what saw the file.
|
|
637
|
+
const braceText = rawText ?? tokens.map((token) => token.value).join(" ");
|
|
638
|
+
let changed = false;
|
|
639
|
+
const resolved = tokens.map((token) => {
|
|
640
|
+
const base = { ...token, braceActive: false };
|
|
641
|
+
if (!token.variableActive) return { ...base, resolved: true };
|
|
642
|
+
const outcome = resolveSubstitutionTarget(token.value, assignments, evaluable);
|
|
643
|
+
if (outcome.value !== token.value) changed = true;
|
|
644
|
+
// The mask described the text as written; it does not describe this.
|
|
645
|
+
return { ...base, value: outcome.value, literalMask: undefined, resolved: outcome.resolved };
|
|
646
|
+
});
|
|
647
|
+
const words = resolved.map((token) => token.value);
|
|
648
|
+
return {
|
|
649
|
+
words,
|
|
650
|
+
text: !changed && rawText !== undefined ? rawText : words.join(" "),
|
|
651
|
+
braceText,
|
|
652
|
+
tokens: resolved,
|
|
653
|
+
// Reported rather than recomputed. A reader deciding for itself whether a
|
|
654
|
+
// substitution would run is a second rule that can disagree with this one,
|
|
655
|
+
// and disagreeing rules are what put the holes here in the first place.
|
|
656
|
+
evaluable
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
191
660
|
export function arrayStartsWith(items, prefix) {
|
|
192
661
|
return prefix.length > 0 && prefix.every((item, index) => items[index] === item);
|
|
193
662
|
}
|
|
@@ -224,6 +693,19 @@ function isAssignment(word) {
|
|
|
224
693
|
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(word);
|
|
225
694
|
}
|
|
226
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Whether a word is a redirection rather than a command or an argument.
|
|
698
|
+
*
|
|
699
|
+
* Covers the separated form (`>`, `2>`, `>|`) and the attached one (`>x`,
|
|
700
|
+
* `2>>x`), which is why the pattern is anchored but not terminated.
|
|
701
|
+
*
|
|
702
|
+
* @param {string} word
|
|
703
|
+
* @returns {boolean}
|
|
704
|
+
*/
|
|
705
|
+
function isLeadingRedirection(word) {
|
|
706
|
+
return /^[0-9]*(?:>>?\|?|<>?|>&|<&)/.test(word);
|
|
707
|
+
}
|
|
708
|
+
|
|
227
709
|
function stripWrapper(words) {
|
|
228
710
|
let current = [...words];
|
|
229
711
|
let changed = true;
|
|
@@ -235,6 +717,18 @@ function stripWrapper(words) {
|
|
|
235
717
|
changed = true;
|
|
236
718
|
}
|
|
237
719
|
|
|
720
|
+
// A redirection may precede the command word, and the shell still runs the
|
|
721
|
+
// command. Leaving it in place made `>x rm -rf /` read as a command called
|
|
722
|
+
// ">x", so every rule that decides from the command word — the recursive
|
|
723
|
+
// removal check, the legacy patterns — saw something it had no opinion
|
|
724
|
+
// about and said nothing.
|
|
725
|
+
while (current.length > 0 && isLeadingRedirection(current[0])) {
|
|
726
|
+
// `> file` puts the target in the next word; `>file` carries its own.
|
|
727
|
+
const attached = /^[0-9]*(?:>>?\|?|<>?|>&|<&)./.test(current[0]);
|
|
728
|
+
current = current.slice(attached ? 1 : 2);
|
|
729
|
+
changed = true;
|
|
730
|
+
}
|
|
731
|
+
|
|
238
732
|
const command = commandBasename(current[0] ?? "");
|
|
239
733
|
if (command === "env") {
|
|
240
734
|
current = current.slice(1);
|
|
@@ -258,9 +752,247 @@ function stripWrapper(words) {
|
|
|
258
752
|
return current;
|
|
259
753
|
}
|
|
260
754
|
|
|
261
|
-
|
|
755
|
+
// `$(printf /)` is `/`. The destructive checks read raw words, so an operand
|
|
756
|
+
// nothing had expanded was compared as the literal text `$(printf /)` and
|
|
757
|
+
// matched no catastrophic target -- while the shell ran the removal of `/`.
|
|
758
|
+
//
|
|
759
|
+
// Two answers, because the substitutions split cleanly in two. `printf` and
|
|
760
|
+
// `echo` with literal arguments are pure text and can be evaluated here, so
|
|
761
|
+
// those resolve and the existing refusal applies to the result. Anything else
|
|
762
|
+
// (`$(mktemp -d)`, `$(git rev-parse --show-toplevel)`) names a path only the
|
|
763
|
+
// shell can produce; refusing those outright would take a common idiom away,
|
|
764
|
+
// and allowing them silently is how the check above was walked around. They
|
|
765
|
+
// become a question instead.
|
|
766
|
+
const STATIC_TEXT_PRODUCERS = new Set(["printf", "echo"]);
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Whether the segment contains a substitution the shell would actually run.
|
|
770
|
+
*
|
|
771
|
+
* Single quotes suspend it entirely, and that is lost by the time a word is
|
|
772
|
+
* tokenized: `rm -rf '$(printf /)'` removes a file with a strange name, and
|
|
773
|
+
* refusing it as a root removal is a refusal of something nobody asked for.
|
|
774
|
+
*
|
|
775
|
+
* @param {string} segment
|
|
776
|
+
* @returns {boolean}
|
|
777
|
+
*/
|
|
778
|
+
function segmentHasUnquotedSubstitution(segment) {
|
|
779
|
+
if (typeof segment !== "string") return false;
|
|
780
|
+
let quote;
|
|
781
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
782
|
+
const char = segment[index];
|
|
783
|
+
if (char === "\\" && quote !== "'") {
|
|
784
|
+
index += 1;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (quote) {
|
|
788
|
+
if (char === quote) quote = undefined;
|
|
789
|
+
else if (quote === '"' && (char === "`" || (char === "$" && segment[index + 1] === "("))) return true;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (char === "'" || char === '"') {
|
|
793
|
+
quote = char;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (char === "`" || (char === "$" && segment[index + 1] === "(")) return true;
|
|
797
|
+
}
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Every command-substitution body in the segment, located by balance rather than
|
|
803
|
+
* by pattern.
|
|
804
|
+
*
|
|
805
|
+
* A regex for `$(...)` cannot see nesting: against `$(printf $(printf /))` it
|
|
806
|
+
* matches the inner half and reports a body of `printf /`, which is a true
|
|
807
|
+
* statement about a substitution nobody asked about. Scanning with the same
|
|
808
|
+
* balance rule the shell uses reports one body, `printf $(printf /)`, and that
|
|
809
|
+
* body is visibly not something this process should try to evaluate.
|
|
810
|
+
*
|
|
811
|
+
* It cannot currently change an outcome, and that is worth stating rather than
|
|
812
|
+
* leaving for the next reader to work out. Evaluation only ever succeeds for a
|
|
813
|
+
* body with no parentheses in it -- the replacement pattern requires that, and
|
|
814
|
+
* anything it leaves behind is caught as a substitution still present in the
|
|
815
|
+
* result. On a paren-free body a pattern scan and a balance scan agree. This is
|
|
816
|
+
* here so the extent of a body is right by construction rather than by that
|
|
817
|
+
* argument continuing to hold.
|
|
818
|
+
*
|
|
819
|
+
* An unterminated substitution yields the rest of the segment as its body. It
|
|
820
|
+
* needs no flag of its own: nothing replaces it, so it survives into the result
|
|
821
|
+
* and is caught there as text that is still a substitution.
|
|
822
|
+
*
|
|
823
|
+
* @param {string} segment
|
|
824
|
+
* @returns {string[]}
|
|
825
|
+
*/
|
|
826
|
+
function substitutionBodies(segment) {
|
|
827
|
+
const bodies = [];
|
|
828
|
+
let quote;
|
|
829
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
830
|
+
// Single quotes suspend substitution, so text inside them is not a body and
|
|
831
|
+
// reading it as one made a whole segment unevaluable on the strength of a
|
|
832
|
+
// filename. `rm -rf $(printf /) '$(a;b)'` really does remove `/`, and the
|
|
833
|
+
// literal beside it was enough to drop the refusal to a question. Double
|
|
834
|
+
// quotes do not suspend it, so those are scanned through.
|
|
835
|
+
if (segment[index] === "\\" && quote !== "'") {
|
|
836
|
+
index += 1;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (quote) {
|
|
840
|
+
if (segment[index] === quote) quote = undefined;
|
|
841
|
+
else if (quote === "'") continue;
|
|
842
|
+
} else if (segment[index] === "'" || segment[index] === '"') {
|
|
843
|
+
quote = segment[index];
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (quote === "'") continue;
|
|
847
|
+
if (segment[index] === "`") {
|
|
848
|
+
const end = segment.indexOf("`", index + 1);
|
|
849
|
+
if (end < 0) {
|
|
850
|
+
bodies.push(segment.slice(index + 1));
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
bodies.push(segment.slice(index + 1, end));
|
|
854
|
+
index = end;
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
if (segment[index] === "$" && segment[index + 1] === "(") {
|
|
858
|
+
const found = findBalanced(segment, index + 1, "(", ")");
|
|
859
|
+
if (!found) {
|
|
860
|
+
bodies.push(segment.slice(index + 2));
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
bodies.push(found.body);
|
|
864
|
+
index = found.end;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return bodies;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// What a substitution body may contain for this process to claim it knows the
|
|
872
|
+
// output: one plain command and its literal operands, nothing else.
|
|
873
|
+
//
|
|
874
|
+
// Everything excluded here is a way for a body to produce output that reading
|
|
875
|
+
// its first command does not describe. `;`, `&` and `|` put a second command
|
|
876
|
+
// after the one being read, so `$(printf /; echo)` runs `printf /` and the
|
|
877
|
+
// evaluation stops at a word that is not the output. A newline is another
|
|
878
|
+
// separator. `$(` and a backtick nest. `<` and `>` move the output somewhere
|
|
879
|
+
// else. A quote or a backslash is gone by the time the word is tokenized, so
|
|
880
|
+
// the body being read is no longer the body the shell had. `$` on its own is a
|
|
881
|
+
// value this cannot resolve.
|
|
882
|
+
const SIMPLE_SUBSTITUTION_BODY = /^[^'"\\`;&|<>()$\n\r]*$/;
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Whether the substitutions in this segment can be evaluated here at all.
|
|
886
|
+
*
|
|
887
|
+
* Resolving to a wrong value and then permitting on it is worse than not
|
|
888
|
+
* resolving: `$(printf '\x2f')` reaches these checks as `$(printf x2f)`
|
|
889
|
+
* because quoting is applied before tokenizing, and `$(printf /; echo)` is `/`
|
|
890
|
+
* however little the first command says so. One body that this cannot read
|
|
891
|
+
* gives up the whole segment, because a target assembled from several of them
|
|
892
|
+
* is only as knowable as its least knowable part.
|
|
893
|
+
*
|
|
894
|
+
* @param {string} segment
|
|
895
|
+
* @returns {boolean}
|
|
896
|
+
*/
|
|
897
|
+
function substitutionsAreLiteral(segment) {
|
|
898
|
+
if (typeof segment !== "string") return false;
|
|
899
|
+
return substitutionBodies(segment).every((body) => SIMPLE_SUBSTITUTION_BODY.test(body));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* @param {string} word
|
|
904
|
+
* @param {Map<string, string>} assignments
|
|
905
|
+
* @param {boolean} evaluable
|
|
906
|
+
* @returns {{value: string, resolved: boolean}}
|
|
907
|
+
*/
|
|
908
|
+
function resolveSubstitutionTarget(word, assignments, evaluable) {
|
|
909
|
+
const substitution = /\$\(([^()]*)\)|`([^`]*)`/g;
|
|
910
|
+
let resolved = true;
|
|
911
|
+
// Always expanded: a variable is resolved from text in this same command and
|
|
912
|
+
// has nothing to do with whether a substitution would run. A word whose `$(`
|
|
913
|
+
// is inside single quotes arrives with `evaluable` unset, so the replacement
|
|
914
|
+
// below leaves it exactly as written.
|
|
915
|
+
const value = expandKnownShellVariables(word, assignments).replace(substitution, (match, parenthesised, backticked) => {
|
|
916
|
+
const inner = (parenthesised ?? backticked ?? "").trim();
|
|
917
|
+
const producer = commandBasename(shellWords(inner)[0] ?? "");
|
|
918
|
+
if (!evaluable || !STATIC_TEXT_PRODUCERS.has(producer)) {
|
|
919
|
+
resolved = false;
|
|
920
|
+
return match;
|
|
921
|
+
}
|
|
922
|
+
const output = staticDataOutput(inner, assignments, producer);
|
|
923
|
+
// More than one word out of a substitution is more than one operand, and
|
|
924
|
+
// stitching them back into one is not what the shell does. `exact` is the
|
|
925
|
+
// other half: a format this renderer does not reproduce yields a value bash
|
|
926
|
+
// never printed, and permitting on that is the failure this whole path is
|
|
927
|
+
// here to avoid.
|
|
928
|
+
if (!output.exact || output.words.length !== 1) {
|
|
929
|
+
resolved = false;
|
|
930
|
+
return match;
|
|
931
|
+
}
|
|
932
|
+
return output.words[0];
|
|
933
|
+
});
|
|
934
|
+
// A substitution left in the result is one the replacement above did not
|
|
935
|
+
// reach -- a nested body, or one whose shape the scanner rejected. The value
|
|
936
|
+
// is not the target either way, so it is not a resolution.
|
|
937
|
+
if (/\$\(|`/.test(value)) return { value, resolved: false };
|
|
938
|
+
return { value, resolved };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const WHOLE_WORD_EXPANSION = /^\$\{[^{}]*\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/;
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* True when the entire target is one parameter expansion that came out the
|
|
945
|
+
* other side unresolved.
|
|
946
|
+
*
|
|
947
|
+
* `${HOME:0:1}` is `/`, and substring expansion is not modelled here -- the
|
|
948
|
+
* operator group in `expandKnownShellVariables` is deliberately narrow, so the
|
|
949
|
+
* word stayed exactly as written and matched none of the catastrophic targets.
|
|
950
|
+
* Naming one more operator each time one is reported is the losing game this
|
|
951
|
+
* file already refuses to play, so the shape is what decides: a target that is
|
|
952
|
+
* nothing but an expansion can be *any* value, root included, and a recursive
|
|
953
|
+
* removal of an unknown value is worth a question.
|
|
954
|
+
*
|
|
955
|
+
* Only the whole word counts. `$TMPDIR/build` cannot become root however
|
|
956
|
+
* `TMPDIR` resolves, and refusing it would stop ordinary work for nothing.
|
|
957
|
+
*
|
|
958
|
+
* @param {string} word
|
|
959
|
+
* @param {Map<string, string>} assignments
|
|
960
|
+
* @returns {boolean}
|
|
961
|
+
*/
|
|
962
|
+
function isUnresolvedWholeWordExpansion(word, assignments) {
|
|
963
|
+
if (!WHOLE_WORD_EXPANSION.test(word)) return false;
|
|
964
|
+
return WHOLE_WORD_EXPANSION.test(expandKnownShellVariables(word, assignments));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** True when the word carries a substitution this process cannot evaluate. */
|
|
968
|
+
function hasUnresolvedSubstitution(word, assignments, evaluable) {
|
|
969
|
+
if (!/\$\(|`/.test(word)) return false;
|
|
970
|
+
return !resolveSubstitutionTarget(word, assignments, evaluable).resolved;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* True when the command name is still being computed when the shell runs it.
|
|
975
|
+
*
|
|
976
|
+
* The word arrives here after every expansion this module can perform, so an
|
|
977
|
+
* expansion still in it is one nobody can evaluate -- and a name nobody can
|
|
978
|
+
* evaluate could be any command at all, including the one being looked for.
|
|
979
|
+
*
|
|
980
|
+
* Testing for a leading `$` was the old reading, and it only caught a name that
|
|
981
|
+
* was *entirely* an expansion. A name with one literal character in front of it
|
|
982
|
+
* ducked underneath: `r${X:+m} -rf /` and `$(echo -n r)${X:+m} -rf /` are both
|
|
983
|
+
* `rm -rf /` when the variable is set, and both read as an ordinary command
|
|
984
|
+
* called something other than `rm`.
|
|
985
|
+
*
|
|
986
|
+
* @param {string} name
|
|
987
|
+
* @returns {boolean}
|
|
988
|
+
*/
|
|
989
|
+
function commandNameIsDynamic(name) {
|
|
990
|
+
return /\$\{|\$\(|`|\$[A-Za-z_]/.test(name);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function rmFinding(words, assignments, evaluable, substituting) {
|
|
262
994
|
const command = commandBasename(words[0] ?? "");
|
|
263
|
-
const dynamicCommand = command
|
|
995
|
+
const dynamicCommand = commandNameIsDynamic(command);
|
|
264
996
|
if (command !== "rm" && !dynamicCommand) return undefined;
|
|
265
997
|
|
|
266
998
|
let recursive = false;
|
|
@@ -284,18 +1016,77 @@ function rmFinding(words) {
|
|
|
284
1016
|
targets.push(word);
|
|
285
1017
|
}
|
|
286
1018
|
|
|
287
|
-
|
|
288
|
-
|
|
1019
|
+
const resolvedTargets = targets.map((target) => resolveSubstitutionTarget(target, assignments, evaluable));
|
|
1020
|
+
if (resolvedTargets.some((target) => isRootOrHomeTarget(target.value)) && (recursive || force || dynamicCommand)) {
|
|
1021
|
+
return { action: "forbid", reason: "Refusing recursive/forced removal of root or home target." };
|
|
1022
|
+
}
|
|
1023
|
+
const opaque = [...new Set([
|
|
1024
|
+
...(substituting ? targets.filter((target) => hasUnresolvedSubstitution(target, assignments, evaluable)) : []),
|
|
1025
|
+
...targets.filter((target) => isUnresolvedWholeWordExpansion(target, assignments))
|
|
1026
|
+
])];
|
|
1027
|
+
if (opaque.length > 0 && (recursive || force)) {
|
|
1028
|
+
return {
|
|
1029
|
+
action: "prompt",
|
|
1030
|
+
reason: `Recursive/forced rm targets a path this policy cannot resolve: ${opaque.join(", ")}. `
|
|
1031
|
+
+ "Its value is produced at run time, so what would be deleted is not knowable here. "
|
|
1032
|
+
+ "Confirm, or run the substitution first and pass the result."
|
|
1033
|
+
};
|
|
289
1034
|
}
|
|
290
1035
|
return undefined;
|
|
291
1036
|
}
|
|
292
1037
|
|
|
293
|
-
function findDeleteFinding(words) {
|
|
294
|
-
|
|
1038
|
+
function findDeleteFinding(words, assignments, evaluable, substituting) {
|
|
1039
|
+
const command = commandBasename(words[0] ?? "");
|
|
1040
|
+
// `-delete` with a root operand is the whole shape; a name still being
|
|
1041
|
+
// computed does not make it something else.
|
|
1042
|
+
if (command !== "find" && !commandNameIsDynamic(command)) return undefined;
|
|
295
1043
|
if (!words.includes("-delete")) return undefined;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1044
|
+
// `find` walks every path it is given before the expression starts, so all of
|
|
1045
|
+
// the leading operands are starting points. Reading only the first one meant
|
|
1046
|
+
// `find fi / -delete` was judged on `fi` -- and brace expansion produces
|
|
1047
|
+
// exactly that shape, since `fi{nd,} / -delete` is `find fi / -delete` by the
|
|
1048
|
+
// time the shell runs it.
|
|
1049
|
+
// `find` takes its own options before the paths start: `-H`, `-L`, `-P`,
|
|
1050
|
+
// `-O<level>`, `-D <opts>`, and `--`. Stopping at the first `-` meant
|
|
1051
|
+
// `find -H / -delete` had no target at all and passed with nothing read.
|
|
1052
|
+
const targets = [];
|
|
1053
|
+
let index = 1;
|
|
1054
|
+
while (index < words.length) {
|
|
1055
|
+
const word = words[index];
|
|
1056
|
+
if (word === "--") {
|
|
1057
|
+
index += 1;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
if (/^-[HLP]$/.test(word) || /^-O\d*$/.test(word)) {
|
|
1061
|
+
index += 1;
|
|
1062
|
+
if (word === "-O" && /^\d+$/.test(words[index] ?? "")) index += 1;
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (word === "-D") {
|
|
1066
|
+
index += 2;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
for (; index < words.length; index += 1) {
|
|
1072
|
+
if (words[index].startsWith("-")) break;
|
|
1073
|
+
targets.push(words[index]);
|
|
1074
|
+
}
|
|
1075
|
+
if (targets.length === 0) return undefined;
|
|
1076
|
+
const resolvedTargets = targets.map((target) => resolveSubstitutionTarget(target, assignments, evaluable));
|
|
1077
|
+
if (resolvedTargets.some((target) => isRootOrHomeTarget(target.value))) {
|
|
1078
|
+
return { action: "forbid", reason: "Refusing find -delete against root or home target." };
|
|
1079
|
+
}
|
|
1080
|
+
const opaque = targets.filter((target) =>
|
|
1081
|
+
(substituting && hasUnresolvedSubstitution(target, assignments, evaluable))
|
|
1082
|
+
|| isUnresolvedWholeWordExpansion(target, assignments));
|
|
1083
|
+
if (opaque.length > 0) {
|
|
1084
|
+
return {
|
|
1085
|
+
action: "prompt",
|
|
1086
|
+
reason: `find -delete starts from a path this policy cannot resolve: ${opaque.join(", ")}. `
|
|
1087
|
+
+ "Its value is produced at run time, so what would be deleted is not knowable here. "
|
|
1088
|
+
+ "Confirm, or run the substitution first and pass the result."
|
|
1089
|
+
};
|
|
299
1090
|
}
|
|
300
1091
|
return undefined;
|
|
301
1092
|
}
|
|
@@ -303,14 +1094,30 @@ function findDeleteFinding(words) {
|
|
|
303
1094
|
function ddFinding(words) {
|
|
304
1095
|
if (commandBasename(words[0] ?? "") !== "dd") return undefined;
|
|
305
1096
|
const out = words.find((word) => /^of=\/dev\/(?:sd[a-z]\d*|hd[a-z]\d*|xvd[a-z]\d*|nvme\d+n\d+(?:p\d+)?|disk\d+|rdisk\d+|mapper\/.+)$/i.test(word));
|
|
306
|
-
return out ? `Refusing dd write to block device ${out}.` : undefined;
|
|
1097
|
+
return out ? { action: "forbid", reason: `Refusing dd write to block device ${out}.` } : undefined;
|
|
307
1098
|
}
|
|
308
1099
|
|
|
309
|
-
|
|
1100
|
+
/**
|
|
1101
|
+
* @param {string[]} words
|
|
1102
|
+
* @param {Map<string, string>} assignments
|
|
1103
|
+
* @param {string} segment
|
|
1104
|
+
* @returns {{action: "forbid"|"prompt", reason: string}[]}
|
|
1105
|
+
*/
|
|
1106
|
+
function semanticCommandFindings(words, assignments, segment) {
|
|
1107
|
+
// A word arrives here with its quotes already removed, so `'$(printf /)'` and
|
|
1108
|
+
// `$(printf /)` are the same three-character-longer string -- and the first
|
|
1109
|
+
// one is a filename with an awkward name, not a substitution. Whether the
|
|
1110
|
+
// segment contains one the shell would actually run is the part only the raw
|
|
1111
|
+
// text still knows.
|
|
1112
|
+
const substituting = segmentHasUnquotedSubstitution(segment);
|
|
1113
|
+
const evaluable = substituting && substitutionsAreLiteral(segment);
|
|
1114
|
+
// `words` arrives brace-expanded from `expandedSegment` -- the shell expands
|
|
1115
|
+
// before it decides what the command is, so that has to happen before any
|
|
1116
|
+
// reader here, not inside one of them.
|
|
310
1117
|
const normalizedWords = stripWrapper(words);
|
|
311
1118
|
return [
|
|
312
|
-
rmFinding(normalizedWords),
|
|
313
|
-
findDeleteFinding(normalizedWords),
|
|
1119
|
+
rmFinding(normalizedWords, assignments, evaluable, substituting),
|
|
1120
|
+
findDeleteFinding(normalizedWords, assignments, evaluable, substituting),
|
|
314
1121
|
ddFinding(normalizedWords),
|
|
315
1122
|
xargsFinding(normalizedWords)
|
|
316
1123
|
].filter(Boolean);
|
|
@@ -321,14 +1128,17 @@ function xargsFinding(words) {
|
|
|
321
1128
|
const commandIndex = words.findIndex((word, index) => index > 0 && !word.startsWith("-"));
|
|
322
1129
|
if (commandIndex < 0) return undefined;
|
|
323
1130
|
const nested = words.slice(commandIndex);
|
|
324
|
-
|
|
1131
|
+
const nestedName = commandBasename(nested[0] ?? "");
|
|
1132
|
+
if (nestedName !== "rm" && !commandNameIsDynamic(nestedName)) return undefined;
|
|
325
1133
|
const hasRecursiveOrForce = nested.slice(1).some((word) => {
|
|
326
1134
|
if (word === "--recursive" || word === "--force") return true;
|
|
327
1135
|
if (!word.startsWith("-") || word.startsWith("--")) return false;
|
|
328
1136
|
const chars = flagChars(word);
|
|
329
1137
|
return chars.includes("r") || chars.includes("R") || chars.includes("f");
|
|
330
1138
|
});
|
|
331
|
-
return hasRecursiveOrForce
|
|
1139
|
+
return hasRecursiveOrForce
|
|
1140
|
+
? { action: "forbid", reason: "Refusing xargs with recursive/forced rm; input target is not visible to policy." }
|
|
1141
|
+
: undefined;
|
|
332
1142
|
}
|
|
333
1143
|
|
|
334
1144
|
function legacyPatternMatchesSegment(pattern, words) {
|
|
@@ -336,6 +1146,18 @@ function legacyPatternMatchesSegment(pattern, words) {
|
|
|
336
1146
|
return arrayStartsWith(words, patternWords);
|
|
337
1147
|
}
|
|
338
1148
|
|
|
1149
|
+
// How far a command may nest interpreters before the policy stops reading, and
|
|
1150
|
+
// how many segments it may expand to in total.
|
|
1151
|
+
//
|
|
1152
|
+
// The previous limit was four, and stopping there meant *not looking* rather
|
|
1153
|
+
// than *refusing*: a payload wrapped in five layers of `bash -c` was inspected
|
|
1154
|
+
// at every level the walk reached and then permitted on the strength of a level
|
|
1155
|
+
// it never opened. The depth now goes far past anything a person writes by hand,
|
|
1156
|
+
// and reaching either limit is itself grounds to refuse, so the answer to a
|
|
1157
|
+
// command too convoluted to read is no rather than silence.
|
|
1158
|
+
const MAX_NESTED_DEPTH = 16;
|
|
1159
|
+
const MAX_INSPECTED_SEGMENTS = 512;
|
|
1160
|
+
|
|
339
1161
|
export function evaluateExecPolicyCore(command, options) {
|
|
340
1162
|
const policy = options.policy ?? {};
|
|
341
1163
|
const mode = options.mode ?? policy.execPolicy?.defaultMode ?? "enforce";
|
|
@@ -346,10 +1168,16 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
346
1168
|
const reasons = [];
|
|
347
1169
|
const pending = splitShellSegments(command).map((segment) => ({ segment, depth: 0 }));
|
|
348
1170
|
const segments = [];
|
|
1171
|
+
// Carried across segments so `D=/; rm -rf "$D"` resolves the same way the
|
|
1172
|
+
// path checks already resolve it.
|
|
1173
|
+
const assignments = new Map();
|
|
349
1174
|
|
|
350
1175
|
while (pending.length > 0) {
|
|
351
1176
|
const { segment, depth } = pending.shift();
|
|
352
|
-
|
|
1177
|
+
// Expanded once here, so every reader below is looking at the word list the
|
|
1178
|
+
// shell would build rather than at the text the author typed.
|
|
1179
|
+
const { words, text, braceText } = expandedSegment(segment, assignments);
|
|
1180
|
+
rememberLeadingShellAssignments(words, assignments);
|
|
353
1181
|
const matches = [];
|
|
354
1182
|
const warnings = [];
|
|
355
1183
|
|
|
@@ -360,15 +1188,15 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
360
1188
|
}
|
|
361
1189
|
|
|
362
1190
|
for (const rule of execPolicy.rules) {
|
|
363
|
-
if (!commandRuleMatches(rule,
|
|
1191
|
+
if (!commandRuleMatches(rule, text, words)) continue;
|
|
364
1192
|
matches.push(`${rule.action}:${rule.id}`);
|
|
365
1193
|
if (rule.action === "forbid") reasons.push(`Forbidden by exec policy ${rule.id}: ${rule.reason}`);
|
|
366
1194
|
if (rule.action === "prompt") reasons.push(`Prompt required by exec policy ${rule.id}: ${rule.reason}`);
|
|
367
1195
|
}
|
|
368
1196
|
|
|
369
|
-
for (const finding of semanticCommandFindings(words)) {
|
|
370
|
-
matches.push(
|
|
371
|
-
reasons.push(finding);
|
|
1197
|
+
for (const finding of semanticCommandFindings(words, assignments, segment)) {
|
|
1198
|
+
matches.push(`${finding.action}:semantic-shell-safety`);
|
|
1199
|
+
reasons.push(finding.reason);
|
|
372
1200
|
}
|
|
373
1201
|
|
|
374
1202
|
for (const pattern of policy.blockedCommandPatterns ?? []) {
|
|
@@ -377,17 +1205,33 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
377
1205
|
reasons.push(`Blocked by legacy policy pattern: ${pattern}`);
|
|
378
1206
|
}
|
|
379
1207
|
|
|
380
|
-
|
|
381
|
-
if (depth
|
|
382
|
-
|
|
1208
|
+
const nested = extractNestedCommands(braceText, words);
|
|
1209
|
+
if (nested.length > 0 && depth >= MAX_NESTED_DEPTH) {
|
|
1210
|
+
matches.push("forbid:nesting-depth");
|
|
1211
|
+
reasons.push(
|
|
1212
|
+
`Forbidden by exec policy nesting-depth: interpreters nest more than ${MAX_NESTED_DEPTH} levels deep, ` +
|
|
1213
|
+
"past the point this policy can read the command. Run it without wrapping it."
|
|
1214
|
+
);
|
|
1215
|
+
} else if (segments.length >= MAX_INSPECTED_SEGMENTS) {
|
|
1216
|
+
matches.push("forbid:nesting-breadth");
|
|
1217
|
+
reasons.push(
|
|
1218
|
+
`Forbidden by exec policy nesting-breadth: the command expands past ${MAX_INSPECTED_SEGMENTS} segments, ` +
|
|
1219
|
+
"past the point this policy can read it. Split it up."
|
|
1220
|
+
);
|
|
1221
|
+
} else {
|
|
1222
|
+
for (const nestedCommand of nested) {
|
|
383
1223
|
for (const nestedSegment of splitShellSegments(nestedCommand)) {
|
|
384
1224
|
pending.push({ segment: nestedSegment, depth: depth + 1 });
|
|
385
1225
|
}
|
|
386
1226
|
}
|
|
387
1227
|
}
|
|
1228
|
+
|
|
1229
|
+
segments.push({ command: segment, expanded: text, words, matches, warnings });
|
|
388
1230
|
}
|
|
389
1231
|
|
|
390
|
-
|
|
1232
|
+
// The expanded text of every segment as well as the raw command, so a pattern
|
|
1233
|
+
// written as `git push` still matches when the author typed `git p{ush,}`.
|
|
1234
|
+
const normalizedCommand = [command, ...segments.map((entry) => entry.expanded)].join("\n").toLowerCase();
|
|
391
1235
|
for (const pattern of policy.requireConfirmationPatterns ?? []) {
|
|
392
1236
|
if (normalizedCommand.includes(String(pattern).toLowerCase())) {
|
|
393
1237
|
reasons.push(`Confirmation required by legacy policy pattern: ${pattern}`);
|
|
@@ -395,8 +1239,16 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
395
1239
|
}
|
|
396
1240
|
}
|
|
397
1241
|
|
|
398
|
-
|
|
399
|
-
|
|
1242
|
+
// The action a check decided, not how its sentence happens to start. Reading
|
|
1243
|
+
// the prose meant a finding was only as strong as its first word: a new one
|
|
1244
|
+
// whose reason did not begin with "Refusing" or "Prompt" was recorded, shown,
|
|
1245
|
+
// and then counted as `allow`. The legacy confirmation patterns below push a
|
|
1246
|
+
// reason without a match, so the prose test stays as well.
|
|
1247
|
+
const actions = segments.flatMap((entry) => entry.matches);
|
|
1248
|
+
const hasForbid = actions.some((action) => action.startsWith("forbid:"))
|
|
1249
|
+
|| reasons.some((reason) => reason.startsWith("Forbidden") || reason.startsWith("Blocked") || reason.startsWith("Refusing"));
|
|
1250
|
+
const hasPrompt = actions.some((action) => action.startsWith("prompt:"))
|
|
1251
|
+
|| reasons.some((reason) => reason.startsWith("Prompt") || reason.startsWith("Confirmation"));
|
|
400
1252
|
return {
|
|
401
1253
|
mode,
|
|
402
1254
|
decision: mode === "off" ? "allow" : hasForbid ? "forbid" : hasPrompt ? "prompt" : "allow",
|
|
@@ -440,16 +1292,31 @@ function findBalanced(text, startIndex, openChar, closeChar) {
|
|
|
440
1292
|
function shellCPayloads(words) {
|
|
441
1293
|
const normalizedWords = stripWrapper(words);
|
|
442
1294
|
const rootCommand = commandBasename(normalizedWords[0] ?? "");
|
|
443
|
-
|
|
1295
|
+
// A name still being computed when the shell runs it could be an interpreter,
|
|
1296
|
+
// and `${X:+b}ash -c 'cat .env'` reads the file whenever the variable is set.
|
|
1297
|
+
// The payload beside a `-c` is a script under that reading, so it is scanned
|
|
1298
|
+
// rather than skipped on the strength of a name nobody can evaluate.
|
|
1299
|
+
const carrier = SHELL_COMMANDS.has(rootCommand) || rootCommand === "xargs" || rootCommand === "find"
|
|
1300
|
+
|| commandNameIsDynamic(rootCommand);
|
|
444
1301
|
if (!carrier) return [];
|
|
445
1302
|
|
|
446
1303
|
const payloads = [];
|
|
447
1304
|
for (let commandIndex = 0; commandIndex < normalizedWords.length; commandIndex += 1) {
|
|
448
|
-
|
|
1305
|
+
const name = commandBasename(normalizedWords[commandIndex] ?? "");
|
|
1306
|
+
if (!SHELL_COMMANDS.has(name) && !commandNameIsDynamic(name)) continue;
|
|
449
1307
|
for (let index = commandIndex + 1; index < normalizedWords.length - 1; index += 1) {
|
|
450
1308
|
const word = normalizedWords[index];
|
|
451
1309
|
if (word === "-c" || (/^-[A-Za-z]+$/.test(word) && word.includes("c"))) {
|
|
452
|
-
|
|
1310
|
+
// A lone `-` or a `--` between `-c` and the script ends the option list
|
|
1311
|
+
// without being the option's argument, so the payload is the word after
|
|
1312
|
+
// them: `bash -c - 'rm -rf /'` runs the removal, and reading `-` as the
|
|
1313
|
+
// script found nothing to inspect. Brace expansion produces exactly
|
|
1314
|
+
// this shape, since `bash -{c,} '...'` is `bash -c - '...'`.
|
|
1315
|
+
let payloadIndex = index + 1;
|
|
1316
|
+
while (normalizedWords[payloadIndex] === "-" || normalizedWords[payloadIndex] === "--") {
|
|
1317
|
+
payloadIndex += 1;
|
|
1318
|
+
}
|
|
1319
|
+
if (payloadIndex < normalizedWords.length) payloads.push(normalizedWords[payloadIndex]);
|
|
453
1320
|
break;
|
|
454
1321
|
}
|
|
455
1322
|
}
|
|
@@ -547,14 +1414,40 @@ function isFilesystemArgument(word) {
|
|
|
547
1414
|
if (!word || word === "-") return false;
|
|
548
1415
|
if (word.startsWith("http://") || word.startsWith("https://")) return false;
|
|
549
1416
|
if (PATH_REDIRECT_OPERATORS.has(word)) return false;
|
|
550
|
-
if (isAssignment(word)) return false;
|
|
551
1417
|
return isPathLike(word) || !word.startsWith("-");
|
|
552
1418
|
}
|
|
553
1419
|
|
|
1420
|
+
/**
|
|
1421
|
+
* The path inside a `key=value` operand.
|
|
1422
|
+
*
|
|
1423
|
+
* The shell treats `NAME=VALUE` as an assignment only before the command word;
|
|
1424
|
+
* after it, the word is an operand and the command decides what it means. This
|
|
1425
|
+
* is only ever asked about words after the command word, so rejecting them all
|
|
1426
|
+
* as assignments hid `dd if=.env` and `dd of=.env` — which `ddFinding` already
|
|
1427
|
+
* parses to catch block-device writes, so the module knew and never said.
|
|
1428
|
+
*
|
|
1429
|
+
* @param {string} word
|
|
1430
|
+
* @returns {string|undefined}
|
|
1431
|
+
*/
|
|
1432
|
+
function operandValuePath(word) {
|
|
1433
|
+
const match = word.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/);
|
|
1434
|
+
if (!match) return undefined;
|
|
1435
|
+
const value = match[2];
|
|
1436
|
+
if (value.startsWith("http://") || value.startsWith("https://")) return undefined;
|
|
1437
|
+
return value;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
554
1440
|
function rememberLeadingShellAssignments(words, assignments) {
|
|
555
1441
|
const remember = (word) => {
|
|
556
1442
|
const match = word.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
557
|
-
if (match)
|
|
1443
|
+
if (!match) return;
|
|
1444
|
+
// `a=(.env)` is an array literal, and storing it whole left the parentheses
|
|
1445
|
+
// glued to the value so `${a[@]}` resolved to a name no file has. Every
|
|
1446
|
+
// element is a path this command may reach, so the whole set is kept and any
|
|
1447
|
+
// subscript resolves to all of it — matching one of them is enough to refuse.
|
|
1448
|
+
const array = match[2].match(/^\((.*)\)$/);
|
|
1449
|
+
const value = array ? array[1].trim() : match[2];
|
|
1450
|
+
assignments.set(match[1], expandKnownShellVariables(value, assignments));
|
|
558
1451
|
};
|
|
559
1452
|
let start = 0;
|
|
560
1453
|
while (start < words.length && isAssignment(words[start])) start += 1;
|
|
@@ -566,16 +1459,86 @@ function rememberLeadingShellAssignments(words, assignments) {
|
|
|
566
1459
|
for (const word of words.slice(start + 1)) remember(word);
|
|
567
1460
|
}
|
|
568
1461
|
|
|
1462
|
+
/**
|
|
1463
|
+
* Applies one parameter-expansion operator to a value the assignments already
|
|
1464
|
+
* resolved.
|
|
1465
|
+
*
|
|
1466
|
+
* These are the operators that rewrite a known value into a different string.
|
|
1467
|
+
* Without them `V=q.env; cat ${V#q}` produced the candidate `${V#q}`, which
|
|
1468
|
+
* matches no protected pattern while the shell opens `.env`. Patterns are
|
|
1469
|
+
* treated as literals rather than as shell globs: matching less is safe here,
|
|
1470
|
+
* because a word this cannot resolve is refused by `unresolvedPathExpansions`
|
|
1471
|
+
* rather than assumed harmless.
|
|
1472
|
+
*
|
|
1473
|
+
* @param {string} operator the characters between the name and the argument
|
|
1474
|
+
* @param {string} argument
|
|
1475
|
+
* @param {string} value the resolved value of the variable
|
|
1476
|
+
* @returns {string|undefined} the rewritten value, or undefined when this
|
|
1477
|
+
* operator is not modelled
|
|
1478
|
+
*/
|
|
1479
|
+
function applyParameterOperator(operator, argument, value) {
|
|
1480
|
+
switch (operator) {
|
|
1481
|
+
case ":+":
|
|
1482
|
+
case "+":
|
|
1483
|
+
return value ? argument : "";
|
|
1484
|
+
case "#":
|
|
1485
|
+
return value.startsWith(argument) ? value.slice(argument.length) : value;
|
|
1486
|
+
case "##": {
|
|
1487
|
+
return value.startsWith(argument) ? value.slice(argument.length) : value;
|
|
1488
|
+
}
|
|
1489
|
+
case "%":
|
|
1490
|
+
case "%%":
|
|
1491
|
+
return value.endsWith(argument) ? value.slice(0, -argument.length || undefined) : value;
|
|
1492
|
+
default:
|
|
1493
|
+
return undefined;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
569
1497
|
function expandKnownShellVariables(value, assignments, resolving = new Set(), depth = 0) {
|
|
570
1498
|
if (depth >= 8) return String(value ?? "");
|
|
571
|
-
return String(value ?? "").replace(
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1499
|
+
return String(value ?? "").replace(
|
|
1500
|
+
// Three shapes: `${NAME<op><arg>}`, `${NAME}` with the `:-`/`:=` default,
|
|
1501
|
+
// and bare `$NAME`. The operator group is deliberately narrow — anything it
|
|
1502
|
+
// does not name stays unexpanded, and unexpanded is what gets refused.
|
|
1503
|
+
/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?(?:(:?[-=+]|#{1,2}|%{1,2}|\/{1,2})([^{}]*))?\}|([A-Za-z_][A-Za-z0-9_]*))/g,
|
|
1504
|
+
(match, braced, operator, argument, plain) => {
|
|
1505
|
+
const name = braced ?? plain;
|
|
1506
|
+
const resolved = assignments.get(name);
|
|
1507
|
+
// The value is recursed with `name` marked so `F=$G; G=$F` terminates. An
|
|
1508
|
+
// operator's argument is separate text that merely mentions the same name,
|
|
1509
|
+
// so it keeps the outer set -- `${V:+$V}` is the value, not a cycle.
|
|
1510
|
+
const recur = (next) => expandKnownShellVariables(next, assignments, resolving, depth + 1);
|
|
1511
|
+
|
|
1512
|
+
if (resolved === undefined || resolving.has(name)) {
|
|
1513
|
+
// `${VAR:-default}` states what it becomes when the variable is unset,
|
|
1514
|
+
// which is the usual case here since nothing in the segment assigned it.
|
|
1515
|
+
// Leaving the expression whole meant `cat .${X:-env}` produced the
|
|
1516
|
+
// candidate `.${X:-env}` — the one form that says where it points was
|
|
1517
|
+
// the one form nothing read.
|
|
1518
|
+
if (operator === ":-" || operator === "-" || operator === ":=" || operator === "=") {
|
|
1519
|
+
return expandKnownShellVariables(argument, assignments, resolving, depth + 1);
|
|
1520
|
+
}
|
|
1521
|
+
return match;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const expanded = expandKnownShellVariables(resolved, assignments, new Set([...resolving, name]), depth + 1);
|
|
1525
|
+
if (operator === undefined) return expanded;
|
|
1526
|
+
if (operator === ":-" || operator === "-" || operator === ":=" || operator === "=") return expanded;
|
|
1527
|
+
|
|
1528
|
+
if (operator === "/" || operator === "//") {
|
|
1529
|
+
const separator = argument.indexOf("/");
|
|
1530
|
+
const pattern = separator >= 0 ? argument.slice(0, separator) : argument;
|
|
1531
|
+
const replacement = separator >= 0 ? argument.slice(separator + 1) : "";
|
|
1532
|
+
if (!pattern) return expanded;
|
|
1533
|
+
return operator === "//"
|
|
1534
|
+
? expanded.split(pattern).join(recur(replacement))
|
|
1535
|
+
: expanded.replace(pattern, recur(replacement));
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const applied = applyParameterOperator(operator, recur(argument), expanded);
|
|
1539
|
+
return applied === undefined ? match : applied;
|
|
1540
|
+
}
|
|
1541
|
+
);
|
|
579
1542
|
}
|
|
580
1543
|
|
|
581
1544
|
function executableArgumentTokens(tokens, words, commandName) {
|
|
@@ -651,10 +1614,14 @@ function shellDataTokens(segment) {
|
|
|
651
1614
|
let quote;
|
|
652
1615
|
let tokenStarted = false;
|
|
653
1616
|
let variableActive = false;
|
|
1617
|
+
// Same per-character flag the word tokenizer carries, for the same reason:
|
|
1618
|
+
// the producer's arguments can come out of the brace word that names it.
|
|
1619
|
+
let literalMask = "";
|
|
654
1620
|
const flush = () => {
|
|
655
1621
|
if (!tokenStarted) return;
|
|
656
|
-
tokens.push({ value: current, variableActive });
|
|
1622
|
+
tokens.push({ value: current, variableActive, literalMask });
|
|
657
1623
|
current = "";
|
|
1624
|
+
literalMask = "";
|
|
658
1625
|
tokenStarted = false;
|
|
659
1626
|
variableActive = false;
|
|
660
1627
|
};
|
|
@@ -665,7 +1632,10 @@ function shellDataTokens(segment) {
|
|
|
665
1632
|
if (quote === "'") {
|
|
666
1633
|
tokenStarted = true;
|
|
667
1634
|
if (char === "'") quote = undefined;
|
|
668
|
-
else
|
|
1635
|
+
else {
|
|
1636
|
+
current += char;
|
|
1637
|
+
literalMask += "1";
|
|
1638
|
+
}
|
|
669
1639
|
continue;
|
|
670
1640
|
}
|
|
671
1641
|
if (quote === '"') {
|
|
@@ -674,10 +1644,12 @@ function shellDataTokens(segment) {
|
|
|
674
1644
|
quote = undefined;
|
|
675
1645
|
} else if (char === "\\" && next !== undefined && /[$`"\\\n]/.test(next)) {
|
|
676
1646
|
current += next;
|
|
1647
|
+
literalMask += "1";
|
|
677
1648
|
index += 1;
|
|
678
1649
|
} else {
|
|
679
1650
|
if (char === "$" && next !== undefined) variableActive = true;
|
|
680
1651
|
current += char;
|
|
1652
|
+
literalMask += "1";
|
|
681
1653
|
}
|
|
682
1654
|
continue;
|
|
683
1655
|
}
|
|
@@ -692,44 +1664,187 @@ function shellDataTokens(segment) {
|
|
|
692
1664
|
}
|
|
693
1665
|
if (char === "\\" && next !== undefined) {
|
|
694
1666
|
current += next;
|
|
1667
|
+
literalMask += "1";
|
|
695
1668
|
index += 1;
|
|
696
1669
|
continue;
|
|
697
1670
|
}
|
|
698
1671
|
if (char === "$" && next !== undefined) variableActive = true;
|
|
699
1672
|
current += char;
|
|
1673
|
+
literalMask += segment[index - 1] === "$" ? "1" : "0";
|
|
700
1674
|
}
|
|
701
1675
|
flush();
|
|
702
1676
|
return tokens;
|
|
703
1677
|
}
|
|
704
1678
|
|
|
1679
|
+
const PRINTF_CONVERSION_PARTS = /%(?:\d+\$)?([-+#0 ']*)(\d+|\*)?(?:\.(\d+|\*))?[hlL]?([bcdeEfgGiosuxXaA])/g;
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* What `printf` writes, as closely as this can model it.
|
|
1683
|
+
*
|
|
1684
|
+
* Substituting the raw argument for every conversion was the whole of it
|
|
1685
|
+
* before, and that is not what the output looks like. A precision truncates
|
|
1686
|
+
* the argument and the rest of the format still prints, so `.e%.1sv` with `nv`
|
|
1687
|
+
* is `.env` rather than `.envv`; a width pads it; and bash reuses the format
|
|
1688
|
+
* while arguments remain, so `%s` with `.e` and `nv` is also `.env`. Each of
|
|
1689
|
+
* those assembled a name out of pieces that were individually harmless, which
|
|
1690
|
+
* is exactly the shape the path checks are looking for.
|
|
1691
|
+
*
|
|
1692
|
+
* @param {string} format
|
|
1693
|
+
* @param {string[]} values
|
|
1694
|
+
* @returns {string}
|
|
1695
|
+
*/
|
|
705
1696
|
function renderStaticPrintf(format, values) {
|
|
706
|
-
let valueIndex = 0;
|
|
707
|
-
let rendered = "";
|
|
708
1697
|
const decodedFormat = decodeShellDataEscapes(format);
|
|
709
|
-
|
|
710
|
-
let
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1698
|
+
let rendered = "";
|
|
1699
|
+
let valueIndex = 0;
|
|
1700
|
+
let passes = 0;
|
|
1701
|
+
// A format with no conversion prints once and ignores the rest, so the reuse
|
|
1702
|
+
// below has to stop on the pass that consumed nothing.
|
|
1703
|
+
let consumed = true;
|
|
1704
|
+
while (consumed && passes < 32 && rendered.length <= 8192 && valueIndex < 32) {
|
|
1705
|
+
let cursor = 0;
|
|
1706
|
+
consumed = false;
|
|
1707
|
+
for (const match of decodedFormat.matchAll(PRINTF_CONVERSION_PARTS)) {
|
|
1708
|
+
if (rendered.length > 8192 || valueIndex >= 32) break;
|
|
1709
|
+
const [whole, flags, width, precision, specifier] = match;
|
|
1710
|
+
const start = match.index ?? 0;
|
|
1711
|
+
rendered += decodedFormat.slice(cursor, start).replace(/%%/g, "%");
|
|
1712
|
+
cursor = start + whole.length;
|
|
1713
|
+
// `*` takes its width or precision from the argument list, which is why
|
|
1714
|
+
// `%*s 0 /` prints `/` and not the zero. Consuming it for a *precision*
|
|
1715
|
+
// decides which argument gets truncated, and that changes the name
|
|
1716
|
+
// (`%.*s 4 .envXX` is `.env`). Consuming it for a *width* changes no
|
|
1717
|
+
// answer any check here asks: padding only inserts spaces, so it can
|
|
1718
|
+
// split a name into words but never spell one, and the argument it would
|
|
1719
|
+
// otherwise mistake for the value is a candidate in its own right. It is
|
|
1720
|
+
// consumed anyway, so the rendering stays what bash prints rather than
|
|
1721
|
+
// being right for the reason nobody is looking.
|
|
1722
|
+
const widthValue = width === "*" ? values[valueIndex++] : width;
|
|
1723
|
+
const precisionValue = precision === "*" ? values[valueIndex++] : precision;
|
|
1724
|
+
const argument = values[valueIndex] ?? "";
|
|
1725
|
+
valueIndex += 1;
|
|
1726
|
+
consumed = true;
|
|
1727
|
+
let text = specifier === "b" ? decodeShellDataEscapes(argument) : argument;
|
|
1728
|
+
if (precisionValue !== undefined && /^\d+$/.test(String(precisionValue))) {
|
|
1729
|
+
text = text.slice(0, Number(precisionValue));
|
|
1730
|
+
}
|
|
1731
|
+
const pad = Number(widthValue);
|
|
1732
|
+
if (Number.isFinite(pad) && pad > text.length) {
|
|
1733
|
+
text = flags.includes("-") ? text.padEnd(pad) : text.padStart(pad);
|
|
1734
|
+
}
|
|
1735
|
+
rendered += text;
|
|
1736
|
+
}
|
|
1737
|
+
rendered += decodedFormat.slice(cursor).replace(/%%/g, "%");
|
|
1738
|
+
passes += 1;
|
|
1739
|
+
if (valueIndex >= values.length) break;
|
|
720
1740
|
}
|
|
721
|
-
rendered += decodedFormat.slice(cursor).replace(/%%/g, "%");
|
|
722
1741
|
return rendered.slice(0, 8192);
|
|
723
1742
|
}
|
|
724
1743
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1744
|
+
// A string conversion with a constant width and precision, which is the whole
|
|
1745
|
+
// of what the renderer above reproduces byte for byte.
|
|
1746
|
+
const EXACT_PRINTF_CONVERSION = /%-?\d*(?:\.\d+)?s/g;
|
|
1747
|
+
|
|
1748
|
+
// Whether this renderer reproduces what bash prints for this format.
|
|
1749
|
+
//
|
|
1750
|
+
// This is the gate on *refusing* a destructive target, so it answers a stricter
|
|
1751
|
+
// question than the rendering does. String conversions with a constant width or
|
|
1752
|
+
// precision are reproduced exactly, and so is format reuse, so a target built
|
|
1753
|
+
// out of those is refused rather than asked about: `$(printf %.0s/ x)` is `/`
|
|
1754
|
+
// and `$(printf %s / /)` is `//` in any shell.
|
|
1755
|
+
//
|
|
1756
|
+
// `*` stays outside it even though the renderer now follows one, because the
|
|
1757
|
+
// width it consumes has its own rules -- a negative one left-aligns -- and a
|
|
1758
|
+
// modelling slip there turns into a refusal of something nobody asked for. `%q`
|
|
1759
|
+
// requotes, `%b` decodes escapes, every numeric conversion reformats, and `%%`
|
|
1760
|
+
// collapses after substitution here rather than before. None of those is
|
|
1761
|
+
// claimed; a target built out of them is confirmed instead, which costs a
|
|
1762
|
+
// question rather than a wrong answer.
|
|
1763
|
+
function printfFormatIsExact(format) {
|
|
1764
|
+
if (!format.includes("%")) return true;
|
|
1765
|
+
const withoutStringConversions = format.replace(EXACT_PRINTF_CONVERSION, "");
|
|
1766
|
+
// Any `%` the string conversions did not account for is a form this does not
|
|
1767
|
+
// reproduce.
|
|
1768
|
+
if (withoutStringConversions.includes("%")) return false;
|
|
1769
|
+
return withoutStringConversions.length !== format.length;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const PRINTF_CONVERSION = /%(?:\d+\$)?[-+#0 ']*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlL]?[A-Za-z]/g;
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* The format's own literal text, with the conversions taken out and what is left
|
|
1776
|
+
* closed up.
|
|
1777
|
+
*
|
|
1778
|
+
* Closed up rather than split, because a conversion that prints nothing lets the
|
|
1779
|
+
* text on either side meet: `.e%.0snv` is `.env`, and splitting there produced
|
|
1780
|
+
* `.e` and `nv`, neither of which is a file anybody protects, while the command
|
|
1781
|
+
* opened one that is.
|
|
1782
|
+
*
|
|
1783
|
+
* Splitting as well adds nothing this misses: a protected path the output
|
|
1784
|
+
* carries in literal text alone is contiguous here too, and one that spans an
|
|
1785
|
+
* argument is covered by the arguments, which are candidates in their own
|
|
1786
|
+
* right.
|
|
1787
|
+
*
|
|
1788
|
+
* This does name files a command sometimes does not open -- `%s.env` with `x`
|
|
1789
|
+
* prints `x.env`, and `.env` is offered anyway. That is the accepted direction:
|
|
1790
|
+
* the same format with an empty argument prints `.env` exactly, and which one
|
|
1791
|
+
* it is cannot be known from the format. A candidate that never materialises
|
|
1792
|
+
* costs one command refused with the reason on screen; a path that materialises
|
|
1793
|
+
* with no candidate costs the check.
|
|
1794
|
+
*
|
|
1795
|
+
* @param {string} format
|
|
1796
|
+
* @returns {string[]}
|
|
1797
|
+
*/
|
|
1798
|
+
function printfFormatLiterals(format) {
|
|
1799
|
+
return [format.replace(PRINTF_CONVERSION, "")].filter(Boolean);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* What a `printf` or `echo` prints, as far as this process can tell.
|
|
1804
|
+
*
|
|
1805
|
+
* `words` is what it prints and nothing else, so a caller deciding whether to
|
|
1806
|
+
* permit a command can only use it when `exact` is set. `candidates` is wider on
|
|
1807
|
+
* purpose: it adds the format's own literal text and the raw arguments, because
|
|
1808
|
+
* a caller looking for a protected path wants every string that could reach the
|
|
1809
|
+
* output. `printf %.0s.env x` prints `.env`, and reading only the substituted
|
|
1810
|
+
* argument returned `x.env` -- a name matching no protected pattern, which is
|
|
1811
|
+
* the wrong direction for that check to be wrong in.
|
|
1812
|
+
*
|
|
1813
|
+
* @returns {{words: string[], candidates: string[], exact: boolean}}
|
|
1814
|
+
*/
|
|
1815
|
+
/**
|
|
1816
|
+
* Where the producer sits in a word list, allowing for a name the shell
|
|
1817
|
+
* assembles. `{printf,} .env | xargs cat` runs `printf`, and matching the word
|
|
1818
|
+
* as written found no producer at all -- so the output was never rendered and
|
|
1819
|
+
* the file it names was never offered.
|
|
1820
|
+
*
|
|
1821
|
+
* @param {string[]} words
|
|
1822
|
+
* @param {string} producerCommand
|
|
1823
|
+
* @returns {number}
|
|
1824
|
+
*/
|
|
1825
|
+
function producerCommandIndex(words, producerCommand) {
|
|
1826
|
+
return words.findIndex((word) => commandBasename(word) === producerCommand
|
|
1827
|
+
|| expandShellBraces(word).some((entry) => commandBasename(entry.value) === producerCommand));
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function staticDataOutput(segment, assignments, producerCommand) {
|
|
1831
|
+
const empty = { words: [], candidates: [], exact: false };
|
|
1832
|
+
// Expanded before the producer is located, so an argument that comes out of
|
|
1833
|
+
// the same brace word as the name is an argument here too: `{printf,.e%.1sv}`
|
|
1834
|
+
// is `printf` plus the format it prints.
|
|
1835
|
+
const tokens = shellDataTokens(segment).flatMap((token) => {
|
|
1836
|
+
const expansions = expandShellBraces(token.value, token.literalMask).filter((entry) => entry.value);
|
|
1837
|
+
if (expansions.length === 1 && expansions[0].value === token.value) return [token];
|
|
1838
|
+
return expansions.map((entry) => ({ ...token, value: entry.value, literalMask: entry.mask }));
|
|
1839
|
+
});
|
|
1840
|
+
const commandIndex = producerCommandIndex(tokens.map((token) => token.value), producerCommand);
|
|
1841
|
+
if (commandIndex < 0) return empty;
|
|
729
1842
|
const args = tokens.slice(commandIndex + 1).map((token) => token.variableActive
|
|
730
1843
|
? expandKnownShellVariables(token.value, assignments)
|
|
731
1844
|
: token.value);
|
|
732
1845
|
let output = "";
|
|
1846
|
+
let exact = false;
|
|
1847
|
+
const extra = [];
|
|
733
1848
|
if (producerCommand === "echo") {
|
|
734
1849
|
let argumentIndex = 0;
|
|
735
1850
|
let escapes = false;
|
|
@@ -739,11 +1854,32 @@ function staticDataOutputCandidates(segment, assignments, producerCommand) {
|
|
|
739
1854
|
argumentIndex += 1;
|
|
740
1855
|
}
|
|
741
1856
|
output = args.slice(argumentIndex).join(" ");
|
|
1857
|
+
// Whether `echo` interprets escapes without `-e` is shell- and
|
|
1858
|
+
// build-dependent, so a command that turns them on is read but not trusted
|
|
1859
|
+
// to the exact byte.
|
|
1860
|
+
exact = !escapes;
|
|
742
1861
|
if (escapes) output = decodeShellDataEscapes(output);
|
|
743
1862
|
} else if (producerCommand === "printf" && args.length > 0) {
|
|
744
|
-
|
|
1863
|
+
// `printf -- /` prints `/`: the builtin takes `--` as the end of its
|
|
1864
|
+
// options, so the format is the argument after it. Reading `--` as the
|
|
1865
|
+
// format produced the value `--`, which is no root and no protected path,
|
|
1866
|
+
// while the shell printed one.
|
|
1867
|
+
const printfArgs = args[0] === "--" ? args.slice(1) : args;
|
|
1868
|
+
const format = printfArgs[0] ?? "";
|
|
1869
|
+
output = renderStaticPrintf(format, printfArgs.slice(1));
|
|
1870
|
+
// Any other option is one this does not model -- `-v NAME` assigns the
|
|
1871
|
+
// result to a variable and prints nothing at all -- so the rendering is not
|
|
1872
|
+
// claimed to be what bash would print.
|
|
1873
|
+
exact = !format.startsWith("-") && printfFormatIsExact(format);
|
|
1874
|
+
extra.push(...printfFormatLiterals(format), ...printfArgs.slice(1));
|
|
745
1875
|
}
|
|
746
|
-
|
|
1876
|
+
const words = output.split(/\s+/).filter(Boolean);
|
|
1877
|
+
const candidates = [...words, ...extra.flatMap((item) => item.split(/\s+/)).filter(Boolean)];
|
|
1878
|
+
return { words, candidates: [...new Set(candidates)], exact };
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function staticDataOutputCandidates(segment, assignments, producerCommand) {
|
|
1882
|
+
return staticDataOutput(segment, assignments, producerCommand).candidates;
|
|
747
1883
|
}
|
|
748
1884
|
|
|
749
1885
|
function echoEscapeMode(words, commandIndex) {
|
|
@@ -773,19 +1909,29 @@ function xargsPipelineInputCandidates(command) {
|
|
|
773
1909
|
const candidates = [];
|
|
774
1910
|
const assignments = new Map();
|
|
775
1911
|
for (let index = 0; index < segments.length; index += 1) {
|
|
776
|
-
|
|
777
|
-
|
|
1912
|
+
// The producer's arguments can come out of the same brace word as its name:
|
|
1913
|
+
// `{printf,.e%.1sv} nv` is `printf .e%.1sv nv`, and reading the name from
|
|
1914
|
+
// the expanded list while slicing arguments off the raw one dropped the
|
|
1915
|
+
// format entirely.
|
|
1916
|
+
const { tokens: producerTokens, words: producerWords } = expandedSegment(segments[index], assignments);
|
|
778
1917
|
rememberLeadingShellAssignments(producerWords, assignments);
|
|
779
1918
|
if (index + 1 >= segments.length) continue;
|
|
780
|
-
|
|
1919
|
+
// The consumer's name is assembled the same ways the producer's is, and
|
|
1920
|
+
// reading it as written meant `echo .env | xa$(printf %s rgs) cat` had no
|
|
1921
|
+
// `xargs` in it as far as this was concerned.
|
|
1922
|
+
const consumerWords = expandedSegment(segments[index + 1], assignments).words;
|
|
781
1923
|
const consumerCommand = commandBasename(stripWrapper(consumerWords)[0] ?? "");
|
|
782
|
-
|
|
1924
|
+
// A consumer whose name is still being computed could be `xargs`, and if it
|
|
1925
|
+
// is, whatever the producer prints is opened. `echo .env | $(eval printf %s
|
|
1926
|
+
// xargs) cat` is that command whenever the substitution runs.
|
|
1927
|
+
if (consumerCommand !== "xargs" && !commandNameIsDynamic(consumerCommand)) continue;
|
|
783
1928
|
const producerCommand = commandBasename(stripWrapper(producerWords)[0] ?? "");
|
|
784
1929
|
if (!DATA_ONLY_COMMANDS.has(producerCommand)) continue;
|
|
785
|
-
const commandIndex = producerWords
|
|
1930
|
+
const commandIndex = producerCommandIndex(producerWords, producerCommand);
|
|
786
1931
|
for (const token of producerTokens.slice(commandIndex + 1)) {
|
|
787
1932
|
const word = token.variableActive ? expandKnownShellVariables(token.value, assignments) : token.value;
|
|
788
1933
|
if (word.startsWith("-") || word === "%s" || word === "%s\\n") continue;
|
|
1934
|
+
// Already brace-expanded by `expandedSegment`, mask and all.
|
|
789
1935
|
candidates.push(word);
|
|
790
1936
|
}
|
|
791
1937
|
if (producerCommand === "printf" || (producerCommand === "echo" && echoEscapeMode(producerWords, commandIndex))) {
|
|
@@ -826,9 +1972,19 @@ function extractAttachedRedirectionPaths(segment) {
|
|
|
826
1972
|
while (segment[index + 1] === "<") index += 1;
|
|
827
1973
|
continue;
|
|
828
1974
|
}
|
|
1975
|
+
// `>&` duplicates a file descriptor only when a digit or `-` follows it.
|
|
1976
|
+
// `>&word` with anything else redirects both streams to a file called
|
|
1977
|
+
// `word`, so skipping the whole form meant `printf x >& .env` truncated a
|
|
1978
|
+
// protected file while the scan moved past it.
|
|
829
1979
|
if (next === "&") {
|
|
1980
|
+
let after = index + 2;
|
|
1981
|
+
while (/\s/.test(segment[after] ?? "")) after += 1;
|
|
1982
|
+
const target = segment[after] ?? "";
|
|
1983
|
+
if (target === "" || target === "-" || /[0-9]/.test(target)) {
|
|
1984
|
+
index += 1;
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
830
1987
|
index += 1;
|
|
831
|
-
continue;
|
|
832
1988
|
}
|
|
833
1989
|
|
|
834
1990
|
if ((char === ">" && (next === ">" || next === "|")) || (char === "<" && next === ">")) index += 1;
|
|
@@ -839,10 +1995,25 @@ function extractAttachedRedirectionPaths(segment) {
|
|
|
839
1995
|
let target = "";
|
|
840
1996
|
let targetQuote;
|
|
841
1997
|
let targetEscaped = false;
|
|
1998
|
+
// The same per-character record the word tokenizer keeps, and it is the
|
|
1999
|
+
// whole answer on what expands: `> {.env,}` writes `.env`, `> "{.env,}"`
|
|
2000
|
+
// writes a file with that literal name, and the quotes are gone by the time
|
|
2001
|
+
// the target is a string. One target can also hold a quoted group beside an
|
|
2002
|
+
// expanding one -- `> "{.env,}"{,}` expands to two copies of a literal
|
|
2003
|
+
// `{.env,}`, an ambiguous redirect that writes nothing -- and a single flag
|
|
2004
|
+
// for the whole target could only say "expand it all", which produced
|
|
2005
|
+
// `.env` and refused a command that opens no such file.
|
|
2006
|
+
let targetMask = "";
|
|
2007
|
+
// Whitespace and `|` inside `$( )` or backticks belong to the target word.
|
|
2008
|
+
// Ending at the first space split `> .en$(echo v)` into a target of
|
|
2009
|
+
// `.en$(echo`, a name that matches nothing and hides the one being written.
|
|
2010
|
+
let targetSubstitution = 0;
|
|
2011
|
+
let targetBackticks = false;
|
|
842
2012
|
for (; cursor < segment.length; cursor += 1) {
|
|
843
2013
|
const targetChar = segment[cursor];
|
|
844
2014
|
if (targetEscaped) {
|
|
845
2015
|
target += targetChar;
|
|
2016
|
+
targetMask += "1";
|
|
846
2017
|
targetEscaped = false;
|
|
847
2018
|
continue;
|
|
848
2019
|
}
|
|
@@ -850,6 +2021,16 @@ function extractAttachedRedirectionPaths(segment) {
|
|
|
850
2021
|
targetEscaped = true;
|
|
851
2022
|
continue;
|
|
852
2023
|
}
|
|
2024
|
+
// A redirection target is a word like any other, so it can be written in
|
|
2025
|
+
// ANSI-C quoting too. `printf x >$'.env'` used to yield the target
|
|
2026
|
+
// `$.env`, which matches nothing, while the shell truncated `.env`.
|
|
2027
|
+
if (targetChar === "$" && segment[cursor + 1] === "'" && !targetQuote) {
|
|
2028
|
+
const ansiC = readAnsiCQuote(segment, cursor);
|
|
2029
|
+
target += ansiC.value;
|
|
2030
|
+
targetMask += "1".repeat(ansiC.value.length);
|
|
2031
|
+
cursor = ansiC.endIndex;
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
853
2034
|
if ((targetChar === "'" || targetChar === '"') && !targetQuote) {
|
|
854
2035
|
targetQuote = targetChar;
|
|
855
2036
|
continue;
|
|
@@ -858,10 +2039,22 @@ function extractAttachedRedirectionPaths(segment) {
|
|
|
858
2039
|
targetQuote = undefined;
|
|
859
2040
|
continue;
|
|
860
2041
|
}
|
|
861
|
-
|
|
2042
|
+
// The depth this character sits at before it can close the substitution
|
|
2043
|
+
// it belongs to, the way the word tokenizer reads it and for the same
|
|
2044
|
+
// reason: read afterwards, the `}` of `${...}` looked like brace syntax
|
|
2045
|
+
// and left the target masked one character short.
|
|
2046
|
+
const enclosingDepth = targetSubstitution;
|
|
2047
|
+
if (!targetQuote) {
|
|
2048
|
+
if (targetChar === "$" && (segment[cursor + 1] === "(" || segment[cursor + 1] === "{")) targetSubstitution += 1;
|
|
2049
|
+
else if ((targetChar === ")" || targetChar === "}") && targetSubstitution > 0) targetSubstitution -= 1;
|
|
2050
|
+
else if (targetChar === "`") targetBackticks = !targetBackticks;
|
|
2051
|
+
}
|
|
2052
|
+
const insideSubstitution = enclosingDepth > 0 || targetSubstitution > 0 || targetBackticks;
|
|
2053
|
+
if (!targetQuote && !insideSubstitution && (/\s/.test(targetChar) || /[;&|<>]/.test(targetChar))) break;
|
|
862
2054
|
target += targetChar;
|
|
2055
|
+
targetMask += (targetQuote || insideSubstitution) ? "1" : "0";
|
|
863
2056
|
}
|
|
864
|
-
if (target) paths.push(target);
|
|
2057
|
+
if (target) paths.push({ value: target, literalMask: targetMask });
|
|
865
2058
|
index = Math.max(index, cursor - 1);
|
|
866
2059
|
}
|
|
867
2060
|
|
|
@@ -871,40 +2064,94 @@ function extractAttachedRedirectionPaths(segment) {
|
|
|
871
2064
|
export function extractShellPathCandidates(command) {
|
|
872
2065
|
const candidates = [...xargsPipelineInputCandidates(command)];
|
|
873
2066
|
const assignments = new Map();
|
|
874
|
-
|
|
2067
|
+
// `braceActive` is the tokenizer's verdict on whether the shell would expand
|
|
2068
|
+
// this word's braces. Expanding unconditionally was the conservative choice
|
|
2069
|
+
// and it refused work nobody asked about: `cat "{.env,}"` opens a file with
|
|
2070
|
+
// that literal name, and offering `.env` for it blocked the command. Every
|
|
2071
|
+
// caller that reads a token has the answer, so it is passed rather than
|
|
2072
|
+
// guessed; the word as written stays a candidate either way.
|
|
2073
|
+
const addCandidate = (candidate, variableActive = true, braceActive = false, literalMask = undefined) => {
|
|
875
2074
|
if (typeof candidate !== "string" || !candidate) return;
|
|
876
|
-
|
|
2075
|
+
const expanded = variableActive ? expandKnownShellVariables(candidate, assignments) : candidate;
|
|
2076
|
+
candidates.push(expanded);
|
|
2077
|
+
if (!braceActive) return;
|
|
2078
|
+
// Expanded with the record the scanner kept, not without one. A target
|
|
2079
|
+
// holding a quoted group beside an expanding one -- `"{.env,}"{,}` -- was
|
|
2080
|
+
// expanded all the way through for want of it, and offered `.env` for a
|
|
2081
|
+
// command that writes no such file.
|
|
2082
|
+
const mask = literalMask?.length === expanded.length ? literalMask : undefined;
|
|
2083
|
+
candidates.push(...expandShellBraces(expanded, mask)
|
|
2084
|
+
.map((entry) => entry.value)
|
|
2085
|
+
.filter((word) => word !== expanded));
|
|
877
2086
|
};
|
|
878
2087
|
const pending = splitShellSegments(command).map((segment) => ({ segment, depth: 0 }));
|
|
879
2088
|
while (pending.length > 0) {
|
|
880
2089
|
const { segment, depth } = pending.shift();
|
|
881
|
-
|
|
882
|
-
|
|
2090
|
+
// One stream for the command name and for the operands alike: `{cat,.env}`
|
|
2091
|
+
// is a `cat` whose only argument came out of the same word, and reading the
|
|
2092
|
+
// name from one list and the arguments from another lost it.
|
|
2093
|
+
const { words, text, tokens, braceText, evaluable } = expandedSegment(segment, assignments);
|
|
2094
|
+
const expandedWords = words;
|
|
883
2095
|
rememberLeadingShellAssignments(words, assignments);
|
|
884
2096
|
const commandName = commandBasename(stripWrapper(words)[0] ?? "");
|
|
885
|
-
|
|
2097
|
+
// Redirection targets are read off the raw segment, because that is the only
|
|
2098
|
+
// text that still shows an operator glued to its path. They get the same
|
|
2099
|
+
// resolution the words got: the data-only skip below never covers them --
|
|
2100
|
+
// `printf` writes to whatever `>` names whether or not `printf` itself opens
|
|
2101
|
+
// files -- so `printf x > .en$(echo v)` truncated `.env` while every reader
|
|
2102
|
+
// that could have resolved the name had been skipped.
|
|
2103
|
+
for (const word of extractAttachedRedirectionPaths(segment).flatMap(redirectionTargetWords)) {
|
|
2104
|
+
const resolvedTarget = resolveSubstitutionTarget(word.value, assignments, evaluable);
|
|
2105
|
+
addCandidate(resolvedTarget.value);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
const inlineFlags = INLINE_CODE_FLAGS.get(commandName);
|
|
2109
|
+
if (inlineFlags) {
|
|
2110
|
+
for (let index = 0; index < words.length - 1; index += 1) {
|
|
2111
|
+
if (!inlineFlags.has(words[index])) continue;
|
|
2112
|
+
for (const literal of quotedShellLiterals(words[index + 1])) addCandidate(literal);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
886
2116
|
if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0) {
|
|
887
2117
|
const argumentTokens = executableArgumentTokens(tokens, words, commandName);
|
|
2118
|
+
// `printf %.0s.env x` prints `.env`: the conversion eats the argument and
|
|
2119
|
+
// the rest of the format is the output. Reading only the substituted
|
|
2120
|
+
// argument produced `x.env`, a name matching no protected pattern, so the
|
|
2121
|
+
// file the command actually names went unseen. Inside the data-only guard
|
|
2122
|
+
// with everything else -- a `printf` of its own prints to stdout, and its
|
|
2123
|
+
// format is text until something downstream opens it.
|
|
2124
|
+
if (commandName === "printf") {
|
|
2125
|
+
for (const literal of printfFormatLiterals(argumentTokens[0]?.value ?? "")) addCandidate(literal);
|
|
2126
|
+
// And the output itself. The literal text and the arguments were both
|
|
2127
|
+
// offered as candidates, but never what they make when printf puts them
|
|
2128
|
+
// together: `.e%.1sv nv` is `.env`, and the pieces on offer were `.ev`
|
|
2129
|
+
// and `nv`. Whether the rendering is exact enough to *refuse* a
|
|
2130
|
+
// destructive target is a separate question -- here an approximate name
|
|
2131
|
+
// costs a refusal with the reason on screen, and a missing one costs the
|
|
2132
|
+
// check.
|
|
2133
|
+
for (const word of staticDataOutput(segment, assignments, "printf").words) addCandidate(word, false);
|
|
2134
|
+
}
|
|
888
2135
|
let searchPatternPending = SEARCH_COMMANDS.has(commandName);
|
|
889
2136
|
for (let index = 0; index < argumentTokens.length; index += 1) {
|
|
890
2137
|
const word = argumentTokens[index].value;
|
|
891
2138
|
if (word === "<<" || word === "<<<") {
|
|
892
2139
|
if (commandName === "xargs" && word === "<<<" && argumentTokens[index + 1]) {
|
|
893
2140
|
const input = argumentTokens[index + 1];
|
|
894
|
-
addCandidate(input.value, input.variableActive);
|
|
2141
|
+
addCandidate(input.value, input.variableActive, input.braceActive);
|
|
895
2142
|
}
|
|
896
2143
|
index += 1;
|
|
897
2144
|
continue;
|
|
898
2145
|
}
|
|
899
2146
|
if (PATH_REDIRECT_OPERATORS.has(word) && argumentTokens[index + 1]) {
|
|
900
2147
|
const target = argumentTokens[index + 1];
|
|
901
|
-
addCandidate(target.value, target.variableActive);
|
|
2148
|
+
addCandidate(target.value, target.variableActive, target.braceActive);
|
|
902
2149
|
index += 1;
|
|
903
2150
|
continue;
|
|
904
2151
|
}
|
|
905
2152
|
if (/^(?:\d*)>>?/.test(word) || /^&>>?/.test(word)) {
|
|
906
2153
|
const pathPart = word.replace(/^(?:\d*|&)>>?/, "");
|
|
907
|
-
if (pathPart) addCandidate(pathPart, argumentTokens[index].variableActive);
|
|
2154
|
+
if (pathPart) addCandidate(pathPart, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
908
2155
|
continue;
|
|
909
2156
|
}
|
|
910
2157
|
const optionEquals = word.indexOf("=");
|
|
@@ -919,7 +2166,7 @@ export function extractShellPathCandidates(command) {
|
|
|
919
2166
|
? word.slice(2)
|
|
920
2167
|
: undefined;
|
|
921
2168
|
if (attachedGrepFile) {
|
|
922
|
-
addCandidate(attachedGrepFile, argumentTokens[index].variableActive);
|
|
2169
|
+
addCandidate(attachedGrepFile, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
923
2170
|
searchPatternPending = false;
|
|
924
2171
|
continue;
|
|
925
2172
|
}
|
|
@@ -929,20 +2176,20 @@ export function extractShellPathCandidates(command) {
|
|
|
929
2176
|
continue;
|
|
930
2177
|
}
|
|
931
2178
|
if (attachedRgOption?.option === "-f") {
|
|
932
|
-
addCandidate(attachedRgOption.value, argumentTokens[index].variableActive);
|
|
2179
|
+
addCandidate(attachedRgOption.value, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
933
2180
|
searchPatternPending = false;
|
|
934
2181
|
continue;
|
|
935
2182
|
}
|
|
936
2183
|
if (attachedRgOption?.option === "-g") {
|
|
937
|
-
addCandidate(attachedRgOption.value, argumentTokens[index].variableActive);
|
|
2184
|
+
addCandidate(attachedRgOption.value, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
938
2185
|
continue;
|
|
939
2186
|
}
|
|
940
2187
|
if (SEARCH_COMMANDS.has(commandName) && (SEARCH_FILE_OPTIONS.has(optionName) || SEARCH_GLOB_OPTIONS.has(optionName))) {
|
|
941
2188
|
if (inlineOptionValue !== undefined) {
|
|
942
|
-
addCandidate(inlineOptionValue, argumentTokens[index].variableActive);
|
|
2189
|
+
addCandidate(inlineOptionValue, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
943
2190
|
} else {
|
|
944
2191
|
const target = argumentTokens[index + 1];
|
|
945
|
-
if (target) addCandidate(target.value, target.variableActive);
|
|
2192
|
+
if (target) addCandidate(target.value, target.variableActive, target.braceActive);
|
|
946
2193
|
index += 1;
|
|
947
2194
|
}
|
|
948
2195
|
if (SEARCH_FILE_OPTIONS.has(optionName) && (optionName === "-f" || optionName === "--file")) searchPatternPending = false;
|
|
@@ -950,7 +2197,7 @@ export function extractShellPathCandidates(command) {
|
|
|
950
2197
|
}
|
|
951
2198
|
const optionPath = pathCandidateFromOption(word);
|
|
952
2199
|
if (optionPath) {
|
|
953
|
-
addCandidate(optionPath, argumentTokens[index].variableActive);
|
|
2200
|
+
addCandidate(optionPath, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
954
2201
|
continue;
|
|
955
2202
|
}
|
|
956
2203
|
if (word.startsWith("-")) continue;
|
|
@@ -958,12 +2205,27 @@ export function extractShellPathCandidates(command) {
|
|
|
958
2205
|
searchPatternPending = false;
|
|
959
2206
|
continue;
|
|
960
2207
|
}
|
|
961
|
-
|
|
2208
|
+
const operandPath = operandValuePath(word);
|
|
2209
|
+
if (operandPath) {
|
|
2210
|
+
addCandidate(operandPath, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
if (isFilesystemArgument(word)) addCandidate(word, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
|
|
962
2214
|
}
|
|
963
2215
|
}
|
|
964
|
-
if (depth <
|
|
965
|
-
for (const nestedCommand of extractNestedCommands(
|
|
2216
|
+
if (depth < MAX_NESTED_DEPTH) {
|
|
2217
|
+
for (const nestedCommand of extractNestedCommands(braceText, expandedWords)) {
|
|
966
2218
|
for (const nestedSegment of splitShellSegments(nestedCommand)) {
|
|
2219
|
+
// `$(echo -e '.en\x76')` prints `.env`, and `printf` decodes its
|
|
2220
|
+
// format the same way. The backslash is gone by the time the body is
|
|
2221
|
+
// tokenized, so the escape is read back off the raw text -- the same
|
|
2222
|
+
// reading the `xargs` producer already gets.
|
|
2223
|
+
const nestedWords = shellWords(nestedSegment);
|
|
2224
|
+
const nestedName = commandBasename(stripWrapper(expandedSegment(nestedSegment, assignments).words)[0] ?? "");
|
|
2225
|
+
const nestedIndex = producerCommandIndex(nestedWords, nestedName);
|
|
2226
|
+
if (nestedName === "printf" || (nestedName === "echo" && echoEscapeMode(nestedWords, nestedIndex))) {
|
|
2227
|
+
for (const literal of escapedLiteralCandidates(nestedSegment)) addCandidate(literal, false);
|
|
2228
|
+
}
|
|
967
2229
|
pending.push({ segment: nestedSegment, depth: depth + 1 });
|
|
968
2230
|
}
|
|
969
2231
|
}
|
|
@@ -975,14 +2237,49 @@ export function extractShellPathCandidates(command) {
|
|
|
975
2237
|
export function extractShellGlobCandidates(command) {
|
|
976
2238
|
const candidates = [];
|
|
977
2239
|
const assignments = new Map();
|
|
978
|
-
const
|
|
2240
|
+
const segments = splitShellSegments(command);
|
|
2241
|
+
// A pattern printed into an `xargs` that opens files is opened, and the
|
|
2242
|
+
// producer that printed it only prints -- so `echo .env* | xargs cat` reads
|
|
2243
|
+
// the secret with the glob sitting in a segment this would otherwise skip.
|
|
2244
|
+
const pending = segments.map((segment, index) => ({
|
|
2245
|
+
segment,
|
|
2246
|
+
depth: 0,
|
|
2247
|
+
piped: segments[index + 1] !== undefined && feedsFileOperandConsumer(segments[index + 1], assignments)
|
|
2248
|
+
}));
|
|
979
2249
|
while (pending.length > 0) {
|
|
980
|
-
const { segment, depth } = pending.shift();
|
|
981
|
-
|
|
982
|
-
|
|
2250
|
+
const { segment, depth, piped } = pending.shift();
|
|
2251
|
+
// The same expanded, resolved stream every other reader takes. Reading the
|
|
2252
|
+
// raw tokens here left the glob layer behind while the literal layer moved
|
|
2253
|
+
// on -- and a pattern is precisely what the literal layer cannot answer for.
|
|
2254
|
+
// `.env*` matches no protected literal, so `{cat,.env*}` had its command in
|
|
2255
|
+
// one unexpanded word, produced no glob candidate at all, and read the
|
|
2256
|
+
// secret. `{grep,-f,.env*,README.md}`, `cat $({echo,.env*})`,
|
|
2257
|
+
// `{bash,} -c 'cat .env*'` and `echo .env* | xargs cat` went the same way.
|
|
2258
|
+
const { words, tokens, braceText } = expandedSegment(segment, assignments);
|
|
983
2259
|
rememberLeadingShellAssignments(words, assignments);
|
|
984
2260
|
const commandName = commandBasename(stripWrapper(words)[0] ?? "");
|
|
985
|
-
|
|
2261
|
+
|
|
2262
|
+
// A redirection target is a file whatever the command does with its
|
|
2263
|
+
// arguments, so it is read before the data-only skip below rather than
|
|
2264
|
+
// inside it: `printf pwned > .en*` truncates whatever `.en*` matches, and
|
|
2265
|
+
// `printf` being a data-only command says nothing about that. Read through
|
|
2266
|
+
// the same scanner the path candidates use, so the operator prefix is off
|
|
2267
|
+
// and `>|`, `>&` and ANSI-C quoting are already handled.
|
|
2268
|
+
// Read through the same word rule as the path candidates, and for the same
|
|
2269
|
+
// reason: a target that expands to two words is an ambiguous redirect that
|
|
2270
|
+
// opens nothing, so `printf x > "{.env*,}"{,}` had no file to report. What
|
|
2271
|
+
// survives that rule is matched against filenames only where the pattern is
|
|
2272
|
+
// unquoted -- `> "{.env*,}"` names one oddly-spelled file -- while a glob
|
|
2273
|
+
// that did survive stays a candidate, since it may still match exactly one.
|
|
2274
|
+
for (const word of extractAttachedRedirectionPaths(segment).flatMap(redirectionTargetWords)) {
|
|
2275
|
+
const expanded = expandKnownShellVariables(word.value, assignments);
|
|
2276
|
+
const substituted = expanded !== word.value;
|
|
2277
|
+
if (wordHasActiveGlob(word.value, word.mask) || (substituted && /[*?[]/.test(expanded))) {
|
|
2278
|
+
candidates.push(expanded);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0 || piped) {
|
|
986
2283
|
const argumentTokens = executableArgumentTokens(tokens, words, commandName);
|
|
987
2284
|
let searchPatternPending = SEARCH_COMMANDS.has(commandName);
|
|
988
2285
|
for (let index = 0; index < argumentTokens.length; index += 1) {
|
|
@@ -1052,8 +2349,13 @@ export function extractShellGlobCandidates(command) {
|
|
|
1052
2349
|
if (token.activeGlob || (token.unquotedVariable && /[*?{\[]/.test(expanded))) candidates.push(expanded);
|
|
1053
2350
|
}
|
|
1054
2351
|
}
|
|
1055
|
-
if (depth <
|
|
1056
|
-
|
|
2352
|
+
if (depth < MAX_NESTED_DEPTH) {
|
|
2353
|
+
// Brace-expanded text, the same as the other two readers take. No case
|
|
2354
|
+
// currently distinguishes it from the raw segment here -- an interpreter
|
|
2355
|
+
// payload is found in the word list rather than the text, and brace
|
|
2356
|
+
// expansion does not move a substitution body -- but a reader deriving
|
|
2357
|
+
// its own view of a segment is the whole of what went wrong before.
|
|
2358
|
+
for (const nestedCommand of extractNestedCommands(braceText, words)) {
|
|
1057
2359
|
for (const nestedSegment of splitShellSegments(nestedCommand)) pending.push({ segment: nestedSegment, depth: depth + 1 });
|
|
1058
2360
|
}
|
|
1059
2361
|
}
|
|
@@ -1061,6 +2363,192 @@ export function extractShellGlobCandidates(command) {
|
|
|
1061
2363
|
return [...new Set(candidates.map(normalizePathCandidate).filter(Boolean))];
|
|
1062
2364
|
}
|
|
1063
2365
|
|
|
2366
|
+
/**
|
|
2367
|
+
* Words in a file position whose final path segment this module could not
|
|
2368
|
+
* resolve to a name.
|
|
2369
|
+
*
|
|
2370
|
+
* Modelling one more expansion operator each time one is reported is a losing
|
|
2371
|
+
* game: bash has a dozen of them, plus arrays, plus command substitution, and
|
|
2372
|
+
* each unmodelled form reads as "this word names no protected path" — the one
|
|
2373
|
+
* answer that is never safe to guess. So the rule is inverted here. A word that
|
|
2374
|
+
* still holds an expansion where its filename lives is *unresolved*, and an
|
|
2375
|
+
* unresolved filename is refused rather than assumed harmless.
|
|
2376
|
+
*
|
|
2377
|
+
* Two conditions narrow it to words that are filenames being assembled, rather
|
|
2378
|
+
* than to every word holding an expansion.
|
|
2379
|
+
*
|
|
2380
|
+
* Only the last path segment counts. `$HOME/notes.txt` names a known file in an
|
|
2381
|
+
* unknown directory, and every protected pattern that could match it is anchored
|
|
2382
|
+
* on the basename anyway, so flagging it would refuse ordinary work for nothing.
|
|
2383
|
+
*
|
|
2384
|
+
* And the expansion has to be *concatenated with literal text*. A word that is
|
|
2385
|
+
* nothing but an expansion — `$(date)`, `${MESSAGE}` — is as likely to be a
|
|
2386
|
+
* commit message as a path, and a pure substitution is already followed into by
|
|
2387
|
+
* `extractNestedCommands`, which reads `cat $(echo .env)` correctly. What that
|
|
2388
|
+
* cannot do is put the pieces back together: `.en$(echo v)` and `$(echo .)env`
|
|
2389
|
+
* are filenames half-written in plain sight, and only a rule about the joining
|
|
2390
|
+
* sees them.
|
|
2391
|
+
*
|
|
2392
|
+
* Quoting decides whether a `$` is an expansion at all, so this reads the
|
|
2393
|
+
* tokenizer's own verdict rather than looking for the character — `cat '$F'`
|
|
2394
|
+
* passes a literal and is not flagged.
|
|
2395
|
+
*
|
|
2396
|
+
* @param {string} command
|
|
2397
|
+
* @returns {string[]} the unresolved words, in the order they appear
|
|
2398
|
+
*/
|
|
2399
|
+
// Strip the expansions out; whatever text is left is the literal part the author
|
|
2400
|
+
// wrote around them. Literal text plus an expansion is a filename being
|
|
2401
|
+
// assembled; an expansion on its own is a value.
|
|
2402
|
+
const EXPANSION_FORM = /\$\{[^{}]*\}|\$\([^()]*\)|`[^`]*`|\$[A-Za-z_][A-Za-z0-9_]*/g;
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* True when the text still holds an expansion this module could not resolve.
|
|
2406
|
+
*
|
|
2407
|
+
* Only parameter expansions count. A command substitution carries a command,
|
|
2408
|
+
* and the nested scan reads it -- `cat $(gh issue create ...)` is answered as
|
|
2409
|
+
* an external write, and answering it here instead would replace that with a
|
|
2410
|
+
* vaguer reason. A parameter expansion carries nothing for anyone else to read,
|
|
2411
|
+
* so if it is not resolved here it is not resolved at all.
|
|
2412
|
+
*
|
|
2413
|
+
* @param {string} text
|
|
2414
|
+
* @returns {boolean}
|
|
2415
|
+
*/
|
|
2416
|
+
function holdsUnresolvedParameter(text) {
|
|
2417
|
+
return /\$\{|\$[A-Za-z_]/.test(text);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/**
|
|
2421
|
+
* True when the name is being built out of pieces rather than named outright.
|
|
2422
|
+
*
|
|
2423
|
+
* Literal text beside an expansion is the obvious form. Two expansions with
|
|
2424
|
+
* nothing between them is the same act -- `$(printf %.1sen .Z)$(printf %.1s vZ)`
|
|
2425
|
+
* spells `.env` and neither half contains it -- and nobody writes a value that
|
|
2426
|
+
* way, so the count is as good a signal as the literal.
|
|
2427
|
+
*
|
|
2428
|
+
* @param {string} basename
|
|
2429
|
+
* @returns {boolean}
|
|
2430
|
+
*/
|
|
2431
|
+
function assemblesFilename(basename) {
|
|
2432
|
+
const expansions = basename.match(EXPANSION_FORM) ?? [];
|
|
2433
|
+
if (expansions.length > 1) return true;
|
|
2434
|
+
const literal = basename.replace(EXPANSION_FORM, "");
|
|
2435
|
+
return literal.length > 0 && expansions.length > 0;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* True when this segment hands what it reads to a command that opens files.
|
|
2440
|
+
*
|
|
2441
|
+
* `echo $F | xargs cat` opens whatever `$F` names, and neither segment shows it:
|
|
2442
|
+
* the producer is `echo`, which only prints, and the consumer's operand list is
|
|
2443
|
+
* empty because the filename arrives on stdin. Judged one segment at a time the
|
|
2444
|
+
* pipeline says nothing, so the producer is judged against the command at the
|
|
2445
|
+
* far end of the pipe instead.
|
|
2446
|
+
*
|
|
2447
|
+
* Every word after `xargs` is examined rather than just the command word,
|
|
2448
|
+
* because the option values are not modelled here and mistaking `-n 1 cat` for
|
|
2449
|
+
* a command named `1` would lose the `cat`. The cost of reading one word too
|
|
2450
|
+
* many is a question about a pipeline that already holds an expansion nobody
|
|
2451
|
+
* can resolve.
|
|
2452
|
+
*
|
|
2453
|
+
* @param {string} segment
|
|
2454
|
+
* @returns {boolean}
|
|
2455
|
+
*/
|
|
2456
|
+
function feedsFileOperandConsumer(segment, assignments) {
|
|
2457
|
+
const words = stripWrapper(expandedSegment(segment, assignments).words);
|
|
2458
|
+
const name = commandBasename(words[0] ?? "");
|
|
2459
|
+
if (name !== "xargs" && !commandNameIsDynamic(name)) return false;
|
|
2460
|
+
return words.slice(1).some((word) => FILE_OPERAND_COMMANDS.has(commandBasename(word)));
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
export function unresolvedPathExpansions(command) {
|
|
2464
|
+
const unresolved = [];
|
|
2465
|
+
const assignments = new Map();
|
|
2466
|
+
const pending = [];
|
|
2467
|
+
// Segments are queued in pipeline order so each one still knows what consumes
|
|
2468
|
+
// it; read on their own they lose the only thing that says what they name.
|
|
2469
|
+
const enqueue = (text, depth) => {
|
|
2470
|
+
const segments = splitShellSegments(text);
|
|
2471
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
2472
|
+
const piped = segments[index + 1] !== undefined && feedsFileOperandConsumer(segments[index + 1], assignments);
|
|
2473
|
+
pending.push({ segment: segments[index], depth, piped });
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
enqueue(command, 0);
|
|
2477
|
+
|
|
2478
|
+
while (pending.length > 0) {
|
|
2479
|
+
const { segment, depth, piped } = pending.shift();
|
|
2480
|
+
// The same single stream the exec policy and the path candidates read.
|
|
2481
|
+
const { words, text, tokens, braceText, evaluable } = expandedSegment(segment, assignments);
|
|
2482
|
+
const expandedWords = words;
|
|
2483
|
+
rememberLeadingShellAssignments(words, assignments);
|
|
2484
|
+
const commandName = commandBasename(stripWrapper(words)[0] ?? "");
|
|
2485
|
+
// A redirection target is a file the shell creates whatever the command is,
|
|
2486
|
+
// so it is read before the data-only skip below.
|
|
2487
|
+
for (const word of extractAttachedRedirectionPaths(segment).flatMap(redirectionTargetWords)) {
|
|
2488
|
+
const expanded = expandKnownShellVariables(word.value, assignments);
|
|
2489
|
+
const basename = expanded.slice(expanded.lastIndexOf("/") + 1);
|
|
2490
|
+
// A redirection target is a file every time, so an expansion anywhere in
|
|
2491
|
+
// it is enough -- there is no reading under which `> $OUT` is a message
|
|
2492
|
+
// rather than a path. Command substitutions count here even though they
|
|
2493
|
+
// do not as operands: whatever the inner command is classified as, its
|
|
2494
|
+
// output still becomes a filename that gets truncated.
|
|
2495
|
+
if (assemblesFilename(basename) || /\$\{|\$\(|`|\$[A-Za-z_]/.test(expanded)) {
|
|
2496
|
+
unresolved.push(word.value);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
// `echo ${a[@]}` prints a name, it does not open one -- unless something
|
|
2500
|
+
// downstream opens what it printed.
|
|
2501
|
+
if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0 || piped) {
|
|
2502
|
+
const argumentTokens = executableArgumentTokens(tokens, words, commandName);
|
|
2503
|
+
for (let index = 0; index < argumentTokens.length; index += 1) {
|
|
2504
|
+
const token = argumentTokens[index];
|
|
2505
|
+
if (!token.variableActive) continue;
|
|
2506
|
+
if (token.value.startsWith("-")) continue;
|
|
2507
|
+
// `grep`'s first bare operand is the pattern, so the command does not
|
|
2508
|
+
// open files by name -- but the word after `-f` does, and
|
|
2509
|
+
// `grep -f ${X:+.env} README.md` reads a protected file with the
|
|
2510
|
+
// command sitting outside every list that says so.
|
|
2511
|
+
const namedByFlag = SEARCH_COMMANDS.has(commandName)
|
|
2512
|
+
&& SEARCH_FILE_OPTIONS.has(argumentTokens[index - 1]?.value ?? "");
|
|
2513
|
+
// `dd if=.en$(echo v)` hides a filename in an operand value. Reading the
|
|
2514
|
+
// key as literal text would refuse every `TAG=build$(date +%s)` too, so
|
|
2515
|
+
// the value is taken on its own and only when it is shaped like a path.
|
|
2516
|
+
const operandValue = operandValuePath(token.value);
|
|
2517
|
+
const word = operandValue ?? token.value;
|
|
2518
|
+
if (operandValue !== undefined
|
|
2519
|
+
&& !operandValue.includes("/")
|
|
2520
|
+
&& !operandValue.startsWith(".")) continue;
|
|
2521
|
+
if (word.startsWith("-")) continue;
|
|
2522
|
+
const expanded = expandKnownShellVariables(word, assignments);
|
|
2523
|
+
const basename = expanded.slice(expanded.lastIndexOf("/") + 1);
|
|
2524
|
+
// A name assembled out of pieces is a filename wherever it appears. A
|
|
2525
|
+
// word that is *nothing but* one parameter expansion is a filename only
|
|
2526
|
+
// where the command says so -- and there it is refused too, because the
|
|
2527
|
+
// whole point of this net is that a form nobody has modelled yet should
|
|
2528
|
+
// cost a question rather than a miss. Widening it to every command would
|
|
2529
|
+
// ask about `git commit -m $MSG`. Words printed into an `xargs cat` are
|
|
2530
|
+
// operands of that `cat`, whatever printed them. And a name nobody can
|
|
2531
|
+
// evaluate could be a command that opens files: `${X:+cat} ${X:+.env}`
|
|
2532
|
+
// reads a protected file whenever the variable is set, and both halves
|
|
2533
|
+
// read as words naming nothing. This only fires when the operand is
|
|
2534
|
+
// unresolved too, so a named command with a literal operand is untouched.
|
|
2535
|
+
const opensFiles = FILE_OPERAND_COMMANDS.has(commandName)
|
|
2536
|
+
|| piped
|
|
2537
|
+
|| namedByFlag
|
|
2538
|
+
|| commandNameIsDynamic(commandName);
|
|
2539
|
+
const bare = opensFiles && holdsUnresolvedParameter(basename);
|
|
2540
|
+
if (assemblesFilename(basename) || bare) unresolved.push(token.value);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
if (depth < MAX_NESTED_DEPTH) {
|
|
2544
|
+
for (const nestedCommand of extractNestedCommands(braceText, expandedWords)) {
|
|
2545
|
+
enqueue(nestedCommand, depth + 1);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
return unresolved;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
1064
2552
|
export function findProtectedPathInCommand(command, protectedPatterns) {
|
|
1065
2553
|
for (const candidate of extractShellPathCandidates(command)) {
|
|
1066
2554
|
const pattern = matchesProtectedPath(candidate, protectedPatterns);
|