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}
50
51use std::sync::LazyLock;
52
53use regex::Regex;
54
55static RE_PROMPT: LazyLock<Regex> =
62 LazyLock::new(|| Regex::new(r"(?i)^\s*(?:=>|u-?boot\s*[>#]|[\w.-]+\s*=>)\s*(\S.*)?$").unwrap());
63
64static RE_ENV_SIZE: LazyLock<Regex> =
67 LazyLock::new(|| Regex::new(r"(?i)^\s*Environment size:\s*(\d+)\s*/\s*(\d+)\s*bytes").unwrap());
68
69static RE_ENV_LINE: LazyLock<Regex> =
72 LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap());
73
74static 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());
81static RE_FIT_SIG: LazyLock<Regex> =
87 LazyLock::new(|| Regex::new(r"(?i)Verifying Signature\b").unwrap());
88static RE_SIG_ALGO: LazyLock<Regex> =
91 LazyLock::new(|| Regex::new(r"(?i)^(?:rsa\d*|ecdsa\d*|pkcs1)").unwrap());
92static 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
128pub struct BootIntegrity {
129 pub image_check: Option<String>,
131 pub image_check_evidence: Option<String>,
132 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 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
147fn keep_first(slot: &mut Option<String>, value: &str) {
150 if slot.is_none() {
151 *slot = Some(value.to_string());
152 }
153}
154
155pub 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
242fn clip(s: &str, max: usize) -> String {
247 s.chars().take(max).collect()
248}
249
250pub fn parse_session(log: &str) -> UbootSession {
259 let mut s = UbootSession::default();
260 let mut in_env_dump = false;
261 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 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 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 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 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
364pub 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 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 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 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
691pub struct Assessment {
692 pub session: UbootSession,
693 pub integrity: BootIntegrity,
694 pub verdicts: Vec<Verdict>,
695}
696
697pub 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}