574 lines
21 KiB
Python
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,
|
|
}
|