From 086dd9824a8c7a8aad78e17827d5b537f301fc50 Mon Sep 17 00:00:00 2001 From: BootIntel Agent Date: Sun, 27 Sep 2026 05:41:49 +0000 Subject: [PATCH] Ask which advisories apply, without sending the boot log The client half of a design whose server half already shipped. The applicability endpoint and the terminal login that reaches it both existed; nothing in the CLI called them, so the feature was a road and a set of keys with no car. A consultant cannot upload a client's boot log. That is a contract matter, not a preference, and it is the objection that keeps this tool out of the segment it fits best. Shipping the curated ruleset down to the client instead would hand over the one asset that compounds. So neither travels: the detectors run locally, exactly as for a plain scan, and only product names and version strings go up. The guarantee is structural rather than a promise. The payload is built from a fixed map of three detector labels to three product names, every value is re-validated against the server's own limits before it leaves, and there is no field a log could occupy. A test asserts that adding one breaks the build. Verified against the Android corpus capture, which carries androidboot.serialno=, vbmeta.device_state=unlocked and androidboot.selinux=permissive, and yields exactly two version strings. --dry-run prints the exact JSON and exits. That is how a consultant shows a client what leaves the machine, so it is documented as a headline capability rather than a debugging aid. The label-to-product translation is the part that would have failed silently. The server matches lowercase product names, so sending "Bootloader" or "U-Boot 2020.10" returns nothing while looking like a server fault. Only U-Boot, Linux and BusyBox currently map to something the catalog holds; the other eleven detectors are not CVE-tracked products and are deliberately absent. A 422 from the server is reported as a bug in this client, not user error, because the same rules are enforced here first. A 403 says local identification still works and how. Verified live end to end: `bootintel login` against the running server, then `scan --applicability` sending 2 components and receiving 67 verdicts, every one a bounded_range match, with "the boot log stayed here" in the output. The probe account and its authorizations were removed afterwards. Verified: 308 tests pass, clippy clean under -D warnings, rustfmt clean, cargo deny reports advisories, bans, licenses and sources all ok. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 29 ++ README.md | 1 + crates/cli/src/api/endpoints.rs | 9 + crates/cli/src/cmd/scan/applicability.rs | 492 +++++++++++++++++++++++ crates/cli/src/cmd/scan/mod.rs | 23 ++ 5 files changed, 554 insertions(+) create mode 100644 crates/cli/src/cmd/scan/applicability.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b1fa3..8e983e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`scan --applicability`** — ask which advisories APPLY without sending the + boot log. The detectors run locally, exactly as for a plain `scan`, and only + the component inventory goes up: product names and version strings. + + A consultant cannot upload a client's boot log. That is a contract matter, + not a preference, and it is the objection that keeps this tool out of the + segment it fits best. Shipping the curated ruleset down to the client + instead would hand over the one asset that compounds. So neither travels. + + Hostnames, internal addressing, MACs, serials, keys, kernel command lines + and partition labels cannot be transmitted by this path structurally, not by + policy: the payload is built from a fixed map of three detector labels to + three product names, and every value is re-validated before it leaves. + Verified against the Android corpus capture, which carries + `androidboot.serialno=`, `vbmeta.device_state=unlocked` and + `androidboot.selinux=permissive`, and yields two version strings and nothing + else. + +- **`--dry-run`** alongside it, printing the exact JSON that would be sent and + exiting without sending. This is how a consultant shows a client what leaves + the machine, so it is a headline capability rather than a debug switch. + + Needs `bootintel login`; local identification stays free and needs no + account. Only U-Boot, Linux and BusyBox currently yield a component the + catalog can match, so those are the only three sent. The other eleven + detectors are not CVE-tracked products, and inventing entries for them would + only produce noise. + ## [0.5.0] — 2026-09-26 — detector parity, and terminal login Detector-set parity with the browser detector library at diff --git a/README.md b/README.md index e9cb412..a163200 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ cargo build --release | Subcommand | What it does | | --- | --- | | `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 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/api/endpoints.rs b/crates/cli/src/api/endpoints.rs index 08b8da1..6758bc9 100644 --- a/crates/cli/src/api/endpoints.rs +++ b/crates/cli/src/api/endpoints.rs @@ -278,3 +278,12 @@ pub fn device_token_url(base: &str) -> String { path_prefix() ) } + +/// Applicability lookup: takes a component inventory, never a log. +pub fn applicability_url(base: &str) -> String { + format!( + "{}{}/analysis/applicability", + base.trim_end_matches('/'), + path_prefix() + ) +} diff --git a/crates/cli/src/cmd/scan/applicability.rs b/crates/cli/src/cmd/scan/applicability.rs new file mode 100644 index 0000000..0765c9a --- /dev/null +++ b/crates/cli/src/cmd/scan/applicability.rs @@ -0,0 +1,492 @@ +//! `scan --applicability` — CVE applicability without sending the boot log. +//! +//! A consultant cannot upload a client's boot log. That is a contract matter +//! rather than a preference, and it is the single objection that keeps +//! BootIntel out of the segment it fits best. Shipping the curated ruleset +//! down to the client instead would hand over the one asset that compounds. +//! +//! So neither travels. The detectors run locally, exactly as they do for a +//! plain `scan`, and only the component inventory goes up: product names and +//! version strings. The server answers the expensive question, which is +//! applicability, not identification. +//! +//! What CANNOT be transmitted by this path, structurally rather than by +//! policy: hostnames, internal addressing, MACs, serials, keys, kernel +//! command lines, partition labels, anything a boot log carries beyond a +//! version number. The payload is built from a fixed map of three detector +//! labels to three product names, and every value is re-validated before it +//! leaves. `--dry-run` prints the exact bytes so a consultant can show a +//! client what leaves the machine; that is a headline capability here, not a +//! debugging aid. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::io::{self, Write}; + +use super::Args; +use bootintel_detectors::Finding; + +/// sysexits.h EX_UNAVAILABLE. +const EX_UNAVAILABLE: i32 = 69; +/// sysexits.h EX_NOPERM. +const EX_NOPERM: i32 = 77; +/// sysexits.h EX_DATAERR. +const EX_DATAERR: i32 = 65; + +/// Mirrors the server's limits so a malformed payload fails here, instantly +/// and locally, instead of after a round trip. Server rejects with 422 on any +/// of these; if it ever does, that is a bug in this mapping, not user error. +const MAX_INVENTORY_ITEMS: usize = 64; +const MAX_COMPONENT_NAME: usize = 80; +const MAX_COMPONENT_VERSION: usize = 40; + +/// The only detector labels that yield a component the catalog can match, and +/// the exact `name` the server expects for each. Translation is required: the +/// server matches on lowercase product names, and sending "Bootloader" or +/// "U-Boot 2020.10" returns nothing while looking like a server fault. +/// +/// Every other detector is deliberately absent. CPU architecture, init +/// system, device family, flash layout, ROM identifier, firmware SDK, runtime +/// firmware and the two exposure detectors are not CVE-tracked products, and +/// inventing entries for them would only produce noise. +const LABEL_TO_PRODUCT: &[(&str, &str)] = &[ + ("Bootloader", "u-boot"), + ("Kernel", "linux"), + ("Userland", "busybox"), +]; + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub(super) struct Component { + pub name: String, + pub version: String, +} + +#[derive(Serialize)] +struct Request<'a> { + inventory: &'a [Component], +} + +#[derive(Deserialize)] +struct Response { + #[serde(default)] + inventory: Vec, + #[serde(default)] + findings: Vec, + #[serde(default)] + log_received: Option, +} + +#[derive(Deserialize)] +struct ResponseComponent { + #[serde(default)] + name: String, + #[serde(default)] + version: String, + #[serde(default)] + status: Option, + #[serde(default)] + cve_count: Option, +} + +#[derive(Deserialize)] +struct Verdict { + #[serde(default)] + cve_id: Option, + #[serde(default)] + severity: Option, + #[serde(default)] + match_basis: Option, + #[serde(default)] + evidence: Option, + #[serde(default)] + remediation: Option, +} + +#[derive(Deserialize)] +struct ErrBody { + #[serde(default)] + detail: Option, +} + +/// Split a detector value like "U-Boot 2020.10" into its version part. +/// +/// Takes the LAST whitespace-separated token, because product names contain +/// spaces ("Espressif ROM bootloader") while versions do not. +fn version_of(value: &str) -> Option<&str> { + let tok = value.split_whitespace().next_back()?; + // A version has to start with a digit to be range-matched. The server + // enforces the same rule, deliberately: a component whose version was + // never observed cannot be matched, and guessing is the bug that used to + // paint unversioned services green. + tok.chars().next().filter(char::is_ascii_digit).map(|_| tok) +} + +/// Build the payload from local findings. Pure, so the test suite can assert +/// exactly what would be transmitted for any capture. +pub(super) fn build_inventory(findings: &[Finding]) -> Vec { + let mut out: Vec = Vec::new(); + for finding in findings { + let Some((_, product)) = LABEL_TO_PRODUCT.iter().find(|(l, _)| *l == finding.label) else { + continue; + }; + let Some(version) = version_of(&finding.value) else { + continue; + }; + if version.len() > MAX_COMPONENT_VERSION || product.len() > MAX_COMPONENT_NAME { + continue; + } + // Nothing multi-line can reach the wire. Belt and braces: the + // detectors are per-line already, but this is the guarantee the + // feature is sold on, so it is checked where it is claimed. + if version.contains(['\n', '\r', '\t']) { + continue; + } + let candidate = Component { + name: (*product).to_string(), + version: version.to_string(), + }; + if !out.contains(&candidate) { + out.push(candidate); + } + if out.len() >= MAX_INVENTORY_ITEMS { + break; + } + } + out +} + +pub(super) fn run(args: &Args, findings: &[Finding]) -> Result<()> { + let inventory = build_inventory(findings); + + if inventory.is_empty() { + eprintln!( + "bootintel: no version-bearing component was identified, so there is nothing to \ + ask about.\n \ + Applicability needs a product AND a version. This capture yielded neither for \ + U-Boot, Linux or BusyBox.\n \ + `bootintel scan` still shows everything identified locally." + ); + std::process::exit(EX_DATAERR); + } + + let body = Request { + inventory: &inventory, + }; + + if args.dry_run { + // The point of the feature, not a debug switch: this is what a + // consultant shows a client to prove what leaves the machine. + let json = serde_json::to_string_pretty(&body).context("serializing the inventory")?; + let mut out = io::stdout().lock(); + writeln!(out, "{json}")?; + writeln!( + out, + "\n{} component(s) would be sent to {}. The boot log would not.", + inventory.len(), + crate::config::resolve_api_base(args.api_base.as_deref()) + )?; + return Ok(()); + } + + let base = crate::config::resolve_api_base(args.api_base.as_deref()); + let key = crate::config::resolve_api_key().unwrap_or_default(); + if key.is_empty() { + eprintln!( + "bootintel: applicability needs an account.\n \ + Run `bootintel login` to sign in from this terminal.\n \ + Local identification stays free: `bootintel scan` needs no account at all." + ); + std::process::exit(EX_NOPERM); + } + + let url = crate::api::endpoints::applicability_url(&base); + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(15)) + .timeout_read(std::time::Duration::from_secs(120)) + .build(); + + let parsed: Response = match agent + .post(&url) + .set("X-API-Key", &key) + .send_json(serde_json::to_value(&body).context("serializing the inventory")?) + { + Ok(r) => r + .into_json() + .context("malformed response from the server")?, + Err(ureq::Error::Status(403, resp)) => { + let detail = detail_of(resp, "your plan does not include applicability lookup"); + eprintln!("bootintel: {detail}"); + eprintln!(" Local identification stays free: `bootintel scan` needs no account."); + std::process::exit(EX_NOPERM); + } + Err(ureq::Error::Status(401, _)) => { + eprintln!("bootintel: not signed in, or the terminal login expired."); + eprintln!(" Run `bootintel login` again."); + std::process::exit(EX_NOPERM); + } + Err(ureq::Error::Status(422, resp)) => { + // This should be unreachable: the same rules are enforced above, + // before anything is sent. Say so, because it is a bug here. + let detail = detail_of(resp, "the server rejected the inventory"); + eprintln!("bootintel: the server rejected a payload this client should have caught."); + eprintln!(" {detail}"); + eprintln!(" That is a bug in bootintel, not in your capture. Please report it"); + eprintln!(" with the output of `--applicability --dry-run`."); + std::process::exit(EX_DATAERR); + } + Err(ureq::Error::Status(code, resp)) => { + eprintln!( + "bootintel: applicability lookup failed ({code}): {}", + detail_of(resp, "unexpected server response") + ); + std::process::exit(EX_UNAVAILABLE); + } + Err(ureq::Error::Transport(t)) => { + eprintln!("bootintel: could not reach {base}: {t}"); + eprintln!(" `bootintel scan` still works offline."); + std::process::exit(EX_UNAVAILABLE); + } + }; + + render(args, &inventory, &parsed) +} + +fn detail_of(resp: ureq::Response, fallback: &str) -> String { + resp.into_json::() + .ok() + .and_then(|b| b.detail) + .unwrap_or_else(|| fallback.to_string()) +} + +fn render(args: &Args, sent: &[Component], resp: &Response) -> Result<()> { + let mut out = io::stdout().lock(); + + if matches!(args.format, super::Format::Json) { + // Echo what was sent alongside what came back. The auditability of + // the answer is the product, so the request is part of the record. + let doc = serde_json::json!({ + "sent_inventory": sent, + "log_received": resp.log_received.unwrap_or(false), + "components": resp.inventory.iter().map(|c| serde_json::json!({ + "name": c.name, "version": c.version, + "status": c.status, "cve_count": c.cve_count, + })).collect::>(), + "findings": resp.findings.iter().map(|f| serde_json::json!({ + "cve_id": f.cve_id, "severity": f.severity, + "match_basis": f.match_basis, "evidence": f.evidence, + "remediation": f.remediation, + })).collect::>(), + }); + writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?; + return Ok(()); + } + + writeln!( + out, + " Sent {} component(s). The boot log stayed here.", + sent.len() + )?; + for c in sent { + writeln!(out, " {} {}", c.name, c.version)?; + } + writeln!(out)?; + + for c in &resp.inventory { + let status = c.status.as_deref().unwrap_or("unknown"); + let count = c.cve_count.unwrap_or(0); + writeln!( + out, + " {:<16} {:<12} {:<10} {} advisory match(es)", + c.name, c.version, status, count + )?; + } + + if resp.findings.is_empty() { + writeln!(out, "\n No applicable advisories in checked coverage.")?; + writeln!( + out, + " That is not proof the device is unaffected: it means nothing matched" + )?; + writeln!( + out, + " the versions observed, within the catalog's coverage." + )?; + return Ok(()); + } + + writeln!( + out, + "\n {} applicable advisory match(es):", + resp.findings.len() + )?; + for f in &resp.findings { + let id = f.cve_id.as_deref().unwrap_or("(no id)"); + let sev = f.severity.as_deref().unwrap_or("unrated"); + let basis = f.match_basis.as_deref().unwrap_or("unspecified"); + writeln!(out, " {id} {sev} [{basis}]")?; + if let Some(ev) = &f.evidence { + writeln!(out, " {ev}")?; + } + } + // Every verdict carries the range it matched and the basis it matched on, + // because a consultant has to be able to defend the answer, not just + // quote it. + writeln!( + out, + "\n match_basis says HOW each one matched. bounded_range and" + )?; + writeln!( + out, + " exact_version are anchored; series_upper_bound follows the kernel's" + )?; + writeln!(out, " own convention for a stable series.")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn f(label: &str, value: &str) -> Finding { + Finding { + label: label.to_string(), + value: value.to_string(), + detail: None, + source: None, + line_number: None, + } + } + + #[test] + fn the_three_matchable_components_translate_to_server_names() { + let got = build_inventory(&[ + f("Bootloader", "U-Boot 2020.10"), + f("Kernel", "Linux 5.4.100"), + f("Userland", "BusyBox 1.19.4"), + ]); + assert_eq!( + got, + vec![ + Component { + name: "u-boot".into(), + version: "2020.10".into() + }, + Component { + name: "linux".into(), + version: "5.4.100".into() + }, + Component { + name: "busybox".into(), + version: "1.19.4".into() + }, + ] + ); + } + + #[test] + fn detectors_that_are_not_cve_tracked_products_are_omitted() { + let got = build_inventory(&[ + f("CPU / Arch", "ARMv7 (32-bit)"), + f("Init system", "procd"), + f("Device family", "OpenWrt"), + f("Flash layout", "7 partitions"), + f("Runtime firmware", "OpenSBI 1.0"), + f("ROM identifier", "Espressif ROM esp32-20160718"), + f("Telnet exposure", "telnetd running"), + ]); + assert!(got.is_empty(), "invented entries would be noise: {got:?}"); + } + + #[test] + fn a_component_with_no_observed_version_is_omitted() { + // The server rejects these, and rightly: an unversioned component + // cannot be range-matched. + for value in ["U-Boot", "U-Boot detected", "BusyBox unknown"] { + assert!( + build_inventory(&[f("Bootloader", value), f("Userland", value)]).is_empty(), + "{value:?} should not produce an inventory entry" + ); + } + } + + #[test] + fn nothing_from_a_boot_log_beyond_a_version_can_reach_the_payload() { + // The shape that actually leaks: a kernel command line carrying a + // serial number and the bootloader unlock state. + let hostile = "Linux 2.6.36 androidboot.serialno=180080072ea00922 \ + androidboot.vbmeta.device_state=unlocked"; + let got = build_inventory(&[f("Kernel", hostile)]); + let json = serde_json::to_string(&got).unwrap(); + for leak in [ + "serialno", + "180080072ea00922", + "device_state", + "unlocked", + "androidboot", + ] { + assert!(!json.contains(leak), "{leak} reached the payload: {json}"); + } + } + + #[test] + fn a_multi_line_value_is_refused() { + let got = build_inventory(&[f("Kernel", "Linux 2.6.36\nU-Boot 1.1.3")]); + let json = serde_json::to_string(&got).unwrap(); + assert!( + !json.contains("U-Boot"), + "a second component leaked through a newline: {json}" + ); + } + + #[test] + fn duplicates_are_collapsed() { + let got = build_inventory(&[f("Kernel", "Linux 5.4.100"), f("Kernel", "Linux 5.4.100")]); + assert_eq!(got.len(), 1); + } + + #[test] + fn every_emitted_name_is_one_the_server_accepts() { + // Server-side software_terms for these three, lowercase. + let accepted = ["u-boot", "linux", "busybox"]; + let got = build_inventory(&[ + f("Bootloader", "U-Boot 2020.10"), + f("Kernel", "Linux 5.4.100"), + f("Userland", "BusyBox 1.19.4"), + ]); + for c in &got { + assert!( + accepted.contains(&c.name.as_str()), + "{} is not a server product name", + c.name + ); + assert!(c.version.chars().next().unwrap().is_ascii_digit()); + assert!(c.name.len() <= MAX_COMPONENT_NAME && c.version.len() <= MAX_COMPONENT_VERSION); + } + } + + #[test] + fn the_payload_has_no_field_a_log_could_occupy() { + let got = build_inventory(&[f("Kernel", "Linux 5.4.100")]); + let v = serde_json::to_value(Request { inventory: &got }).unwrap(); + assert_eq!( + v.as_object().unwrap().keys().collect::>(), + vec!["inventory"] + ); + let item = &v["inventory"][0]; + let mut keys: Vec<_> = item.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + vec!["name", "version"], + "a new field here is a new way to smuggle a log" + ); + } + + #[test] + fn the_inventory_is_bounded() { + let many: Vec = (0..200) + .map(|i| f("Kernel", &format!("Linux 5.4.{i}"))) + .collect(); + assert!(build_inventory(&many).len() <= MAX_INVENTORY_ITEMS); + } +} diff --git a/crates/cli/src/cmd/scan/mod.rs b/crates/cli/src/cmd/scan/mod.rs index 56fc8db..9d93452 100644 --- a/crates/cli/src/cmd/scan/mod.rs +++ b/crates/cli/src/cmd/scan/mod.rs @@ -40,6 +40,7 @@ use bootintel_detectors::{analyze, CRITICAL_LABELS}; use crate::output::{self, Format}; mod api; +mod applicability; mod baseline; mod context; @@ -82,6 +83,20 @@ pub struct Args { #[arg(long)] pub(super) preview: bool, + /// Ask bootintel.com which advisories APPLY, without sending the + /// boot log. The detectors run locally and only the component + /// inventory (product names + version strings) is transmitted, so + /// this is usable on a client device under an NDA where --api is + /// not. Needs `bootintel login`. + #[arg(long, conflicts_with = "api")] + pub(super) applicability: bool, + + /// With --applicability: print the exact JSON that would be sent + /// and exit without sending it. Not a debug switch — this is how + /// you show a client what leaves the machine. + #[arg(long, requires = "applicability")] + pub(super) dry_run: bool, + /// Override the API base URL. Defaults to /// (or $BOOTINTEL_API_BASE). Useful for self-hosted deployments /// and for pointing the CLI at a local mock in tests. @@ -193,6 +208,14 @@ pub fn run(args: Args) -> Result<()> { } let findings = crate::detector_filter::filter(analyze(&raw), &args.only, &args.skip); + + // After the local analysis, deliberately: the inventory is derived from + // the same findings a plain `scan` prints, so what is sent is exactly + // what the user can already see. + if args.applicability { + return applicability::run(&args, &findings); + } + let stdout = io::stdout(); let color = output::resolve_color_mode(args.no_color, &stdout); let mut out = stdout.lock();