refactor: 4개 검사 엔진을 YAML 기반 표준 규칙으로 리팩토링

- YAML 규칙 파일 4개 신규 생성 (html_css, accessibility, seo, performance_security)
  W3C, WCAG 2.0/2.1/2.2, OWASP, Google Search Essentials 공식 표준 기반
- rules/__init__.py: YAML 로더 + 캐싱 + 리로드 모듈
- html_css.py: 30개 폐기 요소, 100+개 폐기 속성을 YAML에서 동적 로드
- accessibility.py: WCAG 버전 선택 지원 (wcag_version 파라미터)
- seo.py: title/description 길이, OG 필수 태그 등 임계값 YAML 로드
- performance_security.py: COOP/COEP/CORP 검사 추가, 정보 노출 헤더 검사 추가,
  TTFB/페이지 크기 임계값 YAML 로드
- PyYAML 의존성 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 15:49:57 +09:00
parent cdb6405714
commit 44ad36e2ab
10 changed files with 3393 additions and 92 deletions

View File

@ -10,13 +10,14 @@ import logging
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
from typing import Optional
from typing import Any, Optional
import httpx
from bs4 import BeautifulSoup
from app.engines.base import BaseChecker
from app.models.schemas import CategoryResult, Issue, calculate_grade
from app.rules import get_rules
logger = logging.getLogger(__name__)
@ -24,6 +25,32 @@ logger = logging.getLogger(__name__)
class PerformanceSecurityChecker(BaseChecker):
"""Performance and security checker engine."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._rules_data = get_rules("performance_security")
def _get_security_headers(self) -> list[dict[str, Any]]:
"""Load required security headers from YAML."""
return self._rules_data.get("security", {}).get("headers", [])
def _get_headers_to_remove(self) -> list[dict[str, Any]]:
"""Load information disclosure headers from YAML."""
return self._rules_data.get("security", {}).get("headers_to_remove", [])
def _get_ttfb_thresholds(self) -> dict[str, int]:
"""Load TTFB thresholds from YAML."""
for metric in self._rules_data.get("performance", {}).get("additional_metrics", []):
if metric.get("id") == "perf-ttfb":
return metric.get("thresholds", {})
return {"good": 800, "needs_improvement": 1800}
def _get_page_size_thresholds(self) -> dict[str, int]:
"""Load total page size thresholds from YAML."""
for check in self._rules_data.get("performance", {}).get("resource_checks", []):
if check.get("id") == "perf-total-page-size":
return check.get("thresholds", {})
return {"good": 1500, "needs_improvement": 3000, "poor": 5000}
@property
def category_name(self) -> str:
return "performance_security"
@ -46,6 +73,12 @@ class PerformanceSecurityChecker(BaseChecker):
issues += self._check_x_xss_protection(headers)
issues += self._check_referrer_policy(headers)
issues += self._check_permissions_policy(headers)
issues += self._check_coop(headers)
issues += self._check_coep(headers)
issues += self._check_corp(headers)
await self.update_progress(50, "정보 노출 헤더 검사 중...")
issues += self._check_info_disclosure(headers)
await self.update_progress(60, "응답 시간 측정 중...")
issues += await self._check_ttfb(url, metrics)
@ -163,15 +196,24 @@ class PerformanceSecurityChecker(BaseChecker):
return []
def _get_security_header_rule(self, rule_id: str) -> dict[str, Any]:
"""Find a specific security header rule from YAML."""
for h in self._get_security_headers():
if h.get("id") == rule_id:
return h
return {}
def _check_hsts(self, headers: dict) -> list[Issue]:
"""P-03: Check Strict-Transport-Security header."""
rule = self._get_security_header_rule("sec-strict-transport-security")
recommended = rule.get("details", {}).get("recommended_value", "max-age=31536000; includeSubDomains")
hsts = self._get_header(headers, "Strict-Transport-Security")
if not hsts:
return [self._create_issue(
code="P-03",
severity="major",
message="Strict-Transport-Security(HSTS) 헤더가 설정되지 않았습니다",
suggestion="HSTS 헤더를 추가하세요: Strict-Transport-Security: max-age=31536000; includeSubDomains",
suggestion=f"HSTS 헤더를 추가하세요: Strict-Transport-Security: {recommended}",
)]
return []
@ -225,13 +267,15 @@ class PerformanceSecurityChecker(BaseChecker):
def _check_referrer_policy(self, headers: dict) -> list[Issue]:
"""P-08: Check Referrer-Policy header."""
rule = self._get_security_header_rule("sec-referrer-policy")
recommended = rule.get("details", {}).get("recommended_value", "strict-origin-when-cross-origin")
rp = self._get_header(headers, "Referrer-Policy")
if not rp:
return [self._create_issue(
code="P-08",
severity="minor",
message="Referrer-Policy 헤더가 설정되지 않았습니다",
suggestion="Referrer-Policy: strict-origin-when-cross-origin을 설정하세요",
suggestion=f"Referrer-Policy: {recommended}을 설정하세요",
)]
return []
@ -247,8 +291,69 @@ class PerformanceSecurityChecker(BaseChecker):
)]
return []
def _check_coop(self, headers: dict) -> list[Issue]:
"""P-15: Check Cross-Origin-Opener-Policy header."""
rule = self._get_security_header_rule("sec-cross-origin-opener-policy")
recommended = rule.get("details", {}).get("recommended_value", "same-origin")
coop = self._get_header(headers, "Cross-Origin-Opener-Policy")
if not coop:
return [self._create_issue(
code="P-15",
severity="minor",
message="Cross-Origin-Opener-Policy(COOP) 헤더가 설정되지 않았습니다",
suggestion=f"COOP 헤더를 추가하세요: Cross-Origin-Opener-Policy: {recommended}",
)]
return []
def _check_coep(self, headers: dict) -> list[Issue]:
"""P-16: Check Cross-Origin-Embedder-Policy header."""
rule = self._get_security_header_rule("sec-cross-origin-embedder-policy")
recommended = rule.get("details", {}).get("recommended_value", "require-corp")
coep = self._get_header(headers, "Cross-Origin-Embedder-Policy")
if not coep:
return [self._create_issue(
code="P-16",
severity="minor",
message="Cross-Origin-Embedder-Policy(COEP) 헤더가 설정되지 않았습니다",
suggestion=f"COEP 헤더를 추가하세요: Cross-Origin-Embedder-Policy: {recommended}",
)]
return []
def _check_corp(self, headers: dict) -> list[Issue]:
"""P-17: Check Cross-Origin-Resource-Policy header."""
rule = self._get_security_header_rule("sec-cross-origin-resource-policy")
recommended = rule.get("details", {}).get("recommended_value", "same-site")
corp = self._get_header(headers, "Cross-Origin-Resource-Policy")
if not corp:
return [self._create_issue(
code="P-17",
severity="minor",
message="Cross-Origin-Resource-Policy(CORP) 헤더가 설정되지 않았습니다",
suggestion=f"CORP 헤더를 추가하세요: Cross-Origin-Resource-Policy: {recommended}",
)]
return []
def _check_info_disclosure(self, headers: dict) -> list[Issue]:
"""P-18: Check for information disclosure headers (Server, X-Powered-By)."""
issues = []
for rule in self._get_headers_to_remove():
header_name = rule.get("details", {}).get("header", "")
value = self._get_header(headers, header_name)
if value:
issues.append(self._create_issue(
code="P-18",
severity="info",
message=f"{header_name} 헤더가 서버 정보를 노출하고 있습니다: {value[:80]}",
suggestion=f"{header_name} 헤더를 제거하여 서버 기술 스택 노출을 방지하세요",
))
return issues
async def _check_ttfb(self, url: str, metrics: dict) -> list[Issue]:
"""P-10: Check Time To First Byte (TTFB)."""
"""P-10: Check Time To First Byte (TTFB) using YAML thresholds."""
thresholds = self._get_ttfb_thresholds()
good_ms = thresholds.get("good", 800)
needs_improvement_ms = thresholds.get("needs_improvement", 1800)
try:
start = time.monotonic()
async with httpx.AsyncClient(
@ -262,18 +367,18 @@ class PerformanceSecurityChecker(BaseChecker):
ttfb_ms = round((time.monotonic() - start) * 1000)
metrics["ttfb_ms"] = ttfb_ms
if ttfb_ms > 2000:
if ttfb_ms > needs_improvement_ms:
return [self._create_issue(
code="P-10",
severity="major",
message=f"응답 시간(TTFB)이 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
message=f"응답 시간(TTFB)이 느립니다: {ttfb_ms}ms (권장 < {good_ms}ms)",
suggestion="서버 응답 속도를 개선하세요 (캐싱, CDN, 서버 최적화)",
)]
elif ttfb_ms > 1000:
elif ttfb_ms > good_ms:
return [self._create_issue(
code="P-10",
severity="minor",
message=f"응답 시간(TTFB)이 다소 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
message=f"응답 시간(TTFB)이 다소 느립니다: {ttfb_ms}ms (권장 < {good_ms}ms)",
suggestion="서버 응답 속도 개선을 고려하세요",
)]
except Exception as e:
@ -288,15 +393,19 @@ class PerformanceSecurityChecker(BaseChecker):
return []
def _check_page_size(self, html_content: str, metrics: dict) -> list[Issue]:
"""P-11: Check HTML page size."""
"""P-11: Check HTML page size using YAML thresholds."""
thresholds = self._get_page_size_thresholds()
poor_kb = thresholds.get("poor", 5000)
poor_bytes = poor_kb * 1024
size_bytes = len(html_content.encode("utf-8"))
metrics["page_size_bytes"] = size_bytes
if size_bytes > 3 * 1024 * 1024: # 3MB
if size_bytes > poor_bytes:
return [self._create_issue(
code="P-11",
severity="minor",
message=f"페이지 크기가 큽니다: {round(size_bytes / 1024 / 1024, 1)}MB (권장 < 3MB)",
message=f"페이지 크기가 큽니다: {round(size_bytes / 1024 / 1024, 1)}MB (권장 < {poor_kb // 1024}MB)",
suggestion="페이지 크기를 줄이세요 (불필요한 코드 제거, 이미지 최적화, 코드 분할)",
)]
return []
@ -387,8 +496,9 @@ class PerformanceSecurityChecker(BaseChecker):
https_ssl_score = max(0, https_ssl_score)
# Security headers component (40% of security)
header_issues = [i for i in issues if i.code in ("P-03", "P-04", "P-05", "P-06", "P-07", "P-08", "P-09")]
total_header_checks = 7
header_codes = {"P-03", "P-04", "P-05", "P-06", "P-07", "P-08", "P-09", "P-15", "P-16", "P-17"}
header_issues = [i for i in issues if i.code in header_codes]
total_header_checks = len(header_codes)
passed_headers = total_header_checks - len(header_issues)
header_score = round(passed_headers / total_header_checks * 100) if total_header_checks else 100
@ -398,13 +508,16 @@ class PerformanceSecurityChecker(BaseChecker):
perf_score = 100
# TTFB component (40% of performance)
ttfb_thresholds = self._get_ttfb_thresholds()
ttfb_good = ttfb_thresholds.get("good", 800)
ttfb_ni = ttfb_thresholds.get("needs_improvement", 1800)
ttfb = metrics.get("ttfb_ms")
if ttfb is not None:
if ttfb <= 500:
if ttfb <= ttfb_good // 2:
ttfb_score = 100
elif ttfb <= 1000:
elif ttfb <= ttfb_good:
ttfb_score = 80
elif ttfb <= 2000:
elif ttfb <= ttfb_ni:
ttfb_score = 60
else:
ttfb_score = 30
@ -412,12 +525,16 @@ class PerformanceSecurityChecker(BaseChecker):
ttfb_score = 50
# Page size component (30% of performance)
size_thresholds = self._get_page_size_thresholds()
good_kb = size_thresholds.get("good", 1500)
ni_kb = size_thresholds.get("needs_improvement", 3000)
poor_kb = size_thresholds.get("poor", 5000)
page_size = metrics.get("page_size_bytes", 0)
if page_size <= 1024 * 1024: # 1MB
if page_size <= good_kb * 1024:
size_score = 100
elif page_size <= 2 * 1024 * 1024: # 2MB
elif page_size <= ni_kb * 1024:
size_score = 80
elif page_size <= 3 * 1024 * 1024: # 3MB
elif page_size <= poor_kb * 1024:
size_score = 60
else:
size_score = 30