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}