G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
483 lines
16 KiB
Python
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
|
|
]
|