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:
유창욱 2026-07-17 10:18:57 +09:00
parent 1fb7a5da58
commit 949c6d05c3
4 changed files with 79 additions and 2 deletions

View file

@ -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=<gpg-id> 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:"

View file

@ -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

View file

@ -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.

View file

@ -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