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 오프라인 번들 재생성.
138 lines
6 KiB
PHP
138 lines
6 KiB
PHP
<?php
|
|
/**
|
|
* 보존기간 정리 — 크론.
|
|
*
|
|
* 17 3 * * * /usr/bin/php /path/to/move_detect_app/tools/maintain.php >> /path/maintain.log 2>&1
|
|
*
|
|
* 지우는 것과 지우지 않는 것이 이 도구의 핵심이다:
|
|
*
|
|
* 스냅샷 이미지 — 지운다. PIPA 대상 개인정보이고 보존기간이 정해져 있다.
|
|
* 성능 시계열 — 지운다. 장치당 하루 2,880행이라 공유호스팅 용량을 가장 먼저 잡아먹는다.
|
|
* nonce — 지운다. 만료된 재전송 방지 토큰은 아무 의미가 없다.
|
|
* 이벤트 기록 — **기본적으로 지우지 않는다**. 산업안전 조사나 노사 분쟁에서 수년 뒤에도
|
|
* 필요할 수 있는 사고 기록이라, 시스템이 임의로 폐기하면 안 된다.
|
|
* config.php 의 event_retention_days 를 명시적으로 설정한 현장만 정리한다.
|
|
*
|
|
* --dry-run 으로 무엇이 지워질지 먼저 확인할 수 있다.
|
|
*/
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__ . '/bootstrap.php';
|
|
|
|
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository,
|
|
PdoAlertRepository};
|
|
|
|
[$cfg, $pdo] = tool_boot();
|
|
$args = tool_args($argv);
|
|
$dry = isset($args['dry-run']);
|
|
$now = time();
|
|
|
|
function say(string $m): void { fwrite(STDOUT, date('c') . ' ' . $m . "\n"); }
|
|
|
|
$done = [];
|
|
|
|
// ---- 스냅샷 이미지 (PIPA) ------------------------------------------------------------
|
|
$snapDays = $cfg->snapshotRetentionDays();
|
|
if ($snapDays > 0) {
|
|
$cutoff = $now - $snapDays * 86400;
|
|
$snaps = new PdoSnapshotRepository($pdo, $cfg->snapshotDir());
|
|
if ($dry) {
|
|
$st = $pdo->prepare('SELECT COUNT(*) FROM move_event_snapshots WHERE created_at < ?');
|
|
$st->execute([$cutoff]);
|
|
$done[] = 'snapshots(dry) ' . (int)$st->fetchColumn();
|
|
} else {
|
|
// 기존 보존 로직을 그대로 쓴다 (파일 + 행을 함께 지운다). 메서드가 없는 구버전
|
|
// 저장소에서도 도구가 죽지 않도록 방어한다.
|
|
$purge = method_exists($snaps, 'purgeOlderThan') ? $snaps->purgeOlderThan($cutoff) : 0;
|
|
$done[] = "snapshots $purge";
|
|
}
|
|
} else {
|
|
$done[] = 'snapshots disabled';
|
|
}
|
|
|
|
// ---- 성능 시계열 ---------------------------------------------------------------------
|
|
$metricDays = $cfg->metricRetentionDays();
|
|
if ($metricDays > 0) {
|
|
$cutoff = $now - $metricDays * 86400;
|
|
if ($dry) {
|
|
$st = $pdo->prepare('SELECT COUNT(*) FROM move_device_metrics WHERE ts < ?');
|
|
$st->execute([$cutoff]);
|
|
$done[] = 'metrics(dry) ' . (int)$st->fetchColumn();
|
|
} else {
|
|
$done[] = 'metrics ' . (new PdoDeviceMetricRepository($pdo))->prune($cutoff);
|
|
}
|
|
}
|
|
|
|
// ---- 통보 원장 -------------------------------------------------------------------------
|
|
// 해소된 전달만 지운다. status='failed' 는 남긴다 — "아무에게도 닿지 않았다"는 사실 자체가
|
|
// 조사에서 필요한 발견이고, 그것을 청소 작업이 조용히 지워서는 안 된다.
|
|
// 이벤트 보존이 꺼져 있으면(기본값) 이 표는 유일하게 무한 증가하는 표가 된다.
|
|
$alertDays = max(30, $metricDays); // 성능 시계열보다 짧게 잡을 이유가 없다
|
|
$cutoff = $now - $alertDays * 86400;
|
|
if ($dry) {
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM move_alert_log
|
|
WHERE queued_at < ? AND status IN ('sent','suppressed')");
|
|
$st->execute([$cutoff]);
|
|
$done[] = 'alert_log(dry) ' . (int)$st->fetchColumn();
|
|
} else {
|
|
$done[] = 'alert_log ' . (new PdoAlertRepository($pdo))->pruneResolved($cutoff);
|
|
}
|
|
|
|
// ---- nonce ---------------------------------------------------------------------------
|
|
if ($dry) {
|
|
$st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?');
|
|
$st->execute([$now]);
|
|
$done[] = 'nonces(dry) ' . (int)$st->fetchColumn();
|
|
} else {
|
|
$st = $pdo->prepare('DELETE FROM move_nonces WHERE expires_at < ?');
|
|
$st->execute([$now]);
|
|
$done[] = 'nonces ' . $st->rowCount();
|
|
}
|
|
|
|
// ---- rate limit 버킷 ------------------------------------------------------------------
|
|
if (!$dry) {
|
|
$st = $pdo->prepare('DELETE FROM move_rate_limits WHERE window_start < ?');
|
|
$st->execute([intdiv($now, 60) - 1440]); // 하루치만 남긴다
|
|
$done[] = 'rate_limits ' . $st->rowCount();
|
|
}
|
|
|
|
// ---- 이벤트 기록 (기본 보존) ----------------------------------------------------------
|
|
$eventDays = $cfg->eventRetentionDays();
|
|
if ($eventDays > 0) {
|
|
$cutoff = (float)($now - $eventDays * 86400);
|
|
if ($dry) {
|
|
$st = $pdo->prepare('SELECT COUNT(*) FROM move_events WHERE occurred_at < ?');
|
|
$st->execute([sprintf('%.6f', $cutoff)]);
|
|
$done[] = 'events(dry) ' . (int)$st->fetchColumn();
|
|
} else {
|
|
// 한 번에 다 지우면 공유호스팅에서 락이 길어져 ingest 가 밀린다. 배치로 끊는다.
|
|
$events = new PdoEventRepository($pdo);
|
|
$total = 0;
|
|
for ($i = 0; $i < 20; $i++) {
|
|
$n = $events->purgeOlderThan($cutoff, 500);
|
|
$total += $n;
|
|
if ($n === 0) break;
|
|
}
|
|
$done[] = "events $total";
|
|
}
|
|
} else {
|
|
$done[] = 'events kept (retention disabled — 사고 기록은 기본 영구 보존)';
|
|
}
|
|
|
|
// ---- 용량 경고 -------------------------------------------------------------------------
|
|
$minFree = $cfg->storageMinFreeMb();
|
|
if ($minFree > 0 && is_dir($cfg->snapshotDir())) {
|
|
$free = @disk_free_space($cfg->snapshotDir());
|
|
if ($free !== false && $free !== null) {
|
|
$freeMb = (int)floor($free / 1048576);
|
|
if ($freeMb < $minFree) {
|
|
// 정리를 마친 뒤에도 부족하다면 사람이 개입해야 한다. 종료 코드로도 알린다.
|
|
say("WARNING: 스냅샷 볼륨 여유 {$freeMb}MB (기준 {$minFree}MB) — 보존기간을 줄이거나 용량을 확보하세요.");
|
|
say(implode(' · ', $done) . ($dry ? ' (dry-run)' : ''));
|
|
exit(3);
|
|
}
|
|
}
|
|
}
|
|
|
|
say(implode(' · ', $done) . ($dry ? ' (dry-run)' : ''));
|
|
exit(0);
|