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