guardia-itsm/routers/sbom.py
2026-06-03 08:48:51 +09:00

151 lines
6.5 KiB
Python

"""SBOM — Software Bill of Materials (CycloneDX/SPDX) 생성·관리"""
from __future__ import annotations
import json, logging, uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, SBOMRecord, SBOMComponent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sbom", tags=["SBOM"])
async def _ssh_scan_packages(server_id: int, db) -> list:
"""에이전트리스 SSH로 서버 패키지 목록 수집."""
# 실제 구현: paramiko SSH 실행
# 여기서는 샘플 데이터 반환
return [
{"name": "python3", "version": "3.11.0", "type": "runtime"},
{"name": "fastapi", "version": "0.100.0", "type": "library", "ecosystem": "pypi"},
{"name": "sqlalchemy", "version": "2.0.0", "type": "library", "ecosystem": "pypi"},
{"name": "nginx", "version": "1.24.0", "type": "system", "ecosystem": "apt"},
{"name": "postgresql", "version": "16.0", "type": "system", "ecosystem": "apt"},
]
def _build_cyclonedx(sbom: SBOMRecord, components: list) -> dict:
return {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": f"urn:uuid:{uuid.uuid4()}",
"version": 1,
"metadata": {
"timestamp": datetime.utcnow().isoformat() + "Z",
"tools": [{"vendor": "GUARDiA", "name": "sbom-scanner", "version": "1.0"}],
"component": {"type": "container", "name": f"server-{sbom.server_id}",
"version": "1.0", "bom-ref": f"server-{sbom.server_id}"}
},
"components": [
{"type": "library", "name": c["name"], "version": c["version"],
"purl": f"pkg:{c.get('ecosystem','generic')}/{c['name']}@{c['version']}"}
for c in components
]
}
class VEXStatement(BaseModel):
sbom_id: int; component_name: str; cve_id: str
status: str # not_affected | affected | fixed | under_investigation
justification: str = ""
@router.post("/generate")
async def generate_sbom(server_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
packages = await _ssh_scan_packages(server_id, db)
record = SBOMRecord(
server_id=server_id, format="CycloneDX", spec_version="1.4",
component_count=len(packages), status="COMPLETED",
generated_by=user.id, created_at=datetime.utcnow()
)
db.add(record); await db.commit(); await db.refresh(record)
for pkg in packages:
db.add(SBOMComponent(
sbom_id=record.id, name=pkg["name"], version=pkg["version"],
component_type=pkg.get("type", "library"), ecosystem=pkg.get("ecosystem", ""),
purl=f"pkg:{pkg.get('ecosystem','generic')}/{pkg['name']}@{pkg['version']}"
))
await db.commit()
return {"sbom_id": record.id, "server_id": server_id, "component_count": len(packages)}
@router.get("/list")
async def list_sboms(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(SBOMRecord).order_by(desc(SBOMRecord.created_at)).limit(50))
return [{"id":s.id,"server_id":s.server_id,"format":s.format,
"component_count":s.component_count,"status":s.status,"created_at":s.created_at}
for s in rows.scalars().all()]
@router.get("/{sbom_id}")
async def get_sbom(sbom_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(SBOMRecord).where(SBOMRecord.id == sbom_id))
sbom = row.scalar_one_or_none()
if not sbom: raise HTTPException(404)
comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id))
comps = comp_rows.scalars().all()
return {
"id": sbom.id, "server_id": sbom.server_id, "format": sbom.format,
"created_at": sbom.created_at,
"components": [{"name":c.name,"version":c.version,"purl":c.purl,"ecosystem":c.ecosystem}
for c in comps]
}
@router.get("/{sbom_id}/export")
async def export_sbom(sbom_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(SBOMRecord).where(SBOMRecord.id == sbom_id))
sbom = row.scalar_one_or_none()
if not sbom: raise HTTPException(404)
comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id))
comps = [{"name":c.name,"version":c.version,"type":c.component_type,"ecosystem":c.ecosystem}
for c in comp_rows.scalars().all()]
cyclonedx = _build_cyclonedx(sbom, comps)
return Response(content=json.dumps(cyclonedx, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=sbom-{sbom_id}.cdx.json"})
@router.post("/{sbom_id}/scan")
async def scan_vulnerabilities(sbom_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""SBOM 컴포넌트 → CVE 매핑 (vuln_scan.py 연동)."""
comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id))
comps = comp_rows.scalars().all()
vuln_count = 0
for c in comps:
# 실제: NVD/KISA CVE DB 조회
if c.name in ("python3", "nginx", "openssl"):
c.cve_ids = '["CVE-2024-SAMPLE"]'
vuln_count += 1
await db.commit()
return {"sbom_id": sbom_id, "scanned": len(comps), "vulnerable": vuln_count}
@router.get("/dashboard")
async def sbom_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
from sqlalchemy import func as sa_func
total = (await db.execute(select(sa_func.count(SBOMRecord.id)))).scalar() or 0
return {"total_sboms": total, "format_breakdown": {"CycloneDX": total}}
@router.post("/vex")
async def create_vex(body: VEXStatement, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
return {
"ok": True, "vex": {
"sbom_id": body.sbom_id, "component": body.component_name,
"cve": body.cve_id, "status": body.status,
"justification": body.justification,
"timestamp": datetime.utcnow().isoformat()
}
}