@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/bin/agentic-security.js +39 -3
  3. package/dist/113.index.js +294 -5
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +7 -4
  6. package/dist/238.index.js +218 -0
  7. package/dist/259.index.js +975 -0
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +294 -5
  11. package/dist/637.index.js +1 -1
  12. package/dist/agentic-security.mjs +18 -57
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/package.json +19 -10
  15. package/src/engine.js +48 -1
  16. package/src/ir/parser-js.js +8 -0
  17. package/src/llm-validator/cost-ceiling.js +199 -0
  18. package/src/llm-validator/index.js +241 -12
  19. package/src/llm-validator/local-endpoint.js +90 -0
  20. package/src/mcp/tools.js +2 -2
  21. package/src/posture/CLAUDE.md +83 -6
  22. package/src/posture/accuracy-scorecard.js +37 -6
  23. package/src/posture/attestation.js +7 -4
  24. package/src/posture/corpus-enroll.js +303 -0
  25. package/src/posture/corpus-match.js +67 -0
  26. package/src/posture/custom-rules.js +2 -2
  27. package/src/posture/execution-proof.js +44 -4
  28. package/src/posture/fix-metrics.js +197 -0
  29. package/src/posture/fix-verify.js +76 -2
  30. package/src/posture/integrity.js +42 -9
  31. package/src/posture/learning.js +8 -1
  32. package/src/posture/model-routing.js +26 -0
  33. package/src/posture/model-trust.js +174 -0
  34. package/src/posture/poc-inprocess.js +165 -0
  35. package/src/posture/prove-findings.js +148 -0
  36. package/src/posture/root-cause-sweep.js +0 -0
  37. package/src/posture/rule-overrides.js +64 -3
  38. package/src/posture/state-dir.js +25 -0
  39. package/src/posture/vuln-archaeology.js +231 -0
  40. package/src/report/index.js +7 -0
  41. package/src/runScan.js +2 -6
  42. package/src/sandbox/CLAUDE.md +190 -46
  43. package/src/sandbox/backend-namespace.js +328 -48
  44. package/src/sandbox/backend-userspace.js +6 -19
  45. package/src/sandbox/capabilities.js +132 -4
  46. package/src/sandbox/limits.js +21 -0
  47. package/src/sandbox/result.js +1 -1
  48. package/src/sast/CLAUDE.md +4 -0
  49. package/src/sast/crypto-specialist.js +247 -0
  50. package/src/util/glob.js +173 -0
@@ -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 })` probes for one confinement primitive, cached after
56
- the first call (`resetCapabilityCache()` clears it, used between tests):
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 | Primitive checked | Backend selected |
60
+ | Platform | Candidate backend | Selected when |
59
61
  |---|---|---|
60
- | macOS family | userspace confinement binary present and executable | `'userspace'` |
61
- | Linux family | kernel-namespace tool present and executable | `'namespace'` |
62
- | neither found | — | `'disabled'` |
63
-
64
- Each primitive is probed across a **candidate list** of plausible install
65
- paths (`CONFINE_BINS_USERSPACE` / `CONFINE_BINS_NAMESPACE`), not a single
66
- hardcoded path. A miss still fails closed to `'disabled'`, which is safe but
67
- a single path would be a false negative on any distribution that installs the
68
- binary elsewhere, silently costing that host its sandbox. The backends run the
69
- resolved path, not the canonical one.
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 primitive is found, `detectBackend` returns `'disabled'` and
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) — implemented, NOT verified on this
115
- platform.** The required namespace tool is absent on the macOS development
116
- host, so `backend-namespace.js`'s escape tests skip with a recorded reason
117
- rather than being asserted against. Nothing in this guide should be read as a
118
- claim that the namespace backend's isolation has been demonstrated by
119
- execution anywhere. It must be verified on a Linux host — with the same
120
- both-direction escape-attempt tests used for the userspace backend — before
121
- anything downstream (e.g. an R2 execution-verification tier) relies on it.
122
-
123
- **And it confines less than "unverified" suggests. Writes are NOT confined on
124
- this backend that is false by inspection, not merely undemonstrated.** The
125
- backend enters new mount/PID/IPC/UTS namespaces and, by default, an empty
126
- network namespace. The empty network namespace is the *only* confinement it
127
- implements: it has no route anywhere, which denies egress. For the
128
- filesystem there is **no remount, no bind mount and no `pivot_root`** — only a
129
- `cd` into the sandbox root. `cd` sets the working directory; it does not
130
- restrict where a process may write. A confined command writing to an absolute
131
- path outside the root (a home directory, a system config path) will
132
- **succeed**, subject only to ordinary filesystem permissions. The new mount
133
- namespace isolates mount-table *changes* made by the confined process; it does
134
- not make the host filesystem read-only.
135
-
136
- On a Linux host `detectBackend()` selects this backend automatically, so a
137
- caller there gets network isolation and resource limits and **no write
138
- confinement at all**. Do not run anything on that path that must not touch the
139
- host filesystem. Closing the gap means implementing a read-only remount (or
140
- equivalent) *and* verifying it by execution on a Linux host with both-direction
141
- escape tests — the guide must not claim write confinement here before both
142
- have happened.
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 confines *less* than that: per the section above, it
167
- implements network isolation only and does **not** confine writes at all.)
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