@checkstack/backend-api 0.20.0 → 0.21.1
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 +169 -0
- package/package.json +15 -14
- 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 +177 -11
- 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/types.ts +5 -38
- package/src/zod-config.test.ts +60 -0
- package/src/zod-config.ts +38 -14
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildSubprocessEnv,
|
|
4
|
+
FORBIDDEN_ENV_KEYS,
|
|
5
|
+
isForbiddenEnvKey,
|
|
6
|
+
pickSafeEnv,
|
|
7
|
+
SAFE_ENV_VARS,
|
|
8
|
+
} from "./env-guard";
|
|
9
|
+
|
|
10
|
+
describe("pickSafeEnv", () => {
|
|
11
|
+
it("only forwards keys in the safe whitelist", () => {
|
|
12
|
+
process.env.TEST_LEAK_SECRET = "DO_NOT_LEAK";
|
|
13
|
+
process.env.PATH = process.env.PATH ?? "/usr/bin";
|
|
14
|
+
const env = pickSafeEnv();
|
|
15
|
+
delete process.env.TEST_LEAK_SECRET;
|
|
16
|
+
|
|
17
|
+
expect(env.TEST_LEAK_SECRET).toBeUndefined();
|
|
18
|
+
expect(env.PATH).toBeDefined();
|
|
19
|
+
for (const key of Object.keys(env)) {
|
|
20
|
+
expect(SAFE_ENV_VARS).toContain(key as (typeof SAFE_ENV_VARS)[number]);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("isForbiddenEnvKey", () => {
|
|
26
|
+
it("flags the exact-match denylist keys", () => {
|
|
27
|
+
for (const key of FORBIDDEN_ENV_KEYS) {
|
|
28
|
+
expect(isForbiddenEnvKey(key)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("flags BUN_CONFIG_* by prefix", () => {
|
|
33
|
+
expect(isForbiddenEnvKey("BUN_CONFIG_REGISTRY")).toBe(true);
|
|
34
|
+
expect(isForbiddenEnvKey("BUN_CONFIG_")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not flag ordinary keys", () => {
|
|
38
|
+
expect(isForbiddenEnvKey("PAYLOAD_ID")).toBe(false);
|
|
39
|
+
expect(isForbiddenEnvKey("API_TOKEN")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("buildSubprocessEnv", () => {
|
|
44
|
+
const base = { PATH: "/safe/bin", HOME: "/home/u" };
|
|
45
|
+
|
|
46
|
+
it("drops forbidden override keys when the denylist is applied", () => {
|
|
47
|
+
const { env, dropped } = buildSubprocessEnv({
|
|
48
|
+
base,
|
|
49
|
+
overrides: {
|
|
50
|
+
LD_PRELOAD: "/evil.so",
|
|
51
|
+
NODE_OPTIONS: "--require /evil.js",
|
|
52
|
+
API_TOKEN: "ok",
|
|
53
|
+
},
|
|
54
|
+
applyDenylist: true,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(env.LD_PRELOAD).toBeUndefined();
|
|
58
|
+
expect(env.NODE_OPTIONS).toBeUndefined();
|
|
59
|
+
expect(env.API_TOKEN).toBe("ok");
|
|
60
|
+
// base survives
|
|
61
|
+
expect(env.PATH).toBe("/safe/bin");
|
|
62
|
+
expect(dropped.sort()).toEqual(["LD_PRELOAD", "NODE_OPTIONS"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a PATH override is dropped but the safe-base PATH is retained", () => {
|
|
66
|
+
const { env, dropped } = buildSubprocessEnv({
|
|
67
|
+
base,
|
|
68
|
+
overrides: { PATH: "/attacker/bin" },
|
|
69
|
+
applyDenylist: true,
|
|
70
|
+
});
|
|
71
|
+
expect(env.PATH).toBe("/safe/bin");
|
|
72
|
+
expect(dropped).toEqual(["PATH"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("passes forbidden keys through when the denylist is NOT applied (back-compat)", () => {
|
|
76
|
+
const { env, dropped } = buildSubprocessEnv({
|
|
77
|
+
base,
|
|
78
|
+
overrides: { LD_PRELOAD: "/x.so", NODE_OPTIONS: "--y" },
|
|
79
|
+
applyDenylist: false,
|
|
80
|
+
});
|
|
81
|
+
expect(env.LD_PRELOAD).toBe("/x.so");
|
|
82
|
+
expect(env.NODE_OPTIONS).toBe("--y");
|
|
83
|
+
expect(dropped).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("merges overrides over base with override winning on non-forbidden keys", () => {
|
|
87
|
+
const { env } = buildSubprocessEnv({
|
|
88
|
+
base: { HOME: "/home/u", TZ: "UTC" },
|
|
89
|
+
overrides: { TZ: "Europe/Berlin", EXTRA: "1" },
|
|
90
|
+
applyDenylist: true,
|
|
91
|
+
});
|
|
92
|
+
expect(env.TZ).toBe("Europe/Berlin");
|
|
93
|
+
expect(env.HOME).toBe("/home/u");
|
|
94
|
+
expect(env.EXTRA).toBe("1");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("PATH override hardening", () => {
|
|
99
|
+
it("PATH is a forbidden caller-override key (curated base PATH survives)", () => {
|
|
100
|
+
expect((FORBIDDEN_ENV_KEYS as readonly string[]).includes("PATH")).toBe(
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
expect(isForbiddenEnvKey("PATH")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized environment hardening for the shared script runners.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
* 1. The `SAFE_ENV_VARS` whitelist + `pickSafeEnv()` (moved here from the two
|
|
6
|
+
* runners, which previously each had an identical copy). This is the
|
|
7
|
+
* curated subset forwarded to user scripts so backend secrets never leak.
|
|
8
|
+
* 2. A forbidden-key DENYLIST applied to the merged env when the sandbox is
|
|
9
|
+
* enabled. This closes the known env-injection escape: a caller/operator
|
|
10
|
+
* supplying `LD_PRELOAD` / `NODE_OPTIONS` / `PATH`-override etc. could
|
|
11
|
+
* otherwise subvert the child process. When the sandbox is disabled the
|
|
12
|
+
* denylist is NOT applied, preserving the exact pre-hardening behavior.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Vars passed through to the subprocess. We intentionally do NOT forward the
|
|
17
|
+
* satellite's full env so backend secrets (DB URLs, API tokens, signing keys)
|
|
18
|
+
* never reach user-authored scripts. PATH / HOME / LANG / ... are kept so
|
|
19
|
+
* `node:child_process`, `node:fs`, locale-sensitive APIs, and ordinary CLI
|
|
20
|
+
* tools behave normally.
|
|
21
|
+
*/
|
|
22
|
+
export const SAFE_ENV_VARS = [
|
|
23
|
+
"PATH",
|
|
24
|
+
"HOME",
|
|
25
|
+
"USER",
|
|
26
|
+
"LANG",
|
|
27
|
+
"LC_ALL",
|
|
28
|
+
"LC_CTYPE",
|
|
29
|
+
"TZ",
|
|
30
|
+
"TMPDIR",
|
|
31
|
+
"HOSTNAME",
|
|
32
|
+
"SHELL",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Forbidden env keys dropped from the merged env when the sandbox is enabled.
|
|
37
|
+
*
|
|
38
|
+
* These either alter the dynamic loader (`LD_*` / `DYLD_*`), inject code into
|
|
39
|
+
* the Node/Bun runtime (`NODE_OPTIONS`), or repoint Bun's install/config
|
|
40
|
+
* behavior (`BUN_INSTALL`, `BUN_CONFIG_*`). A user/operator must not be able
|
|
41
|
+
* to set them; the legitimate safe vars (`PATH`, etc.) are still forwarded via
|
|
42
|
+
* `SAFE_ENV_VARS`, but a caller cannot OVERRIDE `PATH` to a malicious dir
|
|
43
|
+
* because `PATH` is on the denylist for caller-supplied overrides.
|
|
44
|
+
*
|
|
45
|
+
* `BUN_CONFIG_` is a prefix match (any key starting with it is dropped).
|
|
46
|
+
*/
|
|
47
|
+
export const FORBIDDEN_ENV_KEYS = [
|
|
48
|
+
"LD_PRELOAD",
|
|
49
|
+
"LD_LIBRARY_PATH",
|
|
50
|
+
"LD_AUDIT",
|
|
51
|
+
"DYLD_INSERT_LIBRARIES",
|
|
52
|
+
"DYLD_LIBRARY_PATH",
|
|
53
|
+
"NODE_OPTIONS",
|
|
54
|
+
"BUN_INSTALL",
|
|
55
|
+
// PATH is a safe-base var (the curated one is always forwarded), but a
|
|
56
|
+
// caller-supplied PATH OVERRIDE could repoint binary resolution at a
|
|
57
|
+
// malicious directory, so caller overrides of PATH are dropped (the
|
|
58
|
+
// curated base PATH survives). Closes the PATH-override escape (§1/§5.6).
|
|
59
|
+
"PATH",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
/** Key prefixes that are forbidden (any matching key is dropped). */
|
|
63
|
+
export const FORBIDDEN_ENV_PREFIXES = ["BUN_CONFIG_"] as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the curated safe-env base from the current process environment.
|
|
67
|
+
* Identical behavior to the per-runner copies this replaces.
|
|
68
|
+
*/
|
|
69
|
+
export function pickSafeEnv(): Record<string, string> {
|
|
70
|
+
const env: Record<string, string> = {};
|
|
71
|
+
for (const key of SAFE_ENV_VARS) {
|
|
72
|
+
const value = process.env[key];
|
|
73
|
+
if (value !== undefined) {
|
|
74
|
+
env[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return env;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True when `key` is forbidden by the denylist (exact or prefix match). */
|
|
81
|
+
export function isForbiddenEnvKey(key: string): boolean {
|
|
82
|
+
if ((FORBIDDEN_ENV_KEYS as readonly string[]).includes(key)) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return FORBIDDEN_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FilterEnvResult {
|
|
89
|
+
/** The merged env with forbidden keys removed (when enabled). */
|
|
90
|
+
env: Record<string, string>;
|
|
91
|
+
/** Keys that were dropped by the denylist. Empty when none / disabled. */
|
|
92
|
+
dropped: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the final subprocess env: the safe-env base overlaid with the
|
|
97
|
+
* caller-supplied `overrides`. When `applyDenylist` is true, any caller
|
|
98
|
+
* override whose key is on the forbidden denylist is dropped (the safe-env
|
|
99
|
+
* base value, if any, is retained — e.g. the real `PATH` survives, but a
|
|
100
|
+
* caller's attempt to REPLACE it does not).
|
|
101
|
+
*
|
|
102
|
+
* When `applyDenylist` is false the behavior is exactly the prior
|
|
103
|
+
* `{ ...pickSafeEnv(), ...overrides }` merge (back-compat for disabled
|
|
104
|
+
* sandbox / opted-out runs).
|
|
105
|
+
*/
|
|
106
|
+
export function buildSubprocessEnv({
|
|
107
|
+
base,
|
|
108
|
+
overrides,
|
|
109
|
+
applyDenylist,
|
|
110
|
+
}: {
|
|
111
|
+
base: Record<string, string>;
|
|
112
|
+
overrides?: Record<string, string>;
|
|
113
|
+
applyDenylist: boolean;
|
|
114
|
+
}): FilterEnvResult {
|
|
115
|
+
const env: Record<string, string> = { ...base };
|
|
116
|
+
const dropped: string[] = [];
|
|
117
|
+
|
|
118
|
+
if (overrides !== undefined) {
|
|
119
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
120
|
+
if (applyDenylist && isForbiddenEnvKey(key)) {
|
|
121
|
+
dropped.push(key);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
env[key] = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { env, dropped };
|
|
129
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { SandboxCapabilities } from "./capabilities";
|
|
3
|
+
import { buildFilesystemLayer } from "./filesystem";
|
|
4
|
+
import { filesystemPolicySchema } from "./policy";
|
|
5
|
+
|
|
6
|
+
const LINUX_BWRAP: SandboxCapabilities = {
|
|
7
|
+
platform: "linux",
|
|
8
|
+
euidIsRoot: false,
|
|
9
|
+
hasPrlimit: true,
|
|
10
|
+
rlimitNative: true,
|
|
11
|
+
wrapper: "bwrap",
|
|
12
|
+
userNamespaces: true,
|
|
13
|
+
userNsCreatable: true,
|
|
14
|
+
netNamespaces: true,
|
|
15
|
+
netEgressIface: null,
|
|
16
|
+
netEgressAddressing: null,
|
|
17
|
+
netEgressRootless: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const LINUX_NSJAIL: SandboxCapabilities = {
|
|
21
|
+
...LINUX_BWRAP,
|
|
22
|
+
wrapper: "nsjail",
|
|
23
|
+
netEgressIface: "eth0",
|
|
24
|
+
netEgressAddressing: {
|
|
25
|
+
ip: "10.0.0.2",
|
|
26
|
+
netmask: "255.255.255.0",
|
|
27
|
+
gateway: "10.0.0.1",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const LINUX_NO_WRAPPER: SandboxCapabilities = {
|
|
32
|
+
...LINUX_BWRAP,
|
|
33
|
+
wrapper: null,
|
|
34
|
+
netNamespaces: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const LINUX_FIREJAIL: SandboxCapabilities = {
|
|
38
|
+
...LINUX_BWRAP,
|
|
39
|
+
wrapper: "firejail",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const LINUX_NO_USERNS: SandboxCapabilities = {
|
|
43
|
+
...LINUX_BWRAP,
|
|
44
|
+
userNamespaces: false,
|
|
45
|
+
userNsCreatable: false,
|
|
46
|
+
netNamespaces: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const MACOS: SandboxCapabilities = {
|
|
50
|
+
platform: "darwin",
|
|
51
|
+
euidIsRoot: false,
|
|
52
|
+
hasPrlimit: false,
|
|
53
|
+
rlimitNative: false,
|
|
54
|
+
wrapper: null,
|
|
55
|
+
userNamespaces: false,
|
|
56
|
+
userNsCreatable: false,
|
|
57
|
+
netNamespaces: false,
|
|
58
|
+
netEgressIface: null,
|
|
59
|
+
netEgressAddressing: null,
|
|
60
|
+
netEgressRootless: false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// All paths exist by default; tests override per-case.
|
|
64
|
+
const allExist = () => true;
|
|
65
|
+
|
|
66
|
+
function fsPolicy(input: unknown) {
|
|
67
|
+
return filesystemPolicySchema.parse(input);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("buildFilesystemLayer — off", () => {
|
|
71
|
+
it("returns off (no prelude) for mode=off regardless of host", () => {
|
|
72
|
+
const r = buildFilesystemLayer({
|
|
73
|
+
policy: fsPolicy({ mode: "off" }),
|
|
74
|
+
caps: MACOS,
|
|
75
|
+
inputs: {},
|
|
76
|
+
pathExists: allExist,
|
|
77
|
+
});
|
|
78
|
+
expect(r.kind).toBe("off");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("buildFilesystemLayer — bwrap scratch-only", () => {
|
|
83
|
+
const r = buildFilesystemLayer({
|
|
84
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
85
|
+
caps: LINUX_BWRAP,
|
|
86
|
+
inputs: { scratchDir: "/tmp/run-1" },
|
|
87
|
+
pathExists: allExist,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("builds a bwrap prelude", () => {
|
|
91
|
+
expect(r.kind).toBe("enforced");
|
|
92
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
93
|
+
expect(r.prelude[0]).toBe("bwrap");
|
|
94
|
+
expect(r.prelude).toContain("--unshare-all");
|
|
95
|
+
// Network is a SEPARATE layer; FS isolation must keep host net.
|
|
96
|
+
expect(r.prelude).toContain("--share-net");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("binds the scratch dir read-write and chdirs into it", () => {
|
|
100
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
101
|
+
const joined = r.prelude.join(" ");
|
|
102
|
+
expect(joined).toContain("--bind /tmp/run-1 /tmp/run-1");
|
|
103
|
+
expect(joined).toContain("--chdir /tmp/run-1");
|
|
104
|
+
expect(r.prelude[r.prelude.length - 1]).toBe("--");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("ro-binds the base system but NOT node_modules under scratch-only", () => {
|
|
108
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
109
|
+
const joined = r.prelude.join(" ");
|
|
110
|
+
expect(joined).toContain("--ro-bind /usr /usr");
|
|
111
|
+
expect(joined).not.toContain("node_modules");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("buildFilesystemLayer — privilege drop carried by the wrapper", () => {
|
|
116
|
+
it("emits bwrap --uid/--gid when a drop target is supplied", () => {
|
|
117
|
+
const r = buildFilesystemLayer({
|
|
118
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
119
|
+
caps: LINUX_BWRAP,
|
|
120
|
+
inputs: { scratchDir: "/tmp/run-uid", dropUid: 65532, dropGid: 65532 },
|
|
121
|
+
pathExists: allExist,
|
|
122
|
+
});
|
|
123
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
124
|
+
const joined = r.prelude.join(" ");
|
|
125
|
+
expect(joined).toContain("--uid 65532");
|
|
126
|
+
expect(joined).toContain("--gid 65532");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("emits nsjail --user/--group when a drop target is supplied", () => {
|
|
130
|
+
const r = buildFilesystemLayer({
|
|
131
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
132
|
+
caps: LINUX_NSJAIL,
|
|
133
|
+
inputs: { scratchDir: "/tmp/run-uid2", dropUid: 65532, dropGid: 65532 },
|
|
134
|
+
pathExists: allExist,
|
|
135
|
+
});
|
|
136
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
137
|
+
const joined = r.prelude.join(" ");
|
|
138
|
+
expect(joined).toContain("--user 65532");
|
|
139
|
+
expect(joined).toContain("--group 65532");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("omits the uid drop when no target is supplied", () => {
|
|
143
|
+
const r = buildFilesystemLayer({
|
|
144
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
145
|
+
caps: LINUX_BWRAP,
|
|
146
|
+
inputs: { scratchDir: "/tmp/run-nouid" },
|
|
147
|
+
pathExists: allExist,
|
|
148
|
+
});
|
|
149
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
150
|
+
expect(r.prelude).not.toContain("--uid");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("buildFilesystemLayer — bwrap scratch-plus-ro", () => {
|
|
155
|
+
it("ro-binds the reconciled node_modules tree", () => {
|
|
156
|
+
const r = buildFilesystemLayer({
|
|
157
|
+
policy: fsPolicy({ mode: "scratch-plus-ro" }),
|
|
158
|
+
caps: LINUX_BWRAP,
|
|
159
|
+
inputs: {
|
|
160
|
+
scratchDir: "/tmp/run-2",
|
|
161
|
+
nodeModulesDir: "/store/current/node_modules",
|
|
162
|
+
},
|
|
163
|
+
pathExists: allExist,
|
|
164
|
+
});
|
|
165
|
+
expect(r.kind).toBe("enforced");
|
|
166
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
167
|
+
expect(r.prelude.join(" ")).toContain(
|
|
168
|
+
"--ro-bind /store/current/node_modules /store/current/node_modules",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("skips the node_modules bind when the tree does not exist on disk", () => {
|
|
173
|
+
const r = buildFilesystemLayer({
|
|
174
|
+
policy: fsPolicy({ mode: "scratch-plus-ro" }),
|
|
175
|
+
caps: LINUX_BWRAP,
|
|
176
|
+
inputs: {
|
|
177
|
+
scratchDir: "/tmp/run-3",
|
|
178
|
+
nodeModulesDir: "/store/missing/node_modules",
|
|
179
|
+
},
|
|
180
|
+
pathExists: (p) => p !== "/store/missing/node_modules",
|
|
181
|
+
});
|
|
182
|
+
expect(r.kind).toBe("enforced");
|
|
183
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
184
|
+
expect(r.prelude.join(" ")).not.toContain("/store/missing/node_modules");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("skips a base path that is absent on this distro (e.g. /lib64)", () => {
|
|
188
|
+
const r = buildFilesystemLayer({
|
|
189
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
190
|
+
caps: LINUX_BWRAP,
|
|
191
|
+
inputs: { scratchDir: "/tmp/run-4" },
|
|
192
|
+
pathExists: (p) => p !== "/lib64",
|
|
193
|
+
});
|
|
194
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
195
|
+
expect(r.prelude.join(" ")).not.toContain("--ro-bind /lib64");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("buildFilesystemLayer — interpreter bind (P2 fix)", () => {
|
|
200
|
+
it("ro-binds the realpath-resolved interpreter when outside the base paths", () => {
|
|
201
|
+
const r = buildFilesystemLayer({
|
|
202
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
203
|
+
caps: LINUX_BWRAP,
|
|
204
|
+
inputs: {
|
|
205
|
+
scratchDir: "/tmp/run-int",
|
|
206
|
+
interpreterPath: "/home/u/.bun/bin/bun",
|
|
207
|
+
},
|
|
208
|
+
pathExists: allExist,
|
|
209
|
+
realpath: (p) => p, // identity for the test
|
|
210
|
+
});
|
|
211
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
212
|
+
expect(r.prelude.join(" ")).toContain(
|
|
213
|
+
"--ro-bind /home/u/.bun/bin/bun /home/u/.bun/bin/bun",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("does NOT double-bind an interpreter already under a base path", () => {
|
|
218
|
+
const r = buildFilesystemLayer({
|
|
219
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
220
|
+
caps: LINUX_BWRAP,
|
|
221
|
+
inputs: {
|
|
222
|
+
scratchDir: "/tmp/run-int2",
|
|
223
|
+
interpreterPath: "/usr/local/bin/bun",
|
|
224
|
+
},
|
|
225
|
+
pathExists: allExist,
|
|
226
|
+
realpath: (p) => p,
|
|
227
|
+
});
|
|
228
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
229
|
+
// /usr/local/bin/bun is under /usr (a RO base path) → no extra bind.
|
|
230
|
+
const joined = r.prelude.join(" ");
|
|
231
|
+
expect(joined).not.toContain("--ro-bind /usr/local/bin/bun");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("nsjail ro-binds the interpreter too", () => {
|
|
235
|
+
const r = buildFilesystemLayer({
|
|
236
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
237
|
+
caps: LINUX_NSJAIL,
|
|
238
|
+
inputs: {
|
|
239
|
+
scratchDir: "/tmp/run-int3",
|
|
240
|
+
interpreterPath: "/opt/bun/bin/bun",
|
|
241
|
+
},
|
|
242
|
+
pathExists: allExist,
|
|
243
|
+
realpath: (p) => p,
|
|
244
|
+
});
|
|
245
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
246
|
+
expect(r.prelude.join(" ")).toContain(
|
|
247
|
+
"--bindmount_ro /opt/bun/bin/bun:/opt/bun/bin/bun",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("buildFilesystemLayer — network composition (Phase 3)", () => {
|
|
253
|
+
it("emits --unshare-net when the network decision is namespaced (bwrap)", () => {
|
|
254
|
+
const r = buildFilesystemLayer({
|
|
255
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
256
|
+
caps: LINUX_BWRAP,
|
|
257
|
+
inputs: { scratchDir: "/tmp/run-net1" },
|
|
258
|
+
network: { kind: "namespaced", mode: "deny" },
|
|
259
|
+
pathExists: allExist,
|
|
260
|
+
});
|
|
261
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
262
|
+
expect(r.prelude).toContain("--unshare-net");
|
|
263
|
+
expect(r.prelude).not.toContain("--share-net");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("emits --share-net when the network decision keeps the host net", () => {
|
|
267
|
+
const r = buildFilesystemLayer({
|
|
268
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
269
|
+
caps: LINUX_BWRAP,
|
|
270
|
+
inputs: { scratchDir: "/tmp/run-net2" },
|
|
271
|
+
network: { kind: "host", metadataBlockUnenforceable: false },
|
|
272
|
+
pathExists: allExist,
|
|
273
|
+
});
|
|
274
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
275
|
+
expect(r.prelude).toContain("--share-net");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("nsjail plumbs macvlan + installs the nftables ruleset for a namespaced allowlist", () => {
|
|
279
|
+
const r = buildFilesystemLayer({
|
|
280
|
+
policy: fsPolicy({ mode: "off" }),
|
|
281
|
+
caps: LINUX_NSJAIL,
|
|
282
|
+
inputs: {},
|
|
283
|
+
network: {
|
|
284
|
+
kind: "namespaced",
|
|
285
|
+
mode: "allowlist",
|
|
286
|
+
egressIface: "eth0",
|
|
287
|
+
egressAddressing: {
|
|
288
|
+
ip: "10.0.0.2",
|
|
289
|
+
netmask: "255.255.255.0",
|
|
290
|
+
gateway: "10.0.0.1",
|
|
291
|
+
},
|
|
292
|
+
nftRuleset: "table inet ...",
|
|
293
|
+
},
|
|
294
|
+
nftRulesetPath: "/tmp/egress.nft",
|
|
295
|
+
pathExists: allExist,
|
|
296
|
+
});
|
|
297
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
298
|
+
expect(r.prelude).toContain("--clone_newnet");
|
|
299
|
+
// Real uplink plumbed in so the allowed CIDR is reachable, not blackholed.
|
|
300
|
+
expect(r.prelude).toContain("--macvlan_iface");
|
|
301
|
+
expect(r.prelude).toContain("eth0");
|
|
302
|
+
// The endpoint must be ADDRESSED or the macvlan still blackholes (P3 NIT).
|
|
303
|
+
expect(r.prelude).toContain("--macvlan_vs_ip");
|
|
304
|
+
expect(r.prelude).toContain("10.0.0.2");
|
|
305
|
+
expect(r.prelude).toContain("--macvlan_vs_nm");
|
|
306
|
+
expect(r.prelude).toContain("255.255.255.0");
|
|
307
|
+
expect(r.prelude).toContain("--macvlan_vs_gw");
|
|
308
|
+
expect(r.prelude).toContain("10.0.0.1");
|
|
309
|
+
expect(r.prelude).toContain("--nftables_file");
|
|
310
|
+
expect(r.prelude).toContain("/tmp/egress.nft");
|
|
311
|
+
// FS off → host root bound so binaries still resolve.
|
|
312
|
+
expect(r.prelude.join(" ")).toContain("--bindmount /:/");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("deny is routeless: clone_newnet but NO macvlan uplink, NO ruleset", () => {
|
|
316
|
+
const r = buildFilesystemLayer({
|
|
317
|
+
policy: fsPolicy({ mode: "off" }),
|
|
318
|
+
caps: LINUX_NSJAIL,
|
|
319
|
+
inputs: {},
|
|
320
|
+
network: { kind: "namespaced", mode: "deny" },
|
|
321
|
+
pathExists: allExist,
|
|
322
|
+
});
|
|
323
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
324
|
+
expect(r.prelude).toContain("--clone_newnet");
|
|
325
|
+
expect(r.prelude).not.toContain("--macvlan_iface");
|
|
326
|
+
expect(r.prelude).not.toContain("--nftables_file");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("builds a network-only bwrap wrapper (FS off) binding the host root", () => {
|
|
330
|
+
const r = buildFilesystemLayer({
|
|
331
|
+
policy: fsPolicy({ mode: "off" }),
|
|
332
|
+
caps: LINUX_BWRAP,
|
|
333
|
+
inputs: {},
|
|
334
|
+
network: { kind: "namespaced", mode: "deny" },
|
|
335
|
+
pathExists: allExist,
|
|
336
|
+
});
|
|
337
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
338
|
+
expect(r.prelude.join(" ")).toContain("--bind / /");
|
|
339
|
+
expect(r.prelude).toContain("--unshare-net");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("returns off when neither FS nor network needs a wrapper", () => {
|
|
343
|
+
const r = buildFilesystemLayer({
|
|
344
|
+
policy: fsPolicy({ mode: "off" }),
|
|
345
|
+
caps: LINUX_BWRAP,
|
|
346
|
+
inputs: {},
|
|
347
|
+
network: { kind: "host", metadataBlockUnenforceable: false },
|
|
348
|
+
pathExists: allExist,
|
|
349
|
+
});
|
|
350
|
+
expect(r.kind).toBe("off");
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("buildFilesystemLayer — nsjail", () => {
|
|
355
|
+
it("builds an nsjail prelude with the equivalent bind set", () => {
|
|
356
|
+
const r = buildFilesystemLayer({
|
|
357
|
+
policy: fsPolicy({ mode: "scratch-plus-ro" }),
|
|
358
|
+
caps: LINUX_NSJAIL,
|
|
359
|
+
inputs: {
|
|
360
|
+
scratchDir: "/tmp/run-5",
|
|
361
|
+
nodeModulesDir: "/store/current/node_modules",
|
|
362
|
+
},
|
|
363
|
+
pathExists: allExist,
|
|
364
|
+
});
|
|
365
|
+
expect(r.kind).toBe("enforced");
|
|
366
|
+
if (r.kind !== "enforced") throw new Error("expected enforced");
|
|
367
|
+
expect(r.prelude[0]).toBe("nsjail");
|
|
368
|
+
const joined = r.prelude.join(" ");
|
|
369
|
+
expect(joined).toContain("--bindmount /tmp/run-5:/tmp/run-5");
|
|
370
|
+
expect(joined).toContain(
|
|
371
|
+
"--bindmount_ro /store/current/node_modules:/store/current/node_modules",
|
|
372
|
+
);
|
|
373
|
+
expect(joined).toContain("--cwd /tmp/run-5");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("buildFilesystemLayer — degrade paths", () => {
|
|
378
|
+
it("degrades on macOS (no Linux namespaces)", () => {
|
|
379
|
+
const r = buildFilesystemLayer({
|
|
380
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
381
|
+
caps: MACOS,
|
|
382
|
+
inputs: { scratchDir: "/tmp/x" },
|
|
383
|
+
pathExists: allExist,
|
|
384
|
+
});
|
|
385
|
+
expect(r.kind).toBe("degrade");
|
|
386
|
+
if (r.kind !== "degrade") throw new Error("expected degrade");
|
|
387
|
+
expect(r.reason).toContain("platform=darwin");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("degrades when unprivileged user namespaces are disabled", () => {
|
|
391
|
+
const r = buildFilesystemLayer({
|
|
392
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
393
|
+
caps: LINUX_NO_USERNS,
|
|
394
|
+
inputs: { scratchDir: "/tmp/x" },
|
|
395
|
+
pathExists: allExist,
|
|
396
|
+
});
|
|
397
|
+
expect(r.kind).toBe("degrade");
|
|
398
|
+
if (r.kind !== "degrade") throw new Error("expected degrade");
|
|
399
|
+
expect(r.reason).toContain("user namespaces");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("degrades when no namespace wrapper is on PATH", () => {
|
|
403
|
+
const r = buildFilesystemLayer({
|
|
404
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
405
|
+
caps: LINUX_NO_WRAPPER,
|
|
406
|
+
inputs: { scratchDir: "/tmp/x" },
|
|
407
|
+
pathExists: allExist,
|
|
408
|
+
});
|
|
409
|
+
expect(r.kind).toBe("degrade");
|
|
410
|
+
if (r.kind !== "degrade") throw new Error("expected degrade");
|
|
411
|
+
expect(r.reason).toContain("bwrap/nsjail");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("degrades on a firejail-only host (profile model unsupported)", () => {
|
|
415
|
+
const r = buildFilesystemLayer({
|
|
416
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
417
|
+
caps: LINUX_FIREJAIL,
|
|
418
|
+
inputs: { scratchDir: "/tmp/x" },
|
|
419
|
+
pathExists: allExist,
|
|
420
|
+
});
|
|
421
|
+
expect(r.kind).toBe("degrade");
|
|
422
|
+
if (r.kind !== "degrade") throw new Error("expected degrade");
|
|
423
|
+
expect(r.reason).toContain("firejail");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("degrades when the runner supplies no scratch dir", () => {
|
|
427
|
+
const r = buildFilesystemLayer({
|
|
428
|
+
policy: fsPolicy({ mode: "scratch-only" }),
|
|
429
|
+
caps: LINUX_BWRAP,
|
|
430
|
+
inputs: {},
|
|
431
|
+
pathExists: allExist,
|
|
432
|
+
});
|
|
433
|
+
expect(r.kind).toBe("degrade");
|
|
434
|
+
if (r.kind !== "degrade") throw new Error("expected degrade");
|
|
435
|
+
expect(r.reason).toContain("scratch dir");
|
|
436
|
+
});
|
|
437
|
+
});
|