"""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() } }