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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Use `bootintel term` when you want a clean terminal and `bootintel analyze` when
| Inspect a newly connected adapter | `bootintel ports` | Lists candidate ports with USB VID/PID and product metadata when available. |
| Capture and analyze a boot | `bootintel analyze /dev/ttyUSB0 -b 115200 --log-file boot.log` | Preserves raw bytes and prints local findings as the device boots. |
| Analyze a log without hardware | `bootintel scan boot.log --format text` | Runs the local detector set without an account or network connection. |
| Assess what the boot chain permits | `bootintel verdict session.log` | Reads a `printenv` dump taken at the U-Boot prompt and says what it permits. Entirely offline. |
| Compare firmware boots | `bootintel diff before.log after.log` | Shows meaningful boot-log changes between two captures. |
| Gate a build artifact | `bootintel scan boot.log --format sarif --gate-critical` | Emits CI-friendly output and exits non-zero for critical findings. |
| Request richer analysis | `bootintel scan --api --preview boot.log` | Explicitly sends the log to BootIntel's API using the anonymous preview quota. |
Expand Down Expand Up @@ -159,6 +160,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 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 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
1 change: 1 addition & 0 deletions crates/cli/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod scan;
pub mod schema;
pub mod share;
pub mod term;
pub mod verdict;
pub mod version;
pub mod view;
pub mod watch;
Expand Down
253 changes: 253 additions & 0 deletions crates/cli/src/cmd/verdict.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//! `bootintel verdict <file>` — what the U-Boot environment permits.
//!
//! Every other command here reads a boot log, which is a record of what the
//! firmware chose to print. This reads what an operator pulled OUT of a board
//! after interrupting autoboot: a `printenv` dump. The difference is the whole
//! reason for taking the prompt. A boot log can say autoboot looks
//! interruptible; the environment says exactly what happens when you interrupt
//! it, and whether you can change what boots.
//!
//! It runs entirely offline, unlike `scan --api`, and that is deliberate. A
//! U-Boot environment is the most sensitive thing in a capture — `ipaddr`,
//! `serverip`, `ethaddr`, TFTP hosts, a client's internal addressing — so
//! requiring an upload to learn what it permits would put this out of reach of
//! exactly the people it is for. The rules live in
//! `bootintel_detectors::boot_chain` and are pinned against the server
//! implementation by `crates/detectors/tests/boot_chain.rs`.
//!
//! Absence is never reported as good news: U-Boot prints only what is set, so
//! a missing `bootdelay` is `unknown`, not `hardened`.

use anyhow::Result;
use clap::Args as ClapArgs;
use std::io::Write;

use bootintel_detectors::boot_chain::{self, UbootSession, Verdict};

use crate::analyze::render::sanitize_for_term;
use crate::output::{self, ColorMode};

/// Nothing to inspect: an empty file, an empty pipe, whitespace only. The same
/// code `scan` uses, for the same reason — a capture that never happened must
/// not report green.
const EXIT_EMPTY_INPUT: i32 = 2;

/// Content, but no interactive session in it, so there is no environment to
/// assess. Distinct from an empty capture, and non-zero because "I could not
/// answer" must never look like "nothing is wrong".
const EXIT_NO_SESSION: i32 = 3;

#[derive(ClapArgs, Debug)]
pub struct Args {
/// Path to a capture containing a U-Boot session, or `-` for stdin.
#[arg(value_name = "FILE")]
file: String,

/// Read the capture from stdin regardless of `<FILE>`.
#[arg(long)]
stdin: bool,

/// Machine-readable output. Keys match the server's analysis response
/// (`uboot_shell`, `uboot_env`, `boot_chain_verdict`), so a consumer can
/// move between this and `scan --api` without remapping anything.
#[arg(long)]
json: bool,

/// Exit non-zero if any verdict is `exposed`. For CI gating a build's
/// shipped environment: `bootintel verdict capture.log --gate-exposed`.
#[arg(long)]
gate_exposed: bool,

/// Suppress ANSI colour. Colour is otherwise on only when stdout is a TTY
/// and $NO_COLOR is unset.
#[arg(long)]
no_color: bool,
}

pub fn run(args: Args) -> Result<()> {
let log = if args.stdin || args.file == "-" {
crate::input::read_stdin()?
} else {
let path = std::path::PathBuf::from(&args.file);
crate::input::read_file(&path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => anyhow::anyhow!(
"no such file: {}\n Check the path, or pipe from stdin: \
bootintel verdict - < path/to/capture.log",
args.file
),
_ => anyhow::Error::from(e).context(format!("reading {}", args.file)),
})?
};
log.report_replacements();

if log.text.trim().is_empty() {
eprintln!(
"bootintel: empty capture; nothing to assess ({})\n \
Check that the capture actually ran and that the path is the one it wrote.",
log.source_label
);
std::process::exit(EXIT_EMPTY_INPUT);
}

let (session, verdicts) = boot_chain::assess(&log.text);

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);
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))
{
// A reader that has seen enough is not a failure, and must not
// become the verdict either. Same rule as `scan`.
if !crate::is_broken_pipe(&e) {
return Err(e);
}
}
} else if let Err(e) = write_text(&mut out, &log.source_label, &session, &verdicts, color) {
if !crate::is_broken_pipe(&e) {
return Err(e);
}
}

// No session means no answer, which is not the same as a good answer.
if !session.reached {
let _ = out.flush();
eprintln!(
"bootintel: no U-Boot session found in {}; nothing was assessed\n \
This command reads a `printenv` dump taken at the prompt, not a boot log. \
Capture one with `bootintel term`, interrupt autoboot, run `printenv`, and \
save the session. `bootintel scan` is the command for a plain boot log.",
log.source_label
);
std::process::exit(EXIT_NO_SESSION);
}

if args.gate_exposed {
let exposed: Vec<&Verdict> = verdicts.iter().filter(|v| v.state == "exposed").collect();
if !exposed.is_empty() {
let _ = out.flush();
eprintln!(
"bootintel: {} exposed {} in {}",
exposed.len(),
if exposed.len() == 1 {
"verdict"
} else {
"verdicts"
},
log.source_label
);
for v in exposed {
eprintln!(" {} — {}", v.title, v.evidence);
}
std::process::exit(1);
}
}
Ok(())
}

fn json(source: &str, session: &UbootSession, 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());
}
serde_json::json!({
"source": source,
"uboot_shell": shell,
"uboot_env": session.env.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone())))
.collect::<serde_json::Map<String, serde_json::Value>>(),
"boot_chain_verdict": verdicts.iter().map(|v| serde_json::json!({
"title": v.title,
"state": v.state,
"detail": v.detail,
"evidence": v.evidence,
"severity": v.severity,
"remediation": v.remediation,
})).collect::<Vec<_>>(),
})
}

/// Colour by what the reader has to do about it, not by severity name.
fn state_code(state: &str) -> &'static str {
match state {
"exposed" => output::ANSI_BOLD_RED,
"confirmed" => output::ANSI_BOLD_CYAN,
_ => output::ANSI_DIM,
}
}

fn write_text<W: Write>(
out: &mut W,
source: &str,
session: &UbootSession,
verdicts: &[Verdict],
color: ColorMode,
) -> Result<()> {
let on = color == ColorMode::On;
if !session.reached {
writeln!(out, "no U-Boot session in {source}")?;
return Ok(());
}
// Everything below is device-controlled text, so it is sanitized before it
// reaches a terminal: a crafted environment value could otherwise inject
// escape sequences into a consultant's session.
writeln!(
out,
" {} in {source}",
output::wrap("U-Boot session", output::ANSI_BOLD_CYAN, on)
)?;
writeln!(
out,
" evidence {}",
sanitize_for_term(&session.evidence)
)?;
let size = match (session.env_used_bytes, session.env_total_bytes) {
(Some(used), Some(total)) => format!(", environment {used}/{total} bytes"),
_ => String::new(),
};
writeln!(
out,
" {} variable{}{size}",
session.env.len(),
if session.env.len() == 1 { "" } else { "s" }
)?;
writeln!(out)?;

for v in verdicts {
// Pad on the visible width: ANSI escapes have zero display width but
// Rust's formatter counts bytes, so `{:9}` on a wrapped string would
// knock every column out of line.
let pad = 9usize.saturating_sub(v.state.chars().count());
writeln!(
out,
" {}{:pad$} {}",
output::wrap(&v.state, state_code(&v.state), on),
"",
output::wrap(&sanitize_for_term(&v.title), output::ANSI_BOLD_CYAN, on)
)?;
writeln!(out, " {}", sanitize_for_term(&v.detail))?;
writeln!(
out,
" {} {}",
output::wrap("read from", output::ANSI_DIM, on),
sanitize_for_term(&v.evidence)
)?;
if let Some(fix) = &v.remediation {
writeln!(
out,
" {} {}",
output::wrap("fix", output::ANSI_DIM, on),
sanitize_for_term(fix)
)?;
}
writeln!(out)?;
}
Ok(())
}
4 changes: 4 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub struct Cli {
enum Cmd {
/// Analyze a saved boot log file (or stdin with `-`).
Scan(cmd::scan::Args),
/// Assess a U-Boot `printenv` capture: what the boot chain permits.
/// Offline; reads a session taken at the prompt, not a plain boot log.
Verdict(cmd::verdict::Args),
/// Interactive UART terminal (picocom-shaped). Ctrl-A ? for help.
Term(cmd::term::Args),
/// Interactive UART terminal + live client-side detector analysis.
Expand Down Expand Up @@ -187,6 +190,7 @@ fn main() -> Result<()> {
verbose::set_quiet(cli.quiet);
let result = match cli.command {
Cmd::Scan(args) => cmd::scan::run(args),
Cmd::Verdict(args) => cmd::verdict::run(args),
Cmd::Term(args) => cmd::term::run(args),
Cmd::Analyze(args) => cmd::analyze::run(args),
Cmd::Share(args) => cmd::share::run(args),
Expand Down
10 changes: 5 additions & 5 deletions crates/cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,12 +432,12 @@ fn write_json<W: Write>(out: &mut W, findings: &[Finding]) -> Result<()> {
/// ANSI SGR codes. Bare consts (vs a colour crate dep) keep binary
/// size flat — this is the third place we've drawn a line at not
/// pulling a crate in for something a few escape strings can do.
const ANSI_RESET: &str = "\x1b[0m";
const ANSI_BOLD_RED: &str = "\x1b[1;31m";
const ANSI_BOLD_CYAN: &str = "\x1b[1;36m";
const ANSI_DIM: &str = "\x1b[2m";
pub(crate) const ANSI_RESET: &str = "\x1b[0m";
pub(crate) const ANSI_BOLD_RED: &str = "\x1b[1;31m";
pub(crate) const ANSI_BOLD_CYAN: &str = "\x1b[1;36m";
pub(crate) const ANSI_DIM: &str = "\x1b[2m";

fn wrap(s: &str, code: &str, on: bool) -> String {
pub(crate) fn wrap(s: &str, code: &str, on: bool) -> String {
if on {
format!("{code}{s}{ANSI_RESET}")
} else {
Expand Down
Loading
Loading