diff --git a/CHANGELOG.md b/CHANGELOG.md index 42cb500..ee98cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`bootintel verdict` now reports the kernel hardening posture** alongside the + boot chain: mandatory access control, memory initialisation, and kernel address + randomisation, read from what the kernel itself announced at boot. Ported from + the engine and pinned against it by three new kernel-stage fixtures in the + shared expectation. + + It keeps the distinction that decides whether the output is trustworthy: + `selinux=0` on a command line means SELinux was switched off, while `selinux=0` + under `Unknown command line parameters:` means the kernel ignored it and SELinux + is not compiled in at all. A different, worse fact. Likewise `capability` in the + LSM list is not access control, so `lsm=capability,integrity` is reported as + having no MAC while `lsm=capability,yama,apparmor` is not. + + Absence is never evidence: a capture that does not mention KASLR is not a + capture proving it off, and nothing is reported on that basis. + +### Changed +- **`verdict` exits 3 only when a capture yields neither a U-Boot session nor a + hardening posture.** It previously exited 3 whenever there was no session, which + became wrong once a plain boot log could produce a real answer: "nothing was + assessed" would have been false, and a CI job keyed on that code would treat an + answer as a failure to answer. A capture with neither still exits 3. +- `verdict --json` gained an `os_hardening` object, mirroring the engine's key + names. + ## [0.9.0] — 2026-09-28 — the verdict reads the boot output, not just the environment Both halves of the boot-chain verdict now agree about the same device: the engine and diff --git a/README.md b/README.md index ce8b558..790b595 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ cargo build --release | `bootintel scan ` | Analyze a saved boot log. Supports `--format json\|text\|sarif\|junit` and `--gate-critical` for CI gating on autoboot / telnet exposure. `-` reads from stdin. `--api` POSTs to bootintel.com for full CVE + exploit paths (needs `BOOTINTEL_API_KEY`); `--api --preview` uses the anonymous free quota (3/day per IP, no key). `--api-base` overrides the endpoint. | | `bootintel scan --applicability` | Ask which advisories **apply**, sending only the component inventory (names + versions), never the log. Usable on a client device under an NDA where `--api` is not. `--dry-run` prints the exact payload first. Needs `bootintel login`. | | `bootintel analyze --interrupt-autoboot` | Interrupt autoboot on connect and pull the environment, then print the verdict and hand the terminal back. Hammers the key from the moment the port opens instead of waiting to see a countdown, because with `bootdelay=0` U-Boot checks for a keypress exactly once and a key sent in response to the banner arrives after that check; the byte has to already be in the UART. **Power-cycle the board after the tool says it is hammering.** Runs the read-only set `printenv`, `bdinfo`, `mtdparts`; `--at-prompt` replaces it entirely. `--interrupt-key` sends something other than a space (`esc`, `ctrl-c`, a literal string for `CONFIG_AUTOBOOT_KEYED` builds, or hex); CR and LF are refused, because the hammered bytes accumulate in U-Boot's line buffer and a newline would execute whatever they spell. `--reset-line dtr\|rts` pulses a modem line so the reset instant is the tool's rather than a human's, where the adapter is wired for it. Reports the window missed rather than exiting quietly. | -| `bootintel verdict ` | Assess a U-Boot session, not a boot log. Reads a `printenv` dump taken at the prompt and reports what the boot chain permits: whether autoboot is interruptible, whether images are verified, whether a netboot path is pre-configured, whether `bootargs` can be rewritten, and whether `saveenv` makes any of it stick. Every entry names the variable it was read from. `--json` mirrors the server's `uboot_shell` / `uboot_env` / `boot_chain_verdict` keys; `--gate-exposed` exits 1 on any exposed verdict. Runs entirely offline: a U-Boot environment holds a client's internal addressing, so nothing is uploaded. Exits 3 when the capture contains no session, because "could not assess" must not look like "nothing wrong". | +| `bootintel verdict ` | Assess what a capture establishes about the boot: the U-Boot session if it contains one, and the kernel hardening posture if the boot got that far. For the session half it reads a `printenv` dump taken at the prompt Reads a `printenv` dump taken at the prompt and reports what the boot chain permits: whether autoboot is interruptible, whether images are verified, whether a netboot path is pre-configured, whether `bootargs` can be rewritten, and whether `saveenv` makes any of it stick. Every entry names the variable it was read from. `--json` mirrors the server's `uboot_shell` / `uboot_env` / `boot_chain_verdict` keys; `--gate-exposed` exits 1 on any exposed verdict. Runs entirely offline: a U-Boot environment holds a client's internal addressing, so nothing is uploaded. Reports what the kernel announced about mandatory access control, memory initialisation and KASLR, including the distinction between `selinux=0` on a command line (switched off) and `selinux=0` under `Unknown command line parameters:` (not compiled in at all). Exits 3 only when the capture yields neither, because "could not assess" must not look like "nothing wrong". | | `bootintel share ` | Print a bootintel.com share URL with the log embedded via lz-string compression. Nothing is uploaded — the log lives in the URL itself. | | `bootintel ports` | List serial ports on this machine with USB VID/PID + product info when known. | | `bootintel version` | Version, detector count, build metadata. | diff --git a/crates/cli/src/cmd/verdict.rs b/crates/cli/src/cmd/verdict.rs index ae9ef5f..44aad1d 100644 --- a/crates/cli/src/cmd/verdict.rs +++ b/crates/cli/src/cmd/verdict.rs @@ -23,6 +23,7 @@ use clap::Args as ClapArgs; use std::io::Write; use bootintel_detectors::boot_chain::{self, BootIntegrity, UbootSession, Verdict}; +use bootintel_detectors::os_hardening::{self, OsHardening}; use crate::analyze::render::sanitize_for_term; use crate::output::{self, ColorMode}; @@ -90,6 +91,7 @@ pub fn run(args: Args) -> Result<()> { } let assessment = boot_chain::assess(&log.text); + let hardening = os_hardening::parse(&log.text); let (session, integrity, verdicts) = ( &assessment.session, &assessment.integrity, @@ -101,7 +103,7 @@ pub fn run(args: Args) -> Result<()> { let mut out = stdout.lock(); if args.json { - let payload = json(&log.source_label, session, integrity, verdicts); + let payload = json(&log.source_label, session, integrity, &hardening, verdicts); if let Err(e) = serde_json::to_writer_pretty(&mut out, &payload) .map_err(anyhow::Error::from) .and_then(|()| writeln!(out).map_err(anyhow::Error::from)) @@ -117,6 +119,7 @@ pub fn run(args: Args) -> Result<()> { &log.source_label, session, integrity, + &hardening, verdicts, color, ) { @@ -125,8 +128,10 @@ pub fn run(args: Args) -> Result<()> { } } - // No session means no answer, which is not the same as a good answer. - if !session.reached { + // No session means no answer about the BOOT CHAIN. If the kernel reported + // its hardening posture, something was assessed and exiting 3 with "nothing + // was assessed" would be false. + if !session.reached && hardening.is_empty() { let _ = out.flush(); eprintln!( "bootintel: no U-Boot session found in {}; nothing was assessed\n \ @@ -165,6 +170,7 @@ fn json( source: &str, session: &UbootSession, integrity: &BootIntegrity, + hardening: &OsHardening, verdicts: &[Verdict], ) -> serde_json::Value { let mut shell = serde_json::Map::new(); @@ -217,10 +223,39 @@ fn json( bi.insert("image_signature_checked".into(), true.into()); } + // Same key names as the engine's `os_hardening`. + let mut hard = serde_json::Map::new(); + if let Some(m) = &hardening.mem_auto_init { + hard.insert( + "mem_auto_init".into(), + serde_json::json!({ + "stack": m.stack, "heap_alloc": m.heap_alloc, "heap_free": m.heap_free + }), + ); + } + let mut put_hard = |k: &str, val: Option<&str>| { + if let Some(x) = val { + hard.insert(k.into(), x.into()); + } + }; + put_hard("kaslr", hardening.kaslr.as_deref()); + put_hard("kaslr_reason", hardening.kaslr_reason.as_deref()); + put_hard("selinux", hardening.selinux.as_deref()); + put_hard("apparmor", hardening.apparmor.as_deref()); + put_hard( + "ignored_kernel_parameters", + hardening.ignored_kernel_parameters.as_deref(), + ); + if !hardening.lsm.is_empty() { + hard.insert("lsm".into(), hardening.lsm.clone().into()); + hard.insert("mac_modules".into(), hardening.mac_modules.clone().into()); + } + serde_json::json!({ "source": source, "uboot_shell": shell, "boot_integrity": bi, + "os_hardening": hard, "uboot_env": session.env.iter() .map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone()))) .collect::>(), @@ -249,12 +284,17 @@ pub(crate) fn write_text( source: &str, session: &UbootSession, integrity: &BootIntegrity, + hardening: &OsHardening, verdicts: &[Verdict], color: ColorMode, ) -> Result<()> { let on = color == ColorMode::On; if !session.reached { writeln!(out, "no U-Boot session in {source}")?; + // A capture with no prompt can still have told us what the kernel + // enforces, and saying nothing about it would be discarding the half of + // the answer we do have. + write_hardening(out, hardening, on)?; return Ok(()); } // Everything below is device-controlled text, so it is sanitized before it @@ -329,5 +369,65 @@ pub(crate) fn write_text( } writeln!(out)?; } + write_hardening(out, hardening, on)?; + Ok(()) +} + +/// What the kernel said it enforces. Facts, not verdicts: the engine raises the +/// findings, and repeating them here as decisions would be a second opinion +/// nobody asked for. +fn write_hardening(out: &mut W, h: &OsHardening, on: bool) -> Result<()> { + if h.is_empty() { + return Ok(()); + } + writeln!( + out, + " {}", + output::wrap("kernel hardening", output::ANSI_BOLD_CYAN, on) + )?; + if let Some(m) = &h.mem_auto_init { + writeln!( + out, + " memory init stack:{} heap alloc:{} heap free:{}", + sanitize_for_term(&m.stack), + sanitize_for_term(&m.heap_alloc), + sanitize_for_term(&m.heap_free) + )?; + } + if let Some(k) = &h.kaslr { + let reason = h + .kaslr_reason + .as_deref() + .map(|r| format!(" ({})", sanitize_for_term(r))) + .unwrap_or_default(); + writeln!(out, " KASLR {}{reason}", sanitize_for_term(k))?; + } + if !h.lsm.is_empty() { + let mac = if h.mac_modules.is_empty() { + "none provide mandatory access control".to_string() + } else { + format!("MAC: {}", h.mac_modules.join(", ")) + }; + writeln!( + out, + " LSM {} ({})", + sanitize_for_term(&h.lsm.join(", ")), + sanitize_for_term(&mac) + )?; + } + for (label, value) in [("SELinux", &h.selinux), ("AppArmor", &h.apparmor)] { + if let Some(v) = value { + writeln!(out, " {label:<12} {}", sanitize_for_term(v))?; + } + } + if let Some(ignored) = &h.ignored_kernel_parameters { + writeln!( + out, + " ignored {} {}", + sanitize_for_term(ignored), + output::wrap("(the kernel did not apply these)", output::ANSI_DIM, on) + )?; + } + writeln!(out)?; Ok(()) } diff --git a/crates/cli/src/term/run.rs b/crates/cli/src/term/run.rs index 2cfd975..ff6ea5e 100644 --- a/crates/cli/src/term/run.rs +++ b/crates/cli/src/term/run.rs @@ -1553,6 +1553,9 @@ fn apply_autoboot( continue; }; let assessment = bootintel_detectors::boot_chain::assess(analyzer.log_so_far()); + // The same capture also says what the kernel enforces, if the + // board got that far before the operator took the prompt. + let hardening = bootintel_detectors::os_hardening::parse(analyzer.log_so_far()); let _ = write!(out, "\r\n"); let color = if use_color { crate::output::ColorMode::On @@ -1565,6 +1568,7 @@ fn apply_autoboot( source, &assessment.session, &assessment.integrity, + &hardening, &assessment.verdicts, color, ); diff --git a/crates/cli/tests/verdict_cli.rs b/crates/cli/tests/verdict_cli.rs index 3e21d68..1cc3b55 100644 --- a/crates/cli/tests/verdict_cli.rs +++ b/crates/cli/tests/verdict_cli.rs @@ -167,3 +167,52 @@ fn a_crafted_environment_value_cannot_inject_escapes() { "the value itself should still be shown: {text}" ); } + +/// A capture with no U-Boot session can still have told us what the kernel +/// enforces. Exiting 3 with "nothing was assessed" would be false, and a CI job +/// keyed on that exit code would treat a real answer as a failure to answer. +#[test] +fn a_kernel_posture_without_a_session_is_not_nothing() { + let path = fixture( + "kernel-only.log", + "[ 0.000000] Linux version 6.1.46\n\ + [ 0.000000] mem auto-init: stack:off, heap alloc:off, heap free:off\n\ + [ 0.379265] KASLR disabled due to lack of seed\n", + ); + let out = run(&["verdict", path.to_str().unwrap()]); + assert_eq!(code(&out), 0, "stderr: {}", stderr(&out)); + let text = stdout(&out); + assert!(text.contains("kernel hardening"), "{text}"); + assert!(text.contains("lack of seed"), "{text}"); + assert!( + text.contains("no U-Boot session"), + "the unassessed half must still be stated: {text}" + ); +} + +/// A capture with neither a session nor a posture still reports that nothing +/// was assessed, which is the case exit 3 exists for. +#[test] +fn a_capture_with_neither_still_exits_three() { + let path = fixture("nothing.log", "U-Boot 2020.10\nBooting from flash...\n"); + let out = run(&["verdict", path.to_str().unwrap()]); + assert_eq!(code(&out), 3, "stdout: {}", stdout(&out)); + assert!(stderr(&out).contains("nothing was assessed")); +} + +/// The hardening keys match the engine's, so a consumer can move between this +/// and the server response without remapping. +#[test] +fn hardening_json_keys_match_the_server_response() { + let path = fixture( + "trap.log", + "[ 0.000000] Unknown command line parameters: stmmaceth=chain_mode:1 selinux=0\n\ + [ 0.000000] mem auto-init: stack:off, heap alloc:off, heap free:off\n", + ); + let out = run(&["verdict", path.to_str().unwrap(), "--json"]); + let v: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("valid JSON"); + let h = &v["os_hardening"]; + assert_eq!(h["selinux"], "not_supported"); + assert_eq!(h["mem_auto_init"]["stack"], "off"); + assert!(h["ignored_kernel_parameters"].is_string()); +} diff --git a/crates/detectors/src/lib.rs b/crates/detectors/src/lib.rs index 5d0d604..47d38a6 100644 --- a/crates/detectors/src/lib.rs +++ b/crates/detectors/src/lib.rs @@ -18,6 +18,7 @@ use regex::Regex; use std::sync::LazyLock; pub mod boot_chain; +pub mod os_hardening; /// A single detector's output. /// diff --git a/crates/detectors/src/os_hardening.rs b/crates/detectors/src/os_hardening.rs new file mode 100644 index 0000000..0b8fec0 --- /dev/null +++ b/crates/detectors/src/os_hardening.rs @@ -0,0 +1,201 @@ +//! Kernel hardening posture, read from what the kernel announced at boot. +//! +//! A port of `api/analysis_engine/detectors/os_hardening.py`, kept in step by +//! the shared expectation in `tests/fixtures/boot_chain/expect.txt`. +//! +//! The boot log states plainly which protections are active: mandatory access +//! control, memory initialisation, kernel address randomisation. A practitioner +//! reads `mem auto-init: stack:off, heap alloc:off, heap free:off` and knows +//! immediately that a whole class of uninitialised-memory bugs stays exploitable +//! on this device; `grep` hands that back one line at a time with no indication +//! which of the three mattered. +//! +//! # The trap this module is built around +//! +//! `selinux=0` means opposite things depending on the line it sits on: +//! +//! ```text +//! cmdline: console=ttyS3 ... selinux=0 scandelay root=/2 (bootintel-1) +//! Unknown command line parameters: ... selinux=0 (bootintel-6) +//! ``` +//! +//! The first is SELinux switched off. The second is the kernel reporting it did +//! not recognise the parameter, which means SELinux is not compiled in at all: a +//! different and worse fact. Reporting the second as "disabled by boot +//! parameter" would describe a device that does not exist while understating the +//! real finding, so the unknown-parameter line is parsed first and anything +//! listed there is treated as not applied. +//! +//! # What this does not do +//! +//! It records facts and raises no findings, because the Rust and browser +//! detector sets are pinned to the same 14 labels and a fifteenth would break +//! that parity. The engine raises the findings; both sides share the facts. +//! +//! Absence is never evidence: a capture that never mentions KASLR is not a +//! capture proving it off, and nothing here reports it as such. + +use std::sync::LazyLock; + +use regex::Regex; + +// Character-for-character from the engine module, for the same reason as the +// boot-chain patterns: reasoning about whether two hand-written tokenisers agree +// is more expensive than keeping them identical. +// +// Unanchored on purpose. The same kernel line arrives with a `[ 0.000000]` +// prefix on one device and a `Feb 25 13:51:14 host kernel:` syslog prefix on +// three others; anchoring it silently misses those. +static RE_MEM_AUTO_INIT: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)mem auto-init:\s*stack:(\S+?),\s*heap alloc:(\S+?),\s*heap free:(\S+?)\s*$") + .unwrap() +}); + +// `KASLR disabled due to lack of seed` is the embedded failure mode: the kernel +// supports randomisation and the bootloader handed it no entropy, so it is off +// on a device whose vendor believes it is on. +static RE_KASLR_OFF: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bKASLR disabled(?:\s+due to\s+(.+?))?\s*$").unwrap()); +static RE_KASLR_ON: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bKASLR enabled\b").unwrap()); + +static RE_LSM_LIST: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bLSM:\s*initializing\s+lsm=(\S+)").unwrap()); +static RE_APPARMOR_OFF: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)AppArmor:\s*AppArmor disabled by boot time parameter").unwrap() +}); +static RE_SELINUX_STATE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)SELinux:\s*(Initializing|Permissive|Enforcing|Disabled at runtime)").unwrap() +}); +static RE_UNKNOWN_PARAMS: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)Unknown command line parameters:\s*(.+?)\s*$").unwrap()); +static RE_CMDLINE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)(?:Kernel command line|cmdline|bootargs)\s*[:=]").unwrap()); +static RE_SELINUX_OFF: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bselinux=0\b").unwrap()); + +/// Modules that provide mandatory access control, as opposed to the ones every +/// kernel has. `capability` is always present and enforces nothing of the kind. +const MAC_MODULES: &[&str] = &["selinux", "apparmor", "smack", "tomoyo"]; + +/// `mem auto-init: stack:off, heap alloc:off, heap free:off`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MemAutoInit { + pub stack: String, + pub heap_alloc: String, + pub heap_free: String, +} + +/// What the kernel said about its own hardening. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct OsHardening { + pub mem_auto_init: Option, + /// `enabled` or `disabled`. + pub kaslr: Option, + pub kaslr_reason: Option, + /// The active security modules, as the kernel listed them. + pub lsm: Vec, + /// The subset of `lsm` that actually provides mandatory access control. + pub mac_modules: Vec, + /// `disabled_by_parameter`, `not_supported`, or a runtime state. + pub selinux: Option, + pub apparmor: Option, + /// Parameters the kernel listed as unrecognised, and therefore did not apply. + pub ignored_kernel_parameters: Option, +} + +impl OsHardening { + /// True when the capture said nothing about any of this. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +/// Read the kernel's hardening report. +pub fn parse(log: &str) -> OsHardening { + let mut h = OsHardening::default(); + for raw in log.lines() { + let line = raw.trim_end_matches(['\r', '\n']); + + // First, because it changes what a later `selinux=0` means. + if let Some(caps) = RE_UNKNOWN_PARAMS.captures(line) { + let ignored = caps[1].to_string(); + if h.ignored_kernel_parameters.is_none() { + h.ignored_kernel_parameters = Some(clip(&ignored, 300)); + } + if RE_SELINUX_OFF.is_match(&ignored) && h.selinux.is_none() { + h.selinux = Some("not_supported".to_string()); + } + continue; + } + + if let Some(caps) = RE_MEM_AUTO_INIT.captures(line) { + if h.mem_auto_init.is_none() { + h.mem_auto_init = Some(MemAutoInit { + stack: caps[1].trim().to_string(), + heap_alloc: caps[2].trim().to_string(), + heap_free: caps[3].trim().to_string(), + }); + continue; + } + } + + if let Some(caps) = RE_KASLR_OFF.captures(line) { + if h.kaslr.is_none() { + h.kaslr = Some("disabled".to_string()); + let reason = caps.get(1).map(|m| m.as_str().trim()).unwrap_or_default(); + if !reason.is_empty() { + h.kaslr_reason = Some(clip(reason, 120)); + } + continue; + } + } + + if RE_KASLR_ON.is_match(line) && h.kaslr.is_none() { + h.kaslr = Some("enabled".to_string()); + continue; + } + + if let Some(caps) = RE_LSM_LIST.captures(line) { + if h.lsm.is_empty() { + h.lsm = caps[1] + .split(',') + .map(|x| x.trim().to_ascii_lowercase()) + .filter(|x| !x.is_empty()) + .collect(); + let mut mac: Vec = h + .lsm + .iter() + .filter(|m| MAC_MODULES.contains(&m.as_str())) + .cloned() + .collect(); + mac.sort(); + mac.dedup(); + h.mac_modules = mac; + continue; + } + } + + if RE_APPARMOR_OFF.is_match(line) && h.apparmor.is_none() { + h.apparmor = Some("disabled_by_parameter".to_string()); + continue; + } + + if let Some(caps) = RE_SELINUX_STATE.captures(line) { + if h.selinux.is_none() { + h.selinux = Some(caps[1].to_ascii_lowercase()); + continue; + } + } + + // A `selinux=0` the kernel DID recognise, on a real command line. + if RE_CMDLINE.is_match(line) && RE_SELINUX_OFF.is_match(line) && h.selinux.is_none() { + h.selinux = Some("disabled_by_parameter".to_string()); + } + } + h +} diff --git a/crates/detectors/tests/boot_chain.rs b/crates/detectors/tests/boot_chain.rs index e9ded96..4b0b58d 100644 --- a/crates/detectors/tests/boot_chain.rs +++ b/crates/detectors/tests/boot_chain.rs @@ -18,7 +18,7 @@ use std::fs; use std::path::PathBuf; -use bootintel_detectors::boot_chain; +use bootintel_detectors::{boot_chain, os_hardening}; fn fixtures() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/boot_chain") @@ -115,6 +115,40 @@ fn render(name: &str, log: &str) -> Vec { integrity.ubifs_unauthenticated.as_deref(), ); integ("env_crc_failed", integrity.env_crc_failed.as_deref()); + // Hardening, in the same field order as `analysis_engine/parity_render.py`. + // `mac_modules` renders even when empty, because "an LSM line was seen and + // none of them provide mandatory access control" is a real finding and a + // different claim from "no LSM line was seen at all". It is therefore keyed + // off `lsm` being present, which is how the engine's dict distinguishes them. + let h = os_hardening::parse(log); + if let Some(m) = &h.mem_auto_init { + field( + &mut out, + " ", + "hardening", + &format!( + "mem_auto_init=stack:{} heap_alloc:{} heap_free:{}", + m.stack, m.heap_alloc, m.heap_free + ), + ); + } + let mut hard = |k: &str, val: Option<&str>| { + if let Some(x) = val { + field(&mut out, " ", "hardening", &format!("{k}={x}")); + } + }; + hard("kaslr", h.kaslr.as_deref()); + hard("kaslr_reason", h.kaslr_reason.as_deref()); + if !h.lsm.is_empty() { + hard("lsm", Some(h.lsm.join(", ").as_str())); + hard("mac_modules", Some(h.mac_modules.join(", ").as_str())); + } + hard("selinux", h.selinux.as_deref()); + hard("apparmor", h.apparmor.as_deref()); + hard( + "ignored_kernel_parameters", + h.ignored_kernel_parameters.as_deref(), + ); for (key, value) in &session.env { field(&mut out, " ", "env", &format!("{key}={value}")); } diff --git a/crates/detectors/tests/fixtures/boot_chain/expect.txt b/crates/detectors/tests/fixtures/boot_chain/expect.txt index 889a02a..a48e7a2 100644 --- a/crates/detectors/tests/fixtures/boot_chain/expect.txt +++ b/crates/detectors/tests/fixtures/boot_chain/expect.txt @@ -287,3 +287,25 @@ evidence Environment size: 1650/4091 bytes detail The environment occupies 1650 of 4091 bytes of writable storage, so `saveenv` can persist a change across reboots. remediation Build with a read-only or signed environment for production. +## fixture selinux-ignored.log fnv1a64=dbd9548b004fc0d9 + reached false + evidence + env_bytes + hardening mem_auto_init=stack:off heap_alloc:off heap_free:off + hardening selinux=not_supported + hardening ignored_kernel_parameters=stmmaceth=chain_mode:1 selinux=0 +## fixture kernel-hardening.log fnv1a64=911e3c25d07b2c10 + reached false + evidence + env_bytes + hardening mem_auto_init=stack:off heap_alloc:off heap_free:off + hardening kaslr=enabled + hardening lsm=capability, integrity + hardening mac_modules= +## fixture kaslr-no-seed.log fnv1a64=61d971aeed51f185 + reached false + evidence + env_bytes + hardening mem_auto_init=stack:off heap_alloc:off heap_free:off + hardening kaslr=disabled + hardening kaslr_reason=lack of seed diff --git a/crates/detectors/tests/fixtures/boot_chain/kaslr-no-seed.log b/crates/detectors/tests/fixtures/boot_chain/kaslr-no-seed.log new file mode 100644 index 0000000..9f5c47f --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/kaslr-no-seed.log @@ -0,0 +1,87 @@ +[ 0.000000] Dentry cache hash table entries: 524288 (order: 10, 4194304 bytes, linear) +[ 0.000000] Inode-cache hash table entries: 262144 (order: 9, 2097152 bytes, linear) +[ 0.000000] mem auto-init: stack:off, heap alloc:off, heap free:off +[ 0.000000] software IO TLB: area num 4. +[ 0.000000] software IO TLB: mapped [mem 0x00000000fbfff000-0x00000000fffff000] (64MB) +[ 0.000000] Memory: 2807964K/4194300K available (12288K kernel code, 1266K rwdata, 4020K rodata, 2112K init, 438K bss, 796512K reserved, 589824K cma-reserved) +[ 0.000000] SLUB: HWalign=64, Order=0-3, MinObjects=0, CPUs=4, Nodes=1 +[ 0.000000] rcu: Preemptible hierarchical RCU implementation. +[ 0.000000] rcu: RCU event tracing is enabled. +[ 0.000000] rcu: RCU restricting CPUs from NR_CPUS=256 to nr_cpu_ids=4. +[ 0.000000] Trampoline variant of Tasks RCU enabled. +[ 0.000000] Tracing variant of Tasks RCU enabled. +[ 0.000000] rcu: RCU calculated value of scheduler-enlistment delay is 25 jiffies. +[ 0.000000] rcu: Adjusting geometry for rcu_fanout_leaf=16, nr_cpu_ids=4 +[ 0.000000] NR_IRQS: 64, nr_irqs: 64, preallocated irqs: 0 +[ 0.000000] GICv3: GIC: Using split EOI/Deactivate mode +[ 0.000000] GICv3: 256 SPIs implemented +[ 0.000000] GICv3: 0 Extended SPIs implemented +[ 0.000000] Root IRQ handler: gic_handle_irq +[ 0.000000] GICv3: GICv3 features: 16 PPIs +[ 0.000000] GICv3: CPU0: found redistributor 0 region 0:0x0000000001880000 +[ 0.000000] ITS [mem 0x01820000-0x0182ffff] +[ 0.000000] GIC: enabling workaround for ITS: Socionext Synquacer pre-ITS +[ 0.000000] ITS@0x0000000001820000: Devices Table too large, reduce ids 20->19 +[ 0.000000] ITS@0x0000000001820000: allocated 524288 Devices @880800000 (flat, esz 8, psz 64K, shr 0) +[ 0.000000] ITS: using cache flushing for cmd queue +[ 0.000000] GICv3: using LPI property table @0x0000000880040000 +[ 0.000000] GIC: using cache flushing for LPI property table +[ 0.000000] GICv3: CPU0: using allocated LPI pending table @0x0000000880050000 +[ 0.000000] rcu: srcu_init: Setting srcu_struct sizes based on contention. +[ 0.000000] arch_timer: cp15 timer(s) running at 200.00MHz (phys). +[ 0.000000] clocksource: arch_sys_counter: mask: 0x3ffffffffffffff max_cycles: 0x2e2049d3e8, max_idle_ns: 440795210634 ns +[ 0.000000] sched_clock: 58 bits at 200MHz, resolution 5ns, wraps every 4398046511102ns +[ 0.008494] Console: colour dummy device 80x25 +[ 0.013081] Calibrating delay loop (skipped), value calculated using timer frequency.. 400.00 BogoMIPS (lpj=800000) +[ 0.023762] pid_max: default: 32768 minimum: 301 +[ 0.028526] LSM: Security Framework initializing +[ 0.033350] Mount-cache hash table entries: 8192 (order: 4, 65536 bytes, linear) +[ 0.040928] Mountpoint-cache hash table entries: 8192 (order: 4, 65536 bytes, linear) +[ 0.050380] cblist_init_generic: Setting adjustable number of callback queues. +[ 0.057817] cblist_init_generic: Setting shift to 2 and lim to 1. +[ 0.064103] cblist_init_generic: Setting adjustable number of callback queues. +[ 0.071497] cblist_init_generic: Setting shift to 2 and lim to 1. +[ 0.077863] rcu: Hierarchical SRCU implementation. +[ 0.082768] rcu: Max phase no-delay instances is 1000. +[ 0.088326] Platform MSI: msi-controller@1820000 domain created +[ 0.094583] PCI/MSI: /bus@f0000/interrupt-controller@1800000/msi-controller@1820000 domain created +[ 0.103978] EFI services will not be available. +[ 0.108834] smp: Bringing up secondary CPUs ... +[ 0.114069] Detected VIPT I-cache on CPU1 +[ 0.114153] GICv3: CPU1: found redistributor 1 region 0:0x00000000018a0000 +[ 0.114169] GICv3: CPU1: using allocated LPI pending table @0x0000000880060000 +[ 0.114213] CPU1: Booted secondary processor 0x0000000001 [0x410fd034] +[ 0.114855] Detected VIPT I-cache on CPU2 +[ 0.114920] GICv3: CPU2: found redistributor 2 region 0:0x00000000018c0000 +[ 0.114933] GICv3: CPU2: using allocated LPI pending table @0x0000000880070000 +[ 0.114963] CPU2: Booted secondary processor 0x0000000002 [0x410fd034] +[ 0.115541] Detected VIPT I-cache on CPU3 +[ 0.115611] GICv3: CPU3: found redistributor 3 region 0:0x00000000018e0000 +[ 0.115623] GICv3: CPU3: using allocated LPI pending table @0x0000000880080000 +[ 0.115651] CPU3: Booted secondary processor 0x0000000003 [0x410fd034] +[ 0.115708] smp: Brought up 1 node, 4 CPUs +[ 0.195426] SMP: Total of 4 processors activated. +[ 0.200238] CPU features: detected: 32-bit EL0 Support +[ 0.205506] CPU features: detected: CRC32 instructions +[ 0.210811] CPU: All CPU(s) started at EL2 +[ 0.215008] alternatives: applying system-wide alternatives +[ 0.222134] devtmpfs: initialized +[ 0.233395] clocksource: jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 7645041785100000 ns +[ 0.243402] futex hash table entries: 1024 (order: 4, 65536 bytes, linear) +[ 0.263871] pinctrl core: initialized pinctrl subsystem +[ 0.269740] DMI not present or invalid. +[ 0.274204] NET: Registered PF_NETLINK/PF_ROUTE protocol family +[ 0.281137] DMA: preallocated 512 KiB GFP_KERNEL pool for atomic allocations +[ 0.288577] DMA: preallocated 512 KiB GFP_KERNEL|GFP_DMA pool for atomic allocations +[ 0.296653] DMA: preallocated 512 KiB GFP_KERNEL|GFP_DMA32 pool for atomic allocations +[ 0.304811] audit: initializing netlink subsys (disabled) +[ 0.310460] audit: type=2000 audit(0.200:1): state=initialized audit_enabled=0 res=1 +[ 0.310840] thermal_sys: Registered thermal governor 'step_wise' +[ 0.318388] thermal_sys: Registered thermal governor 'power_allocator' +[ 0.324566] cpuidle: using governor menu +[ 0.335392] hw-breakpoint: found 6 breakpoint and 4 watchpoint registers. +[ 0.342407] ASID allocator initialised with 65536 entries +[ 0.358562] platform 30200000.dss: Fixed dependency cycle(s) with /bus@f0000/i2c@20010000/sii9022@3b +[ 0.369809] platform connector: Fixed dependency cycle(s) with /bus@f0000/i2c@20010000/sii9022@3b +[ 0.379265] KASLR disabled due to lack of seed +[ 0.390081] HugeTLB: registered 1.00 GiB page size, pre-allocated 0 pages diff --git a/crates/detectors/tests/fixtures/boot_chain/kernel-hardening.log b/crates/detectors/tests/fixtures/boot_chain/kernel-hardening.log new file mode 100644 index 0000000..ba211c0 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/kernel-hardening.log @@ -0,0 +1,78 @@ +[ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd083] +[ 0.000000] Linux version 6.7.9-gentoo (root@hammer) (gcc (Gentoo 13.2.1_p202 40113-r1 p12) 13.2.1 20240113, GNU ld (Gentoo 2.41 p5) 2.41.0) #2 SMP PREEMPT Tu e Mar 12 20:19:44 EDT 2024 +[ 0.000000] KASLR enabled +[ 0.000000] Machine model: SolidRun LX2160A Clearfog CX +[ 0.000000] earlycon: pl11 at MMIO32 0x00000000021c0000 (options '') +[ 0.000000] printk: legacy bootconsole [pl11] enabled +[ 0.000000] efi: UEFI not found. +[ 0.000000] OF: reserved mem: 0x0000000080000000..0x00000000801fffff (2048 Ki B) map non-reusable lpi_rd_table@80000000 +[ 0.000000] NUMA: No NUMA configuration found +[ 0.000000] NUMA: Faking a node at [mem 0x0000000080000000-0x0000002f7fffffff ] +[ 0.000000] NUMA: NODE_DATA [mem 0x2f783a8ac0-0x2f783aafff] +[ 0.000000] Zone ranges: +[ 0.000000] DMA [mem 0x0000000080000000-0x00000000ffffffff] +[ 0.000000] DMA32 empty +[ 0.000000] Normal [mem 0x0000000100000000-0x0000002f7fffffff] +[ 0.000000] Movable zone start for each node +[ 0.000000] Early memory node ranges +[ 0.000000] node 0: [mem 0x0000000080000000-0x00000000fbdfffff] +[ 0.000000] node 0: [mem 0x0000002080000000-0x0000002f7fffffff] +[ 0.000000] Initmem setup node 0 [mem 0x0000000080000000-0x0000002f7fffffff] +[ 0.000000] On node 0, zone Normal: 16896 pages in unavailable ranges +[ 0.000000] cma: Reserved 32 MiB at 0x00000000f9e00000 on node -1 +[ 0.000000] psci: probing for conduit method from DT. +[ 0.000000] psci: PSCIv1.1 detected in firmware. +[ 0.000000] psci: Using standard PSCI v0.2 function IDs +[ 0.000000] psci: MIGRATE_INFO_TYPE not supported. +[ 0.000000] psci: SMC Calling Convention v1.2 +[ 0.000000] percpu: Embedded 31 pages/cpu s87848 r8192 d30936 u126976 +[ 0.000000] Detected PIPT I-cache on CPU0 +[ 0.000000] CPU features: detected: GIC system register CPU interface +[ 0.000000] CPU features: detected: Spectre-v2 +[ 0.000000] CPU features: detected: Spectre-v3a +[ 0.000000] CPU features: detected: Spectre-BHB +[ 0.000000] CPU features: kernel page table isolation forced ON by KASLR +[ 0.000000] CPU features: detected: Kernel page table isolation (KPTI) +[ 0.000000] CPU features: detected: ARM erratum 1742098 +[ 0.000000] CPU features: detected: ARM errata 1165522, 1319367, or 1530923 +[ 0.000000] alternatives: applying boot alternatives +[ 0.000000] Kernel command line: console=ttyAMA0,115200 earlycon=pl011,mmio32 ,0x21c0000 default_hugepagesz=1024m hugepagesz=1024m hugepages=2 pci=pcie_bus_pe rf root=PARTUUID=eb5175f3-e261-744f-8d80-66ab1ea0ce67 rw rootwait +[ 0.000000] Dentry cache hash table entries: 8388608 (order: 14, 67108864 byt es, linear) +[ 0.000000] Inode-cache hash table entries: 4194304 (order: 13, 33554432 byte s, linear) +[ 0.000000] Fallback order for Node 0: 0 +[ 0.000000] Built 1 zonelists, mobility grouping on. Total pages: 15982080 +[ 0.000000] Policy zone: Normal +[ 0.000000] mem auto-init: stack:off, heap alloc:off, heap free:off +[ 0.000000] software IO TLB: area num 16. +[ 0.000000] software IO TLB: mapped [mem 0x00000000f5e00000-0x00000000f9e0000 0] (64MB) +[ 0.000000] Memory: 61452876K/64944128K available (21952K kernel code, 4876K rwdata, 11852K rodata, 9984K init, 562K bss, 3458484K reserved, 32768K cma-reser ved) +[ 0.000000] SLUB: HWalign=64, Order=0-3, MinObjects=0, CPUs=16, Nodes=1 +[ 0.000000] trace event string verifier disabled +[ 0.000000] rcu: Preemptible hierarchical RCU implementation. +[ 0.000000] rcu: RCU event tracing is enabled. +[ 0.000000] Trampoline variant of Tasks RCU enabled. +[ 0.000000] Tracing variant of Tasks RCU enabled. +[ 0.000000] rcu: RCU calculated value of scheduler-enlistment delay is 25 jif fies. +[ 0.000000] NR_IRQS: 64, nr_irqs: 64, preallocated irqs: 0 +[ 0.000000] GICv3: GIC: Using split EOI/Deactivate mode +[ 0.000000] GICv3: 256 SPIs implemented +[ 0.000000] GICv3: 0 Extended SPIs implemented +[ 0.000000] Root IRQ handler: gic_handle_irq +[ 0.000000] GICv3: GICv3 features: 16 PPIs +[ 0.000000] GICv3: CPU0: found redistributor 0 region 0:0x0000000006200000 +[ 0.000000] ITS [mem 0x06020000-0x0603ffff] +[ 0.000000] ITS@0x0000000006020000: allocated 65536 Devices @2080180000 (flat , esz 8, psz 64K, shr 0) +[ 0.000000] ITS: using cache flushing for cmd queue +[ 0.000000] GICv3: Using preallocated redistributor tables +[ 0.000000] GICv3: using LPI property table @0x0000000080000000 +[ 0.000000] GICv3: CPU0: using reserved LPI pending table @0x0000000080010000 +[ 0.000000] rcu: srcu_init: Setting srcu_struct sizes based on contention. +[ 0.000000] arch_timer: cp15 timer(s) running at 25.00MHz (phys). +[ 0.000000] clocksource: arch_sys_counter: mask: 0xffffffffffffff max_cycles: 0x5c409fb33, max_idle_ns: 440795203156 ns +[ 0.000000] sched_clock: 56 bits at 25MHz, resolution 39ns, wraps every 43980 46511103ns +[ 0.008499] Console: colour dummy device 80x25 +[ 0.013034] Calibrating delay loop (skipped), value calculated using timer fr equency.. 50.00 BogoMIPS (lpj=100000) +[ 0.023504] pid_max: default: 32768 minimum: 301 +[ 0.028197] LSM: initializing lsm=capability,integrity +[ 0.033505] Mount-cache hash table entries: 131072 (order: 8, 1048576 bytes, linear) +[ 0.041410] Mountpoint-cache hash table entries: 131072 (order: 8, 1048576 by tes, linear) diff --git a/crates/detectors/tests/fixtures/boot_chain/selinux-ignored.log b/crates/detectors/tests/fixtures/boot_chain/selinux-ignored.log new file mode 100644 index 0000000..2b7115f --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/selinux-ignored.log @@ -0,0 +1,11 @@ +[ 0.000000] percpu: Embedded 17 pages/cpu s31528 r8192 d29912 u69632 +[ 0.000000] Built 1 zonelists, mobility grouping on. Total pages: 1033735 +[ 0.000000] Kernel command line: root=/dev/mmcblk1p3 rw console=tty0 console0 +[ 0.000000] Unknown command line parameters: stmmaceth=chain_mode:1 selinux=0 +[ 0.000000] Dentry cache hash table entries: 524288 (order: 10, 4194304 byte) +[ 0.000000] Inode-cache hash table entries: 262144 (order: 9, 2097152 bytes,) +[ 0.000000] mem auto-init: stack:off, heap alloc:off, heap free:off +[ 0.000000] software IO TLB: mapped [mem 0x00000000fbfff000-0x00000000fffff0) +[ 0.000000] Memory: 3451376K/4192256K available (9735K kernel code, 4976K rw) +[ 0.000000] SLUB: HWalign=64, Order=0-3, MinObjects=0, CPUs=4, Nodes=1 +[ 0.000000] rcu: Hierarchical RCU implementation. diff --git a/crates/detectors/tests/os_hardening.rs b/crates/detectors/tests/os_hardening.rs new file mode 100644 index 0000000..b960281 --- /dev/null +++ b/crates/detectors/tests/os_hardening.rs @@ -0,0 +1,89 @@ +//! Kernel hardening posture, and the two lines that mean opposite things. +//! +//! The rules are pinned against the engine by the shared expectation in +//! `tests/fixtures/boot_chain/expect.txt`, which now carries three kernel-stage +//! fixtures. These cover the cases where a careless implementation says +//! something false. + +use bootintel_detectors::os_hardening::parse; + +/// `selinux=0` means SELinux was switched off when it appears on a command +/// line, and means SELinux is not compiled in at all when it appears under +/// `Unknown command line parameters:`, because that line is the kernel saying it +/// ignored the parameter. The second is a different and worse fact, and +/// reporting it as the first would describe a device that does not exist. +#[test] +fn an_ignored_selinux_parameter_is_not_a_disabled_one() { + let ignored = + parse("[ 0.000000] Unknown command line parameters: stmmaceth=chain_mode:1 selinux=0\n"); + assert_eq!(ignored.selinux.as_deref(), Some("not_supported")); + assert_eq!( + ignored.ignored_kernel_parameters.as_deref(), + Some("stmmaceth=chain_mode:1 selinux=0") + ); + + let disabled = + parse("cmdline: console=ttyS3 earlyprintk clk_ignore_unused selinux=0 scandelay root=/2\n"); + assert_eq!(disabled.selinux.as_deref(), Some("disabled_by_parameter")); +} + +/// `capability` is on every kernel and governs privileged operations only. A +/// list containing it and nothing else is a device with no mandatory access +/// control, and a list containing AppArmor is not. +#[test] +fn capability_alone_is_not_mandatory_access_control() { + let none = parse("[ 0.028197] LSM: initializing lsm=capability,integrity\n"); + assert_eq!(none.lsm, ["capability", "integrity"]); + assert!(none.mac_modules.is_empty()); + + let some = parse("[ 0.028197] LSM: initializing lsm=capability,yama,apparmor\n"); + assert_eq!(some.mac_modules, ["apparmor"]); +} + +/// The same kernel line arrives with a `[ 0.000000]` prefix on one device and +/// a syslog prefix on another. Anchoring the pattern silently drops the second. +#[test] +fn a_syslog_prefixed_kernel_line_is_still_read() { + let h = parse( + "Feb 25 13:51:14 raspberrypi kernel: mem auto-init: stack:all(zero), heap alloc:off, heap free:off\n", + ); + let m = h.mem_auto_init.expect("the line was not read"); + assert_eq!(m.stack, "all(zero)"); + assert_eq!(m.heap_alloc, "off"); +} + +/// The embedded failure mode: the kernel wanted to randomise and the bootloader +/// handed it no entropy, so a device whose vendor believes KASLR is on has it +/// off. The reason is worth keeping, because it names whose bug it is. +#[test] +fn a_missing_kaslr_seed_keeps_its_reason() { + let h = parse("[ 0.379265] KASLR disabled due to lack of seed\n"); + assert_eq!(h.kaslr.as_deref(), Some("disabled")); + assert_eq!(h.kaslr_reason.as_deref(), Some("lack of seed")); + + let on = parse("[ 0.000000] KASLR enabled\n"); + assert_eq!(on.kaslr.as_deref(), Some("enabled")); + assert_eq!(on.kaslr_reason, None); +} + +/// A capture that never mentions KASLR is not a capture proving it off. +/// Reporting hardening as absent because the log is quiet is the same error as +/// inventing an environment out of a kernel command line. +#[test] +fn a_quiet_log_claims_nothing() { + let h = parse( + "U-Boot 2020.10 (Sep 17 2023)\n[ 0.000000] Linux version 5.10.0\n\ + [ 1.000000] procd: - init -\n", + ); + assert!(h.is_empty(), "invented a posture: {h:?}"); +} + +/// First observation wins, so a later line cannot overwrite the evidence that +/// justified the original reading. Mirrors the engine's `setdefault`. +#[test] +fn the_first_reading_of_each_fact_is_kept() { + let h = + parse("[ 0.000000] KASLR enabled\n[ 9.000000] KASLR disabled due to lack of seed\n"); + assert_eq!(h.kaslr.as_deref(), Some("enabled")); + assert_eq!(h.kaslr_reason, None); +}