1use regex::Regex;
19use std::sync::LazyLock;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Finding {
28 pub label: String,
29 pub value: String,
30 pub detail: Option<String>,
31 pub source: Option<String>,
32 pub line_number: Option<usize>,
38}
39
40impl Finding {
41 fn new(label: &str, value: impl Into<String>) -> Self {
42 Self {
43 label: label.to_string(),
44 value: value.into(),
45 detail: None,
46 source: None,
47 line_number: None,
48 }
49 }
50 fn detail(mut self, d: impl Into<String>) -> Self {
51 self.detail = Some(d.into());
52 self
53 }
54 fn source(mut self, s: impl Into<String>) -> Self {
55 self.source = Some(s.into());
56 self
57 }
58}
59
60pub fn analyze(log: &str) -> Vec<Finding> {
88 let original = split_lines(log);
89 let normalized: Vec<String> = original.iter().map(|l| normalize_line(l)).collect();
90 let norm_log = normalized.join("\n");
91 ALL_DETECTORS
92 .iter()
93 .filter_map(|d| (d.run)(&norm_log))
94 .map(|f| attach_evidence(f, &original, &normalized))
95 .collect()
96}
97
98fn split_lines(log: &str) -> Vec<&str> {
103 let mut out = Vec::new();
104 let bytes = log.as_bytes();
105 let mut start = 0usize;
106 let mut i = 0usize;
107 while i < bytes.len() {
108 match bytes[i] {
109 b'\n' => {
110 out.push(&log[start..i]);
111 i += 1;
112 start = i;
113 }
114 b'\r' => {
115 out.push(&log[start..i]);
116 i += if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
118 2
119 } else {
120 1
121 };
122 start = i;
123 }
124 _ => i += 1,
125 }
126 }
127 out.push(&log[start..]);
128 out
129}
130
131static RE_ANSI_CSI: LazyLock<Regex> =
135 LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
136
137static RE_LEADING_TS: LazyLock<Regex> = LazyLock::new(|| {
143 Regex::new(
144 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*",
145 )
146 .unwrap()
147});
148
149fn normalize_line(line: &str) -> String {
155 let mut s = RE_ANSI_CSI.replace_all(line, "").into_owned();
156 for _ in 0..8 {
159 match RE_LEADING_TS.find(&s) {
160 Some(m) if m.end() > 0 => {
161 s = s[m.end()..].to_string();
162 }
163 _ => break,
164 }
165 }
166 s
167}
168
169fn attach_evidence(mut f: Finding, original: &[&str], normalized: &[String]) -> Finding {
178 let Some(src) = f.source.clone() else {
179 return f;
180 };
181 let needle = src.split('\n').next().unwrap_or(&src);
184 if needle.is_empty() {
185 return f;
186 }
187 if let Some(i) = normalized.iter().position(|l| l.contains(needle)) {
188 f.source = Some(original[i].to_string());
189 f.line_number = Some(i + 1);
190 }
191 f
192}
193
194pub fn detector_labels() -> Vec<&'static str> {
197 ALL_DETECTORS.iter().map(|d| d.label).collect()
198}
199
200pub const SAMPLE: &str = "U-Boot 2020.10 (Sep 17 2023 - 11:38:21 +0000)
205Model: TP-Link Archer C7 v5
206DRAM: 128 MiB
207NAND: ONFI device found
208Hit any key to stop autoboot: 3
209[ 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
210[ 0.000000] CPU0 revision is: 00019374 (MIPS 74Kc)
211[ 0.000000] Determined physical RAM map:
212[ 0.000000] memory: 08000000 @ 00000000 (usable)
213[ 1.234567] mtd: device 0 (boot)
214[ 2.456789] eth0: PHY found at 0x00 (Atheros AR8327)
215[ 3.123456] procd: - early -
216[ 3.789012] procd: - init -
217[ 4.012345] hotplug2: Bootup detected
218[ 4.234567] dropbear[1234]: Not backgrounding
219[ 4.345678] uhttpd[1235]: Listening on 0.0.0.0:80 0.0.0.0:443
220[ 4.456789] dnsmasq[1236]: started, version 2.86 cachesize 150
221[ 5.012345] DHCP client bound to address 192.168.1.42";
222
223pub const CRITICAL_LABELS: &[&str] = &["Autoboot interruptable", "Telnet exposure"];
229
230struct Detector {
236 label: &'static str,
237 run: fn(&str) -> Option<Finding>,
238}
239
240static ALL_DETECTORS: &[Detector] = &[
241 Detector {
242 label: "Bootloader",
243 run: run_bootloader,
244 },
245 Detector {
246 label: "Kernel",
247 run: run_kernel,
248 },
249 Detector {
250 label: "CPU / Arch",
251 run: run_cpu_arch,
252 },
253 Detector {
254 label: "Init system",
255 run: run_init_system,
256 },
257 Detector {
258 label: "Device family",
259 run: run_device_family,
260 },
261 Detector {
262 label: "Network",
263 run: run_network,
264 },
265 Detector {
266 label: "Web admin",
267 run: run_web_admin,
268 },
269 Detector {
270 label: "Telnet exposure",
271 run: run_telnet_exposure,
272 },
273 Detector {
274 label: "Autoboot interruptable",
275 run: run_autoboot_interruptable,
276 },
277];
278
279static RE_UBOOT: LazyLock<Regex> =
284 LazyLock::new(|| Regex::new(r"(?m)^U-Boot\s+(\S+(?:[-+][\w.+\-]+)?)\s+\(([^)]+)\)").unwrap());
285static RE_COREBOOT: LazyLock<Regex> =
286 LazyLock::new(|| Regex::new(r"(?m)^coreboot-([\w.\-]+)").unwrap());
287static RE_GRUB: LazyLock<Regex> =
288 LazyLock::new(|| Regex::new(r"(?m)^GRUB\s+(?:version\s+)?([\d.]+)").unwrap());
289static RE_OPENSBI: LazyLock<Regex> =
290 LazyLock::new(|| Regex::new(r"(?i)OpenSBI\s+v?([\d.]+)").unwrap());
291static RE_ESP_ROM: LazyLock<Regex> =
292 LazyLock::new(|| Regex::new(r"(?i)(rst:0x1\s+\(POWERON_RESET\)|esp_image:|chip is)").unwrap());
293
294fn run_bootloader(log: &str) -> Option<Finding> {
295 if let Some(m) = RE_UBOOT.captures(log) {
296 let ver = m.get(1)?.as_str();
297 let build = m.get(2)?.as_str();
298 let source = m.get(0)?.as_str();
299 return Some(
300 Finding::new("Bootloader", format!("U-Boot {ver}"))
301 .detail(build)
302 .source(source),
303 );
304 }
305 if let Some(m) = RE_COREBOOT.captures(log) {
306 let ver = m.get(1)?.as_str();
307 let source = m.get(0)?.as_str();
308 return Some(Finding::new("Bootloader", format!("coreboot {ver}")).source(source));
309 }
310 if let Some(m) = RE_GRUB.captures(log) {
311 let ver = m.get(1)?.as_str();
312 let source = m.get(0)?.as_str();
313 return Some(Finding::new("Bootloader", format!("GRUB {ver}")).source(source));
314 }
315 if let Some(m) = RE_OPENSBI.captures(log) {
316 let ver = m.get(1)?.as_str();
317 let source = m.get(0)?.as_str();
318 return Some(Finding::new("Bootloader", format!("OpenSBI {ver}")).source(source));
319 }
320 if let Some(m) = RE_ESP_ROM.captures(log) {
321 let source = m.get(0)?.as_str();
322 return Some(
323 Finding::new("Bootloader", "Espressif ROM bootloader")
324 .detail("ESP8266/ESP32")
325 .source(source),
326 );
327 }
328 None
329}
330
331static RE_LINUX: LazyLock<Regex> =
334 LazyLock::new(|| Regex::new(r"Linux version\s+(\S+)\s+\([^)]+\)\s+\(([^)]+)\)").unwrap());
335static RE_DARWIN: LazyLock<Regex> =
336 LazyLock::new(|| Regex::new(r"Darwin Kernel Version\s+([^:]+):").unwrap());
337static RE_FREERTOS: LazyLock<Regex> =
338 LazyLock::new(|| Regex::new(r"(?i)FreeRTOS\s+(?:Kernel\s+)?V?([\d.]+)").unwrap());
339static RE_ZEPHYR: LazyLock<Regex> =
340 LazyLock::new(|| Regex::new(r"\*\*\*\s+Booting Zephyr OS build\s+([\w.\-]+)").unwrap());
341
342fn run_kernel(log: &str) -> Option<Finding> {
343 if let Some(m) = RE_LINUX.captures(log) {
344 let ver = m.get(1)?.as_str();
345 let toolchain = m.get(2)?.as_str();
346 let source_full = m.get(0)?.as_str();
347 let detail: String = toolchain.chars().take(80).collect();
349 let source: String = source_full.chars().take(200).collect();
350 return Some(
351 Finding::new("Kernel", format!("Linux {ver}"))
352 .detail(detail)
353 .source(source),
354 );
355 }
356 if let Some(m) = RE_DARWIN.captures(log) {
357 let ver = m.get(1)?.as_str().trim();
358 let source = m.get(0)?.as_str();
359 return Some(Finding::new("Kernel", format!("Darwin {ver}")).source(source));
360 }
361 if let Some(m) = RE_FREERTOS.captures(log) {
362 let ver = m.get(1)?.as_str();
363 let source = m.get(0)?.as_str();
364 return Some(Finding::new("Kernel", format!("FreeRTOS {ver}")).source(source));
365 }
366 if let Some(m) = RE_ZEPHYR.captures(log) {
367 let ver = m.get(1)?.as_str();
368 let source = m.get(0)?.as_str();
369 return Some(Finding::new("Kernel", format!("Zephyr {ver}")).source(source));
370 }
371 None
372}
373
374static RE_MIPS: LazyLock<Regex> = LazyLock::new(|| {
377 Regex::new(r"(?i)CPU\s*\d?\s+revision is:\s+\w+\s+\((MIPS\s+[\w\-]+)\)").unwrap()
378});
379static RE_ARM64: LazyLock<Regex> = LazyLock::new(|| {
380 Regex::new(r"(?i)Booting Linux on physical CPU.*aarch64|Linux version.*aarch64").unwrap()
381});
382static RE_ARMV7: LazyLock<Regex> =
383 LazyLock::new(|| Regex::new(r"(?i)CPU:\s+ARMv7|Linux version.*\barmv7l\b").unwrap());
384static RE_RISCV: LazyLock<Regex> =
385 LazyLock::new(|| Regex::new(r"(?i)Linux version.*riscv|hart\s+\d+:\s+running").unwrap());
386static RE_X86: LazyLock<Regex> =
387 LazyLock::new(|| Regex::new(r"(?i)Linux version.*x86_64").unwrap());
388static RE_XTENSA: LazyLock<Regex> =
389 LazyLock::new(|| Regex::new(r"(?i)(xtensa|esp32|esp8266)").unwrap());
390
391fn run_cpu_arch(log: &str) -> Option<Finding> {
392 if let Some(m) = RE_MIPS.captures(log) {
393 return Some(Finding::new("CPU / Arch", m.get(1)?.as_str().to_string()));
394 }
395 if RE_ARM64.is_match(log) {
396 return Some(Finding::new("CPU / Arch", "ARM64 (aarch64)"));
397 }
398 if RE_ARMV7.is_match(log) {
399 return Some(Finding::new("CPU / Arch", "ARMv7 (32-bit)"));
400 }
401 if RE_RISCV.is_match(log) {
402 return Some(Finding::new("CPU / Arch", "RISC-V"));
403 }
404 if RE_X86.is_match(log) {
405 return Some(Finding::new("CPU / Arch", "x86_64"));
406 }
407 if RE_XTENSA.is_match(log) {
408 return Some(Finding::new("CPU / Arch", "Xtensa (ESP)"));
409 }
410 None
411}
412
413static RE_PROCD_START: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^procd:").unwrap());
416static RE_PROCD_INIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"procd:\s+-\s+init").unwrap());
417static RE_SYSTEMD: LazyLock<Regex> =
418 LazyLock::new(|| Regex::new(r"(?m)systemd\[1\]:|Welcome to \w+ Linux").unwrap());
419static RE_SYSV: LazyLock<Regex> =
420 LazyLock::new(|| Regex::new(r"(?i)INIT:\s+version\s+([\d.]+)").unwrap());
421static RE_BUSYBOX_INIT: LazyLock<Regex> =
422 LazyLock::new(|| Regex::new(r"BusyBox v[\d.]+\s+\([^)]+\)\s+built-in shell").unwrap());
423static RE_RUNIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"runit:|runit-init").unwrap());
424
425fn run_init_system(log: &str) -> Option<Finding> {
426 if RE_PROCD_START.is_match(log) || RE_PROCD_INIT.is_match(log) {
427 return Some(Finding::new("Init system", "procd").detail("OpenWrt-family"));
428 }
429 if RE_SYSTEMD.is_match(log) {
430 return Some(Finding::new("Init system", "systemd"));
431 }
432 if let Some(m) = RE_SYSV.captures(log) {
433 let ver = m.get(1)?.as_str();
434 return Some(Finding::new("Init system", format!("SysV init {ver}")));
435 }
436 if RE_BUSYBOX_INIT.is_match(log) {
437 return Some(Finding::new("Init system", "BusyBox init"));
438 }
439 if RE_RUNIT.is_match(log) {
440 return Some(Finding::new("Init system", "runit"));
441 }
442 None
443}
444
445static RE_OPENWRT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)openwrt").unwrap());
448static RE_OPENWRT_GCC: LazyLock<Regex> =
449 LazyLock::new(|| Regex::new(r"(?i)OpenWrt GCC[^)]*\d{4}-\w+").unwrap());
450static RE_OPENWRT_R: LazyLock<Regex> =
451 LazyLock::new(|| Regex::new(r"(?i)OpenWrt\s+(r\d+[\-\w]+)").unwrap());
452static RE_RPI: LazyLock<Regex> =
453 LazyLock::new(|| Regex::new(r"(?i)Raspberry\s*Pi|bcm27\d{2}|bcm28\d{2}").unwrap());
454static RE_BUILDROOT: LazyLock<Regex> =
455 LazyLock::new(|| Regex::new(r"(?i)buildroot|br-\d{4}").unwrap());
456static RE_YOCTO: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)yocto|poky-\w+").unwrap());
457static RE_ESP_FAM: LazyLock<Regex> =
458 LazyLock::new(|| Regex::new(r"(?i)(esp32|esp8266|esp_image)").unwrap());
459static RE_MEDIATEK: LazyLock<Regex> =
460 LazyLock::new(|| Regex::new(r"(?i)Mediatek|MT76\d{2}|MT79\d{2}").unwrap());
461static RE_QUALCOMM: LazyLock<Regex> =
462 LazyLock::new(|| Regex::new(r"(?i)Qualcomm|IPQ\d{4}").unwrap());
463static RE_ALLWINNER: LazyLock<Regex> =
464 LazyLock::new(|| Regex::new(r"(?i)(Allwinner|sunxi|H\d{1,3}\s+SoC)").unwrap());
465
466fn run_device_family(log: &str) -> Option<Finding> {
467 if RE_OPENWRT.is_match(log) {
468 let detail = RE_OPENWRT_GCC
470 .find(log)
471 .or_else(|| RE_OPENWRT_R.find(log))
472 .map(|m| m.as_str().chars().take(80).collect::<String>());
473 let mut f = Finding::new("Device family", "OpenWrt");
474 if let Some(d) = detail {
475 f = f.detail(d);
476 }
477 return Some(f);
478 }
479 if RE_RPI.is_match(log) {
480 return Some(Finding::new("Device family", "Raspberry Pi family"));
481 }
482 if RE_BUILDROOT.is_match(log) {
483 return Some(Finding::new("Device family", "Buildroot"));
484 }
485 if RE_YOCTO.is_match(log) {
486 return Some(Finding::new("Device family", "Yocto / Poky"));
487 }
488 if RE_ESP_FAM.is_match(log) {
489 return Some(Finding::new("Device family", "Espressif (ESP)"));
490 }
491 if RE_MEDIATEK.is_match(log) {
492 return Some(Finding::new("Device family", "MediaTek SoC"));
493 }
494 if RE_QUALCOMM.is_match(log) {
495 return Some(Finding::new("Device family", "Qualcomm IPQ"));
496 }
497 if RE_ALLWINNER.is_match(log) {
498 return Some(Finding::new("Device family", "Allwinner sunxi"));
499 }
500 None
501}
502
503static RE_DHCP: LazyLock<Regex> =
506 LazyLock::new(|| Regex::new(r"(?i)DHCP\s+(?:client\s+)?bound to address\s+([\d.]+)").unwrap());
507static RE_DNSMASQ: LazyLock<Regex> =
508 LazyLock::new(|| Regex::new(r"(?i)dnsmasq\[\d+\]:\s+started,\s+version\s+([\d.]+)").unwrap());
509static RE_DROPBEAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)dropbear\[\d+\]").unwrap());
510
511fn run_network(log: &str) -> Option<Finding> {
512 if let Some(m) = RE_DHCP.captures(log) {
513 let ip = m.get(1)?.as_str();
514 let source = m.get(0)?.as_str();
515 return Some(
516 Finding::new("Network", "DHCP client active")
517 .detail(format!("Lease: {ip}"))
518 .source(source),
519 );
520 }
521 if let Some(m) = RE_DNSMASQ.captures(log) {
522 let ver = m.get(1)?.as_str();
523 let source = m.get(0)?.as_str();
524 return Some(Finding::new("Network", format!("dnsmasq {ver} active")).source(source));
525 }
526 if let Some(m) = RE_DROPBEAR.find(log) {
527 return Some(Finding::new("Network", "Dropbear SSH started").source(m.as_str()));
528 }
529 None
530}
531
532static RE_UHTTPD: LazyLock<Regex> =
535 LazyLock::new(|| Regex::new(r"(?i)uhttpd\[\d+\]:\s+Listening on\s+([\d.:]+)").unwrap());
536static RE_LIGHTTPD: LazyLock<Regex> =
537 LazyLock::new(|| Regex::new(r"(?i)lighttpd/([\d.]+)").unwrap());
538static RE_NGINX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)nginx/([\d.]+)").unwrap());
539
540fn run_web_admin(log: &str) -> Option<Finding> {
541 if let Some(m) = RE_UHTTPD.captures(log) {
542 let listen = m.get(1)?.as_str();
543 let source = m.get(0)?.as_str();
544 return Some(
545 Finding::new("Web admin", "uhttpd active")
546 .detail(format!("Listen: {listen}"))
547 .source(source),
548 );
549 }
550 if let Some(m) = RE_LIGHTTPD.captures(log) {
551 let ver = m.get(1)?.as_str();
552 let source = m.get(0)?.as_str();
553 return Some(Finding::new("Web admin", format!("lighttpd {ver}")).source(source));
554 }
555 if let Some(m) = RE_NGINX.captures(log) {
556 let ver = m.get(1)?.as_str();
557 let source = m.get(0)?.as_str();
558 return Some(Finding::new("Web admin", format!("nginx {ver}")).source(source));
559 }
560 None
561}
562
563static RE_TELNET: LazyLock<Regex> = LazyLock::new(|| {
566 Regex::new(r"(?i)telnetd?\[\d+\]?[^\n]*?(?:listening|started|on\s+\d)").unwrap()
567});
568
569fn run_telnet_exposure(log: &str) -> Option<Finding> {
570 if let Some(m) = RE_TELNET.find(log) {
571 return Some(
572 Finding::new("Telnet exposure", "Telnet service started")
573 .detail("Clear-text, review immediately")
574 .source(m.as_str()),
575 );
576 }
577 None
578}
579
580static RE_AUTOBOOT_ANY: LazyLock<Regex> =
583 LazyLock::new(|| Regex::new(r"Hit any key to stop autoboot:\s*[1-9]").unwrap());
584static RE_AUTOBOOT_CAP: LazyLock<Regex> =
585 LazyLock::new(|| Regex::new(r"Hit any key to stop autoboot:\s*\d+").unwrap());
586
587fn run_autoboot_interruptable(log: &str) -> Option<Finding> {
588 if RE_AUTOBOOT_ANY.is_match(log) {
589 if let Some(m) = RE_AUTOBOOT_CAP.find(log) {
593 return Some(
594 Finding::new("Autoboot interruptable", "Yes")
595 .detail("U-Boot will accept any key during the countdown")
596 .source(m.as_str()),
597 );
598 }
599 }
600 None
601}