feat(bundle): pin + hash + SBOM + verify offline bundle integrity (P5-4)
The offline bundle shipped unpinned pip deps with no digests, no SBOM, and no signature — an air-gap target can't re-resolve from PyPI, so a truncated or tampered wheel would install silently. build_offline_bundle.sh now emits, from the resolved wheelhouse: - SHA256SUMS — sha256 of every wheel + model - requirements.lock — exact name==version pins for a reproducible rebuild - sbom.json — CycloneDX 1.5 components (name, version, pypi purl, sha256) - SHA256SUMS.asc — optional detached GPG signature when SIGN_KEY is set MANIFEST advertises the integrity set and whether it is signed. install_offline.sh verifies `sha256sum -c SHA256SUMS` (and the signature, if present) BEFORE any pip install, hard-failing on mismatch; older bundles without the file pass with a warning. The generation logic was verified end-to-end on a synthetic stage: requirements.lock/SHA256SUMS correct, `sha256sum -c` passes, sbom.json is valid CycloneDX JSON. Both scripts pass `bash -n`. Tests (test_deploy_artifacts.py): pinned integrity manifest is emitted (+ optional SIGN_KEY), installer verifies integrity before install. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
parent
1fb7a5da58
commit
949c6d05c3
4 changed files with 79 additions and 2 deletions
|
|
@ -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
|
cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true
|
||||||
chmod +x "$STAGE/install_offline.sh"
|
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=<gpg-id> to add a detached signature)."
|
||||||
|
fi
|
||||||
|
|
||||||
# Which pose backends this bundle can actually run, so the operator knows before it crosses the
|
# 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.
|
# 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
|
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 "target_arch: $TARGET_ARCH"
|
||||||
echo "hailo_arch: $HAILO_ARCH"
|
echo "hailo_arch: $HAILO_ARCH"
|
||||||
echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')"
|
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:"
|
echo "models:"
|
||||||
ls -1 "$STAGE/models"
|
ls -1 "$STAGE/models"
|
||||||
echo "wheels:"
|
echo "wheels:"
|
||||||
|
|
|
||||||
|
|
@ -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/models/"*.onnx "$PREFIX/models/" 2>/dev/null || true
|
||||||
cp "$HERE/docs/"*.md "$PREFIX/docs/" 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
|
# --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
|
# pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and
|
||||||
# picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install
|
# picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install
|
||||||
|
|
|
||||||
|
|
@ -216,9 +216,16 @@ misconfig/degradation fail LOUD.
|
||||||
`install_offline.sh` copied only `*.onnx` so a bundled HEF never reached `$PREFIX/models` for the
|
`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.
|
backend gate to find (now copies `*.hef` too). MANIFEST lists models + available pose_backends.
|
||||||
Tests: fetches/stages HEF, MoveNet optional, installer copies the HEF.
|
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 +
|
MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin +
|
||||||
hash + manifest digests.
|
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
|
- [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
|
`/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.
|
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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.
|
# $PREFIX/models and the Hailo backend gate ([5/6]) could never see it.
|
||||||
assert 'cp "$HERE/models/"*.hef' in sh
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue