POSA_LEAKSMS/php/ops_checks.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

107 lines
6.3 KiB
PHP

<?php
// =============================================================================
// ops_checks.php - shared operational/security self-check helpers
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
function ops_table_exists(PDO $db, string $table): bool {
$stmt = $db->prepare("
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
");
$stmt->execute([':table' => $table]);
return (int)$stmt->fetchColumn() > 0;
}
function ops_column_exists(PDO $db, string $table, string $column): bool {
$stmt = $db->prepare("
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
AND COLUMN_NAME = :column
");
$stmt->execute([':table' => $table, ':column' => $column]);
return (int)$stmt->fetchColumn() > 0;
}
function ops_check(string $category, string $label, bool $ok, string $detail, string $fix = '', string $severity = 'warn', string $evidence = ''): array {
return [
'category' => $category,
'label' => $label,
'ok' => $ok,
'detail' => $detail,
'fix' => $fix,
'severity' => $severity,
'evidence' => $evidence,
];
}
function collect_ops_checks(): array {
$checks = [];
try {
$db = get_db();
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
$required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log'];
foreach ($required_tables as $table) {
$exists = ops_table_exists($db, $table);
$checks[] = ops_check(
'DB',
"테이블 {$table}",
$exists,
$exists ? '확인됨' : '누락됨',
'sql/schema_sht30.sql 실행',
'critical',
'DB 테이블 목록 또는 설치 점검 화면'
);
}
// 최근 온습도 측정 수신 여부(센서 동작/수집 확인)
if (ops_table_exists($db, 'sensor_metric')) {
$recent_metric_stmt = $db->query("
SELECT COUNT(*)
FROM sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
");
$recent_metric = (int)$recent_metric_stmt->fetchColumn();
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신');
}
if (ops_table_exists($db, 'sms_log')) {
$recent_sms_fail_stmt = $db->query("
SELECT COUNT(*)
FROM sms_log
WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
");
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수');
}
} catch (Throwable $e) {
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면');
}
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000';
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 /etc/sht30-sensor.env(또는 펌웨어 secrets.h)의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인');
$checks[] = ops_check('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "new-password" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인');
$checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표');
$checks[] = ops_check('관리자 인증', '감사로그', is_file(admin_audit_log_path()) || is_writable(__DIR__ . '/var'), admin_audit_log_path(), '로그인 성공/실패, 로그아웃, 관리자 활동 기록 확인', 'warn', 'php/var/admin_audit.log');
$checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표');
$thr_ok = (METRIC_TEMP_LOW_C < METRIC_TEMP_HIGH_C) && (METRIC_RH_LOW < METRIC_RH_HIGH);
$checks[] = ops_check('설정', '온습도 임계치', $thr_ok, sprintf('온도 %s~%s℃ / 습도 %s~%s%%', METRIC_TEMP_LOW_C, METRIC_TEMP_HIGH_C, METRIC_RH_LOW, METRIC_RH_HIGH), 'config.local.php의 METRIC_* 임계치를 운영값으로 설정(저온<고온, 저습<고습)', 'warn', '임계치 설정 확인');
$checks[] = ops_check('파일', '상태 파일 폴더', is_dir(__DIR__ . '/var') && is_writable(__DIR__ . '/var'), __DIR__ . '/var', 'php/var 생성 및 쓰기 권한 확인', 'warn', '폴더 권한 화면');
$checks[] = ops_check('보관기간', '보관기간 정리', is_file(__DIR__ . '/retention_cleanup.php'), 'SMS ' . SMS_LOG_RETENTION_DAYS . '일 / 센서로그 ' . SENSOR_LOG_RETENTION_DAYS . '일 / 측정 ' . SENSOR_METRIC_RETENTION_DAYS . '일', 'php retention_cleanup.php --dry-run 결과 확인 후 --execute 수행', 'warn', 'retention_cleanup.php 실행 결과');
$checks[] = ops_check('백업', '백업 증적', is_file(dirname(__DIR__) . '/scripts/backup_evidence.php'), 'scripts/backup_evidence.php', '백업 파일 목록과 복구 테스트 결과를 Markdown으로 보관', 'warn', 'backup-evidence 보고서');
return $checks;
}
function ops_failed_count(array $checks): int {
return count(array_filter($checks, fn($check) => !$check['ok']));
}