POSA_LEAKSMS/php/retention_cleanup.php
유창욱 90f121e14c chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트).
전체 코드리뷰에서 도출된 보안 하드닝 10건 반영:
- 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시)
- 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php)
- 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점)
- CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php)
- 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화

_backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
2026-06-20 09:37:40 +09:00

122 lines
4.9 KiB
PHP

<?php
// =============================================================================
// retention_cleanup.php - 로그/측정 보관기간 정리 (SMS 로그, 센서 로그, 측정 이력,
// 관리자 감사로그)
//
// Usage:
// php retention_cleanup.php --dry-run
// php retention_cleanup.php --execute
// =============================================================================
if (php_sapi_name() !== 'cli') {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden\n";
exit;
}
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
$execute = in_array('--execute', $argv, true);
$dryRun = !$execute || in_array('--dry-run', $argv, true);
function retention_cutoff(int $days): string {
return date('Y-m-d H:i:s', time() - ($days * 86400));
}
function retention_count(PDO $db, string $sql, array $params): int {
$stmt = $db->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
function retention_delete(PDO $db, string $sql, array $params, bool $dryRun): int {
if ($dryRun) {
return 0;
}
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
$summary = [
'mode' => $dryRun ? 'dry-run' : 'execute',
'sms_log_retention_days' => SMS_LOG_RETENTION_DAYS,
'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS,
'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS,
'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS,
'sms_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
'admin_audit' => ['kept_lines' => null, 'removed_lines' => null],
];
try {
$db = get_db();
} catch (Throwable $e) {
$summary['error'] = 'DB connection failed: ' . $e->getMessage();
header('Content-Type: application/json; charset=utf-8');
echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(2);
}
$smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS);
$summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
$summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
$sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS);
$summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
$summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
$metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS);
$summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
$summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
$auditPath = admin_audit_log_path();
if (is_file($auditPath)) {
$cutoffTs = time() - (ADMIN_AUDIT_RETENTION_DAYS * 86400);
$kept = [];
$removed = 0;
// 감사로그 회전은 동시 append(admin_audit_log: file_put_contents FILE_APPEND|LOCK_EX)와
// 경합한다. 읽기+필터+재기록 전체를 배타 락(flock LOCK_EX)으로 묶어, 회전 도중
// 들어온 기록이 유실되지 않게 한다(append 는 락 해제까지 대기 후 새 파일에 추가).
$fh = fopen($auditPath, 'c+');
if ($fh !== false && flock($fh, LOCK_EX)) {
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
$record = json_decode($line, true);
$recordTs = isset($record['time']) ? strtotime((string)$record['time']) : false;
if ($recordTs !== false && $recordTs < $cutoffTs) {
$removed++;
continue;
}
$kept[] = $line;
}
if (!$dryRun && $removed > 0) {
ftruncate($fh, 0);
rewind($fh);
fwrite($fh, $kept ? implode(PHP_EOL, $kept) . PHP_EOL : '');
fflush($fh);
}
flock($fh, LOCK_UN);
}
if ($fh !== false) {
fclose($fh);
}
$summary['admin_audit']['kept_lines'] = count($kept);
$summary['admin_audit']['removed_lines'] = $removed;
}
admin_audit_log('retention_cleanup', 'system', true, [
'mode' => $summary['mode'],
'sms_matches' => $summary['sms_log']['matched'],
'sensor_matches' => $summary['sensor_log']['matched'],
'metric_matches' => $summary['sensor_metric']['matched'],
]);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;