Repository navigation
Decide the boot chain offline: bootintel verdict - #17
Merged
Merged
Conversation
Every other command here reads a boot log, which is a record of what the firmware chose to print. This reads what an operator pulled OUT of a board after interrupting autoboot: a printenv dump. The difference is the whole reason for taking the prompt. A boot log can say autoboot looks interruptible; the environment says exactly what happens when you interrupt it, and whether you can change what boots. It runs entirely offline, unlike `scan --api`, and that is the point. A U-Boot environment is the most sensitive thing in a capture (ipaddr, serverip, ethaddr, TFTP hosts, a client's internal addressing), so requiring an upload to learn what it permits would put this out of reach of exactly the people it is for. Applicability lookup stays server-side because the curated CVE ruleset is the asset that compounds; "bootdelay above zero means the prompt is reachable" is domain knowledge any practitioner already has, and shipping it costs nothing. crates/detectors/src/boot_chain.rs parse a session, decide what it permits crates/cli/src/cmd/verdict.rs the command, text + JSON + --gate-exposed Deliberately not a detector: the cross-implementation parity test asserts the Rust and browser detector sets match exactly at 14 labels, and a fifteenth would break it. The three parsing regexes are character-for-character the ones in the engine, because keeping them literally identical is cheaper than reasoning about whether two hand-written tokenisers agree. No serde added to the detectors crate; the CLI serialises. The cost of a second implementation is drift, which is the exact problem the detector parity work just fixed. So a committed expectation file is the reference for both, tests/fixtures/boot_chain/expect.txt here and a byte-identical copy in bootintel.com, each repo asserting its own side against it. Regenerated only by scripts/gen-boot-chain-expect.sh, which writes both. Verified it fails on a one-word wording change (mutated the Rust side to check). Exit codes carry the discipline the verdicts do: 3 when the capture holds no session, because "could not assess" must never look like "nothing is wrong", and absence of a variable is reported as unknown rather than hardened, because U-Boot prints only what is set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex
added a commit
that referenced
this pull request
Sep 28, 2026
## What `bootintel analyze <port> --interrupt-autoboot` takes the U-Boot prompt, pulls the environment, prints the boot-chain verdict, and hands the terminal back. This closes the loop #17 opened: capture to verdict with no typing and no byte leaving the machine. ``` [bootintel] ▸ hammering space now: POWER-CYCLE THE BOARD. The key has to be waiting in the UART before U-Boot looks, so this only works if the reset happens after this line. [bootintel] ▸ prompt reached: "=>". Autoboot was interrupted. [bootintel] ▸ typing `printenv` [bootintel] ▸ environment captured. The prompt is yours; the verdict is below. exposed Image verification verify is disabled, so U-Boot will not check image checksums before booting. read from verify=no ``` ## Why it is not "watch for the countdown, then send a key" That is the obvious design and it does not work. The autoboot delay is a loop around `tstc()`, and `bootdelay=0` means the check happens **once**. Boards ship that way. By the time the countdown has crossed the wire, been read by this process and been recognised, the window is gone: at 115200 a character is already ~87us of wire time, and the read is scheduled by the OS, not by us. What works is that the byte is already in the UART's receive register when the board looks. So this hammers from the instant the port opens and asks the operator to power-cycle *after* that starts. There is nothing to detect, which is the point. `--reset-line dtr|rts` removes human timing entirely, where the adapter is wired to the board's reset. ## Split by requirement, not by layer | Part | Requirement | Where | | --- | --- | --- | | The hammer | must be FAST | a thread that writes one byte string on a timer and holds no logic at all | | The decisions | must be RIGHT | `src/term/autoboot.rs`: a pure state machine over received bytes, no clock, no I/O | Which is what makes the timing claim testable. `timing_tests` simulates a board whose check fires once, at seven instants from 1ms to 900ms: caught every time. The contrast test arms the hammer only after the countdown is visible (the design this replaces) and misses. ## Safety, on a client's only sample of a device - The hammer **never sends CR or LF**. Refused when the key is parsed, asserted in tests: hammered bytes accumulate in U-Boot's line buffer and a newline would execute whatever they spell. - Default key is a space; default commands are read-only (`printenv`, `bdinfo`, `mtdparts`). `--at-prompt` replaces that set entirely, so the operator decides exactly what is typed. - A bare CR flushes the accumulated bytes before anything is typed. - Every byte the tool sends is announced, so a client transcript shows which bytes were the tool's. - Tuning flags without `--interrupt-autoboot` are refused rather than ignored: someone who passed `--interrupt-key stop` and got a plain terminal would reasonably think the tool tried. ## Two false-positive guards - A `#` prompt **after the kernel handoff** is a Linux shell. It is ignored and hammering continues until a fresh bootloader banner clears the latch. Typing `printenv` into a root shell would have produced a confident, wrong verdict. - A prompt that **does not answer** re-arms rather than abandoning the attempt. `Loading: #` mid-transfer and a board that resets under us are both cheaper to retry than to give up on. ## Verification - `cargo test --workspace`: 349 passed, 0 failed (was 324). `clippy -D warnings` and `fmt --check` clean. - End-to-end against a socat PTY pair and a simulated `bootdelay=0` board: caught the single check, ran all three commands, printed the verdict. The board saw only those three commands despite 594 buffered spaces and a stray `0x04` from the harness, which is the CR flush doing its job. - The missed-window path was verified too, by getting the harness ordering wrong first: it reported the miss with the four things worth checking and never implied success. Not wired into `--tui` yet; that combination is refused rather than silently ignored. No version bump. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex
added a commit
that referenced
this pull request
Sep 28, 2026
Version bump, lockfile, changelog, and one docs fix. No code changes: everything in this release is already on `main` and was reviewed in #17 and #18. ## What ships | | | | --- | --- | | #17 | `bootintel verdict <capture>` — assess a U-Boot session offline | | #18 | `analyze --interrupt-autoboot` — take the prompt and pull the environment | Together: bench to verdict with no upload and no typing. ## Why MINOR Two new subcommands' worth of surface and no behaviour change for anyone who does not use them. The policy at the top of `CHANGELOG.md` reserves PATCH for changes with no user-visible behaviour change, and MAJOR for renamed flags, schema changes, or exit-code policy changes. ## The one non-mechanical change `crates/cli/Cargo.toml` pins `bootintel-detectors` by exact version, because the published binary crate has to name a version that exists on the index. That bump is part of cutting a release rather than something cargo derives, and forgetting it fails `cargo update -w` immediately, so it cannot reach a release quietly. It is still the step that gets missed, so `docs/releasing.md` step 1 now names it. ## Verification - `cargo test --workspace`: 349 passed, 0 failed - `cargo clippy --workspace --all-targets -- -D warnings`: clean - `cargo fmt --all --check`: clean - `cargo build --release` then `bootintel --version` reports `bootintel 0.8.0` After merge: dispatch `cli-release` for `0.8.0` with `publish_crates`, verify the draft against `SHA256SUMS`, publish and mark latest, then move the Homebrew formula. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex
added a commit
that referenced
this pull request
Sep 28, 2026
Follows #17/#18. The engine learned to read what the bootloader actually verified ([BootIntel.com@fd96903](BootIntel/BootIntel.com@fd969038)); until the CLI does too, the offline verdict says "I cannot tell whether images are checked" about captures that answer the question themselves, and the two implementations disagree about the same device. ## What the CLI now reports ``` confirmed Image verification The bootloader checked the image before booting it, using a legacy uImage CRC, and the check passed. That proves the image was not corrupt. It is not a signature: anyone who can write the image can recompute the checksum, so this stops bit-rot rather than an attacker. read from Verifying Checksum ... OK exposed Secure boot anchor The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned image. Whatever the bootloader does about checksums afterwards is advisory: the chain has no anchor. read from hab fuse not enabled ``` A passing checksum is `confirmed`, never `hardened`. Only a verified signature (`sha256,rsa2048:dev+ OK`) is `hardened`. The anchor entry is emitted without needing a `printenv`, since the fuse fact does not depend on one. ## The fixtures are the point Neither is synthetic: | Fixture | Source | What it pins | | --- | --- | --- | | `hab-unblown.log` | bootintel-7 lines 1-45 | prompt reached with **no** environment, unblown HAB fuse, and a harness line quoting `'Bad Linux ARM64 Image magic!'` that must not trip the anchored failure pattern | | `verified-image.log` | bootintel-20 lines 55-160 | environment provable only retroactively, plus a **failed** check (`Bad Magic Number`, from an operator's `iminfo`) and a **passed** one in that order | Both implementations reproduce `expect.txt` byte for byte across five fixtures. That is the first time the guarantee has covered anything but a synthetic capture and two environments. ## A bug found while porting The engine's signature pattern included `## Checking (hash|sign)`, and U-Boot's ordinary FIT output is `## Checking hash(es) for FIT Image at ...`. Every device using **unsigned** FIT hashes would have been reported as having had a signature verified, and the verdict would have told a client authenticity was established. No corpus log prints that line, which is exactly why it survived review: absence of evidence read as correctness. The real tell is the algorithm list, so that is what both sides key on now, with a test for each shape. ## Breaking, library only `boot_chain::assess` returns an `Assessment { session, integrity, verdicts }` instead of a tuple, and `verdict` takes the integrity alongside the session. The alternative was a second entry point for the same operation; two functions differing only in how much they tell you is worse for whoever reads it next. **No CLI flag, output or exit code changed.** `verdict --json` gains a `boot_integrity` object mirroring the engine's key names, omitting fields the capture said nothing about rather than emitting nulls. Under the policy at the top of `CHANGELOG.md` that puts the next release at 0.9.0. ## Verification - `cargo test --workspace`: 349 passed, 0 failed - `clippy --workspace --all-targets -- -D warnings` and `fmt --check`: clean - Engine side green at 418 passed in `ci-api-regression`, pushed and deployed - Ran the built binary against both new fixtures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
bootintel verdict <capture>reads a U-Bootprintenvdump taken at the prompt and reports what the boot chain permits. Text,--json, and--gate-exposedfor CI.Every other command reads a boot log, which is a record of what the firmware chose to print. This reads what an operator pulled out of a board after interrupting autoboot. A boot log can say autoboot looks interruptible; the environment says exactly what happens when you interrupt it, and whether you can change what boots.
Why offline
A U-Boot environment is the most sensitive thing in a capture:
ipaddr,serverip,ethaddr, TFTP hosts, a client's internal addressing. Requiring an upload to learn what it permits would put this out of reach of exactly the people it is for. Applicability lookup stays server-side because the curated CVE ruleset is the asset that compounds; "bootdelay above zero means the prompt is reachable" is domain knowledge any practitioner already has.Layout
crates/detectors/src/boot_chain.rscrates/cli/src/cmd/verdict.rs--gate-exposedcrates/detectors/tests/boot_chain.rscrates/cli/tests/verdict_cli.rsDeliberately not a detector: the cross-implementation parity test asserts the Rust and browser detector sets match exactly at 14 labels, and a fifteenth would break it. No serde added to
bootintel-detectors; the CLI serialises.Drift control
The same rules now ship twice, here and in the bootintel.com engine, which is the exact problem the detector parity work just fixed. Neither side is the reference:
tests/fixtures/boot_chain/expect.txtis, and bootintel.com holds a byte-identical copy thatapi/tests/test_boot_chain_parity.pyasserts against.scripts/gen-boot-chain-expect.sh(in bootintel.com) is the only sanctioned way to change it and writes both copies in one go.Two of the three fixtures are real boards with unrecognised vendor prompts (
RTL8672 #), so the environment is only provable retroactively from theEnvironment size:line, and two values wrap across lines. That is the path a tokeniser rewrite breaks silently.Discipline carried into the exit codes
0assessed, nothing exposed (with--gate-exposed)1at least one exposed verdict, each named with the variable it was read from2empty capture3content, but no session in it. "Could not assess" must never look like "nothing is wrong".Absence of a variable is
unknown, neverhardened: U-Boot prints only what is set. Device-controlled text is sanitised before it reaches a terminal, since a crafted environment value is an ANSI-injection vector into a consultant's session.Verification
cargo test --workspace: 324 passed, 0 failedcargo clippy --workspace --all-targets -- -D warnings: cleancargo fmt --all --check: cleanci-api-regression(406 passed): BootIntel.com@0a9298ceNo version bump in this PR.
🤖 Generated with Claude Code