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