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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user