@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,514 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import type { SandboxCapabilities } from "./capabilities";
|
|
3
|
+
import type { NetworkDecision } from "./network";
|
|
4
|
+
import type { FilesystemPolicy } from "./policy";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filesystem isolation (plan §5.2) composed with network egress control
|
|
8
|
+
* (plan §5.3). Both are delivered by the SAME namespace-capable external
|
|
9
|
+
* wrapper (`bwrap` first, then `nsjail`) per the external-wrapper-first
|
|
10
|
+
* decision (§6 / D2): re-implementing unprivileged mount/user/net namespaces
|
|
11
|
+
* natively is a large, security-critical surface, and these tools are small,
|
|
12
|
+
* audited, and built exactly for this.
|
|
13
|
+
*
|
|
14
|
+
* Filesystem modes:
|
|
15
|
+
* - `scratch-only`: the child sees a minimal read-only base system
|
|
16
|
+
* (`/usr`, `/bin`, `/lib`, `/lib64`, `/etc`) plus its per-run scratch dir
|
|
17
|
+
* mounted read-write. Everything else on the host FS is invisible.
|
|
18
|
+
* - `scratch-plus-ro`: `scratch-only` PLUS a read-only bind of the reconciled
|
|
19
|
+
* `resolutionRoot/node_modules` so managed-package imports still resolve.
|
|
20
|
+
*
|
|
21
|
+
* Network composition (Phase 3): the FS wrapper used to hard-code
|
|
22
|
+
* `--share-net` / `--disable_clone_newnet` to keep the host network namespace,
|
|
23
|
+
* because network was a separate, not-yet-built layer. Now the FS and network
|
|
24
|
+
* layers COMPOSE in one wrapper invocation: when the resolved
|
|
25
|
+
* {@link NetworkDecision} keeps the host net we still emit `--share-net`; when
|
|
26
|
+
* it asks for a fresh namespace we emit the net-unshare flags (and, for
|
|
27
|
+
* `nsjail`, install the egress nftables ruleset). A network-only run (FS
|
|
28
|
+
* `off` but `network.deny`/`allowlist`) still produces a wrapper invocation so
|
|
29
|
+
* the namespace can be created.
|
|
30
|
+
*
|
|
31
|
+
* `firejail` is detected for capability reporting but never used to DELIVER
|
|
32
|
+
* namespaces here (its profile model does not map onto the per-run bind/net set
|
|
33
|
+
* we build), so a firejail-only host degrades both layers (surfaced).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Inputs the runner supplies for the FS layer of a single run. */
|
|
37
|
+
export interface FilesystemRunInputs {
|
|
38
|
+
/**
|
|
39
|
+
* Per-run writable scratch directory (the ESM runner's `mkdtemp` dir). The
|
|
40
|
+
* child's CWD. Required to build any FS confinement — without a scratch dir
|
|
41
|
+
* there is nothing safe to make writable, so the FS layer degrades. A
|
|
42
|
+
* network-only run (no FS confinement) does not need it.
|
|
43
|
+
*/
|
|
44
|
+
scratchDir?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Absolute path to the reconciled `node_modules` tree to expose read-only
|
|
47
|
+
* under `scratch-plus-ro`. When the resolution root is unset (the ESM runner
|
|
48
|
+
* was given no `resolutionRoot`), there are no managed packages to bind.
|
|
49
|
+
*/
|
|
50
|
+
nodeModulesDir?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Absolute path to the language interpreter the runner execs (e.g.
|
|
53
|
+
* `process.execPath` for the ESM runner's Bun runtime). Under FS confinement
|
|
54
|
+
* the host FS is hidden, so the interpreter binary — which commonly lives
|
|
55
|
+
* outside `/usr`/`/bin` (e.g. `~/.bun/bin/bun`, `/usr/local/bin/bun`) — must
|
|
56
|
+
* be read-only bound into the namespace or the child cannot exec the runtime.
|
|
57
|
+
* The shell runner omits it (`sh` resolves from the bound `/bin`).
|
|
58
|
+
*/
|
|
59
|
+
interpreterPath?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The dedicated low-privilege UID/GID to drop to via the NAMESPACE WRAPPER
|
|
62
|
+
* (`bwrap --uid/--gid`, `nsjail --user/--group`). Set ONLY on the legacy
|
|
63
|
+
* ROOT-supervisor path: a root supervisor needs the wrapper to map the child
|
|
64
|
+
* to a dedicated low-priv id. It is left UNSET under the shipped NON-ROOT
|
|
65
|
+
* supervisor model, where the child INHERITS the supervisor's non-root uid by
|
|
66
|
+
* construction (and a rootless `--uid` to a DIFFERENT id is impossible without
|
|
67
|
+
* subuid/newuidmap, and unnecessary). The runner never passes `uid`/`gid` to
|
|
68
|
+
* `Bun.spawn` either: Bun silently ignores those, and honouring them would
|
|
69
|
+
* spawn the wrapper itself as the dropped id and break userns creation.
|
|
70
|
+
*/
|
|
71
|
+
dropUid?: number;
|
|
72
|
+
dropGid?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type FilesystemBuildResult =
|
|
76
|
+
| { kind: "off" }
|
|
77
|
+
| {
|
|
78
|
+
kind: "enforced";
|
|
79
|
+
/** argv prelude that wraps the real command (e.g. `bwrap ... --`). */
|
|
80
|
+
prelude: string[];
|
|
81
|
+
}
|
|
82
|
+
| {
|
|
83
|
+
/**
|
|
84
|
+
* The ROOTLESS egress path: the wrapper cannot be a plain argv prelude
|
|
85
|
+
* because `slirp4netns` must be orchestrated from OUTSIDE the namespace
|
|
86
|
+
* (in the parent netns) with a race-free PID + ready handshake. So this
|
|
87
|
+
* returns the `bwrap` argv (WITHOUT the trailing `--`) for
|
|
88
|
+
* {@link buildSpawnHardening} to fold into a generated launcher script
|
|
89
|
+
* (see `rootless-egress.ts`), which is staged on disk and exec'd as the
|
|
90
|
+
* actual prelude. The launcher wires the nft filter (fail-closed) + the
|
|
91
|
+
* slirp4netns userspace stack into the same namespace.
|
|
92
|
+
*/
|
|
93
|
+
kind: "enforced-rootless-egress";
|
|
94
|
+
/** `bwrap` argv WITHOUT the trailing `--` (the launcher appends it). */
|
|
95
|
+
bwrapArgv: string[];
|
|
96
|
+
}
|
|
97
|
+
| { kind: "degrade"; reason: string };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read-only base system paths exposed inside the namespace so ordinary
|
|
101
|
+
* binaries (`sh`, coreutils) and their shared libraries resolve. Only paths
|
|
102
|
+
* that exist on the host are bound; a missing path is skipped (e.g. `/lib64` is
|
|
103
|
+
* absent on some distros). The language interpreter is bound separately (it may
|
|
104
|
+
* live outside these roots).
|
|
105
|
+
*/
|
|
106
|
+
const RO_BASE_PATHS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] as const;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the interpreter to a stable real path and dedupe it against the
|
|
110
|
+
* read-only base binds. Returns undefined when there is nothing extra to bind
|
|
111
|
+
* (no interpreter given, or it already lives under a bound base path).
|
|
112
|
+
*/
|
|
113
|
+
function resolveInterpreterBind({
|
|
114
|
+
interpreterPath,
|
|
115
|
+
pathExists,
|
|
116
|
+
realpath,
|
|
117
|
+
}: {
|
|
118
|
+
interpreterPath: string | undefined;
|
|
119
|
+
pathExists: (path: string) => boolean;
|
|
120
|
+
realpath: (path: string) => string;
|
|
121
|
+
}): string | undefined {
|
|
122
|
+
if (interpreterPath === undefined) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
let resolved: string;
|
|
126
|
+
try {
|
|
127
|
+
resolved = realpath(interpreterPath);
|
|
128
|
+
} catch {
|
|
129
|
+
resolved = interpreterPath;
|
|
130
|
+
}
|
|
131
|
+
if (!pathExists(resolved)) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
// Already covered by a RO base bind → no separate bind needed.
|
|
135
|
+
if (RO_BASE_PATHS.some((base) => resolved === base || resolved.startsWith(`${base}/`))) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build a `bwrap` argv prelude. Confines the child to `scratchDir` (read-write)
|
|
143
|
+
* over a read-only minimal base system when FS confinement is on, and applies
|
|
144
|
+
* the resolved network decision: `--share-net` to keep the host net, or
|
|
145
|
+
* `--unshare-net` for a fresh (deny / nsjail-handled) namespace.
|
|
146
|
+
*/
|
|
147
|
+
function buildBwrapPrelude({
|
|
148
|
+
fsConfinement,
|
|
149
|
+
scratchDir,
|
|
150
|
+
nodeModulesDir,
|
|
151
|
+
interpreterBind,
|
|
152
|
+
net,
|
|
153
|
+
dropUid,
|
|
154
|
+
dropGid,
|
|
155
|
+
pathExists,
|
|
156
|
+
terminate = true,
|
|
157
|
+
}: {
|
|
158
|
+
fsConfinement: boolean;
|
|
159
|
+
scratchDir: string | undefined;
|
|
160
|
+
nodeModulesDir: string | undefined;
|
|
161
|
+
interpreterBind: string | undefined;
|
|
162
|
+
net: NetworkDecision;
|
|
163
|
+
/** Low-priv UID/GID to drop to inside the namespace (`bwrap --uid/--gid`). */
|
|
164
|
+
dropUid: number | undefined;
|
|
165
|
+
dropGid: number | undefined;
|
|
166
|
+
pathExists: (path: string) => boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Append the trailing `--` that separates the bwrap flags from the child
|
|
169
|
+
* argv. True for the ordinary argv-prelude path; FALSE for the rootless
|
|
170
|
+
* egress path, where the launcher appends `--info-fd N --` itself.
|
|
171
|
+
*/
|
|
172
|
+
terminate?: boolean;
|
|
173
|
+
}): string[] {
|
|
174
|
+
// Net flag composes with the FS unshare: keep host net, or take a fresh one.
|
|
175
|
+
const netFlag = net.kind === "namespaced" ? "--unshare-net" : "--share-net";
|
|
176
|
+
const args: string[] = ["bwrap", "--unshare-all", netFlag, "--die-with-parent"];
|
|
177
|
+
|
|
178
|
+
// Privilege drop INSIDE the user namespace. `--unshare-all` includes
|
|
179
|
+
// `--unshare-user`, so `--uid`/`--gid` map the child to the dedicated
|
|
180
|
+
// low-priv id. This is the path that ACTUALLY drops privilege (Bun.spawn's
|
|
181
|
+
// uid/gid is silently ignored), so the EffectiveSandbox `enforced.privilege`
|
|
182
|
+
// is only truthful when the wrapper is engaged. Emit gid first so the
|
|
183
|
+
// supplementary-group reset that bwrap performs lands on the right primary.
|
|
184
|
+
if (dropUid !== undefined) {
|
|
185
|
+
if (dropGid !== undefined) {
|
|
186
|
+
args.push("--gid", String(dropGid));
|
|
187
|
+
}
|
|
188
|
+
args.push("--uid", String(dropUid));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Filesystem binds FIRST, then `--proc` / `--dev` overlay on top of the
|
|
192
|
+
// confined tree (bwrap applies operations in order, so a later `--proc`
|
|
193
|
+
// correctly mounts over the bound root rather than being hidden by it).
|
|
194
|
+
if (fsConfinement && scratchDir !== undefined) {
|
|
195
|
+
for (const base of RO_BASE_PATHS) {
|
|
196
|
+
if (pathExists(base)) {
|
|
197
|
+
args.push("--ro-bind", base, base);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (interpreterBind !== undefined) {
|
|
201
|
+
args.push("--ro-bind", interpreterBind, interpreterBind);
|
|
202
|
+
}
|
|
203
|
+
// Fresh tmpfs at /tmp BEFORE the scratch bind. The per-run scratch dir is
|
|
204
|
+
// commonly created under the host `/tmp` (os.tmpdir()), so mounting the
|
|
205
|
+
// tmpfs first and binding the scratch dir ON TOP of it keeps the scratch
|
|
206
|
+
// bind visible. The reverse order would let the tmpfs MASK the scratch
|
|
207
|
+
// bind, leaving the child with no CWD (`chdir` then fails and the run
|
|
208
|
+
// hangs/breaks). bwrap applies operations in order, so order matters here.
|
|
209
|
+
args.push("--tmpfs", "/tmp", "--bind", scratchDir, scratchDir);
|
|
210
|
+
if (nodeModulesDir !== undefined && pathExists(nodeModulesDir)) {
|
|
211
|
+
args.push("--ro-bind", nodeModulesDir, nodeModulesDir);
|
|
212
|
+
}
|
|
213
|
+
// The standard pseudo-filesystems on top of the confined tree.
|
|
214
|
+
args.push("--proc", "/proc", "--dev", "/dev", "--chdir", scratchDir);
|
|
215
|
+
} else {
|
|
216
|
+
// Network-only confinement: `--unshare-all` also unshares the mount
|
|
217
|
+
// namespace, so without binds the child would see an empty root. Keep the
|
|
218
|
+
// host filesystem fully visible (no FS layer requested) by binding `/`,
|
|
219
|
+
// then overlay fresh /proc and /dev for the new namespaces.
|
|
220
|
+
args.push("--bind", "/", "/", "--proc", "/proc", "--dev", "/dev");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (terminate) {
|
|
224
|
+
args.push("--");
|
|
225
|
+
}
|
|
226
|
+
return args;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build the nsjail network flags for a decision.
|
|
231
|
+
*
|
|
232
|
+
* - `host` → `--disable_clone_newnet` (keep host net).
|
|
233
|
+
* - `deny` (no iface) → `--clone_newnet` only: a ROUTELESS namespace,
|
|
234
|
+
* loopback only — that is the goal, no filter/uplink.
|
|
235
|
+
* - `allowlist` (+iface) → `--clone_newnet` + `--macvlan_iface <iface>` to
|
|
236
|
+
* plumb REAL egress into the namespace, the
|
|
237
|
+
* `--macvlan_vs_ip/_nm/_gw` triple to ADDRESS the
|
|
238
|
+
* endpoint so it actually routes (an unaddressed
|
|
239
|
+
* macvlan still blackholes), + a final
|
|
240
|
+
* `--nftables_file <path>` to filter it. The plumbed +
|
|
241
|
+
* ADDRESSED uplink is what makes the allowlist
|
|
242
|
+
* reachable rather than a blackhole.
|
|
243
|
+
*/
|
|
244
|
+
function buildNsjailNetFlags({
|
|
245
|
+
net,
|
|
246
|
+
nftRulesetPath,
|
|
247
|
+
}: {
|
|
248
|
+
net: NetworkDecision;
|
|
249
|
+
nftRulesetPath: string | undefined;
|
|
250
|
+
}): string[] {
|
|
251
|
+
if (net.kind !== "namespaced") {
|
|
252
|
+
return ["--disable_clone_newnet"];
|
|
253
|
+
}
|
|
254
|
+
const flags: string[] = ["--clone_newnet"];
|
|
255
|
+
// Plumb real egress for a filtered allowlist (never for routeless deny).
|
|
256
|
+
if (net.egressIface !== undefined) {
|
|
257
|
+
flags.push("--macvlan_iface", net.egressIface);
|
|
258
|
+
// Address the endpoint so traffic ROUTES out of the macvlan; without these
|
|
259
|
+
// the macvlan interface is up but has no IP/route and still blackholes. The
|
|
260
|
+
// decision only carries addressing when it was available, and `allowlist` /
|
|
261
|
+
// metadata-block only reach `namespaced` WITH addressing (see network.ts).
|
|
262
|
+
if (net.egressAddressing !== undefined) {
|
|
263
|
+
flags.push(
|
|
264
|
+
"--macvlan_vs_ip",
|
|
265
|
+
net.egressAddressing.ip,
|
|
266
|
+
"--macvlan_vs_nm",
|
|
267
|
+
net.egressAddressing.netmask,
|
|
268
|
+
"--macvlan_vs_gw",
|
|
269
|
+
net.egressAddressing.gateway,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Install the egress filter when we have both a ruleset and a place to stage
|
|
274
|
+
// it. (Only meaningful alongside the macvlan uplink above; a deny namespace
|
|
275
|
+
// carries no ruleset.)
|
|
276
|
+
if (net.nftRuleset !== undefined && nftRulesetPath !== undefined) {
|
|
277
|
+
flags.push("--nftables_file", nftRulesetPath);
|
|
278
|
+
}
|
|
279
|
+
return flags;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build an `nsjail` argv prelude with the equivalent bind set + network
|
|
284
|
+
* handling (see {@link buildNsjailNetFlags}).
|
|
285
|
+
*/
|
|
286
|
+
function buildNsjailPrelude({
|
|
287
|
+
fsConfinement,
|
|
288
|
+
scratchDir,
|
|
289
|
+
nodeModulesDir,
|
|
290
|
+
interpreterBind,
|
|
291
|
+
net,
|
|
292
|
+
dropUid,
|
|
293
|
+
dropGid,
|
|
294
|
+
nftRulesetPath,
|
|
295
|
+
pathExists,
|
|
296
|
+
}: {
|
|
297
|
+
fsConfinement: boolean;
|
|
298
|
+
scratchDir: string | undefined;
|
|
299
|
+
nodeModulesDir: string | undefined;
|
|
300
|
+
interpreterBind: string | undefined;
|
|
301
|
+
net: NetworkDecision;
|
|
302
|
+
/** Low-priv UID/GID to drop to inside the namespace (`nsjail --user/--group`). */
|
|
303
|
+
dropUid: number | undefined;
|
|
304
|
+
dropGid: number | undefined;
|
|
305
|
+
nftRulesetPath: string | undefined;
|
|
306
|
+
pathExists: (path: string) => boolean;
|
|
307
|
+
}): string[] {
|
|
308
|
+
const netFlags = buildNsjailNetFlags({ net, nftRulesetPath });
|
|
309
|
+
const args: string[] = ["nsjail", "--quiet", "--mode", "o", ...netFlags];
|
|
310
|
+
|
|
311
|
+
// Privilege drop inside the nsjail user namespace. `--user`/`--group` map the
|
|
312
|
+
// inside-namespace id to the dedicated low-priv id (the path that actually
|
|
313
|
+
// drops; Bun.spawn's uid/gid is ignored).
|
|
314
|
+
if (dropUid !== undefined) {
|
|
315
|
+
args.push("--user", String(dropUid));
|
|
316
|
+
if (dropGid !== undefined) {
|
|
317
|
+
args.push("--group", String(dropGid));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (fsConfinement && scratchDir !== undefined) {
|
|
322
|
+
for (const base of RO_BASE_PATHS) {
|
|
323
|
+
if (pathExists(base)) {
|
|
324
|
+
args.push("--bindmount_ro", `${base}:${base}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (interpreterBind !== undefined) {
|
|
328
|
+
args.push("--bindmount_ro", `${interpreterBind}:${interpreterBind}`);
|
|
329
|
+
}
|
|
330
|
+
args.push("--bindmount", `${scratchDir}:${scratchDir}`);
|
|
331
|
+
if (nodeModulesDir !== undefined && pathExists(nodeModulesDir)) {
|
|
332
|
+
args.push("--bindmount_ro", `${nodeModulesDir}:${nodeModulesDir}`);
|
|
333
|
+
}
|
|
334
|
+
args.push("--cwd", scratchDir);
|
|
335
|
+
} else {
|
|
336
|
+
// Network-only confinement: keep the host filesystem visible (no FS layer
|
|
337
|
+
// requested) by binding the host root, so ordinary binaries still resolve.
|
|
338
|
+
args.push("--bindmount", "/:/");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
args.push("--");
|
|
342
|
+
return args;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolve the combined filesystem + network namespace layer for a run into
|
|
347
|
+
* either an argv prelude (enforced) or a degrade reason. Pure & synchronous;
|
|
348
|
+
* the only side-channels are the injectable `pathExists` / `realpath` probes
|
|
349
|
+
* (default to the real `node:fs` calls) so tests can drive them without
|
|
350
|
+
* touching disk.
|
|
351
|
+
*
|
|
352
|
+
* The `network` decision (resolved upstream by `buildNetworkLayer`) is folded
|
|
353
|
+
* into the SAME wrapper invocation so FS and net compose rather than fight. A
|
|
354
|
+
* run that confines only the network (FS `off` but `network` namespaced) still
|
|
355
|
+
* produces a wrapper prelude.
|
|
356
|
+
*
|
|
357
|
+
* @returns `off` when neither FS nor network needs a wrapper; `enforced` with
|
|
358
|
+
* the wrapper prelude when one is needed AND deliverable; `degrade` (with a
|
|
359
|
+
* reason) when FS confinement was requested but cannot be delivered. The
|
|
360
|
+
* network degrade decision is resolved before this is called and handled by
|
|
361
|
+
* the caller; here a `network.kind === "host"` simply keeps host net.
|
|
362
|
+
*/
|
|
363
|
+
export function buildFilesystemLayer({
|
|
364
|
+
policy,
|
|
365
|
+
caps,
|
|
366
|
+
inputs,
|
|
367
|
+
network,
|
|
368
|
+
nftRulesetPath,
|
|
369
|
+
pathExists = existsSyncDefault,
|
|
370
|
+
realpath = realpathDefault,
|
|
371
|
+
}: {
|
|
372
|
+
policy: FilesystemPolicy;
|
|
373
|
+
caps: SandboxCapabilities;
|
|
374
|
+
inputs: FilesystemRunInputs;
|
|
375
|
+
/**
|
|
376
|
+
* The resolved network decision to compose into the wrapper. When omitted,
|
|
377
|
+
* the host net is kept (the pre-Phase-3 behavior).
|
|
378
|
+
*/
|
|
379
|
+
network?: NetworkDecision;
|
|
380
|
+
/**
|
|
381
|
+
* Path to a file holding the nftables ruleset (written by the runner) for
|
|
382
|
+
* `nsjail --nftables_file`. Required only when `network.nftRuleset` is set.
|
|
383
|
+
*/
|
|
384
|
+
nftRulesetPath?: string;
|
|
385
|
+
pathExists?: (path: string) => boolean;
|
|
386
|
+
realpath?: (path: string) => string;
|
|
387
|
+
}): FilesystemBuildResult {
|
|
388
|
+
const net: NetworkDecision = network ?? {
|
|
389
|
+
kind: "host",
|
|
390
|
+
metadataBlockUnenforceable: false,
|
|
391
|
+
};
|
|
392
|
+
const wantsFsConfinement = policy.mode !== "off";
|
|
393
|
+
const wantsNetNamespace = net.kind === "namespaced";
|
|
394
|
+
|
|
395
|
+
// Nothing needs a wrapper: FS off AND net stays on the host.
|
|
396
|
+
if (!wantsFsConfinement && !wantsNetNamespace) {
|
|
397
|
+
return { kind: "off" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// A wrapper is needed (for FS confinement and/or a net namespace). Gate on
|
|
401
|
+
// wrapper capability. FS-specific degrade reasons are only relevant when FS
|
|
402
|
+
// confinement was actually requested; otherwise this is a network-only
|
|
403
|
+
// wrapper and the network degrade was already resolved upstream — but we
|
|
404
|
+
// still need the same primitives, so guard them here too.
|
|
405
|
+
if (caps.platform !== "linux") {
|
|
406
|
+
return {
|
|
407
|
+
kind: "degrade",
|
|
408
|
+
reason: `${wantsFsConfinement ? "filesystem" : "network"} isolation requires Linux namespaces (platform=${caps.platform}); running with full host FS/net`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (!caps.userNamespaces) {
|
|
412
|
+
return {
|
|
413
|
+
kind: "degrade",
|
|
414
|
+
reason:
|
|
415
|
+
"namespace isolation requires unprivileged user namespaces, which are disabled on this host; running with full host FS/net",
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (caps.wrapper === null) {
|
|
419
|
+
return {
|
|
420
|
+
kind: "degrade",
|
|
421
|
+
reason:
|
|
422
|
+
"namespace isolation requires a wrapper (bwrap/nsjail), none found on PATH; running with full host FS/net",
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (caps.wrapper === "firejail") {
|
|
426
|
+
return {
|
|
427
|
+
kind: "degrade",
|
|
428
|
+
reason:
|
|
429
|
+
"namespace isolation via firejail is not supported (profile model); install bwrap or nsjail; running with full host FS/net",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (wantsFsConfinement && inputs.scratchDir === undefined) {
|
|
433
|
+
return {
|
|
434
|
+
kind: "degrade",
|
|
435
|
+
reason:
|
|
436
|
+
"filesystem isolation needs a per-run scratch dir, none was provided by this runner; running with full host FS",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const interpreterBind = resolveInterpreterBind({
|
|
441
|
+
interpreterPath: inputs.interpreterPath,
|
|
442
|
+
pathExists,
|
|
443
|
+
realpath,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const nodeModulesDir =
|
|
447
|
+
policy.mode === "scratch-plus-ro" ? inputs.nodeModulesDir : undefined;
|
|
448
|
+
|
|
449
|
+
// The ROOTLESS egress path is bwrap-only and cannot be a plain argv prelude:
|
|
450
|
+
// slirp4netns is orchestrated from the parent netns via a launcher. Build the
|
|
451
|
+
// bwrap argv WITHOUT the trailing `--` and hand it to the caller, which folds
|
|
452
|
+
// it into the launcher script (see rootless-egress.ts). `--unshare-net` is
|
|
453
|
+
// already in the bwrap argv (the `namespaced` decision selects it below), so
|
|
454
|
+
// bwrap takes the fresh net namespace slirp4netns then plumbs into.
|
|
455
|
+
if (net.kind === "namespaced" && net.egressPath === "rootless") {
|
|
456
|
+
// The rootless path is only reachable when caps.wrapper === "bwrap" (see
|
|
457
|
+
// capabilities.ts: netEgressRootless gates on bwrap). Guard defensively.
|
|
458
|
+
if (caps.wrapper !== "bwrap") {
|
|
459
|
+
return {
|
|
460
|
+
kind: "degrade",
|
|
461
|
+
reason:
|
|
462
|
+
"rootless egress (slirp4netns) is delivered only via bwrap; this host's wrapper cannot orchestrate it; egress unrestricted",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const bwrapArgv = buildBwrapPrelude({
|
|
466
|
+
fsConfinement: wantsFsConfinement,
|
|
467
|
+
scratchDir: inputs.scratchDir,
|
|
468
|
+
nodeModulesDir,
|
|
469
|
+
interpreterBind,
|
|
470
|
+
net,
|
|
471
|
+
dropUid: inputs.dropUid,
|
|
472
|
+
dropGid: inputs.dropGid,
|
|
473
|
+
pathExists,
|
|
474
|
+
terminate: false,
|
|
475
|
+
});
|
|
476
|
+
return { kind: "enforced-rootless-egress", bwrapArgv };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const prelude =
|
|
480
|
+
caps.wrapper === "bwrap"
|
|
481
|
+
? buildBwrapPrelude({
|
|
482
|
+
fsConfinement: wantsFsConfinement,
|
|
483
|
+
scratchDir: inputs.scratchDir,
|
|
484
|
+
nodeModulesDir,
|
|
485
|
+
interpreterBind,
|
|
486
|
+
net,
|
|
487
|
+
dropUid: inputs.dropUid,
|
|
488
|
+
dropGid: inputs.dropGid,
|
|
489
|
+
pathExists,
|
|
490
|
+
})
|
|
491
|
+
: buildNsjailPrelude({
|
|
492
|
+
fsConfinement: wantsFsConfinement,
|
|
493
|
+
scratchDir: inputs.scratchDir,
|
|
494
|
+
nodeModulesDir,
|
|
495
|
+
interpreterBind,
|
|
496
|
+
net,
|
|
497
|
+
dropUid: inputs.dropUid,
|
|
498
|
+
dropGid: inputs.dropGid,
|
|
499
|
+
nftRulesetPath,
|
|
500
|
+
pathExists,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
return { kind: "enforced", prelude };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Default on-disk probe. Tests inject a fake to stay off the filesystem. */
|
|
507
|
+
function existsSyncDefault(path: string): boolean {
|
|
508
|
+
return existsSync(path);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Default realpath resolver. Tests inject a fake to stay off the filesystem. */
|
|
512
|
+
function realpathDefault(path: string): string {
|
|
513
|
+
return realpathSync(path);
|
|
514
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for PER-RUN FORK-BOMB CONTAINMENT (Item 1), exercising the
|
|
3
|
+
* REAL shell + ESM runners against a real `bwrap` + `prlimit` on a host that can
|
|
4
|
+
* create unprivileged user namespaces. Pure/argv-level coverage (that `--nproc`
|
|
5
|
+
* is emitted inside the bwrap userns) lives in `wrapper.test.ts`; this pins the
|
|
6
|
+
* one thing that cannot: that the cap + the per-run PID namespace genuinely
|
|
7
|
+
* CONTAIN an aggressive fork bomb (it fails cleanly, capped) while the
|
|
8
|
+
* supervisor process stays alive and able to fork.
|
|
9
|
+
*
|
|
10
|
+
* The mechanism: rootless `bwrap --unshare-all` creates a fresh USER namespace
|
|
11
|
+
* (so the per-(uid, userns) RLIMIT_NPROC isolates THIS run's process count even
|
|
12
|
+
* though the child shares the supervisor's uid 65532) AND a fresh PID namespace
|
|
13
|
+
* (so a single kill of the wrapper reaps the whole fork tree). Verified
|
|
14
|
+
* in-container: the bomb hits the cap and the supervisor keeps forking.
|
|
15
|
+
*
|
|
16
|
+
* Gated behind `CHECKSTACK_IT=1` AND auto-skipped when the host cannot create a
|
|
17
|
+
* user namespace + lacks a wrapper + prlimit (the detected capabilities say no),
|
|
18
|
+
* so the default `bun test` and non-Linux/non-rootless CI never run it.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, expect, it } from "bun:test";
|
|
21
|
+
import { detectSandboxCapabilities } from "./capabilities";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_SANDBOX_PROFILE,
|
|
24
|
+
sandboxPolicySchema,
|
|
25
|
+
type SandboxPolicy,
|
|
26
|
+
} from "./policy";
|
|
27
|
+
import { registerSandboxPolicyProvider } from "./provider";
|
|
28
|
+
import { defaultShellScriptRunner } from "../shell-script-runner";
|
|
29
|
+
import { defaultEsmScriptRunner } from "../esm-script-runner";
|
|
30
|
+
|
|
31
|
+
const caps = detectSandboxCapabilities();
|
|
32
|
+
// The cap is per-run-isolated only when the bwrap user namespace can actually be
|
|
33
|
+
// created and rlimits are enforceable; mirror exactly the in-container target.
|
|
34
|
+
const enabled =
|
|
35
|
+
Boolean(process.env.CHECKSTACK_IT) &&
|
|
36
|
+
caps.platform === "linux" &&
|
|
37
|
+
caps.wrapper === "bwrap" &&
|
|
38
|
+
caps.userNsCreatable &&
|
|
39
|
+
caps.rlimitNative;
|
|
40
|
+
|
|
41
|
+
/** Can the supervisor still spawn a process right now? */
|
|
42
|
+
function supervisorCanFork(): boolean {
|
|
43
|
+
const r = Bun.spawnSync(["sh", "-c", "echo alive"]);
|
|
44
|
+
return (
|
|
45
|
+
r.exitCode === 0 && new TextDecoder().decode(r.stdout).trim() === "alive"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe.skipIf(!enabled)("per-run fork-bomb containment (real bwrap)", () => {
|
|
50
|
+
it("caps a shell fork bomb and keeps the supervisor alive", async () => {
|
|
51
|
+
// A low maxProcesses so the cap bites quickly and deterministically.
|
|
52
|
+
const policy: SandboxPolicy = sandboxPolicySchema.parse({
|
|
53
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
54
|
+
resources: { ...DEFAULT_SANDBOX_PROFILE.resources, maxProcesses: 64 },
|
|
55
|
+
});
|
|
56
|
+
registerSandboxPolicyProvider(async () => policy);
|
|
57
|
+
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
const res = await defaultShellScriptRunner.run({
|
|
60
|
+
script: ":(){ :|:& };:",
|
|
61
|
+
timeoutMs: 8000,
|
|
62
|
+
});
|
|
63
|
+
const elapsed = Date.now() - start;
|
|
64
|
+
|
|
65
|
+
// The run fails cleanly (the cap kills the bomb), WITHOUT timing out, and
|
|
66
|
+
// every other layer stays enforced with no downgrades.
|
|
67
|
+
expect(res.timedOut).toBe(false);
|
|
68
|
+
expect(elapsed).toBeLessThan(8000);
|
|
69
|
+
expect(res.sandbox?.enforced.resources).toBe(true);
|
|
70
|
+
expect(res.sandbox?.enforced.filesystem).toBe(true);
|
|
71
|
+
expect(res.sandbox?.enforced.network).toBe(true);
|
|
72
|
+
expect(res.sandbox?.enforced.privilege).toBe(true);
|
|
73
|
+
expect(res.sandbox?.downgrades ?? []).toHaveLength(0);
|
|
74
|
+
// The fork-bomb cap is genuinely enforced per-run, so there is NO
|
|
75
|
+
// "RLIMIT_NPROC not applied" note on this wrapped path.
|
|
76
|
+
expect(
|
|
77
|
+
(res.sandbox?.notes ?? []).some((n) => n.note.includes("RLIMIT_NPROC")),
|
|
78
|
+
).toBe(false);
|
|
79
|
+
// The load-bearing assertion: the supervisor survived the bomb.
|
|
80
|
+
expect(supervisorCanFork()).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("caps an ESM spawn-loop bomb and keeps the supervisor alive", async () => {
|
|
84
|
+
const policy: SandboxPolicy = sandboxPolicySchema.parse({
|
|
85
|
+
...DEFAULT_SANDBOX_PROFILE,
|
|
86
|
+
resources: { ...DEFAULT_SANDBOX_PROFILE.resources, maxProcesses: 64 },
|
|
87
|
+
});
|
|
88
|
+
registerSandboxPolicyProvider(async () => policy);
|
|
89
|
+
|
|
90
|
+
const script = [
|
|
91
|
+
'const { spawn } = await import("node:child_process");',
|
|
92
|
+
"let n = 0;",
|
|
93
|
+
"try {",
|
|
94
|
+
" while (n < 100000) { spawn('sleep', ['30']); n++; }",
|
|
95
|
+
"} catch {}",
|
|
96
|
+
"export default { spawned: n };",
|
|
97
|
+
].join("\n");
|
|
98
|
+
|
|
99
|
+
const res = await defaultEsmScriptRunner.run({
|
|
100
|
+
script,
|
|
101
|
+
context: {},
|
|
102
|
+
timeoutMs: 8000,
|
|
103
|
+
});
|
|
104
|
+
expect(res.timedOut).toBe(false);
|
|
105
|
+
expect(res.sandbox?.enforced.resources).toBe(true);
|
|
106
|
+
expect(res.sandbox?.enforced.privilege).toBe(true);
|
|
107
|
+
expect(supervisorCanFork()).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("still runs a benign script to success under the same fail-closed default", async () => {
|
|
111
|
+
registerSandboxPolicyProvider(async () => DEFAULT_SANDBOX_PROFILE);
|
|
112
|
+
const ok = await defaultShellScriptRunner.run({
|
|
113
|
+
script: "echo hi; id -u",
|
|
114
|
+
timeoutMs: 5000,
|
|
115
|
+
});
|
|
116
|
+
expect(ok.exitCode).toBe(0);
|
|
117
|
+
// The script runs as the non-root supervisor uid by inheritance.
|
|
118
|
+
expect(ok.stdout).toContain("hi");
|
|
119
|
+
expect(ok.sandbox?.downgrades ?? []).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
});
|