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 오프라인 번들 재생성.
31 KiB
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.
- P1-1 [H]
edge/serverclient.py,edge/alert.py—requestssends the defaultpython-requests/x.yUser-Agent; cafe24's shared-hosting WAF commonly blocks it with an HTML 403 while curl passes. Every event then silently fails. Alsoallow_redirects=Trueturns 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_AGENTin alert.py, both clients send it +allow_redirects=False; tests in test_alert.py / test_serverclient.py. - P1-2 [B]
deploy/systemd/move-detect-edge.env.example,setup_online.shprompt, docs —CAFE24_URLmust behttps://<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_detectsubdir. - P1-3 [H]
edge/config.py:40+edge/daemon.py— emptyCAFE24_URL/DEVICE_SECRETdefaults 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. - P1-4 [H]
edge/daemon.py:23—run_loopswallows every per-frame/per-camera exception with no log and no degraded-monitoring signal → a permanently broken pipeline looks green. Fix: rate-limitedlogging.exception+ consecutive-error counter. DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera; test_daemon.py locks the rate-limit. - P1-5 [H]
edge/daemon.py:33— heartbeat hardcodesfps=0.0and counts ticks wheresource.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. - 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; emitdevice_fault/monitoring_degradedwhen 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. - 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. - 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 —attemptscolumn; a row is dropped aftermax_send_attempts(default 20) so a poison head unwedges the queue; test locks the drain-past-poison behavior. - 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-generatedevent_idin the payload; server dedups on it. DONE — the edgeEventnow carries a stableevent_uuid(uuid4 hex, generated once at creation, preserved through the offline queue + every retry). Server:events.event_uuidCHAR(32) NULL UNIQUE;EventValidatoraccepts/validates it;IngestControllerreturns the already-stored event (200dedup: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). - 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_envvalidates; test_config.py locks it. - P1-11 [L]
edge/daemon.py:120—QUEUE_DBdefaults to relativequeue.db, unwritable under the hardened unit (ProtectSystem=strict) → crash loop when env omitted. Fix: absolute default under the state dir. DONE —queue_db_pathdefaults to /var/lib/move-detect/queue.db; test_config.py locks it.
Phase 2 — Core safety correctness (fall / zone / tracking)
- 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. - P2-2 [B]
edge/engine.py:72— apinch(협착) zone falls through toZONE_WARNING; the PINCH event + HIGH severity are never emitted. Fix: mappinch→PINCH,restricted→ZONE_RESTRICTED, else WARNING. DONE — dict map pinch→PINCH/restricted→ZONE_RESTRICTED; test_engine.py locks PINCH+HIGH. - 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. - 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. - 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. - 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 viatracker.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. - 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. - 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. - 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 —_footaverages 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. - 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. - P2-11 [L]
edge/geometry.py:39—head_below_hipsuses the NOSE keypoint regardless of confidence. Fix: confidence-gate. DONE —head_below_hipsreturns 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)
- P3-1 [H]
edge/app.py:18—FaceBlurrerblurs a fixed ~40×40px patch regardless of face size; close faces stay identifiable. Fix: size the blur box from the person bbox. DONE —_head_boxessizes the box from the person bbox (scales with distance) + bbox-top fallback when the nose keypoint is low-confidence. test_faceblur.py locks both. - P3-2 [H]
server/src/Ingest/IngestController.php:68— worker snapshots are stored forever; no retention/purge anywhere. PIPA violation. Fix:snapshot_retention_daysconfig + a token-guarded purge route +SnapshotRepository::purgeOlderThan. DONE (retention = 7 days) —snapshot_retention_daysconfig;purgeOlderThanon 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. - P3-2b [M]
server/src/Dashboard/EventListController.php:17—site_scopeis stored in the session but never enforced; any authenticated user sees every device's worker snapshots. Fix: scope event/snapshot queries by the session'ssite_scope. DONE (site↔device model approved) — addeddevices.site; ingest denormalizes it ontoevents.site(server-derived, never client-supplied).App\Security\SiteScopecentralizes 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. - 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, mirrorfetch_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)
- P4-1 [H]
server/public/index.php:106— no top-level try/catch; a PDOException becomes a raw 500 that leaks DSN/db_user ifdisplay_errorsis 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.
- error_log; a
- 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. - 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. - P4-4 [M]
server/src/Http/Router.php:18— route params are neverrawurldecoded; acamera_idwith a space/+/Korean saves zones but never loads them. Fix: decode captured params. DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases. - 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 keysetEventRepository::page(limit, severity, beforeTs, beforeId)(Pdo + InMemory), a(occurred_at DESC, event_id DESC)cursor stable under concurrent inserts. Controller takeslimit(default 50, cap 200) +cursor("occurred_at:event_id"), returnsnext_cursoron 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. TesttestListPaginatesWithCursor. - 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_zone400; testtestSaveOwnershipMismatchIs400NotErrorwith a throwing repo double. - 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 — extractedstoreSnapshot(): re-encode→write→index in order; every failure (re-encode, file write, DB index) nowerror_logs instead of a silentcontinue; a DB-index failure after the file lands@unlinks the orphan. A snapshot problem no longer fails the ingest — the event is already stored, 201 stands. TesttestSnapshotIndexFailureCleansOrphanFileButKeepsEvent(throwing repo double → asserts 201, event stored, no leftover *.jpg). - P4-8 [L]
server/src/Auth/AuthController.php:31— login form emits a CSRF token thatloginnever checks. Fix: verify it (or drop it). DONE — verified (completed the half-built defense, not dropped). login() checksCsrf::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; addedtestLoginRejectsMissingOrWrongCsrf. - P4-9 [L]
server/src/Auth/AuthController.php:71— logout callsregenerate()aftersession_destroy()→ E_WARNING, old cookie not replaced. Fix: reorder. DONE — logout() rotates the id then wipes (was regenerate-after-destroy). - 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. - P4-11 [L]
server/src/Http/Request.php:45—pathForroutes 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.
- P5-1 [B]
deploy/bundle/install_offline.sh:79— the OFFLINE installer has no backend-usability gate (setup_online's [6/8] does): it installsPOSE_BACKEND=movenetverbatim 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. - 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: assertuname -m == TARGET_ARCH. DONE — native branch now hard-fails when uname -m != TARGET_ARCH. - 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 (viafetch_hailo_hef --arch) into the bundle. DONE — build now fetches the$HAILO_ARCH(default hailo8) HEF viafetch_hailo_hef(pinned sha256, aborts on mismatch) and stagesyolov8s_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 underset -e(now guarded/optional → Hailo-only bundle), andinstall_offline.shcopied only*.onnxso a bundled HEF never reached$PREFIX/modelsfor the backend gate to find (now copies*.heftoo). 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, MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. Fix: pin + hash + manifest digests. DONE — build emitsSHA256SUMS(sha256 of every wheel + model),requirements.lock(exactname==versionpins for a reproducible rebuild), andsbom.json(CycloneDX 1.5: name, version, pypi purl, sha256). Optional detached GPG signature viaSIGN_KEY→SHA256SUMS.asc.install_offline.shrunssha256sum -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 -cpasses). Tests lock the manifest + the install-time check. - P5-5 [H]
deploy/systemd/move-detect-edge.service:46OFF-HW—DeviceAllowomits/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 — addedchar-media+/dev/dma_heap/. OFF-HW: confirm on the Pi with the CSI camera. - 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
- 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 어느 쪽 테스트도 잡을 수 없던 자리다. - A2 [B]
server/tools/seed_demo.php—--purge가 위험성평가를reviewed_by='안전관리자' AND site='factory-a'로 지웠다. '안전관리자'는 이 제품의 기본 관리자 계정명이고 'factory-a'는 흔한 현장명이라, 운영 DB 에서 한 번 실행하면 실제 위험성평가가 복구 불가능하게 사라진다. DONE — 데모 행은DEMO_PREFIX/DEMO_ACTOR/DEMO_LABEL표식으로만 식별. 표식 없는 행은 어떤 경우에도 건드리지 않는다. - 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
- A4 [H] 신규 유형이
app.js/detail.js/report.js의TYPE_LABELS와 이벤트 유형 필터에 없어 화면에camera_moved같은 원시 코드가 그대로 표시됐다. 조사자가 그 유형만 걸러 볼 방법도 없었다. DONE — 3개 맵 + 필터 갱신, A1 의 파리티 테스트가 재발을 막는다. - A5 [H] 서버가
date_default_timezone_set를 하지 않아 통보 메일의 시각이 PHP 기본 (공유호스팅은 흔히 UTC)으로 찍혔다. 대시보드는 브라우저 시각을 쓰므로, 콘솔이 13:01 로 보여준 넘어짐을 메일이 04:01 로 적는다 — 응급 대응에서 읽는 유일한 문장이 9시간 어긋난다. DONE —Config::timezone()(기본Asia/Seoul, 잘못된 값이면 폴백하되 발송은 막지 않음),Channels가 프로세스 전역 기본값을 건드리지 않고 해당 시간대로 렌더. - 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
- A7 [M]
AlertDispatcher::run이 매분 창 전체를 다시 훑으며 이미 보낸 (event, channel) 쌍마다 실패할 INSERT 를 한 번씩 재시도했다. 비용이 '새 이벤트'가 아니라 '창 길이'에 비례해, 크론이 불안정해 창을 넓힌 현장일수록 부하가 커진다. DONE —claimedSince()로 창의 원장을 한 번에 읽어 사전 필터.claim()은 여전히 최종 권위(경합 시)다. 회귀 테스트가 2·3회차 실행에서 claim 시도가 0 임을 고정. - A8 [M]
move_alert_log에 보존 정책이 없어 유일하게 무한 증가하는 표였다 (이벤트 보존은 기본 비활성이라 CASCADE 도 일어나지 않는다). DONE —maintain.php가 해소된 전달(sent/suppressed)만 정리. failed 는 남긴다 — "아무에게도 닿지 않았다"는 사실이 조사에서 필요한 발견이라 청소가 지우면 안 된다. - A9 [M] 전역 관리자가 현장 채널을 수정하면
site가 NULL 로 덮여 그 채널이 모든 현장에 발화하게 됐다. 폼 어디에도 그런 안내가 없다. DONE — 수정 시 기존 site 보존, 신규 생성만 호출자 스코프. 양쪽 회귀 테스트 추가. - A10 [M] 일괄 확인이 대상마다 fetch 를 하나씩 띄웠다. 7건에서는 무해하지만 실제
백로그는 수백 건이고, HTTP/2 에서는 브라우저가 직렬화하지 않아 공유호스팅에 한꺼번에 떨어진다.
DONE — 동시 4개로 제한하고 진행 중에도 목록을 갱신(멈춘 것처럼 보이지 않게), 완료 토스트에
성공/전체를 함께 표기.
LOW
- A11 [L] 패키지 테스트가
tools/배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를 읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. DONE — ① 모든 CLI 도구가bootstrap.php(SAPI 가드)를 require 하는지 ② 패키지에서 tools/ 가 private 폴더에만 있는지 ③ 웹 루트의 PHP 가index.php/_preflight.php뿐인지 검사. - A12 [L] 감사 리포트 판정 배너와
CLAUDE.md의 "43/43" 표기가 stale. DONE — 이 문서 상단 배너 + CLAUDE.md 갱신.
재감사에서 확인만 하고 수정하지 않은 항목 (판단 기록)
- 웹훅 SSRF (내부망 주소 지정 가능) — 채널 등록은 admin 전용이고, 이 기능의 정당한 용도가 바로 사내망 그룹웨어·문자게이트웨이다. 사설 IP 대역을 막으면 기능 자체가 성립하지 않는다. http(s) 한정 + 리다이렉트 금지 + 토큰 헤더까지가 방어선. 수용.
- 서버가 엣지 GPIO 핀 번호를 지정 — 손상된 서버가 임의 핀을 구동할 수 있다. 다만 서버는 이미 존(감지 판정 자체)을 내려보내는 신뢰 주체이고, 핀 범위는 서버측에서 0~40 으로 검증된다. 이 신뢰 경계를 좁히려면 장치측 화이트리스트가 필요 — 실장비 커미셔닝 항목으로 이관.
/setup4단계가 첫 카메라의 존만 확인 — 다른 카메라에 존이 있어도 "없음"으로 보일 수 있다. 상태 문구에 "첫 카메라에" 를 명시해 오해를 줄였고, 전 카메라 조회는 온보딩 화면이 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_uuididempotency key (stable through the queue) + server dedup on a UNIQUE column. P3-2b:devices.site→ denormalizedevents.site;SiteScopeenforced 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.