zioinfo-mail/itsm/core/compliance_check.py
DESKTOP-TKLFCPR\ython 82a4c72080 feat(itsm): PMS/준수성/JMeter/공공기관 기능 + Nifty UI + 로고 Copyright
[PMS 완성]
- core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX)
- routers/si_report.py: daily|weekly|monthly + 메신저 발송
- routers/deliverables.py: 산출물 CRUD + 제출/검토
- si_issues.py: 이슈→SR 자동 연결
- scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서
- models.py: Deliverable 모델

[준수성 자동 점검]
- core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙
- routers/compliance.py: 스캔 + HTML/Excel 보고서

[JMeter 성능 테스트]
- routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서

[공공기관 필수 기능]
- routers/public_checklist.py: 행안부 기준 19개 항목

[UI/브랜드]
- 로고(ziologo.png) + Copyright 2026 All Rights Reserved
- Nifty 계층형 사이드바 (PMS 서브메뉴)
- X-Powered-By + X-Copyright 응답 헤더
- manual/15_UI_Nifty_가이드.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:50:29 +09:00

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
GUARDiA 준수성 자동 점검 엔진
1. 시큐어코딩 (행안부 SW 보안약점 기준 + OWASP Top 10)
2. 웹 접근성 (WCAG 2.1 / KWCAG 2.1 — 한국형)
3. 개인정보보호법 (PIPA — 개인정보 식별자 탐지)
"""
from __future__ import annotations
import ast
import re
import os
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 1. 시큐어코딩 점검 ───────────────────────────────────────────────────────
# 행안부 SW 보안약점 주요 패턴
SECURE_CODING_RULES = [
# SQL 인젝션
{
"id": "SC-001",
"category": "SQL 인젝션",
"severity": "CRITICAL",
"pattern": r'execute\s*\(\s*f["\'].*\{|execute\s*\(\s*".*%.*%|execute\s*\(\s*\'.*\'\.format\(',
"message": "SQL 문자열 직접 포맷팅 — 파라미터 바인딩 사용 필요",
},
# XSS
{
"id": "SC-002",
"category": "XSS",
"severity": "HIGH",
"pattern": r'innerHTML\s*=\s*(?!"|\')|\.html\s*\(',
"message": "innerHTML 직접 삽입 — textContent 또는 escapeHtml 함수 사용 필요",
},
# 하드코딩 비밀번호/키
{
"id": "SC-003",
"category": "하드코딩 자격증명",
"severity": "CRITICAL",
"pattern": r'(?i)(password|passwd|secret|api_key|apikey|token)\s*=\s*["\'][^"\']{6,}["\']',
"message": "하드코딩된 자격증명 — 환경변수 또는 시크릿 관리자 사용 필요",
},
# OS 명령어 인젝션
{
"id": "SC-004",
"category": "OS 명령어 인젝션",
"severity": "CRITICAL",
"pattern": r'os\.system\s*\(|subprocess\.call\s*\(.*shell\s*=\s*True|eval\s*\(',
"message": "shell=True 또는 eval 사용 — 입력값 검증 필수",
},
# 경로 조작
{
"id": "SC-005",
"category": "경로 조작",
"severity": "HIGH",
"pattern": r'open\s*\(\s*.*\+|open\s*\(\s*f["\']',
"message": "사용자 입력 기반 파일 경로 — Path.resolve().relative_to() 검증 필수",
},
# 정보 노출
{
"id": "SC-006",
"category": "민감 정보 노출",
"severity": "MEDIUM",
"pattern": r'traceback\.print_exc\(\)|print\s*\(.*exception|logger\.(info|debug).*password',
"message": "예외 스택트레이스 직접 출력 — 상세 오류 메시지 은닉 필요",
},
# CSRF 위험 (GET으로 상태 변경)
{
"id": "SC-007",
"category": "CSRF",
"severity": "MEDIUM",
"pattern": r'@router\.get\s*\([^\)]+\)\s*\nasync def.*(delete|remove|drop|truncate)',
"message": "GET 메서드로 데이터 변경 — POST/DELETE 사용 및 CSRF 토큰 적용 필요",
},
# 취약한 암호화
{
"id": "SC-008",
"category": "취약 암호화",
"severity": "HIGH",
"pattern": r'md5|sha1\s*\(|DES\.|RC4\.',
"message": "취약한 해시/암호화 알고리즘 — SHA-256 이상 또는 AES-256-GCM 사용 필요",
},
]
# ── 2. 웹 접근성 점검 (HTML 기반) ────────────────────────────────────────────
ACCESSIBILITY_RULES = [
{
"id": "WA-001",
"category": "대체 텍스트",
"level": "A",
"pattern": r'<img(?![^>]*alt=)[^>]*>',
"message": "img 요소에 alt 속성 없음 — 시각 장애인 스크린리더 접근 불가",
},
{
"id": "WA-002",
"category": "색상 대비",
"level": "AA",
"pattern": r'color:\s*#(?:aaa|999|bbb|ccc|ddd|eee|f{3,6})',
"message": "낮은 색상 대비 — 4.5:1 이상 비율 필요 (WCAG 2.1 AA)",
},
{
"id": "WA-003",
"category": "키보드 접근성",
"level": "A",
"pattern": r'onclick="[^"]*"(?![^>]*tabindex)',
"message": "onclick만 있는 요소 — tabindex + onkeydown 추가 또는 <button> 사용",
},
{
"id": "WA-004",
"category": "폼 레이블",
"level": "A",
"pattern": r'<input(?![^>]*aria-label|[^>]*id=)[^>]*>',
"message": "input 요소에 label/aria-label 없음 — 폼 접근성 미준수",
},
{
"id": "WA-005",
"category": "언어 설정",
"level": "A",
"pattern": r'<html(?![^>]*lang=)',
"message": "html 요소에 lang 속성 없음 — <html lang=\"ko\"> 필요",
},
{
"id": "WA-006",
"category": "포커스 표시",
"level": "AA",
"pattern": r'outline:\s*none|outline:\s*0',
"message": "포커스 표시 제거 — 키보드 사용자 탐색 불가, :focus-visible 활용 권장",
},
{
"id": "WA-007",
"category": "ARIA 역할",
"level": "A",
"pattern": r'<div\s+onclick|<span\s+onclick',
"message": "div/span에 onclick — <button role=\"button\"> 또는 role+tabindex 추가 필요",
},
]
# ── 3. 개인정보보호법 위반 탐지 ──────────────────────────────────────────────
PIPA_RULES = [
{
"id": "PI-001",
"category": "주민등록번호",
"severity": "CRITICAL",
"pattern": r'\d{6}[-]\d{7}|\d{13}',
"message": "주민등록번호 패턴 감지 — 법적 수집 제한 항목 (개인정보보호법 제24조)",
},
{
"id": "PI-002",
"category": "신용카드번호",
"severity": "CRITICAL",
"pattern": r'\b(?:\d{4}[-\s]?){3}\d{4}\b',
"message": "신용카드번호 패턴 감지 — 마스킹 처리 필요",
},
{
"id": "PI-003",
"category": "이메일 수집",
"severity": "MEDIUM",
"pattern": r'email.*=.*Column.*String|Column\(.*email',
"message": "이메일 DB 저장 — 수집 동의 여부 및 암호화 저장 확인 필요",
},
{
"id": "PI-004",
"category": "비밀번호 평문",
"severity": "CRITICAL",
"pattern": r'password.*=.*Column(?!.*hash)|password_hash\s*=\s*None',
"message": "비밀번호 평문 저장 의심 — bcrypt/argon2 해시 저장 필수",
},
{
"id": "PI-005",
"category": "전화번호",
"severity": "LOW",
"pattern": r'01[016789][-]?\d{3,4}[-]?\d{4}',
"message": "전화번호 패턴 감지 — 불필요한 수집 여부 검토 필요",
},
{
"id": "PI-006",
"category": "로그에 개인정보",
"severity": "HIGH",
"pattern": r'logger\.(info|debug|warning).*(?:email|phone|name|password)',
"message": "로그에 개인정보 기록 의심 — 로그 마스킹 처리 필요",
},
]
# ── 점검 실행 ────────────────────────────────────────────────────────────────
def scan_file(filepath: str, content: str) -> list[dict]:
"""단일 파일에 대한 전체 점검 수행."""
ext = Path(filepath).suffix.lower()
findings = []
rule_sets = []
if ext in (".py",):
rule_sets = [SECURE_CODING_RULES, PIPA_RULES]
if ext in (".html", ".css", ".js"):
rule_sets.append(ACCESSIBILITY_RULES)
if ext in (".html", ".js"):
rule_sets.append(SECURE_CODING_RULES) # XSS 등
if ext in (".py", ".js", ".html"):
rule_sets.append(PIPA_RULES)
# 중복 제거
seen_ids: set = set()
for rules in rule_sets:
for rule in rules:
if rule["id"] in seen_ids:
continue
seen_ids.add(rule["id"])
try:
matches = list(re.finditer(rule["pattern"], content, re.IGNORECASE | re.MULTILINE))
for m in matches:
line_no = content[:m.start()].count("\n") + 1
findings.append({
"file": filepath,
"line": line_no,
"rule_id": rule["id"],
"category": rule["category"],
"severity": rule.get("severity", rule.get("level", "INFO")),
"message": rule["message"],
"snippet": content[max(0, m.start()-20):m.end()+20].strip()[:100],
})
except re.error:
pass
return findings
async def scan_project(base_dir: str = None) -> dict[str, Any]:
"""GUARDiA 프로젝트 전체 준수성 점검."""
if not base_dir:
base_dir = str(Path(__file__).parent.parent)
target_dirs = [
Path(base_dir) / "routers",
Path(base_dir) / "core",
Path(base_dir) / "static",
]
all_findings = []
scanned = 0
for tdir in target_dirs:
if not tdir.exists():
continue
for fpath in tdir.rglob("*"):
if fpath.suffix not in (".py", ".html", ".js", ".css"):
continue
if fpath.name.startswith("."):
continue
try:
content = fpath.read_text(encoding="utf-8", errors="ignore")
findings = scan_file(str(fpath.relative_to(base_dir)), content)
all_findings.extend(findings)
scanned += 1
except Exception:
pass
# 집계
by_severity: dict = {}
by_category: dict = {}
for f in all_findings:
sev = f["severity"]
cat = f["category"]
by_severity[sev] = by_severity.get(sev, 0) + 1
by_category[cat] = by_category.get(cat, 0) + 1
critical = by_severity.get("CRITICAL", 0)
high = by_severity.get("HIGH", 0)
risk_level = "CRITICAL" if critical > 0 else "HIGH" if high > 0 else "MEDIUM" if all_findings else "LOW"
return {
"scan_time": datetime.utcnow().isoformat(),
"scanned_files": scanned,
"total_findings": len(all_findings),
"risk_level": risk_level,
"by_severity": by_severity,
"by_category": by_category,
"findings": all_findings[:200], # 최대 200건
"summary": {
"secure_coding": sum(1 for f in all_findings if f["rule_id"].startswith("SC")),
"accessibility": sum(1 for f in all_findings if f["rule_id"].startswith("WA")),
"privacy": sum(1 for f in all_findings if f["rule_id"].startswith("PI")),
}
}