Skip to main content

bootintel_detectors/
lib.rs

1//! Client-side boot-log detectors for BootIntel.
2//!
3//! Mirrors the 14-detector browser detector library at
4//! 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;
20pub mod boot_chain;
21pub mod os_hardening;
22
23/// A single detector's output.
24///
25/// Matches the browser tool's `Finding` type field-for-field so JSON
26/// output can be diffed against the browser tool's output byte-for-byte
27/// on the same input.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Finding {
30    pub label: String,
31    pub value: String,
32    pub detail: Option<String>,
33    pub source: Option<String>,
34    /// 1-based line number of `source` within the analyzed log.
35    /// Populated by `analyze()`; `None` when the evidence could not be
36    /// located (or when a `Finding` is constructed by hand, e.g. from
37    /// an archived JSON envelope). Additive — existing consumers that
38    /// don't know about it are unaffected.
39    pub line_number: Option<usize>,
40}
41
42impl Finding {
43    fn new(label: &str, value: impl Into<String>) -> Self {
44        Self {
45            label: label.to_string(),
46            value: value.into(),
47            detail: None,
48            source: None,
49            line_number: None,
50        }
51    }
52    fn detail(mut self, d: impl Into<String>) -> Self {
53        self.detail = Some(d.into());
54        self
55    }
56    fn source(mut self, s: impl Into<String>) -> Self {
57        self.source = Some(s.into());
58        self
59    }
60}
61
62/// Analyze a boot log against every detector; return findings in
63/// detector-registration order. Detectors that don't match are
64/// silently dropped.
65///
66/// # Line normalization
67///
68/// Several detectors are line-anchored (`(?m)^U-Boot`, `(?m)^GRUB`,
69/// `(?m)^coreboot-`, `(?m)^procd:`). Real captures very often carry a
70/// per-line prefix that defeats a bare `^`:
71///
72///   * `[12:34:56.789] ` — minicom / picocom / tio timestamping
73///   * `[2026-09-23T10:00:00.000Z] ` — **our own** `--log-timestamps`
74///   * `[    0.000000] ` — kernel printk timestamps
75///   * `\x1b[32m` — ANSI colour from a colourising bootloader
76///
77/// Before `--log-timestamps` existed this was merely common; now the
78/// tool's own capture mode breaks its own `scan`, which is why the
79/// normalization lives here rather than being pushed onto callers.
80///
81/// So: split into lines, strip ANSI CSI sequences and any run of
82/// leading bracketed timestamps, and run the detectors over the
83/// normalized text. Evidence is then mapped back to the **original**
84/// (unmodified) line, so `source` always shows the user what their
85/// capture actually contained, prefix and all.
86///
87/// Ported from the browser `analyze()` so both implementations agree
88/// on what a "line" is and what gets stripped.
89///
90/// Two exceptions, mirroring the browser: the policy observations
91/// (`Telnet exposure`, `Autoboot interruptable`) run against the
92/// **original** lines. Normalizing is a presentation choice for the
93/// inventory detectors; a policy rule should see exactly what the
94/// capture contained.
95pub fn analyze(log: &str) -> Vec<Finding> {
96    let original = split_lines(log);
97    let normalized_owned: Vec<String> = original.iter().map(|l| normalize_line(l)).collect();
98    let normalized: Vec<&str> = normalized_owned.iter().map(String::as_str).collect();
99    let norm_log = normalized.join("\n");
100    let orig_log = original.join("\n");
101    ALL_DETECTORS
102        .iter()
103        .filter_map(|d| {
104            let (joined, lines) = if ORIGINAL_INPUT_LABELS.contains(&d.label) {
105                (&orig_log, &original)
106            } else {
107                (&norm_log, &normalized)
108            };
109            let finding = (d.run)(joined)?;
110            Some(attach_evidence(d, finding, &original, lines))
111        })
112        .collect()
113}
114
115/// Detectors that read the user's original lines instead of the
116/// normalized ones. Same two labels the browser exempts.
117const ORIGINAL_INPUT_LABELS: &[&str] = &["Telnet exposure", "Autoboot interruptable"];
118
119/// Split on any of CRLF / LF / CR. `str::lines()` only handles LF and
120/// CRLF; a bare-CR stream (some bootloaders emit CR-only line endings)
121/// would otherwise arrive as one giant line and defeat every
122/// line-anchored detector.
123fn split_lines(log: &str) -> Vec<&str> {
124    let mut out = Vec::new();
125    let bytes = log.as_bytes();
126    let mut start = 0usize;
127    let mut i = 0usize;
128    while i < bytes.len() {
129        match bytes[i] {
130            b'\n' => {
131                out.push(&log[start..i]);
132                i += 1;
133                start = i;
134            }
135            b'\r' => {
136                out.push(&log[start..i]);
137                // CRLF counts as one terminator.
138                i += if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
139                    2
140                } else {
141                    1
142                };
143                start = i;
144            }
145            _ => i += 1,
146        }
147    }
148    out.push(&log[start..]);
149    out
150}
151
152/// ANSI CSI escape sequence: ESC `[` params intermediates final.
153/// Same character classes as the Node analyzer's
154/// `/\x1b\[[0-?]*[ -/]*[@-~]/g`.
155static RE_ANSI_CSI: LazyLock<Regex> =
156    LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
157
158/// One leading bracketed timestamp. Three accepted shapes, matching
159/// the Node analyzer:
160///   * `[12:34:56]` / `[12:34:56.789]`     — wall-clock terminal logger
161///   * `[2026-09-23T10:00:00.000Z]`        — ISO-8601 (our --log-timestamps)
162///   * `[    0.000000]`                    — kernel printk seconds
163static RE_LEADING_TS: LazyLock<Regex> = LazyLock::new(|| {
164    Regex::new(
165        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*",
166    )
167    .unwrap()
168});
169
170/// Strip ANSI CSI sequences and leading bracketed timestamps from one
171/// line. The timestamp strip loops: `bootintel analyze --log-timestamps`
172/// over a Linux console produces *two* stacked prefixes
173/// (`[2026-…Z] [    0.000000] Linux version …`), and the Node
174/// analyzer's single-shot strip would leave the second one in place.
175fn normalize_line(line: &str) -> String {
176    let mut s = RE_ANSI_CSI.replace_all(line, "").into_owned();
177    // Bounded loop — a pathological line of nothing but bracketed
178    // timestamps must not spin forever.
179    for _ in 0..8 {
180        match RE_LEADING_TS.find(&s) {
181            Some(m) if m.end() > 0 => {
182                s = s[m.end()..].to_string();
183            }
184            _ => break,
185        }
186    }
187    s
188}
189
190/// Point a finding's `source` at the original, unmodified line that
191/// produced it, and record that line's 1-based number.
192///
193/// `source` and `line_number` are **derived**, never set by the
194/// detector: the detector is re-run against each line on its own and
195/// the first line that reproduces the same `value` + `detail` is the
196/// originating evidence. Byte-for-byte the browser's algorithm, which
197/// matters for two reasons:
198///
199///   * a detector that set its own `source` from a whole-log match
200///     could point at text that is not on any single line, breaking
201///     the "source is the original line" contract the CSV/JSON
202///     consumers rely on;
203///   * an **aggregate** detector (`Flash layout`, whose `value` counts
204///     partitions across many lines) cannot be reproduced from one
205///     line, so no line matches and the finding correctly ends up with
206///     no `source` / `line_number` — the evidence is the whole table.
207///
208/// When no line reproduces the finding, whatever `source` the detector
209/// itself recorded is left in place (also the browser's behavior: it
210/// spreads the located evidence over the finding only when found).
211fn attach_evidence(d: &Detector, mut f: Finding, original: &[&str], lines: &[&str]) -> Finding {
212    if let Some(i) = lines
213        .iter()
214        .position(|line| (d.run)(line).is_some_and(|c| c.value == f.value && c.detail == f.detail))
215    {
216        f.source = Some(original[i].to_string());
217        f.line_number = Some(i + 1);
218    }
219    f
220}
221
222/// Return the labels of every registered detector, in order. Used by
223/// the sync-check script + `bootintel version`.
224pub fn detector_labels() -> Vec<&'static str> {
225    ALL_DETECTORS.iter().map(|d| d.label).collect()
226}
227
228/// A canned MIPS OpenWrt boot log used by tests and by `--sample`
229/// on the CLI. Byte-for-byte identical to the SAMPLE constant in the
230/// browser detector library so browser + CLI output can be diffed
231/// against each other.
232pub const SAMPLE: &str = "U-Boot 2020.10 (Sep 17 2023 - 11:38:21 +0000)
233Model: TP-Link Archer C7 v5
234DRAM:  128 MiB
235NAND:  ONFI device found
236Hit any key to stop autoboot:  3
237[    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
238[    0.000000] CPU0 revision is: 00019374 (MIPS 74Kc)
239[    0.000000] Determined physical RAM map:
240[    0.000000]  memory: 08000000 @ 00000000 (usable)
241[    1.234567] mtd: device 0 (boot)
242[    2.456789] eth0: PHY found at 0x00 (Atheros AR8327)
243[    3.123456] procd: - early -
244[    3.789012] procd: - init -
245[    4.012345] hotplug2: Bootup detected
246[    4.234567] dropbear[1234]: Not backgrounding
247[    4.345678] uhttpd[1235]: Listening on 0.0.0.0:80 0.0.0.0:443
248[    4.456789] dnsmasq[1236]: started, version 2.86 cachesize 150
249[    5.012345] DHCP client bound to address 192.168.1.42";
250
251/// Labels that mark a finding as a **critical exposure** — surfaced
252/// in scan text output, batch rollups, HTML/SARIF/JUnit rendering,
253/// `--gate-critical`, and the TUI's critical highlight. Kept as one
254/// list here so scan + batch + output + tui + analyze all agree on
255/// what "critical" means without drifting.
256pub const CRITICAL_LABELS: &[&str] = &["Autoboot interruptable", "Telnet exposure"];
257
258// ── Detector registry ────────────────────────────────────────────────
259//
260// Each entry mirrors a browser detector object. Preserve order + labels
261// so JSON output stays comparable between browser + CLI.
262
263struct Detector {
264    label: &'static str,
265    run: fn(&str) -> Option<Finding>,
266}
267
268static ALL_DETECTORS: &[Detector] = &[
269    Detector {
270        label: "Bootloader",
271        run: run_bootloader,
272    },
273    Detector {
274        label: "Runtime firmware",
275        run: run_runtime_firmware,
276    },
277    Detector {
278        label: "ROM identifier",
279        run: run_rom_identifier,
280    },
281    Detector {
282        label: "Firmware SDK",
283        run: run_firmware_sdk,
284    },
285    Detector {
286        label: "Kernel",
287        run: run_kernel,
288    },
289    Detector {
290        label: "CPU / Arch",
291        run: run_cpu_arch,
292    },
293    Detector {
294        label: "Userland",
295        run: run_userland,
296    },
297    Detector {
298        label: "Flash layout",
299        run: run_flash_layout,
300    },
301    Detector {
302        label: "Init system",
303        run: run_init_system,
304    },
305    Detector {
306        label: "Device family",
307        run: run_device_family,
308    },
309    Detector {
310        label: "Network",
311        run: run_network,
312    },
313    Detector {
314        label: "Web admin",
315        run: run_web_admin,
316    },
317    Detector {
318        label: "Telnet exposure",
319        run: run_telnet_exposure,
320    },
321    Detector {
322        label: "Autoboot interruptable",
323        run: run_autoboot_interruptable,
324    },
325];
326
327// ── 1. Bootloader ────────────────────────────────────────────────────
328
329// U-Boot line pattern. Multiline `(?m)` so `^` matches the start of
330// any line, not just the whole log. Captures the version + build tag.
331static RE_UBOOT: LazyLock<Regex> =
332    LazyLock::new(|| Regex::new(r"(?m)^U-Boot\s+(\S+(?:[-+][\w.+\-]+)?)\s+\(([^)]+)\)").unwrap());
333static RE_COREBOOT: LazyLock<Regex> =
334    LazyLock::new(|| Regex::new(r"(?m)^coreboot-([\w.\-]+)").unwrap());
335static RE_GRUB: LazyLock<Regex> =
336    LazyLock::new(|| Regex::new(r"(?m)^GRUB\s+(?:version\s+)?([\d.]+)").unwrap());
337static RE_ESP_ROM: LazyLock<Regex> =
338    LazyLock::new(|| Regex::new(r"(?i)(rst:0x1\s+\(POWERON_RESET\)|esp_image:|chip is)").unwrap());
339
340fn run_bootloader(log: &str) -> Option<Finding> {
341    if let Some(m) = RE_UBOOT.captures(log) {
342        let ver = m.get(1)?.as_str();
343        let build = m.get(2)?.as_str();
344        let source = m.get(0)?.as_str();
345        return Some(
346            Finding::new("Bootloader", format!("U-Boot {ver}"))
347                .detail(build)
348                .source(source),
349        );
350    }
351    if let Some(m) = RE_COREBOOT.captures(log) {
352        let ver = m.get(1)?.as_str();
353        let source = m.get(0)?.as_str();
354        return Some(Finding::new("Bootloader", format!("coreboot {ver}")).source(source));
355    }
356    if let Some(m) = RE_GRUB.captures(log) {
357        let ver = m.get(1)?.as_str();
358        let source = m.get(0)?.as_str();
359        return Some(Finding::new("Bootloader", format!("GRUB {ver}")).source(source));
360    }
361    if let Some(m) = RE_ESP_ROM.captures(log) {
362        let source = m.get(0)?.as_str();
363        return Some(
364            Finding::new("Bootloader", "Espressif ROM bootloader")
365                .detail("ESP8266/ESP32")
366                .source(source),
367        );
368    }
369    None
370}
371
372// ── 2. Runtime firmware ──────────────────────────────────────────────
373//
374// OpenSBI is the RISC-V M-mode runtime that hands off to U-Boot, not a
375// bootloader — a RISC-V board reports both, and folding OpenSBI into
376// `Bootloader` (as this crate used to) both mislabeled it and hid
377// whichever of the two lost the precedence race.
378
379static RE_OPENSBI: LazyLock<Regex> = LazyLock::new(|| {
380    Regex::new(r"(?m)^\s*OpenSBI\s+v?(\d+(?:\.\d+)+(?:[-+][A-Za-z0-9_.+\-]+)?)").unwrap()
381});
382
383fn run_runtime_firmware(log: &str) -> Option<Finding> {
384    let m = RE_OPENSBI.captures(log)?;
385    let ver = m.get(1)?.as_str();
386    Some(Finding::new("Runtime firmware", format!("OpenSBI {ver}")))
387}
388
389// ── 3. ROM identifier ────────────────────────────────────────────────
390//
391// The mask-ROM build stamp an ESP prints before anything else
392// (`ESP-ROM:esp32s3-20210327`). It identifies the silicon revision's
393// ROM image, which is what a ROM-level exploit is written against —
394// separate from the `Bootloader` finding the same log also produces.
395
396static RE_ESP_ROM_ID: LazyLock<Regex> =
397    LazyLock::new(|| Regex::new(r"(?m)^\s*ESP-ROM:([A-Za-z0-9._+\-]+)").unwrap());
398
399fn run_rom_identifier(log: &str) -> Option<Finding> {
400    let m = RE_ESP_ROM_ID.captures(log)?;
401    let id = m.get(1)?.as_str();
402    Some(Finding::new(
403        "ROM identifier",
404        format!("Espressif ROM {id}"),
405    ))
406}
407
408// ── 4. Firmware SDK ──────────────────────────────────────────────────
409//
410// ESP-IDF version out of the 2nd-stage bootloader banner. The SDK
411// version is the CVE-relevant identifier on an ESP target — the ROM
412// stamp above rarely moves, the SDK does.
413
414static RE_ESP_IDF: LazyLock<Regex> = LazyLock::new(|| {
415    Regex::new(r"\bboot: ESP-IDF\s+([A-Za-z0-9._+\-]+)\s+2nd stage bootloader\b").unwrap()
416});
417
418fn run_firmware_sdk(log: &str) -> Option<Finding> {
419    let m = RE_ESP_IDF.captures(log)?;
420    let ver = m.get(1)?.as_str();
421    Some(Finding::new("Firmware SDK", format!("ESP-IDF {ver}")))
422}
423
424// ── 5. Kernel ────────────────────────────────────────────────────────
425
426// The build-metadata parentheses are optional: plenty of vendor kernels
427// print `Linux version 3.0.15-ts-armv7l` and stop. `[ \t]` rather than
428// `\s` so the match cannot run past the end of the banner line.
429static RE_LINUX: LazyLock<Regex> = LazyLock::new(|| {
430    Regex::new(r"Linux version[ \t]+(\S+)(?:[ \t]+\([^\r\n)]+\)[ \t]+\(([^\r\n)]+)\))?").unwrap()
431});
432static RE_DARWIN: LazyLock<Regex> =
433    LazyLock::new(|| Regex::new(r"Darwin Kernel Version\s+([^:]+):").unwrap());
434static RE_FREERTOS: LazyLock<Regex> =
435    LazyLock::new(|| Regex::new(r"(?i)FreeRTOS\s+(?:Kernel\s+)?V?([\d.]+)").unwrap());
436static RE_ZEPHYR: LazyLock<Regex> =
437    LazyLock::new(|| Regex::new(r"\*\*\*\s+Booting Zephyr OS build\s+([\w.\-]+)").unwrap());
438
439fn run_kernel(log: &str) -> Option<Finding> {
440    if let Some(m) = RE_LINUX.captures(log) {
441        let ver = m.get(1)?.as_str();
442        let source_full = m.get(0)?.as_str();
443        // Match the browser slicing: detail truncated at 80, source at 200.
444        let source: String = source_full.chars().take(200).collect();
445        let mut f = Finding::new("Kernel", format!("Linux {ver}")).source(source);
446        if let Some(toolchain) = m.get(2) {
447            f = f.detail(toolchain.as_str().chars().take(80).collect::<String>());
448        }
449        return Some(f);
450    }
451    if let Some(m) = RE_DARWIN.captures(log) {
452        let ver = m.get(1)?.as_str().trim();
453        let source = m.get(0)?.as_str();
454        return Some(Finding::new("Kernel", format!("Darwin {ver}")).source(source));
455    }
456    if let Some(m) = RE_FREERTOS.captures(log) {
457        let ver = m.get(1)?.as_str();
458        let source = m.get(0)?.as_str();
459        return Some(Finding::new("Kernel", format!("FreeRTOS {ver}")).source(source));
460    }
461    if let Some(m) = RE_ZEPHYR.captures(log) {
462        let ver = m.get(1)?.as_str();
463        let source = m.get(0)?.as_str();
464        return Some(Finding::new("Kernel", format!("Zephyr {ver}")).source(source));
465    }
466    None
467}
468
469// ── 6. CPU / Arch ────────────────────────────────────────────────────
470
471static RE_MIPS: LazyLock<Regex> = LazyLock::new(|| {
472    Regex::new(r"(?i)CPU\s*\d?\s+revision is:\s+\w+\s+\((MIPS\s+[\w\-]+)\)").unwrap()
473});
474static RE_ARM64: LazyLock<Regex> = LazyLock::new(|| {
475    Regex::new(r"(?i)Booting Linux on physical CPU.*aarch64|Linux version.*aarch64").unwrap()
476});
477static RE_ARMV7: LazyLock<Regex> =
478    LazyLock::new(|| Regex::new(r"(?i)CPU:\s+ARMv7|Linux version.*\barmv7l\b").unwrap());
479static RE_RISCV: LazyLock<Regex> =
480    LazyLock::new(|| Regex::new(r"(?i)Linux version.*riscv|hart\s+\d+:\s+running").unwrap());
481static RE_X86: LazyLock<Regex> =
482    LazyLock::new(|| Regex::new(r"(?i)Linux version.*x86_64").unwrap());
483// Only the literal architecture name. `esp32` / `esp8266` are a device
484// family, not a CPU, and are reported as such by `Device family` —
485// matching them here made the CLI claim an arch the browser did not.
486static RE_XTENSA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)\bxtensa\b").unwrap());
487
488fn run_cpu_arch(log: &str) -> Option<Finding> {
489    if let Some(m) = RE_MIPS.captures(log) {
490        return Some(Finding::new("CPU / Arch", m.get(1)?.as_str().to_string()));
491    }
492    if RE_ARM64.is_match(log) {
493        return Some(Finding::new("CPU / Arch", "ARM64 (aarch64)"));
494    }
495    if RE_ARMV7.is_match(log) {
496        return Some(Finding::new("CPU / Arch", "ARMv7 (32-bit)"));
497    }
498    if RE_RISCV.is_match(log) {
499        return Some(Finding::new("CPU / Arch", "RISC-V"));
500    }
501    if RE_X86.is_match(log) {
502        return Some(Finding::new("CPU / Arch", "x86_64"));
503    }
504    if RE_XTENSA.is_match(log) {
505        return Some(Finding::new("CPU / Arch", "Xtensa (ESP)"));
506    }
507    None
508}
509
510// ── 7. Userland ──────────────────────────────────────────────────────
511//
512// BusyBox prints its version on 7 of the 31 public corpus captures and
513// this crate reported it zero times, so the offline story was missing
514// the most common userland component in the category.
515
516static RE_BUSYBOX: LazyLock<Regex> =
517    LazyLock::new(|| Regex::new(r"(?i)BusyBox\s+v?(\d[\d.]*)").unwrap());
518
519fn run_userland(log: &str) -> Option<Finding> {
520    let m = RE_BUSYBOX.captures(log)?;
521    let ver = m.get(1)?.as_str();
522    Some(Finding::new("Userland", format!("BusyBox {ver}")))
523}
524
525// ── 8. Flash layout ──────────────────────────────────────────────────
526//
527// The partition map is what a flash-clip read needs, and it appears in
528// roughly half the corpus. Offsets come straight from the kernel's own
529// MTD registration lines.
530//
531// The one **aggregate** detector: it folds many lines into a single
532// finding whose `value` counts partitions, so no single line reproduces
533// it and `attach_evidence` leaves it without a `source` / `line_number`.
534
535static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
536    // Character class spelled out rather than `\w` so it means the same
537    // set as the browser's `[\w/.-]` (ASCII) instead of Rust's
538    // Unicode-aware `\w`.
539    Regex::new(r#"(?i)0x0*([0-9a-f]+)-0x0*([0-9a-f]+)\s*:\s*"([A-Za-z0-9_/.\-]+)""#).unwrap()
540});
541
542fn run_flash_layout(log: &str) -> Option<Finding> {
543    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
544    let mut named: Vec<String> = Vec::new();
545    for m in RE_MTD_PART.captures_iter(log) {
546        let (Some(start), Some(end), Some(name)) = (m.get(1), m.get(2), m.get(3)) else {
547            continue;
548        };
549        // A capture can print the partition table more than once (a
550        // reset loop, or two flash devices registering). Key on the
551        // exact offsets so the same region is listed once rather than
552        // inflating the count — bootintel-9.txt reports 14 partitions
553        // without this for a device that has 7.
554        if !seen.insert(format!(
555            "{}-{}-{}",
556            start.as_str(),
557            end.as_str(),
558            name.as_str()
559        )) {
560            continue;
561        }
562        let size = hex_to_f64(end.as_str()) - hex_to_f64(start.as_str());
563        named.push(format!("{} ({})", name.as_str(), human_size(size)));
564    }
565    if named.is_empty() {
566        return None;
567    }
568    let plural = if named.len() == 1 { "" } else { "s" };
569    Some(
570        Finding::new("Flash layout", format!("{} partition{plural}", named.len()))
571            .detail(named.join(", ")),
572    )
573}
574
575/// `parseInt(hex, 16)` in f64, so a hostile line with a 40-digit offset
576/// widens to infinity the way the browser does instead of overflowing.
577fn hex_to_f64(hex: &str) -> f64 {
578    hex.chars().fold(0.0, |acc, c| {
579        acc * 16.0 + f64::from(c.to_digit(16).unwrap_or(0))
580    })
581}
582
583/// Render a partition size the way the browser does: whole kibibytes,
584/// or mebibytes with one decimal place when it isn't a whole MiB.
585///
586/// The decimal is computed in integer tenths rather than with `{:.1}`
587/// because the two languages break ties differently: JavaScript's
588/// `toFixed` rounds a tie away from zero (1.25 → "1.3") while Rust's
589/// formatter rounds to even (1.25 → "1.2"). Partition tables hit exact
590/// ties routinely — any whole 1.25 MiB region does it, e.g. the 1280 KiB
591/// `kernel` partition in sample bootintel-12.txt — so the naive version
592/// diverges from the browser on real corpus logs.
593fn human_size(size: f64) -> String {
594    let kb = (size / 1024.0).round();
595    // `-0.0` would print as "-0"; the browser prints "0".
596    let kb = if kb == 0.0 { 0.0 } else { kb };
597    if kb < 1024.0 {
598        return format!("{kb}K");
599    }
600    let whole = kb as i128;
601    if whole % 1024 == 0 {
602        return format!("{}M", whole / 1024);
603    }
604    // round((kb / 1024) * 10) with ties away from zero, in integers.
605    // Falls back to the formatter only for absurd offsets (a hostile
606    // 40-hex-digit line) where the integer math would overflow.
607    match whole
608        .checked_mul(20)
609        .and_then(|v| v.checked_add(1024))
610        .map(|v| v / 2048)
611    {
612        Some(tenths) => format!("{}.{}M", tenths / 10, tenths % 10),
613        None => format!("{:.1}M", kb / 1024.0),
614    }
615}
616
617// ── 9. Init system ───────────────────────────────────────────────────
618
619static RE_PROCD_START: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^procd:").unwrap());
620static RE_PROCD_INIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"procd:\s+-\s+init").unwrap());
621static RE_SYSTEMD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)systemd\[1\]:").unwrap());
622static RE_SYSV: LazyLock<Regex> =
623    LazyLock::new(|| Regex::new(r"(?i)INIT:\s+version\s+([\d.]+)").unwrap());
624static RE_RUNIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"runit:|runit-init").unwrap());
625
626fn run_init_system(log: &str) -> Option<Finding> {
627    if RE_PROCD_START.is_match(log) || RE_PROCD_INIT.is_match(log) {
628        return Some(Finding::new("Init system", "procd").detail("OpenWrt-family"));
629    }
630    if RE_SYSTEMD.is_match(log) {
631        return Some(Finding::new("Init system", "systemd"));
632    }
633    if let Some(m) = RE_SYSV.captures(log) {
634        let ver = m.get(1)?.as_str();
635        return Some(Finding::new("Init system", format!("SysV init {ver}")));
636    }
637    if RE_RUNIT.is_match(log) {
638        return Some(Finding::new("Init system", "runit"));
639    }
640    None
641}
642
643// ── 10. Device family ─────────────────────────────────────────────────
644
645static RE_OPENWRT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)openwrt").unwrap());
646static RE_OPENWRT_GCC: LazyLock<Regex> =
647    LazyLock::new(|| Regex::new(r"(?i)OpenWrt GCC[^)]*\d{4}-\w+").unwrap());
648static RE_OPENWRT_R: LazyLock<Regex> =
649    LazyLock::new(|| Regex::new(r"(?i)OpenWrt\s+(r\d+[\-\w]+)").unwrap());
650static RE_RPI: LazyLock<Regex> =
651    LazyLock::new(|| Regex::new(r"(?i)Raspberry\s*Pi|bcm27\d{2}|bcm28\d{2}").unwrap());
652static RE_BUILDROOT: LazyLock<Regex> =
653    LazyLock::new(|| Regex::new(r"(?i)buildroot|br-\d{4}").unwrap());
654static RE_YOCTO: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)yocto|poky-\w+").unwrap());
655static RE_ESP_FAM: LazyLock<Regex> =
656    LazyLock::new(|| Regex::new(r"(?i)(esp32|esp8266|esp_image)").unwrap());
657static RE_MEDIATEK: LazyLock<Regex> =
658    LazyLock::new(|| Regex::new(r"(?i)Mediatek|MT76\d{2}|MT79\d{2}").unwrap());
659static RE_QUALCOMM: LazyLock<Regex> =
660    LazyLock::new(|| Regex::new(r"(?i)Qualcomm|IPQ\d{4}").unwrap());
661static RE_ALLWINNER: LazyLock<Regex> =
662    LazyLock::new(|| Regex::new(r"(?i)(Allwinner|sunxi|H\d{1,3}\s+SoC)").unwrap());
663
664fn run_device_family(log: &str) -> Option<Finding> {
665    if RE_OPENWRT.is_match(log) {
666        // Prefer the GCC-tag line, fall back to the release marker.
667        let detail = RE_OPENWRT_GCC
668            .find(log)
669            .or_else(|| RE_OPENWRT_R.find(log))
670            .map(|m| m.as_str().chars().take(80).collect::<String>());
671        let mut f = Finding::new("Device family", "OpenWrt");
672        if let Some(d) = detail {
673            f = f.detail(d);
674        }
675        return Some(f);
676    }
677    if RE_RPI.is_match(log) {
678        return Some(Finding::new("Device family", "Raspberry Pi family"));
679    }
680    if RE_BUILDROOT.is_match(log) {
681        return Some(Finding::new("Device family", "Buildroot"));
682    }
683    if RE_YOCTO.is_match(log) {
684        return Some(Finding::new("Device family", "Yocto / Poky"));
685    }
686    if RE_ESP_FAM.is_match(log) {
687        return Some(Finding::new("Device family", "Espressif (ESP)"));
688    }
689    if RE_MEDIATEK.is_match(log) {
690        return Some(Finding::new("Device family", "MediaTek SoC"));
691    }
692    if RE_QUALCOMM.is_match(log) {
693        return Some(Finding::new("Device family", "Qualcomm IPQ"));
694    }
695    if RE_ALLWINNER.is_match(log) {
696        return Some(Finding::new("Device family", "Allwinner sunxi"));
697    }
698    None
699}
700
701// ── 11. Network ───────────────────────────────────────────────────────
702
703static RE_DHCP: LazyLock<Regex> =
704    LazyLock::new(|| Regex::new(r"(?i)DHCP\s+(?:client\s+)?bound to address\s+([\d.]+)").unwrap());
705static RE_DNSMASQ: LazyLock<Regex> =
706    LazyLock::new(|| Regex::new(r"(?i)dnsmasq\[\d+\]:\s+started,\s+version\s+([\d.]+)").unwrap());
707static RE_DROPBEAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)dropbear\[\d+\]").unwrap());
708
709fn run_network(log: &str) -> Option<Finding> {
710    if let Some(m) = RE_DHCP.captures(log) {
711        let ip = m.get(1)?.as_str();
712        let source = m.get(0)?.as_str();
713        return Some(
714            Finding::new("Network", "DHCP client active")
715                .detail(format!("Lease: {ip}"))
716                .source(source),
717        );
718    }
719    if let Some(m) = RE_DNSMASQ.captures(log) {
720        let ver = m.get(1)?.as_str();
721        let source = m.get(0)?.as_str();
722        return Some(Finding::new("Network", format!("dnsmasq {ver} active")).source(source));
723    }
724    if let Some(m) = RE_DROPBEAR.find(log) {
725        return Some(Finding::new("Network", "Dropbear SSH started").source(m.as_str()));
726    }
727    None
728}
729
730// ── 12. Web admin ─────────────────────────────────────────────────────
731
732static RE_UHTTPD: LazyLock<Regex> =
733    LazyLock::new(|| Regex::new(r"(?i)uhttpd\[\d+\]:\s+Listening on\s+([\d.:]+)").unwrap());
734static RE_LIGHTTPD: LazyLock<Regex> =
735    LazyLock::new(|| Regex::new(r"(?i)lighttpd/([\d.]+)").unwrap());
736static RE_NGINX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)nginx/([\d.]+)").unwrap());
737
738fn run_web_admin(log: &str) -> Option<Finding> {
739    if let Some(m) = RE_UHTTPD.captures(log) {
740        let listen = m.get(1)?.as_str();
741        let source = m.get(0)?.as_str();
742        return Some(
743            Finding::new("Web admin", "uhttpd active")
744                .detail(format!("Listen: {listen}"))
745                .source(source),
746        );
747    }
748    if let Some(m) = RE_LIGHTTPD.captures(log) {
749        let ver = m.get(1)?.as_str();
750        let source = m.get(0)?.as_str();
751        return Some(Finding::new("Web admin", format!("lighttpd {ver}")).source(source));
752    }
753    if let Some(m) = RE_NGINX.captures(log) {
754        let ver = m.get(1)?.as_str();
755        let source = m.get(0)?.as_str();
756        return Some(Finding::new("Web admin", format!("nginx {ver}")).source(source));
757    }
758    None
759}
760
761// ── 13. Telnet exposure ───────────────────────────────────────────────
762
763static RE_TELNET: LazyLock<Regex> = LazyLock::new(|| {
764    Regex::new(r"(?i)telnetd?\[\d+\]?[^\n]*?(?:listening|started|on\s+\d)").unwrap()
765});
766
767fn run_telnet_exposure(log: &str) -> Option<Finding> {
768    if let Some(m) = RE_TELNET.find(log) {
769        return Some(
770            Finding::new("Telnet exposure", "Telnet service started")
771                .detail("Clear-text, review immediately")
772                .source(m.as_str()),
773        );
774    }
775    None
776}
777
778// ── 14. Autoboot interruptable ────────────────────────────────────────
779
780static RE_AUTOBOOT_ANY: LazyLock<Regex> =
781    LazyLock::new(|| Regex::new(r"Hit any key to stop autoboot:\s*[1-9]").unwrap());
782static RE_AUTOBOOT_CAP: LazyLock<Regex> =
783    LazyLock::new(|| Regex::new(r"Hit any key to stop autoboot:\s*\d+").unwrap());
784
785fn run_autoboot_interruptable(log: &str) -> Option<Finding> {
786    if RE_AUTOBOOT_ANY.is_match(log) {
787        // Second regex is broader (matches 0 too) so we can capture the
788        // actual matched source line — matches the TS behavior which
789        // does the same two-step.
790        if let Some(m) = RE_AUTOBOOT_CAP.find(log) {
791            return Some(
792                Finding::new("Autoboot interruptable", "Yes")
793                    .detail("U-Boot will accept any key during the countdown")
794                    .source(m.as_str()),
795            );
796        }
797    }
798    None
799}