mirror of
https://github.com/bombsquad-community/plugin-manager.git
synced 2026-08-27 01:12:38 +00:00
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.
This commit is contained in:
parent
d442c44ea8
commit
bc5c5aafc8
4 changed files with 450 additions and 29 deletions
|
|
@ -11,6 +11,39 @@ 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):
|
||||
|
|
@ -40,8 +73,19 @@ class TestPluginManagerMetadata(unittest.TestCase):
|
|||
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():
|
||||
commit = self.repository.commit(version_metadata["commit_sha"])
|
||||
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()
|
||||
|
|
@ -144,9 +188,21 @@ class BaseCategoryMetadataTestCases:
|
|||
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():
|
||||
commit = self.repository.commit(version_metadata["commit_sha"])
|
||||
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue