|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT License. |
| 3 | + |
| 4 | +// Fixed, hand-audited lsm/file_open file-access enforcer. |
| 5 | +// |
| 6 | +// This is the kernel half of `regorus-bpf-lsm`. Like its egress sibling, it is |
| 7 | +// intentionally a *fixed* program: policy is data, not code. The user-space |
| 8 | +// exporter (`regorus_bpf_lsm::export`) compiles a `regorus-lift` EnforcerConfig |
| 9 | +// into the rule table consumed here; this program merely scans that table. |
| 10 | +// |
| 11 | +// It is INTENDED to mirror the user-space reference enforcer |
| 12 | +// `regorus_bpf_lsm::enforce`: |
| 13 | +// |
| 14 | +// * extract the path and op from the opened `struct file`, |
| 15 | +// * scan up to MAX_RULES rule rows, |
| 16 | +// * ALLOW iff some rule's full conjunction matches (wildcards match |
| 17 | +// anything); otherwise the request is UNDECIDED, which collapses to DENY. |
| 18 | +// |
| 19 | +// For the LSM hook, the boundary verdicts map to return values: |
| 20 | +// ALLOW -> 0 (permit the open), DENY/UNDECIDED -> -EPERM (block). |
| 21 | +// |
| 22 | +// --------------------------------------------------------------------------- |
| 23 | +// Phase-2 SOUNDNESS CAVEAT (documented, intentional). |
| 24 | +// |
| 25 | +// The kernel cannot trivially do arbitrary-length string matching, and |
| 26 | +// `bpf_d_path` returns a path that is NOT the canonicalized string the policy |
| 27 | +// was written against (no symlink/`..`/mount-namespace normalization), and is |
| 28 | +// read into a BOUNDED buffer (MAX_PATH_PREFIX bytes). This program is therefore |
| 29 | +// BEST-EFFORT: |
| 30 | +// |
| 31 | +// * It compares only the first MAX_PATH_PREFIX bytes. |
| 32 | +// * EXACT matches additionally require the kernel path length to equal the |
| 33 | +// pattern length (so a bounded read cannot let a longer path masquerade as |
| 34 | +// a shorter exact pattern). |
| 35 | +// * A pattern longer than MAX_PATH_PREFIX is treated as a NON-match |
| 36 | +// (fail-closed), never a truncated/over-permissive match. |
| 37 | +// |
| 38 | +// Canonicalization stays in USER SPACE. The conformance reference for the |
| 39 | +// lowered IR is the user-space enforcer (`src/enforcer.rs`), NOT this program. |
| 40 | +// |
| 41 | +// --------------------------------------------------------------------------- |
| 42 | +// Kernel-version assumptions: |
| 43 | +// * BTF + CO-RE (a kernel with /sys/kernel/btf/vmlinux). |
| 44 | +// * BPF LSM support (CONFIG_BPF_LSM, `lsm.s`/`lsm` hooks) and the |
| 45 | +// `bpf_d_path` helper, both available on modern (>= 5.10) kernels. |
| 46 | +// We cannot LOAD this here (uid 1000, no CAP_BPF); the gated compile test only |
| 47 | +// checks that it COMPILES against the live BTF. If `struct file`/`struct path` |
| 48 | +// are absent from BTF we still produce a compilable object via the guards below. |
| 49 | + |
| 50 | +#include "vmlinux.h" |
| 51 | +#include "regorus_bpf_compat.h" |
| 52 | + |
| 53 | +#define MAX_RULES 64 |
| 54 | +// Must match regorus_bpf_lsm::abi::MAX_PATH_PREFIX. |
| 55 | +#define MAX_PATH_PREFIX 256 |
| 56 | + |
| 57 | +// Path match kinds (must match regorus_bpf_lsm::plan::PathMatch ordering intent). |
| 58 | +#define PATH_ANY 0 |
| 59 | +#define PATH_EXACT 1 |
| 60 | +#define PATH_PREFIX 2 |
| 61 | + |
| 62 | +// Op match kinds (must match regorus_bpf_lsm::plan::OpMatch). |
| 63 | +#define OP_MATCH_ANY 0 |
| 64 | +#define OP_MATCH_EXACT 1 |
| 65 | + |
| 66 | +// File operations (must match regorus_bpf_lsm::abi::FileOp). |
| 67 | +#define OP_READ 0 |
| 68 | +#define OP_WRITE 1 |
| 69 | +#define OP_EXEC 2 |
| 70 | + |
| 71 | +// Verdict ABI (must match regorus_bpf_lsm::abi::Verdict). |
| 72 | +#define VERDICT_DENY 0 |
| 73 | +#define VERDICT_ALLOW 1 |
| 74 | +#define VERDICT_UNDECIDED 2 |
| 75 | + |
| 76 | +// FMODE_* bits from include/linux/fs.h (stable UAPI-adjacent constants). |
| 77 | +#define FMODE_READ_BIT 0x1 |
| 78 | +#define FMODE_WRITE_BIT 0x2 |
| 79 | +#define FMODE_EXEC_BIT 0x20 |
| 80 | + |
| 81 | +#ifndef EPERM |
| 82 | +#define EPERM 1 |
| 83 | +#endif |
| 84 | + |
| 85 | +// One rule row: a full conjunction over the two observable fields. `pattern` |
| 86 | +// holds the path bytes (exact or prefix), `pattern_len` its length in bytes |
| 87 | +// (<= MAX_PATH_PREFIX; a longer pattern is rejected by user space before it |
| 88 | +// reaches the kernel, and additionally guarded here). |
| 89 | +struct file_rule { |
| 90 | + __u8 path_kind; // PATH_ANY | PATH_EXACT | PATH_PREFIX |
| 91 | + __u8 op_kind; // OP_MATCH_ANY | OP_MATCH_EXACT |
| 92 | + __u8 op_value; // OP_READ | OP_WRITE | OP_EXEC |
| 93 | + __u8 _pad; |
| 94 | + __u32 pattern_len; |
| 95 | + char pattern[MAX_PATH_PREFIX]; |
| 96 | +}; |
| 97 | + |
| 98 | +// The rule table, populated by user space from the exported FilePlan. |
| 99 | +struct { |
| 100 | + __uint(type, BPF_MAP_TYPE_ARRAY); |
| 101 | + __uint(max_entries, MAX_RULES); |
| 102 | + __type(key, __u32); |
| 103 | + __type(value, struct file_rule); |
| 104 | +} file_rules SEC(".maps"); |
| 105 | + |
| 106 | +// Number of populated rule rows (index 0). A request matches only rows |
| 107 | +// [0, rule_count); the rest of the fixed-size table is ignored. |
| 108 | +struct { |
| 109 | + __uint(type, BPF_MAP_TYPE_ARRAY); |
| 110 | + __uint(max_entries, 1); |
| 111 | + __type(key, __u32); |
| 112 | + __type(value, __u32); |
| 113 | +} file_rule_count SEC(".maps"); |
| 114 | + |
| 115 | +// A single-entry control map: when value != 0, enforcement is active. (When the |
| 116 | +// table is empty / unconfigured this lets the loader choose fail-open during |
| 117 | +// rollout; the default compiled-in behaviour is fail-closed.) |
| 118 | +struct { |
| 119 | + __uint(type, BPF_MAP_TYPE_ARRAY); |
| 120 | + __uint(max_entries, 1); |
| 121 | + __type(key, __u32); |
| 122 | + __type(value, __u32); |
| 123 | +} file_enabled SEC(".maps"); |
| 124 | + |
| 125 | +// A scratch percpu buffer to read the path into (the stack is too small for |
| 126 | +// MAX_PATH_PREFIX). |
| 127 | +struct { |
| 128 | + __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); |
| 129 | + __uint(max_entries, 1); |
| 130 | + __type(key, __u32); |
| 131 | + __type(value, char[MAX_PATH_PREFIX]); |
| 132 | +} file_path_scratch SEC(".maps"); |
| 133 | + |
| 134 | +// bpf_d_path (helper ID 147): copy a `struct path` into `buf`, returning the |
| 135 | +// length (including NUL) on success or a negative errno. Declared here because |
| 136 | +// the minimal compat shim only provides bpf_map_lookup_elem. When the real |
| 137 | +// libbpf headers are present they declare it for us, so only add it in shim |
| 138 | +// mode. |
| 139 | +#if !__has_include(<bpf/bpf_helpers.h>) |
| 140 | +static long (*bpf_d_path)(struct path *path, char *buf, __u32 sz) = (void *)147; |
| 141 | +#endif |
| 142 | + |
| 143 | +static __always_inline __u8 op_from_fmode(unsigned int f_mode) |
| 144 | +{ |
| 145 | + // Exec is the most restrictive intent; classify it first. Then write, |
| 146 | + // then default to read. |
| 147 | + if (f_mode & FMODE_EXEC_BIT) |
| 148 | + return OP_EXEC; |
| 149 | + if (f_mode & FMODE_WRITE_BIT) |
| 150 | + return OP_WRITE; |
| 151 | + return OP_READ; |
| 152 | +} |
| 153 | + |
| 154 | +// Bounded comparison of the first `n` bytes of `a` and `b`. |
| 155 | +static __always_inline bool bytes_equal(const char *a, const char *b, __u32 n) |
| 156 | +{ |
| 157 | + for (__u32 i = 0; i < MAX_PATH_PREFIX; i++) { |
| 158 | + if (i >= n) |
| 159 | + break; |
| 160 | + if (a[i] != b[i]) |
| 161 | + return false; |
| 162 | + } |
| 163 | + return true; |
| 164 | +} |
| 165 | + |
| 166 | +static __always_inline bool path_matches(const struct file_rule *r, |
| 167 | + const char *path, __u32 path_len) |
| 168 | +{ |
| 169 | + __u32 plen = r->pattern_len; |
| 170 | + |
| 171 | + if (r->path_kind == PATH_ANY) |
| 172 | + return true; |
| 173 | + |
| 174 | + // A pattern we could not fully store cannot be verified soundly. |
| 175 | + if (plen == 0 || plen > MAX_PATH_PREFIX) |
| 176 | + return false; |
| 177 | + |
| 178 | + if (r->path_kind == PATH_EXACT) { |
| 179 | + // Exact requires equal length AND equal bytes; otherwise a longer |
| 180 | + // path sharing a prefix could masquerade as the exact pattern. |
| 181 | + if (path_len != plen) |
| 182 | + return false; |
| 183 | + return bytes_equal(path, r->pattern, plen); |
| 184 | + } |
| 185 | + |
| 186 | + if (r->path_kind == PATH_PREFIX) { |
| 187 | + // The path must be at least as long as the prefix and share it. |
| 188 | + if (path_len < plen) |
| 189 | + return false; |
| 190 | + return bytes_equal(path, r->pattern, plen); |
| 191 | + } |
| 192 | + |
| 193 | + return false; |
| 194 | +} |
| 195 | + |
| 196 | +static __always_inline bool op_matches(const struct file_rule *r, __u8 op) |
| 197 | +{ |
| 198 | + if (r->op_kind == OP_MATCH_ANY) |
| 199 | + return true; |
| 200 | + return r->op_kind == OP_MATCH_EXACT && op == r->op_value; |
| 201 | +} |
| 202 | + |
| 203 | +SEC("lsm/file_open") |
| 204 | +int regorus_file_open(unsigned long long *ctx) |
| 205 | +{ |
| 206 | + // LSM/BTF programs receive a pointer to an array of u64 arguments. For |
| 207 | + // `file_open(struct file *file)` the file pointer is ctx[0]. (This is the |
| 208 | + // manual equivalent of libbpf's BPF_PROG() unwrapping, which the minimal |
| 209 | + // compat shim does not provide.) |
| 210 | + struct file *file = (struct file *)ctx[0]; |
| 211 | + |
| 212 | + __u32 zero = 0; |
| 213 | + |
| 214 | + __u32 *enabled = bpf_map_lookup_elem(&file_enabled, &zero); |
| 215 | + if (enabled && *enabled == 0) { |
| 216 | + // Enforcement disabled: allow (fail-open is an explicit opt-in). |
| 217 | + return 0; |
| 218 | + } |
| 219 | + |
| 220 | + char *buf = bpf_map_lookup_elem(&file_path_scratch, &zero); |
| 221 | + if (!buf) |
| 222 | + return -EPERM; // no scratch -> cannot evaluate -> fail closed |
| 223 | + |
| 224 | + // Best-effort: read a bounded, NON-canonical path string. Direct field |
| 225 | + // access (`&file->f_path`) is CO-RE-relocated by clang against the BTF. |
| 226 | + long ret = bpf_d_path(&file->f_path, buf, MAX_PATH_PREFIX); |
| 227 | + if (ret <= 0) |
| 228 | + return -EPERM; // could not extract the path -> fail closed |
| 229 | + |
| 230 | + // `ret` includes the trailing NUL; the byte length is ret - 1. |
| 231 | + __u32 path_len = (__u32)ret; |
| 232 | + if (path_len > 0) |
| 233 | + path_len -= 1; |
| 234 | + if (path_len > MAX_PATH_PREFIX) |
| 235 | + path_len = MAX_PATH_PREFIX; |
| 236 | + |
| 237 | + __u8 op = op_from_fmode(file->f_mode); |
| 238 | + |
| 239 | + __u32 *count_p = bpf_map_lookup_elem(&file_rule_count, &zero); |
| 240 | + __u32 count = count_p ? *count_p : 0; |
| 241 | + if (count > MAX_RULES) |
| 242 | + count = MAX_RULES; |
| 243 | + |
| 244 | + int verdict = VERDICT_UNDECIDED; |
| 245 | + |
| 246 | + for (__u32 i = 0; i < MAX_RULES; i++) { |
| 247 | + if (i >= count) |
| 248 | + break; |
| 249 | + struct file_rule *r = bpf_map_lookup_elem(&file_rules, &i); |
| 250 | + if (!r) |
| 251 | + continue; |
| 252 | + if (path_matches(r, buf, path_len) && op_matches(r, op)) { |
| 253 | + verdict = VERDICT_ALLOW; |
| 254 | + break; |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + // Boundary collapse: only ALLOW returns 0 (permit); UNDECIDED/DENY map to |
| 259 | + // -EPERM (block the open). |
| 260 | + return verdict == VERDICT_ALLOW ? 0 : -EPERM; |
| 261 | +} |
| 262 | + |
| 263 | +char LICENSE[] SEC("license") = "GPL"; |
0 commit comments