guardia-itsm/routers/policy_engine.py
2026-06-04 08:13:41 +09:00

574 lines
21 KiB
Python

"""
정책 엔진 API — 공공기관 IT 표준 정책 평가·위반 관리
엔드포인트:
GET /api/policy/rules — 정책 규칙 목록
POST /api/policy/rules — 규칙 생성
PUT /api/policy/rules/{id} — 규칙 수정
POST /api/policy/evaluate — 정책 평가 실행
GET /api/policy/violations — 위반 목록
POST /api/policy/violations/{id}/remediate — 위반 교정
GET /api/policy/templates — 공공기관 표준 템플릿
GET /api/policy/dashboard — 준수 현황 대시보드
공공기관 IT 표준 정책 5개 시드:
1. SSH root 직접 접속 금지
2. 비밀번호 90일 주기 변경
3. 미사용 계정 정리 (90일 미접속)
4. 보안 패치 30일 내 적용
5. 데이터 백업 7일 주기 검증
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import SessionLocal, get_db
from models import PolicyRule, PolicyViolation, User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/policy", tags=["정책 엔진"])
# ── 공공기관 IT 표준 정책 시드 ────────────────────────────────────────────────────
_DEFAULT_POLICIES = [
{
"name": "SSH root 직접 접속 금지",
"category": "security",
"condition": json.dumps({
"type": "ssh_config_check",
"file": "/etc/ssh/sshd_config",
"key": "PermitRootLogin",
"expected": "no",
"description": "SSH 데몬 설정에서 PermitRootLogin이 no여야 합니다",
}, ensure_ascii=False),
"severity": "CRITICAL",
"auto_remediate": False,
"active": True,
},
{
"name": "비밀번호 90일 주기 변경",
"category": "access",
"condition": json.dumps({
"type": "password_policy_check",
"file": "/etc/login.defs",
"key": "PASS_MAX_DAYS",
"max_value": 90,
"description": "최대 비밀번호 유효 기간이 90일을 초과하면 안 됩니다",
}, ensure_ascii=False),
"severity": "HIGH",
"auto_remediate": False,
"active": True,
},
{
"name": "미사용 계정 정리 (90일 미접속)",
"category": "access",
"condition": json.dumps({
"type": "inactive_account_check",
"threshold_days": 90,
"description": "90일 이상 미접속 계정은 비활성화하거나 삭제해야 합니다",
"cmd": "lastlog -b 90 | grep -v 'Never logged' | tail -n +2",
}, ensure_ascii=False),
"severity": "HIGH",
"auto_remediate": False,
"active": True,
},
{
"name": "보안 패치 30일 내 적용",
"category": "patch",
"condition": json.dumps({
"type": "patch_recency_check",
"max_days": 30,
"description": "보안 패치는 공개 후 30일 이내에 적용해야 합니다",
"cmd": "yum check-update --security 2>/dev/null | grep -c '^' || apt-get --just-print upgrade 2>/dev/null | grep -c 'security'",
}, ensure_ascii=False),
"severity": "HIGH",
"auto_remediate": False,
"active": True,
},
{
"name": "데이터 백업 7일 주기 검증",
"category": "backup",
"condition": json.dumps({
"type": "backup_verification_check",
"max_days": 7,
"description": "데이터 백업은 7일 이내에 검증·완료되어야 합니다",
"backup_path": "/backup",
"cmd": "find /backup -name '*.tar.gz' -mtime -7 | wc -l",
}, ensure_ascii=False),
"severity": "MEDIUM",
"auto_remediate": False,
"active": True,
},
]
# 공공기관 표준 정책 템플릿 목록 (GET /api/policy/templates 응답용)
_POLICY_TEMPLATES = [
{
"template_id": "T-SEC-001",
"name": "SSH 보안 강화",
"category": "security",
"severity": "CRITICAL",
"description": "국가정보원 사이버안전센터 SSH 보안 가이드라인 준수",
"reference": "NIST SP 800-123 / 국정원 보안취약점 점검 기준",
"conditions": [
"PermitRootLogin no",
"PasswordAuthentication no (키 기반 인증 권장)",
"AllowUsers 명시적 허용",
"Protocol 2 강제",
],
},
{
"template_id": "T-ACC-001",
"name": "계정 및 패스워드 관리",
"category": "access",
"severity": "HIGH",
"description": "행정안전부 전자정부 SW 개발·운영자를 위한 소프트웨어 개발보안 가이드",
"reference": "행안부 정보보호 관리체계 인증기준 (ISMS-P)",
"conditions": [
"비밀번호 최소 8자리 이상, 복잡도 요구",
"최대 유효기간 90일",
"미사용 계정 30일 이후 잠금, 90일 이후 삭제",
"동일 비밀번호 재사용 5회 제한",
],
},
{
"template_id": "T-PAT-001",
"name": "취약점 패치 관리",
"category": "patch",
"severity": "HIGH",
"description": "CSAP (클라우드 서비스 보안인증제) 보안 패치 관리 기준",
"reference": "과기정통부 CSAP SaaS 보안인증 기준",
"conditions": [
"CVSS 9.0 이상: 패치 공개 후 7일 내 적용",
"CVSS 7.0~8.9: 패치 공개 후 30일 내 적용",
"CVSS 4.0~6.9: 패치 공개 후 90일 내 적용",
"패치 전 스테이징 환경 검증 필수",
],
},
{
"template_id": "T-BAK-001",
"name": "데이터 백업 및 복구",
"category": "backup",
"severity": "MEDIUM",
"description": "공공기관 정보시스템 연속성 관리 가이드라인",
"reference": "행안부 전자정부 서비스 연속성 관리 지침",
"conditions": [
"중요 데이터: 매일 백업, 7일 주기 복구 검증",
"시스템 이미지: 주 1회 백업",
"백업 데이터 오프사이트 보관 (물리적 분리)",
"RTO 4시간 이내, RPO 24시간 이내",
],
},
{
"template_id": "T-LOG-001",
"name": "로그 관리 및 감사",
"category": "operation",
"severity": "MEDIUM",
"description": "개인정보보호법 및 전자금융거래법 로그 보관 기준",
"reference": "개인정보보호법 제29조 / ISMS-P 기술적 보호조치",
"conditions": [
"보안 이벤트 로그: 최소 6개월 보관",
"접근 로그: 최소 1년 보관",
"로그 무결성 검증 (Hash Chain 또는 WORM 스토리지)",
"실시간 로그 수집 및 이상 탐지 연동",
],
},
]
# ── 시드 초기화 ─────────────────────────────────────────────────────────────────
async def seed_policies() -> None:
"""애플리케이션 시작 시 기본 정책 5개 시드."""
async with SessionLocal() as db:
existing = await db.scalar(select(func.count()).select_from(PolicyRule))
if existing and existing > 0:
return
for p_data in _DEFAULT_POLICIES:
rule = PolicyRule(**p_data)
db.add(rule)
await db.commit()
logger.info("[policy-engine] 기본 정책 %d개 시드 완료", len(_DEFAULT_POLICIES))
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────────
class PolicyRuleCreate(BaseModel):
name: str
category: str = "security"
condition: Optional[str] = None # JSON 문자열
severity: str = "MEDIUM"
auto_remediate: bool = False
active: bool = True
class PolicyRuleUpdate(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
condition: Optional[str] = None
severity: Optional[str] = None
auto_remediate: Optional[bool] = None
active: Optional[bool] = None
class EvaluateRequest(BaseModel):
rule_ids: Optional[List[int]] = None # None이면 활성 규칙 전체
targets: Optional[List[str]] = None # 평가 대상 (서버명 목록)
class RemediateRequest(BaseModel):
note: Optional[str] = None
# ── 헬퍼: 정책 평가 시뮬레이션 ─────────────────────────────────────────────────
def _evaluate_rule(rule: PolicyRule, target: str) -> tuple[bool, str]:
"""
정책 규칙을 단일 대상에 평가.
운영 환경에서는 SSH 실행 또는 CMDB 조회로 실제 평가한다.
현재는 시뮬레이션 모드: 조건 파싱 후 통과/위반 여부 반환.
"""
if not rule.condition:
return True, "평가 조건 없음 — 통과"
try:
condition = json.loads(rule.condition)
except json.JSONDecodeError:
return False, "조건 JSON 파싱 실패"
check_type = condition.get("type", "unknown")
description = condition.get("description", "")
# 시뮬레이션: 실제 SSH 없이 결과 반환 (운영 시 SSH 실행으로 교체)
# 실제 구현에서는 target 서버에 SSH 연결 후 cmd 실행 결과를 평가한다
return True, f"[시뮬레이션] {check_type}: {description} — 통과"
# ── 엔드포인트 ───────────────────────────────────────────────────────────────────
@router.get("/rules", summary="정책 규칙 목록")
async def list_rules(
active_only: bool = False,
category: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[dict]:
stmt = select(PolicyRule).order_by(PolicyRule.id)
if active_only:
stmt = stmt.where(PolicyRule.active == True) # noqa: E712
if category:
stmt = stmt.where(PolicyRule.category == category)
rows = await db.execute(stmt)
rules = rows.scalars().all()
# 규칙별 위반 건수 포함
results = []
for rule in rules:
v_count = await db.scalar(
select(func.count()).select_from(PolicyViolation)
.where(PolicyViolation.rule_id == rule.id)
.where(PolicyViolation.status == "open")
) or 0
results.append({
"id": rule.id,
"name": rule.name,
"category": rule.category,
"condition": rule.condition,
"severity": rule.severity,
"auto_remediate": rule.auto_remediate,
"active": rule.active,
"open_violations": v_count,
"created_at": rule.created_at.isoformat() if rule.created_at else None,
})
return results
@router.post("/rules", status_code=201, summary="정책 규칙 생성")
async def create_rule(
payload: PolicyRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin_role),
) -> dict:
rule = PolicyRule(
name=payload.name,
category=payload.category,
condition=payload.condition,
severity=payload.severity,
auto_remediate=payload.auto_remediate,
active=payload.active,
)
db.add(rule)
await db.commit()
await db.refresh(rule)
logger.info("[policy-engine] 규칙 생성: id=%d name=%s by=%s", rule.id, rule.name, current_user.username)
return {"id": rule.id, "name": rule.name, "severity": rule.severity}
@router.put("/rules/{rule_id}", summary="정책 규칙 수정")
async def update_rule(
rule_id: int,
payload: PolicyRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin_role),
) -> dict:
rule = await db.get(PolicyRule, rule_id)
if not rule:
raise HTTPException(status_code=404, detail="정책 규칙을 찾을 수 없습니다")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(rule, field, value)
await db.commit()
await db.refresh(rule)
logger.info("[policy-engine] 규칙 수정: id=%d by=%s", rule_id, current_user.username)
return {"id": rule.id, "name": rule.name, "active": rule.active}
@router.post("/evaluate", summary="정책 평가 실행")
async def evaluate_policies(
payload: EvaluateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
# 평가 대상 규칙 조회
stmt = select(PolicyRule).where(PolicyRule.active == True) # noqa: E712
if payload.rule_ids:
stmt = stmt.where(PolicyRule.id.in_(payload.rule_ids))
rows = await db.execute(stmt)
rules = rows.scalars().all()
targets = payload.targets or ["default-target"]
violations_created = []
passed_count = 0
violated_count = 0
for rule in rules:
for target in targets:
passed, detail = _evaluate_rule(rule, target)
if not passed:
# 위반 기록 생성
violation = PolicyViolation(
rule_id=rule.id,
target=target,
detail=detail,
status="open",
)
db.add(violation)
violated_count += 1
violations_created.append({
"rule_id": rule.id,
"rule_name": rule.name,
"target": target,
"severity": rule.severity,
"detail": detail,
})
else:
passed_count += 1
await db.commit()
total = passed_count + violated_count
compliance_rate = round(passed_count / total * 100, 1) if total > 0 else 100.0
logger.info(
"[policy-engine] 평가 완료: rules=%d targets=%d passed=%d violated=%d by=%s",
len(rules), len(targets), passed_count, violated_count, current_user.username,
)
return {
"evaluated_rules": len(rules),
"evaluated_targets": len(targets),
"passed_count": passed_count,
"violated_count": violated_count,
"compliance_rate": compliance_rate,
"violations": violations_created,
}
@router.get("/violations", summary="위반 목록 조회")
async def list_violations(
status: Optional[str] = None,
severity: Optional[str] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[dict]:
stmt = (
select(PolicyViolation)
.order_by(desc(PolicyViolation.created_at))
.limit(limit)
)
if status:
stmt = stmt.where(PolicyViolation.status == status)
rows = await db.execute(stmt)
violations = rows.scalars().all()
results = []
for v in violations:
rule_name = None
rule_severity = None
if v.rule_id:
rule = await db.get(PolicyRule, v.rule_id)
if rule:
rule_name = rule.name
rule_severity = rule.severity
# severity 필터 (rule에서 가져옴)
if severity and rule_severity and rule_severity.upper() != severity.upper():
continue
results.append({
"id": v.id,
"rule_id": v.rule_id,
"rule_name": rule_name,
"severity": rule_severity,
"target": v.target,
"detail": v.detail,
"status": v.status,
"remediated_at": v.remediated_at.isoformat() if v.remediated_at else None,
"created_at": v.created_at.isoformat() if v.created_at else None,
})
return results
@router.post("/violations/{violation_id}/remediate", summary="위반 교정 처리")
async def remediate_violation(
violation_id: int,
payload: RemediateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
violation = await db.get(PolicyViolation, violation_id)
if not violation:
raise HTTPException(status_code=404, detail="위반 항목을 찾을 수 없습니다")
if violation.status == "remediated":
raise HTTPException(status_code=409, detail="이미 교정 완료된 위반입니다")
violation.status = "remediated"
violation.remediated_at = datetime.utcnow()
if payload.note:
existing = violation.detail or ""
violation.detail = f"{existing}\n[교정 메모] {payload.note}".strip()
await db.commit()
await db.refresh(violation)
logger.info(
"[policy-engine] 위반 교정: violation_id=%d by=%s",
violation_id, current_user.username,
)
return {
"id": violation.id,
"status": violation.status,
"remediated_at": violation.remediated_at.isoformat(),
"message": "위반 항목이 교정 완료로 처리되었습니다.",
}
@router.get("/templates", summary="공공기관 표준 정책 템플릿")
async def list_templates(
current_user: User = Depends(get_current_user),
) -> list[dict]:
"""공공기관 IT 관리 표준(행안부/NIST/CSAP/ISMS-P) 기반 정책 템플릿 목록."""
return _POLICY_TEMPLATES
@router.get("/dashboard", summary="정책 준수 현황 대시보드")
async def policy_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
total_rules = await db.scalar(select(func.count()).select_from(PolicyRule)) or 0
active_rules = await db.scalar(
select(func.count()).select_from(PolicyRule).where(PolicyRule.active == True) # noqa: E712
) or 0
total_violations = await db.scalar(select(func.count()).select_from(PolicyViolation)) or 0
open_violations = await db.scalar(
select(func.count()).select_from(PolicyViolation)
.where(PolicyViolation.status == "open")
) or 0
remediated_violations = await db.scalar(
select(func.count()).select_from(PolicyViolation)
.where(PolicyViolation.status == "remediated")
) or 0
# 심각도별 오픈 위반 집계
severity_breakdown: dict[str, int] = {}
rows = await db.execute(
select(PolicyRule.severity, func.count(PolicyViolation.id))
.join(PolicyViolation, PolicyRule.id == PolicyViolation.rule_id, isouter=True)
.where(PolicyViolation.status == "open")
.group_by(PolicyRule.severity)
)
for severity, cnt in rows.all():
if severity:
severity_breakdown[severity] = cnt
# 카테고리별 규칙 집계
category_breakdown: dict[str, int] = {}
rows = await db.execute(
select(PolicyRule.category, func.count(PolicyRule.id)).group_by(PolicyRule.category)
)
for category, cnt in rows.all():
if category:
category_breakdown[category] = cnt
# 최근 위반 5건
recent_rows = await db.execute(
select(PolicyViolation)
.where(PolicyViolation.status == "open")
.order_by(desc(PolicyViolation.created_at))
.limit(5)
)
recent_violations = []
for v in recent_rows.scalars().all():
rule_name = None
severity = None
if v.rule_id:
rule = await db.get(PolicyRule, v.rule_id)
if rule:
rule_name = rule.name
severity = rule.severity
recent_violations.append({
"id": v.id,
"rule_name": rule_name,
"severity": severity,
"target": v.target,
"created_at": v.created_at.isoformat() if v.created_at else None,
})
compliance_rate = (
round((total_violations - open_violations) / total_violations * 100, 1)
if total_violations > 0 else 100.0
)
return {
"summary": {
"total_rules": total_rules,
"active_rules": active_rules,
"total_violations": total_violations,
"open_violations": open_violations,
"remediated_violations": remediated_violations,
"compliance_rate": compliance_rate,
},
"severity_breakdown": severity_breakdown,
"category_breakdown": category_breakdown,
"recent_violations": recent_violations,
}