guardia-itsm/routers/compliance.py
2026-05-30 23:02:43 +09:00

230 lines
9.8 KiB
Python

"""
준수성 자동 점검 API (시큐어코딩 / 웹 접근성 / 개인정보보호법)
엔드포인트:
POST /api/compliance/scan — 전체 프로젝트 스캔 (ADMIN 전용)
GET /api/compliance/results — 최근 스캔 결과 조회
GET /api/compliance/rules — 점검 규칙 목록
POST /api/compliance/scan/file — 파일 텍스트 단건 점검
GET /api/compliance/report/html — HTML 점검 보고서
GET /api/compliance/report/excel — Excel 점검 보고서
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/compliance", tags=["compliance"])
# 최근 스캔 결과 캐시 (메모리)
_last_result: dict = {}
class FileScanRequest(BaseModel):
filename: str
content: str
# ── 전체 스캔 ─────────────────────────────────────────────────────────────────
@router.post("/scan")
async def run_scan(
current_user: User = Depends(require_admin_role),
):
"""GUARDiA 소스 전체 준수성 점검 (ADMIN 전용)."""
global _last_result
from core.compliance_check import scan_project
try:
result = await scan_project()
_last_result = result
return {
"message": f"점검 완료 — {result['scanned_files']}개 파일, {result['total_findings']}건 발견",
"risk_level": result["risk_level"],
"total_findings": result["total_findings"],
"by_severity": result["by_severity"],
"summary": result["summary"],
}
except Exception as e:
raise HTTPException(500, f"점검 오류: {str(e)[:200]}")
# ── 결과 조회 ─────────────────────────────────────────────────────────────────
@router.get("/results")
async def get_results(
category: Optional[str] = None,
severity: Optional[str] = None,
limit: int = 50,
current_user: User = Depends(get_current_user),
):
"""최근 스캔 결과 조회."""
if not _last_result:
return {"message": "스캔 결과 없음 — POST /api/compliance/scan 실행 필요", "findings": []}
findings = _last_result.get("findings", [])
if category:
findings = [f for f in findings if category.lower() in f["category"].lower()]
if severity:
findings = [f for f in findings if f["severity"].upper() == severity.upper()]
return {
"scan_time": _last_result.get("scan_time"),
"risk_level": _last_result.get("risk_level"),
"total_findings": _last_result.get("total_findings", 0),
"by_severity": _last_result.get("by_severity", {}),
"by_category": _last_result.get("by_category", {}),
"summary": _last_result.get("summary", {}),
"findings": findings[:limit],
}
# ── 규칙 목록 ─────────────────────────────────────────────────────────────────
@router.get("/rules")
async def list_rules(
_u: User = Depends(get_current_user),
):
"""점검 규칙 목록 (패턴 제외)."""
from core.compliance_check import SECURE_CODING_RULES, ACCESSIBILITY_RULES, PIPA_RULES
return {
"secure_coding": [{"id": r["id"], "category": r["category"], "severity": r["severity"], "message": r["message"]} for r in SECURE_CODING_RULES],
"accessibility": [{"id": r["id"], "category": r["category"], "level": r["level"], "message": r["message"]} for r in ACCESSIBILITY_RULES],
"privacy": [{"id": r["id"], "category": r["category"], "severity": r["severity"], "message": r["message"]} for r in PIPA_RULES],
}
# ── 단건 파일 점검 ────────────────────────────────────────────────────────────
@router.post("/scan/file")
async def scan_single_file(
body: FileScanRequest,
_u: User = Depends(get_current_user),
):
"""파일 텍스트 단건 점검 (개발 중 빠른 검토용)."""
from core.compliance_check import scan_file
findings = scan_file(body.filename, body.content)
return {
"filename": body.filename,
"findings": findings,
"total": len(findings),
"by_severity": {
sev: sum(1 for f in findings if f["severity"] == sev)
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
if any(f["severity"] == sev for f in findings)
}
}
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
@router.get("/report/html", response_class=HTMLResponse)
async def compliance_html_report(
_u: User = Depends(get_current_user),
):
"""준수성 점검 HTML 보고서."""
if not _last_result:
return HTMLResponse("<h2>스캔 결과 없음</h2><p>POST /api/compliance/scan 을 먼저 실행하세요.</p>")
findings = _last_result.get("findings", [])
sev_color = {"CRITICAL": "#fed7d7", "HIGH": "#feebc8", "MEDIUM": "#fefcbf", "LOW": "#e6fffa"}
def _row(f):
bg = sev_color.get(f["severity"], "#fff")
return (
f"<tr style='background:{bg}'>"
f"<td>{f['rule_id']}</td><td>{f['category']}</td>"
f"<td><b>{f['severity']}</b></td>"
f"<td>{f['file']}:{f['line']}</td>"
f"<td style='font-size:12px'>{f['message']}</td>"
f"<td><code style='font-size:11px'>{f.get('snippet','')[:60]}</code></td></tr>"
)
rows = "".join(_row(f) for f in findings[:100])
risk = _last_result.get("risk_level", "?")
risk_badge_color = {"CRITICAL": "#c53030", "HIGH": "#c05621", "MEDIUM": "#744210", "LOW": "#276749"}.get(risk, "#666")
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
<title>GUARDiA 준수성 점검 보고서</title>
<style>
body{{font-family:Arial,sans-serif;margin:30px;color:#333;font-size:13px}}
h1{{color:#1a365d}} h2{{color:#2c5282;margin-top:20px}}
table{{border-collapse:collapse;width:100%}} th{{background:#2d3748;color:#fff;padding:8px;text-align:left}}
td{{padding:6px 8px;border-bottom:1px solid #e2e8f0}}
.badge{{padding:3px 10px;border-radius:12px;font-size:11px;font-weight:bold;color:white;background:{risk_badge_color}}}
</style></head><body>
<h1>GUARDiA 준수성 점검 보고서</h1>
<p>점검일시: {_last_result.get('scan_time','')} | 스캔 파일: {_last_result.get('scanned_files',0)}개</p>
<p>종합 위험도: <span class="badge">{risk}</span>
| 총 발견: {_last_result.get('total_findings',0)}
| 시큐어코딩: {_last_result.get('summary',{}).get('secure_coding',0)}
| 웹접근성: {_last_result.get('summary',{}).get('accessibility',0)}
| 개인정보: {_last_result.get('summary',{}).get('privacy',0)}
</p>
<h2>점검 결과 (상위 100건)</h2>
<table>
<tr><th>규칙ID</th><th>분류</th><th>심각도</th><th>위치</th><th>내용</th><th>코드</th></tr>
{rows}
</table>
<p style="margin-top:24px;color:#718096;font-size:11px">
Copyright &copy; 2026 GUARDiA All Rights Reserved. | 이 보고서는 자동 생성되었습니다.
</p></body></html>"""
return HTMLResponse(html)
# ── Excel 보고서 ─────────────────────────────────────────────────────────────
@router.get("/report/excel")
async def compliance_excel_report(
_u: User = Depends(get_current_user),
):
"""준수성 점검 Excel 보고서."""
if not _last_result:
raise HTTPException(404, "스캔 결과 없음 — POST /api/compliance/scan 먼저 실행")
try:
import io, openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
except ImportError:
raise HTTPException(500, "openpyxl 미설치")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "준수성 점검 결과"
headers = ["규칙 ID", "분류", "심각도", "파일", "라인", "내용", "코드 스니펫"]
for col, h in enumerate(headers, 1):
c = ws.cell(row=1, column=col, value=h)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor="1a365d")
sev_colors = {"CRITICAL": "FED7D7", "HIGH": "FEEBC8", "MEDIUM": "FEFCBF", "LOW": "E6FFFA"}
for row, f in enumerate(_last_result.get("findings", []), 2):
color = sev_colors.get(f["severity"], "FFFFFF")
vals = [f["rule_id"], f["category"], f["severity"], f["file"], f["line"], f["message"], f.get("snippet", "")]
for col, val in enumerate(vals, 1):
c = ws.cell(row=row, column=col, value=val)
c.fill = PatternFill("solid", fgColor=color)
for col_idx, width in enumerate([10, 18, 12, 40, 7, 50, 40], 1):
ws.column_dimensions[ws.cell(1, col_idx).column_letter].width = width
buf = io.BytesIO()
wb.save(buf)
today = datetime.utcnow().strftime("%Y%m%d")
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="GUARDiA_compliance_{today}.xlsx"'},
)