zioinfo-mail/workspace/guardia-itsm/routers/vuln_scan.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

483 lines
16 KiB
Python

"""
D-4: 보안 취약점 자동 스캔 API 라우터
엔드포인트:
POST /api/vuln/scan — 대상 서버 스캔 시작 (비동기)
GET /api/vuln/scans — 스캔 이력 조회
GET /api/vuln/scans/{scan_id} — 스캔 결과 상세
POST /api/vuln/quick-check — 빠른 단일 포트/서비스 점검
GET /api/vuln/cve/{cve_id} — CVE 상세 정보
POST /api/vuln/cvss — CVSS 점수 계산
GET /api/vuln/stats — 취약점 통계 요약
GET /api/vuln/policies — 스캔 정책 조회
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Dict, List, Optional
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, UserRole
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/vuln", tags=["vuln_scan"])
# ── 스캔 결과 인메모리 스토어 ──────────────────────────────────────────────────
_scan_results: Dict[str, Dict] = {}
_scan_queue: List[str] = [] # 진행 중 scan_id
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class ScanRequestIn(BaseModel):
host: str
ports: Optional[List[int]] = None
include_llm: bool = False
timeout: float = 1.0
sr_id: Optional[str] = None # 연관 SR
note: Optional[str] = None
class QuickCheckIn(BaseModel):
host: str
port: int
service: Optional[str] = None
class CVSSCalcIn(BaseModel):
attack_vector: str = "NETWORK" # NETWORK|ADJACENT|LOCAL|PHYSICAL
complexity: str = "LOW" # LOW|HIGH
privileges: str = "NONE" # NONE|LOW|HIGH
impact: str = "HIGH" # NONE|LOW|MEDIUM|HIGH
# ── 백그라운드 스캔 실행기 ─────────────────────────────────────────────────────
async def _run_scan_bg(scan_id: str, host: str, ports, include_llm: bool,
timeout: float, requester: str):
"""백그라운드에서 스캔을 실행하고 결과를 저장."""
from core.vuln_scan import run_vulnerability_scan
_scan_results[scan_id]["status"] = "RUNNING"
try:
result = await run_vulnerability_scan(host, ports, include_llm, timeout)
result["scan_id"] = scan_id
result["requester"] = requester
result["status"] = "COMPLETED"
_scan_results[scan_id].update(result)
logger.info("스캔 완료: %s → risk=%s score=%d",
scan_id, result["risk_level"], result["risk_score"])
except Exception as e:
_scan_results[scan_id]["status"] = "FAILED"
_scan_results[scan_id]["error"] = str(e)[:100]
logger.error("스캔 실패: %s%s", scan_id, str(e)[:80])
finally:
if scan_id in _scan_queue:
_scan_queue.remove(scan_id)
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.post("/scan", status_code=202)
async def start_scan(
body: ScanRequestIn,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""
취약점 스캔 시작 (202 Accepted — 비동기 실행).
보안: PM/ADMIN만 스캔 가능, 스캔 대상 기록 필수.
"""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
if not body.host:
raise HTTPException(400, "host는 필수입니다.")
if body.timeout > 5.0:
raise HTTPException(400, "timeout은 최대 5초입니다.")
# scan_id 생성
import hashlib
scan_id = hashlib.sha256(
f"{body.host}:{datetime.utcnow().isoformat()}:{current_user.username}".encode()
).hexdigest()[:12]
_scan_results[scan_id] = {
"scan_id": scan_id,
"host": body.host,
"status": "QUEUED",
"requester": current_user.username,
"requested_at": datetime.utcnow().isoformat(),
"sr_id": body.sr_id,
"note": body.note,
}
_scan_queue.append(scan_id)
background_tasks.add_task(
_run_scan_bg,
scan_id, body.host, body.ports,
body.include_llm, body.timeout,
current_user.username,
)
logger.info("스캔 요청: %s%s by %s", scan_id, body.host, current_user.username)
return {
"scan_id": scan_id,
"status": "QUEUED",
"message": "스캔이 시작되었습니다. GET /api/vuln/scans/{scan_id}로 결과를 확인하세요.",
"host": body.host,
}
@router.get("/scans")
async def list_scans(
status: Optional[str] = Query(None),
host: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""스캔 이력 조회."""
results = list(_scan_results.values())
# ENGINEER는 본인 스캔만
if current_user.role == UserRole.ENGINEER:
results = [r for r in results if r.get("requester") == current_user.username]
if status:
results = [r for r in results if r.get("status") == status.upper()]
if host:
results = [r for r in results if host in r.get("host", "")]
results_sorted = sorted(
results,
key=lambda x: x.get("requested_at", ""),
reverse=True,
)
return {
"total": len(results_sorted),
"scans": results_sorted[offset: offset + limit],
"running": len(_scan_queue),
}
@router.get("/scans/{scan_id}")
async def get_scan_result(
scan_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""스캔 결과 상세 조회."""
r = _scan_results.get(scan_id)
if not r:
raise HTTPException(404, f"스캔 {scan_id}를 찾을 수 없습니다.")
if current_user.role == UserRole.ENGINEER and r.get("requester") != current_user.username:
raise HTTPException(403, "본인 스캔만 조회할 수 있습니다.")
return r
@router.post("/quick-check")
async def quick_check(
body: QuickCheckIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""
단일 포트/서비스 빠른 점검.
"""
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "로그인 사용자만 접근 가능합니다.")
from core.vuln_scan import _scan_port, _grab_banner, check_version_vulns, DANGER_PORTS
is_open = _scan_port(body.host, body.port, timeout=1.0)
result = {
"host": body.host,
"port": body.port,
"service": body.service,
"is_open": is_open,
"banner": None,
"vulns": [],
"risk": "UNKNOWN",
"checked_at": datetime.utcnow().isoformat(),
}
if is_open:
banner = _grab_banner(body.host, body.port, timeout=2.0)
result["banner"] = banner
if banner:
result["vulns"] = check_version_vulns(banner, body.service or "")
result["risk"] = "HIGH" if body.port in DANGER_PORTS else "LOW"
return result
@router.get("/cve/{cve_id}")
async def get_cve_info(
cve_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""CVE 상세 정보 조회 (내부 DB)."""
from core.vuln_scan import VULN_VERSION_PATTERNS
cve_upper = cve_id.upper()
matches = [
{
"cve_id": cve,
"service": svc,
"pattern": pat,
"severity": sev,
"description": desc,
}
for svc, pat, cve, sev, desc in VULN_VERSION_PATTERNS
if cve.upper() == cve_upper
]
if not matches:
raise HTTPException(404, f"{cve_id}는 내부 DB에 없습니다.")
return matches[0]
@router.post("/cvss")
async def calculate_cvss(
body: CVSSCalcIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""CVSS v3.1 단순화 점수 계산."""
from core.vuln_scan import calculate_cvss_simplified
score = calculate_cvss_simplified(
body.attack_vector, body.complexity,
body.privileges, body.impact,
)
severity = (
"CRITICAL" if score >= 9.0 else
"HIGH" if score >= 7.0 else
"MEDIUM" if score >= 4.0 else
"LOW" if score > 0.0 else
"NONE"
)
return {
"score": score,
"severity": severity,
"attack_vector": body.attack_vector,
"complexity": body.complexity,
"privileges": body.privileges,
"impact": body.impact,
"note": "단순화된 CVSS v3.1 근사 계산입니다.",
}
@router.get("/stats")
async def vuln_stats(
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""취약점 스캔 통계."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
completed = [r for r in _scan_results.values() if r.get("status") == "COMPLETED"]
total_vulns = sum(len(r.get("vulnerabilities", [])) for r in completed)
total_configs = sum(len(r.get("config_issues", [])) for r in completed)
sev_totals: Dict[str, int] = {}
for r in completed:
for sev, cnt in (r.get("severity_summary") or {}).items():
sev_totals[sev] = sev_totals.get(sev, 0) + cnt
avg_risk = (
sum(r.get("risk_score", 0) for r in completed) / len(completed)
if completed else 0
)
return {
"total_scans": len(_scan_results),
"completed_scans": len(completed),
"running_scans": len(_scan_queue),
"total_vulns": total_vulns,
"total_config_issues": total_configs,
"severity_totals": sev_totals,
"avg_risk_score": round(avg_risk, 1),
}
@router.get("/policies")
async def get_scan_policies(
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""스캔 정책 목록."""
from core.vuln_scan import _DANGER_PATTERNS, DANGER_PORTS
return {
"scan_policies": [
{"name": "스캔 권한", "value": "PM/ADMIN만 가능"},
{"name": "위험 포트", "value": sorted(DANGER_PORTS)},
{"name": "타임아웃 최대", "value": "5초"},
{"name": "외부 DB 조회", "value": "금지 (내부망 전용)"},
{"name": "root 계정 사용", "value": "금지"},
{"name": "LLM 분석", "value": "내부 Ollama sLLM만 허용"},
],
"cve_db_count": len(
__import__("core.vuln_scan", fromlist=["VULN_VERSION_PATTERNS"]).VULN_VERSION_PATTERNS
),
"danger_port_count": len(DANGER_PORTS),
}
# ── G-8: 보안 패치 추적 ──────────────────────────────────────────────────────
class PatchUpdateIn(BaseModel):
cve_id: str
patch_note: Optional[str] = None
patched_at: Optional[str] = None
status: str = "PATCHED" # PATCHED|WONTFIX|MITIGATED
@router.get("/patches")
async def list_patches(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""패치 이력 목록 조회."""
from models import VulnPatchRecord
from sqlalchemy import select, desc
rows = (await db.execute(
select(VulnPatchRecord).order_by(desc(VulnPatchRecord.created_at)).offset(skip).limit(limit)
)).scalars().all()
return [
{
"id": r.id,
"scan_id": r.scan_id,
"cve_id": r.cve_id,
"cvss_score": r.cvss_score,
"severity": r.severity,
"status": r.status,
"patch_note": r.patch_note,
"patched_at": r.patched_at.isoformat() if r.patched_at else None,
"patched_by": r.patched_by,
"created_at": r.created_at.isoformat(),
}
for r in rows
]
@router.post("/scans/{scan_id}/patch", status_code=201)
async def mark_patch(
scan_id: str,
body: PatchUpdateIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""취약점 패치 완료 기록."""
from models import VulnPatchRecord
from sqlalchemy import select
if current_user.role == UserRole.CUSTOMER:
raise HTTPException(403, "패치 기록은 ADMIN/PM/ENGINEER만 가능합니다.")
patched_at = None
if body.patched_at:
try:
patched_at = datetime.fromisoformat(body.patched_at)
except ValueError:
patched_at = datetime.utcnow()
else:
patched_at = datetime.utcnow()
# 기존 레코드 확인
existing = (await db.execute(
select(VulnPatchRecord).where(
VulnPatchRecord.scan_id == scan_id,
VulnPatchRecord.cve_id == body.cve_id,
)
)).scalars().first()
if existing:
existing.status = body.status
existing.patch_note = body.patch_note
existing.patched_at = patched_at
existing.patched_by = current_user.username
existing.updated_at = datetime.utcnow()
else:
existing = VulnPatchRecord(
scan_id = scan_id,
cve_id = body.cve_id,
status = body.status,
patch_note = body.patch_note,
patched_at = patched_at,
patched_by = current_user.username,
)
db.add(existing)
await db.commit()
return {"message": f"CVE {body.cve_id} 패치 상태를 {body.status}로 기록했습니다."}
@router.get("/patch-stats")
async def patch_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""패치 현황 통계."""
from models import VulnPatchRecord
from sqlalchemy import select, func as sqlfunc, case
rows = (await db.execute(select(VulnPatchRecord))).scalars().all()
total = len(rows)
patched = sum(1 for r in rows if r.status == "PATCHED")
open_ = sum(1 for r in rows if r.status == "OPEN")
by_sev = {}
for r in rows:
sev = r.severity or "UNKNOWN"
by_sev.setdefault(sev, {"total": 0, "patched": 0})
by_sev[sev]["total"] += 1
if r.status == "PATCHED":
by_sev[sev]["patched"] += 1
return {
"total_vulns": total,
"patched": patched,
"open": open_,
"patch_rate_pct": round(patched / total * 100, 1) if total else 0.0,
"by_severity": by_sev,
}
@router.get("/overdue-patches")
async def overdue_patches(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""30일 이상 미패치 취약점 목록."""
from models import VulnPatchRecord
from sqlalchemy import select
cutoff = datetime.utcnow().replace(hour=0, minute=0, second=0) - __import__("datetime").timedelta(days=30)
rows = (await db.execute(
select(VulnPatchRecord).where(
VulnPatchRecord.status == "OPEN",
VulnPatchRecord.created_at <= cutoff,
).order_by(VulnPatchRecord.created_at)
)).scalars().all()
return [
{
"id": r.id,
"cve_id": r.cve_id,
"cvss_score": r.cvss_score,
"severity": r.severity,
"created_at": r.created_at.isoformat(),
"overdue_days": (datetime.utcnow() - r.created_at).days,
}
for r in rows
]