bombsquad-plugin-manager/test/test_checks.py
Loup-Garou911XD bc5c5aafc8
Split ci.yml to close a pull_request_target pwn-request hole
pull_request_target checked out fork PR branches with the repo's
write-scoped GITHUB_TOKEN and ran autopep8/metadata scripts/tests
against that fork content, letting a malicious PR rewrite test/*.py
for arbitrary code execution with push access and secrets. It's also
been failing outright for weeks since actions/checkout now blocks
unsafe fork checkouts here without explicit opt-in.

Split into ci-check.yml (plain pull_request, GitHub's read-only
no-secrets token, safe to run fork code) which uploads a diff
artifact, and ci-apply.yml (workflow_run, privileged) which only
applies that diff via `git apply`, never executing fork content.
ci.yml keeps just the push-to-main job as the strict integrity check.

Because GitHub runs the PR's own copy of ci-check.yml for
pull_request events, that artifact is attacker-authored: ci-apply.yml
therefore resolves PR identity from the workflow_run payload plus the
API rather than the artifact, passes every dynamic value through env:
instead of ${{ }} in run: blocks (which the runner substitutes before
the shell parses, so quotes don't contain it), and validates branch,
repo, sha and PR-number shapes before use. The patch itself stays
untrusted input: allowlist-validated and applied only to the fork's
own branch.

test_checks.py adds an env-gated lenient mode so ci-check.yml's
preview run doesn't fail on a brand-new plugin's not-yet-existing
commit sha, while history and push-to-main stay strict.
2026-08-09 22:56:49 +05:30

273 lines
12 KiB
Python

import git
import hashlib
import json
import re
import io
import os
import pathlib
from packaging.version import Version
import unittest
# Raised by Repo.commit() when a revision cannot be resolved. Both entries are
# required because which one you get depends on how the sha is spelled: an
# unresolvable abbreviated name - the 7-8 char form this repo actually stores,
# see auto_apply_version_metadata.py - raises BadName, whereas a full-length
# but absent 40-hex sha raises plain ValueError. BadName/BadObject are NOT
# ValueError subclasses, so neither entry is redundant. Kept narrow so lenient
# mode can't mask unrelated repository errors.
#
# BadName/BadObject originate in gitdb but are re-exported by GitPython in
# git.exc.__all__, so git.exc.* is the supported public spelling and is used
# here deliberately - it avoids importing gitdb, a transitive dependency that
# test/pip_reqs.txt does not declare directly.
UNRESOLVED_COMMIT_ERRORS = (ValueError, git.exc.BadName, git.exc.BadObject)
def is_unpublished_version(repository, version_metadata):
"""True if this version entry is being introduced by the PR under test.
Such an entry can't satisfy the history checks yet: its metadata is either
still an unstamped ``null`` placeholder, or was stamped against the current
HEAD by ci-check.yml's preview - describing the reformatted working tree
rather than anything committed. Published entries always point at an
earlier commit whose tree really does contain the described file, so they
are unaffected. Only consulted in lenient mode (ci-check.yml); the
authoritative strict run on push-to-main still validates these.
"""
if not version_metadata or not version_metadata.get("commit_sha"):
return True
try:
return repository.commit(version_metadata["commit_sha"]) == repository.head.commit
except UNRESOLVED_COMMIT_ERRORS:
return False # unresolvable: let the caller's handler report it
class TestPluginManagerMetadata(unittest.TestCase):
def setUp(self):
with open("index.json", "rb") as fin:
self.content = json.load(fin)
self.plugin_manager = "plugin_manager.py"
self.api_version_regexp = re.compile(b"(?<=ba_meta require api )(.*)")
self.plugin_manager_version_regexp = re.compile(b"(?<=PLUGIN_MANAGER_VERSION = )(.*)")
self.current_path = pathlib.Path()
self.changelog = self.current_path / "CHANGELOG.md"
self.repository = git.Repo()
def test_keys(self):
self.assertTrue(isinstance(self.content["plugin_manager_url"], str))
self.assertTrue(isinstance(self.content["versions"], dict))
self.assertTrue(isinstance(self.content["categories"], list))
self.assertTrue(isinstance(self.content["external_source_url"], str))
def test_versions_order(self):
versions = list(self.content["versions"].items())
sorted_versions = sorted(
versions,
key=lambda version: Version(version[0]),
reverse=True,
)
assert sorted_versions == versions
def test_versions(self):
lenient = os.environ.get("PLUGMAN_CI_LENIENT_HISTORY") == "1"
for version_name, version_metadata in self.content["versions"].items():
if lenient and is_unpublished_version(self.repository, version_metadata):
print(f"[lenient] skipping {version_name}: not committed yet")
continue
try:
commit = self.repository.commit(version_metadata["commit_sha"])
except UNRESOLVED_COMMIT_ERRORS as err:
if lenient:
print(f"[lenient] skipping {version_name}: commit "
f"{version_metadata['commit_sha']} not found yet ({err})")
continue
raise
plugin_manager = commit.tree / self.plugin_manager
with io.BytesIO(plugin_manager.data_stream.read()) as fin:
content = fin.read()
md5sum = hashlib.md5(content).hexdigest()
api_version = self.api_version_regexp.search(content).group()
plugin_manager_version = self.plugin_manager_version_regexp.search(content).group()
if md5sum != version_metadata["md5sum"]:
self.fail(
"Plugin manager MD5 checksum changed;\n"
f"{version_metadata['md5sum']} (mentioned in index.json) ->\n"
f"{md5sum} (actual)"
)
self.assertEqual(int(api_version.decode("utf-8")), version_metadata["api_version"])
self.assertEqual(plugin_manager_version.decode("utf-8"), f'"{version_name}"')
def test_latest_version(self):
versions = tuple(self.content["versions"].items())
latest_version_name, latest_version_metadata = versions[0]
plugin_manager = self.current_path / self.plugin_manager
with open(plugin_manager, "rb") as fin:
content = fin.read()
md5sum = hashlib.md5(content).hexdigest()
api_version = self.api_version_regexp.search(content).group()
plugin_manager_version = self.plugin_manager_version_regexp.search(content).group()
if md5sum != latest_version_metadata["md5sum"]:
self.fail(
"Plugin manager MD5 checksum changed;\n"
f"{latest_version_metadata['md5sum']} (mentioned in index.json) ->\n"
f"{md5sum} (actual)"
)
self.assertEqual(int(api_version.decode("utf-8")), latest_version_metadata["api_version"])
self.assertEqual(plugin_manager_version.decode("utf-8"), f'"{latest_version_name}"')
def test_changelog_entries(self):
versions = tuple(self.content["versions"].keys())
with open(self.changelog, "r") as fin:
changelog = fin.read()
for version in versions:
changelog_version_header = f"## {version}"
if changelog_version_header not in changelog:
self.fail(f"Changelog entry for plugin manager {version} is missing.")
class TestPluginMetadata(unittest.TestCase):
def setUp(self):
self.category_directories = tuple(
f'{os.path.join("plugins", path)}'
for path in os.listdir("plugins") if os.path.isdir(path)
)
def test_no_duplicates(self):
unique_plugins = set()
total_plugin_count = 0
for category in self.category_directories:
plugins = os.listdir(category)
total_plugin_count += len(plugins)
unique_plugins.update(plugins)
self.assertEqual(len(unique_plugins), total_plugin_count)
class BaseCategoryMetadataTestCases:
class BaseTest(unittest.TestCase):
def setUp(self):
self.api_version_regexp = re.compile(b"(?<=ba_meta require api )(.*)")
self.current_path = pathlib.Path()
self.repository = git.Repo()
def test_keys(self):
self.assertEqual(self.content["name"], self.name)
self.assertTrue(isinstance(self.content["description"], str))
self.assertTrue(self.content["plugins_base_url"].startswith("https"))
self.assertTrue(isinstance(self.content["plugins"], dict))
def test_versions_order(self):
for plugin_metadata in self.content["plugins"].values():
versions = list(plugin_metadata["versions"].items())
sorted_versions = sorted(
versions,
key=lambda version: Version(version[0]),
reverse=True,
)
self.assertEqual(sorted_versions, versions)
def test_plugin_keys(self):
for plugin_metadata in self.content["plugins"].values():
self.assertTrue(isinstance(plugin_metadata["description"], str))
self.assertTrue(isinstance(plugin_metadata["external_url"], str))
self.assertTrue(isinstance(plugin_metadata["authors"], list))
self.assertTrue(len(plugin_metadata["authors"]) > 0)
for author in plugin_metadata["authors"]:
self.assertTrue(isinstance(author["name"], str))
self.assertTrue(isinstance(author["email"], str))
self.assertTrue(isinstance(author["discord"], str))
self.assertTrue(isinstance(plugin_metadata["versions"], dict))
self.assertTrue(len(plugin_metadata["versions"]) > 0)
def test_versions(self):
lenient = os.environ.get("PLUGMAN_CI_LENIENT_HISTORY") == "1"
for plugin_name, plugin_metadata in self.content["plugins"].items():
for version_name, version_metadata in plugin_metadata["versions"].items():
if lenient and is_unpublished_version(self.repository, version_metadata):
print(f"[lenient] skipping {plugin_name} {version_name}: "
"not committed yet")
continue
try:
commit = self.repository.commit(version_metadata["commit_sha"])
except UNRESOLVED_COMMIT_ERRORS as err:
if lenient:
print(f"[lenient] skipping {plugin_name} {version_name}: "
f"commit {version_metadata['commit_sha']} not found yet ({err})")
continue
raise
plugin = os.path.join(self.category, f"{plugin_name}.py")
plugin_commit_sha = commit.tree / plugin
with io.BytesIO(plugin_commit_sha.data_stream.read()) as fin:
content = fin.read()
md5sum = hashlib.md5(content).hexdigest()
api_version = self.api_version_regexp.search(content).group()
if md5sum != version_metadata["md5sum"]:
self.fail(
f"{plugin} checksum changed for version {version_name};\n"
f"{version_metadata['md5sum']} (mentioned in {self.category_metadata_file}) ->\n"
f"{md5sum} (actual)"
)
self.assertEqual(int(api_version.decode("utf-8")),
version_metadata["api_version"])
def test_latest_version(self):
for plugin_name, plugin_metadata in self.content["plugins"].items():
latest_version_name, latest_version_metadata = tuple(
plugin_metadata["versions"].items())[0]
plugin = self.current_path / self.category / f"{plugin_name}.py"
with open(plugin, "rb") as fin:
content = fin.read()
md5sum = hashlib.md5(content).hexdigest()
api_version = self.api_version_regexp.search(content).group()
if md5sum != latest_version_metadata["md5sum"]:
self.fail(
f"Latest version {latest_version_name} of "
f"{plugin} checksum changed;\n"
f"{latest_version_metadata['md5sum']} (mentioned in {self.category_metadata_file}) ->\n"
f"{md5sum} (actual)"
)
self.assertEqual(md5sum, latest_version_metadata["md5sum"])
self.assertEqual(int(api_version.decode("utf-8")),
latest_version_metadata["api_version"])
class TestUtilitiesCategoryMetadata(BaseCategoryMetadataTestCases.BaseTest):
def setUp(self):
super().setUp()
self.name = "Utilities"
self.category = os.path.join("plugins", "utilities")
self.category_metadata_file = f"{self.category}.json"
with open(self.category_metadata_file, "rb") as fin:
self.content = json.load(fin)
class TestMapsCategoryMetadata(BaseCategoryMetadataTestCases.BaseTest):
def setUp(self):
super().setUp()
self.name = "Maps"
self.category = os.path.join("plugins", "maps")
self.category_metadata_file = f"{self.category}.json"
with open(self.category_metadata_file, "rb") as fin:
self.content = json.load(fin)
class TestMinigamesCategoryMetadata(BaseCategoryMetadataTestCases.BaseTest):
def setUp(self):
super().setUp()
self.name = "Minigames"
self.category = os.path.join("plugins", "minigames")
self.category_metadata_file = f"{self.category}.json"
with open(self.category_metadata_file, "rb") as fin:
self.content = json.load(fin)