@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
|
@@ -0,0 +1,178 @@
|
|
|
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 } 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
|
+
return out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
unset SBX_PLAN SBX_FINAL SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP SBX_NEWROOT SBX_MASKS SBX_CANARY SBX_STRICT SBX_CWD
|
|
255
|
+
cd "$_cwd" && exec "$@"
|
|
256
|
+
`;
|
package/src/sandbox/supervise.js
CHANGED
|
@@ -16,15 +16,17 @@
|
|
|
16
16
|
// 4. The same cleanup runs on a normal exit, so a backgrounded grandchild
|
|
17
17
|
// cannot outlive a run that "succeeded".
|
|
18
18
|
//
|
|
19
|
-
// KNOWN GAP (not hidden): a descendant that
|
|
20
|
-
// between two sweeps, with its parent exiting
|
|
21
|
-
// reparented away from the tree and is not found. The
|
|
22
|
-
// window; it does not close it. Only a PID-namespace
|
|
23
|
-
// it, and
|
|
19
|
+
// KNOWN GAP on the userspace backend (not hidden): a descendant that
|
|
20
|
+
// double-forks and calls setsid between two sweeps, with its parent exiting
|
|
21
|
+
// before the next sweep, is reparented away from the tree and is not found. The
|
|
22
|
+
// sweep interval bounds the window; it does not close it. Only a PID-namespace
|
|
23
|
+
// or cgroup boundary closes it, and macOS has neither. The namespace backend
|
|
24
|
+
// has the boundary (see `runConfinedSupervised`).
|
|
24
25
|
import { spawn, spawnSync } from 'node:child_process';
|
|
25
26
|
import { detectBackend } from './capabilities.js';
|
|
26
27
|
import { runDisabled } from './backend-disabled.js';
|
|
27
28
|
import { buildUserspaceInvocation } from './backend-userspace.js';
|
|
29
|
+
import { buildNamespaceInvocation } from './backend-namespace.js';
|
|
28
30
|
import { buildResult, errorResult } from './result.js';
|
|
29
31
|
|
|
30
32
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -163,31 +165,48 @@ export function superviseSpawn(bin, args, {
|
|
|
163
165
|
*
|
|
164
166
|
* Backend coverage, stated plainly:
|
|
165
167
|
* userspace supervised (verified by execution on macOS)
|
|
166
|
-
* namespace
|
|
167
|
-
*
|
|
168
|
+
* namespace supervised ONLY where the PID namespace is created with
|
|
169
|
+
* `--kill-child`: killing the unshare process then makes the
|
|
170
|
+
* kernel kill every member of the namespace, however it detached
|
|
171
|
+
* (setsid, double fork). Where the host's unshare lacks the flag
|
|
172
|
+
* the run is refused, since the sweep alone would be the whole
|
|
173
|
+
* guarantee. That it holds on a host is shown by the active probe
|
|
174
|
+
* (`linux-probes.js`), not by this comment.
|
|
168
175
|
* disabled refused, as always
|
|
169
176
|
*/
|
|
170
177
|
export async function runConfinedSupervised(argv, opts = {}) {
|
|
171
178
|
const backend = detectBackend({ force: opts.force });
|
|
172
179
|
if (backend === 'disabled') return runDisabled(argv, opts);
|
|
173
|
-
if (backend !== 'userspace') {
|
|
180
|
+
if (backend !== 'userspace' && backend !== 'namespace') {
|
|
174
181
|
return errorResult(backend, `process-tree termination is not implemented or verified on the ${backend} backend; refusing to execute`);
|
|
175
182
|
}
|
|
176
|
-
const inv = buildUserspaceInvocation(argv, opts);
|
|
183
|
+
const inv = backend === 'userspace' ? buildUserspaceInvocation(argv, opts) : buildNamespaceInvocation(argv, opts);
|
|
177
184
|
if (inv.error) return inv.error;
|
|
185
|
+
if (backend === 'namespace' && !inv.treeKill) {
|
|
186
|
+
inv.dispose();
|
|
187
|
+
return errorResult(backend, 'process-tree termination is not implemented or verified on the namespace backend (this host cannot make the kernel kill the namespace with its supervisor); refusing to execute');
|
|
188
|
+
}
|
|
178
189
|
const r = await superviseSpawn(inv.bin, inv.args, {
|
|
179
190
|
cwd: inv.cwd, env: inv.env, timeoutMs: opts.timeoutMs ?? 10000, graceMs: opts.graceMs ?? 1000,
|
|
180
191
|
signal: opts.signal, maxOutputBytes: opts.maxOutputBytes,
|
|
181
192
|
});
|
|
193
|
+
let rawStderr = r.stderr;
|
|
194
|
+
let unsupported = inv.unsupported;
|
|
195
|
+
if (backend === 'namespace') {
|
|
196
|
+
const fin = inv.finish(r.stderr, { hadError: !!r.spawnError || r.timedOut });
|
|
197
|
+
if (fin.error) return fin.error;
|
|
198
|
+
rawStderr = fin.stderr;
|
|
199
|
+
unsupported = fin.unsupported;
|
|
200
|
+
}
|
|
182
201
|
const res = buildResult({
|
|
183
|
-
backend
|
|
202
|
+
backend,
|
|
184
203
|
spawnResult: {
|
|
185
204
|
status: r.exitCode,
|
|
186
205
|
stdout: r.stdout,
|
|
187
|
-
stderr:
|
|
206
|
+
stderr: rawStderr,
|
|
188
207
|
error: r.timedOut ? { code: 'ETIMEDOUT' } : r.spawnError ? { code: 'SPAWN', message: r.spawnError } : null,
|
|
189
208
|
},
|
|
190
|
-
unsupported
|
|
209
|
+
unsupported,
|
|
191
210
|
});
|
|
192
211
|
let status = res.status;
|
|
193
212
|
let stderr = res.stderr;
|