feat(delivery-audit): privacy — face-blur sizing + model integrity (P3-1, P3-3)

- P3-1 [H] _head_boxes sizes the anonymization region from the person bbox (scales with
  distance) + bbox-top fallback when the nose keypoint is low-confidence; a fixed ~40px
  patch had left close faces identifiable. test_faceblur.py.
- P3-3 [H] fetch_movenet.py rewritten to stage-validate (reject HTML/LFS-pointer, optional
  pinned sha256+size, fail loud) per the SHA256 spec requirement. Discovery: the old PINTO
  raw URL is DEAD (GitHub 404 → it was silently saving an HTML page as the .onnx). Non-blocking
  for the Hailo delivery path; the CPU MoveNet fallback needs a valid MOVENET_ONNX_URL pinned.

python + phpunit suites green.

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 09:35:02 +09:00
parent 1187df5628
commit 7a86545367
4 changed files with 124 additions and 11 deletions

View file

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

View file

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

View file

@ -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"<!doc":
return False
if b"version https://git-lfs" in head:
return False
return True
def main():
os.makedirs(os.path.dirname(DEST), exist_ok=True)
print(f"downloading MoveNet ONNX -> {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()

25
tests/test_faceblur.py Normal file
View file

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