Skip to content

Commit 8edc756

Browse files
RoyLinRoyLin
authored andcommitted
feat(fileguard): fanotify FAN_OPEN_PERM file-access intervention (build-validated)
The other half of the intervention ask ('阻止某些文件读写'). eBPF LSM file_open is blocked here (bpf not in the kernel's lsm= set), so this is a stock-kernel fanotify guard: userspace-policy-driven (external deny-prefix file), denies open() of matching paths. Loads policy before marking the mount (else the guard's own open self-deadlocks). Runtime validation pending a non-prod VM (same gate as egress enforcement).
1 parent d4c9855 commit 8edc756

3 files changed

Lines changed: 131 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

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

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- **File-access intervention** (opt-in): `a3s-observer-fileguard` — a fanotify `FAN_OPEN_PERM`
10+
guard that denies `open()` of paths matching an external deny-prefix policy file (the other
11+
example from the intervention ask, "阻止某些文件读写"). Chosen over eBPF LSM `file_open`
12+
because `bpf` is **not** in this kernel's active `lsm=` set (would need a custom boot
13+
cmdline); fanotify is stock-kernel and userspace-policy-driven — the same external model as
14+
the egress guard. **Build-validated; runtime validation pending a non-prod VM** (file
15+
enforcement on a host is gated to a non-prod box, same as egress).
16+
517
## [0.3.0] — external intervention interface (enforcement)
618

719
### Added

a3s-observer-collector/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ anyhow = "1"
1313
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] }
1414
tracing = "0.1"
1515
tracing-subscriber = "0.3"
16+
libc = "0.2"
1617

1718
[build-dependencies]
1819
aya-build = "0.1"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//! a3s-observer-fileguard — OPT-IN file-access intervention via fanotify `FAN_OPEN_PERM`.
2+
//!
3+
//! The eBPF-native path (LSM `file_open`) needs `bpf` in the kernel's `lsm=` set, which is
4+
//! not enabled by default (checked: `lockdown,capability,landlock,yama,apparmor`). fanotify
5+
//! works on a stock kernel and is userspace-policy-driven — the same external-intervention
6+
//! model: the policy lives outside (a plain deny-prefix file any controller writes), the
7+
//! kernel asks this guard ALLOW/DENY per open. See `docs/enforcement.md`.
8+
//!
9+
//! sudo a3s-observer-fileguard <watch-path> <policy-file>
10+
//!
11+
//! `<policy-file>`: one path prefix per line (`#` comments); an open whose resolved path
12+
//! starts with any prefix is denied (EPERM). Fail-open on anything else.
13+
14+
use anyhow::{anyhow, Context as _};
15+
use std::fs;
16+
17+
const FAN_ALLOW: u32 = 0x01;
18+
const FAN_DENY: u32 = 0x02;
19+
20+
fn main() -> anyhow::Result<()> {
21+
let mut args = std::env::args().skip(1);
22+
let usage = "usage: a3s-observer-fileguard <watch-path> <policy-file>";
23+
let watch = args.next().context(usage)?;
24+
let policy_path = args.next().context(usage)?;
25+
26+
// FAN_CLASS_CONTENT enables permission (allow/deny) events.
27+
let fan = unsafe {
28+
libc::fanotify_init(
29+
libc::FAN_CLASS_CONTENT | libc::FAN_CLOEXEC,
30+
libc::O_RDONLY as u32,
31+
)
32+
};
33+
if fan < 0 {
34+
return Err(anyhow!(
35+
"fanotify_init failed (needs root/CAP_SYS_ADMIN): {}",
36+
std::io::Error::last_os_error()
37+
));
38+
}
39+
// Read the policy BEFORE marking: once the mount is marked, any open on it (including this
40+
// process's own) is gated until the read loop responds — opening the policy file after the
41+
// mark would deadlock. ponytail: read-once; restart to reload, add mtime-poll if needed.
42+
let deny = load_policy(&policy_path);
43+
// Watch the whole mount of `watch`; we filter by path against the policy.
44+
let cpath = std::ffi::CString::new(watch.clone())?;
45+
let rc = unsafe {
46+
libc::fanotify_mark(
47+
fan,
48+
libc::FAN_MARK_ADD | libc::FAN_MARK_MOUNT,
49+
libc::FAN_OPEN_PERM,
50+
libc::AT_FDCWD,
51+
cpath.as_ptr(),
52+
)
53+
};
54+
if rc < 0 {
55+
return Err(anyhow!(
56+
"fanotify_mark {watch} failed: {}",
57+
std::io::Error::last_os_error()
58+
));
59+
}
60+
eprintln!(
61+
"a3s-observer-fileguard: FAN_OPEN_PERM on mount of {watch}; {} deny-prefixes from {policy_path}",
62+
deny.len()
63+
);
64+
65+
let mut buf = [0u8; 8192];
66+
loop {
67+
let n = unsafe { libc::read(fan, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
68+
if n <= 0 {
69+
if n < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
70+
continue;
71+
}
72+
break;
73+
}
74+
let mut off = 0usize;
75+
let meta_sz = std::mem::size_of::<libc::fanotify_event_metadata>();
76+
while off + meta_sz <= n as usize {
77+
let meta = unsafe { &*(buf.as_ptr().add(off) as *const libc::fanotify_event_metadata) };
78+
if meta.vers != libc::FANOTIFY_METADATA_VERSION {
79+
break;
80+
}
81+
if meta.mask & u64::from(libc::FAN_OPEN_PERM) != 0 && meta.fd >= 0 {
82+
let path = fs::read_link(format!("/proc/self/fd/{}", meta.fd))
83+
.map(|p| p.to_string_lossy().into_owned())
84+
.unwrap_or_default();
85+
let denied = deny.iter().any(|d| path.starts_with(d.as_str()));
86+
let resp = libc::fanotify_response {
87+
fd: meta.fd,
88+
response: if denied { FAN_DENY } else { FAN_ALLOW },
89+
};
90+
unsafe {
91+
libc::write(
92+
fan,
93+
&resp as *const _ as *const libc::c_void,
94+
std::mem::size_of::<libc::fanotify_response>(),
95+
);
96+
}
97+
if denied {
98+
eprintln!("[fileguard] DENY open {path}");
99+
}
100+
}
101+
if meta.fd >= 0 {
102+
unsafe { libc::close(meta.fd) };
103+
}
104+
off += meta.event_len as usize;
105+
}
106+
}
107+
Ok(())
108+
}
109+
110+
fn load_policy(path: &str) -> Vec<String> {
111+
std::fs::read_to_string(path)
112+
.unwrap_or_default()
113+
.lines()
114+
.map(str::trim)
115+
.filter(|l| !l.is_empty() && !l.starts_with('#'))
116+
.map(str::to_owned)
117+
.collect()
118+
}

0 commit comments

Comments
 (0)