1use std::collections::BTreeMap;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Verdict {
30 pub title: String,
31 pub state: String,
34 pub detail: String,
35 pub evidence: String,
36 pub severity: String,
37 pub remediation: Option<String>,
38}
39
40#[derive(Debug, Default, Clone, PartialEq, Eq)]
42pub struct UbootSession {
43 pub reached: bool,
44 pub evidence: String,
45 pub env: BTreeMap<String, String>,
47 pub env_used_bytes: Option<u64>,
48 pub env_total_bytes: Option<u64>,
49 pub bdinfo: BTreeMap<String, String>,
51 pub mtd_device: Option<String>,
53 pub mtd_partitions: Vec<MtdPartition>,
55}
56
57use std::sync::LazyLock;
58
59use regex::Regex;
60
61static RE_PROMPT: LazyLock<Regex> =
68 LazyLock::new(|| Regex::new(r"(?i)^\s*(?:=>|u-?boot\s*[>#]|[\w.-]+\s*=>)\s*(\S.*)?$").unwrap());
69
70static RE_ENV_SIZE: LazyLock<Regex> =
73 LazyLock::new(|| Regex::new(r"(?i)^\s*Environment size:\s*(\d+)\s*/\s*(\d+)\s*bytes").unwrap());
74
75static RE_ENV_LINE: LazyLock<Regex> =
78 LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap());
79
80static 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());
87static RE_FIT_SIG: LazyLock<Regex> =
93 LazyLock::new(|| Regex::new(r"(?i)Verifying Signature\b").unwrap());
94static RE_SIG_ALGO: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r"(?i)^(?:rsa\d*|ecdsa\d*|pkcs1)").unwrap());
98static 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
134pub struct BootIntegrity {
135 pub image_check: Option<String>,
137 pub image_check_evidence: Option<String>,
138 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 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
153fn keep_first(slot: &mut Option<String>, value: &str) {
156 if slot.is_none() {
157 *slot = Some(value.to_string());
158 }
159}
160
161pub 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
248static RE_BDINFO: LazyLock<Regex> =
254 LazyLock::new(|| Regex::new(r"^\s*([A-Za-z][\w /()\-]{0,31}?)\s+=\s+(\S.*?)\s*$").unwrap());
255
256const 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
291static 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
306pub struct MtdPartition {
307 pub name: String,
308 pub size: u64,
309 pub offset: u64,
310 pub read_only: bool,
312}
313
314fn clip(s: &str, max: usize) -> String {
319 s.chars().take(max).collect()
320}
321
322pub fn parse_session(log: &str) -> UbootSession {
331 let mut s = UbootSession::default();
332 let mut in_env_dump = false;
333 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 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 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 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 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 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 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
479pub 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 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 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 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
806pub struct Assessment {
807 pub session: UbootSession,
808 pub integrity: BootIntegrity,
809 pub verdicts: Vec<Verdict>,
810}
811
812pub 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}