Skip to main content

bootintel_detectors/
lib.rs

1//! Client-side boot-log detectors for BootIntel.
2//!
3//! Mirrors the 9-detector streaming subset of the browser detector
4//! library at bootintel.com/tools/fingerprint. Kept pure (no I/O, no
5//! async, no serde) so this crate can be reused in other contexts —
6//! a future WASM build for the browser tool, a plugin for third-party
7//! firmware-analysis tooling, etc.
8//!
9//! Sync discipline: each detector below is a one-for-one port of a
10//! browser detector entry. When adding, removing, or renaming a
11//! detector, keep both sources aligned.
12//!
13//! Regex flavor: only features supported by both JavaScript regex
14//! and Rust `regex` are used (no lookaround, no backreferences). If
15//! a future detector needs lookaround, pull in `fancy-regex` for
16//! just that detector — do not switch the whole library.
17
18use regex::Regex;
19use std::sync::LazyLock;
20
21/// A single detector's output.
22///
23/// Matches the browser tool's `Finding` type field-for-field so JSON
24/// output can be diffed against the browser tool's output byte-for-byte
25/// on the same input.
26#[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    /// 1-based line number of `source` within the analyzed log.
33    /// Populated by `analyze()`; `None` when the evidence could not be
34    /// located (or when a `Finding` is constructed by hand, e.g. from
35    /// an archived JSON envelope). Additive — existing consumers that
36    /// don't know about it are unaffected.
37    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
60/// Analyze a boot log against every detector; return findings in
61/// detector-registration order. Detectors that don't match are
62/// silently dropped.
63///
64/// # Line normalization
65///
66/// Several detectors are line-anchored (`(?m)^U-Boot`, `(?m)^GRUB`,
67/// `(?m)^coreboot-`, `(?m)^procd:`). Real captures very often carry a
68/// per-line prefix that defeats a bare `^`:
69///
70///   * `[12:34:56.789] ` — minicom / picocom / tio timestamping
71///   * `[2026-09-23T10:00:00.000Z] ` — **our own** `--log-timestamps`
72///   * `[    0.000000] ` — kernel printk timestamps
73///   * `\x1b[32m` — ANSI colour from a colourising bootloader
74///
75/// Before `--log-timestamps` existed this was merely common; now the
76/// tool's own capture mode breaks its own `scan`, which is why the
77/// normalization lives here rather than being pushed onto callers.
78///
79/// So: split into lines, strip ANSI CSI sequences and any run of
80/// leading bracketed timestamps, and run the detectors over the
81/// normalized text. Evidence is then mapped back to the **original**
82/// (unmodified) line, so `source` always shows the user what their
83/// capture actually contained, prefix and all.
84///
85/// Ported from the legacy Node analyzer's `analyze()` so both
86/// implementations agree on what a "line" is and what gets stripped.
87pub 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
98/// Split on any of CRLF / LF / CR. `str::lines()` only handles LF and
99/// CRLF; a bare-CR stream (some bootloaders emit CR-only line endings)
100/// would otherwise arrive as one giant line and defeat every
101/// line-anchored detector.
102fn 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                // CRLF counts as one terminator.
117                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
131/// ANSI CSI escape sequence: ESC `[` params intermediates final.
132/// Same character classes as the Node analyzer's
133/// `/\x1b\[[0-?]*[ -/]*[@-~]/g`.
134static RE_ANSI_CSI: LazyLock<Regex> =
135    LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
136
137/// One leading bracketed timestamp. Three accepted shapes, matching
138/// the Node analyzer:
139///   * `[12:34:56]` / `[12:34:56.789]`     — wall-clock terminal logger
140///   * `[2026-09-23T10:00:00.000Z]`        — ISO-8601 (our --log-timestamps)
141///   * `[    0.000000]`                    — kernel printk seconds
142static 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
149/// Strip ANSI CSI sequences and leading bracketed timestamps from one
150/// line. The timestamp strip loops: `bootintel analyze --log-timestamps`
151/// over a Linux console produces *two* stacked prefixes
152/// (`[2026-…Z] [    0.000000] Linux version …`), and the Node
153/// analyzer's single-shot strip would leave the second one in place.
154fn normalize_line(line: &str) -> String {
155    let mut s = RE_ANSI_CSI.replace_all(line, "").into_owned();
156    // Bounded loop — a pathological line of nothing but bracketed
157    // timestamps must not spin forever.
158    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
169/// Point a finding's `source` at the original, unmodified line that
170/// produced it, and record that line's 1-based number.
171///
172/// Detectors return the matched substring as `source`, taken from the
173/// normalized text. Locating it is a plain substring search over the
174/// normalized lines — far cheaper than the Node analyzer's re-run of
175/// every detector against every line, and exact for the same reason
176/// (the needle came out of that text verbatim).
177fn attach_evidence(mut f: Finding, original: &[&str], normalized: &[String]) -> Finding {
178    let Some(src) = f.source.clone() else {
179        return f;
180    };
181    // A few regexes (`[^)]+`) can span a newline; anchor on the first
182    // physical line of the match.
183    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
194/// Return the labels of every registered detector, in order. Used by
195/// the sync-check script + `bootintel version`.
196pub fn detector_labels() -> Vec<&'static str> {
197    ALL_DETECTORS.iter().map(|d| d.label).collect()
198}
199
200/// A canned MIPS OpenWrt boot log used by tests and by `--sample`
201/// on the CLI. Byte-for-byte identical to the SAMPLE constant in the
202/// browser detector library so browser + CLI output can be diffed
203/// against each other.
204pub 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
223/// Labels that mark a finding as a **critical exposure** — surfaced
224/// in scan text output, batch rollups, HTML/SARIF/JUnit rendering,
225/// `--gate-critical`, and the TUI's critical highlight. Kept as one
226/// list here so scan + batch + output + tui + analyze all agree on
227/// what "critical" means without drifting.
228pub const CRITICAL_LABELS: &[&str] = &["Autoboot interruptable", "Telnet exposure"];
229
230// ── Detector registry ────────────────────────────────────────────────
231//
232// Each entry mirrors a browser detector object. Preserve order + labels
233// so JSON output stays comparable between browser + CLI.
234
235struct 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
279// ── 1. Bootloader ────────────────────────────────────────────────────
280
281// U-Boot line pattern. Multiline `(?m)` so `^` matches the start of
282// any line, not just the whole log. Captures the version + build tag.
283static 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
331// ── 2. Kernel ────────────────────────────────────────────────────────
332
333static 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        // Match the TS slicing: detail truncated at 80, source at 200.
348        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
374// ── 3. CPU / Arch ────────────────────────────────────────────────────
375
376static 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
413// ── 4. Init system ───────────────────────────────────────────────────
414
415static 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
445// ── 5. Device family ─────────────────────────────────────────────────
446
447static 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        // Prefer the GCC-tag line, fall back to the release marker.
469        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
503// ── 6. Network ───────────────────────────────────────────────────────
504
505static 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
532// ── 7. Web admin ─────────────────────────────────────────────────────
533
534static 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
563// ── 8. Telnet exposure ───────────────────────────────────────────────
564
565static 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
580// ── 9. Autoboot interruptable ────────────────────────────────────────
581
582static 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        // Second regex is broader (matches 0 too) so we can capture the
590        // actual matched source line — matches the TS behavior which
591        // does the same two-step.
592        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}