| 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  |
| 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()) |