linprov_common/lib.rs
1//! Types shared between the eBPF program (`linprov-ebpf`) and the userspace
2//! daemon (`linprov`). Everything here is `repr(C)` and Pod-friendly so it
3//! survives a round-trip through a ring buffer and a kernel xattr.
4//!
5//! The crate compiles `no_std` by default (for the BPF target). Enable the
6//! `user` feature in userspace to pull in `bytemuck::Pod` / `Zeroable`
7//! derives on the wire types.
8//!
9//! Wire shapes at a glance:
10//!
11//! - [`OriginRecord`] is what the daemon stores in the xattr and in the BPF
12//! `INODE_MARKS` map. BPF writes most of it in `file_open`; userspace
13//! augments `creator_path` from `/proc/$pid/exe`.
14//! - [`Event`] is the ringbuf record streamed from BPF to userspace.
15//! - [`AllowRule`] is one allowlist rule, packed into the BPF
16//! `ALLOW_RULES` array. String dims are stored as [`fnv_hash`] values
17//! so the BPF side can compare without carrying full byte arrays.
18//!
19//! ```
20//! use linprov_common::{fnv_hash, dim};
21//!
22//! // Both sides hash strings the same way; same input → same u64.
23//! assert_eq!(fnv_hash("/usr/bin/curl"), fnv_hash("/usr/bin/curl"));
24//! assert_ne!(fnv_hash("/usr/bin/curl"), fnv_hash("/usr/bin/wget"));
25//!
26//! // Dimension bits are independent flags on AllowRule::flags.
27//! let two_dim = dim::CREATOR_UID | dim::CREATOR_COMM;
28//! assert_eq!(two_dim.count_ones(), 2);
29//! ```
30
31#![cfg_attr(not(feature = "user"), no_std)]
32
33pub const COMM_LEN: usize = 16;
34
35/// Live exec/target path buffer size: the ringbuf [`Event`] filename
36/// and the per-CPU scratch the target-dim walks scan. Sized to Linux
37/// `PATH_MAX` so `target_filename` / `target_folder` match the full
38/// execution path at any depth and any length. These buffers are
39/// transient (per-CPU scratch + ringbuf), never persisted, so they
40/// aren't bound by the xattr block-size limit that caps stored data.
41pub const EXEC_PATH_LEN: usize = 4096;
42
43/// Max bytes the BPF path walks inspect. Equal to [`EXEC_PATH_LEN`]:
44/// the walk body is a `bpf_loop` callback the verifier inspects once,
45/// so this is bounded only by the buffer, not the instruction budget.
46pub const PATH_HASH_SCAN_LEN: usize = EXEC_PATH_LEN;
47
48/// Number of landing-folder ancestor hashes stored per record, for
49/// nested `landing_folder` matching. The walk records the hash of each
50/// `/`-terminated prefix of the landing path (shallow → deep) into a
51/// `[u64; MAX_FOLDER_ANCESTORS]`; a rule matches if its folder hash
52/// equals any of them, so `landing_folder=/home/user/` matches a file
53/// that landed in `/home/user/Downloads/sub/`. Bounds nesting *depth*
54/// (path length is still unbounded — these are hashes). Must be a power
55/// of two: the in-kernel walk masks the index (`& (N-1)`) to keep the
56/// array write provably in-bounds without a panic branch. Real landing
57/// paths sit well under this, so the mask never actually wraps.
58///
59/// Capped at 32 by the BPF 512-byte stack limit: the `file_open` walk
60/// holds this array by value in its `bpf_loop` context (`32 × 8 = 256`
61/// bytes, plus the other context fields). 64 would overflow the stack
62/// frame — it'd need the array in a per-CPU map instead, not worth it
63/// when 32 ancestor levels already exceeds any real landing path.
64pub const MAX_FOLDER_ANCESTORS: usize = 32;
65
66// FNV-1a-64 constants. Used by both sides to hash strings.
67pub const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
68pub const FNV_PRIME: u64 = 0x100_0000_01b3;
69
70/// Hash a string with FNV-1a-64. Byte-by-byte, no trailing NUL, no
71/// padding — identical on the BPF and userspace sides.
72///
73/// Both sides MUST compute the same hash for the same input; the FNV
74/// constants ([`FNV_OFFSET`], [`FNV_PRIME`]) are fixed for that reason.
75///
76/// ```
77/// use linprov_common::fnv_hash;
78/// // FNV-1a of the empty string is the offset basis.
79/// assert_eq!(fnv_hash(""), 0xcbf2_9ce4_8422_2325);
80/// // Distinct inputs hash distinctly.
81/// assert_ne!(fnv_hash("/tmp/"), fnv_hash("/etc/"));
82/// ```
83pub fn fnv_hash(s: &str) -> u64 {
84 fnv_hash_bytes(s.as_bytes())
85}
86
87/// Same as [`fnv_hash`], but takes a byte slice. Useful when the source
88/// isn't UTF-8 (e.g., a `[u8; EXEC_PATH_LEN]` filename buffer read out
89/// of a ringbuf event).
90pub fn fnv_hash_bytes(bytes: &[u8]) -> u64 {
91 let mut h = FNV_OFFSET;
92 for &b in bytes {
93 h ^= b as u64;
94 h = h.wrapping_mul(FNV_PRIME);
95 }
96 h
97}
98
99// ----- Allowlist rule. One per line in the allowlist file; each rule
100// is a conjunction of (dim, value) conditions. Rules OR together.
101
102/// `flags` bits on [`AllowRule`]. Set bits indicate which dims this
103/// rule requires the record / execve context to match.
104pub mod dim {
105 pub const TARGET_FILENAME: u32 = 1 << 0;
106 pub const TARGET_FOLDER: u32 = 1 << 1;
107 pub const LANDING_FILENAME: u32 = 1 << 2;
108 pub const LANDING_FOLDER: u32 = 1 << 3;
109 pub const CREATOR_PROCESS: u32 = 1 << 4;
110 pub const CREATOR_COMM: u32 = 1 << 5;
111 pub const CREATOR_UID: u32 = 1 << 6;
112 pub const EXECUTION_UID: u32 = 1 << 7;
113}
114
115/// Maximum number of allowlist rules carried by the BPF Array map.
116/// Each rule check is ~30 ops + 2 folder lookups; the verifier walks
117/// the full bounded loop, so this caps the per-execve cost.
118pub const MAX_RULES: usize = 32;
119
120/// One allowlist rule. Set bits in `flags` mark required dims; the
121/// corresponding fields below are then compared against the record /
122/// execve context at enforce time. Cleared bits → field ignored.
123///
124/// Strings are stored as FNV-1a-64 hashes (computed identically in
125/// userspace and BPF). Collision probability for distinct strings under
126/// FNV-64 is negligible at any realistic allowlist size.
127#[repr(C)]
128#[derive(Copy, Clone)]
129#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
130pub struct AllowRule {
131 pub flags: u32,
132 pub creator_uid: u32,
133 pub execution_uid: u32,
134 pub _pad: u32,
135 pub creator_comm: [u8; COMM_LEN],
136 pub target_filename_hash: u64,
137 pub target_folder_hash: u64,
138 pub landing_filename_hash: u64,
139 pub landing_folder_hash: u64,
140 pub creator_process_hash: u64,
141}
142
143pub const XATTR_NAME: &str = "security.bpf.linprov.origin";
144
145pub const EVENT_KIND_NETWORK_FILE_OPEN: u32 = 1;
146pub const EVENT_KIND_EXECVE: u32 = 2;
147
148/// Runtime mode communicated to the eBPF program via the CONFIG map.
149pub const MODE_OBSERVE: u32 = 0;
150pub const MODE_SOAK: u32 = 1; // eBPF behaves like OBSERVE; userspace records paths
151pub const MODE_ENFORCE: u32 = 2;
152
153/// Current schema version of [`OriginRecord`]. Records carrying a different
154/// version are treated as unmarked.
155///
156/// v4 made the record fully hash-based: the variable-length path fields
157/// (`creator_path`, the landing folder, the landing basename) became
158/// `u64` FNV hashes instead of fixed buffers. This lifts the path-length
159/// ceiling (a hash is the same 8 bytes whether the path is 12 or 4096
160/// bytes) and shrinks the record to 64 bytes, well under the xattr
161/// block limit. Human-readable resolution of those hashes lives in the
162/// plaintext audit db (see the `hashdb` userspace module), not in the
163/// record. v3 records (which embedded path strings) are treated as
164/// unmarked and get re-marked on next open.
165pub const ORIGIN_VERSION: u32 = 4;
166
167/// Provenance record. Carried in the `security.bpf.linprov.origin` xattr
168/// and in the INODE_MARKS storage map. Fixed 64 bytes — every
169/// variable-length field is an FNV-1a-64 hash, so the record never
170/// grows with path length and always fits a single xattr block.
171///
172/// Filled in stages:
173/// * BPF `file_open` sets `version`, `pid`, `ts_boot_ns`, `comm`,
174/// `creator_uid`, and the two landing hashes (`landing_folder_hash`,
175/// `landing_basename_hash`), computed in one pass over the landing
176/// path. `creator_path_hash` is left 0 — BPF can't cheaply resolve
177/// the creator's exe path here.
178/// * Userspace, on the corresponding ringbuf event, reads
179/// `/proc/$pid/exe`, fills `creator_path_hash`, and overwrites the
180/// xattr with the augmented record. It also records each hash →
181/// path mapping in the plaintext audit db so logs, soak, and the
182/// user's own `grep` can resolve hashes back to paths.
183///
184/// `creator_path_hash == 0` is the "not yet augmented" sentinel:
185/// `bprm_check_security` reads the storage record first and falls
186/// through to the xattr when it sees a zero creator hash. Rules keyed
187/// on `creator_process` won't match an unaugmented record, but other
188/// dims still do.
189#[repr(C)]
190#[derive(Copy, Clone)]
191#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
192pub struct OriginRecord {
193 pub version: u32,
194 pub pid: u32,
195 pub ts_boot_ns: u64,
196 pub comm: [u8; COMM_LEN],
197 pub creator_uid: u32,
198 pub _pad: u32,
199 /// FNV-1a-64 of the creator's full exe path (`/proc/$pid/exe`).
200 /// 0 until userspace augments the record.
201 pub creator_path_hash: u64,
202 /// FNV-1a-64 of the landing file's immediate parent directory,
203 /// including the trailing `/` (matches `normalize_folder`). Always
204 /// the immediate parent regardless of depth — used for exact
205 /// `landing_folder` matching and for soak/log resolution.
206 pub landing_folder_hash: u64,
207 /// FNV-1a-64 of the landing file's basename (final path component,
208 /// no slash).
209 pub landing_basename_hash: u64,
210 /// FNV-1a-64 of each `/`-terminated ancestor of the landing path
211 /// (shallow → deep), up to [`MAX_FOLDER_ANCESTORS`]. Enables nested
212 /// `landing_folder` matching: a rule whose folder hash equals any
213 /// entry matches. Unused slots are 0.
214 pub landing_ancestor_hashes: [u64; MAX_FOLDER_ANCESTORS],
215}
216
217/// Ring-buffer record. Two kinds:
218/// NetworkFileOpen — informational; eBPF just wrote (or tried to write)
219/// the xattr. `status` is the kfunc return code.
220/// Execve — bprm_check fired AND the file already carried the mark.
221/// `origin` is the record we read back; `status` is unused.
222#[repr(C)]
223#[derive(Copy, Clone)]
224#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
225pub struct Event {
226 pub kind: u32,
227 pub pid: u32,
228 pub tgid: u32,
229 pub status: i32,
230 pub comm: [u8; COMM_LEN],
231 pub origin: OriginRecord,
232 /// The live path: landing path for `NetworkFileOpen`, exec/target
233 /// path for `Execve`. Sized to `PATH_MAX`; transient (ringbuf only).
234 pub filename: [u8; EXEC_PATH_LEN],
235}
236
237impl Event {
238 pub const SIZE: usize = core::mem::size_of::<Self>();
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn fnv_known_vectors() {
247 // FNV-1a-64 offset basis for the empty string.
248 assert_eq!(fnv_hash(""), 0xcbf2_9ce4_8422_2325);
249 // Pre-computed reference values from a separate FNV implementation.
250 assert_eq!(fnv_hash("a"), 0xaf63_dc4c_8601_ec8c);
251 assert_eq!(fnv_hash("foobar"), 0x85944171_f73967e8);
252 }
253
254 #[test]
255 fn fnv_string_and_bytes_agree() {
256 let s = "/usr/bin/curl";
257 assert_eq!(fnv_hash(s), fnv_hash_bytes(s.as_bytes()));
258 }
259
260 #[test]
261 fn dim_flags_are_unique() {
262 let all = [
263 dim::TARGET_FILENAME,
264 dim::TARGET_FOLDER,
265 dim::LANDING_FILENAME,
266 dim::LANDING_FOLDER,
267 dim::CREATOR_PROCESS,
268 dim::CREATOR_COMM,
269 dim::CREATOR_UID,
270 dim::EXECUTION_UID,
271 ];
272 let mut acc = 0u32;
273 for d in all {
274 assert_eq!(d.count_ones(), 1, "each dim is one bit");
275 assert_eq!(acc & d, 0, "dim {d:#b} overlaps with prior {acc:#b}");
276 acc |= d;
277 }
278 }
279
280 #[test]
281 fn origin_record_size_is_v4_expected() {
282 // 4 + 4 + 8 + 16 + 4 + 4 + 8 + 8 + 8 + 8*MAX_FOLDER_ANCESTORS
283 let base = 4 + 4 + 8 + 16 + 4 + 4 + 8 + 8 + 8;
284 assert_eq!(
285 core::mem::size_of::<OriginRecord>(),
286 base + 8 * MAX_FOLDER_ANCESTORS
287 );
288 }
289
290 #[test]
291 fn allow_rule_size_has_no_padding() {
292 // 4 + 4 + 4 + 4 + 16 + 8*5 = 72
293 assert_eq!(core::mem::size_of::<AllowRule>(), 72);
294 }
295
296 #[test]
297 fn fnv_constants_match_reference() {
298 // FNV-1a-64 parameters per http://www.isthe.com/chongo/tech/comp/fnv/
299 assert_eq!(FNV_OFFSET, 0xcbf2_9ce4_8422_2325);
300 assert_eq!(FNV_PRIME, 0x100_0000_01b3);
301 }
302}