feat(observability): 수집 거부 출처 IP 로그 + 대시보드 노출
수집 거부 카운터는 사유별 숫자만 남겨, "우리 노드 오설정 vs 외부 스캐너"를 구분할 수 없었다. 서명불일치 2건이 우리 노드의 키 불일치인지 인터넷 프로브인지 대시보드만으론 판별 불가했다. - config.php: 최근 거부 이벤트 링버퍼(var/ingest_reject_events.json, 상한 INGEST_REJECT_EVENTS_MAX=50, flock 기반 DoS 안전) 추가. record_ingest_rejection() 이 모든 거부에서 출처 IP(REMOTE_ADDR, 형식검증)를 자동 기록한다. 본문 유래 값(device_id 등)은 신뢰불가 → ingest_sanitize_token() 으로 제어문자 제거·절단 (로그 인젝션 방어). var/ 는 .htaccess 로 웹 접근 차단됨. - api/sensor_data.php: 서명 거부 시 서명모드(raw=키불일치/none=헤더스트립)와 device_id 를 컨텍스트로 함께 남겨 출처 판별을 돕는다. - dashboard.php: '수집 거부' 위젯 아래 '최근 거부 출처' 표(시각·IP·사유·모드· device_id) 노출. 모든 필드 htmlspecialchars. - tests/ingest_reject_log_test.php: 로그 인젝션 제거·IP 형식검증 회귀 추가, run_tests.sh 등록. security_hardening_test.php 소스 회귀 5종 추가. 주: config.php 에는 작업 트리에 있던 선행 변경(METRIC_SENSOR_IDS — 대시보드 온습도 센서카드 목록)이 함께 포함된다.
This commit is contained in:
parent
0728e45894
commit
7fa39406d3
6 changed files with 182 additions and 2 deletions
|
|
@ -178,7 +178,9 @@ if ($has_raw_sig) {
|
||||||
}
|
}
|
||||||
if (!$sig_ok) {
|
if (!$sig_ok) {
|
||||||
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
|
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
|
||||||
record_ingest_rejection('signature');
|
// 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께
|
||||||
|
// 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별.
|
||||||
|
record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]);
|
||||||
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ function api_key_is_default(): bool {
|
||||||
// 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은
|
// 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은
|
||||||
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
|
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
|
||||||
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
|
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
|
||||||
|
// 최근 거부 이벤트 링버퍼 크기(출처 IP 로그). 무단 접근 출처 식별용, 파일 무한증가 방지.
|
||||||
|
define('INGEST_REJECT_EVENTS_MAX', (int)cfg('INGEST_REJECT_EVENTS_MAX', 50));
|
||||||
|
|
||||||
// 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량
|
// 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량
|
||||||
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
|
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
|
||||||
|
|
@ -247,6 +249,21 @@ $leak_sensor_ids = array_values(array_filter(
|
||||||
));
|
));
|
||||||
define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]);
|
define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]);
|
||||||
|
|
||||||
|
// ─── 온습도(SHT30) 센서 sensor_id 목록 ───────────────────
|
||||||
|
// 상단 "센서 현재 상태" 카드에 항상 표시할 온습도 센서. 누수 카드와 동일 원칙 —
|
||||||
|
// 아직 데이터가 없어도(미수신) placeholder 카드로 노출해, 기대 센서가 화면에서
|
||||||
|
// 조용히 사라지지 않게 한다(예: SHT30 최초 기동 전). 기본 [2](2번=SHT30 온습도).
|
||||||
|
// 신규 온습도 지점은 config.local.php 로 오버라이드한다. 예: [2, 4] (문자열 "2,4" 허용)
|
||||||
|
$metric_sensor_ids = cfg('METRIC_SENSOR_IDS', [2]);
|
||||||
|
if (is_string($metric_sensor_ids)) {
|
||||||
|
$metric_sensor_ids = explode(',', $metric_sensor_ids);
|
||||||
|
}
|
||||||
|
$metric_sensor_ids = array_values(array_filter(
|
||||||
|
array_map('intval', (array)$metric_sensor_ids),
|
||||||
|
static fn($id) => $id >= 1 && $id <= 255
|
||||||
|
));
|
||||||
|
define('METRIC_SENSOR_IDS', $metric_sensor_ids !== [] ? $metric_sensor_ids : [2]);
|
||||||
|
|
||||||
// ─── 대시보드 세션 인증 ──────────────────────────────────
|
// ─── 대시보드 세션 인증 ──────────────────────────────────
|
||||||
define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
|
define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
|
||||||
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
|
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
|
||||||
|
|
@ -336,7 +353,11 @@ function ingest_rejection_path(): string {
|
||||||
return __DIR__ . '/var/ingest_rejections.json';
|
return __DIR__ . '/var/ingest_rejections.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
function record_ingest_rejection(string $reason): void {
|
function record_ingest_rejection(string $reason, array $ctx = []): void {
|
||||||
|
// 출처 IP 포함 최근 거부 이벤트를 별도 링 파일에 남긴다(카운터와 독립 — 하나가
|
||||||
|
// 실패해도 다른 하나는 기록). '무단 접근' 출처(우리 노드 vs 외부 스캐너) 식별용.
|
||||||
|
record_ingest_reject_event($reason, $ctx);
|
||||||
|
|
||||||
$fh = @fopen(ingest_rejection_path(), 'c+');
|
$fh = @fopen(ingest_rejection_path(), 'c+');
|
||||||
if ($fh === false) {
|
if ($fh === false) {
|
||||||
return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
|
return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
|
||||||
|
|
@ -385,6 +406,80 @@ function read_ingest_rejections(int $days = 1): array {
|
||||||
return $totals;
|
return $totals;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 수집 거부 이벤트 링 (출처 IP 포함 — 무단 접근 식별) ──────────────────────
|
||||||
|
// 카운터(사유별 숫자)만으론 "우리 노드 오설정 vs 외부 스캐너"를 구분 못 한다. 최근 거부
|
||||||
|
// 몇 건을 출처 IP·사유·서명모드와 함께 별도 파일 링버퍼에 남겨, 대시보드/ops 가 출처를
|
||||||
|
// 즉시 분별한다. 인증 전 경로라 DB 대신 파일 flock(위조 폭주에도 부담 작음), 개수 상한
|
||||||
|
// (INGEST_REJECT_EVENTS_MAX)으로 무한증가를 막는다. 본문 유래 값(device_id 등)은 신뢰
|
||||||
|
// 불가 → 로그 인젝션 방지를 위해 제어문자 제거·절단한다. var/ 는 .htaccess 로 웹 차단됨.
|
||||||
|
function ingest_reject_events_path(): string {
|
||||||
|
return __DIR__ . '/var/ingest_reject_events.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신뢰 가능한 출처 IP(REMOTE_ADDR). 형식 검증 실패/부재는 '?'. XFF 는 위조 가능하므로
|
||||||
|
// 신뢰 출처로 쓰지 않는다(참고용은 record 에서 별도 'fwd' 필드로만 남긴다).
|
||||||
|
function ingest_client_ip(): string {
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
return (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP)) ? $ip : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그 인젝션/제어문자 방지: 인쇄가능 문자만 남기고(제어문자→공백) 최대 길이로 절단.
|
||||||
|
function ingest_sanitize_token(string $s, int $max = 64): string {
|
||||||
|
$s = preg_replace('/[[:cntrl:]]+/u', ' ', $s);
|
||||||
|
$s = trim((string)$s);
|
||||||
|
if ($max > 0) {
|
||||||
|
$s = function_exists('mb_substr') ? mb_substr($s, 0, $max) : substr($s, 0, $max);
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 거부 이벤트 1건을 링버퍼(별도 파일)에 추가한다(best-effort, DoS 안전).
|
||||||
|
// $ctx: ['mode'=>서명모드, 'device_id'=>본문 device_id(신뢰불가)] — 선택.
|
||||||
|
function record_ingest_reject_event(string $reason, array $ctx = []): void {
|
||||||
|
$fh = @fopen(ingest_reject_events_path(), 'c+');
|
||||||
|
if ($fh === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (flock($fh, LOCK_EX)) {
|
||||||
|
$raw = stream_get_contents($fh);
|
||||||
|
$events = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
|
||||||
|
if (!is_array($events)) { $events = []; }
|
||||||
|
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||||
|
$events[] = [
|
||||||
|
't' => date('Y-m-d H:i:s'),
|
||||||
|
'ip' => ingest_client_ip(),
|
||||||
|
'reason' => ingest_sanitize_token($reason, 24),
|
||||||
|
'mode' => isset($ctx['mode']) ? ingest_sanitize_token((string)$ctx['mode'], 12) : '',
|
||||||
|
'dev' => isset($ctx['device_id']) ? ingest_sanitize_token((string)$ctx['device_id'], 32) : '',
|
||||||
|
'fwd' => (is_string($fwd) && $fwd !== '') ? ingest_sanitize_token($fwd, 48) : '',
|
||||||
|
];
|
||||||
|
$max = (int)INGEST_REJECT_EVENTS_MAX;
|
||||||
|
if ($max > 0 && count($events) > $max) {
|
||||||
|
$events = array_slice($events, -$max);
|
||||||
|
}
|
||||||
|
ftruncate($fh, 0);
|
||||||
|
rewind($fh);
|
||||||
|
fwrite($fh, (string)json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
fflush($fh);
|
||||||
|
flock($fh, LOCK_UN);
|
||||||
|
}
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 거부 이벤트 최신순 $limit 건. 대시보드 '최근 거부 출처' 표에 사용.
|
||||||
|
function read_ingest_reject_events(int $limit = 20): array {
|
||||||
|
$file = ingest_reject_events_path();
|
||||||
|
if (!is_file($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$events = json_decode((string)@file_get_contents($file), true);
|
||||||
|
if (!is_array($events)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$events = array_reverse($events); // 최신 먼저
|
||||||
|
return $limit > 0 ? array_slice($events, 0, $limit) : $events;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
|
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
|
||||||
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
|
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
|
||||||
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
|
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,8 @@ $recent_alert_fail = (int)$db->query("
|
||||||
// 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가).
|
// 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가).
|
||||||
$ingest_rejections = read_ingest_rejections(1);
|
$ingest_rejections = read_ingest_rejections(1);
|
||||||
$rejection_total = array_sum($ingest_rejections);
|
$rejection_total = array_sum($ingest_rejections);
|
||||||
|
// 최근 거부 출처(IP·사유·모드) — '우리 노드 오설정 vs 외부 스캐너' 판별용.
|
||||||
|
$reject_events = read_ingest_reject_events(10);
|
||||||
|
|
||||||
// ── 누수 모듈 현재 상태 + 사고/사진 ───────────────────
|
// ── 누수 모듈 현재 상태 + 사고/사진 ───────────────────
|
||||||
// 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로
|
// 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로
|
||||||
|
|
@ -601,6 +603,25 @@ foreach ($chart_points as $ci => $cp) {
|
||||||
}
|
}
|
||||||
echo implode(' · ', $parts);
|
echo implode(' · ', $parts);
|
||||||
?></p>
|
?></p>
|
||||||
|
<?php if (!empty($reject_events)): ?>
|
||||||
|
<details class="reject-sources">
|
||||||
|
<summary>최근 거부 출처 <?= count($reject_events) ?>건 (우리 노드 vs 외부 스캐너 판별)</summary>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>시각</th><th>출처 IP</th><th>사유</th><th>모드</th><th>device_id</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($reject_events as $ev): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars((string)($ev['t'] ?? '')) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($ev['ip'] ?? '?')) ?><?php if (!empty($ev['fwd'])): ?> <small>(xff <?= htmlspecialchars((string)$ev['fwd']) ?>)</small><?php endif; ?></td>
|
||||||
|
<td><?= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($ev['mode'] ?? '')) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($ev['dev'] ?? '')) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ run_php tests/raw_body_signature_test.php
|
||||||
run_php tests/security_hardening_test.php
|
run_php tests/security_hardening_test.php
|
||||||
run_php tests/threshold_sms_test.php
|
run_php tests/threshold_sms_test.php
|
||||||
run_php tests/cooldown_episode_test.php # 에피소드 인지 쿨다운(복구 후 재발 누락 회귀)
|
run_php tests/cooldown_episode_test.php # 에피소드 인지 쿨다운(복구 후 재발 누락 회귀)
|
||||||
|
run_php tests/ingest_reject_log_test.php # 수집 거부 출처 로그(IP·로그인젝션 방지)
|
||||||
run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴
|
run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴
|
||||||
run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수
|
run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수
|
||||||
|
|
||||||
|
|
|
||||||
55
tests/ingest_reject_log_test.php
Normal file
55
tests/ingest_reject_log_test.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 수집 거부 출처 로그 헬퍼 회귀 (DB/파일 없이 순수 로직만).
|
||||||
|
// ingest_sanitize_token(): 로그 인젝션 방지(제어문자 제거·절단)
|
||||||
|
// ingest_client_ip(): REMOTE_ADDR 형식검증(부재/위조형식 → '?')
|
||||||
|
// 함수/상수 존재: record_ingest_reject_event / read_ingest_reject_events / INGEST_REJECT_EVENTS_MAX
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
require_once $root . '/php/config.php';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
function check(bool $cond, string $msg): void { global $failures; if (!$cond) { $failures[] = $msg; } }
|
||||||
|
function eq($a, $b, string $msg): void {
|
||||||
|
check($a === $b, $msg . ' (expected ' . var_export($b, true) . ', got ' . var_export($a, true) . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 함수/상수 존재 ──────────────────────────────────────────
|
||||||
|
check(function_exists('ingest_sanitize_token'), 'ingest_sanitize_token() should exist.');
|
||||||
|
check(function_exists('ingest_client_ip'), 'ingest_client_ip() should exist.');
|
||||||
|
check(function_exists('record_ingest_reject_event'), 'record_ingest_reject_event() should exist.');
|
||||||
|
check(function_exists('read_ingest_reject_events'), 'read_ingest_reject_events() should exist.');
|
||||||
|
check(defined('INGEST_REJECT_EVENTS_MAX'), 'INGEST_REJECT_EVENTS_MAX should be defined.');
|
||||||
|
|
||||||
|
// ── ingest_sanitize_token: 로그 인젝션/제어문자 방지 ────────
|
||||||
|
eq(ingest_sanitize_token('rpi4-leak-02', 32), 'rpi4-leak-02', 'plain token unchanged');
|
||||||
|
// 개행/캐리지리턴 → 위조 로그줄 주입 차단(제어문자는 공백 처리)
|
||||||
|
check(strpos(ingest_sanitize_token("rpi\n[API] 서명 불일치 from evil", 64), "\n") === false,
|
||||||
|
'newline stripped (log injection blocked)');
|
||||||
|
check(strpos(ingest_sanitize_token("a\r\nb", 64), "\r") === false, 'carriage return stripped');
|
||||||
|
check(strpos(ingest_sanitize_token("a\tb", 64), "\t") === false, 'tab stripped');
|
||||||
|
// 길이 절단
|
||||||
|
check(mb_strlen(ingest_sanitize_token(str_repeat('x', 100), 32)) === 32, 'truncated to max length');
|
||||||
|
eq(ingest_sanitize_token(' spaced ', 32), 'spaced', 'trimmed');
|
||||||
|
eq(ingest_sanitize_token('abc', 0), 'abc', 'max 0 -> no truncation');
|
||||||
|
|
||||||
|
// ── ingest_client_ip: REMOTE_ADDR 형식검증 ──────────────────
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '203.0.113.9';
|
||||||
|
eq(ingest_client_ip(), '203.0.113.9', 'valid IPv4 returned');
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '2001:db8::1';
|
||||||
|
eq(ingest_client_ip(), '2001:db8::1', 'valid IPv6 returned');
|
||||||
|
$_SERVER['REMOTE_ADDR'] = 'not-an-ip';
|
||||||
|
eq(ingest_client_ip(), '?', 'invalid IP format -> ?');
|
||||||
|
unset($_SERVER['REMOTE_ADDR']);
|
||||||
|
eq(ingest_client_ip(), '?', 'missing REMOTE_ADDR -> ?');
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Ingest reject-log test failures:\n");
|
||||||
|
foreach ($failures as $f) {
|
||||||
|
fwrite(STDERR, "- {$f}\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Ingest reject-log checks passed.\n";
|
||||||
|
|
@ -222,6 +222,12 @@ check(str_contains($config, 'function record_ingest_rejection'), 'config.php sho
|
||||||
check(str_contains($config, 'function read_ingest_rejections'), 'config.php should provide read_ingest_rejections().');
|
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($sensorApi, 'record_ingest_rejection'), 'sensor_data.php should count ingest rejections.');
|
||||||
check(str_contains($dashboard, 'ingest_rejections'), 'dashboard.php should surface 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 발송실패 가시화 + 오프라인 재시도
|
// 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($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.');
|
check(str_contains($dashboard, 'recent_alert_fail'), 'dashboard.php should show a failed-alert-SMS banner.');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue