@checkstack/backend-api 0.20.0 → 0.21.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 +151 -0
- package/package.json +12 -11
- package/src/auth-strategy.ts +6 -3
- package/src/bearer-token.ts +13 -0
- package/src/collector-strategy.ts +9 -0
- package/src/config-versioning.test.ts +227 -0
- package/src/config-versioning.ts +172 -0
- package/src/core-services.ts +14 -0
- package/src/esm-script-runner.test.ts +55 -16
- package/src/esm-script-runner.ts +212 -55
- package/src/index.ts +3 -0
- package/src/render-templatable-config.test.ts +168 -0
- package/src/render-templatable-config.ts +193 -0
- package/src/schema-utils.ts +3 -0
- package/src/script-sandbox/capabilities.test.ts +122 -0
- package/src/script-sandbox/capabilities.ts +372 -0
- package/src/script-sandbox/capped-output.test.ts +116 -0
- package/src/script-sandbox/capped-output.ts +172 -0
- package/src/script-sandbox/env-guard.test.ts +105 -0
- package/src/script-sandbox/env-guard.ts +129 -0
- package/src/script-sandbox/filesystem.test.ts +437 -0
- package/src/script-sandbox/filesystem.ts +514 -0
- package/src/script-sandbox/forkbomb.it.test.ts +121 -0
- package/src/script-sandbox/global-default.test.ts +161 -0
- package/src/script-sandbox/global-default.ts +100 -0
- package/src/script-sandbox/index.ts +14 -0
- package/src/script-sandbox/network.test.ts +356 -0
- package/src/script-sandbox/network.ts +373 -0
- package/src/script-sandbox/observability.test.ts +210 -0
- package/src/script-sandbox/observability.ts +168 -0
- package/src/script-sandbox/output-truncation.test.ts +53 -0
- package/src/script-sandbox/output-truncation.ts +69 -0
- package/src/script-sandbox/policy.test.ts +189 -0
- package/src/script-sandbox/policy.ts +220 -0
- package/src/script-sandbox/provider.test.ts +61 -0
- package/src/script-sandbox/provider.ts +134 -0
- package/src/script-sandbox/readiness.test.ts +80 -0
- package/src/script-sandbox/readiness.ts +117 -0
- package/src/script-sandbox/report.ts +88 -0
- package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
- package/src/script-sandbox/rootless-egress.test.ts +99 -0
- package/src/script-sandbox/rootless-egress.ts +218 -0
- package/src/script-sandbox/shell-quote.test.ts +32 -0
- package/src/script-sandbox/shell-quote.ts +10 -0
- package/src/script-sandbox/wrapper.test.ts +1194 -0
- package/src/script-sandbox/wrapper.ts +714 -0
- package/src/shell-script-runner.test.ts +243 -0
- package/src/shell-script-runner.ts +210 -45
- package/src/zod-config.test.ts +60 -0
- package/src/zod-config.ts +38 -14
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { SandboxCapabilities } from "./capabilities";
|
|
3
|
+
import { DEFAULT_SANDBOX_PROFILE, sandboxPolicySchema } from "./policy";
|
|
4
|
+
import { SandboxUnavailableError } from "./report";
|
|
5
|
+
import { buildSpawnHardening } from "./wrapper";
|
|
6
|
+
|
|
7
|
+
const baseEnv = { PATH: "/usr/bin", HOME: "/home/u" };
|
|
8
|
+
|
|
9
|
+
const LINUX_ROOT: SandboxCapabilities = {
|
|
10
|
+
platform: "linux",
|
|
11
|
+
euidIsRoot: true,
|
|
12
|
+
hasPrlimit: true,
|
|
13
|
+
rlimitNative: true,
|
|
14
|
+
wrapper: null,
|
|
15
|
+
userNamespaces: true,
|
|
16
|
+
userNsCreatable: true,
|
|
17
|
+
netNamespaces: false,
|
|
18
|
+
netEgressIface: null,
|
|
19
|
+
netEgressAddressing: null,
|
|
20
|
+
netEgressRootless: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const LINUX_NONROOT_BWRAP: SandboxCapabilities = {
|
|
24
|
+
platform: "linux",
|
|
25
|
+
euidIsRoot: false,
|
|
26
|
+
hasPrlimit: true,
|
|
27
|
+
rlimitNative: true,
|
|
28
|
+
wrapper: "bwrap",
|
|
29
|
+
userNamespaces: true,
|
|
30
|
+
userNsCreatable: true,
|
|
31
|
+
netNamespaces: true,
|
|
32
|
+
netEgressIface: null, // bwrap cannot plumb egress (macvlan); rootless tested separately
|
|
33
|
+
netEgressAddressing: null,
|
|
34
|
+
netEgressRootless: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const LINUX_NONROOT_NSJAIL: SandboxCapabilities = {
|
|
38
|
+
...LINUX_NONROOT_BWRAP,
|
|
39
|
+
wrapper: "nsjail",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Root host WITH a bwrap wrapper + user namespaces: the wrapper can carry the
|
|
43
|
+
// `--uid` privilege drop (the only path that actually drops, since Bun.spawn
|
|
44
|
+
// ignores uid/gid). This is the container model the shipped images target.
|
|
45
|
+
const LINUX_NONROOT_BWRAP_ROOT: SandboxCapabilities = {
|
|
46
|
+
...LINUX_NONROOT_BWRAP,
|
|
47
|
+
euidIsRoot: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// nsjail host that CAN plumb real egress (root + uplink + static addressing) —
|
|
51
|
+
// required for a functional allowlist / metadata block.
|
|
52
|
+
const LINUX_ROOT_NSJAIL_PLUMBED: SandboxCapabilities = {
|
|
53
|
+
...LINUX_NONROOT_NSJAIL,
|
|
54
|
+
euidIsRoot: true,
|
|
55
|
+
netEgressIface: "eth0",
|
|
56
|
+
netEgressAddressing: {
|
|
57
|
+
ip: "10.0.0.2",
|
|
58
|
+
netmask: "255.255.255.0",
|
|
59
|
+
gateway: "10.0.0.1",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// bwrap host with the ROOTLESS slirp4netns egress path (no privileged macvlan):
|
|
64
|
+
// the common rootless-container case. allowlist + metadata block are deliverable
|
|
65
|
+
// here via the launcher, without root / uplink / operator addressing.
|
|
66
|
+
const LINUX_BWRAP_ROOTLESS: SandboxCapabilities = {
|
|
67
|
+
...LINUX_NONROOT_BWRAP,
|
|
68
|
+
netEgressRootless: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const MACOS_NONE: SandboxCapabilities = {
|
|
72
|
+
platform: "darwin",
|
|
73
|
+
euidIsRoot: false,
|
|
74
|
+
hasPrlimit: false,
|
|
75
|
+
rlimitNative: false,
|
|
76
|
+
wrapper: null,
|
|
77
|
+
userNamespaces: false,
|
|
78
|
+
userNsCreatable: false,
|
|
79
|
+
netNamespaces: false,
|
|
80
|
+
netEgressIface: null,
|
|
81
|
+
netEgressAddressing: null,
|
|
82
|
+
netEgressRootless: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe("buildSpawnHardening — disabled sandbox", () => {
|
|
86
|
+
it("behaves like the prior merge with no denylist / caps / uid", () => {
|
|
87
|
+
const policy = sandboxPolicySchema.parse({ enabled: false });
|
|
88
|
+
const h = buildSpawnHardening({
|
|
89
|
+
policy,
|
|
90
|
+
caps: LINUX_ROOT,
|
|
91
|
+
baseEnv,
|
|
92
|
+
envOverrides: { LD_PRELOAD: "/x.so", FOO: "bar" },
|
|
93
|
+
});
|
|
94
|
+
expect(h.wrapCmd(["sh", "-c", "x"])).toEqual(["sh", "-c", "x"]);
|
|
95
|
+
expect(h.env.LD_PRELOAD).toBe("/x.so"); // NOT dropped when disabled
|
|
96
|
+
expect(h.env.FOO).toBe("bar");
|
|
97
|
+
expect(h.uid).toBeUndefined();
|
|
98
|
+
expect(h.maxOutputBytes).toBeUndefined();
|
|
99
|
+
expect(h.droppedEnvKeys).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("buildSpawnHardening — resources on a capable Linux root host", () => {
|
|
104
|
+
// A genuinely capable scenario for the RESOURCE + PRIVILEGE layers. The
|
|
105
|
+
// privilege drop is performed by the WRAPPER (bwrap `--uid`), not by
|
|
106
|
+
// Bun.spawn (which ignores uid/gid), so RLIMIT_NPROC — which is per-UID and
|
|
107
|
+
// only safe once the drop is in effect — requires a wrapper-capable host AND
|
|
108
|
+
// FS confinement so the wrapper is engaged. A scratch dir is supplied so the
|
|
109
|
+
// bwrap prelude is built.
|
|
110
|
+
const h = buildSpawnHardening({
|
|
111
|
+
policy: sandboxPolicySchema.parse({
|
|
112
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
113
|
+
// Keep the network on the host so this stays a focused resource/privilege
|
|
114
|
+
// test (no egress-plumbing dependency).
|
|
115
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
116
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
117
|
+
}),
|
|
118
|
+
caps: LINUX_NONROOT_BWRAP_ROOT,
|
|
119
|
+
baseEnv,
|
|
120
|
+
envOverrides: {},
|
|
121
|
+
filesystem: { scratchDir: "/tmp/run-res" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("prepends an FS wrapper + prlimit prelude carrying the uid drop", () => {
|
|
125
|
+
const wrapped = h.wrapCmd(["sh", "-c", "echo hi"]);
|
|
126
|
+
expect(wrapped[0]).toBe("bwrap");
|
|
127
|
+
// The wrapper carries the privilege drop (the path that actually drops).
|
|
128
|
+
expect(wrapped).toContain("--uid");
|
|
129
|
+
expect(wrapped).toContain("1001");
|
|
130
|
+
expect(wrapped).toContain("--gid");
|
|
131
|
+
// prlimit applies INSIDE the wrapper, with all caps including nproc (safe
|
|
132
|
+
// now that the drop is genuinely in effect).
|
|
133
|
+
expect(wrapped).toContain("prlimit");
|
|
134
|
+
expect(wrapped).toContain("--cpu=60");
|
|
135
|
+
expect(wrapped).toContain("--nofile=1024");
|
|
136
|
+
expect(wrapped).toContain("--nproc=256");
|
|
137
|
+
expect(wrapped).toContain("--fsize=268435456");
|
|
138
|
+
// Memory is NOT capped via `--as` (RLIMIT_AS breaks the interpreter); it is
|
|
139
|
+
// the JS heap cap + the cgroup limit instead.
|
|
140
|
+
expect(wrapped.some((a) => a.startsWith("--as="))).toBe(false);
|
|
141
|
+
expect(wrapped.slice(-3)).toEqual(["sh", "-c", "echo hi"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("marks resources + privilege enforced and surfaces maxOutputBytes + heap cap", () => {
|
|
145
|
+
expect(h.effective.enforced.resources).toBe(true);
|
|
146
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
147
|
+
expect(h.maxOutputBytes).toBe(5 * 1024 * 1024);
|
|
148
|
+
// Memory enforced via the JS heap cap, not RLIMIT_AS.
|
|
149
|
+
expect(h.nodeMemoryFlagEnv?.NODE_OPTIONS).toContain("--max-old-space-size=512");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("drops privilege to the configured uid via the wrapper when euid is root + uid set", () => {
|
|
153
|
+
const withUid = buildSpawnHardening({
|
|
154
|
+
policy: sandboxPolicySchema.parse({
|
|
155
|
+
filesystem: { mode: "scratch-only" },
|
|
156
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
157
|
+
}),
|
|
158
|
+
caps: LINUX_NONROOT_BWRAP_ROOT,
|
|
159
|
+
baseEnv,
|
|
160
|
+
filesystem: { scratchDir: "/tmp/run-res2" },
|
|
161
|
+
});
|
|
162
|
+
// `h.uid`/`gid` are set for OBSERVABILITY (the wrapper carries the real
|
|
163
|
+
// `--uid` drop); the runner never passes them to Bun.spawn.
|
|
164
|
+
expect(withUid.uid).toBe(1001);
|
|
165
|
+
expect(withUid.gid).toBe(1001);
|
|
166
|
+
expect(withUid.effective.enforced.privilege).toBe(true);
|
|
167
|
+
expect(withUid.wrapCmd(["sh", "-c", "x"])).toContain("--uid");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("does NOT enforce privilege (and surfaces it) when no wrapper engages", () => {
|
|
171
|
+
// FS off + host net = no wrapper. Bun.spawn's uid is a silent no-op, so the
|
|
172
|
+
// drop cannot happen; the layer must report not-enforced + a downgrade
|
|
173
|
+
// rather than a false positive.
|
|
174
|
+
const noWrap = buildSpawnHardening({
|
|
175
|
+
policy: sandboxPolicySchema.parse({
|
|
176
|
+
onUnavailable: "degrade",
|
|
177
|
+
filesystem: { mode: "off" },
|
|
178
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
179
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
180
|
+
}),
|
|
181
|
+
caps: LINUX_NONROOT_BWRAP_ROOT,
|
|
182
|
+
baseEnv,
|
|
183
|
+
});
|
|
184
|
+
expect(noWrap.effective.enforced.privilege).toBe(false);
|
|
185
|
+
const reasons = noWrap.effective.downgrades
|
|
186
|
+
.filter((d) => d.layer === "privilege")
|
|
187
|
+
.map((d) => d.reason);
|
|
188
|
+
expect(reasons.some((r) => r.includes("namespace wrapper"))).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("buildSpawnHardening — degraded macOS host (onUnavailable: degrade)", () => {
|
|
193
|
+
// The shipped DEFAULT_SANDBOX_PROFILE is now fail-closed, so to exercise the
|
|
194
|
+
// degrade path on a capability-less host we explicitly use the opt-in
|
|
195
|
+
// `degrade` variant (the operator's documented escape hatch for weak hosts).
|
|
196
|
+
const DEGRADE_DEFAULT = sandboxPolicySchema.parse({
|
|
197
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
198
|
+
onUnavailable: "degrade",
|
|
199
|
+
});
|
|
200
|
+
const h = buildSpawnHardening({
|
|
201
|
+
policy: DEGRADE_DEFAULT,
|
|
202
|
+
caps: MACOS_NONE,
|
|
203
|
+
baseEnv,
|
|
204
|
+
envOverrides: {},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("does NOT hard-break: still returns a hardening result", () => {
|
|
208
|
+
expect(h).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not add a prlimit prelude (no rlimit capability)", () => {
|
|
212
|
+
expect(h.wrapCmd(["sh", "-c", "x"])).toEqual(["sh", "-c", "x"]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("surfaces the resource downgrade; privilege is enforced by non-root inheritance", () => {
|
|
216
|
+
const layers = h.effective.downgrades.map((d) => d.layer);
|
|
217
|
+
expect(layers).toContain("resources");
|
|
218
|
+
// Privilege is NOT a downgrade here: MACOS_NONE has a NON-root supervisor
|
|
219
|
+
// (euidIsRoot:false), so the child inherits non-root and cannot be
|
|
220
|
+
// host-root - enforced by construction even with no wrapper.
|
|
221
|
+
expect(layers).not.toContain("privilege");
|
|
222
|
+
expect(h.effective.enforced.resources).toBe(false);
|
|
223
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("still enforces the portable subset: output cap + ESM memory fallback", () => {
|
|
227
|
+
expect(h.maxOutputBytes).toBe(5 * 1024 * 1024);
|
|
228
|
+
expect(h.nodeMemoryFlagEnv?.NODE_OPTIONS).toContain("--max-old-space-size=");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("still applies the env denylist (portable, every platform)", () => {
|
|
232
|
+
const denied = buildSpawnHardening({
|
|
233
|
+
policy: DEGRADE_DEFAULT,
|
|
234
|
+
caps: MACOS_NONE,
|
|
235
|
+
baseEnv,
|
|
236
|
+
envOverrides: { LD_PRELOAD: "/x.so" },
|
|
237
|
+
});
|
|
238
|
+
expect(denied.env.LD_PRELOAD).toBeUndefined();
|
|
239
|
+
expect(denied.droppedEnvKeys).toContain("LD_PRELOAD");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("buildSpawnHardening — RLIMIT_NPROC fork-bomb cap (Item 1: per-run containment)", () => {
|
|
244
|
+
it("omits --nproc and surfaces a downgrade when no wrapper namespace engages (root, inherit)", () => {
|
|
245
|
+
// Root supervisor, no wrapper-capable host (wrapper:null) => no user
|
|
246
|
+
// namespace to isolate the count, and the child stays host-root: the cap
|
|
247
|
+
// would throttle the host uid, so it is omitted and surfaced.
|
|
248
|
+
const policy = sandboxPolicySchema.parse({
|
|
249
|
+
resources: { maxProcesses: 256, cpuSeconds: 10 },
|
|
250
|
+
privilege: { mode: "inherit" },
|
|
251
|
+
});
|
|
252
|
+
const h = buildSpawnHardening({ policy, caps: LINUX_ROOT, baseEnv });
|
|
253
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
254
|
+
expect(wrapped).toContain("--cpu=10");
|
|
255
|
+
expect(wrapped.some((a) => a.startsWith("--nproc="))).toBe(false);
|
|
256
|
+
const resourceDowngrade = h.effective.downgrades.find(
|
|
257
|
+
(d) => d.layer === "resources",
|
|
258
|
+
);
|
|
259
|
+
expect(resourceDowngrade?.reason).toContain("RLIMIT_NPROC");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("APPLIES --nproc under a NON-ROOT supervisor when the bwrap wrapper engages (the shipped model)", () => {
|
|
263
|
+
// The core fix: rootless `bwrap --unshare-all` creates a fresh USER
|
|
264
|
+
// namespace, so the per-(uid, userns) RLIMIT_NPROC isolates THIS run's
|
|
265
|
+
// process count even though the child shares the supervisor's uid 65532.
|
|
266
|
+
// No privilege drop, no root: the fork-bomb cap is genuinely per-run here.
|
|
267
|
+
const policy = sandboxPolicySchema.parse({
|
|
268
|
+
resources: { maxProcesses: 128 },
|
|
269
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
270
|
+
privilege: { mode: "drop-to-uid" }, // satisfied by inheritance (non-root)
|
|
271
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
272
|
+
});
|
|
273
|
+
const h = buildSpawnHardening({
|
|
274
|
+
policy,
|
|
275
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
276
|
+
baseEnv,
|
|
277
|
+
filesystem: { scratchDir: "/tmp/run-fb" },
|
|
278
|
+
});
|
|
279
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
280
|
+
// bwrap wrapper engaged (its own user + PID namespace) -> nproc applied.
|
|
281
|
+
expect(wrapped).toContain("bwrap");
|
|
282
|
+
expect(wrapped).toContain("--unshare-all");
|
|
283
|
+
expect(wrapped).toContain("--nproc=128");
|
|
284
|
+
// No spawn-level uid/gid (inheritance, not a wrapper --uid drop).
|
|
285
|
+
expect(h.uid).toBeUndefined();
|
|
286
|
+
// Privilege enforced by inheritance; resources enforced; NO nproc note.
|
|
287
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
288
|
+
expect(h.effective.enforced.resources).toBe(true);
|
|
289
|
+
expect(
|
|
290
|
+
h.effective.notes.some((n) => n.note.includes("RLIMIT_NPROC")),
|
|
291
|
+
).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("omits --nproc + emits a NON-FATAL note when no wrapper engages (non-root, FS off, host net)", () => {
|
|
295
|
+
// The corner case: non-root supervisor but FS off AND host net => no
|
|
296
|
+
// wrapper user namespace, so the child shares the supervisor's uid+userns
|
|
297
|
+
// and a per-uid cap would throttle the supervisor. Omitted, surfaced as a
|
|
298
|
+
// note (never a downgrade, so fail-closed still runs), never applied unsafely.
|
|
299
|
+
const policy = sandboxPolicySchema.parse({
|
|
300
|
+
resources: { maxProcesses: 256, cpuSeconds: 10 },
|
|
301
|
+
filesystem: { mode: "off" },
|
|
302
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
303
|
+
privilege: { mode: "inherit" },
|
|
304
|
+
});
|
|
305
|
+
const h = buildSpawnHardening({ policy, caps: LINUX_NONROOT_BWRAP, baseEnv });
|
|
306
|
+
expect(
|
|
307
|
+
h.wrapCmd(["sh", "-c", "x"]).some((a) => a.startsWith("--nproc=")),
|
|
308
|
+
).toBe(false);
|
|
309
|
+
expect(
|
|
310
|
+
h.effective.notes.some((n) => n.note.includes("RLIMIT_NPROC")),
|
|
311
|
+
).toBe(true);
|
|
312
|
+
// It is a NOTE, not a downgrade (must not trip onUnavailable: fail).
|
|
313
|
+
expect(
|
|
314
|
+
h.effective.downgrades.some((d) => d.layer === "resources"),
|
|
315
|
+
).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("emits --nproc when the privilege drop actually takes effect (wrapper carries --uid)", () => {
|
|
319
|
+
// The drop is performed by the wrapper, so it needs a wrapper-capable root
|
|
320
|
+
// host + FS confinement to engage. Only then is RLIMIT_NPROC safe.
|
|
321
|
+
const policy = sandboxPolicySchema.parse({
|
|
322
|
+
resources: { maxProcesses: 256 },
|
|
323
|
+
filesystem: { mode: "scratch-only" },
|
|
324
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
325
|
+
});
|
|
326
|
+
const h = buildSpawnHardening({
|
|
327
|
+
policy,
|
|
328
|
+
caps: LINUX_NONROOT_BWRAP_ROOT,
|
|
329
|
+
baseEnv,
|
|
330
|
+
filesystem: { scratchDir: "/tmp/run-nproc" },
|
|
331
|
+
});
|
|
332
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
333
|
+
expect(wrapped).toContain("--nproc=256");
|
|
334
|
+
expect(wrapped).toContain("--uid");
|
|
335
|
+
// `h.uid` is set for observability; the wrapper carries the real drop.
|
|
336
|
+
expect(h.uid).toBe(1001);
|
|
337
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("omits --nproc when drop-to-uid was requested but euid is not root", () => {
|
|
341
|
+
const policy = sandboxPolicySchema.parse({
|
|
342
|
+
resources: { maxProcesses: 256, cpuSeconds: 10 },
|
|
343
|
+
privilege: { mode: "drop-to-uid", uid: 1001 },
|
|
344
|
+
});
|
|
345
|
+
const h = buildSpawnHardening({ policy, caps: LINUX_NONROOT_BWRAP, baseEnv });
|
|
346
|
+
expect(h.wrapCmd(["sh", "-c", "x"]).some((a) => a.startsWith("--nproc="))).toBe(
|
|
347
|
+
false,
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("buildSpawnHardening — filesystem layer (Phase 2)", () => {
|
|
353
|
+
it("wraps the command in a bwrap prelude when FS isolation is enforceable", () => {
|
|
354
|
+
const policy = sandboxPolicySchema.parse({
|
|
355
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
356
|
+
privilege: { mode: "inherit" },
|
|
357
|
+
});
|
|
358
|
+
const h = buildSpawnHardening({
|
|
359
|
+
policy,
|
|
360
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
361
|
+
baseEnv,
|
|
362
|
+
filesystem: {
|
|
363
|
+
scratchDir: "/tmp/run-a",
|
|
364
|
+
nodeModulesDir: "/store/current/node_modules",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
368
|
+
expect(wrapped[0]).toBe("bwrap");
|
|
369
|
+
// The real command is still at the tail.
|
|
370
|
+
expect(wrapped.slice(-2)).toEqual([process.execPath, "runner.mjs"]);
|
|
371
|
+
expect(h.effective.enforced.filesystem).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("composes the FS wrapper OUTSIDE the prlimit prelude", () => {
|
|
375
|
+
const policy = sandboxPolicySchema.parse({
|
|
376
|
+
filesystem: { mode: "scratch-only" },
|
|
377
|
+
resources: { cpuSeconds: 30 },
|
|
378
|
+
privilege: { mode: "inherit" },
|
|
379
|
+
});
|
|
380
|
+
const h = buildSpawnHardening({
|
|
381
|
+
policy,
|
|
382
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
383
|
+
baseEnv,
|
|
384
|
+
filesystem: { scratchDir: "/tmp/run-b" },
|
|
385
|
+
});
|
|
386
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
387
|
+
const bwrapIdx = wrapped.indexOf("bwrap");
|
|
388
|
+
const prlimitIdx = wrapped.indexOf("prlimit");
|
|
389
|
+
expect(bwrapIdx).toBe(0);
|
|
390
|
+
expect(prlimitIdx).toBeGreaterThan(bwrapIdx);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("degrades FS to off + surfaces it on a no-wrapper host", () => {
|
|
394
|
+
const policy = sandboxPolicySchema.parse({
|
|
395
|
+
filesystem: { mode: "scratch-only" },
|
|
396
|
+
privilege: { mode: "inherit" },
|
|
397
|
+
});
|
|
398
|
+
const h = buildSpawnHardening({
|
|
399
|
+
policy,
|
|
400
|
+
caps: LINUX_ROOT, // wrapper: null
|
|
401
|
+
baseEnv,
|
|
402
|
+
filesystem: { scratchDir: "/tmp/run-c" },
|
|
403
|
+
});
|
|
404
|
+
expect(h.effective.enforced.filesystem).toBe(false);
|
|
405
|
+
expect(h.effective.downgrades.map((d) => d.layer)).toContain("filesystem");
|
|
406
|
+
// No bwrap/nsjail in the wrapped argv when degraded.
|
|
407
|
+
expect(h.wrapCmd(["sh", "-c", "x"])).not.toContain("bwrap");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("degrades FS when the runner provides no scratch dir (e.g. shell runner)", () => {
|
|
411
|
+
const policy = sandboxPolicySchema.parse({
|
|
412
|
+
filesystem: { mode: "scratch-only" },
|
|
413
|
+
privilege: { mode: "inherit" },
|
|
414
|
+
});
|
|
415
|
+
const h = buildSpawnHardening({
|
|
416
|
+
policy,
|
|
417
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
418
|
+
baseEnv,
|
|
419
|
+
// no filesystem inputs
|
|
420
|
+
});
|
|
421
|
+
expect(h.effective.enforced.filesystem).toBe(false);
|
|
422
|
+
const fsDowngrade = h.effective.downgrades.find(
|
|
423
|
+
(d) => d.layer === "filesystem",
|
|
424
|
+
);
|
|
425
|
+
expect(fsDowngrade?.reason).toContain("scratch dir");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("fail-closed: FS unenforceable + onUnavailable:fail throws before spawn", () => {
|
|
429
|
+
const policy = sandboxPolicySchema.parse({
|
|
430
|
+
onUnavailable: "fail",
|
|
431
|
+
filesystem: { mode: "scratch-only" },
|
|
432
|
+
privilege: { mode: "inherit" },
|
|
433
|
+
network: { denyLinkLocalAndMetadata: false },
|
|
434
|
+
});
|
|
435
|
+
expect(() =>
|
|
436
|
+
buildSpawnHardening({
|
|
437
|
+
policy,
|
|
438
|
+
caps: LINUX_ROOT, // no wrapper
|
|
439
|
+
baseEnv,
|
|
440
|
+
filesystem: { scratchDir: "/tmp/run-d" },
|
|
441
|
+
}),
|
|
442
|
+
).toThrow(SandboxUnavailableError);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("buildSpawnHardening — filesystem interpreter bind (P2 fix)", () => {
|
|
447
|
+
it("ro-binds the interpreter when it lives outside the base paths", () => {
|
|
448
|
+
const policy = sandboxPolicySchema.parse({
|
|
449
|
+
filesystem: { mode: "scratch-only" },
|
|
450
|
+
privilege: { mode: "inherit" },
|
|
451
|
+
network: { denyLinkLocalAndMetadata: false },
|
|
452
|
+
});
|
|
453
|
+
const h = buildSpawnHardening({
|
|
454
|
+
policy,
|
|
455
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
456
|
+
baseEnv,
|
|
457
|
+
filesystem: {
|
|
458
|
+
scratchDir: "/tmp/run-i",
|
|
459
|
+
interpreterPath: "/home/u/.bun/bin/bun",
|
|
460
|
+
},
|
|
461
|
+
// pathExists/realpath are real here; assert via wrapping the prelude.
|
|
462
|
+
});
|
|
463
|
+
const wrapped = h.wrapCmd(["/home/u/.bun/bin/bun", "runner.mjs"]).join(" ");
|
|
464
|
+
// Bind only emitted when the interpreter exists on disk; this is a unit
|
|
465
|
+
// host where it likely does not, so we assert the COMPOSITION shape instead
|
|
466
|
+
// in the dedicated filesystem.test. Here we at least confirm no crash and a
|
|
467
|
+
// bwrap prelude.
|
|
468
|
+
expect(wrapped.startsWith("bwrap")).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("buildSpawnHardening — TMPDIR override under FS confinement (P2 fix)", () => {
|
|
473
|
+
it("pins TMPDIR=/tmp when the FS wrapper is enforced", () => {
|
|
474
|
+
const policy = sandboxPolicySchema.parse({
|
|
475
|
+
filesystem: { mode: "scratch-only" },
|
|
476
|
+
privilege: { mode: "inherit" },
|
|
477
|
+
network: { denyLinkLocalAndMetadata: false },
|
|
478
|
+
});
|
|
479
|
+
const h = buildSpawnHardening({
|
|
480
|
+
policy,
|
|
481
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
482
|
+
baseEnv: { ...baseEnv, TMPDIR: "/host/tmp" },
|
|
483
|
+
filesystem: { scratchDir: "/tmp/run-t" },
|
|
484
|
+
});
|
|
485
|
+
expect(h.env.TMPDIR).toBe("/tmp");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("does NOT override TMPDIR when FS confinement is off", () => {
|
|
489
|
+
const policy = sandboxPolicySchema.parse({
|
|
490
|
+
filesystem: { mode: "off" },
|
|
491
|
+
privilege: { mode: "inherit" },
|
|
492
|
+
network: { denyLinkLocalAndMetadata: false },
|
|
493
|
+
});
|
|
494
|
+
const h = buildSpawnHardening({
|
|
495
|
+
policy,
|
|
496
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
497
|
+
baseEnv: { ...baseEnv, TMPDIR: "/host/tmp" },
|
|
498
|
+
});
|
|
499
|
+
expect(h.env.TMPDIR).toBe("/host/tmp");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe("buildSpawnHardening — network egress (Phase 3)", () => {
|
|
504
|
+
it("deny: composes a fresh net namespace into the FS wrapper (bwrap)", () => {
|
|
505
|
+
const policy = sandboxPolicySchema.parse({
|
|
506
|
+
filesystem: { mode: "scratch-only" },
|
|
507
|
+
network: { mode: "deny" },
|
|
508
|
+
privilege: { mode: "inherit" },
|
|
509
|
+
});
|
|
510
|
+
const h = buildSpawnHardening({
|
|
511
|
+
policy,
|
|
512
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
513
|
+
baseEnv,
|
|
514
|
+
filesystem: { scratchDir: "/tmp/run-n1" },
|
|
515
|
+
});
|
|
516
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
517
|
+
// ONE wrapper invocation, taking a fresh net namespace (no --share-net).
|
|
518
|
+
expect(wrapped[0]).toBe("bwrap");
|
|
519
|
+
expect(wrapped).toContain("--unshare-net");
|
|
520
|
+
expect(wrapped).not.toContain("--share-net");
|
|
521
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
522
|
+
expect(h.effective.enforced.filesystem).toBe(true);
|
|
523
|
+
expect(h.nftRuleset).toBeUndefined(); // deny needs no ruleset
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("FS stays on host net when network is unrestricted + block disabled", () => {
|
|
527
|
+
const policy = sandboxPolicySchema.parse({
|
|
528
|
+
filesystem: { mode: "scratch-only" },
|
|
529
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
530
|
+
privilege: { mode: "inherit" },
|
|
531
|
+
});
|
|
532
|
+
const h = buildSpawnHardening({
|
|
533
|
+
policy,
|
|
534
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
535
|
+
baseEnv,
|
|
536
|
+
filesystem: { scratchDir: "/tmp/run-n2" },
|
|
537
|
+
});
|
|
538
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
539
|
+
expect(wrapped).toContain("--share-net");
|
|
540
|
+
expect(wrapped).not.toContain("--unshare-net");
|
|
541
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("allowlist: plumbs macvlan egress + nft ruleset on a plumbed nsjail host", () => {
|
|
545
|
+
const policy = sandboxPolicySchema.parse({
|
|
546
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
547
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
548
|
+
privilege: { mode: "inherit" },
|
|
549
|
+
});
|
|
550
|
+
const h = buildSpawnHardening({
|
|
551
|
+
policy,
|
|
552
|
+
caps: LINUX_ROOT_NSJAIL_PLUMBED,
|
|
553
|
+
baseEnv,
|
|
554
|
+
filesystem: { scratchDir: "/tmp/run-n3" },
|
|
555
|
+
nftRulesetPath: "/tmp/run-n3/egress.nft",
|
|
556
|
+
});
|
|
557
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
558
|
+
expect(wrapped[0]).toBe("nsjail");
|
|
559
|
+
expect(wrapped).toContain("--clone_newnet");
|
|
560
|
+
// Real uplink plumbed in → allowed CIDR reachable (not a blackhole).
|
|
561
|
+
expect(wrapped).toContain("--macvlan_iface");
|
|
562
|
+
expect(wrapped).toContain("eth0");
|
|
563
|
+
expect(wrapped).toContain("--nftables_file");
|
|
564
|
+
expect(wrapped).toContain("/tmp/run-n3/egress.nft");
|
|
565
|
+
expect(h.nftRuleset).toContain("10.0.0.0/8");
|
|
566
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("allowlist degrades (surfaced, NOT blackhole) on an nsjail host without plumbing", () => {
|
|
570
|
+
const policy = sandboxPolicySchema.parse({
|
|
571
|
+
filesystem: { mode: "off" },
|
|
572
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
573
|
+
privilege: { mode: "inherit" },
|
|
574
|
+
});
|
|
575
|
+
const h = buildSpawnHardening({
|
|
576
|
+
policy,
|
|
577
|
+
caps: LINUX_NONROOT_NSJAIL, // nsjail but euid not root / no iface
|
|
578
|
+
baseEnv,
|
|
579
|
+
});
|
|
580
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
581
|
+
expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
|
|
582
|
+
// Degrade-to-host-net: NO routeless namespace engaged.
|
|
583
|
+
expect(h.wrapCmd(["sh", "-c", "x"])).not.toContain("--clone_newnet");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("allowlist degrades on bwrap (no plumbing), surfaced", () => {
|
|
587
|
+
const policy = sandboxPolicySchema.parse({
|
|
588
|
+
filesystem: { mode: "off" },
|
|
589
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
590
|
+
privilege: { mode: "inherit" },
|
|
591
|
+
});
|
|
592
|
+
const h = buildSpawnHardening({
|
|
593
|
+
policy,
|
|
594
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
595
|
+
baseEnv,
|
|
596
|
+
});
|
|
597
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
598
|
+
expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
|
|
599
|
+
expect(h.wrapCmd(["sh", "-c", "x"])).not.toContain("--unshare-net");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("allowlist: ROOTLESS path builds a launcher prelude + nft filter, enforced.network truthful", () => {
|
|
603
|
+
const policy = sandboxPolicySchema.parse({
|
|
604
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
605
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
606
|
+
privilege: { mode: "inherit" },
|
|
607
|
+
});
|
|
608
|
+
const h = buildSpawnHardening({
|
|
609
|
+
policy,
|
|
610
|
+
caps: LINUX_BWRAP_ROOTLESS,
|
|
611
|
+
baseEnv,
|
|
612
|
+
filesystem: { scratchDir: "/tmp/run-rl1" },
|
|
613
|
+
nftRulesetPath: "/tmp/run-rl1/egress.nft",
|
|
614
|
+
rootlessLauncherPath: "/tmp/run-rl1/rootless-egress.sh",
|
|
615
|
+
});
|
|
616
|
+
// The prelude is the staged launcher script (slirp4netns + fail-closed nft).
|
|
617
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
618
|
+
expect(wrapped[0]).toBe("sh");
|
|
619
|
+
expect(wrapped[1]).toBe("/tmp/run-rl1/rootless-egress.sh");
|
|
620
|
+
// The real command is appended as the launcher's positional args.
|
|
621
|
+
expect(wrapped).toContain(process.execPath);
|
|
622
|
+
expect(wrapped).toContain("runner.mjs");
|
|
623
|
+
// The launcher script + nft ruleset are produced for the runner to stage.
|
|
624
|
+
expect(h.rootlessLauncher).toBeDefined();
|
|
625
|
+
expect(h.rootlessLauncher).toContain("slirp4netns --configure");
|
|
626
|
+
expect(h.rootlessLauncher).toContain("--unshare-net"); // bwrap takes a fresh netns
|
|
627
|
+
expect(h.nftRuleset).toContain("10.0.0.0/8");
|
|
628
|
+
expect(h.nftRuleset).toContain("169.254.0.0/16"); // metadata still blocked
|
|
629
|
+
// enforced.network is TRUE only because the filter is loaded fail-closed
|
|
630
|
+
// inside a plumbed (slirp4netns) namespace — real, filtered egress.
|
|
631
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
632
|
+
expect(h.effective.enforced.filesystem).toBe(true);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("ROOTLESS path threads the prlimit prelude AFTER the launcher, BEFORE the command", () => {
|
|
636
|
+
// A rootless bwrap host that is ALSO euid-root (so the uid drop takes
|
|
637
|
+
// effect, enabling RLIMIT_NPROC) with full rlimit caps. The launcher path
|
|
638
|
+
// must come first; the prlimit prelude + real command are appended as the
|
|
639
|
+
// launcher's positional args, so prlimit runs INSIDE the namespace (via the
|
|
640
|
+
// inner `exec "$@"`) and caps the real command — not the launcher itself.
|
|
641
|
+
const rootlessRoot: SandboxCapabilities = {
|
|
642
|
+
...LINUX_BWRAP_ROOTLESS,
|
|
643
|
+
euidIsRoot: true,
|
|
644
|
+
};
|
|
645
|
+
const policy = sandboxPolicySchema.parse({
|
|
646
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
647
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
648
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
649
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
650
|
+
});
|
|
651
|
+
const h = buildSpawnHardening({
|
|
652
|
+
policy,
|
|
653
|
+
caps: rootlessRoot,
|
|
654
|
+
baseEnv,
|
|
655
|
+
filesystem: { scratchDir: "/tmp/run-rl4" },
|
|
656
|
+
nftRulesetPath: "/tmp/run-rl4/egress.nft",
|
|
657
|
+
rootlessLauncherPath: "/tmp/run-rl4/rootless-egress.sh",
|
|
658
|
+
});
|
|
659
|
+
// The uid drop took effect (precondition for --nproc) and resources/network
|
|
660
|
+
// are both enforced. `h.uid` is set for observability (the wrapper carries
|
|
661
|
+
// the real `--uid` drop; the runner never passes it to Bun.spawn).
|
|
662
|
+
expect(h.uid).toBe(1001);
|
|
663
|
+
expect(h.effective.enforced.resources).toBe(true);
|
|
664
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
665
|
+
|
|
666
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
667
|
+
// Launcher first.
|
|
668
|
+
expect(wrapped[0]).toBe("sh");
|
|
669
|
+
expect(wrapped[1]).toBe("/tmp/run-rl4/rootless-egress.sh");
|
|
670
|
+
// The prlimit prelude is threaded as the launcher's positional args, AFTER
|
|
671
|
+
// the launcher path, terminated by `--`, then the real command.
|
|
672
|
+
const prlimitIdx = wrapped.indexOf("prlimit");
|
|
673
|
+
const sepIdx = wrapped.indexOf("--", prlimitIdx);
|
|
674
|
+
const cmdIdx = wrapped.indexOf(process.execPath);
|
|
675
|
+
expect(prlimitIdx).toBeGreaterThan(1); // after `sh <launcher>`
|
|
676
|
+
expect(wrapped).toContain("--cpu=60");
|
|
677
|
+
expect(wrapped.some((a) => a.startsWith("--as="))).toBe(false); // no RLIMIT_AS
|
|
678
|
+
expect(wrapped).toContain("--nproc=256"); // emitted because the uid dropped
|
|
679
|
+
// Ordering: launcher path < prlimit < `--` < real command.
|
|
680
|
+
expect(prlimitIdx).toBeLessThan(sepIdx);
|
|
681
|
+
expect(sepIdx).toBeLessThan(cmdIdx);
|
|
682
|
+
// The launcher itself is NOT wrapped by prlimit (it precedes the prelude).
|
|
683
|
+
expect(wrapped.indexOf("/tmp/run-rl4/rootless-egress.sh")).toBeLessThan(
|
|
684
|
+
prlimitIdx,
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("ROOTLESS path degrades (surfaced) when the runner cannot stage a launcher", () => {
|
|
689
|
+
// No rootlessLauncherPath provided → cannot orchestrate slirp4netns → must
|
|
690
|
+
// degrade to host net, NOT take a routeless namespace (blackhole).
|
|
691
|
+
const policy = sandboxPolicySchema.parse({
|
|
692
|
+
filesystem: { mode: "off" },
|
|
693
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
694
|
+
privilege: { mode: "inherit" },
|
|
695
|
+
});
|
|
696
|
+
const h = buildSpawnHardening({
|
|
697
|
+
policy,
|
|
698
|
+
caps: LINUX_BWRAP_ROOTLESS,
|
|
699
|
+
baseEnv,
|
|
700
|
+
nftRulesetPath: "/tmp/run-rl2/egress.nft",
|
|
701
|
+
// rootlessLauncherPath intentionally omitted.
|
|
702
|
+
});
|
|
703
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
704
|
+
expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
|
|
705
|
+
expect(h.rootlessLauncher).toBeUndefined();
|
|
706
|
+
// Degrade-to-host-net: the cmd runs as-is (no launcher prelude, no netns).
|
|
707
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
708
|
+
expect(wrapped).toEqual(["sh", "-c", "x"]);
|
|
709
|
+
expect(wrapped).not.toContain("/tmp/run-rl2/rootless-egress.sh");
|
|
710
|
+
expect(wrapped).not.toContain("--unshare-net");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("a NON-EMPTY allowlist enforces egress via the ROOTLESS path", () => {
|
|
714
|
+
// A non-empty allowlist genuinely needs plumbed+filtered egress; on a
|
|
715
|
+
// rootless bwrap host it engages the slirp4netns launcher so the nft filter
|
|
716
|
+
// is installed in a real namespace - enforced.network truthful, never a
|
|
717
|
+
// blackhole. (The EMPTY-allowlist secure default takes the simpler routeless
|
|
718
|
+
// deny path; see the dedicated test below.)
|
|
719
|
+
const h = buildSpawnHardening({
|
|
720
|
+
policy: sandboxPolicySchema.parse({
|
|
721
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
722
|
+
onUnavailable: "degrade",
|
|
723
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
724
|
+
}),
|
|
725
|
+
caps: LINUX_BWRAP_ROOTLESS,
|
|
726
|
+
baseEnv,
|
|
727
|
+
filesystem: { scratchDir: "/tmp/run-rl3" },
|
|
728
|
+
nftRulesetPath: "/tmp/run-rl3/egress.nft",
|
|
729
|
+
rootlessLauncherPath: "/tmp/run-rl3/rootless-egress.sh",
|
|
730
|
+
});
|
|
731
|
+
expect(h.rootlessLauncher).toBeDefined();
|
|
732
|
+
expect(h.nftRuleset).toContain("169.254.0.0/16");
|
|
733
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("EMPTY-allowlist secure default resolves to the routeless DENY netns (works without plumbing)", () => {
|
|
737
|
+
// The shipped secure default is `allowlist` with an EMPTY allow list =
|
|
738
|
+
// deny ALL egress. That is semantically `deny`, so it takes the routeless
|
|
739
|
+
// namespace path: NO slirp4netns/macvlan plumbing and NO nftables ruleset
|
|
740
|
+
// are needed, so it enforces on ANY netns-capable host (incl. rootless
|
|
741
|
+
// containers where in-namespace nft is not permitted). A routeless netns
|
|
742
|
+
// also inherently blocks metadata (nothing routes).
|
|
743
|
+
const h = buildSpawnHardening({
|
|
744
|
+
policy: sandboxPolicySchema.parse({
|
|
745
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
746
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
747
|
+
}),
|
|
748
|
+
caps: LINUX_ROOT_NSJAIL_PLUMBED,
|
|
749
|
+
baseEnv,
|
|
750
|
+
filesystem: { scratchDir: "/tmp/run-id1" },
|
|
751
|
+
nftRulesetPath: "/tmp/run-id1/egress.nft",
|
|
752
|
+
});
|
|
753
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
754
|
+
// Routeless deny netns: NO macvlan uplink, NO egress filter file.
|
|
755
|
+
expect(wrapped).toContain("--clone_newnet");
|
|
756
|
+
expect(wrapped).not.toContain("--macvlan_iface");
|
|
757
|
+
expect(wrapped).not.toContain("--nftables_file");
|
|
758
|
+
expect(h.nftRuleset).toBeUndefined();
|
|
759
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
760
|
+
expect(h.effective.downgrades.map((d) => d.layer)).not.toContain("network");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("a NON-EMPTY allowlist enforces via the macvlan uplink on a plumbed nsjail host", () => {
|
|
764
|
+
const h = buildSpawnHardening({
|
|
765
|
+
policy: sandboxPolicySchema.parse({
|
|
766
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
767
|
+
network: { mode: "allowlist", allow: ["203.0.113.0/24"] },
|
|
768
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
769
|
+
}),
|
|
770
|
+
caps: LINUX_ROOT_NSJAIL_PLUMBED,
|
|
771
|
+
baseEnv,
|
|
772
|
+
filesystem: { scratchDir: "/tmp/run-id1b" },
|
|
773
|
+
nftRulesetPath: "/tmp/run-id1b/egress.nft",
|
|
774
|
+
});
|
|
775
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
776
|
+
expect(wrapped).toContain("--macvlan_iface");
|
|
777
|
+
expect(wrapped).toContain("--nftables_file");
|
|
778
|
+
expect(h.nftRuleset).toContain("169.254.0.0/16");
|
|
779
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("a NON-EMPTY allowlist does NOT blackhole on an nsjail host without plumbing (host net, surfaced)", () => {
|
|
783
|
+
// BLOCKER guard: a genuine (non-empty) allowlist on a host that cannot plumb
|
|
784
|
+
// egress must keep HOST net (no routeless netns that would blackhole the
|
|
785
|
+
// allowed destinations), surface the gap, and report enforced.network=false.
|
|
786
|
+
const h = buildSpawnHardening({
|
|
787
|
+
policy: sandboxPolicySchema.parse({
|
|
788
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
789
|
+
onUnavailable: "degrade",
|
|
790
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
791
|
+
}),
|
|
792
|
+
caps: LINUX_NONROOT_NSJAIL,
|
|
793
|
+
baseEnv,
|
|
794
|
+
filesystem: { scratchDir: "/tmp/run-id2" },
|
|
795
|
+
});
|
|
796
|
+
const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
|
|
797
|
+
expect(wrapped).not.toContain("--clone_newnet"); // no severing namespace
|
|
798
|
+
expect(wrapped).not.toContain("--unshare-net");
|
|
799
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
800
|
+
const nd = h.effective.downgrades.find((d) => d.layer === "network");
|
|
801
|
+
// The allowlist could not be plumbed; egress is left on host net rather
|
|
802
|
+
// than blackholed.
|
|
803
|
+
expect(nd?.reason).toContain("blackhole");
|
|
804
|
+
expect(h.nftRuleset).toBeUndefined();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("network-only deny (no FS) still builds a wrapper keeping host FS visible", () => {
|
|
808
|
+
const policy = sandboxPolicySchema.parse({
|
|
809
|
+
filesystem: { mode: "off" },
|
|
810
|
+
network: { mode: "deny" },
|
|
811
|
+
privilege: { mode: "inherit" },
|
|
812
|
+
});
|
|
813
|
+
const h = buildSpawnHardening({
|
|
814
|
+
policy,
|
|
815
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
816
|
+
baseEnv,
|
|
817
|
+
});
|
|
818
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
819
|
+
expect(wrapped[0]).toBe("bwrap");
|
|
820
|
+
expect(wrapped).toContain("--unshare-net");
|
|
821
|
+
// No FS confinement → host FS bound in so `sh` still resolves.
|
|
822
|
+
expect(wrapped.join(" ")).toContain("--bind / /");
|
|
823
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("metadata block requested but unenforceable (bwrap) is surfaced, not pretended", () => {
|
|
827
|
+
const policy = sandboxPolicySchema.parse({
|
|
828
|
+
filesystem: { mode: "off" },
|
|
829
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: true },
|
|
830
|
+
privilege: { mode: "inherit" },
|
|
831
|
+
});
|
|
832
|
+
const h = buildSpawnHardening({
|
|
833
|
+
policy,
|
|
834
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
835
|
+
baseEnv,
|
|
836
|
+
});
|
|
837
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
838
|
+
const nd = h.effective.downgrades.find((d) => d.layer === "network");
|
|
839
|
+
expect(nd?.reason).toContain("metadata");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("network deny degrades on macOS and is surfaced", () => {
|
|
843
|
+
const policy = sandboxPolicySchema.parse({
|
|
844
|
+
filesystem: { mode: "off" },
|
|
845
|
+
network: { mode: "deny" },
|
|
846
|
+
privilege: { mode: "inherit" },
|
|
847
|
+
});
|
|
848
|
+
const h = buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv });
|
|
849
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
850
|
+
expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
|
|
851
|
+
expect(h.wrapCmd(["sh", "-c", "x"])).toEqual(["sh", "-c", "x"]);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("fail-closed: network deny unenforceable + onUnavailable:fail throws", () => {
|
|
855
|
+
const policy = sandboxPolicySchema.parse({
|
|
856
|
+
onUnavailable: "fail",
|
|
857
|
+
filesystem: { mode: "off" },
|
|
858
|
+
network: { mode: "deny" },
|
|
859
|
+
privilege: { mode: "inherit" },
|
|
860
|
+
});
|
|
861
|
+
expect(() =>
|
|
862
|
+
buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv }),
|
|
863
|
+
).toThrow(SandboxUnavailableError);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe("buildSpawnHardening — non-root supervisor privilege model", () => {
|
|
868
|
+
// The shipped images run the SUPERVISOR as non-root (uid 65532). The script
|
|
869
|
+
// inherits non-root from the supervisor by construction, so it can NEVER be
|
|
870
|
+
// host-root, regardless of whether a wrapper carries a `--uid` drop. A
|
|
871
|
+
// rootless `--uid` to a DIFFERENT id is impossible (no subuid/newuidmap) and
|
|
872
|
+
// not needed. So `enforced.privilege` must be TRUE on every non-root-euid
|
|
873
|
+
// path, and the wrapper must NOT carry a `--uid` flag.
|
|
874
|
+
const policy = sandboxPolicySchema.parse({
|
|
875
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
876
|
+
network: { mode: "deny" },
|
|
877
|
+
privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("enforces privilege by inheritance when euid is non-root + wrapper engaged", () => {
|
|
881
|
+
const h = buildSpawnHardening({
|
|
882
|
+
policy,
|
|
883
|
+
caps: LINUX_NONROOT_BWRAP, // euidIsRoot: false
|
|
884
|
+
baseEnv,
|
|
885
|
+
filesystem: { scratchDir: "/tmp/run-nr1" },
|
|
886
|
+
});
|
|
887
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
888
|
+
// No wrapper-level uid drop (rootless cannot map to a different id).
|
|
889
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
890
|
+
expect(wrapped).not.toContain("--uid");
|
|
891
|
+
expect(wrapped).not.toContain("--gid");
|
|
892
|
+
// Spawn-level uid/gid are NOT carried (no-op + forward-compat hazard).
|
|
893
|
+
expect(h.uid).toBeUndefined();
|
|
894
|
+
expect(h.gid).toBeUndefined();
|
|
895
|
+
// No privilege downgrade surfaced (it IS enforced by inheritance).
|
|
896
|
+
expect(h.effective.downgrades.map((d) => d.layer)).not.toContain("privilege");
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("enforces privilege by inheritance when euid is non-root + NO wrapper", () => {
|
|
900
|
+
// FS off + host net = no wrapper. With a non-root supervisor the child STILL
|
|
901
|
+
// cannot be host-root (inheritance), so privilege is enforced and there is
|
|
902
|
+
// no spawn uid/gid and no downgrade.
|
|
903
|
+
const h = buildSpawnHardening({
|
|
904
|
+
policy: sandboxPolicySchema.parse({
|
|
905
|
+
onUnavailable: "degrade",
|
|
906
|
+
filesystem: { mode: "off" },
|
|
907
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
908
|
+
privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
|
|
909
|
+
}),
|
|
910
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
911
|
+
baseEnv,
|
|
912
|
+
});
|
|
913
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
914
|
+
expect(h.uid).toBeUndefined();
|
|
915
|
+
expect(h.gid).toBeUndefined();
|
|
916
|
+
expect(h.effective.downgrades.map((d) => d.layer)).not.toContain("privilege");
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("does NOT report privilege enforced when euid IS root and NO wrapper carries the drop", () => {
|
|
920
|
+
// Legacy root-supervisor path: a drop-to-uid with no wrapper engaged cannot
|
|
921
|
+
// drop (Bun.spawn uid/gid is a silent no-op), so the child WOULD run as
|
|
922
|
+
// host-root. Must NOT claim enforced; must surface a downgrade.
|
|
923
|
+
const h = buildSpawnHardening({
|
|
924
|
+
policy: sandboxPolicySchema.parse({
|
|
925
|
+
onUnavailable: "degrade",
|
|
926
|
+
filesystem: { mode: "off" },
|
|
927
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
928
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
929
|
+
}),
|
|
930
|
+
caps: LINUX_NONROOT_BWRAP_ROOT, // euidIsRoot: true
|
|
931
|
+
baseEnv,
|
|
932
|
+
});
|
|
933
|
+
expect(h.effective.enforced.privilege).toBe(false);
|
|
934
|
+
expect(h.effective.downgrades.map((d) => d.layer)).toContain("privilege");
|
|
935
|
+
// No spawn uid/gid even on the root path when no wrapper carries the drop:
|
|
936
|
+
// passing them is a no-op today and a forward-compat hazard (a future Bun
|
|
937
|
+
// honoring them would spawn bwrap itself as the dropped id, breaking userns).
|
|
938
|
+
expect(h.uid).toBeUndefined();
|
|
939
|
+
expect(h.gid).toBeUndefined();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("root supervisor + wrapper still carries --uid drop and reports enforced", () => {
|
|
943
|
+
const h = buildSpawnHardening({
|
|
944
|
+
policy: sandboxPolicySchema.parse({
|
|
945
|
+
filesystem: { mode: "scratch-only" },
|
|
946
|
+
privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
|
|
947
|
+
}),
|
|
948
|
+
caps: LINUX_NONROOT_BWRAP_ROOT,
|
|
949
|
+
baseEnv,
|
|
950
|
+
filesystem: { scratchDir: "/tmp/run-root-drop" },
|
|
951
|
+
});
|
|
952
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
953
|
+
const wrapped = h.wrapCmd(["sh", "-c", "x"]);
|
|
954
|
+
expect(wrapped).toContain("--uid");
|
|
955
|
+
expect(wrapped).toContain("1001");
|
|
956
|
+
// On the genuine root-wrapper drop path `h.uid` IS set, but for
|
|
957
|
+
// OBSERVABILITY only - the runners never pass it to Bun.spawn (verified in
|
|
958
|
+
// the runner tests). The wrapper's `--uid` is what actually drops.
|
|
959
|
+
expect(h.uid).toBe(1001);
|
|
960
|
+
expect(h.gid).toBe(1001);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it("fail-closed default works on a non-root rootless host (no unexpected downgrades)", () => {
|
|
964
|
+
// The KEY acceptance: under the shipped fail-default on a non-root rootless
|
|
965
|
+
// bwrap host, every applicable layer is enforced and the run is NOT refused.
|
|
966
|
+
const h = buildSpawnHardening({
|
|
967
|
+
policy: sandboxPolicySchema.parse({
|
|
968
|
+
...DEFAULT_SANDBOX_PROFILE, // onUnavailable: "fail"
|
|
969
|
+
privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
|
|
970
|
+
}),
|
|
971
|
+
caps: { ...LINUX_BWRAP_ROOTLESS, euidIsRoot: false },
|
|
972
|
+
baseEnv,
|
|
973
|
+
filesystem: { scratchDir: "/tmp/run-faildef" },
|
|
974
|
+
nftRulesetPath: "/tmp/run-faildef/egress.nft",
|
|
975
|
+
rootlessLauncherPath: "/tmp/run-faildef/rootless-egress.sh",
|
|
976
|
+
});
|
|
977
|
+
// Empty allowlist => routeless deny netns (no plumbing needed).
|
|
978
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
979
|
+
expect(h.effective.enforced.filesystem).toBe(true);
|
|
980
|
+
expect(h.effective.enforced.resources).toBe(true);
|
|
981
|
+
expect(h.effective.enforced.privilege).toBe(true);
|
|
982
|
+
expect(h.effective.downgrades).toEqual([]);
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
describe("buildSpawnHardening — shell memory honesty (review MAJOR)", () => {
|
|
987
|
+
it("emits a non-fatal memory note (NOT a downgrade) when the node heap cap is not applied", () => {
|
|
988
|
+
// Shell runs do not consume NODE_OPTIONS=--max-old-space-size, so per-run
|
|
989
|
+
// memory is NOT enforced; the ceiling is the cgroup. This must be surfaced
|
|
990
|
+
// as a NOTE, never a downgrade, and must NOT fail-close.
|
|
991
|
+
const policy = sandboxPolicySchema.parse({
|
|
992
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
993
|
+
onUnavailable: "fail",
|
|
994
|
+
privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
|
|
995
|
+
});
|
|
996
|
+
const h = buildSpawnHardening({
|
|
997
|
+
policy,
|
|
998
|
+
caps: { ...LINUX_BWRAP_ROOTLESS, euidIsRoot: false },
|
|
999
|
+
baseEnv,
|
|
1000
|
+
filesystem: { scratchDir: "/tmp/run-shmem" },
|
|
1001
|
+
appliesNodeMemoryCap: false, // shell runner
|
|
1002
|
+
});
|
|
1003
|
+
const memNote = h.effective.notes.find(
|
|
1004
|
+
(n) => n.layer === "resources" && n.note.includes("memoryBytes"),
|
|
1005
|
+
);
|
|
1006
|
+
expect(memNote).toBeDefined();
|
|
1007
|
+
expect(memNote?.note).toContain("cgroup");
|
|
1008
|
+
// Memory must NOT appear as a downgrade (would fail-close + break all shell).
|
|
1009
|
+
expect(
|
|
1010
|
+
h.effective.downgrades.some(
|
|
1011
|
+
(d) => d.layer === "resources" && d.reason.includes("memory"),
|
|
1012
|
+
),
|
|
1013
|
+
).toBe(false);
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("does NOT emit the shell memory note when the node heap cap IS applied (ESM)", () => {
|
|
1017
|
+
const policy = sandboxPolicySchema.parse({
|
|
1018
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
1019
|
+
privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
|
|
1020
|
+
});
|
|
1021
|
+
const h = buildSpawnHardening({
|
|
1022
|
+
policy,
|
|
1023
|
+
caps: { ...LINUX_BWRAP_ROOTLESS, euidIsRoot: false },
|
|
1024
|
+
baseEnv,
|
|
1025
|
+
filesystem: { scratchDir: "/tmp/run-esmmem" },
|
|
1026
|
+
appliesNodeMemoryCap: true, // ESM runner
|
|
1027
|
+
});
|
|
1028
|
+
// No MEMORY note for ESM (the heap cap IS applied). An unrelated nproc note
|
|
1029
|
+
// may exist under the non-root model; we assert specifically on memory.
|
|
1030
|
+
expect(
|
|
1031
|
+
h.effective.notes.find(
|
|
1032
|
+
(n) => n.layer === "resources" && n.note.includes("memoryBytes"),
|
|
1033
|
+
),
|
|
1034
|
+
).toBeUndefined();
|
|
1035
|
+
expect(h.nodeMemoryFlagEnv?.NODE_OPTIONS).toContain("--max-old-space-size=");
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it("REGRESSION GUARD: shell memory is never reported as per-run enforced", () => {
|
|
1039
|
+
// This test must FAIL if a future change ever makes the hardening builder
|
|
1040
|
+
// imply a per-run memory guarantee for a runner that does not apply the heap
|
|
1041
|
+
// cap (i.e. the shell runner, appliesNodeMemoryCap !== true). The honest
|
|
1042
|
+
// representation is: a memory note is present whenever memoryBytes is set but
|
|
1043
|
+
// the node heap cap is not applied.
|
|
1044
|
+
for (const caps of [
|
|
1045
|
+
LINUX_NONROOT_BWRAP,
|
|
1046
|
+
LINUX_NONROOT_BWRAP_ROOT,
|
|
1047
|
+
MACOS_NONE,
|
|
1048
|
+
] as const) {
|
|
1049
|
+
const h = buildSpawnHardening({
|
|
1050
|
+
policy: sandboxPolicySchema.parse({
|
|
1051
|
+
onUnavailable: "degrade",
|
|
1052
|
+
resources: { memoryBytes: 512 * 1024 * 1024 },
|
|
1053
|
+
filesystem: { mode: "off" },
|
|
1054
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
1055
|
+
privilege: { mode: "inherit" },
|
|
1056
|
+
}),
|
|
1057
|
+
caps,
|
|
1058
|
+
baseEnv,
|
|
1059
|
+
// appliesNodeMemoryCap omitted => false => the shell-runner contract.
|
|
1060
|
+
});
|
|
1061
|
+
const memNote = h.effective.notes.find(
|
|
1062
|
+
(n) => n.layer === "resources" && n.note.includes("memoryBytes"),
|
|
1063
|
+
);
|
|
1064
|
+
expect(memNote).toBeDefined();
|
|
1065
|
+
// And memory is never surfaced as a fatal downgrade for shell.
|
|
1066
|
+
expect(
|
|
1067
|
+
h.effective.downgrades.some(
|
|
1068
|
+
(d) => d.layer === "resources" && d.reason.includes("memory"),
|
|
1069
|
+
),
|
|
1070
|
+
).toBe(false);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("emits no memory note when no memory cap is requested", () => {
|
|
1075
|
+
const policy = sandboxPolicySchema.parse({
|
|
1076
|
+
onUnavailable: "degrade",
|
|
1077
|
+
resources: { maxOutputBytes: 1024 },
|
|
1078
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
1079
|
+
privilege: { mode: "inherit" },
|
|
1080
|
+
});
|
|
1081
|
+
const h = buildSpawnHardening({
|
|
1082
|
+
policy,
|
|
1083
|
+
caps: LINUX_NONROOT_BWRAP,
|
|
1084
|
+
baseEnv,
|
|
1085
|
+
appliesNodeMemoryCap: false,
|
|
1086
|
+
});
|
|
1087
|
+
expect(h.effective.notes.find((n) => n.layer === "resources")).toBeUndefined();
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
describe("buildSpawnHardening — onUnavailable: fail", () => {
|
|
1092
|
+
it("throws SandboxUnavailableError before spawn on a degraded host", () => {
|
|
1093
|
+
const policy = sandboxPolicySchema.parse({
|
|
1094
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
1095
|
+
onUnavailable: "fail",
|
|
1096
|
+
});
|
|
1097
|
+
expect(() =>
|
|
1098
|
+
buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv }),
|
|
1099
|
+
).toThrow(SandboxUnavailableError);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it("does NOT throw when everything is enforceable", () => {
|
|
1103
|
+
// A policy that only requests output truncation + inherit privilege is
|
|
1104
|
+
// fully enforceable everywhere.
|
|
1105
|
+
const policy = sandboxPolicySchema.parse({
|
|
1106
|
+
onUnavailable: "fail",
|
|
1107
|
+
resources: { maxOutputBytes: 1024 },
|
|
1108
|
+
network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
|
|
1109
|
+
});
|
|
1110
|
+
expect(() =>
|
|
1111
|
+
buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv }),
|
|
1112
|
+
).not.toThrow();
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe("buildSpawnHardening — live netns probe verdict (Item 3)", () => {
|
|
1117
|
+
// A bwrap host where the LIVE clone probe SUCCEEDED: netNamespaces +
|
|
1118
|
+
// userNsCreatable are true, so a fresh-netns deny is genuinely deliverable.
|
|
1119
|
+
const PROBE_TRUE: SandboxCapabilities = {
|
|
1120
|
+
...LINUX_NONROOT_BWRAP,
|
|
1121
|
+
userNamespaces: true,
|
|
1122
|
+
userNsCreatable: true,
|
|
1123
|
+
netNamespaces: true,
|
|
1124
|
+
};
|
|
1125
|
+
// The SAME bwrap host, but the live clone(CLONE_NEWUSER|CLONE_NEWNET) probe
|
|
1126
|
+
// FAILED (e.g. default Docker seccomp blocks the clone even though a wrapper
|
|
1127
|
+
// is on PATH). capabilities.ts derives netNamespaces=false + userNamespaces=
|
|
1128
|
+
// false from the live probe, so the wrapper cannot create the namespace.
|
|
1129
|
+
const PROBE_FALSE: SandboxCapabilities = {
|
|
1130
|
+
...LINUX_NONROOT_BWRAP,
|
|
1131
|
+
userNamespaces: false,
|
|
1132
|
+
userNsCreatable: false,
|
|
1133
|
+
netNamespaces: false,
|
|
1134
|
+
netEgressRootless: false,
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
it("probe true -> network deny enforced (no downgrade)", () => {
|
|
1138
|
+
const policy = sandboxPolicySchema.parse({
|
|
1139
|
+
onUnavailable: "fail",
|
|
1140
|
+
filesystem: { mode: "off" },
|
|
1141
|
+
network: { mode: "deny", denyLinkLocalAndMetadata: true },
|
|
1142
|
+
privilege: { mode: "inherit" },
|
|
1143
|
+
});
|
|
1144
|
+
const h = buildSpawnHardening({
|
|
1145
|
+
policy,
|
|
1146
|
+
caps: PROBE_TRUE,
|
|
1147
|
+
baseEnv,
|
|
1148
|
+
filesystem: { scratchDir: "/tmp/scratch" },
|
|
1149
|
+
});
|
|
1150
|
+
expect(h.effective.enforced.network).toBe(true);
|
|
1151
|
+
expect(
|
|
1152
|
+
h.effective.downgrades.some((d) => d.layer === "network"),
|
|
1153
|
+
).toBe(false);
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
it("probe false -> network NOT enforced + downgrade (fail-closed refuses)", () => {
|
|
1157
|
+
const policy = sandboxPolicySchema.parse({
|
|
1158
|
+
onUnavailable: "fail",
|
|
1159
|
+
filesystem: { mode: "off" },
|
|
1160
|
+
network: { mode: "deny", denyLinkLocalAndMetadata: true },
|
|
1161
|
+
privilege: { mode: "inherit" },
|
|
1162
|
+
});
|
|
1163
|
+
// Under fail-closed the run is REFUSED rather than falsely reported
|
|
1164
|
+
// enforced — the silent gap Item 3 closes (bwrap on PATH but the live clone
|
|
1165
|
+
// is blocked, so a routeless namespace cannot be created at spawn).
|
|
1166
|
+
expect(() =>
|
|
1167
|
+
buildSpawnHardening({
|
|
1168
|
+
policy,
|
|
1169
|
+
caps: PROBE_FALSE,
|
|
1170
|
+
baseEnv,
|
|
1171
|
+
filesystem: { scratchDir: "/tmp/scratch" },
|
|
1172
|
+
}),
|
|
1173
|
+
).toThrow(SandboxUnavailableError);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it("probe false under degrade -> enforced.network false + surfaced downgrade", () => {
|
|
1177
|
+
const policy = sandboxPolicySchema.parse({
|
|
1178
|
+
onUnavailable: "degrade",
|
|
1179
|
+
filesystem: { mode: "off" },
|
|
1180
|
+
network: { mode: "deny", denyLinkLocalAndMetadata: true },
|
|
1181
|
+
privilege: { mode: "inherit" },
|
|
1182
|
+
});
|
|
1183
|
+
const h = buildSpawnHardening({
|
|
1184
|
+
policy,
|
|
1185
|
+
caps: PROBE_FALSE,
|
|
1186
|
+
baseEnv,
|
|
1187
|
+
filesystem: { scratchDir: "/tmp/scratch" },
|
|
1188
|
+
});
|
|
1189
|
+
expect(h.effective.enforced.network).toBe(false);
|
|
1190
|
+
expect(
|
|
1191
|
+
h.effective.downgrades.some((d) => d.layer === "network"),
|
|
1192
|
+
).toBe(true);
|
|
1193
|
+
});
|
|
1194
|
+
});
|