@clear-capabilities/agentic-security-scanner 0.164.0 → 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 +10 -0
- package/dist/9560.index.js +5 -5
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +1 -1
- package/src/capabilities/CLAUDE.md +13 -10
- package/src/capabilities/attack-coverage.js +1 -1
- package/src/capabilities/manifest.js +2 -2
- package/src/capabilities/probes.js +3 -3
- package/src/capabilities/report.js +5 -4
- package/src/capabilities/runner.js +6 -1
- package/src/sandbox/CLAUDE.md +18 -7
- package/src/sandbox/backend-namespace.js +25 -10
- package/src/sandbox/capabilities.js +10 -0
- package/src/sandbox/control-probes.js +37 -3
- package/src/sandbox/limits.js +2 -0
- package/src/sandbox/linux-probes.js +2 -1
- package/src/sandbox/linux-rootfs.js +8 -1
- package/src/sandbox/supervise.js +8 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5a4520f3bff6f1e77c0bb2bfaa0346b11c1447eb3a8433bc1661d2f58c03537a agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.0",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -74,13 +74,18 @@ declared roots and multi-root writes on the namespace backend (a pivoted tmpfs
|
|
|
74
74
|
root; protected paths absent or masked; a PID namespace the kernel tears down).
|
|
75
75
|
NOT verified: **mediated network** is not implemented on Linux (an empty network
|
|
76
76
|
namespace has no path to a proxy), so a task that declares a destination is
|
|
77
|
-
`blocked` there with `network-mediation is unsupported`, never allowed
|
|
78
|
-
**process-count
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
77
|
+
`blocked` there with `network-mediation is unsupported`, never allowed. The
|
|
78
|
+
**process-count cap** IS proved on Linux (the `sandbox-linux` job's `process-cap`
|
|
79
|
+
probe): the resource prelude runs under dash, whose `ulimit` has no `-u`, so the
|
|
80
|
+
namespace backend applies the cap with `prlimit --nproc` to the task's command
|
|
81
|
+
after the confinement is built, and `maxProcesses` in a manifest is passed through
|
|
82
|
+
and reported `enforced` when the run is at the `enforced` level (on macOS it stays
|
|
83
|
+
`unverified`: the cap is per-user and system-wide there). Memory caps are
|
|
84
|
+
`not-enforced`. The capability, trust-boundary and corpus suites RUN on the Linux
|
|
85
|
+
backend and assert Linux semantics (an undeclared path has no name, so a refusal
|
|
86
|
+
is ENOENT or EROFS rather than `denied`; liveness is judged by heartbeat, not host
|
|
87
|
+
pid; a declared network destination blocks the task); `sandbox-linux` fails if any
|
|
88
|
+
of them skips.
|
|
84
89
|
|
|
85
90
|
## Known limits (also in `REPORT_LIMITATIONS`)
|
|
86
91
|
|
|
@@ -112,7 +117,5 @@ attempts were stopped on this backend in this run; it does not show the sandbox
|
|
|
112
117
|
### What is still not claimed
|
|
113
118
|
|
|
114
119
|
Tool and delegation checks are policy at the tool boundary (the tool then runs in the server process): the
|
|
115
|
-
runner remains the only enforcement layer. Linux is `partially-verified` in the platform statement (the controls listed there, on the hosted job only) and the attack-coverage record
|
|
116
|
-
`sandbox-linux` CI job: it would need execution cases that skip loudly (never silently pass) on Linux, and that
|
|
117
|
-
has not been shown from this workspace. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
|
|
120
|
+
runner remains the only enforcement layer. Linux is `partially-verified` in the platform statement (the controls listed there, on the hosted job only) and the attack-coverage record keeps `platforms.linux: 'unverified'` as a standing statement even though the corpus now runs on the hosted Linux runner inside the capabilities suite: turning that into a verified claim is a decision for the owners, not a side effect of the suite passing. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
|
|
118
121
|
function used by the suite; it is not yet called from `scripts/release-check.mjs`.
|
|
@@ -34,7 +34,7 @@ export const KNOWN_LIMITS = Object.freeze([
|
|
|
34
34
|
'HTTPS to a declared destination is an opaque tunnel: its payload is not inspected.',
|
|
35
35
|
'The mediation proxy accepts connections from any local process, and forwards only to the declared destinations.',
|
|
36
36
|
'Tool and delegation checks are policy at the tool boundary (in-process); only the runner enforces.',
|
|
37
|
-
'
|
|
37
|
+
'A process-count cap is claimed only on the Linux namespace backend, where an active probe proved it on the hosted job; it is not claimed on macOS.',
|
|
38
38
|
]);
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -44,8 +44,8 @@ const MANIFEST_REQUIRED = ['schema', 'schemaVersion', 'taskId', 'repository', 'p
|
|
|
44
44
|
|
|
45
45
|
// Resource limits a manifest can carry. The first three come straight from the
|
|
46
46
|
// assurance config (same ranges), the file-size cap is enforced by the sandbox
|
|
47
|
-
// prelude,
|
|
48
|
-
//
|
|
47
|
+
// prelude, the process-count cap is enforced on the Linux namespace backend only (prlimit, proved by an
|
|
48
|
+
// active probe), and the memory cap is CARRIED but never claimed enforced.
|
|
49
49
|
export const RESOURCE_RANGES = Object.freeze({
|
|
50
50
|
timeoutMs: { min: LIMITS.timeoutMs.min, max: LIMITS.timeoutMs.max },
|
|
51
51
|
maxOutputBytes: { min: LIMITS.maxOutputBytes.min, max: LIMITS.maxOutputBytes.max },
|
|
@@ -208,11 +208,11 @@ export function platformStatements() {
|
|
|
208
208
|
evidence: 'sandbox-linux CI job (hosted ubuntu-latest, x86_64); scripts/sandbox-linux-verify.mjs',
|
|
209
209
|
verifiedControls: Object.freeze([
|
|
210
210
|
'write-confinement', 'read-denial', 'env-scrub', 'network', 'tree-termination', 'file-size-limit',
|
|
211
|
-
'fs-read-confinement', 'fs-multi-root-write',
|
|
211
|
+
'fs-read-confinement', 'fs-multi-root-write', 'process-cap',
|
|
212
212
|
]),
|
|
213
213
|
unsupportedControls: Object.freeze(['network-mediation']),
|
|
214
|
-
unassertedControls: Object.freeze([
|
|
215
|
-
note: 'advertised for enforced mode. Proved by active probes on the hosted Linux runner (not on every Linux host): write-confinement, read-denial, env-scrub, network, tree-termination, file-size-limit, fs-read-confinement, fs-multi-root-write. Mediated network is NOT implemented on the namespace backend (an empty network namespace has no path to a proxy), so a task that declares a network destination is blocked on Linux, never allowed. Process-count caps are
|
|
214
|
+
unassertedControls: Object.freeze([]),
|
|
215
|
+
note: 'advertised for enforced mode. Proved by active probes on the hosted Linux runner (not on every Linux host): write-confinement, read-denial, env-scrub, network, tree-termination, file-size-limit, fs-read-confinement, fs-multi-root-write, process-cap (a per-user-namespace process-count cap applied with prlimit after the confinement is built; the shell ulimit it replaces does not exist in dash). Mediated network is NOT implemented on the namespace backend (an empty network namespace has no path to a proxy), so a task that declares a network destination is blocked on Linux, never allowed. Process-count caps are claimed on Linux only, and only as that probe shows them; memory caps are not enforced. The attack corpus is not run in the sandbox-linux job, so corpus coverage stays unverified on Linux.',
|
|
216
216
|
},
|
|
217
217
|
darwin: {
|
|
218
218
|
backend: 'userspace', status: 'host-proved-not-advertised',
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// A capability that only an in-process check covers (tools, delegation) says so
|
|
15
15
|
// and is never `enforced`; that check explains a decision, it does not stop the
|
|
16
|
-
// task.
|
|
17
|
-
// backend
|
|
16
|
+
// task. A process-count cap is `enforced` only on a run whose process-cap probe
|
|
17
|
+
// proved it (the Linux namespace backend); on the macOS backend it is carried and
|
|
18
|
+
// reported `unverified`.
|
|
18
19
|
import { platformStatements } from './probes.js';
|
|
19
20
|
|
|
20
21
|
const FS_READ = ['fs-read-confinement', 'read-denial'];
|
|
@@ -28,7 +29,7 @@ export const REPORT_LIMITATIONS = Object.freeze([
|
|
|
28
29
|
'HTTPS is an opaque tunnel to a declared destination: its payload is not inspected. Plaintext HTTP is filtered for secrets before it is forwarded.',
|
|
29
30
|
'The mediation proxy accepts connections from any local process. It only forwards to declared destinations, so the exposure is the declared set.',
|
|
30
31
|
'A process that double-forks and calls setsid between two supervisor sweeps can outlive the task; only a PID namespace or cgroup closes that gap.',
|
|
31
|
-
'
|
|
32
|
+
'A process-count cap is enforced only on the Linux namespace backend, where it is applied by prlimit after the confinement is built and was proved by an active probe; on macOS it is per-user and system-wide on the host, a soft brake that is carried and not claimed.',
|
|
32
33
|
'Address-space (memory) caps are not enforceable on macOS and are carried, not enforced.',
|
|
33
34
|
]);
|
|
34
35
|
|
|
@@ -73,7 +74,7 @@ export function buildCapabilityReport({ bound, probeReport, level, required }) {
|
|
|
73
74
|
timeoutMs: { requested: m.resources.timeoutMs ?? null, enforced: level === 'enforced', by: 'supervisor deadline with process-tree termination' },
|
|
74
75
|
maxOutputBytes: { requested: m.resources.maxOutputBytes ?? null, enforced: level === 'enforced', by: 'supervisor output cap with process-tree termination' },
|
|
75
76
|
maxFileSizeKb: { requested: m.resources.maxFileSizeKb ?? null, enforced: level === 'enforced' && fileLimitProved, state: c['file-size-limit']?.state ?? 'not-probed' },
|
|
76
|
-
maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced:
|
|
77
|
+
maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced: m.resources.maxProcesses != null && level === 'enforced' && c['process-cap']?.state === 'proved', state: c['process-cap']?.state === 'proved' ? 'proved' : 'unverified' },
|
|
77
78
|
maxMemoryMiB: { requested: m.resources.maxMemoryMiB ?? null, enforced: false, state: 'not-enforced' },
|
|
78
79
|
},
|
|
79
80
|
limitations: [...REPORT_LIMITATIONS],
|
|
@@ -190,7 +190,12 @@ export async function runCapabilityTask(bound, request, opts = {}) {
|
|
|
190
190
|
denyReadPaths: protectedPaths,
|
|
191
191
|
env: taskEnv, networkProxyPort: proxy ? proxy.port : null,
|
|
192
192
|
timeoutMs, graceMs: 1000, maxOutputBytes, signal, force,
|
|
193
|
-
limits:
|
|
193
|
+
limits: {
|
|
194
|
+
...(m.resources.maxFileSizeKb ? { maxFileSizeKb: m.resources.maxFileSizeKb } : {}),
|
|
195
|
+
// Only the namespace backend applies it (prlimit, after the confinement is built). The macOS backend keeps its own ambient-relative
|
|
196
|
+
// default: a requested cap there would be per-user and system-wide, which is not what the manifest asked for.
|
|
197
|
+
...(m.resources.maxProcesses && backend === 'namespace' ? { maxProcs: m.resources.maxProcesses } : {}),
|
|
198
|
+
},
|
|
194
199
|
});
|
|
195
200
|
const executed = r.supervised === true;
|
|
196
201
|
if (!executed) {
|
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -383,13 +383,24 @@ then runs supervised with protected paths read-denied.
|
|
|
383
383
|
where `--kill-child` is available (see above).
|
|
384
384
|
- **Active probes**: write confinement, read denial, env scrub, network, tree
|
|
385
385
|
termination and the file-size limit each run an attack plus a positive
|
|
386
|
-
control through the real backend. `process-cap` is
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
resource prelude is run by `/bin/sh`, which is dash on the hosted runner,
|
|
390
|
-
dash's `ulimit` has no `-u` option (`ulimit: Illegal option -u` appears in
|
|
391
|
-
the `sandbox-linux` log), so the cap was never applied at all.
|
|
392
|
-
|
|
386
|
+
control through the real backend. `process-cap` is `unverified` on the macOS
|
|
387
|
+
backend and must not be claimed enforced there: the cap is per-uid and
|
|
388
|
+
system-wide, a soft brake. On Linux the earlier non-refusal had a known cause:
|
|
389
|
+
the resource prelude is run by `/bin/sh`, which is dash on the hosted runner,
|
|
390
|
+
and dash's `ulimit` has no `-u` option (`ulimit: Illegal option -u` appears in
|
|
391
|
+
the `sandbox-linux` log), so the cap was never applied at all. The namespace
|
|
392
|
+
backend now applies it with `prlimit --nproc=N --` (util-linux), in the final
|
|
393
|
+
stage and to the caller's command alone (`FINAL_SCRIPT`; a cap applied earlier
|
|
394
|
+
would starve the confinement setup of processes). Inside the user namespace the
|
|
395
|
+
limit binds: the kernel waives RLIMIT_NPROC only for a process holding
|
|
396
|
+
CAP_SYS_RESOURCE or CAP_SYS_ADMIN in the INITIAL namespace, and a namespace root
|
|
397
|
+
has neither (its capability set is dropped as well). With no requested cap none
|
|
398
|
+
is applied (the old ambient-relative default never took effect on Linux), and a
|
|
399
|
+
host without `prlimit` declares `maxProcs` in `unsupported` rather than dropping
|
|
400
|
+
it. `probeProcessCap` (`control-probes.js`) is the proof: six concurrent
|
|
401
|
+
children all start under a generous cap and the kernel refuses some under a cap
|
|
402
|
+
of 3, in default and in capability mode; `linux-enforcement.test.js` runs it
|
|
403
|
+
against runners that ignore the cap and that cap everything.
|
|
393
404
|
`capabilityReport()` carries the standing note that Linux enforcement is
|
|
394
405
|
evidenced only where the `sandbox-linux` CI job ran the same probes.
|
|
395
406
|
- **Status**: target output is returned labelled `untrusted` and is never parsed
|
|
@@ -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
|
|
@@ -127,10 +128,10 @@ import fs from 'node:fs';
|
|
|
127
128
|
import os from 'node:os';
|
|
128
129
|
import path from 'node:path';
|
|
129
130
|
import {
|
|
130
|
-
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin,
|
|
131
|
+
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin, resolvePrlimitBin,
|
|
131
132
|
cachedNamespaceVariant, cacheNamespaceVariant,
|
|
132
133
|
} from './capabilities.js';
|
|
133
|
-
import { buildLimitPrelude,
|
|
134
|
+
import { buildLimitPrelude, validateMaxProcs } from './limits.js';
|
|
134
135
|
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
135
136
|
import {
|
|
136
137
|
buildRootPlan, serializePlan, PIVOT_SETUP_SCRIPT, FINAL_SCRIPT, MARK_SETUP_FAILED, MARK_NO_PRIVDROP,
|
|
@@ -308,7 +309,7 @@ export function buildNamespaceInvocation(argv, {
|
|
|
308
309
|
} = {}, deps = {}) {
|
|
309
310
|
const d = {
|
|
310
311
|
nsBin: resolveNamespaceBin, mountBin: resolveMountBin, privDropBin: resolvePrivDropBin,
|
|
311
|
-
pivotBin: resolvePivotBin, umountBin: resolveUmountBin, nsArgs: resolveNamespaceArgs, ...deps,
|
|
312
|
+
pivotBin: resolvePivotBin, umountBin: resolveUmountBin, prlimitBin: resolvePrlimitBin, nsArgs: resolveNamespaceArgs, ...deps,
|
|
312
313
|
};
|
|
313
314
|
if (!root) return { error: errorResult('namespace', 'runNamespace requires a sandbox root') };
|
|
314
315
|
if (networkProxyPort != null) {
|
|
@@ -358,18 +359,31 @@ export function buildNamespaceInvocation(argv, {
|
|
|
358
359
|
if (wd !== resolvedRoot) return { error: errorResult('namespace', 'a working directory other than the sandbox root needs capability mode (readRoots); refusing to execute') };
|
|
359
360
|
}
|
|
360
361
|
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
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
|
+
}
|
|
366
379
|
|
|
367
380
|
let prelude, unsupported;
|
|
368
381
|
try {
|
|
369
|
-
({ prelude, unsupported } = buildLimitPrelude(
|
|
382
|
+
({ prelude, unsupported } = buildLimitPrelude({ ...limits, maxProcs: null }));
|
|
370
383
|
} catch (e) {
|
|
371
384
|
return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
|
|
372
385
|
}
|
|
386
|
+
if (nproc != null && !prlimitBin) unsupported = [...unsupported, 'maxProcs'];
|
|
373
387
|
|
|
374
388
|
// Fail closed: no usable variant means the confinement cannot be
|
|
375
389
|
// established, so nothing is executed. There is deliberately no path that
|
|
@@ -401,6 +415,7 @@ export function buildNamespaceInvocation(argv, {
|
|
|
401
415
|
SBX_CANARY: canary,
|
|
402
416
|
SBX_FINAL: FINAL_SCRIPT,
|
|
403
417
|
};
|
|
418
|
+
if (nproc != null && prlimitBin) { sbxEnv.SBX_NPROC = String(nproc); sbxEnv.SBX_PRLIMIT = prlimitBin; }
|
|
404
419
|
let script;
|
|
405
420
|
if (strict) {
|
|
406
421
|
Object.assign(sbxEnv, {
|
|
@@ -77,6 +77,15 @@ export const CONFINE_BINS_UMOUNT = Object.freeze([
|
|
|
77
77
|
'/usr/bin/umount', '/bin/umount', '/sbin/umount', '/usr/sbin/umount',
|
|
78
78
|
]);
|
|
79
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
|
+
|
|
80
89
|
// Back-compat single-path exports: the first (canonical) candidate.
|
|
81
90
|
export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
|
|
82
91
|
export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
|
|
@@ -107,6 +116,7 @@ export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NA
|
|
|
107
116
|
export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
|
|
108
117
|
export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
|
|
109
118
|
export function resolvePivotBin() { return resolveConfineBin(CONFINE_BINS_PIVOT); }
|
|
119
|
+
export function resolvePrlimitBin() { return resolveConfineBin(CONFINE_BINS_PRLIMIT); }
|
|
110
120
|
export function resolveUmountBin() { return resolveConfineBin(CONFINE_BINS_UMOUNT); }
|
|
111
121
|
|
|
112
122
|
// Bounded on purpose: a capability check must never hang a scan. The probe is
|
|
@@ -195,9 +195,41 @@ function probeFileSize() {
|
|
|
195
195
|
|
|
196
196
|
const UNPROBED_PROCESS_CAP = {
|
|
197
197
|
state: 'unverified',
|
|
198
|
-
reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall)
|
|
198
|
+
reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall); no enforcement is claimed on that backend. On the Linux namespace backend the cap is probed (see probeProcessCap)',
|
|
199
199
|
};
|
|
200
200
|
|
|
201
|
+
const PROC_CAP_FORKS = 6;
|
|
202
|
+
/** Forks `PROC_CAP_FORKS` concurrent children; each child announces itself, so a refusal shows as missing announcements. */
|
|
203
|
+
export const PROC_CAP_SCRIPT = `i=0; while [ $i -lt ${PROC_CAP_FORKS} ]; do i=$((i+1)); ( echo CHILD_$i; sleep 1 ) & done; wait; echo DONE`;
|
|
204
|
+
|
|
205
|
+
const countChildren = (r) => (String(r.stdout || '').match(/CHILD_\d/g) || []).length;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The process-count cap on the namespace backend. A command that forks six
|
|
209
|
+
* concurrent children is run twice through the real backend: under a generous cap
|
|
210
|
+
* every child must start (the positive control: the probe can succeed, and the cap
|
|
211
|
+
* mechanism itself does not break ordinary forking), and under a cap far below
|
|
212
|
+
* what it needs the kernel must refuse some of them. RLIMIT_NPROC is not enforced
|
|
213
|
+
* for a process holding CAP_SYS_RESOURCE in the initial namespace, so whether it
|
|
214
|
+
* binds inside the user namespace is exactly what this measures rather than assumes.
|
|
215
|
+
*
|
|
216
|
+
* `run` is a seam so a test can hand it a runner that ignores the cap (or one that
|
|
217
|
+
* refuses everything) and see the probe say so.
|
|
218
|
+
*/
|
|
219
|
+
export function probeProcessCap({ run = runConfined, mode = {} } = {}) {
|
|
220
|
+
const root = mk('agsec-probe-p-');
|
|
221
|
+
try {
|
|
222
|
+
const cmd = ['/bin/sh', '-c', PROC_CAP_SCRIPT];
|
|
223
|
+
const generous = run(cmd, { root, ...mode, timeoutMs: 10000, limits: { maxProcs: 512 } });
|
|
224
|
+
const gOk = countChildren(generous);
|
|
225
|
+
if (gOk !== PROC_CAP_FORKS) return notProved(`positive control failed: only ${gOk} of ${PROC_CAP_FORKS} children started under a generous cap (${generous.status})`);
|
|
226
|
+
const tight = run(cmd, { root, ...mode, timeoutMs: 10000, limits: { maxProcs: 3 } });
|
|
227
|
+
const tOk = countChildren(tight);
|
|
228
|
+
if (tOk >= PROC_CAP_FORKS) return notProved(`the process-count cap did not refuse: all ${tOk} children started under a cap of 3`);
|
|
229
|
+
return proved(`${gOk} of ${PROC_CAP_FORKS} children started under a cap of 512; only ${tOk} under a cap of 3`);
|
|
230
|
+
} finally { rm(root); }
|
|
231
|
+
}
|
|
232
|
+
|
|
201
233
|
const _cache = new Map();
|
|
202
234
|
export function resetProbeCache() { _cache.clear(); }
|
|
203
235
|
|
|
@@ -229,7 +261,9 @@ export async function probeControls({ force, probes = {} } = {}) {
|
|
|
229
261
|
await run('network', probeNetwork);
|
|
230
262
|
await run('tree-termination', () => probeTree(backend));
|
|
231
263
|
await run('file-size-limit', probeFileSize);
|
|
232
|
-
|
|
264
|
+
if (probes['process-cap']) controls['process-cap'] = await probes['process-cap']();
|
|
265
|
+
else if (backend === 'namespace') await run('process-cap', () => probeProcessCap());
|
|
266
|
+
else controls['process-cap'] = UNPROBED_PROCESS_CAP;
|
|
233
267
|
}
|
|
234
268
|
const report = { platform: process.platform, backend, controls };
|
|
235
269
|
if (!Object.keys(probes).length) _cache.set(backend, report);
|
|
@@ -259,7 +293,7 @@ export function capabilityReport(report) {
|
|
|
259
293
|
notes: [
|
|
260
294
|
'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
|
|
261
295
|
'Linux enforcement cannot be exercised on a macOS host. It is evidenced only where the sandbox-linux CI job ran this same probe set.',
|
|
262
|
-
'
|
|
296
|
+
'A process-count cap is claimed only where its probe reports proved (the Linux namespace backend, on the hosted job); it is never claimed on the macOS backend.',
|
|
263
297
|
],
|
|
264
298
|
};
|
|
265
299
|
}
|
package/src/sandbox/limits.js
CHANGED
|
@@ -54,6 +54,8 @@ export function buildLimitPrelude({
|
|
|
54
54
|
* e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
|
|
55
55
|
* anything that is not a finite, non-negative number.
|
|
56
56
|
*/
|
|
57
|
+
export function validateMaxProcs(v) { return _num('maxProcs', v); }
|
|
58
|
+
|
|
57
59
|
function _num(name, v) {
|
|
58
60
|
const n = Number(v);
|
|
59
61
|
if (!Number.isFinite(n) || n < 0) {
|
|
@@ -27,7 +27,7 @@ import os from 'node:os';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { runConfined } from './index.js';
|
|
29
29
|
import { buildNamespaceInvocation } from './backend-namespace.js';
|
|
30
|
-
import { heartbeatShell } from './control-probes.js';
|
|
30
|
+
import { heartbeatShell, probeProcessCap } from './control-probes.js';
|
|
31
31
|
|
|
32
32
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
33
|
const NODE = process.execPath;
|
|
@@ -174,5 +174,6 @@ export async function runLinuxProbes() {
|
|
|
174
174
|
await step('protected-canary-masked:default', () => probeProtectedCanaryMasked({ run: (a, o) => runConfined(a, { ...o, readRoots: undefined }) }));
|
|
175
175
|
await step('protected-canary-masked:capability', () => probeProtectedCanaryMasked());
|
|
176
176
|
await step('capability-network', () => probeCapabilityNetwork());
|
|
177
|
+
await step('process-cap:capability', () => probeProcessCap({ mode: { readRoots: [] } }));
|
|
177
178
|
return out;
|
|
178
179
|
}
|
|
@@ -251,6 +251,13 @@ if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
|
|
|
251
251
|
fi
|
|
252
252
|
rm -f "$ROOT/.agsec-sbx-wcheck"
|
|
253
253
|
_cwd="\${SBX_CWD:-$ROOT}"
|
|
254
|
-
|
|
254
|
+
_nproc="$SBX_NPROC"; _prlimit="$SBX_PRLIMIT"
|
|
255
|
+
unset SBX_PLAN SBX_FINAL SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP SBX_NEWROOT SBX_MASKS SBX_CANARY SBX_STRICT SBX_CWD SBX_NPROC SBX_PRLIMIT
|
|
256
|
+
# The process-count cap is applied last, to the caller's command only: the checks
|
|
257
|
+
# above fork, and so does the setup before them. A prlimit that cannot run stops
|
|
258
|
+
# the command (the exec fails), never lets it run uncapped.
|
|
259
|
+
if [ -n "$_nproc" ]; then
|
|
260
|
+
cd "$_cwd" && exec "$_prlimit" --nproc="$_nproc" -- "$@"
|
|
261
|
+
fi
|
|
255
262
|
cd "$_cwd" && exec "$@"
|
|
256
263
|
`;
|
package/src/sandbox/supervise.js
CHANGED
|
@@ -87,7 +87,10 @@ export function superviseSpawn(bin, args, {
|
|
|
87
87
|
let timer = null;
|
|
88
88
|
|
|
89
89
|
const sweep = () => { if (pgid) for (const p of snapshotTree(pgid)) tracked.add(p); };
|
|
90
|
-
|
|
90
|
+
// sweepMs <= 0 turns the periodic sweep off. Each sweep is a SYNCHRONOUS `ps`, which blocks the controller's event loop for as long as
|
|
91
|
+
// it runs; on the namespace backend the kernel ends every member with the supervisor (--kill-child), so the periodic sweep adds nothing
|
|
92
|
+
// there and only costs responsiveness. The cleanup below still takes its own snapshots.
|
|
93
|
+
const sweeper = sweepMs > 0 ? setInterval(sweep, sweepMs) : null;
|
|
91
94
|
|
|
92
95
|
const survivors = () => {
|
|
93
96
|
sweep();
|
|
@@ -126,7 +129,8 @@ export function superviseSpawn(bin, args, {
|
|
|
126
129
|
function finish() {
|
|
127
130
|
if (finishing) return finishing;
|
|
128
131
|
finishing = (async () => {
|
|
129
|
-
|
|
132
|
+
if (sweeper) clearInterval(sweeper);
|
|
133
|
+
clearTimeout(timer);
|
|
130
134
|
const termination = await terminate();
|
|
131
135
|
try { child.stdout?.destroy(); child.stderr?.destroy(); } catch { /* ignore */ }
|
|
132
136
|
resolve({ spawnError, exitCode, exitSignal, stdout, stderr, timedOut, cancelled, outputCapped, termination });
|
|
@@ -180,7 +184,7 @@ export async function runConfinedSupervised(argv, opts = {}) {
|
|
|
180
184
|
if (backend !== 'userspace' && backend !== 'namespace') {
|
|
181
185
|
return errorResult(backend, `process-tree termination is not implemented or verified on the ${backend} backend; refusing to execute`);
|
|
182
186
|
}
|
|
183
|
-
const inv = backend === 'userspace' ? buildUserspaceInvocation(argv, opts) : buildNamespaceInvocation(argv, opts);
|
|
187
|
+
const inv = backend === 'userspace' ? buildUserspaceInvocation(argv, opts) : buildNamespaceInvocation(argv, opts, opts.deps);
|
|
184
188
|
if (inv.error) return inv.error;
|
|
185
189
|
if (backend === 'namespace' && !inv.treeKill) {
|
|
186
190
|
inv.dispose();
|
|
@@ -188,7 +192,7 @@ export async function runConfinedSupervised(argv, opts = {}) {
|
|
|
188
192
|
}
|
|
189
193
|
const r = await superviseSpawn(inv.bin, inv.args, {
|
|
190
194
|
cwd: inv.cwd, env: inv.env, timeoutMs: opts.timeoutMs ?? 10000, graceMs: opts.graceMs ?? 1000,
|
|
191
|
-
signal: opts.signal, maxOutputBytes: opts.maxOutputBytes,
|
|
195
|
+
signal: opts.signal, maxOutputBytes: opts.maxOutputBytes, ...(backend === 'namespace' ? { sweepMs: 0 } : {}),
|
|
192
196
|
});
|
|
193
197
|
let rawStderr = r.stderr;
|
|
194
198
|
let unsupported = inv.unsupported;
|