From f6e1aef71f0561a78ced1a88c7d244b6645dad2b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:13:34 +0000 Subject: [PATCH] security: enforce verified publisher boundaries and path validation Co-authored-by: ClausValcaTD <193546948+ClausValcaTD@users.noreply.github.com> --- .github/CODEOWNERS | 6 ++ .gitignore | 1 + public/api/catalog.json | 2 +- scripts/build_catalog.py | 41 +++++++++-- scripts/validate_plugin.py | 30 +++++++- tests/test_security.py | 137 +++++++++++++++++++++++++++++++++++++ verified_publishers.json | 3 + 7 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .gitignore create mode 100644 tests/test_security.py create mode 100644 verified_publishers.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..909d7d4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +* @M5Devs +/publishers/ @M5Devs +/scripts/ @M5Devs +/.github/ @M5Devs +schema.sql @M5Devs +verified_publishers.json @M5Devs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/public/api/catalog.json b/public/api/catalog.json index 5270bf9..0993952 100644 --- a/public/api/catalog.json +++ b/public/api/catalog.json @@ -1,6 +1,6 @@ { "version": "1.0", - "generated_at": "2026-10-17T12:02:24.692910+00:00", + "generated_at": "2026-10-17T12:10:19.477217+00:00", "total_plugins": 3, "plugins": [ { diff --git a/scripts/build_catalog.py b/scripts/build_catalog.py index 23830a9..d350912 100644 --- a/scripts/build_catalog.py +++ b/scripts/build_catalog.py @@ -1,11 +1,22 @@ #!/usr/bin/env python3 from datetime import datetime, timezone import json - from pathlib import Path import sys -def load_publishers() -> dict: +def load_verified_publishers() -> set: + v_file = Path("verified_publishers.json") + if v_file.exists(): + try: + with open(v_file, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return set(data) + except Exception as e: + print(f"⚠️ Warning: Failed to load verified publishers from {v_file}: {e}") + return set() + +def load_publishers(verified_ids: set) -> dict: publishers = {} pub_dir = Path("publishers") if pub_dir.exists(): @@ -14,7 +25,10 @@ def load_publishers() -> dict: with open(p_file, "r", encoding="utf-8") as f: data = json.load(f) if "id" in data: - publishers[data["id"]] = data + pub_id = data["id"] + data["is_verified"] = pub_id in verified_ids + data.pop("verified", None) + publishers[pub_id] = data except Exception as e: print(f"⚠️ Warning: Failed to load publisher {p_file}: {e}") return publishers @@ -25,7 +39,8 @@ def build_catalog(): print("❌ 'manifests/' directory not found.") sys.exit(1) - publishers = load_publishers() + verified_ids = load_verified_publishers() + publishers = load_publishers(verified_ids) plugins = [] manifest_files = sorted(list(manifests_dir.rglob("manifest.json"))) @@ -36,17 +51,29 @@ def build_catalog(): with open(m_file, "r", encoding="utf-8") as f: manifest = json.load(f) - author_id = manifest.get("author", "") + # Ignore any is_verified or verified flags originating from individual manifest files + clean_manifest = { + k: v for k, v in manifest.items() + if k not in ("is_verified", "verified") + } + + author_id = clean_manifest.get("author") or clean_manifest.get("publisher_id", "") + publisher_data = publishers.get(author_id, { "id": author_id, "name": author_id, "github": "", - "is_verified": False, + "is_verified": author_id in verified_ids, "badge": None }) + # Ensure publisher_data is_verified strictly matches the authoritative list + publisher_data = dict(publisher_data) + publisher_data["is_verified"] = author_id in verified_ids + publisher_data.pop("verified", None) + plugin_entry = { - **manifest, + **clean_manifest, "publisher": publisher_data } plugins.append(plugin_entry) diff --git a/scripts/validate_plugin.py b/scripts/validate_plugin.py index a28a6a2..0229185 100644 --- a/scripts/validate_plugin.py +++ b/scripts/validate_plugin.py @@ -26,6 +26,16 @@ def validate_manifest(file_path: Path) -> bool: is_valid = True + # Security check 1: Reject any community manifest that attempts to declare is_verified or verified + for verified_key in ("is_verified", "verified"): + if verified_key in data: + if data[verified_key] is True or str(data[verified_key]).lower() == "true": + print( + f"❌ Security Violation in {file_path}: Manifest attempts to declare " + f"'{verified_key}': {data[verified_key]}. Self-verification is strictly forbidden." + ) + is_valid = False + for field in REQUIRED_FIELDS: if field not in data or data[field] is None: print(f"❌ Error in {file_path}: Missing mandatory field '{field}'.") @@ -37,13 +47,29 @@ def validate_manifest(file_path: Path) -> bool: print(f"❌ Error in {file_path}: Field 'id' must not be empty.") is_valid = False - if "author" in data and isinstance(data["author"], str): - author_id = data["author"] + author_id = data.get("author") + if author_id and isinstance(author_id, str): publisher_path = Path("publishers") / f"{author_id}.json" if not publisher_path.exists(): print(f"❌ Error in {file_path}: Author/Publisher '{author_id}' record not found at {publisher_path}.") is_valid = False + # Security check 2: Ensure the manifest's publisher_id/author matches the directory path (manifests///) + # Expected relative path format: manifests/////manifest.json + parts = file_path.parts + # Looking for 'manifests' in path parts + if "manifests" in parts: + idx = parts.index("manifests") + # Expected structure after 'manifests': letter (idx+1), publisher_id (idx+2) + if len(parts) > idx + 2: + expected_publisher = parts[idx + 2] + if author_id != expected_publisher: + print( + f"❌ Security Violation in {file_path}: Publisher ID in manifest ('{author_id}') " + f"does not match directory path publisher ID ('{expected_publisher}')." + ) + is_valid = False + if "conversions" in data: if not isinstance(data["conversions"], list) or len(data["conversions"]) == 0: print(f"❌ Error in {file_path}: Field 'conversions' must be a non-empty list.") diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..30a1953 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,137 @@ +import json +import subprocess +import sys +import tempfile +from pathlib import Path +import unittest + +class SecurityTests(unittest.TestCase): + def test_build_catalog_ignores_fraudulent_verification(self): + """Test that build_catalog.py sets is_verified=False for unlisted publishers even if manifest claims is_verified: true.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + + # Setup verified_publishers.json in temp directory + verified_pub = tmppath / "verified_publishers.json" + verified_pub.write_text(json.dumps(["m5dev"]), encoding="utf-8") + + # Setup publishers directory + pub_dir = tmppath / "publishers" + pub_dir.mkdir() + (pub_dir / "m5dev.json").write_text(json.dumps({ + "id": "m5dev", "name": "M5", "is_verified": True + }), encoding="utf-8") + (pub_dir / "attacker.json").write_text(json.dumps({ + "id": "attacker", "name": "Attacker", "is_verified": True + }), encoding="utf-8") + + # Setup manifests + manifest_dir = tmppath / "manifests" / "a" / "attacker" / "bad-plugin" / "1.0.0" + manifest_dir.mkdir(parents=True) + manifest = { + "id": "bad-plugin", + "name": "Bad Plugin", + "version": "1.0.0", + "author": "attacker", + "conversions": ["ISO"], + "is_verified": True, + "verified": True + } + (manifest_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + code = f""" +import sys +from pathlib import Path +import os +os.chdir(r"{tmpdir}") +sys.path.insert(0, r"{Path('.').absolute()}") +from scripts.build_catalog import build_catalog +build_catalog() +""" + res = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + self.assertEqual(res.returncode, 0, msg=f"build_catalog failed: {res.stderr}\n{res.stdout}") + + catalog_path = tmppath / "public" / "api" / "catalog.json" + self.assertTrue(catalog_path.exists()) + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + + plugin = catalog["plugins"][0] + self.assertFalse(plugin["publisher"]["is_verified"], "Unverified publisher was marked verified!") + self.assertNotIn("is_verified", plugin, "is_verified should be removed from plugin root object") + + def test_validate_plugin_rejects_self_verification(self): + """Test that validate_plugin.py rejects manifests declaring is_verified: true.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + pub_dir = tmppath / "publishers" + pub_dir.mkdir() + (pub_dir / "attacker.json").write_text(json.dumps({ + "id": "attacker", "name": "Attacker" + }), encoding="utf-8") + + manifest_dir = tmppath / "manifests" / "a" / "attacker" / "bad-plugin" / "1.0.0" + manifest_dir.mkdir(parents=True) + manifest = { + "id": "bad-plugin", + "name": "Bad Plugin", + "version": "1.0.0", + "author": "attacker", + "conversions": ["ISO"], + "is_verified": True + } + manifest_file = manifest_dir / "manifest.json" + manifest_file.write_text(json.dumps(manifest), encoding="utf-8") + + code = f""" +import sys +from pathlib import Path +import os +os.chdir(r"{tmpdir}") +sys.path.insert(0, r"{Path('.').absolute()}") +from scripts.validate_plugin import validate_manifest +valid = validate_manifest(Path(r"{manifest_file}")) +sys.exit(0 if valid else 1) +""" + res = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + self.assertNotEqual(res.returncode, 0, "validate_plugin should have rejected self-verified manifest!") + self.assertIn("Security Violation", res.stdout) + + def test_validate_plugin_rejects_path_mismatch(self): + """Test that validate_plugin.py rejects manifests where author != path publisher_id.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + pub_dir = tmppath / "publishers" + pub_dir.mkdir() + (pub_dir / "victim.json").write_text(json.dumps({ + "id": "victim", "name": "Victim" + }), encoding="utf-8") + + # Manifest in attacker directory claiming author is victim + manifest_dir = tmppath / "manifests" / "a" / "attacker" / "impostor-plugin" / "1.0.0" + manifest_dir.mkdir(parents=True) + manifest = { + "id": "impostor-plugin", + "name": "Impostor Plugin", + "version": "1.0.0", + "author": "victim", + "conversions": ["ISO"] + } + manifest_file = manifest_dir / "manifest.json" + manifest_file.write_text(json.dumps(manifest), encoding="utf-8") + + code = f""" +import sys +from pathlib import Path +import os +os.chdir(r"{tmpdir}") +sys.path.insert(0, r"{Path('.').absolute()}") +from scripts.validate_plugin import validate_manifest +valid = validate_manifest(Path(r"{manifest_file}")) +sys.exit(0 if valid else 1) +""" + res = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + self.assertNotEqual(res.returncode, 0, "validate_plugin should have rejected path mismatch!") + self.assertIn("Security Violation", res.stdout) + +if __name__ == "__main__": + unittest.main() diff --git a/verified_publishers.json b/verified_publishers.json new file mode 100644 index 0000000..a5a8fbe --- /dev/null +++ b/verified_publishers.json @@ -0,0 +1,3 @@ +[ + "m5dev" +]