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;
21
22/// A single detector's output.
23///
24/// Matches the browser tool's `Finding` type field-for-field so JSON
25/// output can be diffed against the browser tool's output byte-for-byte
26/// on the same input.
27#[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    /// 1-based line number of `source` within the analyzed log.
34    /// Populated by `analyze()`; `None` when the evidence could not be
35    /// located (or when a `Finding` is constructed by hand, e.g. from
36    /// an archived JSON envelope). Additive — existing consumers that
37    /// don't know about it are unaffected.
38    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
61/// Analyze a boot log against every detector; return findings in
62/// detector-registration order. Detectors that don't match are
63/// silently dropped.
64///
65/// # Line normalization
66///
67/// Several detectors are line-anchored (`(?m)^U-Boot`, `(?m)^GRUB`,
68/// `(?m)^coreboot-`, `(?m)^procd:`). Real captures very often carry a
69/// per-line prefix that defeats a bare `^`:
70///
71///   * `[12:34:56.789] ` — minicom / picocom / tio timestamping
72///   * `[2026-09-23T10:00:00.000Z] ` — **our own** `--log-timestamps`
73///   * `[    0.000000] ` — kernel printk timestamps
74///   * `\x1b[32m` — ANSI colour from a colourising bootloader
75///
76/// Before `--log-timestamps` existed this was merely common; now the
77/// tool's own capture mode breaks its own `scan`, which is why the
78/// normalization lives here rather than being pushed onto callers.
79///
80/// So: split into lines, strip ANSI CSI sequences and any run of
81/// leading bracketed timestamps, and run the detectors over the
82/// normalized text. Evidence is then mapped back to the **original**
83/// (unmodified) line, so `source` always shows the user what their
84/// capture actually contained, prefix and all.
85///
86/// Ported from the browser `analyze()` so both implementations agree
87/// on what a "line" is and what gets stripped.
88///
89/// Two exceptions, mirroring the browser: the policy observations
90/// (`Telnet exposure`, `Autoboot interruptable`) run against the
91/// **original** lines. Normalizing is a presentation choice for the
92/// inventory detectors; a policy rule should see exactly what the
93/// capture contained.
94pub 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
114/// Detectors that read the user's original lines instead of the
115/// normalized ones. Same two labels the browser exempts.
116const ORIGINAL_INPUT_LABELS: &[&str] = &["Telnet exposure", "Autoboot interruptable"];
117
118/// Split on any of CRLF / LF / CR. `str::lines()` only handles LF and
119/// CRLF; a bare-CR stream (some bootloaders emit CR-only line endings)
120/// would otherwise arrive as one giant line and defeat every
121/// line-anchored detector.
122fn 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                // CRLF counts as one terminator.
137                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
151/// ANSI CSI escape sequence: ESC `[` params intermediates final.
152/// Same character classes as the Node analyzer's
153/// `/\x1b\[[0-?]*[ -/]*[@-~]/g`.
154static RE_ANSI_CSI: LazyLock<Regex> =
155    LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
156
157/// One leading bracketed timestamp. Three accepted shapes, matching
158/// the Node analyzer:
159///   * `[12:34:56]` / `[12:34:56.789]`     — wall-clock terminal logger
160///   * `[2026-09-23T10:00:00.000Z]`        — ISO-8601 (our --log-timestamps)
161///   * `[    0.000000]`                    — kernel printk seconds
162static 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
169/// Strip ANSI CSI sequences and leading bracketed timestamps from one
170/// line. The timestamp strip loops: `bootintel analyze --log-timestamps`
171/// over a Linux console produces *two* stacked prefixes
172/// (`[2026-…Z] [    0.000000] Linux version …`), and the Node
173/// analyzer's single-shot strip would leave the second one in place.
174fn normalize_line(line: &str) -> String {
175    let mut s = RE_ANSI_CSI.replace_all(line, "").into_owned();
176    // Bounded loop — a pathological line of nothing but bracketed
177    // timestamps must not spin forever.
178    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
189/// Point a finding's `source` at the original, unmodified line that
190/// produced it, and record that line's 1-based number.
191///
192/// `source` and `line_number` are **derived**, never set by the
193/// detector: the detector is re-run against each line on its own and
194/// the first line that reproduces the same `value` + `detail` is the
195/// originating evidence. Byte-for-byte the browser's algorithm, which
196/// matters for two reasons:
197///
198///   * a detector that set its own `source` from a whole-log match
199///     could point at text that is not on any single line, breaking
200///     the "source is the original line" contract the CSV/JSON
201///     consumers rely on;
202///   * an **aggregate** detector (`Flash layout`, whose `value` counts
203///     partitions across many lines) cannot be reproduced from one
204///     line, so no line matches and the finding correctly ends up with
205///     no `source` / `line_number` — the evidence is the whole table.
206///
207/// When no line reproduces the finding, whatever `source` the detector
208/// itself recorded is left in place (also the browser's behavior: it
209/// spreads the located evidence over the finding only when found).
210fn 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
221/// Return the labels of every registered detector, in order. Used by
222/// the sync-check script + `bootintel version`.
223pub fn detector_labels() -> Vec<&'static str> {
224    ALL_DETECTORS.iter().map(|d| d.label).collect()
225}
226
227/// A canned MIPS OpenWrt boot log used by tests and by `--sample`
228/// on the CLI. Byte-for-byte identical to the SAMPLE constant in the
229/// browser detector library so browser + CLI output can be diffed
230/// against each other.
231pub 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
250/// Labels that mark a finding as a **critical exposure** — surfaced
251/// in scan text output, batch rollups, HTML/SARIF/JUnit rendering,
252/// `--gate-critical`, and the TUI's critical highlight. Kept as one
253/// list here so scan + batch + output + tui + analyze all agree on
254/// what "critical" means without drifting.
255pub const CRITICAL_LABELS: &[&str] = &["Autoboot interruptable", "Telnet exposure"];
256
257// ── Detector registry ────────────────────────────────────────────────
258//
259// Each entry mirrors a browser detector object. Preserve order + labels
260// so JSON output stays comparable between browser + CLI.
261
262struct 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
326// ── 1. Bootloader ────────────────────────────────────────────────────
327
328// U-Boot line pattern. Multiline `(?m)` so `^` matches the start of
329// any line, not just the whole log. Captures the version + build tag.
330static 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
371// ── 2. Runtime firmware ──────────────────────────────────────────────
372//
373// OpenSBI is the RISC-V M-mode runtime that hands off to U-Boot, not a
374// bootloader — a RISC-V board reports both, and folding OpenSBI into
375// `Bootloader` (as this crate used to) both mislabeled it and hid
376// whichever of the two lost the precedence race.
377
378static 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
388// ── 3. ROM identifier ────────────────────────────────────────────────
389//
390// The mask-ROM build stamp an ESP prints before anything else
391// (`ESP-ROM:esp32s3-20210327`). It identifies the silicon revision's
392// ROM image, which is what a ROM-level exploit is written against —
393// separate from the `Bootloader` finding the same log also produces.
394
395static 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
407// ── 4. Firmware SDK ──────────────────────────────────────────────────
408//
409// ESP-IDF version out of the 2nd-stage bootloader banner. The SDK
410// version is the CVE-relevant identifier on an ESP target — the ROM
411// stamp above rarely moves, the SDK does.
412
413static 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
423// ── 5. Kernel ────────────────────────────────────────────────────────
424
425// The build-metadata parentheses are optional: plenty of vendor kernels
426// print `Linux version 3.0.15-ts-armv7l` and stop. `[ \t]` rather than
427// `\s` so the match cannot run past the end of the banner line.
428static 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        // Match the browser slicing: detail truncated at 80, source at 200.
443        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
468// ── 6. CPU / Arch ────────────────────────────────────────────────────
469
470static 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());
482// Only the literal architecture name. `esp32` / `esp8266` are a device
483// family, not a CPU, and are reported as such by `Device family` —
484// matching them here made the CLI claim an arch the browser did not.
485static 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
509// ── 7. Userland ──────────────────────────────────────────────────────
510//
511// BusyBox prints its version on 7 of the 31 public corpus captures and
512// this crate reported it zero times, so the offline story was missing
513// the most common userland component in the category.
514
515static 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
524// ── 8. Flash layout ──────────────────────────────────────────────────
525//
526// The partition map is what a flash-clip read needs, and it appears in
527// roughly half the corpus. Offsets come straight from the kernel's own
528// MTD registration lines.
529//
530// The one **aggregate** detector: it folds many lines into a single
531// finding whose `value` counts partitions, so no single line reproduces
532// it and `attach_evidence` leaves it without a `source` / `line_number`.
533
534static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
535    // Character class spelled out rather than `\w` so it means the same
536    // set as the browser's `[\w/.-]` (ASCII) instead of Rust's
537    // Unicode-aware `\w`.
538    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        // A capture can print the partition table more than once (a
549        // reset loop, or two flash devices registering). Key on the
550        // exact offsets so the same region is listed once rather than
551        // inflating the count — bootintel-9.txt reports 14 partitions
552        // without this for a device that has 7.
553        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
574/// `parseInt(hex, 16)` in f64, so a hostile line with a 40-digit offset
575/// widens to infinity the way the browser does instead of overflowing.
576fn 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
582/// Render a partition size the way the browser does: whole kibibytes,
583/// or mebibytes with one decimal place when it isn't a whole MiB.
584///
585/// The decimal is computed in integer tenths rather than with `{:.1}`
586/// because the two languages break ties differently: JavaScript's
587/// `toFixed` rounds a tie away from zero (1.25 → "1.3") while Rust's
588/// formatter rounds to even (1.25 → "1.2"). Partition tables hit exact
589/// ties routinely — any whole 1.25 MiB region does it, e.g. the 1280 KiB
590/// `kernel` partition in sample bootintel-12.txt — so the naive version
591/// diverges from the browser on real corpus logs.
592fn human_size(size: f64) -> String {
593    let kb = (size / 1024.0).round();
594    // `-0.0` would print as "-0"; the browser prints "0".
595    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    // round((kb / 1024) * 10) with ties away from zero, in integers.
604    // Falls back to the formatter only for absurd offsets (a hostile
605    // 40-hex-digit line) where the integer math would overflow.
606    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
616// ── 9. Init system ───────────────────────────────────────────────────
617
618static 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
642// ── 10. Device family ─────────────────────────────────────────────────
643
644static 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        // Prefer the GCC-tag line, fall back to the release marker.
666        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
700// ── 11. Network ───────────────────────────────────────────────────────
701
702static 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
729// ── 12. Web admin ─────────────────────────────────────────────────────
730
731static 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
760// ── 13. Telnet exposure ───────────────────────────────────────────────
761
762static 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
777// ── 14. Autoboot interruptable ────────────────────────────────────────
778
779static 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        // Second regex is broader (matches 0 too) so we can capture the
787        // actual matched source line — matches the TS behavior which
788        // does the same two-step.
789        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}