diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index 74e4b8a..d222712 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -110,8 +110,10 @@ misconfig/degradation fail LOUD. ## Phase 3 — Privacy / PIPA (delivery compliance) -- [ ] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of +- [x] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox. + DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top + fallback when the nose keypoint is low-confidence. test_faceblur.py locks both. - [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days` config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`. @@ -125,9 +127,14 @@ misconfig/degradation fail LOUD. column and `events` link only by `device_id`. Enforcing scope needs a site↔device model (e.g. add `devices.site`, filter events by the caller's site). Deferred until that is decided. Interim mitigation: keep every dashboard account admin/single-site so scope is moot. -- [ ] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref +- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror `fetch_hailo_hef.py`. + DONE (with a caveat) — rewrote to stage-validate: rejects HTML/LFS-pointer, optional pinned + sha256+size, temp-file staging, fails LOUD. DISCOVERY: the old URL is DEAD (GitHub 404 / HTML + — PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to + pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet + is the optional CPU fallback); flag for whoever needs CPU MoveNet. ## Phase 4 — Cloud hardening (shared hosting) diff --git a/edge/app.py b/edge/app.py index a16db08..ffcf651 100644 --- a/edge/app.py +++ b/edge/app.py @@ -11,11 +11,21 @@ from edge.models import BBox def _head_boxes(persons: list) -> list: - # 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체) + # Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed + # ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face + # fully identifiable. Centered on the nose when it is confident, else on the bbox's top + # (the head is the upper part of the person) so a bad keypoint can't move the blur off it. boxes = [] for p in persons: + bb = p.bbox nose = p.keypoints[0] - boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15)) + half_w = max(8.0, 0.25 * bb.width) + if nose.conf >= 0.3: + cx, cy = nose.x, nose.y + else: + cx, cy = bb.cx, bb.y1 + 0.12 * bb.height + boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height, + cx + half_w, cy + 0.08 * bb.height)) return boxes diff --git a/edge/tools/fetch_movenet.py b/edge/tools/fetch_movenet.py index 4b9c6e3..4c3249b 100644 --- a/edge/tools/fetch_movenet.py +++ b/edge/tools/fetch_movenet.py @@ -1,16 +1,87 @@ -"""빌드머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0.""" +"""빌드/온라인 머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0. + +Integrity is enforced (spec: SHA256/GPG verify of every vendored model). The download is staged +to a temp file, validated, and only then moved into place — a truncated / HTML-error-page / +tampered download must never be saved as the model (onnxruntime would then fail to load it, and +the operator would see a cryptic runtime error instead of a clear fetch failure). + +STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on +external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is +no known-good sha256 to pin here yet. Until a working source is set: + * this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets), + instead of silently saving garbage; + * set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes. +For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is +non-blocking; the CPU fallback needs this resolved. +""" +import hashlib import os +import sys +import tempfile import urllib.request -# MoveNet Lightning ONNX (예시 소스; 조직 미러로 교체 가능) -URL = "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/movenet_singlepose_lightning_4.onnx" +URL = os.environ.get( + "MOVENET_ONNX_URL", + "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/" + "movenet_singlepose_lightning_4.onnx", +) DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx") +# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned +# (still validated as a real ONNX below, but not byte-identical-verified). +PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "") +PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0") + + +def _looks_like_onnx(head: bytes) -> bool: + # ONNX is a protobuf ModelProto; it is NOT text. Reject an HTML error page / LFS pointer + # outright (the current dead URL returns exactly that). + stripped = head.lstrip() + if stripped[:1] in (b"<", b"{") or stripped[:5].lower() == b" {DEST}") - urllib.request.urlretrieve(URL, DEST) - print("done") + dest = os.path.abspath(DEST) + os.makedirs(os.path.dirname(dest), exist_ok=True) + print(f"downloading MoveNet ONNX\n {URL}\n -> {dest}") + tmp = None + try: + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest), prefix=".movenet.", suffix=".part") + os.close(fd) + urllib.request.urlretrieve(URL, tmp) + + with open(tmp, "rb") as f: + head = f.read(64) + if not _looks_like_onnx(head): + raise SystemExit( + f"ERROR: {URL}\n did not return an ONNX model (looks like HTML / an error page /" + f" an LFS pointer).\n Set MOVENET_ONNX_URL to a valid source and pin" + f" MOVENET_ONNX_SHA256. (Hailo path does not need this model.)" + ) + + size = os.path.getsize(tmp) + digest = hashlib.sha256(open(tmp, "rb").read()).hexdigest() + if PINNED_SHA256 and (digest != PINNED_SHA256 or (PINNED_SIZE and size != PINNED_SIZE)): + raise SystemExit( + f"ERROR: MoveNet ONNX does not match the pinned digest.\n" + f" expected: {PINNED_SIZE} bytes, sha256 {PINNED_SHA256}\n" + f" got : {size} bytes, sha256 {digest}\n Refusing to install it." + ) + if not PINNED_SHA256: + print(f" WARNING: integrity not pinned (MOVENET_ONNX_SHA256 unset). Got {size} bytes," + f" sha256 {digest}. Pin this once the source is trusted.", file=sys.stderr) + + os.replace(tmp, dest) + tmp = None + print(f"done: {dest} ({size} bytes)") + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + if __name__ == "__main__": main() diff --git a/tests/test_faceblur.py b/tests/test_faceblur.py new file mode 100644 index 0000000..cdc0846 --- /dev/null +++ b/tests/test_faceblur.py @@ -0,0 +1,25 @@ +"""Face-anonymization sizing (PIPA). _head_boxes must scale the blur region with the person's +size — a fixed ~40x40px patch leaves a close worker's face fully identifiable, defeating the +anonymization the spec requires. No hardware: synthetic Person/BBox.""" +from edge.app import _head_boxes +from edge.models import Person, BBox, Keypoint + + +def _person(bbox, nose_conf=0.9): + nose = Keypoint(bbox.cx, bbox.y1 + 0.1 * bbox.height, nose_conf) + return Person(bbox, [nose] + [Keypoint(bbox.cx, bbox.cy, nose_conf) for _ in range(16)]) + + +def test_head_box_scales_with_person_size(): + small = _head_boxes([_person(BBox(0, 0, 40, 120))])[0] + big = _head_boxes([_person(BBox(0, 0, 400, 1200))])[0] # 10x bigger person + assert big.width > small.width * 5, "head box must scale with the person, not be fixed" + assert big.width > 40, "a close face must be covered by more than the old ~40px box" + + +def test_head_box_falls_back_to_bbox_top_when_nose_low_confidence(): + p = _person(BBox(0, 0, 100, 300), nose_conf=0.1) # nose keypoint unreliable + box = _head_boxes([p])[0] + assert box.width > 0 and box.height > 0 + # anchored to the head region (top of the bbox), not wherever the low-conf nose landed + assert box.y1 < p.bbox.cy