Skip to main content

bootintel_detectors/
boot_chain.rs

1//! U-Boot interactive session: environment, and the boot-chain verdict.
2//!
3//! A port of `api/analysis_engine/detectors/uboot_env.py` and
4//! `_build_boot_chain_verdict` in `engine.py`. It runs LOCALLY on purpose.
5//!
6//! Applicability lookup stays server-side because the curated CVE ruleset is
7//! the asset that compounds. This is the opposite case: "bootdelay above zero
8//! means the prompt is reachable" is domain knowledge any practitioner
9//! already has, so shipping it costs nothing, and a U-Boot environment is the
10//! single most sensitive thing in a capture (ipaddr, serverip, ethaddr, TFTP
11//! hosts, a client's internal addressing). Requiring an upload to learn what
12//! the environment permits would put this out of reach of exactly the people
13//! it is for.
14//!
15//! Two implementations of the same rules can drift, which is the problem the
16//! detector-parity work just fixed. `tests/boot_chain_parity.rs` pins this one
17//! against a shared expectation file that the Python side asserts against too.
18//!
19//! NOT a detector. Adding a label would break the 14-detector parity with the
20//! browser library, so this is a separate entry point.
21
22use std::collections::BTreeMap;
23
24/// One decision about the boot chain, with the variable it was read from.
25///
26/// A consultant has to defend the answer in a client report rather than quote
27/// a tool, so `evidence` is not optional.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Verdict {
30    pub title: String,
31    /// `exposed`, `hardened`, `confirmed`, or `unknown`. Never a bare boolean:
32    /// U-Boot only prints what is set, so absence is unknown, not good news.
33    pub state: String,
34    pub detail: String,
35    pub evidence: String,
36    pub severity: String,
37    pub remediation: Option<String>,
38}
39
40/// What a capture proves about an interactive U-Boot session.
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
42pub struct UbootSession {
43    pub reached: bool,
44    pub evidence: String,
45    /// BTreeMap so ordering is deterministic, which the parity test needs.
46    pub env: BTreeMap<String, String>,
47    pub env_used_bytes: Option<u64>,
48    pub env_total_bytes: Option<u64>,
49    /// `bdinfo` output: what the board reports about itself.
50    pub bdinfo: BTreeMap<String, String>,
51    /// The flash device named by an `mtdparts` dump.
52    pub mtd_device: Option<String>,
53    /// Partitions from an `mtdparts` dump, in the order printed.
54    pub mtd_partitions: Vec<MtdPartition>,
55}
56
57use std::sync::LazyLock;
58
59use regex::Regex;
60
61// The three patterns below are character-for-character the ones in
62// `api/analysis_engine/detectors/uboot_env.py`. Keeping them literally
63// identical is cheaper than reasoning about whether two hand-written
64// tokenisers agree.
65//
66// U-Boot's prompt. `=>` is the default; vendors commonly rebrand it.
67static RE_PROMPT: LazyLock<Regex> =
68    LazyLock::new(|| Regex::new(r"(?i)^\s*(?:=>|u-?boot\s*[>#]|[\w.-]+\s*=>)\s*(\S.*)?$").unwrap());
69
70// The definitive marker that a printenv dump just happened: U-Boot prints
71// this as the last line of `printenv`.
72static RE_ENV_SIZE: LazyLock<Regex> =
73    LazyLock::new(|| Regex::new(r"(?i)^\s*Environment size:\s*(\d+)\s*/\s*(\d+)\s*bytes").unwrap());
74
75// An environment entry. U-Boot allows almost anything in a value, including
76// spaces and semicolons, so the key is what must be constrained.
77static RE_ENV_LINE: LazyLock<Regex> =
78    LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap());
79
80// The integrity patterns, character-for-character from
81// `api/analysis_engine/detectors/boot_integrity.py`, for the same reason as the
82// session patterns above.
83static RE_CHECKSUM: LazyLock<Regex> =
84    LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Checksum\s*\.\.\.\s*(.*)$").unwrap());
85static RE_FIT_HASH: LazyLock<Regex> =
86    LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Hash Integrity\s*\.\.\.\s*(.*)$").unwrap());
87// Deliberately NOT `## Checking (hash|sign)`. U-Boot's ordinary FIT output is
88// `## Checking hash(es) for FIT Image at ...`, so that pattern reported a
89// verified SIGNATURE on every device using unsigned FIT hashes, which is the
90// common case. No corpus log prints the line, which is why the bug survived
91// review on the engine side.
92static RE_FIT_SIG: LazyLock<Regex> =
93    LazyLock::new(|| Regex::new(r"(?i)Verifying Signature\b").unwrap());
94// An algorithm entry that means a signature rather than a digest. U-Boot prints
95// `sha256,rsa2048:dev+ OK` when a signature node was checked.
96static RE_SIG_ALGO: LazyLock<Regex> =
97    LazyLock::new(|| Regex::new(r"(?i)^(?:rsa\d*|ecdsa\d*|pkcs1)").unwrap());
98// Anchored to the whole line: a substring match on "Bad Magic Number" also
99// catches `fsck.ext2: Bad magic number in super-block`, which is a filesystem
100// complaint and produced a false high-severity finding on the engine side.
101static RE_BAD: LazyLock<Regex> = LazyLock::new(|| {
102    Regex::new(r"(?i)^\s*(Bad Data Hash|Bad Header Checksum|Bad Magic Number|Bad Data CRC)\.?\s*$")
103        .unwrap()
104});
105static RE_BAD_SIG: LazyLock<Regex> = LazyLock::new(|| {
106    Regex::new(r"(?i)signature check failed|Verifying Hash Integrity\s*\.\.\.\s*error").unwrap()
107});
108static RE_HAB_OFF: LazyLock<Regex> =
109    LazyLock::new(|| Regex::new(r"(?i)hab fuse not enabled").unwrap());
110static RE_HAB_ON: LazyLock<Regex> =
111    LazyLock::new(|| Regex::new(r"(?i)hab fuse (?:is )?enabled").unwrap());
112static RE_UBIFS_UNAUTH: LazyLock<Regex> = LazyLock::new(|| {
113    Regex::new(r"(?i)UBIFS\s*\(([^)]*)\):\s*Mounting in unauthenticated mode").unwrap()
114});
115static RE_ENV_CRC: LazyLock<Regex> =
116    LazyLock::new(|| Regex::new(r"(?i)bad CRC, using default environment").unwrap());
117static RE_OK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)\bOK\b").unwrap());
118static RE_ALGO_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[+,\s]+").unwrap());
119
120/// What the bootloader actually DID about verifying the image it booted, as
121/// opposed to what the environment says it is configured to do.
122///
123/// The distinction this type exists to preserve: a checksum is not a signature.
124/// `Verifying Checksum ... OK` proves the image was not corrupt. Anyone who can
125/// write the image can recompute the CRC, so it stops bit-rot, not an attacker.
126/// Only a signature establishes that the image came from the signer, and on
127/// i.MX none of it is enforced while the HAB fuse is unblown.
128///
129/// Unlike the engine, this does not raise findings for any of it: the Rust and
130/// browser detector sets are pinned to the same 14 labels, and a fifteenth would
131/// break that parity. The facts and the verdicts are what the two
132/// implementations share.
133#[derive(Debug, Default, Clone, PartialEq, Eq)]
134pub struct BootIntegrity {
135    /// `uimage_crc` or `fit_hash`.
136    pub image_check: Option<String>,
137    pub image_check_evidence: Option<String>,
138    /// `passed`, `failed`, or `not_captured` when the check began but the
139    /// capture lost its result. "The check ran" is a different claim from "the
140    /// check passed".
141    pub image_check_result: Option<String>,
142    pub image_hash_algorithms: Vec<String>,
143    pub image_signature_checked: bool,
144    pub image_signature_evidence: Option<String>,
145    pub image_check_failed: Option<String>,
146    /// `not_enabled` or `enabled`.
147    pub hab_fuse: Option<String>,
148    pub hab_evidence: Option<String>,
149    pub ubifs_unauthenticated: Option<String>,
150    pub env_crc_failed: Option<String>,
151}
152
153/// First observation wins, so a later repeat cannot overwrite the evidence line
154/// that justified the original claim. Mirrors the engine's `setdefault`.
155fn keep_first(slot: &mut Option<String>, value: &str) {
156    if slot.is_none() {
157        *slot = Some(value.to_string());
158    }
159}
160
161/// Read the verification a bootloader reported performing.
162pub fn parse_integrity(log: &str) -> BootIntegrity {
163    let mut bi = BootIntegrity::default();
164    for raw in log.lines() {
165        let line = raw.trim_end_matches(['\r', '\n']);
166        let s = clip(line.trim(), 200);
167
168        if let Some(caps) = RE_CHECKSUM.captures(line) {
169            let result = caps[1].trim();
170            keep_first(&mut bi.image_check, "uimage_crc");
171            keep_first(&mut bi.image_check_evidence, &s);
172            keep_first(
173                &mut bi.image_check_result,
174                if RE_OK.is_match(result) {
175                    "passed"
176                } else if result.is_empty() {
177                    "not_captured"
178                } else {
179                    "failed"
180                },
181            );
182            continue;
183        }
184
185        if let Some(caps) = RE_FIT_HASH.captures(line) {
186            let tail = caps[1].trim();
187            let algos: Vec<String> = RE_ALGO_SPLIT
188                .split(tail)
189                .filter(|a| !a.is_empty() && !RE_OK.is_match(a))
190                .map(str::to_string)
191                .collect();
192            keep_first(&mut bi.image_check, "fit_hash");
193            keep_first(&mut bi.image_check_evidence, &s);
194            keep_first(
195                &mut bi.image_check_result,
196                if RE_OK.is_match(tail) {
197                    "passed"
198                } else {
199                    "failed"
200                },
201            );
202            if !algos.is_empty() {
203                if bi.image_hash_algorithms.is_empty() {
204                    bi.image_hash_algorithms = algos.clone();
205                }
206                if algos.iter().any(|a| RE_SIG_ALGO.is_match(a)) {
207                    bi.image_signature_checked = true;
208                    keep_first(&mut bi.image_signature_evidence, &s);
209                }
210            }
211            continue;
212        }
213
214        if RE_FIT_SIG.is_match(line) {
215            bi.image_signature_checked = true;
216            keep_first(&mut bi.image_signature_evidence, &s);
217            continue;
218        }
219
220        if RE_BAD.is_match(line) || RE_BAD_SIG.is_match(line) {
221            keep_first(&mut bi.image_check_failed, &s);
222            continue;
223        }
224
225        if RE_HAB_OFF.is_match(line) {
226            keep_first(&mut bi.hab_fuse, "not_enabled");
227            keep_first(&mut bi.hab_evidence, &s);
228            continue;
229        }
230        if RE_HAB_ON.is_match(line) {
231            keep_first(&mut bi.hab_fuse, "enabled");
232            keep_first(&mut bi.hab_evidence, &s);
233            continue;
234        }
235
236        if RE_ENV_CRC.is_match(line) {
237            keep_first(&mut bi.env_crc_failed, &s);
238            continue;
239        }
240
241        if let Some(caps) = RE_UBIFS_UNAUTH.captures(line) {
242            keep_first(&mut bi.ubifs_unauthenticated, caps[1].trim());
243        }
244    }
245    bi
246}
247
248// bdinfo prints aligned `name = value`. WHITESPACE BOTH SIDES OF THE `=` IS
249// REQUIRED, and it is what separates a bdinfo line from an environment line:
250// `printenv` emits `ethaddr=00:1F:...` with no spaces, bdinfo pads to a column
251// and emits `ethaddr     = 00:1F:...`. Without that, every environment dump
252// containing baudrate or ethaddr would also be read as board info.
253static RE_BDINFO: LazyLock<Regex> =
254    LazyLock::new(|| Regex::new(r"^\s*([A-Za-z][\w /()\-]{0,31}?)\s+=\s+(\S.*?)\s*$").unwrap());
255
256/// The allowlist is what makes bdinfo self-evidencing, which matters because the
257/// command that produced it cannot be relied on: bootintel-20 prints a full dump
258/// after `Boot-> bdinfo`, and `Boot->` is not U-Boot's default prompt, so a
259/// command-gated parser read that dump as nothing at all.
260///
261/// `start` and `size` are deliberately absent: too generic to stand alone.
262/// bootintel-5 prints an MTD table as `mtd_part[0]:` / `name = KERNEL` /
263/// `size = 0x180000`, and `size` in this list recorded that as board info.
264const BDINFO_KEYS: &[&str] = &[
265    "arch_number",
266    "boot_params",
267    "dram bank",
268    "flashstart",
269    "flashsize",
270    "flashoffset",
271    "baudrate",
272    "relocaddr",
273    "reloc off",
274    "ethaddr",
275    "ip_addr",
276    "fdt_blob",
277    "irq_sp",
278    "sp start",
279    "eth0name",
280    "memstart",
281    "memsize",
282    "eth1name",
283    "ethaddr1",
284    "current eth",
285    "fdt_addr",
286    "sp_start",
287    "reloc_off",
288    "dram_bank",
289];
290
291// `device nor0 <spi0.0>, # parts = 4`. The bracketed chip identifier is optional
292// and the space in `# parts` is real. The engine's first version of this pattern
293// required `#parts` with no space and no brackets, so it never matched U-Boot's
294// actual output: the partition lines parsed and the device name did not. No
295// public corpus log contains an mtdparts dump, so nothing caught it until a
296// fixture was written for the documented format.
297static RE_MTD_DEV: LazyLock<Regex> = LazyLock::new(|| {
298    Regex::new(r"(?i)^\s*device\s+(\S+)(?:\s+<[^>]*>)?\s*,\s*#\s*parts\s*=\s*(\d+)").unwrap()
299});
300static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
301    Regex::new(r"(?i)^\s*\d+:\s*(\S+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+(\d)").unwrap()
302});
303
304/// One partition as `mtdparts` printed it at the prompt.
305#[derive(Debug, Default, Clone, PartialEq, Eq)]
306pub struct MtdPartition {
307    pub name: String,
308    pub size: u64,
309    pub offset: u64,
310    /// The `mask_flags` column: 1 means the partition is marked read-only.
311    pub read_only: bool,
312}
313
314/// Truncate to `max` CHARACTERS, mirroring Python's `s[:max]`.
315///
316/// `String::truncate` counts bytes and panics mid-codepoint, and a capture is
317/// arbitrary bytes off a serial line, so this is not hypothetical.
318fn clip(s: &str, max: usize) -> String {
319    s.chars().take(max).collect()
320}
321
322/// Parse a capture for an interactive session. Conservative by construction:
323/// `KEY=value` lines are everywhere in a boot log (kernel command line, vendor
324/// config dumps), so variables are only committed once the capture proves it
325/// holds a real dump, and only inside a block delimited by U-Boot's own
326/// markers.
327///
328/// `bdinfo` and `mtdparts` parsing is deliberately not ported: nothing in the
329/// verdict reads them, and an unused second implementation is pure drift risk.
330pub fn parse_session(log: &str) -> UbootSession {
331    let mut s = UbootSession::default();
332    let mut in_env_dump = false;
333    // Buffered against the chance an `Environment size:` line is coming.
334    // Bounded, and discarded the moment the run of KEY=value lines breaks, so
335    // ordinary log noise cannot accumulate into a fake environment: a real
336    // dump is one contiguous block.
337    let mut candidates: Vec<(String, String)> = Vec::new();
338    let mut continuations = 0usize;
339
340    fn note(s: &mut UbootSession, line: &str) {
341        if !s.reached {
342            s.reached = true;
343            s.evidence = clip(line.trim(), 200);
344        }
345    }
346
347    for raw in log.lines() {
348        let line = raw.trim_end_matches(['\r', '\n']);
349
350        // The prompt itself. Also the boundary that closes an env dump:
351        // anything after a fresh prompt is a new command's output.
352        if let Some(caps) = RE_PROMPT.captures(line) {
353            note(&mut s, line);
354            let typed = caps
355                .get(1)
356                .map(|m| m.as_str().trim().to_ascii_lowercase())
357                .unwrap_or_default();
358            in_env_dump = ["printenv", "print", "env print"]
359                .iter()
360                .any(|p| typed.starts_with(p));
361            continue;
362        }
363
364        if let Some(caps) = RE_ENV_SIZE.captures(line) {
365            // The retroactive case, and the common one. U-Boot prints this
366            // line LAST, so a capture that starts mid-session, or whose prompt
367            // line was eaten, still proves the block just above was an
368            // environment.
369            note(&mut s, line);
370            s.env_used_bytes = caps[1].parse().ok();
371            s.env_total_bytes = caps[2].parse().ok();
372            in_env_dump = false;
373            for (k, val) in candidates.drain(..) {
374                s.env.entry(k).or_insert(val);
375            }
376            continuations = 0;
377            continue;
378        }
379
380        if let Some(caps) = RE_ENV_LINE.captures(line) {
381            let key = caps[1].to_string();
382            let value = clip(caps[2].trim(), 1024);
383            if in_env_dump {
384                // First write wins: U-Boot prints each variable once, and a
385                // later identical-looking line is more likely log noise than
386                // a redefine.
387                s.env.entry(key).or_insert(value);
388            } else {
389                continuations = 0;
390                if candidates.len() < 256 {
391                    candidates.push((key, value));
392                }
393            }
394            continue;
395        }
396
397        if line.trim().is_empty() {
398            continue;
399        }
400
401        // bdinfo and mtdparts, recognised by their own shape rather than by
402        // having seen the command that produced them, for the reason in
403        // BDINFO_KEYS. Checked before the continuation logic so a bdinfo line
404        // is never appended to a buffered environment value.
405        if let Some(caps) = RE_BDINFO.captures(line) {
406            let key = caps[1].trim();
407            if BDINFO_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
408                s.bdinfo
409                    .entry(key.to_string())
410                    .or_insert_with(|| caps[2].trim().to_string());
411                note(&mut s, line);
412                continue;
413            }
414        }
415        if let Some(caps) = RE_MTD_DEV.captures(line) {
416            if s.mtd_device.is_none() {
417                s.mtd_device = Some(caps[1].to_string());
418            }
419            note(&mut s, line);
420            continue;
421        }
422        if let Some(caps) = RE_MTD_PART.captures(line) {
423            // Hex without a `0x`, as U-Boot prints it. A width beyond u64 is not
424            // a partition table, so a failed parse drops the row rather than
425            // inventing a zero.
426            if let (Ok(size), Ok(offset)) = (
427                u64::from_str_radix(&caps[2], 16),
428                u64::from_str_radix(&caps[3], 16),
429            ) {
430                let part = MtdPartition {
431                    name: caps[1].to_string(),
432                    size,
433                    offset,
434                    read_only: &caps[4] == "1",
435                };
436                if !s.mtd_partitions.contains(&part) {
437                    s.mtd_partitions.push(part);
438                }
439                note(&mut s, line);
440                continue;
441            }
442        }
443
444        // A long U-Boot value wraps in a terminal capture, so the continuation
445        // has no `KEY=`. Treating it as a boundary discarded whole
446        // environments. Append instead, bounded: a couple of wrapped lines is
447        // normal, a long run means the dump ended and this is ordinary output.
448        if !candidates.is_empty() && continuations < 3 {
449            continuations += 1;
450            let last = candidates.last_mut().expect("checked non-empty");
451            last.1 = clip(&(last.1.clone() + line.trim()), 1024);
452            continue;
453        }
454        candidates.clear();
455        continuations = 0;
456    }
457    s
458}
459
460fn v(
461    out: &mut Vec<Verdict>,
462    title: &str,
463    state: &str,
464    detail: &str,
465    evidence: &str,
466    severity: &str,
467    remediation: Option<&str>,
468) {
469    out.push(Verdict {
470        title: title.to_string(),
471        state: state.to_string(),
472        detail: detail.to_string(),
473        evidence: evidence.to_string(),
474        severity: severity.to_string(),
475        remediation: remediation.map(str::to_string),
476    });
477}
478
479/// Turn a pulled environment into decisions, not observations.
480///
481/// The boot log can say autoboot looks interruptible. The environment says
482/// what happens when you interrupt it, and whether you can change what boots.
483pub fn verdict(s: &UbootSession, bi: &BootIntegrity) -> Vec<Verdict> {
484    let mut out = Vec::new();
485    if !s.reached {
486        return out;
487    }
488
489    v(
490        &mut out,
491        "U-Boot shell reached",
492        "confirmed",
493        "An operator interrupted autoboot and got a command prompt. Everything below \
494       was read from the device, not inferred from its boot output.",
495        if s.evidence.is_empty() {
496            "U-Boot prompt"
497        } else {
498            &s.evidence
499        },
500        "high",
501        Some(
502            "Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password.",
503        ),
504    );
505
506    // Image verification, preferring what was observed over what was configured.
507    //
508    // One entry, never two. An earlier version emitted a speculative "unknown"
509    // alongside an explicit verify=no and produced two contradictory verdicts
510    // for one question; the same trap is here in a new form, because a capture
511    // can carry BOTH verify=no and an observed checksum pass. Rather than
512    // silently picking a winner, the entry reports the observation and names the
513    // conflict.
514    //
515    // Read before the environment because none of it depends on a printenv
516    // having been captured: bootintel-7 reaches a prompt, never dumps the
517    // environment, and still reports `hab fuse not enabled`.
518    let verify_off = matches!(
519        s.env
520            .get("verify")
521            .map(|x| x.trim().to_ascii_lowercase())
522            .as_deref(),
523        Some("n") | Some("no") | Some("0") | Some("false")
524    );
525    let conflict = if verify_off {
526        " The environment says verify=no, yet the bootloader still reported a check, so either \
527         this capture predates that setting or a different boot path ran."
528    } else {
529        ""
530    };
531    let observed = bi.image_check.as_deref();
532    let result = bi.image_check_result.as_deref();
533    if bi.image_signature_checked {
534        v(
535            &mut out,
536            "Image verification",
537            "hardened",
538            &format!(
539                "A signature was checked before boot, which establishes that the image is the \
540                    one the signer produced, not merely an uncorrupted one.{conflict}"
541            ),
542            bi.image_signature_evidence
543                .as_deref()
544                .unwrap_or("signature check observed"),
545            "medium",
546            Some(
547                "Confirm the verifying key lives somewhere an attacker with flash write access \
548                cannot replace it.",
549            ),
550        );
551    } else if observed.is_some() && result == Some("passed") {
552        let mechanism = if observed == Some("fit_hash") {
553            let algos = if bi.image_hash_algorithms.is_empty() {
554                "unspecified".to_string()
555            } else {
556                bi.image_hash_algorithms.join(", ")
557            };
558            format!("a FIT hash ({algos})")
559        } else {
560            "a legacy uImage CRC".to_string()
561        };
562        v(
563            &mut out,
564            "Image verification",
565            "confirmed",
566            &format!(
567                "The bootloader checked the image before booting it, using {mechanism}, and \
568                    the check passed. That proves the image was not corrupt. It is not a \
569                    signature: anyone who can write the image can recompute the checksum, so this \
570                    stops bit-rot rather than an attacker.{conflict}"
571            ),
572            bi.image_check_evidence.as_deref().unwrap_or(""),
573            "medium",
574            Some(
575                "Move to signed FIT images (CONFIG_FIT_SIGNATURE) so a deliberate modification is \
576                detected and not just a corrupt one.",
577            ),
578        );
579    } else if observed.is_some() && result == Some("not_captured") {
580        v(
581            &mut out,
582            "Image verification",
583            "unknown",
584            &format!(
585                "The bootloader began an image check but its result is not in the capture, so \
586                    whether it passed is unknown.{conflict}"
587            ),
588            bi.image_check_evidence.as_deref().unwrap_or(""),
589            "info",
590            None,
591        );
592    } else if verify_off {
593        v(
594            &mut out,
595            "Image verification",
596            "exposed",
597            "verify is disabled, so U-Boot will not check image checksums before booting.",
598            &format!(
599                "verify={}",
600                s.env.get("verify").map(String::as_str).unwrap_or("")
601            ),
602            "high",
603            Some("Set verify=yes, and prefer signed FIT images over checksums."),
604        );
605    }
606
607    // The anchor. On i.MX none of the above is enforced while the fuse is unblown.
608    if bi.hab_fuse.as_deref() == Some("not_enabled") {
609        v(
610            &mut out,
611            "Secure boot anchor",
612            "exposed",
613            "The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned \
614           image. Whatever the bootloader does about checksums afterwards is advisory: the chain \
615           has no anchor.",
616            bi.hab_evidence.as_deref().unwrap_or("hab fuse not enabled"),
617            "high",
618            Some(
619                "Blow the HAB fuse and close the device only after a signed image is confirmed to \
620                boot, since the operation is irreversible.",
621            ),
622        );
623    }
624
625    if s.env.is_empty() {
626        v(
627            &mut out,
628            "Environment not captured",
629            "unknown",
630            "The shell was reached but no printenv output was captured, so the boot \
631           chain below could not be assessed. Run `printenv` at the prompt.",
632            &s.evidence,
633            "info",
634            None,
635        );
636        return out;
637    }
638
639    match s.env.get("bootdelay") {
640        Some(raw) => {
641            let ev = format!("bootdelay={raw}");
642            match raw.trim().parse::<i64>() {
643                Err(_) => v(
644                    &mut out,
645                    "Autoboot delay",
646                    "unknown",
647                    &format!("bootdelay is not a number: {raw:?}."),
648                    &ev,
649                    "info",
650                    None,
651                ),
652                Ok(d) if d < 0 => v(
653                    &mut out,
654                    "Autoboot delay",
655                    "hardened",
656                    "bootdelay is negative, so autoboot cannot be interrupted by a keypress. \
657                     The prompt was still reached, so something else allowed it.",
658                    &ev,
659                    "medium",
660                    None,
661                ),
662                Ok(0) => v(
663                    &mut out,
664                    "Autoboot delay",
665                    "hardened",
666                    "bootdelay is 0: no interrupt window. The prompt was still reached, so \
667                     something else allowed it.",
668                    &ev,
669                    "medium",
670                    None,
671                ),
672                Ok(d) => v(
673                    &mut out,
674                    "Autoboot delay",
675                    "exposed",
676                    &format!(
677                        "bootdelay is {d}s, so anyone with console access gets {d}s to \
678                              take the prompt on every boot."
679                    ),
680                    &ev,
681                    "high",
682                    Some("Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED)."),
683                ),
684            }
685        }
686        None => v(
687            &mut out,
688            "Autoboot delay",
689            "unknown",
690            "bootdelay is not set in the environment, so the built-in default applies \
691             and cannot be read from here.",
692            "bootdelay absent",
693            "info",
694            None,
695        ),
696    }
697
698    if let Some(cmd) = s.env.get("bootcmd") {
699        let short: String = cmd.chars().take(160).collect();
700        v(
701            &mut out,
702            "Boot command",
703            "exposed",
704            "bootcmd is readable and, with the prompt reachable, settable. Whoever holds \
705           the console decides what the device boots.",
706            &format!("bootcmd={short}"),
707            "high",
708            Some(
709                "Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a \
710                password at the prompt.",
711            ),
712        );
713        let verify_set = s.env.contains_key("verify");
714        let boots_image = ["bootm", "bootz", "booti"].iter().any(|t| cmd.contains(t));
715        // Only speculate when the capture contains no observation at all. The
716        // whole point of reading the boot output is to stop guessing here.
717        if boots_image && !verify_set && !cmd.contains("verify") && observed.is_none() {
718            v(
719                &mut out,
720                "Image verification",
721                "unknown",
722                "bootcmd boots an image without a visible verification step. That is not \
723               proof verification is absent: a FIT signature check can be implicit in \
724               the image. Confirm with the boot output of an actual `bootm`.",
725                &format!("bootcmd={short}"),
726                "info",
727                None,
728            );
729        }
730    }
731
732    if s.env.contains_key("ipaddr") && s.env.contains_key("serverip") {
733        let parts: Vec<String> = ["ethaddr", "gatewayip", "ipaddr", "netmask", "serverip"]
734            .iter()
735            .filter_map(|k| s.env.get(*k).map(|val| format!("{k}={val}")))
736            .collect();
737        v(
738            &mut out,
739            "Network boot path",
740            "exposed",
741            "ipaddr and serverip are both set, so the bootloader is pre-configured to \
742           fetch over the network. That is a route in as much as a recovery route out.",
743            &parts.join(", "),
744            "medium",
745            Some("Clear ipaddr/serverip on production images unless netboot is required."),
746        );
747    }
748
749    if let Some(args) = s.env.get("bootargs") {
750        let short: String = args.chars().take(160).collect();
751        let ev = format!("bootargs={short}");
752        let debug = [
753            ("init=/bin/sh", "a root shell as init"),
754            ("init=/bin/bash", "a root shell as init"),
755            ("single", "single-user mode"),
756            ("rdinit=/bin/sh", "a root shell as rdinit"),
757        ]
758        .iter()
759        .find(|(tok, _)| args.contains(tok))
760        .map(|(_, what)| *what);
761        match debug {
762            Some(what) => v(
763                &mut out,
764                "Boot arguments",
765                "exposed",
766                &format!("bootargs already requests {what}."),
767                &ev,
768                "high",
769                Some("Remove debug boot arguments from production images."),
770            ),
771            None => v(
772                &mut out,
773                "Boot arguments",
774                "confirmed",
775                "bootargs is readable and settable from the prompt, which is how a root \
776                 shell is usually obtained on a board like this.",
777                &ev,
778                "medium",
779                Some("Lock the environment so bootargs cannot be rewritten at the console."),
780            ),
781        }
782    }
783
784    if let (Some(used), Some(total)) = (s.env_used_bytes, s.env_total_bytes) {
785        if total > 0 {
786            v(
787                &mut out,
788                "Environment storage",
789                "confirmed",
790                &format!(
791                    "The environment occupies {used} of {total} bytes of writable storage, so \
792                        `saveenv` can persist a change across reboots."
793                ),
794                &format!("Environment size: {used}/{total} bytes"),
795                "medium",
796                Some("Build with a read-only or signed environment for production."),
797            );
798        }
799    }
800    out
801}
802
803/// Convenience: parse and decide in one call.
804/// Everything one capture establishes about the boot chain.
805#[derive(Debug, Default, Clone, PartialEq, Eq)]
806pub struct Assessment {
807    pub session: UbootSession,
808    pub integrity: BootIntegrity,
809    pub verdicts: Vec<Verdict>,
810}
811
812/// Parse and decide in one call.
813///
814/// This replaced a `(UbootSession, Vec<Verdict>)` tuple when integrity reading
815/// landed: a breaking change to a published crate, which under the policy at the
816/// top of CHANGELOG.md moves the minor version pre-1.0. The alternative was a
817/// second name for the same operation, and two entry points differing only in
818/// how much they tell you is worse for whoever reads this next.
819pub fn assess(log: &str) -> Assessment {
820    let session = parse_session(log);
821    let integrity = parse_integrity(log);
822    let verdicts = verdict(&session, &integrity);
823    Assessment {
824        session,
825        integrity,
826        verdicts,
827    }
828}