diff --git a/README.md b/README.md
index e21bd05..3a36849 100644
--- a/README.md
+++ b/README.md
@@ -12,35 +12,35 @@ maps the regular CRS IDs from 900K for each rule to the range 9,900,000 - 9,999,
| *Plugin Name* | *Rule ID Range* | *Repository* | *Type* | *Status* | *CI* | *License* |
|---|---|---|---|---|---|---|
-| template | 9,500,000 - 9,500,999 | [coreruleset/template-plugin](https://github.com/coreruleset/template-plugin) | official | ✅ tested |  | Apache-2.0 |
+| template | 9,500,000 - 9,500,999 | [coreruleset/template-plugin](https://github.com/coreruleset/template-plugin) | official | ✅ tested |
| Apache-2.0 |
| auto-decoding | 9,501,000 - 9,501,999 | [coreruleset/auto-decoding-plugin](https://github.com/coreruleset/auto-decoding-plugin) | official | untested | | Apache-2.0 |
| antivirus | 9,502,000 - 9,502,999 | [coreruleset/antivirus-plugin](https://github.com/coreruleset/antivirus-plugin) | official | being tested | | Apache-2.0 |
-| body-decompress | 9,503,000 - 9,503,999 | [coreruleset/body-decompress-plugin](https://github.com/coreruleset/body-decompress-plugin) | official | being tested |  | Apache-2.0 |
-| fake-bot | 9,504,000 - 9,504,999 | [coreruleset/fake-bot-plugin](https://github.com/coreruleset/fake-bot-plugin) | official | ✅ tested |  | Apache-2.0 |
-| google-oauth2 | 9,505,000 - 9,505,999 | [coreruleset/google-oauth2-plugin](https://github.com/coreruleset/google-oauth2-plugin) | official | ✅ tested |  | Apache-2.0 |
-| drupal-rule-exclusions | 9,506,000 - 9,506,999 | [coreruleset/drupal-rule-exclusions-plugin](https://github.com/coreruleset/drupal-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| wordpress-rule-exclusions | 9,507,000 - 9,507,999 | [coreruleset/wordpress-rule-exclusions-plugin](https://github.com/coreruleset/wordpress-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| nextcloud-rule-exclusions | 9,508,000 - 9,508,999 | [coreruleset/nextcloud-rule-exclusions-plugin](https://github.com/coreruleset/nextcloud-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| dokuwiki-rule-exclusions | 9,509,000 - 9,509,999 | [coreruleset/dokuwiki-rule-exclusions-plugin](https://github.com/coreruleset/dokuwiki-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| cpanel-rule-exclusions | 9,510,000 - 9,510,999 | [coreruleset/cpanel-rule-exclusions-plugin](https://github.com/coreruleset/cpanel-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| xenforo-rule-exclusions | 9,511,000 - 9,511,999 | [coreruleset/xenforo-rule-exclusions-plugin](https://github.com/coreruleset/xenforo-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| phpbb-rule-exclusions | 9,512,000 - 9,512,999 | [coreruleset/phpbb-rule-exclusions-plugin](https://github.com/coreruleset/phpbb-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
-| phpmyadmin-rule-exclusions | 9,513,000 - 9,513,999 | [coreruleset/phpmyadmin-rule-exclusions-plugin](https://github.com/coreruleset/phpmyadmin-rule-exclusions-plugin) | official | ✅ tested |  | Apache-2.0 |
+| body-decompress | 9,503,000 - 9,503,999 | [coreruleset/body-decompress-plugin](https://github.com/coreruleset/body-decompress-plugin) | official | being tested |
| Apache-2.0 |
+| fake-bot | 9,504,000 - 9,504,999 | [coreruleset/fake-bot-plugin](https://github.com/coreruleset/fake-bot-plugin) | official | ✅ tested |
| Apache-2.0 |
+| google-oauth2 | 9,505,000 - 9,505,999 | [coreruleset/google-oauth2-plugin](https://github.com/coreruleset/google-oauth2-plugin) | official | ✅ tested |
| Apache-2.0 |
+| drupal-rule-exclusions | 9,506,000 - 9,506,999 | [coreruleset/drupal-rule-exclusions-plugin](https://github.com/coreruleset/drupal-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| wordpress-rule-exclusions | 9,507,000 - 9,507,999 | [coreruleset/wordpress-rule-exclusions-plugin](https://github.com/coreruleset/wordpress-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| nextcloud-rule-exclusions | 9,508,000 - 9,508,999 | [coreruleset/nextcloud-rule-exclusions-plugin](https://github.com/coreruleset/nextcloud-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| dokuwiki-rule-exclusions | 9,509,000 - 9,509,999 | [coreruleset/dokuwiki-rule-exclusions-plugin](https://github.com/coreruleset/dokuwiki-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| cpanel-rule-exclusions | 9,510,000 - 9,510,999 | [coreruleset/cpanel-rule-exclusions-plugin](https://github.com/coreruleset/cpanel-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| xenforo-rule-exclusions | 9,511,000 - 9,511,999 | [coreruleset/xenforo-rule-exclusions-plugin](https://github.com/coreruleset/xenforo-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| phpbb-rule-exclusions | 9,512,000 - 9,512,999 | [coreruleset/phpbb-rule-exclusions-plugin](https://github.com/coreruleset/phpbb-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
+| phpmyadmin-rule-exclusions | 9,513,000 - 9,513,999 | [coreruleset/phpmyadmin-rule-exclusions-plugin](https://github.com/coreruleset/phpmyadmin-rule-exclusions-plugin) | official | ✅ tested |
| Apache-2.0 |
| dos-protection-modsecurity | 9,514,000 - 9,514,999 | [coreruleset/dos-protection-plugin-modsecurity](https://github.com/coreruleset/dos-protection-plugin-modsecurity) | official | untested | | Apache-2.0 |
-| machine-learning-integration-plugin | 9,516,000 - 9,516,999 | [coreruleset/machine-learning-integration-plugin](https://github.com/coreruleset/machine-learning-integration-plugin) | official | draft |  | Apache-2.0 |
-| performance-plugin | 9,517,000 - 9,517,999 | [coreruleset/performance-plugin](https://github.com/coreruleset/performance-plugin) | official | draft (Private) |  | Apache-2.0 |
-| ghost-rule-exclusions | 9,518,000 - 9,518,999 | [coreruleset/ghost-rule-exclusions-plugin](https://github.com/coreruleset/ghost-rule-exclusions-plugin) | official | draft (Private) |  | Apache-2.0 |
-| roundcube-rule-exclusions-plugin | 9,519,000 - 9,519,999 | [EsadCetiner/roundcube-rule-exclusions-plugin](https://github.com/EsadCetiner/roundcube-rule-exclusions-plugin) | 3rd-party | ✅ tested |  | GPL-2.0 |
-| sogo-rule-exclusions-plugin | 9,520,000 - 9,520,999 | [EsadCetiner/sogo-rule-exclusions-plugin](https://github.com/EsadCetiner/sogo-rule-exclusions-plugin) | 3rd-party | ✅ tested |  | GPL-2.0 |
-| iredadmin-rule-exclusions-plugin | 9,521,000 - 9,521,999 | [EsadCetiner/iredadmin-rule-exclusions-plugin](https://github.com/EsadCetiner/iredadmin-rule-exclusions-plugin) | 3rd-party | ✅ tested |  | GPL-2.0 |
-| wordpress-hardening-plugin | 9,522,000 - 9,522,999 | [eilandert/wordpress-hardening-plugin](https://github.com/eilandert/wordpress-hardening-plugin) | 3rd-party | ✅ tested |  | Apache-2.0 |
+| machine-learning-integration-plugin | 9,516,000 - 9,516,999 | [coreruleset/machine-learning-integration-plugin](https://github.com/coreruleset/machine-learning-integration-plugin) | official | draft |
| Apache-2.0 |
+| performance-plugin | 9,517,000 - 9,517,999 | [coreruleset/performance-plugin](https://github.com/coreruleset/performance-plugin) | official | draft (Private) |
| Apache-2.0 |
+| ghost-rule-exclusions | 9,518,000 - 9,518,999 | [coreruleset/ghost-rule-exclusions-plugin](https://github.com/coreruleset/ghost-rule-exclusions-plugin) | official | draft (Private) |
| Apache-2.0 |
+| roundcube-rule-exclusions-plugin | 9,519,000 - 9,519,999 | [EsadCetiner/roundcube-rule-exclusions-plugin](https://github.com/EsadCetiner/roundcube-rule-exclusions-plugin) | 3rd-party | ✅ tested |
| GPL-2.0 |
+| sogo-rule-exclusions-plugin | 9,520,000 - 9,520,999 | [EsadCetiner/sogo-rule-exclusions-plugin](https://github.com/EsadCetiner/sogo-rule-exclusions-plugin) | 3rd-party | ✅ tested |
| GPL-2.0 |
+| iredadmin-rule-exclusions-plugin | 9,521,000 - 9,521,999 | [EsadCetiner/iredadmin-rule-exclusions-plugin](https://github.com/EsadCetiner/iredadmin-rule-exclusions-plugin) | 3rd-party | ✅ tested |
| GPL-2.0 |
+| wordpress-hardening-plugin | 9,522,000 - 9,522,999 | [eilandert/wordpress-hardening-plugin](https://github.com/eilandert/wordpress-hardening-plugin) | 3rd-party | ✅ tested |
| Apache-2.0 |
| database-logging-plugin | 9,523,000 - 9,523,999 | [coreruleset/database-logging-plugin](https://github.com/coreruleset/database-logging-plugin) | official | untested | | Apache-2.0 |
-| referer-hardening-plugin | 9,524,000 - 9,524,999 | [coreruleset/referer-hardening-plugin](https://github.com/coreruleset/referer-hardening-plugin) | official | ✅ tested |  | Apache-2.0 |
-| false-positive-report-plugin | 9,525,000 - 9,525,999 | [coreruleset/false-positive-report-plugin](https://github.com/coreruleset/false-positive-report-plugin) | official | ✅ tested |  | Apache-2.0 |
-| traffic-observation-plugin | 9,526,000 - 9,526,999 | [coreruleset/traffic-observation-plugin](https://github.com/coreruleset/traffic-observation-plugin) | official | untested |  | Apache-2.0 |
+| referer-hardening-plugin | 9,524,000 - 9,524,999 | [coreruleset/referer-hardening-plugin](https://github.com/coreruleset/referer-hardening-plugin) | official | ✅ tested |
| Apache-2.0 |
+| false-positive-report-plugin | 9,525,000 - 9,525,999 | [coreruleset/false-positive-report-plugin](https://github.com/coreruleset/false-positive-report-plugin) | official | ✅ tested |
| Apache-2.0 |
+| traffic-observation-plugin | 9,526,000 - 9,526,999 | [coreruleset/traffic-observation-plugin](https://github.com/coreruleset/traffic-observation-plugin) | official | untested |
| Apache-2.0 |
| netnea-crs-upgrading-plugin | 9,527,000 - 9,527,999 | [netnea/netnea-crs-upgrading-plugin](https://github.com/netnea/netnea-crs-upgrading-plugin) | 3rd-party | untested | | GPL-3.0 |
-| plausible-rule-exclusions-plugin | 9,528,000 - 9,528,999 | [EsadCetiner/plausible-rule-exclusions-plugin](https://github.com/EsadCetiner/plausible-rule-exclusions-plugin) | 3rd-party | ✅ tested |  | GPL-2.0 |
-| vimbadmin-crs-plugin | 9,529,000 - 9,529,999 | [eilandert/vimbadmin-crs-plugin](https://github.com/eilandert/vimbadmin-crs-plugin) | 3rd-party | ✅ tested |  | Apache-2.0 |
+| plausible-rule-exclusions-plugin | 9,528,000 - 9,528,999 | [EsadCetiner/plausible-rule-exclusions-plugin](https://github.com/EsadCetiner/plausible-rule-exclusions-plugin) | 3rd-party | ✅ tested |
| GPL-2.0 |
+| vimbadmin-crs-plugin | 9,529,000 - 9,529,999 | [eilandert/vimbadmin-crs-plugin](https://github.com/eilandert/vimbadmin-crs-plugin) | 3rd-party | ✅ tested |
| Apache-2.0 |
| incubator | 9,900,000 - 9,999,999 | [coreruleset/incubator-plugin](https://github.com/coreruleset/incubator-plugin) | official | being tested | | Apache-2.0 |
diff --git a/registry-schema.json b/registry-schema.json
index 1517f0b..9fd120b 100644
--- a/registry-schema.json
+++ b/registry-schema.json
@@ -53,8 +53,8 @@
},
"repository": {
"type": "string",
- "pattern": "^https://github\\.com/[^/]+/[^/]+/?$",
- "description": "URL of the plugin source repository (currently only GitHub repositories are supported)"
+ "pattern": "^https://github\\.com/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/?$",
+ "description": "URL of the plugin source repository (currently only GitHub repositories are supported); the owner segment follows GitHub's username/org charset (alphanumerics and single hyphens, no dots or underscores) and the repo segment follows GitHub's repo charset (alphanumerics, '.', '_', '-'), so this value can never break out of the markdown/HTML it's rendered into"
},
"type": {
"type": "string",
diff --git a/scripts/generate_registry.py b/scripts/generate_registry.py
index bc2ae20..00234d9 100755
--- a/scripts/generate_registry.py
+++ b/scripts/generate_registry.py
@@ -12,6 +12,7 @@
import json
import re
import sys
+from html import escape
from pathlib import Path
import yaml
@@ -36,7 +37,7 @@ def format_range(rule_id_range: dict) -> str:
def format_repository(repository: str) -> str:
slug = repository.removeprefix("https://github.com/").rstrip("/")
- return f"[{slug}]({repository})"
+ return f"[{escape(slug, quote=True)}]({escape(repository, quote=True)})"
def format_status(plugin: dict) -> str:
@@ -50,7 +51,8 @@ def format_status(plugin: dict) -> str:
def format_ci(plugin: dict) -> str:
if not plugin.get("ci"):
return ""
- return f"}/actions/workflows/integration.yml/badge.svg)"
+ url = f"{plugin['repository'].rstrip('/')}/actions/workflows/integration.yml/badge.svg"
+ return f'
'
def render_row(plugin: dict) -> str:
@@ -105,7 +107,7 @@ def render_readme(registry: dict) -> None:
pattern = re.compile(re.escape(BEGIN_MARKER) + r".*?" + re.escape(END_MARKER), re.DOTALL)
if not pattern.search(readme):
raise SystemExit(f"README.md is missing {BEGIN_MARKER} / {END_MARKER} markers")
- README.write_text(pattern.sub(table, readme))
+ README.write_text(pattern.sub(lambda _match: table, readme))
def render_json(registry: dict) -> None:
diff --git a/tests/registry/invalid/bad-repository-owner-punctuation.yaml b/tests/registry/invalid/bad-repository-owner-punctuation.yaml
new file mode 100644
index 0000000..16b979f
--- /dev/null
+++ b/tests/registry/invalid/bad-repository-owner-punctuation.yaml
@@ -0,0 +1,8 @@
+schema_version: 1
+plugins:
+ - name: example
+ rule_id_range: {start: 9990000, end: 9990999}
+ repository: https://github.com/foo.bar/example-plugin
+ type: official
+ status: tested
+ license: Apache-2.0
diff --git a/tests/registry/invalid/bad-repository-special-chars.yaml b/tests/registry/invalid/bad-repository-special-chars.yaml
new file mode 100644
index 0000000..0a34c36
--- /dev/null
+++ b/tests/registry/invalid/bad-repository-special-chars.yaml
@@ -0,0 +1,8 @@
+schema_version: 1
+plugins:
+ - name: example
+ rule_id_range: {start: 9990000, end: 9990999}
+ repository: 'https://github.com/foo/bar">'
+ type: official
+ status: tested
+ license: Apache-2.0
diff --git a/tests/registry/test_validate.py b/tests/registry/test_validate.py
index 604e53b..99ef08e 100755
--- a/tests/registry/test_validate.py
+++ b/tests/registry/test_validate.py
@@ -3,15 +3,18 @@
# requires-python = ">=3.11"
# dependencies = ["pyyaml==6.0.3"]
# ///
-"""Self-check for the semantic validation in scripts/generate_registry.py.
+"""Self-check for scripts/generate_registry.py: semantic validation and rendering safety.
check-jsonschema only catches structural errors; duplicate names and
overlapping rule ID ranges are cross-item constraints it cannot express,
-so they are asserted here instead.
+so they are asserted here instead. This also covers rendering-safety
+regressions (HTML/markdown escaping, regex-replacement backreferences)
+that a schema alone can't guarantee against.
"""
import importlib.util
-import sys
+import os
+import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
@@ -26,7 +29,7 @@ def load(path: Path) -> dict:
return yaml.safe_load(path.read_text())
-def main() -> None:
+def check_semantic_validation() -> None:
registry = load(ROOT / "registry.yaml")
assert generate_registry.validate(registry) == [], "registry.yaml must be semantically valid"
@@ -38,6 +41,67 @@ def main() -> None:
errors = generate_registry.validate(overlapping)
assert any("overlapping rule ID ranges" in e for e in errors), errors
+
+def check_ci_badge_escaping() -> None:
+ # registry-schema.json's pattern rules this repository value out today, but
+ # the escaping is defense in depth, independent of the schema.
+ malicious = {"ci": True, "repository": 'https://github.com/foo/bar" onerror="alert(1)'}
+ badge = generate_registry.format_ci(malicious)
+ # The tag has exactly three literal attributes (alt=".." src=".." height="..") -> 6 quotes.
+ # A leaked, unescaped quote from the malicious value would add 2 more.
+ assert badge.count('"') == 6, badge
+ assert """ in badge, badge
+
+
+def check_repository_link_escaping() -> None:
+ malicious = "https://github.com/foo/bar"
+ rendered = generate_registry.format_repository(malicious)
+ assert "