move_detect/server/tools/dispatch_alerts.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

125 lines
5.8 KiB
PHP

<?php
/**
* 통보 디스패처 — 크론 진입점.
*
* 크론 등록 (1분 주기). 아래 한 줄을 그대로 쓰되, 앞의 분 필드는 별표+슬래시+1 입니다 —
* 이 주석 안에 그대로 적으면 PHP 블록 주석이 거기서 끝나 파일이 파싱되지 않습니다:
*
* <매분> /usr/bin/php /path/to/move_detect_app/tools/dispatch_alerts.php >> /path/alerts.log 2>&1
*
* 정확한 크론 표기는 server/docs/server-deploy.md §11 을 보십시오.
*
* 하는 일 3가지:
* 1. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다.
* 2. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널).
* 3. 실패한 전달을 재시도한다.
*
* 이 파일이 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 아니다.
* 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞는 상황이 정확히 이 구멍이었다.
*
* --dry-run 은 어떤 통보가 나갔을지만 출력하고 아무것도 보내지 않으며 원장에도 쓰지 않는다.
* 채널을 처음 설정할 때 반드시 먼저 돌려볼 것 — 잘못된 수신자 목록으로 첫 발송을 하면
* 그 사이트에서는 두 번 다시 이 기능을 켜지 않는다.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use App\Alert\{AlertDispatcher, Channels, EscalationWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository};
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
$now = time();
$dryRun = isset($args['dry-run']);
// 창은 재시도 여유를 포함해야 한다: 크론이 몇 번 건너뛰어도 그 사이 이벤트가 통보 없이
// 흘러가면 안 된다. 기본 1시간.
$window = max(60, min(86400, (int)($args['window'] ?? 3600)));
$escalateAfter = max(30, min(86400, (int)($args['escalate-after'] ?? $cfg->escalateAfterSeconds())));
$limit = max(1, min(2000, (int)($args['limit'] ?? 500)));
$maxAttempts = max(1, min(20, (int)($args['max-attempts'] ?? 5)));
$events = new PdoEventRepository($pdo);
$alerts = new PdoAlertRepository($pdo);
$suppressions = new PdoSuppressionRepository($pdo);
$baseUrl = $cfg->publicBaseUrl();
if ($baseUrl === '') {
fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n");
exit(2);
}
$channels = new Channels($baseUrl, 8, $cfg->timezone());
// --- 1. 에스컬레이션 발행 -------------------------------------------------------------
$watch = new EscalationWatch($events);
$escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit);
if ($escalated !== []) {
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
}
// --- 2. 통보 --------------------------------------------------------------------------
$dispatcher = new AlertDispatcher($events, $alerts, $suppressions);
// local 채널은 디스패처가 계획 단계에서 제외한다 (엣지가 자체 GPIO 로 즉시 울린다 —
// App\Alert\AlertDispatcher::planFor 주석 참조). 여기 도달하는 것은 email/webhook 뿐이다.
$send = function (array $event, array $channel) use ($channels, $dryRun): void {
if ($dryRun) {
fwrite(STDOUT, sprintf(" [dry-run] event=%d -> %s(%s) : %s\n",
$event['event_id'], $channel['kind'], $channel['name'], $channels->subject($event)));
return;
}
$channels->send($event, $channel);
};
$stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0];
try {
if ($dryRun) {
// run() claims a ledger row per delivery, which is exactly what must NOT happen during a
// rehearsal: a claimed row is never retried, so a dry run would silently consume the
// first real notification for every event in the window. Plan only.
foreach ($events->forNotification((float)($now - $window), $limit) as $ev) {
$stats['considered']++;
foreach ($dispatcher->planFor($ev, $alerts->channels($ev['site'] ?? null), $now) as $d) {
if ($d['suppressed']) { $stats['suppressed']++; continue; }
$send($ev, $d['channel']);
$stats['sent']++;
}
}
} else {
$stats = $dispatcher->run($now, $window, $limit, $send);
}
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' dispatcher error: ' . $e->getMessage() . "\n");
exit(1);
}
// --- 3. 실패 재시도 -------------------------------------------------------------------
$retried = 0;
if (!$dryRun) {
foreach ($alerts->retryable($maxAttempts, 100) as $row) {
$event = $events->find((int)$row['event_id']);
if ($event === null) continue;
// 재시도 시점에 이미 확인된 사건이면 더 보내지 않는다 — 사람이 이미 붙었다.
if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) continue;
$channel = null;
foreach ($alerts->allChannels(null) as $c) {
if ((int)$c['channel_id'] === (int)$row['channel_id']) { $channel = $c; break; }
}
if ($channel === null) continue;
try {
$channels->send($event, $channel);
$alerts->markSent((int)$row['event_id'], (int)$row['channel_id'], $now);
$retried++;
} catch (Throwable $e) {
$alerts->markFailed((int)$row['event_id'], (int)$row['channel_id'], $e->getMessage(), $now);
}
}
}
fwrite(STDOUT, sprintf(
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d%s\n",
date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'],
$stats['failed'], $stats['skipped'], $retried, count($escalated), $dryRun ? ' (dry-run)' : ''
));
exit(0);