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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Verdict>)`
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,
Expand Down
90 changes: 85 additions & 5 deletions crates/cli/src/cmd/verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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))
Expand All @@ -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);
}
Expand Down Expand Up @@ -149,17 +161,66 @@ 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());
if let (Some(used), Some(total)) = (session.env_used_bytes, session.env_total_bytes) {
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::<serde_json::Map<String, serde_json::Value>>(),
Expand Down Expand Up @@ -187,6 +248,7 @@ pub(crate) fn write_text<W: Write>(
out: &mut W,
source: &str,
session: &UbootSession,
integrity: &BootIntegrity,
verdicts: &[Verdict],
color: ColorMode,
) -> Result<()> {
Expand Down Expand Up @@ -218,6 +280,24 @@ pub(crate) fn write_text<W: Write>(
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 {
Expand Down
13 changes: 9 additions & 4 deletions crates/cli/src/term/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1552,17 +1552,22 @@ fn apply_autoboot<W: Write>(
);
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
} else {
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();
}
}
Expand Down
Loading
Loading