@westbayberry/dg 2.3.2 → 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -208
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +4 -2
|
@@ -101,6 +101,14 @@ function splitSegments(line) {
|
|
|
101
101
|
let buf = "";
|
|
102
102
|
let quote = null;
|
|
103
103
|
let ansiC = false;
|
|
104
|
+
// Track depth INSIDE substitution groups — `$(…)` command-sub and `<(…)`/`>(…)`
|
|
105
|
+
// process-sub — whose bodies collectSegments re-extracts and re-screens as a
|
|
106
|
+
// whole. A separator there (`sh <(true | echo npm i evil)`) must not shear the
|
|
107
|
+
// group, or the body extractor never sees a balanced span. A BARE `(…)` subshell
|
|
108
|
+
// is NOT tracked: its `&&`/`;`-joined install (`(cd x && npm i evil)`) has no
|
|
109
|
+
// separate extractor, so it must still split normally.
|
|
110
|
+
const parenTracked = [];
|
|
111
|
+
let substDepth = 0;
|
|
104
112
|
const push = () => {
|
|
105
113
|
const t = buf.trim();
|
|
106
114
|
if (t) {
|
|
@@ -160,6 +168,27 @@ function splitSegments(line) {
|
|
|
160
168
|
buf += ch;
|
|
161
169
|
continue;
|
|
162
170
|
}
|
|
171
|
+
if (ch === "(") {
|
|
172
|
+
const tracked = line[i - 1] === "<" || line[i - 1] === ">" || line[i - 1] === "$";
|
|
173
|
+
parenTracked.push(tracked);
|
|
174
|
+
if (tracked) {
|
|
175
|
+
substDepth += 1;
|
|
176
|
+
}
|
|
177
|
+
buf += ch;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (ch === ")") {
|
|
181
|
+
if (parenTracked.pop() === true && substDepth > 0) {
|
|
182
|
+
substDepth -= 1;
|
|
183
|
+
}
|
|
184
|
+
buf += ch;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (substDepth > 0) {
|
|
188
|
+
// Inside a substitution group: keep the body intact for its extractor.
|
|
189
|
+
buf += ch;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
163
192
|
if (ch === "#" && (i === 0 || /\s/.test(line[i - 1] ?? ""))) {
|
|
164
193
|
// A bash comment begins at an unquoted `#` on a word boundary and runs to
|
|
165
194
|
// end of line; the shell never executes that text, so splitting it into a
|
|
@@ -200,25 +229,149 @@ function splitSegments(line) {
|
|
|
200
229
|
push();
|
|
201
230
|
return segments;
|
|
202
231
|
}
|
|
232
|
+
// Extract a balanced `(…)` span starting at the index of its opening paren,
|
|
233
|
+
// honoring nested parens and quotes inside (so a `)` inside a quoted string does
|
|
234
|
+
// not close it early — `$(echo "(" ; npm install evil)` yields the whole body,
|
|
235
|
+
// not a truncated one that would drop the install). Returns null if unbalanced.
|
|
236
|
+
function extractParenBody(line, openParen) {
|
|
237
|
+
let depth = 0;
|
|
238
|
+
let q = null;
|
|
239
|
+
for (let j = openParen; j < line.length; j += 1) {
|
|
240
|
+
const c = line[j] ?? "";
|
|
241
|
+
if (q === "'") {
|
|
242
|
+
if (c === "'") {
|
|
243
|
+
q = null;
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (q === '"') {
|
|
248
|
+
if (c === "\\" && j + 1 < line.length) {
|
|
249
|
+
j += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (c === '"') {
|
|
253
|
+
q = null;
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === "\\" && j + 1 < line.length) {
|
|
258
|
+
j += 1;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (c === "'" || c === '"') {
|
|
262
|
+
q = c;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (c === "(") {
|
|
266
|
+
depth += 1;
|
|
267
|
+
}
|
|
268
|
+
else if (c === ")") {
|
|
269
|
+
depth -= 1;
|
|
270
|
+
if (depth === 0) {
|
|
271
|
+
return { text: line.slice(openParen + 1, j).trim(), end: j };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
function extractBacktick(line, openTick) {
|
|
278
|
+
for (let j = openTick + 1; j < line.length; j += 1) {
|
|
279
|
+
const c = line[j] ?? "";
|
|
280
|
+
if (c === "\\" && j + 1 < line.length) {
|
|
281
|
+
j += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (c === "`") {
|
|
285
|
+
return { text: line.slice(openTick + 1, j).trim(), end: j };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
// `$(…)`, backtick, and process substitution `<(…)` / `>(…)` run their body as a
|
|
291
|
+
// command, so an install hidden inside one must be re-screened — but only where
|
|
292
|
+
// the shell would actually execute it. A substitution inside SINGLE quotes is
|
|
293
|
+
// literal text the shell never runs (`git commit -m '$(npm install x)'`), so
|
|
294
|
+
// extracting it is a false block. Double-quoted `$(…)`/backtick still execute,
|
|
295
|
+
// so those are kept; `<(…)`/`>(…)` are a word-level construct only when unquoted.
|
|
203
296
|
function substitutionBodies(line) {
|
|
204
297
|
const bodies = [];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
298
|
+
let quote = null;
|
|
299
|
+
let ansiC = false;
|
|
300
|
+
const take = (body, i) => {
|
|
301
|
+
if (body) {
|
|
302
|
+
if (body.text) {
|
|
303
|
+
bodies.push(body.text);
|
|
304
|
+
}
|
|
305
|
+
return body.end;
|
|
306
|
+
}
|
|
307
|
+
return i;
|
|
308
|
+
};
|
|
309
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
310
|
+
const ch = line[i] ?? "";
|
|
311
|
+
if (ansiC) {
|
|
312
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
313
|
+
i += 1;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (ch === "'") {
|
|
317
|
+
ansiC = false;
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (quote === "'") {
|
|
322
|
+
if (ch === "'") {
|
|
323
|
+
quote = null;
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (quote === '"') {
|
|
328
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
329
|
+
i += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (ch === '"') {
|
|
333
|
+
quote = null;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === "$" && line[i + 1] === "(") {
|
|
337
|
+
i = take(extractParenBody(line, i + 1), i);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (ch === "`") {
|
|
341
|
+
i = take(extractBacktick(line, i), i);
|
|
214
342
|
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
346
|
+
i += 1;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (ch === "$" && line[i + 1] === "'") {
|
|
350
|
+
ansiC = true;
|
|
351
|
+
i += 1;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (ch === "'" || ch === '"') {
|
|
355
|
+
quote = ch;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (ch === "$" && line[i + 1] === "(") {
|
|
359
|
+
i = take(extractParenBody(line, i + 1), i);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if ((ch === "<" || ch === ">") && line[i + 1] === "(") {
|
|
363
|
+
i = take(extractParenBody(line, i + 1), i);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (ch === "`") {
|
|
367
|
+
i = take(extractBacktick(line, i), i);
|
|
215
368
|
}
|
|
216
369
|
}
|
|
217
370
|
return bodies;
|
|
218
371
|
}
|
|
219
372
|
function collectSegments(line) {
|
|
220
373
|
const texts = [];
|
|
221
|
-
const pending = [line];
|
|
374
|
+
const pending = [stripHeredocBodies(line)];
|
|
222
375
|
while (pending.length > 0) {
|
|
223
376
|
const current = pending.pop();
|
|
224
377
|
if (current === undefined || texts.includes(current)) {
|
|
@@ -226,6 +379,9 @@ function collectSegments(line) {
|
|
|
226
379
|
}
|
|
227
380
|
texts.push(current);
|
|
228
381
|
pending.push(...substitutionBodies(current));
|
|
382
|
+
pending.push(...hereStringBodies(current));
|
|
383
|
+
pending.push(...pipedLiteralScripts(current));
|
|
384
|
+
pending.push(...processSubScripts(current));
|
|
229
385
|
}
|
|
230
386
|
const segments = [];
|
|
231
387
|
for (const segment of texts.flatMap(splitSegments)) {
|
|
@@ -338,7 +494,15 @@ function lexSegment(segment) {
|
|
|
338
494
|
}
|
|
339
495
|
if (c === "\\" && i + 1 < n) {
|
|
340
496
|
const nx = segment[i + 1] ?? "";
|
|
341
|
-
if (nx ===
|
|
497
|
+
if (nx === "\n") {
|
|
498
|
+
i += 2;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (nx === "\r") {
|
|
502
|
+
i += segment[i + 2] === "\n" ? 3 : 2;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (nx === '"' || nx === "\\" || nx === "`" || nx === "$") {
|
|
342
506
|
cur += nx;
|
|
343
507
|
i += 2;
|
|
344
508
|
continue;
|
|
@@ -384,6 +548,15 @@ function lexSegment(segment) {
|
|
|
384
548
|
continue;
|
|
385
549
|
}
|
|
386
550
|
if (ch === "\\") {
|
|
551
|
+
const nx = segment[i + 1];
|
|
552
|
+
if (nx === "\n") {
|
|
553
|
+
i += 2;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (nx === "\r") {
|
|
557
|
+
i += segment[i + 2] === "\n" ? 3 : 2;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
387
560
|
if (i + 1 < n) {
|
|
388
561
|
cur += segment[i + 1];
|
|
389
562
|
started = true;
|
|
@@ -431,6 +604,11 @@ function lexSegment(segment) {
|
|
|
431
604
|
return { tokens, ok: true };
|
|
432
605
|
}
|
|
433
606
|
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
607
|
+
const BWRAP_SPEC = {
|
|
608
|
+
valueFlags: new Set(["--args", "--argv0", "--chdir", "--unsetenv", "--uid", "--gid", "--hostname", "--tmpfs", "--dir", "--proc", "--dev", "--mqueue", "--remount-ro", "--size", "--perms", "--seccomp", "--userns", "--userns2", "--pidns", "--cap-add", "--cap-drop", "--exec-label", "--file-label", "--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd", "--sync-fd", "--overlay-src", "--tmp-overlay", "--ro-overlay"]),
|
|
609
|
+
dualValueFlags: new Set(["--bind", "--ro-bind", "--dev-bind", "--bind-try", "--ro-bind-try", "--dev-bind-try", "--bind-fd", "--ro-bind-fd", "--bind-data", "--ro-bind-data", "--file", "--symlink", "--setenv", "--chmod"]),
|
|
610
|
+
tripleValueFlags: new Set(["--overlay"]),
|
|
611
|
+
};
|
|
434
612
|
const WRAPPERS = new Map([
|
|
435
613
|
["sudo", { valueFlags: new Set(["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "-h", "--host", "-R", "--chroot", "-D", "--chdir"]) }],
|
|
436
614
|
["doas", { valueFlags: new Set(["-u", "-C", "-a"]) }],
|
|
@@ -444,31 +622,534 @@ const WRAPPERS = new Map([
|
|
|
444
622
|
["ionice", { valueFlags: new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid"]) }],
|
|
445
623
|
["nohup", { valueFlags: new Set() }],
|
|
446
624
|
["setsid", { valueFlags: new Set() }],
|
|
447
|
-
["chrt", { valueFlags: new Set(["-T", "-P", "-D"]) }],
|
|
625
|
+
["chrt", { valueFlags: new Set(["-T", "-P", "-D"]), bareValue: /^\d+$/ }],
|
|
448
626
|
["time", { valueFlags: new Set(["-o", "--output", "-f", "--format"]) }],
|
|
449
627
|
["timeout", { valueFlags: new Set(["-s", "--signal", "-k", "--kill-after"]), bareValue: /^\d+(\.\d+)?[smhd]?$/ }],
|
|
450
628
|
["stdbuf", { valueFlags: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }],
|
|
629
|
+
// macOS keeps-awake prefix (`caffeinate npm i x`) — a built-in on the platform
|
|
630
|
+
// dg ships to, so a real exec wrapper that otherwise waves the install through.
|
|
631
|
+
["caffeinate", { valueFlags: new Set(["-t", "-w"]) }],
|
|
632
|
+
// macOS architecture launcher (`arch -x86_64 npm i x`, `arch -arch x86_64 …`).
|
|
633
|
+
["arch", { valueFlags: new Set(["-arch"]) }],
|
|
634
|
+
["eatmydata", { valueFlags: new Set() }],
|
|
635
|
+
["proxychains", { valueFlags: new Set(["-f"]) }],
|
|
636
|
+
["proxychains4", { valueFlags: new Set(["-f"]) }],
|
|
637
|
+
["torsocks", { valueFlags: new Set(["-p", "--port", "-a", "--address", "-c", "--config"]) }],
|
|
638
|
+
["numactl", { valueFlags: new Set(["-N", "--cpunodebind", "-m", "--membind", "-C", "--physcpubind", "-i", "--interleave", "-p", "--preferred", "-s", "--shm"]) }],
|
|
639
|
+
// GNU watch runs its operand through `sh -c`, so `watch npm install x` is a real
|
|
640
|
+
// install; the remaining tokens ARE that command, so a plain unwrap is correct.
|
|
641
|
+
["watch", { valueFlags: new Set(["-n", "--interval"]) }],
|
|
642
|
+
// taskset/setarch carry one bare operand (a CPU mask / an arch name) before the
|
|
643
|
+
// command; a package-manager head never matches those shapes, so consuming the
|
|
644
|
+
// shaped operand cannot swallow the real command head.
|
|
645
|
+
["taskset", { valueFlags: new Set(["-c", "--cpu-list", "-p", "--pid"]), bareValue: /^(?:0x[0-9a-fA-F]+|[0-9][0-9,-]*)$/ }],
|
|
646
|
+
["setarch", { valueFlags: new Set(), bareValue: /^(?:i[3-6]86|x86_64|ppc(?:64(?:le)?)?|s390x?|sparc(?:64)?|mips(?:64)?|ia64|aarch64|arm(?:v[0-9]+l)?|linux32|linux64|uname-2\.6)$/ }],
|
|
647
|
+
// `runuser -u user -- npm i x` execs the command after `--` directly (the `--`
|
|
648
|
+
// token starts with `-`, so the wrapper loop consumes it); the `-c '<script>'`
|
|
649
|
+
// shell form is caught by the su/runuser/flock branch in commandTokens first.
|
|
650
|
+
["runuser", { valueFlags: new Set(["-u", "--user", "-g", "--group", "-G", "--supp-group", "-s", "--shell"]) }],
|
|
651
|
+
// PolicyKit sibling of sudo/doas; container init/exec wrappers. gosu/su-exec
|
|
652
|
+
// carry one bare user[:group] operand and are handled in commandTokens below.
|
|
653
|
+
["pkexec", { valueFlags: new Set(["--user"]) }],
|
|
654
|
+
["tini", { valueFlags: new Set(["-e", "-p"]) }],
|
|
655
|
+
["dumb-init", { valueFlags: new Set(["-r", "--rewrite"]) }],
|
|
451
656
|
["xargs", { valueFlags: new Set(["-a", "--arg-file", "-d", "--delimiter", "-E", "-e", "--eof", "-I", "-i", "--replace", "-L", "-l", "--max-lines", "-n", "--max-args", "-P", "--max-procs", "-s", "--max-chars"]) }],
|
|
657
|
+
// busybox is a multi-call binary: `busybox sh -c '…'` runs the sh applet. With
|
|
658
|
+
// no value flags the applet name becomes the next head, so `sh` re-enters the
|
|
659
|
+
// SHELL_EXEC `-c` extraction below.
|
|
660
|
+
["busybox", { valueFlags: new Set() }],
|
|
661
|
+
["unshare", { valueFlags: new Set(["-S", "--setuid", "-G", "--setgid", "--map-user", "--map-group", "--setgroups", "-w", "--wd"]) }],
|
|
662
|
+
["nsenter", { valueFlags: new Set(["-t", "--target", "-S", "--setuid", "-G", "--setgid", "-w", "--wd"]) }],
|
|
663
|
+
["setpriv", { valueFlags: new Set(["--reuid", "--regid", "--groups", "--bounding-set", "--inh-caps", "--ambient-caps", "--securebits", "--pdeathsig", "--selinux-label", "--apparmor-profile"]) }],
|
|
664
|
+
["flatpak-spawn", { valueFlags: new Set(["--env", "--directory", "--forward-fd"]) }],
|
|
665
|
+
// bwrap/bubblewrap is the standard Linux sandbox (Flatpak/CI). Its bind/setenv
|
|
666
|
+
// family takes TWO operands (SRC DEST / VAR VALUE) and --overlay takes THREE, so
|
|
667
|
+
// the multi-value sets are load-bearing: a short skip parks on a later operand
|
|
668
|
+
// and waves the wrapped `npm install` through. Zero-arg flags (--unshare-*,
|
|
669
|
+
// --die-with-parent, --clearenv, …) are intentionally absent so they are
|
|
670
|
+
// consumed flag-only. Shared by both spellings so the two can't drift.
|
|
671
|
+
["bwrap", BWRAP_SPEC],
|
|
672
|
+
["bubblewrap", BWRAP_SPEC],
|
|
673
|
+
]);
|
|
674
|
+
// Shells that run their `-c` operand as a command (so `ksh -c 'npm i x'` must be
|
|
675
|
+
// re-parsed). script(1) runs its `-c`/`--command` value the same way.
|
|
676
|
+
const SHELL_EXEC = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "mksh", "fish", "script"]);
|
|
677
|
+
// Commands that take a package-manager NAME as literal DATA (search/print/inspect),
|
|
678
|
+
// not as a command to run. The unknown-wrapper backstop skips these so a benign
|
|
679
|
+
// `grep npm install …` / `echo npm install` is not deferred.
|
|
680
|
+
const DATA_HEADS = new Set([
|
|
681
|
+
"echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "cat", "man", "which", "type",
|
|
682
|
+
"whereis", "find", "ls", "head", "tail", "sed", "awk", "test", "[", "basename", "dirname",
|
|
683
|
+
"wc", "sort", "uniq", "tr", "cut", "tee", "less", "more", "cmp", "diff",
|
|
452
684
|
]);
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
685
|
+
// Control-flow keywords that introduce a command. The segmenter splits on `;`,
|
|
686
|
+
// so the command after one lands at the head of its segment behind the keyword
|
|
687
|
+
// — strip it or a real install behind it is read as the no-op keyword. This
|
|
688
|
+
// covers both the body introducers (`then`/`do`/`else`/`elif`) and the
|
|
689
|
+
// condition introducers (`if`/`while`/`until`): bash runs the condition pipeline
|
|
690
|
+
// of `if npm install x; then …` and `until npm install x; do …`, so the install
|
|
691
|
+
// there must be classified, not waved through. `!` negates the next command.
|
|
692
|
+
const SHELL_KEYWORDS = new Set(["then", "do", "else", "elif", "!", "if", "while", "until"]);
|
|
459
693
|
// A `case … in <pat>) <cmd> ;;` arm label, e.g. `a)`, `*)`, `*.js)`, `a|b)`.
|
|
460
694
|
const CASE_LABEL = /^[A-Za-z0-9_*?.\][|@!+-]+\)$/;
|
|
695
|
+
// Commands that run their heredoc body as shell input. `.`/`source` read it as a
|
|
696
|
+
// script; `eval` evaluates it; the shells exec it. A heredoc fed to one of these
|
|
697
|
+
// (`sh <<EOF … EOF`, `. /dev/stdin <<EOF`) executes the body, so the body must be
|
|
698
|
+
// screened. A heredoc to a data sink (cat/tee/wc/ssh) is inert text.
|
|
699
|
+
const HEREDOC_EXEC_HEADS = new Set([...SHELL_EXEC, "eval", "source", "."]);
|
|
700
|
+
// Locate heredoc introducers (`<<WORD`, `<<-WORD`, `<<"WORD"`, `<<'WORD'`) on a
|
|
701
|
+
// line, quote-aware so a `<<` inside a string is not one, and excluding the
|
|
702
|
+
// here-string `<<<`.
|
|
703
|
+
function heredocDelimiters(introLine) {
|
|
704
|
+
const result = [];
|
|
705
|
+
let quote = null;
|
|
706
|
+
for (let i = 0; i < introLine.length; i += 1) {
|
|
707
|
+
const ch = introLine[i] ?? "";
|
|
708
|
+
if (quote) {
|
|
709
|
+
if (ch === "\\" && quote === '"' && i + 1 < introLine.length) {
|
|
710
|
+
i += 1;
|
|
711
|
+
}
|
|
712
|
+
else if (ch === quote) {
|
|
713
|
+
quote = null;
|
|
714
|
+
}
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (ch === "\\") {
|
|
718
|
+
i += 1;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
if (ch === "'" || ch === '"') {
|
|
722
|
+
quote = ch;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (ch === "<" && introLine[i + 1] === "<" && introLine[i + 2] !== "<") {
|
|
726
|
+
let j = i + 2;
|
|
727
|
+
let strip = false;
|
|
728
|
+
if (introLine[j] === "-") {
|
|
729
|
+
strip = true;
|
|
730
|
+
j += 1;
|
|
731
|
+
}
|
|
732
|
+
while (introLine[j] === " " || introLine[j] === "\t") {
|
|
733
|
+
j += 1;
|
|
734
|
+
}
|
|
735
|
+
let wordQuote = null;
|
|
736
|
+
if (introLine[j] === '"' || introLine[j] === "'") {
|
|
737
|
+
wordQuote = introLine[j] ?? null;
|
|
738
|
+
j += 1;
|
|
739
|
+
}
|
|
740
|
+
let delim = "";
|
|
741
|
+
while (j < introLine.length) {
|
|
742
|
+
const c = introLine[j] ?? "";
|
|
743
|
+
if (wordQuote) {
|
|
744
|
+
if (c === wordQuote) {
|
|
745
|
+
j += 1;
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
delim += c;
|
|
749
|
+
j += 1;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (/[A-Za-z0-9_]/.test(c)) {
|
|
753
|
+
delim += c;
|
|
754
|
+
j += 1;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
if (delim) {
|
|
760
|
+
result.push({ delim, strip });
|
|
761
|
+
i = j - 1;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
function hasUnquotedPipe(line) {
|
|
768
|
+
let quote = null;
|
|
769
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
770
|
+
const ch = line[i] ?? "";
|
|
771
|
+
if (quote) {
|
|
772
|
+
if (ch === "\\" && quote === '"' && i + 1 < line.length) {
|
|
773
|
+
i += 1;
|
|
774
|
+
}
|
|
775
|
+
else if (ch === quote) {
|
|
776
|
+
quote = null;
|
|
777
|
+
}
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (ch === "\\") {
|
|
781
|
+
i += 1;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (ch === "'" || ch === '"') {
|
|
785
|
+
quote = ch;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (ch === "|") {
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
// Would the shell run the heredoc body attached to this introducer line? A pipe
|
|
795
|
+
// could feed it to a downstream shell (fail closed), and any command whose real
|
|
796
|
+
// head (wrappers unwrapped: `env sh`, `timeout sh`, `xargs sh`) is a shell/eval
|
|
797
|
+
// /source executes it. Otherwise the body is inert data to a sink.
|
|
798
|
+
function introducerExecutesBody(introLine) {
|
|
799
|
+
if (hasUnquotedPipe(introLine)) {
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
for (const seg of splitSegments(introLine)) {
|
|
803
|
+
const head = commandBasename(commandTokens(seg).tokens[0] ?? "");
|
|
804
|
+
if (HEREDOC_EXEC_HEADS.has(head)) {
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
// Drop heredoc bodies the shell never executes so their lines are not mis-read as
|
|
811
|
+
// command segments (`cat <<EOF … npm install x … EOF` is data to cat, not an
|
|
812
|
+
// install). Bodies a shell would run are kept for screening; an unterminated
|
|
813
|
+
// heredoc is kept too (fail closed).
|
|
814
|
+
function stripHeredocBodies(line) {
|
|
815
|
+
if (!line.includes("<<")) {
|
|
816
|
+
return line;
|
|
817
|
+
}
|
|
818
|
+
const physical = line.split("\n");
|
|
819
|
+
const out = [];
|
|
820
|
+
let i = 0;
|
|
821
|
+
while (i < physical.length) {
|
|
822
|
+
const cur = physical[i] ?? "";
|
|
823
|
+
out.push(cur);
|
|
824
|
+
i += 1;
|
|
825
|
+
const introducers = heredocDelimiters(cur);
|
|
826
|
+
if (introducers.length === 0) {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const executes = introducerExecutesBody(cur);
|
|
830
|
+
for (const introducer of introducers) {
|
|
831
|
+
const body = [];
|
|
832
|
+
let terminated = false;
|
|
833
|
+
while (i < physical.length) {
|
|
834
|
+
const bodyLine = physical[i] ?? "";
|
|
835
|
+
const probe = introducer.strip ? bodyLine.replace(/^\t+/, "") : bodyLine;
|
|
836
|
+
i += 1;
|
|
837
|
+
if (probe === introducer.delim) {
|
|
838
|
+
terminated = true;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
body.push(bodyLine);
|
|
842
|
+
}
|
|
843
|
+
if (executes || !terminated) {
|
|
844
|
+
out.push(...body);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return out.join("\n");
|
|
849
|
+
}
|
|
850
|
+
// Quote/escape-aware index of the first `<<<` here-string operator, or -1.
|
|
851
|
+
function firstHereString(seg) {
|
|
852
|
+
let quote = null;
|
|
853
|
+
for (let i = 0; i < seg.length; i += 1) {
|
|
854
|
+
const ch = seg[i] ?? "";
|
|
855
|
+
if (quote === "'") {
|
|
856
|
+
if (ch === "'")
|
|
857
|
+
quote = null;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (quote === '"') {
|
|
861
|
+
if (ch === "\\" && i + 1 < seg.length) {
|
|
862
|
+
i += 1;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (ch === '"')
|
|
866
|
+
quote = null;
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (ch === "\\" && i + 1 < seg.length) {
|
|
870
|
+
i += 1;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (ch === "'" || ch === '"') {
|
|
874
|
+
quote = ch;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (ch === "<" && seg[i + 1] === "<" && seg[i + 2] === "<")
|
|
878
|
+
return i;
|
|
879
|
+
}
|
|
880
|
+
return -1;
|
|
881
|
+
}
|
|
882
|
+
// `<head> <<< WORD` feeds WORD to head's stdin; a shell/eval/source head runs it
|
|
883
|
+
// as a script. The lexer drops the `<<<` redirection target, and heredoc parsing
|
|
884
|
+
// skips `<<<`, so an install hidden in a here-string is never screened. Recover
|
|
885
|
+
// the (dequoted) word for those heads so it is re-parsed; a here-string to a sink
|
|
886
|
+
// (cat/grep) is inert and ignored.
|
|
887
|
+
function hereStringBodies(line) {
|
|
888
|
+
const out = [];
|
|
889
|
+
for (const seg of splitSegments(line)) {
|
|
890
|
+
const at = firstHereString(seg);
|
|
891
|
+
if (at < 0) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
const head = commandBasename(commandTokens(seg.slice(0, at)).tokens[0] ?? "");
|
|
895
|
+
if (!HEREDOC_EXEC_HEADS.has(head)) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const word = lexSegment(seg.slice(at + 3)).tokens[0];
|
|
899
|
+
if (word) {
|
|
900
|
+
out.push(word);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return out;
|
|
904
|
+
}
|
|
905
|
+
// Split a line into pipeline stages, recording whether each stage feeds the next
|
|
906
|
+
// through a single `|` (not `||`/`;`/`&&`/`&`). Quote/escape/ANSI-C aware so a
|
|
907
|
+
// `|` inside a string is data, not a stage boundary.
|
|
908
|
+
function splitPipeStages(line) {
|
|
909
|
+
const stages = [];
|
|
910
|
+
let buf = "";
|
|
911
|
+
let quote = null;
|
|
912
|
+
let ansiC = false;
|
|
913
|
+
const parenTracked = [];
|
|
914
|
+
let substDepth = 0;
|
|
915
|
+
const push = (pipedToNext) => {
|
|
916
|
+
stages.push({ text: buf.trim(), pipedToNext });
|
|
917
|
+
buf = "";
|
|
918
|
+
};
|
|
919
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
920
|
+
const ch = line[i] ?? "";
|
|
921
|
+
if (ansiC) {
|
|
922
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
923
|
+
buf += ch + line[i + 1];
|
|
924
|
+
i += 1;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
buf += ch;
|
|
928
|
+
if (ch === "'")
|
|
929
|
+
ansiC = false;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (quote === "'") {
|
|
933
|
+
buf += ch;
|
|
934
|
+
if (ch === "'")
|
|
935
|
+
quote = null;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (quote === '"') {
|
|
939
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
940
|
+
buf += ch + line[i + 1];
|
|
941
|
+
i += 1;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
buf += ch;
|
|
945
|
+
if (ch === '"')
|
|
946
|
+
quote = null;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (ch === "$" && line[i + 1] === "'") {
|
|
950
|
+
ansiC = true;
|
|
951
|
+
buf += "$'";
|
|
952
|
+
i += 1;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
956
|
+
buf += ch + line[i + 1];
|
|
957
|
+
i += 1;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (ch === "'" || ch === '"') {
|
|
961
|
+
quote = ch;
|
|
962
|
+
buf += ch;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (ch === "(") {
|
|
966
|
+
const tracked = line[i - 1] === "<" || line[i - 1] === ">" || line[i - 1] === "$";
|
|
967
|
+
parenTracked.push(tracked);
|
|
968
|
+
if (tracked)
|
|
969
|
+
substDepth += 1;
|
|
970
|
+
buf += ch;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (ch === ")") {
|
|
974
|
+
if (parenTracked.pop() === true && substDepth > 0)
|
|
975
|
+
substDepth -= 1;
|
|
976
|
+
buf += ch;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (substDepth > 0) {
|
|
980
|
+
buf += ch;
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (ch === "|") {
|
|
984
|
+
if (line[i + 1] === "|") {
|
|
985
|
+
push(false);
|
|
986
|
+
i += 1;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
push(true);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (ch === "\n" || ch === "\r" || ch === ";") {
|
|
993
|
+
push(false);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (ch === "&") {
|
|
997
|
+
if (line[i + 1] === "&") {
|
|
998
|
+
push(false);
|
|
999
|
+
i += 1;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (line[i - 1] === ">" || line[i + 1] === ">") {
|
|
1003
|
+
buf += ch;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
push(false);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
buf += ch;
|
|
1010
|
+
}
|
|
1011
|
+
push(false);
|
|
1012
|
+
return stages;
|
|
1013
|
+
}
|
|
1014
|
+
// `echo/printf "<script>" | … | sh` (bash/eval/source/.): the printed literal is
|
|
1015
|
+
// run by the downstream shell, so the install in it must be screened. Scan the
|
|
1016
|
+
// WHOLE pipeline (not just the stage adjacent to the shell) so an interposed
|
|
1017
|
+
// passthrough filter (`echo x | cat | sh`) can't hide the producer, and emit
|
|
1018
|
+
// several candidate scripts per producer so a printf format string
|
|
1019
|
+
// (`printf '%s\n' 'npm install evil'`) can't shield the install. A pipeline into
|
|
1020
|
+
// a non-shell (grep/wc) stays inert.
|
|
1021
|
+
function pipedLiteralScripts(line) {
|
|
1022
|
+
const out = [];
|
|
1023
|
+
const stages = splitPipeStages(line);
|
|
1024
|
+
let i = 0;
|
|
1025
|
+
while (i < stages.length) {
|
|
1026
|
+
let j = i;
|
|
1027
|
+
while (j < stages.length && stages[j]?.pipedToNext) {
|
|
1028
|
+
j += 1;
|
|
1029
|
+
}
|
|
1030
|
+
const pipeline = stages.slice(i, j + 1);
|
|
1031
|
+
i = j + 1;
|
|
1032
|
+
if (pipeline.length < 2) {
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const lastHead = commandBasename(commandTokens(pipeline[pipeline.length - 1]?.text ?? "").tokens[0] ?? "");
|
|
1036
|
+
if (!HEREDOC_EXEC_HEADS.has(lastHead)) {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
for (let k = 0; k < pipeline.length - 1; k += 1) {
|
|
1040
|
+
const stageText = pipeline[k]?.text ?? "";
|
|
1041
|
+
const prod = commandTokens(stageText);
|
|
1042
|
+
const phead = commandBasename(prod.tokens[0] ?? "");
|
|
1043
|
+
// A producer that relays a process-sub file's bytes downstream
|
|
1044
|
+
// (`cat <(echo npm i evil) | sh`) feeds the printed text to the shell too;
|
|
1045
|
+
// already scoped to a shell-terminated pipeline by the lastHead guard above.
|
|
1046
|
+
out.push(...procSubLiteralBodies(stageText));
|
|
1047
|
+
if (phead !== "echo" && phead !== "printf") {
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
const args = prod.tokens.slice(1).filter((t) => !t.startsWith("-"));
|
|
1051
|
+
if (args.length === 0) {
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
out.push(args.join(" "));
|
|
1055
|
+
if (phead === "printf" && args.length > 1) {
|
|
1056
|
+
// printf's first non-flag arg is the format; the install may live in the
|
|
1057
|
+
// operands it substitutes, so also screen the operands alone.
|
|
1058
|
+
out.push(args.slice(1).join(" "));
|
|
1059
|
+
for (const operand of args.slice(1)) {
|
|
1060
|
+
out.push(operand);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return out;
|
|
1066
|
+
}
|
|
1067
|
+
// `<head> <(echo|printf …)` — a shell/eval/source head runs the PRODUCER's output
|
|
1068
|
+
// as a script (bash reads the /dev/fd the substitution resolves to). The lexer
|
|
1069
|
+
// drops `<(…)` as a redirection, so the install in the producer is otherwise
|
|
1070
|
+
// never screened. This is the process-substitution sibling of the `echo … | sh`
|
|
1071
|
+
// (pipedLiteralScripts) and `sh <<< …` (hereStringBodies) cases.
|
|
1072
|
+
function procSubLiteralBodies(text, depth = 0) {
|
|
1073
|
+
const out = [];
|
|
1074
|
+
if (depth > 8) {
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
let quote = null;
|
|
1078
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
1079
|
+
const ch = text[i] ?? "";
|
|
1080
|
+
if (quote) {
|
|
1081
|
+
if (ch === "\\" && quote === '"' && i + 1 < text.length) {
|
|
1082
|
+
i += 1;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
if (ch === quote) {
|
|
1086
|
+
quote = null;
|
|
1087
|
+
}
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
1091
|
+
i += 1;
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (ch === "'" || ch === '"') {
|
|
1095
|
+
quote = ch;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if ((ch === "<" || ch === ">") && text[i + 1] === "(") {
|
|
1099
|
+
const body = extractParenBody(text, i + 1);
|
|
1100
|
+
if (!body) {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
// The body is run and its OUTPUT becomes the consumer's script, so harvest
|
|
1104
|
+
// an echo/printf literal from ANY pipe stage of the body (`<(true | echo
|
|
1105
|
+
// npm i evil)`), not just its head. A relay producer wrapping a nested
|
|
1106
|
+
// process-sub (`<(cat <(echo npm i evil))`) is reached by the recursion.
|
|
1107
|
+
for (const stage of splitPipeStages(body.text)) {
|
|
1108
|
+
const prod = commandTokens(stage.text);
|
|
1109
|
+
const phead = commandBasename(prod.tokens[0] ?? "");
|
|
1110
|
+
if (phead === "echo" || phead === "printf") {
|
|
1111
|
+
const args = prod.tokens.slice(1).filter((t) => !t.startsWith("-"));
|
|
1112
|
+
if (args.length > 0) {
|
|
1113
|
+
out.push(args.join(" "));
|
|
1114
|
+
if (phead === "printf" && args.length > 1) {
|
|
1115
|
+
out.push(args.slice(1).join(" "));
|
|
1116
|
+
for (const operand of args.slice(1)) {
|
|
1117
|
+
out.push(operand);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
out.push(...procSubLiteralBodies(body.text, depth + 1));
|
|
1124
|
+
i = body.end;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return out;
|
|
1128
|
+
}
|
|
1129
|
+
function processSubScripts(line) {
|
|
1130
|
+
const out = [];
|
|
1131
|
+
for (const seg of splitSegments(line)) {
|
|
1132
|
+
const head = commandBasename(commandTokens(seg).tokens[0] ?? "");
|
|
1133
|
+
if (!HEREDOC_EXEC_HEADS.has(head)) {
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
out.push(...procSubLiteralBodies(seg));
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
}
|
|
461
1140
|
// Package managers dg doesn't statically screen (no shim, no classifier). A
|
|
462
1141
|
// recognized install verb for one of these can't be screened by the hook, so it
|
|
463
1142
|
// defers to the runtime gate rather than waving it through silently.
|
|
464
1143
|
const UNSUPPORTED_INSTALLS = new Map([
|
|
465
|
-
["bun", new Set(["add", "install", "i", "x"])],
|
|
466
|
-
["deno", new Set(["install", "add"])],
|
|
467
|
-
["poetry", new Set(["add", "install"])],
|
|
468
|
-
["pdm", new Set(["add", "install"])],
|
|
469
|
-
["conda", new Set(["install", "create"])],
|
|
470
|
-
["mamba", new Set(["install", "create"])],
|
|
471
|
-
["
|
|
1144
|
+
["bun", new Set(["add", "install", "i", "x", "create"])],
|
|
1145
|
+
["deno", new Set(["install", "add", "cache"])],
|
|
1146
|
+
["poetry", new Set(["add", "install", "update"])],
|
|
1147
|
+
["pdm", new Set(["add", "install", "update"])],
|
|
1148
|
+
["conda", new Set(["install", "create", "update"])],
|
|
1149
|
+
["mamba", new Set(["install", "create", "update"])],
|
|
1150
|
+
["micromamba", new Set(["install", "create", "update"])],
|
|
1151
|
+
["pipenv", new Set(["install", "update", "sync"])],
|
|
1152
|
+
["gem", new Set(["install", "i", "fetch"])],
|
|
472
1153
|
// `go install`/`go get` fetch and build remote modules; `go build`/`run`/`test`
|
|
473
1154
|
// operate on already-resolved local code, so they stay passthrough.
|
|
474
1155
|
["go", new Set(["install", "get"])],
|
|
@@ -477,10 +1158,64 @@ const UNSUPPORTED_INSTALLS = new Map([
|
|
|
477
1158
|
// fetched and executed, so a recognized-but-unsupported runner with a target
|
|
478
1159
|
// defers to the runtime gate instead of a silent allow (`bunx <pkg>`).
|
|
479
1160
|
const UNSUPPORTED_RUNNERS = new Set(["bunx"]);
|
|
1161
|
+
// Verb-form fetch-and-run launchers: `npm exec <tool> <tool-args…>`, `pnpm dlx`,
|
|
1162
|
+
// `yarn dlx`, `pnpm exec`, `pipx run`. Only the first positional (the fetched
|
|
1163
|
+
// tool) is a package; the rest are arguments to that tool, not packages to
|
|
1164
|
+
// screen. (The no-verb forms `npx`/`pnpx`/`uvx` already slice to the first.)
|
|
1165
|
+
const RUNNER_VERBS = new Set(["exec", "dlx", "run"]);
|
|
1166
|
+
// Install/fetch subcommands across the managers. Used only to decide whether a
|
|
1167
|
+
// segment whose head is dynamic (`$M install x`, `$(which npm) install x`)
|
|
1168
|
+
// looks like an install and must defer rather than wave through.
|
|
1169
|
+
const INSTALL_VERBS = new Set([
|
|
1170
|
+
"install", "i", "ci", "add", "get", "download", "wheel", "sync", "upgrade", "dlx", "inject", "create",
|
|
1171
|
+
"exec", "x", "up", "update", "fetch", "dedupe", "tool",
|
|
1172
|
+
"in", "ins", "inst", "insta", "instal", "isnt", "isnta", "isntal", "isntall", "it", "cit", "sit", "ic",
|
|
1173
|
+
]);
|
|
1174
|
+
// `${IFS}` / `$IFS` expand to whitespace, so an attacker glues a command with
|
|
1175
|
+
// them to dodge word-splitting (`npm${IFS}install${IFS}evil`). Default IFS is
|
|
1176
|
+
// space/tab/newline; normalize the obfuscation back to a space before lexing.
|
|
1177
|
+
// `$IFS` is only the variable when not followed by another word character.
|
|
1178
|
+
const IFS_OBFUSCATION = /\$\{IFS\}|\$IFS(?![A-Za-z0-9_])/g;
|
|
480
1179
|
function commandBasename(token) {
|
|
481
1180
|
const slash = token.lastIndexOf("/");
|
|
482
1181
|
return slash === -1 ? token : token.slice(slash + 1);
|
|
483
1182
|
}
|
|
1183
|
+
// Bash allows whitespace between a function name and its `()` (`f () {…}`,
|
|
1184
|
+
// `f ( ) {…}`, `function f () {…}`), which the lexer splits into separate
|
|
1185
|
+
// `(`/`)`/`()`/`(){` tokens. Collapse that leading run into the glued `name()`
|
|
1186
|
+
// shape so the function-definition strip in commandTokens recognizes it. Only
|
|
1187
|
+
// fires when a `{` body follows the parens — a subshell command list `(cmd)`
|
|
1188
|
+
// has no name token before the paren, so it is untouched.
|
|
1189
|
+
function glueFunctionDefParens(tokens) {
|
|
1190
|
+
const i = tokens[0] === "function" ? 1 : 0;
|
|
1191
|
+
const name = tokens[i] ?? "";
|
|
1192
|
+
if (!/^[A-Za-z_][\w-]*$/.test(name)) {
|
|
1193
|
+
return [...tokens];
|
|
1194
|
+
}
|
|
1195
|
+
let j = i + 1;
|
|
1196
|
+
let sawParen = false;
|
|
1197
|
+
while (j < tokens.length && (tokens[j] === "(" || tokens[j] === ")")) {
|
|
1198
|
+
sawParen = true;
|
|
1199
|
+
j += 1;
|
|
1200
|
+
}
|
|
1201
|
+
let bracePart = "";
|
|
1202
|
+
if (j < tokens.length && (tokens[j] === "()" || tokens[j] === "(){")) {
|
|
1203
|
+
sawParen = true;
|
|
1204
|
+
if (tokens[j] === "(){") {
|
|
1205
|
+
bracePart = "{";
|
|
1206
|
+
}
|
|
1207
|
+
j += 1;
|
|
1208
|
+
}
|
|
1209
|
+
if (!sawParen || j === i + 1) {
|
|
1210
|
+
return [...tokens];
|
|
1211
|
+
}
|
|
1212
|
+
const braceFollows = bracePart === "{" || (j < tokens.length && (tokens[j] === "{" || (tokens[j] ?? "").startsWith("{")));
|
|
1213
|
+
if (!braceFollows) {
|
|
1214
|
+
return [...tokens];
|
|
1215
|
+
}
|
|
1216
|
+
const collapsed = bracePart === "{" ? `${name}(){` : `${name}()`;
|
|
1217
|
+
return [...tokens.slice(0, i), collapsed, ...tokens.slice(j)];
|
|
1218
|
+
}
|
|
484
1219
|
// Drop shell grouping punctuation: a subshell `(…)`, a brace group `{ …; }`, and
|
|
485
1220
|
// stray `;` so the wrapped command underneath is reached.
|
|
486
1221
|
function stripGrouping(tokens) {
|
|
@@ -492,15 +1227,40 @@ function stripGrouping(tokens) {
|
|
|
492
1227
|
return out.filter((t) => t.length > 0);
|
|
493
1228
|
}
|
|
494
1229
|
function commandTokens(segment) {
|
|
495
|
-
const lexed = lexSegment(segment);
|
|
496
|
-
|
|
1230
|
+
const lexed = lexSegment(segment.replace(IFS_OBFUSCATION, " "));
|
|
1231
|
+
const normalized = glueFunctionDefParens(lexed.tokens);
|
|
1232
|
+
let tokens = stripGrouping(normalized);
|
|
497
1233
|
let viaXargs = false;
|
|
498
|
-
const
|
|
1234
|
+
const inlineEnv = {};
|
|
1235
|
+
const x = () => ({
|
|
1236
|
+
...(viaXargs ? { viaXargs: true } : {}),
|
|
1237
|
+
...(Object.keys(inlineEnv).length > 0 ? { inlineEnv } : {}),
|
|
1238
|
+
});
|
|
499
1239
|
for (;;) {
|
|
500
1240
|
while (tokens.length > 0 && ENV_ASSIGNMENT.test(tokens[0] ?? "")) {
|
|
1241
|
+
// An inline per-command assignment (`NPM_CONFIG_REGISTRY=https://evil npm i x`,
|
|
1242
|
+
// also behind `env VAR=val …`) is applied by the shell to the install, so a
|
|
1243
|
+
// registry/index override here must defer just like the --flag / process-env
|
|
1244
|
+
// forms. Capture it for the alternate-index check rather than dropping it.
|
|
1245
|
+
const assignment = tokens[0] ?? "";
|
|
1246
|
+
const eq = assignment.indexOf("=");
|
|
1247
|
+
inlineEnv[assignment.slice(0, eq)] = assignment.slice(eq + 1);
|
|
501
1248
|
tokens = tokens.slice(1);
|
|
502
1249
|
}
|
|
503
1250
|
const kw = tokens[0] ?? "";
|
|
1251
|
+
if (kw === "coproc") {
|
|
1252
|
+
// `coproc <cmd>`, anonymous `coproc { <cmd>; }`, and named `coproc NAME
|
|
1253
|
+
// { <cmd>; }`. Only the NAMED form has a name token to drop — detect it in
|
|
1254
|
+
// the pre-stripGrouping order (a bare name immediately followed by `{`), so
|
|
1255
|
+
// the anonymous form's command head (`npm`) is never mistaken for the name.
|
|
1256
|
+
const ci = normalized.indexOf("coproc");
|
|
1257
|
+
const named = ci >= 0 && normalized[ci + 2] === "{" && /^[A-Za-z_]\w*$/.test(normalized[ci + 1] ?? "");
|
|
1258
|
+
tokens = tokens.slice(1);
|
|
1259
|
+
if (named) {
|
|
1260
|
+
tokens = tokens.slice(1);
|
|
1261
|
+
}
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
504
1264
|
if (SHELL_KEYWORDS.has(kw)) {
|
|
505
1265
|
tokens = tokens.slice(1);
|
|
506
1266
|
continue;
|
|
@@ -516,13 +1276,52 @@ function commandTokens(segment) {
|
|
|
516
1276
|
tokens = tokens.slice(1);
|
|
517
1277
|
continue;
|
|
518
1278
|
}
|
|
1279
|
+
// A function DEFINITION whose body holds a real install: `f(){ npm i x; }`,
|
|
1280
|
+
// `f() { … }`, `function f { … }`, `function f() { … }`. The segmenter split
|
|
1281
|
+
// on `;`, so the body lands in this segment behind the def prefix — strip the
|
|
1282
|
+
// prefix (and any glued `{`) so the body command is classified, not the def.
|
|
1283
|
+
if (kw === "function") {
|
|
1284
|
+
tokens = tokens.slice(1);
|
|
1285
|
+
if (/^[A-Za-z_][\w-]*(?:\(\))?\{?$/.test(tokens[0] ?? "")) {
|
|
1286
|
+
tokens = tokens.slice(1);
|
|
1287
|
+
}
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (/^[A-Za-z_][\w-]*\(\)\{?$/.test(kw)) {
|
|
1291
|
+
tokens = tokens.slice(1);
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
519
1294
|
const head = commandBasename(tokens[0] ?? "");
|
|
520
1295
|
if (head === "eval" && tokens.length > 1) {
|
|
521
1296
|
return { tokens, ok: lexed.ok, shellScript: tokens.slice(1).join(" "), ...x() };
|
|
522
1297
|
}
|
|
1298
|
+
if (head === "trap") {
|
|
1299
|
+
// `trap 'COMMANDS' SIGSPEC` runs COMMANDS when the trap fires — the same
|
|
1300
|
+
// run-attacker-text-as-script shape as eval/sh -c. Re-parse the action.
|
|
1301
|
+
// Skip the list/print flags (-l/-p) and the `--` separator; the reset form
|
|
1302
|
+
// (`trap - EXIT`) and the bare list forms carry no action to screen.
|
|
1303
|
+
let k = 1;
|
|
1304
|
+
while (k < tokens.length && (tokens[k] ?? "").startsWith("-") && tokens[k] !== "-") {
|
|
1305
|
+
const consumedSeparator = tokens[k] === "--";
|
|
1306
|
+
k += 1;
|
|
1307
|
+
if (consumedSeparator) {
|
|
1308
|
+
break;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
const action = tokens[k];
|
|
1312
|
+
if (action !== undefined && action !== "-") {
|
|
1313
|
+
return { tokens, ok: lexed.ok, shellScript: action, ...x() };
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
523
1316
|
if (SHELL_EXEC.has(head)) {
|
|
524
1317
|
const ci = tokens.indexOf("-c");
|
|
525
|
-
|
|
1318
|
+
let script = ci >= 0 ? tokens[ci + 1] : undefined;
|
|
1319
|
+
if (script === undefined && tokens.slice(1).some((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t))) {
|
|
1320
|
+
// A combined short-flag cluster carrying -c (`bash -lc`, `sh -cx`,
|
|
1321
|
+
// `bash -ic`): bash reads the command string from the first operand
|
|
1322
|
+
// after the option group, so it is the first non-flag token.
|
|
1323
|
+
script = tokens.slice(1).find((t) => !t.startsWith("-"));
|
|
1324
|
+
}
|
|
526
1325
|
if (script !== undefined) {
|
|
527
1326
|
return { tokens, ok: lexed.ok, shellScript: script, ...x() };
|
|
528
1327
|
}
|
|
@@ -552,6 +1351,67 @@ function commandTokens(segment) {
|
|
|
552
1351
|
continue;
|
|
553
1352
|
}
|
|
554
1353
|
}
|
|
1354
|
+
if (head === "env") {
|
|
1355
|
+
// `env -S '<cmd args>'` / `env --split-string=<cmd>` splits the string
|
|
1356
|
+
// into argv and runs it — a real exec path, so re-parse it as a script.
|
|
1357
|
+
for (let k = 1; k < tokens.length; k += 1) {
|
|
1358
|
+
const t = tokens[k] ?? "";
|
|
1359
|
+
const next = tokens[k + 1];
|
|
1360
|
+
if ((t === "-S" || t === "--split-string") && next !== undefined) {
|
|
1361
|
+
return { tokens, ok: lexed.ok, shellScript: next, ...x() };
|
|
1362
|
+
}
|
|
1363
|
+
const glued = /^(?:-S|--split-string=)(.+)$/.exec(t);
|
|
1364
|
+
if (glued && glued[1]) {
|
|
1365
|
+
return { tokens, ok: lexed.ok, shellScript: glued[1], ...x() };
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (head === "su" || head === "runuser" || head === "flock") {
|
|
1370
|
+
// su/runuser run a `-c '<script>'` (also `--session-command`) value through
|
|
1371
|
+
// a login shell; flock runs its `-c '<command>'` value through $SHELL. Find
|
|
1372
|
+
// that script and re-parse it so a wrapped install can't hide behind the
|
|
1373
|
+
// privilege / lock prefix.
|
|
1374
|
+
for (let k = 1; k < tokens.length; k += 1) {
|
|
1375
|
+
const t = tokens[k] ?? "";
|
|
1376
|
+
const next = tokens[k + 1];
|
|
1377
|
+
if ((t === "-c" || t === "--command" || t === "--session-command") && next !== undefined) {
|
|
1378
|
+
return { tokens, ok: lexed.ok, shellScript: next, ...x() };
|
|
1379
|
+
}
|
|
1380
|
+
const glued = /^(?:-c|--command=|--session-command=)(.+)$/.exec(t);
|
|
1381
|
+
if (glued && glued[1]) {
|
|
1382
|
+
return { tokens, ok: lexed.ok, shellScript: glued[1], ...x() };
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (head === "flock") {
|
|
1387
|
+
// No `-c` form: `flock [opts] <lockfile|fd> <command...>`. Skip flock's own
|
|
1388
|
+
// options (and the values of -w/-E), drop the one mandatory lock operand,
|
|
1389
|
+
// then re-enter the loop with the wrapped command at the head.
|
|
1390
|
+
const flockValueFlags = new Set(["-w", "--timeout", "-E", "--conflict-exit-code"]);
|
|
1391
|
+
let k = 1;
|
|
1392
|
+
while (k < tokens.length && (tokens[k] ?? "").startsWith("-")) {
|
|
1393
|
+
const t = tokens[k] ?? "";
|
|
1394
|
+
k += 1;
|
|
1395
|
+
if (flockValueFlags.has(t) && k < tokens.length && !(tokens[k] ?? "").startsWith("-")) {
|
|
1396
|
+
k += 1;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
if (k < tokens.length) {
|
|
1400
|
+
k += 1;
|
|
1401
|
+
}
|
|
1402
|
+
tokens = tokens.slice(k);
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (head === "gosu" || head === "su-exec") {
|
|
1406
|
+
// `gosu user[:group] <command...>` / `su-exec` — drop the head and the one
|
|
1407
|
+
// mandatory user operand, then re-enter the loop with the wrapped command.
|
|
1408
|
+
let k = 1;
|
|
1409
|
+
if (k < tokens.length && !(tokens[k] ?? "").startsWith("-")) {
|
|
1410
|
+
k += 1;
|
|
1411
|
+
}
|
|
1412
|
+
tokens = tokens.slice(k);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
555
1415
|
const spec = WRAPPERS.get(head);
|
|
556
1416
|
if (head === "" || spec === undefined) {
|
|
557
1417
|
return { tokens, ok: lexed.ok, ...x() };
|
|
@@ -560,35 +1420,57 @@ function commandTokens(segment) {
|
|
|
560
1420
|
viaXargs = true;
|
|
561
1421
|
}
|
|
562
1422
|
tokens = tokens.slice(1);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (
|
|
1423
|
+
// Consume leading wrapper option words and a bare value (timeout's
|
|
1424
|
+
// duration, chrt's priority) in any order, so the real command head is
|
|
1425
|
+
// reached even when the bare value follows the flags (`timeout -s KILL 60
|
|
1426
|
+
// npm i x`).
|
|
1427
|
+
for (;;) {
|
|
1428
|
+
const t0 = tokens[0];
|
|
1429
|
+
if (t0 !== undefined && spec.bareValue && !t0.startsWith("-") && spec.bareValue.test(t0)) {
|
|
570
1430
|
tokens = tokens.slice(1);
|
|
1431
|
+
continue;
|
|
571
1432
|
}
|
|
1433
|
+
if (t0 !== undefined && t0.startsWith("-")) {
|
|
1434
|
+
tokens = tokens.slice(1);
|
|
1435
|
+
const consume = spec.tripleValueFlags?.has(t0) ? 3 : spec.dualValueFlags?.has(t0) ? 2 : spec.valueFlags.has(t0) ? 1 : 0;
|
|
1436
|
+
for (let c = 0; c < consume && tokens.length > 0 && !(tokens[0] ?? "").startsWith("-"); c += 1) {
|
|
1437
|
+
tokens = tokens.slice(1);
|
|
1438
|
+
}
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
break;
|
|
572
1442
|
}
|
|
573
1443
|
}
|
|
574
1444
|
}
|
|
575
1445
|
function parseSpecToken(eco, token) {
|
|
576
1446
|
if (eco === "npm") {
|
|
1447
|
+
// `alias@npm:real-pkg[@version]` installs real-pkg under a local alias, so
|
|
1448
|
+
// the package that actually lands is the alias target — screen THAT, not the
|
|
1449
|
+
// alias name.
|
|
1450
|
+
const alias = token.indexOf("@npm:");
|
|
1451
|
+
if (alias > 0) {
|
|
1452
|
+
return parseSpecToken("npm", token.slice(alias + "@npm:".length));
|
|
1453
|
+
}
|
|
577
1454
|
const at = token.lastIndexOf("@");
|
|
578
1455
|
if (at > 0) {
|
|
579
1456
|
return { name: token.slice(0, at), version: token.slice(at + 1) || null };
|
|
580
1457
|
}
|
|
581
1458
|
return { name: token, version: null };
|
|
582
1459
|
}
|
|
583
|
-
// pypi: only an exact `name==version` is a pinned version; ranges are
|
|
584
|
-
|
|
1460
|
+
// pypi: only an exact `name[extras]==version` is a pinned version; ranges are
|
|
1461
|
+
// unpinned. The optional `[extras]` is dropped from the screened name.
|
|
1462
|
+
const exact = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?==([^,;\s]+)$/.exec(token);
|
|
585
1463
|
if (exact && exact[1] && exact[2]) {
|
|
586
1464
|
return { name: exact[1], version: exact[2] };
|
|
587
1465
|
}
|
|
588
|
-
const ranged = /^([A-Za-z0-9._-]+)
|
|
1466
|
+
const ranged = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*(?:===|>=|<=|~=|!=|<|>)/.exec(token);
|
|
589
1467
|
if (ranged && ranged[1]) {
|
|
590
1468
|
return { name: ranged[1], version: null };
|
|
591
1469
|
}
|
|
1470
|
+
const bare = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?$/.exec(token);
|
|
1471
|
+
if (bare && bare[1]) {
|
|
1472
|
+
return { name: bare[1], version: null };
|
|
1473
|
+
}
|
|
592
1474
|
return { name: token, version: null };
|
|
593
1475
|
}
|
|
594
1476
|
// A token built from a shell variable or substitution — its real value is only
|
|
@@ -656,6 +1538,83 @@ function extractUvRunWithSpecs(rawArgs) {
|
|
|
656
1538
|
}
|
|
657
1539
|
return { specs, remoteUnverifiable, dynamic };
|
|
658
1540
|
}
|
|
1541
|
+
// A single brace list `pre{a,b,c}post` expands to `prea bpost cpost` in bash, so
|
|
1542
|
+
// `npm install {evil,lodash}` really installs both — expand it or the literal
|
|
1543
|
+
// `{evil,lodash}` is screened as one bogus name and the real packages are
|
|
1544
|
+
// missed. Sequence braces (`{1..5}`) and nesting are left intact (no comma).
|
|
1545
|
+
// A leftover comma-brace after expandBraces means the expansion was incomplete
|
|
1546
|
+
// (hit the cap, or malformed) — the caller defers rather than trusting the verb.
|
|
1547
|
+
const UNEXPANDED_BRACE = /\{[^{}]*,[^{}]*\}/;
|
|
1548
|
+
const BRACE_EXPANSION_CAP = 1024;
|
|
1549
|
+
// Full bash-style brace expansion: a word is the cartesian product over EVERY
|
|
1550
|
+
// brace group in it (recursively), e.g. `{i,ci}{,x}` -> i ix ci cix. The old
|
|
1551
|
+
// single-group form let an adjacent-group verb (`npm {i,ci}{,x} evil`) slip past
|
|
1552
|
+
// classification. Sequence braces (`{1..5}`, no top-level comma) and malformed/
|
|
1553
|
+
// comma-less groups stay literal. Over the cap we return the token literal; the
|
|
1554
|
+
// caller treats a surviving comma-brace as undecidable (fail closed).
|
|
1555
|
+
function expandBraces(token) {
|
|
1556
|
+
const out = expandBracesInto(token, []);
|
|
1557
|
+
return out.length > 0 && out.length <= BRACE_EXPANSION_CAP ? out : [token];
|
|
1558
|
+
}
|
|
1559
|
+
function expandBracesInto(token, acc) {
|
|
1560
|
+
if (acc.length > BRACE_EXPANSION_CAP) {
|
|
1561
|
+
return acc;
|
|
1562
|
+
}
|
|
1563
|
+
let depth = 0;
|
|
1564
|
+
let open = -1;
|
|
1565
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
1566
|
+
const c = token[i];
|
|
1567
|
+
if (c === "{") {
|
|
1568
|
+
if (depth === 0) {
|
|
1569
|
+
open = i;
|
|
1570
|
+
}
|
|
1571
|
+
depth += 1;
|
|
1572
|
+
}
|
|
1573
|
+
else if (c === "}") {
|
|
1574
|
+
depth -= 1;
|
|
1575
|
+
if (depth === 0 && open >= 0) {
|
|
1576
|
+
const inner = token.slice(open + 1, i);
|
|
1577
|
+
const parts = splitTopLevelCommas(inner);
|
|
1578
|
+
if (parts.length > 1) {
|
|
1579
|
+
const prefix = token.slice(0, open);
|
|
1580
|
+
const suffix = token.slice(i + 1);
|
|
1581
|
+
for (const part of parts) {
|
|
1582
|
+
expandBracesInto(`${prefix}${part}${suffix}`, acc);
|
|
1583
|
+
if (acc.length > BRACE_EXPANSION_CAP) {
|
|
1584
|
+
return acc;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return acc;
|
|
1588
|
+
}
|
|
1589
|
+
open = -1;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
acc.push(token);
|
|
1594
|
+
return acc;
|
|
1595
|
+
}
|
|
1596
|
+
// Split on commas that are NOT inside a nested {...}; returns one element when
|
|
1597
|
+
// there is no top-level comma (so `{1..5}` / `{a}` stay a single literal group).
|
|
1598
|
+
function splitTopLevelCommas(inner) {
|
|
1599
|
+
const parts = [];
|
|
1600
|
+
let depth = 0;
|
|
1601
|
+
let start = 0;
|
|
1602
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
1603
|
+
const c = inner[i];
|
|
1604
|
+
if (c === "{") {
|
|
1605
|
+
depth += 1;
|
|
1606
|
+
}
|
|
1607
|
+
else if (c === "}") {
|
|
1608
|
+
depth -= 1;
|
|
1609
|
+
}
|
|
1610
|
+
else if (c === "," && depth === 0) {
|
|
1611
|
+
parts.push(inner.slice(start, i));
|
|
1612
|
+
start = i + 1;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
parts.push(inner.slice(start));
|
|
1616
|
+
return parts.length > 1 ? parts : [];
|
|
1617
|
+
}
|
|
659
1618
|
function extractSpecs(manager, eco, rawArgs) {
|
|
660
1619
|
if (manager === "uv" && rawArgs.find((a) => !a.startsWith("-")) === "run") {
|
|
661
1620
|
return extractUvRunWithSpecs(rawArgs);
|
|
@@ -663,6 +1622,11 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
663
1622
|
const valueFlags = eco === "pypi" ? PIP_VALUE_FLAGS : NPM_VALUE_FLAGS;
|
|
664
1623
|
const noVerb = manager === "npx" || manager === "pnpx" || manager === "uvx";
|
|
665
1624
|
const positionals = [];
|
|
1625
|
+
// `npx/npm exec/pnpm dlx --package=<pkg>` and `pipx run --spec=<pkg>` fetch the
|
|
1626
|
+
// flag's value, not the trailing bin — screen those too or `--package=evil`
|
|
1627
|
+
// rides through while only the benign bin name is checked (FW-15).
|
|
1628
|
+
const packageFlagLong = eco === "pypi" ? "--spec" : "--package";
|
|
1629
|
+
const packageFlagTokens = [];
|
|
666
1630
|
// Most managers have a one-word verb (`pip install <pkg>`). `uv pip install`,
|
|
667
1631
|
// `uv tool install`, and `pipx inject <venv> <pkg>` are two leading tokens
|
|
668
1632
|
// before the package, so consume the extra leading positional or the scan
|
|
@@ -670,12 +1634,28 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
670
1634
|
let verbWords = noVerb ? 0 : 1;
|
|
671
1635
|
let consumed = 0;
|
|
672
1636
|
let firstSeen = false;
|
|
1637
|
+
let verb = "";
|
|
673
1638
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
674
1639
|
const a = rawArgs[i];
|
|
675
1640
|
if (a === undefined) {
|
|
676
1641
|
continue;
|
|
677
1642
|
}
|
|
678
1643
|
if (a.startsWith("-")) {
|
|
1644
|
+
const eq = a.indexOf("=");
|
|
1645
|
+
const flagName = eq >= 0 ? a.slice(0, eq) : a;
|
|
1646
|
+
if (flagName === packageFlagLong || (noVerb && eco !== "pypi" && flagName === "-p")) {
|
|
1647
|
+
if (eq >= 0) {
|
|
1648
|
+
packageFlagTokens.push(a.slice(eq + 1));
|
|
1649
|
+
}
|
|
1650
|
+
else {
|
|
1651
|
+
const next = rawArgs[i + 1];
|
|
1652
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
1653
|
+
packageFlagTokens.push(next);
|
|
1654
|
+
i += 1;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
679
1659
|
if (valueFlags.has(a)) {
|
|
680
1660
|
i += 1;
|
|
681
1661
|
}
|
|
@@ -683,6 +1663,7 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
683
1663
|
}
|
|
684
1664
|
if (!firstSeen) {
|
|
685
1665
|
firstSeen = true;
|
|
1666
|
+
verb = a;
|
|
686
1667
|
if (manager === "uv" && (a === "pip" || a === "tool")) {
|
|
687
1668
|
verbWords = 2;
|
|
688
1669
|
}
|
|
@@ -696,7 +1677,11 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
696
1677
|
}
|
|
697
1678
|
positionals.push(a);
|
|
698
1679
|
}
|
|
699
|
-
const
|
|
1680
|
+
const onlyFirstPositional = noVerb || RUNNER_VERBS.has(verb);
|
|
1681
|
+
const tokens = [
|
|
1682
|
+
...(onlyFirstPositional ? positionals.slice(0, 1) : positionals),
|
|
1683
|
+
...packageFlagTokens,
|
|
1684
|
+
].flatMap(expandBraces);
|
|
700
1685
|
const specs = [];
|
|
701
1686
|
const remoteUnverifiable = [];
|
|
702
1687
|
const dynamic = [];
|
|
@@ -768,6 +1753,11 @@ function extractAlternateIndexes(eco, rawArgs, env) {
|
|
|
768
1753
|
value = a.slice(f.length + 1);
|
|
769
1754
|
break;
|
|
770
1755
|
}
|
|
1756
|
+
// Glued short form: -ihttps://… / -fhttps://… (pip accepts no separator).
|
|
1757
|
+
if (f.length === 2 && a.startsWith(f) && a.length > 2 && a[2] !== "=") {
|
|
1758
|
+
value = a.slice(2);
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
771
1761
|
}
|
|
772
1762
|
}
|
|
773
1763
|
if (value !== undefined && isAlternateIndexValue(value)) {
|
|
@@ -856,6 +1846,52 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
|
|
|
856
1846
|
if (UNSUPPORTED_RUNNERS.has(manager) && verb0 && !isDynamicToken(verb0)) {
|
|
857
1847
|
return undecidableVerdict(env, `dg cannot statically screen a ${manager} fetch-and-run; it is covered only when the network gate is on`);
|
|
858
1848
|
}
|
|
1849
|
+
const head0 = tokens[0] ?? "";
|
|
1850
|
+
// A command name built from a substitution (`$(echo npm) install x`,
|
|
1851
|
+
// `` `which npm` install x ``, `sh -c "$(…)"`) runs whatever it outputs —
|
|
1852
|
+
// statically undecidable, so always defer. A bare `$VAR` head only defers
|
|
1853
|
+
// when an install verb follows, so a benign `$EDITOR file` is not nagged.
|
|
1854
|
+
if (head0.startsWith("$(") || head0.includes("`") || (isDynamicToken(head0) && tokens.slice(1).some((t) => INSTALL_VERBS.has(t)))) {
|
|
1855
|
+
return undecidableVerdict(env, `dg cannot statically resolve a dynamically-built command (${head0}) that may install packages`);
|
|
1856
|
+
}
|
|
1857
|
+
// Backstop for an UNKNOWN exec-prefix wrapper (a privilege/sandbox/init tool
|
|
1858
|
+
// not in WRAPPERS): if its operand is itself a package-manager install
|
|
1859
|
+
// (`<wrapper> [flags] <pm> <install-verb> …`), defer rather than wave it
|
|
1860
|
+
// through, so a wrapper the allowlist hasn't enumerated can't silently re-open
|
|
1861
|
+
// the firewall. The `<wrapper> <pm> <verb>` shape skips tools with a
|
|
1862
|
+
// subcommand/target before the manager (docker run …, ssh host …); DATA_HEADS
|
|
1863
|
+
// skips commands that take a manager NAME as literal data (grep/echo/…).
|
|
1864
|
+
if (manager && !DATA_HEADS.has(manager) && !isDynamicToken(head0)) {
|
|
1865
|
+
const rest = tokens.slice(1);
|
|
1866
|
+
let i = 0;
|
|
1867
|
+
while (i < rest.length && (rest[i] ?? "").startsWith("-")) {
|
|
1868
|
+
const flag = rest[i] ?? "";
|
|
1869
|
+
i += 1;
|
|
1870
|
+
// A bare (non `=`) leading flag of an unknown wrapper may take a separate
|
|
1871
|
+
// value (`systemd-run -p MemoryMax=1G …`, `proot -b /tmp …`); consume it
|
|
1872
|
+
// so the scan reaches the real wrapped head — but never swallow another
|
|
1873
|
+
// flag or a package-manager token, so `ssh -p 22 host …` still stops at
|
|
1874
|
+
// the host and `wrapper --foo npm …` still stops at npm.
|
|
1875
|
+
if (!flag.includes("=")) {
|
|
1876
|
+
const value = rest[i];
|
|
1877
|
+
if (value !== undefined &&
|
|
1878
|
+
!value.startsWith("-") &&
|
|
1879
|
+
!isSupportedPackageManager(normalizeManagerName(commandBasename(value)))) {
|
|
1880
|
+
i += 1;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
const operandManager = normalizeManagerName(commandBasename(rest[i] ?? ""));
|
|
1885
|
+
// Reuse the classifier (the single source of truth) rather than re-deriving
|
|
1886
|
+
// install-ness from a verb table — that catches bare `yarn` (no verb, but an
|
|
1887
|
+
// install of every lockfile dep), npx/pnpx/uvx, and any future protected
|
|
1888
|
+
// shape, with no drift. A non-protected operand (yarn --version, a non-pm
|
|
1889
|
+
// subcommand like `docker run`) stays allow.
|
|
1890
|
+
if (isSupportedPackageManager(operandManager) &&
|
|
1891
|
+
classifyPackageManagerInvocation(operandManager, rest.slice(i + 1)).kind === "protected") {
|
|
1892
|
+
return undecidableVerdict(env, `dg cannot statically screen an install behind an unrecognized wrapper (${manager}); it is covered only when the network gate is on`);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
859
1895
|
return ALLOW;
|
|
860
1896
|
}
|
|
861
1897
|
if (!ok) {
|
|
@@ -863,7 +1899,16 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
|
|
|
863
1899
|
// (unbalanced quote / trailing escape). Never wave it through.
|
|
864
1900
|
return { decision: "ask", reason: `dg could not safely parse this ${manager} command, so it cannot be verified` };
|
|
865
1901
|
}
|
|
866
|
-
|
|
1902
|
+
// Brace-expand the args the shell would (`npm {install,ci} evil` -> install +
|
|
1903
|
+
// ci) BEFORE classification: the lexer keeps a comma-brace as one literal
|
|
1904
|
+
// token, so a braced verb (`{install,ci}`) otherwise matches no install verb
|
|
1905
|
+
// and waves the install through. extractSpecs re-expands the specs idempotently.
|
|
1906
|
+
const args = tokens.slice(1).flatMap(expandBraces);
|
|
1907
|
+
if (args.some((a) => UNEXPANDED_BRACE.test(a))) {
|
|
1908
|
+
// A comma-brace survived expansion (over the cap, or malformed) — we can't
|
|
1909
|
+
// know which install the shell would run, so defer rather than wave it through.
|
|
1910
|
+
return undecidableVerdict(env, "dg cannot statically expand a brace-heavy command that may install packages");
|
|
1911
|
+
}
|
|
867
1912
|
const verb = args.find((a) => !a.startsWith("-")) ?? "";
|
|
868
1913
|
if (isDynamicToken(verb)) {
|
|
869
1914
|
// A dynamically-built subcommand (`npm $i pkg`) can't be classified — defer,
|
|
@@ -893,17 +1938,24 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
|
|
|
893
1938
|
// hostile repo can't slip a malicious direct package past the static hook;
|
|
894
1939
|
// the transitive tree stays the runtime network gate's job.
|
|
895
1940
|
let manifestTruncated = false;
|
|
1941
|
+
let manifestAlternateIndexes = [];
|
|
896
1942
|
if (specs.length === 0 && remoteUnverifiable.length === 0) {
|
|
897
1943
|
const manifest = eco === "npm" ? readNpmManifestSpecs(cwd) : readPipRequirementSpecs(args, cwd);
|
|
898
1944
|
if (manifest) {
|
|
899
1945
|
specs = manifest.specs;
|
|
900
1946
|
manifestTruncated = manifest.truncated;
|
|
1947
|
+
manifestAlternateIndexes = manifest.alternateIndexes ?? [];
|
|
901
1948
|
}
|
|
902
1949
|
}
|
|
903
1950
|
if (specs.some((s) => CONTROL_CHARS.test(s.name) || (s.version !== null && CONTROL_CHARS.test(s.version)))) {
|
|
904
1951
|
return { decision: "deny", reason: `dg refused a ${manager} install with a malformed package spec` };
|
|
905
1952
|
}
|
|
906
|
-
const alternateIndexes = extractAlternateIndexes(eco, args, env);
|
|
1953
|
+
const alternateIndexes = extractAlternateIndexes(eco, args, { ...env, ...(parsed.inlineEnv ?? {}) });
|
|
1954
|
+
for (const idx of manifestAlternateIndexes) {
|
|
1955
|
+
if (isAlternateIndexValue(idx)) {
|
|
1956
|
+
alternateIndexes.push(idx);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
907
1959
|
const unverifiableReasons = [];
|
|
908
1960
|
if (remoteUnverifiable.length > 0) {
|
|
909
1961
|
unverifiableReasons.push(`non-registry source${remoteUnverifiable.length > 1 ? "s" : ""}: ${remoteUnverifiable.join(", ")}`);
|
|
@@ -948,6 +2000,8 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
|
|
|
948
2000
|
ecosystem: eco,
|
|
949
2001
|
env,
|
|
950
2002
|
fetchImpl,
|
|
2003
|
+
publicFallback: true,
|
|
2004
|
+
bill: false,
|
|
951
2005
|
...(cooldown ? { cooldown: cooldown.param } : {}),
|
|
952
2006
|
});
|
|
953
2007
|
}
|