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:
@ -1,7 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Accessibility (WCAG 2.1 AA) Checker Engine (F-003).
|
Accessibility (WCAG) Checker Engine (F-003).
|
||||||
Uses Playwright + axe-core for comprehensive accessibility testing.
|
Uses Playwright + axe-core for comprehensive accessibility testing.
|
||||||
Falls back to BeautifulSoup-based checks if Playwright is unavailable.
|
Falls back to BeautifulSoup-based checks if Playwright is unavailable.
|
||||||
|
Supports WCAG version selection (2.0/2.1/2.2) via rules/accessibility.yaml.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@ -14,6 +15,7 @@ from bs4 import BeautifulSoup
|
|||||||
|
|
||||||
from app.engines.base import BaseChecker
|
from app.engines.base import BaseChecker
|
||||||
from app.models.schemas import CategoryResult, Issue
|
from app.models.schemas import CategoryResult, Issue
|
||||||
|
from app.rules import get_rules
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -24,21 +26,42 @@ AXE_CORE_JS_PATH = Path(__file__).parent / "axe_core" / "axe.min.js"
|
|||||||
AXE_RULE_MESSAGES = {
|
AXE_RULE_MESSAGES = {
|
||||||
"image-alt": ("A-01", "이미지에 대체 텍스트(alt)가 없습니다", "1.1.1"),
|
"image-alt": ("A-01", "이미지에 대체 텍스트(alt)가 없습니다", "1.1.1"),
|
||||||
"color-contrast": ("A-02", "텍스트와 배경의 색상 대비가 부족합니다", "1.4.3"),
|
"color-contrast": ("A-02", "텍스트와 배경의 색상 대비가 부족합니다", "1.4.3"),
|
||||||
|
"color-contrast-enhanced": ("A-02", "텍스트와 배경의 색상 대비가 향상된 기준을 충족하지 않습니다", "1.4.6"),
|
||||||
"keyboard": ("A-03", "키보드로 접근할 수 없는 요소가 있습니다", "2.1.1"),
|
"keyboard": ("A-03", "키보드로 접근할 수 없는 요소가 있습니다", "2.1.1"),
|
||||||
"focus-visible": ("A-04", "키보드 포커스가 시각적으로 표시되지 않습니다", "2.4.7"),
|
"focus-visible": ("A-04", "키보드 포커스가 시각적으로 표시되지 않습니다", "2.4.7"),
|
||||||
"label": ("A-05", "폼 요소에 레이블이 연결되지 않았습니다", "1.3.1"),
|
"label": ("A-05", "폼 요소에 레이블이 연결되지 않았습니다", "1.3.1"),
|
||||||
"input-label": ("A-05", "입력 요소에 레이블이 없습니다", "1.3.1"),
|
"input-label": ("A-05", "입력 요소에 레이블이 없습니다", "1.3.1"),
|
||||||
|
"input-button-name": ("A-05", "입력 버튼에 접근 가능한 이름이 없습니다", "4.1.2"),
|
||||||
|
"select-name": ("A-05", "select 요소에 접근 가능한 이름이 없습니다", "4.1.2"),
|
||||||
"aria-valid-attr": ("A-06", "유효하지 않은 ARIA 속성이 사용되었습니다", "4.1.2"),
|
"aria-valid-attr": ("A-06", "유효하지 않은 ARIA 속성이 사용되었습니다", "4.1.2"),
|
||||||
"aria-roles": ("A-06", "유효하지 않은 ARIA 역할이 사용되었습니다", "4.1.2"),
|
"aria-roles": ("A-06", "유효하지 않은 ARIA 역할이 사용되었습니다", "4.1.2"),
|
||||||
"aria-required-attr": ("A-06", "필수 ARIA 속성이 누락되었습니다", "4.1.2"),
|
"aria-required-attr": ("A-06", "필수 ARIA 속성이 누락되었습니다", "4.1.2"),
|
||||||
"aria-valid-attr-value": ("A-06", "ARIA 속성 값이 올바르지 않습니다", "4.1.2"),
|
"aria-valid-attr-value": ("A-06", "ARIA 속성 값이 올바르지 않습니다", "4.1.2"),
|
||||||
|
"aria-allowed-attr": ("A-06", "허용되지 않는 ARIA 속성이 사용되었습니다", "4.1.2"),
|
||||||
|
"aria-allowed-role": ("A-06", "허용되지 않는 ARIA 역할이 사용되었습니다", "4.1.2"),
|
||||||
|
"aria-hidden-body": ("A-06", "body 요소에 aria-hidden이 설정되어 있습니다", "4.1.2"),
|
||||||
|
"aria-hidden-focus": ("A-06", "aria-hidden 요소 내에 포커스 가능한 요소가 있습니다", "4.1.2"),
|
||||||
"link-name": ("A-07", "링크 텍스트가 목적을 설명하지 않습니다", "2.4.4"),
|
"link-name": ("A-07", "링크 텍스트가 목적을 설명하지 않습니다", "2.4.4"),
|
||||||
"html-has-lang": ("A-08", "HTML 요소에 lang 속성이 없습니다", "3.1.1"),
|
"html-has-lang": ("A-08", "HTML 요소에 lang 속성이 없습니다", "3.1.1"),
|
||||||
"html-lang-valid": ("A-08", "HTML lang 속성 값이 올바르지 않습니다", "3.1.1"),
|
"html-lang-valid": ("A-08", "HTML lang 속성 값이 올바르지 않습니다", "3.1.1"),
|
||||||
|
"valid-lang": ("A-08", "lang 속성 값이 올바르지 않습니다", "3.1.2"),
|
||||||
"bypass": ("A-09", "건너뛰기 링크(skip navigation)가 없습니다", "2.4.1"),
|
"bypass": ("A-09", "건너뛰기 링크(skip navigation)가 없습니다", "2.4.1"),
|
||||||
|
"region": ("A-09", "랜드마크 영역 밖에 콘텐츠가 있습니다", "2.4.1"),
|
||||||
"no-autoplay-audio": ("A-10", "자동 재생 미디어에 정지/음소거 컨트롤이 없습니다", "1.4.2"),
|
"no-autoplay-audio": ("A-10", "자동 재생 미디어에 정지/음소거 컨트롤이 없습니다", "1.4.2"),
|
||||||
"audio-caption": ("A-10", "오디오/비디오에 자막이 없습니다", "1.2.2"),
|
"audio-caption": ("A-10", "오디오/비디오에 자막이 없습니다", "1.2.2"),
|
||||||
"video-caption": ("A-10", "비디오에 자막이 없습니다", "1.2.2"),
|
"video-caption": ("A-10", "비디오에 자막이 없습니다", "1.2.2"),
|
||||||
|
"document-title": ("A-11", "페이지에 제목(title)이 없습니다", "2.4.2"),
|
||||||
|
"empty-heading": ("A-12", "빈 heading 요소가 있습니다", "2.4.6"),
|
||||||
|
"frame-title": ("A-13", "iframe에 접근 가능한 제목이 없습니다", "4.1.2"),
|
||||||
|
"button-name": ("A-14", "버튼에 접근 가능한 이름이 없습니다", "4.1.2"),
|
||||||
|
"meta-refresh": ("A-15", "meta refresh로 시간 제한이 설정되어 있습니다", "2.2.1"),
|
||||||
|
"meta-viewport-large": ("A-16", "사용자의 확대/축소가 제한되어 있습니다", "1.4.4"),
|
||||||
|
"autocomplete-valid": ("A-17", "autocomplete 속성이 올바르지 않습니다", "1.3.5"),
|
||||||
|
"target-size": ("A-18", "터치 대상 크기가 최소 기준을 충족하지 않습니다", "2.5.8"),
|
||||||
|
"scrollable-region-focusable": ("A-19", "스크롤 가능한 영역에 키보드 접근이 불가합니다", "2.1.1"),
|
||||||
|
"tabindex": ("A-20", "tabindex 값이 0보다 크게 설정되어 있습니다", "2.4.3"),
|
||||||
|
"blink": ("A-21", "깜빡이는 콘텐츠가 있습니다", "2.2.2"),
|
||||||
|
"marquee": ("A-21", "자동 스크롤 콘텐츠(marquee)가 있습니다", "2.2.2"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# axe-core impact to severity mapping
|
# axe-core impact to severity mapping
|
||||||
@ -49,9 +72,56 @@ IMPACT_TO_SEVERITY = {
|
|||||||
"minor": "info",
|
"minor": "info",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WCAG version to axe-core tags mapping (loaded from YAML at runtime)
|
||||||
|
WCAG_VERSION_PRESETS = {
|
||||||
|
"2.0_A": ["wcag2a"],
|
||||||
|
"2.0_AA": ["wcag2a", "wcag2aa"],
|
||||||
|
"2.1_AA": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
|
||||||
|
"2.2_AA": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
|
||||||
|
"2.2_full": ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag22aa"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_axe_tags_for_version(wcag_version: str = "2.1_AA") -> list[str]:
|
||||||
|
"""Get axe-core tags for a given WCAG version preset."""
|
||||||
|
rules = get_rules("accessibility")
|
||||||
|
presets = rules.get("compliance_presets", {})
|
||||||
|
|
||||||
|
# Map user-friendly names to YAML preset keys
|
||||||
|
version_map = {
|
||||||
|
"2.0_A": "wcag_20_a",
|
||||||
|
"2.0_AA": "wcag_20_aa",
|
||||||
|
"2.1_AA": "wcag_21_aa",
|
||||||
|
"2.2_AA": "wcag_22_aa",
|
||||||
|
"2.2_full": "wcag_22_full",
|
||||||
|
}
|
||||||
|
|
||||||
|
yaml_key = version_map.get(wcag_version)
|
||||||
|
if yaml_key and yaml_key in presets:
|
||||||
|
return presets[yaml_key].get("tags", WCAG_VERSION_PRESETS.get(wcag_version, ["wcag2a", "wcag2aa"]))
|
||||||
|
|
||||||
|
return WCAG_VERSION_PRESETS.get(wcag_version, ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wcag_level_label(wcag_version: str) -> str:
|
||||||
|
"""Get human-readable WCAG level label."""
|
||||||
|
labels = {
|
||||||
|
"2.0_A": "WCAG 2.0 Level A",
|
||||||
|
"2.0_AA": "WCAG 2.0 Level AA",
|
||||||
|
"2.1_AA": "WCAG 2.1 Level AA",
|
||||||
|
"2.2_AA": "WCAG 2.2 Level AA",
|
||||||
|
"2.2_full": "WCAG 2.2 All Levels",
|
||||||
|
}
|
||||||
|
return labels.get(wcag_version, "WCAG 2.1 Level AA")
|
||||||
|
|
||||||
|
|
||||||
class AccessibilityChecker(BaseChecker):
|
class AccessibilityChecker(BaseChecker):
|
||||||
"""Accessibility (WCAG 2.1 AA) checker engine."""
|
"""Accessibility (WCAG) checker engine with version selection."""
|
||||||
|
|
||||||
|
def __init__(self, progress_callback=None, wcag_version: str = "2.1_AA"):
|
||||||
|
super().__init__(progress_callback)
|
||||||
|
self.wcag_version = wcag_version
|
||||||
|
self.axe_tags = _get_axe_tags_for_version(wcag_version)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def category_name(self) -> str:
|
def category_name(self) -> str:
|
||||||
@ -86,12 +156,10 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
await page.goto(url, wait_until="networkidle", timeout=30000)
|
await page.goto(url, wait_until="networkidle", timeout=30000)
|
||||||
|
|
||||||
await self.update_progress(40, "axe-core 주입 중...")
|
await self.update_progress(40, "axe-core 주입 중...")
|
||||||
# Load axe-core JS
|
|
||||||
if AXE_CORE_JS_PATH.exists() and AXE_CORE_JS_PATH.stat().st_size > 1000:
|
if AXE_CORE_JS_PATH.exists() and AXE_CORE_JS_PATH.stat().st_size > 1000:
|
||||||
axe_js = AXE_CORE_JS_PATH.read_text(encoding="utf-8")
|
axe_js = AXE_CORE_JS_PATH.read_text(encoding="utf-8")
|
||||||
await page.evaluate(axe_js)
|
await page.evaluate(axe_js)
|
||||||
else:
|
else:
|
||||||
# Fallback: load from CDN
|
|
||||||
await page.evaluate("""
|
await page.evaluate("""
|
||||||
async () => {
|
async () => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
@ -104,22 +172,27 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
await self.update_progress(60, "접근성 검사 실행 중...")
|
await self.update_progress(60, f"접근성 검사 실행 중 ({_get_wcag_level_label(self.wcag_version)})...")
|
||||||
axe_results = await page.evaluate("""
|
|
||||||
() => {
|
# Build axe-core tag list including best-practice
|
||||||
return new Promise((resolve, reject) => {
|
axe_tag_list = self.axe_tags + ["best-practice"]
|
||||||
if (typeof axe === 'undefined') {
|
tags_js = json.dumps(axe_tag_list)
|
||||||
|
|
||||||
|
axe_results = await page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
return new Promise((resolve, reject) => {{
|
||||||
|
if (typeof axe === 'undefined') {{
|
||||||
reject(new Error('axe-core not loaded'));
|
reject(new Error('axe-core not loaded'));
|
||||||
return;
|
return;
|
||||||
}
|
}}
|
||||||
axe.run(document, {
|
axe.run(document, {{
|
||||||
runOnly: {
|
runOnly: {{
|
||||||
type: 'tag',
|
type: 'tag',
|
||||||
values: ['wcag2a', 'wcag2aa', 'best-practice']
|
values: {tags_js}
|
||||||
}
|
}}
|
||||||
}).then(resolve).catch(reject);
|
}}).then(resolve).catch(reject);
|
||||||
});
|
}});
|
||||||
}
|
}}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
await self.update_progress(80, "결과 분석 중...")
|
await self.update_progress(80, "결과 분석 중...")
|
||||||
@ -134,7 +207,7 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
category="accessibility",
|
category="accessibility",
|
||||||
score=score,
|
score=score,
|
||||||
issues=issues,
|
issues=issues,
|
||||||
wcag_level="AA",
|
wcag_level=_get_wcag_level_label(self.wcag_version),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_with_beautifulsoup(self, url: str, html_content: str) -> CategoryResult:
|
async def _check_with_beautifulsoup(self, url: str, html_content: str) -> CategoryResult:
|
||||||
@ -170,7 +243,7 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
category="accessibility",
|
category="accessibility",
|
||||||
score=score,
|
score=score,
|
||||||
issues=issues,
|
issues=issues,
|
||||||
wcag_level="AA",
|
wcag_level=_get_wcag_level_label(self.wcag_version),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_axe_results(self, axe_results: dict) -> list[Issue]:
|
def _parse_axe_results(self, axe_results: dict) -> list[Issue]:
|
||||||
@ -182,15 +255,15 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
impact = violation.get("impact", "minor")
|
impact = violation.get("impact", "minor")
|
||||||
severity = IMPACT_TO_SEVERITY.get(impact, "info")
|
severity = IMPACT_TO_SEVERITY.get(impact, "info")
|
||||||
|
|
||||||
# Map to our issue codes
|
|
||||||
if rule_id in AXE_RULE_MESSAGES:
|
if rule_id in AXE_RULE_MESSAGES:
|
||||||
code, korean_msg, wcag = AXE_RULE_MESSAGES[rule_id]
|
code, korean_msg, wcag = AXE_RULE_MESSAGES[rule_id]
|
||||||
else:
|
else:
|
||||||
code = "A-06"
|
code = "A-99"
|
||||||
korean_msg = violation.get("description", "접근성 위반 사항이 발견되었습니다")
|
korean_msg = violation.get("description", "접근성 위반 사항이 발견되었습니다")
|
||||||
wcag = "4.1.2"
|
# Try to extract WCAG criterion from tags
|
||||||
|
tags = violation.get("tags", [])
|
||||||
|
wcag = self._extract_wcag_from_tags(tags)
|
||||||
|
|
||||||
# Get affected elements
|
|
||||||
nodes = violation.get("nodes", [])
|
nodes = violation.get("nodes", [])
|
||||||
element = None
|
element = None
|
||||||
if nodes:
|
if nodes:
|
||||||
@ -211,7 +284,6 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
if ratio:
|
if ratio:
|
||||||
detail = f" (대비율: {ratio}:1, 최소 4.5:1 필요)"
|
detail = f" (대비율: {ratio}:1, 최소 4.5:1 필요)"
|
||||||
|
|
||||||
# Create the issue with node count info
|
|
||||||
node_count = len(nodes)
|
node_count = len(nodes)
|
||||||
count_info = f" ({node_count}개 요소)" if node_count > 1 else ""
|
count_info = f" ({node_count}개 요소)" if node_count > 1 else ""
|
||||||
|
|
||||||
@ -226,11 +298,19 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_wcag_from_tags(tags: list[str]) -> str:
|
||||||
|
"""Extract WCAG criterion number from axe-core tags (e.g., 'wcag111' -> '1.1.1')."""
|
||||||
|
for tag in tags:
|
||||||
|
if tag.startswith("wcag") and not tag.startswith("wcag2"):
|
||||||
|
# e.g., "wcag111" -> "1.1.1"
|
||||||
|
digits = tag[4:]
|
||||||
|
if len(digits) >= 3:
|
||||||
|
return f"{digits[0]}.{digits[1]}.{digits[2:]}"
|
||||||
|
return "4.1.2"
|
||||||
|
|
||||||
def _calculate_axe_score(self, axe_results: dict) -> int:
|
def _calculate_axe_score(self, axe_results: dict) -> int:
|
||||||
"""
|
"""Calculate score based on axe-core violations."""
|
||||||
Calculate score based on axe-core violations.
|
|
||||||
critical=-20, serious=-10, moderate=-5, minor=-2
|
|
||||||
"""
|
|
||||||
severity_weights = {
|
severity_weights = {
|
||||||
"critical": 20,
|
"critical": 20,
|
||||||
"serious": 10,
|
"serious": 10,
|
||||||
@ -284,7 +364,6 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
if inp.get("aria-label") or inp.get("aria-labelledby") or inp.get("title"):
|
if inp.get("aria-label") or inp.get("aria-labelledby") or inp.get("title"):
|
||||||
has_label = True
|
has_label = True
|
||||||
|
|
||||||
# Check if wrapped in label
|
|
||||||
parent_label = inp.find_parent("label")
|
parent_label = inp.find_parent("label")
|
||||||
if parent_label:
|
if parent_label:
|
||||||
has_label = True
|
has_label = True
|
||||||
@ -377,10 +456,9 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
|
|
||||||
def _bs_check_skip_nav(self, soup: BeautifulSoup) -> list[Issue]:
|
def _bs_check_skip_nav(self, soup: BeautifulSoup) -> list[Issue]:
|
||||||
"""A-09: Check for skip navigation link."""
|
"""A-09: Check for skip navigation link."""
|
||||||
# Look for skip nav patterns
|
|
||||||
skip_links = soup.find_all("a", href=True)
|
skip_links = soup.find_all("a", href=True)
|
||||||
has_skip = False
|
has_skip = False
|
||||||
for link in skip_links[:10]: # Check first 10 links
|
for link in skip_links[:10]:
|
||||||
href = link.get("href", "")
|
href = link.get("href", "")
|
||||||
text = link.get_text(strip=True).lower()
|
text = link.get_text(strip=True).lower()
|
||||||
if href.startswith("#") and any(
|
if href.startswith("#") and any(
|
||||||
@ -417,6 +495,6 @@ class AccessibilityChecker(BaseChecker):
|
|||||||
suggestion="autoplay 미디어에 controls 속성을 추가하거나 muted 속성을 사용하세요",
|
suggestion="autoplay 미디어에 controls 속성을 추가하거나 muted 속성을 사용하세요",
|
||||||
wcag_criterion="1.4.2",
|
wcag_criterion="1.4.2",
|
||||||
))
|
))
|
||||||
break # Report only first
|
break
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
HTML/CSS Standards Checker Engine (F-002).
|
HTML/CSS Standards Checker Engine (F-002).
|
||||||
Checks HTML5 validity, semantic tags, CSS inline usage, etc.
|
Checks HTML5 validity, semantic tags, CSS inline usage, etc.
|
||||||
Uses BeautifulSoup4 + html5lib for parsing.
|
Uses BeautifulSoup4 + html5lib for parsing.
|
||||||
|
Rules loaded from rules/html_css.yaml.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@ -13,15 +14,28 @@ from bs4 import BeautifulSoup
|
|||||||
|
|
||||||
from app.engines.base import BaseChecker
|
from app.engines.base import BaseChecker
|
||||||
from app.models.schemas import CategoryResult, Issue
|
from app.models.schemas import CategoryResult, Issue
|
||||||
|
from app.rules import get_rules
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEPRECATED_TAGS = [
|
|
||||||
"font", "center", "marquee", "blink", "strike", "big", "tt",
|
|
||||||
"basefont", "applet", "dir", "isindex",
|
|
||||||
]
|
|
||||||
|
|
||||||
SEMANTIC_TAGS = ["header", "nav", "main", "footer", "section", "article"]
|
def _load_obsolete_elements() -> list[dict]:
|
||||||
|
"""Load obsolete elements from YAML."""
|
||||||
|
rules = get_rules("html_css")
|
||||||
|
return rules.get("obsolete_elements", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_obsolete_attributes() -> dict[str, list[dict]]:
|
||||||
|
"""Load obsolete attributes from YAML, keyed by element name."""
|
||||||
|
rules = get_rules("html_css")
|
||||||
|
return rules.get("obsolete_attributes", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _load_semantic_tags() -> list[str]:
|
||||||
|
"""Load structural semantic tag names from YAML."""
|
||||||
|
rules = get_rules("html_css")
|
||||||
|
structural = rules.get("semantic_elements", {}).get("structural", [])
|
||||||
|
return [item["tag"] for item in structural]
|
||||||
|
|
||||||
|
|
||||||
class HtmlCssChecker(BaseChecker):
|
class HtmlCssChecker(BaseChecker):
|
||||||
@ -50,16 +64,21 @@ class HtmlCssChecker(BaseChecker):
|
|||||||
await self.update_progress(50, "시맨틱 태그 검사 중...")
|
await self.update_progress(50, "시맨틱 태그 검사 중...")
|
||||||
issues += self._check_semantic_tags(soup)
|
issues += self._check_semantic_tags(soup)
|
||||||
|
|
||||||
await self.update_progress(60, "이미지 alt 속성 검사 중...")
|
await self.update_progress(55, "이미지 alt 속성 검사 중...")
|
||||||
issues += self._check_img_alt(soup)
|
issues += self._check_img_alt(soup)
|
||||||
|
|
||||||
await self.update_progress(70, "중복 ID 검사 중...")
|
await self.update_progress(60, "중복 ID 검사 중...")
|
||||||
issues += self._check_duplicate_ids(soup)
|
issues += self._check_duplicate_ids(soup)
|
||||||
|
|
||||||
await self.update_progress(80, "링크 및 스타일 검사 중...")
|
await self.update_progress(65, "링크 및 스타일 검사 중...")
|
||||||
issues += self._check_empty_links(soup)
|
issues += self._check_empty_links(soup)
|
||||||
issues += self._check_inline_styles(soup)
|
issues += self._check_inline_styles(soup)
|
||||||
issues += self._check_deprecated_tags(soup)
|
|
||||||
|
await self.update_progress(75, "Obsolete 태그 검사 중...")
|
||||||
|
issues += self._check_obsolete_tags(soup)
|
||||||
|
|
||||||
|
await self.update_progress(80, "Obsolete 속성 검사 중...")
|
||||||
|
issues += self._check_obsolete_attributes(soup)
|
||||||
|
|
||||||
await self.update_progress(90, "heading 구조 검사 중...")
|
await self.update_progress(90, "heading 구조 검사 중...")
|
||||||
issues += self._check_heading_hierarchy(soup)
|
issues += self._check_heading_hierarchy(soup)
|
||||||
@ -134,21 +153,22 @@ class HtmlCssChecker(BaseChecker):
|
|||||||
|
|
||||||
def _check_semantic_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
def _check_semantic_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||||
"""H-05: Check for semantic HTML5 tag usage."""
|
"""H-05: Check for semantic HTML5 tag usage."""
|
||||||
|
semantic_tags = _load_semantic_tags()
|
||||||
found_tags = set()
|
found_tags = set()
|
||||||
for tag_name in SEMANTIC_TAGS:
|
for tag_name in semantic_tags:
|
||||||
if soup.find(tag_name):
|
if soup.find(tag_name):
|
||||||
found_tags.add(tag_name)
|
found_tags.add(tag_name)
|
||||||
|
|
||||||
if not found_tags:
|
if not found_tags:
|
||||||
|
tag_list = ", ".join(semantic_tags)
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="H-05",
|
code="H-05",
|
||||||
severity="minor",
|
severity="minor",
|
||||||
message="시맨틱 태그가 사용되지 않았습니다 (header, nav, main, footer, section, article)",
|
message=f"시맨틱 태그가 사용되지 않았습니다 ({tag_list})",
|
||||||
suggestion="적절한 시맨틱 태그를 사용하여 문서 구조를 명확히 하세요",
|
suggestion="적절한 시맨틱 태그를 사용하여 문서 구조를 명확히 하세요",
|
||||||
)]
|
)]
|
||||||
|
|
||||||
missing = set(SEMANTIC_TAGS) - found_tags
|
missing = set(semantic_tags) - found_tags
|
||||||
# Only report if major structural elements are missing (main is most important)
|
|
||||||
if "main" in missing:
|
if "main" in missing:
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="H-05",
|
code="H-05",
|
||||||
@ -240,20 +260,102 @@ class HtmlCssChecker(BaseChecker):
|
|||||||
))
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
def _check_deprecated_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
def _check_obsolete_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||||
"""H-10: Check for deprecated HTML tags."""
|
"""H-10: Check for obsolete HTML tags (loaded from YAML)."""
|
||||||
issues = []
|
issues = []
|
||||||
for tag_name in DEPRECATED_TAGS:
|
obsolete = _load_obsolete_elements()
|
||||||
|
|
||||||
|
for entry in obsolete:
|
||||||
|
tag_name = entry["tag"]
|
||||||
found = soup.find_all(tag_name)
|
found = soup.find_all(tag_name)
|
||||||
if found:
|
if found:
|
||||||
|
replacement = entry.get("replacement", "CSS")
|
||||||
|
severity = entry.get("severity", "major")
|
||||||
first_el = found[0]
|
first_el = found[0]
|
||||||
issues.append(self._create_issue(
|
issues.append(self._create_issue(
|
||||||
code="H-10",
|
code="H-10",
|
||||||
severity="major",
|
severity=severity,
|
||||||
message=f"사용 중단된(deprecated) 태그 <{tag_name}>이(가) {len(found)}회 사용되었습니다",
|
message=f"사용 중단된(obsolete) 태그 <{tag_name}>이(가) {len(found)}회 사용되었습니다",
|
||||||
element=self._truncate_element(str(first_el)),
|
element=self._truncate_element(str(first_el)),
|
||||||
line=self._get_line_number(first_el),
|
line=self._get_line_number(first_el),
|
||||||
suggestion=f"<{tag_name}> 대신 CSS를 사용하여 스타일을 적용하세요",
|
suggestion=f"<{tag_name}> 대신 {replacement}을(를) 사용하세요",
|
||||||
|
))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def _check_obsolete_attributes(self, soup: BeautifulSoup) -> list[Issue]:
|
||||||
|
"""H-13: Check for obsolete HTML attributes (loaded from YAML)."""
|
||||||
|
issues = []
|
||||||
|
obsolete_attrs = _load_obsolete_attributes()
|
||||||
|
|
||||||
|
# Check element-specific obsolete attributes
|
||||||
|
element_checks = {
|
||||||
|
"a": "a", "body": "body", "br": "br", "form": "form",
|
||||||
|
"hr": "hr", "html": "html", "iframe": "iframe", "img": "img",
|
||||||
|
"input": "input", "link": "link", "meta": "meta", "script": "script",
|
||||||
|
"style": "style", "table": "table", "embed": "embed",
|
||||||
|
}
|
||||||
|
# Multi-element groups
|
||||||
|
multi_checks = {
|
||||||
|
"td_th": ["td", "th"],
|
||||||
|
"tr": ["tr"],
|
||||||
|
"thead_tbody_tfoot": ["thead", "tbody", "tfoot"],
|
||||||
|
"ol_ul": ["ol", "ul"],
|
||||||
|
"heading": ["h1", "h2", "h3", "h4", "h5", "h6"],
|
||||||
|
"embed": ["embed"],
|
||||||
|
}
|
||||||
|
|
||||||
|
found_count = 0
|
||||||
|
first_element = None
|
||||||
|
first_line = None
|
||||||
|
first_attr = None
|
||||||
|
|
||||||
|
# Single-element checks
|
||||||
|
for yaml_key, html_tag in element_checks.items():
|
||||||
|
attr_list = obsolete_attrs.get(yaml_key, [])
|
||||||
|
for attr_entry in attr_list:
|
||||||
|
attr_name = attr_entry["attr"]
|
||||||
|
elements = soup.find_all(html_tag, attrs={attr_name: True})
|
||||||
|
if elements:
|
||||||
|
found_count += len(elements)
|
||||||
|
if first_element is None:
|
||||||
|
first_element = self._truncate_element(str(elements[0]))
|
||||||
|
first_line = self._get_line_number(elements[0])
|
||||||
|
first_attr = f'{html_tag}[{attr_name}]'
|
||||||
|
|
||||||
|
# Multi-element group checks
|
||||||
|
for yaml_key, html_tags in multi_checks.items():
|
||||||
|
attr_list = obsolete_attrs.get(yaml_key, [])
|
||||||
|
for attr_entry in attr_list:
|
||||||
|
attr_name = attr_entry["attr"]
|
||||||
|
for html_tag in html_tags:
|
||||||
|
elements = soup.find_all(html_tag, attrs={attr_name: True})
|
||||||
|
if elements:
|
||||||
|
found_count += len(elements)
|
||||||
|
if first_element is None:
|
||||||
|
first_element = self._truncate_element(str(elements[0]))
|
||||||
|
first_line = self._get_line_number(elements[0])
|
||||||
|
first_attr = f'{html_tag}[{attr_name}]'
|
||||||
|
|
||||||
|
# Global obsolete attributes
|
||||||
|
global_attrs = obsolete_attrs.get("global", [])
|
||||||
|
for attr_entry in global_attrs:
|
||||||
|
attr_name = attr_entry["attr"]
|
||||||
|
elements = soup.find_all(attrs={attr_name: True})
|
||||||
|
if elements:
|
||||||
|
found_count += len(elements)
|
||||||
|
if first_element is None:
|
||||||
|
first_element = self._truncate_element(str(elements[0]))
|
||||||
|
first_line = self._get_line_number(elements[0])
|
||||||
|
first_attr = attr_name
|
||||||
|
|
||||||
|
if found_count > 0:
|
||||||
|
issues.append(self._create_issue(
|
||||||
|
code="H-13",
|
||||||
|
severity="minor",
|
||||||
|
message=f"사용 중단된(obsolete) HTML 속성이 {found_count}개 발견되었습니다 (예: {first_attr})",
|
||||||
|
element=first_element,
|
||||||
|
line=first_line,
|
||||||
|
suggestion="사용 중단된 HTML 속성 대신 CSS를 사용하세요 (W3C HTML Living Standard 참조)",
|
||||||
))
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
@ -277,7 +379,7 @@ class HtmlCssChecker(BaseChecker):
|
|||||||
line=self._get_line_number(heading),
|
line=self._get_line_number(heading),
|
||||||
suggestion=f"h{prev_level} 다음에는 h{prev_level + 1}을 사용하세요",
|
suggestion=f"h{prev_level} 다음에는 h{prev_level + 1}을 사용하세요",
|
||||||
))
|
))
|
||||||
break # Only report first skip
|
break
|
||||||
prev_level = level
|
prev_level = level
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
@ -295,14 +397,12 @@ class HtmlCssChecker(BaseChecker):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_line_number(element) -> Optional[int]:
|
def _get_line_number(element) -> Optional[int]:
|
||||||
"""Extract source line number from a BeautifulSoup element."""
|
|
||||||
if element and hasattr(element, "sourceline"):
|
if element and hasattr(element, "sourceline"):
|
||||||
return element.sourceline
|
return element.sourceline
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _truncate_element(element_str: str, max_len: int = 200) -> str:
|
def _truncate_element(element_str: str, max_len: int = 200) -> str:
|
||||||
"""Truncate element string for display."""
|
|
||||||
if len(element_str) > max_len:
|
if len(element_str) > max_len:
|
||||||
return element_str[:max_len] + "..."
|
return element_str[:max_len] + "..."
|
||||||
return element_str
|
return element_str
|
||||||
|
|||||||
@ -10,13 +10,14 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.engines.base import BaseChecker
|
from app.engines.base import BaseChecker
|
||||||
from app.models.schemas import CategoryResult, Issue, calculate_grade
|
from app.models.schemas import CategoryResult, Issue, calculate_grade
|
||||||
|
from app.rules import get_rules
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -24,6 +25,32 @@ logger = logging.getLogger(__name__)
|
|||||||
class PerformanceSecurityChecker(BaseChecker):
|
class PerformanceSecurityChecker(BaseChecker):
|
||||||
"""Performance and security checker engine."""
|
"""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
|
@property
|
||||||
def category_name(self) -> str:
|
def category_name(self) -> str:
|
||||||
return "performance_security"
|
return "performance_security"
|
||||||
@ -46,6 +73,12 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
issues += self._check_x_xss_protection(headers)
|
issues += self._check_x_xss_protection(headers)
|
||||||
issues += self._check_referrer_policy(headers)
|
issues += self._check_referrer_policy(headers)
|
||||||
issues += self._check_permissions_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, "응답 시간 측정 중...")
|
await self.update_progress(60, "응답 시간 측정 중...")
|
||||||
issues += await self._check_ttfb(url, metrics)
|
issues += await self._check_ttfb(url, metrics)
|
||||||
@ -163,15 +196,24 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
|
|
||||||
return []
|
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]:
|
def _check_hsts(self, headers: dict) -> list[Issue]:
|
||||||
"""P-03: Check Strict-Transport-Security header."""
|
"""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")
|
hsts = self._get_header(headers, "Strict-Transport-Security")
|
||||||
if not hsts:
|
if not hsts:
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="P-03",
|
code="P-03",
|
||||||
severity="major",
|
severity="major",
|
||||||
message="Strict-Transport-Security(HSTS) 헤더가 설정되지 않았습니다",
|
message="Strict-Transport-Security(HSTS) 헤더가 설정되지 않았습니다",
|
||||||
suggestion="HSTS 헤더를 추가하세요: Strict-Transport-Security: max-age=31536000; includeSubDomains",
|
suggestion=f"HSTS 헤더를 추가하세요: Strict-Transport-Security: {recommended}",
|
||||||
)]
|
)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -225,13 +267,15 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
|
|
||||||
def _check_referrer_policy(self, headers: dict) -> list[Issue]:
|
def _check_referrer_policy(self, headers: dict) -> list[Issue]:
|
||||||
"""P-08: Check Referrer-Policy header."""
|
"""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")
|
rp = self._get_header(headers, "Referrer-Policy")
|
||||||
if not rp:
|
if not rp:
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="P-08",
|
code="P-08",
|
||||||
severity="minor",
|
severity="minor",
|
||||||
message="Referrer-Policy 헤더가 설정되지 않았습니다",
|
message="Referrer-Policy 헤더가 설정되지 않았습니다",
|
||||||
suggestion="Referrer-Policy: strict-origin-when-cross-origin을 설정하세요",
|
suggestion=f"Referrer-Policy: {recommended}을 설정하세요",
|
||||||
)]
|
)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -247,8 +291,69 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
)]
|
)]
|
||||||
return []
|
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]:
|
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:
|
try:
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@ -262,18 +367,18 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
ttfb_ms = round((time.monotonic() - start) * 1000)
|
ttfb_ms = round((time.monotonic() - start) * 1000)
|
||||||
metrics["ttfb_ms"] = ttfb_ms
|
metrics["ttfb_ms"] = ttfb_ms
|
||||||
|
|
||||||
if ttfb_ms > 2000:
|
if ttfb_ms > needs_improvement_ms:
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="P-10",
|
code="P-10",
|
||||||
severity="major",
|
severity="major",
|
||||||
message=f"응답 시간(TTFB)이 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
|
message=f"응답 시간(TTFB)이 느립니다: {ttfb_ms}ms (권장 < {good_ms}ms)",
|
||||||
suggestion="서버 응답 속도를 개선하세요 (캐싱, CDN, 서버 최적화)",
|
suggestion="서버 응답 속도를 개선하세요 (캐싱, CDN, 서버 최적화)",
|
||||||
)]
|
)]
|
||||||
elif ttfb_ms > 1000:
|
elif ttfb_ms > good_ms:
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="P-10",
|
code="P-10",
|
||||||
severity="minor",
|
severity="minor",
|
||||||
message=f"응답 시간(TTFB)이 다소 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
|
message=f"응답 시간(TTFB)이 다소 느립니다: {ttfb_ms}ms (권장 < {good_ms}ms)",
|
||||||
suggestion="서버 응답 속도 개선을 고려하세요",
|
suggestion="서버 응답 속도 개선을 고려하세요",
|
||||||
)]
|
)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -288,15 +393,19 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _check_page_size(self, html_content: str, metrics: dict) -> list[Issue]:
|
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"))
|
size_bytes = len(html_content.encode("utf-8"))
|
||||||
metrics["page_size_bytes"] = size_bytes
|
metrics["page_size_bytes"] = size_bytes
|
||||||
|
|
||||||
if size_bytes > 3 * 1024 * 1024: # 3MB
|
if size_bytes > poor_bytes:
|
||||||
return [self._create_issue(
|
return [self._create_issue(
|
||||||
code="P-11",
|
code="P-11",
|
||||||
severity="minor",
|
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="페이지 크기를 줄이세요 (불필요한 코드 제거, 이미지 최적화, 코드 분할)",
|
suggestion="페이지 크기를 줄이세요 (불필요한 코드 제거, 이미지 최적화, 코드 분할)",
|
||||||
)]
|
)]
|
||||||
return []
|
return []
|
||||||
@ -387,8 +496,9 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
https_ssl_score = max(0, https_ssl_score)
|
https_ssl_score = max(0, https_ssl_score)
|
||||||
|
|
||||||
# Security headers component (40% of security)
|
# 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")]
|
header_codes = {"P-03", "P-04", "P-05", "P-06", "P-07", "P-08", "P-09", "P-15", "P-16", "P-17"}
|
||||||
total_header_checks = 7
|
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)
|
passed_headers = total_header_checks - len(header_issues)
|
||||||
header_score = round(passed_headers / total_header_checks * 100) if total_header_checks else 100
|
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
|
perf_score = 100
|
||||||
|
|
||||||
# TTFB component (40% of performance)
|
# 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")
|
ttfb = metrics.get("ttfb_ms")
|
||||||
if ttfb is not None:
|
if ttfb is not None:
|
||||||
if ttfb <= 500:
|
if ttfb <= ttfb_good // 2:
|
||||||
ttfb_score = 100
|
ttfb_score = 100
|
||||||
elif ttfb <= 1000:
|
elif ttfb <= ttfb_good:
|
||||||
ttfb_score = 80
|
ttfb_score = 80
|
||||||
elif ttfb <= 2000:
|
elif ttfb <= ttfb_ni:
|
||||||
ttfb_score = 60
|
ttfb_score = 60
|
||||||
else:
|
else:
|
||||||
ttfb_score = 30
|
ttfb_score = 30
|
||||||
@ -412,12 +525,16 @@ class PerformanceSecurityChecker(BaseChecker):
|
|||||||
ttfb_score = 50
|
ttfb_score = 50
|
||||||
|
|
||||||
# Page size component (30% of performance)
|
# 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)
|
page_size = metrics.get("page_size_bytes", 0)
|
||||||
if page_size <= 1024 * 1024: # 1MB
|
if page_size <= good_kb * 1024:
|
||||||
size_score = 100
|
size_score = 100
|
||||||
elif page_size <= 2 * 1024 * 1024: # 2MB
|
elif page_size <= ni_kb * 1024:
|
||||||
size_score = 80
|
size_score = 80
|
||||||
elif page_size <= 3 * 1024 * 1024: # 3MB
|
elif page_size <= poor_kb * 1024:
|
||||||
size_score = 60
|
size_score = 60
|
||||||
else:
|
else:
|
||||||
size_score = 30
|
size_score = 30
|
||||||
|
|||||||
@ -7,13 +7,14 @@ import re
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse, urljoin
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.engines.base import BaseChecker
|
from app.engines.base import BaseChecker
|
||||||
from app.models.schemas import CategoryResult, Issue
|
from app.models.schemas import CategoryResult, Issue
|
||||||
|
from app.rules import get_rules
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -21,6 +22,23 @@ logger = logging.getLogger(__name__)
|
|||||||
class SeoChecker(BaseChecker):
|
class SeoChecker(BaseChecker):
|
||||||
"""SEO optimization checker engine."""
|
"""SEO optimization checker engine."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._rules_data = get_rules("seo")
|
||||||
|
|
||||||
|
def _get_seo_rule(self, rule_id: str) -> dict[str, Any]:
|
||||||
|
"""Lookup a rule by id from YAML data."""
|
||||||
|
for rule in self._rules_data.get("rules", []):
|
||||||
|
if rule.get("id") == rule_id:
|
||||||
|
return rule
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_threshold(self, rule_id: str, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a specific threshold from a rule's details."""
|
||||||
|
rule = self._get_seo_rule(rule_id)
|
||||||
|
details = rule.get("details", {})
|
||||||
|
return details.get(key, default)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def category_name(self) -> str:
|
def category_name(self) -> str:
|
||||||
return "seo"
|
return "seo"
|
||||||
@ -73,9 +91,11 @@ class SeoChecker(BaseChecker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _check_title(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
def _check_title(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
||||||
"""S-01: Check title tag existence and length (10-60 chars)."""
|
"""S-01: Check title tag existence and length."""
|
||||||
issues = []
|
issues = []
|
||||||
title = soup.find("title")
|
title = soup.find("title")
|
||||||
|
min_len = self._get_threshold("seo-title-tag", "min_length", 10)
|
||||||
|
max_len = self._get_threshold("seo-title-tag", "max_length", 60)
|
||||||
|
|
||||||
if title is None or not title.string or title.string.strip() == "":
|
if title is None or not title.string or title.string.strip() == "":
|
||||||
meta_info["title"] = None
|
meta_info["title"] = None
|
||||||
@ -84,7 +104,7 @@ class SeoChecker(BaseChecker):
|
|||||||
code="S-01",
|
code="S-01",
|
||||||
severity="critical",
|
severity="critical",
|
||||||
message="<title> 태그가 없거나 비어있습니다",
|
message="<title> 태그가 없거나 비어있습니다",
|
||||||
suggestion="검색 결과에 표시될 10-60자 길이의 페이지 제목을 설정하세요",
|
suggestion=f"검색 결과에 표시될 {min_len}-{max_len}자 길이의 페이지 제목을 설정하세요",
|
||||||
))
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
@ -93,28 +113,30 @@ class SeoChecker(BaseChecker):
|
|||||||
meta_info["title"] = title_text
|
meta_info["title"] = title_text
|
||||||
meta_info["title_length"] = title_len
|
meta_info["title_length"] = title_len
|
||||||
|
|
||||||
if title_len < 10:
|
if title_len < min_len:
|
||||||
issues.append(self._create_issue(
|
issues.append(self._create_issue(
|
||||||
code="S-01",
|
code="S-01",
|
||||||
severity="critical",
|
severity="critical",
|
||||||
message=f"title이 너무 짧습니다 ({title_len}자, 권장 10-60자)",
|
message=f"title이 너무 짧습니다 ({title_len}자, 권장 {min_len}-{max_len}자)",
|
||||||
element=f"<title>{title_text}</title>",
|
element=f"<title>{title_text}</title>",
|
||||||
suggestion="검색 결과에 효과적으로 표시되도록 10자 이상의 제목을 작성하세요",
|
suggestion=f"검색 결과에 효과적으로 표시되도록 {min_len}자 이상의 제목을 작성하세요",
|
||||||
))
|
))
|
||||||
elif title_len > 60:
|
elif title_len > max_len:
|
||||||
issues.append(self._create_issue(
|
issues.append(self._create_issue(
|
||||||
code="S-01",
|
code="S-01",
|
||||||
severity="minor",
|
severity="minor",
|
||||||
message=f"title이 너무 깁니다 ({title_len}자, 권장 10-60자)",
|
message=f"title이 너무 깁니다 ({title_len}자, 권장 {min_len}-{max_len}자)",
|
||||||
element=f"<title>{title_text[:50]}...</title>",
|
element=f"<title>{title_text[:50]}...</title>",
|
||||||
suggestion="검색 결과에서 잘리지 않도록 60자 이내로 제목을 줄이세요",
|
suggestion=f"검색 결과에서 잘리지 않도록 {max_len}자 이내로 제목을 줄이세요",
|
||||||
))
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
def _check_meta_description(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
def _check_meta_description(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
||||||
"""S-02: Check meta description existence and length (50-160 chars)."""
|
"""S-02: Check meta description existence and length."""
|
||||||
issues = []
|
issues = []
|
||||||
desc = soup.find("meta", attrs={"name": re.compile(r"^description$", re.I)})
|
desc = soup.find("meta", attrs={"name": re.compile(r"^description$", re.I)})
|
||||||
|
min_len = self._get_threshold("seo-meta-description", "min_length", 50)
|
||||||
|
max_len = self._get_threshold("seo-meta-description", "max_length", 160)
|
||||||
|
|
||||||
if desc is None or not desc.get("content"):
|
if desc is None or not desc.get("content"):
|
||||||
meta_info["description"] = None
|
meta_info["description"] = None
|
||||||
@ -123,7 +145,7 @@ class SeoChecker(BaseChecker):
|
|||||||
code="S-02",
|
code="S-02",
|
||||||
severity="major",
|
severity="major",
|
||||||
message="meta description이 없습니다",
|
message="meta description이 없습니다",
|
||||||
suggestion='<meta name="description" content="페이지 설명">을 추가하세요 (50-160자 권장)',
|
suggestion=f'<meta name="description" content="페이지 설명">을 추가하세요 ({min_len}-{max_len}자 권장)',
|
||||||
))
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
@ -132,19 +154,19 @@ class SeoChecker(BaseChecker):
|
|||||||
meta_info["description"] = content
|
meta_info["description"] = content
|
||||||
meta_info["description_length"] = content_len
|
meta_info["description_length"] = content_len
|
||||||
|
|
||||||
if content_len < 50:
|
if content_len < min_len:
|
||||||
issues.append(self._create_issue(
|
issues.append(self._create_issue(
|
||||||
code="S-02",
|
code="S-02",
|
||||||
severity="major",
|
severity="major",
|
||||||
message=f"meta description이 너무 짧습니다 ({content_len}자, 권장 50-160자)",
|
message=f"meta description이 너무 짧습니다 ({content_len}자, 권장 {min_len}-{max_len}자)",
|
||||||
suggestion="검색 결과에서 페이지를 효과적으로 설명하도록 50자 이상으로 작성하세요",
|
suggestion=f"검색 결과에서 페이지를 효과적으로 설명하도록 {min_len}자 이상으로 작성하세요",
|
||||||
))
|
))
|
||||||
elif content_len > 160:
|
elif content_len > max_len:
|
||||||
issues.append(self._create_issue(
|
issues.append(self._create_issue(
|
||||||
code="S-02",
|
code="S-02",
|
||||||
severity="minor",
|
severity="minor",
|
||||||
message=f"meta description이 너무 깁니다 ({content_len}자, 권장 50-160자)",
|
message=f"meta description이 너무 깁니다 ({content_len}자, 권장 {min_len}-{max_len}자)",
|
||||||
suggestion="검색 결과에서 잘리지 않도록 160자 이내로 줄이세요",
|
suggestion=f"검색 결과에서 잘리지 않도록 {max_len}자 이내로 줄이세요",
|
||||||
))
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
@ -163,9 +185,13 @@ class SeoChecker(BaseChecker):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _check_og_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
def _check_og_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||||
"""S-04: Check Open Graph tags (og:title, og:description, og:image)."""
|
"""S-04: Check Open Graph tags from YAML rule definitions."""
|
||||||
issues = []
|
issues = []
|
||||||
required_og = ["og:title", "og:description", "og:image"]
|
rule = self._get_seo_rule("seo-open-graph")
|
||||||
|
required_tags = rule.get("details", {}).get("required_tags", [])
|
||||||
|
required_og = [t["property"] for t in required_tags] if required_tags else [
|
||||||
|
"og:title", "og:description", "og:image",
|
||||||
|
]
|
||||||
missing = []
|
missing = []
|
||||||
|
|
||||||
for prop in required_og:
|
for prop in required_og:
|
||||||
|
|||||||
67
backend/app/rules/__init__.py
Normal file
67
backend/app/rules/__init__.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Rules Loader - YAML 기반 표준 규칙 데이터 로드 및 캐싱.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.rules import get_rules
|
||||||
|
rules = get_rules("html_css") # html_css.yaml 전체 로드
|
||||||
|
rules = get_rules("accessibility") # accessibility.yaml 전체 로드
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RULES_DIR = Path(__file__).parent
|
||||||
|
_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_rules(category: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load and cache YAML rules for a given category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: One of "html_css", "accessibility", "seo", "performance_security"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed YAML data as a dictionary.
|
||||||
|
"""
|
||||||
|
if category in _cache:
|
||||||
|
return _cache[category]
|
||||||
|
|
||||||
|
yaml_path = RULES_DIR / f"{category}.yaml"
|
||||||
|
if not yaml_path.exists():
|
||||||
|
logger.error("Rules file not found: %s", yaml_path)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with open(yaml_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
_cache[category] = data
|
||||||
|
logger.info("Loaded rules: %s (%d bytes)", category, yaml_path.stat().st_size)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def reload_rules(category: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Clear cache and reload rules.
|
||||||
|
If category is None, reload all cached rules.
|
||||||
|
"""
|
||||||
|
if category:
|
||||||
|
_cache.pop(category, None)
|
||||||
|
get_rules(category)
|
||||||
|
else:
|
||||||
|
categories = list(_cache.keys())
|
||||||
|
_cache.clear()
|
||||||
|
for cat in categories:
|
||||||
|
get_rules(cat)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_categories() -> list[str]:
|
||||||
|
"""Return list of available rule categories."""
|
||||||
|
return [
|
||||||
|
p.stem for p in RULES_DIR.glob("*.yaml")
|
||||||
|
]
|
||||||
830
backend/app/rules/accessibility.yaml
Normal file
830
backend/app/rules/accessibility.yaml
Normal file
@ -0,0 +1,830 @@
|
|||||||
|
# ============================================================
|
||||||
|
# WCAG Accessibility Rules
|
||||||
|
# Based on: W3C WCAG 2.0, 2.1, 2.2 + axe-core Rule Mapping
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: "WCAG Accessibility Standards"
|
||||||
|
version: "1.0.0"
|
||||||
|
last_updated: "2026-02-13"
|
||||||
|
sources:
|
||||||
|
- name: "Web Content Accessibility Guidelines (WCAG) 2.2"
|
||||||
|
url: "https://www.w3.org/TR/WCAG22/"
|
||||||
|
version: "2.2"
|
||||||
|
date: "2023-10-05"
|
||||||
|
- name: "Web Content Accessibility Guidelines (WCAG) 2.1"
|
||||||
|
url: "https://www.w3.org/TR/WCAG21/"
|
||||||
|
version: "2.1"
|
||||||
|
date: "2018-06-05"
|
||||||
|
- name: "Web Content Accessibility Guidelines (WCAG) 2.0"
|
||||||
|
url: "https://www.w3.org/TR/WCAG20/"
|
||||||
|
version: "2.0"
|
||||||
|
date: "2008-12-11"
|
||||||
|
- name: "axe-core Rule Descriptions"
|
||||||
|
url: "https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md"
|
||||||
|
- name: "axe-core API Documentation"
|
||||||
|
url: "https://www.deque.com/axe/core-documentation/api-documentation/"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# axe-core Tag Mapping
|
||||||
|
# These tags control which rules axe-core runs
|
||||||
|
# IMPORTANT: Tags are NOT inclusive - wcag2aa only runs AA rules,
|
||||||
|
# NOT A rules. Combine tags for full compliance testing.
|
||||||
|
# ============================================================
|
||||||
|
axe_core_tags:
|
||||||
|
wcag20:
|
||||||
|
- tag: "wcag2a"
|
||||||
|
description: "WCAG 2.0 Level A rules only"
|
||||||
|
- tag: "wcag2aa"
|
||||||
|
description: "WCAG 2.0 Level AA rules only"
|
||||||
|
- tag: "wcag2aaa"
|
||||||
|
description: "WCAG 2.0 Level AAA rules only"
|
||||||
|
wcag21:
|
||||||
|
- tag: "wcag21a"
|
||||||
|
description: "WCAG 2.1 Level A rules only (new in 2.1)"
|
||||||
|
- tag: "wcag21aa"
|
||||||
|
description: "WCAG 2.1 Level AA rules only (new in 2.1)"
|
||||||
|
wcag22:
|
||||||
|
- tag: "wcag22aa"
|
||||||
|
description: "WCAG 2.2 Level AA rules only (new in 2.2)"
|
||||||
|
other:
|
||||||
|
- tag: "best-practice"
|
||||||
|
description: "Common accessibility best practices (not WCAG-specific)"
|
||||||
|
- tag: "section508"
|
||||||
|
description: "Section 508 compliance rules"
|
||||||
|
|
||||||
|
# Compliance presets (combine tags for full testing)
|
||||||
|
compliance_presets:
|
||||||
|
wcag_20_a:
|
||||||
|
tags: ["wcag2a"]
|
||||||
|
description: "WCAG 2.0 Level A compliance"
|
||||||
|
wcag_20_aa:
|
||||||
|
tags: ["wcag2a", "wcag2aa"]
|
||||||
|
description: "WCAG 2.0 Level AA compliance"
|
||||||
|
wcag_21_aa:
|
||||||
|
tags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]
|
||||||
|
description: "WCAG 2.1 Level AA compliance (most common requirement)"
|
||||||
|
wcag_22_aa:
|
||||||
|
tags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]
|
||||||
|
description: "WCAG 2.2 Level AA compliance (latest standard)"
|
||||||
|
wcag_22_full:
|
||||||
|
tags: ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag22aa"]
|
||||||
|
description: "WCAG 2.2 all levels including AAA"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# WCAG 2.2 Complete Success Criteria
|
||||||
|
# Total: 86 criteria (4.1.1 Parsing removed in 2.2)
|
||||||
|
# Distribution: Level A (32), Level AA (24), Level AAA (30)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
principles:
|
||||||
|
# ========================================================
|
||||||
|
# Principle 1: PERCEIVABLE
|
||||||
|
# ========================================================
|
||||||
|
- id: "perceivable"
|
||||||
|
name: "Perceivable"
|
||||||
|
description: "Information and user interface components must be presentable to users in ways they can perceive"
|
||||||
|
|
||||||
|
guidelines:
|
||||||
|
# --- 1.1 Text Alternatives ---
|
||||||
|
- id: "1.1"
|
||||||
|
name: "Text Alternatives"
|
||||||
|
description: "Provide text alternatives for any non-text content"
|
||||||
|
criteria:
|
||||||
|
- id: "1.1.1"
|
||||||
|
name: "Non-text Content"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "All non-text content has a text alternative that serves the equivalent purpose"
|
||||||
|
axe_rules: ["image-alt", "input-image-alt", "area-alt", "object-alt", "svg-img-alt"]
|
||||||
|
|
||||||
|
# --- 1.2 Time-based Media ---
|
||||||
|
- id: "1.2"
|
||||||
|
name: "Time-based Media"
|
||||||
|
description: "Provide alternatives for time-based media"
|
||||||
|
criteria:
|
||||||
|
- id: "1.2.1"
|
||||||
|
name: "Audio-only and Video-only (Prerecorded)"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "An alternative is provided for prerecorded audio-only and video-only media"
|
||||||
|
axe_rules: ["audio-caption", "video-caption"]
|
||||||
|
|
||||||
|
- id: "1.2.2"
|
||||||
|
name: "Captions (Prerecorded)"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Captions are provided for all prerecorded audio content in synchronized media"
|
||||||
|
axe_rules: ["video-caption"]
|
||||||
|
|
||||||
|
- id: "1.2.3"
|
||||||
|
name: "Audio Description or Media Alternative (Prerecorded)"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "An alternative for time-based media or audio description is provided"
|
||||||
|
axe_rules: ["video-description"]
|
||||||
|
|
||||||
|
- id: "1.2.4"
|
||||||
|
name: "Captions (Live)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Captions are provided for all live audio content in synchronized media"
|
||||||
|
|
||||||
|
- id: "1.2.5"
|
||||||
|
name: "Audio Description (Prerecorded)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Audio description is provided for all prerecorded video content"
|
||||||
|
|
||||||
|
- id: "1.2.6"
|
||||||
|
name: "Sign Language (Prerecorded)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Sign language interpretation is provided for prerecorded audio"
|
||||||
|
|
||||||
|
- id: "1.2.7"
|
||||||
|
name: "Extended Audio Description (Prerecorded)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Extended audio description is provided when pauses are insufficient"
|
||||||
|
|
||||||
|
- id: "1.2.8"
|
||||||
|
name: "Media Alternative (Prerecorded)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "A text alternative is provided for all prerecorded synchronized media"
|
||||||
|
|
||||||
|
- id: "1.2.9"
|
||||||
|
name: "Audio-only (Live)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "A text alternative is provided for live audio-only content"
|
||||||
|
|
||||||
|
# --- 1.3 Adaptable ---
|
||||||
|
- id: "1.3"
|
||||||
|
name: "Adaptable"
|
||||||
|
description: "Create content that can be presented in different ways without losing information"
|
||||||
|
criteria:
|
||||||
|
- id: "1.3.1"
|
||||||
|
name: "Info and Relationships"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Information, structure, and relationships can be programmatically determined"
|
||||||
|
axe_rules: ["aria-required-parent", "aria-required-children", "definition-list", "dlitem", "list", "listitem", "th-has-data-cells", "td-headers-attr", "p-as-heading"]
|
||||||
|
|
||||||
|
- id: "1.3.2"
|
||||||
|
name: "Meaningful Sequence"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Correct reading sequence can be programmatically determined"
|
||||||
|
|
||||||
|
- id: "1.3.3"
|
||||||
|
name: "Sensory Characteristics"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Instructions do not rely solely on sensory characteristics"
|
||||||
|
|
||||||
|
- id: "1.3.4"
|
||||||
|
name: "Orientation"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Content does not restrict viewing to a single display orientation"
|
||||||
|
|
||||||
|
- id: "1.3.5"
|
||||||
|
name: "Identify Input Purpose"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Input field purpose can be programmatically determined"
|
||||||
|
axe_rules: ["autocomplete-valid"]
|
||||||
|
|
||||||
|
- id: "1.3.6"
|
||||||
|
name: "Identify Purpose"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "The purpose of UI components, icons, and regions can be programmatically determined"
|
||||||
|
|
||||||
|
# --- 1.4 Distinguishable ---
|
||||||
|
- id: "1.4"
|
||||||
|
name: "Distinguishable"
|
||||||
|
description: "Make it easier for users to see and hear content"
|
||||||
|
criteria:
|
||||||
|
- id: "1.4.1"
|
||||||
|
name: "Use of Color"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Color is not the only visual means of conveying information"
|
||||||
|
axe_rules: ["link-in-text-block"]
|
||||||
|
|
||||||
|
- id: "1.4.2"
|
||||||
|
name: "Audio Control"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Mechanism to pause/stop/control volume of auto-playing audio"
|
||||||
|
axe_rules: ["no-autoplay-audio"]
|
||||||
|
|
||||||
|
- id: "1.4.3"
|
||||||
|
name: "Contrast (Minimum)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Text has contrast ratio of at least 4.5:1 (3:1 for large text)"
|
||||||
|
axe_rules: ["color-contrast"]
|
||||||
|
|
||||||
|
- id: "1.4.4"
|
||||||
|
name: "Resize Text"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Text can be resized up to 200% without loss of content or functionality"
|
||||||
|
axe_rules: ["meta-viewport-large"]
|
||||||
|
|
||||||
|
- id: "1.4.5"
|
||||||
|
name: "Images of Text"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Text is used instead of images of text where possible"
|
||||||
|
|
||||||
|
- id: "1.4.6"
|
||||||
|
name: "Contrast (Enhanced)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Text has contrast ratio of at least 7:1 (4.5:1 for large text)"
|
||||||
|
axe_rules: ["color-contrast-enhanced"]
|
||||||
|
|
||||||
|
- id: "1.4.7"
|
||||||
|
name: "Low or No Background Audio"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Prerecorded speech audio has low or no background noise"
|
||||||
|
|
||||||
|
- id: "1.4.8"
|
||||||
|
name: "Visual Presentation"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Text blocks have configurable visual presentation"
|
||||||
|
|
||||||
|
- id: "1.4.9"
|
||||||
|
name: "Images of Text (No Exception)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Images of text are only used for pure decoration or essential cases"
|
||||||
|
|
||||||
|
- id: "1.4.10"
|
||||||
|
name: "Reflow"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Content can reflow without scrolling in two dimensions at 320px/256px"
|
||||||
|
|
||||||
|
- id: "1.4.11"
|
||||||
|
name: "Non-text Contrast"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "UI components and graphics have contrast ratio of at least 3:1"
|
||||||
|
|
||||||
|
- id: "1.4.12"
|
||||||
|
name: "Text Spacing"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Content adapts to specified text spacing without loss"
|
||||||
|
|
||||||
|
- id: "1.4.13"
|
||||||
|
name: "Content on Hover or Focus"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Hoverable/focusable additional content is dismissible, hoverable, persistent"
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# Principle 2: OPERABLE
|
||||||
|
# ========================================================
|
||||||
|
- id: "operable"
|
||||||
|
name: "Operable"
|
||||||
|
description: "User interface components and navigation must be operable"
|
||||||
|
|
||||||
|
guidelines:
|
||||||
|
# --- 2.1 Keyboard Accessible ---
|
||||||
|
- id: "2.1"
|
||||||
|
name: "Keyboard Accessible"
|
||||||
|
description: "Make all functionality available from a keyboard"
|
||||||
|
criteria:
|
||||||
|
- id: "2.1.1"
|
||||||
|
name: "Keyboard"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "All functionality is operable through a keyboard interface"
|
||||||
|
axe_rules: ["scrollable-region-focusable"]
|
||||||
|
|
||||||
|
- id: "2.1.2"
|
||||||
|
name: "No Keyboard Trap"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Keyboard focus can be moved away from any component"
|
||||||
|
|
||||||
|
- id: "2.1.3"
|
||||||
|
name: "Keyboard (No Exception)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "All functionality is operable through keyboard without exception"
|
||||||
|
|
||||||
|
- id: "2.1.4"
|
||||||
|
name: "Character Key Shortcuts"
|
||||||
|
level: "A"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Single character key shortcuts can be turned off or remapped"
|
||||||
|
axe_rules: ["accesskeys"]
|
||||||
|
|
||||||
|
# --- 2.2 Enough Time ---
|
||||||
|
- id: "2.2"
|
||||||
|
name: "Enough Time"
|
||||||
|
description: "Provide users enough time to read and use content"
|
||||||
|
criteria:
|
||||||
|
- id: "2.2.1"
|
||||||
|
name: "Timing Adjustable"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Time limits can be turned off, adjusted, or extended"
|
||||||
|
axe_rules: ["meta-refresh"]
|
||||||
|
|
||||||
|
- id: "2.2.2"
|
||||||
|
name: "Pause, Stop, Hide"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Moving, blinking, scrolling, or auto-updating content can be controlled"
|
||||||
|
axe_rules: ["blink", "marquee"]
|
||||||
|
|
||||||
|
- id: "2.2.3"
|
||||||
|
name: "No Timing"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Timing is not an essential part of the activity"
|
||||||
|
|
||||||
|
- id: "2.2.4"
|
||||||
|
name: "Interruptions"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Interruptions can be postponed or suppressed"
|
||||||
|
|
||||||
|
- id: "2.2.5"
|
||||||
|
name: "Re-authenticating"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Data is preserved when re-authenticating after session expiry"
|
||||||
|
|
||||||
|
- id: "2.2.6"
|
||||||
|
name: "Timeouts"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Users are warned about data loss from inactivity timeouts"
|
||||||
|
|
||||||
|
# --- 2.3 Seizures and Physical Reactions ---
|
||||||
|
- id: "2.3"
|
||||||
|
name: "Seizures and Physical Reactions"
|
||||||
|
description: "Do not design content that causes seizures or physical reactions"
|
||||||
|
criteria:
|
||||||
|
- id: "2.3.1"
|
||||||
|
name: "Three Flashes or Below Threshold"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Pages do not contain content that flashes more than three times per second"
|
||||||
|
|
||||||
|
- id: "2.3.2"
|
||||||
|
name: "Three Flashes"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Pages do not contain any content that flashes more than three times per second"
|
||||||
|
|
||||||
|
- id: "2.3.3"
|
||||||
|
name: "Animation from Interactions"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Motion animation triggered by interaction can be disabled"
|
||||||
|
|
||||||
|
# --- 2.4 Navigable ---
|
||||||
|
- id: "2.4"
|
||||||
|
name: "Navigable"
|
||||||
|
description: "Provide ways to help users navigate, find content, and determine where they are"
|
||||||
|
criteria:
|
||||||
|
- id: "2.4.1"
|
||||||
|
name: "Bypass Blocks"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Mechanism available to bypass blocks of content repeated on pages"
|
||||||
|
axe_rules: ["bypass", "region"]
|
||||||
|
|
||||||
|
- id: "2.4.2"
|
||||||
|
name: "Page Titled"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Web pages have titles that describe topic or purpose"
|
||||||
|
axe_rules: ["document-title"]
|
||||||
|
|
||||||
|
- id: "2.4.3"
|
||||||
|
name: "Focus Order"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Focus order preserves meaning and operability"
|
||||||
|
axe_rules: ["tabindex"]
|
||||||
|
|
||||||
|
- id: "2.4.4"
|
||||||
|
name: "Link Purpose (In Context)"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Link purpose can be determined from link text or context"
|
||||||
|
axe_rules: ["link-name"]
|
||||||
|
|
||||||
|
- id: "2.4.5"
|
||||||
|
name: "Multiple Ways"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "More than one way available to locate a page in a set"
|
||||||
|
|
||||||
|
- id: "2.4.6"
|
||||||
|
name: "Headings and Labels"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Headings and labels describe topic or purpose"
|
||||||
|
axe_rules: ["empty-heading"]
|
||||||
|
|
||||||
|
- id: "2.4.7"
|
||||||
|
name: "Focus Visible"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Keyboard focus indicator is visible"
|
||||||
|
|
||||||
|
- id: "2.4.8"
|
||||||
|
name: "Location"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Information about the user's location within a set of pages is available"
|
||||||
|
|
||||||
|
- id: "2.4.9"
|
||||||
|
name: "Link Purpose (Link Only)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Link purpose can be determined from link text alone"
|
||||||
|
|
||||||
|
- id: "2.4.10"
|
||||||
|
name: "Section Headings"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Section headings are used to organize content"
|
||||||
|
|
||||||
|
- id: "2.4.11"
|
||||||
|
name: "Focus Not Obscured (Minimum)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Focused component is not entirely hidden by author-created content"
|
||||||
|
|
||||||
|
- id: "2.4.12"
|
||||||
|
name: "Focus Not Obscured (Enhanced)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "No part of the focused component is hidden by author-created content"
|
||||||
|
|
||||||
|
- id: "2.4.13"
|
||||||
|
name: "Focus Appearance"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Focus indicator meets minimum area and contrast requirements"
|
||||||
|
|
||||||
|
# --- 2.5 Input Modalities ---
|
||||||
|
- id: "2.5"
|
||||||
|
name: "Input Modalities"
|
||||||
|
description: "Make it easier to operate through various inputs beyond keyboard"
|
||||||
|
criteria:
|
||||||
|
- id: "2.5.1"
|
||||||
|
name: "Pointer Gestures"
|
||||||
|
level: "A"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Multipoint/path-based gestures have single-pointer alternatives"
|
||||||
|
|
||||||
|
- id: "2.5.2"
|
||||||
|
name: "Pointer Cancellation"
|
||||||
|
level: "A"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Functions using single pointer can be cancelled"
|
||||||
|
|
||||||
|
- id: "2.5.3"
|
||||||
|
name: "Label in Name"
|
||||||
|
level: "A"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Visible label is part of the accessible name"
|
||||||
|
axe_rules: ["label-title-only"]
|
||||||
|
|
||||||
|
- id: "2.5.4"
|
||||||
|
name: "Motion Actuation"
|
||||||
|
level: "A"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Motion-activated functions have UI alternatives and can be disabled"
|
||||||
|
|
||||||
|
- id: "2.5.5"
|
||||||
|
name: "Target Size (Enhanced)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Target size is at least 44 by 44 CSS pixels"
|
||||||
|
|
||||||
|
- id: "2.5.6"
|
||||||
|
name: "Concurrent Input Mechanisms"
|
||||||
|
level: "A"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Content does not restrict use of available input modalities"
|
||||||
|
|
||||||
|
- id: "2.5.7"
|
||||||
|
name: "Dragging Movements"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Drag functions have single-pointer alternatives"
|
||||||
|
|
||||||
|
- id: "2.5.8"
|
||||||
|
name: "Target Size (Minimum)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Target size is at least 24 by 24 CSS pixels"
|
||||||
|
axe_rules: ["target-size"]
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# Principle 3: UNDERSTANDABLE
|
||||||
|
# ========================================================
|
||||||
|
- id: "understandable"
|
||||||
|
name: "Understandable"
|
||||||
|
description: "Information and the operation of user interface must be understandable"
|
||||||
|
|
||||||
|
guidelines:
|
||||||
|
# --- 3.1 Readable ---
|
||||||
|
- id: "3.1"
|
||||||
|
name: "Readable"
|
||||||
|
description: "Make text content readable and understandable"
|
||||||
|
criteria:
|
||||||
|
- id: "3.1.1"
|
||||||
|
name: "Language of Page"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Default human language of each page can be programmatically determined"
|
||||||
|
axe_rules: ["html-has-lang", "html-lang-valid"]
|
||||||
|
|
||||||
|
- id: "3.1.2"
|
||||||
|
name: "Language of Parts"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Language of each passage or phrase can be programmatically determined"
|
||||||
|
axe_rules: ["valid-lang"]
|
||||||
|
|
||||||
|
- id: "3.1.3"
|
||||||
|
name: "Unusual Words"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Mechanism is available for unusual words or jargon"
|
||||||
|
|
||||||
|
- id: "3.1.4"
|
||||||
|
name: "Abbreviations"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Mechanism for identifying expanded form of abbreviations"
|
||||||
|
|
||||||
|
- id: "3.1.5"
|
||||||
|
name: "Reading Level"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Supplemental content for text beyond lower secondary education level"
|
||||||
|
|
||||||
|
- id: "3.1.6"
|
||||||
|
name: "Pronunciation"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Mechanism for identifying pronunciation of ambiguous words"
|
||||||
|
|
||||||
|
# --- 3.2 Predictable ---
|
||||||
|
- id: "3.2"
|
||||||
|
name: "Predictable"
|
||||||
|
description: "Make web pages appear and operate in predictable ways"
|
||||||
|
criteria:
|
||||||
|
- id: "3.2.1"
|
||||||
|
name: "On Focus"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Receiving focus does not initiate a change of context"
|
||||||
|
|
||||||
|
- id: "3.2.2"
|
||||||
|
name: "On Input"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Changing a UI component setting does not automatically cause a change of context"
|
||||||
|
|
||||||
|
- id: "3.2.3"
|
||||||
|
name: "Consistent Navigation"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Navigation repeated on pages occurs in the same relative order"
|
||||||
|
|
||||||
|
- id: "3.2.4"
|
||||||
|
name: "Consistent Identification"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Components with the same functionality are identified consistently"
|
||||||
|
|
||||||
|
- id: "3.2.5"
|
||||||
|
name: "Change on Request"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Changes of context are initiated only by user request"
|
||||||
|
|
||||||
|
- id: "3.2.6"
|
||||||
|
name: "Consistent Help"
|
||||||
|
level: "A"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Help mechanisms occur in the same relative order across pages"
|
||||||
|
|
||||||
|
# --- 3.3 Input Assistance ---
|
||||||
|
- id: "3.3"
|
||||||
|
name: "Input Assistance"
|
||||||
|
description: "Help users avoid and correct mistakes"
|
||||||
|
criteria:
|
||||||
|
- id: "3.3.1"
|
||||||
|
name: "Error Identification"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Input errors are automatically detected and described to the user"
|
||||||
|
axe_rules: ["aria-input-field-name"]
|
||||||
|
|
||||||
|
- id: "3.3.2"
|
||||||
|
name: "Labels or Instructions"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Labels or instructions are provided when content requires user input"
|
||||||
|
axe_rules: ["label", "input-button-name", "select-name"]
|
||||||
|
|
||||||
|
- id: "3.3.3"
|
||||||
|
name: "Error Suggestion"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Error suggestions are provided when errors are detected and suggestions are known"
|
||||||
|
|
||||||
|
- id: "3.3.4"
|
||||||
|
name: "Error Prevention (Legal, Financial, Data)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Submissions are reversible, checked, or confirmed for legal/financial/data"
|
||||||
|
|
||||||
|
- id: "3.3.5"
|
||||||
|
name: "Help"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Context-sensitive help is available"
|
||||||
|
|
||||||
|
- id: "3.3.6"
|
||||||
|
name: "Error Prevention (All)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Submissions are reversible, checked, or confirmed for all user input"
|
||||||
|
|
||||||
|
- id: "3.3.7"
|
||||||
|
name: "Redundant Entry"
|
||||||
|
level: "A"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Previously entered information is auto-populated or available for selection"
|
||||||
|
|
||||||
|
- id: "3.3.8"
|
||||||
|
name: "Accessible Authentication (Minimum)"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "Cognitive function test is not required for authentication"
|
||||||
|
|
||||||
|
- id: "3.3.9"
|
||||||
|
name: "Accessible Authentication (Enhanced)"
|
||||||
|
level: "AAA"
|
||||||
|
since: "2.2"
|
||||||
|
description: "No cognitive function test is required for authentication (no exceptions)"
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# Principle 4: ROBUST
|
||||||
|
# ========================================================
|
||||||
|
- id: "robust"
|
||||||
|
name: "Robust"
|
||||||
|
description: "Content must be robust enough to be interpreted by a wide variety of user agents"
|
||||||
|
|
||||||
|
guidelines:
|
||||||
|
# --- 4.1 Compatible ---
|
||||||
|
- id: "4.1"
|
||||||
|
name: "Compatible"
|
||||||
|
description: "Maximize compatibility with current and future user agents"
|
||||||
|
criteria:
|
||||||
|
# Note: 4.1.1 Parsing was REMOVED in WCAG 2.2
|
||||||
|
# It was deemed obsolete as modern browsers handle parsing errors gracefully
|
||||||
|
|
||||||
|
- id: "4.1.2"
|
||||||
|
name: "Name, Role, Value"
|
||||||
|
level: "A"
|
||||||
|
since: "2.0"
|
||||||
|
description: "Name, role, and value of all UI components can be programmatically determined"
|
||||||
|
axe_rules: ["aria-allowed-attr", "aria-allowed-role", "aria-hidden-body", "aria-hidden-focus", "aria-roles", "aria-valid-attr", "aria-valid-attr-value", "button-name", "frame-title", "image-alt", "input-button-name", "input-image-alt", "label", "link-name", "select-name"]
|
||||||
|
|
||||||
|
- id: "4.1.3"
|
||||||
|
name: "Status Messages"
|
||||||
|
level: "AA"
|
||||||
|
since: "2.1"
|
||||||
|
description: "Status messages can be programmatically determined without receiving focus"
|
||||||
|
axe_rules: ["aria-progressbar-name"]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Version Diff Summary
|
||||||
|
# What's new in each version
|
||||||
|
# ============================================================
|
||||||
|
version_diff:
|
||||||
|
removed_in_22:
|
||||||
|
- id: "4.1.1"
|
||||||
|
name: "Parsing"
|
||||||
|
reason: "Modern browsers handle parsing errors; criterion was obsolete"
|
||||||
|
|
||||||
|
new_in_21:
|
||||||
|
level_a:
|
||||||
|
- "1.3.4 Orientation" # Note: Listed as A in some sources, AA in W3C spec
|
||||||
|
- "2.1.4 Character Key Shortcuts"
|
||||||
|
- "2.5.1 Pointer Gestures"
|
||||||
|
- "2.5.2 Pointer Cancellation"
|
||||||
|
- "2.5.3 Label in Name"
|
||||||
|
- "2.5.4 Motion Actuation"
|
||||||
|
- "2.5.6 Concurrent Input Mechanisms"
|
||||||
|
level_aa:
|
||||||
|
- "1.3.4 Orientation"
|
||||||
|
- "1.3.5 Identify Input Purpose"
|
||||||
|
- "1.4.10 Reflow"
|
||||||
|
- "1.4.11 Non-text Contrast"
|
||||||
|
- "1.4.12 Text Spacing"
|
||||||
|
- "1.4.13 Content on Hover or Focus"
|
||||||
|
level_aaa:
|
||||||
|
- "1.3.6 Identify Purpose"
|
||||||
|
- "2.2.6 Timeouts"
|
||||||
|
- "2.3.3 Animation from Interactions"
|
||||||
|
- "2.5.5 Target Size (Enhanced)"
|
||||||
|
|
||||||
|
new_in_22:
|
||||||
|
level_a:
|
||||||
|
- "3.2.6 Consistent Help"
|
||||||
|
- "3.3.7 Redundant Entry"
|
||||||
|
level_aa:
|
||||||
|
- "2.4.11 Focus Not Obscured (Minimum)"
|
||||||
|
- "2.5.7 Dragging Movements"
|
||||||
|
- "2.5.8 Target Size (Minimum)"
|
||||||
|
- "3.3.8 Accessible Authentication (Minimum)"
|
||||||
|
level_aaa:
|
||||||
|
- "2.4.12 Focus Not Obscured (Enhanced)"
|
||||||
|
- "2.4.13 Focus Appearance"
|
||||||
|
- "3.3.9 Accessible Authentication (Enhanced)"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Additional axe-core Best Practice Rules
|
||||||
|
# (Not mapped to specific WCAG criteria but recommended)
|
||||||
|
# ============================================================
|
||||||
|
best_practices:
|
||||||
|
- id: "landmark-one-main"
|
||||||
|
description: "Document should have one main landmark"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "landmark-complementary-is-top-level"
|
||||||
|
description: "Aside/complementary should be top-level"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "landmark-no-duplicate-banner"
|
||||||
|
description: "Document should have at most one banner landmark"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "landmark-no-duplicate-contentinfo"
|
||||||
|
description: "Document should have at most one contentinfo landmark"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "landmark-no-duplicate-main"
|
||||||
|
description: "Document should have at most one main landmark"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "page-has-heading-one"
|
||||||
|
description: "Page should contain a level-one heading"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "heading-order"
|
||||||
|
description: "Heading levels should increase by one"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "scope-attr-valid"
|
||||||
|
description: "scope attribute should be used correctly"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "skip-link"
|
||||||
|
description: "Skip navigation link should be provided"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "tabindex"
|
||||||
|
description: "Tabindex should not be greater than zero"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "duplicate-id-active"
|
||||||
|
description: "Active elements should not have duplicate IDs"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- id: "duplicate-id-aria"
|
||||||
|
description: "ARIA IDs should be unique"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- id: "frame-tested"
|
||||||
|
description: "Frames should be tested with axe-core"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "aria-text"
|
||||||
|
description: "Elements with role=text should have no focusable descendants"
|
||||||
|
severity: "minor"
|
||||||
821
backend/app/rules/html_css.yaml
Normal file
821
backend/app/rules/html_css.yaml
Normal file
@ -0,0 +1,821 @@
|
|||||||
|
# ============================================================
|
||||||
|
# HTML/CSS Web Standards Rules
|
||||||
|
# Based on: W3C HTML Living Standard (WHATWG), CSS Specifications
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: "HTML/CSS Web Standards"
|
||||||
|
version: "1.0.0"
|
||||||
|
last_updated: "2026-02-13"
|
||||||
|
sources:
|
||||||
|
- name: "HTML Living Standard (WHATWG)"
|
||||||
|
url: "https://html.spec.whatwg.org/multipage/"
|
||||||
|
section: "16 Obsolete features"
|
||||||
|
- name: "HTML Living Standard - Obsolete Features"
|
||||||
|
url: "https://html.spec.whatwg.org/multipage/obsolete.html"
|
||||||
|
- name: "MDN Web Docs - HTML Elements Reference"
|
||||||
|
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element"
|
||||||
|
- name: "W3C CSS Specifications"
|
||||||
|
url: "https://www.w3.org/Style/CSS/"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. Obsolete (Non-Conforming) HTML Elements
|
||||||
|
# Source: HTML Living Standard Section 16
|
||||||
|
# ============================================================
|
||||||
|
obsolete_elements:
|
||||||
|
# --- Entirely Obsolete (must not be used) ---
|
||||||
|
- tag: "applet"
|
||||||
|
replacement: "embed or object"
|
||||||
|
reason: "Outdated plugin technology (Java applets)"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "acronym"
|
||||||
|
replacement: "abbr"
|
||||||
|
reason: "Redundant; abbr covers both abbreviations and acronyms"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "bgsound"
|
||||||
|
replacement: "audio"
|
||||||
|
reason: "Proprietary (IE-only) audio element"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "dir"
|
||||||
|
replacement: "ul"
|
||||||
|
reason: "Non-standard directory list"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "frame"
|
||||||
|
replacement: "iframe with CSS, or server-side includes"
|
||||||
|
reason: "Frame-based layouts are obsolete"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "frameset"
|
||||||
|
replacement: "iframe with CSS, or server-side includes"
|
||||||
|
reason: "Frame-based layouts are obsolete"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "noframes"
|
||||||
|
replacement: "N/A (remove with frame/frameset)"
|
||||||
|
reason: "Related to obsolete frames"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "isindex"
|
||||||
|
replacement: "form with input[type=text]"
|
||||||
|
reason: "Outdated form method"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "keygen"
|
||||||
|
replacement: "Web Cryptography API"
|
||||||
|
reason: "Certificate enrollment; use Web Crypto API"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "listing"
|
||||||
|
replacement: "pre + code"
|
||||||
|
reason: "Obsolete code presentation element"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "menuitem"
|
||||||
|
replacement: "Script handling contextmenu event"
|
||||||
|
reason: "Context menu item (never widely supported)"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "nextid"
|
||||||
|
replacement: "GUIDs or UUIDs"
|
||||||
|
reason: "Obsolete identifier generation"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "noembed"
|
||||||
|
replacement: "object instead of embed"
|
||||||
|
reason: "Fallback for embed; use object element"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "param"
|
||||||
|
replacement: "data attribute on object"
|
||||||
|
reason: "Object parameter passing"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "plaintext"
|
||||||
|
replacement: "MIME type text/plain"
|
||||||
|
reason: "Obsolete text rendering mode"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "rb"
|
||||||
|
replacement: "ruby element directly"
|
||||||
|
reason: "Ruby base text (use ruby directly)"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "rtc"
|
||||||
|
replacement: "Nested ruby elements"
|
||||||
|
reason: "Ruby text container (use nested ruby)"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "strike"
|
||||||
|
replacement: "del (edits) or s (no longer relevant)"
|
||||||
|
reason: "Presentational strikethrough"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "xmp"
|
||||||
|
replacement: "pre + code with escaped entities"
|
||||||
|
reason: "Obsolete code display element"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
# --- Presentational Elements (use CSS instead) ---
|
||||||
|
- tag: "basefont"
|
||||||
|
replacement: "CSS font properties"
|
||||||
|
reason: "Base font styling (presentational)"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "big"
|
||||||
|
replacement: "CSS font-size or semantic elements (h1-h6, strong, mark)"
|
||||||
|
reason: "Presentational text sizing"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "blink"
|
||||||
|
replacement: "CSS animations/transitions"
|
||||||
|
reason: "Presentational text animation"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "center"
|
||||||
|
replacement: "CSS text-align or margin auto"
|
||||||
|
reason: "Presentational centering"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "font"
|
||||||
|
replacement: "CSS font properties"
|
||||||
|
reason: "Presentational font styling"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "marquee"
|
||||||
|
replacement: "CSS animations/transitions"
|
||||||
|
reason: "Presentational scrolling text"
|
||||||
|
severity: "critical"
|
||||||
|
|
||||||
|
- tag: "multicol"
|
||||||
|
replacement: "CSS columns"
|
||||||
|
reason: "Presentational multi-column layout"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "nobr"
|
||||||
|
replacement: "CSS white-space: nowrap"
|
||||||
|
reason: "Presentational no-break text"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- tag: "spacer"
|
||||||
|
replacement: "CSS margin/padding"
|
||||||
|
reason: "Presentational spacing"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- tag: "tt"
|
||||||
|
replacement: "kbd, var, code, or samp (context-dependent)"
|
||||||
|
reason: "Presentational monospace text"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Obsolete HTML Attributes
|
||||||
|
# Source: HTML Living Standard Section 16
|
||||||
|
# ============================================================
|
||||||
|
obsolete_attributes:
|
||||||
|
# --- Global Attributes ---
|
||||||
|
global:
|
||||||
|
- attr: "contextmenu"
|
||||||
|
replacement: "Script for contextmenu event"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "datasrc"
|
||||||
|
replacement: "XMLHttpRequest / Fetch API"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "datafld"
|
||||||
|
replacement: "XMLHttpRequest / Fetch API"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "dataformatas"
|
||||||
|
replacement: "XMLHttpRequest / Fetch API"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "dropzone"
|
||||||
|
replacement: "Script for drag/drop events"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
# --- Element-Specific Attributes ---
|
||||||
|
a:
|
||||||
|
- attr: "charset"
|
||||||
|
replacement: "HTTP Content-Type header"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "coords"
|
||||||
|
replacement: "area element for image maps"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "shape"
|
||||||
|
replacement: "area element for image maps"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "methods"
|
||||||
|
replacement: "HTTP OPTIONS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "name"
|
||||||
|
replacement: "id attribute"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "rev"
|
||||||
|
replacement: "rel with opposite term"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "urn"
|
||||||
|
replacement: "href attribute"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
body:
|
||||||
|
- attr: "alink"
|
||||||
|
replacement: "CSS :active pseudo-class"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "bgcolor"
|
||||||
|
replacement: "CSS background-color"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "bottommargin"
|
||||||
|
replacement: "CSS margin-bottom"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "leftmargin"
|
||||||
|
replacement: "CSS margin-left"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "link"
|
||||||
|
replacement: "CSS color for links"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "marginheight"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "marginwidth"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "rightmargin"
|
||||||
|
replacement: "CSS margin-right"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "text"
|
||||||
|
replacement: "CSS color"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "topmargin"
|
||||||
|
replacement: "CSS margin-top"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "vlink"
|
||||||
|
replacement: "CSS :visited pseudo-class"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
br:
|
||||||
|
- attr: "clear"
|
||||||
|
replacement: "CSS clear property"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
form:
|
||||||
|
- attr: "accept"
|
||||||
|
replacement: "accept attribute on individual input elements"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
head:
|
||||||
|
- attr: "profile"
|
||||||
|
replacement: "Omit (unnecessary)"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
hr:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "color"
|
||||||
|
replacement: "CSS border-color / background-color"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "noshade"
|
||||||
|
replacement: "CSS border/background"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "size"
|
||||||
|
replacement: "CSS height"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "width"
|
||||||
|
replacement: "CSS width"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
html:
|
||||||
|
- attr: "manifest"
|
||||||
|
replacement: "Service Workers"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "version"
|
||||||
|
replacement: "Omit (unnecessary)"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
iframe:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "allowtransparency"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "frameborder"
|
||||||
|
replacement: "CSS border"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "framespacing"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "hspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "longdesc"
|
||||||
|
replacement: "Link to description page"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "marginheight"
|
||||||
|
replacement: "CSS padding"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "marginwidth"
|
||||||
|
replacement: "CSS padding"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "scrolling"
|
||||||
|
replacement: "CSS overflow"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "vspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
img:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS float or vertical-align"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "border"
|
||||||
|
replacement: "CSS border"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "hspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "lowsrc"
|
||||||
|
replacement: "Progressive JPEG or srcset"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "name"
|
||||||
|
replacement: "id attribute"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "vspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
input:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "border"
|
||||||
|
replacement: "CSS border"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "hspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "vspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
link:
|
||||||
|
- attr: "charset"
|
||||||
|
replacement: "HTTP Content-Type header"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "methods"
|
||||||
|
replacement: "HTTP OPTIONS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "rev"
|
||||||
|
replacement: "rel with opposite term"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "target"
|
||||||
|
replacement: "Omit"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
meta:
|
||||||
|
- attr: "scheme"
|
||||||
|
replacement: "Include scheme in value"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
object:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "archive"
|
||||||
|
replacement: "data and type attributes"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "border"
|
||||||
|
replacement: "CSS border"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "classid"
|
||||||
|
replacement: "data and type attributes"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "code"
|
||||||
|
replacement: "data and type attributes"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "codebase"
|
||||||
|
replacement: "data and type attributes"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "codetype"
|
||||||
|
replacement: "data and type attributes"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "declare"
|
||||||
|
replacement: "Repeat element"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "hspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "standby"
|
||||||
|
replacement: "Optimize resource loading"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "typemustmatch"
|
||||||
|
replacement: "Avoid untrusted resources"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "vspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
script:
|
||||||
|
- attr: "charset"
|
||||||
|
replacement: "Omit (UTF-8 required)"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "event"
|
||||||
|
replacement: "DOM event listeners"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "for"
|
||||||
|
replacement: "DOM event listeners"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "language"
|
||||||
|
replacement: "Omit for JavaScript"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
style:
|
||||||
|
- attr: "type"
|
||||||
|
replacement: "Omit for CSS (default)"
|
||||||
|
severity: "info"
|
||||||
|
|
||||||
|
table:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "bgcolor"
|
||||||
|
replacement: "CSS background-color"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "border"
|
||||||
|
replacement: "CSS border"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "bordercolor"
|
||||||
|
replacement: "CSS border-color"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "cellpadding"
|
||||||
|
replacement: "CSS padding on td/th"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "cellspacing"
|
||||||
|
replacement: "CSS border-spacing"
|
||||||
|
severity: "major"
|
||||||
|
- attr: "frame"
|
||||||
|
replacement: "CSS border"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "height"
|
||||||
|
replacement: "CSS height"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "rules"
|
||||||
|
replacement: "CSS border on td/th"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "summary"
|
||||||
|
replacement: "caption element or aria-describedby"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "width"
|
||||||
|
replacement: "CSS width"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
td_th:
|
||||||
|
- attr: "abbr"
|
||||||
|
applies_to: "td only"
|
||||||
|
replacement: "Descriptive text or title attribute"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS text-align"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "axis"
|
||||||
|
replacement: "scope on th"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "bgcolor"
|
||||||
|
replacement: "CSS background-color"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "char"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "charoff"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "height"
|
||||||
|
replacement: "CSS height"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "nowrap"
|
||||||
|
replacement: "CSS white-space"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "valign"
|
||||||
|
replacement: "CSS vertical-align"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "width"
|
||||||
|
replacement: "CSS width"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
tr:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS text-align"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "bgcolor"
|
||||||
|
replacement: "CSS background-color"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "char"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "charoff"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "height"
|
||||||
|
replacement: "CSS height"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "valign"
|
||||||
|
replacement: "CSS vertical-align"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
thead_tbody_tfoot:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS text-align"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "char"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "charoff"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "height"
|
||||||
|
replacement: "CSS height"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "valign"
|
||||||
|
replacement: "CSS vertical-align"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
ol_ul:
|
||||||
|
- attr: "compact"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "type"
|
||||||
|
applies_to: "ul only (ol type is valid)"
|
||||||
|
replacement: "CSS list-style-type"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
heading:
|
||||||
|
- attr: "align"
|
||||||
|
applies_to: "h1-h6"
|
||||||
|
replacement: "CSS text-align"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
embed:
|
||||||
|
- attr: "align"
|
||||||
|
replacement: "CSS"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "hspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "name"
|
||||||
|
replacement: "id attribute"
|
||||||
|
severity: "minor"
|
||||||
|
- attr: "vspace"
|
||||||
|
replacement: "CSS margin"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. Semantic HTML5 Elements
|
||||||
|
# Source: HTML Living Standard - Sections, Grouping
|
||||||
|
# ============================================================
|
||||||
|
semantic_elements:
|
||||||
|
structural:
|
||||||
|
- tag: "header"
|
||||||
|
description: "Introductory content or navigational aids for a section/page"
|
||||||
|
typical_use: "Site header, section header"
|
||||||
|
- tag: "nav"
|
||||||
|
description: "Section with navigation links"
|
||||||
|
typical_use: "Main navigation, breadcrumbs, table of contents"
|
||||||
|
- tag: "main"
|
||||||
|
description: "Dominant content of the body (unique per page)"
|
||||||
|
typical_use: "Primary content area (one per page)"
|
||||||
|
- tag: "footer"
|
||||||
|
description: "Footer for its nearest sectioning content/root"
|
||||||
|
typical_use: "Site footer, section footer"
|
||||||
|
- tag: "aside"
|
||||||
|
description: "Content tangentially related to surrounding content"
|
||||||
|
typical_use: "Sidebar, pull quotes, related links"
|
||||||
|
- tag: "section"
|
||||||
|
description: "Generic standalone section of a document"
|
||||||
|
typical_use: "Thematic grouping with heading"
|
||||||
|
- tag: "article"
|
||||||
|
description: "Self-contained composition independently distributable"
|
||||||
|
typical_use: "Blog post, news article, forum post, comment"
|
||||||
|
|
||||||
|
text_level:
|
||||||
|
- tag: "figure"
|
||||||
|
description: "Self-contained content with optional caption"
|
||||||
|
typical_use: "Images, diagrams, code listings with captions"
|
||||||
|
- tag: "figcaption"
|
||||||
|
description: "Caption for a figure element"
|
||||||
|
typical_use: "Image caption, diagram description"
|
||||||
|
- tag: "details"
|
||||||
|
description: "Disclosure widget for additional information"
|
||||||
|
typical_use: "FAQ, expandable sections"
|
||||||
|
- tag: "summary"
|
||||||
|
description: "Visible heading for a details element"
|
||||||
|
typical_use: "Click-to-expand label"
|
||||||
|
- tag: "mark"
|
||||||
|
description: "Text highlighted for reference or notation"
|
||||||
|
typical_use: "Search result highlighting"
|
||||||
|
- tag: "time"
|
||||||
|
description: "Machine-readable date/time"
|
||||||
|
typical_use: "Publication dates, event times"
|
||||||
|
- tag: "address"
|
||||||
|
description: "Contact information for author/owner"
|
||||||
|
typical_use: "Author contact info in article/footer"
|
||||||
|
- tag: "search"
|
||||||
|
description: "Container for search functionality"
|
||||||
|
typical_use: "Search form wrapper (new in HTML5.2+)"
|
||||||
|
|
||||||
|
interactive:
|
||||||
|
- tag: "dialog"
|
||||||
|
description: "Dialog box or interactive component"
|
||||||
|
typical_use: "Modal dialogs, alerts"
|
||||||
|
- tag: "menu"
|
||||||
|
description: "List of commands or options"
|
||||||
|
typical_use: "Context menus, toolbars"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. Required/Recommended Meta Tags
|
||||||
|
# Source: HTML Living Standard, MDN Web Docs
|
||||||
|
# ============================================================
|
||||||
|
meta_tags:
|
||||||
|
required:
|
||||||
|
- name: "charset"
|
||||||
|
element: '<meta charset="UTF-8">'
|
||||||
|
description: "Character encoding declaration (must be UTF-8)"
|
||||||
|
severity: "critical"
|
||||||
|
standard: "HTML Living Standard"
|
||||||
|
|
||||||
|
- name: "viewport"
|
||||||
|
element: '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||||
|
description: "Viewport configuration for responsive design"
|
||||||
|
severity: "critical"
|
||||||
|
standard: "CSS Device Adaptation"
|
||||||
|
|
||||||
|
recommended:
|
||||||
|
- name: "description"
|
||||||
|
element: '<meta name="description" content="...">'
|
||||||
|
description: "Page description for search engines (150-160 chars)"
|
||||||
|
severity: "major"
|
||||||
|
standard: "HTML Living Standard"
|
||||||
|
|
||||||
|
- name: "title"
|
||||||
|
element: "<title>Page Title</title>"
|
||||||
|
description: "Document title (required by spec, shown in browser tab)"
|
||||||
|
severity: "critical"
|
||||||
|
standard: "HTML Living Standard"
|
||||||
|
|
||||||
|
- name: "lang"
|
||||||
|
element: '<html lang="ko">'
|
||||||
|
description: "Document language declaration"
|
||||||
|
severity: "major"
|
||||||
|
standard: "HTML Living Standard"
|
||||||
|
|
||||||
|
- name: "content-type"
|
||||||
|
element: 'Content-Type HTTP header or <meta http-equiv="Content-Type">'
|
||||||
|
description: "MIME type and encoding declaration"
|
||||||
|
severity: "major"
|
||||||
|
standard: "HTML Living Standard"
|
||||||
|
|
||||||
|
social_media:
|
||||||
|
- name: "og:title"
|
||||||
|
element: '<meta property="og:title" content="...">'
|
||||||
|
description: "Open Graph title for social sharing"
|
||||||
|
severity: "minor"
|
||||||
|
standard: "Open Graph Protocol"
|
||||||
|
|
||||||
|
- name: "og:description"
|
||||||
|
element: '<meta property="og:description" content="...">'
|
||||||
|
description: "Open Graph description for social sharing"
|
||||||
|
severity: "minor"
|
||||||
|
standard: "Open Graph Protocol"
|
||||||
|
|
||||||
|
- name: "og:image"
|
||||||
|
element: '<meta property="og:image" content="...">'
|
||||||
|
description: "Open Graph image for social sharing"
|
||||||
|
severity: "minor"
|
||||||
|
standard: "Open Graph Protocol"
|
||||||
|
|
||||||
|
- name: "og:url"
|
||||||
|
element: '<meta property="og:url" content="...">'
|
||||||
|
description: "Canonical URL for Open Graph"
|
||||||
|
severity: "minor"
|
||||||
|
standard: "Open Graph Protocol"
|
||||||
|
|
||||||
|
- name: "og:type"
|
||||||
|
element: '<meta property="og:type" content="website">'
|
||||||
|
description: "Content type for Open Graph"
|
||||||
|
severity: "minor"
|
||||||
|
standard: "Open Graph Protocol"
|
||||||
|
|
||||||
|
- name: "twitter:card"
|
||||||
|
element: '<meta name="twitter:card" content="summary_large_image">'
|
||||||
|
description: "Twitter Card type"
|
||||||
|
severity: "info"
|
||||||
|
standard: "Twitter Cards"
|
||||||
|
|
||||||
|
- name: "twitter:title"
|
||||||
|
element: '<meta name="twitter:title" content="...">'
|
||||||
|
description: "Twitter Card title"
|
||||||
|
severity: "info"
|
||||||
|
standard: "Twitter Cards"
|
||||||
|
|
||||||
|
- name: "twitter:description"
|
||||||
|
element: '<meta name="twitter:description" content="...">'
|
||||||
|
description: "Twitter Card description"
|
||||||
|
severity: "info"
|
||||||
|
standard: "Twitter Cards"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. Document Structure Rules
|
||||||
|
# Source: HTML Living Standard
|
||||||
|
# ============================================================
|
||||||
|
document_structure:
|
||||||
|
doctype:
|
||||||
|
rule: "Document must start with <!DOCTYPE html>"
|
||||||
|
severity: "critical"
|
||||||
|
description: "HTML5 doctype declaration required"
|
||||||
|
|
||||||
|
heading_hierarchy:
|
||||||
|
rule: "Headings must follow proper hierarchy (h1 > h2 > h3...)"
|
||||||
|
severity: "major"
|
||||||
|
checks:
|
||||||
|
- id: "single-h1"
|
||||||
|
description: "Page should have exactly one h1 element"
|
||||||
|
severity: "major"
|
||||||
|
- id: "no-skipped-levels"
|
||||||
|
description: "Heading levels should not be skipped (e.g., h1 to h3)"
|
||||||
|
severity: "major"
|
||||||
|
- id: "logical-order"
|
||||||
|
description: "Headings should follow logical document outline"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
image_alt:
|
||||||
|
rule: "All img elements must have alt attribute"
|
||||||
|
severity: "critical"
|
||||||
|
description: "Alternative text for images (accessibility + validity)"
|
||||||
|
exceptions:
|
||||||
|
- "Decorative images may use alt=''"
|
||||||
|
- "Images with role='presentation' may omit alt"
|
||||||
|
|
||||||
|
inline_styles:
|
||||||
|
rule: "Avoid inline style attributes"
|
||||||
|
severity: "minor"
|
||||||
|
description: "Inline styles reduce maintainability and violate separation of concerns"
|
||||||
|
|
||||||
|
duplicate_ids:
|
||||||
|
rule: "Element id attributes must be unique within the document"
|
||||||
|
severity: "critical"
|
||||||
|
description: "Duplicate IDs cause accessibility and JavaScript issues"
|
||||||
|
|
||||||
|
empty_links:
|
||||||
|
rule: "Anchor elements should have accessible content"
|
||||||
|
severity: "major"
|
||||||
|
description: "Links without text or aria-label are not accessible"
|
||||||
|
|
||||||
|
table_structure:
|
||||||
|
rule: "Data tables should have proper structure"
|
||||||
|
severity: "major"
|
||||||
|
checks:
|
||||||
|
- id: "table-has-thead"
|
||||||
|
description: "Data tables should have thead with th elements"
|
||||||
|
- id: "table-has-caption"
|
||||||
|
description: "Complex tables should have caption or aria-label"
|
||||||
|
- id: "th-has-scope"
|
||||||
|
description: "th elements should have scope attribute"
|
||||||
|
|
||||||
|
form_structure:
|
||||||
|
rule: "Form elements should have proper labels"
|
||||||
|
severity: "major"
|
||||||
|
checks:
|
||||||
|
- id: "input-has-label"
|
||||||
|
description: "Every form input should have an associated label"
|
||||||
|
- id: "form-has-submit"
|
||||||
|
description: "Forms should have a submit mechanism"
|
||||||
|
- id: "fieldset-has-legend"
|
||||||
|
description: "Fieldset elements should have a legend"
|
||||||
|
|
||||||
|
link_integrity:
|
||||||
|
rule: "Links and resources should be valid"
|
||||||
|
severity: "minor"
|
||||||
|
checks:
|
||||||
|
- id: "no-empty-href"
|
||||||
|
description: "Links should not have empty href attributes"
|
||||||
|
- id: "valid-rel"
|
||||||
|
description: "Link rel values should be valid"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. CSS Best Practices
|
||||||
|
# Source: CSS Specifications, MDN Web Docs
|
||||||
|
# ============================================================
|
||||||
|
css_checks:
|
||||||
|
- id: "no-important-overuse"
|
||||||
|
description: "Avoid excessive use of !important declarations"
|
||||||
|
severity: "minor"
|
||||||
|
standard: "CSS Cascading and Inheritance"
|
||||||
|
|
||||||
|
- id: "vendor-prefix-check"
|
||||||
|
description: "Check for unnecessary vendor prefixes on well-supported properties"
|
||||||
|
severity: "info"
|
||||||
|
standard: "CSS Specifications"
|
||||||
|
|
||||||
|
- id: "no-universal-selector-performance"
|
||||||
|
description: "Avoid universal selector (*) in complex selectors for performance"
|
||||||
|
severity: "info"
|
||||||
|
standard: "CSS Selectors Level 4"
|
||||||
730
backend/app/rules/performance_security.yaml
Normal file
730
backend/app/rules/performance_security.yaml
Normal file
@ -0,0 +1,730 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Performance & Security Rules
|
||||||
|
# Based on: Core Web Vitals, Lighthouse, OWASP, Mozilla Observatory
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: "Performance & Security Standards"
|
||||||
|
version: "1.0.0"
|
||||||
|
last_updated: "2026-02-13"
|
||||||
|
sources:
|
||||||
|
- name: "Google Core Web Vitals"
|
||||||
|
url: "https://developers.google.com/search/docs/appearance/core-web-vitals"
|
||||||
|
- name: "Lighthouse Performance Audits"
|
||||||
|
url: "https://developer.chrome.com/docs/lighthouse/performance/"
|
||||||
|
- name: "OWASP Secure Headers Project"
|
||||||
|
url: "https://owasp.org/www-project-secure-headers/"
|
||||||
|
- name: "OWASP HTTP Headers Cheat Sheet"
|
||||||
|
url: "https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html"
|
||||||
|
- name: "Mozilla Observatory"
|
||||||
|
url: "https://observatory.mozilla.org/"
|
||||||
|
- name: "OWASP Top 10 (2021)"
|
||||||
|
url: "https://owasp.org/www-project-top-ten/"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PERFORMANCE RULES
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
performance:
|
||||||
|
# --- Core Web Vitals ---
|
||||||
|
core_web_vitals:
|
||||||
|
- id: "perf-lcp"
|
||||||
|
name: "Largest Contentful Paint (LCP)"
|
||||||
|
description: "Measures loading performance - time to render the largest content element"
|
||||||
|
severity: "critical"
|
||||||
|
category: "loading"
|
||||||
|
standard: "Google Core Web Vitals"
|
||||||
|
thresholds:
|
||||||
|
good: 2500 # ms
|
||||||
|
needs_improvement: 4000
|
||||||
|
poor: 4001 # above this
|
||||||
|
unit: "milliseconds"
|
||||||
|
tips:
|
||||||
|
- "Optimize and compress images (WebP/AVIF format)"
|
||||||
|
- "Preload critical resources"
|
||||||
|
- "Remove render-blocking resources"
|
||||||
|
- "Use a CDN for static assets"
|
||||||
|
- "Optimize server response time (TTFB < 800ms)"
|
||||||
|
|
||||||
|
- id: "perf-inp"
|
||||||
|
name: "Interaction to Next Paint (INP)"
|
||||||
|
description: "Measures responsiveness - latency of all user interactions"
|
||||||
|
severity: "critical"
|
||||||
|
category: "interactivity"
|
||||||
|
standard: "Google Core Web Vitals"
|
||||||
|
note: "Replaced FID (First Input Delay) in March 2024"
|
||||||
|
thresholds:
|
||||||
|
good: 200 # ms
|
||||||
|
needs_improvement: 500
|
||||||
|
poor: 501
|
||||||
|
unit: "milliseconds"
|
||||||
|
tips:
|
||||||
|
- "Break up long tasks (> 50ms)"
|
||||||
|
- "Reduce JavaScript execution time"
|
||||||
|
- "Use web workers for heavy computation"
|
||||||
|
- "Minimize main thread work"
|
||||||
|
- "Optimize event handlers"
|
||||||
|
|
||||||
|
- id: "perf-cls"
|
||||||
|
name: "Cumulative Layout Shift (CLS)"
|
||||||
|
description: "Measures visual stability - unexpected layout shifts during page life"
|
||||||
|
severity: "critical"
|
||||||
|
category: "visual_stability"
|
||||||
|
standard: "Google Core Web Vitals"
|
||||||
|
thresholds:
|
||||||
|
good: 0.1
|
||||||
|
needs_improvement: 0.25
|
||||||
|
poor: 0.26
|
||||||
|
unit: "score"
|
||||||
|
tips:
|
||||||
|
- "Set explicit width/height on images and video"
|
||||||
|
- "Reserve space for ads and embeds"
|
||||||
|
- "Avoid inserting content above existing content"
|
||||||
|
- "Use CSS contain for dynamic content"
|
||||||
|
- "Preload web fonts and use font-display: swap"
|
||||||
|
|
||||||
|
# --- Additional Performance Metrics ---
|
||||||
|
additional_metrics:
|
||||||
|
- id: "perf-fcp"
|
||||||
|
name: "First Contentful Paint (FCP)"
|
||||||
|
description: "Time to render the first piece of DOM content"
|
||||||
|
severity: "major"
|
||||||
|
category: "loading"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
thresholds:
|
||||||
|
good: 1800 # ms
|
||||||
|
needs_improvement: 3000
|
||||||
|
poor: 3001
|
||||||
|
unit: "milliseconds"
|
||||||
|
|
||||||
|
- id: "perf-ttfb"
|
||||||
|
name: "Time to First Byte (TTFB)"
|
||||||
|
description: "Time from request to first byte of response"
|
||||||
|
severity: "major"
|
||||||
|
category: "server"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
thresholds:
|
||||||
|
good: 800 # ms
|
||||||
|
needs_improvement: 1800
|
||||||
|
poor: 1801
|
||||||
|
unit: "milliseconds"
|
||||||
|
tips:
|
||||||
|
- "Use a CDN"
|
||||||
|
- "Optimize server-side rendering"
|
||||||
|
- "Enable HTTP/2 or HTTP/3"
|
||||||
|
- "Optimize database queries"
|
||||||
|
|
||||||
|
- id: "perf-si"
|
||||||
|
name: "Speed Index"
|
||||||
|
description: "How quickly content is visually displayed during page load"
|
||||||
|
severity: "major"
|
||||||
|
category: "loading"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
thresholds:
|
||||||
|
good: 3400 # ms
|
||||||
|
needs_improvement: 5800
|
||||||
|
poor: 5801
|
||||||
|
unit: "milliseconds"
|
||||||
|
|
||||||
|
- id: "perf-tbt"
|
||||||
|
name: "Total Blocking Time (TBT)"
|
||||||
|
description: "Total time where main thread was blocked for > 50ms between FCP and TTI"
|
||||||
|
severity: "major"
|
||||||
|
category: "interactivity"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
thresholds:
|
||||||
|
good: 200 # ms
|
||||||
|
needs_improvement: 600
|
||||||
|
poor: 601
|
||||||
|
unit: "milliseconds"
|
||||||
|
|
||||||
|
# --- Resource Optimization ---
|
||||||
|
resource_checks:
|
||||||
|
- id: "perf-total-page-size"
|
||||||
|
name: "Total Page Size"
|
||||||
|
description: "Total size of all resources loaded by the page"
|
||||||
|
severity: "major"
|
||||||
|
category: "resources"
|
||||||
|
standard: "Web Performance Best Practice"
|
||||||
|
thresholds:
|
||||||
|
good: 1500 # KB
|
||||||
|
needs_improvement: 3000
|
||||||
|
poor: 5000
|
||||||
|
unit: "kilobytes"
|
||||||
|
|
||||||
|
- id: "perf-total-requests"
|
||||||
|
name: "Total HTTP Requests"
|
||||||
|
description: "Total number of HTTP requests made by the page"
|
||||||
|
severity: "major"
|
||||||
|
category: "resources"
|
||||||
|
standard: "Web Performance Best Practice"
|
||||||
|
thresholds:
|
||||||
|
good: 50
|
||||||
|
needs_improvement: 80
|
||||||
|
poor: 100
|
||||||
|
unit: "count"
|
||||||
|
|
||||||
|
- id: "perf-image-optimization"
|
||||||
|
name: "Image Optimization"
|
||||||
|
description: "Images should be properly optimized"
|
||||||
|
severity: "major"
|
||||||
|
category: "resources"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
checks:
|
||||||
|
- id: "uses-webp-avif"
|
||||||
|
description: "Use modern image formats (WebP, AVIF)"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "responsive-images"
|
||||||
|
description: "Use srcset for responsive images"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "lazy-loading"
|
||||||
|
description: "Offscreen images should use lazy loading"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "image-dimensions"
|
||||||
|
description: "Images should have explicit width and height"
|
||||||
|
severity: "major"
|
||||||
|
- id: "oversized-images"
|
||||||
|
description: "Images should not be larger than their display size"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "perf-js-optimization"
|
||||||
|
name: "JavaScript Optimization"
|
||||||
|
description: "JavaScript should be properly optimized"
|
||||||
|
severity: "major"
|
||||||
|
category: "resources"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
checks:
|
||||||
|
- id: "minified-js"
|
||||||
|
description: "JavaScript should be minified"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "no-render-blocking-js"
|
||||||
|
description: "Non-critical JS should use async or defer"
|
||||||
|
severity: "major"
|
||||||
|
- id: "unused-js"
|
||||||
|
description: "Remove unused JavaScript"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "js-bundle-size"
|
||||||
|
description: "Individual JS bundles should be under 250KB (compressed)"
|
||||||
|
max_size_kb: 250
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "perf-css-optimization"
|
||||||
|
name: "CSS Optimization"
|
||||||
|
description: "CSS should be properly optimized"
|
||||||
|
severity: "minor"
|
||||||
|
category: "resources"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
checks:
|
||||||
|
- id: "minified-css"
|
||||||
|
description: "CSS should be minified"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "no-render-blocking-css"
|
||||||
|
description: "Non-critical CSS should be deferred"
|
||||||
|
severity: "major"
|
||||||
|
- id: "unused-css"
|
||||||
|
description: "Remove unused CSS rules"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "critical-css-inlined"
|
||||||
|
description: "Critical CSS should be inlined"
|
||||||
|
severity: "info"
|
||||||
|
|
||||||
|
- id: "perf-font-optimization"
|
||||||
|
name: "Font Optimization"
|
||||||
|
description: "Web fonts should be properly optimized"
|
||||||
|
severity: "minor"
|
||||||
|
category: "resources"
|
||||||
|
standard: "Web Performance Best Practice"
|
||||||
|
checks:
|
||||||
|
- id: "font-display"
|
||||||
|
description: "Use font-display: swap or optional"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "preload-fonts"
|
||||||
|
description: "Preload critical fonts"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "font-subsetting"
|
||||||
|
description: "Use font subsetting for CJK fonts"
|
||||||
|
severity: "info"
|
||||||
|
- id: "woff2-format"
|
||||||
|
description: "Use WOFF2 format for web fonts"
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
# --- Caching & Compression ---
|
||||||
|
caching:
|
||||||
|
- id: "perf-compression"
|
||||||
|
name: "Text Compression"
|
||||||
|
description: "Text resources should be served with compression"
|
||||||
|
severity: "major"
|
||||||
|
category: "network"
|
||||||
|
standard: "Lighthouse"
|
||||||
|
details:
|
||||||
|
supported_encodings:
|
||||||
|
- "gzip"
|
||||||
|
- "br (Brotli - preferred)"
|
||||||
|
- "zstd"
|
||||||
|
applies_to:
|
||||||
|
- "text/html"
|
||||||
|
- "text/css"
|
||||||
|
- "application/javascript"
|
||||||
|
- "application/json"
|
||||||
|
- "image/svg+xml"
|
||||||
|
|
||||||
|
- id: "perf-cache-headers"
|
||||||
|
name: "Cache Headers"
|
||||||
|
description: "Static resources should have proper cache headers"
|
||||||
|
severity: "major"
|
||||||
|
category: "network"
|
||||||
|
standard: "HTTP Caching (RFC 7234)"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "has-cache-control"
|
||||||
|
description: "Static assets should have Cache-Control header"
|
||||||
|
- id: "long-cache-lifetime"
|
||||||
|
description: "Static assets should have cache lifetime >= 1 year"
|
||||||
|
recommended: "Cache-Control: public, max-age=31536000, immutable"
|
||||||
|
- id: "etag"
|
||||||
|
description: "Resources should have ETag for validation"
|
||||||
|
|
||||||
|
- id: "perf-http2"
|
||||||
|
name: "HTTP/2 or HTTP/3"
|
||||||
|
description: "Site should use HTTP/2 or HTTP/3 protocol"
|
||||||
|
severity: "minor"
|
||||||
|
category: "network"
|
||||||
|
standard: "IETF RFC 9113 (HTTP/2), RFC 9114 (HTTP/3)"
|
||||||
|
details:
|
||||||
|
description: "HTTP/2+ provides multiplexing, header compression, and server push"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SECURITY RULES
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
security:
|
||||||
|
# --- HTTP Security Headers (OWASP) ---
|
||||||
|
headers:
|
||||||
|
- id: "sec-strict-transport-security"
|
||||||
|
name: "Strict-Transport-Security (HSTS)"
|
||||||
|
description: "Enforces HTTPS-only access to prevent protocol downgrade attacks"
|
||||||
|
severity: "critical"
|
||||||
|
category: "transport"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
standard_ref: "RFC 6797"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Strict-Transport-Security"
|
||||||
|
recommended_value: "max-age=63072000; includeSubDomains; preload"
|
||||||
|
directives:
|
||||||
|
- name: "max-age"
|
||||||
|
description: "Time in seconds browser should remember HTTPS-only"
|
||||||
|
recommended: 63072000 # 2 years
|
||||||
|
minimum: 31536000 # 1 year
|
||||||
|
- name: "includeSubDomains"
|
||||||
|
description: "Apply to all subdomains"
|
||||||
|
recommended: true
|
||||||
|
- name: "preload"
|
||||||
|
description: "Allow inclusion in browser HSTS preload list"
|
||||||
|
recommended: true
|
||||||
|
note: "Only effective over HTTPS connections"
|
||||||
|
|
||||||
|
- id: "sec-content-security-policy"
|
||||||
|
name: "Content-Security-Policy (CSP)"
|
||||||
|
description: "Restricts content origins to prevent XSS and injection attacks"
|
||||||
|
severity: "critical"
|
||||||
|
category: "injection"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
standard_ref: "W3C CSP Level 3"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Content-Security-Policy"
|
||||||
|
recommended_directives:
|
||||||
|
- directive: "default-src"
|
||||||
|
description: "Fallback for other directives"
|
||||||
|
recommended: "'self'"
|
||||||
|
- directive: "script-src"
|
||||||
|
description: "Valid sources for JavaScript"
|
||||||
|
recommended: "'self'"
|
||||||
|
avoid: "'unsafe-inline', 'unsafe-eval'"
|
||||||
|
- directive: "style-src"
|
||||||
|
description: "Valid sources for stylesheets"
|
||||||
|
recommended: "'self'"
|
||||||
|
- directive: "img-src"
|
||||||
|
description: "Valid sources for images"
|
||||||
|
recommended: "'self' data:"
|
||||||
|
- directive: "font-src"
|
||||||
|
description: "Valid sources for fonts"
|
||||||
|
recommended: "'self'"
|
||||||
|
- directive: "connect-src"
|
||||||
|
description: "Valid targets for XMLHttpRequest, Fetch, WebSocket"
|
||||||
|
recommended: "'self'"
|
||||||
|
- directive: "frame-ancestors"
|
||||||
|
description: "Valid parents for embedding (replaces X-Frame-Options)"
|
||||||
|
recommended: "'none'"
|
||||||
|
- directive: "base-uri"
|
||||||
|
description: "Restricts URLs for base element"
|
||||||
|
recommended: "'self'"
|
||||||
|
- directive: "form-action"
|
||||||
|
description: "Restricts form submission targets"
|
||||||
|
recommended: "'self'"
|
||||||
|
- directive: "object-src"
|
||||||
|
description: "Valid sources for plugins"
|
||||||
|
recommended: "'none'"
|
||||||
|
- directive: "upgrade-insecure-requests"
|
||||||
|
description: "Upgrade HTTP requests to HTTPS"
|
||||||
|
recommended: true
|
||||||
|
|
||||||
|
- id: "sec-x-frame-options"
|
||||||
|
name: "X-Frame-Options"
|
||||||
|
description: "Prevents page from being displayed in frames (clickjacking protection)"
|
||||||
|
severity: "critical"
|
||||||
|
category: "clickjacking"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
standard_ref: "RFC 7034"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "X-Frame-Options"
|
||||||
|
recommended_value: "DENY"
|
||||||
|
valid_values:
|
||||||
|
- value: "DENY"
|
||||||
|
description: "Page cannot be displayed in any frame"
|
||||||
|
- value: "SAMEORIGIN"
|
||||||
|
description: "Page can only be displayed in frame on same origin"
|
||||||
|
note: "CSP frame-ancestors is the modern replacement"
|
||||||
|
|
||||||
|
- id: "sec-x-content-type-options"
|
||||||
|
name: "X-Content-Type-Options"
|
||||||
|
description: "Prevents MIME type sniffing attacks"
|
||||||
|
severity: "major"
|
||||||
|
category: "injection"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "X-Content-Type-Options"
|
||||||
|
recommended_value: "nosniff"
|
||||||
|
description: "Blocks browsers from guessing MIME types, preventing XSS via MIME confusion"
|
||||||
|
|
||||||
|
- id: "sec-referrer-policy"
|
||||||
|
name: "Referrer-Policy"
|
||||||
|
description: "Controls referrer information sent with requests"
|
||||||
|
severity: "major"
|
||||||
|
category: "privacy"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
standard_ref: "W3C Referrer Policy"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Referrer-Policy"
|
||||||
|
recommended_value: "strict-origin-when-cross-origin"
|
||||||
|
valid_values:
|
||||||
|
- value: "no-referrer"
|
||||||
|
description: "Never send referrer"
|
||||||
|
security: "highest"
|
||||||
|
- value: "no-referrer-when-downgrade"
|
||||||
|
description: "Don't send referrer on HTTPS → HTTP"
|
||||||
|
security: "medium"
|
||||||
|
- value: "origin"
|
||||||
|
description: "Only send the origin"
|
||||||
|
security: "high"
|
||||||
|
- value: "origin-when-cross-origin"
|
||||||
|
description: "Full URL for same-origin, origin for cross-origin"
|
||||||
|
security: "medium"
|
||||||
|
- value: "same-origin"
|
||||||
|
description: "Only send referrer for same-origin requests"
|
||||||
|
security: "high"
|
||||||
|
- value: "strict-origin"
|
||||||
|
description: "Send origin when protocol stays same"
|
||||||
|
security: "high"
|
||||||
|
- value: "strict-origin-when-cross-origin"
|
||||||
|
description: "Full URL for same-origin, origin for cross-origin (same protocol)"
|
||||||
|
security: "medium-high"
|
||||||
|
- value: "unsafe-url"
|
||||||
|
description: "Always send full URL"
|
||||||
|
security: "none"
|
||||||
|
|
||||||
|
- id: "sec-permissions-policy"
|
||||||
|
name: "Permissions-Policy"
|
||||||
|
description: "Controls browser feature access (geolocation, camera, etc.)"
|
||||||
|
severity: "major"
|
||||||
|
category: "privacy"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
standard_ref: "W3C Permissions Policy"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Permissions-Policy"
|
||||||
|
recommended_value: "geolocation=(), camera=(), microphone=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
|
||||||
|
controllable_features:
|
||||||
|
- feature: "geolocation"
|
||||||
|
description: "Access to user's location"
|
||||||
|
default_recommendation: "()" # deny all
|
||||||
|
- feature: "camera"
|
||||||
|
description: "Access to device camera"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "microphone"
|
||||||
|
description: "Access to device microphone"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "payment"
|
||||||
|
description: "Payment Request API"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "usb"
|
||||||
|
description: "WebUSB API"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "magnetometer"
|
||||||
|
description: "Magnetometer sensor"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "gyroscope"
|
||||||
|
description: "Gyroscope sensor"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "accelerometer"
|
||||||
|
description: "Accelerometer sensor"
|
||||||
|
default_recommendation: "()"
|
||||||
|
- feature: "autoplay"
|
||||||
|
description: "Auto-play media"
|
||||||
|
default_recommendation: "(self)"
|
||||||
|
- feature: "fullscreen"
|
||||||
|
description: "Fullscreen API"
|
||||||
|
default_recommendation: "(self)"
|
||||||
|
- feature: "interest-cohort"
|
||||||
|
description: "FLoC / Topics API (ad tracking)"
|
||||||
|
default_recommendation: "()"
|
||||||
|
|
||||||
|
- id: "sec-cross-origin-opener-policy"
|
||||||
|
name: "Cross-Origin-Opener-Policy (COOP)"
|
||||||
|
description: "Isolates browsing context to prevent Spectre-type attacks"
|
||||||
|
severity: "minor"
|
||||||
|
category: "isolation"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Cross-Origin-Opener-Policy"
|
||||||
|
recommended_value: "same-origin"
|
||||||
|
valid_values:
|
||||||
|
- "unsafe-none"
|
||||||
|
- "same-origin-allow-popups"
|
||||||
|
- "same-origin"
|
||||||
|
|
||||||
|
- id: "sec-cross-origin-embedder-policy"
|
||||||
|
name: "Cross-Origin-Embedder-Policy (COEP)"
|
||||||
|
description: "Restricts cross-origin resource loading"
|
||||||
|
severity: "minor"
|
||||||
|
category: "isolation"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Cross-Origin-Embedder-Policy"
|
||||||
|
recommended_value: "require-corp"
|
||||||
|
valid_values:
|
||||||
|
- "unsafe-none"
|
||||||
|
- "require-corp"
|
||||||
|
- "credentialless"
|
||||||
|
|
||||||
|
- id: "sec-cross-origin-resource-policy"
|
||||||
|
name: "Cross-Origin-Resource-Policy (CORP)"
|
||||||
|
description: "Blocks resource loading from unauthorized origins"
|
||||||
|
severity: "minor"
|
||||||
|
category: "isolation"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
check_type: "header_check"
|
||||||
|
details:
|
||||||
|
header: "Cross-Origin-Resource-Policy"
|
||||||
|
recommended_value: "same-site"
|
||||||
|
valid_values:
|
||||||
|
- "same-site"
|
||||||
|
- "same-origin"
|
||||||
|
- "cross-origin"
|
||||||
|
|
||||||
|
# --- Headers to Remove (Information Disclosure) ---
|
||||||
|
headers_to_remove:
|
||||||
|
- id: "sec-remove-server"
|
||||||
|
name: "Remove Server Header"
|
||||||
|
description: "Server header exposes web server technology"
|
||||||
|
severity: "minor"
|
||||||
|
category: "information_disclosure"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
details:
|
||||||
|
header: "Server"
|
||||||
|
action: "Remove or set to non-informative value"
|
||||||
|
reason: "Prevents fingerprinting of web server software"
|
||||||
|
|
||||||
|
- id: "sec-remove-x-powered-by"
|
||||||
|
name: "Remove X-Powered-By Header"
|
||||||
|
description: "X-Powered-By exposes application framework"
|
||||||
|
severity: "minor"
|
||||||
|
category: "information_disclosure"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
details:
|
||||||
|
header: "X-Powered-By"
|
||||||
|
action: "Remove entirely"
|
||||||
|
reason: "Prevents fingerprinting of application framework (Express, PHP, ASP.NET)"
|
||||||
|
|
||||||
|
- id: "sec-remove-x-aspnet-version"
|
||||||
|
name: "Remove X-AspNet-Version Header"
|
||||||
|
description: "Exposes .NET framework version"
|
||||||
|
severity: "minor"
|
||||||
|
category: "information_disclosure"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
details:
|
||||||
|
header: "X-AspNet-Version"
|
||||||
|
action: "Remove entirely"
|
||||||
|
|
||||||
|
- id: "sec-remove-x-aspnetmvc-version"
|
||||||
|
name: "Remove X-AspNetMvc-Version Header"
|
||||||
|
description: "Exposes ASP.NET MVC version"
|
||||||
|
severity: "minor"
|
||||||
|
category: "information_disclosure"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
details:
|
||||||
|
header: "X-AspNetMvc-Version"
|
||||||
|
action: "Remove entirely"
|
||||||
|
|
||||||
|
# --- Deprecated Headers ---
|
||||||
|
deprecated_headers:
|
||||||
|
- id: "sec-no-x-xss-protection"
|
||||||
|
name: "X-XSS-Protection (Deprecated)"
|
||||||
|
description: "Legacy XSS filter - should be disabled in favor of CSP"
|
||||||
|
severity: "info"
|
||||||
|
category: "legacy"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
details:
|
||||||
|
header: "X-XSS-Protection"
|
||||||
|
recommended_value: "0"
|
||||||
|
reason: "Modern browsers have removed XSS auditor; use CSP instead"
|
||||||
|
note: "Setting to 1; mode=block can introduce vulnerabilities in older browsers"
|
||||||
|
|
||||||
|
- id: "sec-no-public-key-pins"
|
||||||
|
name: "Public-Key-Pins (HPKP) - Removed"
|
||||||
|
description: "HTTP Public Key Pinning is deprecated and should not be used"
|
||||||
|
severity: "info"
|
||||||
|
category: "legacy"
|
||||||
|
standard: "OWASP Secure Headers Project"
|
||||||
|
details:
|
||||||
|
header: "Public-Key-Pins"
|
||||||
|
action: "Do not use"
|
||||||
|
reason: "Risk of permanent site lockout; replaced by Certificate Transparency"
|
||||||
|
|
||||||
|
# --- Transport Security ---
|
||||||
|
transport:
|
||||||
|
- id: "sec-https"
|
||||||
|
name: "HTTPS Enforcement"
|
||||||
|
description: "Site must be served over HTTPS"
|
||||||
|
severity: "critical"
|
||||||
|
category: "transport"
|
||||||
|
standard: "OWASP / Google"
|
||||||
|
checks:
|
||||||
|
- id: "uses-https"
|
||||||
|
description: "Page is served over HTTPS"
|
||||||
|
severity: "critical"
|
||||||
|
- id: "no-mixed-content"
|
||||||
|
description: "No HTTP resources loaded on HTTPS page"
|
||||||
|
severity: "critical"
|
||||||
|
- id: "http-redirects-to-https"
|
||||||
|
description: "HTTP requests redirect to HTTPS"
|
||||||
|
severity: "major"
|
||||||
|
- id: "valid-certificate"
|
||||||
|
description: "SSL/TLS certificate is valid and not expired"
|
||||||
|
severity: "critical"
|
||||||
|
- id: "strong-tls-version"
|
||||||
|
description: "Uses TLS 1.2 or higher"
|
||||||
|
severity: "major"
|
||||||
|
details:
|
||||||
|
minimum_version: "TLS 1.2"
|
||||||
|
recommended_version: "TLS 1.3"
|
||||||
|
deprecated_versions:
|
||||||
|
- "SSL 2.0"
|
||||||
|
- "SSL 3.0"
|
||||||
|
- "TLS 1.0"
|
||||||
|
- "TLS 1.1"
|
||||||
|
|
||||||
|
# --- Cookie Security ---
|
||||||
|
cookies:
|
||||||
|
- id: "sec-cookie-secure"
|
||||||
|
name: "Secure Cookie Flag"
|
||||||
|
description: "Cookies should have Secure flag over HTTPS"
|
||||||
|
severity: "major"
|
||||||
|
category: "cookies"
|
||||||
|
standard: "OWASP Session Management"
|
||||||
|
details:
|
||||||
|
flag: "Secure"
|
||||||
|
description: "Cookie only sent over HTTPS connections"
|
||||||
|
|
||||||
|
- id: "sec-cookie-httponly"
|
||||||
|
name: "HttpOnly Cookie Flag"
|
||||||
|
description: "Session cookies should have HttpOnly flag"
|
||||||
|
severity: "major"
|
||||||
|
category: "cookies"
|
||||||
|
standard: "OWASP Session Management"
|
||||||
|
details:
|
||||||
|
flag: "HttpOnly"
|
||||||
|
description: "Cookie not accessible via JavaScript (prevents XSS theft)"
|
||||||
|
|
||||||
|
- id: "sec-cookie-samesite"
|
||||||
|
name: "SameSite Cookie Attribute"
|
||||||
|
description: "Cookies should have SameSite attribute"
|
||||||
|
severity: "major"
|
||||||
|
category: "cookies"
|
||||||
|
standard: "OWASP Session Management"
|
||||||
|
details:
|
||||||
|
attribute: "SameSite"
|
||||||
|
recommended_value: "Lax"
|
||||||
|
valid_values:
|
||||||
|
- value: "Strict"
|
||||||
|
description: "Cookie not sent in any cross-site request"
|
||||||
|
- value: "Lax"
|
||||||
|
description: "Cookie sent in top-level navigations (recommended default)"
|
||||||
|
- value: "None"
|
||||||
|
description: "Cookie sent in all contexts (requires Secure flag)"
|
||||||
|
|
||||||
|
# --- Content Security ---
|
||||||
|
content:
|
||||||
|
- id: "sec-subresource-integrity"
|
||||||
|
name: "Subresource Integrity (SRI)"
|
||||||
|
description: "External scripts/styles should use integrity attribute"
|
||||||
|
severity: "minor"
|
||||||
|
category: "supply_chain"
|
||||||
|
standard: "W3C Subresource Integrity"
|
||||||
|
check_type: "attribute_check"
|
||||||
|
details:
|
||||||
|
applies_to:
|
||||||
|
- "script[src] from CDN"
|
||||||
|
- "link[rel=stylesheet] from CDN"
|
||||||
|
attribute: "integrity"
|
||||||
|
description: "Hash-based verification of external resources"
|
||||||
|
|
||||||
|
- id: "sec-form-action-https"
|
||||||
|
name: "Form Action HTTPS"
|
||||||
|
description: "Form actions should use HTTPS"
|
||||||
|
severity: "major"
|
||||||
|
category: "transport"
|
||||||
|
standard: "OWASP"
|
||||||
|
check_type: "form_check"
|
||||||
|
details:
|
||||||
|
description: "Form submissions should always go to HTTPS endpoints"
|
||||||
|
|
||||||
|
- id: "sec-target-blank-rel"
|
||||||
|
name: "Target Blank Security"
|
||||||
|
description: "Links with target=_blank should have rel=noopener"
|
||||||
|
severity: "minor"
|
||||||
|
category: "injection"
|
||||||
|
standard: "Web Security Best Practice"
|
||||||
|
check_type: "link_check"
|
||||||
|
details:
|
||||||
|
description: "Prevents tab-napping attacks via window.opener"
|
||||||
|
recommended: 'rel="noopener noreferrer"'
|
||||||
|
note: "Modern browsers set noopener by default, but explicit is safer"
|
||||||
|
|
||||||
|
- id: "sec-no-inline-event-handlers"
|
||||||
|
name: "No Inline Event Handlers"
|
||||||
|
description: "Avoid inline event handlers (onclick, onload, etc.)"
|
||||||
|
severity: "minor"
|
||||||
|
category: "injection"
|
||||||
|
standard: "OWASP / CSP"
|
||||||
|
check_type: "attribute_check"
|
||||||
|
details:
|
||||||
|
description: "Inline event handlers are incompatible with strict CSP"
|
||||||
|
blocked_attributes:
|
||||||
|
- "onclick"
|
||||||
|
- "onload"
|
||||||
|
- "onerror"
|
||||||
|
- "onmouseover"
|
||||||
|
- "onsubmit"
|
||||||
|
- "onfocus"
|
||||||
|
- "onblur"
|
||||||
|
- "onchange"
|
||||||
|
- "onkeydown"
|
||||||
|
- "onkeyup"
|
||||||
|
- "onkeypress"
|
||||||
529
backend/app/rules/seo.yaml
Normal file
529
backend/app/rules/seo.yaml
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
# ============================================================
|
||||||
|
# SEO (Search Engine Optimization) Rules
|
||||||
|
# Based on: Google Search Essentials, Schema.org, Core Web Vitals
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: "SEO Standards"
|
||||||
|
version: "1.0.0"
|
||||||
|
last_updated: "2026-02-13"
|
||||||
|
sources:
|
||||||
|
- name: "Google Search Essentials"
|
||||||
|
url: "https://developers.google.com/search/docs/essentials"
|
||||||
|
description: "Google's core guidelines for search visibility"
|
||||||
|
- name: "Google Search Central - Technical Requirements"
|
||||||
|
url: "https://developers.google.com/search/docs/crawling-indexing"
|
||||||
|
- name: "Core Web Vitals"
|
||||||
|
url: "https://developers.google.com/search/docs/appearance/core-web-vitals"
|
||||||
|
- name: "Schema.org"
|
||||||
|
url: "https://schema.org/"
|
||||||
|
description: "Structured data vocabulary"
|
||||||
|
- name: "Open Graph Protocol"
|
||||||
|
url: "https://ogp.me/"
|
||||||
|
- name: "Lighthouse SEO Audit"
|
||||||
|
url: "https://developer.chrome.com/docs/lighthouse/seo/"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. Title & Meta Tags
|
||||||
|
# ============================================================
|
||||||
|
rules:
|
||||||
|
# --- Essential Meta Tags ---
|
||||||
|
- id: "seo-title-tag"
|
||||||
|
name: "Title Tag"
|
||||||
|
description: "Page must have a unique, descriptive <title> tag"
|
||||||
|
severity: "critical"
|
||||||
|
category: "meta"
|
||||||
|
standard: "Google Search Essentials"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
tag: "title"
|
||||||
|
requirements:
|
||||||
|
- "Must be present and non-empty"
|
||||||
|
- "Should be 30-60 characters for optimal display"
|
||||||
|
- "Should be unique across the site"
|
||||||
|
- "Should accurately describe page content"
|
||||||
|
max_length: 60
|
||||||
|
min_length: 10
|
||||||
|
|
||||||
|
- id: "seo-meta-description"
|
||||||
|
name: "Meta Description"
|
||||||
|
description: "Page should have a descriptive meta description"
|
||||||
|
severity: "major"
|
||||||
|
category: "meta"
|
||||||
|
standard: "Google Search Essentials"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
tag: '<meta name="description">'
|
||||||
|
requirements:
|
||||||
|
- "Should be 120-160 characters for optimal display"
|
||||||
|
- "Should be unique across the site"
|
||||||
|
- "Should accurately summarize page content"
|
||||||
|
max_length: 160
|
||||||
|
min_length: 50
|
||||||
|
|
||||||
|
- id: "seo-meta-viewport"
|
||||||
|
name: "Viewport Meta Tag"
|
||||||
|
description: "Page must have viewport meta tag for mobile compatibility"
|
||||||
|
severity: "critical"
|
||||||
|
category: "meta"
|
||||||
|
standard: "Google Mobile-First Indexing"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
tag: '<meta name="viewport">'
|
||||||
|
recommended_value: "width=device-width, initial-scale=1"
|
||||||
|
|
||||||
|
- id: "seo-charset"
|
||||||
|
name: "Character Encoding"
|
||||||
|
description: "Page must declare character encoding"
|
||||||
|
severity: "major"
|
||||||
|
category: "meta"
|
||||||
|
standard: "HTML Living Standard"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
tag: '<meta charset="UTF-8">'
|
||||||
|
|
||||||
|
- id: "seo-lang-attribute"
|
||||||
|
name: "HTML Language Attribute"
|
||||||
|
description: "HTML element should have lang attribute"
|
||||||
|
severity: "major"
|
||||||
|
category: "meta"
|
||||||
|
standard: "Google Search Essentials"
|
||||||
|
check_type: "attribute_check"
|
||||||
|
details:
|
||||||
|
element: "html"
|
||||||
|
attribute: "lang"
|
||||||
|
description: "Helps search engines serve language-appropriate results"
|
||||||
|
|
||||||
|
# --- Canonical & Duplicate Content ---
|
||||||
|
- id: "seo-canonical-url"
|
||||||
|
name: "Canonical URL"
|
||||||
|
description: "Page should have a canonical URL to prevent duplicate content"
|
||||||
|
severity: "major"
|
||||||
|
category: "meta"
|
||||||
|
standard: "Google Search Central"
|
||||||
|
check_type: "link_tag_check"
|
||||||
|
details:
|
||||||
|
tag: '<link rel="canonical">'
|
||||||
|
requirements:
|
||||||
|
- "Should be an absolute URL"
|
||||||
|
- "Should point to the preferred version of the page"
|
||||||
|
- "Must be self-referencing or point to a valid page"
|
||||||
|
|
||||||
|
- id: "seo-hreflang"
|
||||||
|
name: "Hreflang Tags"
|
||||||
|
description: "Multilingual pages should have hreflang annotations"
|
||||||
|
severity: "minor"
|
||||||
|
category: "meta"
|
||||||
|
standard: "Google Search Central - Internationalization"
|
||||||
|
check_type: "link_tag_check"
|
||||||
|
details:
|
||||||
|
tag: '<link rel="alternate" hreflang="...">'
|
||||||
|
description: "Tells Google which language versions exist for a page"
|
||||||
|
|
||||||
|
# --- Robots Control ---
|
||||||
|
- id: "seo-meta-robots"
|
||||||
|
name: "Meta Robots Tag"
|
||||||
|
description: "Check for meta robots directives"
|
||||||
|
severity: "major"
|
||||||
|
category: "crawling"
|
||||||
|
standard: "Google Search Central"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
tag: '<meta name="robots">'
|
||||||
|
valid_values:
|
||||||
|
- "index"
|
||||||
|
- "noindex"
|
||||||
|
- "follow"
|
||||||
|
- "nofollow"
|
||||||
|
- "noarchive"
|
||||||
|
- "nosnippet"
|
||||||
|
- "max-snippet"
|
||||||
|
- "max-image-preview"
|
||||||
|
- "max-video-preview"
|
||||||
|
warning_values:
|
||||||
|
- value: "noindex"
|
||||||
|
message: "Page is blocked from indexing"
|
||||||
|
- value: "nofollow"
|
||||||
|
message: "Links on this page will not be followed"
|
||||||
|
- value: "none"
|
||||||
|
message: "Page is blocked from indexing and links won't be followed"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Content Structure
|
||||||
|
# ============================================================
|
||||||
|
- id: "seo-heading-structure"
|
||||||
|
name: "Heading Structure"
|
||||||
|
description: "Page should have proper heading hierarchy for SEO"
|
||||||
|
severity: "major"
|
||||||
|
category: "content"
|
||||||
|
standard: "Google Search Essentials"
|
||||||
|
check_type: "heading_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "has-h1"
|
||||||
|
description: "Page should have exactly one H1 tag"
|
||||||
|
severity: "critical"
|
||||||
|
- id: "h1-not-empty"
|
||||||
|
description: "H1 tag should not be empty"
|
||||||
|
severity: "critical"
|
||||||
|
- id: "heading-hierarchy"
|
||||||
|
description: "Headings should follow logical hierarchy (no skipping levels)"
|
||||||
|
severity: "major"
|
||||||
|
- id: "heading-keywords"
|
||||||
|
description: "Headings should contain relevant keywords"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "no-multiple-h1"
|
||||||
|
description: "Page should not have multiple H1 tags"
|
||||||
|
severity: "major"
|
||||||
|
|
||||||
|
- id: "seo-image-alt"
|
||||||
|
name: "Image Alt Text"
|
||||||
|
description: "Images should have descriptive alt attributes for SEO"
|
||||||
|
severity: "major"
|
||||||
|
category: "content"
|
||||||
|
standard: "Google Search Essentials - Images"
|
||||||
|
check_type: "image_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "has-alt"
|
||||||
|
description: "All images should have alt attribute"
|
||||||
|
severity: "critical"
|
||||||
|
- id: "alt-not-empty"
|
||||||
|
description: "Alt text should not be empty (unless decorative)"
|
||||||
|
severity: "major"
|
||||||
|
- id: "alt-not-filename"
|
||||||
|
description: "Alt text should not be just a filename"
|
||||||
|
severity: "minor"
|
||||||
|
- id: "alt-not-too-long"
|
||||||
|
description: "Alt text should be under 125 characters"
|
||||||
|
max_length: 125
|
||||||
|
severity: "minor"
|
||||||
|
|
||||||
|
- id: "seo-internal-links"
|
||||||
|
name: "Internal Linking"
|
||||||
|
description: "Page should have internal links for crawlability"
|
||||||
|
severity: "minor"
|
||||||
|
category: "content"
|
||||||
|
standard: "Google Search Central - Links"
|
||||||
|
check_type: "link_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "has-internal-links"
|
||||||
|
description: "Page should contain internal links"
|
||||||
|
- id: "no-broken-links"
|
||||||
|
description: "Internal links should not return 404"
|
||||||
|
- id: "descriptive-anchor"
|
||||||
|
description: "Link anchor text should be descriptive (not 'click here')"
|
||||||
|
|
||||||
|
- id: "seo-content-length"
|
||||||
|
name: "Content Length"
|
||||||
|
description: "Page should have sufficient text content"
|
||||||
|
severity: "minor"
|
||||||
|
category: "content"
|
||||||
|
standard: "SEO Best Practice"
|
||||||
|
check_type: "content_check"
|
||||||
|
details:
|
||||||
|
min_word_count: 300
|
||||||
|
description: "Pages with thin content may rank poorly"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. Technical SEO
|
||||||
|
# ============================================================
|
||||||
|
- id: "seo-robots-txt"
|
||||||
|
name: "Robots.txt"
|
||||||
|
description: "Site should have a valid robots.txt file"
|
||||||
|
severity: "major"
|
||||||
|
category: "crawling"
|
||||||
|
standard: "Google Search Central - Robots.txt"
|
||||||
|
check_type: "file_check"
|
||||||
|
details:
|
||||||
|
path: "/robots.txt"
|
||||||
|
checks:
|
||||||
|
- id: "exists"
|
||||||
|
description: "robots.txt file should exist"
|
||||||
|
- id: "valid-syntax"
|
||||||
|
description: "robots.txt should have valid syntax"
|
||||||
|
- id: "not-blocking-important"
|
||||||
|
description: "Should not block important resources (CSS, JS, images)"
|
||||||
|
- id: "has-sitemap-reference"
|
||||||
|
description: "Should reference XML sitemap"
|
||||||
|
|
||||||
|
- id: "seo-sitemap-xml"
|
||||||
|
name: "XML Sitemap"
|
||||||
|
description: "Site should have a valid XML sitemap"
|
||||||
|
severity: "major"
|
||||||
|
category: "crawling"
|
||||||
|
standard: "Google Search Central - Sitemaps"
|
||||||
|
check_type: "file_check"
|
||||||
|
details:
|
||||||
|
paths:
|
||||||
|
- "/sitemap.xml"
|
||||||
|
- "/sitemap_index.xml"
|
||||||
|
checks:
|
||||||
|
- id: "exists"
|
||||||
|
description: "XML sitemap should exist"
|
||||||
|
- id: "valid-xml"
|
||||||
|
description: "Sitemap should be valid XML"
|
||||||
|
- id: "referenced-in-robots"
|
||||||
|
description: "Sitemap should be referenced in robots.txt"
|
||||||
|
|
||||||
|
- id: "seo-https"
|
||||||
|
name: "HTTPS"
|
||||||
|
description: "Site should be served over HTTPS"
|
||||||
|
severity: "critical"
|
||||||
|
category: "security_seo"
|
||||||
|
standard: "Google Search Essentials"
|
||||||
|
check_type: "protocol_check"
|
||||||
|
details:
|
||||||
|
description: "HTTPS is a confirmed Google ranking signal"
|
||||||
|
|
||||||
|
- id: "seo-mobile-friendly"
|
||||||
|
name: "Mobile Friendliness"
|
||||||
|
description: "Page should be mobile-friendly (mobile-first indexing)"
|
||||||
|
severity: "critical"
|
||||||
|
category: "mobile"
|
||||||
|
standard: "Google Mobile-First Indexing"
|
||||||
|
check_type: "mobile_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "viewport-meta"
|
||||||
|
description: "Has viewport meta tag"
|
||||||
|
- id: "responsive-design"
|
||||||
|
description: "Uses responsive CSS (media queries or fluid layout)"
|
||||||
|
- id: "no-horizontal-scroll"
|
||||||
|
description: "No horizontal scrolling at mobile widths"
|
||||||
|
- id: "readable-font-size"
|
||||||
|
description: "Font size is readable without zooming (>= 16px base)"
|
||||||
|
- id: "tap-targets"
|
||||||
|
description: "Tap targets are at least 48x48 CSS pixels"
|
||||||
|
|
||||||
|
- id: "seo-page-speed"
|
||||||
|
name: "Page Load Speed"
|
||||||
|
description: "Page should load quickly for better SEO"
|
||||||
|
severity: "major"
|
||||||
|
category: "performance_seo"
|
||||||
|
standard: "Google Core Web Vitals"
|
||||||
|
check_type: "performance_check"
|
||||||
|
details:
|
||||||
|
metrics:
|
||||||
|
- name: "LCP"
|
||||||
|
description: "Largest Contentful Paint"
|
||||||
|
good: "<= 2.5s"
|
||||||
|
needs_improvement: "<= 4.0s"
|
||||||
|
poor: "> 4.0s"
|
||||||
|
- name: "INP"
|
||||||
|
description: "Interaction to Next Paint"
|
||||||
|
good: "<= 200ms"
|
||||||
|
needs_improvement: "<= 500ms"
|
||||||
|
poor: "> 500ms"
|
||||||
|
- name: "CLS"
|
||||||
|
description: "Cumulative Layout Shift"
|
||||||
|
good: "<= 0.1"
|
||||||
|
needs_improvement: "<= 0.25"
|
||||||
|
poor: "> 0.25"
|
||||||
|
|
||||||
|
- id: "seo-url-structure"
|
||||||
|
name: "URL Structure"
|
||||||
|
description: "URLs should be clean and descriptive"
|
||||||
|
severity: "minor"
|
||||||
|
category: "technical"
|
||||||
|
standard: "Google Search Essentials - URL Structure"
|
||||||
|
check_type: "url_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "readable-url"
|
||||||
|
description: "URL should be human-readable (not query strings)"
|
||||||
|
- id: "no-underscores"
|
||||||
|
description: "URLs should use hyphens, not underscores"
|
||||||
|
- id: "lowercase"
|
||||||
|
description: "URLs should be lowercase"
|
||||||
|
- id: "no-excessive-depth"
|
||||||
|
description: "URL path should not be excessively deep (> 4 levels)"
|
||||||
|
max_depth: 4
|
||||||
|
|
||||||
|
- id: "seo-redirect-check"
|
||||||
|
name: "Redirect Handling"
|
||||||
|
description: "Check for proper redirect implementation"
|
||||||
|
severity: "major"
|
||||||
|
category: "technical"
|
||||||
|
standard: "Google Search Central - Redirects"
|
||||||
|
check_type: "redirect_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "no-redirect-chains"
|
||||||
|
description: "Avoid redirect chains (>2 hops)"
|
||||||
|
- id: "use-301"
|
||||||
|
description: "Permanent redirects should use 301 status"
|
||||||
|
- id: "no-meta-refresh-redirect"
|
||||||
|
description: "Avoid meta refresh redirects"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. Structured Data
|
||||||
|
# ============================================================
|
||||||
|
- id: "seo-structured-data"
|
||||||
|
name: "Structured Data (Schema.org)"
|
||||||
|
description: "Page should include structured data for rich results"
|
||||||
|
severity: "minor"
|
||||||
|
category: "structured_data"
|
||||||
|
standard: "Schema.org / Google Structured Data"
|
||||||
|
check_type: "structured_data_check"
|
||||||
|
details:
|
||||||
|
formats:
|
||||||
|
- "JSON-LD (recommended)"
|
||||||
|
- "Microdata"
|
||||||
|
- "RDFa"
|
||||||
|
common_types:
|
||||||
|
- type: "WebSite"
|
||||||
|
description: "Site-level information with search action"
|
||||||
|
- type: "Organization"
|
||||||
|
description: "Organization/company information"
|
||||||
|
- type: "BreadcrumbList"
|
||||||
|
description: "Breadcrumb navigation structure"
|
||||||
|
- type: "Article"
|
||||||
|
description: "News article, blog post"
|
||||||
|
- type: "Product"
|
||||||
|
description: "Product information with reviews/pricing"
|
||||||
|
- type: "FAQPage"
|
||||||
|
description: "Frequently asked questions"
|
||||||
|
- type: "LocalBusiness"
|
||||||
|
description: "Local business information"
|
||||||
|
- type: "Event"
|
||||||
|
description: "Event information"
|
||||||
|
- type: "Recipe"
|
||||||
|
description: "Recipe with ingredients and instructions"
|
||||||
|
- type: "HowTo"
|
||||||
|
description: "Step-by-step instructions"
|
||||||
|
- type: "VideoObject"
|
||||||
|
description: "Video content metadata"
|
||||||
|
|
||||||
|
- id: "seo-json-ld-valid"
|
||||||
|
name: "JSON-LD Validity"
|
||||||
|
description: "JSON-LD structured data should be valid"
|
||||||
|
severity: "minor"
|
||||||
|
category: "structured_data"
|
||||||
|
standard: "Schema.org"
|
||||||
|
check_type: "structured_data_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "valid-json"
|
||||||
|
description: "JSON-LD must be valid JSON"
|
||||||
|
- id: "has-context"
|
||||||
|
description: "Must include @context: https://schema.org"
|
||||||
|
- id: "has-type"
|
||||||
|
description: "Must include @type property"
|
||||||
|
- id: "required-properties"
|
||||||
|
description: "Must include required properties for the type"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. Social Media / Open Graph
|
||||||
|
# ============================================================
|
||||||
|
- id: "seo-open-graph"
|
||||||
|
name: "Open Graph Tags"
|
||||||
|
description: "Page should have Open Graph meta tags for social sharing"
|
||||||
|
severity: "minor"
|
||||||
|
category: "social"
|
||||||
|
standard: "Open Graph Protocol"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
required_tags:
|
||||||
|
- property: "og:title"
|
||||||
|
description: "Title for social sharing"
|
||||||
|
- property: "og:description"
|
||||||
|
description: "Description for social sharing"
|
||||||
|
- property: "og:image"
|
||||||
|
description: "Image for social sharing (min 1200x630px recommended)"
|
||||||
|
- property: "og:url"
|
||||||
|
description: "Canonical URL for social sharing"
|
||||||
|
- property: "og:type"
|
||||||
|
description: "Content type (website, article, etc.)"
|
||||||
|
recommended_tags:
|
||||||
|
- property: "og:site_name"
|
||||||
|
description: "Website name"
|
||||||
|
- property: "og:locale"
|
||||||
|
description: "Locale for the content"
|
||||||
|
|
||||||
|
- id: "seo-twitter-cards"
|
||||||
|
name: "Twitter Card Tags"
|
||||||
|
description: "Page should have Twitter Card meta tags"
|
||||||
|
severity: "info"
|
||||||
|
category: "social"
|
||||||
|
standard: "Twitter Cards"
|
||||||
|
check_type: "meta_tag_check"
|
||||||
|
details:
|
||||||
|
tags:
|
||||||
|
- name: "twitter:card"
|
||||||
|
description: "Card type (summary, summary_large_image, player)"
|
||||||
|
required: true
|
||||||
|
- name: "twitter:title"
|
||||||
|
description: "Title for Twitter sharing"
|
||||||
|
required: false
|
||||||
|
- name: "twitter:description"
|
||||||
|
description: "Description for Twitter sharing"
|
||||||
|
required: false
|
||||||
|
- name: "twitter:image"
|
||||||
|
description: "Image for Twitter sharing"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. Crawling & Indexing
|
||||||
|
# ============================================================
|
||||||
|
- id: "seo-crawlability"
|
||||||
|
name: "Page Crawlability"
|
||||||
|
description: "Page should be crawlable by search engines"
|
||||||
|
severity: "critical"
|
||||||
|
category: "crawling"
|
||||||
|
standard: "Google Search Central"
|
||||||
|
check_type: "crawl_check"
|
||||||
|
details:
|
||||||
|
checks:
|
||||||
|
- id: "status-200"
|
||||||
|
description: "Page should return HTTP 200 status"
|
||||||
|
- id: "not-blocked-robots"
|
||||||
|
description: "Page should not be blocked by robots.txt"
|
||||||
|
- id: "not-noindex"
|
||||||
|
description: "Page should not have noindex directive (unless intended)"
|
||||||
|
- id: "content-type-html"
|
||||||
|
description: "Content-Type should be text/html"
|
||||||
|
|
||||||
|
- id: "seo-favicon"
|
||||||
|
name: "Favicon"
|
||||||
|
description: "Site should have a favicon"
|
||||||
|
severity: "info"
|
||||||
|
category: "technical"
|
||||||
|
standard: "Google Search Central"
|
||||||
|
check_type: "file_check"
|
||||||
|
details:
|
||||||
|
description: "Favicons appear in search results and browser tabs"
|
||||||
|
check_locations:
|
||||||
|
- '<link rel="icon">'
|
||||||
|
- '<link rel="shortcut icon">'
|
||||||
|
- "/favicon.ico"
|
||||||
|
|
||||||
|
- id: "seo-404-page"
|
||||||
|
name: "Custom 404 Page"
|
||||||
|
description: "Site should have a custom 404 error page"
|
||||||
|
severity: "minor"
|
||||||
|
category: "technical"
|
||||||
|
standard: "Google Search Essentials"
|
||||||
|
check_type: "http_check"
|
||||||
|
details:
|
||||||
|
description: "Custom 404 pages help users navigate back to working pages"
|
||||||
|
|
||||||
|
- id: "seo-nofollow-usage"
|
||||||
|
name: "Nofollow Link Usage"
|
||||||
|
description: "Check for proper use of rel=nofollow on links"
|
||||||
|
severity: "info"
|
||||||
|
category: "links"
|
||||||
|
standard: "Google Search Central - Links"
|
||||||
|
check_type: "link_check"
|
||||||
|
details:
|
||||||
|
rel_values:
|
||||||
|
- value: "nofollow"
|
||||||
|
description: "Do not follow this link"
|
||||||
|
use_case: "User-generated content, untrusted links"
|
||||||
|
- value: "ugc"
|
||||||
|
description: "User-generated content"
|
||||||
|
use_case: "Comments, forum posts"
|
||||||
|
- value: "sponsored"
|
||||||
|
description: "Paid/sponsored link"
|
||||||
|
use_case: "Advertisements, sponsored content"
|
||||||
@ -30,5 +30,8 @@ playwright>=1.49.0
|
|||||||
weasyprint>=62.0
|
weasyprint>=62.0
|
||||||
Jinja2>=3.1.0
|
Jinja2>=3.1.0
|
||||||
|
|
||||||
|
# Rules (YAML)
|
||||||
|
PyYAML>=6.0.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-slugify>=8.0.0
|
python-slugify>=8.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user