diff --git a/TRIAGE.json b/TRIAGE.json index e2adde1..6eff22e 100644 --- a/TRIAGE.json +++ b/TRIAGE.json @@ -707,7 +707,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "not_applicable", + "where": "8e2cdbb, 3225d44", + "note": "Stale: crates/userspace/src/hardening.rs::drop_privileges sets PR_SET_NO_NEW_PRIVS and empties the capability bounding set except the DAC pair; VM case `priv-drop` asserts NoNewPrivs=1 and CapBnd=0x6. The report predates that work." + } }, { "id": "f003", @@ -960,7 +965,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "015-hooks-fail-closed", + "note": "LSM hooks refuse what they cannot evaluate (`deny_unevaluated`): a bpf_d_path failure, a null struct argument, an unavailable scratch slot, and any non-IP address family now return the denial errno with an audit record instead of allowing. Research R16; VM cases exec-unresolvable-denied / file-unresolvable-denied / net-unix-denied." + } }, { "id": "f039", @@ -1255,7 +1265,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "015-hooks-fail-closed", + "note": "LSM hooks refuse what they cannot evaluate (`deny_unevaluated`): a bpf_d_path failure, a null struct argument, an unavailable scratch slot, and any non-IP address family now return the denial errno with an audit record instead of allowing. Research R16; VM cases exec-unresolvable-denied / file-unresolvable-denied / net-unix-denied." + } }, { "id": "f049", @@ -2152,6 +2167,23 @@ "status": "fixed", "where": "014-terminal-safety", "note": "Untrusted text is escaped where it enters a front-end (`safe_text::safe_block`/`safe_line`, applied in repl::terminal's ReplOutput impl and tui::app::handle_session) and only then styled; the interactive consent prompt escapes every field it prints; and skill frontmatter carrying control or bidi characters is refused at load." + }, + { + "findings": [ + "f007", + "f034" + ], + "status": "fixed", + "where": "015-hooks-fail-closed", + "note": "LSM hooks refuse what they cannot evaluate (`deny_unevaluated`): a bpf_d_path failure, a null struct argument, an unavailable scratch slot, and any non-IP address family now return the denial errno with an audit record instead of allowing. Research R16; VM cases exec-unresolvable-denied / file-unresolvable-denied / net-unix-denied." + }, + { + "findings": [ + "f010" + ], + "status": "not_applicable", + "where": "8e2cdbb, 3225d44", + "note": "Report predates the tool-child privilege drop; no_new_privs and the bounding-set drop are in place and VM-verified (`priv-drop`)." } ] } diff --git a/TRIAGE.md b/TRIAGE.md index 3968fa1..cbb26b3 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -14,6 +14,8 @@ are left in place unedited for provenance. | f018, f020, f022, f026, f028, f030 (all six HIGHs) | `8e2cdbb` + `3225d44` — closed and VM-verified (31/31 matrix) | | f001, f002, f012, f014, f025 (+ absorbed f006, f032) | branch `013-attenuation-inheritance` — one root cause: attenuation validated only what a child *stated*, so omission widened authority. `Policy::derive` now returns the *effective* child policy (silence inherits, it does not reset) and refuses child grants reaching into FR-008 protected regions. See research R15; regression tests in `crates/core/tests/attenuation.rs`. | | f040, f046 (+ absorbed f047) | branch `014-terminal-safety` — untrusted text is escaped where it enters a front-end (`safe_text`, applied in `repl::terminal` and `tui::app::handle_session`) and only then styled; the consent prompt escapes every field it prints; skill frontmatter carrying control or bidi characters is refused at load. | +| f007, f034 | branch `015-hooks-fail-closed` — LSM hooks refuse what they cannot evaluate: a `bpf_d_path` failure, a null struct argument, an unavailable scratch slot, and any non-IP address family are denied and audited instead of allowed. Research R16; VM cases `exec-unresolvable-denied`, `file-unresolvable-denied`, `net-unix-denied` (35/35). | +| f010 | **Not a defect — stale report.** `hardening.rs::drop_privileges` already sets `PR_SET_NO_NEW_PRIVS` and empties the capability bounding set except the DAC pair; VM case `priv-drop` asserts `NoNewPrivs=1 CapBnd=0x6`. Closed by `8e2cdbb`/`3225d44`, which the report predates. | ## Act on these ### [HIGH] Provider TOML can send an arbitrary environment secret to an attacker endpoint (f018) diff --git a/crates/ebpf/src/main.rs b/crates/ebpf/src/main.rs index 4c93f03..344d0ab 100644 --- a/crates/ebpf/src/main.rs +++ b/crates/ebpf/src/main.rs @@ -13,6 +13,14 @@ //! deny-by-default writes for scopes that declare a writable surface. The requested access is the //! `FMODE_WRITE` bit of `file->f_mode`, read by a direct load at a constant offset. //! +//! **A hook that cannot evaluate an operation refuses it** ([`deny_unevaluated`]). Null arguments, +//! an unavailable scratch buffer, a `bpf_d_path` failure, and an address family the hook does not +//! decode all return the denial errno rather than `0`, and all emit an audit record. The trigger is +//! reachable by an attacker — a directory chain longer than the 4KB path buffer, a pathless memfd +//! image, an AF_UNIX destination the `host:port` policy language cannot describe — so allowing on +//! failure handed out exactly the operation the scope exists to mediate. This only applies once the +//! scope has been shown to enforce that dimension; an unenforced scope still returns `0` early. +//! //! `bpf_d_path` needs a `*mut path`; we get it as `&file->f_path`. The `file`/`path`/`linux_binprm` //! structs in `aya-ebpf-bindings` are opaque, so we read fields at compile-time-constant offsets (the //! BPF verifier requires *constant* offsets into a BTF pointer, so these cannot be made @@ -127,6 +135,28 @@ static PATHBUF: PerCpuArray<[u8; PATH_MAX]> = PerCpuArray::with_max_entries(1, 0 #[map] static AUDIT_RB: RingBuf = RingBuf::with_byte_size(256 * 1024, 0); +/// Refuse an operation the hook enforces but could not evaluate — a null argument, an unreadable +/// struct field, an unresolvable path, an address family with no allowlist to check against. +/// +/// Constitution I is unconditional: "cannot tell" is "no". Every one of these branches used to +/// `return 0`, which meant an attacker who could *provoke* the failure — a directory chain longer +/// than `bpf_d_path`'s buffer, a socket family the hook does not decode — got an unenforced +/// operation out of an enforcing scope. Reaching one of these is either an attack or a bug in bee, +/// and both deserve the audit record this emits. +/// +/// Only ever called after the scope has been shown to enforce the dimension in question (a network +/// flag, a deny list, an exec allowlist), so an unenforced scope is unaffected. `errno` is negative, +/// as returned; observe mode records without blocking, exactly as a policy denial does. +fn deny_unevaluated(cgid: u64, meta: ScopeMeta, op: Op, errno: i32) -> i32 { + let observe = meta.mode == ScopeMode::Observe as u8; + emit_audit(cgid, op, observe, errno, None); + if observe { + 0 + } else { + errno + } +} + #[lsm(hook = "socket_connect")] pub fn socket_connect(ctx: LsmContext) -> i32 { let (cgid, meta) = match resolve_scope() { @@ -140,7 +170,7 @@ pub fn socket_connect(ctx: LsmContext) -> i32 { // arg1 of socket_connect is `struct sockaddr *address` (UAPI-stable layout). let addr: *const u8 = ctx.arg(1); if addr.is_null() { - return 0; + return deny_unevaluated(cgid, meta, Op::Connect, -EPERM); } let family = unsafe { bpf_probe_read_kernel(addr as *const u16).unwrap_or(0) }; @@ -160,7 +190,12 @@ pub fn socket_connect(ctx: LsmContext) -> i32 { key.port = u16::from_be(port_be); key.addr = a; } - _ => return 0, // non-IP (unix, netlink, …) — not enforced here + // A non-IP family (unix, netlink, …). The policy language describes destinations as + // `host:port`, so there is no rule that could ever allow one — and "no rule matches" in an + // enforcing scope means deny, not allow. An AF_UNIX connect to a local agent socket is + // egress just as surely as a TCP one; letting it through because the allowlist cannot spell + // it is the enforcement gap, not the policy's silence. + _ => return deny_unevaluated(cgid, meta, Op::Connect, -EPERM), } if unsafe { NET_ALLOW.get(&key) }.is_some() { @@ -190,12 +225,12 @@ pub fn file_open(ctx: LsmContext) -> i32 { // Resolve the path into the per-CPU buffer. let buf_ptr = match PATHBUF.get_ptr_mut(0) { Some(p) => p, - None => return 0, + None => return deny_unevaluated(cgid, meta, Op::FileOpen, -EACCES), }; // arg0 of file_open is `struct file *`. let file: *const c_void = ctx.arg(0); if file.is_null() { - return 0; + return deny_unevaluated(cgid, meta, Op::FileOpen, -EACCES); } // Requested access: direct load of `file->f_mode` (a scalar field on a trusted LSM BTF pointer, // so the verifier maps offset 20 to the u32 field — same mechanism as `bprm->file` below). @@ -206,7 +241,10 @@ pub fn file_open(ctx: LsmContext) -> i32 { // SAFETY: bpf_d_path writes up to PATH_MAX bytes into buf_ptr and returns the length (incl. NUL). let ret = unsafe { bpf_d_path(path_ptr, buf_ptr as *mut i8, PATH_MAX as u32) }; if ret <= 0 { - return 0; // could not resolve — do not block + // Unresolvable (most often a resolved path longer than the 4KB buffer). A rule list cannot + // be applied to a path we do not have, and a scope with deny rules does not get to skip them + // because an attacker nested the target deeply enough. + return deny_unevaluated(cgid, meta, Op::FileOpen, -EACCES); } let plen = resolved_len(ret); @@ -241,23 +279,26 @@ pub fn bprm_check_security(ctx: LsmContext) -> i32 { // arg0 of bprm_check_security is `struct linux_binprm *`; read bprm->file (a `struct file*`). let bprm: *const u8 = ctx.arg(0); if bprm.is_null() { - return 0; + return deny_unevaluated(cgid, meta, Op::Exec, -EACCES); } // Direct load of bprm->file. Because `bprm` is a trusted LSM BTF pointer, the verifier maps // offset 64 to the `file*` field and keeps the loaded value a trusted pointer (which // `bpf_d_path` requires) — unlike `bpf_probe_read`, which would yield an untyped scalar. let file_val = unsafe { *((bprm as usize + BINPRM_FILE_OFF) as *const usize) }; if file_val == 0 { - return 0; + return deny_unevaluated(cgid, meta, Op::Exec, -EACCES); } let buf_ptr = match PATHBUF.get_ptr_mut(0) { Some(p) => p, - None => return 0, + None => return deny_unevaluated(cgid, meta, Op::Exec, -EACCES), }; let path_ptr = (file_val + FILE_F_PATH_OFF) as *mut path; let ret = unsafe { bpf_d_path(path_ptr, buf_ptr as *mut i8, PATH_MAX as u32) }; if ret <= 0 { - return 0; // cannot resolve — do not block + // An allowlist names paths; an image whose path will not resolve cannot be on it. This is + // the sharpest of the four — a >4KB directory chain, or a memfd image with no path at all, + // was the way to exec anything at all out of an exec-enforced scope. + return deny_unevaluated(cgid, meta, Op::Exec, -EACCES); } let plen = resolved_len(ret); let buf = unsafe { &*buf_ptr }; diff --git a/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md b/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md index 1ca436f..e6a50c5 100644 --- a/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md +++ b/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md @@ -54,6 +54,11 @@ sensitive_paths = ["~/.ssh", "~/.aws", "~/.gnupg", "~/.config/gcloud"] AS-2), independent of whether the policy has other writable roots. - Segment (`**/name`) and single-`*` glob **filesystem** rules are not enforceable in-kernel and are refused (fail-closed) for every mode — see compile/scope errors below. +- **Unevaluable operations are denied**: inside a scope that enforces a dimension, an operation the + kernel hook cannot evaluate is refused and audited — a path `bpf_d_path` cannot resolve (over ~4KB + resolved length, or a pathless image), or a socket family the `host:port` language cannot describe. + In practice: a network-enforced scope refuses AF_UNIX/AF_NETLINK connects outright, and no enforced + scope can open or execute a path longer than the kernel's path buffer (research R16). - **Protected defaults (FR-008)**: within any writable root, VCS metadata (`.git`), bee's own config (`.bee`), `~/.ssh`, and `~/.aws` are read-only/denied unless a rule explicitly grants otherwise. - **Path tokens**: `:project_root` and `~` resolve to absolute paths at compile time. diff --git a/specs/001-ebpf-agent-sandbox/research.md b/specs/001-ebpf-agent-sandbox/research.md index 77bf172..6e22f18 100644 --- a/specs/001-ebpf-agent-sandbox/research.md +++ b/specs/001-ebpf-agent-sandbox/research.md @@ -373,6 +373,48 @@ property belongs to attenuation, and every future backend would have to re-imple --- +## R16. Hooks fail closed on what they cannot evaluate (FR-009, Constitution I) + +**Context**: Four branches in the LSM programs returned `0` (allow) when the hook could not reach a +decision: `bpf_d_path` failure in `file_open` and `bprm_check_security`, a null `bprm`/`file`/sockaddr +argument, an unavailable `PATHBUF` slot, and — in `socket_connect` — any address family other than +AF_INET/AF_INET6. Three of the four are attacker-provokable from inside a scope. `bpf_d_path` fails +with `-ENAMETOOLONG` once the resolved path exceeds its 4KB buffer, and `execve` never has to pass a +path that long: `chdir` down a deep chain and exec a short *relative* name, and the kernel resolves it +to something the hook cannot render. That was a general escape from an exec-enforced scope (triage +f007). The family default was the same shape in the network hook: an AF_UNIX connect to a local agent +socket left an egress-enforced scope unmediated (f034). + +**Decision**: A hook that enforces a dimension refuses what it cannot evaluate — `deny_unevaluated` +returns the denial errno and emits an audit record, in every one of those branches. The guard runs +only *after* the scope has been shown to enforce that dimension (network flag set, deny list present, +exec allowlist present), so an unenforced scope is untouched and observe mode still records without +blocking. + +For non-IP families this is a policy statement as much as a code change: the authoring language spells +destinations as `host:port`, so no rule can ever name an AF_UNIX or AF_NETLINK peer — and "no rule +matches" in an enforcing scope means deny. The cost is real and accepted: inside a network-enforced +scope, local socket IPC is refused outright, and inside any enforced scope a path longer than 4KB +cannot be opened or executed. + +**Rationale**: The alternative readings of a resolution failure — "probably benign", "not our +business" — are exactly the readings an attacker wants, and neither is available under Constitution I. +Auditing the refusal keeps a genuine bee bug (a wrong struct offset, an exhausted per-CPU slot) +diagnosable rather than silent. + +**Alternatives**: (a) fall back to an inode identity check for exec — the TOCTOU-hard answer, and the +right long-term one, but it requires the inode-pinning backend that `EnforcementPlan` currently refuses +(`UnsupportedInodePin`); deny-on-unresolvable is correct in the meantime and stays correct after; +(b) allow non-IP families and add an `AF_UNIX` allowlist to the policy language — more expressive, but +it widens the authoring surface and the attenuation rules for a case no bee policy has yet asked for; +(c) raise the path buffer — moves the threshold without closing anything, and PATH_MAX is the kernel's +own ceiling. + +**Verification**: VM matrix cases `exec-unresolvable-denied`, `file-unresolvable-denied`, and +`net-unix-denied`; all three fail against the pre-fix binary. 35/35 on the BPF-LSM VM. + +--- + ## Resolved Technical Context values | Field | Value | diff --git a/test/vm/README.md b/test/vm/README.md index 82fa5cb..75a8c7b 100644 --- a/test/vm/README.md +++ b/test/vm/README.md @@ -44,11 +44,13 @@ Override target via env: `NS`, `VM`, `KEY`. | `rw-read-source` / `rw-write-source-denied` | read/write modes: a `read`-marked source tree is readable but write-opens are blocked (`EACCES`) — US2 AS-2 | | `rw-write-scratch` / `rw-write-default-deny` | a `write`-granted scratch dir is writable; an unlisted path is denied by default (scope declares a writable surface) | | `exec-allow` / `exec-deny` | exec allowlist: allowlisted binary runs, un-listed `execve` denied | +| `exec-unresolvable-denied` / `file-unresolvable-denied` | fail-closed on what a hook cannot evaluate: a directory chain past `bpf_d_path`'s 4KB buffer makes the resolved path unrenderable, and the exec/open is refused rather than allowed (research R16) | +| `net-unix-denied` | a network-enforced scope refuses an AF_UNIX connect — the `host:port` language cannot name one, and no rule matching means deny | | `observe-mode` | dry-run: operation allowed but emits a `decision:"observed"` audit event | | `atten-reject` | subagent attenuation: an over-broad child (`--parent`) is refused before running | | `atten-subset-allow` / `atten-subset-deny` | a valid subset child runs and enforces its *narrower* policy (a dest the parent allows but the child dropped is blocked) | | `scope-isolation` | a process outside any bee scope is unaffected | | `episode-file-deny` / `episode-allow` | LLM agent harness (002): a scripted episode's tool call that reads a policy-denied path returns kernel `EACCES` in the transcript with a `file_open` denial and status `completed` (US1 AS-1); a permissive in-scope write succeeds with no denials (US1 AS-2) | -All 23 pass on the reference VM. See the repo root `README.md` for the enforcement design and the +All 35 pass on the reference VM. See the repo root `README.md` for the enforcement design and the `bpf_d_path` / offset caveats. diff --git a/test/vm/remote-matrix.sh b/test/vm/remote-matrix.sh index d311dac..6b7f3b8 100755 --- a/test/vm/remote-matrix.sh +++ b/test/vm/remote-matrix.sh @@ -44,6 +44,36 @@ if connected "$out"; then emit net-allow PASS "http=$out"; else emit net-allow F out=$(run_bee "$WORK/net.toml" -- bash -c 'curl -sS --max-time 8 -o /dev/null -w %{http_code} https://8.8.8.8') if [ "$out" = 000 ]; then emit net-deny PASS "connection blocked"; else emit net-deny FAIL "http=$out (expected block)"; fi +# A non-IP destination is still egress. The policy language spells destinations `host:port`, so no +# rule can ever allow an AF_UNIX socket — and "no rule matches" in an enforcing scope means deny. +# The hook used to return allow for every family it did not decode, so a local agent socket was +# reachable from a network-enforced scope (f034). +SOCK=/tmp/bee-af-unix.sock +rm -f "$SOCK" +python3 - "$SOCK" <<'EOF' & +import socket, sys, os +p = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.bind(p); s.listen(1); os.chmod(p, 0o777) +try: s.accept() +except Exception: pass +EOF +listener=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -S "$SOCK" ] && break; sleep 0.3; done +out=$(run_bee "$WORK/net.toml" -- python3 -c " +import socket,sys +s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +try: + s.connect('$SOCK'); print('CONNECTED') +except OSError as e: + print('REFUSED', e.errno) +" 2>&1) +case "$out" in + *REFUSED*) emit net-unix-denied PASS "AF_UNIX connect refused ($out)" ;; + *) emit net-unix-denied FAIL "AF_UNIX reachable from an enforced scope ($out)" ;; +esac +kill "$listener" 2>/dev/null; wait "$listener" 2>/dev/null; rm -f "$SOCK" + # ---------------------------------------------------------------- file deny / allow cat >"$WORK/fs.toml" <<'EOF' [policy] @@ -133,6 +163,55 @@ out=$(run_bee "$WORK/exec.toml" -- bash "$WORK/exectest.sh") echo "$out" | grep -q CAT_OK && emit exec-allow PASS "allowlisted exec ran" || emit exec-allow FAIL "allowed exec blocked" echo "$out" | grep -q 'NC_RC=126' && emit exec-deny PASS "unlisted exec denied" || emit exec-deny FAIL "nc not denied ($(echo "$out" | tr '\n' ' '))" +# ------------------------------------------- unresolvable paths fail closed (f007) +# `bpf_d_path` fails (-ENAMETOOLONG) once the resolved path exceeds its 4KB buffer. Both hooks used +# to return allow in that case, so a deep-enough directory chain was a general escape: `execve` of a +# short *relative* name never has to pass a >PATH_MAX argument, but the kernel still resolves it to +# one. Build such a chain outside the scope, then try to use it from inside. +DEEP=/home/ubuntu/deeptest +SEG=$(printf 'd%.0s' $(seq 1 60)) +sudo rm -rf "$DEEP"; mkdir -p "$DEEP" +( cd "$DEEP" && for _ in $(seq 1 80); do mkdir -p "$SEG" && cd "$SEG" || exit 1; done \ + && cp /bin/echo ./x && echo DEEPDATA > ./deep.txt ) +# 80 × 61 chars of chain, well past PATH_MAX — confirm before trusting either result. +depth_ok=$(cd "$DEEP" && for _ in $(seq 1 80); do cd "$SEG"; done && pwd | wc -c) +# Builtins only for the descent: this script runs under the exec allowlist, and a `seq` that gets +# denied would leave us in the shallow directory and turn the case into a false PASS. +cat >"$WORK/deep.sh" </dev/null; echo "EXEC_RC=\$?" +cat ./deep.txt 2>/dev/null; echo "READ_RC=\$?" +EOF + +if [ "$depth_ok" -le 4096 ]; then + emit exec-unresolvable-denied FAIL "chain only $depth_ok bytes — test cannot provoke d_path failure" + emit file-unresolvable-denied FAIL "chain only $depth_ok bytes — test cannot provoke d_path failure" +else + # `seq`/`cat` are on the allowlist, `./x` (a copy of echo) is not — but the point is that it is + # unresolvable, so it must be refused whatever its name would have been. + out=$(run_bee "$WORK/exec.toml" -- bash "$WORK/deep.sh" 2>&1) + if echo "$out" | grep -q DESCENT_FAILED; then + emit exec-unresolvable-denied FAIL "descent broke — case proves nothing ($(echo "$out" | tr '\n' ' '))" + elif echo "$out" | grep -q DEEP_EXEC_OK; then + emit exec-unresolvable-denied FAIL "exec of an unresolvable image ran ($(echo "$out" | tr '\n' ' '))" + else + emit exec-unresolvable-denied PASS "unresolvable exec refused" + fi + # Same provocation against the file hook: a deny-list scope must not open what it cannot resolve. + out=$(run_bee "$WORK/fs.toml" -- bash "$WORK/deep.sh" 2>&1) + if echo "$out" | grep -q DESCENT_FAILED; then + emit file-unresolvable-denied FAIL "descent broke — case proves nothing ($(echo "$out" | tr '\n' ' '))" + elif echo "$out" | grep -q DEEPDATA; then + emit file-unresolvable-denied FAIL "read of an unresolvable path succeeded" + else + emit file-unresolvable-denied PASS "unresolvable open refused" + fi +fi +sudo rm -rf "$DEEP" + # ---------------------------------------------------- search tool: library search runs IN-scope # The `search` tool execs `bee search-worker` (ripgrep as a library) through the sandbox, so its file # opens are mediated by the LSM. Prove it: a deny policy over one subtree must make a secret there