move_detect/server/src/Alert/AlertDispatcher.php
유창욱 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

140 lines
7.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\Alert;
use App\Repository\{AlertRepository, EventRepository, SuppressionRepository};
/**
* Decides WHICH notifications an event earns, and when.
*
* This is the piece the product was missing entirely: until now a detection reached a human only
* if a browser tab happened to be open and someone happened to be looking at it. A fall at 03:00
* produced a row in a database and nothing else.
*
* Deliberately pure decision logic — no I/O, no clock, no sending. plan() takes the world as
* arguments and returns a list of deliveries to attempt, so every rule below (severity floors,
* escalation delays, suppression windows, already-acknowledged short-circuit) is unit testable
* without a database, an SMTP server or a wall clock. Sending lives in the channels; claiming
* and recording lives in the repository.
*
* THE RULES, and why each exists:
*
* - Severity floor per channel. A beacon that fires on 정보 events is a beacon nobody looks at.
* - step 0 fires immediately; step >0 fires only after escalate_after_s of the event still
* being unacknowledged. That is what converts "we detected it" into "somebody was made to
* look at it", and it is the only mechanism here that can wake a second person.
* - An acknowledged event escalates no further. Escalation exists to find a human, and one has
* already been found.
* - A false-positive tag stops everything, for the same reason.
* - Suppression (planned maintenance / work permit) blocks the SEND but is still recorded, as
* status='suppressed'. An investigator has to be able to see that an alarm was raised and
* deliberately withheld — a silently dropped notification is indistinguishable from one that
* was never generated, and that difference is exactly what an inquiry turns on.
*/
final class AlertDispatcher
{
/** Ranked so a channel's min_severity acts as a floor, not an equality test. */
private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3];
public function __construct(
private EventRepository $events,
private AlertRepository $alerts,
private SuppressionRepository $suppressions,
) {}
/**
* @param array $event a row from EventRepository (mapRow shape)
* @param array $channels channel rows for this event's site
* @return array<int,array{channel:array,suppressed:bool}> deliveries to attempt
*/
public function planFor(array $event, array $channels, int $now): array
{
// Resolved events stop the chain. Note this covers the escalation steps too: an event
// acknowledged 30 seconds in never reaches the 5-minute escalation channel.
if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) return [];
$rank = self::SEVERITY_RANK[$event['severity']] ?? 0;
$age = $now - (int)$event['occurred_at'];
// One suppression lookup per event covers all of its scopes. Zone is included because a
// permit is usually issued for an area, not for a camera.
$scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]];
if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']];
$suppressed = $this->suppressions->activeFor($scopes, $now) !== [];
$out = [];
foreach ($channels as $ch) {
if (empty($ch['enabled'])) continue;
// `local` beacons are fired by the edge node itself the moment its own detector
// produces the event — no server round trip, and no dependency on this job running
// at all. Their rows exist only to carry the GPIO configuration down through
// GET /api/config, so the server has nothing to deliver and must not claim a
// delivery row for them (which would show up as a permanent failure).
if ($ch['kind'] === 'local') continue;
if ($rank < (self::SEVERITY_RANK[$ch['min_severity']] ?? 0)) continue;
// A site-scoped channel only serves its own site; a NULL-site channel is global.
if ($ch['site'] !== null && $ch['site'] !== ($event['site'] ?? null)) continue;
// Not yet due: a later dispatcher run will pick it up once the event has aged.
if ((int)$ch['step'] > 0 && $age < (int)$ch['escalate_after_s']) continue;
$out[] = ['channel' => $ch, 'suppressed' => $suppressed];
}
return $out;
}
/**
* Run one dispatch pass over the recent window.
*
* @param callable(array $event, array $channel): void $send throws on failure
* @return array{considered:int,sent:int,suppressed:int,failed:int,skipped:int}
*/
public function run(int $now, int $windowSeconds, int $limit, callable $send): array
{
$stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0];
$events = $this->events->forNotification((float)($now - $windowSeconds), $limit);
// One channel read per site, not per event: a busy window is hundreds of events over a
// handful of sites, and this job runs on shared hosting where every query costs.
$channelsBySite = [];
// Everything already in the ledger for this window, in ONE query. Without it the run
// re-attempts claim() for every pair it delivered on every previous run — an INSERT that
// fails on the unique key, once per (event × channel), once a minute, forever. claim()
// stays the authority (two runs can still overlap between this read and the write); this
// is purely the cheap pre-filter that keeps the cost proportional to NEW events.
$alreadyClaimed = $this->alerts->claimedSince($now - $windowSeconds - 60);
foreach ($events as $event) {
$stats['considered']++;
$site = $event['site'] ?? null;
$key = $site ?? "\x00global";
if (!array_key_exists($key, $channelsBySite)) {
$channelsBySite[$key] = $this->alerts->channels($site);
}
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
$channelId = (int)$delivery['channel']['channel_id'];
$eventId = (int)$event['event_id'];
// Cheap pre-filter (see $alreadyClaimed) — skips the overwhelming majority of
// pairs on every run after the first.
if (isset($alreadyClaimed[$eventId . ':' . $channelId])) { $stats['skipped']++; continue; }
// claim() remains the authority: the pre-filter is a snapshot, and a concurrent
// run can claim a pair between that read and this write. Losing the race means
// someone else owns this delivery — skip, do not send.
if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
if ($delivery['suppressed']) {
$this->alerts->markSuppressed($eventId, $channelId, $now);
$stats['suppressed']++;
continue;
}
try {
$send($event, $delivery['channel']);
$this->alerts->markSent($eventId, $channelId, $now);
$stats['sent']++;
} catch (\Throwable $e) {
// Never fatal: one unreachable SMTP relay must not stop the webhook that
// would have reached somebody. The row keeps the error for the retry pass.
$this->alerts->markFailed($eventId, $channelId, $e->getMessage(), $now);
$stats['failed']++;
}
}
}
return $stats;
}
}