fix(verify): enforce anti-spoof block + EAR + aged-threshold + SHA-pin + verify-challenge (2026-05-12 ML review)#102
Merged
Conversation
…pin SHA, add verify-challenge
Closes 4 P0/P1 findings from the 2026-05-12 ML review:
Bug 1 (P0) — Anti-spoof `recommended_action='block'` is advisory
AntispoofPipelineAssembler attached `recommended_action='block'` to /verify
responses but the route still returned 200/verified=True. Added
`ANTISPOOF_BLOCK_ENFORCE=true` (default ON in prod). When any layer votes
block (face_usability_block, hybrid_fusion_is_spoof, or recommended_action='block')
the route now raises HTTP 403 with `{error_code: ANTISPOOF_BLOCKED, reason: <category>}`.
Flip flag false for canary/observation rollout. Tests:
tests/integration/test_verify_antispoof_block_enforce.py (8 assertions, 4 for Bug 1).
Bug 2 (P0) — Blink-cache / EAR work unreachable from /verify
The 2026-05-11 spoof-detector paper-P0 (blink cache + EAR recalibration)
lived in `src.infrastructure.analyzers.blink_analyzer` but was never wired
into the route. Added `_evaluate_ear_liveness_safe()` that runs MediaPipe
FaceLandmarker on the uploaded still frame, computes EAR via the
spoof-detector library (EAR_THRESHOLD=0.18), and vetoes on closed eyes.
Multi-frame BlinkAnalyzer state (V-shape detection) is explicitly out of
scope here — the cache only helps with multi-face/frame video sessions
that the current /verify single-still-frame contract doesn't provide.
Default OFF (ANTISPOOF_EAR_VETO_ENABLED) until ops deploys the
face_landmarker.task asset; helper fails-soft to None when the model
or MediaPipe is missing. Companion spoof-detector PR exposes the
blink_analyzer module on the public `spoof_detector.*` namespace
(per `feedback_spoof_detector_architecture`, algorithms live there).
Bug 3 (P0) — VERIFICATION_THRESHOLD_AGED semantics inverted
Comparator is `verified = distance < threshold`; default was
THRESHOLD=0.45, THRESHOLD_AGED=0.38 — making aged users *stricter*
(higher FRR), the opposite of the adaptive feature's intent. Raised
THRESHOLD_AGED default to 0.55 (still well below Facenet cosine
operating-point ceiling ~0.6 so FAR stays controlled). Added a
Pydantic model_validator that hard-rejects aged < standard at
config-load — the regression cannot silently come back via env-file
edits. .env.example documents the comparator semantics inline.
Tests: tests/unit/test_verification_threshold_aged.py (4 assertions).
Bug 4 (P1) — Web puzzles call onSuccess client-side, no server validation
Added POST /api/v1/liveness/verify-challenge for the web
biometric-puzzles training surface. Single-action contract:
`{action, start_timestamp_ms, end_timestamp_ms, confidence, ...}` →
`{verified, action, duration_seconds, reason_code, message}`. Structural
validation only (action enum, timestamps monotonic + sane duration
120ms..60s, confidence floor 0.5). Heavier server-side detection
belongs to multi-step /liveness/verify. Tests:
tests/integration/test_verify_challenge_endpoint.py (7 assertions).
Web-app wiring lands in a companion PR on web-app.
Bug 5 (P1) — SHA256 model integrity pins empty / advisory
`_verify_model_integrity` previously logged a WARNING when the pin was
empty. Added `DEEPFACE_SHA256_REQUIRED=true` (default). With this flag
on AND ENVIRONMENT=production, an empty pin now raises RuntimeError at
model-load — defense against silent ~/.deepface/weights/ rotations.
Operator action: compute `sha256sum` against the in-container
facenet512_weights.h5 and pin it via DEEPFACE_FACENET512_SHA256 in
.env.prod (captured 2026-05-12 from running container:
3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f).
The face/hand_landmarker.task hashes intentionally stay empty —
those models are NOT loaded server-side; the server only delivers
them as static SHA256-verified assets to clients. Tests:
tests/unit/test_deepface_sha256_required.py (5 assertions).
Test results (DATABASE_URL=postgresql://test:test@localhost:5432/test):
- 4 new unit tests (verification_threshold_aged)
- 5 new unit tests (deepface_sha256_required)
- 8 new integration tests (verify_antispoof_block_enforce)
- 7 new integration tests (verify_challenge_endpoint)
- 6 pre-existing integration tests (verify_antispoof_wiring) — now also
run locally thanks to added `resemblyzer` mock (baseline-rot fix).
- test_config_validator.py — 14 pre-existing tests still green.
Total: 44 pass / 0 fail locally.
Operator action items:
1. Pin `DEEPFACE_FACENET512_SHA256` in /opt/projects/fivucsas/biometric-processor/.env.prod
with the value captured above (already added to local .env.prod, NOT committed
because .env.prod is gitignored).
2. Rebuild biometric-processor container to pick up these changes.
3. Decide whether to flip `ANTISPOOF_BLOCK_ENFORCE=false` for a canary rollout
before relying on the default-ON behavior.
4. To enable Bug 2 EAR veto: deploy `models/face_landmarker.task`, set
`FACE_LANDMARKER_MODEL_PATH`, then `ANTISPOOF_EAR_VETO_ENABLED=true`.
5. Add the identity-core-api proxy for `/biometric/puzzles/verify-challenge`
when convenient — web-app soft-passes on 404 until it lands.
Memory rules respected:
- feedback_spoof_detector_architecture: algorithms come from spoof-detector
via the new public shim; biometric-processor only imports + wires.
- feedback_liveness_hybrid_vs_passive: no liveness backend changes; prod
LIVENESS_BACKEND remains as configured by ops.
- feedback_readonly_rootfs_cache_dirs: new lazy FaceLandmarker init
respects the existing FACE_LANDMARKER_MODEL_PATH env contract; cache
dirs unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Author
|
Merge order note (2026-05-12) Hard dependency: spoof-detector #18 must merge first — this PR imports Suggested sequence:
Operator coordination (SHA pin, container rebuild, ANTISPOOF_BLOCK_ENFORCE canary, EAR model deploy, identity-core-api proxy follow-up) is in parent Rollingcat-Software/FIVUCSAS#67 |
This was referenced May 12, 2026
ahmetabdullahgultekin
added a commit
that referenced
this pull request
May 28, 2026
#104) Closes the 4th recurrence of feedback_readonly_rootfs_cache_dirs (DeepFace + Numba + UniFace, now MiniFASNet). With read_only:true rootfs and the cache named volume owned by root:root, DeepFace running as uid 100 silently failed to download MiniFASNet weights on first inference, collapsing the anti-spoof verdict to a false-positive. Today's hot-fix manually docker-cp'd the .pth files into the live volume; that fix was load-bearing on operator memory and would have vanished on the next `docker volume rm`. Defense in depth, two layers: 1. Image bake-in. New `model-fetcher` build stage downloads the four critical weight files with SHA256 verification: - facenet512_weights.h5 3f76b51... - centerface.onnx 77e394b... - 2.7_80x80_MiniFASNetV2.pth a5eb02e... - 4_0_0_80x80_MiniFASNetV1SE.pth 84ee1d3... All four match upstream (serengil/deepface_models, Star-Clouds/CenterFace, minivision-ai/Silent-Face-Anti-Spoofing) and the running container's live SHAs. COPY'd into the runtime stage at /opt/baked-models/.deepface with --chown=100:101. 2. Entrypoint shim (deploy/entrypoint.sh). Runs as root, chowns any externally-mounted /tmp/.deepface cache volume to 100:101, seeds missing weight files from the baked /opt/baked-models layer (so a wiped named volume self-heals on next boot), then drops to uid 100 via gosu before exec'ing the CMD. Idempotent + best-effort. Pins the app user UID/GID to 100/101 explicitly so host-side chown matches across rebuilds (the previous --system numbering was implicit and drifted). Companion changes: - .env.example documents DEEPFACE_FACENET512_SHA256 (required runtime pin per PR #102 `DEEPFACE_SHA256_REQUIRED=true`) plus the three other SHAs for audit reference. - docker-compose.prod.yml comments document that the `biometric_models` volume is now self-healing and `docker volume rm` is safe (operator no longer has to remember the manual docker-cp dance). Coordinated with parent PR (OPERATOR_ACTIONS_2026-05-12.md item 11) which gives the post-merge cleanup runbook. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ahmetabdullahgultekin
added a commit
that referenced
this pull request
May 28, 2026
…106) The mediapipe.solutions namespace was removed in mediapipe 0.10.35 (currently deployed in production). Every /verify call was logging "Landmark detection failed: module 'mediapipe' has no attribute 'solutions'" and all temporal/face signals (EAR, MAR, yaw, pitch, roll, blink, smile) collapsed to null in the liveness_calibration event. Ports four server-side consumers from the legacy face_mesh API to the new mp.tasks.vision.FaceLandmarker API: app/infrastructure/ml/landmarks/mediapipe_landmarks.py app/infrastructure/ml/quality/quality_assessor.py app/infrastructure/ml/proctoring/mediapipe_gaze_tracker.py (VIDEO mode) app/infrastructure/ml/liveness/active_liveness_detector.py tests/demo_local.py (test harness) Result-shape adaptations: Old: result.multi_face_landmarks[0].landmark[i].x New: result.face_landmarks[0][i].x The Tasks API requires a .task model asset. Centralised the path-resolution + SHA256 integrity check in a new shared loader so the four call sites + the verification route's _evaluate_ear_liveness_safe (PR #102) follow one contract: app/infrastructure/ml/landmarks/face_landmarker_loader.py - FACE_LANDMARKER_MODEL_PATH override (env-var first, repo-root fallback) - FACE_LANDMARKER_MODEL_SHA256 verification (warn-and-disable on mismatch) - VIDEO running-mode with monotonic timestamp_ms (gaze tracker) - IMAGE running-mode (the other three) Model delivery: Dockerfile bakes face_landmarker.task (float16/latest, ~3.7 MB) into /app/models/ via curl with build-time SHA256 verification. PIN: 64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff Documented in .env.example. Configured first-class in app/core/config.py (FACE_LANDMARKER_MODEL_PATH, FACE_LANDMARKER_MODEL_SHA256 already declared per PR #102; this PR adds the path default + the live SHA reference). Tests: + 22 new unit tests (loader path/SHA/import fail-soft, ported sites asserting new result shape adaption, gaze tracker VIDEO-mode timestamp monotonicity) + 1 new integration test that AST-walks app/ for any executable reference to mp.solutions / mediapipe.solutions and fails the build if any survive (regression guard). Verified with `grep -rn "mp.solutions" app/ tests/` on branch HEAD: zero executable references; all hits are docstrings/comments documenting the migration. Depends on PR #102 for the FACE_LANDMARKER_MODEL_SHA256 settings field + the _evaluate_ear_liveness_safe loader pattern this PR generalises. No conflict in the shared verification.py path (that route is unchanged by this PR). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes 4 P0 + 1 P1 from the 2026-05-12 ML review:
recommended_action='block'is now enforced. NewANTISPOOF_BLOCK_ENFORCE=trueflag (default ON) returns HTTP 403 instead of an advisory 200/verified=True.ANTISPOOF_EAR_VETO_ENABLEDflag (default OFF — operator opts in oncemodels/face_landmarker.taskis deployed).VERIFICATION_THRESHOLD_AGEDsemantics fixed: was 0.38 (stricter than 0.45 default — opposite of intent), now 0.55 (more lenient underdistance < thresholdcomparator). Pydantic validator hard-rejects aged < standard at config-load.POST /liveness/verify-challengeendpoint for the web biometric-puzzles training surface; structural validation (action enum, timestamp monotonicity, duration sanity, confidence floor). Companion web-app PR wires it.DEEPFACE_SHA256_REQUIRED=truedefault in prod: empty pin now raises at model-load (was warn-and-skip). Pinned Facenet512 SHA256 =3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f(captured from running container; goes into ops-managed.env.prod).Companion PRs:
fix/2026-05-12-liveness-and-puzzlesfix/2026-05-12-expose-blink-analyzer-shimTest plan
pytest tests/unit/test_verification_threshold_aged.py— 4 passpytest tests/unit/test_deepface_sha256_required.py— 5 passpytest tests/integration/test_verify_antispoof_block_enforce.py— 8 passpytest tests/integration/test_verify_challenge_endpoint.py— 7 passpytest tests/integration/test_verify_antispoof_wiring.py— 6 pass (existing; baseline-rot mock now also applied)pytest tests/unit/test_config_validator.py— 14 pre-existing tests still greenDEEPFACE_FACENET512_SHA256in.env.prod(already added to local file, not committed because gitignored)ANTISPOOF_BLOCK_ENFORCE=falsefirst or accept default-ONmodels/face_landmarker.task+ flipANTISPOOF_EAR_VETO_ENABLED=true44 of 44 added/touched tests pass locally with
DATABASE_URL=postgresql://test:test@localhost:5432/test. The bio baseline 79-failing-tests rot was untouched (out of scope).🤖 Generated with Claude Code