diff --git a/README.md b/README.md index a163200..18eaa4e 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -159,6 +160,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 verdict ` | 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 ` | 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. | diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index 4d32470..9fedf0b 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -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; diff --git a/crates/cli/src/cmd/verdict.rs b/crates/cli/src/cmd/verdict.rs new file mode 100644 index 0000000..5a73cd2 --- /dev/null +++ b/crates/cli/src/cmd/verdict.rs @@ -0,0 +1,253 @@ +//! `bootintel verdict ` — 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 ``. + #[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::>(), + "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::>(), + }) +} + +/// 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( + 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(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index bb59e7a..79c5392 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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. @@ -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), diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index a7e2b6e..23d6902 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -432,12 +432,12 @@ fn write_json(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 { diff --git a/crates/cli/tests/verdict_cli.rs b/crates/cli/tests/verdict_cli.rs new file mode 100644 index 0000000..3e21d68 --- /dev/null +++ b/crates/cli/tests/verdict_cli.rs @@ -0,0 +1,169 @@ +//! `bootintel verdict` driven as a process. +//! +//! The library-level rules are pinned against the server implementation in +//! `crates/detectors/tests/boot_chain.rs`. What only the binary can show is the +//! part a CI job depends on: which exit code means what, and that a capture +//! that could not be assessed never looks like a clean one. + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +const BIN: &str = env!("CARGO_BIN_EXE_bootintel"); + +/// A session with nothing exposed: autoboot cannot be interrupted, images are +/// verified, no netboot path, and no bootcmd to rewrite. +const HARDENED: &str = + "=> printenv\nbootdelay=-1\nverify=yes\nEnvironment size: 20/65532 bytes\n=>\n"; + +/// The same board with the defaults a vendor actually ships. +const EXPOSED: &str = "=> printenv\nbootdelay=3\nbootcmd=bootm 0x82000000\nverify=no\n\ + ipaddr=10.0.0.5\nserverip=10.0.0.1\nEnvironment size: 90/65532 bytes\n=>\n"; + +/// A plain boot log: no session, but plenty of `KEY=value` noise that a +/// careless parser would turn into an environment. +const PLAIN: &str = "U-Boot 2020.10 (Sep 17 2023 - 11:38:21 +0000)\n\ + Hit any key to stop autoboot: 2\n\ + [ 0.000000] Kernel command line: console=ttyS0\n\ + CONFIG_FOO=bar\nbootcmd=this is not really an environment\n"; + +fn fixture(name: &str, body: &str) -> PathBuf { + let dir = std::env::var("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()) + .join("bootintel-verdict"); + std::fs::create_dir_all(&dir).expect("scratch dir"); + let path = dir.join(name); + std::fs::write(&path, body).expect("writing fixture"); + path +} + +fn run(args: &[&str]) -> Output { + Command::new(BIN) + .args(args) + .env("BOOTINTEL_NO_HISTORY", "1") + .env("NO_COLOR", "1") + .output() + .expect("running bootintel") +} + +fn code(out: &Output) -> i32 { + out.status.code().unwrap_or(-1) +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +#[test] +fn a_hardened_environment_passes_the_gate() { + let path = fixture("hardened.log", HARDENED); + let out = run(&["verdict", path.to_str().unwrap(), "--gate-exposed"]); + assert_eq!(code(&out), 0, "stderr: {}", stderr(&out)); + let text = stdout(&out); + assert!(text.contains("hardened"), "{text}"); + assert!(!text.contains("exposed"), "{text}"); +} + +#[test] +fn an_exposed_environment_fails_the_gate_and_names_why() { + let path = fixture("exposed.log", EXPOSED); + let out = run(&["verdict", path.to_str().unwrap(), "--gate-exposed"]); + assert_eq!(code(&out), 1, "stdout: {}", stdout(&out)); + let err = stderr(&out); + // A CI log is often the only thing anyone reads, so the failure has to + // carry the variable it was read from, not just a count. + assert!(err.contains("Autoboot delay"), "{err}"); + assert!(err.contains("bootdelay=3"), "{err}"); +} + +/// The case this whole command exists to get right. A boot log with no session +/// cannot be assessed, and "I could not answer" must not be reported as "there +/// is nothing wrong": a CI gate that goes green on it is worse than no gate. +#[test] +fn a_log_with_no_session_is_not_a_pass() { + let path = fixture("plain.log", PLAIN); + let out = run(&["verdict", path.to_str().unwrap(), "--gate-exposed"]); + assert_eq!(code(&out), 3, "stdout: {}", stdout(&out)); + let err = stderr(&out); + assert!(err.contains("no U-Boot session"), "{err}"); + // And it points at the command that does handle a plain boot log. + assert!(err.contains("bootintel scan"), "{err}"); +} + +#[test] +fn an_empty_capture_is_not_a_pass() { + let path = fixture("empty.log", " \n\n"); + let out = run(&["verdict", path.to_str().unwrap()]); + assert_eq!(code(&out), 2, "stdout: {}", stdout(&out)); + assert!(stderr(&out).contains("empty capture"), "{}", stderr(&out)); +} + +#[test] +fn json_keys_match_the_server_response() { + // A consumer should be able to move between this and `scan --api` without + // remapping, so the key names are part of the contract. + let path = fixture("json.log", EXPOSED); + let out = run(&["verdict", path.to_str().unwrap(), "--json"]); + assert_eq!(code(&out), 0, "stderr: {}", stderr(&out)); + let v: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("valid JSON"); + assert_eq!(v["uboot_shell"]["reached"], true); + assert_eq!(v["uboot_shell"]["env_total_bytes"], 65532); + assert_eq!(v["uboot_env"]["verify"], "no"); + let verdicts = v["boot_chain_verdict"].as_array().expect("an array"); + assert!(!verdicts.is_empty()); + for entry in verdicts { + for key in ["title", "state", "detail", "evidence", "severity"] { + assert!(entry[key].is_string(), "{key} missing from {entry}"); + } + // remediation is nullable, not absent: a consumer indexing it should + // get null rather than a KeyError-shaped surprise. + assert!(entry.get("remediation").is_some(), "{entry}"); + } +} + +#[test] +fn a_session_can_arrive_on_stdin() { + let mut child = Command::new(BIN) + .args(["verdict", "-", "--json"]) + .env("BOOTINTEL_NO_HISTORY", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawning bootintel"); + child + .stdin + .take() + .expect("stdin") + .write_all(EXPOSED.as_bytes()) + .expect("writing capture"); + let out = child.wait_with_output().expect("waiting"); + assert_eq!(code(&out), 0, "stderr: {}", stderr(&out)); + let v: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("valid JSON"); + assert_eq!(v["source"], "stdin"); + assert_eq!(v["uboot_env"]["bootdelay"], "3"); +} + +/// Device-controlled text must not reach a terminal unescaped: a crafted +/// environment value is an ANSI-injection vector into a consultant's session. +#[test] +fn a_crafted_environment_value_cannot_inject_escapes() { + let nasty = "=> printenv\nbootdelay=1\nbootargs=x\x1b[2J\x1b[31mowned\n\ + Environment size: 40/65532 bytes\n"; + let path = fixture("escapes.log", nasty); + let out = run(&["verdict", path.to_str().unwrap()]); + let text = stdout(&out); + assert!( + !text.contains('\x1b'), + "raw escape survived into stdout: {text:?}" + ); + assert!( + text.contains("owned"), + "the value itself should still be shown: {text}" + ); +} diff --git a/crates/detectors/src/boot_chain.rs b/crates/detectors/src/boot_chain.rs new file mode 100644 index 0000000..6f746ca --- /dev/null +++ b/crates/detectors/src/boot_chain.rs @@ -0,0 +1,421 @@ +//! U-Boot interactive session: environment, and the boot-chain verdict. +//! +//! A port of `api/analysis_engine/detectors/uboot_env.py` and +//! `_build_boot_chain_verdict` in `engine.py`. It runs LOCALLY on purpose. +//! +//! Applicability lookup stays server-side because the curated CVE ruleset is +//! the asset that compounds. This is the opposite case: "bootdelay above zero +//! means the prompt is reachable" is domain knowledge any practitioner +//! already has, so shipping it costs nothing, and a U-Boot environment is the +//! single most sensitive thing in a capture (ipaddr, serverip, ethaddr, TFTP +//! hosts, a client's internal addressing). Requiring an upload to learn what +//! the environment permits would put this out of reach of exactly the people +//! it is for. +//! +//! Two implementations of the same rules can drift, which is the problem the +//! detector-parity work just fixed. `tests/boot_chain_parity.rs` pins this one +//! against a shared expectation file that the Python side asserts against too. +//! +//! NOT a detector. Adding a label would break the 14-detector parity with the +//! browser library, so this is a separate entry point. + +use std::collections::BTreeMap; + +/// One decision about the boot chain, with the variable it was read from. +/// +/// A consultant has to defend the answer in a client report rather than quote +/// a tool, so `evidence` is not optional. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Verdict { + pub title: String, + /// `exposed`, `hardened`, `confirmed`, or `unknown`. Never a bare boolean: + /// U-Boot only prints what is set, so absence is unknown, not good news. + pub state: String, + pub detail: String, + pub evidence: String, + pub severity: String, + pub remediation: Option, +} + +/// What a capture proves about an interactive U-Boot session. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct UbootSession { + pub reached: bool, + pub evidence: String, + /// BTreeMap so ordering is deterministic, which the parity test needs. + pub env: BTreeMap, + pub env_used_bytes: Option, + pub env_total_bytes: Option, +} + +use std::sync::LazyLock; + +use regex::Regex; + +// The three patterns below are character-for-character the ones in +// `api/analysis_engine/detectors/uboot_env.py`. Keeping them literally +// identical is cheaper than reasoning about whether two hand-written +// tokenisers agree. +// +// U-Boot's prompt. `=>` is the default; vendors commonly rebrand it. +static RE_PROMPT: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^\s*(?:=>|u-?boot\s*[>#]|[\w.-]+\s*=>)\s*(\S.*)?$").unwrap()); + +// The definitive marker that a printenv dump just happened: U-Boot prints +// this as the last line of `printenv`. +static RE_ENV_SIZE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^\s*Environment size:\s*(\d+)\s*/\s*(\d+)\s*bytes").unwrap()); + +// An environment entry. U-Boot allows almost anything in a value, including +// spaces and semicolons, so the key is what must be constrained. +static RE_ENV_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap()); + +/// Truncate to `max` CHARACTERS, mirroring Python's `s[:max]`. +/// +/// `String::truncate` counts bytes and panics mid-codepoint, and a capture is +/// arbitrary bytes off a serial line, so this is not hypothetical. +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +/// Parse a capture for an interactive session. Conservative by construction: +/// `KEY=value` lines are everywhere in a boot log (kernel command line, vendor +/// config dumps), so variables are only committed once the capture proves it +/// holds a real dump, and only inside a block delimited by U-Boot's own +/// markers. +/// +/// `bdinfo` and `mtdparts` parsing is deliberately not ported: nothing in the +/// verdict reads them, and an unused second implementation is pure drift risk. +pub fn parse_session(log: &str) -> UbootSession { + let mut s = UbootSession::default(); + let mut in_env_dump = false; + // Buffered against the chance an `Environment size:` line is coming. + // Bounded, and discarded the moment the run of KEY=value lines breaks, so + // ordinary log noise cannot accumulate into a fake environment: a real + // dump is one contiguous block. + let mut candidates: Vec<(String, String)> = Vec::new(); + let mut continuations = 0usize; + + fn note(s: &mut UbootSession, line: &str) { + if !s.reached { + s.reached = true; + s.evidence = clip(line.trim(), 200); + } + } + + for raw in log.lines() { + let line = raw.trim_end_matches(['\r', '\n']); + + // The prompt itself. Also the boundary that closes an env dump: + // anything after a fresh prompt is a new command's output. + if let Some(caps) = RE_PROMPT.captures(line) { + note(&mut s, line); + let typed = caps + .get(1) + .map(|m| m.as_str().trim().to_ascii_lowercase()) + .unwrap_or_default(); + in_env_dump = ["printenv", "print", "env print"] + .iter() + .any(|p| typed.starts_with(p)); + continue; + } + + if let Some(caps) = RE_ENV_SIZE.captures(line) { + // The retroactive case, and the common one. U-Boot prints this + // line LAST, so a capture that starts mid-session, or whose prompt + // line was eaten, still proves the block just above was an + // environment. + note(&mut s, line); + s.env_used_bytes = caps[1].parse().ok(); + s.env_total_bytes = caps[2].parse().ok(); + in_env_dump = false; + for (k, val) in candidates.drain(..) { + s.env.entry(k).or_insert(val); + } + continuations = 0; + continue; + } + + if let Some(caps) = RE_ENV_LINE.captures(line) { + let key = caps[1].to_string(); + let value = clip(caps[2].trim(), 1024); + if in_env_dump { + // First write wins: U-Boot prints each variable once, and a + // later identical-looking line is more likely log noise than + // a redefine. + s.env.entry(key).or_insert(value); + } else { + continuations = 0; + if candidates.len() < 256 { + candidates.push((key, value)); + } + } + continue; + } + + if line.trim().is_empty() { + continue; + } + + // A long U-Boot value wraps in a terminal capture, so the continuation + // has no `KEY=`. Treating it as a boundary discarded whole + // environments. Append instead, bounded: a couple of wrapped lines is + // normal, a long run means the dump ended and this is ordinary output. + if !candidates.is_empty() && continuations < 3 { + continuations += 1; + let last = candidates.last_mut().expect("checked non-empty"); + last.1 = clip(&(last.1.clone() + line.trim()), 1024); + continue; + } + candidates.clear(); + continuations = 0; + } + s +} + +fn v( + out: &mut Vec, + title: &str, + state: &str, + detail: &str, + evidence: &str, + severity: &str, + remediation: Option<&str>, +) { + out.push(Verdict { + title: title.to_string(), + state: state.to_string(), + detail: detail.to_string(), + evidence: evidence.to_string(), + severity: severity.to_string(), + remediation: remediation.map(str::to_string), + }); +} + +/// Turn a pulled environment into decisions, not observations. +/// +/// The boot log can say autoboot looks interruptible. The environment says +/// what happens when you interrupt it, and whether you can change what boots. +pub fn verdict(s: &UbootSession) -> Vec { + let mut out = Vec::new(); + if !s.reached { + return out; + } + + v( + &mut out, + "U-Boot shell reached", + "confirmed", + "An operator interrupted autoboot and got a command prompt. Everything below \ + was read from the device, not inferred from its boot output.", + if s.evidence.is_empty() { + "U-Boot prompt" + } else { + &s.evidence + }, + "high", + Some( + "Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password.", + ), + ); + + if s.env.is_empty() { + v( + &mut out, + "Environment not captured", + "unknown", + "The shell was reached but no printenv output was captured, so the boot \ + chain below could not be assessed. Run `printenv` at the prompt.", + &s.evidence, + "info", + None, + ); + return out; + } + + match s.env.get("bootdelay") { + Some(raw) => { + let ev = format!("bootdelay={raw}"); + match raw.trim().parse::() { + Err(_) => v( + &mut out, + "Autoboot delay", + "unknown", + &format!("bootdelay is not a number: {raw:?}."), + &ev, + "info", + None, + ), + Ok(d) if d < 0 => v( + &mut out, + "Autoboot delay", + "hardened", + "bootdelay is negative, so autoboot cannot be interrupted by a keypress. \ + The prompt was still reached, so something else allowed it.", + &ev, + "medium", + None, + ), + Ok(0) => v( + &mut out, + "Autoboot delay", + "hardened", + "bootdelay is 0: no interrupt window. The prompt was still reached, so \ + something else allowed it.", + &ev, + "medium", + None, + ), + Ok(d) => v( + &mut out, + "Autoboot delay", + "exposed", + &format!( + "bootdelay is {d}s, so anyone with console access gets {d}s to \ + take the prompt on every boot." + ), + &ev, + "high", + Some("Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED)."), + ), + } + } + None => v( + &mut out, + "Autoboot delay", + "unknown", + "bootdelay is not set in the environment, so the built-in default applies \ + and cannot be read from here.", + "bootdelay absent", + "info", + None, + ), + } + + if let Some(cmd) = s.env.get("bootcmd") { + let short: String = cmd.chars().take(160).collect(); + v( + &mut out, + "Boot command", + "exposed", + "bootcmd is readable and, with the prompt reachable, settable. Whoever holds \ + the console decides what the device boots.", + &format!("bootcmd={short}"), + "high", + Some( + "Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a \ + password at the prompt.", + ), + ); + let verify_set = s.env.contains_key("verify"); + let boots_image = ["bootm", "bootz", "booti"].iter().any(|t| cmd.contains(t)); + if boots_image && !verify_set && !cmd.contains("verify") { + v( + &mut out, + "Image verification", + "unknown", + "bootcmd boots an image without a visible verification step. That is not \ + proof verification is absent: a FIT signature check can be implicit in \ + the image. Confirm with the boot output of an actual `bootm`.", + &format!("bootcmd={short}"), + "info", + None, + ); + } + } + + if let Some(val) = s.env.get("verify") { + if matches!( + val.trim().to_ascii_lowercase().as_str(), + "n" | "no" | "0" | "false" + ) { + v( + &mut out, + "Image verification", + "exposed", + "verify is disabled, so U-Boot will not check image checksums before booting.", + &format!("verify={val}"), + "high", + Some("Set verify=yes, and prefer signed FIT images over checksums."), + ); + } + } + + if s.env.contains_key("ipaddr") && s.env.contains_key("serverip") { + let parts: Vec = ["ethaddr", "gatewayip", "ipaddr", "netmask", "serverip"] + .iter() + .filter_map(|k| s.env.get(*k).map(|val| format!("{k}={val}"))) + .collect(); + v( + &mut out, + "Network boot path", + "exposed", + "ipaddr and serverip are both set, so the bootloader is pre-configured to \ + fetch over the network. That is a route in as much as a recovery route out.", + &parts.join(", "), + "medium", + Some("Clear ipaddr/serverip on production images unless netboot is required."), + ); + } + + if let Some(args) = s.env.get("bootargs") { + let short: String = args.chars().take(160).collect(); + let ev = format!("bootargs={short}"); + let debug = [ + ("init=/bin/sh", "a root shell as init"), + ("init=/bin/bash", "a root shell as init"), + ("single", "single-user mode"), + ("rdinit=/bin/sh", "a root shell as rdinit"), + ] + .iter() + .find(|(tok, _)| args.contains(tok)) + .map(|(_, what)| *what); + match debug { + Some(what) => v( + &mut out, + "Boot arguments", + "exposed", + &format!("bootargs already requests {what}."), + &ev, + "high", + Some("Remove debug boot arguments from production images."), + ), + None => v( + &mut out, + "Boot arguments", + "confirmed", + "bootargs is readable and settable from the prompt, which is how a root \ + shell is usually obtained on a board like this.", + &ev, + "medium", + Some("Lock the environment so bootargs cannot be rewritten at the console."), + ), + } + } + + if let (Some(used), Some(total)) = (s.env_used_bytes, s.env_total_bytes) { + if total > 0 { + v( + &mut out, + "Environment storage", + "confirmed", + &format!( + "The environment occupies {used} of {total} bytes of writable storage, so \ + `saveenv` can persist a change across reboots." + ), + &format!("Environment size: {used}/{total} bytes"), + "medium", + Some("Build with a read-only or signed environment for production."), + ); + } + } + out +} + +/// Convenience: parse and decide in one call. +pub fn assess(log: &str) -> (UbootSession, Vec) { + let s = parse_session(log); + let verdicts = verdict(&s); + (s, verdicts) +} diff --git a/crates/detectors/src/lib.rs b/crates/detectors/src/lib.rs index 42312c6..5d0d604 100644 --- a/crates/detectors/src/lib.rs +++ b/crates/detectors/src/lib.rs @@ -17,6 +17,7 @@ use regex::Regex; use std::sync::LazyLock; +pub mod boot_chain; /// A single detector's output. /// diff --git a/crates/detectors/tests/boot_chain.rs b/crates/detectors/tests/boot_chain.rs new file mode 100644 index 0000000..fb58a47 --- /dev/null +++ b/crates/detectors/tests/boot_chain.rs @@ -0,0 +1,247 @@ +//! The Rust half of the cross-implementation boot-chain check. +//! +//! The same verdict logic ships twice: here, and in Python in the bootintel.com +//! engine. The reason it ships twice is that a consultancy under an NDA cannot +//! upload a capture and still needs the answer, so `bootintel` decides +//! offline. The risk is that two implementations of one ruleset drift, which +//! would be worse than a bug: the CLI and the dashboard would tell one +//! operator two different things about one board. +//! +//! Neither side is the reference. `tests/fixtures/boot_chain/expect.txt` is, +//! and the engine repo holds a byte-identical copy that its own test +//! (`api/tests/test_boot_chain_parity.py`) asserts against. The file is +//! generated by `scripts/gen-boot-chain-expect.sh` in bootintel.com, which +//! writes both copies in one go. Do not hand-edit it to make this pass: the +//! only thing that makes green meaningful here is that the other repo is +//! reading the same bytes. + +use std::fs; +use std::path::PathBuf; + +use bootintel_detectors::boot_chain; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/boot_chain") +} + +/// FNV-1a, matching the generator. Not a security property: it detects a +/// fixture log edited without regenerating the expectation. Chosen because it +/// is eight lines in both languages and adds no dependency to a crate that is +/// deliberately regex-only. +fn fnv1a64(data: &[u8]) -> String { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for byte in data { + h = (h ^ u64::from(*byte)).wrapping_mul(0x100_0000_01b3); + } + format!("{h:016x}") +} + +fn field(out: &mut Vec, indent: &str, key: &str, value: &str) { + if value.is_empty() { + out.push(format!("{indent}{key}")); + } else { + out.push(format!("{indent}{key} {value}")); + } +} + +/// Byte-for-byte the format the Python renderer emits. A plain line format +/// rather than JSON so it diffs cleanly in review, and so this crate needs no +/// serde: it stays serde-free on purpose to keep the CLI binary small. +fn render(name: &str, log: &str) -> Vec { + let (session, verdicts) = boot_chain::assess(log); + let mut out = vec![format!( + "## fixture {name} fnv1a64={}", + fnv1a64(log.as_bytes()) + )]; + field( + &mut out, + " ", + "reached", + if session.reached { "true" } else { "false" }, + ); + field(&mut out, " ", "evidence", &session.evidence); + let bytes = match (session.env_used_bytes, session.env_total_bytes) { + (Some(used), Some(total)) if total > 0 => format!("{used}/{total}"), + _ => String::new(), + }; + field(&mut out, " ", "env_bytes", &bytes); + for (key, value) in &session.env { + field(&mut out, " ", "env", &format!("{key}={value}")); + } + for verdict in &verdicts { + out.push(" verdict".to_string()); + field(&mut out, " ", "title", &verdict.title); + field(&mut out, " ", "state", &verdict.state); + field(&mut out, " ", "severity", &verdict.severity); + field(&mut out, " ", "evidence", &verdict.evidence); + field(&mut out, " ", "detail", &verdict.detail); + field( + &mut out, + " ", + "remediation", + verdict.remediation.as_deref().unwrap_or(""), + ); + } + out +} + +fn expectation() -> String { + fs::read_to_string(fixtures().join("expect.txt")).expect("expect.txt is committed") +} + +fn declared_fixtures(expected: &str) -> Vec { + expected + .lines() + .filter_map(|l| l.strip_prefix("## fixture ")) + .map(|rest| { + rest.split_whitespace() + .next() + .unwrap_or_default() + .to_string() + }) + .collect() +} + +#[test] +fn the_cli_reproduces_the_shared_expectation() { + let expected = expectation(); + let mut lines: Vec = expected + .lines() + .filter(|l| l.starts_with('#') && !l.starts_with("##")) + .map(str::to_string) + .collect(); + for name in declared_fixtures(&expected) { + let log = fs::read_to_string(fixtures().join(&name)).expect("fixture is committed"); + lines.extend(render(&name, &log)); + } + let actual = lines.join("\n") + "\n"; + if actual != expected { + // Show the first divergence rather than two 200-line blobs. + let (want, got): (Vec<&str>, Vec<&str>) = + (expected.lines().collect(), actual.lines().collect()); + let at = want + .iter() + .zip(got.iter()) + .position(|(a, b)| a != b) + .unwrap_or(want.len().min(got.len())); + panic!( + "Rust output no longer matches the shared expectation at line {}:\n \ + expect.txt: {:?}\n this crate: {:?}\n\nIf the rule changed, regenerate BOTH \ + copies with scripts/gen-boot-chain-expect.sh in bootintel.com. If it did not, \ + the two implementations have drifted.", + at + 1, + want.get(at).unwrap_or(&""), + got.get(at).unwrap_or(&""), + ); + } +} + +#[test] +fn every_fixture_is_pinned_to_its_recorded_hash() { + let expected = expectation(); + let pins: Vec<&str> = expected + .lines() + .filter_map(|l| l.strip_prefix("## fixture ")) + .collect(); + assert!(!pins.is_empty(), "expectation file declares no fixtures"); + for line in pins { + let mut parts = line.split_whitespace(); + let name = parts.next().expect("fixture name"); + let pin = parts.next().expect("fixture pin"); + let bytes = fs::read(fixtures().join(name)).expect("fixture is committed"); + assert_eq!( + pin, + format!("fnv1a64={}", fnv1a64(&bytes)), + "{name} changed since expect.txt was generated" + ); + } +} + +/// The guard that matters most, and the mirror of the CVE over-matching bug: +/// `KEY=value` lines are everywhere in a boot log, so a careless parser would +/// invent an environment out of a kernel command line and report confident +/// verdicts about a device nobody ever talked to. +#[test] +fn a_plain_boot_log_yields_no_session_and_no_verdict() { + let plain = "U-Boot 2020.10 (Sep 17 2023 - 11:38:21 +0000)\n\ + Hit any key to stop autoboot: 2\n\ + Booting from flash...\n\ + [ 0.000000] Kernel command line: console=ttyS0 root=/dev/mtdblock2\n\ + CONFIG_FOO=bar\n\ + PATH=/usr/bin:/bin\n\ + bootcmd=this is not really an environment\n\ + [ 1.000000] procd: - init -\n"; + let (session, verdicts) = boot_chain::assess(plain); + assert!(!session.reached, "claimed a shell on a log with no session"); + assert!( + session.env.is_empty(), + "invented an environment: {:?}", + session.env + ); + assert!(verdicts.is_empty(), "produced verdicts with no session"); +} + +#[test] +fn an_unterminated_run_of_key_values_is_discarded() { + // Same shape as a real dump but with no `Environment size:` line, so + // nothing proves it was one. Absence of proof is not a verdict. + let log = "bootdelay=3\nbootcmd=bootm 0x82000000\nverify=no\n"; + let (session, verdicts) = boot_chain::assess(log); + assert!(session.env.is_empty()); + assert!(verdicts.is_empty()); +} + +/// Absence is reported as unknown, never as good news: U-Boot prints only what +/// is set, so a missing `bootdelay` means the compiled-in default applies and +/// cannot be read from a capture. +#[test] +fn a_missing_variable_is_unknown_rather_than_hardened() { + let log = "=> printenv\nbootcmd=run sfboot\nEnvironment size: 20/65532 bytes\n"; + let (_, verdicts) = boot_chain::assess(log); + let delay = verdicts + .iter() + .find(|v| v.title == "Autoboot delay") + .expect("an absent bootdelay still gets a verdict"); + assert_eq!(delay.state, "unknown"); + assert_eq!(delay.evidence, "bootdelay absent"); + assert!(verdicts.iter().all(|v| v.state != "hardened")); +} + +#[test] +fn a_wrapped_value_is_rejoined_rather_than_ending_the_dump() { + let log = fs::read_to_string(fixtures().join("wrapped-env.log")).expect("fixture"); + let (session, _) = boot_chain::assess(&log); + let upfw = session + .env + .get("upfw") + .expect("the wrapped variable survived"); + assert!( + upfw.ends_with("${filesize};"), + "continuation line was dropped: {upfw}" + ); + // And the variable AFTER the wrap is still there, which is what a + // boundary-on-continuation bug would have eaten. + assert!(session.env.contains_key("upbootldr")); +} + +#[test] +fn every_verdict_names_the_variable_it_read() { + // A consultant has to defend the answer in a client report rather than + // quote a tool, so an unevidenced verdict is a broken one. + for name in declared_fixtures(&expectation()) { + let log = fs::read_to_string(fixtures().join(&name)).expect("fixture"); + for verdict in boot_chain::verdict(&boot_chain::parse_session(&log)) { + assert!( + !verdict.evidence.trim().is_empty(), + "{name}: {:?} has no evidence", + verdict.title + ); + assert!( + ["confirmed", "exposed", "hardened", "unknown"].contains(&verdict.state.as_str()), + "{name}: {:?} has state {:?}", + verdict.title, + verdict.state + ); + } + } +} diff --git a/crates/detectors/tests/fixtures/boot_chain/expect.txt b/crates/detectors/tests/fixtures/boot_chain/expect.txt new file mode 100644 index 0000000..abc3bd7 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/expect.txt @@ -0,0 +1,216 @@ +# Boot-chain verdict expectation. This file is BYTE-IDENTICAL in two repos: +# bootintel.com api/tests/fixtures/boot_chain/expect.txt +# BootIntel/cli crates/detectors/tests/fixtures/boot_chain/expect.txt +# Each repo asserts its own implementation reproduces it, so the Python engine +# and the Rust CLI cannot disagree about a device without a test going red. +# Regenerate BOTH copies with: python3 scripts/gen-boot-chain-expect.py +# Do not hand-edit. fnv1a64 pins the fixture log the block was produced from. +## fixture session.log fnv1a64=0dc3e8116533707d + reached true + evidence => printenv + env_bytes 412/65532 + env baudrate=115200 + env bootargs=console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs + env bootcmd=bootm 0x9f020000 + env bootdelay=2 + env ethaddr=00:11:22:33:44:55 + env ipaddr=192.168.1.1 + env serverip=192.168.1.100 + env verify=no + verdict + title U-Boot shell reached + state confirmed + severity high + evidence => printenv + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Autoboot delay + state exposed + severity high + evidence bootdelay=2 + detail bootdelay is 2s, so anyone with console access gets 2s to take the prompt on every boot. + remediation Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED). + verdict + title Boot command + state exposed + severity high + evidence bootcmd=bootm 0x9f020000 + detail bootcmd is readable and, with the prompt reachable, settable. Whoever holds the console decides what the device boots. + remediation Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a password at the prompt. + verdict + title Image verification + state exposed + severity high + evidence verify=no + detail verify is disabled, so U-Boot will not check image checksums before booting. + remediation Set verify=yes, and prefer signed FIT images over checksums. + verdict + title Network boot path + state exposed + severity medium + evidence ethaddr=00:11:22:33:44:55, ipaddr=192.168.1.1, serverip=192.168.1.100 + detail ipaddr and serverip are both set, so the bootloader is pre-configured to fetch over the network. That is a route in as much as a recovery route out. + remediation Clear ipaddr/serverip on production images unless netboot is required. + verdict + title Boot arguments + state confirmed + severity medium + evidence bootargs=console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs + detail bootargs is readable and settable from the prompt, which is how a root shell is usually obtained on a board like this. + remediation Lock the environment so bootargs cannot be rewritten at the console. + verdict + title Environment storage + state confirmed + severity medium + evidence Environment size: 412/65532 bytes + detail The environment occupies 412 of 65532 bytes of writable storage, so `saveenv` can persist a change across reboots. + remediation Build with a read-only or signed environment for production. +## fixture wrapped-env.log fnv1a64=ef1ee16cc0231e53 + reached true + evidence Environment size: 843/65532 bytes + env_bytes 843/65532 + env addmisc=setenv bootargs 'console=ttyS0,115200 mem=32M root=/dev/sda1 rootdelay=10' + env addmtd=setenv bootargs 'console=ttyS0,115200 mem=32M root=31:3' + env baudrate=115200 + env bootargs=console=ttyS0,115200 mem=32M root=/dev/sda1 rootdelay=10 + env bootcmd=run addmisc; bootm 0xBD040000 + env bootdelay=6 + env bootfile=/tftpboot/vmlinux + env bootmtd=run addmtd; bootm 0xBD040000 + env ethaddr=00:C0:01:00:00:01 + env ipaddr=2.2.2.1 + env load=tftp 80500000 ${u-boot} + env serverip=2.2.2.4 + env stderr=serial + env stdin=serial + env stdout=serial + env upbootldr=sf probe 0xbd000000; mw 0x81000000 0 0x20000; tftpboot 0x81000000 2.2.2.4:boot.owrt.bin; sf erase 0xbd000000 ${filesize}; sf write 0x81000000 0xbd000000 ${filesize}; + env upfw=sf probe 0xbd000000; mw 0x81000000 0 0x100000;tftpboot 0x81000000 2.2.2.4:z.img;sf erase 0xBD040000 ${filesize};sf write 0x81000000 0xBD040000 ${filesize}; + verdict + title U-Boot shell reached + state confirmed + severity high + evidence Environment size: 843/65532 bytes + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Autoboot delay + state exposed + severity high + evidence bootdelay=6 + detail bootdelay is 6s, so anyone with console access gets 6s to take the prompt on every boot. + remediation Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED). + verdict + title Boot command + state exposed + severity high + evidence bootcmd=run addmisc; bootm 0xBD040000 + detail bootcmd is readable and, with the prompt reachable, settable. Whoever holds the console decides what the device boots. + remediation Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a password at the prompt. + verdict + title Image verification + state unknown + severity info + evidence bootcmd=run addmisc; bootm 0xBD040000 + detail bootcmd boots an image without a visible verification step. That is not proof verification is absent: a FIT signature check can be implicit in the image. Confirm with the boot output of an actual `bootm`. + remediation + verdict + title Network boot path + state exposed + severity medium + evidence ethaddr=00:C0:01:00:00:01, ipaddr=2.2.2.1, serverip=2.2.2.4 + detail ipaddr and serverip are both set, so the bootloader is pre-configured to fetch over the network. That is a route in as much as a recovery route out. + remediation Clear ipaddr/serverip on production images unless netboot is required. + verdict + title Boot arguments + state confirmed + severity medium + evidence bootargs=console=ttyS0,115200 mem=32M root=/dev/sda1 rootdelay=10 + detail bootargs is readable and settable from the prompt, which is how a root shell is usually obtained on a board like this. + remediation Lock the environment so bootargs cannot be rewritten at the console. + verdict + title Environment storage + state confirmed + severity medium + evidence Environment size: 843/65532 bytes + detail The environment occupies 843 of 65532 bytes of writable storage, so `saveenv` can persist a change across reboots. + remediation Build with a read-only or signed environment for production. +## fixture netboot-env.log fnv1a64=d29e04c430dc7db4 + reached true + evidence Environment size: 1524/65532 bytes + env_bytes 1524/65532 + env arm_freq=0x00112032 + env baudrate=115200 + env bootargs=console=ttySGK0,115200 noinitrd mem=38M root=/dev/mtdblock3 rootfstype=squashfs mtdparts=gk7101_flash:256K(boot),64K(bootenv),2560K(kernel),7168K(rootfs),1024K(rom),5312K(APP) phytype=0 + env bootcmd=run sfboot + env bootdelay=1 + env bootfile=zImage + env bsbsize=2M + env consoledev=ttySGK0 + env ethact=gk7101 + env ethaddr=3C:97:0E:22:E1:14 + env fileaddr=C1000000 + env filesize=3AC000 + env gatewayip=192.168.1.1 + env hostname="gk7101" + env ipaddr=192.168.1.229 + env loadaddr=0xC1000000 + env mem=35M + env mtdparts=gk7101_flash:256K(boot),64K(bootenv),2M(kernel),7M(rootfs),1M(rom),5824K(APP) + env netdev=eth0 + env netmask=255.255.255.0 + env nfsserver=11.1.4.19 + env phytype=0 + env rootfstype=mtdparts=gk7101_flash:256K(boot),64K(bootenv),2560K(kernel),7168K(rootfs),1024K(rom),5312K(APP) rootfstype=squashfs root=/dev/mtdblock3 + env rootpath=/home/star/nfs/gk/rootfs_uClibc_v112/ + env serverip=192.168.1.225 + env sfboot=setenv bootargs console=${consoledev},${baudrate} noinitrd mem=${mem} rw ${rootfstype} init=linuxrc ip=${ipaddr}:${serverip}:${gatewayip}:${netmask}:${hostname}:${netdev} mac=${ethaddr} phytype=${phytype};sf probe 0 0;sf read ${loadaddr} ${sfkernel} ${filesize}; bootm + env sfkernel=0x50000 + env soctype=1 + env stderr=serial + env stdin=serial + env stdout=serial + env tftpboot=setenv bootargs root=/dev/nfs nfsroot=${nfsserver}:${rootpath},proto=tcp,nfsvers=3,nolock ip=${ipaddr}:${serverip}:${gatewayip}:${netmask}:${hostname}:${netdev} mac=${ethaddr} phytype=${phytype} console=${consoledev},${baudrate} mem=${mem};tftpboot ${bootfile};bootm + verdict + title U-Boot shell reached + state confirmed + severity high + evidence Environment size: 1524/65532 bytes + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Autoboot delay + state exposed + severity high + evidence bootdelay=1 + detail bootdelay is 1s, so anyone with console access gets 1s to take the prompt on every boot. + remediation Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED). + verdict + title Boot command + state exposed + severity high + evidence bootcmd=run sfboot + detail bootcmd is readable and, with the prompt reachable, settable. Whoever holds the console decides what the device boots. + remediation Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a password at the prompt. + verdict + title Network boot path + state exposed + severity medium + evidence ethaddr=3C:97:0E:22:E1:14, gatewayip=192.168.1.1, ipaddr=192.168.1.229, netmask=255.255.255.0, serverip=192.168.1.225 + detail ipaddr and serverip are both set, so the bootloader is pre-configured to fetch over the network. That is a route in as much as a recovery route out. + remediation Clear ipaddr/serverip on production images unless netboot is required. + verdict + title Boot arguments + state confirmed + severity medium + evidence bootargs=console=ttySGK0,115200 noinitrd mem=38M root=/dev/mtdblock3 rootfstype=squashfs mtdparts=gk7101_flash:256K(boot),64K(bootenv),2560K(kernel),7168K(rootfs),1024K( + detail bootargs is readable and settable from the prompt, which is how a root shell is usually obtained on a board like this. + remediation Lock the environment so bootargs cannot be rewritten at the console. + verdict + title Environment storage + state confirmed + severity medium + evidence Environment size: 1524/65532 bytes + detail The environment occupies 1524 of 65532 bytes of writable storage, so `saveenv` can persist a change across reboots. + remediation Build with a read-only or signed environment for production. diff --git a/crates/detectors/tests/fixtures/boot_chain/netboot-env.log b/crates/detectors/tests/fixtures/boot_chain/netboot-env.log new file mode 100644 index 0000000..f4a9734 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/netboot-env.log @@ -0,0 +1,54 @@ + +U-Boot 2012.10 (Sep 29 2006 - 00:20:41) for GK7102 rb jxh42 v1.00 (GOKE) + +HAL: 20151223 +DRAM: 64 MiB +Flash: 16 MiB +16 MiB +NAND: SPINAND MID = 0xff, DID = 0xffff, Data = 0x1ffffff !spinand_board_init[1581]: No support this SPI nand! +SF: Detected GD25Q128C with page size 256 B, sector size 64 KiB, total size 16 MiB +In: serial +Out: serial +Err: serial +Net: arm_freq(600MHz)..............0x112032 +use int MII.............. +gk7101 +Hit any key to stop autoboot: 1 0 +Gwell7102 # printenv +[PROCESS_SEPARATORS] printenv +arm_freq=0x00112032 +baudrate=115200 +bootargs=console=ttySGK0,115200 noinitrd mem=38M root=/dev/mtdblock3 rootfstype=squashfs mtdparts=gk7101_flash:256K(boot),64K(bootenv),2560K(kernel),7168K(rootfs),1024K(rom),5312K(APP) phytype=0 +bootcmd=run sfboot +bootdelay=1 +bootfile=zImage +bsbsize=2M +consoledev=ttySGK0 +ethact=gk7101 +ethaddr=3C:97:0E:22:E1:14 +fileaddr=C1000000 +filesize=3AC000 +gatewayip=192.168.1.1 +hostname="gk7101" +ipaddr=192.168.1.229 +loadaddr=0xC1000000 +mem=35M +mtdparts=gk7101_flash:256K(boot),64K(bootenv),2M(kernel),7M(rootfs),1M(rom),5824K(APP) +netdev=eth0 +netmask=255.255.255.0 +nfsserver=11.1.4.19 +phytype=0 +rootfstype=mtdparts=gk7101_flash:256K(boot),64K(bootenv),2560K(kernel),7168K(rootfs),1024K(rom),5312K(APP) rootfstype=squashfs root=/dev/mtdblock3 +rootpath=/home/star/nfs/gk/rootfs_uClibc_v112/ +serverip=192.168.1.225 +sfboot=setenv bootargs console=${consoledev},${baudrate} noinitrd mem=${mem} rw ${rootfstype} init=linuxrc ip=${ipaddr}:${serverip}:${gatewayip}:${netmask}:${hostname}:${netdev} mac=${ethaddr} phytype=${phytype};sf probe 0 0;sf read ${loadaddr} ${sfkernel} ${filesize}; bootm +sfkernel=0x50000 +soctype=1 +stderr=serial +stdin=serial +stdout=serial +tftpboot=setenv bootargs root=/dev/nfs nfsroot=${nfsserver}:${rootpath},proto=tcp,nfsvers=3,nolock ip=${ipaddr}:${serverip}:${gatewayip}:${netmask}:${hostname}:${netdev} mac=${ethaddr} phytype=${phytype} console=${consoledev},${baudrate} mem=${mem};tftpboot ${bootfile};bootm + +Environment size: 1524/65532 bytes +Gwell7102 # help +[PROCESS_SEPARATORS] help diff --git a/crates/detectors/tests/fixtures/boot_chain/session.log b/crates/detectors/tests/fixtures/boot_chain/session.log new file mode 100644 index 0000000..cb25c6e --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/session.log @@ -0,0 +1,15 @@ +U-Boot 2020.10 (Sep 17 2023 - 11:38:21 +0000) + +DRAM: 128 MiB +Hit any key to stop autoboot: 2 +=> printenv +baudrate=115200 +bootargs=console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs +bootcmd=bootm 0x9f020000 +bootdelay=2 +ethaddr=00:11:22:33:44:55 +ipaddr=192.168.1.1 +serverip=192.168.1.100 +verify=no +Environment size: 412/65532 bytes +=> diff --git a/crates/detectors/tests/fixtures/boot_chain/wrapped-env.log b/crates/detectors/tests/fixtures/boot_chain/wrapped-env.log new file mode 100644 index 0000000..f9fd09d --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/wrapped-env.log @@ -0,0 +1,33 @@ +RTL8672 # +RTL8672 # +RTL8672 # +RTL8672 # à€þü<ÿü?ÿÀ€ÿþÿàüøþøÿÿ?ÿþÿþÿü +RTL8672 # +RTL8672 # +RTL8672 # +RTL8672 # +RTL8672 # +RTL8672 # printenv +addmisc=setenv bootargs 'console=ttyS0,115200 mem=32M root=/dev/sda1 rootdelay=10' +addmtd=setenv bootargs 'console=ttyS0,115200 mem=32M root=31:3' +baudrate=115200 +bootargs=console=ttyS0,115200 mem=32M root=/dev/sda1 rootdelay=10 +bootcmd=run addmisc; bootm 0xBD040000 +bootdelay=6 +bootfile=/tftpboot/vmlinux +bootmtd=run addmtd; bootm 0xBD040000 +ethaddr=00:C0:01:00:00:01 +ipaddr=2.2.2.1 +load=tftp 80500000 ${u-boot} +serverip=2.2.2.4 +stderr=serial +stdin=serial +stdout=serial +upbootldr=sf probe 0xbd000000; mw 0x81000000 0 0x20000; tftpboot 0x81000000 2.2.2.4:boot.owrt.bin; s +f erase 0xbd000000 ${filesize}; sf write 0x81000000 0xbd000000 ${filesize}; +upfw=sf probe 0xbd000000; mw 0x81000000 0 0x100000;tftpboot 0x81000000 2.2.2.4:z.img;sf erase 0xBD04 +0000 ${filesize};sf write 0x81000000 0xBD040000 ${filesize}; + +Environment size: 843/65532 bytes +RTL8672 # run upfw +TFTP from server 2.2.2.4; our IP address is 2.2.2.1