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 오프라인 번들 재생성.
140 lines
7.7 KiB
PHP
140 lines
7.7 KiB
PHP
<?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;
|
||
}
|
||
}
|