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. |
| Take the prompt on a board you have | `bootintel analyze /dev/ttyUSB0 --interrupt-autoboot` | Hammers the interrupt key, lands at `=>`, pulls the environment, prints the verdict. Power-cycle the board when it says to. |
| 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. |
Expand Down Expand Up @@ -160,6 +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 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. |
Expand Down
129 changes: 129 additions & 0 deletions crates/cli/src/cmd/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ use anyhow::{bail, Result};
use clap::Args as ClapArgs;
use serialport::{DataBits, FlowControl, Parity, StopBits};
use std::path::PathBuf;
use std::time::Duration;

use crate::analyze::state::AnalyzeState;
use crate::term::autoboot;
use crate::term::hotkey::EscapePrefix;
use crate::term::logfile::LogFileMode;
use crate::term::run::{run_session, validate_port_hint, ApiConfig, TermOptions};
Expand Down Expand Up @@ -105,6 +107,53 @@ pub struct Args {
#[arg(long)]
no_live_display: bool,

/// Interrupt autoboot on connect, take the U-Boot prompt, pull the
/// environment, and print what the boot chain permits.
///
/// 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
/// keypress exactly once, so the key has to be in the UART before it
/// looks. POWER-CYCLE THE BOARD after this starts. Runs the read-only
/// commands (`printenv`, `bdinfo`, `mtdparts`), prints the verdict, then
/// hands the terminal back to you. Entirely offline.
#[arg(long)]
interrupt_autoboot: bool,

/// Key hammered during the window: `space` (default), `esc`, `ctrl-c`,
/// `tab`, a literal string for builds with CONFIG_AUTOBOOT_KEYED (e.g.
/// `--interrupt-key stop`), or hex (`0x1b`). CR and LF are refused: the
/// bytes accumulate in U-Boot's line buffer and a newline would execute
/// whatever they spell.
#[arg(long, value_name = "SPEC")]
interrupt_key: Option<String>,

/// Milliseconds between hammer writes. Default 5. The aim is a byte
/// waiting in the receiver, not saturating the line.
#[arg(long, value_name = "MS")]
interrupt_interval: Option<u64>,

/// Seconds to keep trying before reporting the window missed. Default 45,
/// which spans a hand power-cycle and several boot-loop cycles.
#[arg(long, value_name = "SECS")]
interrupt_timeout: Option<u64>,

/// Command to run once the prompt is held. Repeatable; replaces the
/// read-only default set (`printenv`, `bdinfo`, `mtdparts`) entirely, so
/// you decide exactly what is typed at a client's board.
#[arg(long = "at-prompt", value_name = "CMD", action = clap::ArgAction::Append)]
at_prompt: Vec<String>,

/// Pulse a modem control line to reset the board, so the reset instant is
/// this tool's rather than a human's. Only works where the adapter's DTR
/// or RTS is actually wired to the board's reset, which many are not; on
/// the rest it does nothing and you should power-cycle by hand.
#[arg(long, value_name = "LINE", value_parser = ["none", "dtr", "rts"])]
reset_line: Option<String>,

/// Milliseconds to hold the reset line low. Default 250.
#[arg(long, value_name = "MS")]
reset_hold_ms: Option<u64>,

/// Arm Ctrl-A f for full server-side analysis (CVE matching +
/// exploit paths + optional AI summary). Requires BOOTINTEL_API_KEY
/// unless combined with --preview.
Expand Down Expand Up @@ -177,6 +226,7 @@ pub fn run(args: Args) -> Result<()> {
analyzer.live_display = false;
}

let interrupt = build_interrupt_config(&args)?;
let api = build_api_config(&args)?;
let escape_prefix =
EscapePrefix::parse(&args.escape).map_err(|e| anyhow::anyhow!("--escape: {e}"))?;
Expand Down Expand Up @@ -215,6 +265,7 @@ pub fn run(args: Args) -> Result<()> {
macros: super::term::build_macros(&args.macros_file, &args.macro_)?,
analyzer: Some(analyzer),
api,
interrupt,
escape_prefix,
};

Expand Down Expand Up @@ -253,6 +304,84 @@ pub fn run(args: Args) -> Result<()> {
result
}

/// Build the autoboot interrupter config, or None when the feature was not
/// asked for.
///
/// Tuning flags are refused without `--interrupt-autoboot` rather than
/// silently ignored: someone who typed `--interrupt-key stop` and got a plain
/// terminal would reasonably conclude the tool had tried and failed.
fn build_interrupt_config(args: &Args) -> Result<Option<autoboot::Config>> {
let tuning_used = args.interrupt_key.is_some()
|| args.interrupt_interval.is_some()
|| args.interrupt_timeout.is_some()
|| !args.at_prompt.is_empty()
|| args.reset_line.is_some()
|| args.reset_hold_ms.is_some();
if !args.interrupt_autoboot {
if tuning_used {
bail!(
"the --interrupt-* / --at-prompt / --reset-* flags only apply with --interrupt-autoboot.\n example: bootintel analyze /dev/ttyUSB0 --interrupt-autoboot --interrupt-key esc"
);
}
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(),
Some(spec) => {
autoboot::parse_key(spec).map_err(|e| anyhow::anyhow!("--interrupt-key: {e}"))?
}
};
let interval = match args.interrupt_interval {
None => defaults.interval,
// A zero interval is a busy loop on a shared serial port, which starves
// the reader thread and can only make the window harder to catch.
Some(0) => bail!("--interrupt-interval must be at least 1ms"),
Some(ms) => Duration::from_millis(ms),
};
let timeout = match args.interrupt_timeout {
None => defaults.timeout,
Some(0) => bail!("--interrupt-timeout must be at least 1s"),
Some(secs) => Duration::from_secs(secs),
};
let commands = if args.at_prompt.is_empty() {
defaults.commands.clone()
} else {
for cmd in &args.at_prompt {
if cmd.contains('\r') || cmd.contains('\n') {
bail!("--at-prompt {cmd:?} contains a newline; pass one command per flag");
}
if cmd.trim().is_empty() {
bail!("--at-prompt cannot be empty");
}
}
args.at_prompt.clone()
};
let reset_line = match args.reset_line.as_deref() {
None | Some("none") => autoboot::ResetLine::None,
Some("dtr") => autoboot::ResetLine::Dtr,
Some("rts") => autoboot::ResetLine::Rts,
Some(other) => bail!("--reset-line {other:?}: expected none, dtr, or rts"),
};
Ok(Some(autoboot::Config {
key,
interval,
timeout,
commands,
reset_line,
reset_hold: args
.reset_hold_ms
.map(Duration::from_millis)
.unwrap_or(defaults.reset_hold),
..defaults
}))
}

/// Build the ApiConfig from CLI flags + env. None when --api wasn't
/// requested. Errors early on illegal combos so the user finds out
/// before starting a terminal session (rather than mid-session on
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/cmd/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ pub fn run(args: Args) -> Result<()> {
// Term mode never runs the analyzer. Users who want live
// analysis should use `bootintel analyze` instead.
analyzer: None,
// `term` is the plain terminal. The interrupter reports a verdict from
// the captured session, which needs the analyzer, so it lives on
// `analyze` alone rather than half-working here.
interrupt: None,
// Nor does it wire the api. Ctrl-A f prints a hint pointing
// at `bootintel analyze --api` if the user tries anyway.
api: None,
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/cmd/verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ fn state_code(state: &str) -> &'static str {
}
}

fn write_text<W: Write>(
pub(crate) fn write_text<W: Write>(
out: &mut W,
source: &str,
session: &UbootSession,
Expand Down
90 changes: 90 additions & 0 deletions crates/cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,3 +851,93 @@ mod tests {
assert!(s.contains("**1 finding total**"), "singular: {s}");
}
}

/// Wraps a writer so bare LF becomes CRLF.
///
/// Exists so one renderer can serve both a normal stdout and a terminal in raw
/// mode. Raw mode turns off ONLCR, so a `\n` moves down without returning the
/// carriage and every line starts further right than the last. Rather than keep
/// a second copy of each renderer with `\r\n` baked in (two copies of the same
/// output drift, and the boot-chain verdict is the last place that should
/// happen), the renderer keeps writing `\n` and this fixes it up in transit.
pub(crate) struct CrlfWriter<W: std::io::Write> {
inner: W,
/// So an LF that already follows a CR is left alone rather than becoming
/// CRCRLF.
last_was_cr: bool,
}

impl<W: std::io::Write> CrlfWriter<W> {
pub(crate) fn new(inner: W) -> Self {
Self {
inner,
last_was_cr: false,
}
}
}

impl<W: std::io::Write> std::io::Write for CrlfWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let mut out = Vec::with_capacity(buf.len() + 8);
for b in buf {
if *b == b'\n' && !self.last_was_cr {
out.push(b'\r');
}
self.last_was_cr = *b == b'\r';
out.push(*b);
}
self.inner.write_all(&out)?;
// Report the caller's byte count, not ours: a short write here would
// make the caller re-send bytes we already expanded and wrote.
Ok(buf.len())
}

fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}

#[cfg(test)]
mod crlf_tests {
use super::CrlfWriter;
use std::io::Write;

fn through(input: &str) -> String {
let mut sink = Vec::new();
{
let mut w = CrlfWriter::new(&mut sink);
w.write_all(input.as_bytes()).unwrap();
}
String::from_utf8(sink).unwrap()
}

#[test]
fn bare_lf_becomes_crlf() {
assert_eq!(through("a\nb\n"), "a\r\nb\r\n");
}

#[test]
fn an_existing_crlf_is_left_alone() {
assert_eq!(through("a\r\nb"), "a\r\nb");
}

#[test]
fn the_split_across_writes_is_handled() {
// The CR and the LF can arrive in separate write calls, which is
// exactly what a formatter doing many small writes produces.
let mut sink = Vec::new();
{
let mut w = CrlfWriter::new(&mut sink);
w.write_all(b"a\r").unwrap();
w.write_all(b"\nb").unwrap();
}
assert_eq!(String::from_utf8(sink).unwrap(), "a\r\nb");
}

#[test]
fn the_caller_sees_its_own_byte_count() {
let mut sink = Vec::new();
let mut w = CrlfWriter::new(&mut sink);
assert_eq!(w.write(b"x\ny").unwrap(), 3);
}
}
Loading
Loading