@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
|
@@ -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 {
|
|
@@ -145,9 +195,41 @@ function probeFileSize() {
|
|
|
145
195
|
|
|
146
196
|
const UNPROBED_PROCESS_CAP = {
|
|
147
197
|
state: 'unverified',
|
|
148
|
-
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)',
|
|
149
199
|
};
|
|
150
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
|
+
|
|
151
233
|
const _cache = new Map();
|
|
152
234
|
export function resetProbeCache() { _cache.clear(); }
|
|
153
235
|
|
|
@@ -177,9 +259,11 @@ export async function probeControls({ force, probes = {} } = {}) {
|
|
|
177
259
|
await run('read-denial', () => probeReadDenial(backend));
|
|
178
260
|
await run('env-scrub', probeEnv);
|
|
179
261
|
await run('network', probeNetwork);
|
|
180
|
-
await run('tree-termination', probeTree);
|
|
262
|
+
await run('tree-termination', () => probeTree(backend));
|
|
181
263
|
await run('file-size-limit', probeFileSize);
|
|
182
|
-
|
|
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;
|
|
183
267
|
}
|
|
184
268
|
const report = { platform: process.platform, backend, controls };
|
|
185
269
|
if (!Object.keys(probes).length) _cache.set(backend, report);
|
|
@@ -209,7 +293,7 @@ export function capabilityReport(report) {
|
|
|
209
293
|
notes: [
|
|
210
294
|
'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
|
|
211
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.',
|
|
212
|
-
'
|
|
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.',
|
|
213
297
|
],
|
|
214
298
|
};
|
|
215
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) {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Active probes specific to the Linux namespace backend (X-502, X-503, CORE-003).
|
|
2
|
+
//
|
|
3
|
+
// These are the controls that are only true because of what the kernel does
|
|
4
|
+
// with namespaces, each established the way `control-probes.js` establishes the
|
|
5
|
+
// others: an ATTACK that must fail, paired with a POSITIVE CONTROL showing the
|
|
6
|
+
// same probe succeeds when the control is off, so a probe that can never
|
|
7
|
+
// succeed cannot pass for a working control.
|
|
8
|
+
//
|
|
9
|
+
// pid-namespace the payload is pid 1 and cannot see host pids
|
|
10
|
+
// init-kill-reaps-detached killing the namespace's supervisor (or its init)
|
|
11
|
+
// ends a double-forked, setsid member that no
|
|
12
|
+
// process-group or sweep logic could find
|
|
13
|
+
// protected-canary-masked a protected file and directory under a DECLARED
|
|
14
|
+
// read root are unreadable (masked), and readable
|
|
15
|
+
// when not protected
|
|
16
|
+
// capability-network in capability mode a loopback listener is
|
|
17
|
+
// reachable only when network is allowed
|
|
18
|
+
//
|
|
19
|
+
// Each takes a `run` seam so a test can hand it a runner that is deliberately
|
|
20
|
+
// wrong and see the probe say so. Nothing here asserts a result in advance: a
|
|
21
|
+
// probe that cannot establish its own positive control reports `not-proved`.
|
|
22
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { runConfined } from './index.js';
|
|
29
|
+
import { buildNamespaceInvocation } from './backend-namespace.js';
|
|
30
|
+
import { heartbeatShell, probeProcessCap } from './control-probes.js';
|
|
31
|
+
|
|
32
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
const NODE = process.execPath;
|
|
34
|
+
const proved = (evidence) => ({ state: 'proved', evidence });
|
|
35
|
+
const notProved = (reason) => ({ state: 'not-proved', reason });
|
|
36
|
+
|
|
37
|
+
function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); }
|
|
38
|
+
function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
39
|
+
const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
|
|
40
|
+
|
|
41
|
+
/** The payload is pid 1 of its own PID namespace and sees no host process. */
|
|
42
|
+
export async function probePidNamespace({ run = runConfined } = {}) {
|
|
43
|
+
const root = mk('agsec-lp-pid-');
|
|
44
|
+
try {
|
|
45
|
+
// A host process that certainly exists and is not in the payload's tree.
|
|
46
|
+
const r = run(['/bin/sh', '-c', 'echo PID=$$; ls /proc'], { root, timeoutMs: 8000 });
|
|
47
|
+
const out = String(r.stdout || '');
|
|
48
|
+
const m = /PID=(\d+)/.exec(out);
|
|
49
|
+
if (!m) return notProved(`positive control failed: the probe payload did not report (${r.status})`);
|
|
50
|
+
if (m[1] !== '1') return notProved(`the payload is pid ${m[1]}, not pid 1 of a new PID namespace`);
|
|
51
|
+
const visible = out.split('\n').map((l) => l.trim()).filter((l) => /^\d+$/.test(l));
|
|
52
|
+
if (!visible.length) return notProved('positive control failed: /proc listed no processes, so host visibility could not be judged');
|
|
53
|
+
if (visible.includes(String(process.pid)) || visible.includes(String(process.ppid))) {
|
|
54
|
+
return notProved('the payload can see host processes in /proc');
|
|
55
|
+
}
|
|
56
|
+
return proved(`the payload is pid 1; /proc lists ${visible.length} process(es) and none of the host's`);
|
|
57
|
+
} finally { rm(root); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function heartbeatLines(root, n) {
|
|
61
|
+
try { return fs.readFileSync(path.join(root, `hb${n}`), 'utf8').split('\n').filter(Boolean).length; } catch { return 0; } // agentic-security-ignore: CWE-400
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function childrenOf(pid) {
|
|
65
|
+
const r = spawnSync('ps', ['-o', 'pid=', '--ppid', String(pid)], { encoding: 'utf8', timeout: 3000 });
|
|
66
|
+
return String(r.stdout || '').split('\n').map((x) => Number(x.trim())).filter(Number.isFinite).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start a namespace-confined tree that includes a double-forked, setsid member,
|
|
71
|
+
* then kill ONE process from the host: the supervisor (`unshare`) or the
|
|
72
|
+
* namespace's init (its child). Either way the kernel must take every member
|
|
73
|
+
* of the namespace with it.
|
|
74
|
+
*
|
|
75
|
+
* @param {'supervisor'|'init'} victim
|
|
76
|
+
*/
|
|
77
|
+
export async function probeInitKillReapsDetached({ victim = 'supervisor', settleMs = 700, deps } = {}) {
|
|
78
|
+
const root = mk('agsec-lp-init-');
|
|
79
|
+
let inv = null; let child = null;
|
|
80
|
+
try {
|
|
81
|
+
const script = [
|
|
82
|
+
`( ${heartbeatShell(1)} ) &`,
|
|
83
|
+
`( setsid /bin/sh -c '${heartbeatShell(2)}' >/dev/null 2>&1 & ) &`,
|
|
84
|
+
`( trap '' TERM; ${heartbeatShell(3)} ) &`,
|
|
85
|
+
'wait',
|
|
86
|
+
].join('\n');
|
|
87
|
+
inv = buildNamespaceInvocation(['/bin/sh', '-c', script], { root }, deps);
|
|
88
|
+
if (inv.error) return { state: 'unsupported', reason: String(inv.error.stderr || '').split('\n')[0] };
|
|
89
|
+
if (!inv.treeKill) return { state: 'unsupported', reason: 'this host cannot make the kernel kill the namespace with its supervisor' };
|
|
90
|
+
child = spawn(inv.bin, inv.args, { cwd: inv.cwd, env: inv.env, stdio: 'ignore' });
|
|
91
|
+
const lines = (n) => heartbeatLines(root, n);
|
|
92
|
+
const names = [1, 2, 3];
|
|
93
|
+
const deadline = Date.now() + 8000;
|
|
94
|
+
while (Date.now() < deadline && !names.every((n) => lines(n) >= 3)) await sleep(100);
|
|
95
|
+
if (!names.every((n) => lines(n) >= 3)) return notProved('positive control failed: the tree did not start and heartbeat before the kill');
|
|
96
|
+
|
|
97
|
+
let targets = [child.pid];
|
|
98
|
+
if (victim === 'init') {
|
|
99
|
+
// The namespace's init is the supervisor's direct child (unshare forks
|
|
100
|
+
// it). Kill every direct child, never the supervisor itself.
|
|
101
|
+
targets = childrenOf(child.pid);
|
|
102
|
+
if (!targets.length) return notProved('positive control failed: no namespace init found under the supervisor');
|
|
103
|
+
}
|
|
104
|
+
for (const t of targets) { try { process.kill(t, 'SIGKILL'); } catch { /* already gone */ } }
|
|
105
|
+
await sleep(settleMs);
|
|
106
|
+
const mid = names.map(lines);
|
|
107
|
+
await sleep(settleMs);
|
|
108
|
+
const late = names.map(lines);
|
|
109
|
+
const alive = names.filter((n, i) => late[i] > mid[i]);
|
|
110
|
+
if (alive.length) return notProved(`members of the namespace outlived the kill of its ${victim} (heartbeat ${alive.join(',')})`);
|
|
111
|
+
return proved(`SIGKILL of the namespace ${victim} ended a plain member, a double-forked setsid member and a SIGTERM-ignoring member`);
|
|
112
|
+
} finally {
|
|
113
|
+
try { if (child) child.kill('SIGKILL'); } catch { /* gone */ }
|
|
114
|
+
if (inv && !inv.error) { try { inv.finish('', { hadError: true }); } catch { /* best effort */ } }
|
|
115
|
+
rm(root);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A protected file and directory under a declared read root are unreadable, and readable when unprotected. */
|
|
120
|
+
export async function probeProtectedCanaryMasked({ run = runConfined } = {}) {
|
|
121
|
+
const root = mk('agsec-lp-prot-'); const base = mk('agsec-lp-pbase-');
|
|
122
|
+
try {
|
|
123
|
+
const dirTok = token('SEALEDDIR'); const fileTok = token('SEALEDFILE'); const pubTok = token('PUBLIC');
|
|
124
|
+
fs.mkdirSync(path.join(base, 'sealed'));
|
|
125
|
+
fs.writeFileSync(path.join(base, 'sealed', 'k.txt'), dirTok);
|
|
126
|
+
fs.writeFileSync(path.join(base, 'sealed-file.txt'), fileTok);
|
|
127
|
+
fs.writeFileSync(path.join(base, 'public.txt'), pubTok);
|
|
128
|
+
const cmd = ['/bin/sh', '-c', `cat '${base}/public.txt' '${base}/sealed/k.txt' '${base}/sealed-file.txt' 2>&1; true`];
|
|
129
|
+
const open = run(cmd, { root, readRoots: [base], timeoutMs: 8000 });
|
|
130
|
+
const o = String(open.stdout || '');
|
|
131
|
+
if (!(o.includes(pubTok) && o.includes(dirTok) && o.includes(fileTok))) {
|
|
132
|
+
return notProved(`positive control failed: the files were not readable without protection (${open.status})`);
|
|
133
|
+
}
|
|
134
|
+
const closed = run(cmd, { root, readRoots: [base], denyReadPaths: [path.join(base, 'sealed'), path.join(base, 'sealed-file.txt')], timeoutMs: 8000 });
|
|
135
|
+
const c = String(closed.stdout || '');
|
|
136
|
+
if (!c.includes(pubTok)) return notProved(`positive control failed: the unprotected file stopped being readable (${closed.status})`);
|
|
137
|
+
if (c.includes(dirTok)) return notProved('a protected directory under a declared root was readable');
|
|
138
|
+
if (c.includes(fileTok)) return notProved('a protected file under a declared root was readable');
|
|
139
|
+
return proved('a protected directory and file under a declared read root were unreadable; the unprotected file and both canaries were readable without protection');
|
|
140
|
+
} finally { rm(root); rm(base); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** In capability mode a loopback listener is reachable with network allowed and unreachable otherwise. */
|
|
144
|
+
export async function probeCapabilityNetwork({ run = runConfined } = {}) {
|
|
145
|
+
const root = mk('agsec-lp-net-');
|
|
146
|
+
let hits = 0;
|
|
147
|
+
const server = net.createServer((s) => { hits += 1; s.destroy(); });
|
|
148
|
+
try {
|
|
149
|
+
await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', res); });
|
|
150
|
+
const port = server.address().port;
|
|
151
|
+
const script = `require('net').connect(${port},'127.0.0.1').on('error',()=>process.exit(0)).on('connect',()=>process.exit(0));setTimeout(()=>process.exit(0),1500)`;
|
|
152
|
+
const exeReal = fs.realpathSync(NODE);
|
|
153
|
+
run([NODE, '-e', script], { root, readRoots: [exeReal], allowNetwork: true, timeoutMs: 8000 });
|
|
154
|
+
await sleep(200);
|
|
155
|
+
const open = hits;
|
|
156
|
+
if (open < 1) return notProved('positive control failed: the loopback listener saw no connection with network allowed');
|
|
157
|
+
const denied = run([NODE, '-e', script], { root, readRoots: [exeReal], allowNetwork: false, timeoutMs: 8000 });
|
|
158
|
+
await sleep(200);
|
|
159
|
+
if (hits > open) return notProved('a connection reached a loopback listener with network denied');
|
|
160
|
+
void denied;
|
|
161
|
+
return proved('a connection was seen with network allowed and none with network denied (capability mode)');
|
|
162
|
+
} finally { server.close(); rm(root); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every Linux-specific probe, in a fixed order. */
|
|
166
|
+
export async function runLinuxProbes() {
|
|
167
|
+
const out = {};
|
|
168
|
+
const step = async (name, fn) => {
|
|
169
|
+
try { out[name] = await fn(); } catch (e) { out[name] = notProved(`probe threw: ${String(e.message).split('\n')[0]}`); }
|
|
170
|
+
};
|
|
171
|
+
await step('pid-namespace', () => probePidNamespace());
|
|
172
|
+
await step('init-kill-reaps-detached:supervisor', () => probeInitKillReapsDetached({ victim: 'supervisor' }));
|
|
173
|
+
await step('init-kill-reaps-detached:init', () => probeInitKillReapsDetached({ victim: 'init' }));
|
|
174
|
+
await step('protected-canary-masked:default', () => probeProtectedCanaryMasked({ run: (a, o) => runConfined(a, { ...o, readRoots: undefined }) }));
|
|
175
|
+
await step('protected-canary-masked:capability', () => probeProtectedCanaryMasked());
|
|
176
|
+
await step('capability-network', () => probeCapabilityNetwork());
|
|
177
|
+
await step('process-cap:capability', () => probeProcessCap({ mode: { readRoots: [] } }));
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Pure construction of the Linux root filesystem the namespace backend pivots
|
|
2
|
+
// into (capability mode, X-502).
|
|
3
|
+
//
|
|
4
|
+
// Nothing in this file touches the kernel. It turns declared roots into a PLAN
|
|
5
|
+
// (which host paths are made visible, read-only or read-write, and which are
|
|
6
|
+
// masked) and a POSIX shell script that carries the plan out inside fresh user,
|
|
7
|
+
// mount and PID namespaces. Keeping it pure is the point: the construction can
|
|
8
|
+
// be unit-tested on any host, with stand-in mount tools, and the claim that the
|
|
9
|
+
// kernel then enforces it is made only by the active probes that run on a Linux
|
|
10
|
+
// host (`linux-probes.js`, the `sandbox-linux` CI job).
|
|
11
|
+
//
|
|
12
|
+
// The model, in one paragraph. A new mount namespace gets a tmpfs root that
|
|
13
|
+
// holds NOTHING. Only the paths in the plan are bind-mounted into it, at the
|
|
14
|
+
// same absolute path they have on the host: the minimal runtime baseline
|
|
15
|
+
// (interpreter, libraries, a few identity files) and the declared read roots
|
|
16
|
+
// read-only, the declared write roots read-write, then a fresh /proc and a
|
|
17
|
+
// minimal /dev. The skeleton is remounted read-only and the process pivots into
|
|
18
|
+
// it; the old root is detached. A protected path is not denied, it is ABSENT:
|
|
19
|
+
// the kernel has no name for it, so there is nothing to open, to follow a link
|
|
20
|
+
// to, or to reach with `..`. Paths keep their meaning, so a caller's absolute
|
|
21
|
+
// paths mean the same thing on this backend as on the userspace one.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
// What every confined process needs just to start: the dynamic loader, system
|
|
26
|
+
// libraries, the shell and coreutils. Mounted read-only. Directories that are
|
|
27
|
+
// symlinks on the host (`/bin -> usr/bin`) are recreated as symlinks.
|
|
28
|
+
export const BASELINE_DIRS = Object.freeze([
|
|
29
|
+
'/usr', '/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/etc/alternatives',
|
|
30
|
+
]);
|
|
31
|
+
// Identity and loader files; no credentials. Read-only. A missing one is skipped.
|
|
32
|
+
export const BASELINE_FILES = Object.freeze([
|
|
33
|
+
'/etc/ld.so.cache', '/etc/ld.so.conf', '/etc/localtime', '/etc/nsswitch.conf', '/etc/passwd', '/etc/group',
|
|
34
|
+
]);
|
|
35
|
+
// Device nodes a process legitimately needs. Nothing else from /dev.
|
|
36
|
+
export const DEV_NODES = Object.freeze(['null', 'zero', 'full', 'random', 'urandom', 'tty']);
|
|
37
|
+
|
|
38
|
+
const BAD_CHARS = /[\0\n\t]/;
|
|
39
|
+
|
|
40
|
+
/** True for a path the plan format can carry (absolute, no NUL/newline/tab, not the root). */
|
|
41
|
+
export function isPlanPath(p) {
|
|
42
|
+
return typeof p === 'string' && p.startsWith('/') && p !== '/' && !BAD_CHARS.test(p);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function realOrResolved(p, realpath) {
|
|
46
|
+
const abs = path.resolve(p);
|
|
47
|
+
try { return realpath(abs); } catch { return abs; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function within(parent, child) {
|
|
51
|
+
return child === parent || child.startsWith(parent.endsWith('/') ? parent : parent + '/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the plan.
|
|
56
|
+
* @param {object} o
|
|
57
|
+
* @param {string} o.root the sandbox root (always read-write)
|
|
58
|
+
* @param {string[]} [o.readRoots] read-only roots (directories or files)
|
|
59
|
+
* @param {string[]} [o.writeRoots] read-write roots
|
|
60
|
+
* @param {string[]} [o.denyReadPaths] paths that must not be readable
|
|
61
|
+
* @param {string} [o.cwd] working directory (must lie inside a bound path)
|
|
62
|
+
* @param {boolean} [o.baseline=true] include the runtime baseline
|
|
63
|
+
* @param {(p:string)=>string} [o.realpath] seam for tests
|
|
64
|
+
* @returns {{ok:true, entries:{path:string, mode:'ro'|'rw'}[], masks:string[], cwd:string} | {ok:false, error:string}}
|
|
65
|
+
*/
|
|
66
|
+
export function buildRootPlan({
|
|
67
|
+
root, readRoots = [], writeRoots = [], denyReadPaths = [], cwd = null, baseline = true,
|
|
68
|
+
realpath = fs.realpathSync,
|
|
69
|
+
} = {}) {
|
|
70
|
+
if (!root) return { ok: false, error: 'a sandbox root is required' };
|
|
71
|
+
const want = new Map(); // path -> 'ro' | 'rw' (rw wins)
|
|
72
|
+
const add = (p, mode) => {
|
|
73
|
+
if (want.get(p) === 'rw') return;
|
|
74
|
+
want.set(p, mode);
|
|
75
|
+
};
|
|
76
|
+
const clean = (p, what) => {
|
|
77
|
+
if (typeof p !== 'string' || !p) return { error: `${what} must be a non-empty path` };
|
|
78
|
+
const real = realOrResolved(p, realpath);
|
|
79
|
+
if (real === '/') return { error: `${what} may not be the filesystem root` };
|
|
80
|
+
if (!isPlanPath(real)) return { error: `${what} cannot be carried into a mount plan (${JSON.stringify(p)})` };
|
|
81
|
+
return { path: real };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (baseline) {
|
|
85
|
+
for (const p of [...BASELINE_DIRS, ...BASELINE_FILES]) add(p, 'ro');
|
|
86
|
+
}
|
|
87
|
+
const rootC = clean(root, 'the sandbox root');
|
|
88
|
+
if (rootC.error) return { ok: false, error: rootC.error };
|
|
89
|
+
add(rootC.path, 'rw');
|
|
90
|
+
for (const p of readRoots || []) {
|
|
91
|
+
const c = clean(p, 'a read root');
|
|
92
|
+
if (c.error) return { ok: false, error: c.error };
|
|
93
|
+
add(c.path, 'ro');
|
|
94
|
+
}
|
|
95
|
+
for (const p of writeRoots || []) {
|
|
96
|
+
const c = clean(p, 'a write root');
|
|
97
|
+
if (c.error) return { ok: false, error: c.error };
|
|
98
|
+
add(c.path, 'rw');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Drop an entry already covered by a bound ancestor with at least its access:
|
|
102
|
+
// binding a file over a path inside a read-only mount would have to create it
|
|
103
|
+
// there, which a read-only mount refuses, and it adds nothing.
|
|
104
|
+
const all = [...want.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
105
|
+
const entries = [];
|
|
106
|
+
for (const [p, mode] of all) {
|
|
107
|
+
const covered = entries.some((e) => e.path !== p && within(e.path, p) && (e.mode === 'rw' || mode === 'ro'));
|
|
108
|
+
if (!covered) entries.push({ path: p, mode });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const masks = [];
|
|
112
|
+
for (const p of denyReadPaths || []) {
|
|
113
|
+
if (typeof p !== 'string' || !p) continue;
|
|
114
|
+
const real = realOrResolved(p, realpath);
|
|
115
|
+
if (!isPlanPath(real)) {
|
|
116
|
+
if (real === '/' || BAD_CHARS.test(real)) return { ok: false, error: `a denied path cannot be carried into a mount plan (${JSON.stringify(p)})` };
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// A denied path that contains a bound root would hide the root; refuse
|
|
120
|
+
// rather than guess which of the two the caller meant.
|
|
121
|
+
const hides = [rootC.path, ...[...(readRoots || []), ...(writeRoots || [])].map((r) => realOrResolved(r, realpath))]
|
|
122
|
+
.find((r) => within(real, r));
|
|
123
|
+
if (hides) return { ok: false, error: `a denied path overlaps a bound root (${real})` };
|
|
124
|
+
if (!masks.includes(real)) masks.push(real);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let workdir = rootC.path;
|
|
128
|
+
if (cwd) {
|
|
129
|
+
const c = clean(cwd, 'the working directory');
|
|
130
|
+
if (c.error) return { ok: false, error: c.error };
|
|
131
|
+
if (!entries.some((e) => within(e.path, c.path))) return { ok: false, error: `the working directory is outside every bound path (${c.path})` };
|
|
132
|
+
workdir = c.path;
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, entries, masks, cwd: workdir };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The plan as the tab-separated text the setup script reads. */
|
|
138
|
+
export function serializePlan(plan) {
|
|
139
|
+
const lines = [];
|
|
140
|
+
for (const e of plan.entries) lines.push(`B\t${e.mode}\t${e.path}`);
|
|
141
|
+
for (const m of plan.masks) lines.push(`M\t-\t${m}`);
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
|
|
146
|
+
export const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The setup script for capability mode. Runs inside the new namespaces as the
|
|
150
|
+
* mapped root, before anything of the caller's. Reads, from the environment:
|
|
151
|
+
* SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP absolute tool paths
|
|
152
|
+
* SBX_NEWROOT an empty host directory to become the new root
|
|
153
|
+
* SBX_PLAN the serialized plan
|
|
154
|
+
* SBX_FINAL the second-stage script (final privilege state)
|
|
155
|
+
* Every step is fail-closed: a failure prints the marker and exits before the
|
|
156
|
+
* caller's command is exec'd.
|
|
157
|
+
*/
|
|
158
|
+
export const PIVOT_SETUP_SCRIPT = `
|
|
159
|
+
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
160
|
+
# Make one bind mount read-only. A plain remount is tried first; some kernels
|
|
161
|
+
# lock the flags a bind inherits inside a user namespace (nosuid, nodev, ...),
|
|
162
|
+
# in which case the remount must restate them, so the second attempt reads the
|
|
163
|
+
# mount's current options and replaces only the access mode.
|
|
164
|
+
_ro() {
|
|
165
|
+
"$SBX_MOUNT" -o remount,bind,ro "$1" 2>/dev/null && return 0
|
|
166
|
+
_cur=$(while read -r _a _b _c _d _mp _o _rest; do if [ "$_mp" = "$1" ]; then echo "$_o"; break; fi; done < /proc/self/mountinfo)
|
|
167
|
+
case "$_cur" in *,*) "$SBX_MOUNT" -o "remount,bind,ro,\${_cur#*,}" "$1" ;; *) "$SBX_MOUNT" -o remount,bind,ro "$1" ;; esac
|
|
168
|
+
}
|
|
169
|
+
NR="$SBX_NEWROOT"
|
|
170
|
+
[ -n "$NR" ] && [ -d "$NR" ] || _fail "no new root directory"
|
|
171
|
+
"$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
|
|
172
|
+
"$SBX_MOUNT" -t tmpfs -o mode=0755,size=16m tmpfs "$NR" || _fail "the new root could not be created"
|
|
173
|
+
_tab=$(printf '\\t')
|
|
174
|
+
while IFS="$_tab" read -r _op _mode _p; do
|
|
175
|
+
[ -n "$_op" ] || continue
|
|
176
|
+
case "$_op" in
|
|
177
|
+
B)
|
|
178
|
+
if [ -L "$_p" ]; then
|
|
179
|
+
mkdir -p "$NR\${_p%/*}" || _fail "could not prepare a link"
|
|
180
|
+
ln -s "$(readlink "$_p")" "$NR$_p" || _fail "could not recreate a link"
|
|
181
|
+
elif [ -d "$_p" ]; then
|
|
182
|
+
mkdir -p "$NR$_p" || _fail "could not prepare a mount point"
|
|
183
|
+
"$SBX_MOUNT" --bind "$_p" "$NR$_p" || _fail "a path could not be made visible"
|
|
184
|
+
if [ "$_mode" = ro ]; then
|
|
185
|
+
_ro "$NR$_p" || _fail "a path could not be made read-only"
|
|
186
|
+
fi
|
|
187
|
+
elif [ -e "$_p" ]; then
|
|
188
|
+
mkdir -p "$NR\${_p%/*}" || _fail "could not prepare a mount point"
|
|
189
|
+
[ -e "$NR$_p" ] || : > "$NR$_p" || _fail "could not prepare a file mount point"
|
|
190
|
+
"$SBX_MOUNT" --bind "$_p" "$NR$_p" || _fail "a file could not be made visible"
|
|
191
|
+
if [ "$_mode" = ro ]; then
|
|
192
|
+
_ro "$NR$_p" || _fail "a file could not be made read-only"
|
|
193
|
+
fi
|
|
194
|
+
fi
|
|
195
|
+
;;
|
|
196
|
+
esac
|
|
197
|
+
done <<SBX_PLAN_EOF
|
|
198
|
+
$SBX_PLAN
|
|
199
|
+
SBX_PLAN_EOF
|
|
200
|
+
mkdir -p "$NR/proc" "$NR/dev" || _fail "could not prepare /proc and /dev"
|
|
201
|
+
"$SBX_MOUNT" -t proc proc "$NR/proc" 2>/dev/null || true
|
|
202
|
+
"$SBX_MOUNT" -t tmpfs -o mode=0755,size=64k tmpfs "$NR/dev" || _fail "could not create /dev"
|
|
203
|
+
for _n in ${DEV_NODES.join(' ')}; do
|
|
204
|
+
[ -e "/dev/$_n" ] || continue
|
|
205
|
+
: > "$NR/dev/$_n" || continue
|
|
206
|
+
"$SBX_MOUNT" --bind "/dev/$_n" "$NR/dev/$_n" 2>/dev/null || { [ "$_n" = null ] && _fail "/dev/null could not be provided"; }
|
|
207
|
+
done
|
|
208
|
+
while IFS="$_tab" read -r _op _mode _p; do
|
|
209
|
+
[ "$_op" = M ] || continue
|
|
210
|
+
if [ -d "$NR$_p" ]; then
|
|
211
|
+
"$SBX_MOUNT" -t tmpfs -o size=4k,mode=000 tmpfs "$NR$_p" || _fail "a protected directory could not be masked"
|
|
212
|
+
elif [ -e "$NR$_p" ]; then
|
|
213
|
+
"$SBX_MOUNT" --bind /dev/null "$NR$_p" || _fail "a protected file could not be masked"
|
|
214
|
+
fi
|
|
215
|
+
done <<SBX_PLAN_EOF
|
|
216
|
+
$SBX_PLAN
|
|
217
|
+
SBX_PLAN_EOF
|
|
218
|
+
mkdir "$NR/.old" || _fail "could not prepare the old-root mount point"
|
|
219
|
+
_ro "$NR" || _fail "the new root could not be made read-only"
|
|
220
|
+
cd "$NR" || _fail "could not enter the new root"
|
|
221
|
+
"$SBX_PIVOT" "$NR" "$NR/.old" || _fail "pivot_root failed"
|
|
222
|
+
cd / || _fail "could not enter the pivoted root"
|
|
223
|
+
"$SBX_UMOUNT" -l /.old || _fail "the old root could not be detached"
|
|
224
|
+
if [ -z "$SBX_PRIVDROP" ] || ! "$SBX_PRIVDROP" --no-new-privs --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c 'exit 0' 2>/dev/null; then
|
|
225
|
+
_fail "privilege drop is unavailable, and capability mode does not run without it"
|
|
226
|
+
fi
|
|
227
|
+
exec "$SBX_PRIVDROP" --no-new-privs --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Second stage, in the final privilege state. Both directions are checked by
|
|
232
|
+
* execution on every run: the host canary and the filesystem root are refused,
|
|
233
|
+
* the sandbox root is writable. Either check failing means the sandbox is not
|
|
234
|
+
* what it claims, so the command is not run.
|
|
235
|
+
*/
|
|
236
|
+
export const FINAL_SCRIPT = `
|
|
237
|
+
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
238
|
+
if ( : > "$SBX_CANARY" ) 2>/dev/null; then
|
|
239
|
+
_fail "an out-of-root write is still possible; refusing to execute"
|
|
240
|
+
fi
|
|
241
|
+
if [ -n "$SBX_STRICT" ]; then
|
|
242
|
+
if ( : > /.agsec-rootcheck ) 2>/dev/null; then
|
|
243
|
+
_fail "the filesystem root is writable; refusing to execute"
|
|
244
|
+
fi
|
|
245
|
+
if [ -e /.old/usr ]; then
|
|
246
|
+
_fail "the old root is still reachable; refusing to execute"
|
|
247
|
+
fi
|
|
248
|
+
fi
|
|
249
|
+
if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
|
|
250
|
+
_fail "the sandbox root is not writable; refusing to execute"
|
|
251
|
+
fi
|
|
252
|
+
rm -f "$ROOT/.agsec-sbx-wcheck"
|
|
253
|
+
_cwd="\${SBX_CWD:-$ROOT}"
|
|
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
|
|
262
|
+
cd "$_cwd" && exec "$@"
|
|
263
|
+
`;
|