From 2319612ea43e57a9b01fe9913896a80bcc7a7c7c Mon Sep 17 00:00:00 2001 From: BootIntel Agent Date: Mon, 28 Sep 2026 10:12:38 +0000 Subject: [PATCH] Port boot_integrity to Rust, and pin it with two real fixtures The engine learned to read what the bootloader actually verified. Until the CLI does too, the offline verdict says "I cannot tell whether images are checked" about captures that answer the question on their own, and the two implementations disagree about the same device, which is the failure the shared expectation file exists to prevent. So this ports it, and the fixtures are the point rather than an afterthought. Neither new fixture is synthetic: hab-unblown.log bootintel-7 lines 1-45. Reaches the prompt via `u-boot=> setenv factorymode 1`, never dumps the environment, and reports `hab fuse not enabled`. It pins that integrity facts are read WITHOUT a printenv, and that a harness line quoting U-Boot error strings ('Bad Linux ARM64 Image magic!') does not trip the anchored failure pattern. verified-image.log bootintel-20 lines 55-160. Environment provable only retroactively from `Environment size:`, and the capture carries a FAILED check (`Bad Magic Number`, from an operator's `iminfo`) and a PASSED one (`Verifying Checksum ... OK`) in that order. Both recorded, which is the honest reading of a capture holding both. Both implementations now reproduce the expectation byte for byte across five fixtures, which is the first time the guarantee has covered anything but a synthetic capture and two environments. One bug found while porting, in code already shipped on the engine side. The signature pattern included `## Checking (hash|sign)`, and U-Boot's ordinary FIT output is `## Checking hash(es) for FIT Image at ...`. Every device using UNSIGNED FIT hashes would have been reported as having had a signature verified, and the verdict would have told a client authenticity was established. No corpus log prints that line, which is exactly why it survived: absence of evidence read as correctness. The real tell is the algorithm list, which reads `sha256,rsa2048:dev+ OK` when a signature node was checked, so that is what is keyed on now. The renderer behind the expectation was also duplicated between the generator and the test that checks it. Two renderers behind a file whose entire job is to prove two implementations agree is the same mistake one level up; it now lives in `analysis_engine/parity_render.py` and both import it. The Rust mirror stays a mirror on purpose: the claim is that two independent implementations agree, which sharing code would quietly stop testing. 349 tests, clippy clean under -D warnings, rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 28 ++ crates/cli/src/cmd/verdict.rs | 90 ++++- crates/cli/src/term/run.rs | 13 +- crates/detectors/src/boot_chain.rs | 338 ++++++++++++++++-- crates/detectors/tests/boot_chain.rs | 208 ++++++++++- .../tests/fixtures/boot_chain/expect.txt | 89 ++++- .../tests/fixtures/boot_chain/hab-unblown.log | 45 +++ .../fixtures/boot_chain/verified-image.log | 106 ++++++ 8 files changed, 864 insertions(+), 53 deletions(-) create mode 100644 crates/detectors/tests/fixtures/boot_chain/hab-unblown.log create mode 100644 crates/detectors/tests/fixtures/boot_chain/verified-image.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e7015..98d8cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`bootintel verdict` now reports what the bootloader actually verified**, not + only what the environment says it is configured to do. A capture containing + `Verifying Checksum ... OK` or `Verifying Hash Integrity ... sha256+ OK` no + longer gets "I cannot tell whether images are checked" while the answer sits in + the same log. Also reads the i.MX HAB fuse and UBIFS unauthenticated mounts. + + The distinction is kept deliberately: a passing checksum is reported as + `confirmed`, never `hardened`, and the detail says why. A CRC proves the image + was not corrupt; anyone who can write the image can recompute it. Only a + signature (`sha256,rsa2048:dev+ OK`) reports as `hardened`. + + New verdict entry **Secure boot anchor** when the SoC reports `hab fuse not + enabled`, because while that fuse is unblown the boot ROM runs unsigned images + whatever the bootloader prints afterwards. It is emitted without needing a + `printenv`, since the fact does not depend on one. + +### Changed +- **Breaking, library only: `boot_chain::assess` returns an `Assessment` struct** + (`session`, `integrity`, `verdicts`) instead of a `(UbootSession, Vec)` + tuple, and `boot_chain::verdict` takes the integrity alongside the session. The + alternative was a second entry point for the same operation, and two functions + differing only in how much they tell you is worse for whoever reads it next. No + CLI flag, output or exit code changed. +- `verdict --json` gained a `boot_integrity` object, mirroring the engine's key + names. Fields are omitted when the capture said nothing about them rather than + emitted as null. + ## [0.8.0] — 2026-09-28 — read the boot chain on the bench, offline Two halves of one workflow: take the U-Boot prompt on a board in front of you, diff --git a/crates/cli/src/cmd/verdict.rs b/crates/cli/src/cmd/verdict.rs index 5d527ce..ae9ef5f 100644 --- a/crates/cli/src/cmd/verdict.rs +++ b/crates/cli/src/cmd/verdict.rs @@ -22,7 +22,7 @@ use anyhow::Result; use clap::Args as ClapArgs; use std::io::Write; -use bootintel_detectors::boot_chain::{self, UbootSession, Verdict}; +use bootintel_detectors::boot_chain::{self, BootIntegrity, UbootSession, Verdict}; use crate::analyze::render::sanitize_for_term; use crate::output::{self, ColorMode}; @@ -89,14 +89,19 @@ pub fn run(args: Args) -> Result<()> { std::process::exit(EXIT_EMPTY_INPUT); } - let (session, verdicts) = boot_chain::assess(&log.text); + let assessment = boot_chain::assess(&log.text); + let (session, integrity, verdicts) = ( + &assessment.session, + &assessment.integrity, + &assessment.verdicts, + ); let stdout = std::io::stdout(); let color = output::resolve_color_mode(args.no_color, &stdout); let mut out = stdout.lock(); if args.json { - let payload = json(&log.source_label, &session, &verdicts); + let payload = json(&log.source_label, session, integrity, 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)) @@ -107,7 +112,14 @@ pub fn run(args: Args) -> Result<()> { return Err(e); } } - } else if let Err(e) = write_text(&mut out, &log.source_label, &session, &verdicts, color) { + } else if let Err(e) = write_text( + &mut out, + &log.source_label, + session, + integrity, + verdicts, + color, + ) { if !crate::is_broken_pipe(&e) { return Err(e); } @@ -149,7 +161,12 @@ pub fn run(args: Args) -> Result<()> { Ok(()) } -fn json(source: &str, session: &UbootSession, verdicts: &[Verdict]) -> serde_json::Value { +fn json( + source: &str, + session: &UbootSession, + integrity: &BootIntegrity, + verdicts: &[Verdict], +) -> serde_json::Value { let mut shell = serde_json::Map::new(); shell.insert("reached".into(), session.reached.into()); shell.insert("evidence".into(), session.evidence.clone().into()); @@ -157,9 +174,53 @@ fn json(source: &str, session: &UbootSession, verdicts: &[Verdict]) -> serde_jso shell.insert("env_used_bytes".into(), used.into()); shell.insert("env_total_bytes".into(), total.into()); } + // Mirrors the engine's `boot_integrity` key names, and omits what was not + // observed rather than emitting nulls: absence of a field means the capture + // said nothing, which is different from a field saying "no". + let mut bi = serde_json::Map::new(); + let mut put = |k: &str, val: Option<&str>| { + if let Some(x) = val { + bi.insert(k.into(), x.into()); + } + }; + put("image_check", integrity.image_check.as_deref()); + put( + "image_check_result", + integrity.image_check_result.as_deref(), + ); + put( + "image_check_evidence", + integrity.image_check_evidence.as_deref(), + ); + put( + "image_check_failed", + integrity.image_check_failed.as_deref(), + ); + put("hab_fuse", integrity.hab_fuse.as_deref()); + put("hab_evidence", integrity.hab_evidence.as_deref()); + put( + "ubifs_unauthenticated", + integrity.ubifs_unauthenticated.as_deref(), + ); + put("env_crc_failed", integrity.env_crc_failed.as_deref()); + put( + "image_signature_evidence", + integrity.image_signature_evidence.as_deref(), + ); + if !integrity.image_hash_algorithms.is_empty() { + bi.insert( + "image_hash_algorithms".into(), + integrity.image_hash_algorithms.clone().into(), + ); + } + if integrity.image_signature_checked { + bi.insert("image_signature_checked".into(), true.into()); + } + serde_json::json!({ "source": source, "uboot_shell": shell, + "boot_integrity": bi, "uboot_env": session.env.iter() .map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone()))) .collect::>(), @@ -187,6 +248,7 @@ pub(crate) fn write_text( out: &mut W, source: &str, session: &UbootSession, + integrity: &BootIntegrity, verdicts: &[Verdict], color: ColorMode, ) -> Result<()> { @@ -218,6 +280,24 @@ pub(crate) fn write_text( session.env.len(), if session.env.len() == 1 { "" } else { "s" } )?; + if let Some(check) = &integrity.image_check { + let mechanism = if check == "fit_hash" { + let algos = if integrity.image_hash_algorithms.is_empty() { + "unspecified".to_string() + } else { + integrity.image_hash_algorithms.join(", ") + }; + format!("FIT hash ({algos})") + } else { + "uImage CRC".to_string() + }; + writeln!( + out, + " image check {} ({})", + sanitize_for_term(&mechanism), + integrity.image_check_result.as_deref().unwrap_or("unknown") + )?; + } writeln!(out)?; for v in verdicts { diff --git a/crates/cli/src/term/run.rs b/crates/cli/src/term/run.rs index fe9e903..2cfd975 100644 --- a/crates/cli/src/term/run.rs +++ b/crates/cli/src/term/run.rs @@ -1552,8 +1552,7 @@ fn apply_autoboot( ); continue; }; - let (session, verdicts) = - bootintel_detectors::boot_chain::assess(analyzer.log_so_far()); + let assessment = bootintel_detectors::boot_chain::assess(analyzer.log_so_far()); let _ = write!(out, "\r\n"); let color = if use_color { crate::output::ColorMode::On @@ -1561,8 +1560,14 @@ fn apply_autoboot( crate::output::ColorMode::Off }; let mut crlf = crate::output::CrlfWriter::new(&mut *out); - let _ = - crate::cmd::verdict::write_text(&mut crlf, source, &session, &verdicts, color); + let _ = crate::cmd::verdict::write_text( + &mut crlf, + source, + &assessment.session, + &assessment.integrity, + &assessment.verdicts, + color, + ); let _ = out.flush(); } } diff --git a/crates/detectors/src/boot_chain.rs b/crates/detectors/src/boot_chain.rs index 6f746ca..47f42cb 100644 --- a/crates/detectors/src/boot_chain.rs +++ b/crates/detectors/src/boot_chain.rs @@ -71,6 +71,174 @@ static RE_ENV_SIZE: LazyLock = static RE_ENV_LINE: LazyLock = LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap()); +// The integrity patterns, character-for-character from +// `api/analysis_engine/detectors/boot_integrity.py`, for the same reason as the +// session patterns above. +static RE_CHECKSUM: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Checksum\s*\.\.\.\s*(.*)$").unwrap()); +static RE_FIT_HASH: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Hash Integrity\s*\.\.\.\s*(.*)$").unwrap()); +// Deliberately NOT `## Checking (hash|sign)`. U-Boot's ordinary FIT output is +// `## Checking hash(es) for FIT Image at ...`, so that pattern reported a +// verified SIGNATURE on every device using unsigned FIT hashes, which is the +// common case. No corpus log prints the line, which is why the bug survived +// review on the engine side. +static RE_FIT_SIG: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)Verifying Signature\b").unwrap()); +// An algorithm entry that means a signature rather than a digest. U-Boot prints +// `sha256,rsa2048:dev+ OK` when a signature node was checked. +static RE_SIG_ALGO: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^(?:rsa\d*|ecdsa\d*|pkcs1)").unwrap()); +// Anchored to the whole line: a substring match on "Bad Magic Number" also +// catches `fsck.ext2: Bad magic number in super-block`, which is a filesystem +// complaint and produced a false high-severity finding on the engine side. +static RE_BAD: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\s*(Bad Data Hash|Bad Header Checksum|Bad Magic Number|Bad Data CRC)\.?\s*$") + .unwrap() +}); +static RE_BAD_SIG: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)signature check failed|Verifying Hash Integrity\s*\.\.\.\s*error").unwrap() +}); +static RE_HAB_OFF: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)hab fuse not enabled").unwrap()); +static RE_HAB_ON: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)hab fuse (?:is )?enabled").unwrap()); +static RE_UBIFS_UNAUTH: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)UBIFS\s*\(([^)]*)\):\s*Mounting in unauthenticated mode").unwrap() +}); +static RE_ENV_CRC: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)bad CRC, using default environment").unwrap()); +static RE_OK: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\bOK\b").unwrap()); +static RE_ALGO_SPLIT: LazyLock = LazyLock::new(|| Regex::new(r"[+,\s]+").unwrap()); + +/// What the bootloader actually DID about verifying the image it booted, as +/// opposed to what the environment says it is configured to do. +/// +/// The distinction this type exists to preserve: a checksum is not a signature. +/// `Verifying Checksum ... OK` proves the image was not corrupt. Anyone who can +/// write the image can recompute the CRC, so it stops bit-rot, not an attacker. +/// Only a signature establishes that the image came from the signer, and on +/// i.MX none of it is enforced while the HAB fuse is unblown. +/// +/// Unlike the engine, this does not raise findings for any of it: the Rust and +/// browser detector sets are pinned to the same 14 labels, and a fifteenth would +/// break that parity. The facts and the verdicts are what the two +/// implementations share. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct BootIntegrity { + /// `uimage_crc` or `fit_hash`. + pub image_check: Option, + pub image_check_evidence: Option, + /// `passed`, `failed`, or `not_captured` when the check began but the + /// capture lost its result. "The check ran" is a different claim from "the + /// check passed". + pub image_check_result: Option, + pub image_hash_algorithms: Vec, + pub image_signature_checked: bool, + pub image_signature_evidence: Option, + pub image_check_failed: Option, + /// `not_enabled` or `enabled`. + pub hab_fuse: Option, + pub hab_evidence: Option, + pub ubifs_unauthenticated: Option, + pub env_crc_failed: Option, +} + +/// First observation wins, so a later repeat cannot overwrite the evidence line +/// that justified the original claim. Mirrors the engine's `setdefault`. +fn keep_first(slot: &mut Option, value: &str) { + if slot.is_none() { + *slot = Some(value.to_string()); + } +} + +/// Read the verification a bootloader reported performing. +pub fn parse_integrity(log: &str) -> BootIntegrity { + let mut bi = BootIntegrity::default(); + for raw in log.lines() { + let line = raw.trim_end_matches(['\r', '\n']); + let s = clip(line.trim(), 200); + + if let Some(caps) = RE_CHECKSUM.captures(line) { + let result = caps[1].trim(); + keep_first(&mut bi.image_check, "uimage_crc"); + keep_first(&mut bi.image_check_evidence, &s); + keep_first( + &mut bi.image_check_result, + if RE_OK.is_match(result) { + "passed" + } else if result.is_empty() { + "not_captured" + } else { + "failed" + }, + ); + continue; + } + + if let Some(caps) = RE_FIT_HASH.captures(line) { + let tail = caps[1].trim(); + let algos: Vec = RE_ALGO_SPLIT + .split(tail) + .filter(|a| !a.is_empty() && !RE_OK.is_match(a)) + .map(str::to_string) + .collect(); + keep_first(&mut bi.image_check, "fit_hash"); + keep_first(&mut bi.image_check_evidence, &s); + keep_first( + &mut bi.image_check_result, + if RE_OK.is_match(tail) { + "passed" + } else { + "failed" + }, + ); + if !algos.is_empty() { + if bi.image_hash_algorithms.is_empty() { + bi.image_hash_algorithms = algos.clone(); + } + if algos.iter().any(|a| RE_SIG_ALGO.is_match(a)) { + bi.image_signature_checked = true; + keep_first(&mut bi.image_signature_evidence, &s); + } + } + continue; + } + + if RE_FIT_SIG.is_match(line) { + bi.image_signature_checked = true; + keep_first(&mut bi.image_signature_evidence, &s); + continue; + } + + if RE_BAD.is_match(line) || RE_BAD_SIG.is_match(line) { + keep_first(&mut bi.image_check_failed, &s); + continue; + } + + if RE_HAB_OFF.is_match(line) { + keep_first(&mut bi.hab_fuse, "not_enabled"); + keep_first(&mut bi.hab_evidence, &s); + continue; + } + if RE_HAB_ON.is_match(line) { + keep_first(&mut bi.hab_fuse, "enabled"); + keep_first(&mut bi.hab_evidence, &s); + continue; + } + + if RE_ENV_CRC.is_match(line) { + keep_first(&mut bi.env_crc_failed, &s); + continue; + } + + if let Some(caps) = RE_UBIFS_UNAUTH.captures(line) { + keep_first(&mut bi.ubifs_unauthenticated, caps[1].trim()); + } + } + bi +} + /// Truncate to `max` CHARACTERS, mirroring Python's `s[:max]`. /// /// `String::truncate` counts bytes and panics mid-codepoint, and a capture is @@ -197,7 +365,7 @@ fn v( /// /// The boot log can say autoboot looks interruptible. The environment says /// what happens when you interrupt it, and whether you can change what boots. -pub fn verdict(s: &UbootSession) -> Vec { +pub fn verdict(s: &UbootSession, bi: &BootIntegrity) -> Vec { let mut out = Vec::new(); if !s.reached { return out; @@ -220,6 +388,125 @@ pub fn verdict(s: &UbootSession) -> Vec { ), ); + // Image verification, preferring what was observed over what was configured. + // + // One entry, never two. An earlier version emitted a speculative "unknown" + // alongside an explicit verify=no and produced two contradictory verdicts + // for one question; the same trap is here in a new form, because a capture + // can carry BOTH verify=no and an observed checksum pass. Rather than + // silently picking a winner, the entry reports the observation and names the + // conflict. + // + // Read before the environment because none of it depends on a printenv + // having been captured: bootintel-7 reaches a prompt, never dumps the + // environment, and still reports `hab fuse not enabled`. + let verify_off = matches!( + s.env + .get("verify") + .map(|x| x.trim().to_ascii_lowercase()) + .as_deref(), + Some("n") | Some("no") | Some("0") | Some("false") + ); + let conflict = if verify_off { + " The environment says verify=no, yet the bootloader still reported a check, so either \ + this capture predates that setting or a different boot path ran." + } else { + "" + }; + let observed = bi.image_check.as_deref(); + let result = bi.image_check_result.as_deref(); + if bi.image_signature_checked { + v( + &mut out, + "Image verification", + "hardened", + &format!( + "A signature was checked before boot, which establishes that the image is the \ + one the signer produced, not merely an uncorrupted one.{conflict}" + ), + bi.image_signature_evidence + .as_deref() + .unwrap_or("signature check observed"), + "medium", + Some( + "Confirm the verifying key lives somewhere an attacker with flash write access \ + cannot replace it.", + ), + ); + } else if observed.is_some() && result == Some("passed") { + let mechanism = if observed == Some("fit_hash") { + let algos = if bi.image_hash_algorithms.is_empty() { + "unspecified".to_string() + } else { + bi.image_hash_algorithms.join(", ") + }; + format!("a FIT hash ({algos})") + } else { + "a legacy uImage CRC".to_string() + }; + v( + &mut out, + "Image verification", + "confirmed", + &format!( + "The bootloader checked the image before booting it, using {mechanism}, and \ + the check passed. That proves the image was not corrupt. It is not a \ + signature: anyone who can write the image can recompute the checksum, so this \ + stops bit-rot rather than an attacker.{conflict}" + ), + bi.image_check_evidence.as_deref().unwrap_or(""), + "medium", + Some( + "Move to signed FIT images (CONFIG_FIT_SIGNATURE) so a deliberate modification is \ + detected and not just a corrupt one.", + ), + ); + } else if observed.is_some() && result == Some("not_captured") { + v( + &mut out, + "Image verification", + "unknown", + &format!( + "The bootloader began an image check but its result is not in the capture, so \ + whether it passed is unknown.{conflict}" + ), + bi.image_check_evidence.as_deref().unwrap_or(""), + "info", + None, + ); + } else if verify_off { + v( + &mut out, + "Image verification", + "exposed", + "verify is disabled, so U-Boot will not check image checksums before booting.", + &format!( + "verify={}", + s.env.get("verify").map(String::as_str).unwrap_or("") + ), + "high", + Some("Set verify=yes, and prefer signed FIT images over checksums."), + ); + } + + // The anchor. On i.MX none of the above is enforced while the fuse is unblown. + if bi.hab_fuse.as_deref() == Some("not_enabled") { + v( + &mut out, + "Secure boot anchor", + "exposed", + "The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned \ + image. Whatever the bootloader does about checksums afterwards is advisory: the chain \ + has no anchor.", + bi.hab_evidence.as_deref().unwrap_or("hab fuse not enabled"), + "high", + Some( + "Blow the HAB fuse and close the device only after a signed image is confirmed to \ + boot, since the operation is irreversible.", + ), + ); + } + if s.env.is_empty() { v( &mut out, @@ -310,7 +597,9 @@ pub fn verdict(s: &UbootSession) -> Vec { ); let verify_set = s.env.contains_key("verify"); let boots_image = ["bootm", "bootz", "booti"].iter().any(|t| cmd.contains(t)); - if boots_image && !verify_set && !cmd.contains("verify") { + // Only speculate when the capture contains no observation at all. The + // whole point of reading the boot output is to stop guessing here. + if boots_image && !verify_set && !cmd.contains("verify") && observed.is_none() { v( &mut out, "Image verification", @@ -325,23 +614,6 @@ pub fn verdict(s: &UbootSession) -> Vec { } } - if let Some(val) = s.env.get("verify") { - if matches!( - val.trim().to_ascii_lowercase().as_str(), - "n" | "no" | "0" | "false" - ) { - v( - &mut out, - "Image verification", - "exposed", - "verify is disabled, so U-Boot will not check image checksums before booting.", - &format!("verify={val}"), - "high", - Some("Set verify=yes, and prefer signed FIT images over checksums."), - ); - } - } - if s.env.contains_key("ipaddr") && s.env.contains_key("serverip") { let parts: Vec = ["ethaddr", "gatewayip", "ipaddr", "netmask", "serverip"] .iter() @@ -414,8 +686,28 @@ pub fn verdict(s: &UbootSession) -> Vec { } /// Convenience: parse and decide in one call. -pub fn assess(log: &str) -> (UbootSession, Vec) { - let s = parse_session(log); - let verdicts = verdict(&s); - (s, verdicts) +/// Everything one capture establishes about the boot chain. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Assessment { + pub session: UbootSession, + pub integrity: BootIntegrity, + pub verdicts: Vec, +} + +/// Parse and decide in one call. +/// +/// This replaced a `(UbootSession, Vec)` tuple when integrity reading +/// landed: a breaking change to a published crate, which under the policy at the +/// top of CHANGELOG.md moves the minor version pre-1.0. The alternative was a +/// second name for the same operation, and two entry points differing only in +/// how much they tell you is worse for whoever reads this next. +pub fn assess(log: &str) -> Assessment { + let session = parse_session(log); + let integrity = parse_integrity(log); + let verdicts = verdict(&session, &integrity); + Assessment { + session, + integrity, + verdicts, + } } diff --git a/crates/detectors/tests/boot_chain.rs b/crates/detectors/tests/boot_chain.rs index fb58a47..e9ded96 100644 --- a/crates/detectors/tests/boot_chain.rs +++ b/crates/detectors/tests/boot_chain.rs @@ -48,7 +48,8 @@ fn field(out: &mut Vec, indent: &str, key: &str, value: &str) { /// rather than JSON so it diffs cleanly in review, and so this crate needs no /// serde: it stays serde-free on purpose to keep the CLI binary small. fn render(name: &str, log: &str) -> Vec { - let (session, verdicts) = boot_chain::assess(log); + let a = boot_chain::assess(log); + let (session, integrity, verdicts) = (&a.session, &a.integrity, &a.verdicts); let mut out = vec![format!( "## fixture {name} fnv1a64={}", fnv1a64(log.as_bytes()) @@ -65,10 +66,59 @@ fn render(name: &str, log: &str) -> Vec { _ => String::new(), }; field(&mut out, " ", "env_bytes", &bytes); + // Same field order as `analysis_engine/parity_render.py`, and absent fields + // are omitted rather than rendered empty: "the capture said nothing about + // this" is a different claim from "this is off". + let mut integ = |k: &str, val: Option<&str>| { + if let Some(x) = val { + field(&mut out, " ", "integrity", &format!("{k}={x}")); + } + }; + integ("image_check", integrity.image_check.as_deref()); + integ( + "image_check_result", + integrity.image_check_result.as_deref(), + ); + integ( + "image_check_evidence", + integrity.image_check_evidence.as_deref(), + ); + let algos = integrity.image_hash_algorithms.join(", "); + integ( + "image_hash_algorithms", + if algos.is_empty() { + None + } else { + Some(algos.as_str()) + }, + ); + integ( + "image_signature_checked", + if integrity.image_signature_checked { + Some("true") + } else { + None + }, + ); + integ( + "image_signature_evidence", + integrity.image_signature_evidence.as_deref(), + ); + integ( + "image_check_failed", + integrity.image_check_failed.as_deref(), + ); + integ("hab_fuse", integrity.hab_fuse.as_deref()); + integ("hab_evidence", integrity.hab_evidence.as_deref()); + integ( + "ubifs_unauthenticated", + integrity.ubifs_unauthenticated.as_deref(), + ); + integ("env_crc_failed", integrity.env_crc_failed.as_deref()); for (key, value) in &session.env { field(&mut out, " ", "env", &format!("{key}={value}")); } - for verdict in &verdicts { + for verdict in verdicts { out.push(" verdict".to_string()); field(&mut out, " ", "title", &verdict.title); field(&mut out, " ", "state", &verdict.state); @@ -171,14 +221,17 @@ fn a_plain_boot_log_yields_no_session_and_no_verdict() { PATH=/usr/bin:/bin\n\ bootcmd=this is not really an environment\n\ [ 1.000000] procd: - init -\n"; - let (session, verdicts) = boot_chain::assess(plain); - assert!(!session.reached, "claimed a shell on a log with no session"); + let a = boot_chain::assess(plain); assert!( - session.env.is_empty(), + !a.session.reached, + "claimed a shell on a log with no session" + ); + assert!( + a.session.env.is_empty(), "invented an environment: {:?}", - session.env + a.session.env ); - assert!(verdicts.is_empty(), "produced verdicts with no session"); + assert!(a.verdicts.is_empty(), "produced verdicts with no session"); } #[test] @@ -186,9 +239,9 @@ fn an_unterminated_run_of_key_values_is_discarded() { // Same shape as a real dump but with no `Environment size:` line, so // nothing proves it was one. Absence of proof is not a verdict. let log = "bootdelay=3\nbootcmd=bootm 0x82000000\nverify=no\n"; - let (session, verdicts) = boot_chain::assess(log); - assert!(session.env.is_empty()); - assert!(verdicts.is_empty()); + let a = boot_chain::assess(log); + assert!(a.session.env.is_empty()); + assert!(a.verdicts.is_empty()); } /// Absence is reported as unknown, never as good news: U-Boot prints only what @@ -197,7 +250,7 @@ fn an_unterminated_run_of_key_values_is_discarded() { #[test] fn a_missing_variable_is_unknown_rather_than_hardened() { let log = "=> printenv\nbootcmd=run sfboot\nEnvironment size: 20/65532 bytes\n"; - let (_, verdicts) = boot_chain::assess(log); + let verdicts = boot_chain::assess(log).verdicts; let delay = verdicts .iter() .find(|v| v.title == "Autoboot delay") @@ -210,7 +263,7 @@ fn a_missing_variable_is_unknown_rather_than_hardened() { #[test] fn a_wrapped_value_is_rejoined_rather_than_ending_the_dump() { let log = fs::read_to_string(fixtures().join("wrapped-env.log")).expect("fixture"); - let (session, _) = boot_chain::assess(&log); + let session = boot_chain::assess(&log).session; let upfw = session .env .get("upfw") @@ -230,7 +283,7 @@ fn every_verdict_names_the_variable_it_read() { // quote a tool, so an unevidenced verdict is a broken one. for name in declared_fixtures(&expectation()) { let log = fs::read_to_string(fixtures().join(&name)).expect("fixture"); - for verdict in boot_chain::verdict(&boot_chain::parse_session(&log)) { + for verdict in boot_chain::assess(&log).verdicts { assert!( !verdict.evidence.trim().is_empty(), "{name}: {:?} has no evidence", @@ -245,3 +298,132 @@ fn every_verdict_names_the_variable_it_read() { } } } + +/// A checksum is not a signature, and the difference is the whole reason this +/// module reads the boot output rather than trusting `verify=`. +#[test] +fn a_passing_checksum_is_reported_as_integrity_not_authenticity() { + let log = "## Booting kernel from Legacy Image at 82000000 ...\n\ + Verifying Checksum ... OK\n\ + => printenv\nbootcmd=bootm 0x82000000\n\ + Environment size: 20/65532 bytes\n"; + let a = boot_chain::assess(log); + assert_eq!(a.integrity.image_check.as_deref(), Some("uimage_crc")); + assert_eq!(a.integrity.image_check_result.as_deref(), Some("passed")); + let v = a + .verdicts + .iter() + .find(|v| v.title == "Image verification") + .expect("a verdict about verification"); + // `confirmed`, not `hardened`: the check happened, and it does not protect + // against someone who can rewrite the image and its checksum. + assert_eq!(v.state, "confirmed"); + assert!(v.detail.contains("not a signature"), "{}", v.detail); + assert!(!a.integrity.image_signature_checked); +} + +/// U-Boot prints `sha256,rsa2048:dev+ OK` when a signature node was verified, +/// and `sha256+ OK` when only a digest was. Reporting the second as a signature +/// would tell a client authenticity was established when it was not. No corpus +/// log exercises either, so these are the documented formats. +#[test] +fn only_a_signature_algorithm_establishes_authenticity() { + let unsigned = boot_chain::parse_integrity(" Verifying Hash Integrity ... sha256+ OK\n"); + assert_eq!(unsigned.image_hash_algorithms, ["sha256"]); + assert!( + !unsigned.image_signature_checked, + "an unsigned FIT hash was reported as a verified signature" + ); + + let signed = + boot_chain::parse_integrity(" Verifying Hash Integrity ... sha256,rsa2048:dev+ OK\n"); + assert_eq!(signed.image_hash_algorithms, ["sha256", "rsa2048:dev"]); + assert!(signed.image_signature_checked); +} + +/// The false positive that reached production on the engine side: a substring +/// match on "Bad Magic Number" catches a filesystem complaint and reports a +/// failed image verification on a device whose bootloader said no such thing. +#[test] +fn a_filesystem_complaint_is_not_an_image_check_failure() { + let fsck = boot_chain::parse_integrity( + "fsck.ext2: Bad magic number in super-block while trying to open /dev/pramdisk0\n", + ); + assert_eq!(fsck.image_check_failed, None); + + // And a test harness quoting U-Boot's error strings in a list, which is what + // bootintel-7 actually contains. + let harness = boot_chain::parse_integrity( + "bootloader-commands: Wait for prompt ['=>', 'Bad Linux ARM64 Image magic!', 'TIMEOUT']\n", + ); + assert_eq!(harness.image_check_failed, None); + + // U-Boot's own standalone line still registers. + for line in [" Bad Magic Number\n", "Bad Header Checksum.\n"] { + assert!( + boot_chain::parse_integrity(line) + .image_check_failed + .is_some(), + "{line:?} was not recognised" + ); + } +} + +/// The HAB fuse is the anchor: while it is unblown the boot ROM runs unsigned +/// images whatever the bootloader prints afterwards. It must not depend on +/// having captured a printenv, because bootintel-7 does not have one. +#[test] +fn the_secure_boot_anchor_does_not_need_an_environment() { + let log = "Normal Boot\nhab fuse not enabled\nu-boot=> boot\n"; + let a = boot_chain::assess(log); + assert!(a.session.env.is_empty(), "this capture has no environment"); + let anchor = a + .verdicts + .iter() + .find(|v| v.title == "Secure boot anchor") + .expect("the anchor verdict survives a missing environment"); + assert_eq!(anchor.state, "exposed"); + assert_eq!(anchor.severity, "high"); +} + +/// A capture can carry both `verify=no` and an observed passing check. Emitting +/// one verdict for each produced two contradictory answers to one question, +/// which is a bug this codebase has already fixed once in another form. +#[test] +fn a_config_and_observation_conflict_is_one_verdict_that_names_it() { + let log = " Verifying Checksum ... OK\n=> printenv\nverify=no\n\ + Environment size: 20/65532 bytes\n"; + let entries: Vec<_> = boot_chain::assess(log) + .verdicts + .into_iter() + .filter(|v| v.title == "Image verification") + .collect(); + assert_eq!( + entries.len(), + 1, + "two verdicts for one question: {entries:?}" + ); + assert!( + entries[0].detail.contains("verify=no"), + "the conflict is not explained: {}", + entries[0].detail + ); +} + +/// "The check ran" is a different claim from "the check passed". +#[test] +fn a_check_with_no_captured_result_is_unknown_rather_than_passing() { + let log = " Verifying Checksum ...\n=> printenv\nbootcmd=bootm 0x82000000\n\ + Environment size: 20/65532 bytes\n"; + let a = boot_chain::assess(log); + assert_eq!( + a.integrity.image_check_result.as_deref(), + Some("not_captured") + ); + let v = a + .verdicts + .iter() + .find(|v| v.title == "Image verification") + .expect("a verdict"); + assert_eq!(v.state, "unknown"); +} diff --git a/crates/detectors/tests/fixtures/boot_chain/expect.txt b/crates/detectors/tests/fixtures/boot_chain/expect.txt index abc3bd7..889a02a 100644 --- a/crates/detectors/tests/fixtures/boot_chain/expect.txt +++ b/crates/detectors/tests/fixtures/boot_chain/expect.txt @@ -3,7 +3,7 @@ # BootIntel/cli crates/detectors/tests/fixtures/boot_chain/expect.txt # Each repo asserts its own implementation reproduces it, so the Python engine # and the Rust CLI cannot disagree about a device without a test going red. -# Regenerate BOTH copies with: python3 scripts/gen-boot-chain-expect.py +# Regenerate BOTH copies with: scripts/gen-boot-chain-expect.sh # Do not hand-edit. fnv1a64 pins the fixture log the block was produced from. ## fixture session.log fnv1a64=0dc3e8116533707d reached true @@ -24,6 +24,13 @@ evidence => printenv detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Image verification + state exposed + severity high + evidence verify=no + detail verify is disabled, so U-Boot will not check image checksums before booting. + remediation Set verify=yes, and prefer signed FIT images over checksums. verdict title Autoboot delay state exposed @@ -38,13 +45,6 @@ evidence bootcmd=bootm 0x9f020000 detail bootcmd is readable and, with the prompt reachable, settable. Whoever holds the console decides what the device boots. remediation Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a password at the prompt. - verdict - title Image verification - state exposed - severity high - evidence verify=no - detail verify is disabled, so U-Boot will not check image checksums before booting. - remediation Set verify=yes, and prefer signed FIT images over checksums. verdict title Network boot path state exposed @@ -214,3 +214,76 @@ evidence Environment size: 1524/65532 bytes detail The environment occupies 1524 of 65532 bytes of writable storage, so `saveenv` can persist a change across reboots. remediation Build with a read-only or signed environment for production. +## fixture hab-unblown.log fnv1a64=5dc74812dbd1cb3f + reached true + evidence u-boot=> setenv factorymode 1 + env_bytes + integrity hab_fuse=not_enabled + integrity hab_evidence=hab fuse not enabled + verdict + title U-Boot shell reached + state confirmed + severity high + evidence u-boot=> setenv factorymode 1 + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Secure boot anchor + state exposed + severity high + evidence hab fuse not enabled + detail The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned image. Whatever the bootloader does about checksums afterwards is advisory: the chain has no anchor. + remediation Blow the HAB fuse and close the device only after a signed image is confirmed to boot, since the operation is irreversible. + verdict + title Environment not captured + state unknown + severity info + evidence u-boot=> setenv factorymode 1 + detail The shell was reached but no printenv output was captured, so the boot chain below could not be assessed. Run `printenv` at the prompt. + remediation +## fixture verified-image.log fnv1a64=2b755c9900cf45ff + reached true + evidence Environment size: 1650/4091 bytes + env_bytes 1650/4091 + integrity image_check=uimage_crc + integrity image_check_result=passed + integrity image_check_evidence=Verifying Checksum ... OK + integrity image_check_failed=Bad Magic Number + env AP_FLAG=0 + env LOAD_SENSOR_IMG=0 + env MOSTRECENTKERNEL=1 + env WATCHDOG_COUNT=0 + env filesize=ba3 + env mem=130560k + env stderr=serial + env stdin=serial + env stdout=serial + env ver=U-Boot 1.2.0.07-dirty (Apr 20 2017 - 18:13:34) + verdict + title U-Boot shell reached + state confirmed + severity high + evidence Environment size: 1650/4091 bytes + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Image verification + state confirmed + severity medium + evidence Verifying Checksum ... OK + detail The bootloader checked the image before booting it, using a legacy uImage CRC, and the check passed. That proves the image was not corrupt. It is not a signature: anyone who can write the image can recompute the checksum, so this stops bit-rot rather than an attacker. + remediation Move to signed FIT images (CONFIG_FIT_SIGNATURE) so a deliberate modification is detected and not just a corrupt one. + verdict + title Autoboot delay + state unknown + severity info + evidence bootdelay absent + detail bootdelay is not set in the environment, so the built-in default applies and cannot be read from here. + remediation + verdict + title Environment storage + state confirmed + severity medium + 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. diff --git a/crates/detectors/tests/fixtures/boot_chain/hab-unblown.log b/crates/detectors/tests/fixtures/boot_chain/hab-unblown.log new file mode 100644 index 0000000..dbec548 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/hab-unblown.log @@ -0,0 +1,45 @@ +U-Boot SPL 2022.04 (Oct 05 2023 - 14:06:15 +0000) +power_bd71837_init +DDRINFO: start DRAM init +DDRINFO: DRAM rate 1600MTS +DDRINFO:ddrphy calibration done +DDRINFO: ddrmix config done +Normal Boot +Trying to boot from MMC1 +hab fuse not enabled +Authenticate image from DDR location 0x401fcdc0... +NOTICE: BL31: v2.6(release):lf-5.15.32-2.0.0-0-gc6a19b1a3-dirty +NOTICE: BL31: Built : 06:37:22, Jun 7 2022 +U-Boot 2022.04 (Oct 05 2023 - 14:06:15 +0000) +CPU: i.MX8MMD rev1.0 1600 MHz (running at 1200 MHz) +CPU: Industrial temperature grade (-40C to 105C) at 43C +Reset cause: POR +Model: grip-aio_200-02122A +DRAM: 224 MiB +board_init +Core: 61 devices, 21 uclasses, devicetree: separate +MMC: FSL_SDHC: 0 +Loading Environment from nowhere... OK +In: serial +Out: serial +Err: serial +SEC0: RNG instantiated + BuildInfo: + - ATF c6a19b1 +facmod value is 1! +pulse number is 0 +�flash target is MMC:0 +Fastboot: Normal +Normal Boot + +Hit any key to stop autoboot: 2 +end: 3.4.2 bootloader-interrupt (duration 00:00:04) [common] +start: 3.4.3 bootloader-commands (timeout 00:02:52) [common] +Setting prompt string to ['=>'] +bootloader-commands: Wait for prompt ['=>'] (timeout 00:02:52) + 0 +Setting prompt string to ['=>', 'Resetting CPU', 'Must RESET board to recover', 'TIMEOUT', 'Retry count exceeded', 'Retry time exceeded; starting again', 'ERROR: The remote end did not respond in time.', 'File not found', 'Bad Linux ARM64 Image magic!', 'Wrong Ramdisk Image Format', 'Ramdisk image is corrupt', 'ERROR: Failed to allocate', 'TFTP error: trying to overwrite reserved memory', 'Bad Linux RISCV Image magic!', 'Wrong Image Format for boot', 'ERROR: Did not find a cmdline Flattened Device Tree', 'ERROR: RD image overlaps OS image', 'Invalid partition'] +Sending with 5 millisecond of delay +setenv factorymode 1 +u-boot=> setenv factorymode 1 +bootloader-commands: Wait for prompt ['=>', 'Resetting CPU', 'Must RESET board to recover', 'TIMEOUT', 'Retry count exceeded', 'Retry time exceeded; starting again', 'ERROR: The remote end did not respond in time.', 'File not found', 'Bad Linux ARM64 Image magic!', 'Wrong Ramdisk Image Format', 'Ramdisk image is corrupt', 'ERROR: Failed to allocate', 'TFTP error: trying to overwrite reserved memory', 'Bad Linux RISCV Image magic!', 'Wrong Image Format for boot', 'ERROR: Did not find a cmdline Flattened Device Tree', 'ERROR: RD image overlaps OS image', 'Invalid partition'] (timeout 00:02:51) diff --git a/crates/detectors/tests/fixtures/boot_chain/verified-image.log b/crates/detectors/tests/fixtures/boot_chain/verified-image.log new file mode 100644 index 0000000..13e6256 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/verified-image.log @@ -0,0 +1,106 @@ +AP_FLAG=0 +filesize=ba3 +LOAD_SENSOR_IMG=0 +MOSTRECENTKERNEL=1 +WATCHDOG_COUNT=0 +stdin=serial +stdout=serial +stderr=serial +mem=130560k +ver=U-Boot 1.2.0.07-dirty (Apr 20 2017 - 18:13:34) + +Environment size: 1650/4091 bytes +Boot-> help +? - alias for 'help' +autoscr - run script from memory +base - print or set address offset +bdinfo - print Board Info structure +boot - boot default, i.e., run 'bootcmd' +bootd - boot default, i.e., run 'bootcmd' +bootelf - Boot from an ELF image in memory +bootm - boot application image from memory +bootp - boot image via network using BootP/TFTP protocol +bootvx - Boot vxWorks from an ELF image +chenv - manipulate environment variables +cmp - memory compare +coninfo - print console devices and information +cp - memory copy +crc32 - checksum calculation +defenv - get environment variables from default block +dhcp - invoke DHCP client to obtain IP/boot params +echo - echo args to console +erase - erase FLASH memory +exit - exit script +flashtest - do flash check from 0xbf000000 to 0xbff00000 +flinfo - print FLASH memory information +fsinfo - print information about filesystems +fsload - load binary file from a filesystem image +go - start application at address 'addr' +help - print online help +iminfo - print header information for application image +imls - list all images found in flash +itest - return true/false on integer compare +loadb - load binary file over serial line (kermit mode) +loads - load S-Record file over serial line +loady - load binary file over serial line (ymodem mode) +loop - infinite loop on address range +ls - list files in a directory (default /) +md - memory display +mm - memory modify (auto-incrementing) +mtest - simple RAM test +mw - memory write (fill) +nfs - boot image via network using NFS protocol +nm - memory modify (constant address) +ping - send ICMP ECHO_REQUEST to network host +printenv- print environment variables +protect - enable or disable FLASH write protection +rarpboot- boot image via network using RARP/TFTP protocol +reset - Perform RESET of the CPU +run - run commands in an environment variable +saveenv - save environment variables to persistent storage +setenv - set environment variables +sleep - delay execution for some time +test - minimal test like /bin/sh +tftpboot- boot image via network using TFTP protocol +version - print monitor version +Boot-> bdinfo +boot_params = 0x87D2EFB0 +memstart = 0x80000000 +memsize = 0x08000000 +flashstart = 0xBF000000 +flashsize = 0x01000000 +flashoffset = 0x00000000 +ethaddr = 00:1F:45:F2:B7:3B +ip_addr = 192.168.1.20 +baudrate = 115200 bps +Boot-> md +Usage: +md - memory display + +Boot-> iminfo + +## Checking Image at 80100000 ... + Bad Magic Number +Boot-> imls +Boot-> go +Usage: +go - start application at address 'addr' + +Boot-> boot +### JFFS2 loading 'boot_kernel.img2' to 0x80400000 +Scanning JFFS2 FS: . done. +### JFFS2 load complete: 2979 bytes loaded to 0x80400000 + +## Checking Image at 80400000 ... + Image Name: Boot ROM Script + Created: 2010-06-21 17:46:19 UTC + Image Type: PowerPC Linux Script (uncompressed) + Data Size: 2915 Bytes = 2.8 kB + Load Address: 00000000 + Entry Point: 00000000 + Verifying Checksum ... OK +## Executing script at 80400000 +check for watchdog, limit=3... +check for reset by watchdog... +no watchdog... +bSaveEnviron=0 image_order=secondary primary