528 lines
20 KiB
Python
528 lines
20 KiB
Python
"""
|
|
자율 패치 관리 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
|