zioinfo-mail/workspace/guardia-itsm/core/compliance_check.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +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")),
}
}