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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,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 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. Works with `--tui`, where the verdicts appear at the top of the findings pane. Reports the window missed rather than exiting quietly. |
| `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. |
Expand Down
8 changes: 2 additions & 6 deletions crates/cli/src/cmd/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -325,11 +326,6 @@ fn build_interrupt_config(args: &Args) -> Result<Option<autoboot::Config>> {
}
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(),
Expand Down
8 changes: 8 additions & 0 deletions crates/cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputPrompt>,

/// 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<bootintel_detectors::boot_chain::Verdict>,
}

/// Bottom-line input modal state. Small enough to keep inline in App;
Expand Down Expand Up @@ -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(),
}
}

Expand Down
57 changes: 47 additions & 10 deletions crates/cli/src/tui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListItem> = 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<ListItem> = 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::<String>(),
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 {
Expand Down
Loading
Loading