""" 자율 패치 관리 API 라우터 엔드포인트: GET /api/patch/pending — 패치 대기 목록 (pending|approved 상태) POST /api/patch/scan — CVE 스캔 + 패치 계획 자동 생성 GET /api/patch/plans — 전체 패치 계획 목록 POST /api/patch/plans/{id}/approve — 패치 승인 (admin 전용) POST /api/patch/plans/{id}/execute — 패치 실행 (SSH, 승인 후만 가능) POST /api/patch/plans/{id}/rollback — 패치 롤백 GET /api/patch/history — 패치 이력 (done|failed|rolled_back) 원칙: - 반드시 approved 상태에서만 실행 가능 - paramiko SSH 실행 - 실패 시 자동 롤백 시도 - 서버 IP/자격증명 절대 응답에 노출 금지 """ from __future__ import annotations import asyncio import json import logging import re from datetime import datetime, timezone from typing import Dict, List, Optional, Any from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import select, or_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role as require_admin from database import get_db, SessionLocal from models import PatchPlan, Server, User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/patch", tags=["patch_management"]) # ── 위험 명령어 패턴 (보안 불변 규칙) ───────────────────────────────────────── _DANGEROUS_PATTERN = re.compile( r"rm\s+-rf\s+/|mkfs|dd\s+if=|shutdown|reboot|halt|poweroff|" r":(){ :|:& };:|chmod\s+777\s+/|wget\s+.*\|\s*sh|curl\s+.*\|\s*bash", re.IGNORECASE, ) def _validate_cmd(cmd: str) -> None: """SSH 실행 전 위험 패턴 차단.""" if _DANGEROUS_PATTERN.search(cmd): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="위험한 명령어 패턴이 감지되었습니다.", ) # ── Pydantic 스키마 ────────────────────────────────────────────────────────── class PatchScanIn(BaseModel): server_ids: List[int] = Field(..., description="스캔 대상 서버 ID 목록") cve_ids: Optional[List[str]] = Field(None, description="특정 CVE ID 목록 (없으면 전체 스캔)") auto_plan: bool = Field(True, description="패치 계획 자동 생성 여부") class PatchPlanOut(BaseModel): id: int cve_id: Optional[str] severity: str affected_servers: Optional[str] # JSON patch_cmd: Optional[str] rollback_cmd: Optional[str] status: str approved_by: Optional[str] approved_at: Optional[datetime] executed_at: Optional[datetime] executed_by: Optional[str] result_log: Optional[str] created_by: Optional[str] created_at: datetime updated_at: datetime class Config: from_attributes = True class PatchApproveIn(BaseModel): note: Optional[str] = None class PatchExecuteIn(BaseModel): confirm: bool = Field(..., description="실행 확인 플래그 — True 필수") # ── SSH 실행 유틸리티 ────────────────────────────────────────────────────────── async def _ssh_execute(server: Server, cmd: str) -> Dict[str, Any]: """ paramiko를 사용하여 SSH 명령을 실행한다. 서버 자격증명은 응답에 절대 포함하지 않는다. """ try: import paramiko from cryptography.hazmat.primitives.ciphers.aead import AESGCM import base64, os # AES-256-GCM 복호화 enc_key = os.environ.get("GUARDIA_ENC_KEY", "guardia-default-enc-key-32bytes!!").encode() enc_key = enc_key[:32].ljust(32, b"0") password = None if server.os_pw_enc: try: raw = base64.b64decode(server.os_pw_enc) nonce, ct = raw[:12], raw[12:] aesgcm = AESGCM(enc_key) password = aesgcm.decrypt(nonce, ct, None).decode() except Exception: password = None loop = asyncio.get_event_loop() def _run_sync(): client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) connect_kwargs: Dict[str, Any] = { "hostname": server.ip_addr, "port": server.port or 22, "username": server.ssh_user, "timeout": 30, } if server.ssh_method == "KEY" and server.ssh_key_path: connect_kwargs["key_filename"] = server.ssh_key_path elif password: connect_kwargs["password"] = password client.connect(**connect_kwargs) try: _, stdout, stderr = client.exec_command(cmd, timeout=120) out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") rc = stdout.channel.recv_exit_status() return {"stdout": out[:2000], "stderr": err[:500], "rc": rc} finally: client.close() result = await loop.run_in_executor(None, _run_sync) return result except ImportError: # paramiko 미설치 환경 — 시뮬레이션 logger.warning("paramiko 미설치: SSH 시뮬레이션 모드") await asyncio.sleep(0.5) return {"stdout": "[SIMULATED] 패치 명령 실행 완료", "stderr": "", "rc": 0} except Exception as e: logger.error("SSH 실행 오류 (server_id=%s): %s", server.id, str(e)[:100]) return {"stdout": "", "stderr": str(e)[:200], "rc": 1} # ── 백그라운드 패치 실행기 ───────────────────────────────────────────────────── async def _execute_patch_bg(plan_id: int, executor: str): """백그라운드에서 패치 계획을 실행한다.""" async with SessionLocal() as db: plan = await db.get(PatchPlan, plan_id) if not plan or plan.status != "approved": return plan.status = "executing" plan.executed_at = datetime.now(timezone.utc) plan.executed_by = executor await db.commit() await db.refresh(plan) try: server_ids = json.loads(plan.affected_servers or "[]") results = [] all_success = True for sid in server_ids: server = await db.get(Server, sid) if not server: results.append({"server_id": sid, "status": "not_found"}) all_success = False continue _validate_cmd(plan.patch_cmd or "") res = await _ssh_execute(server, plan.patch_cmd) success = res["rc"] == 0 results.append({ "server_id": sid, "server_name": server.server_name, "status": "success" if success else "failed", "rc": res["rc"], "stdout": res["stdout"][:500], "stderr": res["stderr"][:200], }) if not success: all_success = False plan.result_log = json.dumps(results, ensure_ascii=False) if all_success: plan.status = "done" logger.info("패치 완료: plan_id=%d", plan_id) else: # 실패 시 자동 롤백 logger.warning("패치 실패 — 자동 롤백 시작: plan_id=%d", plan_id) plan.status = "rolling_back" await db.commit() if plan.rollback_cmd: rollback_results = [] for sid in server_ids: server = await db.get(Server, sid) if not server: continue try: _validate_cmd(plan.rollback_cmd) rb_res = await _ssh_execute(server, plan.rollback_cmd) rollback_results.append({ "server_id": sid, "server_name": server.server_name, "rollback_rc": rb_res["rc"], }) except Exception as ex: rollback_results.append({ "server_id": sid, "rollback_error": str(ex)[:100], }) # 롤백 결과 병합 existing = json.loads(plan.result_log or "[]") plan.result_log = json.dumps( {"patch": existing, "rollback": rollback_results}, ensure_ascii=False, ) plan.status = "rolled_back" logger.info("자동 롤백 완료: plan_id=%d", plan_id) await db.commit() except Exception as e: logger.error("패치 실행 오류: plan_id=%d — %s", plan_id, str(e)[:100]) plan.status = "failed" plan.result_log = json.dumps({"error": str(e)[:200]}, ensure_ascii=False) await db.commit() # ── 엔드포인트 ──────────────────────────────────────────────────────────────── @router.get("/pending", response_model=List[PatchPlanOut]) async def get_pending_patches( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """패치 대기 목록 — pending 또는 approved 상태.""" result = await db.execute( select(PatchPlan) .where(or_(PatchPlan.status == "pending", PatchPlan.status == "approved")) .order_by(PatchPlan.created_at.desc()) ) return result.scalars().all() @router.post("/scan", status_code=status.HTTP_201_CREATED) async def scan_and_create_plans( body: PatchScanIn, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """CVE 스캔 후 패치 계획 자동 생성. Ollama를 활용해 패치 명령어를 추천한다.""" if not body.server_ids: raise HTTPException(status_code=400, detail="server_ids가 비어 있습니다.") # 대상 서버 검증 servers_found = [] for sid in body.server_ids: srv = await db.get(Server, sid) if srv: servers_found.append(srv) if not servers_found: raise HTTPException(status_code=404, detail="유효한 서버를 찾을 수 없습니다.") created_plans = [] cve_list = body.cve_ids or ["CVE-SCAN-AUTO"] for cve_id in cve_list: # Ollama로 패치 명령어 생성 시도 patch_cmd, rollback_cmd = await _generate_patch_commands(cve_id, servers_found) severity = _estimate_severity(cve_id) plan = PatchPlan( cve_id=cve_id, severity=severity, affected_servers=json.dumps([s.id for s in servers_found]), patch_cmd=patch_cmd, rollback_cmd=rollback_cmd, status="pending", created_by=current_user.username, ) db.add(plan) created_plans.append(cve_id) await db.commit() return { "message": f"{len(created_plans)}개 패치 계획이 생성되었습니다.", "plans_created": len(created_plans), "cve_ids": created_plans, "server_count": len(servers_found), "note": "패치 실행 전 반드시 관리자 승인이 필요합니다.", } @router.get("/plans", response_model=List[PatchPlanOut]) async def list_patch_plans( status_filter: Optional[str] = Query(None, alias="status"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """전체 패치 계획 목록.""" q = select(PatchPlan).order_by(PatchPlan.created_at.desc()).limit(limit).offset(offset) if status_filter: q = q.where(PatchPlan.status == status_filter) result = await db.execute(q) return result.scalars().all() @router.post("/plans/{plan_id}/approve") async def approve_patch_plan( plan_id: int, body: PatchApproveIn, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_admin), ): """패치 승인 — admin 전용. 승인 후에만 execute 가능.""" plan = await db.get(PatchPlan, plan_id) if not plan: raise HTTPException(status_code=404, detail=f"패치 계획 {plan_id}를 찾을 수 없습니다.") if plan.status != "pending": raise HTTPException( status_code=400, detail=f"pending 상태에서만 승인 가능합니다. 현재: {plan.status}", ) plan.status = "approved" plan.approved_by = current_user.username plan.approved_at = datetime.now(timezone.utc) await db.commit() return { "message": "패치 계획이 승인되었습니다.", "plan_id": plan_id, "approved_by": current_user.username, "note": body.note, } @router.post("/plans/{plan_id}/execute") async def execute_patch_plan( plan_id: int, body: PatchExecuteIn, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """패치 실행 — approved 상태에서만 가능. 백그라운드 SSH 실행.""" if not body.confirm: raise HTTPException(status_code=400, detail="confirm=true 확인이 필요합니다.") plan = await db.get(PatchPlan, plan_id) if not plan: raise HTTPException(status_code=404, detail=f"패치 계획 {plan_id}를 찾을 수 없습니다.") if plan.status != "approved": raise HTTPException( status_code=400, detail=f"approved 상태에서만 실행 가능합니다. 현재: {plan.status}", ) if not plan.patch_cmd: raise HTTPException(status_code=400, detail="patch_cmd가 없습니다.") _validate_cmd(plan.patch_cmd) background_tasks.add_task(_execute_patch_bg, plan_id, current_user.username) return { "message": "패치 실행이 시작되었습니다.", "plan_id": plan_id, "status": "executing", "note": "실패 시 자동 롤백이 시도됩니다. /api/patch/plans?status=done 으로 결과를 확인하세요.", } @router.post("/plans/{plan_id}/rollback") async def rollback_patch_plan( plan_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_admin), ): """수동 롤백 — admin 전용. done|failed 상태에서 수동 롤백.""" plan = await db.get(PatchPlan, plan_id) if not plan: raise HTTPException(status_code=404, detail=f"패치 계획 {plan_id}를 찾을 수 없습니다.") if plan.status not in ("done", "failed"): raise HTTPException( status_code=400, detail=f"done 또는 failed 상태에서만 수동 롤백 가능합니다. 현재: {plan.status}", ) if not plan.rollback_cmd: raise HTTPException(status_code=400, detail="rollback_cmd가 없습니다.") _validate_cmd(plan.rollback_cmd) plan.status = "approved" # 롤백을 위해 임시 approved로 전환 await db.commit() # 롤백 전용 실행 (rollback_cmd를 patch_cmd로 치환하여 재실행) async def _do_rollback(pid: int, user: str): async with SessionLocal() as _db: p = await _db.get(PatchPlan, pid) if not p: return # patch_cmd와 rollback_cmd를 교환하여 재실행 original_patch = p.patch_cmd p.patch_cmd = p.rollback_cmd p.rollback_cmd = original_patch await _db.commit() await _execute_patch_bg(pid, user) background_tasks.add_task(_do_rollback, plan_id, current_user.username) return { "message": "수동 롤백이 시작되었습니다.", "plan_id": plan_id, } @router.get("/history", response_model=List[PatchPlanOut]) async def get_patch_history( limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """패치 이력 — done|failed|rolled_back 상태.""" result = await db.execute( select(PatchPlan) .where(PatchPlan.status.in_(["done", "failed", "rolled_back"])) .order_by(PatchPlan.executed_at.desc()) .limit(limit) .offset(offset) ) return result.scalars().all() # ── 헬퍼 함수 ───────────────────────────────────────────────────────────────── def _estimate_severity(cve_id: str) -> str: """CVE ID 접미사 패턴으로 심각도를 추정 (실제 NVD 조회 없이 휴리스틱).""" cve_upper = cve_id.upper() if any(k in cve_upper for k in ["CRITICAL", "CRIT"]): return "CRITICAL" if any(k in cve_upper for k in ["HIGH"]): return "HIGH" if any(k in cve_upper for k in ["LOW"]): return "LOW" return "MEDIUM" async def _generate_patch_commands(cve_id: str, servers: List[Server]): """ Ollama를 통해 CVE에 적합한 패치 명령어를 생성한다. Ollama 불가 시 OS별 기본 패키지 업데이트 명령을 반환한다. """ # 대표 서버 OS 타입 결정 os_types = list({s.os_type for s in servers if s.os_type}) os_hint = os_types[0] if os_types else "linux" # 기본 패치 명령어 (OS별) os_lower = os_hint.lower() if "ubuntu" in os_lower or "debian" in os_lower: patch_cmd = f"apt-get update && apt-get upgrade -y --no-install-recommends" rollback_cmd = "apt-get autoremove -y" elif "centos" in os_lower or "rhel" in os_lower or "rocky" in os_lower: patch_cmd = f"yum update -y" rollback_cmd = "yum history undo last -y" else: patch_cmd = f"yum update -y || apt-get upgrade -y" rollback_cmd = "echo 'manual rollback required'" # Ollama로 더 정밀한 명령어 생성 시도 try: import httpx prompt = ( f"CVE ID: {cve_id}, OS: {os_hint}\n" f"리눅스 서버에서 이 CVE를 패치하는 단일 쉘 명령어와 롤백 명령어를 " f"JSON 형식으로 반환하세요: " f'{{\"patch\": \"명령어\", \"rollback\": \"롤백명령어\"}} ' f"위험한 명령어(rm -rf /, mkfs 등)는 절대 포함하지 마세요." ) async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( "http://localhost:11434/api/generate", json={"model": "llama3", "prompt": prompt, "stream": False}, ) if resp.status_code == 200: text = resp.json().get("response", "") # JSON 파싱 시도 import re as _re m = _re.search(r'\{[^{}]+\}', text) if m: data = json.loads(m.group()) candidate_patch = data.get("patch", "") candidate_rollback = data.get("rollback", "") if candidate_patch and not _DANGEROUS_PATTERN.search(candidate_patch): patch_cmd = candidate_patch if candidate_rollback and not _DANGEROUS_PATTERN.search(candidate_rollback): rollback_cmd = candidate_rollback except Exception: # Ollama 불가 — 기본값 사용 pass return patch_cmd, rollback_cmd