Files
web-inspector/backend/app/engines/performance_security.py
jungwoo choi b5fa5d96b9 feat: 웹사이트 표준화 검사 도구 구현
- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목)
- FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트)
- Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트)
- Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392)
- 전체 테스트 32/32 PASS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:57:27 +09:00

455 lines
18 KiB
Python

"""
Performance/Security Checker Engine (F-005).
Checks security headers, HTTPS, SSL certificate, response time, page size, etc.
"""
import re
import ssl
import socket
import logging
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
from typing import Optional
import httpx
from bs4 import BeautifulSoup
from app.engines.base import BaseChecker
from app.models.schemas import CategoryResult, Issue, calculate_grade
logger = logging.getLogger(__name__)
class PerformanceSecurityChecker(BaseChecker):
"""Performance and security checker engine."""
@property
def category_name(self) -> str:
return "performance_security"
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
issues: list[Issue] = []
metrics: dict = {}
await self.update_progress(10, "HTTPS 검사 중...")
issues += self._check_https(url, metrics)
await self.update_progress(20, "SSL 인증서 검사 중...")
issues += await self._check_ssl(url, metrics)
await self.update_progress(35, "보안 헤더 검사 중...")
issues += self._check_hsts(headers)
issues += self._check_csp(headers)
issues += self._check_x_content_type(headers)
issues += self._check_x_frame_options(headers)
issues += self._check_x_xss_protection(headers)
issues += self._check_referrer_policy(headers)
issues += self._check_permissions_policy(headers)
await self.update_progress(60, "응답 시간 측정 중...")
issues += await self._check_ttfb(url, metrics)
await self.update_progress(70, "페이지 크기 분석 중...")
issues += self._check_page_size(html_content, metrics)
await self.update_progress(80, "리다이렉트 검사 중...")
issues += await self._check_redirects(url, metrics)
await self.update_progress(85, "압축 검사 중...")
issues += self._check_compression(headers, metrics)
await self.update_progress(90, "혼합 콘텐츠 검사 중...")
issues += self._check_mixed_content(url, html_content)
score, sub_scores = self._calculate_composite_score(issues, metrics)
await self.update_progress(100, "완료")
return self._build_result(
category="performance_security",
score=score,
issues=issues,
sub_scores=sub_scores,
metrics=metrics,
)
def _check_https(self, url: str, metrics: dict) -> list[Issue]:
"""P-01: Check HTTPS usage."""
parsed = urlparse(url)
is_https = parsed.scheme == "https"
metrics["https"] = is_https
if not is_https:
return [self._create_issue(
code="P-01",
severity="critical",
message="HTTPS를 사용하지 않고 있습니다",
suggestion="사이트 보안을 위해 HTTPS를 적용하세요",
)]
return []
async def _check_ssl(self, url: str, metrics: dict) -> list[Issue]:
"""P-02: Check SSL certificate validity and expiry."""
parsed = urlparse(url)
if parsed.scheme != "https":
metrics["ssl_valid"] = False
metrics["ssl_expiry_days"] = None
return [self._create_issue(
code="P-02",
severity="critical",
message="HTTPS를 사용하지 않아 SSL 인증서를 확인할 수 없습니다",
suggestion="SSL 인증서를 설치하고 HTTPS를 적용하세요",
)]
hostname = parsed.hostname
port = parsed.port or 443
try:
ctx = ssl.create_default_context()
conn = ctx.wrap_socket(
socket.socket(socket.AF_INET),
server_hostname=hostname,
)
conn.settimeout(5)
conn.connect((hostname, port))
cert = conn.getpeercert()
conn.close()
# Check expiry
not_after = cert.get("notAfter")
if not_after:
expiry_date = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
days_remaining = (expiry_date - datetime.now()).days
metrics["ssl_valid"] = True
metrics["ssl_expiry_days"] = days_remaining
if days_remaining < 0:
return [self._create_issue(
code="P-02",
severity="critical",
message="SSL 인증서가 만료되었습니다",
suggestion="SSL 인증서를 즉시 갱신하세요",
)]
elif days_remaining < 30:
return [self._create_issue(
code="P-02",
severity="major",
message=f"SSL 인증서가 {days_remaining}일 후 만료됩니다",
suggestion="인증서 만료 전에 갱신하세요",
)]
else:
metrics["ssl_valid"] = True
metrics["ssl_expiry_days"] = None
except ssl.SSLError as e:
metrics["ssl_valid"] = False
metrics["ssl_expiry_days"] = None
return [self._create_issue(
code="P-02",
severity="critical",
message=f"SSL 인증서가 유효하지 않습니다: {str(e)[:100]}",
suggestion="유효한 SSL 인증서를 설치하세요",
)]
except Exception as e:
logger.warning("SSL check failed for %s: %s", url, str(e))
metrics["ssl_valid"] = None
metrics["ssl_expiry_days"] = None
return [self._create_issue(
code="P-02",
severity="minor",
message="SSL 인증서를 확인할 수 없습니다",
suggestion="서버의 SSL 설정을 점검하세요",
)]
return []
def _check_hsts(self, headers: dict) -> list[Issue]:
"""P-03: Check Strict-Transport-Security header."""
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",
)]
return []
def _check_csp(self, headers: dict) -> list[Issue]:
"""P-04: Check Content-Security-Policy header."""
csp = self._get_header(headers, "Content-Security-Policy")
if not csp:
return [self._create_issue(
code="P-04",
severity="major",
message="Content-Security-Policy(CSP) 헤더가 설정되지 않았습니다",
suggestion="CSP 헤더를 추가하여 XSS 공격을 방지하세요",
)]
return []
def _check_x_content_type(self, headers: dict) -> list[Issue]:
"""P-05: Check X-Content-Type-Options header."""
xcto = self._get_header(headers, "X-Content-Type-Options")
if not xcto or "nosniff" not in xcto.lower():
return [self._create_issue(
code="P-05",
severity="minor",
message="X-Content-Type-Options 헤더가 설정되지 않았습니다",
suggestion="X-Content-Type-Options: nosniff 헤더를 추가하세요",
)]
return []
def _check_x_frame_options(self, headers: dict) -> list[Issue]:
"""P-06: Check X-Frame-Options header."""
xfo = self._get_header(headers, "X-Frame-Options")
if not xfo:
return [self._create_issue(
code="P-06",
severity="minor",
message="X-Frame-Options 헤더가 설정되지 않았습니다",
suggestion="클릭재킹 방지를 위해 X-Frame-Options: DENY 또는 SAMEORIGIN을 설정하세요",
)]
return []
def _check_x_xss_protection(self, headers: dict) -> list[Issue]:
"""P-07: Check X-XSS-Protection header (deprecated notice)."""
xxp = self._get_header(headers, "X-XSS-Protection")
if xxp:
return [self._create_issue(
code="P-07",
severity="info",
message="X-XSS-Protection 헤더가 설정되어 있습니다 (현재 deprecated)",
suggestion="X-XSS-Protection 대신 Content-Security-Policy를 사용하세요",
)]
return []
def _check_referrer_policy(self, headers: dict) -> list[Issue]:
"""P-08: Check Referrer-Policy header."""
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을 설정하세요",
)]
return []
def _check_permissions_policy(self, headers: dict) -> list[Issue]:
"""P-09: Check Permissions-Policy header."""
pp = self._get_header(headers, "Permissions-Policy")
if not pp:
return [self._create_issue(
code="P-09",
severity="minor",
message="Permissions-Policy 헤더가 설정되지 않았습니다",
suggestion="Permissions-Policy 헤더를 추가하여 브라우저 기능 접근을 제한하세요",
)]
return []
async def _check_ttfb(self, url: str, metrics: dict) -> list[Issue]:
"""P-10: Check Time To First Byte (TTFB)."""
try:
start = time.monotonic()
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
follow_redirects=True,
verify=False,
) as client:
resp = await client.get(url, headers={
"User-Agent": "WebInspector/1.0 (Inspection Bot)",
})
ttfb_ms = round((time.monotonic() - start) * 1000)
metrics["ttfb_ms"] = ttfb_ms
if ttfb_ms > 2000:
return [self._create_issue(
code="P-10",
severity="major",
message=f"응답 시간(TTFB)이 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
suggestion="서버 응답 속도를 개선하세요 (캐싱, CDN, 서버 최적화)",
)]
elif ttfb_ms > 1000:
return [self._create_issue(
code="P-10",
severity="minor",
message=f"응답 시간(TTFB)이 다소 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
suggestion="서버 응답 속도 개선을 고려하세요",
)]
except Exception as e:
logger.warning("TTFB check failed for %s: %s", url, str(e))
metrics["ttfb_ms"] = None
return [self._create_issue(
code="P-10",
severity="major",
message="응답 시간(TTFB)을 측정할 수 없습니다",
suggestion="서버 접근성을 확인하세요",
)]
return []
def _check_page_size(self, html_content: str, metrics: dict) -> list[Issue]:
"""P-11: Check HTML page size."""
size_bytes = len(html_content.encode("utf-8"))
metrics["page_size_bytes"] = size_bytes
if size_bytes > 3 * 1024 * 1024: # 3MB
return [self._create_issue(
code="P-11",
severity="minor",
message=f"페이지 크기가 큽니다: {round(size_bytes / 1024 / 1024, 1)}MB (권장 < 3MB)",
suggestion="페이지 크기를 줄이세요 (불필요한 코드 제거, 이미지 최적화, 코드 분할)",
)]
return []
async def _check_redirects(self, url: str, metrics: dict) -> list[Issue]:
"""P-12: Check redirect chain length."""
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
follow_redirects=True,
verify=False,
) as client:
resp = await client.get(url, headers={
"User-Agent": "WebInspector/1.0 (Inspection Bot)",
})
redirect_count = len(resp.history)
metrics["redirect_count"] = redirect_count
if redirect_count >= 3:
return [self._create_issue(
code="P-12",
severity="minor",
message=f"리다이렉트가 {redirect_count}회 발생합니다 (권장 < 3회)",
suggestion="리다이렉트 체인을 줄여 로딩 속도를 개선하세요",
)]
except Exception as e:
logger.warning("Redirect check failed for %s: %s", url, str(e))
metrics["redirect_count"] = None
return []
def _check_compression(self, headers: dict, metrics: dict) -> list[Issue]:
"""P-13: Check response compression (Gzip/Brotli)."""
encoding = self._get_header(headers, "Content-Encoding")
if encoding:
metrics["compression"] = encoding.lower()
return []
metrics["compression"] = None
return [self._create_issue(
code="P-13",
severity="minor",
message="응답 압축(Gzip/Brotli)이 적용되지 않았습니다",
suggestion="서버에서 Gzip 또는 Brotli 압축을 활성화하세요",
)]
def _check_mixed_content(self, url: str, html_content: str) -> list[Issue]:
"""P-14: Check for mixed content (HTTP resources on HTTPS page)."""
parsed = urlparse(url)
if parsed.scheme != "https":
return []
soup = BeautifulSoup(html_content, "html5lib")
mixed_elements = []
# Check src attributes
for tag in soup.find_all(["img", "script", "link", "iframe", "audio", "video", "source"]):
src = tag.get("src") or tag.get("href")
if src and src.startswith("http://"):
mixed_elements.append(tag)
if mixed_elements:
return [self._create_issue(
code="P-14",
severity="major",
message=f"혼합 콘텐츠 발견: HTTPS 페이지에서 HTTP 리소스 {len(mixed_elements)}개 로드",
element=self._truncate_element(str(mixed_elements[0])) if mixed_elements else None,
suggestion="모든 리소스를 HTTPS로 변경하세요",
)]
return []
def _calculate_composite_score(self, issues: list[Issue], metrics: dict) -> tuple[int, dict]:
"""
Calculate composite score:
Security (70%): HTTPS/SSL (30%) + Security Headers (40%)
Performance (30%): Response time (40%) + Page size (30%) + Compression (30%)
"""
# Security score
security_score = 100
# HTTPS/SSL component (30% of security)
https_ssl_score = 100
for issue in issues:
if issue.code in ("P-01", "P-02"):
if issue.severity.value == "critical":
https_ssl_score -= 50
elif issue.severity.value == "major":
https_ssl_score -= 25
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
passed_headers = total_header_checks - len(header_issues)
header_score = round(passed_headers / total_header_checks * 100) if total_header_checks else 100
security_score = round(https_ssl_score * 0.43 + header_score * 0.57)
# Performance score
perf_score = 100
# TTFB component (40% of performance)
ttfb = metrics.get("ttfb_ms")
if ttfb is not None:
if ttfb <= 500:
ttfb_score = 100
elif ttfb <= 1000:
ttfb_score = 80
elif ttfb <= 2000:
ttfb_score = 60
else:
ttfb_score = 30
else:
ttfb_score = 50
# Page size component (30% of performance)
page_size = metrics.get("page_size_bytes", 0)
if page_size <= 1024 * 1024: # 1MB
size_score = 100
elif page_size <= 2 * 1024 * 1024: # 2MB
size_score = 80
elif page_size <= 3 * 1024 * 1024: # 3MB
size_score = 60
else:
size_score = 30
# Compression component (30% of performance)
compression = metrics.get("compression")
compression_score = 100 if compression else 50
perf_score = round(ttfb_score * 0.4 + size_score * 0.3 + compression_score * 0.3)
# Composite
overall = round(security_score * 0.7 + perf_score * 0.3)
overall = max(0, min(100, overall))
sub_scores = {
"security": security_score,
"performance": perf_score,
}
return overall, sub_scores
@staticmethod
def _get_header(headers: dict, name: str) -> Optional[str]:
"""Case-insensitive header lookup."""
for key, value in headers.items():
if key.lower() == name.lower():
return value
return None
@staticmethod
def _truncate_element(element_str: str, max_len: int = 200) -> str:
if len(element_str) > max_len:
return element_str[:max_len] + "..."
return element_str