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