The admission gate ran the install scanner (dangerous fails) but nothing refused a compiled binary, committed bytecode, or an archive hiding either — the shapes the scanner cannot read. This adds that enforcement to pinned-source-validate. - scripts/scan_plugin_payloads.py (stdlib-only, so the no-install validator and third-party CI can run it): flags compiled binaries by magic header (ELF, Mach-O, PE/MZ, WebAssembly — not by extension, which a rename defeats), .pyc/.pyo bytecode (incl. sourceless/hash-forged caches that run instead of the .py beside them), and archives whose members are not reviewable source. Plain-text source bundles and inert assets (fonts, images) pass. - plugin-catalog-ci.yml: after `hermes plugins validate`, each changed entry is scanned; a finding fails it. An entry may list `attested_binaries:` paths, each verified with `gh attestation verify --repo <repo> --source-digest <sha>` against the pinned commit; verified paths are dropped before the scan, any other binary still fails. Repo-controlled filenames are newline-stripped in the log so none can forge a workflow command. - validate_plugin_catalog.py: `attested_binaries` is a known key, shape-checked as a list of relative paths. - Tests: unit tests for the detector and two new workflow-driven bypass cases (bundled .exe, committed .pyc) in the confinement harness, which runs the real `run:` block. Full-content re-sweep of all 543 pins: only tabro carries a native binary (it is delisted in the companion PR). Three entries (crawl4ai, hermes-vision-inline, mermail) commit __pycache__/*.pyc build litter and would fail rule 17 on their next pin bump — pre-existing, not touched here.
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Unit tests for the catalog payload scanner (README rule 17).
|
|
|
|
``scripts/scan_plugin_payloads.py`` refuses compiled binaries, committed Python
|
|
bytecode and archives hiding either, while leaving plain-text source bundles and
|
|
inert assets (fonts, images) alone. Invariants, not line counts:
|
|
one benign shape stays clean, one attack shape per bypass still fails.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import io
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "scan_plugin_payloads.py"
|
|
|
|
_spec = importlib.util.spec_from_file_location("scan_plugin_payloads", SCRIPT)
|
|
mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(mod)
|
|
|
|
|
|
def _scan(d: Path) -> list[str]:
|
|
return mod.scan(d)
|
|
|
|
|
|
def _run(d: Path) -> subprocess.CompletedProcess:
|
|
return subprocess.run([sys.executable, str(SCRIPT), str(d)], capture_output=True, text=True, check=False)
|
|
|
|
|
|
def test_plain_source_tree_is_clean(tmp_path):
|
|
(tmp_path / "__init__.py").write_text("x = 1\n")
|
|
(tmp_path / "README.md").write_text("# hi\n")
|
|
(tmp_path / "data.json").write_text("{}\n")
|
|
assert _scan(tmp_path) == []
|
|
assert _run(tmp_path).returncode == 0
|
|
|
|
|
|
@pytest.mark.parametrize("name,head", [
|
|
("evil.exe", b"MZ\x90\x00"),
|
|
("renamed.js", b"MZ\x90\x00"), # PE header, source extension
|
|
("lib.so", b"\x7fELF\x02\x01\x01"),
|
|
("odd.dat", b"\x7fELF"), # ELF, unknown extension
|
|
("mod.wasm", b"\x00asm\x01\x00"),
|
|
("bin.dylib", b"\xcf\xfa\xed\xfe"),
|
|
])
|
|
def test_compiled_binary_is_flagged_by_header(tmp_path, name, head):
|
|
(tmp_path / name).write_bytes(head + b"\x00" * 32)
|
|
findings = _scan(tmp_path)
|
|
assert any(name in f and "compiled binary" in f for f in findings), findings
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["cached.pyc", "sub/thing.pyo"])
|
|
def test_committed_bytecode_is_flagged(tmp_path, name):
|
|
p = tmp_path / name
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_bytes(b"\x00" * 16)
|
|
findings = _scan(tmp_path)
|
|
assert any(name.split("/")[-1] in f and "bytecode" in f for f in findings), findings
|
|
|
|
|
|
def test_source_tarball_is_clean(tmp_path):
|
|
arc = tmp_path / "bundle.tar.gz"
|
|
with tarfile.open(arc, "w:gz") as tf:
|
|
for member_name, body in [("a.py", b"x=1\n"), ("docs/readme.md", b"# hi\n"),
|
|
("Dockerfile", b"FROM scratch\n"), ("logs.jsonl", b"{}\n"),
|
|
("font.ttf", b"\x00\x01\x00\x00font")]:
|
|
ti = tarfile.TarInfo(member_name); ti.size = len(body)
|
|
tf.addfile(ti, io.BytesIO(body))
|
|
assert _scan(tmp_path) == []
|
|
|
|
|
|
def test_tarball_hiding_a_binary_fails(tmp_path):
|
|
arc = tmp_path / "bundle.tar.gz"
|
|
with tarfile.open(arc, "w:gz") as tf:
|
|
body = b"\x7fELF" + b"\x00" * 40 # ELF under a .py member name
|
|
ti = tarfile.TarInfo("mod.py"); ti.size = len(body)
|
|
tf.addfile(ti, io.BytesIO(body))
|
|
findings = _scan(tmp_path)
|
|
assert any("compiled binary" in f for f in findings), findings
|
|
|
|
|
|
def test_tarball_with_unknown_blob_member_fails(tmp_path):
|
|
arc = tmp_path / "bundle.tgz"
|
|
with tarfile.open(arc, "w:gz") as tf:
|
|
body = b"\x00\x01\x02opaque"
|
|
ti = tarfile.TarInfo("payload.bin"); ti.size = len(body)
|
|
tf.addfile(ti, io.BytesIO(body))
|
|
findings = _scan(tmp_path)
|
|
assert any("not reviewable source" in f for f in findings), findings
|
|
|
|
|
|
def test_zip_hiding_a_binary_fails(tmp_path):
|
|
arc = tmp_path / "bundle.zip"
|
|
with zipfile.ZipFile(arc, "w") as zf:
|
|
zf.writestr("inner.exe", b"MZ\x90\x00")
|
|
findings = _scan(tmp_path)
|
|
assert any("not reviewable source" in f for f in findings), findings
|
|
|
|
|
|
def test_symlinks_are_not_followed(tmp_path):
|
|
(tmp_path / "real.py").write_text("x=1\n")
|
|
try:
|
|
(tmp_path / "link").symlink_to("/bin/ls")
|
|
except OSError:
|
|
pytest.skip("symlinks unsupported")
|
|
assert _scan(tmp_path) == []
|