@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.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 +101 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +209 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -162
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +22 -14
- 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 +154 -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 +5 -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/posture/CLAUDE.md +115 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-verify.js +55 -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/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 +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/yaml.js +24 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# src/sandbox/
|
|
2
|
+
|
|
3
|
+
Confined execution facility for running untrusted target code and candidate
|
|
4
|
+
exploits (R1 of `docs/ROADMAP.md`). This is a hard prerequisite for anything
|
|
5
|
+
that executes code the scanner did not write — no other module in this
|
|
6
|
+
repository runs target code, confined or otherwise.
|
|
7
|
+
|
|
8
|
+
## Entry point
|
|
9
|
+
|
|
10
|
+
Everything goes through `index.js`:
|
|
11
|
+
|
|
12
|
+
- `sandboxAvailable() -> boolean` — true iff a real confinement primitive was
|
|
13
|
+
detected on this host.
|
|
14
|
+
- `runConfined(argv, opts) -> { status, denied, stdout, stderr, exitCode, timedOut, backend }`
|
|
15
|
+
— dispatches to whichever backend `detectBackend()` selected. `opts.force`
|
|
16
|
+
overrides detection (used by tests, and by any caller that wants to force
|
|
17
|
+
the disabled path deliberately).
|
|
18
|
+
|
|
19
|
+
`status` is one of `'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' |
|
|
20
|
+
'error'`. All three backends return the identical shape, so callers never
|
|
21
|
+
branch on which backend ran. **`runConfined` never throws** — a missing
|
|
22
|
+
`root`, an unresolvable root, a missing confinement binary, or an invalid
|
|
23
|
+
resource limit all return `status: 'error'` in the normal shape. (A caller
|
|
24
|
+
that wraps it in `try`/`catch` and "falls back" is a classic route to
|
|
25
|
+
unconfined execution, so there is nothing to catch.)
|
|
26
|
+
|
|
27
|
+
## `blocked` vs `nonzero` vs `ok` — and the limit of what is observable
|
|
28
|
+
|
|
29
|
+
An earlier version derived `status` purely from the exit code, which conflated
|
|
30
|
+
two unrelated outcomes: a program that ran fine and exited 3 was reported
|
|
31
|
+
`'blocked'`, while a program whose out-of-root write was **denied** but which
|
|
32
|
+
exited 0 was reported `'ok'` — a clean run, as far as the caller could tell.
|
|
33
|
+
Both are now separated:
|
|
34
|
+
|
|
35
|
+
| Field | Meaning |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `denied: true` | A confinement violation was **observed** in the confined process's error output. |
|
|
38
|
+
| `status: 'blocked'` | `denied` was true — something was refused. |
|
|
39
|
+
| `status: 'nonzero'` | The command exited non-zero with **no** denial observed. Ordinary program failure, not a confinement event. |
|
|
40
|
+
| `status: 'ok'` | Exited 0 with no denial observed. |
|
|
41
|
+
|
|
42
|
+
**What `denied: false` does not mean.** The signal is read from the confined
|
|
43
|
+
process's own stderr — these OS primitives give the parent no structured
|
|
44
|
+
violation channel. A program that writes outside the root and swallows its own
|
|
45
|
+
error message produces no signal at all, so `denied: false` means "no denial
|
|
46
|
+
was observed", **not** "no denial occurred". `status: 'ok'` is proof that the
|
|
47
|
+
command exited 0 and said nothing about a refusal; it is **not** proof that
|
|
48
|
+
the sandbox refused nothing. Downstream consumers (e.g. an R2 execution
|
|
49
|
+
verification tier) must not read `'ok'` as "ran unimpeded". The reliable
|
|
50
|
+
negative evidence remains the one the escape tests use: check for the side
|
|
51
|
+
effect (the out-of-root file does not exist), not the status.
|
|
52
|
+
|
|
53
|
+
## Backend selection (`capabilities.js`)
|
|
54
|
+
|
|
55
|
+
`detectBackend({ force })` probes for one confinement primitive, cached after
|
|
56
|
+
the first call (`resetCapabilityCache()` clears it, used between tests):
|
|
57
|
+
|
|
58
|
+
| Platform | Primitive checked | Backend selected |
|
|
59
|
+
|---|---|---|
|
|
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.
|
|
70
|
+
|
|
71
|
+
## Fail-closed rule
|
|
72
|
+
|
|
73
|
+
If no primitive is found, `detectBackend` returns `'disabled'` and
|
|
74
|
+
`runConfined` dispatches to `backend-disabled.js`, which **refuses to execute
|
|
75
|
+
the command at all** — it returns `status: 'disabled'` without ever spawning
|
|
76
|
+
a process. There is no code path in this module that runs target code
|
|
77
|
+
unconfined. An unavailable sandbox disables the execution feature; it never
|
|
78
|
+
silently degrades to running the command directly. This is proven by an
|
|
79
|
+
executing test (`sandbox.test.js`): the disabled backend is invoked with a
|
|
80
|
+
command that would create a marker file, and the test asserts the file does
|
|
81
|
+
not exist afterward.
|
|
82
|
+
|
|
83
|
+
## What is verified on which platform
|
|
84
|
+
|
|
85
|
+
This module was developed and its tests run on a macOS host. Guarantees below
|
|
86
|
+
are stated per platform — do not extrapolate one platform's result to the
|
|
87
|
+
other.
|
|
88
|
+
|
|
89
|
+
**Userspace backend (macOS family) — verified by execution on this platform:**
|
|
90
|
+
- A write outside the sandbox root is blocked; the target file is never
|
|
91
|
+
created.
|
|
92
|
+
- Outbound network connections are blocked.
|
|
93
|
+
- A wall-clock overrun stops the **direct child** (`status: 'timeout'`,
|
|
94
|
+
`timedOut: true`) — but see "Timeout does not kill the process tree" below.
|
|
95
|
+
This is not full termination and must not be described as such.
|
|
96
|
+
- Benign in-root work (writes inside the root, ordinary commands) still
|
|
97
|
+
succeeds — the gate holds in both directions, not just the blocking one.
|
|
98
|
+
- Fork-storm containment is **weak, not strong, on this platform**. The
|
|
99
|
+
process-count limit (`ulimit -u` / `RLIMIT_NPROC`) is a per-uid, **system-wide**
|
|
100
|
+
cap here, not a per-process-tree cap — it counts every process the user
|
|
101
|
+
owns on the whole machine, not just the sandboxed subtree. Ambient process
|
|
102
|
+
count for a normal user on this host is on the order of several hundred, so
|
|
103
|
+
any usable cap has to sit at "ambient + margin" or it starves the user's own
|
|
104
|
+
unrelated processes before the sandboxed command even starts. That means a
|
|
105
|
+
fork storm inside the sandbox can still spawn a meaningful number of
|
|
106
|
+
processes — bounded to ambient-plus-margin, not to some small absolute
|
|
107
|
+
number — before the cap bites. Treat this as a soft brake, not a hard wall.
|
|
108
|
+
- Address-space capping (`ulimit -v`) is **not enforceable** on this platform.
|
|
109
|
+
`limits.js` (`buildLimitPrelude`) detects this and reports the limit in its
|
|
110
|
+
`unsupported` array instead of emitting a `ulimit -v` line that would
|
|
111
|
+
silently do nothing — an unenforced limit must never look like an enforced
|
|
112
|
+
one.
|
|
113
|
+
|
|
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.
|
|
143
|
+
|
|
144
|
+
## Timeout does not kill the process tree
|
|
145
|
+
|
|
146
|
+
`timeoutMs` is enforced with `spawnSync`'s timeout, which signals **only the
|
|
147
|
+
process this module spawned**. Verified by execution on the macOS family: with
|
|
148
|
+
`timeoutMs: 1200`, a command that backgrounded a 4-second child returned
|
|
149
|
+
`status: 'timeout'` and the grandchild survived, completing its work *after*
|
|
150
|
+
the result was returned. So `'timeout'` means "we stopped waiting and killed
|
|
151
|
+
the process we spawned", not "the process tree was terminated". Survivors stay
|
|
152
|
+
inside the policy profile — their writes and egress remain confined — but they
|
|
153
|
+
are still running and still consuming resources. A caller that needs a hard
|
|
154
|
+
tree kill must implement it.
|
|
155
|
+
|
|
156
|
+
The namespace backend is structurally better here: it runs the confined
|
|
157
|
+
command under `--pid --fork`, so the direct child is pid 1 of a new PID
|
|
158
|
+
namespace and killing it should take the namespace's processes with it. That
|
|
159
|
+
is a reasoned expectation from the flags, **not** an executed result — it
|
|
160
|
+
needs the same Linux-host verification as everything else on that backend.
|
|
161
|
+
|
|
162
|
+
## Known limitation, deliberately accepted: reads are not confined
|
|
163
|
+
|
|
164
|
+
The userspace policy allows `(allow file-read*)` globally — that backend
|
|
165
|
+
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.)
|
|
168
|
+
A confined command can read any file on the host the OS-level
|
|
169
|
+
permissions allow, including outside the sandbox root. Exfiltration of
|
|
170
|
+
readable host files (writing what was read to network or to a location the
|
|
171
|
+
attacker later reads through some other channel) is **out of scope for this
|
|
172
|
+
module**. This is a deliberate R1 scope cut, not an oversight: tightening
|
|
173
|
+
reads requires a threat model for what a confined process may legitimately
|
|
174
|
+
need to read, which belongs with the execution-verification work that
|
|
175
|
+
consumes this sandbox, not with the sandbox primitive itself.
|
|
176
|
+
|
|
177
|
+
### The parent environment is NOT one of the things a confined process may read
|
|
178
|
+
|
|
179
|
+
Secrets carried in the parent process's environment (API tokens, cloud keys,
|
|
180
|
+
registry auth) are a *distinct* exposure from unconfined file reads — the
|
|
181
|
+
sandbox would be handing them over rather than merely failing to hide them —
|
|
182
|
+
so they are not covered by the scope cut above. Every real backend therefore
|
|
183
|
+
runs the command with a **minimal constructed environment**
|
|
184
|
+
(`buildConfinedEnv` in `result.js`): `PATH`, `ROOT`, `HOME`, `TMPDIR`, `LANG`,
|
|
185
|
+
with `HOME`/`TMPDIR` pointed at the sandbox root. `process.env` is not
|
|
186
|
+
forwarded. A caller that genuinely needs a variable inside passes it
|
|
187
|
+
explicitly as `opts.env`, which is merged on top of the base — an opt-in, one
|
|
188
|
+
variable at a time, not a blanket export.
|
|
189
|
+
|
|
190
|
+
## Resource limits (`limits.js`)
|
|
191
|
+
|
|
192
|
+
`buildLimitPrelude({ maxProcs, maxFileSizeKb, maxAddressSpaceKb })` returns
|
|
193
|
+
`{ prelude, unsupported }`. `prelude` is a shell fragment of `ulimit` calls to
|
|
194
|
+
prefix before the confined command; `unsupported` lists any requested limit
|
|
195
|
+
that the current platform cannot enforce, so a caller can log or surface that
|
|
196
|
+
degradation rather than assume the limit applied silently.
|
|
197
|
+
|
|
198
|
+
Limit values are interpolated into a shell fragment, so they are **coerced
|
|
199
|
+
with `Number()` and rejected unless finite and non-negative** (`RangeError`,
|
|
200
|
+
which the backends turn into `status: 'error'`). Before that, a
|
|
201
|
+
config-supplied string such as `'999; echo INJECTED'` was emitted verbatim and
|
|
202
|
+
its payload ran — not an escape (the prelude runs inside the confinement) but
|
|
203
|
+
a way for a config-derived value to silently *disable* the limits it was
|
|
204
|
+
supposed to set.
|
|
205
|
+
|
|
206
|
+
## Extending this module
|
|
207
|
+
|
|
208
|
+
- Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
|
|
209
|
+
keep returning the exact same result shape as each other and as
|
|
210
|
+
`backend-disabled.js` — callers dispatch on `status`/`backend`, not on
|
|
211
|
+
which module ran.
|
|
212
|
+
- Any new backend must add its own both-direction escape test
|
|
213
|
+
(`sandbox-escape.test.js`) before being wired into `index.js`: a `GOOD` case
|
|
214
|
+
showing legitimate in-root work still succeeds, and one `BAD` case per
|
|
215
|
+
escape vector the backend claims to block.
|
|
216
|
+
- Do not add a code path that runs a command when `detectBackend()` returns
|
|
217
|
+
`'disabled'`. If a future backend needs a new capability check, add it to
|
|
218
|
+
`detectBackend`, not around it.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Fail-closed backend. Selected when no confinement primitive is available.
|
|
2
|
+
// It must NEVER execute the command — an unavailable sandbox disables
|
|
3
|
+
// execution features, it does not bypass them.
|
|
4
|
+
export function runDisabled(_argv, _opts) {
|
|
5
|
+
return {
|
|
6
|
+
status: 'disabled',
|
|
7
|
+
denied: false,
|
|
8
|
+
stdout: '',
|
|
9
|
+
stderr: 'agentic-security: refusing to execute — no confinement primitive available on this host.',
|
|
10
|
+
exitCode: null,
|
|
11
|
+
timedOut: false,
|
|
12
|
+
backend: 'disabled',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Kernel-namespace confinement backend (Linux family).
|
|
2
|
+
//
|
|
3
|
+
// STATUS: implemented, not verified on this platform — the required
|
|
4
|
+
// kernel-namespace tool is not present here, so its escape tests skip with a
|
|
5
|
+
// recorded reason. Verify on a Linux host before relying on it for R2.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS BACKEND ACTUALLY CONFINES — do not overstate it. It enters new
|
|
8
|
+
// mount/PID/IPC/UTS namespaces and, unless `allowNetwork`, an empty network
|
|
9
|
+
// namespace. The empty network namespace has no route anywhere, and that is
|
|
10
|
+
// the ONE confinement this backend implements: network egress.
|
|
11
|
+
//
|
|
12
|
+
// It does NOT confine writes. There is no remount, no bind mount and no
|
|
13
|
+
// pivot_root here — only a `cd` into the sandbox root. A `cd` sets the working
|
|
14
|
+
// directory; it does not restrict where a process may write. A confined
|
|
15
|
+
// command that writes to an absolute path outside the root (a home directory,
|
|
16
|
+
// a system config path) will SUCCEED, subject only to ordinary filesystem
|
|
17
|
+
// permissions. The new mount namespace isolates mount-table CHANGES made by
|
|
18
|
+
// the confined process from the host; it does not make the host filesystem
|
|
19
|
+
// read-only. Treat filesystem confinement as ABSENT on this backend until a
|
|
20
|
+
// read-only remount is implemented AND verified by execution on a Linux host.
|
|
21
|
+
//
|
|
22
|
+
// TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
|
|
23
|
+
// the direct child. On this backend the direct child is the namespace tool
|
|
24
|
+
// running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
|
|
25
|
+
// expected to take the whole namespace's processes with it — better than the
|
|
26
|
+
// userspace backend, where a backgrounded grandchild demonstrably survives.
|
|
27
|
+
// "Expected", not verified: like everything else here it needs a Linux host.
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import { resolveNamespaceBin } from './capabilities.js';
|
|
31
|
+
import { buildLimitPrelude } from './limits.js';
|
|
32
|
+
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
33
|
+
|
|
34
|
+
export function runNamespace(argv, {
|
|
35
|
+
root,
|
|
36
|
+
timeoutMs = 10000,
|
|
37
|
+
allowNetwork = false,
|
|
38
|
+
limits = {},
|
|
39
|
+
env = {},
|
|
40
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
41
|
+
} = {}) {
|
|
42
|
+
// Documented shape, never a throw — see the same note in backend-userspace.
|
|
43
|
+
if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
|
|
44
|
+
|
|
45
|
+
const bin = resolveNamespaceBin();
|
|
46
|
+
if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
|
|
47
|
+
|
|
48
|
+
let resolvedRoot;
|
|
49
|
+
try {
|
|
50
|
+
// Resolve symlinks so the path the kernel actually sees matches what we
|
|
51
|
+
// hand to the child.
|
|
52
|
+
resolvedRoot = fs.realpathSync(root);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let prelude, unsupported;
|
|
58
|
+
try {
|
|
59
|
+
({ prelude, unsupported } = buildLimitPrelude(limits));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return errorResult('namespace', `invalid resource limit: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
// `cd` only sets the working directory — it is NOT write confinement. See
|
|
64
|
+
// the header note.
|
|
65
|
+
const inner = `${prelude}cd "$ROOT" && exec "$@"`;
|
|
66
|
+
|
|
67
|
+
const nsArgs = ['--mount', '--pid', '--ipc', '--uts', '--fork'];
|
|
68
|
+
if (!allowNetwork) nsArgs.push('--net');
|
|
69
|
+
|
|
70
|
+
const r = spawnSync(
|
|
71
|
+
bin,
|
|
72
|
+
[...nsArgs, '/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: 'namespace', spawnResult: r, unsupported });
|
|
83
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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 } from './limits.js';
|
|
18
|
+
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
19
|
+
|
|
20
|
+
// `ulimit -u` (RLIMIT_NPROC) is a per-uid, system-wide cap on this platform,
|
|
21
|
+
// not a per-process-tree cap (verified by execution in Task 2). A fixed
|
|
22
|
+
// default like the 64 in buildLimitPrelude() breaks ordinary, non-adversarial
|
|
23
|
+
// runs on any host whose user already has more than ~64 ambient processes —
|
|
24
|
+
// which is common. So unless the caller passes an explicit maxProcs, we
|
|
25
|
+
// compute one relative to the ambient count for this uid, the same
|
|
26
|
+
// workaround Task 2 used in its own test, to keep default behavior usable
|
|
27
|
+
// without pretending a low fixed cap is real containment.
|
|
28
|
+
function _ambientProcCount() {
|
|
29
|
+
try {
|
|
30
|
+
const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
|
|
31
|
+
return Number(String(out.stdout || '').trim()) || 200;
|
|
32
|
+
} catch {
|
|
33
|
+
return 200;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _profile({ allowNetwork }) {
|
|
38
|
+
return [
|
|
39
|
+
'(version 1)',
|
|
40
|
+
'(deny default)',
|
|
41
|
+
'(allow process-exec process-fork)',
|
|
42
|
+
'(allow sysctl-read)',
|
|
43
|
+
'(allow file-read*)',
|
|
44
|
+
'(allow file-write* (subpath (param "ROOT")))',
|
|
45
|
+
allowNetwork ? '(allow network*)' : '',
|
|
46
|
+
].filter(Boolean).join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runUserspace(argv, {
|
|
50
|
+
root,
|
|
51
|
+
timeoutMs = 10000,
|
|
52
|
+
allowNetwork = false,
|
|
53
|
+
limits = {},
|
|
54
|
+
env = {},
|
|
55
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
56
|
+
} = {}) {
|
|
57
|
+
// Documented shape, never a throw: a caller that wraps this in try/catch and
|
|
58
|
+
// "falls back" is a classic route to unconfined execution.
|
|
59
|
+
if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
|
|
60
|
+
|
|
61
|
+
const bin = resolveUserspaceBin();
|
|
62
|
+
if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
|
|
63
|
+
|
|
64
|
+
let resolvedRoot;
|
|
65
|
+
try {
|
|
66
|
+
// Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
|
|
67
|
+
// subpath param matches the path the kernel actually sees.
|
|
68
|
+
resolvedRoot = fs.realpathSync(root);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const effectiveLimits = {
|
|
74
|
+
...limits,
|
|
75
|
+
maxProcs: limits.maxProcs ?? (_ambientProcCount() + 64),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let prelude, unsupported;
|
|
79
|
+
try {
|
|
80
|
+
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return errorResult('userspace', `invalid resource limit: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
const inner = `${prelude}exec "$@"`;
|
|
85
|
+
|
|
86
|
+
const r = spawnSync(
|
|
87
|
+
bin,
|
|
88
|
+
['-p', _profile({ allowNetwork }), '-D', `ROOT=${resolvedRoot}`,
|
|
89
|
+
'/bin/sh', '-c', inner, '_sbx', ...argv],
|
|
90
|
+
{
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
timeout: timeoutMs,
|
|
93
|
+
maxBuffer,
|
|
94
|
+
cwd: resolvedRoot,
|
|
95
|
+
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// Referenced by path, never by product name (see Global Constraints).
|
|
7
|
+
//
|
|
8
|
+
// Each family lists every plausible install location, probed in order. A
|
|
9
|
+
// single hardcoded path is safe (a miss fails closed to 'disabled') but it is
|
|
10
|
+
// a FALSE NEGATIVE: a host that does have the primitive somewhere else loses
|
|
11
|
+
// the sandbox silently. Probing the candidate set removes that failure mode.
|
|
12
|
+
export const CONFINE_BINS_USERSPACE = Object.freeze([
|
|
13
|
+
'/usr/bin/sandbox-exec',
|
|
14
|
+
'/usr/local/bin/sandbox-exec',
|
|
15
|
+
]);
|
|
16
|
+
export const CONFINE_BINS_NAMESPACE = Object.freeze([
|
|
17
|
+
'/usr/bin/unshare',
|
|
18
|
+
'/bin/unshare',
|
|
19
|
+
'/usr/local/bin/unshare',
|
|
20
|
+
'/sbin/unshare',
|
|
21
|
+
'/usr/sbin/unshare',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Back-compat single-path exports: the first (canonical) candidate.
|
|
25
|
+
export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
|
|
26
|
+
export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
|
|
27
|
+
|
|
28
|
+
let _cached = null;
|
|
29
|
+
|
|
30
|
+
export function resetCapabilityCache() { _cached = null; }
|
|
31
|
+
|
|
32
|
+
/** First executable candidate, or null when none of them exists. */
|
|
33
|
+
export function resolveConfineBin(candidates) {
|
|
34
|
+
for (const p of candidates) if (_isExecutable(p)) return p;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
|
|
39
|
+
export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
|
|
40
|
+
|
|
41
|
+
export function detectBackend({ force } = {}) {
|
|
42
|
+
if (force) return force;
|
|
43
|
+
if (_cached) return _cached;
|
|
44
|
+
let b = 'disabled';
|
|
45
|
+
if (process.platform === 'darwin' && resolveUserspaceBin()) b = 'userspace';
|
|
46
|
+
else if (process.platform === 'linux' && resolveNamespaceBin()) b = 'namespace';
|
|
47
|
+
_cached = b;
|
|
48
|
+
return b;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _isExecutable(p) {
|
|
52
|
+
try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Resource caps applied as a shell prelude, shared by every real backend.
|
|
2
|
+
//
|
|
3
|
+
// Address-space capping (`ulimit -v`) is NOT enforced on the macOS family —
|
|
4
|
+
// verified by execution. We therefore DECLARE it unsupported rather than
|
|
5
|
+
// emitting a limit that silently does nothing, which would be a false
|
|
6
|
+
// assurance of containment.
|
|
7
|
+
export function buildLimitPrelude({
|
|
8
|
+
maxProcs = 64,
|
|
9
|
+
maxFileSizeKb = 65536,
|
|
10
|
+
maxAddressSpaceKb = null,
|
|
11
|
+
} = {}) {
|
|
12
|
+
const parts = [];
|
|
13
|
+
const unsupported = [];
|
|
14
|
+
|
|
15
|
+
if (maxProcs != null) parts.push(`ulimit -u ${_num('maxProcs', maxProcs)}`);
|
|
16
|
+
if (maxFileSizeKb != null) parts.push(`ulimit -f ${_num('maxFileSizeKb', maxFileSizeKb)}`);
|
|
17
|
+
|
|
18
|
+
if (maxAddressSpaceKb != null) {
|
|
19
|
+
if (process.platform === 'linux') parts.push(`ulimit -v ${_num('maxAddressSpaceKb', maxAddressSpaceKb)}`);
|
|
20
|
+
else unsupported.push('maxAddressSpaceKb');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const prelude = parts.length ? parts.join('; ') + '; ' : '';
|
|
24
|
+
return { prelude, unsupported };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Limit values are interpolated into a shell fragment, so a non-numeric value
|
|
29
|
+
* is shell text. Verified by execution: `maxProcs: '999; echo INJECTED'`
|
|
30
|
+
* emitted `ulimit -u 999; echo INJECTED` and the payload ran. That is not a
|
|
31
|
+
* sandbox escape (the prelude runs INSIDE the confinement) but it lets a
|
|
32
|
+
* config-derived value silently DISABLE the very limits it was meant to set —
|
|
33
|
+
* e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
|
|
34
|
+
* anything that is not a finite, non-negative number.
|
|
35
|
+
*/
|
|
36
|
+
function _num(name, v) {
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
39
|
+
throw new RangeError(`${name} must be a finite, non-negative number (got ${JSON.stringify(v)})`);
|
|
40
|
+
}
|
|
41
|
+
return Math.floor(n);
|
|
42
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shared result construction for every real backend.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (the misread it prevents): status used to be derived purely
|
|
4
|
+
// from the exit code — `exitCode !== 0` was reported as `'blocked'`. That
|
|
5
|
+
// conflates two entirely different outcomes:
|
|
6
|
+
//
|
|
7
|
+
// 1. A program that ran fine and chose to exit non-zero (a failing test, a
|
|
8
|
+
// grep with no match) was labelled 'blocked' — a false confinement claim.
|
|
9
|
+
// 2. A program whose out-of-root write was DENIED but which still exited 0
|
|
10
|
+
// was labelled 'ok' — the caller saw a clean run and could not tell that
|
|
11
|
+
// the sandbox had refused something. Verified by execution: the denied
|
|
12
|
+
// write returns exit 0 when the command swallows the failure.
|
|
13
|
+
//
|
|
14
|
+
// So the two signals are now separated:
|
|
15
|
+
//
|
|
16
|
+
// - `denied` — a confinement violation was OBSERVED in the child's error
|
|
17
|
+
// output. Best effort, see the honesty note below.
|
|
18
|
+
// - `status` — 'blocked' when a denial was observed, 'nonzero' when the
|
|
19
|
+
// command merely exited non-zero with no denial signal, 'ok'
|
|
20
|
+
// only when it exited 0 with no denial signal.
|
|
21
|
+
//
|
|
22
|
+
// HONESTY NOTE — what `denied:false` does and does not mean. The denial signal
|
|
23
|
+
// is read from the confined process's own stderr (the OS primitives here do
|
|
24
|
+
// not hand the parent a structured violation channel). A program that writes
|
|
25
|
+
// out of root and swallows its own error message produces NO signal, so
|
|
26
|
+
// `denied:false` means "no denial was observed", NOT "no denial occurred".
|
|
27
|
+
// Never treat `status:'ok'` as proof that nothing was refused. It is proof
|
|
28
|
+
// only that the command exited 0 and said nothing about a refusal.
|
|
29
|
+
|
|
30
|
+
const DENIAL_PATTERNS = [
|
|
31
|
+
/operation not permitted/i,
|
|
32
|
+
/permission denied/i,
|
|
33
|
+
/read-only file system/i,
|
|
34
|
+
/deny file-write/i,
|
|
35
|
+
/deny network/i,
|
|
36
|
+
/network is unreachable/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** True iff the confined process's error output shows an observed denial. */
|
|
40
|
+
export function detectDenial(stderr) {
|
|
41
|
+
const s = String(stderr || '');
|
|
42
|
+
return DENIAL_PATTERNS.some((re) => re.test(s));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the single result shape every backend returns. `status` is one of
|
|
47
|
+
* 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
|
|
48
|
+
*/
|
|
49
|
+
export function buildResult({ backend, spawnResult, unsupported = [] }) {
|
|
50
|
+
const r = spawnResult;
|
|
51
|
+
const rawStderr = r.stderr ?? '';
|
|
52
|
+
const timedOut = r.error?.code === 'ETIMEDOUT';
|
|
53
|
+
const denied = detectDenial(rawStderr);
|
|
54
|
+
|
|
55
|
+
let status;
|
|
56
|
+
if (timedOut) status = 'timeout';
|
|
57
|
+
else if (r.error) status = 'error';
|
|
58
|
+
else if (denied) status = 'blocked';
|
|
59
|
+
else if (r.status !== 0) status = 'nonzero';
|
|
60
|
+
else status = 'ok';
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
status,
|
|
64
|
+
denied,
|
|
65
|
+
stdout: r.stdout ?? '',
|
|
66
|
+
stderr: rawStderr + (unsupported.length ? `\n[sandbox] limits not enforceable here: ${unsupported.join(', ')}` : ''),
|
|
67
|
+
exitCode: r.status ?? null,
|
|
68
|
+
timedOut,
|
|
69
|
+
backend,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Documented-shape error result: runConfined never throws at its callers. */
|
|
74
|
+
export function errorResult(backend, message) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'error',
|
|
77
|
+
denied: false,
|
|
78
|
+
stdout: '',
|
|
79
|
+
stderr: `agentic-security: ${message}`,
|
|
80
|
+
exitCode: null,
|
|
81
|
+
timedOut: false,
|
|
82
|
+
backend,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Minimal environment handed to untrusted code. The parent environment is NOT
|
|
88
|
+
* forwarded: it routinely carries credentials (tokens, cloud keys, registry
|
|
89
|
+
* auth) and the confined program can read and exfiltrate them. This is a
|
|
90
|
+
* distinct exposure from the accepted "reads are not confined" scope cut —
|
|
91
|
+
* that one is about files on disk, this one is about secrets the parent hands
|
|
92
|
+
* over for free. Callers that need extra variables pass them explicitly via
|
|
93
|
+
* `opts.env`, which is merged on top of this base.
|
|
94
|
+
*/
|
|
95
|
+
export function buildConfinedEnv({ root, env = {} } = {}) {
|
|
96
|
+
return {
|
|
97
|
+
PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
98
|
+
ROOT: root,
|
|
99
|
+
HOME: root,
|
|
100
|
+
TMPDIR: root,
|
|
101
|
+
LANG: 'C',
|
|
102
|
+
...env,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/sca/dep-confusion.js
CHANGED