main
py 180 lines 6.46 KB
Raw
1 #!/usr/bin/env python3
2 """Validate that published content does not hotlink external images.
3
4 Scans Markdown content files and frontmatter for:
5 - Remote image URLs (http/https) in Markdown image syntax or HTML img tags
6 - External og:image or cover_image values in YAML frontmatter
7 - SAS tokens, credentials, or tracking parameters in image URLs
8 - References to images not present in the image registry
9
10 Exit code 0 = clean, 1 = violations found.
11 """
12
13 from __future__ import annotations
14
15 import json
16 import re
17 import sys
18 from pathlib import Path
19
20 CONTENT_DIR = Path("content")
21 REGISTRY_PATH = Path("data/image-registry.json")
22
23 # Patterns that indicate remote/external image references
24 REMOTE_URL_PATTERN = re.compile(r"https?://[^\s\"'>)\]]+", re.IGNORECASE)
25 MD_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
26 HTML_IMG_PATTERN = re.compile(r"<img[^>]+src=[\"']([^\"']+)[\"']", re.IGNORECASE)
27
28 # Frontmatter fields that reference images
29 IMAGE_FRONTMATTER_FIELDS = ("cover_image", "og_image", "image", "thumbnail")
30
31 # Patterns indicating secrets/credentials in URLs
32 SECRET_PATTERNS = [
33 re.compile(r"[?&](?:sig|sv|se|sp|spr|srt|ss)=", re.IGNORECASE), # Azure SAS
34 re.compile(r"[?&](?:token|api_key|apikey|secret|password)=", re.IGNORECASE),
35 re.compile(
36 r"[?&](?:utm_source|utm_medium|utm_campaign|fbclid|gclid)=", re.IGNORECASE
37 ), # Tracking
38 ]
39
40
41 def _is_remote_url(value: str) -> bool:
42 return value.startswith(("http://", "https://", "//"))
43
44
45 def _extract_frontmatter(text: str) -> dict[str, str]:
46 """Extract YAML frontmatter image fields (simple key: value parsing)."""
47 fields: dict[str, str] = {}
48 if not text.startswith("---"):
49 return fields
50 end = text.find("\n---", 3)
51 if end == -1:
52 return fields
53 fm_block = text[3:end]
54 for line in fm_block.splitlines():
55 for field in IMAGE_FRONTMATTER_FIELDS:
56 if line.strip().startswith(f"{field}:"):
57 value = line.split(":", 1)[1].strip().strip("\"'")
58 if value:
59 fields[field] = value
60 return fields
61
62
63 def _check_secrets_in_url(url: str) -> list[str]:
64 """Check for credentials or tracking params in a URL."""
65 issues = []
66 for pat in SECRET_PATTERNS:
67 if pat.search(url):
68 issues.append(f"URL contains suspicious parameters: {url[:120]}")
69 break
70 return issues
71
72
73 def validate_file(filepath: Path) -> list[str]:
74 """Validate a single content file. Returns list of violations."""
75 violations: list[str] = []
76 try:
77 text = filepath.read_text(encoding="utf-8")
78 except (OSError, UnicodeDecodeError):
79 return violations
80
81 # Check frontmatter image fields
82 fm_fields = _extract_frontmatter(text)
83 for field, value in fm_fields.items():
84 if _is_remote_url(value):
85 violations.append(
86 f"{filepath}: frontmatter '{field}' hotlinks external URL: {value[:100]}"
87 )
88 violations.extend(
89 f"{filepath}: frontmatter '{field}' {issue}" for issue in _check_secrets_in_url(value)
90 )
91
92 # Check Markdown image syntax ![alt](url)
93 for match in MD_IMAGE_PATTERN.finditer(text):
94 url = match.group(2).strip()
95 if _is_remote_url(url):
96 violations.append(f"{filepath}: Markdown image hotlinks external URL: {url[:100]}")
97 violations.extend(f"{filepath}: {issue}" for issue in _check_secrets_in_url(url))
98
99 # Check HTML <img src="...">
100 for match in HTML_IMG_PATTERN.finditer(text):
101 url = match.group(1).strip()
102 if _is_remote_url(url):
103 violations.append(f"{filepath}: HTML img hotlinks external URL: {url[:100]}")
104 violations.extend(f"{filepath}: {issue}" for issue in _check_secrets_in_url(url))
105
106 return violations
107
108
109 def validate_content(content_dir: Path = CONTENT_DIR) -> list[str]:
110 """Scan all .md files under content_dir for image policy violations."""
111 all_violations: list[str] = []
112 if not content_dir.exists():
113 return all_violations
114 for md_file in sorted(content_dir.rglob("*.md")):
115 all_violations.extend(validate_file(md_file))
116 return all_violations
117
118
119 def validate_registry_references(
120 content_dir: Path = CONTENT_DIR,
121 registry_path: Path = REGISTRY_PATH,
122 ) -> list[str]:
123 """Verify local image references in frontmatter exist in the registry."""
124 violations: list[str] = []
125 if not registry_path.exists():
126 violations.append(f"Image registry file not found: {registry_path}")
127 return violations
128 try:
129 registry = json.loads(registry_path.read_text(encoding="utf-8"))
130 except (json.JSONDecodeError, ValueError):
131 violations.append(f"Cannot parse registry: {registry_path}")
132 return violations
133
134 # Build a normalized set for matching. Hugo resolves covers via resources.Get
135 # using asset-relative paths (e.g., "covers/foo.webp"), while on-disk files
136 # live under "assets/covers/". Register both forms for consistent matching.
137 registered_files: set[str] = set()
138 for img in registry.get("images", []):
139 filename = img.get("filename", "")
140 registered_files.add(filename)
141 if filename.startswith("assets/"):
142 registered_files.add(filename[len("assets/") :])
143 else:
144 registered_files.add(f"assets/{filename}")
145
146 if not content_dir.exists():
147 return violations
148
149 for md_file in sorted(content_dir.rglob("*.md")):
150 try:
151 text = md_file.read_text(encoding="utf-8")
152 except (OSError, UnicodeDecodeError):
153 continue
154 fm_fields = _extract_frontmatter(text)
155 for field, value in fm_fields.items():
156 if not _is_remote_url(value) and value and value not in registered_files:
157 # Only flag non-generated assets (covers/ prefix indicates registry-managed)
158 if value.startswith("covers/") or value.startswith("assets/covers/"):
159 violations.append(
160 f"{md_file}: frontmatter '{field}' references unregistered image: {value}"
161 )
162 return violations
163
164
165 def main() -> int:
166 violations = validate_content()
167 violations.extend(validate_registry_references())
168
169 if violations:
170 print("Image policy violations found:", file=sys.stderr)
171 for v in violations:
172 print(f" FAIL: {v}", file=sys.stderr)
173 return 1
174
175 print("Content image validation passed: no hotlinks, secrets, or unregistered covers found.")
176 return 0
177
178
179 if __name__ == "__main__":
180 raise SystemExit(main())