@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,373 @@
|
|
|
1
|
+
import type { MacvlanAddressing, SandboxCapabilities } from "./capabilities";
|
|
2
|
+
import type { NetworkPolicy } from "./policy";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Network egress control (plan §5.3 / §6 D3).
|
|
6
|
+
*
|
|
7
|
+
* Egress is filtered at the KERNEL — a network namespace — not at the language
|
|
8
|
+
* runtime, so it covers `fetch`, raw sockets, DNS, and anything else the child
|
|
9
|
+
* tries, uniformly. There is no app-level shim a script can bypass.
|
|
10
|
+
*
|
|
11
|
+
* Mechanism, delegated to the same external wrapper that delivers filesystem
|
|
12
|
+
* isolation (external-wrapper-first, §6 D2):
|
|
13
|
+
*
|
|
14
|
+
* - `deny` = drop the child into a fresh network namespace with ONLY
|
|
15
|
+
* loopback. A fresh netns is ROUTELESS — it has no path to the
|
|
16
|
+
* host network — so "no egress" is the kernel default and that
|
|
17
|
+
* is EXACTLY the goal. Deliverable by ANY namespace wrapper
|
|
18
|
+
* (`bwrap --unshare-net`, `nsjail --clone_newnet`); no packet
|
|
19
|
+
* filter is needed because there is nothing to filter.
|
|
20
|
+
* - `allowlist` = a fresh network namespace PLUS REAL EGRESS plumbed into it
|
|
21
|
+
* PLUS an nftables ruleset that permits only the listed
|
|
22
|
+
* IPv4/IPv6 CIDRs (and loopback) and rejects everything else.
|
|
23
|
+
* The plumbing is the critical part: a fresh netns is
|
|
24
|
+
* routeless, so without an uplink the allowlist would blackhole
|
|
25
|
+
* ALL traffic (including the allowed CIDRs) — that is a silent
|
|
26
|
+
* under-block, NOT enforcement. Egress is plumbed by ONE of two
|
|
27
|
+
* paths, preferred in order:
|
|
28
|
+
* 1. PRIVILEGED macvlan (`nsjail` + CAP_NET_ADMIN + a usable
|
|
29
|
+
* host interface + static addressing, i.e.
|
|
30
|
+
* `caps.netEgressIface` + `caps.netEgressAddressing`); or
|
|
31
|
+
* 2. ROOTLESS slirp4netns (`bwrap` + creatable user+net
|
|
32
|
+
* namespace + `slirp4netns`, i.e. `caps.netEgressRootless`)
|
|
33
|
+
* — a userspace TCP/IP stack with deterministic built-in
|
|
34
|
+
* addressing, needing NO privilege/uplink/operator config.
|
|
35
|
+
* Either way the SAME nftables ruleset filters the plumbed
|
|
36
|
+
* egress. When NEITHER path is available it DEGRADES (surfaced)
|
|
37
|
+
* to host net — never a blackhole, never a silent allow-all.
|
|
38
|
+
* - `unrestricted` = keep the host network namespace. The ALWAYS-ON
|
|
39
|
+
* metadata/link-local block (`denyLinkLocalAndMetadata`) needs
|
|
40
|
+
* a netns WITH real egress so that everything EXCEPT the
|
|
41
|
+
* blocked ranges still reaches the network. If egress can be
|
|
42
|
+
* plumbed, the block is realised as a default-accept-except-
|
|
43
|
+
* metadata ruleset inside a plumbed namespace. If it CANNOT be
|
|
44
|
+
* plumbed (`bwrap`-only, no uplink, non-root), engaging a
|
|
45
|
+
* routeless netns would sever ALL egress — so we DO NOT engage
|
|
46
|
+
* one: we keep host net and surface the block as unenforceable.
|
|
47
|
+
*
|
|
48
|
+
* INVARIANT: a routeless fresh netns is used ONLY for `deny`. `unrestricted`
|
|
49
|
+
* and `allowlist` either get real plumbed + filtered egress, or degrade to
|
|
50
|
+
* surfaced host net. `enforced.network` reflects reality — it is never true
|
|
51
|
+
* when egress is actually severed or unfiltered.
|
|
52
|
+
*
|
|
53
|
+
* IP/CIDR only for v1 (§6 D3). Domain allowlisting (DNS-aware) is a v2 concern.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Always-blocked destinations, even in `unrestricted`/`allowlist` mode when
|
|
58
|
+
* `denyLinkLocalAndMetadata` is on. Covers IPv4 link-local + the cloud-metadata
|
|
59
|
+
* endpoint and the IPv6 link-local / unique-local ranges (the IPv6 metadata
|
|
60
|
+
* alias `fd00:ec2::254` falls inside `fc00::/7`).
|
|
61
|
+
*/
|
|
62
|
+
export const ALWAYS_BLOCKED_CIDRS = [
|
|
63
|
+
// IPv4 link-local — includes 169.254.169.254 (AWS/GCP/Azure/OpenStack metadata).
|
|
64
|
+
"169.254.0.0/16",
|
|
65
|
+
// IPv6 link-local.
|
|
66
|
+
"fe80::/10",
|
|
67
|
+
// IPv6 unique-local — covers fd00:ec2::254 (IMDS over IPv6) and similar.
|
|
68
|
+
"fc00::/7",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
/** Loopback ranges always permitted (the child's own localhost). */
|
|
72
|
+
const LOOPBACK_CIDRS = ["127.0.0.0/8", "::1/128"] as const;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The net behaviour the wrapper must deliver, resolved from policy + caps.
|
|
76
|
+
* Consumed by the namespace composer in `wrapper.ts`/`filesystem.ts`.
|
|
77
|
+
*/
|
|
78
|
+
export type NetworkDecision =
|
|
79
|
+
| {
|
|
80
|
+
/** Keep the host net namespace; no kernel filter applied. */
|
|
81
|
+
kind: "host";
|
|
82
|
+
/**
|
|
83
|
+
* True when the metadata/link-local block was REQUESTED but cannot be
|
|
84
|
+
* enforced without a netns + nftables on this host. The caller surfaces
|
|
85
|
+
* this as a downgrade. When false, nothing was requested (or it is moot).
|
|
86
|
+
*/
|
|
87
|
+
metadataBlockUnenforceable: boolean;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
/**
|
|
91
|
+
* Drop into a fresh net namespace. For `deny` this is routeless
|
|
92
|
+
* (loopback only) and that is the goal. For `allowlist` it is plumbed
|
|
93
|
+
* with `egressIface` and filtered by `nftRuleset`.
|
|
94
|
+
*/
|
|
95
|
+
kind: "namespaced";
|
|
96
|
+
mode: "deny" | "allowlist";
|
|
97
|
+
/**
|
|
98
|
+
* How real egress is plumbed into the namespace for `allowlist`:
|
|
99
|
+
* - `"macvlan"` — privileged nsjail macvlan uplink (`egressIface` +
|
|
100
|
+
* `egressAddressing` present);
|
|
101
|
+
* - `"rootless"` — unprivileged slirp4netns userspace stack (no iface /
|
|
102
|
+
* addressing; the launcher orchestrates it).
|
|
103
|
+
* Absent for `deny` (a routeless namespace IS the goal — no plumbing).
|
|
104
|
+
*/
|
|
105
|
+
egressPath?: "macvlan" | "rootless";
|
|
106
|
+
/**
|
|
107
|
+
* An nftables ruleset to install inside the child's namespace, or
|
|
108
|
+
* undefined for pure `deny` (a loopback-only routeless netns needs no
|
|
109
|
+
* rules — there is no route out to filter). Present for `allowlist` (incl.
|
|
110
|
+
* the metadata-only block realised as an allowlist-shaped ruleset).
|
|
111
|
+
*/
|
|
112
|
+
nftRuleset?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The host interface to plumb real egress into the namespace via macvlan.
|
|
115
|
+
* Present (and required) for `allowlist` via the MACVLAN path — without it
|
|
116
|
+
* the namespace is routeless and the allowlist would blackhole everything.
|
|
117
|
+
* Absent for `deny` (routeless IS the goal) and for the ROOTLESS path
|
|
118
|
+
* (slirp4netns needs no host iface).
|
|
119
|
+
*/
|
|
120
|
+
egressIface?: string;
|
|
121
|
+
/**
|
|
122
|
+
* Static addressing for the macvlan endpoint (`--macvlan_vs_ip/_nm/_gw`).
|
|
123
|
+
* Present alongside `egressIface` for the MACVLAN `allowlist` /
|
|
124
|
+
* metadata-block; a macvlan with no address still blackholes, so
|
|
125
|
+
* addressing is REQUIRED to route and the macvlan decision only reaches
|
|
126
|
+
* `namespaced` allowlist when it is available. Absent for the ROOTLESS
|
|
127
|
+
* path (slirp4netns has deterministic built-in addressing).
|
|
128
|
+
*/
|
|
129
|
+
egressAddressing?: MacvlanAddressing;
|
|
130
|
+
}
|
|
131
|
+
| {
|
|
132
|
+
/** Could not enforce as requested; caller degrades or fails. */
|
|
133
|
+
kind: "degrade";
|
|
134
|
+
reason: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the nftables ruleset installed inside the child's network namespace.
|
|
139
|
+
*
|
|
140
|
+
* For `allowlist`: default-drop egress, then accept loopback, accept the listed
|
|
141
|
+
* CIDRs, and (independently of the allowlist) drop the always-blocked
|
|
142
|
+
* metadata/link-local ranges so a CIDR that overlaps them cannot re-open the
|
|
143
|
+
* hole.
|
|
144
|
+
*
|
|
145
|
+
* For the `unrestricted` metadata block: default-accept egress, but drop the
|
|
146
|
+
* always-blocked ranges.
|
|
147
|
+
*/
|
|
148
|
+
export function buildEgressNftRules({
|
|
149
|
+
mode,
|
|
150
|
+
allow,
|
|
151
|
+
denyMetadata,
|
|
152
|
+
}: {
|
|
153
|
+
mode: "allowlist" | "metadata-only";
|
|
154
|
+
allow: string[];
|
|
155
|
+
denyMetadata: boolean;
|
|
156
|
+
}): string {
|
|
157
|
+
const v4Allow: string[] = [];
|
|
158
|
+
const v6Allow: string[] = [];
|
|
159
|
+
for (const cidr of allow) {
|
|
160
|
+
(isIpv6Cidr(cidr) ? v6Allow : v4Allow).push(cidr);
|
|
161
|
+
}
|
|
162
|
+
const blocked = denyMetadata ? [...ALWAYS_BLOCKED_CIDRS] : [];
|
|
163
|
+
const v4Blocked = blocked.filter((c) => !isIpv6Cidr(c));
|
|
164
|
+
const v6Blocked = blocked.filter((c) => isIpv6Cidr(c));
|
|
165
|
+
|
|
166
|
+
// Always reject the metadata/link-local ranges FIRST so neither an explicit
|
|
167
|
+
// allow entry nor the default-accept (metadata-only) can reach them.
|
|
168
|
+
const blockLines: string[] = [
|
|
169
|
+
...(v4Blocked.length > 0 ? [` ip daddr { ${v4Blocked.join(", ")} } drop`] : []),
|
|
170
|
+
...(v6Blocked.length > 0 ? [` ip6 daddr { ${v6Blocked.join(", ")} } drop`] : []),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
// For allowlist: loopback is always reachable (the child's own services),
|
|
174
|
+
// then the explicit allow entries, then a final default drop.
|
|
175
|
+
const allowLines: string[] =
|
|
176
|
+
mode === "allowlist"
|
|
177
|
+
? [
|
|
178
|
+
` ip daddr { ${LOOPBACK_CIDRS[0]} } accept`,
|
|
179
|
+
` ip6 daddr { ${LOOPBACK_CIDRS[1]} } accept`,
|
|
180
|
+
...(v4Allow.length > 0 ? [` ip daddr { ${v4Allow.join(", ")} } accept`] : []),
|
|
181
|
+
...(v6Allow.length > 0 ? [` ip6 daddr { ${v6Allow.join(", ")} } accept`] : []),
|
|
182
|
+
" drop",
|
|
183
|
+
]
|
|
184
|
+
: [];
|
|
185
|
+
|
|
186
|
+
const lines: string[] = [
|
|
187
|
+
"table inet checkstack_egress {",
|
|
188
|
+
" chain output {",
|
|
189
|
+
" type filter hook output priority 0; policy accept;",
|
|
190
|
+
...blockLines,
|
|
191
|
+
...allowLines,
|
|
192
|
+
" }",
|
|
193
|
+
"}",
|
|
194
|
+
];
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Crude IPv4-vs-IPv6 CIDR discriminator: IPv6 literals contain a colon. */
|
|
199
|
+
function isIpv6Cidr(cidr: string): boolean {
|
|
200
|
+
return cidr.includes(":");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve WHICH plumbed-egress path is deliverable on this host, preferring the
|
|
205
|
+
* privileged macvlan path over the rootless slirp4netns path. Returns `null`
|
|
206
|
+
* when NEITHER is available (the caller then degrades-and-surfaces — never a
|
|
207
|
+
* blackhole). Pure; reads only the pre-detected caps.
|
|
208
|
+
*/
|
|
209
|
+
type EgressPlumbing =
|
|
210
|
+
| { path: "macvlan"; iface: string; addressing: MacvlanAddressing }
|
|
211
|
+
| { path: "rootless" };
|
|
212
|
+
|
|
213
|
+
function resolveEgressPlumbing(caps: SandboxCapabilities): EgressPlumbing | null {
|
|
214
|
+
// 1. Privileged macvlan: needs the iface AND static addressing (an
|
|
215
|
+
// unaddressed macvlan blackholes). Preferred when available.
|
|
216
|
+
if (caps.netEgressIface !== null && caps.netEgressAddressing !== null) {
|
|
217
|
+
return {
|
|
218
|
+
path: "macvlan",
|
|
219
|
+
iface: caps.netEgressIface,
|
|
220
|
+
addressing: caps.netEgressAddressing,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// 2. Rootless slirp4netns: no privilege / uplink / operator addressing
|
|
224
|
+
// required; the launcher orchestrates a userspace stack with deterministic
|
|
225
|
+
// addressing and filters it with the same nftables ruleset.
|
|
226
|
+
if (caps.netEgressRootless) {
|
|
227
|
+
return { path: "rootless" };
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the network layer for a run. Pure & synchronous (no probing — caps
|
|
234
|
+
* are pre-detected and cached upstream).
|
|
235
|
+
*
|
|
236
|
+
* The caller (the namespace composer) maps the decision onto wrapper flags:
|
|
237
|
+
* - `host` → keep the host net (`--share-net` / `--disable_clone_newnet`).
|
|
238
|
+
* - `namespaced` → create a net namespace (`--unshare-net` /
|
|
239
|
+
* `--clone_newnet`) and, when `nftRuleset` is set, install it.
|
|
240
|
+
* - `degrade` → drop network control to the host net (default) or refuse
|
|
241
|
+
* (`onUnavailable: "fail"`), per the caller.
|
|
242
|
+
*/
|
|
243
|
+
export function buildNetworkLayer({
|
|
244
|
+
policy,
|
|
245
|
+
caps,
|
|
246
|
+
}: {
|
|
247
|
+
policy: NetworkPolicy;
|
|
248
|
+
caps: SandboxCapabilities;
|
|
249
|
+
}): NetworkDecision {
|
|
250
|
+
// --- unrestricted: only the always-on metadata block may apply ----------
|
|
251
|
+
if (policy.mode === "unrestricted") {
|
|
252
|
+
if (!policy.denyLinkLocalAndMetadata) {
|
|
253
|
+
// Nothing requested at the network layer: pure host net, nothing to do.
|
|
254
|
+
return { kind: "host", metadataBlockUnenforceable: false };
|
|
255
|
+
}
|
|
256
|
+
// The metadata/link-local block must keep ordinary egress WORKING while
|
|
257
|
+
// dropping only the blocked ranges. That requires a netns WITH real egress
|
|
258
|
+
// plumbed in (a routeless OR unaddressed namespace would sever ALL traffic —
|
|
259
|
+
// a blackhole, not a block). Engage a namespace only when egress can
|
|
260
|
+
// actually be plumbed (privileged macvlan OR rootless slirp4netns);
|
|
261
|
+
// otherwise keep host net and surface the block as unenforceable. NEVER
|
|
262
|
+
// engage a routeless/unaddressed netns here (that is the BLOCKER this
|
|
263
|
+
// guards against).
|
|
264
|
+
const plumbing = resolveEgressPlumbing(caps);
|
|
265
|
+
if (plumbing !== null) {
|
|
266
|
+
const nftRuleset = buildEgressNftRules({
|
|
267
|
+
mode: "metadata-only",
|
|
268
|
+
allow: [],
|
|
269
|
+
denyMetadata: true,
|
|
270
|
+
});
|
|
271
|
+
return plumbing.path === "macvlan"
|
|
272
|
+
? {
|
|
273
|
+
kind: "namespaced",
|
|
274
|
+
mode: "allowlist",
|
|
275
|
+
egressPath: "macvlan",
|
|
276
|
+
egressIface: plumbing.iface,
|
|
277
|
+
egressAddressing: plumbing.addressing,
|
|
278
|
+
nftRuleset,
|
|
279
|
+
}
|
|
280
|
+
: {
|
|
281
|
+
kind: "namespaced",
|
|
282
|
+
mode: "allowlist",
|
|
283
|
+
egressPath: "rootless",
|
|
284
|
+
nftRuleset,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return { kind: "host", metadataBlockUnenforceable: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- deny: a routeless fresh netns IS the goal --------------------------
|
|
291
|
+
if (policy.mode === "deny") {
|
|
292
|
+
if (caps.platform !== "linux") {
|
|
293
|
+
return {
|
|
294
|
+
kind: "degrade",
|
|
295
|
+
reason: `network deny requires Linux net namespaces (platform=${caps.platform}); egress unrestricted`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (!caps.netNamespaces) {
|
|
299
|
+
return {
|
|
300
|
+
kind: "degrade",
|
|
301
|
+
reason:
|
|
302
|
+
"network deny requires a net-namespace-capable wrapper (bwrap/nsjail) + unprivileged user namespaces, unavailable on this host; egress unrestricted",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// Loopback-only routeless namespace = no egress. No filter, no plumbing.
|
|
306
|
+
return { kind: "namespaced", mode: "deny" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- allowlist: needs a fresh netns WITH plumbed egress + a filter ------
|
|
310
|
+
if (caps.platform !== "linux") {
|
|
311
|
+
return {
|
|
312
|
+
kind: "degrade",
|
|
313
|
+
reason: `network allowlist requires Linux net namespaces (platform=${caps.platform}); egress unrestricted`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (!caps.netNamespaces) {
|
|
317
|
+
return {
|
|
318
|
+
kind: "degrade",
|
|
319
|
+
reason:
|
|
320
|
+
"network allowlist requires a net-namespace-capable wrapper + unprivileged user namespaces, unavailable on this host; egress unrestricted",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// EMPTY allowlist == deny ALL egress. This is the SECURE DEFAULT
|
|
324
|
+
// (`network: allowlist, allow: []`). Semantically it permits nothing, so it is
|
|
325
|
+
// exactly the `deny` routeless-namespace behaviour: loopback only, no route
|
|
326
|
+
// out. Resolve it to the routeless `deny` path, which needs NEITHER plumbed
|
|
327
|
+
// egress (slirp4netns/macvlan) NOR an nftables ruleset, and therefore works on
|
|
328
|
+
// ANY host with a net-namespace-capable wrapper - including rootless
|
|
329
|
+
// containers where in-namespace nftables is not permitted. A routeless netns
|
|
330
|
+
// also inherently blocks metadata/link-local (nothing routes), so the always-
|
|
331
|
+
// on block is satisfied. Non-empty allow lists still take the plumbed+filtered
|
|
332
|
+
// path below (which requires macvlan or rootless slirp4netns + working nft).
|
|
333
|
+
if (policy.allow.length === 0) {
|
|
334
|
+
return { kind: "namespaced", mode: "deny" };
|
|
335
|
+
}
|
|
336
|
+
const plumbing = resolveEgressPlumbing(caps);
|
|
337
|
+
if (plumbing === null) {
|
|
338
|
+
// The wrapper can create the namespace, but we cannot plumb real egress
|
|
339
|
+
// into it by EITHER path:
|
|
340
|
+
// - privileged macvlan needs nsjail + CAP_NET_ADMIN + a usable host iface +
|
|
341
|
+
// static addressing (CHECKSTACK_SANDBOX_MACVLAN_IP/_NM/_GW);
|
|
342
|
+
// - rootless slirp4netns needs bwrap + a creatable user+net namespace +
|
|
343
|
+
// slirp4netns on PATH.
|
|
344
|
+
// A routeless netns would blackhole the allowed CIDRs too — a silent
|
|
345
|
+
// under-block, not enforcement — so degrade-and-surface to host net.
|
|
346
|
+
return {
|
|
347
|
+
kind: "degrade",
|
|
348
|
+
reason:
|
|
349
|
+
"network allowlist requires plumbing real egress into the namespace, by EITHER the privileged macvlan path (nsjail + CAP_NET_ADMIN + a usable host interface + CHECKSTACK_SANDBOX_MACVLAN_IP/_NM/_GW addressing) OR the rootless path (bwrap + an unprivileged user+net namespace + slirp4netns on PATH); this host can deliver neither, and a routeless namespace would blackhole the allowed destinations; egress unrestricted",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const nftRuleset = buildEgressNftRules({
|
|
354
|
+
mode: "allowlist",
|
|
355
|
+
allow: policy.allow,
|
|
356
|
+
denyMetadata: policy.denyLinkLocalAndMetadata,
|
|
357
|
+
});
|
|
358
|
+
return plumbing.path === "macvlan"
|
|
359
|
+
? {
|
|
360
|
+
kind: "namespaced",
|
|
361
|
+
mode: "allowlist",
|
|
362
|
+
egressPath: "macvlan",
|
|
363
|
+
egressIface: plumbing.iface,
|
|
364
|
+
egressAddressing: plumbing.addressing,
|
|
365
|
+
nftRuleset,
|
|
366
|
+
}
|
|
367
|
+
: {
|
|
368
|
+
kind: "namespaced",
|
|
369
|
+
mode: "allowlist",
|
|
370
|
+
egressPath: "rootless",
|
|
371
|
+
nftRuleset,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { SandboxCapabilities } from "./capabilities";
|
|
3
|
+
import {
|
|
4
|
+
logSandboxCapabilitiesAtStartup,
|
|
5
|
+
summarizeCapabilities,
|
|
6
|
+
summarizeRunDowngrades,
|
|
7
|
+
surfaceRunDowngrades,
|
|
8
|
+
} from "./observability";
|
|
9
|
+
import { DEFAULT_SANDBOX_PROFILE } from "./policy";
|
|
10
|
+
import type { EffectiveSandbox } from "./report";
|
|
11
|
+
|
|
12
|
+
const MACOS_NONE: SandboxCapabilities = {
|
|
13
|
+
platform: "darwin",
|
|
14
|
+
euidIsRoot: false,
|
|
15
|
+
hasPrlimit: false,
|
|
16
|
+
rlimitNative: false,
|
|
17
|
+
wrapper: null,
|
|
18
|
+
userNamespaces: false,
|
|
19
|
+
userNsCreatable: false,
|
|
20
|
+
netNamespaces: false,
|
|
21
|
+
netEgressIface: null,
|
|
22
|
+
netEgressAddressing: null,
|
|
23
|
+
netEgressRootless: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const LINUX_FULL: SandboxCapabilities = {
|
|
27
|
+
platform: "linux",
|
|
28
|
+
euidIsRoot: true,
|
|
29
|
+
hasPrlimit: true,
|
|
30
|
+
rlimitNative: true,
|
|
31
|
+
wrapper: "nsjail",
|
|
32
|
+
userNamespaces: true,
|
|
33
|
+
userNsCreatable: true,
|
|
34
|
+
netNamespaces: true,
|
|
35
|
+
netEgressIface: "eth0",
|
|
36
|
+
netEgressAddressing: {
|
|
37
|
+
ip: "10.0.0.2",
|
|
38
|
+
netmask: "255.255.255.0",
|
|
39
|
+
gateway: "10.0.0.1",
|
|
40
|
+
},
|
|
41
|
+
netEgressRootless: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function recordingLogger() {
|
|
45
|
+
const infos: Array<{ message: string; args: unknown[] }> = [];
|
|
46
|
+
const warns: Array<{ message: string; args: unknown[] }> = [];
|
|
47
|
+
return {
|
|
48
|
+
infos,
|
|
49
|
+
warns,
|
|
50
|
+
logger: {
|
|
51
|
+
info(message: string, ...args: unknown[]) {
|
|
52
|
+
infos.push({ message, args });
|
|
53
|
+
},
|
|
54
|
+
warn(message: string, ...args: unknown[]) {
|
|
55
|
+
warns.push({ message, args });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("summarizeCapabilities", () => {
|
|
62
|
+
it("projects the detected primitives into a flat, loggable shape", () => {
|
|
63
|
+
const s = summarizeCapabilities(LINUX_FULL);
|
|
64
|
+
expect(s.platform).toBe("linux");
|
|
65
|
+
expect(s.supervisorEuidIsRoot).toBe(true);
|
|
66
|
+
expect(s.wrapper).toBe("nsjail");
|
|
67
|
+
expect(s.netEgressIface).toBe("eth0");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("logSandboxCapabilitiesAtStartup", () => {
|
|
72
|
+
it("emits ONE info line with capabilities + effective enforcement", () => {
|
|
73
|
+
const { infos, logger } = recordingLogger();
|
|
74
|
+
logSandboxCapabilitiesAtStartup({
|
|
75
|
+
logger,
|
|
76
|
+
globalDefault: DEFAULT_SANDBOX_PROFILE,
|
|
77
|
+
caps: LINUX_FULL,
|
|
78
|
+
});
|
|
79
|
+
expect(infos).toHaveLength(1);
|
|
80
|
+
expect(infos[0]?.message).toContain("script sandbox capabilities");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("never throws even for an onUnavailable:fail global default on a weak host", () => {
|
|
84
|
+
const { logger } = recordingLogger();
|
|
85
|
+
expect(() =>
|
|
86
|
+
logSandboxCapabilitiesAtStartup({
|
|
87
|
+
logger,
|
|
88
|
+
globalDefault: { ...DEFAULT_SANDBOX_PROFILE, onUnavailable: "fail" },
|
|
89
|
+
caps: MACOS_NONE,
|
|
90
|
+
}),
|
|
91
|
+
).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("summarizeRunDowngrades", () => {
|
|
96
|
+
it("returns undefined when fully enforced (nothing to surface)", () => {
|
|
97
|
+
const effective: EffectiveSandbox = {
|
|
98
|
+
requested: DEFAULT_SANDBOX_PROFILE,
|
|
99
|
+
enforced: {
|
|
100
|
+
resources: true,
|
|
101
|
+
filesystem: true,
|
|
102
|
+
network: true,
|
|
103
|
+
privilege: true,
|
|
104
|
+
},
|
|
105
|
+
downgrades: [],
|
|
106
|
+
notes: [],
|
|
107
|
+
platform: "linux",
|
|
108
|
+
};
|
|
109
|
+
expect(summarizeRunDowngrades(effective)).toBeUndefined();
|
|
110
|
+
expect(summarizeRunDowngrades(undefined)).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("summarizes the degraded layers + reasons", () => {
|
|
114
|
+
const effective: EffectiveSandbox = {
|
|
115
|
+
requested: DEFAULT_SANDBOX_PROFILE,
|
|
116
|
+
enforced: {
|
|
117
|
+
resources: false,
|
|
118
|
+
filesystem: false,
|
|
119
|
+
network: false,
|
|
120
|
+
privilege: false,
|
|
121
|
+
},
|
|
122
|
+
downgrades: [
|
|
123
|
+
{ layer: "network", reason: "metadata block unenforceable" },
|
|
124
|
+
{ layer: "filesystem", reason: "no wrapper" },
|
|
125
|
+
],
|
|
126
|
+
notes: [],
|
|
127
|
+
platform: "darwin",
|
|
128
|
+
};
|
|
129
|
+
const summary = summarizeRunDowngrades(effective);
|
|
130
|
+
expect(summary?.layers).toEqual(["network", "filesystem"]);
|
|
131
|
+
expect(summary?.reasons.network).toContain("metadata");
|
|
132
|
+
expect(summary?.platform).toBe("darwin");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("surfaceRunDowngrades", () => {
|
|
137
|
+
it("logs a single structured warning when a layer degraded", () => {
|
|
138
|
+
const { warns, logger } = recordingLogger();
|
|
139
|
+
surfaceRunDowngrades({
|
|
140
|
+
logger,
|
|
141
|
+
effective: {
|
|
142
|
+
requested: DEFAULT_SANDBOX_PROFILE,
|
|
143
|
+
enforced: {
|
|
144
|
+
resources: true,
|
|
145
|
+
filesystem: false,
|
|
146
|
+
network: false,
|
|
147
|
+
privilege: true,
|
|
148
|
+
},
|
|
149
|
+
downgrades: [
|
|
150
|
+
{ layer: "filesystem", reason: "no wrapper on this host" },
|
|
151
|
+
],
|
|
152
|
+
notes: [],
|
|
153
|
+
platform: "darwin",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
expect(warns).toHaveLength(1);
|
|
157
|
+
expect(warns[0]?.message).toContain("script sandbox degraded");
|
|
158
|
+
expect(warns[0]?.message).toContain("filesystem");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("is a no-op when the run was fully enforced (no downgrades, no notes)", () => {
|
|
162
|
+
const { warns, infos, logger } = recordingLogger();
|
|
163
|
+
surfaceRunDowngrades({
|
|
164
|
+
logger,
|
|
165
|
+
effective: {
|
|
166
|
+
requested: DEFAULT_SANDBOX_PROFILE,
|
|
167
|
+
enforced: {
|
|
168
|
+
resources: true,
|
|
169
|
+
filesystem: true,
|
|
170
|
+
network: true,
|
|
171
|
+
privilege: true,
|
|
172
|
+
},
|
|
173
|
+
downgrades: [],
|
|
174
|
+
notes: [],
|
|
175
|
+
platform: "linux",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
expect(warns).toHaveLength(0);
|
|
179
|
+
expect(infos).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("logs non-fatal notes at INFO (separate from downgrades)", () => {
|
|
183
|
+
const { warns, infos, logger } = recordingLogger();
|
|
184
|
+
surfaceRunDowngrades({
|
|
185
|
+
logger,
|
|
186
|
+
effective: {
|
|
187
|
+
requested: DEFAULT_SANDBOX_PROFILE,
|
|
188
|
+
enforced: {
|
|
189
|
+
resources: true,
|
|
190
|
+
filesystem: true,
|
|
191
|
+
network: true,
|
|
192
|
+
privilege: true,
|
|
193
|
+
},
|
|
194
|
+
downgrades: [],
|
|
195
|
+
notes: [
|
|
196
|
+
{
|
|
197
|
+
layer: "resources",
|
|
198
|
+
note: "per-run memory (memoryBytes) is NOT enforced; cgroup is the ceiling",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
platform: "linux",
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
// A note is NOT a degradation: no warning, an INFO line instead.
|
|
205
|
+
expect(warns).toHaveLength(0);
|
|
206
|
+
expect(infos).toHaveLength(1);
|
|
207
|
+
expect(infos[0]?.message).toContain("script sandbox notes");
|
|
208
|
+
expect(infos[0]?.message).toContain("resources");
|
|
209
|
+
});
|
|
210
|
+
});
|