Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
* @M5Devs
/publishers/ @M5Devs
/scripts/ @M5Devs
/.github/ @M5Devs
schema.sql @M5Devs
verified_publishers.json @M5Devs
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
2 changes: 1 addition & 1 deletion public/api/catalog.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
41 changes: 34 additions & 7 deletions scripts/build_catalog.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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
Expand All @@ -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")))
Expand All @@ -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)
Expand Down
30 changes: 28 additions & 2 deletions scripts/validate_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'.")
Expand All @@ -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/<letter>/<publisher_id>/)
# Expected relative path format: manifests/<letter>/<publisher_id>/<plugin_id>/<version>/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.")
Expand Down
137 changes: 137 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions verified_publishers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[
"m5dev"
]
Loading