#!/usr/bin/env bash
# 서버스캔 설치형 점검 (리눅스) — serverscan.co.kr
#
#  * 읽기 전용입니다. 파일을 고치거나 지우거나 설치하지 않습니다.
#  * 네트워크로 아무것도 보내지 않습니다. 결과는 JSON 파일 하나로만 남습니다.
#  * 결과에는 파일 "내용"이 들어가지 않습니다. 경로 · 건수 · 설정 이름 · 소프트웨어 버전만 담깁니다.
#  * 결과 파일을 직접 열어 확인한 뒤 https://serverscan.co.kr/cli 에 올리면 리포트로 볼 수 있습니다.
#
# 사용법:  bash safe-scan.sh [웹 루트 경로] [-o 결과파일.json] [--access-log 접속로그경로]
#          (root 로 실행하면 sshd · 로그 · sudoers 처럼 root 만 읽을 수 있는 항목까지 봅니다)
set -u
export LC_ALL=C
VERSION="1.0"
WEBROOT=""
OUT="safe-scan-$(hostname -s 2>/dev/null || echo host)-$(date +%Y%m%d-%H%M%S).json"
while [ $# -gt 0 ]; do
  case "$1" in
    -o) OUT="${2:-$OUT}"; shift 2 ;;
    --access-log) EXTRA_ALOG="${2:-}"; shift 2 ;;
    -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
    *) WEBROOT="$1"; shift ;;
  esac
done
if [ -z "$WEBROOT" ]; then
  for d in /var/www/html /var/www /usr/share/nginx/html /srv/www /home/*/public_html; do [ -d "$d" ] && { WEBROOT="$d"; break; }; done
fi
MAXFILES=20000      # 웹 루트에서 살펴볼 최대 파일 수
STARTED="$(date -Iseconds)"

# ── JSON 도우미 ──
je() { printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' | awk 'BEGIN{ORS=""} {gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); gsub(/\t/,"\\t"); gsub(/\r/,""); if (NR>1) print "\\n"; print}'; }
FINDINGS=""; CHECKS=""; TECH=""
add_finding() {  # check_id severity title evidence recommendation
  [ -n "$FINDINGS" ] && FINDINGS="$FINDINGS,"
  FINDINGS="$FINDINGS{\"check_id\":\"$1\",\"severity\":\"$2\",\"title\":\"$(je "$3")\",\"evidence\":\"$(je "$4")\",\"recommendation\":\"$(je "$5")\"}"
  NF=$((NF+1)); [ "$2" = high ] && NH=$((NH+1))
}
add_check() {  # check_id name category status summary
  [ -n "$CHECKS" ] && CHECKS="$CHECKS,"
  CHECKS="$CHECKS{\"check_id\":\"$1\",\"name\":\"$(je "$2")\",\"category\":\"$(je "$3")\",\"status\":\"$4\",\"summary\":\"$(je "$5")\"}"
}
add_tech() { [ -z "$2" ] && return; [ -n "$TECH" ] && TECH="$TECH,"; TECH="$TECH{\"name\":\"$(je "$1")\",\"version\":\"$(je "$2")\"}"; }
begin() { NF=0; NH=0; printf '  · %s … ' "$1" >&2; }
finish() {  # check_id name category summary
  local st=pass; [ "$NF" -gt 0 ] && st=warn; [ "$NH" -gt 0 ] && st=fail
  add_check "$1" "$2" "$3" "$st" "$4"; printf '%s\n' "$4" >&2
}
first_lines() { head -n "${2:-8}" | sed "s#^$WEBROOT##"; }   # 경로만, 웹 루트 기준으로 줄여서
conf_val() { # 파일 키 → 마지막으로 유효한 값(주석 제외)
  grep -iE "^[[:space:]]*$2[[:space:]=]" "$1" 2>/dev/null | grep -v '^[[:space:]]*[#;]' | tail -n1 | sed -E "s/^[[:space:]]*$2[[:space:]=]+//I; s/[[:space:]\"';]+$//"
}

echo "서버스캔 설치형 점검 $VERSION — 읽기 전용, 네트워크 전송 없음" >&2
echo "  웹 루트: ${WEBROOT:-(찾지 못함)}" >&2

# ── 1. 소프트웨어 버전(CVE 대조용 — 이름과 버전만) ──
v=$(nginx -v 2>&1 | grep -oE 'nginx/[0-9.]+' | cut -d/ -f2); add_tech nginx "$v"
v=$( (apache2 -v 2>/dev/null || httpd -v 2>/dev/null) | grep -oE 'Apache/[0-9.]+' | cut -d/ -f2); add_tech "Apache httpd" "$v"
v=$(php -r 'echo PHP_VERSION;' 2>/dev/null | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+'); add_tech PHP "$v"
v=$(openssl version 2>/dev/null | awk '{print $2}' | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+[a-z]?'); add_tech OpenSSL "$v"
mv=$(mysqld --version 2>/dev/null || mysql --version 2>/dev/null)
if echo "$mv" | grep -qi mariadb; then add_tech MariaDB "$(echo "$mv" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+-MariaDB' | head -n1 | cut -d- -f1)"
else add_tech MySQL "$(echo "$mv" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"; fi

# ── 2. 설정 파일 위험 옵션 ──
begin "설정 파일"
if [ -r /etc/ssh/sshd_config ]; then
  prl=$(conf_val /etc/ssh/sshd_config PermitRootLogin); pa=$(conf_val /etc/ssh/sshd_config PasswordAuthentication)
  [ "${prl,,}" = yes ] && add_finding cli_config high "SSH root 로그인 허용" "/etc/ssh/sshd_config: PermitRootLogin yes" "PermitRootLogin no(또는 prohibit-password)로 바꾸고 일반 계정 + sudo 로 접속하세요."
  [ "${pa,,}" = yes ] && add_finding cli_config medium "SSH 비밀번호 로그인 허용" "/etc/ssh/sshd_config: PasswordAuthentication yes" "SSH 키 로그인만 허용(PasswordAuthentication no)하고 fail2ban 같은 차단 도구를 쓰세요."
fi
for ini in $(php -r 'echo php_ini_loaded_file();' 2>/dev/null) /etc/php/*/fpm/php.ini /etc/php/*/apache2/php.ini; do
  [ -r "$ini" ] || continue
  [ "$(conf_val "$ini" display_errors | tr A-Z a-z)" = on ] && add_finding cli_config medium "PHP 오류가 화면에 그대로 나옴" "$ini: display_errors = On" "운영 서버는 display_errors = Off, log_errors = On 으로 두세요."
  [ "$(conf_val "$ini" allow_url_include | tr A-Z a-z)" = on ] && add_finding cli_config high "PHP 원격 파일 포함 허용" "$ini: allow_url_include = On" "allow_url_include = Off 로 바꾸세요(원격 코드 실행 통로)."
  [ "$(conf_val "$ini" expose_php | tr A-Z a-z)" = on ] && add_finding cli_config low "PHP 버전을 응답 헤더에 노출" "$ini: expose_php = On" "expose_php = Off 로 바꾸세요."
  case "$ini" in */fpm/*) fp=$(conf_val "$ini" cgi.fix_pathinfo); [ "$fp" != 0 ] && command -v nginx >/dev/null 2>&1 && add_finding cli_config low "PHP 경로 해석 설정(cgi.fix_pathinfo)이 켜져 있음" "$ini: cgi.fix_pathinfo = ${fp:-1(기본값)}" "nginx + PHP-FPM 이면 cgi.fix_pathinfo = 0 으로 두고, FPM 의 security.limit_extensions = .php 를 확인하세요(이미지로 위장한 스크립트 실행 방지)." ;; esac
done
for f in /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf /etc/nginx/sites-enabled/*; do
  [ -r "$f" ] || continue
  grep -qE '^[[:space:]]*server_tokens[[:space:]]+on' "$f" && add_finding cli_config low "nginx 버전 노출" "$f: server_tokens on" "http 블록에 server_tokens off; 를 넣으세요."
  grep -qE '^[[:space:]]*autoindex[[:space:]]+on' "$f" && add_finding cli_config medium "디렉터리 목록 보기가 켜져 있음(nginx)" "$f: autoindex on" "autoindex off; 로 바꾸세요. 폴더 안 파일 이름이 그대로 보여요."
done
for f in /etc/apache2/apache2.conf /etc/apache2/sites-enabled/* /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/*.conf; do
  [ -r "$f" ] || continue
  ix=$(grep -nE '^[[:space:]]*Options[[:space:]].*([[:space:]]|\+)Indexes' "$f" | grep -v -- '-Indexes' | head -n 3)
  [ -n "$ix" ] && add_finding cli_config medium "디렉터리 목록 보기가 켜져 있음(Apache)" "$f:
$ix" "Options 에서 Indexes 를 빼거나 -Indexes 로 바꾸세요."
done
for f in /etc/apache2/conf-enabled/security.conf /etc/httpd/conf/httpd.conf; do
  [ -r "$f" ] || continue
  st=$(conf_val "$f" ServerTokens); [ -n "$st" ] && [ "${st,,}" != prod ] && add_finding cli_config low "Apache 버전 노출" "$f: ServerTokens $st" "ServerTokens Prod, ServerSignature Off 로 바꾸세요."
done
for f in /etc/mysql/mysql.conf.d/mysqld.cnf /etc/mysql/my.cnf /etc/my.cnf /etc/mysql/mariadb.conf.d/50-server.cnf; do
  [ -r "$f" ] || continue
  ba=$(conf_val "$f" bind-address); [ "$ba" = "0.0.0.0" ] || [ "$ba" = "*" ] && add_finding cli_config medium "DB 가 모든 네트워크에서 접속을 받음" "$f: bind-address = $ba" "웹서버와 같은 서버라면 bind-address = 127.0.0.1 로 바꾸고, 아니면 방화벽으로 접속 IP 를 제한하세요."
done
finish cli_config "설정 파일 위험 옵션" "서버 설정" "SSH · PHP · 웹서버 · DB 설정 확인 (위험 옵션 ${NF}건)"

# ── 3. 웹셸 · 악성 파일 ──
begin "웹셸 패턴"
if [ -n "$WEBROOT" ] && [ -d "$WEBROOT" ]; then
  LIST=$(mktemp); trap 'rm -f "$LIST"' EXIT
  find "$WEBROOT" -xdev -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.php[0-9]' -o -name '*.jsp' -o -name '*.asp' -o -name '*.aspx' -o -name '*.inc' \) -size -3M 2>/dev/null | head -n "$MAXFILES" > "$LIST"
  NFILES=$(wc -l < "$LIST")
  hits=$(tr '\n' '\0' < "$LIST" | xargs -0 -r nice -n 19 grep -lIE 'eval[[:space:]]*\([[:space:]]*(base64_decode|gzinflate|gzuncompress|str_rot13|\$_(POST|GET|REQUEST|COOKIE))|assert[[:space:]]*\([[:space:]]*\$_(POST|GET|REQUEST)|(system|shell_exec|passthru|popen|proc_open)[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)|FilesMan|b374k|WSO [0-9]|c99shell|r57shell' 2>/dev/null)
  [ -n "$hits" ] && add_finding webshell_pattern high "웹셸로 의심되는 파일 $(echo "$hits" | wc -l)개" "$(echo "$hits" | first_lines)" "파일을 바로 지우지 말고 먼저 복사해 보관한 뒤(수사 · 원인 분석용) 접근을 막으세요. 생성 시각 전후 접속 로그를 확인하고, 업로드 기능과 관리자 비밀번호를 점검하세요."
  up=$(find "$WEBROOT" -xdev -type f \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.jsp' \) \( -path '*upload*' -o -path '*/data/*' -o -path '*/files/*' -o -path '*/images/*' \) 2>/dev/null | head -n 50)
  [ -n "$up" ] && add_finding webshell_pattern medium "업로드 폴더 안의 실행 파일 $(echo "$up" | wc -l)개" "$(echo "$up" | first_lines)" "업로드 폴더에서는 스크립트가 실행되지 않게 막으세요(nginx: location ~* /uploads/.*\\.php\$ { deny all; } · Apache: php_admin_flag engine off)."
  NRECENT=$(tr '\n' '\0' < "$LIST" | xargs -0 -r stat -c %Y 2>/dev/null | awk -v t=$(( $(date +%s) - 604800 )) '$1 > t' | wc -l)
  finish webshell_pattern "웹셸 · 악성 파일" "침해 흔적" "웹 루트 파일 ${NFILES}개를 패턴으로 검사 · 최근 7일 변경 ${NRECENT}개"
else
  add_check webshell_pattern "웹셸 · 악성 파일" "침해 흔적" error "웹 루트를 찾지 못해 건너뛰었어요(경로를 인자로 주세요)"; echo "건너뜀" >&2
fi

# ── 4. 코드 취약점(규칙 기반) ──
begin "코드 취약점"
if [ -n "$WEBROOT" ] && [ -s "${LIST:-/nonexistent}" ]; then
  sqli=$(tr '\n' '\0' < "$LIST" | xargs -0 -r nice -n 19 grep -lIE '(mysql_query|mysqli_query|->query|pg_query)[[:space:]]*\([^;]*\$_(GET|POST|REQUEST|COOKIE)' 2>/dev/null)
  [ -n "$sqli" ] && add_finding code_vuln high "SQL 에 입력값을 그대로 넣는 코드 $(echo "$sqli" | wc -l)곳" "$(echo "$sqli" | first_lines)" "입력값을 쿼리 문자열에 붙이지 말고 준비된 문장(prepared statement, bind_param)으로 바꾸세요."
  lfi=$(tr '\n' '\0' < "$LIST" | xargs -0 -r nice -n 19 grep -lIE '(include|require)(_once)?[[:space:](]*\$_(GET|POST|REQUEST|COOKIE)' 2>/dev/null)
  [ -n "$lfi" ] && add_finding code_vuln high "입력값으로 파일을 불러오는 코드 $(echo "$lfi" | wc -l)곳" "$(echo "$lfi" | first_lines)" "불러올 파일 이름은 허용 목록에서만 고르게 바꾸세요."
  xss=$(tr '\n' '\0' < "$LIST" | xargs -0 -r nice -n 19 grep -lIE '(echo|print)[[:space:]]+\$_(GET|POST|REQUEST|COOKIE)' 2>/dev/null)
  [ -n "$xss" ] && add_finding code_vuln medium "입력값을 그대로 화면에 출력하는 코드 $(echo "$xss" | wc -l)곳" "$(echo "$xss" | first_lines)" "출력 전에 htmlspecialchars(\$v, ENT_QUOTES) 로 감싸세요."
  finish code_vuln "코드 취약점" "코드" "SQL 주입 · 파일 포함 · XSS 패턴 검사 (${NF}종)"
else
  add_check code_vuln "코드 취약점" "코드" error "웹 루트를 찾지 못해 건너뛰었어요"; echo "건너뜀" >&2
fi

# ── 5. 파일 권한 · 민감 파일 ──
begin "파일 권한"
if [ -n "$WEBROOT" ] && [ -d "$WEBROOT" ]; then
  ww=$(find "$WEBROOT" -xdev -type f -perm -0002 2>/dev/null | head -n 50)
  [ -n "$ww" ] && add_finding file_permission medium "누구나 고칠 수 있는 파일(권한 777 등) $(echo "$ww" | wc -l)개 이상" "$(echo "$ww" | first_lines)" "chmod o-w 로 다른 사용자 쓰기 권한을 없애세요(보통 파일 644, 폴더 755)."
  sens=$(find "$WEBROOT" -xdev -maxdepth 4 \( -name '.env' -o -name '.git' -o -name '*.sql' -o -name '*.sql.gz' -o -name '*.bak' -o -name 'id_rsa' -o -name '*.pem' -o -name '*.key' \) 2>/dev/null | head -n 50)
  [ -n "$sens" ] && add_finding file_permission high "웹 루트 안의 민감 파일 $(echo "$sens" | wc -l)개" "$(echo "$sens" | first_lines)" "비밀 · 백업 · 키 파일은 웹 루트 밖으로 옮기세요. 웹에서 열렸다면 안의 비밀번호와 키를 모두 바꾸세요."
fi
for f in /etc/passwd /etc/shadow; do
  [ -e "$f" ] || continue; p=$(stat -c %a "$f" 2>/dev/null)
  [ "$f" = /etc/shadow ] && [ -n "$p" ] && [ $((8#$p & 8#004)) -ne 0 ] && add_finding file_permission high "/etc/shadow 를 누구나 읽을 수 있음" "/etc/shadow 권한 $p" "chmod 640 /etc/shadow (소유 root:shadow) 로 바꾸세요."
done
finish file_permission "파일 권한 · 민감 파일" "파일" "웹 루트 권한 · 민감 파일 위치 확인 (${NF}건)"

# ── 6. 계정 · 인증 설정 ──
begin "계정"
uid0=$(awk -F: '$3==0 && $1!="root"{print $1}' /etc/passwd 2>/dev/null)
[ -n "$uid0" ] && add_finding accounts high "root 와 같은 권한(UID 0) 계정" "$uid0" "root 외에 UID 0 계정이 있으면 안 돼요. 만든 기억이 없다면 침해를 의심하고 바로 확인하세요."
if [ -r /etc/shadow ]; then
  empty=$(awk -F: '$2==""{print $1}' /etc/shadow)
  [ -n "$empty" ] && add_finding accounts high "비밀번호가 비어 있는 계정" "$empty" "passwd -l 로 잠그거나 비밀번호를 설정하세요."
fi
np=$(grep -hE '^[^#].*NOPASSWD' /etc/sudoers /etc/sudoers.d/* 2>/dev/null | grep -v '^Defaults' | head -n 5)
[ -n "$np" ] && add_finding accounts medium "비밀번호 없이 sudo 가능한 설정" "$(echo "$np" | sed -E 's/[[:space:]]+/ /g')" "꼭 필요한 명령만 NOPASSWD 로 허용하고 ALL 은 피하세요."
shells=$(awk -F: '$7 !~ /(nologin|false|sync|shutdown|halt)$/ {print $1}' /etc/passwd 2>/dev/null | tr '\n' ' ')
finish accounts "계정 · 인증 설정" "계정" "로그인 가능한 계정: ${shells:-없음}"

# ── 7. 로그 이상 징후 ──
begin "로그"
AUTH=""; for f in /var/log/auth.log /var/log/secure; do [ -r "$f" ] && { AUTH="$f"; break; }; done
if [ -n "$AUTH" ]; then
  nfail=$(grep -c 'Failed password' "$AUTH" 2>/dev/null || echo 0)
  top=$(grep 'Failed password' "$AUTH" 2>/dev/null | grep -oE 'from [0-9a-f.:]+' | awk '{print $2}' | sort | uniq -c | sort -rn | head -n 5 | awk '{print $2" ("$1"회)"}')
  [ "$nfail" -ge 100 ] && add_finding log_anomaly medium "SSH 비밀번호 대입 시도 ${nfail}회" "$AUTH 기준 상위 접속지:
$top" "SSH 비밀번호 로그인을 끄고(키만 허용) fail2ban 으로 반복 실패 IP 를 막으세요."
  okroot=$(grep -cE 'Accepted (password|publickey) for root' "$AUTH" 2>/dev/null || echo 0)
  [ "$okroot" -gt 0 ] && add_finding log_anomaly low "root 로 직접 로그인한 기록 ${okroot}회" "$AUTH" "본인 접속이 맞는지 확인하고, root 직접 로그인은 막는 것이 좋아요."
  finish log_anomaly "로그 이상 징후" "침해 흔적" "$AUTH 확인 (로그인 실패 ${nfail}회)"
else
  add_check log_anomaly "로그 이상 징후" "침해 흔적" info "인증 로그를 읽을 권한이 없어 건너뛰었어요(root 로 실행하면 확인)"; echo "건너뜀(권한 없음)" >&2
fi

# ── 7-2. 접속 로그 공격 흔적 ──
begin "접속 로그"
ALOGS=""; [ -n "${EXTRA_ALOG:-}" ] && [ -r "$EXTRA_ALOG" ] && ALOGS=" $EXTRA_ALOG"; for f in /var/log/nginx/access.log /var/log/apache2/access.log /var/log/httpd/access_log; do [ -r "$f" ] && ALOGS="$ALOGS $f"; done
if [ -n "$ALOGS" ]; then
  ATK='union([[:space:]]|%20|\+|/\*\*/)+(all([[:space:]]|%20|\+)+)?select|information_schema|sleep\(|benchmark\(|%27([[:space:]]|%20|\+)*or([[:space:]]|%20|\+)|\.\./\.\./|%2e%2e%2f|/etc/passwd|<script|%3cscript|base64_decode'
  natk=$(cat $ALOGS 2>/dev/null | grep -ciE "$ATK" || true)
  topa=$(cat $ALOGS 2>/dev/null | grep -iE "$ATK" | awk '{print $1}' | sort | uniq -c | sort -rn | head -n 5 | awk '{print $2" ("$1"회)"}')
  [ "${natk:-0}" -ge 20 ] && add_finding log_anomaly medium "웹 공격 시도 흔적 ${natk}건(SQL 주입 · 경로 조작 · XSS)" "$ALOGS 기준 상위 요청지:
$topa" "대부분 자동 스캐너지만, 같은 IP 가 계속 시도하면 방화벽 · WAF 로 막으세요. 아래 코드 취약점 항목과 함께 확인하세요."
  wsh=$(cat $ALOGS 2>/dev/null | grep -iE '"(GET|POST) [^"]*/(c99|r57|wso|b374k|shell|cmd|up|alfa)[a-z0-9_-]*\.php[^"]*" 200 ' | awk '{print $7}' | sed 's/?.*//' | sort | uniq -c | sort -rn | head -n 5 | awk '{print $2" ("$1"회)"}')
  [ -n "$wsh" ] && add_finding log_anomaly high "웹셸 이름의 파일이 정상 응답(200)한 기록" "$wsh" "해당 파일이 실제로 있는지 바로 확인하고, 있다면 보관 후 격리하세요. 접속 IP · 시각으로 침입 경로를 추적하세요."
  add_check access_log "접속 로그 공격 흔적" "침해 흔적" "$( [ "$NH" -gt 0 ] && echo fail || ([ "$NF" -gt 0 ] && echo warn || echo pass) )" "공격 패턴 요청 ${natk:-0}건 확인"; echo "공격 패턴 ${natk:-0}건" >&2
else
  add_check access_log "접속 로그 공격 흔적" "침해 흔적" info "접속 로그를 읽을 권한이 없어 건너뛰었어요(root 로 실행하면 확인)"; echo "건너뜀(권한 없음)" >&2
fi

OS=$( (. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME") || uname -s)
cat > "$OUT" <<JSON
{"scan_id":"","target":"$(je "$(hostname -f 2>/dev/null || hostname)")","stage":"cli","started_at":"$STARTED","findings":[${FINDINGS}],"checks":[${CHECKS}],"meta":{"os":"$(je "$OS")","hostname":"$(je "$(hostname -s 2>/dev/null)")","webroot":"$(je "$WEBROOT")","engine":"safe-scan.sh/$VERSION","user":"$(je "$(id -un)")","tech":[${TECH}]}}
JSON
echo "" >&2
echo "완료: $OUT" >&2
echo "파일을 열어 내용을 확인한 뒤 https://serverscan.co.kr/cli 에 올리면 리포트로 볼 수 있어요." >&2
