use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
pub title: String,
pub state: String,
pub detail: String,
pub evidence: String,
pub severity: String,
pub remediation: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UbootSession {
pub reached: bool,
pub evidence: String,
pub env: BTreeMap<String, String>,
pub env_used_bytes: Option<u64>,
pub env_total_bytes: Option<u64>,
}
use std::sync::LazyLock;
use regex::Regex;
static RE_PROMPT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\s*(?:=>|u-?boot\s*[>#]|[\w.-]+\s*=>)\s*(\S.*)?$").unwrap());
static RE_ENV_SIZE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\s*Environment size:\s*(\d+)\s*/\s*(\d+)\s*bytes").unwrap());
static RE_ENV_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap());
fn clip(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
pub fn parse_session(log: &str) -> UbootSession {
let mut s = UbootSession::default();
let mut in_env_dump = false;
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']);
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) {
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 {
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;
}
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<Verdict>,
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),
});
}
pub fn verdict(s: &UbootSession) -> Vec<Verdict> {
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::<i64>() {
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<String> = ["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
}
pub fn assess(log: &str) -> (UbootSession, Vec<Verdict>) {
let s = parse_session(log);
let verdicts = verdict(&s);
(s, verdicts)
}