@clear-capabilities/agentic-security-scanner 0.130.0 → 0.133.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 +247 -0
- package/bin/agentic-security.js +39 -3
- package/dist/113.index.js +294 -5
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +7 -4
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +294 -5
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +18 -57
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +19 -10
- package/src/engine.js +48 -1
- package/src/ir/parser-js.js +8 -0
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +241 -12
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +83 -6
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/attestation.js +7 -4
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +67 -0
- package/src/posture/custom-rules.js +2 -2
- package/src/posture/execution-proof.js +44 -4
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +76 -2
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +165 -0
- package/src/posture/prove-findings.js +148 -0
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +7 -0
- package/src/runScan.js +2 -6
- package/src/sandbox/CLAUDE.md +190 -46
- package/src/sandbox/backend-namespace.js +328 -48
- package/src/sandbox/backend-userspace.js +6 -19
- package/src/sandbox/capabilities.js +132 -4
- package/src/sandbox/limits.js +21 -0
- package/src/sandbox/result.js +1 -1
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
- package/src/util/glob.js +173 -0
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -50,27 +50,68 @@ verification tier) must not read `'ok'` as "ran unimpeded". The reliable
|
|
|
50
50
|
negative evidence remains the one the escape tests use: check for the side
|
|
51
51
|
effect (the out-of-root file does not exist), not the status.
|
|
52
52
|
|
|
53
|
-
## Backend selection (`capabilities.js`)
|
|
53
|
+
## Backend selection (`capabilities.js`) — functional, not presence-based
|
|
54
54
|
|
|
55
|
-
`detectBackend({ force })`
|
|
56
|
-
|
|
55
|
+
`detectBackend({ force })` selects a backend by **executing a trivial command
|
|
56
|
+
(`exit 0`) through that backend's real code path** and reporting the backend
|
|
57
|
+
only if that run succeeds. Availability means "confinement demonstrably works
|
|
58
|
+
here", never "the confinement binary is installed".
|
|
57
59
|
|
|
58
|
-
| Platform |
|
|
60
|
+
| Platform | Candidate backend | Selected when |
|
|
59
61
|
|---|---|---|
|
|
60
|
-
| macOS family | userspace
|
|
61
|
-
| Linux family |
|
|
62
|
-
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
a
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
| macOS family | `'userspace'` | a trivial command ran confined and returned `status:'ok'` |
|
|
63
|
+
| Linux family | `'namespace'` | a trivial command ran confined and returned `status:'ok'` |
|
|
64
|
+
| any | `'disabled'` | no candidate's probe succeeded |
|
|
65
|
+
|
|
66
|
+
A candidate whose probe fails is **skipped**, detection falls through to the
|
|
67
|
+
next candidate, and with nothing left the answer is `'disabled'`. The probe is
|
|
68
|
+
never allowed to pass by weakening confinement: there is no branch that drops a
|
|
69
|
+
flag to get a green run, because a backend that can only succeed unconfined is
|
|
70
|
+
not an available backend.
|
|
71
|
+
|
|
72
|
+
**Why presence was the wrong question.** Verified on a Linux CI runner: the
|
|
73
|
+
kernel-namespace tool is installed and executable, but the distribution
|
|
74
|
+
restricts unprivileged user-namespace creation, so every privilege variant in
|
|
75
|
+
`backend-namespace.js` fails and no confined command can start. Presence-based
|
|
76
|
+
detection reported `'namespace'` and `sandboxAvailable()` answered `true` while
|
|
77
|
+
every actual run failed. `sandboxAvailable()` is the signal callers use to
|
|
78
|
+
decide whether it is safe to **execute untrusted code**; answering "the tool is
|
|
79
|
+
installed" when the honest answer is "confinement does not work here" is false
|
|
80
|
+
assurance of exactly the kind this module exists to prevent.
|
|
81
|
+
|
|
82
|
+
**On a host that restricts unprivileged namespace creation, the backend
|
|
83
|
+
therefore reports unavailable and the execution features that depend on it are
|
|
84
|
+
DISABLED — not degraded.** `detectBackend()` returns `'disabled'`,
|
|
85
|
+
`sandboxAvailable()` returns `false`, `runConfined` refuses to execute, and
|
|
86
|
+
`execution-proof.js` leaves findings at their static tier with a reason naming
|
|
87
|
+
the sandbox. Nothing runs unconfined and no weaker confinement is substituted.
|
|
88
|
+
The sandbox-dependent tests in `sandbox-escape.test.js` and
|
|
89
|
+
`execution-proof.test.js` skip there, each with an explicit
|
|
90
|
+
"SKIPPED, NOT PASSED … UNVERIFIED here" reason — a skip is a declared gap in
|
|
91
|
+
verification, never a pass.
|
|
92
|
+
|
|
93
|
+
**Cost and bounds.** The probe costs one spawn and its result (positive *and*
|
|
94
|
+
negative) is cached for the process, so ordinary scans pay it at most once;
|
|
95
|
+
`resetCapabilityCache()` clears it. The probe runs with a short timeout
|
|
96
|
+
(4 s default, `AGENTIC_SECURITY_SANDBOX_PROBE_TIMEOUT_MS` to override) and a
|
|
97
|
+
throw is treated as a failure, so a capability check can never hang a scan.
|
|
98
|
+
`force` bypasses probing entirely.
|
|
99
|
+
|
|
100
|
+
`detectBackend` also accepts `{ probes, candidates }` — a test seam that drives
|
|
101
|
+
the selection contract with stand-ins on any platform. It cannot produce
|
|
102
|
+
unconfined execution: dispatch in `index.js` still goes to the real backend.
|
|
103
|
+
|
|
104
|
+
Each primitive's binary is resolved across a **candidate list** of plausible
|
|
105
|
+
install paths (`CONFINE_BINS_USERSPACE` / `CONFINE_BINS_NAMESPACE`), not a
|
|
106
|
+
single hardcoded path, and that lookup now serves only as a cheap fast-negative
|
|
107
|
+
before the real probe. A miss still fails closed to `'disabled'`, which is safe
|
|
108
|
+
— but a single path would be a false negative on any distribution that installs
|
|
109
|
+
the binary elsewhere, silently costing that host its sandbox. The backends run
|
|
110
|
+
the resolved path, not the canonical one.
|
|
70
111
|
|
|
71
112
|
## Fail-closed rule
|
|
72
113
|
|
|
73
|
-
If no
|
|
114
|
+
If no candidate backend's functional probe succeeds, `detectBackend` returns `'disabled'` and
|
|
74
115
|
`runConfined` dispatches to `backend-disabled.js`, which **refuses to execute
|
|
75
116
|
the command at all** — it returns `status: 'disabled'` without ever spawning
|
|
76
117
|
a process. There is no code path in this module that runs target code
|
|
@@ -111,35 +152,138 @@ other.
|
|
|
111
152
|
silently do nothing — an unenforced limit must never look like an enforced
|
|
112
153
|
one.
|
|
113
154
|
|
|
114
|
-
**Kernel-namespace backend (Linux family) —
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
**Kernel-namespace backend (Linux family) — verified by execution in CI.** The
|
|
156
|
+
required namespace tool is absent on the macOS development host, so
|
|
157
|
+
`backend-namespace.js`'s escape tests skip there with a recorded reason rather
|
|
158
|
+
than being asserted against. The verification therefore lives in CI, and it has
|
|
159
|
+
now run: on the `sandbox-linux` job the functional probe selected the
|
|
160
|
+
`namespace` backend and the full escape suite executed and passed (Ubuntu
|
|
161
|
+
24.04, kernel `6.17.0-1020-azure`, 41 assertions, 0 failures). All eight
|
|
162
|
+
escape-attempt cases are asserted on that host in both directions — in-root
|
|
163
|
+
write succeeds, out-of-root write is blocked and creates no file, a denied
|
|
164
|
+
write is not reported as a clean run, the confined process cannot rebind the
|
|
165
|
+
filesystem writable again, an ordinary non-zero exit stays `nonzero` rather
|
|
166
|
+
than `blocked`, the parent environment is not handed over, outbound network is
|
|
167
|
+
blocked, and the wall-clock behaviour is pinned as a KNOWN GAP (see below —
|
|
168
|
+
the timeout does not actually stop the payload here).
|
|
169
|
+
|
|
170
|
+
**The timeout: two wrong claims, settled by two CI runs.** This guide once
|
|
171
|
+
carried the userspace caveat verbatim ("stops the direct child, not the process
|
|
172
|
+
tree") and `backend-namespace.js` went further, reasoning that killing pid 1 of a
|
|
173
|
+
PID namespace would reap everything and beat userspace. A test was added to check
|
|
174
|
+
rather than assume, and CI corrected it twice:
|
|
175
|
+
|
|
176
|
+
1. With the default **SIGTERM** the timeout did nothing: a 1200 ms budget against
|
|
177
|
+
a payload sleeping 30 s returned after `30057 ms`, payload run to completion.
|
|
178
|
+
The kernel drops default-action signals sent to a PID namespace's pid 1 from
|
|
179
|
+
outside it.
|
|
180
|
+
2. With **SIGKILL** — which cannot be ignored — the call returns in about 1.2 s,
|
|
181
|
+
so the direct child IS bounded. A backgrounded grandchild still survived and
|
|
182
|
+
wrote its marker.
|
|
183
|
+
|
|
184
|
+
**Settled behaviour: SIGKILL bounds the direct child promptly; it does not reap
|
|
185
|
+
the process tree.** That is the same limitation the userspace backend carries —
|
|
186
|
+
not better, which is what this module claimed for a long time. Confinement is
|
|
187
|
+
unaffected: survivors stay inside the mount and network namespaces and can
|
|
188
|
+
neither write out of root nor reach the network. What is missing is a bound on
|
|
189
|
+
how long descendants run, so a caller needing one must impose it itself
|
|
190
|
+
(`posture/prove-findings.js` does). Pinned by "KNOWN GAP: the timeout bounds the
|
|
191
|
+
direct child but does NOT reap the tree", which fails in both directions.
|
|
192
|
+
|
|
193
|
+
One further limit is unchanged: this is one kernel and one
|
|
194
|
+
image: a different kernel is a different host fact, which is exactly why the
|
|
195
|
+
job runs per push rather than being recorded once and trusted forever.
|
|
196
|
+
|
|
197
|
+
**How it stays verified: the `sandbox-linux` CI job.** Hosted runners restrict
|
|
198
|
+
unprivileged user-namespace creation at the kernel's access-control layer, so
|
|
199
|
+
the functional probe fails by default and the escape suite would skip — which
|
|
200
|
+
is why this backend went unverified for as long as it did. The `sandbox-linux`
|
|
201
|
+
job in `.github/workflows/ci.yml` relaxes that **host policy** for itself (it
|
|
202
|
+
has passwordless root) and then runs the existing suite unchanged. It relaxes a
|
|
203
|
+
restriction on creating namespaces; it does not relax a single assertion or
|
|
204
|
+
confinement flag. `scripts/sandbox-linux-verify.mjs` then prints the selected
|
|
205
|
+
backend and `RAN`/`SKIPPED` for every test and **exits non-zero unless the
|
|
206
|
+
kernel-namespace suite actually ran**, so a skip can never be mistaken for a
|
|
207
|
+
pass in a green job. That guard is what makes the verification durable rather
|
|
208
|
+
than a one-time observation: if a future runner image re-tightens the policy,
|
|
209
|
+
the job fails rather than quietly reverting to "skipped, green".
|
|
210
|
+
|
|
211
|
+
**Privilege: the namespaces are acquired unprivileged, and the flag set is
|
|
212
|
+
probed rather than assumed.** Creating mount/PID/IPC/UTS/network namespaces
|
|
213
|
+
directly requires `CAP_SYS_ADMIN`; an ordinary CI account does not have it, so
|
|
214
|
+
asking for them bare fails with a permission error and the backend cannot start
|
|
215
|
+
at all. `resolveNamespaceArgs()` therefore tries an ordered list of
|
|
216
|
+
privilege-acquisition prefixes — user namespace with the invoking user mapped
|
|
217
|
+
to root inside it, then user namespace with the user mapped to itself, then no
|
|
218
|
+
prefix (which needs root) — and **executes a trivial command under each**,
|
|
219
|
+
selecting the first that actually succeeds. The result is cached per
|
|
220
|
+
binary/network shape and cleared by `resetCapabilityCache()`.
|
|
221
|
+
|
|
222
|
+
The confinement flags are identical across every variant and are **never
|
|
223
|
+
relaxed to make a run succeed**: `--net` is present in every probed variant
|
|
224
|
+
whenever `allowNetwork` is false, and `--mount` unconditionally, because the
|
|
225
|
+
write confinement is built inside that mount namespace — dropping either to
|
|
226
|
+
get a green run would remove a confinement. If no variant succeeds the backend returns
|
|
227
|
+
`status: 'error'` and **nothing is executed**, the same fail-closed rule as the
|
|
228
|
+
disabled backend. The selection contract (flags always present, `allowNetwork`
|
|
229
|
+
the only way `--net` is absent, `null` when every probe fails) is asserted by
|
|
230
|
+
executing tests in `sandbox.test.js` driven with stand-in binaries, so it holds
|
|
231
|
+
on any platform; whether a given kernel actually grants the namespaces is a
|
|
232
|
+
per-host fact only that host can answer.
|
|
233
|
+
|
|
234
|
+
**Write confinement: implemented and verified in CI** (see the escape-suite
|
|
235
|
+
result above; cases 1–4 are exactly this mechanism). This backend used to confine
|
|
236
|
+
network egress and nothing else — no remount, no bind mount, no `pivot_root`,
|
|
237
|
+
just a `cd` — so an absolute out-of-root write succeeded. That gap is now
|
|
238
|
+
closed in code:
|
|
239
|
+
|
|
240
|
+
1. A **private mount namespace** in which every mount point present at setup
|
|
241
|
+
time is rebound **read-only**, and only the sandbox root is rebound
|
|
242
|
+
read-write. An out-of-root write therefore fails with `EROFS`, whose error
|
|
243
|
+
text is one of `result.js`'s denial patterns — so an escape attempt
|
|
244
|
+
surfaces as `status:'blocked'` + `denied:true`, the same shape the
|
|
245
|
+
userspace backend produces for the same attempt.
|
|
246
|
+
2. A **capability drop** (whole bounding + inheritable set, plus the `noroot`
|
|
247
|
+
secure bits so uid 0 stops implying privilege) applied *after* the mounts
|
|
248
|
+
and *before* the caller's command. Without it the payload would hold
|
|
249
|
+
`CAP_SYS_ADMIN` over its own mount namespace — the namespaces are acquired
|
|
250
|
+
via a user namespace — and could simply rebind the tree writable again.
|
|
251
|
+
3. A **per-run proof by execution**, not a reasoned expectation. The parent
|
|
252
|
+
seeds a canary path *outside* the sandbox root; the confined shell, already
|
|
253
|
+
in its final deprivileged state, attempts to create it and refuses to
|
|
254
|
+
`exec` the caller's command if that write succeeds. The parent then
|
|
255
|
+
re-checks the canary from outside, so the verdict does not depend on the
|
|
256
|
+
confined shell being honest about its own exit code.
|
|
257
|
+
|
|
258
|
+
**Why read-only rebind and not `pivot_root`.** `pivot_root` is the stronger
|
|
259
|
+
primitive — after detaching the old root, out-of-root paths are absent from
|
|
260
|
+
the mount namespace rather than merely read-only. It was rejected for three
|
|
261
|
+
concrete reasons. (a) It requires materialising a system tree (shell, C
|
|
262
|
+
library, whatever a PoC invokes) inside the *caller's* sandbox root, polluting
|
|
263
|
+
a directory the caller owns and reads back. (b) It changes path semantics —
|
|
264
|
+
`$ROOT` becomes `/` — so the two backends stop being interchangeable for the
|
|
265
|
+
same caller input. (c) An out-of-root write would then fail with `ENOENT`,
|
|
266
|
+
indistinguishable from an ordinary missing path, which destroys the `denied`
|
|
267
|
+
signal exactly where it matters most. The read-only rebind keeps paths, keeps
|
|
268
|
+
the denial signal, and keeps both backends answering the same way.
|
|
269
|
+
|
|
270
|
+
**Fail-closed throughout.** No namespace variant, no filesystem-attach
|
|
271
|
+
utility, a mount tree that cannot be rebound read-only, a sandbox root that
|
|
272
|
+
cannot be rebound writable, or a canary that turns out writable — each returns
|
|
273
|
+
`status:'error'` with **nothing executed**. There is no branch that proceeds
|
|
274
|
+
with the filesystem open.
|
|
275
|
+
|
|
276
|
+
**The one hardening that can be absent, and it is declared.** If the
|
|
277
|
+
privilege-dropping utility is not on the host, the command still runs under
|
|
278
|
+
the read-only mount tree but the result carries `privilegeDrop` in the
|
|
279
|
+
`unsupported` list (surfaced on stderr as `[sandbox] not enforceable here:
|
|
280
|
+
privilegeDrop`), the same mechanism `limits.js` uses for an unenforceable
|
|
281
|
+
limit. It is never silently skipped, and the escape test that covers the
|
|
282
|
+
rebind attack fails if it is missing rather than quietly passing.
|
|
283
|
+
|
|
284
|
+
**None of the above has been executed anywhere yet.** It is asserted by
|
|
285
|
+
`sandbox-escape.test.js`'s kernel-namespace suite, which skips on macOS. Until
|
|
286
|
+
a CI log shows that suite `RAN`, this section describes code, not evidence.
|
|
143
287
|
|
|
144
288
|
## Timeout does not kill the process tree
|
|
145
289
|
|
|
@@ -163,8 +307,8 @@ needs the same Linux-host verification as everything else on that backend.
|
|
|
163
307
|
|
|
164
308
|
The userspace policy allows `(allow file-read*)` globally — that backend
|
|
165
309
|
confines **writes**, network egress, and resource use, but **not reads**. (The
|
|
166
|
-
kernel-namespace backend
|
|
167
|
-
|
|
310
|
+
kernel-namespace backend has the same cut: per the section above its mount
|
|
311
|
+
tree is rebound read-only, not detached, so everything on it stays readable.)
|
|
168
312
|
A confined command can read any file on the host the OS-level
|
|
169
313
|
permissions allow, including outside the sandbox root. Exfiltration of
|
|
170
314
|
readable host files (writing what was read to network or to a location the
|