@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 +223 -0
- package/bin/agentic-security.js +52 -2
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +498 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +220 -0
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +4 -4
- package/dist/526.index.js +844 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +106 -194
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -17
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +170 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +13 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +193 -1
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +202 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +3 -3
- package/src/posture/execution-proof.js +92 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +129 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +5 -7
- package/src/sandbox/CLAUDE.md +340 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +335 -0
- package/src/sandbox/backend-userspace.js +83 -0
- package/src/sandbox/capabilities.js +181 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +63 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/glob.js +173 -0
- package/src/util/yaml.js +24 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Kernel-namespace confinement backend (Linux family).
|
|
2
|
+
//
|
|
3
|
+
// STATUS. This backend cannot be exercised on the macOS development host — the
|
|
4
|
+
// required kernel-namespace tool is absent, so its escape tests skip with a
|
|
5
|
+
// recorded reason there. Whether the confinement described below actually
|
|
6
|
+
// holds is a per-host fact that only a Linux host can answer, and only by
|
|
7
|
+
// EXECUTING the escape suite. Do not read this comment as a verification
|
|
8
|
+
// claim; read `src/sandbox/CLAUDE.md` for what has and has not been executed.
|
|
9
|
+
//
|
|
10
|
+
// WHAT THIS BACKEND CONFINES.
|
|
11
|
+
//
|
|
12
|
+
// 1. NETWORK EGRESS — an empty network namespace (`--net`, unless the caller
|
|
13
|
+
// passes `allowNetwork`). It has no route anywhere.
|
|
14
|
+
//
|
|
15
|
+
// 2. FILESYSTEM WRITES — a private mount namespace in which every mount
|
|
16
|
+
// point present at setup time is rebound READ-ONLY, and only the sandbox
|
|
17
|
+
// root is rebound read-write. An out-of-root write therefore fails with
|
|
18
|
+
// EROFS. That error text is one of `result.js`'s denial patterns, so an
|
|
19
|
+
// escape attempt surfaces as `status:'blocked'` + `denied:true` — the
|
|
20
|
+
// same shape the userspace backend produces, which is the main reason
|
|
21
|
+
// this shape was chosen over `pivot_root` (see below).
|
|
22
|
+
//
|
|
23
|
+
// 3. RESOURCE CAPS — the shared `ulimit` prelude.
|
|
24
|
+
//
|
|
25
|
+
// WHY READ-ONLY REBIND RATHER THAN pivot_root. `pivot_root` into the sandbox
|
|
26
|
+
// root is the stronger primitive: after detaching the old root, out-of-root
|
|
27
|
+
// paths are not merely read-only, they are absent from the mount namespace
|
|
28
|
+
// entirely. It was rejected here for three concrete reasons. (a) It requires
|
|
29
|
+
// materialising a system tree (the shell, the C library, the utilities a PoC
|
|
30
|
+
// invokes) inside the caller's sandbox root, which pollutes a directory the
|
|
31
|
+
// caller owns and reads back. (b) It changes path semantics: `$ROOT` becomes
|
|
32
|
+
// `/`, so a caller's absolute paths mean something different on this backend
|
|
33
|
+
// than on the userspace one, and the two backends stop being interchangeable.
|
|
34
|
+
// (c) An out-of-root write would then fail with ENOENT, which is
|
|
35
|
+
// indistinguishable from an ordinary missing path and cannot be reported as a
|
|
36
|
+
// confinement denial — the caller loses the `denied` signal precisely where it
|
|
37
|
+
// matters most. The read-only rebind keeps paths, keeps the denial signal, and
|
|
38
|
+
// keeps both backends returning the same thing for the same escape attempt.
|
|
39
|
+
//
|
|
40
|
+
// HONEST LIMIT OF THE READ-ONLY REBIND. The namespaces are acquired by
|
|
41
|
+
// creating a user namespace, and the confined process is therefore (initially)
|
|
42
|
+
// privileged inside it — it holds CAP_SYS_ADMIN over the mount namespace it
|
|
43
|
+
// runs in, and could rebind the tree read-write again. That would gut the
|
|
44
|
+
// confinement, so after the mounts are established and before the caller's
|
|
45
|
+
// command is executed, the backend drops the whole capability set (bounding,
|
|
46
|
+
// inheritable, and — via the `noroot` secure bits — the implicit privileges of
|
|
47
|
+
// uid 0) and only then executes. If the privilege-dropping utility is not
|
|
48
|
+
// present on the host the command still runs under the read-only mount tree,
|
|
49
|
+
// but the result DECLARES `privilegeDrop` unenforced (in `unsupported`, the
|
|
50
|
+
// same mechanism `limits.js` uses) rather than pretending the hardening
|
|
51
|
+
// applied. It is never silently skipped.
|
|
52
|
+
//
|
|
53
|
+
// FAIL-CLOSED, AND VERIFIED PER RUN RATHER THAN ASSUMED. Every step that
|
|
54
|
+
// establishes confinement aborts the run on failure: no namespace variant, no
|
|
55
|
+
// filesystem-attach utility, a mount tree that cannot be made read-only, a
|
|
56
|
+
// sandbox root that turns out not to be writable — each returns
|
|
57
|
+
// `status:'error'` with nothing executed. Beyond that, the confinement is PROVEN by execution on
|
|
58
|
+
// every single run: the parent creates a canary path OUTSIDE the sandbox root,
|
|
59
|
+
// and the confined shell — already in its final, deprivileged state —
|
|
60
|
+
// attempts to create it. If that write succeeds, confinement is not in force
|
|
61
|
+
// and the shell exits WITHOUT running the caller's command. A reasoned
|
|
62
|
+
// expectation that "the remount should have worked" is exactly the class of
|
|
63
|
+
// claim this module exists to refuse.
|
|
64
|
+
//
|
|
65
|
+
// TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
|
|
66
|
+
// the direct child. On this backend the direct child is the namespace tool
|
|
67
|
+
// running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
|
|
68
|
+
// expected to take the whole namespace's processes with it — better than the
|
|
69
|
+
// userspace backend, where a backgrounded grandchild demonstrably survives.
|
|
70
|
+
// "Expected", NOT verified, and this one did not clear with the rest: the
|
|
71
|
+
// escape suite has now RUN and passed on a Linux runner, but its wall-clock
|
|
72
|
+
// case asserts only that the DIRECT CHILD is stopped. No test observes whether
|
|
73
|
+
// a backgrounded grandchild dies with the PID namespace, so tree-kill remains
|
|
74
|
+
// a reasoned expectation. Do not state it as a guarantee until a test asserts
|
|
75
|
+
// the grandchild is gone.
|
|
76
|
+
//
|
|
77
|
+
// PRIVILEGE. Creating mount/PID/IPC/UTS/network namespaces directly requires
|
|
78
|
+
// CAP_SYS_ADMIN, which an ordinary CI account does not have — asking for them
|
|
79
|
+
// bare fails with a permission error and the backend cannot start at all. The
|
|
80
|
+
// unprivileged route is to create a USER namespace first and take the
|
|
81
|
+
// requested namespaces inside it, where the invoking user holds the
|
|
82
|
+
// capabilities. So the flag set is chosen by PROBE, not assumed: each variant
|
|
83
|
+
// below is executed with a trivial command and the first one that actually
|
|
84
|
+
// succeeds is used (and cached). Fail-closed: if no variant works the backend
|
|
85
|
+
// returns status 'error' and nothing runs. The confinement flags are NEVER
|
|
86
|
+
// relaxed to make a run succeed — dropping `--net` would remove the network
|
|
87
|
+
// confinement, so `--net` is part of every probed variant when `allowNetwork`
|
|
88
|
+
// is false, and `--mount` is in every variant unconditionally because the
|
|
89
|
+
// write confinement is built inside it.
|
|
90
|
+
import { spawnSync } from 'node:child_process';
|
|
91
|
+
import fs from 'node:fs';
|
|
92
|
+
import os from 'node:os';
|
|
93
|
+
import path from 'node:path';
|
|
94
|
+
import {
|
|
95
|
+
resolveNamespaceBin, resolveMountBin, resolvePrivDropBin,
|
|
96
|
+
cachedNamespaceVariant, cacheNamespaceVariant,
|
|
97
|
+
} from './capabilities.js';
|
|
98
|
+
import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
|
|
99
|
+
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
100
|
+
|
|
101
|
+
// Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
|
|
102
|
+
// prefix; the namespace flags themselves are appended identically to all of
|
|
103
|
+
// them by `_nsArgs`, so no variant can quietly confine less than another.
|
|
104
|
+
//
|
|
105
|
+
// 1. user namespace with the invoking user mapped to root inside it — the
|
|
106
|
+
// unprivileged route, and the one a standard CI runner needs. It is also
|
|
107
|
+
// the only variant under which the write confinement can be built, since
|
|
108
|
+
// rebinding the mount tree needs CAP_SYS_ADMIN in the owning namespace.
|
|
109
|
+
// 2. user namespace with the invoking user mapped to itself — for hosts
|
|
110
|
+
// whose policy permits a user namespace but not the root mapping.
|
|
111
|
+
// 3. no prefix — the direct route, which needs CAP_SYS_ADMIN (i.e. root).
|
|
112
|
+
// Last so an unprivileged host never pays for a doomed attempt first.
|
|
113
|
+
const NS_PRIVILEGE_VARIANTS = Object.freeze([
|
|
114
|
+
Object.freeze(['--user', '--map-root-user']),
|
|
115
|
+
Object.freeze(['--user', '--map-current-user']),
|
|
116
|
+
Object.freeze([]),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
function _nsArgs(privilegeFlags, allowNetwork) {
|
|
120
|
+
const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
|
|
121
|
+
if (!allowNetwork) a.push('--net');
|
|
122
|
+
return a;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Markers the confined shell writes to its own stderr so the parent can tell
|
|
126
|
+
// a confinement-setup failure from ordinary program output. They are stripped
|
|
127
|
+
// from the stderr handed back to the caller.
|
|
128
|
+
//
|
|
129
|
+
// A payload that PRINTS one of these strings can force `status:'error'` (or a
|
|
130
|
+
// false `privilegeDrop` unenforced note). That is the safe direction: the
|
|
131
|
+
// worst it achieves is making its own run look like it did not happen, which
|
|
132
|
+
// no downstream tier reads as evidence of anything. It cannot make an
|
|
133
|
+
// unconfined run look confined.
|
|
134
|
+
const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
|
|
135
|
+
const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
|
|
136
|
+
|
|
137
|
+
// Runs inside the namespaces, still privileged, before the caller's command.
|
|
138
|
+
// Builds the write confinement, then hands off to $SBX_FINAL with the
|
|
139
|
+
// capability set dropped.
|
|
140
|
+
//
|
|
141
|
+
// Order matters: the sandbox root is bound onto itself while the tree is still
|
|
142
|
+
// writable, so the read-only pass and the read-write rebind of the root never
|
|
143
|
+
// have to fight each other. Individual sub-mounts are best-effort (some pseudo
|
|
144
|
+
// filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
|
|
145
|
+
// what actually decides whether the result is trustworthy.
|
|
146
|
+
const SETUP_SCRIPT = `
|
|
147
|
+
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
148
|
+
"$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
|
|
149
|
+
"$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
|
|
150
|
+
"$SBX_MOUNT" --bind "$ROOT" "$ROOT" || _fail "the sandbox root could not be bind-mounted"
|
|
151
|
+
_mps=$(while read -r _a _b _c _d _mp _rest; do printf '%s\\n' "$_mp"; done < /proc/self/mountinfo)
|
|
152
|
+
for _mp in $_mps; do
|
|
153
|
+
[ "$_mp" = "/" ] && continue
|
|
154
|
+
[ "$_mp" = "$ROOT" ] && continue
|
|
155
|
+
case "$_mp" in "$ROOT"/*) continue ;; esac
|
|
156
|
+
"$SBX_MOUNT" -o remount,bind,ro "$_mp" 2>/dev/null || true
|
|
157
|
+
done
|
|
158
|
+
"$SBX_MOUNT" -o remount,bind,ro / || _fail "the root filesystem could not be rebound read-only"
|
|
159
|
+
# Belt and braces: the root was bound before the read-only pass and skipped by
|
|
160
|
+
# it, so this is normally a no-op. Its return code is NOT the gate — the
|
|
161
|
+
# executed in-root write check in $SBX_FINAL is, and that one fails closed.
|
|
162
|
+
"$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
|
|
163
|
+
if [ -n "$SBX_PRIVDROP" ] && "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c 'exit 0' 2>/dev/null; then
|
|
164
|
+
exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
165
|
+
fi
|
|
166
|
+
echo "${MARK_NO_PRIVDROP}" >&2
|
|
167
|
+
exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
// Runs in the FINAL privilege state, immediately before the caller's command.
|
|
171
|
+
// Both directions are checked by execution, every run: the out-of-root canary
|
|
172
|
+
// must be refused, and an in-root write must succeed. Either check failing
|
|
173
|
+
// means the sandbox is not what it claims, so the command is not run.
|
|
174
|
+
const FINAL_SCRIPT = `
|
|
175
|
+
_fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
|
|
176
|
+
if ( : > "$SBX_CANARY" ) 2>/dev/null; then
|
|
177
|
+
_fail "an out-of-root write is still possible; refusing to execute"
|
|
178
|
+
fi
|
|
179
|
+
if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
|
|
180
|
+
_fail "the sandbox root is not writable; refusing to execute"
|
|
181
|
+
fi
|
|
182
|
+
rm -f "$ROOT/.agsec-sbx-wcheck"
|
|
183
|
+
cd "$ROOT" && exec "$@"
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The first privilege variant under which the requested namespaces can
|
|
188
|
+
* actually be created on this host, or null when none can. Probed by running
|
|
189
|
+
* a trivial command — a reasoned expectation about which flags "should" work
|
|
190
|
+
* is exactly what made this backend unusable on an unprivileged runner.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
|
|
193
|
+
const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
|
|
194
|
+
const cached = cachedNamespaceVariant(key);
|
|
195
|
+
if (cached !== undefined) return cached;
|
|
196
|
+
|
|
197
|
+
let chosen = null;
|
|
198
|
+
for (const variant of NS_PRIVILEGE_VARIANTS) {
|
|
199
|
+
const args = _nsArgs(variant, allowNetwork);
|
|
200
|
+
const probe = spawnSync(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
|
|
201
|
+
encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
|
|
202
|
+
});
|
|
203
|
+
if (!probe.error && probe.status === 0) { chosen = args; break; }
|
|
204
|
+
}
|
|
205
|
+
cacheNamespaceVariant(key, chosen);
|
|
206
|
+
return chosen;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Strip the internal markers from stderr before it reaches the caller. */
|
|
210
|
+
function _cleanStderr(s) {
|
|
211
|
+
return String(s || '')
|
|
212
|
+
.split('\n')
|
|
213
|
+
.filter((l) => !l.includes(MARK_SETUP_FAILED) && l.trim() !== MARK_NO_PRIVDROP)
|
|
214
|
+
.join('\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _setupFailureReason(stderr) {
|
|
218
|
+
for (const line of String(stderr || '').split('\n')) {
|
|
219
|
+
const i = line.indexOf(MARK_SETUP_FAILED);
|
|
220
|
+
if (i !== -1) return line.slice(i + MARK_SETUP_FAILED.length).trim();
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function runNamespace(argv, {
|
|
226
|
+
root,
|
|
227
|
+
timeoutMs = 10000,
|
|
228
|
+
allowNetwork = false,
|
|
229
|
+
limits = {},
|
|
230
|
+
env = {},
|
|
231
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
232
|
+
} = {}) {
|
|
233
|
+
// Documented shape, never a throw — see the same note in backend-userspace.
|
|
234
|
+
if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
|
|
235
|
+
|
|
236
|
+
const bin = resolveNamespaceBin();
|
|
237
|
+
if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
|
|
238
|
+
|
|
239
|
+
// Write confinement is built with this utility. No utility, no confinement,
|
|
240
|
+
// no run — there is deliberately no branch that proceeds without it.
|
|
241
|
+
const mountBin = resolveMountBin();
|
|
242
|
+
if (!mountBin) {
|
|
243
|
+
return errorResult('namespace',
|
|
244
|
+
'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let resolvedRoot;
|
|
248
|
+
try {
|
|
249
|
+
// Resolve symlinks so the path the kernel actually sees matches what we
|
|
250
|
+
// hand to the child.
|
|
251
|
+
resolvedRoot = fs.realpathSync(root);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
|
|
257
|
+
// the confined shell has to fork several helpers to BUILD its confinement,
|
|
258
|
+
// so a fixed cap below the ambient count for this uid makes the setup itself
|
|
259
|
+
// fail and the sandbox look broken. See `ambientRelativeMaxProcs`.
|
|
260
|
+
const effectiveLimits = { ...limits, maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs() };
|
|
261
|
+
|
|
262
|
+
let prelude, unsupported;
|
|
263
|
+
try {
|
|
264
|
+
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return errorResult('namespace', `invalid resource limit: ${e.message}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fail closed: no usable variant means the confinement cannot be
|
|
270
|
+
// established, so nothing is executed. There is deliberately no path that
|
|
271
|
+
// drops confinement flags and runs anyway.
|
|
272
|
+
const nsArgs = resolveNamespaceArgs(bin, allowNetwork);
|
|
273
|
+
if (!nsArgs) {
|
|
274
|
+
return errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The canary lives OUTSIDE the sandbox root, in a directory this process
|
|
278
|
+
// just created and can write. If the confined shell can create it, the
|
|
279
|
+
// confinement is not in force and the command is not run.
|
|
280
|
+
let canaryDir = null;
|
|
281
|
+
try {
|
|
282
|
+
canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-canary-'));
|
|
283
|
+
} catch (e) {
|
|
284
|
+
return errorResult('namespace', `could not create the confinement canary: ${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
const canary = path.join(canaryDir, 'out-of-root.canary');
|
|
287
|
+
|
|
288
|
+
let r;
|
|
289
|
+
try {
|
|
290
|
+
r = spawnSync(
|
|
291
|
+
bin,
|
|
292
|
+
[...nsArgs, '/bin/sh', '-c', prelude + SETUP_SCRIPT, '_sbx', ...argv],
|
|
293
|
+
{
|
|
294
|
+
encoding: 'utf8',
|
|
295
|
+
timeout: timeoutMs,
|
|
296
|
+
maxBuffer,
|
|
297
|
+
cwd: resolvedRoot,
|
|
298
|
+
env: {
|
|
299
|
+
...buildConfinedEnv({ root: resolvedRoot, env }),
|
|
300
|
+
SBX_MOUNT: mountBin,
|
|
301
|
+
SBX_PRIVDROP: resolvePrivDropBin() || '',
|
|
302
|
+
SBX_CANARY: canary,
|
|
303
|
+
SBX_FINAL: FINAL_SCRIPT,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// Parent-side confirmation of the same fact the canary check asserts from
|
|
309
|
+
// the inside. Cheap, and it does not depend on the confined shell being
|
|
310
|
+
// honest about its own exit code.
|
|
311
|
+
if (fs.existsSync(canary)) {
|
|
312
|
+
return errorResult('namespace',
|
|
313
|
+
'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host');
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
try { fs.rmSync(canaryDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const rawStderr = r.stderr ?? '';
|
|
320
|
+
const setupFailure = _setupFailureReason(rawStderr);
|
|
321
|
+
if (setupFailure && !r.error) {
|
|
322
|
+
// Confinement could not be established (or could not be proven). Nothing
|
|
323
|
+
// ran: the shell exits before `exec`ing the caller's command.
|
|
324
|
+
return errorResult('namespace', `confinement could not be established: ${setupFailure}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const effectiveUnsupported = [...unsupported];
|
|
328
|
+
if (rawStderr.includes(MARK_NO_PRIVDROP)) effectiveUnsupported.push('privilegeDrop');
|
|
329
|
+
|
|
330
|
+
return buildResult({
|
|
331
|
+
backend: 'namespace',
|
|
332
|
+
spawnResult: { ...r, stderr: _cleanStderr(rawStderr) },
|
|
333
|
+
unsupported: effectiveUnsupported,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Userspace confinement backend (macOS family). Applies a deny-by-default
|
|
2
|
+
// policy profile: reads allowed, writes confined to the sandbox root, no
|
|
3
|
+
// network egress unless explicitly opted in.
|
|
4
|
+
//
|
|
5
|
+
// TIMEOUT SCOPE — read before trusting `status:'timeout'`. The wall-clock
|
|
6
|
+
// timeout is `spawnSync`'s, which signals only the DIRECT child. Verified by
|
|
7
|
+
// execution on this platform: with `timeoutMs: 1200`, a command that
|
|
8
|
+
// backgrounded a 4-second child returned `status:'timeout'` while the
|
|
9
|
+
// grandchild survived the timeout and completed its work afterwards. So
|
|
10
|
+
// 'timeout' means "we stopped waiting and killed the process we spawned", NOT
|
|
11
|
+
// "the process tree was terminated". Anything left running is still inside the
|
|
12
|
+
// policy profile (its writes and network stay confined), but it is still
|
|
13
|
+
// running. Callers that need a hard tree kill must supply it themselves.
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { resolveUserspaceBin } from './capabilities.js';
|
|
17
|
+
import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
|
|
18
|
+
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
19
|
+
|
|
20
|
+
function _profile({ allowNetwork }) {
|
|
21
|
+
return [
|
|
22
|
+
'(version 1)',
|
|
23
|
+
'(deny default)',
|
|
24
|
+
'(allow process-exec process-fork)',
|
|
25
|
+
'(allow sysctl-read)',
|
|
26
|
+
'(allow file-read*)',
|
|
27
|
+
'(allow file-write* (subpath (param "ROOT")))',
|
|
28
|
+
allowNetwork ? '(allow network*)' : '',
|
|
29
|
+
].filter(Boolean).join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runUserspace(argv, {
|
|
33
|
+
root,
|
|
34
|
+
timeoutMs = 10000,
|
|
35
|
+
allowNetwork = false,
|
|
36
|
+
limits = {},
|
|
37
|
+
env = {},
|
|
38
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
39
|
+
} = {}) {
|
|
40
|
+
// Documented shape, never a throw: a caller that wraps this in try/catch and
|
|
41
|
+
// "falls back" is a classic route to unconfined execution.
|
|
42
|
+
if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
|
|
43
|
+
|
|
44
|
+
const bin = resolveUserspaceBin();
|
|
45
|
+
if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
|
|
46
|
+
|
|
47
|
+
let resolvedRoot;
|
|
48
|
+
try {
|
|
49
|
+
// Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
|
|
50
|
+
// subpath param matches the path the kernel actually sees.
|
|
51
|
+
resolvedRoot = fs.realpathSync(root);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const effectiveLimits = {
|
|
57
|
+
...limits,
|
|
58
|
+
maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let prelude, unsupported;
|
|
62
|
+
try {
|
|
63
|
+
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return errorResult('userspace', `invalid resource limit: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
const inner = `${prelude}exec "$@"`;
|
|
68
|
+
|
|
69
|
+
const r = spawnSync(
|
|
70
|
+
bin,
|
|
71
|
+
['-p', _profile({ allowNetwork }), '-D', `ROOT=${resolvedRoot}`,
|
|
72
|
+
'/bin/sh', '-c', inner, '_sbx', ...argv],
|
|
73
|
+
{
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
timeout: timeoutMs,
|
|
76
|
+
maxBuffer,
|
|
77
|
+
cwd: resolvedRoot,
|
|
78
|
+
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
|
|
83
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Detects which OS confinement primitive is available. Fail-closed: when none
|
|
2
|
+
// is found we report 'disabled', which REFUSES execution rather than running
|
|
3
|
+
// target code unconfined.
|
|
4
|
+
//
|
|
5
|
+
// DETECTION IS FUNCTIONAL, NOT PRESENCE-BASED. An earlier version concluded
|
|
6
|
+
// "available" from "the confinement binary is executable". That is a different
|
|
7
|
+
// question from the one callers are actually asking. Verified on a Linux CI
|
|
8
|
+
// runner: the kernel-namespace tool is installed and executable, but the
|
|
9
|
+
// distribution restricts unprivileged user-namespace creation, so every
|
|
10
|
+
// privilege variant fails and no confined command can start. Presence-based
|
|
11
|
+
// detection reported the backend as available anyway, and `sandboxAvailable()`
|
|
12
|
+
// — the signal callers use to decide whether it is safe to EXECUTE UNTRUSTED
|
|
13
|
+
// CODE — answered true while nothing could actually be confined. False
|
|
14
|
+
// assurance about confinement is precisely the failure this module exists to
|
|
15
|
+
// prevent, so a backend now counts as available only if it just ran a trivial
|
|
16
|
+
// command through its real code path.
|
|
17
|
+
//
|
|
18
|
+
// The probe result is cached for the process (one spawn, not one per call —
|
|
19
|
+
// detection sits on the path of ordinary scans) and cleared by
|
|
20
|
+
// `resetCapabilityCache()`.
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { runUserspace } from './backend-userspace.js';
|
|
25
|
+
import { runNamespace } from './backend-namespace.js';
|
|
26
|
+
|
|
27
|
+
// Referenced by path, never by product name (see Global Constraints).
|
|
28
|
+
//
|
|
29
|
+
// Each family lists every plausible install location, probed in order. A
|
|
30
|
+
// single hardcoded path is safe (a miss fails closed to 'disabled') but it is
|
|
31
|
+
// a FALSE NEGATIVE: a host that does have the primitive somewhere else loses
|
|
32
|
+
// the sandbox silently. Probing the candidate set removes that failure mode.
|
|
33
|
+
export const CONFINE_BINS_USERSPACE = Object.freeze([
|
|
34
|
+
'/usr/bin/sandbox-exec',
|
|
35
|
+
'/usr/local/bin/sandbox-exec',
|
|
36
|
+
]);
|
|
37
|
+
export const CONFINE_BINS_NAMESPACE = Object.freeze([
|
|
38
|
+
'/usr/bin/unshare',
|
|
39
|
+
'/bin/unshare',
|
|
40
|
+
'/usr/local/bin/unshare',
|
|
41
|
+
'/sbin/unshare',
|
|
42
|
+
'/usr/sbin/unshare',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// The filesystem-attach utility used by the namespace backend to establish
|
|
46
|
+
// write confinement (read-only rebind of the whole mount tree, read-write
|
|
47
|
+
// rebind of the sandbox root). Resolved by path for the same reason as the
|
|
48
|
+
// others. Absent => the namespace backend cannot establish write confinement
|
|
49
|
+
// and fails closed; it never runs a command with the filesystem open.
|
|
50
|
+
export const CONFINE_BINS_MOUNT = Object.freeze([
|
|
51
|
+
'/usr/bin/mount',
|
|
52
|
+
'/bin/mount',
|
|
53
|
+
'/sbin/mount',
|
|
54
|
+
'/usr/sbin/mount',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// The privilege-dropping utility used to remove CAP_SYS_ADMIN (and everything
|
|
58
|
+
// else) from the confined process *after* the mounts are in place, so the
|
|
59
|
+
// payload cannot simply undo the read-only rebinds. Best-effort hardening on
|
|
60
|
+
// top of the mount confinement, not the confinement itself: when it is absent
|
|
61
|
+
// the run still happens under the read-only mount tree and the result declares
|
|
62
|
+
// `privilegeDrop` unenforced rather than staying silent about it.
|
|
63
|
+
export const CONFINE_BINS_PRIVDROP = Object.freeze([
|
|
64
|
+
'/usr/bin/setpriv',
|
|
65
|
+
'/bin/setpriv',
|
|
66
|
+
'/sbin/setpriv',
|
|
67
|
+
'/usr/sbin/setpriv',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// Back-compat single-path exports: the first (canonical) candidate.
|
|
71
|
+
export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
|
|
72
|
+
export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
|
|
73
|
+
|
|
74
|
+
let _cached = null;
|
|
75
|
+
|
|
76
|
+
// Which namespace-flag variant actually works on this host, keyed by the
|
|
77
|
+
// requested confinement shape. Probing costs a process spawn, so it is done
|
|
78
|
+
// once; `undefined` means "not probed yet", `null` means "probed and nothing
|
|
79
|
+
// worked" (which the backend turns into a fail-closed error, never a run).
|
|
80
|
+
const _nsVariant = new Map();
|
|
81
|
+
|
|
82
|
+
export function resetCapabilityCache() { _cached = null; _nsVariant.clear(); }
|
|
83
|
+
|
|
84
|
+
export function cachedNamespaceVariant(key) {
|
|
85
|
+
return _nsVariant.has(key) ? _nsVariant.get(key) : undefined;
|
|
86
|
+
}
|
|
87
|
+
export function cacheNamespaceVariant(key, value) { _nsVariant.set(key, value); }
|
|
88
|
+
|
|
89
|
+
/** First executable candidate, or null when none of them exists. */
|
|
90
|
+
export function resolveConfineBin(candidates) {
|
|
91
|
+
for (const p of candidates) if (_isExecutable(p)) return p;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
|
|
96
|
+
export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
|
|
97
|
+
export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
|
|
98
|
+
export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
|
|
99
|
+
|
|
100
|
+
// Bounded on purpose: a capability check must never hang a scan. The probe is
|
|
101
|
+
// a single `exit 0` under confinement, so anything beyond a couple of seconds
|
|
102
|
+
// is a host that is not going to answer.
|
|
103
|
+
const PROBE_TIMEOUT_MS = Math.max(
|
|
104
|
+
250,
|
|
105
|
+
Number(process.env.AGENTIC_SECURITY_SANDBOX_PROBE_TIMEOUT_MS) || 4000,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run a trivial command through a backend's real code path and report whether
|
|
110
|
+
* confinement actually worked. Anything other than a clean confined run — a
|
|
111
|
+
* missing binary, a refused namespace, a timeout, a throw — is `false`. There
|
|
112
|
+
* is deliberately no branch that relaxes confinement to make a probe pass: a
|
|
113
|
+
* backend that can only succeed with a flag dropped is not available, it is
|
|
114
|
+
* `'disabled'`.
|
|
115
|
+
*/
|
|
116
|
+
function _probeThroughBackend(runner) {
|
|
117
|
+
let root = null;
|
|
118
|
+
try {
|
|
119
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-probe-'));
|
|
120
|
+
const r = runner(['/bin/sh', '-c', 'exit 0'], { root, timeoutMs: PROBE_TIMEOUT_MS });
|
|
121
|
+
return r?.status === 'ok';
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
} finally {
|
|
125
|
+
if (root) { try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The real probes, one per backend. The binary check stays only as a cheap
|
|
131
|
+
* pre-filter that avoids a pointless temp dir on a host that plainly lacks the
|
|
132
|
+
* primitive — it is no longer the answer, just the fast negative.
|
|
133
|
+
*/
|
|
134
|
+
export function defaultProbes() {
|
|
135
|
+
return {
|
|
136
|
+
userspace: () => (resolveUserspaceBin() ? _probeThroughBackend(runUserspace) : false),
|
|
137
|
+
namespace: () => (resolveNamespaceBin() ? _probeThroughBackend(runNamespace) : false),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Backends worth probing on a platform, most-appropriate first. */
|
|
142
|
+
export function backendCandidates(platform = process.platform) {
|
|
143
|
+
if (platform === 'darwin') return ['userspace'];
|
|
144
|
+
if (platform === 'linux') return ['namespace'];
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {object} [o]
|
|
150
|
+
* @param {string} [o.force] Bypass detection entirely (tests, and callers
|
|
151
|
+
* that want the disabled path deliberately).
|
|
152
|
+
* @param {object} [o.probes] Probe map override — a seam for tests to drive
|
|
153
|
+
* the selection contract with stand-ins on any
|
|
154
|
+
* platform. Cannot cause unconfined execution:
|
|
155
|
+
* dispatch still goes to the real backend.
|
|
156
|
+
* @param {string[]} [o.candidates] Candidate order override (same seam).
|
|
157
|
+
*/
|
|
158
|
+
export function detectBackend({ force, probes, candidates } = {}) {
|
|
159
|
+
if (force) return force;
|
|
160
|
+
if (_cached) return _cached;
|
|
161
|
+
|
|
162
|
+
const probeMap = probes || defaultProbes();
|
|
163
|
+
const order = candidates || backendCandidates();
|
|
164
|
+
|
|
165
|
+
let b = 'disabled';
|
|
166
|
+
for (const name of order) {
|
|
167
|
+
const probe = probeMap[name];
|
|
168
|
+
if (typeof probe !== 'function') continue;
|
|
169
|
+
let works = false;
|
|
170
|
+
try { works = probe() === true; } catch { works = false; }
|
|
171
|
+
if (works) { b = name; break; }
|
|
172
|
+
// Otherwise fall through to the next candidate, and ultimately to
|
|
173
|
+
// 'disabled' — never to "run it anyway".
|
|
174
|
+
}
|
|
175
|
+
_cached = b;
|
|
176
|
+
return b;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _isExecutable(p) {
|
|
180
|
+
try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
|
|
181
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Single entry point for confined execution.
|
|
2
|
+
//
|
|
3
|
+
// Fail-closed by construction: when no confinement primitive is available the
|
|
4
|
+
// disabled backend is selected, which REFUSES to execute. There is deliberately
|
|
5
|
+
// no code path that runs target code unconfined.
|
|
6
|
+
//
|
|
7
|
+
// Result shape (identical for every backend):
|
|
8
|
+
// { status, denied, stdout, stderr, exitCode, timedOut, backend }
|
|
9
|
+
// status: 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
|
|
10
|
+
// See result.js for what 'blocked' vs 'nonzero' mean and, importantly, what
|
|
11
|
+
// `denied:false` does NOT prove. runConfined never throws — a bad root or an
|
|
12
|
+
// invalid limit returns status 'error', because a caller that catches and
|
|
13
|
+
// falls back is a route to unconfined execution.
|
|
14
|
+
import { detectBackend } from './capabilities.js';
|
|
15
|
+
import { runDisabled } from './backend-disabled.js';
|
|
16
|
+
import { runUserspace } from './backend-userspace.js';
|
|
17
|
+
import { runNamespace } from './backend-namespace.js';
|
|
18
|
+
|
|
19
|
+
export { detectBackend, resetCapabilityCache } from './capabilities.js';
|
|
20
|
+
|
|
21
|
+
export function sandboxAvailable() {
|
|
22
|
+
return detectBackend() !== 'disabled';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runConfined(argv, opts = {}) {
|
|
26
|
+
const backend = detectBackend({ force: opts.force });
|
|
27
|
+
if (backend === 'userspace') return runUserspace(argv, opts);
|
|
28
|
+
if (backend === 'namespace') return runNamespace(argv, opts);
|
|
29
|
+
return runDisabled(argv, opts);
|
|
30
|
+
}
|