bootintel_detectors/os_hardening.rs
1//! Kernel hardening posture, read from what the kernel announced at boot.
2//!
3//! A port of `api/analysis_engine/detectors/os_hardening.py`, kept in step by
4//! the shared expectation in `tests/fixtures/boot_chain/expect.txt`.
5//!
6//! The boot log states plainly which protections are active: mandatory access
7//! control, memory initialisation, kernel address randomisation. A practitioner
8//! reads `mem auto-init: stack:off, heap alloc:off, heap free:off` and knows
9//! immediately that a whole class of uninitialised-memory bugs stays exploitable
10//! on this device; `grep` hands that back one line at a time with no indication
11//! which of the three mattered.
12//!
13//! # The trap this module is built around
14//!
15//! `selinux=0` means opposite things depending on the line it sits on:
16//!
17//! ```text
18//! cmdline: console=ttyS3 ... selinux=0 scandelay root=/2 (bootintel-1)
19//! Unknown command line parameters: ... selinux=0 (bootintel-6)
20//! ```
21//!
22//! The first is SELinux switched off. The second is the kernel reporting it did
23//! not recognise the parameter, which means SELinux is not compiled in at all: a
24//! different and worse fact. Reporting the second as "disabled by boot
25//! parameter" would describe a device that does not exist while understating the
26//! real finding, so the unknown-parameter line is parsed first and anything
27//! listed there is treated as not applied.
28//!
29//! # What this does not do
30//!
31//! It records facts and raises no findings, because the Rust and browser
32//! detector sets are pinned to the same 14 labels and a fifteenth would break
33//! that parity. The engine raises the findings; both sides share the facts.
34//!
35//! Absence is never evidence: a capture that never mentions KASLR is not a
36//! capture proving it off, and nothing here reports it as such.
37
38use std::sync::LazyLock;
39
40use regex::Regex;
41
42// Character-for-character from the engine module, for the same reason as the
43// boot-chain patterns: reasoning about whether two hand-written tokenisers agree
44// is more expensive than keeping them identical.
45//
46// Unanchored on purpose. The same kernel line arrives with a `[ 0.000000]`
47// prefix on one device and a `Feb 25 13:51:14 host kernel:` syslog prefix on
48// three others; anchoring it silently misses those.
49static RE_MEM_AUTO_INIT: LazyLock<Regex> = LazyLock::new(|| {
50 Regex::new(r"(?i)mem auto-init:\s*stack:(\S+?),\s*heap alloc:(\S+?),\s*heap free:(\S+?)\s*$")
51 .unwrap()
52});
53
54// `KASLR disabled due to lack of seed` is the embedded failure mode: the kernel
55// supports randomisation and the bootloader handed it no entropy, so it is off
56// on a device whose vendor believes it is on.
57static RE_KASLR_OFF: LazyLock<Regex> =
58 LazyLock::new(|| Regex::new(r"(?i)\bKASLR disabled(?:\s+due to\s+(.+?))?\s*$").unwrap());
59static RE_KASLR_ON: LazyLock<Regex> =
60 LazyLock::new(|| Regex::new(r"(?i)\bKASLR enabled\b").unwrap());
61
62static RE_LSM_LIST: LazyLock<Regex> =
63 LazyLock::new(|| Regex::new(r"(?i)\bLSM:\s*initializing\s+lsm=(\S+)").unwrap());
64static RE_APPARMOR_OFF: LazyLock<Regex> = LazyLock::new(|| {
65 Regex::new(r"(?i)AppArmor:\s*AppArmor disabled by boot time parameter").unwrap()
66});
67static RE_SELINUX_STATE: LazyLock<Regex> = LazyLock::new(|| {
68 Regex::new(r"(?i)SELinux:\s*(Initializing|Permissive|Enforcing|Disabled at runtime)").unwrap()
69});
70static RE_UNKNOWN_PARAMS: LazyLock<Regex> =
71 LazyLock::new(|| Regex::new(r"(?i)Unknown command line parameters:\s*(.+?)\s*$").unwrap());
72static RE_CMDLINE: LazyLock<Regex> =
73 LazyLock::new(|| Regex::new(r"(?i)(?:Kernel command line|cmdline|bootargs)\s*[:=]").unwrap());
74static RE_SELINUX_OFF: LazyLock<Regex> =
75 LazyLock::new(|| Regex::new(r"(?i)\bselinux=0\b").unwrap());
76
77/// Modules that provide mandatory access control, as opposed to the ones every
78/// kernel has. `capability` is always present and enforces nothing of the kind.
79const MAC_MODULES: &[&str] = &["selinux", "apparmor", "smack", "tomoyo"];
80
81/// `mem auto-init: stack:off, heap alloc:off, heap free:off`.
82#[derive(Debug, Default, Clone, PartialEq, Eq)]
83pub struct MemAutoInit {
84 pub stack: String,
85 pub heap_alloc: String,
86 pub heap_free: String,
87}
88
89/// What the kernel said about its own hardening.
90#[derive(Debug, Default, Clone, PartialEq, Eq)]
91pub struct OsHardening {
92 pub mem_auto_init: Option<MemAutoInit>,
93 /// `enabled` or `disabled`.
94 pub kaslr: Option<String>,
95 pub kaslr_reason: Option<String>,
96 /// The active security modules, as the kernel listed them.
97 pub lsm: Vec<String>,
98 /// The subset of `lsm` that actually provides mandatory access control.
99 pub mac_modules: Vec<String>,
100 /// `disabled_by_parameter`, `not_supported`, or a runtime state.
101 pub selinux: Option<String>,
102 pub apparmor: Option<String>,
103 /// Parameters the kernel listed as unrecognised, and therefore did not apply.
104 pub ignored_kernel_parameters: Option<String>,
105}
106
107impl OsHardening {
108 /// True when the capture said nothing about any of this.
109 pub fn is_empty(&self) -> bool {
110 *self == Self::default()
111 }
112}
113
114fn clip(s: &str, max: usize) -> String {
115 s.chars().take(max).collect()
116}
117
118/// Read the kernel's hardening report.
119pub fn parse(log: &str) -> OsHardening {
120 let mut h = OsHardening::default();
121 for raw in log.lines() {
122 let line = raw.trim_end_matches(['\r', '\n']);
123
124 // First, because it changes what a later `selinux=0` means.
125 if let Some(caps) = RE_UNKNOWN_PARAMS.captures(line) {
126 let ignored = caps[1].to_string();
127 if h.ignored_kernel_parameters.is_none() {
128 h.ignored_kernel_parameters = Some(clip(&ignored, 300));
129 }
130 if RE_SELINUX_OFF.is_match(&ignored) && h.selinux.is_none() {
131 h.selinux = Some("not_supported".to_string());
132 }
133 continue;
134 }
135
136 if let Some(caps) = RE_MEM_AUTO_INIT.captures(line) {
137 if h.mem_auto_init.is_none() {
138 h.mem_auto_init = Some(MemAutoInit {
139 stack: caps[1].trim().to_string(),
140 heap_alloc: caps[2].trim().to_string(),
141 heap_free: caps[3].trim().to_string(),
142 });
143 continue;
144 }
145 }
146
147 if let Some(caps) = RE_KASLR_OFF.captures(line) {
148 if h.kaslr.is_none() {
149 h.kaslr = Some("disabled".to_string());
150 let reason = caps.get(1).map(|m| m.as_str().trim()).unwrap_or_default();
151 if !reason.is_empty() {
152 h.kaslr_reason = Some(clip(reason, 120));
153 }
154 continue;
155 }
156 }
157
158 if RE_KASLR_ON.is_match(line) && h.kaslr.is_none() {
159 h.kaslr = Some("enabled".to_string());
160 continue;
161 }
162
163 if let Some(caps) = RE_LSM_LIST.captures(line) {
164 if h.lsm.is_empty() {
165 h.lsm = caps[1]
166 .split(',')
167 .map(|x| x.trim().to_ascii_lowercase())
168 .filter(|x| !x.is_empty())
169 .collect();
170 let mut mac: Vec<String> = h
171 .lsm
172 .iter()
173 .filter(|m| MAC_MODULES.contains(&m.as_str()))
174 .cloned()
175 .collect();
176 mac.sort();
177 mac.dedup();
178 h.mac_modules = mac;
179 continue;
180 }
181 }
182
183 if RE_APPARMOR_OFF.is_match(line) && h.apparmor.is_none() {
184 h.apparmor = Some("disabled_by_parameter".to_string());
185 continue;
186 }
187
188 if let Some(caps) = RE_SELINUX_STATE.captures(line) {
189 if h.selinux.is_none() {
190 h.selinux = Some(caps[1].to_ascii_lowercase());
191 continue;
192 }
193 }
194
195 // A `selinux=0` the kernel DID recognise, on a real command line.
196 if RE_CMDLINE.is_match(line) && RE_SELINUX_OFF.is_match(line) && h.selinux.is_none() {
197 h.selinux = Some("disabled_by_parameter".to_string());
198 }
199 }
200 h
201}