1use regex::Regex;
19use std::sync::LazyLock;
20pub mod boot_chain;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Finding {
29 pub label: String,
30 pub value: String,
31 pub detail: Option<String>,
32 pub source: Option<String>,
33 pub line_number: Option<usize>,
39}
40
41impl Finding {
42 fn new(label: &str, value: impl Into<String>) -> Self {
43 Self {
44 label: label.to_string(),
45 value: value.into(),
46 detail: None,
47 source: None,
48 line_number: None,
49 }
50 }
51 fn detail(mut self, d: impl Into<String>) -> Self {
52 self.detail = Some(d.into());
53 self
54 }
55 fn source(mut self, s: impl Into<String>) -> Self {
56 self.source = Some(s.into());
57 self
58 }
59}
60
61pub fn analyze(log: &str) -> Vec<Finding> {
95 let original = split_lines(log);
96 let normalized_owned: Vec<String> = original.iter().map(|l| normalize_line(l)).collect();
97 let normalized: Vec<&str> = normalized_owned.iter().map(String::as_str).collect();
98 let norm_log = normalized.join("\n");
99 let orig_log = original.join("\n");
100 ALL_DETECTORS
101 .iter()
102 .filter_map(|d| {
103 let (joined, lines) = if ORIGINAL_INPUT_LABELS.contains(&d.label) {
104 (&orig_log, &original)
105 } else {
106 (&norm_log, &normalized)
107 };
108 let finding = (d.run)(joined)?;
109 Some(attach_evidence(d, finding, &original, lines))
110 })
111 .collect()
112}
113
114const ORIGINAL_INPUT_LABELS: &[&str] = &["Telnet exposure", "Autoboot interruptable"];
117
118fn split_lines(log: &str) -> Vec<&str> {
123 let mut out = Vec::new();
124 let bytes = log.as_bytes();
125 let mut start = 0usize;
126 let mut i = 0usize;
127 while i < bytes.len() {
128 match bytes[i] {
129 b'\n' => {
130 out.push(&log[start..i]);
131 i += 1;
132 start = i;
133 }
134 b'\r' => {
135 out.push(&log[start..i]);
136 i += if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
138 2
139 } else {
140 1
141 };
142 start = i;
143 }
144 _ => i += 1,
145 }
146 }
147 out.push(&log[start..]);
148 out
149}
150
151static RE_ANSI_CSI: LazyLock<Regex> =
155 LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
156
157static RE_LEADING_TS: LazyLock<Regex> = LazyLock::new(|| {
163 Regex::new(
164 r"^\s*\[(?:\d{2}:\d{2}:\d{2}(?:\.\d+)?|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?|\s*\d+\.\d+)\]\s*",
165 )
166 .unwrap()
167});
168
169fn normalize_line(line: &str) -> String {
175 let mut s = RE_ANSI_CSI.replace_all(line, "").into_owned();
176 for _ in 0..8 {
179 match RE_LEADING_TS.find(&s) {
180 Some(m) if m.end() > 0 => {
181 s = s[m.end()..].to_string();
182 }
183 _ => break,
184 }
185 }
186 s
187}
188
189fn attach_evidence(d: &Detector, mut f: Finding, original: &[&str], lines: &[&str]) -> Finding {
211 if let Some(i) = lines
212 .iter()
213 .position(|line| (d.run)(line).is_some_and(|c| c.value == f.value && c.detail == f.detail))
214 {
215 f.source = Some(original[i].to_string());
216 f.line_number = Some(i + 1);
217 }
218 f
219}
220
221pub fn detector_labels() -> Vec<&'static str> {
224 ALL_DETECTORS.iter().map(|d| d.label).collect()
225}
226
227pub const SAMPLE: &str = "U-Boot 2020.10 (Sep 17 2023 - 11:38:21 +0000)
232Model: TP-Link Archer C7 v5
233DRAM: 128 MiB
234NAND: ONFI device found
235Hit any key to stop autoboot: 3
236[ 0.000000] Linux version 5.15.137 (builder@buildhost) (mips-openwrt-linux-musl-gcc (OpenWrt GCC 11.2.0 r19685-512e76967f), GNU ld (GNU Binutils) 2.37) #0 SMP Tue Nov 14 19:23:42 2023
237[ 0.000000] CPU0 revision is: 00019374 (MIPS 74Kc)
238[ 0.000000] Determined physical RAM map:
239[ 0.000000] memory: 08000000 @ 00000000 (usable)
240[ 1.234567] mtd: device 0 (boot)
241[ 2.456789] eth0: PHY found at 0x00 (Atheros AR8327)
242[ 3.123456] procd: - early -
243[ 3.789012] procd: - init -
244[ 4.012345] hotplug2: Bootup detected
245[ 4.234567] dropbear[1234]: Not backgrounding
246[ 4.345678] uhttpd[1235]: Listening on 0.0.0.0:80 0.0.0.0:443
247[ 4.456789] dnsmasq[1236]: started, version 2.86 cachesize 150
248[ 5.012345] DHCP client bound to address 192.168.1.42";
249
250pub const CRITICAL_LABELS: &[&str] = &["Autoboot interruptable", "Telnet exposure"];
256
257struct Detector {
263 label: &'static str,
264 run: fn(&str) -> Option<Finding>,
265}
266
267static ALL_DETECTORS: &[Detector] = &[
268 Detector {
269 label: "Bootloader",
270 run: run_bootloader,
271 },
272 Detector {
273 label: "Runtime firmware",
274 run: run_runtime_firmware,
275 },
276 Detector {
277 label: "ROM identifier",
278 run: run_rom_identifier,
279 },
280 Detector {
281 label: "Firmware SDK",
282 run: run_firmware_sdk,
283 },
284 Detector {
285 label: "Kernel",
286 run: run_kernel,
287 },
288 Detector {
289 label: "CPU / Arch",
290 run: run_cpu_arch,
291 },
292 Detector {
293 label: "Userland",
294 run: run_userland,
295 },
296 Detector {
297 label: "Flash layout",
298 run: run_flash_layout,
299 },
300 Detector {
301 label: "Init system",
302 run: run_init_system,
303 },
304 Detector {
305 label: "Device family",
306 run: run_device_family,
307 },
308 Detector {
309 label: "Network",
310 run: run_network,
311 },
312 Detector {
313 label: "Web admin",
314 run: run_web_admin,
315 },
316 Detector {
317 label: "Telnet exposure",
318 run: run_telnet_exposure,
319 },
320 Detector {
321 label: "Autoboot interruptable",
322 run: run_autoboot_interruptable,
323 },
324];
325
326static RE_UBOOT: LazyLock<Regex> =
331 LazyLock::new(|| Regex::new(r"(?m)^U-Boot\s+(\S+(?:[-+][\w.+\-]+)?)\s+\(([^)]+)\)").unwrap());
332static RE_COREBOOT: LazyLock<Regex> =
333 LazyLock::new(|| Regex::new(r"(?m)^coreboot-([\w.\-]+)").unwrap());
334static RE_GRUB: LazyLock<Regex> =
335 LazyLock::new(|| Regex::new(r"(?m)^GRUB\s+(?:version\s+)?([\d.]+)").unwrap());
336static RE_ESP_ROM: LazyLock<Regex> =
337 LazyLock::new(|| Regex::new(r"(?i)(rst:0x1\s+\(POWERON_RESET\)|esp_image:|chip is)").unwrap());
338
339fn run_bootloader(log: &str) -> Option<Finding> {
340 if let Some(m) = RE_UBOOT.captures(log) {
341 let ver = m.get(1)?.as_str();
342 let build = m.get(2)?.as_str();
343 let source = m.get(0)?.as_str();
344 return Some(
345 Finding::new("Bootloader", format!("U-Boot {ver}"))
346 .detail(build)
347 .source(source),
348 );
349 }
350 if let Some(m) = RE_COREBOOT.captures(log) {
351 let ver = m.get(1)?.as_str();
352 let source = m.get(0)?.as_str();
353 return Some(Finding::new("Bootloader", format!("coreboot {ver}")).source(source));
354 }
355 if let Some(m) = RE_GRUB.captures(log) {
356 let ver = m.get(1)?.as_str();
357 let source = m.get(0)?.as_str();
358 return Some(Finding::new("Bootloader", format!("GRUB {ver}")).source(source));
359 }
360 if let Some(m) = RE_ESP_ROM.captures(log) {
361 let source = m.get(0)?.as_str();
362 return Some(
363 Finding::new("Bootloader", "Espressif ROM bootloader")
364 .detail("ESP8266/ESP32")
365 .source(source),
366 );
367 }
368 None
369}
370
371static RE_OPENSBI: LazyLock<Regex> = LazyLock::new(|| {
379 Regex::new(r"(?m)^\s*OpenSBI\s+v?(\d+(?:\.\d+)+(?:[-+][A-Za-z0-9_.+\-]+)?)").unwrap()
380});
381
382fn run_runtime_firmware(log: &str) -> Option<Finding> {
383 let m = RE_OPENSBI.captures(log)?;
384 let ver = m.get(1)?.as_str();
385 Some(Finding::new("Runtime firmware", format!("OpenSBI {ver}")))
386}
387
388static RE_ESP_ROM_ID: LazyLock<Regex> =
396 LazyLock::new(|| Regex::new(r"(?m)^\s*ESP-ROM:([A-Za-z0-9._+\-]+)").unwrap());
397
398fn run_rom_identifier(log: &str) -> Option<Finding> {
399 let m = RE_ESP_ROM_ID.captures(log)?;
400 let id = m.get(1)?.as_str();
401 Some(Finding::new(
402 "ROM identifier",
403 format!("Espressif ROM {id}"),
404 ))
405}
406
407static RE_ESP_IDF: LazyLock<Regex> = LazyLock::new(|| {
414 Regex::new(r"\bboot: ESP-IDF\s+([A-Za-z0-9._+\-]+)\s+2nd stage bootloader\b").unwrap()
415});
416
417fn run_firmware_sdk(log: &str) -> Option<Finding> {
418 let m = RE_ESP_IDF.captures(log)?;
419 let ver = m.get(1)?.as_str();
420 Some(Finding::new("Firmware SDK", format!("ESP-IDF {ver}")))
421}
422
423static RE_LINUX: LazyLock<Regex> = LazyLock::new(|| {
429 Regex::new(r"Linux version[ \t]+(\S+)(?:[ \t]+\([^\r\n)]+\)[ \t]+\(([^\r\n)]+)\))?").unwrap()
430});
431static RE_DARWIN: LazyLock<Regex> =
432 LazyLock::new(|| Regex::new(r"Darwin Kernel Version\s+([^:]+):").unwrap());
433static RE_FREERTOS: LazyLock<Regex> =
434 LazyLock::new(|| Regex::new(r"(?i)FreeRTOS\s+(?:Kernel\s+)?V?([\d.]+)").unwrap());
435static RE_ZEPHYR: LazyLock<Regex> =
436 LazyLock::new(|| Regex::new(r"\*\*\*\s+Booting Zephyr OS build\s+([\w.\-]+)").unwrap());
437
438fn run_kernel(log: &str) -> Option<Finding> {
439 if let Some(m) = RE_LINUX.captures(log) {
440 let ver = m.get(1)?.as_str();
441 let source_full = m.get(0)?.as_str();
442 let source: String = source_full.chars().take(200).collect();
444 let mut f = Finding::new("Kernel", format!("Linux {ver}")).source(source);
445 if let Some(toolchain) = m.get(2) {
446 f = f.detail(toolchain.as_str().chars().take(80).collect::<String>());
447 }
448 return Some(f);
449 }
450 if let Some(m) = RE_DARWIN.captures(log) {
451 let ver = m.get(1)?.as_str().trim();
452 let source = m.get(0)?.as_str();
453 return Some(Finding::new("Kernel", format!("Darwin {ver}")).source(source));
454 }
455 if let Some(m) = RE_FREERTOS.captures(log) {
456 let ver = m.get(1)?.as_str();
457 let source = m.get(0)?.as_str();
458 return Some(Finding::new("Kernel", format!("FreeRTOS {ver}")).source(source));
459 }
460 if let Some(m) = RE_ZEPHYR.captures(log) {
461 let ver = m.get(1)?.as_str();
462 let source = m.get(0)?.as_str();
463 return Some(Finding::new("Kernel", format!("Zephyr {ver}")).source(source));
464 }
465 None
466}
467
468static RE_MIPS: LazyLock<Regex> = LazyLock::new(|| {
471 Regex::new(r"(?i)CPU\s*\d?\s+revision is:\s+\w+\s+\((MIPS\s+[\w\-]+)\)").unwrap()
472});
473static RE_ARM64: LazyLock<Regex> = LazyLock::new(|| {
474 Regex::new(r"(?i)Booting Linux on physical CPU.*aarch64|Linux version.*aarch64").unwrap()
475});
476static RE_ARMV7: LazyLock<Regex> =
477 LazyLock::new(|| Regex::new(r"(?i)CPU:\s+ARMv7|Linux version.*\barmv7l\b").unwrap());
478static RE_RISCV: LazyLock<Regex> =
479 LazyLock::new(|| Regex::new(r"(?i)Linux version.*riscv|hart\s+\d+:\s+running").unwrap());
480static RE_X86: LazyLock<Regex> =
481 LazyLock::new(|| Regex::new(r"(?i)Linux version.*x86_64").unwrap());
482static RE_XTENSA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)\bxtensa\b").unwrap());
486
487fn run_cpu_arch(log: &str) -> Option<Finding> {
488 if let Some(m) = RE_MIPS.captures(log) {
489 return Some(Finding::new("CPU / Arch", m.get(1)?.as_str().to_string()));
490 }
491 if RE_ARM64.is_match(log) {
492 return Some(Finding::new("CPU / Arch", "ARM64 (aarch64)"));
493 }
494 if RE_ARMV7.is_match(log) {
495 return Some(Finding::new("CPU / Arch", "ARMv7 (32-bit)"));
496 }
497 if RE_RISCV.is_match(log) {
498 return Some(Finding::new("CPU / Arch", "RISC-V"));
499 }
500 if RE_X86.is_match(log) {
501 return Some(Finding::new("CPU / Arch", "x86_64"));
502 }
503 if RE_XTENSA.is_match(log) {
504 return Some(Finding::new("CPU / Arch", "Xtensa (ESP)"));
505 }
506 None
507}
508
509static RE_BUSYBOX: LazyLock<Regex> =
516 LazyLock::new(|| Regex::new(r"(?i)BusyBox\s+v?(\d[\d.]*)").unwrap());
517
518fn run_userland(log: &str) -> Option<Finding> {
519 let m = RE_BUSYBOX.captures(log)?;
520 let ver = m.get(1)?.as_str();
521 Some(Finding::new("Userland", format!("BusyBox {ver}")))
522}
523
524static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
535 Regex::new(r#"(?i)0x0*([0-9a-f]+)-0x0*([0-9a-f]+)\s*:\s*"([A-Za-z0-9_/.\-]+)""#).unwrap()
539});
540
541fn run_flash_layout(log: &str) -> Option<Finding> {
542 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
543 let mut named: Vec<String> = Vec::new();
544 for m in RE_MTD_PART.captures_iter(log) {
545 let (Some(start), Some(end), Some(name)) = (m.get(1), m.get(2), m.get(3)) else {
546 continue;
547 };
548 if !seen.insert(format!(
554 "{}-{}-{}",
555 start.as_str(),
556 end.as_str(),
557 name.as_str()
558 )) {
559 continue;
560 }
561 let size = hex_to_f64(end.as_str()) - hex_to_f64(start.as_str());
562 named.push(format!("{} ({})", name.as_str(), human_size(size)));
563 }
564 if named.is_empty() {
565 return None;
566 }
567 let plural = if named.len() == 1 { "" } else { "s" };
568 Some(
569 Finding::new("Flash layout", format!("{} partition{plural}", named.len()))
570 .detail(named.join(", ")),
571 )
572}
573
574fn hex_to_f64(hex: &str) -> f64 {
577 hex.chars().fold(0.0, |acc, c| {
578 acc * 16.0 + f64::from(c.to_digit(16).unwrap_or(0))
579 })
580}
581
582fn human_size(size: f64) -> String {
593 let kb = (size / 1024.0).round();
594 let kb = if kb == 0.0 { 0.0 } else { kb };
596 if kb < 1024.0 {
597 return format!("{kb}K");
598 }
599 let whole = kb as i128;
600 if whole % 1024 == 0 {
601 return format!("{}M", whole / 1024);
602 }
603 match whole
607 .checked_mul(20)
608 .and_then(|v| v.checked_add(1024))
609 .map(|v| v / 2048)
610 {
611 Some(tenths) => format!("{}.{}M", tenths / 10, tenths % 10),
612 None => format!("{:.1}M", kb / 1024.0),
613 }
614}
615
616static RE_PROCD_START: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^procd:").unwrap());
619static RE_PROCD_INIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"procd:\s+-\s+init").unwrap());
620static RE_SYSTEMD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)systemd\[1\]:").unwrap());
621static RE_SYSV: LazyLock<Regex> =
622 LazyLock::new(|| Regex::new(r"(?i)INIT:\s+version\s+([\d.]+)").unwrap());
623static RE_RUNIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"runit:|runit-init").unwrap());
624
625fn run_init_system(log: &str) -> Option<Finding> {
626 if RE_PROCD_START.is_match(log) || RE_PROCD_INIT.is_match(log) {
627 return Some(Finding::new("Init system", "procd").detail("OpenWrt-family"));
628 }
629 if RE_SYSTEMD.is_match(log) {
630 return Some(Finding::new("Init system", "systemd"));
631 }
632 if let Some(m) = RE_SYSV.captures(log) {
633 let ver = m.get(1)?.as_str();
634 return Some(Finding::new("Init system", format!("SysV init {ver}")));
635 }
636 if RE_RUNIT.is_match(log) {
637 return Some(Finding::new("Init system", "runit"));
638 }
639 None
640}
641
642static RE_OPENWRT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)openwrt").unwrap());
645static RE_OPENWRT_GCC: LazyLock<Regex> =
646 LazyLock::new(|| Regex::new(r"(?i)OpenWrt GCC[^)]*\d{4}-\w+").unwrap());
647static RE_OPENWRT_R: LazyLock<Regex> =
648 LazyLock::new(|| Regex::new(r"(?i)OpenWrt\s+(r\d+[\-\w]+)").unwrap());
649static RE_RPI: LazyLock<Regex> =
650 LazyLock::new(|| Regex::new(r"(?i)Raspberry\s*Pi|bcm27\d{2}|bcm28\d{2}").unwrap());
651static RE_BUILDROOT: LazyLock<Regex> =
652 LazyLock::new(|| Regex::new(r"(?i)buildroot|br-\d{4}").unwrap());
653static RE_YOCTO: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)yocto|poky-\w+").unwrap());
654static RE_ESP_FAM: LazyLock<Regex> =
655 LazyLock::new(|| Regex::new(r"(?i)(esp32|esp8266|esp_image)").unwrap());
656static RE_MEDIATEK: LazyLock<Regex> =
657 LazyLock::new(|| Regex::new(r"(?i)Mediatek|MT76\d{2}|MT79\d{2}").unwrap());
658static RE_QUALCOMM: LazyLock<Regex> =
659 LazyLock::new(|| Regex::new(r"(?i)Qualcomm|IPQ\d{4}").unwrap());
660static RE_ALLWINNER: LazyLock<Regex> =
661 LazyLock::new(|| Regex::new(r"(?i)(Allwinner|sunxi|H\d{1,3}\s+SoC)").unwrap());
662
663fn run_device_family(log: &str) -> Option<Finding> {
664 if RE_OPENWRT.is_match(log) {
665 let detail = RE_OPENWRT_GCC
667 .find(log)
668 .or_else(|| RE_OPENWRT_R.find(log))
669 .map(|m| m.as_str().chars().take(80).collect::<String>());
670 let mut f = Finding::new("Device family", "OpenWrt");
671 if let Some(d) = detail {
672 f = f.detail(d);
673 }
674 return Some(f);
675 }
676 if RE_RPI.is_match(log) {
677 return Some(Finding::new("Device family", "Raspberry Pi family"));
678 }
679 if RE_BUILDROOT.is_match(log) {
680 return Some(Finding::new("Device family", "Buildroot"));
681 }
682 if RE_YOCTO.is_match(log) {
683 return Some(Finding::new("Device family", "Yocto / Poky"));
684 }
685 if RE_ESP_FAM.is_match(log) {
686 return Some(Finding::new("Device family", "Espressif (ESP)"));
687 }
688 if RE_MEDIATEK.is_match(log) {
689 return Some(Finding::new("Device family", "MediaTek SoC"));
690 }
691 if RE_QUALCOMM.is_match(log) {
692 return Some(Finding::new("Device family", "Qualcomm IPQ"));
693 }
694 if RE_ALLWINNER.is_match(log) {
695 return Some(Finding::new("Device family", "Allwinner sunxi"));
696 }
697 None
698}
699
700static RE_DHCP: LazyLock<Regex> =
703 LazyLock::new(|| Regex::new(r"(?i)DHCP\s+(?:client\s+)?bound to address\s+([\d.]+)").unwrap());
704static RE_DNSMASQ: LazyLock<Regex> =
705 LazyLock::new(|| Regex::new(r"(?i)dnsmasq\[\d+\]:\s+started,\s+version\s+([\d.]+)").unwrap());
706static RE_DROPBEAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)dropbear\[\d+\]").unwrap());
707
708fn run_network(log: &str) -> Option<Finding> {
709 if let Some(m) = RE_DHCP.captures(log) {
710 let ip = m.get(1)?.as_str();
711 let source = m.get(0)?.as_str();
712 return Some(
713 Finding::new("Network", "DHCP client active")
714 .detail(format!("Lease: {ip}"))
715 .source(source),
716 );
717 }
718 if let Some(m) = RE_DNSMASQ.captures(log) {
719 let ver = m.get(1)?.as_str();
720 let source = m.get(0)?.as_str();
721 return Some(Finding::new("Network", format!("dnsmasq {ver} active")).source(source));
722 }
723 if let Some(m) = RE_DROPBEAR.find(log) {
724 return Some(Finding::new("Network", "Dropbear SSH started").source(m.as_str()));
725 }
726 None
727}
728
729static RE_UHTTPD: LazyLock<Regex> =
732 LazyLock::new(|| Regex::new(r"(?i)uhttpd\[\d+\]:\s+Listening on\s+([\d.:]+)").unwrap());
733static RE_LIGHTTPD: LazyLock<Regex> =
734 LazyLock::new(|| Regex::new(r"(?i)lighttpd/([\d.]+)").unwrap());
735static RE_NGINX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)nginx/([\d.]+)").unwrap());
736
737fn run_web_admin(log: &str) -> Option<Finding> {
738 if let Some(m) = RE_UHTTPD.captures(log) {
739 let listen = m.get(1)?.as_str();
740 let source = m.get(0)?.as_str();
741 return Some(
742 Finding::new("Web admin", "uhttpd active")
743 .detail(format!("Listen: {listen}"))
744 .source(source),
745 );
746 }
747 if let Some(m) = RE_LIGHTTPD.captures(log) {
748 let ver = m.get(1)?.as_str();
749 let source = m.get(0)?.as_str();
750 return Some(Finding::new("Web admin", format!("lighttpd {ver}")).source(source));
751 }
752 if let Some(m) = RE_NGINX.captures(log) {
753 let ver = m.get(1)?.as_str();
754 let source = m.get(0)?.as_str();
755 return Some(Finding::new("Web admin", format!("nginx {ver}")).source(source));
756 }
757 None
758}
759
760static RE_TELNET: LazyLock<Regex> = LazyLock::new(|| {
763 Regex::new(r"(?i)telnetd?\[\d+\]?[^\n]*?(?:listening|started|on\s+\d)").unwrap()
764});
765
766fn run_telnet_exposure(log: &str) -> Option<Finding> {
767 if let Some(m) = RE_TELNET.find(log) {
768 return Some(
769 Finding::new("Telnet exposure", "Telnet service started")
770 .detail("Clear-text, review immediately")
771 .source(m.as_str()),
772 );
773 }
774 None
775}
776
777static RE_AUTOBOOT_ANY: LazyLock<Regex> =
780 LazyLock::new(|| Regex::new(r"Hit any key to stop autoboot:\s*[1-9]").unwrap());
781static RE_AUTOBOOT_CAP: LazyLock<Regex> =
782 LazyLock::new(|| Regex::new(r"Hit any key to stop autoboot:\s*\d+").unwrap());
783
784fn run_autoboot_interruptable(log: &str) -> Option<Finding> {
785 if RE_AUTOBOOT_ANY.is_match(log) {
786 if let Some(m) = RE_AUTOBOOT_CAP.find(log) {
790 return Some(
791 Finding::new("Autoboot interruptable", "Yes")
792 .detail("U-Boot will accept any key during the countdown")
793 .source(m.as_str()),
794 );
795 }
796 }
797 None
798}