move_detect/server/src/Repository/EventRepository.php
유창욱 c3227c179c fix(security,edge,test): close the confirmed audit backlog; add the guards that prevent recurrence
감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는
그것이 조용히 지나가게 만든 구조를 고친다.

보안
- CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이
  nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다.
- /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건
  이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다.
- 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로
  쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다.

엣지
- 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다.
- 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다.
- T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 —
  추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다.
  T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게
  추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다.
- systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고
  '살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우).

테스트 — 감사의 최대 갭
- PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다.
  `-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다.
- 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의
  유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체.
- index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트,
  CI 신설(스킵이 하나라도 있으면 실패).
- ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와
  달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다.

프런트
- 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시
  만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이
  device-tree.js에는 이미 있었다.
- 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시.

납품
- VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는
  자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다.
- deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다).
- 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다.

검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인.
브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
2026-08-07 23:31:53 +09:00

81 lines
4.7 KiB
PHP
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.

<?php
declare(strict_types=1);
namespace App\Repository;
interface EventRepository
{
public function insert(array $event): int;
/** The event_id of a previously-ingested event with this uuid, or null. Backs ingest
* idempotency (a re-sent event dedups instead of duplicating). */
public function idByUuid(string $uuid): ?int;
public function recent(int $limit, ?string $severity): array;
/**
* Keyset (cursor) page over the (occurred_at DESC, event_id DESC) sort key.
* Pass null/null for the first page; then the (occurred_at, event_id) of the last
* returned row to fetch strictly older events. Stable under concurrent inserts —
* new events land at the head and never shift a page window.
* $site (when non-null) restricts the page to events whose denormalized site matches —
* the SQL half of the site-scope access control (see App\Security\SiteScope).
*/
public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array;
public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool;
/**
* Spatial points for the heatmap: events of this camera since $sinceOccurredAt that carry
* a spatial point, newest first, capped at $limit. $site (non-null) applies the same
* site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}.
*/
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array;
/**
* Tag an event as a false positive AND acknowledge it (an FP is a handled event —
* leaving it unacknowledged would keep it alarming). Idempotent on the FP flag:
* the FIRST tagger is preserved. Returns false only when the event does not exist.
*/
public function markFalsePositive(int $id, string $user): bool;
/**
* FP-rate aggregation for the stats panel: per (camera_id, event_type) totals and
* false-positive counts since $sinceOccurredAt, only groups with at least one FP.
* $site applies the same fail-closed scope filter as page().
* Rows: {camera_id, event_type, total, fp}.
*/
public function fpStats(float $sinceOccurredAt, ?string $site): array;
/**
* Aggregations for the safety operations report, all over the same window + site scope:
* - by_type: [{event_type, severity, count, unacked, fp}]
* - by_camera: [{camera_id, count, critical_high}] worst-first, top $topCameras
* - tta: {acked, avg_s, max_s} for acknowledged critical+high (FP-tagged rows excluded —
* a dismissed false alarm is not an emergency response)
* - ergo_metrics: raw metrics arrays of ergo_report rows (caller sums)
*/
public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras,
?float $untilOccurredAt = null): array;
/**
* Bare (occurred_at, event_type) pairs of non-ergo events in the window, newest first,
* capped at $limit — bucketing (weekday×hour patterns, spike baselines) happens in PHP
* so the SQL stays portable (the sqlite integration harness has no FROM_UNIXTIME).
*/
public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array;
/** Full mapped event rows in the window, newest first, capped — the label-dataset export. */
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array;
/** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int;
/**
* True if this camera has ever reported an event — the closest thing to "does this camera
* exist", playing the role DeviceRepository::exists() plays for devices.
* NOT move_cameras: that table has no registration flow and no repository (see schema.sql),
* so it is empty in every deployment — checking it would reject every real camera.
*/
public function cameraExists(string $cameraId): bool;
/**
* Delete event rows older than $cutoff, at most $limit per call. Opt-in only
* (Config::eventRetentionDays defaults to 0 = never) — see that method for why safety
* incident rows are not expired by default. Returns the number deleted.
*/
public function purgeOlderThan(float $cutoff, int $limit = 500): int;
/**
* The site this camera's events are recorded under (from its most recent event), or null
* when the camera is unknown OR its device has no site assigned. Pair with cameraExists()
* to tell those two apart, exactly as DeviceRepository::exists()/siteFor() are paired.
*/
public function siteForCamera(string $cameraId): ?string;
}