Skip to content

Commit aec5643

Browse files
authored
fix(interpreter): avoid lazy import in assignment path that trips defense-in-depth (#273) (#277)
Any non-export variable assignment (bare SECRET=s, prefixed SECRET=s cmd, or before a custom command) failed under defense-in-depth with 'dynamic import of Node.js builtin node:module is blocked during script execution'. The assignment path resolved isArray via await import(./expansion.js), whose bundled chunk imports node:module via the createRequire banner; the defense ESM resolve hook blocked it. expansion.js is already statically imported, so isArray now comes from that static import and both lazy imports are removed.
1 parent 38cede9 commit aec5643

3 files changed

Lines changed: 66 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"just-bash": patch
3+
---
4+
5+
interpreter: avoid lazy import in variable assignment path that trips defense-in-depth (fixes #273)
6+
7+
Any non-`export` variable assignment (bare `SECRET=s`, prefixed `SECRET=s cmd`,
8+
or before a custom command) failed with a defense-in-depth security violation
9+
(`dynamic import of Node.js builtin 'node:module' is blocked during script
10+
execution`), while plain commands and `export`-ed assignments passed.
11+
12+
`processScalarAssignment()` resolved `isArray` via `await import("./expansion.js")`
13+
in two spots. In the bundled `dist`, that dynamic `import()` marks `expansion.js`
14+
as a lazily-linked chunk whose `createRequire` banner imports `node:module`; the
15+
defense layer's ESM `resolve` hook blocks that builtin import when the sandbox is
16+
active and untrusted, so it blocked just-bash's own chunk load. The file already
17+
statically imports from `./expansion.js`, so `isArray` is now pulled from that
18+
static import and the two lazy imports are removed — no lazy `node:module`-bearing
19+
chunk is linked at runtime. No public API change.

packages/just-bash/src/interpreter/simple-command-assignments.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
expandWord,
2323
expandWordWithGlob,
2424
getArrayElements,
25+
isArray,
2526
} from "./expansion.js";
2627
import {
2728
parseKeyedElementFromWord,
@@ -785,7 +786,6 @@ async function processScalarAssignment(
785786
finalValue = "0";
786787
}
787788
} else {
788-
const { isArray } = await import("./expansion.js");
789789
const appendKey = isArray(ctx, targetName) ? `${targetName}_0` : targetName;
790790
finalValue = append ? (ctx.state.env.get(appendKey) || "") + value : value;
791791
}
@@ -799,7 +799,6 @@ async function processScalarAssignment(
799799
if (namerefArrayRef) {
800800
actualEnvKey = await computeNamerefArrayEnvKey(ctx, namerefArrayRef);
801801
} else {
802-
const { isArray } = await import("./expansion.js");
803802
if (isArray(ctx, targetName)) {
804803
actualEnvKey = `${targetName}_0`;
805804
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { readFileSync } from "node:fs";
2+
import { fileURLToPath } from "node:url";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { Bash } from "../Bash.js";
5+
import { DefenseInDepthBox } from "../security/defense-in-depth-box.js";
6+
7+
describe("temp env prefix under defense-in-depth", () => {
8+
afterEach(() => {
9+
DefenseInDepthBox.resetInstance();
10+
});
11+
12+
it("runs a command with a leading env assignment", async () => {
13+
const bash = new Bash({ defenseInDepth: true });
14+
const result = await bash.exec("FOO=bar true");
15+
16+
expect(result.stdout).toBe("");
17+
expect(result.stderr).toBe("");
18+
expect(result.exitCode).toBe(0);
19+
});
20+
21+
it("passes the temp binding through to the command", async () => {
22+
const bash = new Bash({ defenseInDepth: true });
23+
const result = await bash.exec("FOO=bar echo hi");
24+
25+
expect(result.stdout).toBe("hi\n");
26+
expect(result.stderr).toBe("");
27+
expect(result.exitCode).toBe(0);
28+
});
29+
30+
// Regression guard for the bundled-dist failure where a leading env
31+
// assignment (`FOO=bar cmd`) threw "security violation: dynamic import of
32+
// Node.js builtin 'node:module' is blocked during script execution". The
33+
// assignment path used `await import("./expansion.js")`, which in the dist
34+
// bundle links a lazy chunk whose static graph pulls in `node:module`; the
35+
// defense-in-depth ESM resolve hook then blocked it. expansion.js is already
36+
// statically imported here, so the lazy import must not be reintroduced.
37+
it("does not lazily import sibling interpreter modules", () => {
38+
const source = readFileSync(
39+
fileURLToPath(
40+
new URL("./simple-command-assignments.ts", import.meta.url),
41+
),
42+
"utf8",
43+
);
44+
expect(source).not.toMatch(/await\s+import\(/);
45+
});
46+
});

0 commit comments

Comments
 (0)