Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/actions/bootintel-scan/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@
#
# For full server-side analysis (CVE matching, exploit paths, PDF),
# add `--api` to your own workflow step with a $BOOTINTEL_API_KEY secret.
#
# Exit codes this step can fail with:
#
# 1 a critical-exposure detector fired (with gate-critical: true)
# 2 the log was empty — nothing was inspected
# 3 the log had content but matched no detector
#
# 2 and 3 are failures on purpose. A capture that never happened (the
# UART did not come up, the adapter fell out, the artifact path was
# wrong) used to report green, which is the worst possible answer for a
# gate. If your logs legitimately contain nothing bootintel recognizes,
# set gate-critical: false and inspect `analysis-status` yourself.

name: bootintel-scan
description: Run BootIntel client-side detectors against a boot log, optionally gating on critical exposure findings.
Expand Down Expand Up @@ -61,6 +73,9 @@ outputs:
findings-count:
description: Number of findings in the report (only populated when format=json).
value: ${{ steps.run.outputs.findings-count }}
analysis-status:
description: "'matched', 'unrecognized', or 'empty' — whether the capture was actually inspected and whether anything was recognized."
value: ${{ steps.run.outputs.analysis-status }}

runs:
using: composite
Expand Down Expand Up @@ -107,6 +122,13 @@ runs:

echo "findings-json=$out" >> "$GITHUB_OUTPUT"

case "$rc" in
2) status=empty ;;
3) status=unrecognized ;;
*) status=matched ;;
esac
echo "analysis-status=$status" >> "$GITHUB_OUTPUT"

if [ "${{ inputs.format }}" = "json" ]; then
if command -v jq >/dev/null; then
count=$(jq '.findings | length' "$out" 2>/dev/null || echo 0)
Expand All @@ -118,4 +140,14 @@ runs:
if [ "${{ inputs.format }}" != "junit" ]; then
cat "$out" || true
fi

# Say plainly why the step failed. Exit 2 and 3 mean the scan
# did not actually inspect anything; without this the run log
# shows a bare non-zero exit and a reader assumes a detector
# fired.
case "$rc" in
2) echo "::error title=Empty capture::The boot log at '${{ inputs.log-file }}' was empty, so nothing was inspected. This is not a passing scan — check that the capture step ran and that the path is correct." ;;
3) echo "::error title=Nothing recognized::The boot log at '${{ inputs.log-file }}' had content but matched none of bootintel's detectors. Common causes: the capture started after the boot banner, or the baud rate was wrong." ;;
esac

exit $rc
73 changes: 72 additions & 1 deletion .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
# the "Publish release" button.
# 4. Computes a combined SHA256SUMS file across all binaries so
# install.sh can verify.
# 5. Generates a signed build-provenance attestation for every
# released artifact (see "Provenance" below).
#
# What it does NOT do:
# - No auto-publish to Homebrew tap / winget / apt / Nix registry.
Expand Down Expand Up @@ -45,8 +47,32 @@ on:
type: boolean
default: false

# Provenance
# ----------
# Every released artifact gets a signed build-provenance attestation
# via actions/attest-build-provenance. That is a Sigstore signature
# over the artifact digest plus a SLSA statement recording which
# workflow, at which commit, on which runner produced it — so a user
# can verify an artifact really came from this repository's release
# workflow and not from someone's laptop:
#
# gh attestation verify bootintel-v0.3.2-x86_64-linux.tar.gz \
# --repo BootIntel/cli
#
# It is free for public repositories, needs no key material of our own
# (keyless: the runner's OIDC identity is what gets signed, and the
# certificate lives in the public Rekor transparency log), and gives a
# stronger guarantee than a paid code-signing certificate would for
# this audience — it binds the artifact to a *build*, not merely to an
# organisation that paid a CA.
#
# SHA256SUMS stays exactly as it was: it answers "did this download
# arrive intact", which is a different question and still the one
# install.sh asks.
permissions:
contents: write # needed to create the release draft
id-token: write # OIDC identity for keyless attestation signing
attestations: write # write the attestation to the repo's store

jobs:
build:
Expand Down Expand Up @@ -152,6 +178,19 @@ jobs:
fi
cat ${{ matrix.asset_name }}.sha256

# Attest the archive we are about to ship. Runs per-matrix-leg so
# each platform's artifact is attested on the runner that built
# it, which is what makes the provenance meaningful.
#
# Skipped on dry runs: a dry run produces nothing anyone will
# download, and attesting it would put a misleading entry in the
# public transparency log.
- name: Attest build provenance
if: '!inputs.dry_run'
uses: actions/attest-build-provenance@v2
with:
subject-path: ${{ matrix.asset_name }}

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
Expand Down Expand Up @@ -194,14 +233,37 @@ jobs:
echo "─────────────────────────────────────"
cat release-assets/SHA256SUMS

- name: Write release notes
run: |
cat > RELEASE_NOTES.md <<'NOTES'
Release notes: see CHANGELOG.md.

This is a DRAFT — publish manually after verifying artifacts.

## Verify what you downloaded

Checksums are in `SHA256SUMS`:

sha256sum -c SHA256SUMS --ignore-missing

Every archive also carries a signed build-provenance attestation,
so you can confirm it was produced by this repository's release
workflow rather than by someone's laptop:

gh attestation verify <archive> --repo BootIntel/cli
NOTES
# Strip the leading indentation the YAML block required.
sed -i 's/^ //' RELEASE_NOTES.md
cat RELEASE_NOTES.md

- name: Create GH Release DRAFT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "cli-v${{ inputs.version }}" \
--draft \
--title "bootintel-cli v${{ inputs.version }}" \
--notes "Release notes: see CHANGELOG.md. This is a DRAFT — publish manually after verifying artifacts." \
--notes-file RELEASE_NOTES.md \
release-assets/*

docker:
Expand All @@ -212,6 +274,8 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4

Expand All @@ -226,6 +290,7 @@ jobs:
uses: docker/setup-buildx-action@v3

- name: Build + push (linux/amd64, linux/arm64)
id: docker_build
uses: docker/build-push-action@v6
with:
context: .
Expand All @@ -237,3 +302,9 @@ jobs:
ghcr.io/bootintel/cli:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Attest image provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/bootintel/cli
subject-digest: ${{ steps.docker_build.outputs.digest }}
push-to-registry: true
102 changes: 101 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,107 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a

## [Unreleased]

_No unreleased changes since 0.3.1._
Correctness batch from an SME review that installed the v0.3.1 release
binary and exercised it against socat PTY pairs. Every item below was
reproduced by running, not by reading.

**This batch changes exit-code policy** (see MAJOR in the versioning
policy above): `scan` now exits 2 for an empty/unusable capture and 3
when nothing was recognized, where it previously exited 0 for both.

### Fixed
- **`--log-file` no longer loses the capture.** Bytes were buffered and
flushed only on `Drop`, so a capture smaller than the 8 KiB buffer —
which is most of them — reached the filesystem only if the session
quit through the clean path. Measured on the v0.3.1 binary: the log
file was 0 bytes at 2, 4, 6, 8 and 10 seconds into a live session and
0 bytes after both SIGINT and SIGTERM, while the session correctly
analyzed those same bytes on screen. Writes are now flushed as they
arrive, which also makes the file `tail -f`-able from another
terminal. SIGINT/SIGTERM/SIGHUP handlers were added so the terminal
exits through its normal path — raw mode restored, capture flushed —
instead of the process dying where it stands.
- **An empty capture no longer passes `--gate-critical` with exit 0.** A
CI job whose UART never came up, whose adapter fell out, or whose
artifact path was wrong reported green. `scan` now follows the legacy
Node analyzer's ladder: 2 for empty or unusable input, 3 for
non-empty input that matched no detector.
- **Line-anchored detectors no longer die on a line prefix.** A plain
`U-Boot 2020.10` line was detected, but the same line behind a
`[12:34:56.789]` prefix, an ISO-8601 timestamp, or an ANSI colour
escape produced no findings and exit 0. This was self-inflicted:
`bootintel analyze --log-timestamps` prefixes every line with an
ISO-8601 timestamp, so the tool's own capture mode broke its own
`scan`. Lines are now normalized (ANSI CSI stripper + bracketed
timestamp stripper, ported from the Node analyzer) before matching,
while evidence still reports the original line verbatim.
- **A non-UTF-8 byte is no longer a hard error.** A capture containing
`\xff\xfe\x80\x81\xc0\xc1` failed with "stream did not contain valid
UTF-8" and exit 1, while the Node analyzer read the same file and
returned findings. That is the normal shape of a real UART capture
(pre-baud-lock noise, framing errors, a binary splash, a reset
mid-line) — and since `--log-file` writes raw bytes, `analyze
--log-file` followed by `scan` could fail on the tool's own output.
Logs are now read as bytes and decoded lossily; `-v` reports how many
bytes were replaced. Line numbers are unaffected.
- **A closed pipe is one silent outcome instead of three.** `bootintel
batch … --format json | head -2` gave exit 1 plus `Error: Broken pipe
(os error 32)` on 6 of 6 runs when output exceeded the 64 KiB pipe
buffer, while smaller outputs raced between 141 and 0. `bootintel
manpage | head` — a packager's first command — hit the same thing.
The broken-pipe check now walks the whole `anyhow` cause chain and
understands `serde_json::Error`, which is how the error actually
arrived, and the result is a silent exit 0 every time.
- **SARIF names the real input file.** Every result carried
`artifactLocation.uri = "boot.log"` regardless of the input, so the
SARIF upload action attached findings to a file not in the repository
and the annotations landed nowhere. The real path is now emitted,
relative to `$GITHUB_WORKSPACE` (or the working directory) where
possible, and `stdin` for piped input. `startLine` now comes from the
detector library rather than a substring search.
- **`-q` quiets, and the upsell is off stdout.** `scan --format text -q`
still printed a three-line block advertising `--api`, on stdout — so
`scan --format text > report.txt` shipped marketing inside a
customer's report, and `-q` did nothing despite its own help text
promising it suppresses banners and status hints. The summary block
now goes to stderr and honours `-q`; stdout carries findings only.
- **`bootintel cve` works when installed.** It resolved
`./data/embedded-cves-feed.json` relative to the working directory, so
it only ever worked from inside a source checkout. The feed is now
read from the platform state dir, populated by a new `--refresh` that
fetches the public feed. Repo-relative paths are still tried last.
- **`bootintel analyze /etc/hostname` is diagnosed correctly.** A
readable regular file reported "permission denied" and advised adding
the user to the `dialout` group. It now says the path is not a serial
device and points at `scan` / `watch`.
- **The non-TTY error has a recovery hint.** `entering terminal raw
mode: No such device or address` was the only error in the CLI that
arrived with no suggested next step.

### Changed
- `bootintel ports` sorts USB adapters first and annotates them with the
manufacturer/product string. Previously 32 bare `/dev/ttyS*` paths
came back in enumeration order with nothing to distinguish the one
adapter the user was looking for.
- `bootintel scan`'s local history write is disclosed on first use — one
stderr notice naming the file, what it records, and how to turn it
off. It remains on by default and entirely local; it was simply never
announced, which sits badly with a tool whose pitch is that it uploads
nothing.

### Added
- `analysis_status` (`matched` / `unrecognized`) on the `scan --format
json` envelope, and `line_number` on each finding. Both are additive;
no existing key changed name or meaning. `bootintel schema` and the
bundled GitHub Action are updated to match.
- Signed build-provenance attestations
(`actions/attest-build-provenance`) for every release artifact and for
the container image. Free for public repositories, keyless, and a
stronger claim than a paid signing certificate — it binds an artifact
to the workflow and commit that built it. `SHA256SUMS` is unchanged.
- Regression tests for each of the above, including four that assert the
log file is non-zero **mid-session** (the pre-existing
flush-on-drop test passed against the broken code).

## [0.3.1] — 2026-08-30 — security-hygiene + refactor + dep bumps

Expand Down
Loading
Loading