@clear-capabilities/agentic-security-scanner 0.163.1 → 0.165.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 +24 -0
- package/dist/9560.index.js +24 -10
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/capabilities/CLAUDE.md +20 -13
- package/src/capabilities/attack-coverage.js +1 -1
- package/src/capabilities/manifest.js +2 -2
- package/src/capabilities/probes.js +22 -8
- package/src/capabilities/report.js +5 -4
- package/src/capabilities/runner.js +6 -1
- package/src/sandbox/CLAUDE.md +61 -18
- package/src/sandbox/backend-namespace.js +246 -117
- package/src/sandbox/capabilities.js +22 -0
- package/src/sandbox/control-probes.js +91 -7
- package/src/sandbox/limits.js +2 -0
- package/src/sandbox/linux-probes.js +179 -0
- package/src/sandbox/linux-rootfs.js +263 -0
- package/src/sandbox/supervise.js +38 -15
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// same shape the userspace backend produces, which is the main reason
|
|
21
21
|
// this shape was chosen over `pivot_root` (see below).
|
|
22
22
|
//
|
|
23
|
-
// 3. RESOURCE CAPS — the shared `ulimit` prelude
|
|
23
|
+
// 3. RESOURCE CAPS — the shared `ulimit` prelude for the file-size and
|
|
24
|
+
// address-space caps; the process-count cap is applied by `prlimit` (below).
|
|
24
25
|
//
|
|
25
26
|
// WHY READ-ONLY REBIND RATHER THAN pivot_root. `pivot_root` into the sandbox
|
|
26
27
|
// root is the stronger primitive: after detaching the old root, out-of-root
|
|
@@ -102,16 +103,39 @@
|
|
|
102
103
|
// confinement, so `--net` is part of every probed variant when `allowNetwork`
|
|
103
104
|
// is false, and `--mount` is in every variant unconditionally because the
|
|
104
105
|
// write confinement is built inside it.
|
|
106
|
+
// TWO MODES. The default (legacy) mode above is unchanged: the whole host tree
|
|
107
|
+
// stays visible and is rebound read-only. CAPABILITY MODE is entered when the
|
|
108
|
+
// caller passes `readRoots` (an array, possibly empty): the process pivots into
|
|
109
|
+
// a tmpfs root that holds only the runtime baseline and the declared roots (see
|
|
110
|
+
// `linux-rootfs.js`), so a path outside them has no name at all. In both modes
|
|
111
|
+
// `denyReadPaths` masks the named host paths (an empty tmpfs over a directory,
|
|
112
|
+
// /dev/null over a file; absent in capability mode unless under a declared
|
|
113
|
+
// root), and the process runs under a PID namespace created with
|
|
114
|
+
// `--kill-child`: when the unshare process dies, the namespace's init gets
|
|
115
|
+
// SIGKILL and the kernel kills every member, which no `setsid` or double fork
|
|
116
|
+
// can leave. `--kill-child` is used only where `unshare` advertises it; where it
|
|
117
|
+
// does not, `treeKill` is false and supervised execution is refused.
|
|
118
|
+
//
|
|
119
|
+
// Mediated network (a proxy reachable from inside an empty network namespace)
|
|
120
|
+
// is NOT implemented. `networkProxyPort` is refused, so a task that needs a
|
|
121
|
+
// network destination is blocked on this backend, never quietly allowed.
|
|
122
|
+
//
|
|
123
|
+
// Nothing above is a verification claim. What has been executed on a Linux
|
|
124
|
+
// host is stated by `sandbox/CLAUDE.md` and evidenced only by the
|
|
125
|
+
// `sandbox-linux` CI job's probes (`linux-probes.js`).
|
|
105
126
|
import { spawnSync } from 'node:child_process';
|
|
106
127
|
import fs from 'node:fs';
|
|
107
128
|
import os from 'node:os';
|
|
108
129
|
import path from 'node:path';
|
|
109
130
|
import {
|
|
110
|
-
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin,
|
|
131
|
+
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin, resolvePrlimitBin,
|
|
111
132
|
cachedNamespaceVariant, cacheNamespaceVariant,
|
|
112
133
|
} from './capabilities.js';
|
|
113
|
-
import { buildLimitPrelude,
|
|
134
|
+
import { buildLimitPrelude, validateMaxProcs } from './limits.js';
|
|
114
135
|
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
136
|
+
import {
|
|
137
|
+
buildRootPlan, serializePlan, PIVOT_SETUP_SCRIPT, FINAL_SCRIPT, MARK_SETUP_FAILED, MARK_NO_PRIVDROP,
|
|
138
|
+
} from './linux-rootfs.js';
|
|
115
139
|
|
|
116
140
|
// Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
|
|
117
141
|
// prefix; the namespace flags themselves are appended identically to all of
|
|
@@ -131,34 +155,35 @@ const NS_PRIVILEGE_VARIANTS = Object.freeze([
|
|
|
131
155
|
Object.freeze([]),
|
|
132
156
|
]);
|
|
133
157
|
|
|
134
|
-
function _nsArgs(privilegeFlags, allowNetwork) {
|
|
158
|
+
function _nsArgs(privilegeFlags, allowNetwork, killChild = false) {
|
|
135
159
|
const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
|
|
160
|
+
if (killChild) a.push('--kill-child');
|
|
136
161
|
if (!allowNetwork) a.push('--net');
|
|
137
162
|
return a;
|
|
138
163
|
}
|
|
139
164
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
165
|
+
/** Whether a resolved argument list carries the PID-namespace tree kill. */
|
|
166
|
+
export function namespaceTreeKill(args) {
|
|
167
|
+
return Array.isArray(args) && args.includes('--kill-child');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The markers live in linux-rootfs.js so both modes agree. A payload that
|
|
171
|
+
// PRINTS one of these strings can force `status:'error'` (or a false
|
|
172
|
+
// `privilegeDrop` unenforced note). That is the safe direction: the worst it
|
|
173
|
+
// achieves is making its own run look like it did not happen, which no
|
|
174
|
+
// downstream tier reads as evidence of anything. It cannot make an unconfined
|
|
175
|
+
// run look confined.
|
|
151
176
|
|
|
152
|
-
//
|
|
153
|
-
// Builds the write confinement, then hands off to $SBX_FINAL
|
|
154
|
-
// capability set dropped.
|
|
177
|
+
// Default mode: runs inside the namespaces, still privileged, before the
|
|
178
|
+
// caller's command. Builds the write confinement, then hands off to $SBX_FINAL
|
|
179
|
+
// with the capability set dropped.
|
|
155
180
|
//
|
|
156
181
|
// Order matters: the sandbox root is bound onto itself while the tree is still
|
|
157
182
|
// writable, so the read-only pass and the read-write rebind of the root never
|
|
158
183
|
// have to fight each other. Individual sub-mounts are best-effort (some pseudo
|
|
159
184
|
// filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
|
|
160
185
|
// what actually decides whether the result is trustworthy.
|
|
161
|
-
const
|
|
186
|
+
export const LEGACY_SETUP_SCRIPT = `
|
|
162
187
|
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
163
188
|
"$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
|
|
164
189
|
"$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
|
|
@@ -175,6 +200,18 @@ done
|
|
|
175
200
|
# it, so this is normally a no-op. Its return code is NOT the gate — the
|
|
176
201
|
# executed in-root write check in $SBX_FINAL is, and that one fails closed.
|
|
177
202
|
"$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
|
|
203
|
+
if [ -n "$SBX_MASKS" ]; then
|
|
204
|
+
while IFS= read -r _m; do
|
|
205
|
+
[ -n "$_m" ] || continue
|
|
206
|
+
if [ -d "$_m" ]; then
|
|
207
|
+
"$SBX_MOUNT" -t tmpfs -o size=4k,mode=000 tmpfs "$_m" || _fail "a protected directory could not be masked"
|
|
208
|
+
elif [ -e "$_m" ]; then
|
|
209
|
+
"$SBX_MOUNT" --bind /dev/null "$_m" || _fail "a protected file could not be masked"
|
|
210
|
+
fi
|
|
211
|
+
done <<SBX_MASK_EOF
|
|
212
|
+
$SBX_MASKS
|
|
213
|
+
SBX_MASK_EOF
|
|
214
|
+
fi
|
|
178
215
|
if [ -n "$SBX_PRIVDROP" ] && "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c 'exit 0' 2>/dev/null; then
|
|
179
216
|
exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
180
217
|
fi
|
|
@@ -182,36 +219,31 @@ echo "${MARK_NO_PRIVDROP}" >&2
|
|
|
182
219
|
exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
183
220
|
`;
|
|
184
221
|
|
|
185
|
-
// Runs in the FINAL privilege state, immediately before the caller's command.
|
|
186
|
-
// Both directions are checked by execution, every run: the out-of-root canary
|
|
187
|
-
// must be refused, and an in-root write must succeed. Either check failing
|
|
188
|
-
// means the sandbox is not what it claims, so the command is not run.
|
|
189
|
-
const FINAL_SCRIPT = `
|
|
190
|
-
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
191
|
-
if ( : > "$SBX_CANARY" ) 2>/dev/null; then
|
|
192
|
-
_fail "an out-of-root write is still possible; refusing to execute"
|
|
193
|
-
fi
|
|
194
|
-
if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
|
|
195
|
-
_fail "the sandbox root is not writable; refusing to execute"
|
|
196
|
-
fi
|
|
197
|
-
rm -f "$ROOT/.agsec-sbx-wcheck"
|
|
198
|
-
cd "$ROOT" && exec "$@"
|
|
199
|
-
`;
|
|
200
|
-
|
|
201
222
|
/**
|
|
202
223
|
* The first privilege variant under which the requested namespaces can
|
|
203
224
|
* actually be created on this host, or null when none can. Probed by running
|
|
204
225
|
* a trivial command — a reasoned expectation about which flags "should" work
|
|
205
226
|
* is exactly what made this backend unusable on an unprivileged runner.
|
|
227
|
+
*
|
|
228
|
+
* `--kill-child` is added only when the binary advertises it in its help text;
|
|
229
|
+
* it is an addition to the confinement, never a requirement for it, so a host
|
|
230
|
+
* without it still gets the same confinement and simply reports `treeKill`
|
|
231
|
+
* false (see `namespaceTreeKill`).
|
|
206
232
|
*/
|
|
207
233
|
export function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
|
|
208
234
|
const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
|
|
209
235
|
const cached = cachedNamespaceVariant(key);
|
|
210
236
|
if (cached !== undefined) return cached;
|
|
211
237
|
|
|
238
|
+
let killChild = false;
|
|
239
|
+
try {
|
|
240
|
+
const help = spawnSync(bin, ['--help'], { encoding: 'utf8', timeout: probeTimeoutMs });
|
|
241
|
+
killChild = /--kill-child/.test(`${help.stdout || ''}${help.stderr || ''}`);
|
|
242
|
+
} catch { killChild = false; }
|
|
243
|
+
|
|
212
244
|
let chosen = null;
|
|
213
245
|
for (const variant of NS_PRIVILEGE_VARIANTS) {
|
|
214
|
-
const args = _nsArgs(variant, allowNetwork);
|
|
246
|
+
const args = _nsArgs(variant, allowNetwork, killChild);
|
|
215
247
|
const probe = spawnSync(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
|
|
216
248
|
encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
249
|
});
|
|
@@ -237,33 +269,62 @@ function _setupFailureReason(stderr) {
|
|
|
237
269
|
return null;
|
|
238
270
|
}
|
|
239
271
|
|
|
240
|
-
|
|
272
|
+
const _within = (parent, child) => child === parent || child.startsWith(parent.endsWith('/') ? parent : parent + '/');
|
|
273
|
+
|
|
274
|
+
function _maskPaths(denyReadPaths, resolvedRoot) {
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const p of denyReadPaths || []) {
|
|
277
|
+
if (typeof p !== 'string' || !p) continue;
|
|
278
|
+
let real = path.resolve(p);
|
|
279
|
+
try { real = fs.realpathSync(real); } catch { /* not present: nothing to mask, and nothing to read */ }
|
|
280
|
+
if (/[\0\n]/.test(real)) return { error: `a denied path cannot be carried into the mount setup (${JSON.stringify(p)})` };
|
|
281
|
+
if (_within(real, resolvedRoot)) return { error: `a denied path overlaps the sandbox root (${real})` };
|
|
282
|
+
if (!out.includes(real)) out.push(real);
|
|
283
|
+
}
|
|
284
|
+
return { paths: out };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Build the exact spawn invocation for a namespace-confined command, without
|
|
289
|
+
* running it. Shared by the synchronous runner and the supervised runner, so
|
|
290
|
+
* both are confined by the SAME setup. Returns `{ error }` in the documented
|
|
291
|
+
* error shape instead of throwing. On success the caller MUST call
|
|
292
|
+
* `inv.finish(rawStderr, {hadError})` exactly once after the process ends: it
|
|
293
|
+
* checks the host-side canary, extracts any setup failure, removes the
|
|
294
|
+
* temporary directories, and returns `{ error }` or `{ stderr, unsupported }`.
|
|
295
|
+
*
|
|
296
|
+
* @param {object} [deps] test seam: tool resolvers and the argument resolver.
|
|
297
|
+
* Cannot weaken the setup: it only changes WHERE the tools are found.
|
|
298
|
+
*/
|
|
299
|
+
export function buildNamespaceInvocation(argv, {
|
|
241
300
|
root,
|
|
242
|
-
timeoutMs = 10000,
|
|
243
301
|
allowNetwork = false,
|
|
244
302
|
limits = {},
|
|
245
303
|
env = {},
|
|
246
|
-
maxBuffer = 8 * 1024 * 1024,
|
|
247
304
|
denyReadPaths = [],
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
305
|
+
readRoots = null,
|
|
306
|
+
writeRoots = [],
|
|
307
|
+
networkProxyPort = null,
|
|
308
|
+
cwd = null,
|
|
309
|
+
} = {}, deps = {}) {
|
|
310
|
+
const d = {
|
|
311
|
+
nsBin: resolveNamespaceBin, mountBin: resolveMountBin, privDropBin: resolvePrivDropBin,
|
|
312
|
+
pivotBin: resolvePivotBin, umountBin: resolveUmountBin, prlimitBin: resolvePrlimitBin, nsArgs: resolveNamespaceArgs, ...deps,
|
|
313
|
+
};
|
|
314
|
+
if (!root) return { error: errorResult('namespace', 'runNamespace requires a sandbox root') };
|
|
315
|
+
if (networkProxyPort != null) {
|
|
316
|
+
return { error: errorResult('namespace', 'mediated network access is not implemented on this backend (an empty network namespace has no path to a proxy); refusing to execute') };
|
|
255
317
|
}
|
|
256
|
-
if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
|
|
257
318
|
|
|
258
|
-
const bin =
|
|
259
|
-
if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
|
|
319
|
+
const bin = d.nsBin();
|
|
320
|
+
if (!bin) return { error: errorResult('namespace', 'no kernel-namespace binary found on this host') };
|
|
260
321
|
|
|
261
322
|
// Write confinement is built with this utility. No utility, no confinement,
|
|
262
323
|
// no run — there is deliberately no branch that proceeds without it.
|
|
263
|
-
const mountBin =
|
|
324
|
+
const mountBin = d.mountBin();
|
|
264
325
|
if (!mountBin) {
|
|
265
|
-
return errorResult('namespace',
|
|
266
|
-
'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined');
|
|
326
|
+
return { error: errorResult('namespace',
|
|
327
|
+
'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined') };
|
|
267
328
|
}
|
|
268
329
|
|
|
269
330
|
let resolvedRoot;
|
|
@@ -272,99 +333,167 @@ export function runNamespace(argv, {
|
|
|
272
333
|
// hand to the child.
|
|
273
334
|
resolvedRoot = fs.realpathSync(root);
|
|
274
335
|
} catch (e) {
|
|
275
|
-
return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
|
|
336
|
+
return { error: errorResult('namespace', `sandbox root is not usable: ${e.message}`) };
|
|
276
337
|
}
|
|
277
338
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
339
|
+
const strict = Array.isArray(readRoots);
|
|
340
|
+
const masked = _maskPaths(denyReadPaths, resolvedRoot);
|
|
341
|
+
if (masked.error) return { error: errorResult('namespace', masked.error) };
|
|
342
|
+
|
|
343
|
+
let plan = null;
|
|
344
|
+
let pivotBin = null; let umountBin = null;
|
|
345
|
+
if (strict) {
|
|
346
|
+
pivotBin = d.pivotBin();
|
|
347
|
+
umountBin = d.umountBin();
|
|
348
|
+
if (!pivotBin || !umountBin) {
|
|
349
|
+
return { error: errorResult('namespace', 'capability mode needs the root-switching and unmount tools, and this host lacks them; refusing to execute') };
|
|
350
|
+
}
|
|
351
|
+
plan = buildRootPlan({ root: resolvedRoot, readRoots, writeRoots, denyReadPaths: masked.paths, cwd });
|
|
352
|
+
if (!plan.ok) return { error: errorResult('namespace', `the filesystem plan is not acceptable: ${plan.error}`) };
|
|
353
|
+
} else if (cwd) {
|
|
354
|
+
// Default mode keeps its one working directory. Silently ignoring a
|
|
355
|
+
// requested directory would run the command somewhere the caller did not
|
|
356
|
+
// choose.
|
|
357
|
+
let wd = null;
|
|
358
|
+
try { wd = fs.realpathSync(cwd); } catch { /* handled below */ }
|
|
359
|
+
if (wd !== resolvedRoot) return { error: errorResult('namespace', 'a working directory other than the sandbox root needs capability mode (readRoots); refusing to execute') };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// PROCESS-COUNT CAP. Not part of the shell prelude on this backend: that shell is
|
|
363
|
+
// often dash, whose `ulimit` has no `-u`, so a cap written there was never applied
|
|
364
|
+
// (found in the hosted log: `ulimit: Illegal option -u`). The cap is applied to the
|
|
365
|
+
// caller's command alone, by `prlimit` in the final stage, AFTER the confinement is
|
|
366
|
+
// built, because the setup itself forks and a cap below its needs would break it.
|
|
367
|
+
// It can bind inside the user namespace: RLIMIT_NPROC is waived only for a process
|
|
368
|
+
// holding CAP_SYS_RESOURCE or CAP_SYS_ADMIN in the INITIAL namespace, which a
|
|
369
|
+
// namespace root does not have (and the capability set is dropped anyway). There is
|
|
370
|
+
// no default cap (the old ambient-relative default was never applied here either);
|
|
371
|
+
// a caller that asks for one gets it, or an honest `unsupported` entry.
|
|
372
|
+
let nproc = null; let prlimitBin = null;
|
|
373
|
+
if (limits.maxProcs != null) {
|
|
374
|
+
try { nproc = validateMaxProcs(limits.maxProcs); } catch (e) {
|
|
375
|
+
return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
|
|
376
|
+
}
|
|
377
|
+
prlimitBin = d.prlimitBin();
|
|
378
|
+
}
|
|
283
379
|
|
|
284
380
|
let prelude, unsupported;
|
|
285
381
|
try {
|
|
286
|
-
({ prelude, unsupported } = buildLimitPrelude(
|
|
382
|
+
({ prelude, unsupported } = buildLimitPrelude({ ...limits, maxProcs: null }));
|
|
287
383
|
} catch (e) {
|
|
288
|
-
return errorResult('namespace', `invalid resource limit: ${e.message}`);
|
|
384
|
+
return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
|
|
289
385
|
}
|
|
386
|
+
if (nproc != null && !prlimitBin) unsupported = [...unsupported, 'maxProcs'];
|
|
290
387
|
|
|
291
388
|
// Fail closed: no usable variant means the confinement cannot be
|
|
292
389
|
// established, so nothing is executed. There is deliberately no path that
|
|
293
390
|
// drops confinement flags and runs anyway.
|
|
294
|
-
const nsArgs =
|
|
391
|
+
const nsArgs = d.nsArgs(bin, allowNetwork);
|
|
295
392
|
if (!nsArgs) {
|
|
296
|
-
return errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined');
|
|
393
|
+
return { error: errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined') };
|
|
297
394
|
}
|
|
298
395
|
|
|
299
396
|
// The canary lives OUTSIDE the sandbox root, in a directory this process
|
|
300
397
|
// just created and can write. If the confined shell can create it, the
|
|
301
398
|
// confinement is not in force and the command is not run.
|
|
302
|
-
|
|
399
|
+
const made = [];
|
|
400
|
+
const dispose = () => { for (const p of made.splice(0)) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
401
|
+
let canaryDir; let newRoot = null;
|
|
303
402
|
try {
|
|
304
403
|
canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-canary-'));
|
|
404
|
+
made.push(canaryDir);
|
|
405
|
+
if (strict) { newRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-newroot-')); made.push(newRoot); }
|
|
305
406
|
} catch (e) {
|
|
306
|
-
|
|
407
|
+
dispose();
|
|
408
|
+
return { error: errorResult('namespace', `could not create the confinement scratch directories: ${e.message}`) };
|
|
307
409
|
}
|
|
308
410
|
const canary = path.join(canaryDir, 'out-of-root.canary');
|
|
309
411
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
// Found by CI, not by reasoning: the first Linux run of the tree-kill
|
|
327
|
-
// test recorded duration_ms 30057 against a 1200 ms budget with the
|
|
328
|
-
// payload run to completion. The comment above this function used to
|
|
329
|
-
// claim the opposite.
|
|
330
|
-
killSignal: 'SIGKILL',
|
|
331
|
-
maxBuffer,
|
|
332
|
-
cwd: resolvedRoot,
|
|
333
|
-
env: {
|
|
334
|
-
...buildConfinedEnv({ root: resolvedRoot, env }),
|
|
335
|
-
SBX_MOUNT: mountBin,
|
|
336
|
-
SBX_PRIVDROP: resolvePrivDropBin() || '',
|
|
337
|
-
SBX_CANARY: canary,
|
|
338
|
-
SBX_FINAL: FINAL_SCRIPT,
|
|
339
|
-
},
|
|
340
|
-
},
|
|
341
|
-
);
|
|
342
|
-
|
|
343
|
-
// Parent-side confirmation of the same fact the canary check asserts from
|
|
344
|
-
// the inside. Cheap, and it does not depend on the confined shell being
|
|
345
|
-
// honest about its own exit code.
|
|
346
|
-
if (fs.existsSync(canary)) {
|
|
347
|
-
return errorResult('namespace',
|
|
348
|
-
'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host');
|
|
349
|
-
}
|
|
350
|
-
} finally {
|
|
351
|
-
try { fs.rmSync(canaryDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
412
|
+
const sbxEnv = {
|
|
413
|
+
SBX_MOUNT: mountBin,
|
|
414
|
+
SBX_PRIVDROP: d.privDropBin() || '',
|
|
415
|
+
SBX_CANARY: canary,
|
|
416
|
+
SBX_FINAL: FINAL_SCRIPT,
|
|
417
|
+
};
|
|
418
|
+
if (nproc != null && prlimitBin) { sbxEnv.SBX_NPROC = String(nproc); sbxEnv.SBX_PRLIMIT = prlimitBin; }
|
|
419
|
+
let script;
|
|
420
|
+
if (strict) {
|
|
421
|
+
Object.assign(sbxEnv, {
|
|
422
|
+
SBX_STRICT: '1', SBX_NEWROOT: newRoot, SBX_PLAN: serializePlan(plan), SBX_PIVOT: pivotBin, SBX_UMOUNT: umountBin, SBX_CWD: plan.cwd,
|
|
423
|
+
});
|
|
424
|
+
script = PIVOT_SETUP_SCRIPT;
|
|
425
|
+
} else {
|
|
426
|
+
if (masked.paths.length) sbxEnv.SBX_MASKS = masked.paths.join('\n');
|
|
427
|
+
script = LEGACY_SETUP_SCRIPT;
|
|
352
428
|
}
|
|
353
429
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
430
|
+
return {
|
|
431
|
+
bin,
|
|
432
|
+
args: [...nsArgs, '/bin/sh', '-c', prelude + script, '_sbx', ...argv],
|
|
433
|
+
cwd: resolvedRoot,
|
|
434
|
+
env: { ...buildConfinedEnv({ root: resolvedRoot, env }), ...sbxEnv },
|
|
435
|
+
unsupported,
|
|
436
|
+
mode: strict ? 'capability' : 'default',
|
|
437
|
+
treeKill: namespaceTreeKill(nsArgs),
|
|
438
|
+
plan,
|
|
439
|
+
dispose,
|
|
440
|
+
finish(rawStderr, { hadError = false } = {}) {
|
|
441
|
+
try {
|
|
442
|
+
// Parent-side confirmation of the same fact the canary check asserts
|
|
443
|
+
// from the inside. Cheap, and it does not depend on the confined shell
|
|
444
|
+
// being honest about its own exit code.
|
|
445
|
+
if (fs.existsSync(canary)) {
|
|
446
|
+
return { error: errorResult('namespace',
|
|
447
|
+
'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host') };
|
|
448
|
+
}
|
|
449
|
+
} finally { dispose(); }
|
|
450
|
+
const raw = String(rawStderr || '');
|
|
451
|
+
const setupFailure = _setupFailureReason(raw);
|
|
452
|
+
if (setupFailure && !hadError) {
|
|
453
|
+
// Confinement could not be established (or could not be proven). Nothing
|
|
454
|
+
// ran: the shell exits before `exec`ing the caller's command.
|
|
455
|
+
return { error: errorResult('namespace', `confinement could not be established: ${setupFailure}`) };
|
|
456
|
+
}
|
|
457
|
+
const eff = [...unsupported];
|
|
458
|
+
if (raw.includes(MARK_NO_PRIVDROP)) eff.push('privilegeDrop');
|
|
459
|
+
return { stderr: _cleanStderr(raw), unsupported: eff };
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function runNamespace(argv, opts = {}) {
|
|
465
|
+
const { timeoutMs = 10000, maxBuffer = 8 * 1024 * 1024 } = opts;
|
|
466
|
+
// Documented shape, never a throw — see the same note in backend-userspace.
|
|
467
|
+
const inv = buildNamespaceInvocation(argv, opts);
|
|
468
|
+
if (inv.error) return inv.error;
|
|
361
469
|
|
|
362
|
-
|
|
363
|
-
|
|
470
|
+
let r;
|
|
471
|
+
try {
|
|
472
|
+
r = spawnSync(inv.bin, inv.args, {
|
|
473
|
+
encoding: 'utf8',
|
|
474
|
+
timeout: timeoutMs,
|
|
475
|
+
// SIGKILL, not the SIGTERM default: the direct child is pid 1 of a new
|
|
476
|
+
// PID namespace, and the kernel does not deliver default-action signals
|
|
477
|
+
// to a namespace's pid 1 from outside it. SIGKILL cannot be ignored.
|
|
478
|
+
// Found by CI: the first Linux run of the tree-kill test recorded
|
|
479
|
+
// duration_ms 30057 against a 1200 ms budget. With `--kill-child` the
|
|
480
|
+
// same SIGKILL, delivered to the unshare process, also reaps the whole
|
|
481
|
+
// namespace (see the header).
|
|
482
|
+
killSignal: 'SIGKILL',
|
|
483
|
+
maxBuffer,
|
|
484
|
+
cwd: inv.cwd,
|
|
485
|
+
env: inv.env,
|
|
486
|
+
});
|
|
487
|
+
} catch (e) {
|
|
488
|
+
inv.dispose();
|
|
489
|
+
return errorResult('namespace', `the confined process could not be started: ${e.message}`);
|
|
490
|
+
}
|
|
364
491
|
|
|
492
|
+
const fin = inv.finish(r.stderr ?? '', { hadError: !!r.error });
|
|
493
|
+
if (fin.error) return fin.error;
|
|
365
494
|
return buildResult({
|
|
366
495
|
backend: 'namespace',
|
|
367
|
-
spawnResult: { ...r, stderr:
|
|
368
|
-
unsupported:
|
|
496
|
+
spawnResult: { ...r, stderr: fin.stderr },
|
|
497
|
+
unsupported: fin.unsupported,
|
|
369
498
|
});
|
|
370
499
|
}
|
|
@@ -67,6 +67,25 @@ export const CONFINE_BINS_PRIVDROP = Object.freeze([
|
|
|
67
67
|
'/usr/sbin/setpriv',
|
|
68
68
|
]);
|
|
69
69
|
|
|
70
|
+
// The two further tools capability mode needs: `pivot_root` to enter the new
|
|
71
|
+
// root and `umount` to detach the old one. Absent either, capability mode
|
|
72
|
+
// cannot be established and fails closed (the legacy mode does not use them).
|
|
73
|
+
export const CONFINE_BINS_PIVOT = Object.freeze([
|
|
74
|
+
'/usr/sbin/pivot_root', '/sbin/pivot_root', '/usr/bin/pivot_root', '/bin/pivot_root',
|
|
75
|
+
]);
|
|
76
|
+
export const CONFINE_BINS_UMOUNT = Object.freeze([
|
|
77
|
+
'/usr/bin/umount', '/bin/umount', '/sbin/umount', '/usr/sbin/umount',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// The process-limit utility. The resource prelude runs under `/bin/sh`, which is dash on
|
|
81
|
+
// many Linux hosts, and dash's `ulimit` has no `-u`: a process-count cap written there is
|
|
82
|
+
// never applied. The namespace backend therefore applies the cap with this tool, in the
|
|
83
|
+
// final stage and only to the caller's command (a cap applied earlier would starve the
|
|
84
|
+
// confinement setup of the processes it needs). Absent => the cap is declared unsupported.
|
|
85
|
+
export const CONFINE_BINS_PRLIMIT = Object.freeze([
|
|
86
|
+
'/usr/bin/prlimit', '/bin/prlimit', '/usr/local/bin/prlimit', '/usr/sbin/prlimit', '/sbin/prlimit',
|
|
87
|
+
]);
|
|
88
|
+
|
|
70
89
|
// Back-compat single-path exports: the first (canonical) candidate.
|
|
71
90
|
export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
|
|
72
91
|
export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
|
|
@@ -96,6 +115,9 @@ export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_US
|
|
|
96
115
|
export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
|
|
97
116
|
export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
|
|
98
117
|
export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
|
|
118
|
+
export function resolvePivotBin() { return resolveConfineBin(CONFINE_BINS_PIVOT); }
|
|
119
|
+
export function resolvePrlimitBin() { return resolveConfineBin(CONFINE_BINS_PRLIMIT); }
|
|
120
|
+
export function resolveUmountBin() { return resolveConfineBin(CONFINE_BINS_UMOUNT); }
|
|
99
121
|
|
|
100
122
|
// Bounded on purpose: a capability check must never hang a scan. The probe is
|
|
101
123
|
// a single `exit 0` under confinement, so anything beyond a couple of seconds
|