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 오프라인 번들 재생성.
97 lines
5.6 KiB
PHP
97 lines
5.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Ingest;
|
|
|
|
final class EventValidator
|
|
{
|
|
/**
|
|
* Accepted wire codes. MUST stay in sync with three other places, and the sync is not
|
|
* enforced by anything but review — RouteTableTest cross-checks routes, but nothing
|
|
* cross-checks this list:
|
|
* - edge/events.py EventType (what the edge can emit)
|
|
* - server/schema.sql chk_events_type (defense-in-depth CHECK)
|
|
* - the front-end TYPE_LABELS maps (app.js / detail.js / report.js / compliance.js /
|
|
* package.js) and the 유형 filter in View/events.php
|
|
*
|
|
* A type missing HERE is the worst of the four: the edge emits it, the server answers 400,
|
|
* and edge/queue.py retries the row 20 times before dropping it — so the detector looks
|
|
* implemented, is fully tested on the device, and produces nothing. That is exactly what
|
|
* happened to camera_moved/camera_obstructed/low_light when they were added.
|
|
*/
|
|
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
|
|
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
|
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
|
|
// 감시 자체의 건전성 (edge/detectors/integrity.py)
|
|
'camera_moved','camera_obstructed','low_light',
|
|
// 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다.
|
|
'unacked_escalation',
|
|
// 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다.
|
|
'ppe_missing','proximity','fire_smoke'];
|
|
private const SEVERITIES = ['critical','high','warning','info'];
|
|
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];
|
|
|
|
public static function validate(array $p): array
|
|
{
|
|
$errors = [];
|
|
foreach (self::REQUIRED as $k) {
|
|
if (!array_key_exists($k, $p) || $p[$k] === '' || $p[$k] === null) $errors[] = "missing:$k";
|
|
}
|
|
if (isset($p['event_type']) && !in_array($p['event_type'], self::TYPES, true)) $errors[] = 'bad:event_type';
|
|
if (isset($p['severity']) && !in_array($p['severity'], self::SEVERITIES, true)) $errors[] = 'bad:severity';
|
|
$snaps = $p['snapshots'] ?? [];
|
|
if (!is_array($snaps)) $errors[] = 'bad:snapshots';
|
|
elseif (count($snaps) > 3) $errors[] = 'too_many:snapshots';
|
|
else foreach ($snaps as $s) if (!is_string($s)) { $errors[] = 'bad:snapshot_item'; break; }
|
|
|
|
// Type guards for required fields
|
|
if (isset($p['device_id']) && !is_string($p['device_id'])) $errors[] = 'bad:device_id';
|
|
if (isset($p['camera_id']) && !is_string($p['camera_id'])) $errors[] = 'bad:camera_id';
|
|
if (isset($p['occurred_at']) && !is_numeric($p['occurred_at'])) $errors[] = 'bad:occurred_at';
|
|
|
|
// Type guards for optional fields
|
|
if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id';
|
|
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
|
|
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
|
|
// Optional normalized point (heatmap raw material): [x, y], both finite numbers. Range is
|
|
// NOT enforced — a foot a hair outside 0..1 must not reject a FALL alert; renderers clamp.
|
|
if (isset($p['spatial'])) {
|
|
$sp = $p['spatial'];
|
|
if (!is_array($sp) || count($sp) !== 2
|
|
|| !is_numeric($sp[0] ?? null) || !is_numeric($sp[1] ?? null)
|
|
|| !is_finite((float)$sp[0]) || !is_finite((float)$sp[1])) {
|
|
$errors[] = 'bad:spatial';
|
|
}
|
|
}
|
|
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
|
|
// it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the
|
|
// snapshot-uuid convention); a malformed key is a bug, not a silent pass.
|
|
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
|
|
$errors[] = 'bad:event_uuid';
|
|
}
|
|
|
|
// Length caps matching the DB columns (device_id/camera_id VARCHAR(64), zone_id
|
|
// CHAR(32)). Without these an overlong id is a MySQL 'Data too long' 500 that the edge
|
|
// re-queues and retries forever; a 400 lets it drop the poison event instead.
|
|
foreach (['device_id' => 64, 'camera_id' => 64, 'zone_id' => 32] as $k => $max) {
|
|
if (isset($p[$k]) && is_string($p[$k]) && mb_strlen($p[$k]) > $max) $errors[] = "too_long:$k";
|
|
}
|
|
|
|
if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []];
|
|
|
|
$clean = [
|
|
'event_uuid' => isset($p['event_uuid']) ? (string)$p['event_uuid'] : null,
|
|
'device_id' => (string)$p['device_id'],
|
|
'camera_id' => (string)$p['camera_id'],
|
|
'event_type' => (string)$p['event_type'],
|
|
'severity' => (string)$p['severity'],
|
|
'track_id' => isset($p['track_id']) ? (int)$p['track_id'] : null,
|
|
'zone_id' => isset($p['zone_id']) ? (string)$p['zone_id'] : null,
|
|
'occurred_at' => (float)$p['occurred_at'],
|
|
'duration_s' => (float)($p['duration_s'] ?? 0.0),
|
|
'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [],
|
|
'spatial_x' => isset($p['spatial']) ? (float)$p['spatial'][0] : null,
|
|
'spatial_y' => isset($p['spatial']) ? (float)$p['spatial'][1] : null,
|
|
];
|
|
return ['ok' => true, 'errors' => [], 'clean' => $clean];
|
|
}
|
|
}
|