Skip to main content

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
33mod siphash;
34pub use siphash::*;
35
36pub const COMM_LEN: usize = 16;
37
38/// Live exec/target path buffer size: the ringbuf [`Event`] filename
39/// and the per-CPU scratch the target-dim walks scan. Sized to Linux
40/// `PATH_MAX` so `target_filename` / `target_folder` match the full
41/// execution path at any depth and any length. These buffers are
42/// transient (per-CPU scratch + ringbuf), never persisted, so they
43/// aren't bound by the xattr block-size limit that caps stored data.
44pub const EXEC_PATH_LEN: usize = 4096;
45
46/// Max bytes the BPF path walks inspect. Equal to [`EXEC_PATH_LEN`]:
47/// the walk body is a `bpf_loop` callback the verifier inspects once,
48/// so this is bounded only by the buffer, not the instruction budget.
49pub const PATH_HASH_SCAN_LEN: usize = EXEC_PATH_LEN;
50
51/// Number of landing-folder ancestor hashes stored per record, for
52/// nested `landing_folder` matching. The walk records the hash of each
53/// `/`-terminated prefix of the landing path (shallow → deep) into a
54/// `[u64; MAX_FOLDER_ANCESTORS]`; a rule matches if its folder hash
55/// equals any of them, so `landing_folder=/home/user/` matches a file
56/// that landed in `/home/user/Downloads/sub/`. Bounds nesting *depth*
57/// (path length is still unbounded — these are hashes). Must be a power
58/// of two: the in-kernel walk masks the index (`& (N-1)`) to keep the
59/// array write provably in-bounds without a panic branch. Real landing
60/// paths sit well under this, so the mask never actually wraps.
61///
62/// Capped at 32 by the BPF 512-byte stack limit: the `file_open` walk
63/// holds this array by value in its `bpf_loop` context (`32 × 8 = 256`
64/// bytes, plus the other context fields). 64 would overflow the stack
65/// frame — it'd need the array in a per-CPU map instead, not worth it
66/// when 32 ancestor levels already exceeds any real landing path.
67pub const MAX_FOLDER_ANCESTORS: usize = 32;
68
69// FNV-1a-64 constants. Used by both sides to hash strings.
70pub const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
71pub const FNV_PRIME: u64 = 0x100_0000_01b3;
72
73/// Hash a string with FNV-1a-64. Byte-by-byte, no trailing NUL, no
74/// padding — identical on the BPF and userspace sides.
75///
76/// Both sides MUST compute the same hash for the same input; the FNV
77/// constants ([`FNV_OFFSET`], [`FNV_PRIME`]) are fixed for that reason.
78///
79/// ```
80/// use linprov_common::fnv_hash;
81/// // FNV-1a of the empty string is the offset basis.
82/// assert_eq!(fnv_hash(""), 0xcbf2_9ce4_8422_2325);
83/// // Distinct inputs hash distinctly.
84/// assert_ne!(fnv_hash("/tmp/"), fnv_hash("/etc/"));
85/// ```
86pub fn fnv_hash(s: &str) -> u64 {
87    fnv_hash_bytes(s.as_bytes())
88}
89
90/// Same as [`fnv_hash`], but takes a byte slice. Useful when the source
91/// isn't UTF-8 (e.g., a `[u8; EXEC_PATH_LEN]` filename buffer read out
92/// of a ringbuf event).
93pub fn fnv_hash_bytes(bytes: &[u8]) -> u64 {
94    let mut h = FNV_OFFSET;
95    for &b in bytes {
96        h ^= b as u64;
97        h = h.wrapping_mul(FNV_PRIME);
98    }
99    h
100}
101
102// ----- Allowlist rule. One per line in the allowlist file; each rule
103// is a conjunction of (dim, value) conditions. Rules OR together.
104
105/// `flags` bits on [`AllowRule`]. Bits 0–7 are the dims a rule requires.
106/// Bits 8+ are *modifiers* on a dim already set.
107pub mod dim {
108    pub const TARGET_FILENAME: u32 = 1 << 0;
109    pub const TARGET_FOLDER: u32 = 1 << 1;
110    pub const LANDING_FILENAME: u32 = 1 << 2;
111    pub const LANDING_FOLDER: u32 = 1 << 3;
112    pub const CREATOR_PROCESS: u32 = 1 << 4;
113    pub const CREATOR_COMM: u32 = 1 << 5;
114    pub const CREATOR_UID: u32 = 1 << 6;
115    pub const EXECUTION_UID: u32 = 1 << 7;
116
117    /// Modifier on `TARGET_FOLDER`: match the folder **or any descendant**
118    /// (`target_folder=/opt/app/*`) instead of only files directly in it
119    /// (`target_folder=/opt/app/`). Recursive = the rule folder is any
120    /// `/`-prefix of the live exec path; exact = the rule folder is the
121    /// executed file's immediate parent.
122    pub const TARGET_FOLDER_RECURSIVE: u32 = 1 << 8;
123    /// Modifier on `LANDING_FOLDER`, same meaning for the landing path.
124    /// Recursive = any of the record's stored ancestor hashes; exact =
125    /// the immediate-parent hash.
126    pub const LANDING_FOLDER_RECURSIVE: u32 = 1 << 9;
127}
128
129/// Maximum number of allowlist rules carried by the BPF `ALLOW_RULES`
130/// Array map (× 72 bytes ≈ 590 KiB at this size).
131///
132/// The per-rule scan runs inside a single `bpf_loop` callback
133/// (`allow_step`), so the verifier inspects the rule body **once** instead
134/// of unrolling it — this value costs map memory and at-most-N runtime
135/// iterations per execve (N = the actual rule count, not this ceiling),
136/// but is O(1) to the verifier and to daemon load time. The earlier
137/// *unrolled* loop topped out below 64 rules against the 1M-instruction
138/// budget; this is 256× the old 32-rule limit. An over-capacity allowlist
139/// degrades gracefully (the daemon loads the first `MAX_RULES` and warns —
140/// see `check_capacity`), so it never crash-loops on a file that outgrew
141/// the ceiling.
142pub const MAX_RULES: usize = 8192;
143
144/// One allowlist rule. Set bits in `flags` mark required dims; the
145/// corresponding fields below are then compared against the record /
146/// execve context at enforce time. Cleared bits → field ignored.
147///
148/// Strings are stored as FNV-1a-64 hashes (computed identically in
149/// userspace and BPF). Collision probability for distinct strings under
150/// FNV-64 is negligible at any realistic allowlist size.
151#[repr(C)]
152#[derive(Copy, Clone)]
153#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
154pub struct AllowRule {
155    pub flags: u32,
156    pub creator_uid: u32,
157    pub execution_uid: u32,
158    pub _pad: u32,
159    pub creator_comm: [u8; COMM_LEN],
160    pub target_filename_hash: u64,
161    pub target_folder_hash: u64,
162    pub landing_filename_hash: u64,
163    pub landing_folder_hash: u64,
164    pub creator_process_hash: u64,
165}
166
167pub const XATTR_NAME: &str = "security.bpf.linprov.origin";
168
169pub const EVENT_KIND_NETWORK_FILE_OPEN: u32 = 1;
170pub const EVENT_KIND_EXECVE: u32 = 2;
171/// A file written by a process that had **read** a marked file (taint
172/// propagation — e.g. `tar`/`unzip` extracting a marked archive, or `cp`
173/// of a marked file). The carried `origin` is inherited from the source
174/// file's record; userspace persists the xattr without re-resolving the
175/// creator (the inherited creator identity is kept verbatim).
176pub const EVENT_KIND_DERIVED_FILE_OPEN: u32 = 3;
177/// A marked file opened for **read** by a known script interpreter
178/// (bash/python/…) — i.e. `python foo.py` / `bash foo.sh` / `. foo.sh`,
179/// where the kernel execve's the unmarked interpreter and the script
180/// itself never reaches `bprm_check_security`. The eBPF `file_open` read
181/// branch runs the same allowlist check used at execve against the
182/// script's path; in enforce mode `status` is the LSM verdict (`-1`
183/// blocked). The `filename` carries the script's path and `comm` the
184/// script's basename, so the script — not the interpreter — is the unit
185/// surfaced in logs and soak. Matches `EVENT_KIND_EXECVE` handling.
186pub const EVENT_KIND_SCRIPT_EXEC: u32 = 4;
187
188/// Runtime mode communicated to the eBPF program via the CONFIG map.
189pub const MODE_OBSERVE: u32 = 0;
190pub const MODE_SOAK: u32 = 1; // eBPF behaves like OBSERVE; userspace records paths
191pub const MODE_ENFORCE: u32 = 2;
192
193/// Current schema version of [`OriginRecord`]. Records carrying a different
194/// version are treated as unmarked.
195///
196/// v4 made the record fully hash-based: the variable-length path fields
197/// (`creator_path`, the landing folder, the landing basename) became
198/// `u64` hashes instead of fixed buffers. This lifts the path-length
199/// ceiling (a hash is the same 8 bytes whether the path is 12 or 4096
200/// bytes) and shrinks the record to 64 bytes, well under the xattr
201/// block limit. Human-readable resolution of those hashes lives in the
202/// plaintext audit db (see the `hashdb` userspace module), not in the
203/// record.
204///
205/// v5 changed the hash function from FNV-1a-64 to keyed SipHash-2-4
206/// (finding #3): FNV is invertible, so a local attacker could construct a
207/// path that collides with an allowlisted one. The record layout is
208/// unchanged — only the algorithm and this version differ. Records from an
209/// older version (different/no key, or FNV) are treated as unmarked and get
210/// re-marked on next open.
211pub const ORIGIN_VERSION: u32 = 5;
212
213/// Provenance record. Carried in the `security.bpf.linprov.origin` xattr
214/// and in the INODE_MARKS storage map. Fixed 64 bytes — every
215/// variable-length field is an FNV-1a-64 hash, so the record never
216/// grows with path length and always fits a single xattr block.
217///
218/// Filled in stages:
219///   * BPF `file_open` sets `version`, `pid`, `ts_boot_ns`, `comm`,
220///     `creator_uid`, and the two landing hashes (`landing_folder_hash`,
221///     `landing_basename_hash`), computed in one pass over the landing
222///     path. `creator_path_hash` is left 0 — BPF can't cheaply resolve
223///     the creator's exe path here.
224///   * Userspace, on the corresponding ringbuf event, reads
225///     `/proc/$pid/exe`, fills `creator_path_hash`, and overwrites the
226///     xattr with the augmented record. It also records each hash →
227///     path mapping in the plaintext audit db so logs, soak, and the
228///     user's own `grep` can resolve hashes back to paths.
229///
230/// `creator_path_hash == 0` is the "not yet augmented" sentinel:
231/// `bprm_check_security` reads the storage record first and falls
232/// through to the xattr when it sees a zero creator hash. Rules keyed
233/// on `creator_process` won't match an unaugmented record, but other
234/// dims still do.
235#[repr(C)]
236#[derive(Copy, Clone)]
237#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
238pub struct OriginRecord {
239    pub version: u32,
240    pub pid: u32,
241    pub ts_boot_ns: u64,
242    pub comm: [u8; COMM_LEN],
243    pub creator_uid: u32,
244    pub _pad: u32,
245    /// FNV-1a-64 of the creator's full exe path (`/proc/$pid/exe`).
246    /// 0 until userspace augments the record.
247    pub creator_path_hash: u64,
248    /// FNV-1a-64 of the landing file's immediate parent directory,
249    /// including the trailing `/` (matches `normalize_folder`). Always
250    /// the immediate parent regardless of depth — used for exact
251    /// `landing_folder` matching and for soak/log resolution.
252    pub landing_folder_hash: u64,
253    /// FNV-1a-64 of the landing file's basename (final path component,
254    /// no slash).
255    pub landing_basename_hash: u64,
256    /// FNV-1a-64 of each `/`-terminated ancestor of the landing path
257    /// (shallow → deep), up to [`MAX_FOLDER_ANCESTORS`]. Enables nested
258    /// `landing_folder` matching: a rule whose folder hash equals any
259    /// entry matches. Unused slots are 0.
260    pub landing_ancestor_hashes: [u64; MAX_FOLDER_ANCESTORS],
261}
262
263/// Ring-buffer record. Two kinds:
264///   NetworkFileOpen — informational; eBPF just wrote (or tried to write)
265///     the xattr. `status` is the kfunc return code.
266///   Execve — bprm_check fired AND the file already carried the mark.
267///     `origin` is the record we read back; `status` is unused.
268#[repr(C)]
269#[derive(Copy, Clone)]
270#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
271pub struct Event {
272    pub kind: u32,
273    pub pid: u32,
274    pub tgid: u32,
275    pub status: i32,
276    pub comm: [u8; COMM_LEN],
277    pub origin: OriginRecord,
278    /// The live path: landing path for `NetworkFileOpen`, exec/target
279    /// path for `Execve`. Sized to `PATH_MAX`; transient (ringbuf only).
280    pub filename: [u8; EXEC_PATH_LEN],
281}
282
283impl Event {
284    pub const SIZE: usize = core::mem::size_of::<Self>();
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn fnv_known_vectors() {
293        // FNV-1a-64 offset basis for the empty string.
294        assert_eq!(fnv_hash(""), 0xcbf2_9ce4_8422_2325);
295        // Pre-computed reference values from a separate FNV implementation.
296        assert_eq!(fnv_hash("a"), 0xaf63_dc4c_8601_ec8c);
297        assert_eq!(fnv_hash("foobar"), 0x85944171_f73967e8);
298    }
299
300    #[test]
301    fn fnv_string_and_bytes_agree() {
302        let s = "/usr/bin/curl";
303        assert_eq!(fnv_hash(s), fnv_hash_bytes(s.as_bytes()));
304    }
305
306    #[test]
307    fn dim_flags_are_unique() {
308        let all = [
309            dim::TARGET_FILENAME,
310            dim::TARGET_FOLDER,
311            dim::LANDING_FILENAME,
312            dim::LANDING_FOLDER,
313            dim::CREATOR_PROCESS,
314            dim::CREATOR_COMM,
315            dim::CREATOR_UID,
316            dim::EXECUTION_UID,
317        ];
318        let mut acc = 0u32;
319        for d in all {
320            assert_eq!(d.count_ones(), 1, "each dim is one bit");
321            assert_eq!(acc & d, 0, "dim {d:#b} overlaps with prior {acc:#b}");
322            acc |= d;
323        }
324    }
325
326    #[test]
327    fn origin_record_size_is_v4_expected() {
328        // 4 + 4 + 8 + 16 + 4 + 4 + 8 + 8 + 8 + 8*MAX_FOLDER_ANCESTORS
329        let base = 4 + 4 + 8 + 16 + 4 + 4 + 8 + 8 + 8;
330        assert_eq!(
331            core::mem::size_of::<OriginRecord>(),
332            base + 8 * MAX_FOLDER_ANCESTORS
333        );
334    }
335
336    #[test]
337    fn allow_rule_size_has_no_padding() {
338        // 4 + 4 + 4 + 4 + 16 + 8*5 = 72
339        assert_eq!(core::mem::size_of::<AllowRule>(), 72);
340    }
341
342    #[test]
343    fn fnv_constants_match_reference() {
344        // FNV-1a-64 parameters per http://www.isthe.com/chongo/tech/comp/fnv/
345        assert_eq!(FNV_OFFSET, 0xcbf2_9ce4_8422_2325);
346        assert_eq!(FNV_PRIME, 0x100_0000_01b3);
347    }
348}