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
33pub const COMM_LEN: usize = 16;
34pub const PATH_LEN: usize = 256;
35pub const CREATOR_PATH_LEN: usize = 256;
36
37/// Max path length the BPF FNV walks inspect (one for `target_filename`,
38/// one for `landing_filename`). Rules whose path-shaped values exceed
39/// this can't possibly match — userspace rejects them at parse time.
40pub const PATH_HASH_SCAN_LEN: usize = 64;
41
42/// Max number of `/`-separated ancestor hashes we collect per filename
43/// for folder-rule matching. Each represents one ancestor directory
44/// (`/`, `/opt/`, `/opt/installed/`, …). Bounded so the verifier can
45/// reason about the rule-iteration loop and the inner folder match.
46pub const MAX_FOLDER_HASHES: usize = 4;
47
48// FNV-1a-64 constants. Used by both sides to hash strings.
49pub const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
50pub const FNV_PRIME: u64 = 0x100_0000_01b3;
51
52/// Hash a string with FNV-1a-64. Byte-by-byte, no trailing NUL, no
53/// padding — identical on the BPF and userspace sides.
54///
55/// Both sides MUST compute the same hash for the same input; the FNV
56/// constants ([`FNV_OFFSET`], [`FNV_PRIME`]) are fixed for that reason.
57///
58/// ```
59/// use linprov_common::fnv_hash;
60/// // FNV-1a of the empty string is the offset basis.
61/// assert_eq!(fnv_hash(""), 0xcbf2_9ce4_8422_2325);
62/// // Distinct inputs hash distinctly.
63/// assert_ne!(fnv_hash("/tmp/"), fnv_hash("/etc/"));
64/// ```
65pub fn fnv_hash(s: &str) -> u64 {
66    fnv_hash_bytes(s.as_bytes())
67}
68
69/// Same as [`fnv_hash`], but takes a byte slice. Useful when the source
70/// isn't UTF-8 (e.g., a `[u8; PATH_LEN]` filename buffer read out of a
71/// ringbuf event).
72pub fn fnv_hash_bytes(bytes: &[u8]) -> u64 {
73    let mut h = FNV_OFFSET;
74    for &b in bytes {
75        h ^= b as u64;
76        h = h.wrapping_mul(FNV_PRIME);
77    }
78    h
79}
80
81// ----- Allowlist rule. One per line in the allowlist file; each rule
82// is a conjunction of (dim, value) conditions. Rules OR together.
83
84/// `flags` bits on [`AllowRule`]. Set bits indicate which dims this
85/// rule requires the record / execve context to match.
86pub mod dim {
87    pub const TARGET_FILENAME: u32 = 1 << 0;
88    pub const TARGET_FOLDER: u32 = 1 << 1;
89    pub const LANDING_FILENAME: u32 = 1 << 2;
90    pub const LANDING_FOLDER: u32 = 1 << 3;
91    pub const CREATOR_PROCESS: u32 = 1 << 4;
92    pub const CREATOR_COMM: u32 = 1 << 5;
93    pub const CREATOR_UID: u32 = 1 << 6;
94    pub const EXECUTION_UID: u32 = 1 << 7;
95}
96
97/// Maximum number of allowlist rules carried by the BPF Array map.
98/// Each rule check is ~30 ops + 2 folder lookups; the verifier walks
99/// the full bounded loop, so this caps the per-execve cost.
100pub const MAX_RULES: usize = 32;
101
102/// One allowlist rule. Set bits in `flags` mark required dims; the
103/// corresponding fields below are then compared against the record /
104/// execve context at enforce time. Cleared bits → field ignored.
105///
106/// Strings are stored as FNV-1a-64 hashes (computed identically in
107/// userspace and BPF). Collision probability for distinct strings under
108/// FNV-64 is negligible at any realistic allowlist size.
109#[repr(C)]
110#[derive(Copy, Clone)]
111#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
112pub struct AllowRule {
113    pub flags: u32,
114    pub creator_uid: u32,
115    pub execution_uid: u32,
116    pub _pad: u32,
117    pub creator_comm: [u8; COMM_LEN],
118    pub target_filename_hash: u64,
119    pub target_folder_hash: u64,
120    pub landing_filename_hash: u64,
121    pub landing_folder_hash: u64,
122    pub creator_process_hash: u64,
123}
124
125pub const XATTR_NAME: &str = "security.bpf.linprov.origin";
126
127pub const EVENT_KIND_NETWORK_FILE_OPEN: u32 = 1;
128pub const EVENT_KIND_EXECVE: u32 = 2;
129
130/// Runtime mode communicated to the eBPF program via the CONFIG map.
131pub const MODE_OBSERVE: u32 = 0;
132pub const MODE_SOAK: u32 = 1; // eBPF behaves like OBSERVE; userspace records paths
133pub const MODE_ENFORCE: u32 = 2;
134
135/// Current schema version of [`OriginRecord`]. Records carrying a different
136/// version are treated as unmarked.
137pub const ORIGIN_VERSION: u32 = 3;
138
139/// Provenance record. Carried in the `security.bpf.linprov.origin` xattr
140/// and in the INODE_MARKS storage map.
141///
142/// Filled in stages:
143///   * BPF `file_open` writes `version`, `pid`, `ts_boot_ns`, `comm`,
144///     `creator_uid`, and `landing_filename` (the path where the file
145///     was first written, via `bpf_d_path`).
146///   * Userspace, on the corresponding ringbuf event, reads
147///     `/proc/$pid/exe` and overwrites the xattr with the augmented
148///     record (`creator_path` filled).
149///
150/// `creator_path` may be all-zeros if the creator process exited
151/// before userspace got to it. Allowlist rules keyed on
152/// `creator_process` won't match such records, but other dims still do.
153#[repr(C)]
154#[derive(Copy, Clone)]
155#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
156pub struct OriginRecord {
157    pub version: u32,
158    pub pid: u32,
159    pub ts_boot_ns: u64,
160    pub comm: [u8; COMM_LEN],
161    pub creator_uid: u32,
162    pub _pad: u32,
163    pub creator_path: [u8; CREATOR_PATH_LEN],
164    pub landing_filename: [u8; PATH_LEN],
165}
166
167/// Ring-buffer record. Two kinds:
168///   NetworkFileOpen — informational; eBPF just wrote (or tried to write)
169///     the xattr. `status` is the kfunc return code.
170///   Execve — bprm_check fired AND the file already carried the mark.
171///     `origin` is the record we read back; `status` is unused.
172#[repr(C)]
173#[derive(Copy, Clone)]
174#[cfg_attr(feature = "user", derive(bytemuck::Pod, bytemuck::Zeroable))]
175pub struct Event {
176    pub kind: u32,
177    pub pid: u32,
178    pub tgid: u32,
179    pub status: i32,
180    pub comm: [u8; COMM_LEN],
181    pub origin: OriginRecord,
182    pub filename: [u8; PATH_LEN],
183}
184
185impl Event {
186    pub const SIZE: usize = core::mem::size_of::<Self>();
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn fnv_known_vectors() {
195        // FNV-1a-64 offset basis for the empty string.
196        assert_eq!(fnv_hash(""), 0xcbf2_9ce4_8422_2325);
197        // Pre-computed reference values from a separate FNV implementation.
198        assert_eq!(fnv_hash("a"), 0xaf63_dc4c_8601_ec8c);
199        assert_eq!(fnv_hash("foobar"), 0x85944171_f73967e8);
200    }
201
202    #[test]
203    fn fnv_string_and_bytes_agree() {
204        let s = "/usr/bin/curl";
205        assert_eq!(fnv_hash(s), fnv_hash_bytes(s.as_bytes()));
206    }
207
208    #[test]
209    fn dim_flags_are_unique() {
210        let all = [
211            dim::TARGET_FILENAME,
212            dim::TARGET_FOLDER,
213            dim::LANDING_FILENAME,
214            dim::LANDING_FOLDER,
215            dim::CREATOR_PROCESS,
216            dim::CREATOR_COMM,
217            dim::CREATOR_UID,
218            dim::EXECUTION_UID,
219        ];
220        let mut acc = 0u32;
221        for d in all {
222            assert_eq!(d.count_ones(), 1, "each dim is one bit");
223            assert_eq!(acc & d, 0, "dim {d:#b} overlaps with prior {acc:#b}");
224            acc |= d;
225        }
226    }
227
228    #[test]
229    fn origin_record_size_is_v3_expected() {
230        // 4 + 4 + 8 + 16 + 4 + 4 + 256 + 256 = 552
231        assert_eq!(core::mem::size_of::<OriginRecord>(), 552);
232    }
233
234    #[test]
235    fn allow_rule_size_has_no_padding() {
236        // 4 + 4 + 4 + 4 + 16 + 8*5 = 72
237        assert_eq!(core::mem::size_of::<AllowRule>(), 72);
238    }
239
240    #[test]
241    fn fnv_constants_match_reference() {
242        // FNV-1a-64 parameters per http://www.isthe.com/chongo/tech/comp/fnv/
243        assert_eq!(FNV_OFFSET, 0xcbf2_9ce4_8422_2325);
244        assert_eq!(FNV_PRIME, 0x100_0000_01b3);
245    }
246}