diff --git a/deploy/bundle/build_offline_bundle.sh b/deploy/bundle/build_offline_bundle.sh index 54d949f..a309730 100755 --- a/deploy/bundle/build_offline_bundle.sh +++ b/deploy/bundle/build_offline_bundle.sh @@ -116,6 +116,41 @@ cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/" cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true chmod +x "$STAGE/install_offline.sh" +# Supply-chain integrity (air-gap: the target can't reach PyPI to re-resolve, so pin + hash here). +# SHA256SUMS — sha256 of every wheel + model; install_offline verifies before installing. +# requirements.lock — exact name==version of every bundled wheel, for a reproducible rebuild. +# sbom.json — CycloneDX component list (name, version, pypi purl, sha256). +echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json)" +( cd "$STAGE" && sha256sum wheelhouse/*.whl models/* > SHA256SUMS ) + +: > "$STAGE/requirements.lock" +{ + printf '{\n "bomFormat": "CycloneDX",\n "specVersion": "1.5",\n "version": 1,\n "components": [\n' + sep="" + for whl in "$WHEELHOUSE"/*.whl; do + [ -e "$whl" ] || continue + base="$(basename "$whl")" + name="$(echo "$base" | cut -d- -f1)" # wheel: {name}-{version}-{pyabi}-...-{plat}.whl + ver="$(echo "$base" | cut -d- -f2)" + sum="$(sha256sum "$whl" | cut -d' ' -f1)" + echo "${name}==${ver}" >> "$STAGE/requirements.lock" + printf '%s {"type":"library","name":"%s","version":"%s","purl":"pkg:pypi/%s@%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \ + "$sep" "$name" "$ver" "$name" "$ver" "$sum" + sep=$',\n' + done + printf '\n ]\n}\n' +} > "$STAGE/sbom.json" +sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock" + +# Optional detached GPG signature over the integrity file. The air-gap target verifies via +# SHA256SUMS regardless; a signature adds provenance when a signing key exists on the build box. +if [ -n "${SIGN_KEY:-}" ]; then + gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS" + echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc" +else + echo ">> NOTE: SHA256SUMS is unsigned (set SIGN_KEY= to add a detached signature)." +fi + # Which pose backends this bundle can actually run, so the operator knows before it crosses the # air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX. HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes @@ -126,6 +161,7 @@ HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes echo "target_arch: $TARGET_ARCH" echo "hailo_arch: $HAILO_ARCH" echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')" + echo "integrity: SHA256SUMS (verified on install), requirements.lock, sbom.json$([ -f "$STAGE/SHA256SUMS.asc" ] && echo ', SHA256SUMS.asc (signed)')" echo "models:" ls -1 "$STAGE/models" echo "wheels:" diff --git a/deploy/bundle/install_offline.sh b/deploy/bundle/install_offline.sh index 3075f8e..ab5ccbc 100755 --- a/deploy/bundle/install_offline.sh +++ b/deploy/bundle/install_offline.sh @@ -46,7 +46,21 @@ cp "$HERE/models/"*.hef "$PREFIX/models/" 2>/dev/null || true cp "$HERE/models/"*.onnx "$PREFIX/models/" 2>/dev/null || true cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true -echo ">> [3/6] python venv + offline wheel install (--no-index)" +echo ">> [3/6] verify bundle integrity + python venv + offline wheel install (--no-index)" +# Supply-chain: verify every bundled wheel + model against the build-time SHA256SUMS BEFORE +# installing anything. A truncated transfer or a tampered wheel must fail here, loudly, not +# install silently. Older bundles without the file are allowed through with a warning. +if [ -f "$HERE/SHA256SUMS" ]; then + ( cd "$HERE" && sha256sum -c SHA256SUMS ) \ + || { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; } + if [ -f "$HERE/SHA256SUMS.asc" ]; then + gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \ + && echo " -> SHA256SUMS signature verified" \ + || echo " -> WARNING: SHA256SUMS.asc present but signature did not verify (missing/again untrusted key?)." >&2 + fi +else + echo " -> WARNING: no SHA256SUMS in bundle; skipping integrity check (rebuild with a newer build_offline_bundle.sh)." >&2 +fi # --system-site-packages so the venv can import the Pi SYSTEM packages that are NOT # pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and # picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index b18d565..4ccbb9c 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -216,9 +216,16 @@ misconfig/degradation fail LOUD. `install_offline.sh` copied only `*.onnx` so a bundled HEF never reached `$PREFIX/models` for the backend gate to find (now copies `*.hef` too). MANIFEST lists models + available pose_backends. Tests: fetches/stages HEF, MoveNet optional, installer copies the HEF. -- [ ] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests, +- [x] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests, MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin + hash + manifest digests. + DONE — build emits `SHA256SUMS` (sha256 of every wheel + model), `requirements.lock` (exact + `name==version` pins for a reproducible rebuild), and `sbom.json` (CycloneDX 1.5: name, version, + pypi purl, sha256). Optional detached GPG signature via `SIGN_KEY` → `SHA256SUMS.asc`. + `install_offline.sh` runs `sha256sum -c SHA256SUMS` (and verifies the signature if present) + BEFORE any pip install, hard-failing on mismatch. Generation logic verified end-to-end on a + synthetic stage (valid JSON, `sha256sum -c` passes). Tests lock the manifest + the install-time + check. - [x] **P5-5 [H]** `deploy/systemd/move-detect-edge.service:46` `OFF-HW` — `DeviceAllow` omits `/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware. diff --git a/tests/test_deploy_artifacts.py b/tests/test_deploy_artifacts.py index 8e24e24..59f2796 100644 --- a/tests/test_deploy_artifacts.py +++ b/tests/test_deploy_artifacts.py @@ -243,3 +243,23 @@ def test_offline_installer_copies_the_hef_into_place(): # install_offline used to copy only *.onnx, so a HEF in the bundle never reached # $PREFIX/models and the Hailo backend gate ([5/6]) could never see it. assert 'cp "$HERE/models/"*.hef' in sh + + +def test_offline_bundle_emits_pinned_integrity_manifest(): + with open(BUILD_OFFLINE, encoding="utf-8") as f: + sh = f.read() + # Air-gap supply-chain: the target can't re-resolve from PyPI, so the build must pin exact + # versions, hash every artifact, and ship an SBOM. GPG signing is optional (SIGN_KEY). + assert "sha256sum wheelhouse/*.whl models/* > SHA256SUMS" in sh + assert "requirements.lock" in sh # exact name==version pins for a reproducible rebuild + assert "sbom.json" in sh + assert "CycloneDX" in sh + assert 'if [ -n "${SIGN_KEY:-}" ]; then' in sh # optional detached signature + + +def test_offline_installer_verifies_integrity_before_install(): + with open(INSTALL_OFFLINE, encoding="utf-8") as f: + sh = f.read() + # A truncated/tampered wheel must fail loudly BEFORE any pip install, not install silently. + assert "sha256sum -c SHA256SUMS" in sh + assert "integrity check failed" in sh