move_detect/docs/delivery-audit.md
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.

CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
  edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
  끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
  검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
  허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
  (tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
  프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
  지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
  한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
  식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
  (/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
  전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
  경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.

HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
  그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
  공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
  04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
  Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
  DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
  이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.

MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
  크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
  claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
  정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
  발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
  공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.

LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
  읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
  PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.

수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.

부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.

검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

404 lines
No EOL
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-08-18): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 감사 43/43 + 2차 재감사(안전 플랫폼 확장 표면) 12/12 해소.
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is
checked off only after a test locks it and verification passes.
Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low ·
`OFF-HW` = cannot be verified without the Hailo board.
---
## Phase 1 — Silent failure & misconfiguration (the #1 delivery risk)
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_serverclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
DONE — env.example + installer prompt now show/require the `/move_detect` subdir.
- [x] **P1-3 [H]** `edge/config.py:40` + `edge/daemon.py` — empty `CAFE24_URL`/`DEVICE_SECRET`
defaults let the daemon run green while every upload fails and the queue grows forever.
**Fix:** fail loud at startup if either is blank.
DONE — `require_upload_config(cfg)` raises at daemon startup; test_config.py locks it.
- [x] **P1-4 [H]** `edge/daemon.py:23` — `run_loop` swallows every per-frame/per-camera
exception with no log and no degraded-monitoring signal → a permanently broken pipeline
looks green. **Fix:** rate-limited `logging.exception` + consecutive-error counter.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the server cannot tell a dead device from a live one.
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag.
- [x] **P1-6 [H]** `edge/adapters/uvc_source.py:12` — a camera that stops delivering frames is
never detected or reopened; monitoring dies silently. **Fix:** fail loud on open failure;
emit `device_fault`/`monitoring_degraded` when a camera goes stale.
DONE — open-failure raises (build_runtimes logs+skips); read() releases+reopens after a
streak; heartbeat's cameras_erroring (P1-5) reports the fault. test_uvc_source.py locks it.
- [x] **P1-7 [M]** `edge/queue.py` — offline sqlite queue has no size cap/TTL; an outage (or a
permanent misconfig) fills the SD card, then new events drop silently. **Fix:** bounded
queue (row/byte budget), log dropped-oldest count.
DONE — `max_rows` (default 2000) evicts oldest on enqueue; test_queue.py locks it.
- [x] **P1-8 [H]** `edge/queue.py:52` — a permanently-rejected event (e.g. 400) is retried
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
poison head unwedges the queue; test locks the drain-past-poison behavior.
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; server dedups on it.
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
creation, preserved through the offline queue + every retry). Server: `events.event_uuid`
CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the
already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key.
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server).
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it.
- [x] **P1-11 [L]** `edge/daemon.py:120` — `QUEUE_DB` defaults to relative `queue.db`,
unwritable under the hardened unit (`ProtectSystem=strict`) → crash loop when env omitted.
**Fix:** absolute default under the state dir.
DONE — `queue_db_path` defaults to /var/lib/move-detect/queue.db; test_config.py locks it.
## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
(zone.py → engine.process → run_once threads frame_wh); Polygon::validate is range-agnostic
so PHP stores it opaquely. test_zone.py locks the normalized contract.
- [x] **P2-2 [B]** `edge/engine.py:72` — a `pinch` (협착) zone falls through to
`ZONE_WARNING`; the PINCH event + HIGH severity are never emitted. **Fix:** map
`pinch→PINCH`, `restricted→ZONE_RESTRICTED`, else WARNING.
DONE — dict map pinch→PINCH/restricted→ZONE_RESTRICTED; test_engine.py locks PINCH+HIGH.
- [x] **P2-3 [H]** `edge/detectors/fall.py:65` — a per-frame keypoint-confidence gate
silently disables fall, no_motion AND ghost for an occluded/low-confidence fallen person —
exactly when someone is on the floor. **Fix:** use bbox cues when keypoints are low-conf.
DONE — bbox cues (aspect/height-collapse) always run; keypoint cues gated by kp_ok;
test_fall.py locks both the low-conf detection and the no-false-positive-standing case.
- [x] **P2-4 [H]** `edge/detectors/ghost.py:38` — a ghost candidate is cancelled when ANY
person (a colleague) passes near the loss point. **Fix:** only the plausibly-same track
cancels it.
DONE — candidate snapshots the active track ids at loss time; only a NEW track (not a
known bystander) near the loss point cancels. test_ghost.py locks the bystander case.
- [x] **P2-5 [H]** `edge/geometry.py:66` — stillness compares raw per-frame displacement to a
fixed threshold with no dt normalization → immobility/no_motion flips with FPS. **Fix:**
normalize by elapsed time.
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
- [x] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
reset per-track safety state on a low-confidence match.
DONE — global greedy (all pairs sorted by IoU desc) replaces track-order greedy; any track
in a contended match (person↔2 tracks or track↔2 persons) is reported via
`tracker.ambiguous()` and run_once resets its engine + smoother state. A still-prone worker
re-confirms within prone_confirm_s. test_tracking.py + test_app.py lock both.
- [x] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s,
dataclass-only, no env knob). FALLEN/no_motion timing unchanged (keyed off prone_since).
test_fall.py locks it.
- [x] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
DONE — run_once feeds the engine (and smoothers) only tracks re-detected THIS tick
(`latest.t == now`); a stale track is no-data until re-detected or lost. Re-detection still
confirms the fall (dropout pauses evidence, doesn't erase it). test_app.py locks both.
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
confidence-gate the ankles, fall back to bbox-bottom-center.
DONE — `_foot` averages only ankles with conf ≥ MIN_ANKLE_CONF (0.2, the existing 'visible'
gate); with neither trustworthy it falls back to bbox bottom-centre. No tuning knob — reuses
the codebase's keypoint-visible convention. test_zone.py locks the fallback.
- [x] **P2-10 [M]** `edge/adapters/movenet_pose.py:34` — the CPU MoveNet fallback is
single-person; with 2+ workers all but one are invisible to every detector, no warning.
**Fix:** warn loudly that MoveNet fallback is single-person (Hailo is multi-person).
DONE — MoveNetPoseAdapter.__init__ logs a startup WARNING ("SINGLE-PERSON … use POSE_BACKEND=
hailo at multi-person sites"). test_movenet_pose.py asserts the warning via caplog.
- [x] **P2-11 [L]** `edge/geometry.py:39` — `head_below_hips` uses the NOSE keypoint
regardless of confidence. **Fix:** confidence-gate.
DONE — `head_below_hips` returns False when nose conf < nose_thr (0.2), so an unconfident/
hallucinated nose no longer fires the fall cue. test_geometry.py locks it; test_fall.py stays
green (the cue's confident-nose path is unchanged).
## Phase 3 — Privacy / PIPA (delivery compliance)
- [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]** `server/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`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored
in the session but never enforced; any authenticated user sees every device's worker
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
`events.site` (server-derived, never client-supplied). `App\Security\SiteScope` centralizes the
rule (NULL scope = global operator). The event LIST filters in SQL (`page(...,$site)`); event
DETAIL + ACK and the SNAPSHOT proxy guard per-row and return 404 (not 403) cross-site so
existence never leaks. Migration ALTERs + provisioning UPDATEs documented in schema.sql.
Tests: list scoped, detail cross-site 404, snapshot cross-site 404 / same-site 200.
- [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 — Server hardening (shared hosting)
- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`.
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
backstop. Package build verified.
- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error.
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
gated 1/100). Full PHP suite green.
- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params.
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
- [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans.
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
file write, DB index) now `error_log`s instead of a silent `continue`; a DB-index failure after
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(throwing repo double → asserts 201, event stored, no leftover *.jpg).
- [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that
`login` never checks. **Fix:** verify it (or drop it).
DONE — verified (completed the half-built defense, not dropped). login() checks
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
- [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator.
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
- [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
(parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain
DONE — pathFor collapses leading slashes before parse_url; BasePathTest locks it.
- [x] **P5-1 [B]** `deploy/bundle/install_offline.sh:79` — the OFFLINE installer has no
backend-usability gate (setup_online's [6/8] does): it installs `POSE_BACKEND=movenet`
verbatim even when the bundle carries no working backend. **Fix:** mirror the [6/8] probe.
DONE — [5/6] probes HEF+hailo_platform / onnx+onnxruntime, reconciles POSE_BACKEND in the
installed env, hard-fails if neither. test_deploy_artifacts.py locks the gate.
- [x] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces
an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`.
DONE — native branch now hard-fails when uname -m != TARGET_ARCH.
- [x] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never
carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via
`fetch_hailo_hef --arch`) into the bundle.
DONE — build now fetches the `$HAILO_ARCH` (default hailo8) HEF via `fetch_hailo_hef` (pinned
sha256, aborts on mismatch) and stages `yolov8s_pose.hef`. Also fixed two latent breaks that
made the offline path unusable regardless: the MoveNet ONNX fetch was unconditional so its dead
source aborted the whole build under `set -e` (now guarded/optional → Hailo-only bundle), and
`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.
- [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.
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
- [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
---
## Phase 6 — 재감사 2026-08-18 (안전 플랫폼 확장 표면)
커밋 `e00ad69` 로 감사 표면이 크게 늘었다: 신규 라우트 17개, DB 표 6종 + 컬럼 4종, CLI 도구
5개, 엣지 모듈 4개(GPIO 출력 포함), 통보 발송 경로(메일·웹훅) 신설, 감사로그 해시체인.
8개 관점(보안·데이터 정합·렌더·패키징·문서 정합·성능·UX·회귀)으로 재감사, 확정 12건 전량 해소.
### CRITICAL
- [x] **A1 [B]** `server/src/Ingest/EventValidator.php` — 신규 감지 유형(`camera_moved`,
`camera_obstructed`, `low_light`)이 ingest 허용 목록에 없어 **서버가 전부 400 으로 거부**했다.
`edge/queue.py` 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
전부 끝난 상태로 end-to-end 무력화되어 있었다 — 로그를 뒤지지 않는 한 아무도 모른다.
DB CHECK 와 프런트 라벨은 갱신했는데 **가운데 검증 계층만** 빠진, 계층 간 목록 중복의 전형적 결함.
DONE — 허용 목록 갱신 + **언어 경계를 넘는 회귀 테스트** 신설
(`tests/test_event_type_parity.py`: `edge/events.py` ↔ EventValidator ↔ schema.sql ↔
프런트 라벨 5종 ↔ 유형 필터). 파이썬·PHP 어느 쪽 테스트도 잡을 수 없던 자리다.
- [x] **A2 [B]** `server/tools/seed_demo.php` — `--purge` 가 위험성평가를
`reviewed_by='안전관리자' AND site='factory-a'` 로 지웠다. '안전관리자'는 이 제품의 **기본
관리자 계정명**이고 'factory-a'는 흔한 현장명이라, 운영 DB 에서 한 번 실행하면 실제
위험성평가가 복구 불가능하게 사라진다.
DONE — 데모 행은 `DEMO_PREFIX`/`DEMO_ACTOR`/`DEMO_LABEL` 표식으로만 식별. 표식 없는 행은
어떤 경우에도 건드리지 않는다.
- [x] **A3 [B]** `server/tools/backup.php` — 웹 공개 경로 차단이 `str_contains($real, '/www/')`
라 **docroot 자체**(`/home/acct/www`)는 뒤에 슬래시가 없어 통과했다. 가장 흔한 오사용이
정확히 그 경우다. 전 현장의 사고 기록 + 작업자 이미지가 웹에서 내려받힌다 (파일명은
`move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만 알면 추측 가능).
DONE — 경로를 세그먼트로 쪼개 `www/public_html/htdocs/html/web/public` 중 하나라도 있으면 거부.
회귀 검증: docroot 자체·하위 경로 모두 차단, 비공개 경로는 통과.
### HIGH
- [x] **A4 [H]** 신규 유형이 `app.js`/`detail.js`/`report.js` 의 `TYPE_LABELS` 와 이벤트 유형
필터에 없어 화면에 `camera_moved` 같은 원시 코드가 그대로 표시됐다. 조사자가 그 유형만
걸러 볼 방법도 없었다. DONE — 3개 맵 + 필터 갱신, A1 의 파리티 테스트가 재발을 막는다.
- [x] **A5 [H]** 서버가 `date_default_timezone_set` 를 하지 않아 통보 메일의 시각이 PHP 기본
(공유호스팅은 흔히 UTC)으로 찍혔다. 대시보드는 브라우저 시각을 쓰므로, 콘솔이 13:01 로
보여준 넘어짐을 메일이 04:01 로 적는다 — 응급 대응에서 읽는 유일한 문장이 9시간 어긋난다.
DONE — `Config::timezone()` (기본 `Asia/Seoul`, 잘못된 값이면 폴백하되 발송은 막지 않음),
`Channels` 가 프로세스 전역 기본값을 건드리지 않고 해당 시간대로 렌더.
- [x] **A6 [H]** 마이그레이션의 `ALTER TABLE … DROP CHECK` 는 MySQL 8.0.16+ 전용 문법이다.
카페24 공유호스팅에 흔한 **MariaDB 는 `DROP CONSTRAINT` 를 쓰므로 마이그레이션이 통째로 실패**한다.
DONE — 해당 블록에 엔진별 분기(A: MySQL 8.0.16+ / B: MariaDB / C: 제약 없음) 를 명시하고,
이 CHECK 가 2차 방어선이며 1차는 `EventValidator` 임을 적어 실패해도 판단할 수 있게 함.
적용 확인용 probe INSERT/DELETE 도 함께 기재.
### MEDIUM
- [x] **A7 [M]** `AlertDispatcher::run` 이 매분 창 전체를 다시 훑으며 이미 보낸
(event, channel) 쌍마다 실패할 INSERT 를 한 번씩 재시도했다. 비용이 '새 이벤트'가 아니라
'창 길이'에 비례해, 크론이 불안정해 창을 넓힌 현장일수록 부하가 커진다.
DONE — `claimedSince()` 로 창의 원장을 한 번에 읽어 사전 필터. `claim()` 은 여전히 최종
권위(경합 시)다. 회귀 테스트가 2·3회차 실행에서 claim 시도가 0 임을 고정.
- [x] **A8 [M]** `move_alert_log` 에 보존 정책이 없어 유일하게 무한 증가하는 표였다
(이벤트 보존은 기본 비활성이라 CASCADE 도 일어나지 않는다).
DONE — `maintain.php` 가 해소된 전달(sent/suppressed)만 정리. **failed 는 남긴다** —
"아무에게도 닿지 않았다"는 사실이 조사에서 필요한 발견이라 청소가 지우면 안 된다.
- [x] **A9 [M]** 전역 관리자가 현장 채널을 수정하면 `site` 가 NULL 로 덮여 그 채널이 **모든
현장에 발화**하게 됐다. 폼 어디에도 그런 안내가 없다.
DONE — 수정 시 기존 site 보존, 신규 생성만 호출자 스코프. 양쪽 회귀 테스트 추가.
- [x] **A10 [M]** 일괄 확인이 대상마다 fetch 를 하나씩 띄웠다. 7건에서는 무해하지만 실제
백로그는 수백 건이고, HTTP/2 에서는 브라우저가 직렬화하지 않아 공유호스팅에 한꺼번에 떨어진다.
DONE — 동시 4개로 제한하고 진행 중에도 목록을 갱신(멈춘 것처럼 보이지 않게), 완료 토스트에
`성공/전체` 를 함께 표기.
### LOW
- [x] **A11 [L]** 패키지 테스트가 `tools/` 배치를 검사하지 않았다. 이 도구들은 메일을 보내고
DB 전체를 읽으므로 www/ 아래로 새면 인증 없는 표면이 된다.
DONE — ① 모든 CLI 도구가 `bootstrap.php`(SAPI 가드)를 require 하는지 ② 패키지에서 tools/ 가
private 폴더에만 있는지 ③ 웹 루트의 PHP 가 `index.php`/`_preflight.php` 뿐인지 검사.
- [x] **A12 [L]** 감사 리포트 판정 배너와 `CLAUDE.md` 의 "43/43" 표기가 stale.
DONE — 이 문서 상단 배너 + CLAUDE.md 갱신.
### 재감사에서 확인만 하고 수정하지 않은 항목 (판단 기록)
- **웹훅 SSRF (내부망 주소 지정 가능)** — 채널 등록은 admin 전용이고, 이 기능의 정당한
용도가 바로 **사내망** 그룹웨어·문자게이트웨이다. 사설 IP 대역을 막으면 기능 자체가
성립하지 않는다. http(s) 한정 + 리다이렉트 금지 + 토큰 헤더까지가 방어선. 수용.
- **서버가 엣지 GPIO 핀 번호를 지정** — 손상된 서버가 임의 핀을 구동할 수 있다. 다만 서버는
이미 존(감지 판정 자체)을 내려보내는 신뢰 주체이고, 핀 범위는 서버측에서 0~40 으로 검증된다.
이 신뢰 경계를 좁히려면 장치측 화이트리스트가 필요 — 실장비 커미셔닝 항목으로 이관.
- **`/setup` 4단계가 첫 카메라의 존만 확인** — 다른 카메라에 존이 있어도 "없음"으로 보일 수
있다. 상태 문구에 "첫 카메라에" 를 명시해 오해를 줄였고, 전 카메라 조회는 온보딩 화면이
N+1 요청을 내는 대가가 이득보다 크다고 판단. 수용.
---
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
- [ ] **GPIO 경광등 실동작** — `edge/beacon.py` 의 핀 구동은 단위 테스트로 증명할 수 없다.
Pi + 릴레이 모듈로 latch/pulse, `active_high` 반전, hold 만료 해제를 육안 확인.
- [ ] **카메라 무결성 임계값** — `IntegrityConfig.moved_mad` / `obstructed_var` /
`low_light_mean` 은 실제 현장 조명·진동에서 튜닝해야 한다. 현재 값은 합성 프레임 기준.
- [ ] **P2-6/7/8** — 낙상 타이밍·트래킹 매칭 (1차 감사에서 이월). 블라인드 수정은 감지 **누락**을
만들 수 있어 실영상 확보 후 조정.
- [ ] **CSI 카메라 / Hailo HEF 추론** — `tests/hardware/` 스킵 항목.
- [ ] **통보 실발송** — 사내 SMTP 릴레이·웹훅 엔드포인트는 현장에만 있다.
`dispatch_alerts.php --dry-run` 으로 대상만 먼저 확인한 뒤 1회 실발송 검증.
---
## Progress log
(updated as each item lands — newest first)
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
P2-6/7/8 (safety-timing / tracking-match — deferred to on-hardware commissioning).
- **2026-07-17 — off-hardware solo work COMPLETE (35/43).** Landed this pass, each test-locked:
P4-8 (login CSRF), P4-6 (zone 400-not-500), P4-5 (cursor pagination), P4-7 (snapshot atomicity
+ orphan cleanup), P5-3 (HEF in the offline bundle + fixed the broken build), P5-4 (pin + hash +
SBOM + install-time verify), P2-9/10/11 (keypoint confidence gating + MoveNet single-person
warning). phpunit 90, pytest 157 — all green.
REMAINING 8, none solo-doable:
· P1-9, P3-2b — need a DATA-MODEL decision (event dedup key + schema column; site↔device model).
· P2-6, P2-7, P2-8 — safety-timing / tracking-match logic that must be validated ON HARDWARE.
Changing them blind could make fall detection MISS events (worse than today's over-alarm,
which is fail-safe), so they are deliberately deferred to on-device commissioning.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
correctness (P2-3,4,5). Remaining: P1-9 (deferred to server batch), P2-6..11 (mostly threshold
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
Nothing committed yet.
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.