단일 1594줄 스크롤 페이지를 상시 영역 + 4탭 서버렌더(?tab=) 구조로 재개편한다. 5인 리뷰 패널(정보구조·관제도메인·UX·구현·접근성) 종합 스펙 기반. 상시 영역(전 탭 공통): - 시스템 상태 히어로 — 누수(is_leak)·미복구 사고를 FORECAST_ENABLED 와 무관하게 반영(기존: 누수 중에도 '정상 감시 중' 표시 가능한 결함). 경보 시 센서명·위치· 현재값·경보 시작 시각·SMS 발송 결과를 한 줄로 명시. - 알림 스트립(SMS 실패/미복구 누수/수집 거부 1줄 요약 + 탭 딥링크) - 대응 필요 패널(플레이북·담당자) — 안전 알림은 탭과 무관하게 상시 노출 - 통합 KPI 스트립 6타일 — 모바일 퀵타일·듀얼 KPI·게이지 배지를 하나로 통합 - 탭바(nav+aria-current, 경보 배지) 탭: - 실시간 현황: 온습도/누수 카드(예보 여유 상시 표시), 미복구 사고, SMS 채널·오늘 요약 - 추이·통계: 온습도 추이 차트, 7일 경보 추이, 경보 종류 도넛(단위 명기), 가동률, 리더보드 - 이력 조회: 누수 사고·측정·이벤트·SMS 이력, 30일 센서별 평균 온습도, SMS 발송 통계 - 시스템 점검: 자가진단, 수집 거부 관측성, SMS 채널 점검, 플레이북 사본, 임계 설정 요약 - ?tab=all: 인쇄용 전체 렌더(details 강제 펼침, 자동 새로고침 없음) 구현: - 데이터 계층 분리(dashboard_data.php): 전역 vs 탭 전용 쿼리 — 로드당 쿼리 ~50→전역 14+탭 5~8개(약 60% 절감). ops_checks 파일 캐시(TTL 120초, 시스템 탭은 우회). - 탭 partial(tabs/*.php)은 dash_render_tab() 함수 스코프 격리(EXTR_SKIP)로 렌더. - 30초 자동 새로고침은 URL 쿼리 보존으로 탭·기간 유지. 스크롤/details 복원은 탭별 키. - 폐쇄망 유지: 외부 CDN/JS 없음(인라인 SVG). app.css 는 기존 규칙 불변, 추가만. - 보안: dashboard_data·forecast·tabs 를 .htaccess FilesMatch + tabs/.htaccess + PHP 직접접근 가드로 삼중 차단. security_hardening_test: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
273 lines
20 KiB
PHP
273 lines
20 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
$root = dirname(__DIR__);
|
|
$failures = [];
|
|
|
|
function check(bool $condition, string $message): void {
|
|
global $failures;
|
|
if (!$condition) {
|
|
$failures[] = $message;
|
|
}
|
|
}
|
|
|
|
function file_text(string $path): string {
|
|
return is_file($path) ? (string)file_get_contents($path) : '';
|
|
}
|
|
|
|
$adminSecurity = $root . '/php/admin_security.php';
|
|
check(is_file($adminSecurity), 'php/admin_security.php should provide shared MFA/audit helpers.');
|
|
|
|
if (is_file($adminSecurity)) {
|
|
require_once $adminSecurity;
|
|
check(function_exists('admin_totp_code'), 'admin_totp_code() should exist.');
|
|
check(function_exists('admin_totp_verify'), 'admin_totp_verify() should exist.');
|
|
check(function_exists('admin_totp_secret'), 'admin_totp_secret() should exist.');
|
|
check(function_exists('admin_totp_uri'), 'admin_totp_uri() should exist.');
|
|
check(function_exists('admin_audit_log'), 'admin_audit_log() should exist.');
|
|
|
|
if (function_exists('admin_totp_code') && function_exists('admin_totp_verify') && function_exists('admin_totp_uri')) {
|
|
$secret = 'JBSWY3DPEHPK3PXP';
|
|
$time = 1716200000;
|
|
$code = admin_totp_code($secret, $time);
|
|
check((bool)preg_match('/^\d{6}$/', $code), 'admin_totp_code() should return a six digit code.');
|
|
check(admin_totp_verify($secret, $code, $time, 0), 'admin_totp_verify() should accept the current timestep code.');
|
|
check(!admin_totp_verify($secret, '000000', $time, 0), 'admin_totp_verify() should reject an invalid code.');
|
|
check(admin_totp_code('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 59) === '287082', 'admin_totp_code() should match the RFC 6238 SHA1 vector truncated to six digits.');
|
|
$uri = admin_totp_uri('SHT30 SMS', 'admin', $secret);
|
|
check(str_starts_with($uri, 'otpauth://totp/'), 'admin_totp_uri() should generate an otpauth URI.');
|
|
check(str_contains($uri, 'issuer=SHT30%20SMS'), 'admin_totp_uri() should include the issuer.');
|
|
check(str_contains($uri, 'secret=' . $secret), 'admin_totp_uri() should include the secret.');
|
|
}
|
|
|
|
if (function_exists('admin_totp_secret')) {
|
|
$generatedSecret = admin_totp_secret();
|
|
check((bool)preg_match('/^[A-Z2-7]{32}$/', $generatedSecret), 'admin_totp_secret() should generate a 32 character Base32 secret.');
|
|
}
|
|
}
|
|
|
|
$config = file_text($root . '/php/config.php');
|
|
$configExample = file_text($root . '/php/config.local.example.php');
|
|
check(str_contains($config, 'ADMIN_TOTP_SECRET'), 'config.php should define ADMIN_TOTP_SECRET.');
|
|
check(str_contains($configExample, 'ADMIN_TOTP_SECRET'), 'config.local.example.php should document ADMIN_TOTP_SECRET.');
|
|
check(str_contains($config, 'MFA_SETUP_TOKEN'), 'config.php should define MFA_SETUP_TOKEN.');
|
|
check(str_contains($configExample, 'MFA_SETUP_TOKEN'), 'config.local.example.php should document MFA_SETUP_TOKEN.');
|
|
|
|
// Regression (security review): the example MUST NOT ship a usable/known TOTP secret.
|
|
// Any non-empty Base32-decodable value silently enables MFA on a publicly-known seed
|
|
// (admin_mfa_configured() = non-empty). Empty '' keeps MFA fail-closed until a real
|
|
// secret is set. Lock this so the placeholder can't regress to a working secret.
|
|
$exampleArr = require $root . '/php/config.local.example.php';
|
|
check(($exampleArr['ADMIN_TOTP_SECRET'] ?? 'x') === '', 'config.local.example.php ADMIN_TOTP_SECRET must be EMPTY (fail-closed; never ship a known/decodable secret).');
|
|
if (function_exists('admin_base32_decode')) {
|
|
check(admin_base32_decode((string)($exampleArr['ADMIN_TOTP_SECRET'] ?? '')) === '', 'example TOTP secret must not decode to a usable key.');
|
|
}
|
|
|
|
$login = file_text($root . '/php/login.php');
|
|
check(str_contains($login, 'admin_totp_verify'), 'login.php should verify MFA codes.');
|
|
check(str_contains($login, 'otp_code'), 'login.php should render an OTP input.');
|
|
check(str_contains($login, 'admin_audit_log'), 'login.php should write administrator audit events.');
|
|
|
|
$setupMfa = $root . '/php/setup_mfa.php';
|
|
check(is_file($setupMfa), 'php/setup_mfa.php should provide Google Authenticator enrollment.');
|
|
$setupMfaText = file_text($setupMfa);
|
|
check(str_contains($setupMfaText, 'otpauth://'), 'setup_mfa.php should expose an otpauth enrollment URI.');
|
|
check(str_contains($setupMfaText, 'Google Authenticator'), 'setup_mfa.php should mention Google Authenticator enrollment.');
|
|
check(str_contains($setupMfaText, 'MFA_SETUP_TOKEN'), 'setup_mfa.php should require a bootstrap setup token when not logged in.');
|
|
check(str_contains($setupMfaText, 'admin_totp_verify'), 'setup_mfa.php should verify a test code before showing config output.');
|
|
|
|
// ── 온습도 임계 경보 / SMS 기능 회귀 ────────────────────────
|
|
$smsSend = file_text($root . '/php/sms_send.php');
|
|
check(str_contains($config, 'METRIC_TEMP_HIGH_C'), 'config.php should define the METRIC_TEMP_HIGH_C threshold.');
|
|
check(str_contains($config, 'METRIC_RH_HIGH'), 'config.php should define the METRIC_RH_HIGH threshold.');
|
|
check(str_contains($config, 'METRIC_ALERT_COOLDOWN_SEC'), 'config.php should define METRIC_ALERT_COOLDOWN_SEC.');
|
|
check(str_contains($smsSend, 'function send_metric_alert_sms'), 'sms_send.php should provide send_metric_alert_sms().');
|
|
check(str_contains($smsSend, 'function send_metric_recovery_sms'), 'sms_send.php should provide send_metric_recovery_sms().');
|
|
check(str_contains($smsSend, 'function evaluate_metric_thresholds'), 'sms_send.php should provide evaluate_metric_thresholds().');
|
|
|
|
// ── 통합 누수+카메라(1번 모듈) 기능 회귀 (소스 기반 고정) ────
|
|
// 누수 모듈이 SHT30 서버에 통합되었으므로, 누수/사진/사고 기능은 존재해야 한다.
|
|
// 대시보드 표면 = 셸(dashboard.php) + 데이터 계층(dashboard_data.php) + 탭 partial
|
|
// (탭 재개편으로 파일이 분리되었으므로 소스 기반 회귀 검사는 표면 전체를 본다)
|
|
$dashboard = file_text($root . '/php/dashboard.php')
|
|
. file_text($root . '/php/dashboard_data.php');
|
|
foreach (['live', 'trend', 'history', 'system'] as $dashTabFile) {
|
|
$dashboard .= file_text($root . '/php/tabs/' . $dashTabFile . '.php');
|
|
}
|
|
$monthly = file_text($root . '/php/monthly_report.php');
|
|
$sensorApi = file_text($root . '/php/api/sensor_data.php');
|
|
|
|
// 수집 API 가 누수 이벤트(leak_alert/leak_recovery)를 수신한다
|
|
check(str_contains($sensorApi, 'leak_alert') && str_contains($sensorApi, 'leak_recovery'),
|
|
'sensor_data.php should accept leak_alert/leak_recovery events.');
|
|
|
|
// 사진 업로드 API 존재 + HMAC 서명/상수시간 비교/기본키 fail-closed
|
|
$photoUpload = $root . '/php/api/photo_upload.php';
|
|
check(is_file($photoUpload), 'php/api/photo_upload.php should exist (leak photo upload endpoint).');
|
|
$photoUploadText = file_text($photoUpload);
|
|
check(str_contains($photoUploadText, 'hash_hmac'), 'photo_upload.php should verify an HMAC signature.');
|
|
check(str_contains($photoUploadText, 'hash_equals'), 'photo_upload.php should use constant-time hash_equals.');
|
|
check(str_contains($photoUploadText, 'api_key_is_default'), 'photo_upload.php should fail closed on the default API key.');
|
|
|
|
// SMS 누수 경보/복구 헬퍼
|
|
check(str_contains($smsSend, 'function send_leak_alert_sms'), 'sms_send.php should provide send_leak_alert_sms().');
|
|
check(str_contains($smsSend, 'function send_leak_recovery_sms'), 'sms_send.php should provide send_leak_recovery_sms().');
|
|
|
|
// 인증 사진 열람 엔드포인트(photo.php): 관리자 세션 가드 + 파일명 정규식 검증
|
|
check(is_file($root . '/php/photo.php'), 'php/photo.php (authenticated leak photo endpoint) should exist.');
|
|
$photoText = file_text($root . '/php/photo.php');
|
|
check(str_contains($photoText, 'admin_enforce_session_timeout') && str_contains($photoText, "\$_SESSION['logged_in']"),
|
|
'photo.php should enforce the same admin session guard as dashboard.php.');
|
|
check(str_contains($photoText, 'leak_[0-9]+_[0-9]+_[0-9a-f]+'),
|
|
'photo.php should validate the server-generated filename regex (defense-in-depth).');
|
|
|
|
// 대시보드가 누수 패널(사진 열람은 인증 endpoint 경유)을 노출
|
|
check(str_contains($dashboard, 'leak_photo') || str_contains($dashboard, 'photo.php?id='),
|
|
'dashboard.php should render the leak panel via the authenticated photo endpoint.');
|
|
|
|
// var/ 자가보호: 사진 디렉터리가 웹 직접 접근으로 노출되지 않도록 .htaccess 가 있어야 한다
|
|
check(is_file($root . '/php/var/.htaccess'), 'php/var/.htaccess should exist (self-contained var/ protection).');
|
|
$varHtaccess = file_text($root . '/php/var/.htaccess');
|
|
check(str_contains($varHtaccess, 'Require all denied'),
|
|
"php/var/.htaccess should deny web access ('Require all denied').");
|
|
check(is_file($root . '/php/var/photos/.htaccess'), 'php/var/photos/.htaccess should exist.');
|
|
|
|
$retention = $root . '/php/retention_cleanup.php';
|
|
check(is_file($retention), 'php/retention_cleanup.php should implement retention cleanup.');
|
|
$retentionText = file_text($retention);
|
|
check(str_contains($retentionText, 'SENSOR_METRIC_RETENTION_DAYS'), 'retention cleanup should use SENSOR_METRIC_RETENTION_DAYS.');
|
|
check(str_contains($retentionText, 'SMS_LOG_RETENTION_DAYS'), 'retention cleanup should use SMS_LOG_RETENTION_DAYS.');
|
|
check(str_contains($retentionText, 'SENSOR_LOG_RETENTION_DAYS'), 'retention cleanup should use SENSOR_LOG_RETENTION_DAYS.');
|
|
check(str_contains($retentionText, '--dry-run'), 'retention cleanup should support --dry-run.');
|
|
|
|
$backup = $root . '/scripts/backup_evidence.php';
|
|
check(is_file($backup), 'scripts/backup_evidence.php should generate backup/restore evidence.');
|
|
$backupText = file_text($backup);
|
|
check(str_contains($backupText, 'backup-evidence'), 'backup evidence script should generate a backup evidence report.');
|
|
check(str_contains($backupText, 'restore'), 'backup evidence script should include restore test evidence fields.');
|
|
|
|
$securityEvidence = file_text($root . '/php/security_evidence.php');
|
|
check(str_contains($securityEvidence, '관리자 MFA'), 'security_evidence.php should report administrator MFA status.');
|
|
check(str_contains($securityEvidence, '감사로그'), 'security_evidence.php should report audit log status.');
|
|
check(str_contains($securityEvidence, '보관기간'), 'security_evidence.php should report retention controls.');
|
|
check(str_contains($securityEvidence, '백업'), 'security_evidence.php should report backup evidence controls.');
|
|
|
|
// ── 보안 하드닝 개선 회귀 (소스 기반 고정) ──────────────────
|
|
$sensorApi = file_text($root . '/php/api/sensor_data.php');
|
|
$schemaSql = file_text($root . '/sql/schema_sht30.sql');
|
|
$testMobile = file_text($root . '/php/test_mobile.php');
|
|
$opsChecks = file_text($root . '/php/ops_checks.php');
|
|
|
|
// 재전송(replay) 멱등 nonce
|
|
check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.');
|
|
check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.');
|
|
check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.');
|
|
check(str_contains($schemaSql, 'leak_ingest_nonce'), 'schema_sht30.sql should create the leak_ingest_nonce table.');
|
|
// sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400)
|
|
check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.');
|
|
|
|
// SMS 길이 캡 + 동시 발송 직렬화(중복 SMS 방지)
|
|
check(str_contains($config, 'SMS_MAX_EUCKR_BYTES'), 'config.php should define SMS_MAX_EUCKR_BYTES.');
|
|
check(str_contains($smsSend, 'function sms_truncate_euckr'), 'sms_send.php should cap SMS length (sms_truncate_euckr).');
|
|
check(str_contains($smsSend, 'GET_LOCK'), 'sms_send.php should serialize duplicate alert SMS with a named lock.');
|
|
|
|
// 경보 태그 단일 출처(상수). 하드코딩 LIKE 리터럴이 제거되어야 한다.
|
|
check(str_contains($config, 'SMS_TAG_HIGH_TEMP'), 'config.php should centralize SMS alert tags as constants.');
|
|
check(str_contains($config, 'function sms_summary_select_sql'), 'config.php should expose sms_summary_select_sql().');
|
|
check(!str_contains($dashboard, "LIKE '[고온경보]%'"), 'dashboard.php should not hardcode alert-tag LIKE literals.');
|
|
check(!str_contains($monthly, "LIKE '[고온경보]%'"), 'monthly_report.php should not hardcode alert-tag LIKE literals.');
|
|
|
|
// 세션 절대 수명 캡(유휴 타임아웃만으로 무한정 유지되는 문제 보완)
|
|
check(str_contains($config, 'SESSION_ABSOLUTE_TIMEOUT'), 'config.php should define an absolute session lifetime cap.');
|
|
check(str_contains($login, 'last_activity'), 'login.php should record last_activity for the idle timer.');
|
|
// 세션 타임아웃은 모든 인증 페이지에서 강제되어야 한다(한 페이지만 검사하면 다른 페이지로 우회 가능)
|
|
check(str_contains(file_text($adminSecurity), 'function admin_enforce_session_timeout'),
|
|
'admin_security.php should provide a shared admin_enforce_session_timeout() helper.');
|
|
foreach (['dashboard.php', 'monthly_report.php', 'security_evidence.php', 'setup_wizard.php'] as $authPage) {
|
|
check(str_contains(file_text($root . '/php/' . $authPage), 'admin_enforce_session_timeout'),
|
|
"{$authPage} should enforce session timeout on every authenticated page.");
|
|
}
|
|
|
|
// ── ①+② 신규 기능 회귀 (소스 기반 고정) ─────────────────────
|
|
$appConfig = file_text($root . '/firmware/common/app_config.h');
|
|
$cron = file_text($root . '/php/cron_heartbeat.php');
|
|
|
|
// ① 폐쇄망 SNTP: 인터넷 NTP 기본값 금지 + 서버시각 응답
|
|
// #define 값만 검사한다(설명 주석에 등장하는 'pool.ntp.org' 토큰에 오탐하지 않도록).
|
|
check(!preg_match('/#define\s+APP_SNTP_SERVER\s+"[^"]*pool\.ntp\.org/', $appConfig), 'app_config.h must NOT default APP_SNTP_SERVER to internet pool.ntp.org (air-gap violation).');
|
|
check(str_contains($appConfig, 'APP_SNTP_SERVER'), 'app_config.h should still define APP_SNTP_SERVER.');
|
|
check(str_contains($sensorApi, 'server_now'), 'sensor_data.php should return server_now (firmware clock correction input).');
|
|
|
|
// ②a SMS 잔액 사전경보
|
|
check(str_contains($config, 'SMS_BALANCE_WARN_THRESHOLD'), 'config.php should define SMS_BALANCE_WARN_THRESHOLD.');
|
|
check(str_contains($config, 'function latest_sms_balance'), 'config.php should expose latest_sms_balance().');
|
|
check(str_contains($smsSend, 'function maybe_warn_sms_balance'), 'sms_send.php should warn on low SMS balance.');
|
|
check(str_contains($opsChecks, 'SMS 잔여'), 'ops_checks.php should surface SMS balance.');
|
|
|
|
// ②b 일일 헬스(생존신호) SMS
|
|
check(str_contains($config, 'HEALTH_SMS_ENABLED'), 'config.php should define HEALTH_SMS_ENABLED.');
|
|
check(str_contains($smsSend, 'function maybe_send_daily_health_sms'), 'sms_send.php should provide the daily health SMS.');
|
|
check(str_contains($cron, 'maybe_send_daily_health_sms'), 'cron_heartbeat.php should invoke the daily health SMS.');
|
|
|
|
// ②c 이슬점/결로 경보
|
|
check(str_contains($config, 'METRIC_DEWPOINT_MARGIN_C'), 'config.php should define METRIC_DEWPOINT_MARGIN_C.');
|
|
check(str_contains($config, 'SMS_TAG_CONDENSATION'), 'config.php should define the condensation alert tag.');
|
|
// dew_point_c() 는 forecast.php 로 이동(단일 출처). sms_send.php 는 require 후 사용한다.
|
|
$forecast = file_text($root . '/php/forecast.php');
|
|
check(str_contains($forecast, 'function dew_point_c'), 'forecast.php should define dew_point_c (single source).');
|
|
check(str_contains($smsSend, 'dew_point_c('), 'sms_send.php should use dew_point_c() for condensation.');
|
|
check(str_contains($smsSend, 'condensation_risk'), 'sms_send.php should classify condensation_risk.');
|
|
|
|
// ②d 수신번호 마스킹
|
|
check(str_contains($config, 'function mask_phone'), 'config.php should provide mask_phone().');
|
|
check(str_contains($dashboard, 'mask_phone'), 'dashboard.php should mask receiver numbers.');
|
|
|
|
// ── 고가치(M) 기능 회귀 (소스 기반 고정) ─────────────────────
|
|
// M#1 수집 거부사유 카운터
|
|
check(str_contains($config, 'function record_ingest_rejection'), 'config.php should provide record_ingest_rejection().');
|
|
check(str_contains($config, 'function read_ingest_rejections'), 'config.php should provide read_ingest_rejections().');
|
|
check(str_contains($sensorApi, 'record_ingest_rejection'), 'sensor_data.php should count ingest rejections.');
|
|
check(str_contains($dashboard, 'ingest_rejections'), 'dashboard.php should surface ingest rejections.');
|
|
// 무단 접근 출처 로그(거부 이벤트 링 — IP·서명모드로 우리 노드 vs 스캐너 판별)
|
|
check(str_contains($config, 'function record_ingest_reject_event'), 'config.php should log reject events with source IP.');
|
|
check(str_contains($config, 'function read_ingest_reject_events'), 'config.php should read recent reject events.');
|
|
check(str_contains($config, 'function ingest_client_ip'), 'config.php should resolve a validated client IP.');
|
|
check(str_contains($config, 'function ingest_sanitize_token'), 'config.php should sanitize logged tokens (log-injection guard).');
|
|
check(str_contains($dashboard, 'reject_events'), 'dashboard.php should surface recent reject sources.');
|
|
// M#2 발송실패 가시화 + 오프라인 재시도
|
|
check(str_contains($cron, 'SMS_RETRY_OFFLINE') && str_contains($cron, 'offline_alerted = 0'), 'cron_heartbeat should retry offline SMS on send failure.');
|
|
check(str_contains($dashboard, 'recent_alert_fail'), 'dashboard.php should show a failed-alert-SMS banner.');
|
|
// M#3 에스컬레이션
|
|
check(str_contains($config, 'SMS_RECIPIENTS_TIER2'), 'config.php should define SMS_RECIPIENTS_TIER2.');
|
|
check(str_contains($config, 'SMS_TAG_ESCALATION'), 'config.php should define the escalation tag.');
|
|
check(str_contains($smsSend, 'function maybe_escalate_alerts'), 'sms_send.php should escalate unrecovered alerts.');
|
|
check(str_contains($cron, 'maybe_escalate_alerts'), 'cron_heartbeat should invoke escalation.');
|
|
// M#4 센서 고착 검출
|
|
check(str_contains($config, 'STUCK_DETECT_ENABLED'), 'config.php should define STUCK_DETECT_ENABLED.');
|
|
check(str_contains($config, 'SMS_TAG_SENSOR_STUCK'), 'config.php should define the sensor-stuck tag.');
|
|
check(str_contains($smsSend, 'function maybe_detect_stuck_sensors'), 'sms_send.php should detect stuck sensors.');
|
|
check(str_contains($smsSend, 'function stddev_pop'), 'sms_send.php should provide stddev_pop().');
|
|
check(str_contains($cron, 'maybe_detect_stuck_sensors'), 'cron_heartbeat should invoke stuck detection.');
|
|
// M#6 카오스 드릴 훅
|
|
check(str_contains($config, 'SMS_FORCE_FAIL'), 'config.php should define SMS_FORCE_FAIL.');
|
|
check(str_contains($smsSend, 'FORCED_FAIL'), 'sms_send.php should honor SMS_FORCE_FAIL.');
|
|
check(is_file($root . '/tests/chaos_drill.php'), 'tests/chaos_drill.php should exist.');
|
|
|
|
// 반사형 XSS 차단: test_mobile.php 는 raw User-Agent 를 그대로 반사하지 않는다
|
|
check(str_contains($testMobile, 'htmlspecialchars') && str_contains($testMobile, 'text/plain'),
|
|
'test_mobile.php should escape and text/plain the reflected User-Agent.');
|
|
|
|
// 운영 자가진단: 레거시 서명 폴백 / MFA 설정 토큰 잔존 경고
|
|
check(str_contains($opsChecks, 'ALLOW_LEGACY_BODY_SIGNATURE'), 'ops_checks.php should flag enabled legacy signature fallback.');
|
|
check(str_contains($opsChecks, 'MFA_SETUP_TOKEN'), 'ops_checks.php should flag a lingering MFA setup token.');
|
|
check(str_contains($configExample, 'ALLOW_LEGACY_BODY_SIGNATURE'), 'config.local.example.php should document ALLOW_LEGACY_BODY_SIGNATURE.');
|
|
|
|
if ($failures) {
|
|
fwrite(STDERR, "Security hardening regression failures:\n");
|
|
foreach ($failures as $failure) {
|
|
fwrite(STDERR, "- {$failure}\n");
|
|
}
|
|
exit(1);
|
|
}
|
|
|
|
echo "Security hardening regression checks passed.\n";
|