Skip to content

Commit fc77792

Browse files
RoyLinRoyLin
authored andcommitted
release: v0.6.0 — exec intervention + fileguard hot-reload (KVM-validated)
a3s-observer-fileguard now denies exec (FAN_OPEN_EXEC_PERM) as well as open, completing the intervention triad (egress + file + exec) on a stock kernel (no bpf-lsm), and hot-reloads its policy every ~2s. KVM-validated: guarded-binary exec -> EPERM, non-guarded runs, hot-reload picks up added paths within the window. SSL content overhead measured negligible (+0.3% CPU, ~50KB RSS over a 3k-TLS soak). Folds in the SECURITY.md/enforcement-docs/otel/eBPF-lint polish. 0.5.1 -> 0.6.0.
1 parent 5addec5 commit fc77792

9 files changed

Lines changed: 97 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22

33
All notable changes to a3s-observer will be documented in this file.
44

5-
## [Unreleased]
5+
## [0.6.0] — exec intervention + fileguard hot-reload
66

77
### Added / Changed
88

9+
- **Exec blocking**`a3s-observer-fileguard` now denies **exec** as well as `open()`
10+
(`FAN_OPEN_EXEC_PERM`), completing the intervention triad (egress + file + exec), all via
11+
fanotify on a stock kernel (no bpf-lsm). KVM-validated: exec of a guarded binary → `EPERM`,
12+
a non-guarded binary runs.
13+
- **fileguard hot-reload** — the policy is re-read every ~2s (marks added/removed live), so an
14+
external controller can change denials without a restart (matching the egress enforcer).
15+
KVM-validated: a path added to the policy is denied within the reload window.
16+
- SSL content-capture overhead measured (KVM, 3k-TLS-call soak): **+0.3% CPU, +~50 KB RSS** vs
17+
baseline — negligible.
918
- `SECURITY.md` — vulnerability disclosure policy + image-signature verification.
1019
- `docs/enforcement.md` reflects the **shipped** guards (egress `cgroup/connect4` v0.3.0, file
1120
fanotify `FAN_OPEN_PERM` v0.4.0); the file guard uses fanotify, not LSM-BPF, because `bpf`

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer"
3-
version = "0.5.1"
3+
version = "0.6.0"
44
edition = "2021"
55
license = "MIT"
66
description = "General-purpose, language-agnostic eBPF observability for AI agents (LLM calls, tools, files, network egress)."

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ The observe-only core is untouched.
6969
| guard | mechanism | denies |
7070
|---|---|---|
7171
| `a3s-observer-enforce` | `cgroup/connect4` eBPF | egress to policy IPs/hosts — **cgroup-scoped**, fail-open |
72-
| `a3s-observer-fileguard` | fanotify `FAN_OPEN_PERM` | `open()` of policy-listed files |
72+
| `a3s-observer-fileguard` | fanotify `FAN_OPEN_PERM` + `FAN_OPEN_EXEC_PERM` | `open()` **and** `exec` of policy-listed files; policy **hot-reloaded** |
7373

7474
Both KVM-validated: a denied connect / file-open returns `EPERM`, everything else is
7575
untouched. Drive it in-process (the `Policy` trait) or out-of-process — `scripts/example-controller.py`

a3s-observer-collector/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-collector"
3-
version = "0.5.1"
3+
version = "0.6.0"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/src/bin/fileguard.rs

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,38 @@
1-
//! a3s-observer-fileguard — OPT-IN file-access intervention via fanotify `FAN_OPEN_PERM`.
1+
//! a3s-observer-fileguard — OPT-IN file/exec access intervention via fanotify.
22
//!
3-
//! Marks each file path listed in the policy and denies `open()` of it (EPERM). Marking the
4-
//! specific files (not the whole mount) is deliberate: a mount-wide `FAN_MARK_MOUNT` perm
5-
//! guard gates *every* open on the filesystem, including system services' own I/O, and can
6-
//! wedge them. The eBPF-native equivalent would be LSM `file_open`, but `bpf` is not in this
7-
//! kernel's `lsm=` set (would need a custom boot cmdline); fanotify is stock-kernel and
8-
//! userspace-policy-driven — the same external-intervention model as the egress guard.
3+
//! Denies `open()` **and** `exec` of the files listed in an external policy file
4+
//! (`FAN_OPEN_PERM | FAN_OPEN_EXEC_PERM` → `EPERM`). It marks the specific listed files (not
5+
//! the whole mount — a mount-wide perm guard gates all system I/O and can wedge services), and
6+
//! it **hot-reloads** the policy every ~2s, so an external controller can add/remove denials
7+
//! live (same model as the egress enforcer).
98
//!
10-
//! sudo a3s-observer-fileguard <policy-file> # one file path per line to deny
9+
//! This covers both the file ("阻止文件读写") and exec ("阻止执行") intervention via one
10+
//! stock-kernel mechanism — the eBPF-native equivalents (LSM `file_open`/`bprm`) need `bpf` in
11+
//! the kernel's `lsm=` set, which isn't enabled by default. fanotify is userspace-policy-driven.
1112
//!
12-
//! ponytail: exact-file marks. Blocking a whole subtree by prefix needs FAN_MARK_MOUNT +
13-
//! path filtering, which gates the entire mount — add that only if a real use case needs it.
13+
//! sudo a3s-observer-fileguard <policy-file> # one path per line; denies open + exec of each
1414
1515
use anyhow::{anyhow, Context as _};
16+
use std::collections::HashSet;
1617
use std::fs;
18+
use std::time::{Duration, Instant};
1719

1820
const FAN_DENY: u32 = 0x02;
1921

22+
fn perm_mask() -> u64 {
23+
u64::from(libc::FAN_OPEN_PERM) | u64::from(libc::FAN_OPEN_EXEC_PERM)
24+
}
25+
2026
fn main() -> anyhow::Result<()> {
2127
let policy_path = std::env::args()
2228
.nth(1)
2329
.context("usage: a3s-observer-fileguard <policy-file>")?;
24-
let deny = load_policy(&policy_path);
25-
if deny.is_empty() {
26-
return Err(anyhow!("no file paths in policy {policy_path}"));
27-
}
2830

31+
// FAN_NONBLOCK so the read drains without blocking; we poll() with a timeout to interleave
32+
// event handling with periodic policy reloads.
2933
let fan = unsafe {
3034
libc::fanotify_init(
31-
libc::FAN_CLASS_CONTENT | libc::FAN_CLOEXEC,
35+
libc::FAN_CLASS_CONTENT | libc::FAN_CLOEXEC | libc::FAN_NONBLOCK,
3236
libc::O_RDONLY as u32,
3337
)
3438
};
@@ -39,42 +43,68 @@ fn main() -> anyhow::Result<()> {
3943
));
4044
}
4145

42-
let mut marked = 0usize;
43-
for p in &deny {
44-
let c = std::ffi::CString::new(p.as_str())?;
45-
let rc = unsafe {
46-
libc::fanotify_mark(
47-
fan,
48-
libc::FAN_MARK_ADD,
49-
libc::FAN_OPEN_PERM,
50-
libc::AT_FDCWD,
51-
c.as_ptr(),
52-
)
53-
};
54-
if rc == 0 {
55-
marked += 1;
46+
let mut marked: HashSet<String> = HashSet::new();
47+
reload(fan, &policy_path, &mut marked);
48+
eprintln!(
49+
"a3s-observer-fileguard: denying open()+exec of {} file(s) from {policy_path} (hot-reload 2s)",
50+
marked.len()
51+
);
52+
53+
let mut pollfd = libc::pollfd {
54+
fd: fan,
55+
events: libc::POLLIN,
56+
revents: 0,
57+
};
58+
let mut buf = [0u8; 8192];
59+
let mut last_reload = Instant::now();
60+
loop {
61+
let r = unsafe { libc::poll(&mut pollfd, 1, 500) };
62+
if r > 0 && pollfd.revents & libc::POLLIN != 0 {
63+
drain(fan, &mut buf);
64+
}
65+
if last_reload.elapsed() >= Duration::from_secs(2) {
66+
reload(fan, &policy_path, &mut marked);
67+
last_reload = Instant::now();
68+
}
69+
}
70+
}
71+
72+
/// Add/remove fanotify marks to match the policy file (called at startup and on each reload).
73+
fn reload(fan: i32, path: &str, marked: &mut HashSet<String>) {
74+
let want: HashSet<String> = load_policy(path).into_iter().collect();
75+
let add: Vec<String> = want.difference(marked).cloned().collect();
76+
let remove: Vec<String> = marked.difference(&want).cloned().collect();
77+
for p in &add {
78+
if mark(fan, libc::FAN_MARK_ADD, p) {
79+
eprintln!("[fileguard] guard {p}");
5680
} else {
5781
eprintln!("warn: cannot mark {p}: {}", std::io::Error::last_os_error());
5882
}
5983
}
60-
eprintln!(
61-
"a3s-observer-fileguard: denying open() of {marked}/{} file(s) from {policy_path}",
62-
deny.len()
63-
);
84+
for p in &remove {
85+
mark(fan, libc::FAN_MARK_REMOVE, p);
86+
eprintln!("[fileguard] unguard {p}");
87+
}
88+
*marked = want;
89+
}
6490

65-
let mut buf = [0u8; 8192];
91+
fn mark(fan: i32, flags: u32, path: &str) -> bool {
92+
let Ok(c) = std::ffi::CString::new(path) else {
93+
return false;
94+
};
95+
unsafe { libc::fanotify_mark(fan, flags, perm_mask(), libc::AT_FDCWD, c.as_ptr()) == 0 }
96+
}
97+
98+
/// Drain all pending permission events, denying each (only guarded files are marked).
99+
fn drain(fan: i32, buf: &mut [u8]) {
100+
let meta_sz = std::mem::size_of::<libc::fanotify_event_metadata>();
66101
loop {
67102
let n = unsafe { libc::read(fan, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
68103
if n <= 0 {
69-
if n < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
70-
continue;
71-
}
72-
break;
104+
break; // EAGAIN (drained) or error
73105
}
74106
let mut off = 0usize;
75-
let meta_sz = std::mem::size_of::<libc::fanotify_event_metadata>();
76107
while off + meta_sz <= n as usize {
77-
// read_unaligned: the byte buffer isn't 8-aligned, so a &reference would be UB.
78108
let meta = unsafe {
79109
std::ptr::read_unaligned(
80110
buf.as_ptr().add(off) as *const libc::fanotify_event_metadata
@@ -83,8 +113,7 @@ fn main() -> anyhow::Result<()> {
83113
if meta.vers != libc::FANOTIFY_METADATA_VERSION {
84114
break;
85115
}
86-
if meta.mask & u64::from(libc::FAN_OPEN_PERM) != 0 && meta.fd >= 0 {
87-
// Only denied files are marked, so every permission event is a denial.
116+
if meta.mask & perm_mask() != 0 && meta.fd >= 0 {
88117
let resp = libc::fanotify_response {
89118
fd: meta.fd,
90119
response: FAN_DENY,
@@ -99,15 +128,19 @@ fn main() -> anyhow::Result<()> {
99128
let path = fs::read_link(format!("/proc/self/fd/{}", meta.fd))
100129
.map(|p| p.to_string_lossy().into_owned())
101130
.unwrap_or_default();
102-
eprintln!("[fileguard] DENY open {path}");
131+
let kind = if meta.mask & u64::from(libc::FAN_OPEN_EXEC_PERM) != 0 {
132+
"exec"
133+
} else {
134+
"open"
135+
};
136+
eprintln!("[fileguard] DENY {kind} {path}");
103137
}
104138
if meta.fd >= 0 {
105139
unsafe { libc::close(meta.fd) };
106140
}
107141
off += meta.event_len as usize;
108142
}
109143
}
110-
Ok(())
111144
}
112145

113146
fn load_policy(path: &str) -> Vec<String> {

a3s-observer-common/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-common"
3-
version = "0.5.1"
3+
version = "0.6.0"
44
edition = "2021"
55
license = "MIT"
66
description = "Shared no_std types crossing the eBPF <-> userspace boundary for a3s-observer."

a3s-observer-ebpf/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-ebpf"
3-
version = "0.5.1"
3+
version = "0.6.0"
44
edition = "2021"
55
license = "MIT"
66
publish = false

deploy/daemonset.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ spec:
2525
terminationGracePeriodSeconds: 30 # the collector flushes a final report on SIGTERM
2626
containers:
2727
- name: a3s-observer
28-
image: 10.12.111.133:49164/a3s/observer:0.5.1 # mirror of ghcr.io/a3s-lab/observer:0.5.1
28+
image: 10.12.111.133:49164/a3s/observer:0.6.0 # mirror of ghcr.io/a3s-lab/observer:0.6.0
2929
securityContext:
3030
# eBPF load + tracepoint attach requires privileged. (Verified: a non-root
3131
# process with only CAP_BPF+CAP_PERFMON fails to attach — the tracefs tracepoint

0 commit comments

Comments
 (0)