""" 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 ]