specpi 0.10.0
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 +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,1426 @@
|
|
|
1
|
+
import { redactCommand } from "./redact.mjs";
|
|
2
|
+
import { analyze as analyzeCmd, literalCmdPayload } from "./cmd.mjs";
|
|
3
|
+
import { analyzeHostArguments, HOST_NAMES as POWERSHELL_HOSTS } from "./powershell.mjs";
|
|
4
|
+
|
|
5
|
+
export const BASH_LIMITS = Object.freeze({ maxInput: 128 * 1024, maxTokens: 4096, maxLeaves: 128, maxDepth: 8 });
|
|
6
|
+
const separators = new Set([";", "&&", "||", "|", "&", "(", ")"]);
|
|
7
|
+
const shells = new Set(["bash", "sh", "zsh", "dash", "ksh", "fish"]);
|
|
8
|
+
const safeSubstitutionConsumers = new Set([
|
|
9
|
+
"echo",
|
|
10
|
+
"printf",
|
|
11
|
+
"pwd",
|
|
12
|
+
"date",
|
|
13
|
+
"cat",
|
|
14
|
+
"head",
|
|
15
|
+
"tail",
|
|
16
|
+
"grep",
|
|
17
|
+
"rg",
|
|
18
|
+
"wc",
|
|
19
|
+
"sort",
|
|
20
|
+
"uniq",
|
|
21
|
+
"cut",
|
|
22
|
+
"tr",
|
|
23
|
+
"basename",
|
|
24
|
+
"dirname",
|
|
25
|
+
]);
|
|
26
|
+
const safeExpansionConsumers = new Set([
|
|
27
|
+
"echo",
|
|
28
|
+
"printf",
|
|
29
|
+
"pwd",
|
|
30
|
+
"date",
|
|
31
|
+
"cat",
|
|
32
|
+
"head",
|
|
33
|
+
"tail",
|
|
34
|
+
"grep",
|
|
35
|
+
"rg",
|
|
36
|
+
"wc",
|
|
37
|
+
"sort",
|
|
38
|
+
"uniq",
|
|
39
|
+
"cut",
|
|
40
|
+
"tr",
|
|
41
|
+
"basename",
|
|
42
|
+
"dirname",
|
|
43
|
+
]);
|
|
44
|
+
// Runners whose -c argument is a shell command string rather than an argv vector.
|
|
45
|
+
const commandStringRunners = new Set([...shells, "su", "runuser", "script"]);
|
|
46
|
+
// Runners that prefix an argv vector. Every entry must also be resolvable by wrapperCommandIndex.
|
|
47
|
+
const wrappers = new Set([
|
|
48
|
+
"sudo",
|
|
49
|
+
"doas",
|
|
50
|
+
"env",
|
|
51
|
+
"command",
|
|
52
|
+
"exec",
|
|
53
|
+
"nohup",
|
|
54
|
+
"nice",
|
|
55
|
+
"time",
|
|
56
|
+
"timeout",
|
|
57
|
+
"chroot",
|
|
58
|
+
"busybox",
|
|
59
|
+
"toybox",
|
|
60
|
+
"setsid",
|
|
61
|
+
"setarch",
|
|
62
|
+
"stdbuf",
|
|
63
|
+
"ionice",
|
|
64
|
+
"taskset",
|
|
65
|
+
"flock",
|
|
66
|
+
"systemd-run",
|
|
67
|
+
"unbuffer",
|
|
68
|
+
"runuser",
|
|
69
|
+
"xvfb-run",
|
|
70
|
+
"proxychains",
|
|
71
|
+
"proxychains4",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
function heredocOpeners(line) {
|
|
75
|
+
const found = [];
|
|
76
|
+
let quote = "",
|
|
77
|
+
escaped = false,
|
|
78
|
+
comment = false;
|
|
79
|
+
for (let index = 0; index < line.length - 1; index += 1) {
|
|
80
|
+
const character = line[index];
|
|
81
|
+
if (comment) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (escaped) {
|
|
86
|
+
escaped = false;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (character === "\\" && quote !== "'") {
|
|
91
|
+
escaped = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (quote) {
|
|
96
|
+
if (character === quote) {
|
|
97
|
+
quote = "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (character === "'" || character === '"') {
|
|
104
|
+
quote = character;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (character === "#" && (index === 0 || /[\s;|&()]/.test(line[index - 1]))) {
|
|
109
|
+
comment = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (character !== "<" || line[index + 1] !== "<" || line[index + 2] === "<") {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
index += 2;
|
|
118
|
+
let stripTabs = false;
|
|
119
|
+
if (line[index] === "-") {
|
|
120
|
+
stripTabs = true;
|
|
121
|
+
index += 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
while (line[index] === " " || line[index] === "\t") {
|
|
125
|
+
index += 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let delimiter = "",
|
|
129
|
+
wordQuote = "",
|
|
130
|
+
wordEscaped = false,
|
|
131
|
+
ansiSingle = false,
|
|
132
|
+
unsupported = false;
|
|
133
|
+
for (; index < line.length; index += 1) {
|
|
134
|
+
const value = line[index];
|
|
135
|
+
if (wordEscaped) {
|
|
136
|
+
delimiter += value;
|
|
137
|
+
wordEscaped = false;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (value === "$" && !wordQuote && (line[index + 1] === "'" || line[index + 1] === '"')) {
|
|
142
|
+
wordQuote = line[index + 1];
|
|
143
|
+
ansiSingle = wordQuote === "'";
|
|
144
|
+
unsupported ||= wordQuote === '"';
|
|
145
|
+
index += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if ((value === "$" || value === "`") && !wordQuote) {
|
|
150
|
+
unsupported = true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (value === "\\" && ansiSingle) {
|
|
154
|
+
unsupported = true;
|
|
155
|
+
delimiter += value;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (value === "\\" && wordQuote !== "'") {
|
|
160
|
+
wordEscaped = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (wordQuote) {
|
|
165
|
+
if (value === wordQuote) {
|
|
166
|
+
wordQuote = "";
|
|
167
|
+
ansiSingle = false;
|
|
168
|
+
} else {
|
|
169
|
+
delimiter += value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (value === "'" || value === '"') {
|
|
176
|
+
wordQuote = value;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (/[\s;|&()<>]/.test(value)) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
delimiter += value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (delimiter) {
|
|
188
|
+
found.push({ delimiter, stripTabs, unsupported });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function literalHeredocBodies(input) {
|
|
196
|
+
const lines = String(input).split(/(?<=\n)/),
|
|
197
|
+
pending = [],
|
|
198
|
+
bodies = [];
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
if (pending.length) {
|
|
201
|
+
let candidate = line.replace(/[\r\n]+$/, "");
|
|
202
|
+
if (pending[0].stripTabs) {
|
|
203
|
+
candidate = candidate.replace(/^\t+/, "");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (candidate === pending[0].delimiter) {
|
|
207
|
+
bodies.push({ body: pending[0].body, unsupported: pending[0].unsupported });
|
|
208
|
+
pending.shift();
|
|
209
|
+
} else {
|
|
210
|
+
pending[0].body += line;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
pending.push(...heredocOpeners(line).map((entry) => ({ ...entry, body: "" })));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { bodies, complete: pending.length === 0 };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function stripHeredocBodies(input) {
|
|
223
|
+
const lines = String(input).split(/(?<=\n)/),
|
|
224
|
+
pending = [],
|
|
225
|
+
output = [];
|
|
226
|
+
for (const line of lines) {
|
|
227
|
+
if (pending.length) {
|
|
228
|
+
let candidate = line.replace(/[\r\n]+$/, "");
|
|
229
|
+
if (pending[0].stripTabs) {
|
|
230
|
+
candidate = candidate.replace(/^\t+/, "");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (candidate === pending[0].delimiter) {
|
|
234
|
+
pending.shift();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
output.push(line.replace(/[^\r\n]/g, " "));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
output.push(line);
|
|
242
|
+
pending.push(...heredocOpeners(line));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return output.join("");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function containsUnquotedForkBomb(input) {
|
|
249
|
+
const command = stripHeredocBodies(input);
|
|
250
|
+
let visible = "",
|
|
251
|
+
quote = "",
|
|
252
|
+
escaped = false,
|
|
253
|
+
comment = false;
|
|
254
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
255
|
+
const character = command[index];
|
|
256
|
+
if (comment) {
|
|
257
|
+
if (character === "\n" || character === "\r") {
|
|
258
|
+
comment = false;
|
|
259
|
+
visible += character;
|
|
260
|
+
} else {
|
|
261
|
+
visible += " ";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (escaped) {
|
|
268
|
+
visible += " ";
|
|
269
|
+
escaped = false;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (character === "\\" && quote !== "'") {
|
|
274
|
+
escaped = true;
|
|
275
|
+
visible += " ";
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (quote) {
|
|
280
|
+
if (character === quote) {
|
|
281
|
+
quote = "";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
visible += " ";
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (character === "'" || character === '"') {
|
|
289
|
+
quote = character;
|
|
290
|
+
visible += " ";
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (character === "#" && (index === 0 || /[\s;|&()]/.test(command[index - 1]))) {
|
|
295
|
+
comment = true;
|
|
296
|
+
visible += " ";
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
visible += character;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const definitions = /(?:^|[\s;])(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*|:)\s*(?:\(\s*\))?\s*\{([^}]*)\}/g;
|
|
304
|
+
let definition;
|
|
305
|
+
while ((definition = definitions.exec(visible))) {
|
|
306
|
+
const escaped = definition[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
307
|
+
const selfBackground = new RegExp(
|
|
308
|
+
`(?:^|[\\s;|&()])${escaped}(?:\\s*\\|\\s*${escaped})?\\s*&(?:[\\s;|&()]|$)`,
|
|
309
|
+
).test(definition[2]);
|
|
310
|
+
const invoked = new RegExp(`(?:^|[\\s;|&()])${escaped}(?:[\\s;|&()]|$)`).test(
|
|
311
|
+
visible.slice(definitions.lastIndex),
|
|
312
|
+
);
|
|
313
|
+
if (selfBackground && invoked) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function token(text, kind = "word", raw = text) {
|
|
322
|
+
return { text, kind, raw };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function scan(input, limits) {
|
|
326
|
+
const tokens = [],
|
|
327
|
+
redirects = [],
|
|
328
|
+
dynamicConstructs = [],
|
|
329
|
+
errors = [];
|
|
330
|
+
let word = "",
|
|
331
|
+
rawWord = "",
|
|
332
|
+
quote = "",
|
|
333
|
+
escaped = false,
|
|
334
|
+
comment = false,
|
|
335
|
+
heredoc = false;
|
|
336
|
+
const flush = () => {
|
|
337
|
+
if (word) {
|
|
338
|
+
tokens.push(token(word, "word", rawWord || word));
|
|
339
|
+
word = "";
|
|
340
|
+
rawWord = "";
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const push = (text, kind) => {
|
|
345
|
+
flush();
|
|
346
|
+
tokens.push(token(text, kind));
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
350
|
+
const c = input[i],
|
|
351
|
+
n = input[i + 1];
|
|
352
|
+
if (comment) {
|
|
353
|
+
if (c === "\n" || c === "\r") {
|
|
354
|
+
comment = false;
|
|
355
|
+
} else {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (escaped) {
|
|
361
|
+
word += c;
|
|
362
|
+
rawWord += c;
|
|
363
|
+
escaped = false;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (c === "\\" && quote !== "'") {
|
|
368
|
+
if (n === "\n") {
|
|
369
|
+
i += 1;
|
|
370
|
+
} else if (n === "\r" && input[i + 2] === "\n") {
|
|
371
|
+
i += 2;
|
|
372
|
+
} else {
|
|
373
|
+
rawWord += c;
|
|
374
|
+
if (quote === '"' && !/[\\$`"]/.test(n || "")) {
|
|
375
|
+
word += "\\";
|
|
376
|
+
} else {
|
|
377
|
+
escaped = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (quote) {
|
|
385
|
+
rawWord += c;
|
|
386
|
+
if (c === quote) {
|
|
387
|
+
quote = "";
|
|
388
|
+
} else {
|
|
389
|
+
if (quote === '"' && (c === "`" || (c === "$" && n === "("))) {
|
|
390
|
+
dynamicConstructs.push({ kind: "quoted-substitution", start: i, end: i + 1 });
|
|
391
|
+
} else if (quote === '"' && c === "$" && (n === "{" || /[A-Za-z_0-9@*#?$!-]/.test(n || ""))) {
|
|
392
|
+
dynamicConstructs.push({ kind: "quoted-expansion", start: i, end: i + 1 });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
word += c;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (c === "'" || c === '"') {
|
|
402
|
+
quote = c;
|
|
403
|
+
rawWord += c;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (c === "#" && !word && (i === 0 || /[\s;|&()]/.test(input[i - 1]))) {
|
|
408
|
+
comment = true;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (c === "\r" || c === "\n") {
|
|
413
|
+
if (c === "\r" && n === "\n") {
|
|
414
|
+
i += 1;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
push(";", "separator");
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (/\s/.test(c)) {
|
|
422
|
+
flush();
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if ((c === "<" || c === ">") && n === "(") {
|
|
427
|
+
dynamicConstructs.push({ kind: "process-substitution", start: i, end: i + 1 });
|
|
428
|
+
word += `${c}(`;
|
|
429
|
+
rawWord += `${c}(`;
|
|
430
|
+
i += 1;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (c === "<" || c === ">") {
|
|
435
|
+
flush();
|
|
436
|
+
let op = c;
|
|
437
|
+
if (n === c || n === "&" || n === "|") {
|
|
438
|
+
op += n;
|
|
439
|
+
i += 1;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (/^[0-9]$/.test(input[i - 1] || "") && !word) {
|
|
443
|
+
op = `${input[i - 1]}${op}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
tokens.push(token(op, "redirect"));
|
|
447
|
+
redirects.push(op);
|
|
448
|
+
if (op.includes("<<")) {
|
|
449
|
+
dynamicConstructs.push({ kind: "heredoc" });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (c === ";" || c === "(" || c === ")" || c === "&" || c === "|") {
|
|
456
|
+
flush();
|
|
457
|
+
let op = c;
|
|
458
|
+
if ((c === "&" || c === "|") && n === c) {
|
|
459
|
+
op += n;
|
|
460
|
+
i += 1;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
push(op, "separator");
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (c === "`" || (c === "$" && n === "(")) {
|
|
468
|
+
dynamicConstructs.push({ kind: "command-substitution", start: i, end: i + 1 });
|
|
469
|
+
word += c;
|
|
470
|
+
rawWord += c;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (c === "$" && n === "{") {
|
|
475
|
+
dynamicConstructs.push({ kind: "environment-expansion", start: i, end: i + 1 });
|
|
476
|
+
word += c;
|
|
477
|
+
rawWord += c;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (c === "$" && /[A-Za-z_0-9@*#?$!-]/.test(n || "")) {
|
|
482
|
+
dynamicConstructs.push({ kind: "environment-expansion", start: i, end: i + 1 });
|
|
483
|
+
word += c;
|
|
484
|
+
rawWord += c;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
word += c;
|
|
489
|
+
rawWord += c;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
flush();
|
|
493
|
+
if (quote || escaped) {
|
|
494
|
+
errors.push("unterminated quote or escape");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (tokens.length > limits.maxTokens) {
|
|
498
|
+
errors.push("token limit exceeded");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { tokens, redirects, dynamicConstructs, parseErrors: errors };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Shell reserved words are STRUCTURE, not programs. They were being taken as the leaf executable, so in
|
|
505
|
+
// `if true; then rm -rf /; fi` the real command survived only as an argument list hanging off a leaf named
|
|
506
|
+
// `then` — and every rule keys on the executable, so the delete was never classified at all.
|
|
507
|
+
// Stripping them in command position puts the actual program back in command position.
|
|
508
|
+
const structuralKeywords = new Set([
|
|
509
|
+
"!",
|
|
510
|
+
"then",
|
|
511
|
+
"else",
|
|
512
|
+
"elif",
|
|
513
|
+
"do",
|
|
514
|
+
"time",
|
|
515
|
+
"{",
|
|
516
|
+
"}",
|
|
517
|
+
"(",
|
|
518
|
+
")",
|
|
519
|
+
"coproc",
|
|
520
|
+
"builtin",
|
|
521
|
+
"command",
|
|
522
|
+
"nocorrect",
|
|
523
|
+
"if",
|
|
524
|
+
"while",
|
|
525
|
+
"until",
|
|
526
|
+
]);
|
|
527
|
+
// Words that only ever close a block, and loop/case headers whose remainder is a word list rather than a command.
|
|
528
|
+
const terminatorKeywords = new Set(["fi", "done", "esac", "in", "]]", "[["]);
|
|
529
|
+
const headerKeywords = new Set(["for", "select", "case", "function"]);
|
|
530
|
+
function leafFrom(words) {
|
|
531
|
+
if (!words.length) {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let start = 0;
|
|
536
|
+
while (start < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[start].text)) {
|
|
537
|
+
start += 1;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// A quoted word is a literal filename, never a reserved word, so `raw` must still match the bare text.
|
|
541
|
+
const bare = (word) => word && (word.raw ?? word.text) === word.text;
|
|
542
|
+
// `if`/`while`/`until` are stripped too: what follows them is the condition, which is itself a real command.
|
|
543
|
+
while (start < words.length && bare(words[start]) && structuralKeywords.has(words[start].text)) {
|
|
544
|
+
start += 1;
|
|
545
|
+
while (start < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[start].text)) {
|
|
546
|
+
start += 1;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const head = words[start];
|
|
551
|
+
if (head && bare(head) && (terminatorKeywords.has(head.text) || headerKeywords.has(head.text))) {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const commandWords = words.slice(start);
|
|
556
|
+
const first = commandWords[0];
|
|
557
|
+
if (!first) {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const executable = first.text;
|
|
562
|
+
// Whitespace in command position means the token was never resolved to one program: `wrapper 'rm -rf /etc'`
|
|
563
|
+
// is either a filename containing spaces or a command string the runner hands to a shell. Stay indeterminate
|
|
564
|
+
// instead of letting normalized() reduce it to a harmless-looking trailing segment.
|
|
565
|
+
const dynamic = /[$`*?{}]|\s/.test(executable) || executable.startsWith("-");
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
shell: "bash",
|
|
569
|
+
executable,
|
|
570
|
+
operation: commandWords
|
|
571
|
+
.slice(1)
|
|
572
|
+
.map((x) => x.text)
|
|
573
|
+
.join(" "),
|
|
574
|
+
args: commandWords.slice(1).map((x) => x.text),
|
|
575
|
+
rawArgs: commandWords.slice(1).map((x) => x.raw ?? x.text),
|
|
576
|
+
redactedTarget: redactCommand(
|
|
577
|
+
commandWords
|
|
578
|
+
.slice(1)
|
|
579
|
+
.map((x) => x.text)
|
|
580
|
+
.join(" "),
|
|
581
|
+
),
|
|
582
|
+
dynamic,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function makeLeaves(tokens, limits) {
|
|
587
|
+
const leaves = [],
|
|
588
|
+
redirects = [],
|
|
589
|
+
dynamicConstructs = [];
|
|
590
|
+
let current = [],
|
|
591
|
+
pendingRedirect,
|
|
592
|
+
pendingSeparator,
|
|
593
|
+
commandId = 0;
|
|
594
|
+
const flush = () => {
|
|
595
|
+
const leaf = leafFrom(current);
|
|
596
|
+
if (leaf) {
|
|
597
|
+
leaf.separatorBefore = pendingSeparator;
|
|
598
|
+
leaf.commandId = commandId;
|
|
599
|
+
leaves.push(leaf);
|
|
600
|
+
pendingSeparator = undefined;
|
|
601
|
+
commandId += 1;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
current = [];
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
for (const item of tokens) {
|
|
608
|
+
if (item.kind === "separator") {
|
|
609
|
+
flush();
|
|
610
|
+
pendingSeparator = item.text;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (item.kind === "redirect") {
|
|
615
|
+
pendingRedirect = item.text;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (pendingRedirect) {
|
|
620
|
+
const detail = {
|
|
621
|
+
operator: pendingRedirect,
|
|
622
|
+
target: item.text,
|
|
623
|
+
separatorBefore: pendingSeparator,
|
|
624
|
+
commandId,
|
|
625
|
+
};
|
|
626
|
+
redirects.push(detail);
|
|
627
|
+
leaves.push({
|
|
628
|
+
shell: "bash",
|
|
629
|
+
executable: "<redirect>",
|
|
630
|
+
operation: `${pendingRedirect} ${item.text}`,
|
|
631
|
+
args: [item.text],
|
|
632
|
+
redactedTarget: redactCommand(item.text),
|
|
633
|
+
redirect: pendingRedirect,
|
|
634
|
+
separatorBefore: pendingSeparator,
|
|
635
|
+
commandId,
|
|
636
|
+
});
|
|
637
|
+
pendingRedirect = undefined;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
current.push(item);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
flush();
|
|
645
|
+
if (pendingRedirect) {
|
|
646
|
+
dynamicConstructs.push({ kind: "missing-redirect-target" });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (leaves.length > limits.maxLeaves) {
|
|
650
|
+
dynamicConstructs.push({ kind: "leaf-limit" });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return { leaves: leaves.slice(0, limits.maxLeaves), redirects, dynamicConstructs };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const wrapperValueOptions = new Map([
|
|
657
|
+
["exec", ["-a"]],
|
|
658
|
+
[
|
|
659
|
+
"sudo",
|
|
660
|
+
[
|
|
661
|
+
"-u",
|
|
662
|
+
"--user",
|
|
663
|
+
"-g",
|
|
664
|
+
"--group",
|
|
665
|
+
"-h",
|
|
666
|
+
"--host",
|
|
667
|
+
"-p",
|
|
668
|
+
"--prompt",
|
|
669
|
+
"-c",
|
|
670
|
+
"--close-from",
|
|
671
|
+
"-t",
|
|
672
|
+
"--command-timeout",
|
|
673
|
+
"-d",
|
|
674
|
+
"--chdir",
|
|
675
|
+
"-r",
|
|
676
|
+
"--chroot",
|
|
677
|
+
"--role",
|
|
678
|
+
"--type",
|
|
679
|
+
],
|
|
680
|
+
],
|
|
681
|
+
["doas", ["-u", "-c"]],
|
|
682
|
+
["env", ["-u", "--unset", "-c", "--chdir", "--argv0", "-s", "--split-string"]],
|
|
683
|
+
["nice", ["-n", "--adjustment"]],
|
|
684
|
+
["time", ["-f", "--format", "-o", "--output"]],
|
|
685
|
+
["timeout", ["-s", "--signal", "-k", "--kill-after"]],
|
|
686
|
+
["chroot", ["--userspec", "--groups"]],
|
|
687
|
+
[
|
|
688
|
+
"xargs",
|
|
689
|
+
[
|
|
690
|
+
"-E",
|
|
691
|
+
"-I",
|
|
692
|
+
"-L",
|
|
693
|
+
"-n",
|
|
694
|
+
"-P",
|
|
695
|
+
"-s",
|
|
696
|
+
"--eof",
|
|
697
|
+
"--replace",
|
|
698
|
+
"--max-lines",
|
|
699
|
+
"--max-args",
|
|
700
|
+
"--max-procs",
|
|
701
|
+
"--max-chars",
|
|
702
|
+
"--arg-file",
|
|
703
|
+
],
|
|
704
|
+
],
|
|
705
|
+
["stdbuf", ["-i", "-o", "-e", "--input", "--output", "--error"]],
|
|
706
|
+
["ionice", ["-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"]],
|
|
707
|
+
["watch", ["-n", "--interval", "--chgexit"]],
|
|
708
|
+
["flock", ["-w", "--wait", "--timeout", "-E", "--conflict-exit-code"]],
|
|
709
|
+
[
|
|
710
|
+
"runuser",
|
|
711
|
+
["-u", "--user", "-g", "--group", "-G", "--supp-group", "-s", "--shell", "-w", "--whitelist-environment"],
|
|
712
|
+
],
|
|
713
|
+
[
|
|
714
|
+
"xvfb-run",
|
|
715
|
+
[
|
|
716
|
+
"-n",
|
|
717
|
+
"--server-num",
|
|
718
|
+
"-s",
|
|
719
|
+
"--server-args",
|
|
720
|
+
"-e",
|
|
721
|
+
"--error-file",
|
|
722
|
+
"-f",
|
|
723
|
+
"--auth-file",
|
|
724
|
+
"-p",
|
|
725
|
+
"--xauth-protocol",
|
|
726
|
+
],
|
|
727
|
+
],
|
|
728
|
+
["proxychains", ["-f"]],
|
|
729
|
+
["proxychains4", ["-f"]],
|
|
730
|
+
[
|
|
731
|
+
"systemd-run",
|
|
732
|
+
[
|
|
733
|
+
"-p",
|
|
734
|
+
"--property",
|
|
735
|
+
"-u",
|
|
736
|
+
"--unit",
|
|
737
|
+
"--description",
|
|
738
|
+
"--slice",
|
|
739
|
+
"--uid",
|
|
740
|
+
"--gid",
|
|
741
|
+
"--nice",
|
|
742
|
+
"--setenv",
|
|
743
|
+
"-E",
|
|
744
|
+
"-M",
|
|
745
|
+
"--machine",
|
|
746
|
+
"-H",
|
|
747
|
+
"--host",
|
|
748
|
+
"--working-directory",
|
|
749
|
+
"--service-type",
|
|
750
|
+
"--timer-property",
|
|
751
|
+
"--on-active",
|
|
752
|
+
"--on-boot",
|
|
753
|
+
"--on-startup",
|
|
754
|
+
"--on-unit-active",
|
|
755
|
+
"--on-unit-inactive",
|
|
756
|
+
"--on-calendar",
|
|
757
|
+
],
|
|
758
|
+
],
|
|
759
|
+
]);
|
|
760
|
+
// Runners whose first positional argument is a parameter — duration, new root, CPU mask, lock file, architecture — not the command.
|
|
761
|
+
const wrapperPositionalParameter = new Set(["timeout", "chroot", "taskset", "flock", "setarch"]);
|
|
762
|
+
function wrapperCommandIndex(name, args) {
|
|
763
|
+
// taskset keeps -c out of the value list so its CPU list is consumed as the positional parameter instead.
|
|
764
|
+
const takesValue = new Set((wrapperValueOptions.get(name) || []).map((option) => option.toLowerCase()));
|
|
765
|
+
let skipPositional = wrapperPositionalParameter.has(name);
|
|
766
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
767
|
+
const arg = String(args[index]);
|
|
768
|
+
if (name === "env" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (arg === "--") {
|
|
773
|
+
return index + 1 < args.length ? index + 1 : -1;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const option = arg.toLowerCase();
|
|
777
|
+
if (takesValue.has(option)) {
|
|
778
|
+
index += 1;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (
|
|
783
|
+
[...takesValue].some((entry) =>
|
|
784
|
+
entry.startsWith("--")
|
|
785
|
+
? option.startsWith(`${entry}=`)
|
|
786
|
+
: option.startsWith(entry) && option.length > entry.length,
|
|
787
|
+
)
|
|
788
|
+
) {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (arg.startsWith("-") && arg !== "-") {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (skipPositional) {
|
|
797
|
+
skipPositional = false;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return index;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return -1;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const stdinCodeInterpreters = new Set([
|
|
808
|
+
"powershell",
|
|
809
|
+
"pwsh",
|
|
810
|
+
"python",
|
|
811
|
+
"python3",
|
|
812
|
+
"py",
|
|
813
|
+
"node",
|
|
814
|
+
"deno",
|
|
815
|
+
"bun",
|
|
816
|
+
"ruby",
|
|
817
|
+
"perl",
|
|
818
|
+
"php",
|
|
819
|
+
"lua",
|
|
820
|
+
"rscript",
|
|
821
|
+
"xargs",
|
|
822
|
+
"source",
|
|
823
|
+
".",
|
|
824
|
+
]);
|
|
825
|
+
function heredocConsumerKind(leaf) {
|
|
826
|
+
let current = leaf;
|
|
827
|
+
for (let depth = 0; current && depth < 8; depth += 1) {
|
|
828
|
+
const name = current.executable
|
|
829
|
+
.toLowerCase()
|
|
830
|
+
.replace(/\.exe$/, "")
|
|
831
|
+
.split(/[\\/]/)
|
|
832
|
+
.pop();
|
|
833
|
+
// `su`/`runuser` with no -c start a shell that reads stdin as commands, exactly like a bare `sh`.
|
|
834
|
+
if (shells.has(name) || name === "su" || name === "runuser") {
|
|
835
|
+
const flag = (current.args || []).findIndex((arg) => /^-[a-z]*c[a-z]*$/i.test(String(arg)));
|
|
836
|
+
if (flag >= 0) {
|
|
837
|
+
// `bash -c 'sh'` runs `sh`, and THAT process still inherits the heredoc on stdin. Treating the
|
|
838
|
+
// body as inert data on the mere presence of -c let `bash -c 'sh' <<EOF … EOF` execute it
|
|
839
|
+
// completely unanalyzed. Resolve what -c actually launches and ask again.
|
|
840
|
+
const payload = current.args[flag + 1];
|
|
841
|
+
if (typeof payload !== "string" || !payload || /[$`]/.test(payload)) {
|
|
842
|
+
return "unknown";
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const inner = leafFrom(
|
|
846
|
+
payload
|
|
847
|
+
.trim()
|
|
848
|
+
.split(/\s+/)
|
|
849
|
+
.map((text) => token(text)),
|
|
850
|
+
);
|
|
851
|
+
if (!inner) {
|
|
852
|
+
return "unknown";
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
current = inner;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// For su/runuser the positional argument is the target USER, not a script, so stdin is still the
|
|
860
|
+
// command source: `su root <<EOF … EOF` runs the body as root.
|
|
861
|
+
if (name === "su" || name === "runuser") {
|
|
862
|
+
return "bash";
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const script = (current.args || []).find((arg) => !String(arg).startsWith("-"));
|
|
866
|
+
|
|
867
|
+
return script ? "data" : "bash";
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (name === "cmd") {
|
|
871
|
+
return "cmd";
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (stdinCodeInterpreters.has(name)) {
|
|
875
|
+
return "unknown";
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (!wrappers.has(name)) {
|
|
879
|
+
return "data";
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (name === "env") {
|
|
883
|
+
const splitIndex = (current.args || []).findIndex((arg) =>
|
|
884
|
+
/^(?:-[sS]|--split-string)(?:=|$)/.test(String(arg)),
|
|
885
|
+
);
|
|
886
|
+
if (splitIndex >= 0) {
|
|
887
|
+
const option = String(current.args[splitIndex]);
|
|
888
|
+
const payload = option.includes("=")
|
|
889
|
+
? option.slice(option.indexOf("=") + 1)
|
|
890
|
+
: current.args[splitIndex + 1];
|
|
891
|
+
if (typeof payload !== "string" || !payload || /[$`]/.test(payload)) {
|
|
892
|
+
return "unknown";
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
current = leafFrom(
|
|
896
|
+
payload
|
|
897
|
+
.trim()
|
|
898
|
+
.split(/\s+/)
|
|
899
|
+
.map((text) => token(text)),
|
|
900
|
+
);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const commandIndex = wrapperCommandIndex(name, current.args || []);
|
|
906
|
+
if (commandIndex < 0) {
|
|
907
|
+
return "data";
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
current = leafFrom(current.args.slice(commandIndex).map((text) => token(text)));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return "unknown";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function sedSubstitutionExecutes(script) {
|
|
917
|
+
const match = /^\s*s([^A-Za-z0-9\\\s])/.exec(script);
|
|
918
|
+
if (!match) {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const delimiter = match[1];
|
|
923
|
+
let escaped = false,
|
|
924
|
+
separators = 0;
|
|
925
|
+
for (let index = match[0].length; index < script.length; index += 1) {
|
|
926
|
+
const value = script[index];
|
|
927
|
+
if (escaped) {
|
|
928
|
+
escaped = false;
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (value === "\\") {
|
|
933
|
+
escaped = true;
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (value !== delimiter) {
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
separators += 1;
|
|
942
|
+
if (separators === 2) {
|
|
943
|
+
return /e/i.test(script.slice(index + 1).trim());
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function sedExecution(args) {
|
|
951
|
+
const payloads = [];
|
|
952
|
+
let dynamic = false;
|
|
953
|
+
for (const raw of args) {
|
|
954
|
+
const script = String(raw);
|
|
955
|
+
if (sedSubstitutionExecutes(script)) {
|
|
956
|
+
dynamic = true;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const expression = /(?:^|[;\n])\s*(?:(?:\d+|\$|\/[^/\n]*\/)\s*)?e(?:[ \t]+([^\n]*)|(?=\s*(?:[;\n]|$)))/g;
|
|
960
|
+
let match;
|
|
961
|
+
while ((match = expression.exec(script))) {
|
|
962
|
+
if (match[1]?.trim()) {
|
|
963
|
+
payloads.push(match[1].trim());
|
|
964
|
+
} else {
|
|
965
|
+
dynamic = true;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return { payloads, dynamic };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function addDecodedPipelines(leaves, options, dynamicConstructs, depth) {
|
|
974
|
+
if (depth >= options.maxDepth) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
for (let index = 0; index + 2 < leaves.length; index += 1) {
|
|
979
|
+
const source = leaves[index],
|
|
980
|
+
decoder = leaves[index + 1],
|
|
981
|
+
interpreter = leaves[index + 2];
|
|
982
|
+
if (decoder.separatorBefore !== "|" || interpreter.separatorBefore !== "|") {
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const sourceName = source.executable
|
|
987
|
+
.toLowerCase()
|
|
988
|
+
.replace(/\.exe$/, "")
|
|
989
|
+
.split(/[\\/]/)
|
|
990
|
+
.pop();
|
|
991
|
+
const decoderName = decoder.executable
|
|
992
|
+
.toLowerCase()
|
|
993
|
+
.replace(/\.exe$/, "")
|
|
994
|
+
.split(/[\\/]/)
|
|
995
|
+
.pop();
|
|
996
|
+
const interpreterName = interpreter.executable
|
|
997
|
+
.toLowerCase()
|
|
998
|
+
.replace(/\.exe$/, "")
|
|
999
|
+
.split(/[\\/]/)
|
|
1000
|
+
.pop();
|
|
1001
|
+
if (
|
|
1002
|
+
sourceName !== "printf" ||
|
|
1003
|
+
decoderName !== "base64" ||
|
|
1004
|
+
!decoder.args.some((arg) => /^(?:-d|--decode)$/i.test(arg)) ||
|
|
1005
|
+
!shells.has(interpreterName) ||
|
|
1006
|
+
interpreter.args.length
|
|
1007
|
+
) {
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (source.args.length !== 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(source.args[0])) {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
let decoded;
|
|
1016
|
+
try {
|
|
1017
|
+
const buffer = Buffer.from(source.args[0], "base64");
|
|
1018
|
+
if (
|
|
1019
|
+
buffer.toString("base64").replace(/=+$/, "") !== source.args[0].replace(/=+$/, "") ||
|
|
1020
|
+
buffer.includes(0)
|
|
1021
|
+
) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
decoded = buffer.toString("utf8");
|
|
1026
|
+
if (decoded.includes("\uFFFD") || Buffer.byteLength(decoded, "utf8") > options.maxInput) {
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
} catch {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
interpreter.nested = analyze(decoded, { ...options, depth: depth + 1 });
|
|
1034
|
+
interpreter.decodedInput = true;
|
|
1035
|
+
dynamicConstructs.push({ kind: "decoded-pipeline" }, ...interpreter.nested.dynamicConstructs);
|
|
1036
|
+
if (interpreter.nested.indeterminate) {
|
|
1037
|
+
dynamicConstructs.push({ kind: "dynamic-decoded-pipeline" });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function cmdEquivalentArgs(leaf) {
|
|
1043
|
+
return (leaf.rawArgs || leaf.args).map((rawValue, index) => {
|
|
1044
|
+
const cooked = String(leaf.args[index] ?? ""),
|
|
1045
|
+
raw = String(rawValue);
|
|
1046
|
+
if (cooked.includes('"')) {
|
|
1047
|
+
return "[dynamic]";
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (/^(["']).*\1$/s.test(raw)) {
|
|
1051
|
+
return `"${cooked}"`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const attached = /^\/(c|k)(["'])(.*)\2$/is.exec(raw);
|
|
1055
|
+
if (attached && /^\/(?:c|k)/i.test(cooked)) {
|
|
1056
|
+
return `${cooked.slice(0, 2)}"${cooked.slice(2)}"`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return raw;
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function addNested(leaves, options, dynamicConstructs, depth) {
|
|
1064
|
+
if (depth >= options.maxDepth) {
|
|
1065
|
+
dynamicConstructs.push({ kind: "depth-limit" });
|
|
1066
|
+
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// A nested analysis that could not be fully resolved must not be reported as a clean parse upstream.
|
|
1071
|
+
const adopt = (leaf, nested, kind) => {
|
|
1072
|
+
leaf.nested = nested;
|
|
1073
|
+
dynamicConstructs.push(...nested.dynamicConstructs);
|
|
1074
|
+
if (nested.indeterminate) {
|
|
1075
|
+
dynamicConstructs.push({ kind: `dynamic-${kind}` });
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
for (const leaf of [...leaves]) {
|
|
1080
|
+
const name = leaf.executable
|
|
1081
|
+
.toLowerCase()
|
|
1082
|
+
.replace(/\.exe$/, "")
|
|
1083
|
+
.split(/[\\/]/)
|
|
1084
|
+
.pop();
|
|
1085
|
+
const cmdPayload = name === "cmd" ? literalCmdPayload(leaf.args, cmdEquivalentArgs(leaf)) : undefined;
|
|
1086
|
+
if (
|
|
1087
|
+
(commandStringRunners.has(name) && leaf.args.some((arg) => /^-[a-z]*c[a-z]*$/i.test(arg))) ||
|
|
1088
|
+
(name === "cmd" && cmdPayload !== undefined)
|
|
1089
|
+
) {
|
|
1090
|
+
const flag = leaf.args.findIndex((arg) => /^-[a-z]*c[a-z]*$/i.test(arg));
|
|
1091
|
+
const nested = name === "cmd" ? cmdPayload : leaf.args[flag + 1];
|
|
1092
|
+
if (!nested || /[$`]/.test(nested)) {
|
|
1093
|
+
dynamicConstructs.push({ kind: "dynamic-nested-shell" });
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
adopt(
|
|
1098
|
+
leaf,
|
|
1099
|
+
name === "cmd"
|
|
1100
|
+
? analyzeCmd(nested, { ...options, depth: depth + 1 })
|
|
1101
|
+
: analyze(nested, { ...options, depth: depth + 1 }),
|
|
1102
|
+
"nested-shell",
|
|
1103
|
+
);
|
|
1104
|
+
// A command string is fully described by its nested analysis; never let a later branch replace it.
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// A PowerShell host invoked from Bash carries its payload in -Command or -EncodedCommand. Classify it
|
|
1109
|
+
// here; otherwise an encoded payload reaches the policy as one opaque base64 argument.
|
|
1110
|
+
if (POWERSHELL_HOSTS.includes(name)) {
|
|
1111
|
+
const host = analyzeHostArguments(leaf.args, { ...options, depth, shell: name });
|
|
1112
|
+
if (host) {
|
|
1113
|
+
if (host.unresolved) {
|
|
1114
|
+
dynamicConstructs.push({ kind: "dynamic-nested-powershell" });
|
|
1115
|
+
} else {
|
|
1116
|
+
adopt(leaf, host.nested, "nested-powershell");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const file = leaf.args.findIndex((arg) => /^-(?:f|fi|fil|file)$/i.test(String(arg)));
|
|
1123
|
+
const supportedFile = file >= 0 && /\.(?:ps1|psm1|psd1)$/i.test(String(leaf.args[file + 1] || ""));
|
|
1124
|
+
const informational = leaf.args.some((arg) => /^-(?:version|help|\?)$/i.test(String(arg)));
|
|
1125
|
+
const hostOnly =
|
|
1126
|
+
informational &&
|
|
1127
|
+
leaf.args.every((arg) => /^-(?:nologo|noprofile|noninteractive|version|help|\?)$/i.test(String(arg)));
|
|
1128
|
+
if (!supportedFile && !hostOnly) {
|
|
1129
|
+
dynamicConstructs.push({ kind: "dynamic-nested-powershell" });
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// watch joins its remaining arguments and hands them to a shell, so its tail is a command string in both
|
|
1136
|
+
// `watch rm -rf /etc` and `watch 'rm -rf /etc'` form.
|
|
1137
|
+
// `trap 'rm -rf /' EXIT` registers a command string that the shell runs later. It reached the analyzer as
|
|
1138
|
+
// an ordinary quoted argument to a command named `trap`, so the payload was never classified.
|
|
1139
|
+
if (name === "trap") {
|
|
1140
|
+
dynamicConstructs.push({ kind: "trap-wrapper" });
|
|
1141
|
+
const handler = (leaf.args || []).find((arg) => !String(arg).startsWith("-"));
|
|
1142
|
+
if (typeof handler !== "string" || !handler || /[$`]/.test(handler) || handler === "-") {
|
|
1143
|
+
if (handler !== "-" && handler !== "") {
|
|
1144
|
+
dynamicConstructs.push({ kind: "dynamic-trap" });
|
|
1145
|
+
}
|
|
1146
|
+
} else {
|
|
1147
|
+
adopt(leaf, analyze(handler, { ...options, depth: depth + 1 }), "trap");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (name === "eval" || name === "xargs" || name === "watch") {
|
|
1154
|
+
dynamicConstructs.push({ kind: `${name}-wrapper` });
|
|
1155
|
+
if (name === "xargs") {
|
|
1156
|
+
dynamicConstructs.push({ kind: "dynamic-xargs-input" });
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const start = name === "eval" ? 0 : wrapperCommandIndex(name, leaf.args || []);
|
|
1160
|
+
const payload = start >= 0 ? leaf.args.slice(start).join(" ") : "";
|
|
1161
|
+
if (!payload || /[$`]/.test(payload)) {
|
|
1162
|
+
dynamicConstructs.push({ kind: `dynamic-${name}` });
|
|
1163
|
+
} else {
|
|
1164
|
+
adopt(leaf, analyze(payload, { ...options, depth: depth + 1 }), name);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (wrappers.has(name)) {
|
|
1171
|
+
dynamicConstructs.push({ kind: `${name}-wrapper` });
|
|
1172
|
+
if (name === "env") {
|
|
1173
|
+
const splitIndex = (leaf.args || []).findIndex((arg) =>
|
|
1174
|
+
/^(?:-[sS]|--split-string)(?:=|$)/.test(String(arg)),
|
|
1175
|
+
);
|
|
1176
|
+
if (splitIndex >= 0) {
|
|
1177
|
+
const option = String(leaf.args[splitIndex]);
|
|
1178
|
+
const payload = option.includes("=")
|
|
1179
|
+
? option.slice(option.indexOf("=") + 1)
|
|
1180
|
+
: leaf.args[splitIndex + 1];
|
|
1181
|
+
if (!payload || /[$`]/.test(payload)) {
|
|
1182
|
+
dynamicConstructs.push({ kind: "dynamic-env-split-string" });
|
|
1183
|
+
} else {
|
|
1184
|
+
adopt(leaf, analyze(payload, { ...options, depth: depth + 1 }), "env-split-string");
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const commandIndex = wrapperCommandIndex(name, leaf.args || []);
|
|
1192
|
+
if (commandIndex < 0) {
|
|
1193
|
+
if (name !== "env") {
|
|
1194
|
+
dynamicConstructs.push({ kind: `dynamic-${name}` });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const childArgs = leaf.args.slice(commandIndex);
|
|
1201
|
+
const child = leafFrom(childArgs.map((text) => token(text)));
|
|
1202
|
+
if (!child) {
|
|
1203
|
+
dynamicConstructs.push({ kind: `dynamic-${name}` });
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
leaf.nested = {
|
|
1208
|
+
shell: "bash",
|
|
1209
|
+
leaves: [child],
|
|
1210
|
+
redirects: [],
|
|
1211
|
+
dynamicConstructs: [],
|
|
1212
|
+
parseErrors: [],
|
|
1213
|
+
indeterminate: child.dynamic,
|
|
1214
|
+
};
|
|
1215
|
+
addNested(leaf.nested.leaves, options, leaf.nested.dynamicConstructs, depth + 1);
|
|
1216
|
+
leaf.nested.indeterminate ||=
|
|
1217
|
+
leaf.nested.leaves.some((entry) => entry.dynamic) ||
|
|
1218
|
+
leaf.nested.dynamicConstructs.some((item) => /limit|dynamic/.test(item.kind));
|
|
1219
|
+
dynamicConstructs.push(...leaf.nested.dynamicConstructs);
|
|
1220
|
+
if (leaf.nested.indeterminate) {
|
|
1221
|
+
dynamicConstructs.push({ kind: `dynamic-${name}` });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (name === "sed") {
|
|
1226
|
+
const execution = sedExecution(leaf.args || []);
|
|
1227
|
+
if (execution.dynamic) {
|
|
1228
|
+
dynamicConstructs.push({ kind: "dynamic-sed-exec" });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (execution.payloads.length) {
|
|
1232
|
+
const payload = execution.payloads.join("; ");
|
|
1233
|
+
if (/[$`]/.test(payload)) {
|
|
1234
|
+
dynamicConstructs.push({ kind: "dynamic-sed-exec" });
|
|
1235
|
+
} else {
|
|
1236
|
+
adopt(leaf, analyze(payload, { ...options, depth: depth + 1 }), "sed-exec");
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const findExec = (arg) => arg === "-exec" || arg === "-execdir" || arg === "-ok" || arg === "-okdir";
|
|
1242
|
+
if (name === "find" && leaf.args.some((arg) => findExec(arg) || arg === "-delete")) {
|
|
1243
|
+
dynamicConstructs.push({ kind: "find-exec" });
|
|
1244
|
+
const execIndex = leaf.args.findIndex(findExec);
|
|
1245
|
+
if (execIndex >= 0) {
|
|
1246
|
+
const payload = leaf.args
|
|
1247
|
+
.slice(execIndex + 1)
|
|
1248
|
+
.filter((arg) => arg !== ";" && arg !== "+")
|
|
1249
|
+
.join(" ");
|
|
1250
|
+
if (!payload || /[$`{}]/.test(payload)) {
|
|
1251
|
+
dynamicConstructs.push({ kind: "dynamic-find-exec" });
|
|
1252
|
+
} else {
|
|
1253
|
+
adopt(leaf, analyze(payload, { ...options, depth: depth + 1 }), "find-exec");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export function analyze(input, options = {}) {
|
|
1261
|
+
const limits = { ...BASH_LIMITS, ...options };
|
|
1262
|
+
if (typeof input !== "string" || Buffer.byteLength(input, "utf8") > limits.maxInput) {
|
|
1263
|
+
return {
|
|
1264
|
+
shell: "bash",
|
|
1265
|
+
leaves: [],
|
|
1266
|
+
redirects: [],
|
|
1267
|
+
dynamicConstructs: [{ kind: "input-limit" }],
|
|
1268
|
+
parseErrors: [],
|
|
1269
|
+
indeterminate: true,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (containsUnquotedForkBomb(input)) {
|
|
1274
|
+
return {
|
|
1275
|
+
shell: "bash",
|
|
1276
|
+
leaves: [
|
|
1277
|
+
{ shell: "bash", executable: "fork-bomb", operation: "", args: [], redactedTarget: "", dynamic: false },
|
|
1278
|
+
],
|
|
1279
|
+
redirects: [],
|
|
1280
|
+
dynamicConstructs: [],
|
|
1281
|
+
parseErrors: [],
|
|
1282
|
+
indeterminate: false,
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const parsedInput = stripHeredocBodies(input);
|
|
1287
|
+
const scanned = scan(parsedInput, limits);
|
|
1288
|
+
const made = makeLeaves(scanned.tokens, limits);
|
|
1289
|
+
const outerLeaves = made.leaves.filter((leaf) => leaf.separatorBefore !== "(");
|
|
1290
|
+
let dynamicConstructs = [...scanned.dynamicConstructs, ...made.dynamicConstructs];
|
|
1291
|
+
const substitutionPattern = /(?:\$\(|<\(|`)\s*([^`()]*?)(?:\)|`)/g;
|
|
1292
|
+
const resolvedSubstitutions = new Set();
|
|
1293
|
+
let substitution;
|
|
1294
|
+
while ((substitution = substitutionPattern.exec(parsedInput))) {
|
|
1295
|
+
if ((options.depth || 0) >= limits.maxDepth) {
|
|
1296
|
+
dynamicConstructs.push({ kind: "depth-limit" });
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const nested = analyze(substitution[1], { ...options, depth: (options.depth || 0) + 1 });
|
|
1301
|
+
made.leaves.push(...nested.leaves);
|
|
1302
|
+
dynamicConstructs.push(...nested.dynamicConstructs);
|
|
1303
|
+
if (nested.indeterminate) {
|
|
1304
|
+
dynamicConstructs.push({ kind: "dynamic-substitution" });
|
|
1305
|
+
} else {
|
|
1306
|
+
resolvedSubstitutions.add(substitution.index);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const substitutionsCannotChangeDangerousArguments = outerLeaves.every((leaf) =>
|
|
1311
|
+
safeSubstitutionConsumers.has(
|
|
1312
|
+
leaf.executable
|
|
1313
|
+
.toLowerCase()
|
|
1314
|
+
.replace(/\.exe$/, "")
|
|
1315
|
+
.split(/[\\/]/)
|
|
1316
|
+
.pop(),
|
|
1317
|
+
),
|
|
1318
|
+
);
|
|
1319
|
+
const expansionsCannotChangeDangerousArguments =
|
|
1320
|
+
outerLeaves.every((leaf) =>
|
|
1321
|
+
safeExpansionConsumers.has(
|
|
1322
|
+
leaf.executable
|
|
1323
|
+
.toLowerCase()
|
|
1324
|
+
.replace(/\.exe$/, "")
|
|
1325
|
+
.split(/[\\/]/)
|
|
1326
|
+
.pop(),
|
|
1327
|
+
),
|
|
1328
|
+
) && !made.redirects.some((redirect) => /[$`]/.test(String(redirect.target || "")));
|
|
1329
|
+
const heredocRedirects = made.redirects.filter((redirect) => String(redirect.operator).includes("<<"));
|
|
1330
|
+
const resolvedHeredocs = [];
|
|
1331
|
+
let resolvedHeredocExecution = false;
|
|
1332
|
+
if (heredocRedirects.length) {
|
|
1333
|
+
const lastInputRedirect = new Map();
|
|
1334
|
+
for (const redirect of made.redirects) {
|
|
1335
|
+
if (String(redirect.operator).includes("<")) {
|
|
1336
|
+
lastInputRedirect.set(redirect.commandId, redirect);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const commandById = new Map(
|
|
1341
|
+
outerLeaves.filter((leaf) => leaf.executable !== "<redirect>").map((leaf) => [leaf.commandId, leaf]),
|
|
1342
|
+
);
|
|
1343
|
+
const details = literalHeredocBodies(input);
|
|
1344
|
+
if (details.complete && details.bodies.length === heredocRedirects.length) {
|
|
1345
|
+
for (let index = 0; index < heredocRedirects.length; index += 1) {
|
|
1346
|
+
const redirect = heredocRedirects[index];
|
|
1347
|
+
const body = details.bodies[index];
|
|
1348
|
+
if (body.unsupported) {
|
|
1349
|
+
resolvedHeredocs.push(false);
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (lastInputRedirect.get(redirect.commandId) !== redirect) {
|
|
1354
|
+
resolvedHeredocs.push(true);
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const kind = heredocConsumerKind(commandById.get(redirect.commandId));
|
|
1359
|
+
if (kind === "data") {
|
|
1360
|
+
resolvedHeredocs.push(true);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (kind === "unknown") {
|
|
1365
|
+
resolvedHeredocs.push(false);
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const nested =
|
|
1370
|
+
kind === "cmd"
|
|
1371
|
+
? analyzeCmd(body.body, { ...options, depth: (options.depth || 0) + 1 })
|
|
1372
|
+
: analyze(body.body, { ...options, depth: (options.depth || 0) + 1 });
|
|
1373
|
+
made.leaves.push(...nested.leaves);
|
|
1374
|
+
dynamicConstructs.push(...nested.dynamicConstructs);
|
|
1375
|
+
if (nested.indeterminate) {
|
|
1376
|
+
dynamicConstructs.push({ kind: "dynamic-heredoc" });
|
|
1377
|
+
resolvedHeredocs.push(false);
|
|
1378
|
+
} else {
|
|
1379
|
+
resolvedHeredocs.push(true);
|
|
1380
|
+
resolvedHeredocExecution = true;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
let heredocIndex = 0;
|
|
1387
|
+
dynamicConstructs = dynamicConstructs.filter((entry) => {
|
|
1388
|
+
if (["command-substitution", "quoted-substitution", "process-substitution"].includes(entry.kind)) {
|
|
1389
|
+
return !substitutionsCannotChangeDangerousArguments || !resolvedSubstitutions.has(entry.start);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (["environment-expansion", "quoted-expansion"].includes(entry.kind)) {
|
|
1393
|
+
return !expansionsCannotChangeDangerousArguments;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (entry.kind === "heredoc") {
|
|
1397
|
+
return !resolvedHeredocs[heredocIndex++];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
return true;
|
|
1401
|
+
});
|
|
1402
|
+
if ((resolvedSubstitutions.size && substitutionsCannotChangeDangerousArguments) || resolvedHeredocExecution) {
|
|
1403
|
+
dynamicConstructs.push({ kind: "resolved-command-execution" });
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
addNested(made.leaves, limits, dynamicConstructs, options.depth || 0);
|
|
1407
|
+
addDecodedPipelines(made.leaves, limits, dynamicConstructs, options.depth || 0);
|
|
1408
|
+
|
|
1409
|
+
return {
|
|
1410
|
+
shell: "bash",
|
|
1411
|
+
leaves: made.leaves,
|
|
1412
|
+
redirects: made.redirects,
|
|
1413
|
+
dynamicConstructs,
|
|
1414
|
+
parseErrors: scanned.parseErrors,
|
|
1415
|
+
indeterminate:
|
|
1416
|
+
scanned.parseErrors.length > 0 ||
|
|
1417
|
+
made.leaves.some((leaf) => leaf.dynamic || leaf.nested?.indeterminate) ||
|
|
1418
|
+
dynamicConstructs.some((item) =>
|
|
1419
|
+
/(?:input|token|leaf|depth)-limit|dynamic-|heredoc|substitution|expansion|missing-redirect/.test(
|
|
1420
|
+
item.kind,
|
|
1421
|
+
),
|
|
1422
|
+
),
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
export const analyzeBash = analyze;
|