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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ cargo build --release
| `bootintel scan <file>` | 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 <file> --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 <port> --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 <file>` | 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 <file>` | 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 <file>` | 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. |
Expand Down
106 changes: 103 additions & 3 deletions crates/cli/src/cmd/verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -117,6 +119,7 @@ pub fn run(args: Args) -> Result<()> {
&log.source_label,
session,
integrity,
&hardening,
verdicts,
color,
) {
Expand All @@ -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 \
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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::<serde_json::Map<String, serde_json::Value>>(),
Expand Down Expand Up @@ -249,12 +284,17 @@ pub(crate) fn write_text<W: Write>(
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
Expand Down Expand Up @@ -329,5 +369,65 @@ pub(crate) fn write_text<W: Write>(
}
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<W: Write>(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(())
}
4 changes: 4 additions & 0 deletions crates/cli/src/term/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,9 @@ fn apply_autoboot<W: Write>(
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
Expand All @@ -1565,6 +1568,7 @@ fn apply_autoboot<W: Write>(
source,
&assessment.session,
&assessment.integrity,
&hardening,
&assessment.verdicts,
color,
);
Expand Down
49 changes: 49 additions & 0 deletions crates/cli/tests/verdict_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
1 change: 1 addition & 0 deletions crates/detectors/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use regex::Regex;
use std::sync::LazyLock;
pub mod boot_chain;
pub mod os_hardening;

/// A single detector's output.
///
Expand Down
Loading
Loading