From 4ec747818db6f673c93a55a86c0b5f7a7c3e497c Mon Sep 17 00:00:00 2001 From: BootIntel Agent Date: Tue, 29 Sep 2026 05:32:11 +0000 Subject: [PATCH] Wire --interrupt-autoboot into the TUI dashboard It was refused there rather than silently ignored, which was right while it was unwired and left the dashboard as the one mode that could not take the prompt. The verdicts render at the top of the findings pane, above the detector findings, because they were read from the device at the prompt and that is better evidence than anything matched out of the scrollback. Putting them in the server pane would have been easier and wrong: that pane says "server" and this is decided entirely locally. The notes go to the status bar and never through the analyzer. Feeding the tool's own output back in would let a detector match it and report bootintel's messages as evidence about the device, so there is a test for that rather than a comment asking someone to be careful. Verified against the socat fake board: the TUI hammered, caught a bootdelay=0 board's single check (582 bytes waiting at the check), and ran printenv, bdinfo and mtdparts. The rendering itself is unit-tested instead, because `script` gives ratatui no real terminal to size against and the alt-screen capture comes back empty. Saying the visual was verified on that evidence would have been wrong, so the part with a decision in it was extracted and driven directly. 390 tests with --features tui, 369 without, clippy clean under -D warnings in both, rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 ++ README.md | 2 +- crates/cli/src/cmd/analyze.rs | 8 +- crates/cli/src/tui/app.rs | 8 + crates/cli/src/tui/render.rs | 57 ++++++-- crates/cli/src/tui/run.rs | 266 ++++++++++++++++++++++++++++++++++ 6 files changed, 338 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 151be78..4ec26bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`--interrupt-autoboot` works with `--tui`.** It was refused there rather than + silently ignored, which was the right call while it was unwired, but it left + the dashboard as the one mode that could not take the prompt. + + The verdicts appear at the top of the findings pane, above the detector + findings, because they were read from the device at the prompt and that is + better evidence than anything matched out of the scrollback. Operator notes go + to the status bar and the miss explanation stays there long enough to act on. + + The tool's own notes never pass through the analyzer. Feeding them back would + let a detector match bootintel's output and report it as evidence about the + device, which is asserted in a test rather than left to care. + ## [0.11.0] — 2026-09-28 — board info and the flash partition table ### Added diff --git a/README.md b/README.md index 790b595..316d757 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ cargo build --release | --- | --- | | `bootintel scan ` | 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 --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 --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 analyze --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. Works with `--tui`, where the verdicts appear at the top of the findings pane. Reports the window missed rather than exiting quietly. | | `bootintel verdict ` | 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 ` | 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. | diff --git a/crates/cli/src/cmd/analyze.rs b/crates/cli/src/cmd/analyze.rs index 0d35c33..1efb98d 100644 --- a/crates/cli/src/cmd/analyze.rs +++ b/crates/cli/src/cmd/analyze.rs @@ -108,7 +108,8 @@ pub struct Args { no_live_display: bool, /// Interrupt autoboot on connect, take the U-Boot prompt, pull the - /// environment, and print what the boot chain permits. + /// environment, and report what the boot chain permits. Works with --tui, + /// where the verdicts appear at the top of the findings pane. /// /// Hammers the interrupt key from the moment the port opens rather than /// waiting to see a countdown: with `bootdelay=0` U-Boot checks for a @@ -325,11 +326,6 @@ fn build_interrupt_config(args: &Args) -> Result> { } return Ok(None); } - if args.tui { - bail!( - "--interrupt-autoboot is not wired into the --tui dashboard yet, and silently \n ignoring it would look like a board that refused to stop. Drop --tui for now." - ); - } let defaults = autoboot::Config::default(); let key = match &args.interrupt_key { None => defaults.key.clone(), diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 4882e34..1330489 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -123,6 +123,13 @@ pub struct App { /// of the serial port. Renderer draws a bottom-line input row when /// this is Some. pub input_prompt: Option, + + /// Verdicts from `--interrupt-autoboot`, once the prompt was taken and the + /// environment pulled. Rendered at the top of the findings pane because + /// that is what they are: conclusions about the device. The server pane + /// would have been the wrong home, since its title says "server" and this + /// is decided entirely locally. + pub boot_chain: Vec, } /// Bottom-line input modal state. Small enough to keep inline in App; @@ -189,6 +196,7 @@ impl App { hex_mode: false, hex_bytes: std::collections::VecDeque::with_capacity(HEX_RING_CAP), input_prompt: None, + boot_chain: Vec::new(), } } diff --git a/crates/cli/src/tui/render.rs b/crates/cli/src/tui/render.rs index de0145b..ec47065 100644 --- a/crates/cli/src/tui/render.rs +++ b/crates/cli/src/tui/render.rs @@ -137,23 +137,60 @@ fn render_findings_pane(f: &mut Frame, area: Rect, app: &App) { let block = Block::default() .borders(Borders::ALL) .border_style(border_style) - .title(format!( - " findings (client) — {} ", - app.analyzer.findings_snapshot().len() - )); + .title(if app.boot_chain.is_empty() { + format!( + " findings (client) — {} ", + app.analyzer.findings_snapshot().len() + ) + } else { + format!( + " findings (client) — {} + {} boot chain ", + app.analyzer.findings_snapshot().len(), + app.boot_chain.len() + ) + }); let inner = block.inner(area); f.render_widget(block, area); - let items: Vec = app - .analyzer - .findings_snapshot() - .iter() - .map(finding_to_list_item) - .collect(); + // Boot-chain verdicts first: they were read from the device at the prompt, + // which is better evidence than anything matched out of the scrollback, and + // burying them under the detector list would invert that. + let mut items: Vec = app.boot_chain.iter().map(verdict_to_list_item).collect(); + items.extend( + app.analyzer + .findings_snapshot() + .iter() + .map(finding_to_list_item), + ); let list = List::new(items); f.render_widget(list, inner); } +/// A verdict, styled by what the reader has to do about it rather than by the +/// severity word: `exposed` is the one that needs acting on. +fn verdict_to_list_item(v: &bootintel_detectors::boot_chain::Verdict) -> ListItem<'static> { + let (glyph, style) = match v.state.as_str() { + "exposed" => ("!", Style::default().fg(ratatui::style::Color::Yellow)), + "hardened" => ("+", Style::default().fg(ratatui::style::Color::Green)), + "confirmed" => ("*", Style::default().fg(ratatui::style::Color::Cyan)), + _ => ("?", Style::default().fg(ratatui::style::Color::DarkGray)), + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{glyph} "), style), + Span::styled(v.title.clone(), style), + Span::raw(" "), + // The evidence travels with the claim here as everywhere else, trimmed + // to what a narrow pane can show. + Span::styled( + sanitize_for_term(&v.evidence) + .chars() + .take(48) + .collect::(), + Style::default().fg(ratatui::style::Color::DarkGray), + ), + ])) +} + fn finding_to_list_item(f: &Finding) -> ListItem<'_> { let is_critical = CRITICAL_LABELS.contains(&f.label.as_str()); let (glyph, style) = if is_critical { diff --git a/crates/cli/src/tui/run.rs b/crates/cli/src/tui/run.rs index 3a9448a..babca3f 100644 --- a/crates/cli/src/tui/run.rs +++ b/crates/cli/src/tui/run.rs @@ -107,6 +107,47 @@ pub fn run_session(opts: TermOptions) -> Result<()> { .context("cloning serial port for reader thread")?; let mut serial_thread = spawn_serial_reader(read_port, tx.clone(), reader_shutdown.clone()); + // Autoboot interrupter, armed here for the same reason as in the plain + // terminal: the key has to be in the board's receiver before U-Boot looks + // at it, so there is nothing to react to and nothing to wait for. See + // crate::term::autoboot. + let hammer_on = Arc::new(AtomicBool::new(false)); + let mut interrupter = match &opts.interrupt { + None => None, + Some(cfg) => { + let mut hammer_port = port + .try_clone() + .context("cloning serial port for the autoboot hammer")?; + let key = cfg.key.clone(); + let interval = cfg.interval; + let flag = hammer_on.clone(); + let hammer_shutdown = shutdown.clone(); + thread::spawn(move || { + let mut was_armed = false; + while !hammer_shutdown.load(Ordering::Relaxed) { + let armed = flag.load(Ordering::Relaxed); + if armed { + // A burst on the arming edge: a board whose autoboot + // check runs once needs a byte already waiting. + let reps = if was_armed { 1 } else { 8 }; + for _ in 0..reps { + if hammer_port.write_all(&key).is_err() { + return; + } + } + let _ = hammer_port.flush(); + } + was_armed = armed; + thread::sleep(interval); + } + }); + Some(crate::term::autoboot::Interrupter::new( + cfg.clone(), + Instant::now(), + )) + } + }; + // Keyboard reader thread. let kb_tx = tx.clone(); let kb_shutdown = shutdown.clone(); @@ -127,6 +168,17 @@ pub fn run_session(opts: TermOptions) -> Result<()> { } }); + if let Some(it) = interrupter.as_mut() { + let actions = it.begin(); + apply_autoboot_tui( + actions, + &mut port, + &mut app, + &hammer_on, + opts.interrupt.as_ref(), + ); + } + let mut hotkey = HotkeyState::new(app.escape_prefix); let mut last_draw = Instant::now(); let exit_reason = loop { @@ -141,6 +193,23 @@ pub fn run_session(opts: TermOptions) -> Result<()> { Ok(e) => e, Err(mpsc::RecvTimeoutError::Timeout) => { app.tick_status_message(); + // A quiet line is what the interrupter is usually waiting for. + let actions = interrupter + .as_mut() + .map(|it| it.poll(Instant::now(), &[])) + .unwrap_or_default(); + if !actions.is_empty() { + apply_autoboot_tui( + actions, + &mut port, + &mut app, + &hammer_on, + opts.interrupt.as_ref(), + ); + } + if interrupter.as_ref().is_some_and(|it| it.is_finished()) { + interrupter = None; + } continue; } Err(mpsc::RecvTimeoutError::Disconnected) => { @@ -156,6 +225,26 @@ pub fn run_session(opts: TermOptions) -> Result<()> { // as one-line-per-message here too. let mapped = app.rx_newline_mode.rewrite(&bytes); app.feed_bytes(&mapped); + // The interrupter sees the same bytes. Its own notes never go + // through feed_bytes: that would put the tool's output into the + // analyzer and let it detect its own messages as device + // evidence. + let actions = interrupter + .as_mut() + .map(|it| it.poll(Instant::now(), &bytes)) + .unwrap_or_default(); + if !actions.is_empty() { + apply_autoboot_tui( + actions, + &mut port, + &mut app, + &hammer_on, + opts.interrupt.as_ref(), + ); + } + if interrupter.as_ref().is_some_and(|it| it.is_finished()) { + interrupter = None; + } } Ev::KeyPress(k) => { // Input-prompt mode swallows keystrokes: nothing goes @@ -694,3 +783,180 @@ fn change_baud(app: &mut App, port: &mut Box, input: &str) { Err(e) => app.set_status_message(format!("set_baud_rate failed: {e}")), } } + +/// Carry out what the interrupter decided, in TUI terms. +/// +/// The plain terminal writes its notes to stdout; here they go to the status +/// bar, and the verdict lands in the findings pane. What neither does is feed +/// them back through the analyzer: the tool's own messages are not evidence +/// about the device, and a detector matching one would be a finding invented +/// out of our own output. +fn apply_autoboot_tui( + actions: Vec, + port: &mut Box, + app: &mut App, + hammer_on: &AtomicBool, + cfg: Option<&crate::term::autoboot::Config>, +) { + use crate::term::autoboot::{Action, ResetLine}; + for action in actions { + match action { + Action::StartHammer => hammer_on.store(true, Ordering::Relaxed), + Action::StopHammer => hammer_on.store(false, Ordering::Relaxed), + Action::PulseReset => { + let (line, hold) = match cfg { + Some(c) => (c.reset_line, c.reset_hold), + None => (ResetLine::None, Duration::from_millis(0)), + }; + let applied = match line { + ResetLine::Dtr => { + let r = port.write_data_terminal_ready(false); + thread::sleep(hold); + let _ = port.write_data_terminal_ready(true); + app.dtr_state = true; + r + } + ResetLine::Rts => { + let r = port.write_request_to_send(false); + thread::sleep(hold); + let _ = port.write_request_to_send(true); + app.rts_state = true; + r + } + ResetLine::None => Ok(()), + }; + if let Err(e) = applied { + app.set_status_message_with_ttl( + format!("could not drive the reset line: {e}. Power-cycle by hand; the hammer is running."), + AUTOBOOT_NOTE_TTL, + ); + } + } + Action::Send(bytes) => { + if port.write_all(&bytes).and_then(|()| port.flush()).is_err() { + app.set_status_message_with_ttl( + "could not write to the port; giving up on the prompt".to_string(), + AUTOBOOT_NOTE_TTL, + ); + hammer_on.store(false, Ordering::Relaxed); + } + } + Action::Note(text) => { + app.set_status_message_with_ttl(text, AUTOBOOT_NOTE_TTL); + } + Action::GaveUp(reason) => { + hammer_on.store(false, Ordering::Relaxed); + // The miss explanation is several lines; the status bar is one. + // First line only, and the rest stays in the log the operator + // still has. + let first = reason.lines().next().unwrap_or(&reason).to_string(); + app.set_status_message_with_ttl(first, AUTOBOOT_GAVE_UP_TTL); + } + Action::Done => record_boot_chain(app), + } + } +} + +/// Put the verdict where the operator will see it, from the session the +/// analyzer has been accumulating all along. +/// +/// Separate from the action loop because this is the part with a decision in +/// it and the rest is thin I/O, so this is what the tests below drive. +fn record_boot_chain(app: &mut App) { + let assessment = bootintel_detectors::boot_chain::assess(app.analyzer.log_so_far()); + app.boot_chain = assessment.verdicts; + let exposed = app + .boot_chain + .iter() + .filter(|v| v.state == "exposed") + .count(); + app.set_status_message_with_ttl( + format!( + "environment captured: {} verdict{}, {exposed} exposed. The prompt is yours.", + app.boot_chain.len(), + if app.boot_chain.len() == 1 { "" } else { "s" } + ), + AUTOBOOT_GAVE_UP_TTL, + ); +} + +/// Long enough to read while a board is booting past it. +const AUTOBOOT_NOTE_TTL: Duration = Duration::from_secs(12); +/// Longer still: these are the two the operator has to act on. +const AUTOBOOT_GAVE_UP_TTL: Duration = Duration::from_secs(45); + +#[cfg(test)] +mod autoboot_tests { + use super::*; + use crate::analyze::state::AnalyzeState; + use crate::term::hotkey::EscapePrefix; + + fn app_with(log: &str) -> App { + let mut analyzer = AnalyzeState::new(None); + analyzer.feed(log.as_bytes()); + App::new( + "/dev/null".into(), + 115200, + analyzer, + None, + EscapePrefix::DEFAULT, + ) + } + + /// The payoff of the feature in this mode: the verdict has to reach the + /// pane, not just the log the operator would have to read themselves. + #[test] + fn the_verdict_reaches_the_findings_pane() { + let mut app = app_with( + "hab fuse not enabled\n=> printenv\nbootdelay=3\nbootcmd=bootm 0x82000000\n\ + verify=no\nEnvironment size: 40/65532 bytes\n=>\n", + ); + assert!( + app.boot_chain.is_empty(), + "nothing before the sequence runs" + ); + record_boot_chain(&mut app); + let titles: Vec<&str> = app.boot_chain.iter().map(|v| v.title.as_str()).collect(); + assert!(titles.contains(&"U-Boot shell reached"), "{titles:?}"); + assert!(titles.contains(&"Secure boot anchor"), "{titles:?}"); + let msg = app + .status_message + .as_ref() + .expect("a status message") + .0 + .clone(); + assert!(msg.contains("exposed"), "{msg}"); + assert!(msg.contains("The prompt is yours"), "{msg}"); + } + + /// A session with nothing in it must not fill the pane with claims. + #[test] + fn a_log_with_no_session_records_no_verdicts() { + let mut app = + app_with("U-Boot 2020.10\nBooting from flash...\nbootcmd=not an environment\n"); + record_boot_chain(&mut app); + assert!( + app.boot_chain.is_empty(), + "invented verdicts: {:?}", + app.boot_chain + ); + } + + /// The tool's own notes go to the status bar and never through the + /// analyzer. Feeding them back would let a detector match bootintel's own + /// output and report it as evidence about the device. + #[test] + fn notes_do_not_become_analyzer_input() { + let mut app = app_with("U-Boot 2020.10\n"); + let before = app.analyzer.captured_bytes(); + app.set_status_message_with_ttl( + "hammering space now: POWER-CYCLE THE BOARD".to_string(), + AUTOBOOT_NOTE_TTL, + ); + assert_eq!( + app.analyzer.captured_bytes(), + before, + "a note reached the analyzer" + ); + } +}