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
1515use anyhow:: { anyhow, Context as _} ;
16+ use std:: collections:: HashSet ;
1617use std:: fs;
18+ use std:: time:: { Duration , Instant } ;
1719
1820const 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+
2026fn 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
113146fn load_policy ( path : & str ) -> Vec < String > {
0 commit comments