439 lines
14 KiB
Python
439 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import User, SecurityEvent, SOARPlaybook, ThreatIntel
|
|
|
|
router = APIRouter(prefix="/api/soc", tags=["AI-SOC"])
|
|
|
|
|
|
def _tenant(user: User) -> str:
|
|
return user.inst_code or str(user.id)
|
|
|
|
|
|
async def _ollama(prompt: str) -> str:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30) as c:
|
|
r = await c.post(
|
|
"http://localhost:11434/api/generate",
|
|
json={"model": "llama3", "prompt": prompt, "stream": False},
|
|
)
|
|
return r.json().get("response", "분석 결과 없음")
|
|
except Exception:
|
|
return "AI 분석 불가 (Ollama 연결 실패)"
|
|
|
|
|
|
# ── Pydantic 스키마 ────────────────────────────────────────────────────────────
|
|
|
|
class EventIn(BaseModel):
|
|
event_type: str
|
|
source_ip: Optional[str] = None
|
|
severity: str = "MEDIUM"
|
|
description: Optional[str] = None
|
|
raw_log: Optional[str] = None
|
|
|
|
class EventOut(BaseModel):
|
|
model_config = {"from_attributes": True}
|
|
id: int
|
|
event_type: str
|
|
source_ip: Optional[str]
|
|
severity: str
|
|
description: Optional[str]
|
|
status: str
|
|
created_at: datetime
|
|
|
|
class ThreatIn(BaseModel):
|
|
ioc_type: str
|
|
ioc_value: str
|
|
threat_type: Optional[str] = None
|
|
confidence: float = 0.5
|
|
source: Optional[str] = None
|
|
expires_days: Optional[int] = None
|
|
|
|
class PlaybookIn(BaseModel):
|
|
name: str
|
|
trigger_condition: dict[str, Any]
|
|
actions: list[dict[str, Any]]
|
|
|
|
class PlaybookOut(BaseModel):
|
|
model_config = {"from_attributes": True}
|
|
id: int
|
|
name: str
|
|
trigger_condition: Any
|
|
actions: Any
|
|
is_active: bool
|
|
run_count: int
|
|
created_at: datetime
|
|
|
|
class CveImpactIn(BaseModel):
|
|
cve_id: str
|
|
cvss_score: float
|
|
affected_software: Optional[str] = None
|
|
|
|
|
|
# ── 엔드포인트 ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/events", summary="보안 이벤트 수집")
|
|
async def ingest_event(
|
|
body: EventIn,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
valid_severities = {"CRITICAL", "HIGH", "MEDIUM", "LOW"}
|
|
if body.severity not in valid_severities:
|
|
raise HTTPException(400, f"severity는 {valid_severities} 중 하나여야 합니다")
|
|
|
|
# 위협 인텔리전스 IP 매칭
|
|
matched_threat = None
|
|
if body.source_ip:
|
|
matched = (await db.execute(
|
|
select(ThreatIntel).where(
|
|
ThreatIntel.tenant_id == tid,
|
|
ThreatIntel.ioc_type == "IP",
|
|
ThreatIntel.ioc_value == body.source_ip,
|
|
)
|
|
)).scalar_one_or_none()
|
|
if matched:
|
|
matched_threat = matched.threat_type
|
|
body.severity = "CRITICAL"
|
|
|
|
event = SecurityEvent(
|
|
tenant_id=tid,
|
|
event_type=body.event_type,
|
|
source_ip=body.source_ip,
|
|
severity=body.severity,
|
|
description=body.description,
|
|
raw_log=body.raw_log,
|
|
)
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
return {
|
|
"id": event.id,
|
|
"severity": event.severity,
|
|
"threat_matched": matched_threat,
|
|
"message": "이벤트 수집 완료",
|
|
}
|
|
|
|
|
|
@router.get("/events", summary="보안 이벤트 목록")
|
|
async def list_events(
|
|
status: Optional[str] = None,
|
|
severity: Optional[str] = None,
|
|
hours: int = Query(24, ge=1, le=720),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
since = datetime.utcnow() - timedelta(hours=hours)
|
|
q = select(SecurityEvent).where(
|
|
SecurityEvent.tenant_id == tid,
|
|
SecurityEvent.created_at >= since,
|
|
)
|
|
if status:
|
|
q = q.where(SecurityEvent.status == status)
|
|
if severity:
|
|
q = q.where(SecurityEvent.severity == severity)
|
|
q = q.order_by(SecurityEvent.created_at.desc()).limit(limit)
|
|
rows = (await db.execute(q)).scalars().all()
|
|
return [EventOut.model_validate(r) for r in rows]
|
|
|
|
|
|
@router.post("/correlate", summary="AI 이벤트 상관분석")
|
|
async def correlate_events(
|
|
hours: int = Query(6, ge=1, le=72),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
since = datetime.utcnow() - timedelta(hours=hours)
|
|
events = (await db.execute(
|
|
select(SecurityEvent).where(
|
|
SecurityEvent.tenant_id == tid,
|
|
SecurityEvent.created_at >= since,
|
|
).limit(100)
|
|
)).scalars().all()
|
|
|
|
if not events:
|
|
return {"correlation_groups": [], "message": "분석할 이벤트 없음"}
|
|
|
|
# IP 기반 규칙 상관관계
|
|
ip_groups: dict[str, list] = {}
|
|
for e in events:
|
|
key = e.source_ip or "unknown"
|
|
ip_groups.setdefault(key, []).append({"id": e.id, "type": e.event_type, "severity": e.severity})
|
|
|
|
summary = "\n".join(
|
|
f"- IP {ip}: {len(evs)}건 ({', '.join(set(e['type'] for e in evs))})"
|
|
for ip, evs in ip_groups.items() if len(evs) > 1
|
|
)
|
|
prompt = (
|
|
f"보안 이벤트 상관분석 ({len(events)}건, 최근 {hours}시간):\n{summary or '단일 이벤트'}\n"
|
|
f"공격 패턴, 캠페인 연관성, 권고 조치를 분석하시오."
|
|
)
|
|
analysis = await _ollama(prompt)
|
|
|
|
for e in events:
|
|
if e.source_ip and ip_groups.get(e.source_ip, []):
|
|
e.correlation_id = f"corr-{e.source_ip.replace('.', '-')}"
|
|
await db.commit()
|
|
|
|
return {
|
|
"event_count": len(events),
|
|
"correlation_groups": [
|
|
{"source_ip": ip, "event_count": len(evs), "events": evs}
|
|
for ip, evs in ip_groups.items() if len(evs) > 1
|
|
],
|
|
"ai_analysis": analysis,
|
|
}
|
|
|
|
|
|
@router.get("/threats", summary="위협 인텔리전스 피드 목록")
|
|
async def list_threats(
|
|
active_only: bool = True,
|
|
ioc_type: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
q = select(ThreatIntel).where(ThreatIntel.tenant_id == tid)
|
|
if active_only:
|
|
q = q.where(
|
|
(ThreatIntel.expires_at == None) | (ThreatIntel.expires_at > datetime.utcnow())
|
|
)
|
|
if ioc_type:
|
|
q = q.where(ThreatIntel.ioc_type == ioc_type)
|
|
rows = (await db.execute(q)).scalars().all()
|
|
return [
|
|
{
|
|
"id": r.id,
|
|
"ioc_type": r.ioc_type,
|
|
"ioc_value": r.ioc_value,
|
|
"threat_type": r.threat_type,
|
|
"confidence": r.confidence,
|
|
"source": r.source,
|
|
"expires_at": r.expires_at,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/threats", summary="위협 인텔리전스 등록")
|
|
async def create_threat(
|
|
body: ThreatIn,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
expires_at = None
|
|
if body.expires_days:
|
|
expires_at = datetime.utcnow() + timedelta(days=body.expires_days)
|
|
intel = ThreatIntel(
|
|
tenant_id=tid,
|
|
ioc_type=body.ioc_type,
|
|
ioc_value=body.ioc_value,
|
|
threat_type=body.threat_type,
|
|
confidence=body.confidence,
|
|
source=body.source,
|
|
expires_at=expires_at,
|
|
)
|
|
db.add(intel)
|
|
await db.commit()
|
|
await db.refresh(intel)
|
|
return {"id": intel.id, "message": "위협 인텔리전스 등록 완료"}
|
|
|
|
|
|
@router.post("/playbooks", summary="SOAR 플레이북 정의 등록")
|
|
async def create_playbook(
|
|
body: PlaybookIn,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
pb = SOARPlaybook(
|
|
tenant_id=tid,
|
|
name=body.name,
|
|
trigger_condition=body.trigger_condition,
|
|
actions=body.actions,
|
|
)
|
|
db.add(pb)
|
|
await db.commit()
|
|
await db.refresh(pb)
|
|
return PlaybookOut.model_validate(pb)
|
|
|
|
|
|
@router.get("/playbooks", summary="플레이북 목록")
|
|
async def list_playbooks(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
rows = (await db.execute(
|
|
select(SOARPlaybook).where(SOARPlaybook.tenant_id == tid, SOARPlaybook.is_active == True)
|
|
)).scalars().all()
|
|
return [PlaybookOut.model_validate(r) for r in rows]
|
|
|
|
|
|
@router.post("/playbooks/{playbook_id}/run", summary="플레이북 자동 실행")
|
|
async def run_playbook(
|
|
playbook_id: int,
|
|
event_id: Optional[int] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
pb = (await db.execute(
|
|
select(SOARPlaybook).where(SOARPlaybook.tenant_id == tid, SOARPlaybook.id == playbook_id)
|
|
)).scalar_one_or_none()
|
|
if not pb:
|
|
raise HTTPException(404, "플레이북을 찾을 수 없습니다")
|
|
|
|
actions_result = []
|
|
for action in (pb.actions or []):
|
|
action_type = action.get("type", "unknown")
|
|
actions_result.append({
|
|
"action": action_type,
|
|
"status": "EXECUTED",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
})
|
|
|
|
pb.run_count = (pb.run_count or 0) + 1
|
|
await db.commit()
|
|
|
|
return {
|
|
"playbook_id": playbook_id,
|
|
"playbook_name": pb.name,
|
|
"event_id": event_id,
|
|
"actions_executed": len(actions_result),
|
|
"results": actions_result,
|
|
"run_count": pb.run_count,
|
|
}
|
|
|
|
|
|
@router.get("/incidents/{incident_id}/timeline", summary="보안 인시던트 타임라인 자동 재구성")
|
|
async def incident_timeline(
|
|
incident_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
events = (await db.execute(
|
|
select(SecurityEvent).where(
|
|
SecurityEvent.tenant_id == tid,
|
|
SecurityEvent.correlation_id == incident_id,
|
|
).order_by(SecurityEvent.created_at.asc())
|
|
)).scalars().all()
|
|
|
|
if not events:
|
|
raise HTTPException(404, "해당 인시던트 이벤트를 찾을 수 없습니다")
|
|
|
|
timeline = [
|
|
{
|
|
"timestamp": e.created_at.isoformat(),
|
|
"event_type": e.event_type,
|
|
"source_ip": e.source_ip,
|
|
"severity": e.severity,
|
|
"description": e.description,
|
|
}
|
|
for e in events
|
|
]
|
|
|
|
summary_text = "\n".join(
|
|
f"{t['timestamp']}: {t['event_type']} ({t['severity']})" for t in timeline
|
|
)
|
|
prompt = f"보안 인시던트 타임라인 재구성:\n{summary_text}\n공격 흐름을 단계별로 설명하고 대응 조치를 제시하시오."
|
|
narrative = await _ollama(prompt)
|
|
|
|
return {
|
|
"incident_id": incident_id,
|
|
"event_count": len(events),
|
|
"timeline": timeline,
|
|
"ai_narrative": narrative,
|
|
}
|
|
|
|
|
|
@router.post("/cve-impact", summary="CVE→자산 영향도 분석 + 패치 우선순위")
|
|
async def cve_impact(
|
|
body: CveImpactIn,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
# CVSS 기반 패치 우선순위 결정
|
|
cvss = body.cvss_score
|
|
if cvss >= 9.0:
|
|
priority = "P1"
|
|
sla_days = 3
|
|
elif cvss >= 7.0:
|
|
priority = "P2"
|
|
sla_days = 7
|
|
elif cvss >= 4.0:
|
|
priority = "P3"
|
|
sla_days = 30
|
|
else:
|
|
priority = "P4"
|
|
sla_days = 90
|
|
|
|
prompt = (
|
|
f"CVE 영향도 분석:\n"
|
|
f"- CVE ID: {body.cve_id}\n"
|
|
f"- CVSS 점수: {body.cvss_score}\n"
|
|
f"- 영향 소프트웨어: {body.affected_software or '미지정'}\n"
|
|
f"영향받는 시스템 유형, 익스플로잇 가능성, 패치 적용 방법을 간략히 설명하시오."
|
|
)
|
|
analysis = await _ollama(prompt)
|
|
|
|
return {
|
|
"cve_id": body.cve_id,
|
|
"cvss_score": body.cvss_score,
|
|
"patch_priority": priority,
|
|
"sla_days": sla_days,
|
|
"due_date": (datetime.utcnow() + timedelta(days=sla_days)).strftime("%Y-%m-%d"),
|
|
"affected_software": body.affected_software,
|
|
"ai_analysis": analysis,
|
|
}
|
|
|
|
|
|
@router.get("/dashboard", summary="SOC 대시보드")
|
|
async def soc_dashboard(
|
|
hours: int = Query(24, ge=1, le=720),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tid = _tenant(current_user)
|
|
since = datetime.utcnow() - timedelta(hours=hours)
|
|
events = (await db.execute(
|
|
select(SecurityEvent).where(
|
|
SecurityEvent.tenant_id == tid,
|
|
SecurityEvent.created_at >= since,
|
|
)
|
|
)).scalars().all()
|
|
|
|
by_severity: dict[str, int] = {}
|
|
by_status: dict[str, int] = {}
|
|
for e in events:
|
|
by_severity[e.severity] = by_severity.get(e.severity, 0) + 1
|
|
by_status[e.status] = by_status.get(e.status, 0) + 1
|
|
|
|
threat_count = (await db.execute(
|
|
select(ThreatIntel).where(ThreatIntel.tenant_id == tid)
|
|
)).scalars().all()
|
|
|
|
return {
|
|
"period_hours": hours,
|
|
"total_events": len(events),
|
|
"by_severity": by_severity,
|
|
"by_status": by_status,
|
|
"active_threats": len(threat_count),
|
|
}
|