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). 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}