@checkstack/backend-api 0.19.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 +205 -0
- package/package.json +12 -11
- package/src/advisory-lock-pool.it.test.ts +282 -0
- package/src/advisory-lock.test.ts +144 -3
- package/src/advisory-lock.ts +97 -55
- 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,88 @@
|
|
|
1
|
+
import type { SandboxPolicy } from "./policy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The set of sandbox layers. Used as keys in the enforced/downgrade maps so
|
|
5
|
+
* the surfaced report names layers consistently.
|
|
6
|
+
*/
|
|
7
|
+
export type SandboxLayer =
|
|
8
|
+
| "resources"
|
|
9
|
+
| "filesystem"
|
|
10
|
+
| "network"
|
|
11
|
+
| "privilege";
|
|
12
|
+
|
|
13
|
+
/** Which layers are actually enforced for a given run. */
|
|
14
|
+
export interface EnforcedLayers {
|
|
15
|
+
resources: boolean;
|
|
16
|
+
filesystem: boolean;
|
|
17
|
+
network: boolean;
|
|
18
|
+
privilege: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A single layer that could not be enforced as requested. */
|
|
22
|
+
export interface SandboxDowngrade {
|
|
23
|
+
layer: SandboxLayer;
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A NON-FATAL informational note about how a layer is enforced for this run.
|
|
29
|
+
*
|
|
30
|
+
* Unlike {@link SandboxDowngrade}, a note NEVER triggers the fail-closed gate
|
|
31
|
+
* (`onUnavailable: "fail"`). It records an accepted, expected enforcement
|
|
32
|
+
* characteristic that an operator should be aware of but that is NOT a failure
|
|
33
|
+
* to enforce the requested policy.
|
|
34
|
+
*
|
|
35
|
+
* The motivating case: SHELL scripts have no per-run memory enforcement. The
|
|
36
|
+
* ESM JS-heap cap (`NODE_OPTIONS=--max-old-space-size`) is consumed only by the
|
|
37
|
+
* ESM runner; the shell runner never applies it, so a shell run's memory
|
|
38
|
+
* ceiling is purely the container cgroup (Docker `--memory` / k8s
|
|
39
|
+
* `resources.limits.memory`). That is a legitimate ceiling, not a missing
|
|
40
|
+
* layer: refusing every shell run under fail-closed because of it would break
|
|
41
|
+
* all shell health-checks and automation. So it is surfaced as a note rather
|
|
42
|
+
* than overloaded onto `downgrades` (which fail-close).
|
|
43
|
+
*/
|
|
44
|
+
export interface SandboxNote {
|
|
45
|
+
layer: SandboxLayer;
|
|
46
|
+
note: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What the sandbox actually did for a run, attached to the run result so call
|
|
51
|
+
* sites can log/surface it. `downgrades` is empty when every requested layer
|
|
52
|
+
* was fully enforced; otherwise it names each dropped layer and why.
|
|
53
|
+
*/
|
|
54
|
+
export interface EffectiveSandbox {
|
|
55
|
+
/** The policy that was requested (fully resolved, post-merge). */
|
|
56
|
+
requested: SandboxPolicy;
|
|
57
|
+
/** Which layers are actually enforced on this host. */
|
|
58
|
+
enforced: EnforcedLayers;
|
|
59
|
+
/** Layers that were requested but degraded to the portable subset. */
|
|
60
|
+
downgrades: SandboxDowngrade[];
|
|
61
|
+
/**
|
|
62
|
+
* Non-fatal informational notes about HOW a layer is enforced (e.g. shell
|
|
63
|
+
* memory bounded by the cgroup rather than per-run). Never triggers
|
|
64
|
+
* fail-closed; surfaced alongside downgrades so operators see the full
|
|
65
|
+
* picture. Empty when there is nothing extra to surface.
|
|
66
|
+
*/
|
|
67
|
+
notes: SandboxNote[];
|
|
68
|
+
/** Host platform the run executed on. */
|
|
69
|
+
platform: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Thrown by the hardening builder when a requested layer cannot be enforced
|
|
74
|
+
* and the policy's `onUnavailable` is `"fail"`. The runner catches this and
|
|
75
|
+
* returns a clean failure result WITHOUT spawning an unsandboxed child.
|
|
76
|
+
*/
|
|
77
|
+
export class SandboxUnavailableError extends Error {
|
|
78
|
+
readonly downgrades: SandboxDowngrade[];
|
|
79
|
+
|
|
80
|
+
constructor(downgrades: SandboxDowngrade[]) {
|
|
81
|
+
const summary = downgrades
|
|
82
|
+
.map((d) => `${d.layer}: ${d.reason}`)
|
|
83
|
+
.join("; ");
|
|
84
|
+
super(`sandbox unavailable: ${summary}`);
|
|
85
|
+
this.name = "SandboxUnavailableError";
|
|
86
|
+
this.downgrades = downgrades;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the ROOTLESS egress path (slirp4netns + fail-closed
|
|
3
|
+
* nftables), exercising the REAL launcher against a real `bwrap` + `slirp4netns`
|
|
4
|
+
* + `nft`. Pure/argv-level coverage lives in `rootless-egress.test.ts`,
|
|
5
|
+
* `network.test.ts`, and `wrapper.test.ts`; this pins the one thing those
|
|
6
|
+
* cannot: that the generated launcher actually plumbs filtered egress and that
|
|
7
|
+
* the filter is genuinely enforced (the allowed dest is reachable, a blocked
|
|
8
|
+
* dest is not), so `enforced.network = true` is truthful.
|
|
9
|
+
*
|
|
10
|
+
* Gated behind `CHECKSTACK_IT=1` AND auto-skipped when the host lacks the
|
|
11
|
+
* primitives (the detected capability says no rootless path), so the default
|
|
12
|
+
* `bun test` and non-Linux/non-rootless CI never run it. It needs a host where
|
|
13
|
+
* `unshare --user --net` works, `slirp4netns` + `bwrap` + `nft` are on PATH.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "bun:test";
|
|
16
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { detectSandboxCapabilities } from "./capabilities";
|
|
20
|
+
import { buildNetworkLayer } from "./network";
|
|
21
|
+
import { buildSpawnHardening } from "./wrapper";
|
|
22
|
+
import { pickSafeEnv } from "./env-guard";
|
|
23
|
+
import { sandboxPolicySchema } from "./policy";
|
|
24
|
+
|
|
25
|
+
const caps = detectSandboxCapabilities();
|
|
26
|
+
const enabled = Boolean(process.env.CHECKSTACK_IT) && caps.netEgressRootless;
|
|
27
|
+
|
|
28
|
+
describe.skipIf(!enabled)("rootless egress (real slirp4netns)", () => {
|
|
29
|
+
it("delivers filtered egress: blocks a non-allowlisted destination", async () => {
|
|
30
|
+
const dir = await mkdtemp(path.join(tmpdir(), "checkstack-rl-it-"));
|
|
31
|
+
try {
|
|
32
|
+
const nftRulesetPath = path.join(dir, "egress.nft");
|
|
33
|
+
const rootlessLauncherPath = path.join(dir, "rootless-egress.sh");
|
|
34
|
+
// Allowlist a single private CIDR that is NOT the public target below.
|
|
35
|
+
const policy = sandboxPolicySchema.parse({
|
|
36
|
+
filesystem: { mode: "off" },
|
|
37
|
+
network: { mode: "allowlist", allow: ["10.255.255.0/24"] },
|
|
38
|
+
privilege: { mode: "inherit" },
|
|
39
|
+
resources: { maxOutputBytes: 64 * 1024 },
|
|
40
|
+
});
|
|
41
|
+
const hardening = buildSpawnHardening({
|
|
42
|
+
policy,
|
|
43
|
+
caps,
|
|
44
|
+
baseEnv: pickSafeEnv(),
|
|
45
|
+
nftRulesetPath,
|
|
46
|
+
rootlessLauncherPath,
|
|
47
|
+
});
|
|
48
|
+
expect(hardening.effective.enforced.network).toBe(true);
|
|
49
|
+
expect(hardening.rootlessLauncher).toBeDefined();
|
|
50
|
+
await writeFile(nftRulesetPath, hardening.nftRuleset ?? "", "utf8");
|
|
51
|
+
await writeFile(rootlessLauncherPath, hardening.rootlessLauncher ?? "", {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
mode: 0o700,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// A blocked destination (1.1.1.1, not in the allowlist) must NOT connect.
|
|
57
|
+
// We use a short-timeout TCP connect; a filtered egress yields failure.
|
|
58
|
+
const cmd = hardening.wrapCmd([
|
|
59
|
+
"sh",
|
|
60
|
+
"-c",
|
|
61
|
+
// `nc`/`/dev/tcp` may be unavailable; use the busybox-ish `timeout` +
|
|
62
|
+
// a connect attempt that returns non-zero when blocked.
|
|
63
|
+
"timeout 3 sh -c 'exec 3<>/dev/tcp/1.1.1.1/443' && echo CONNECTED || echo BLOCKED",
|
|
64
|
+
]);
|
|
65
|
+
const proc = Bun.spawn({ cmd, env: hardening.env, stdout: "pipe", stderr: "pipe" });
|
|
66
|
+
const out = await new Response(proc.stdout).text();
|
|
67
|
+
await proc.exited;
|
|
68
|
+
// The allowlist does not include 1.1.1.1, so egress to it is dropped.
|
|
69
|
+
expect(out).toContain("BLOCKED");
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
}, 30_000);
|
|
74
|
+
|
|
75
|
+
it("the network decision picks the rootless path on this host", () => {
|
|
76
|
+
const d = buildNetworkLayer({
|
|
77
|
+
policy: sandboxPolicySchema.parse({
|
|
78
|
+
network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
|
|
79
|
+
}).network,
|
|
80
|
+
caps,
|
|
81
|
+
});
|
|
82
|
+
expect(d.kind).toBe("namespaced");
|
|
83
|
+
if (d.kind !== "namespaced") throw new Error("expected namespaced");
|
|
84
|
+
expect(d.egressPath).toBe("rootless");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildRootlessInnerScript,
|
|
4
|
+
buildRootlessLauncherScript,
|
|
5
|
+
SLIRP4NETNS_CHILD_IP,
|
|
6
|
+
SLIRP4NETNS_GATEWAY,
|
|
7
|
+
SLIRP4NETNS_TAP_DEVICE,
|
|
8
|
+
} from "./rootless-egress";
|
|
9
|
+
|
|
10
|
+
describe("buildRootlessInnerScript — fail-closed filter ordering", () => {
|
|
11
|
+
const inner = buildRootlessInnerScript({ nftRulesetPath: "/run/egress.nft" });
|
|
12
|
+
|
|
13
|
+
it("loads the nft filter BEFORE signalling readiness or exec'ing", () => {
|
|
14
|
+
const nftIdx = inner.indexOf("nft -f");
|
|
15
|
+
const readyIdx = inner.indexOf("printf ready >&9");
|
|
16
|
+
const execIdx = inner.indexOf('exec "$@"');
|
|
17
|
+
// Filter loads first, THEN we announce the namespace, THEN exec the command.
|
|
18
|
+
expect(nftIdx).toBeGreaterThanOrEqual(0);
|
|
19
|
+
expect(nftIdx).toBeLessThan(readyIdx);
|
|
20
|
+
expect(readyIdx).toBeLessThan(execIdx);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is `set -e` so a failed nft load aborts before exec (never unfiltered)", () => {
|
|
24
|
+
expect(inner.startsWith("set -e")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("waits for the launcher's network-ready signal before exec", () => {
|
|
28
|
+
const readyIdx = inner.indexOf("printf ready >&9");
|
|
29
|
+
const waitIdx = inner.indexOf("IFS= read -r _ <&8");
|
|
30
|
+
const execIdx = inner.indexOf('exec "$@"');
|
|
31
|
+
expect(readyIdx).toBeLessThan(waitIdx);
|
|
32
|
+
expect(waitIdx).toBeLessThan(execIdx);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("execs the real command via positional args (no string splicing)", () => {
|
|
36
|
+
expect(inner).toContain('exec "$@"');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("buildRootlessLauncherScript — slirp4netns orchestration", () => {
|
|
41
|
+
const launcher = buildRootlessLauncherScript({
|
|
42
|
+
bwrapArgv: ["bwrap", "--unshare-all", "--unshare-net", "--die-with-parent"],
|
|
43
|
+
nftRulesetPath: "/run/egress.nft",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("invokes bwrap with --info-fd for a race-free child PID handshake", () => {
|
|
47
|
+
expect(launcher).toContain("--info-fd 7");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("threads the real command through bwrap's inner sh as positional args", () => {
|
|
51
|
+
// `sh -c <inner> checkstack-rootless-egress "$@"` forwards the launcher's
|
|
52
|
+
// own "$@" (the real command) verbatim into the namespace.
|
|
53
|
+
expect(launcher).toContain('checkstack-rootless-egress "$@"');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("configures slirp4netns with the deterministic tap device + ready-fd", () => {
|
|
57
|
+
expect(launcher).toContain("slirp4netns --configure");
|
|
58
|
+
expect(launcher).toContain("--ready-fd=3");
|
|
59
|
+
expect(launcher).toContain(SLIRP4NETNS_TAP_DEVICE);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("signals the child only AFTER slirp4netns readies tap0 (no unfiltered window)", () => {
|
|
63
|
+
const slirpReadIdx = launcher.indexOf('IFS= read -r _ <"$slirp_ready"');
|
|
64
|
+
const netReadyIdx = launcher.indexOf('printf ready > "$net_ready"');
|
|
65
|
+
expect(slirpReadIdx).toBeGreaterThanOrEqual(0);
|
|
66
|
+
expect(netReadyIdx).toBeGreaterThanOrEqual(0);
|
|
67
|
+
// The launcher waits for slirp4netns readiness, THEN releases the child.
|
|
68
|
+
expect(slirpReadIdx).toBeLessThan(netReadyIdx);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("fails closed (non-zero exit) when the child PID cannot be determined", () => {
|
|
72
|
+
expect(launcher).toContain("exit 70");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fails closed (non-zero exit) when slirp4netns never readies", () => {
|
|
76
|
+
expect(launcher).toContain("exit 71");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("propagates the child's exit code", () => {
|
|
80
|
+
expect(launcher).toContain('wait "$bwrap_pid"');
|
|
81
|
+
expect(launcher).toContain("exit $?");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("tears down slirp4netns + temp FIFOs on every exit path", () => {
|
|
85
|
+
expect(launcher).toContain("trap cleanup EXIT INT TERM");
|
|
86
|
+
expect(launcher).toContain('kill "$slirp_pid"');
|
|
87
|
+
expect(launcher).toContain('rm -rf "$work"');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("embeds the provided bwrap argv verbatim (quoted)", () => {
|
|
91
|
+
expect(launcher).toContain("'bwrap' '--unshare-all' '--unshare-net'");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("documents the deterministic addressing constants", () => {
|
|
95
|
+
// Sanity: the deterministic slirp4netns plan is stable (no operator input).
|
|
96
|
+
expect(SLIRP4NETNS_CHILD_IP).toBe("10.0.2.100");
|
|
97
|
+
expect(SLIRP4NETNS_GATEWAY).toBe("10.0.2.2");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rootless network egress via `slirp4netns` (the unprivileged fallback for the
|
|
3
|
+
* privileged macvlan path in `network.ts` / `filesystem.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the macvlan path needs CAP_NET_ADMIN (euid root) + a usable
|
|
6
|
+
* host uplink + operator-supplied static addressing. The common rootless
|
|
7
|
+
* container case (rootless Podman/Docker) has NONE of those but DOES have
|
|
8
|
+
* unprivileged user namespaces. `slirp4netns` is the standard rootless plumbing:
|
|
9
|
+
* it runs in the PARENT network namespace and creates a `tap0` device with a
|
|
10
|
+
* userspace TCP/IP stack INSIDE the child's net namespace, NAT'ing egress out
|
|
11
|
+
* through the parent. So the child gets real, addressed, routed egress with no
|
|
12
|
+
* privilege — and we can filter THAT egress with nftables inside the same netns,
|
|
13
|
+
* exactly like the macvlan path.
|
|
14
|
+
*
|
|
15
|
+
* Determinism (no TOCTOU): slirp4netns uses a fixed, built-in addressing plan —
|
|
16
|
+
* subnet `10.0.2.0/24`, child endpoint `10.0.2.100`, gateway `10.0.2.2`. Unlike
|
|
17
|
+
* macvlan there is nothing to pick from the host, so there is no collision/race
|
|
18
|
+
* footgun and nothing to defer to operator env.
|
|
19
|
+
*
|
|
20
|
+
* Correctness of `enforced.network` (the load-bearing invariant): the egress
|
|
21
|
+
* filter is loaded FAIL-CLOSED. The inner command, the first thing it does
|
|
22
|
+
* inside the namespace, installs the nftables ruleset (default-drop egress +
|
|
23
|
+
* the allowlist accepts + the always-on metadata block) BEFORE `tap0` even
|
|
24
|
+
* exists. So the ordering is:
|
|
25
|
+
* 1. child enters userns+netns (loopback only, routeless — no egress yet),
|
|
26
|
+
* 2. child loads the default-drop nft ruleset (still no egress),
|
|
27
|
+
* 3. child signals "namespace ready" to the launcher,
|
|
28
|
+
* 4. launcher runs `slirp4netns --configure` which brings up the ADDRESSED
|
|
29
|
+
* `tap0` — now the ONLY reachable destinations are the ones the already-
|
|
30
|
+
* loaded ruleset permits,
|
|
31
|
+
* 5. launcher signals "network ready" back,
|
|
32
|
+
* 6. child execs the real command.
|
|
33
|
+
* If slirp4netns never readies, or nft fails to load, the child has NO filtered
|
|
34
|
+
* egress path — never an UNFILTERED one. There is no window in which traffic
|
|
35
|
+
* flows past an un-loaded filter, so when this path is engaged `enforced.network`
|
|
36
|
+
* is truthful.
|
|
37
|
+
*
|
|
38
|
+
* Mechanism: a single launcher SHELL SCRIPT (staged on disk by the runner, like
|
|
39
|
+
* the nft ruleset) is the wrapper prelude. It uses `bwrap`'s `--info-fd` to
|
|
40
|
+
* learn the child PID race-free, and a pair of FIFOs for the two-way ready
|
|
41
|
+
* handshake. `bwrap` is the only wrapper used here because it cleanly exposes
|
|
42
|
+
* the child PID; nsjail's macvlan path is preferred when privileged anyway.
|
|
43
|
+
*
|
|
44
|
+
* Everything below is PURE string/argv construction so it is fully testable
|
|
45
|
+
* without a real `slirp4netns` (the end-to-end behaviour is gated behind
|
|
46
|
+
* `CHECKSTACK_IT`).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { shellQuote } from "./shell-quote";
|
|
50
|
+
|
|
51
|
+
/** slirp4netns built-in deterministic addressing (CIDR/gateway/endpoint). */
|
|
52
|
+
export const SLIRP4NETNS_TAP_DEVICE = "tap0";
|
|
53
|
+
export const SLIRP4NETNS_SUBNET_CIDR = "10.0.2.0/24";
|
|
54
|
+
export const SLIRP4NETNS_GATEWAY = "10.0.2.2";
|
|
55
|
+
export const SLIRP4NETNS_CHILD_IP = "10.0.2.100";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Inputs for the rootless launcher script. All paths are absolute and supplied
|
|
59
|
+
* by the runner (it owns the per-run scratch dir where these are staged).
|
|
60
|
+
*/
|
|
61
|
+
export interface RootlessLauncherInputs {
|
|
62
|
+
/**
|
|
63
|
+
* The `bwrap` argv that creates the child user+net namespace and execs the
|
|
64
|
+
* inner command. Built by the filesystem layer with `--unshare-net` (WITHOUT
|
|
65
|
+
* the trailing `--`; the launcher appends `--info-fd N --`).
|
|
66
|
+
*/
|
|
67
|
+
bwrapArgv: string[];
|
|
68
|
+
/** Path to the staged nftables ruleset file (loaded fail-closed inside). */
|
|
69
|
+
nftRulesetPath: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the inner `sh -c` script run INSIDE the namespace by bwrap. It:
|
|
74
|
+
* 1. loads the nft ruleset (default-drop egress + allowlist + metadata block)
|
|
75
|
+
* FAIL-CLOSED — exits non-zero if it cannot, so we never run unfiltered,
|
|
76
|
+
* 2. signals "namespace ready" so the launcher can run slirp4netns,
|
|
77
|
+
* 3. waits for "network ready",
|
|
78
|
+
* 4. exec's the real command (passed as the inner shell's positional args).
|
|
79
|
+
*
|
|
80
|
+
* The real command is forwarded as the inner shell's `"$@"` (the launcher
|
|
81
|
+
* receives it as ITS positional args and threads it through bwrap into the
|
|
82
|
+
* inner `sh -c ... <name> "$@"`), so the command argv is NEVER string-spliced
|
|
83
|
+
* into the script body — no quoting of caller-controlled argv is needed.
|
|
84
|
+
*
|
|
85
|
+
* `nft` is loaded with the child as (mapped) root inside its own userns, which
|
|
86
|
+
* is sufficient to populate an `inet` table in the child's own net namespace.
|
|
87
|
+
* If a rule class cannot be loaded rootless, `nft -f` exits non-zero and the
|
|
88
|
+
* whole run fails closed (degrade-and-surface upstream), never silently allows.
|
|
89
|
+
*/
|
|
90
|
+
export function buildRootlessInnerScript({
|
|
91
|
+
nftRulesetPath,
|
|
92
|
+
}: {
|
|
93
|
+
nftRulesetPath: string;
|
|
94
|
+
}): string {
|
|
95
|
+
const quotedRuleset = shellQuote(nftRulesetPath);
|
|
96
|
+
// The launcher wires the two ready FIFOs onto fd 9 (write: "namespace ready")
|
|
97
|
+
// and fd 8 (read: "network ready") of this inner shell via bwrap redirection.
|
|
98
|
+
return [
|
|
99
|
+
"set -e",
|
|
100
|
+
// Fail-closed egress filter: default-drop is installed BEFORE tap0 exists,
|
|
101
|
+
// so there is no window where traffic flows past an un-loaded filter.
|
|
102
|
+
`nft -f ${quotedRuleset}`,
|
|
103
|
+
// Tell the launcher the namespace exists + the filter is loaded, so it can
|
|
104
|
+
// attach slirp4netns to our PID.
|
|
105
|
+
"printf ready >&9",
|
|
106
|
+
// Block until the launcher reports tap0 is up + addressed.
|
|
107
|
+
"IFS= read -r _ <&8",
|
|
108
|
+
// exec the real command, forwarded verbatim as positional args.
|
|
109
|
+
'exec "$@"',
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the full launcher shell script. This is what the wrapper prelude
|
|
115
|
+
* resolves to: the runner stages it and the spawned argv is `["sh", path]`.
|
|
116
|
+
*
|
|
117
|
+
* The launcher orchestrates the race-free rootless setup:
|
|
118
|
+
* - creates the two ready FIFOs + a bwrap info pipe,
|
|
119
|
+
* - spawns bwrap (which forks the namespaced child running the inner script),
|
|
120
|
+
* - reads the child PID from bwrap's `--info-fd` JSON,
|
|
121
|
+
* - waits for the child's "namespace ready",
|
|
122
|
+
* - runs `slirp4netns --configure --ready-fd <fd> <pid> tap0`,
|
|
123
|
+
* - signals "network ready" to the child,
|
|
124
|
+
* - waits for the child + propagates its exit code.
|
|
125
|
+
*
|
|
126
|
+
* Any failure (bwrap, slirp4netns, missing PID) aborts the launcher non-zero
|
|
127
|
+
* AFTER ensuring the child never proceeds past its filter load — fail-closed.
|
|
128
|
+
*/
|
|
129
|
+
export function buildRootlessLauncherScript({
|
|
130
|
+
bwrapArgv,
|
|
131
|
+
nftRulesetPath,
|
|
132
|
+
}: RootlessLauncherInputs): string {
|
|
133
|
+
const inner = buildRootlessInnerScript({ nftRulesetPath });
|
|
134
|
+
// bwrap prefix WITHOUT the trailing `--`; the launcher appends `--info-fd`,
|
|
135
|
+
// the `--`, and the inner `sh -c`. `--unshare-net` must already be present
|
|
136
|
+
// (the FS layer adds it); we compose argv purely as strings here.
|
|
137
|
+
const quotedBwrap = bwrapArgv.map((arg) => shellQuote(arg)).join(" ");
|
|
138
|
+
const quotedInner = shellQuote(inner);
|
|
139
|
+
return `#!/bin/sh
|
|
140
|
+
# Rootless egress launcher (slirp4netns + fail-closed nftables). Generated by
|
|
141
|
+
# @checkstack/backend-api script sandbox. Do not edit.
|
|
142
|
+
#
|
|
143
|
+
# fd map:
|
|
144
|
+
# 7 bwrap --info-fd -> $info file (child PID as JSON)
|
|
145
|
+
# 8 child "network ready" <- net_ready FIFO (launcher writes when tap0 up)
|
|
146
|
+
# 9 child "namespace ready"-> ns_ready FIFO (child writes after nft load)
|
|
147
|
+
# 3 slirp4netns --ready-fd -> slirp_ready FIFO (slirp writes when tap0 up)
|
|
148
|
+
set -eu
|
|
149
|
+
|
|
150
|
+
work="$(mktemp -d "\${TMPDIR:-/tmp}/checkstack-slirp.XXXXXX")"
|
|
151
|
+
ns_ready="$work/ns_ready"
|
|
152
|
+
net_ready="$work/net_ready"
|
|
153
|
+
slirp_ready="$work/slirp_ready"
|
|
154
|
+
info="$work/bwrap_info"
|
|
155
|
+
mkfifo "$ns_ready" "$net_ready" "$slirp_ready"
|
|
156
|
+
|
|
157
|
+
slirp_pid=""
|
|
158
|
+
bwrap_pid=""
|
|
159
|
+
cleanup() {
|
|
160
|
+
status=$?
|
|
161
|
+
if [ -n "$slirp_pid" ]; then kill "$slirp_pid" 2>/dev/null || true; fi
|
|
162
|
+
rm -rf "$work" 2>/dev/null || true
|
|
163
|
+
exit "$status"
|
|
164
|
+
}
|
|
165
|
+
trap cleanup EXIT INT TERM
|
|
166
|
+
|
|
167
|
+
# Spawn bwrap (which forks the namespaced child running the inner script). The
|
|
168
|
+
# real command argv arrives as the launcher's own "$@" and is threaded verbatim
|
|
169
|
+
# into the inner shell's positional args (no string splicing of caller argv).
|
|
170
|
+
# fd 7 -> info file; child's fd 9 -> ns_ready (write); child's fd 8 <- net_ready.
|
|
171
|
+
# bwrap passes inherited fds >=3 through to the sandboxed process (it only
|
|
172
|
+
# consumes the ones named by its own options, e.g. --info-fd 7), so fds 8 and 9
|
|
173
|
+
# survive into the inner shell for the ready handshake.
|
|
174
|
+
${quotedBwrap} --info-fd 7 -- \\
|
|
175
|
+
sh -c ${quotedInner} checkstack-rootless-egress "$@" \\
|
|
176
|
+
7>"$info" 9>"$ns_ready" 8<"$net_ready" &
|
|
177
|
+
bwrap_pid=$!
|
|
178
|
+
|
|
179
|
+
# Wait for the child to announce "namespace ready + filter loaded". By this
|
|
180
|
+
# point bwrap has written the child PID to the info file.
|
|
181
|
+
IFS= read -r _ <"$ns_ready" || true
|
|
182
|
+
child_pid=""
|
|
183
|
+
if [ -s "$info" ]; then
|
|
184
|
+
child_pid="$(sed -n 's/.*"child-pid"[: ]*\\([0-9][0-9]*\\).*/\\1/p' "$info")"
|
|
185
|
+
fi
|
|
186
|
+
if [ -z "$child_pid" ]; then
|
|
187
|
+
echo "rootless-egress: could not determine namespaced child PID" >&2
|
|
188
|
+
kill "$bwrap_pid" 2>/dev/null || true
|
|
189
|
+
wait "$bwrap_pid" 2>/dev/null || true
|
|
190
|
+
exit 70
|
|
191
|
+
fi
|
|
192
|
+
|
|
193
|
+
# Bring up tap0 inside the child's netns with slirp4netns' deterministic
|
|
194
|
+
# addressing. --ready-fd writes one byte once tap0 is configured. The child's
|
|
195
|
+
# nft filter is ALREADY loaded (default-drop), so egress opens ONLY to the
|
|
196
|
+
# permitted destinations — never an unfiltered window.
|
|
197
|
+
slirp4netns --configure --mtu=65520 --disable-host-loopback \\
|
|
198
|
+
--ready-fd=3 "$child_pid" ${SLIRP4NETNS_TAP_DEVICE} \\
|
|
199
|
+
3>"$slirp_ready" >/dev/null 2>"$work/slirp_err" &
|
|
200
|
+
slirp_pid=$!
|
|
201
|
+
if ! IFS= read -r _ <"$slirp_ready"; then
|
|
202
|
+
echo "rootless-egress: slirp4netns failed to ready tap0" >&2
|
|
203
|
+
cat "$work/slirp_err" >&2 2>/dev/null || true
|
|
204
|
+
kill "$bwrap_pid" 2>/dev/null || true
|
|
205
|
+
wait "$bwrap_pid" 2>/dev/null || true
|
|
206
|
+
exit 71
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
# Tell the child tap0 is up; it then exec's the real command.
|
|
210
|
+
printf ready > "$net_ready"
|
|
211
|
+
|
|
212
|
+
# Propagate the child's exit code. Under set -e a non-zero child makes wait
|
|
213
|
+
# itself fail, which fires the EXIT trap with that status (the trap is what
|
|
214
|
+
# actually propagates the code); the explicit "|| exit STATUS" makes the
|
|
215
|
+
# propagation path obvious to a reader rather than relying on the trap alone.
|
|
216
|
+
wait "$bwrap_pid" || exit $?
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { shellQuote } from "./shell-quote";
|
|
3
|
+
|
|
4
|
+
describe("shellQuote", () => {
|
|
5
|
+
it("wraps a plain value in single quotes", () => {
|
|
6
|
+
expect(shellQuote("/tmp/x.nft")).toBe("'/tmp/x.nft'");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("escapes embedded single quotes via the '\\'' idiom", () => {
|
|
10
|
+
// O'Brien -> 'O'\''Brien'
|
|
11
|
+
expect(shellQuote("O'Brien")).toBe(`'O'\\''Brien'`);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("neutralizes shell metacharacters (spaces, ;, $, backticks)", () => {
|
|
15
|
+
const quoted = shellQuote("a b; rm -rf / $(whoami) `id`");
|
|
16
|
+
// Everything is inside one single-quoted span, so nothing expands.
|
|
17
|
+
expect(quoted.startsWith("'")).toBe(true);
|
|
18
|
+
expect(quoted.endsWith("'")).toBe(true);
|
|
19
|
+
expect(quoted).toContain("rm -rf / $(whoami)");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is idempotently round-trippable through sh", async () => {
|
|
23
|
+
const value = "weird 'value' with $pace; and `ticks`";
|
|
24
|
+
const proc = Bun.spawn({
|
|
25
|
+
cmd: ["sh", "-c", `printf %s ${shellQuote(value)}`],
|
|
26
|
+
stdout: "pipe",
|
|
27
|
+
});
|
|
28
|
+
const out = await new Response(proc.stdout).text();
|
|
29
|
+
await proc.exited;
|
|
30
|
+
expect(out).toBe(value);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX single-quote shell escaping for argv fragments embedded into a
|
|
3
|
+
* generated `sh` script (the rootless egress launcher). Wraps the value in
|
|
4
|
+
* single quotes and escapes any embedded single quote via the standard
|
|
5
|
+
* `'\''` idiom, so the result is safe to splice into a `sh -c` body or an
|
|
6
|
+
* `exec` line regardless of its contents.
|
|
7
|
+
*/
|
|
8
|
+
export function shellQuote(value: string): string {
|
|
9
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
10
|
+
}
|