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 오프라인 번들 재생성.
173 lines
7.7 KiB
PHP
173 lines
7.7 KiB
PHP
<?php
|
|
/**
|
|
* 백업 — DB 덤프 + 스냅샷 아카이브.
|
|
*
|
|
* php tools/backup.php --out=/path/to/backups
|
|
* php tools/backup.php --out=/path --no-snapshots (DB만; 이미지가 클 때)
|
|
*
|
|
* 왜 필요한가
|
|
* 납품 후 고객이 스스로 하기 가장 어려운 일이 백업이다. 카페24 관리자 화면의 DB 백업은
|
|
* `move_` 표만 골라내지 못하고, 스냅샷 파일은 아예 대상이 아니다. 사고 기록과 그 증거
|
|
* 이미지가 함께 남지 않으면 백업의 의미가 절반이다 — 조사에서 필요한 것은 둘 다이다.
|
|
*
|
|
* 왜 mysqldump 를 쓰지 않는가
|
|
* 공유호스팅에는 shell_exec 도 mysqldump 도 없는 경우가 흔하다. PDO 로 읽어 SQL 텍스트를
|
|
* 직접 쓰면 어디서든 돈다. 대신 대용량에서는 느리므로, 이벤트 표는 청크로 읽는다.
|
|
*
|
|
* 복구
|
|
* phpMyAdmin 에서 덤프 SQL 을 실행하고, 스냅샷 tar 를 storage/snapshots/ 에 풀면 된다.
|
|
* 자세한 절차는 출력 파일 옆의 RESTORE.txt 에 함께 기록된다.
|
|
*/
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__ . '/bootstrap.php';
|
|
|
|
[$cfg, $pdo] = tool_boot();
|
|
$args = tool_args($argv);
|
|
|
|
$outDir = rtrim((string)($args['out'] ?? ''), '/\\');
|
|
if ($outDir === '') {
|
|
fwrite(STDERR, "사용법: php tools/backup.php --out=<디렉터리> [--no-snapshots]\n");
|
|
exit(2);
|
|
}
|
|
if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) {
|
|
fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n");
|
|
exit(2);
|
|
}
|
|
// 백업 파일에는 전 현장의 사고 기록과 작업자 이미지(개인정보)가 들어 있다. 웹에서 읽히는
|
|
// 위치에 두면 그 자체가 사고다 — 파일명은 `move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만
|
|
// 알면 추측 가능하다.
|
|
//
|
|
// 감사 지적(A3): 예전 검사는 `str_contains($real, '/www/')` 였다. 하위 경로
|
|
// (/home/acct/www/backups)는 막았지만 **docroot 자체**(/home/acct/www)는 슬래시가 뒤에
|
|
// 없어 그대로 통과했다. 가장 흔한 오사용이 정확히 그 케이스다.
|
|
// 이제 경로를 세그먼트로 쪼개 웹 루트 이름이 하나라도 있으면 거부한다.
|
|
const WEB_DIR_NAMES = ['www', 'public_html', 'htdocs', 'html', 'web', 'public'];
|
|
$real = realpath($outDir);
|
|
if ($real !== false) {
|
|
$segments = array_filter(explode('/', str_replace('\\', '/', $real)), 'strlen');
|
|
foreach ($segments as $seg) {
|
|
if (in_array(strtolower($seg), WEB_DIR_NAMES, true)) {
|
|
fwrite(STDERR,
|
|
"거부: 백업 대상 경로에 웹 공개 디렉터리('{$seg}')가 포함되어 있습니다.\n"
|
|
. " 경로: {$real}\n"
|
|
. " 이 파일은 전 현장의 사고 기록과 작업자 이미지를 담습니다. www/ 밖(FTP 최상위\n"
|
|
. " 또는 그 상위)의 비공개 디렉터리를 지정하세요.\n");
|
|
exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
$stamp = date('Ymd-His');
|
|
$sqlPath = $outDir . '/move_detect-' . $stamp . '.sql';
|
|
$fh = fopen($sqlPath, 'wb');
|
|
if ($fh === false) { fwrite(STDERR, "덤프 파일을 열 수 없습니다.\n"); exit(2); }
|
|
|
|
// 이 순서는 복구 시 참조 무결성 순서다 (events 가 snapshots/alert_log 보다 먼저).
|
|
$TABLES = [
|
|
'move_users', 'move_devices', 'move_cameras', 'move_zones', 'move_thresholds',
|
|
'move_events', 'move_event_snapshots', 'move_actions', 'move_audit_log',
|
|
'move_suppressions', 'move_alert_channels', 'move_alert_log',
|
|
'move_shift_handovers', 'move_risk_controls', 'move_device_metrics',
|
|
];
|
|
// nonce / rate_limits 는 재생성되는 휘발성 데이터라 일부러 제외한다.
|
|
|
|
function q(PDO $pdo, $v): string
|
|
{
|
|
if ($v === null) return 'NULL';
|
|
if (is_int($v) || is_float($v)) return (string)$v;
|
|
return $pdo->quote((string)$v);
|
|
}
|
|
|
|
fwrite($fh, "-- move_detect backup {$stamp}\n");
|
|
fwrite($fh, "-- 복구: phpMyAdmin 에서 이 파일을 실행한 뒤 스냅샷 아카이브를 storage/snapshots/ 에 풉니다.\n");
|
|
fwrite($fh, "SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
|
|
|
|
$counts = [];
|
|
foreach ($TABLES as $table) {
|
|
try {
|
|
$probe = $pdo->query("SELECT * FROM {$table} LIMIT 1");
|
|
} catch (Throwable $e) {
|
|
// 마이그레이션 전 DB 에는 없는 표가 있다. 백업이 그걸로 실패하면 안 된다.
|
|
$counts[$table] = 'skipped';
|
|
continue;
|
|
}
|
|
$cols = [];
|
|
for ($i = 0; $i < $probe->columnCount(); $i++) {
|
|
$cols[] = $probe->getColumnMeta($i)['name'];
|
|
}
|
|
$colList = '`' . implode('`, `', $cols) . '`';
|
|
|
|
$n = 0;
|
|
$offset = 0;
|
|
fwrite($fh, "\n-- {$table}\n");
|
|
while (true) {
|
|
// 청크 단위: 이벤트가 수십만 건인 현장에서 한 번에 fetchAll 하면 메모리가 터진다.
|
|
$st = $pdo->prepare("SELECT * FROM {$table} LIMIT 500 OFFSET {$offset}");
|
|
$st->execute();
|
|
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
|
if ($rows === []) break;
|
|
foreach ($rows as $row) {
|
|
$vals = [];
|
|
foreach ($cols as $c) $vals[] = q($pdo, $row[$c] ?? null);
|
|
fwrite($fh, "INSERT INTO {$table} ({$colList}) VALUES (" . implode(', ', $vals) . ");\n");
|
|
$n++;
|
|
}
|
|
$offset += 500;
|
|
}
|
|
$counts[$table] = $n;
|
|
}
|
|
fwrite($fh, "\nSET FOREIGN_KEY_CHECKS=1;\n");
|
|
fclose($fh);
|
|
@chmod($sqlPath, 0600);
|
|
|
|
// ---- 스냅샷 ---------------------------------------------------------------------------
|
|
$snapNote = 'skipped (--no-snapshots)';
|
|
if (!isset($args['no-snapshots'])) {
|
|
$dir = rtrim($cfg->snapshotDir(), '/\\');
|
|
if (!is_dir($dir)) {
|
|
$snapNote = 'snapshot dir 없음';
|
|
} elseif (!class_exists('ZipArchive')) {
|
|
// ext-zip 이 없으면 아카이브를 만들 수 없다. 조용히 넘어가면 이미지 없는 백업을
|
|
// 완전한 백업으로 착각하게 된다.
|
|
$snapNote = 'ZipArchive 없음 — storage/snapshots/ 를 FTP 로 별도 내려받으세요';
|
|
} else {
|
|
$zipPath = $outDir . '/move_detect-snapshots-' . $stamp . '.zip';
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
|
$files = glob($dir . '/*.jpg') ?: [];
|
|
foreach ($files as $f) $zip->addFile($f, basename($f));
|
|
$zip->close();
|
|
@chmod($zipPath, 0600);
|
|
$snapNote = count($files) . '개 -> ' . basename($zipPath);
|
|
} else {
|
|
$snapNote = '아카이브를 만들지 못했습니다';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 복구 안내 -------------------------------------------------------------------------
|
|
$restore = $outDir . '/RESTORE.txt';
|
|
file_put_contents($restore, implode("\n", [
|
|
'move_detect 복구 절차',
|
|
'',
|
|
'1. 새 DB 를 만들고 schema.sql 을 먼저 실행합니다 (표 구조).',
|
|
'2. migrations/ 의 SQL 을 날짜 순서대로 실행합니다.',
|
|
'3. 이 폴더의 move_detect-<시각>.sql 을 실행합니다 (데이터).',
|
|
'4. move_detect-snapshots-<시각>.zip 을 풀어 storage/snapshots/ 에 넣습니다.',
|
|
'5. config.php 의 DB 접속 정보를 새 DB 로 맞춥니다.',
|
|
'',
|
|
'주의:',
|
|
'- 이 백업에는 사고 기록과 작업자 이미지(개인정보)가 들어 있습니다.',
|
|
' 웹에서 접근 가능한 경로에 두지 마십시오.',
|
|
'- move_nonces / move_rate_limits 는 휘발성이라 백업에 포함되지 않습니다 (자동 재생성).',
|
|
'- 복구 후 tools/verify_audit_chain.php 로 감사로그 무결성을 확인하십시오.',
|
|
'',
|
|
'생성: ' . date('c'),
|
|
]) . "\n");
|
|
|
|
fwrite(STDOUT, "DB 덤프: {$sqlPath}\n");
|
|
foreach ($counts as $t => $n) fwrite(STDOUT, sprintf(" %-24s %s\n", $t, $n));
|
|
fwrite(STDOUT, "스냅샷: {$snapNote}\n");
|
|
fwrite(STDOUT, "복구 안내: {$restore}\n");
|
|
exit(0);
|