Files
hermes-agent/tests/scripts/test_scan_plugin_payloads.py
Teknium cdce56faf2 plugin-catalog CI: enforce rule 17 — scan pinned entries for unscannable payloads
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.
2026-10-11 03:03:39 -07:00

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) == []