11//! a3s-observer-fileguard — OPT-IN file-access intervention via fanotify `FAN_OPEN_PERM`.
22//!
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`.
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.
89//!
9- //! sudo a3s-observer-fileguard <watch-path> < policy-file>
10+ //! sudo a3s-observer-fileguard <policy-file> # one file path per line to deny
1011//!
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 .
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 .
1314
1415use anyhow:: { anyhow, Context as _} ;
1516use std:: fs;
1617
17- const FAN_ALLOW : u32 = 0x01 ;
1818const FAN_DENY : u32 = 0x02 ;
1919
2020fn 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) ?;
21+ let policy_path = std:: env:: args ( )
22+ . nth ( 1 )
23+ . 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+ }
2528
26- // FAN_CLASS_CONTENT enables permission (allow/deny) events.
2729 let fan = unsafe {
2830 libc:: fanotify_init (
2931 libc:: FAN_CLASS_CONTENT | libc:: FAN_CLOEXEC ,
@@ -36,29 +38,27 @@ fn main() -> anyhow::Result<()> {
3638 std:: io:: Error :: last_os_error( )
3739 ) ) ;
3840 }
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- ) ) ;
41+
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 ;
56+ } else {
57+ eprintln ! ( "warn: cannot mark {p}: {}" , std:: io:: Error :: last_os_error( ) ) ;
58+ }
5959 }
6060 eprintln ! (
61- "a3s-observer-fileguard: FAN_OPEN_PERM on mount of {watch}; {} deny-prefixes from {policy_path}" ,
61+ "a3s-observer-fileguard: denying open() of {marked}/ {} file(s) from {policy_path}" ,
6262 deny. len( )
6363 ) ;
6464
@@ -74,18 +74,20 @@ fn main() -> anyhow::Result<()> {
7474 let mut off = 0usize ;
7575 let meta_sz = std:: mem:: size_of :: < libc:: fanotify_event_metadata > ( ) ;
7676 while off + meta_sz <= n as usize {
77- let meta = unsafe { & * ( buf. as_ptr ( ) . add ( off) as * const libc:: fanotify_event_metadata ) } ;
77+ // read_unaligned: the byte buffer isn't 8-aligned, so a &reference would be UB.
78+ let meta = unsafe {
79+ std:: ptr:: read_unaligned (
80+ buf. as_ptr ( ) . add ( off) as * const libc:: fanotify_event_metadata
81+ )
82+ } ;
7883 if meta. vers != libc:: FANOTIFY_METADATA_VERSION {
7984 break ;
8085 }
8186 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 ( ) ) ) ;
87+ // Only denied files are marked, so every permission event is a denial.
8688 let resp = libc:: fanotify_response {
8789 fd : meta. fd ,
88- response : if denied { FAN_DENY } else { FAN_ALLOW } ,
90+ response : FAN_DENY ,
8991 } ;
9092 unsafe {
9193 libc:: write (
@@ -94,9 +96,10 @@ fn main() -> anyhow::Result<()> {
9496 std:: mem:: size_of :: < libc:: fanotify_response > ( ) ,
9597 ) ;
9698 }
97- if denied {
98- eprintln ! ( "[fileguard] DENY open {path}" ) ;
99- }
99+ let path = fs:: read_link ( format ! ( "/proc/self/fd/{}" , meta. fd) )
100+ . map ( |p| p. to_string_lossy ( ) . into_owned ( ) )
101+ . unwrap_or_default ( ) ;
102+ eprintln ! ( "[fileguard] DENY open {path}" ) ;
100103 }
101104 if meta. fd >= 0 {
102105 unsafe { libc:: close ( meta. fd ) } ;
0 commit comments