<?php
/* 서버스캔 경량 점검 (공유 웹호스팅 · FTP 전용) — https://serverscan.co.kr/cli#lite
 *
 *  * 읽기 전용입니다. 파일을 고치거나 지우지 않고, 네트워크로 아무것도 보내지 않습니다.
 *  * 결과에는 파일 "내용"이 들어가지 않습니다. 경로 · 건수 · 설정 이름 · 버전만 담깁니다.
 *  * 이 파일에 박힌 접속 열쇠(?k=)가 있어야만 실행되고, 받은 지 3일이 지나면 스스로 멈춥니다.
 *  * 점검이 끝나면 이 파일을 꼭 지워 주세요.
 *
 * 사용법: FTP 로 웹 루트(예: public_html, www)에 올린 뒤 브라우저에서
 *         https://내도메인/serverscan-ea255eb9.php?k=66870285654abe0bec7cd5992c4c9df9  를 열고 결과 JSON 을 받아
 *         서버스캔 /cli 에 올리면 리포트로 볼 수 있습니다.
 */
declare(strict_types=1);
const DS_KEY = '66870285654abe0bec7cd5992c4c9df9';
const DS_ISSUED = 1793779553;   // 내려받은 시각 — 이때부터 3일 동안만 동작
const DS_TTL = 3 * 86400;
const DS_MAXFILES = 15000;

header('X-Robots-Tag: noindex, nofollow'); header('Cache-Control: no-store'); header('X-Content-Type-Options: nosniff');
if (!hash_equals(DS_KEY, (string)($_GET['k'] ?? ''))) { http_response_code(404); exit('Not Found'); }
if (time() - DS_ISSUED > DS_TTL) { http_response_code(410); exit('이 점검 파일은 받은 지 3일이 지나 멈췄어요. 서버에서 지우고 새로 받아 주세요.'); }
@set_time_limit(120); @ini_set('memory_limit', '256M');

$self = realpath(__FILE__);
$root = realpath((string)($_SERVER['DOCUMENT_ROOT'] ?? '')) ?: dirname($self);
$F = array(); $C = array(); $NF = 0; $NH = 0;
function ds_f(string $id, string $sev, string $t, string $ev, string $rec): void { global $F, $NF, $NH; $F[] = array('check_id' => $id, 'severity' => $sev, 'title' => $t, 'evidence' => $ev, 'recommendation' => $rec); $NF++; if ($sev === 'high') $NH++; }
function ds_begin(): void { global $NF, $NH; $NF = 0; $NH = 0; }
function ds_end(string $id, string $name, string $cat, string $sum): void { global $C, $NF, $NH; $C[] = array('check_id' => $id, 'name' => $name, 'category' => $cat, 'status' => $NH ? 'fail' : ($NF ? 'warn' : 'pass'), 'summary' => $sum); }
function ds_rel(string $p): string { global $root; return strpos($p, $root) === 0 ? substr($p, strlen($root)) : $p; }
function ds_list(array $a, int $n = 8): string { return implode("\n", array_map('ds_rel', array_slice($a, 0, $n))) . (count($a) > $n ? "\n외 " . (count($a) - $n) . '개' : ''); }
function ds_on(string $k): bool { $v = strtolower((string)ini_get($k)); return $v === '1' || $v === 'on' || $v === 'true'; }

/* 파일 목록(웹 루트 아래, 이 파일 제외, 심볼릭 링크 따라가지 않음) */
$files = array(); $exec = array(); $count = 0;
try {
  $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, RecursiveIteratorIterator::CATCH_GET_CHILD);
  foreach ($it as $fi) {
    if ($fi->isLink()) continue;
    $p = $fi->getPathname(); if ($p === $self) continue;
    if (++$count > DS_MAXFILES) break;
    $files[] = $fi;
    if ($fi->isFile() && preg_match('/\.(php\d?|phtml|phar|inc)$/i', $p) && $fi->getSize() < 3 * 1048576) $exec[] = $p;
  }
} catch (Throwable $e) { /* 읽을 수 없는 폴더는 건너뛴다 */ }

/* 1. PHP 설정 */
ds_begin();
if (ds_on('display_errors')) ds_f('cli_config', 'medium', 'PHP 오류가 화면에 그대로 나옴', 'display_errors = On', '호스팅 관리 화면의 PHP 설정이나 .user.ini 에 display_errors = Off 를 넣으세요.');
if (ds_on('allow_url_include')) ds_f('cli_config', 'high', 'PHP 원격 파일 포함 허용', 'allow_url_include = On', '호스팅 업체에 allow_url_include = Off 를 요청하세요.');
if (ds_on('expose_php')) ds_f('cli_config', 'low', 'PHP 버전을 응답 헤더에 노출', 'expose_php = On', 'expose_php = Off 로 바꾸세요(호스팅 PHP 설정).');
if (trim((string)ini_get('disable_functions')) === '') ds_f('cli_config', 'low', '위험한 PHP 함수가 하나도 막혀 있지 않음', 'disable_functions 비어 있음(system · exec · shell_exec 등 사용 가능)', '쓰지 않는 함수(system, exec, shell_exec, passthru, proc_open, popen)를 막아 달라고 호스팅 업체에 요청하세요. 웹셸이 올라와도 피해가 줄어요.');
if (ini_get('session.cookie_httponly') !== '1') ds_f('cli_config', 'low', '세션 쿠키에 HttpOnly 가 꺼져 있음', 'session.cookie_httponly = ' . var_export(ini_get('session.cookie_httponly'), true), 'session.cookie_httponly = 1 로 바꾸세요.');
ds_end('cli_config', '설정 파일 위험 옵션', '서버 설정', 'PHP ' . PHP_VERSION . ' 설정 확인 (위험 옵션 ' . $NF . '건)');

/* 2. 웹셸 · 악성 파일 */
ds_begin();
$shell = array(); $recent = array(); $week = time() - 7 * 86400;
$sig = '/eval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|\$_(POST|GET|REQUEST|COOKIE))|assert\s*\(\s*\$_(POST|GET|REQUEST)|(system|shell_exec|passthru|popen|proc_open)\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)|FilesMan|b374k|WSO [0-9]|c99shell|r57shell/i';
foreach ($exec as $p) { $s = @file_get_contents($p, false, null, 0, 2 * 1048576); if ($s !== false && preg_match($sig, $s)) $shell[] = $p; if (@filemtime($p) > $week) $recent[] = $p; }
if ($shell) ds_f('webshell_pattern', 'high', '웹셸로 의심되는 파일 ' . count($shell) . '개', ds_list($shell), '파일을 바로 지우지 말고 먼저 내려받아 보관한 뒤 서버에서 지우거나 이름을 바꾸세요. FTP · 관리자 비밀번호를 모두 바꾸고 호스팅 업체에 알리세요.');
$up = array_values(array_filter($exec, fn($p) => preg_match('#/(upload|uploads|data|files|images|img|attach)/#i', ds_rel($p))));
if ($up) ds_f('webshell_pattern', 'medium', '업로드 폴더 안의 실행 파일 ' . count($up) . '개', ds_list($up), '업로드 폴더에 .htaccess 로 실행을 막으세요: <FilesMatch "\.(php|phtml|phar)$"> Require all denied </FilesMatch>');
ds_end('webshell_pattern', '웹셸 · 악성 파일', '침해 흔적', '실행 파일 ' . count($exec) . '개 검사 · 최근 7일 변경 ' . count($recent) . '개');

/* 3. 코드 취약점(규칙 기반) */
ds_begin();
$rules = array(
  array('/(mysql_query|mysqli_query|->query|pg_query)\s*\([^;]*\$_(GET|POST|REQUEST|COOKIE)/i', 'high', 'SQL 에 입력값을 그대로 넣는 코드', '입력값을 쿼리 문자열에 붙이지 말고 준비된 문장(prepared statement)으로 바꾸세요.'),
  array('/(include|require)(_once)?[\s(]*\$_(GET|POST|REQUEST|COOKIE)/i', 'high', '입력값으로 파일을 불러오는 코드', '불러올 파일 이름은 허용 목록에서만 고르게 바꾸세요.'),
  array('/(echo|print)\s+\$_(GET|POST|REQUEST|COOKIE)/i', 'medium', '입력값을 그대로 화면에 출력하는 코드', '출력 전에 htmlspecialchars($v, ENT_QUOTES) 로 감싸세요.'),
);
$n = 0;
foreach ($rules as [$re, $sev, $t, $rec]) {
  $hit = array(); foreach ($exec as $p) { $s = @file_get_contents($p, false, null, 0, 2 * 1048576); if ($s !== false && preg_match($re, $s)) $hit[] = $p; }
  if ($hit) { ds_f('code_vuln', $sev, $t . ' ' . count($hit) . '곳', ds_list($hit), $rec); $n++; }
}
ds_end('code_vuln', '코드 취약점', '코드', 'SQL 주입 · 파일 포함 · XSS 패턴 검사 (' . $n . '종)');

/* 4. 파일 권한 · 민감 파일 */
ds_begin();
$ww = array(); $sens = array(); $wexec = 0;
foreach ($files as $fi) {
  $p = $fi->getPathname(); $perm = @fileperms($p);
  if ($fi->isFile() && $perm !== false && ($perm & 0002)) $ww[] = $p;
  if (preg_match('#/(\.env|\.git|[^/]+\.sql(\.gz)?|[^/]+\.bak|id_rsa|[^/]+\.pem|[^/]+\.key)$#i', $p)) $sens[] = $p;
}
foreach ($exec as $p) if (is_writable($p)) $wexec++;
if ($ww) ds_f('file_permission', 'medium', '누구나 고칠 수 있는 파일(권한 777 · 666) ' . count($ww) . '개', ds_list($ww), 'FTP 프로그램에서 파일 권한을 644(폴더 755)로 바꾸세요.');
if ($sens) ds_f('file_permission', 'high', '웹 루트 안의 민감 파일 ' . count($sens) . '개', ds_list($sens), '비밀 · 백업 · 키 파일은 웹 루트 밖으로 옮기거나 지우세요. 웹에서 열렸다면 안의 비밀번호를 모두 바꾸세요.');
ds_end('file_permission', '파일 권한 · 민감 파일', '파일', '파일 ' . count($files) . '개 확인 · 웹 계정이 고칠 수 있는 실행 파일 ' . $wexec . '개');

$C[] = array('check_id' => 'accounts', 'name' => '계정 · 인증 설정', 'category' => '계정', 'status' => 'info', 'summary' => '공유 호스팅에서는 서버 계정을 볼 수 없어 건너뛰었어요');
$C[] = array('check_id' => 'log_anomaly', 'name' => '로그 이상 징후', 'category' => '침해 흔적', 'status' => 'info', 'summary' => '공유 호스팅에서는 서버 로그를 볼 수 없어 건너뛰었어요');

$tech = array(array('name' => 'PHP', 'version' => PHP_VERSION));
if (preg_match('#(Apache|nginx|LiteSpeed)/([0-9.]+)#i', (string)($_SERVER['SERVER_SOFTWARE'] ?? ''), $m)) $tech[] = array('name' => strcasecmp($m[1], 'apache') === 0 ? 'Apache httpd' : $m[1], 'version' => $m[2]);
$out = array('scan_id' => '', 'target' => (string)($_SERVER['HTTP_HOST'] ?? 'shared-hosting'), 'stage' => 'cli', 'started_at' => date('c'), 'findings' => $F, 'checks' => $C,
  'meta' => array('os' => PHP_OS_FAMILY . ' · 공유 웹호스팅', 'hostname' => '', 'webroot' => '(웹 루트)', 'engine' => 'safe-scan-lite.php/1.0', 'user' => '', 'tech' => $tech));
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="safe-scan-lite-' . preg_replace('/[^a-z0-9.-]/i', '', (string)($_SERVER['HTTP_HOST'] ?? 'site')) . '-' . date('Ymd-His') . '.json"');
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
