Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a

## [Unreleased]

### Added
- **`bootintel verdict` now reports board info and the flash partition table**
when a capture contains a `bdinfo` or `mtdparts` dump. The partition rows carry
the `mask_flags` read-only marker, which is the operationally interesting
column: it says which partitions an operator at that prompt can rewrite.

Both were dead code on the engine side and the port started by measuring that,
not by assuming it. Across all 31 public corpus logs they produced nothing,
while two of those logs contain a full bdinfo dump. The cause was a command
gate: the blocks were only parsed once the prompt regex had matched the line
where the command was typed, and `Boot-> bdinfo` is not U-Boot's default
prompt. They are now recognised by their own shape.

The discriminator between board info and an environment variable is the
whitespace around the `=`: `printenv` emits `baudrate=115200`, `bdinfo` pads to
a column and emits `baudrate = 115200 bps`.

`verdict --json` gained `bdinfo` and `mtd_device` under `uboot_shell`, and a
top-level `mtd_partitions` array, matching the engine's placement.

## [0.10.0] — 2026-09-28 — the kernel's own hardening report

`bootintel verdict` now answers for captures that never reach a U-Boot prompt.
Expand Down
52 changes: 52 additions & 0 deletions crates/cli/src/cmd/verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,22 @@ fn json(
shell.insert("env_used_bytes".into(), used.into());
shell.insert("env_total_bytes".into(), total.into());
}
// Same placement as the engine: board info and the flash device hang off the
// shell, partitions are top level and tagged with the parser that found them.
if !session.bdinfo.is_empty() {
shell.insert(
"bdinfo".into(),
session
.bdinfo
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone())))
.collect::<serde_json::Map<String, serde_json::Value>>()
.into(),
);
}
if let Some(dev) = &session.mtd_device {
shell.insert("mtd_device".into(), dev.clone().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".
Expand Down Expand Up @@ -259,6 +275,13 @@ fn json(
"uboot_env": session.env.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone())))
.collect::<serde_json::Map<String, serde_json::Value>>(),
"mtd_partitions": session.mtd_partitions.iter().map(|p| serde_json::json!({
"name": p.name,
"size": p.size,
"offset": p.offset,
"read_only": p.read_only,
"source": "uboot_mtdparts",
})).collect::<Vec<_>>(),
"boot_chain_verdict": verdicts.iter().map(|v| serde_json::json!({
"title": v.title,
"state": v.state,
Expand Down Expand Up @@ -320,6 +343,35 @@ pub(crate) fn write_text<W: Write>(
session.env.len(),
if session.env.len() == 1 { "" } else { "s" }
)?;
if !session.bdinfo.is_empty() {
writeln!(
out,
" board info {} field{}",
session.bdinfo.len(),
if session.bdinfo.len() == 1 { "" } else { "s" }
)?;
}
if !session.mtd_partitions.is_empty() {
let dev = session.mtd_device.as_deref().unwrap_or("flash");
writeln!(
out,
" {} partitions on {}",
session.mtd_partitions.len(),
sanitize_for_term(dev)
)?;
for p in &session.mtd_partitions {
// The read-only flag is the operationally interesting column: it is
// what says which partitions an operator at this prompt can rewrite.
writeln!(
out,
" {:<16} 0x{:08x} @ 0x{:08x}{}",
sanitize_for_term(&p.name),
p.size,
p.offset,
if p.read_only { " read-only" } else { "" }
)?;
}
}
if let Some(check) = &integrity.image_check {
let mechanism = if check == "fit_hash" {
let algos = if integrity.image_hash_algorithms.is_empty() {
Expand Down
115 changes: 115 additions & 0 deletions crates/detectors/src/boot_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ pub struct UbootSession {
pub env: BTreeMap<String, String>,
pub env_used_bytes: Option<u64>,
pub env_total_bytes: Option<u64>,
/// `bdinfo` output: what the board reports about itself.
pub bdinfo: BTreeMap<String, String>,
/// The flash device named by an `mtdparts` dump.
pub mtd_device: Option<String>,
/// Partitions from an `mtdparts` dump, in the order printed.
pub mtd_partitions: Vec<MtdPartition>,
}

use std::sync::LazyLock;
Expand Down Expand Up @@ -239,6 +245,72 @@ pub fn parse_integrity(log: &str) -> BootIntegrity {
bi
}

// bdinfo prints aligned `name = value`. WHITESPACE BOTH SIDES OF THE `=` IS
// REQUIRED, and it is what separates a bdinfo line from an environment line:
// `printenv` emits `ethaddr=00:1F:...` with no spaces, bdinfo pads to a column
// and emits `ethaddr = 00:1F:...`. Without that, every environment dump
// containing baudrate or ethaddr would also be read as board info.
static RE_BDINFO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*([A-Za-z][\w /()\-]{0,31}?)\s+=\s+(\S.*?)\s*$").unwrap());

/// The allowlist is what makes bdinfo self-evidencing, which matters because the
/// command that produced it cannot be relied on: bootintel-20 prints a full dump
/// after `Boot-> bdinfo`, and `Boot->` is not U-Boot's default prompt, so a
/// command-gated parser read that dump as nothing at all.
///
/// `start` and `size` are deliberately absent: too generic to stand alone.
/// bootintel-5 prints an MTD table as `mtd_part[0]:` / `name = KERNEL` /
/// `size = 0x180000`, and `size` in this list recorded that as board info.
const BDINFO_KEYS: &[&str] = &[
"arch_number",
"boot_params",
"dram bank",
"flashstart",
"flashsize",
"flashoffset",
"baudrate",
"relocaddr",
"reloc off",
"ethaddr",
"ip_addr",
"fdt_blob",
"irq_sp",
"sp start",
"eth0name",
"memstart",
"memsize",
"eth1name",
"ethaddr1",
"current eth",
"fdt_addr",
"sp_start",
"reloc_off",
"dram_bank",
];

// `device nor0 <spi0.0>, # parts = 4`. The bracketed chip identifier is optional
// and the space in `# parts` is real. The engine's first version of this pattern
// required `#parts` with no space and no brackets, so it never matched U-Boot's
// actual output: the partition lines parsed and the device name did not. No
// public corpus log contains an mtdparts dump, so nothing caught it until a
// fixture was written for the documented format.
static RE_MTD_DEV: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*device\s+(\S+)(?:\s+<[^>]*>)?\s*,\s*#\s*parts\s*=\s*(\d+)").unwrap()
});
static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*\d+:\s*(\S+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+(\d)").unwrap()
});

/// One partition as `mtdparts` printed it at the prompt.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MtdPartition {
pub name: String,
pub size: u64,
pub offset: u64,
/// The `mask_flags` column: 1 means the partition is marked read-only.
pub read_only: bool,
}

/// Truncate to `max` CHARACTERS, mirroring Python's `s[:max]`.
///
/// `String::truncate` counts bytes and panics mid-codepoint, and a capture is
Expand Down Expand Up @@ -326,6 +398,49 @@ pub fn parse_session(log: &str) -> UbootSession {
continue;
}

// bdinfo and mtdparts, recognised by their own shape rather than by
// having seen the command that produced them, for the reason in
// BDINFO_KEYS. Checked before the continuation logic so a bdinfo line
// is never appended to a buffered environment value.
if let Some(caps) = RE_BDINFO.captures(line) {
let key = caps[1].trim();
if BDINFO_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
s.bdinfo
.entry(key.to_string())
.or_insert_with(|| caps[2].trim().to_string());
note(&mut s, line);
continue;
}
}
if let Some(caps) = RE_MTD_DEV.captures(line) {
if s.mtd_device.is_none() {
s.mtd_device = Some(caps[1].to_string());
}
note(&mut s, line);
continue;
}
if let Some(caps) = RE_MTD_PART.captures(line) {
// Hex without a `0x`, as U-Boot prints it. A width beyond u64 is not
// a partition table, so a failed parse drops the row rather than
// inventing a zero.
if let (Ok(size), Ok(offset)) = (
u64::from_str_radix(&caps[2], 16),
u64::from_str_radix(&caps[3], 16),
) {
let part = MtdPartition {
name: caps[1].to_string(),
size,
offset,
read_only: &caps[4] == "1",
};
if !s.mtd_partitions.contains(&part) {
s.mtd_partitions.push(part);
}
note(&mut s, line);
continue;
}
}

// A long U-Boot value wraps in a terminal capture, so the continuation
// has no `KEY=`. Treating it as a boundary discarded whole
// environments. Append instead, bounded: a couple of wrapped lines is
Expand Down
96 changes: 96 additions & 0 deletions crates/detectors/tests/boot_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ fn render(name: &str, log: &str) -> Vec<String> {
"ignored_kernel_parameters",
h.ignored_kernel_parameters.as_deref(),
);
for (key, value) in &session.bdinfo {
field(&mut out, " ", "bdinfo", &format!("{key}={value}"));
}
if let Some(dev) = &session.mtd_device {
field(&mut out, " ", "mtd_device", dev);
}
for part in &session.mtd_partitions {
field(
&mut out,
" ",
"mtd_part",
&format!(
"{} size=0x{:x} offset=0x{:x} ro={}",
part.name, part.size, part.offset, part.read_only
),
);
}
for (key, value) in &session.env {
field(&mut out, " ", "env", &format!("{key}={value}"));
}
Expand Down Expand Up @@ -461,3 +478,82 @@ fn a_check_with_no_captured_result_is_unknown_rather_than_passing() {
.expect("a verdict");
assert_eq!(v.state, "unknown");
}

/// bdinfo is recognised by its own shape, because the command that produced it
/// cannot be relied on: bootintel-20 prints a full dump after `Boot-> bdinfo`,
/// and `Boot->` is not U-Boot's default prompt. A command-gated parser read that
/// entire dump as nothing.
#[test]
fn a_bdinfo_dump_behind_a_vendor_prompt_is_still_read() {
let log = "Boot-> bdinfo\n\
boot_params = 0x87D2EFB0\n\
memstart = 0x80000000\n\
flashsize = 0x01000000\n\
ethaddr = 00:1F:45:F2:B7:3B\n";
let s = boot_chain::parse_session(log);
assert_eq!(
s.bdinfo.get("boot_params").map(String::as_str),
Some("0x87D2EFB0")
);
assert_eq!(
s.bdinfo.get("memstart").map(String::as_str),
Some("0x80000000")
);
assert_eq!(s.bdinfo.len(), 4);
assert!(s.reached, "a bdinfo dump proves someone was at the prompt");
}

/// The discriminator between board info and an environment variable is the
/// whitespace around the `=`. Without it, every environment dump containing
/// baudrate or ethaddr would also be recorded as board info.
#[test]
fn an_environment_line_is_not_board_info() {
let log = "=> printenv\nbaudrate=115200\nethaddr=00:11:22:33:44:55\n\
Environment size: 40/65532 bytes\n";
let s = boot_chain::parse_session(log);
assert!(
s.bdinfo.is_empty(),
"read the environment as board info: {:?}",
s.bdinfo
);
assert_eq!(s.env.len(), 2);
}

/// `size` and `start` are too generic to be board-info keys on their own.
/// bootintel-5 prints an MTD table in a vendor format whose rows are exactly
/// `size = 0x180000`, and treating that as bdinfo was a live false positive.
#[test]
fn a_vendor_mtd_table_is_not_board_info() {
let log = "mtd_part[0]:\nname = KERNEL\nsize = 0x180000\noffset = 0x37000\n";
let s = boot_chain::parse_session(log);
assert!(s.bdinfo.is_empty(), "{:?}", s.bdinfo);
}

/// U-Boot prints `device nor0 <spi0.0>, # parts = 4`: the bracketed chip id is
/// optional and the space in `# parts` is real. The engine's first pattern
/// required neither and so never matched, recording the partitions but not the
/// device they belong to.
#[test]
fn an_mtdparts_dump_is_parsed_including_its_device() {
let log = "=> mtdparts\n\n\
device nor0 <spi0.0>, # parts = 4\n \
#: name size offset mask_flags\n \
0: u-boot 0x00020000 0x00000000 1\n \
1: kernel 0x00100000 0x00020000 0\n";
let s = boot_chain::parse_session(log);
assert_eq!(s.mtd_device.as_deref(), Some("nor0"));
assert_eq!(s.mtd_partitions.len(), 2);
assert_eq!(s.mtd_partitions[0].name, "u-boot");
assert_eq!(s.mtd_partitions[0].size, 0x20000);
assert!(
s.mtd_partitions[0].read_only,
"mask_flags 1 means read-only"
);
assert!(!s.mtd_partitions[1].read_only);
}

#[test]
fn a_device_line_without_the_bracketed_chip_id_also_parses() {
let s = boot_chain::parse_session("device nand0, #parts = 2\n");
assert_eq!(s.mtd_device.as_deref(), Some("nand0"));
}
Loading
Loading