@clear-capabilities/agentic-security-scanner 0.163.1 → 0.164.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 +14 -0
- package/dist/9560.index.js +22 -8
- 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 +15 -11
- package/src/capabilities/probes.js +22 -8
- package/src/sandbox/CLAUDE.md +49 -17
- package/src/sandbox/backend-namespace.js +223 -109
- package/src/sandbox/capabilities.js +12 -0
- package/src/sandbox/control-probes.js +54 -4
- package/src/sandbox/linux-probes.js +178 -0
- package/src/sandbox/linux-rootfs.js +256 -0
- package/src/sandbox/supervise.js +31 -12
|
@@ -102,16 +102,39 @@
|
|
|
102
102
|
// confinement, so `--net` is part of every probed variant when `allowNetwork`
|
|
103
103
|
// is false, and `--mount` is in every variant unconditionally because the
|
|
104
104
|
// write confinement is built inside it.
|
|
105
|
+
// TWO MODES. The default (legacy) mode above is unchanged: the whole host tree
|
|
106
|
+
// stays visible and is rebound read-only. CAPABILITY MODE is entered when the
|
|
107
|
+
// caller passes `readRoots` (an array, possibly empty): the process pivots into
|
|
108
|
+
// a tmpfs root that holds only the runtime baseline and the declared roots (see
|
|
109
|
+
// `linux-rootfs.js`), so a path outside them has no name at all. In both modes
|
|
110
|
+
// `denyReadPaths` masks the named host paths (an empty tmpfs over a directory,
|
|
111
|
+
// /dev/null over a file; absent in capability mode unless under a declared
|
|
112
|
+
// root), and the process runs under a PID namespace created with
|
|
113
|
+
// `--kill-child`: when the unshare process dies, the namespace's init gets
|
|
114
|
+
// SIGKILL and the kernel kills every member, which no `setsid` or double fork
|
|
115
|
+
// can leave. `--kill-child` is used only where `unshare` advertises it; where it
|
|
116
|
+
// does not, `treeKill` is false and supervised execution is refused.
|
|
117
|
+
//
|
|
118
|
+
// Mediated network (a proxy reachable from inside an empty network namespace)
|
|
119
|
+
// is NOT implemented. `networkProxyPort` is refused, so a task that needs a
|
|
120
|
+
// network destination is blocked on this backend, never quietly allowed.
|
|
121
|
+
//
|
|
122
|
+
// Nothing above is a verification claim. What has been executed on a Linux
|
|
123
|
+
// host is stated by `sandbox/CLAUDE.md` and evidenced only by the
|
|
124
|
+
// `sandbox-linux` CI job's probes (`linux-probes.js`).
|
|
105
125
|
import { spawnSync } from 'node:child_process';
|
|
106
126
|
import fs from 'node:fs';
|
|
107
127
|
import os from 'node:os';
|
|
108
128
|
import path from 'node:path';
|
|
109
129
|
import {
|
|
110
|
-
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin,
|
|
130
|
+
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin,
|
|
111
131
|
cachedNamespaceVariant, cacheNamespaceVariant,
|
|
112
132
|
} from './capabilities.js';
|
|
113
133
|
import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
|
|
114
134
|
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
135
|
+
import {
|
|
136
|
+
buildRootPlan, serializePlan, PIVOT_SETUP_SCRIPT, FINAL_SCRIPT, MARK_SETUP_FAILED, MARK_NO_PRIVDROP,
|
|
137
|
+
} from './linux-rootfs.js';
|
|
115
138
|
|
|
116
139
|
// Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
|
|
117
140
|
// prefix; the namespace flags themselves are appended identically to all of
|
|
@@ -131,34 +154,35 @@ const NS_PRIVILEGE_VARIANTS = Object.freeze([
|
|
|
131
154
|
Object.freeze([]),
|
|
132
155
|
]);
|
|
133
156
|
|
|
134
|
-
function _nsArgs(privilegeFlags, allowNetwork) {
|
|
157
|
+
function _nsArgs(privilegeFlags, allowNetwork, killChild = false) {
|
|
135
158
|
const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
|
|
159
|
+
if (killChild) a.push('--kill-child');
|
|
136
160
|
if (!allowNetwork) a.push('--net');
|
|
137
161
|
return a;
|
|
138
162
|
}
|
|
139
163
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// A payload that PRINTS one of these strings can force `status:'error'` (or a
|
|
145
|
-
// false `privilegeDrop` unenforced note). That is the safe direction: the
|
|
146
|
-
// worst it achieves is making its own run look like it did not happen, which
|
|
147
|
-
// no downstream tier reads as evidence of anything. It cannot make an
|
|
148
|
-
// unconfined run look confined.
|
|
149
|
-
const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
|
|
150
|
-
const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
|
|
164
|
+
/** Whether a resolved argument list carries the PID-namespace tree kill. */
|
|
165
|
+
export function namespaceTreeKill(args) {
|
|
166
|
+
return Array.isArray(args) && args.includes('--kill-child');
|
|
167
|
+
}
|
|
151
168
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
169
|
+
// The markers live in linux-rootfs.js so both modes agree. A payload that
|
|
170
|
+
// PRINTS one of these strings can force `status:'error'` (or a false
|
|
171
|
+
// `privilegeDrop` unenforced note). That is the safe direction: the worst it
|
|
172
|
+
// achieves is making its own run look like it did not happen, which no
|
|
173
|
+
// downstream tier reads as evidence of anything. It cannot make an unconfined
|
|
174
|
+
// run look confined.
|
|
175
|
+
|
|
176
|
+
// Default mode: runs inside the namespaces, still privileged, before the
|
|
177
|
+
// caller's command. Builds the write confinement, then hands off to $SBX_FINAL
|
|
178
|
+
// with the capability set dropped.
|
|
155
179
|
//
|
|
156
180
|
// Order matters: the sandbox root is bound onto itself while the tree is still
|
|
157
181
|
// writable, so the read-only pass and the read-write rebind of the root never
|
|
158
182
|
// have to fight each other. Individual sub-mounts are best-effort (some pseudo
|
|
159
183
|
// filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
|
|
160
184
|
// what actually decides whether the result is trustworthy.
|
|
161
|
-
const
|
|
185
|
+
export const LEGACY_SETUP_SCRIPT = `
|
|
162
186
|
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
163
187
|
"$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
|
|
164
188
|
"$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
|
|
@@ -175,6 +199,18 @@ done
|
|
|
175
199
|
# it, so this is normally a no-op. Its return code is NOT the gate — the
|
|
176
200
|
# executed in-root write check in $SBX_FINAL is, and that one fails closed.
|
|
177
201
|
"$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
|
|
202
|
+
if [ -n "$SBX_MASKS" ]; then
|
|
203
|
+
while IFS= read -r _m; do
|
|
204
|
+
[ -n "$_m" ] || continue
|
|
205
|
+
if [ -d "$_m" ]; then
|
|
206
|
+
"$SBX_MOUNT" -t tmpfs -o size=4k,mode=000 tmpfs "$_m" || _fail "a protected directory could not be masked"
|
|
207
|
+
elif [ -e "$_m" ]; then
|
|
208
|
+
"$SBX_MOUNT" --bind /dev/null "$_m" || _fail "a protected file could not be masked"
|
|
209
|
+
fi
|
|
210
|
+
done <<SBX_MASK_EOF
|
|
211
|
+
$SBX_MASKS
|
|
212
|
+
SBX_MASK_EOF
|
|
213
|
+
fi
|
|
178
214
|
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
215
|
exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
180
216
|
fi
|
|
@@ -182,36 +218,31 @@ echo "${MARK_NO_PRIVDROP}" >&2
|
|
|
182
218
|
exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
183
219
|
`;
|
|
184
220
|
|
|
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
221
|
/**
|
|
202
222
|
* The first privilege variant under which the requested namespaces can
|
|
203
223
|
* actually be created on this host, or null when none can. Probed by running
|
|
204
224
|
* a trivial command — a reasoned expectation about which flags "should" work
|
|
205
225
|
* is exactly what made this backend unusable on an unprivileged runner.
|
|
226
|
+
*
|
|
227
|
+
* `--kill-child` is added only when the binary advertises it in its help text;
|
|
228
|
+
* it is an addition to the confinement, never a requirement for it, so a host
|
|
229
|
+
* without it still gets the same confinement and simply reports `treeKill`
|
|
230
|
+
* false (see `namespaceTreeKill`).
|
|
206
231
|
*/
|
|
207
232
|
export function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
|
|
208
233
|
const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
|
|
209
234
|
const cached = cachedNamespaceVariant(key);
|
|
210
235
|
if (cached !== undefined) return cached;
|
|
211
236
|
|
|
237
|
+
let killChild = false;
|
|
238
|
+
try {
|
|
239
|
+
const help = spawnSync(bin, ['--help'], { encoding: 'utf8', timeout: probeTimeoutMs });
|
|
240
|
+
killChild = /--kill-child/.test(`${help.stdout || ''}${help.stderr || ''}`);
|
|
241
|
+
} catch { killChild = false; }
|
|
242
|
+
|
|
212
243
|
let chosen = null;
|
|
213
244
|
for (const variant of NS_PRIVILEGE_VARIANTS) {
|
|
214
|
-
const args = _nsArgs(variant, allowNetwork);
|
|
245
|
+
const args = _nsArgs(variant, allowNetwork, killChild);
|
|
215
246
|
const probe = spawnSync(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
|
|
216
247
|
encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
248
|
});
|
|
@@ -237,33 +268,62 @@ function _setupFailureReason(stderr) {
|
|
|
237
268
|
return null;
|
|
238
269
|
}
|
|
239
270
|
|
|
240
|
-
|
|
271
|
+
const _within = (parent, child) => child === parent || child.startsWith(parent.endsWith('/') ? parent : parent + '/');
|
|
272
|
+
|
|
273
|
+
function _maskPaths(denyReadPaths, resolvedRoot) {
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const p of denyReadPaths || []) {
|
|
276
|
+
if (typeof p !== 'string' || !p) continue;
|
|
277
|
+
let real = path.resolve(p);
|
|
278
|
+
try { real = fs.realpathSync(real); } catch { /* not present: nothing to mask, and nothing to read */ }
|
|
279
|
+
if (/[\0\n]/.test(real)) return { error: `a denied path cannot be carried into the mount setup (${JSON.stringify(p)})` };
|
|
280
|
+
if (_within(real, resolvedRoot)) return { error: `a denied path overlaps the sandbox root (${real})` };
|
|
281
|
+
if (!out.includes(real)) out.push(real);
|
|
282
|
+
}
|
|
283
|
+
return { paths: out };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Build the exact spawn invocation for a namespace-confined command, without
|
|
288
|
+
* running it. Shared by the synchronous runner and the supervised runner, so
|
|
289
|
+
* both are confined by the SAME setup. Returns `{ error }` in the documented
|
|
290
|
+
* error shape instead of throwing. On success the caller MUST call
|
|
291
|
+
* `inv.finish(rawStderr, {hadError})` exactly once after the process ends: it
|
|
292
|
+
* checks the host-side canary, extracts any setup failure, removes the
|
|
293
|
+
* temporary directories, and returns `{ error }` or `{ stderr, unsupported }`.
|
|
294
|
+
*
|
|
295
|
+
* @param {object} [deps] test seam: tool resolvers and the argument resolver.
|
|
296
|
+
* Cannot weaken the setup: it only changes WHERE the tools are found.
|
|
297
|
+
*/
|
|
298
|
+
export function buildNamespaceInvocation(argv, {
|
|
241
299
|
root,
|
|
242
|
-
timeoutMs = 10000,
|
|
243
300
|
allowNetwork = false,
|
|
244
301
|
limits = {},
|
|
245
302
|
env = {},
|
|
246
|
-
maxBuffer = 8 * 1024 * 1024,
|
|
247
303
|
denyReadPaths = [],
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
304
|
+
readRoots = null,
|
|
305
|
+
writeRoots = [],
|
|
306
|
+
networkProxyPort = null,
|
|
307
|
+
cwd = null,
|
|
308
|
+
} = {}, deps = {}) {
|
|
309
|
+
const d = {
|
|
310
|
+
nsBin: resolveNamespaceBin, mountBin: resolveMountBin, privDropBin: resolvePrivDropBin,
|
|
311
|
+
pivotBin: resolvePivotBin, umountBin: resolveUmountBin, nsArgs: resolveNamespaceArgs, ...deps,
|
|
312
|
+
};
|
|
313
|
+
if (!root) return { error: errorResult('namespace', 'runNamespace requires a sandbox root') };
|
|
314
|
+
if (networkProxyPort != null) {
|
|
315
|
+
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
316
|
}
|
|
256
|
-
if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
|
|
257
317
|
|
|
258
|
-
const bin =
|
|
259
|
-
if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
|
|
318
|
+
const bin = d.nsBin();
|
|
319
|
+
if (!bin) return { error: errorResult('namespace', 'no kernel-namespace binary found on this host') };
|
|
260
320
|
|
|
261
321
|
// Write confinement is built with this utility. No utility, no confinement,
|
|
262
322
|
// no run — there is deliberately no branch that proceeds without it.
|
|
263
|
-
const mountBin =
|
|
323
|
+
const mountBin = d.mountBin();
|
|
264
324
|
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');
|
|
325
|
+
return { error: errorResult('namespace',
|
|
326
|
+
'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined') };
|
|
267
327
|
}
|
|
268
328
|
|
|
269
329
|
let resolvedRoot;
|
|
@@ -272,7 +332,30 @@ export function runNamespace(argv, {
|
|
|
272
332
|
// hand to the child.
|
|
273
333
|
resolvedRoot = fs.realpathSync(root);
|
|
274
334
|
} catch (e) {
|
|
275
|
-
return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
|
|
335
|
+
return { error: errorResult('namespace', `sandbox root is not usable: ${e.message}`) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const strict = Array.isArray(readRoots);
|
|
339
|
+
const masked = _maskPaths(denyReadPaths, resolvedRoot);
|
|
340
|
+
if (masked.error) return { error: errorResult('namespace', masked.error) };
|
|
341
|
+
|
|
342
|
+
let plan = null;
|
|
343
|
+
let pivotBin = null; let umountBin = null;
|
|
344
|
+
if (strict) {
|
|
345
|
+
pivotBin = d.pivotBin();
|
|
346
|
+
umountBin = d.umountBin();
|
|
347
|
+
if (!pivotBin || !umountBin) {
|
|
348
|
+
return { error: errorResult('namespace', 'capability mode needs the root-switching and unmount tools, and this host lacks them; refusing to execute') };
|
|
349
|
+
}
|
|
350
|
+
plan = buildRootPlan({ root: resolvedRoot, readRoots, writeRoots, denyReadPaths: masked.paths, cwd });
|
|
351
|
+
if (!plan.ok) return { error: errorResult('namespace', `the filesystem plan is not acceptable: ${plan.error}`) };
|
|
352
|
+
} else if (cwd) {
|
|
353
|
+
// Default mode keeps its one working directory. Silently ignoring a
|
|
354
|
+
// requested directory would run the command somewhere the caller did not
|
|
355
|
+
// choose.
|
|
356
|
+
let wd = null;
|
|
357
|
+
try { wd = fs.realpathSync(cwd); } catch { /* handled below */ }
|
|
358
|
+
if (wd !== resolvedRoot) return { error: errorResult('namespace', 'a working directory other than the sandbox root needs capability mode (readRoots); refusing to execute') };
|
|
276
359
|
}
|
|
277
360
|
|
|
278
361
|
// Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
|
|
@@ -285,86 +368,117 @@ export function runNamespace(argv, {
|
|
|
285
368
|
try {
|
|
286
369
|
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
287
370
|
} catch (e) {
|
|
288
|
-
return errorResult('namespace', `invalid resource limit: ${e.message}`);
|
|
371
|
+
return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
|
|
289
372
|
}
|
|
290
373
|
|
|
291
374
|
// Fail closed: no usable variant means the confinement cannot be
|
|
292
375
|
// established, so nothing is executed. There is deliberately no path that
|
|
293
376
|
// drops confinement flags and runs anyway.
|
|
294
|
-
const nsArgs =
|
|
377
|
+
const nsArgs = d.nsArgs(bin, allowNetwork);
|
|
295
378
|
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');
|
|
379
|
+
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
380
|
}
|
|
298
381
|
|
|
299
382
|
// The canary lives OUTSIDE the sandbox root, in a directory this process
|
|
300
383
|
// just created and can write. If the confined shell can create it, the
|
|
301
384
|
// confinement is not in force and the command is not run.
|
|
302
|
-
|
|
385
|
+
const made = [];
|
|
386
|
+
const dispose = () => { for (const p of made.splice(0)) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
387
|
+
let canaryDir; let newRoot = null;
|
|
303
388
|
try {
|
|
304
389
|
canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-canary-'));
|
|
390
|
+
made.push(canaryDir);
|
|
391
|
+
if (strict) { newRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-newroot-')); made.push(newRoot); }
|
|
305
392
|
} catch (e) {
|
|
306
|
-
|
|
393
|
+
dispose();
|
|
394
|
+
return { error: errorResult('namespace', `could not create the confinement scratch directories: ${e.message}`) };
|
|
307
395
|
}
|
|
308
396
|
const canary = path.join(canaryDir, 'out-of-root.canary');
|
|
309
397
|
|
|
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 */ }
|
|
398
|
+
const sbxEnv = {
|
|
399
|
+
SBX_MOUNT: mountBin,
|
|
400
|
+
SBX_PRIVDROP: d.privDropBin() || '',
|
|
401
|
+
SBX_CANARY: canary,
|
|
402
|
+
SBX_FINAL: FINAL_SCRIPT,
|
|
403
|
+
};
|
|
404
|
+
let script;
|
|
405
|
+
if (strict) {
|
|
406
|
+
Object.assign(sbxEnv, {
|
|
407
|
+
SBX_STRICT: '1', SBX_NEWROOT: newRoot, SBX_PLAN: serializePlan(plan), SBX_PIVOT: pivotBin, SBX_UMOUNT: umountBin, SBX_CWD: plan.cwd,
|
|
408
|
+
});
|
|
409
|
+
script = PIVOT_SETUP_SCRIPT;
|
|
410
|
+
} else {
|
|
411
|
+
if (masked.paths.length) sbxEnv.SBX_MASKS = masked.paths.join('\n');
|
|
412
|
+
script = LEGACY_SETUP_SCRIPT;
|
|
352
413
|
}
|
|
353
414
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
415
|
+
return {
|
|
416
|
+
bin,
|
|
417
|
+
args: [...nsArgs, '/bin/sh', '-c', prelude + script, '_sbx', ...argv],
|
|
418
|
+
cwd: resolvedRoot,
|
|
419
|
+
env: { ...buildConfinedEnv({ root: resolvedRoot, env }), ...sbxEnv },
|
|
420
|
+
unsupported,
|
|
421
|
+
mode: strict ? 'capability' : 'default',
|
|
422
|
+
treeKill: namespaceTreeKill(nsArgs),
|
|
423
|
+
plan,
|
|
424
|
+
dispose,
|
|
425
|
+
finish(rawStderr, { hadError = false } = {}) {
|
|
426
|
+
try {
|
|
427
|
+
// Parent-side confirmation of the same fact the canary check asserts
|
|
428
|
+
// from the inside. Cheap, and it does not depend on the confined shell
|
|
429
|
+
// being honest about its own exit code.
|
|
430
|
+
if (fs.existsSync(canary)) {
|
|
431
|
+
return { error: errorResult('namespace',
|
|
432
|
+
'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host') };
|
|
433
|
+
}
|
|
434
|
+
} finally { dispose(); }
|
|
435
|
+
const raw = String(rawStderr || '');
|
|
436
|
+
const setupFailure = _setupFailureReason(raw);
|
|
437
|
+
if (setupFailure && !hadError) {
|
|
438
|
+
// Confinement could not be established (or could not be proven). Nothing
|
|
439
|
+
// ran: the shell exits before `exec`ing the caller's command.
|
|
440
|
+
return { error: errorResult('namespace', `confinement could not be established: ${setupFailure}`) };
|
|
441
|
+
}
|
|
442
|
+
const eff = [...unsupported];
|
|
443
|
+
if (raw.includes(MARK_NO_PRIVDROP)) eff.push('privilegeDrop');
|
|
444
|
+
return { stderr: _cleanStderr(raw), unsupported: eff };
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
361
448
|
|
|
362
|
-
|
|
363
|
-
|
|
449
|
+
export function runNamespace(argv, opts = {}) {
|
|
450
|
+
const { timeoutMs = 10000, maxBuffer = 8 * 1024 * 1024 } = opts;
|
|
451
|
+
// Documented shape, never a throw — see the same note in backend-userspace.
|
|
452
|
+
const inv = buildNamespaceInvocation(argv, opts);
|
|
453
|
+
if (inv.error) return inv.error;
|
|
454
|
+
|
|
455
|
+
let r;
|
|
456
|
+
try {
|
|
457
|
+
r = spawnSync(inv.bin, inv.args, {
|
|
458
|
+
encoding: 'utf8',
|
|
459
|
+
timeout: timeoutMs,
|
|
460
|
+
// SIGKILL, not the SIGTERM default: the direct child is pid 1 of a new
|
|
461
|
+
// PID namespace, and the kernel does not deliver default-action signals
|
|
462
|
+
// to a namespace's pid 1 from outside it. SIGKILL cannot be ignored.
|
|
463
|
+
// Found by CI: the first Linux run of the tree-kill test recorded
|
|
464
|
+
// duration_ms 30057 against a 1200 ms budget. With `--kill-child` the
|
|
465
|
+
// same SIGKILL, delivered to the unshare process, also reaps the whole
|
|
466
|
+
// namespace (see the header).
|
|
467
|
+
killSignal: 'SIGKILL',
|
|
468
|
+
maxBuffer,
|
|
469
|
+
cwd: inv.cwd,
|
|
470
|
+
env: inv.env,
|
|
471
|
+
});
|
|
472
|
+
} catch (e) {
|
|
473
|
+
inv.dispose();
|
|
474
|
+
return errorResult('namespace', `the confined process could not be started: ${e.message}`);
|
|
475
|
+
}
|
|
364
476
|
|
|
477
|
+
const fin = inv.finish(r.stderr ?? '', { hadError: !!r.error });
|
|
478
|
+
if (fin.error) return fin.error;
|
|
365
479
|
return buildResult({
|
|
366
480
|
backend: 'namespace',
|
|
367
|
-
spawnResult: { ...r, stderr:
|
|
368
|
-
unsupported:
|
|
481
|
+
spawnResult: { ...r, stderr: fin.stderr },
|
|
482
|
+
unsupported: fin.unsupported,
|
|
369
483
|
});
|
|
370
484
|
}
|
|
@@ -67,6 +67,16 @@ 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
|
+
|
|
70
80
|
// Back-compat single-path exports: the first (canonical) candidate.
|
|
71
81
|
export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
|
|
72
82
|
export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
|
|
@@ -96,6 +106,8 @@ export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_US
|
|
|
96
106
|
export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
|
|
97
107
|
export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
|
|
98
108
|
export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
|
|
109
|
+
export function resolvePivotBin() { return resolveConfineBin(CONFINE_BINS_PIVOT); }
|
|
110
|
+
export function resolveUmountBin() { return resolveConfineBin(CONFINE_BINS_UMOUNT); }
|
|
99
111
|
|
|
100
112
|
// Bounded on purpose: a capability check must never hang a scan. The probe is
|
|
101
113
|
// a single `exit 0` under confinement, so anything beyond a couple of seconds
|
|
@@ -53,7 +53,7 @@ function probeWrite() {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function probeReadDenial(backend) {
|
|
56
|
-
if (backend !== 'userspace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
|
|
56
|
+
if (backend !== 'userspace' && backend !== 'namespace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
|
|
57
57
|
const root = mk('agsec-probe-r-'); const secretDir = mk('agsec-probe-rs-');
|
|
58
58
|
try {
|
|
59
59
|
const secret = path.join(secretDir, 'sealed.txt');
|
|
@@ -103,7 +103,11 @@ async function probeNetwork() {
|
|
|
103
103
|
} finally { server.close(); rm(root); }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
async function probeTree() {
|
|
106
|
+
async function probeTree(backend) {
|
|
107
|
+
// Inside a PID namespace the pids a payload records are not host pids, so the
|
|
108
|
+
// pid-based check below would test unrelated host processes. The namespace
|
|
109
|
+
// backend gets a probe that never turns a recorded pid into a signal.
|
|
110
|
+
if (backend === 'namespace') return probeTreeHeartbeat();
|
|
107
111
|
const root = mk('agsec-probe-t-');
|
|
108
112
|
try {
|
|
109
113
|
// Three tree shapes: a plain background child, a grandchild, and one that
|
|
@@ -115,7 +119,7 @@ async function probeTree() {
|
|
|
115
119
|
'wait',
|
|
116
120
|
].join('\n');
|
|
117
121
|
const r = await runConfinedSupervised(['/bin/sh', '-c', script], { root, timeoutMs: 1500, graceMs: 300 });
|
|
118
|
-
if (
|
|
122
|
+
if (!r.supervised) {
|
|
119
123
|
return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
|
|
120
124
|
}
|
|
121
125
|
const pids = ['p1', 'p2', 'p3'].map((f) => { try { return Number(fs.readFileSync(path.join(root, f), 'utf8')); } catch { return NaN; } });
|
|
@@ -128,6 +132,52 @@ async function probeTree() {
|
|
|
128
132
|
} finally { rm(root); }
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/**
|
|
136
|
+
* A shell loop that appends a counter line to `$ROOT/hb<n>` every 200 ms and
|
|
137
|
+
* stops by itself after `max` lines, so a probe that finds a survivor cannot
|
|
138
|
+
* leave it running for ever.
|
|
139
|
+
*/
|
|
140
|
+
export function heartbeatShell(n, max = 300) {
|
|
141
|
+
return `i=0; while [ $i -lt ${max} ]; do i=$((i+1)); echo $i >> "$ROOT/hb${n}"; sleep 0.2; done`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tree termination by heartbeat, for a backend whose payload pids are not host
|
|
145
|
+
// pids. Four tree shapes each append a line to their own file every 200 ms: a
|
|
146
|
+
// plain background loop, a grandchild, one that ignores SIGTERM, and a
|
|
147
|
+
// double-forked member that left the session with setsid. After the supervised
|
|
148
|
+
// run returns, a process that survived keeps appending. The positive control is
|
|
149
|
+
// the same files: each must have advanced during the run, so a heartbeat that
|
|
150
|
+
// could never advance cannot pass for a tree that was terminated.
|
|
151
|
+
export async function probeTreeHeartbeat({ run = runConfinedSupervised, settleMs = 700 } = {}) {
|
|
152
|
+
const root = mk('agsec-probe-th-');
|
|
153
|
+
try {
|
|
154
|
+
const beat = (n) => heartbeatShell(n);
|
|
155
|
+
const script = [
|
|
156
|
+
`( ${beat(1)} ) &`,
|
|
157
|
+
`( ( ${beat(2)} ) & wait ) &`,
|
|
158
|
+
`( trap '' TERM; ${beat(3)} ) &`,
|
|
159
|
+
`( setsid /bin/sh -c '${beat(4)}' >/dev/null 2>&1 & ) &`,
|
|
160
|
+
'wait',
|
|
161
|
+
].join('\n');
|
|
162
|
+
const r = await run(['/bin/sh', '-c', script], { root, timeoutMs: 1800, graceMs: 300 });
|
|
163
|
+
if (!r.supervised) {
|
|
164
|
+
return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
|
|
165
|
+
}
|
|
166
|
+
const lines = (n) => { try { return fs.readFileSync(path.join(root, `hb${n}`), 'utf8').split('\n').filter(Boolean).length; } catch { return 0; } };
|
|
167
|
+
const names = [1, 2, 3, 4];
|
|
168
|
+
const atReturn = names.map(lines);
|
|
169
|
+
const stalled = names.filter((n, i) => atReturn[i] < 3);
|
|
170
|
+
if (stalled.length) return notProved(`positive control failed: heartbeat ${stalled.join(',')} did not advance while the run was live`);
|
|
171
|
+
await sleep(settleMs);
|
|
172
|
+
const mid = names.map(lines);
|
|
173
|
+
await sleep(settleMs);
|
|
174
|
+
const late = names.map(lines);
|
|
175
|
+
const alive = names.filter((n, i) => late[i] > mid[i]);
|
|
176
|
+
if (alive.length) return notProved(`members of the tree were still running after the run ended (heartbeat ${alive.join(',')})`);
|
|
177
|
+
return proved('four tree shapes (background, grandchild, SIGTERM-ignoring, setsid double fork) all stopped when the run ended');
|
|
178
|
+
} finally { rm(root); }
|
|
179
|
+
}
|
|
180
|
+
|
|
131
181
|
function probeFileSize() {
|
|
132
182
|
const root = mk('agsec-probe-f-');
|
|
133
183
|
try {
|
|
@@ -177,7 +227,7 @@ export async function probeControls({ force, probes = {} } = {}) {
|
|
|
177
227
|
await run('read-denial', () => probeReadDenial(backend));
|
|
178
228
|
await run('env-scrub', probeEnv);
|
|
179
229
|
await run('network', probeNetwork);
|
|
180
|
-
await run('tree-termination', probeTree);
|
|
230
|
+
await run('tree-termination', () => probeTree(backend));
|
|
181
231
|
await run('file-size-limit', probeFileSize);
|
|
182
232
|
controls['process-cap'] = probes['process-cap'] ? await probes['process-cap']() : UNPROBED_PROCESS_CAP;
|
|
183
233
|
}
|