""" 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']*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 추가 또는 사용", }, { "id": "WA-004", "category": "폼 레이블", "level": "A", "pattern": r']*aria-label|[^>]*id=)[^>]*>', "message": "input 요소에 label/aria-label 없음 — 폼 접근성 미준수", }, { "id": "WA-005", "category": "언어 설정", "level": "A", "pattern": r']*lang=)', "message": "html 요소에 lang 속성 없음 — 필요", }, { "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' 또는 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")), } }