292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""
|
||
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")),
|
||
}
|
||
}
|