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.
This commit is contained in:
Teknium
2026-10-11 02:34:57 -07:00
parent efd17a70b5
commit cdce56faf2
5 changed files with 385 additions and 0 deletions

View File

@@ -119,6 +119,10 @@ jobs:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
# A hostile repo URL must never block the job on a credential prompt.
GIT_TERMINAL_PROMPT: "0"
# `gh attestation verify` (README rule 17) reads the attestation from
# GitHub and needs a token. Read-only default token; the step runs
# pinned-repo code, so nothing broader is exposed here.
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
FAILED=0
@@ -257,6 +261,78 @@ jobs:
FAILED=1; echo "::endgroup::"; continue
fi
# UNSCANNABLE-PAYLOAD GATE (README rule 17). A reviewer and the install
# scanner must be able to read everything shipped at the pin as source:
# no compiled binaries (by header), committed bytecode, or archives
# hiding either. Entries that genuinely need a native component list
# each one under `attested_binaries:` and the binary must carry a
# GitHub build-provenance attestation tying it to this repo's own
# workflow at the pinned source digest; those paths are exempted here
# and verified below. No stdin: the parser must not drain the entry fd.
ATTESTED=$(python3 -I - "$entry" <<'PYEOF' </dev/null
import re, shlex, sys
from ruamel.yaml import YAML
try:
with open(sys.argv[1], encoding="utf-8") as fh:
data = YAML(typ="safe").load(fh)
except Exception:
data = None
paths = data.get("attested_binaries") if isinstance(data, dict) else None
if not isinstance(paths, list):
paths = []
ok = []
for p in paths:
# Relative path, no '.'/'..' segments, no absolute/newline: it is
# joined onto the clone and echoed into the log.
if (isinstance(p, str) and re.fullmatch(r"[A-Za-z0-9._/-]+", p)
and not any(seg in ("", ".", "..") for seg in p.split("/"))):
ok.append(p)
else:
print("BAD=" + shlex.quote(f"attested_binaries entry {p!r} is not a plain relative path"))
sys.exit(0)
print("PATHS=" + shlex.quote("\n".join(ok)))
PYEOF
) || ATTESTED="BAD='attested_binaries parser failed'"
ATT_BAD=""; ATT_PATHS=""
eval "${ATTESTED/PATHS=/ATT_PATHS=}"; eval "${ATTESTED/BAD=/ATT_BAD=}" 2>/dev/null || true
if [ -n "${ATT_BAD:-}" ]; then
echo "::error file=$entry::$ATT_BAD"
FAILED=1; echo "::endgroup::"; continue
fi
# Verify each declared binary's provenance against THIS repo at the
# pinned commit; a verified path is dropped from the clone so the
# detector that follows ignores it, and any other binary still fails.
ATT_OK=1
if [ -n "${ATT_PATHS:-}" ]; then
OWNER_REPO=$(printf '%s' "$REPO" | sed -E 's#^https://github\.com/##; s#\.git$##; s#/$##')
while IFS= read -r bpath; do
[ -z "$bpath" ] && continue
BFILE="$PLUGIN_DIR/$bpath"
if [ ! -f "$BFILE" ] || [ -L "$BFILE" ]; then
echo "::error file=$entry::attested_binaries path is not a regular file: $bpath"
ATT_OK=0; break
fi
if ! timeout 120 gh attestation verify "$BFILE" \
--repo "$OWNER_REPO" --source-digest "$SHA" \
--predicate-type https://slsa.dev/provenance/v1 </dev/null; then
echo "::error file=$entry::no build-provenance attestation for $bpath at $OWNER_REPO@$SHA (README rule 17)"
ATT_OK=0; break
fi
rm -f -- "$BFILE"
done <<EOF_ATT
$ATT_PATHS
EOF_ATT
fi
if [ "$ATT_OK" -ne 1 ]; then
FAILED=1; echo "::endgroup::"; continue
fi
if ! PAYLOAD=$(python3 "$GITHUB_WORKSPACE/scripts/scan_plugin_payloads.py" "$PLUGIN_DIR" </dev/null); then
# Filenames are repo-controlled: strip newlines so none can forge a
# workflow command in the log.
echo "::error file=$entry::unscannable executable payload (README rule 17): $(printf '%s' "$PAYLOAD" | tr '\n' ';')"
FAILED=1; echo "::endgroup::"; continue
fi
# SELF-UPDATER GATE (README rule 3): the pin is the only update path.
# A desktop bundle that both fetches from GitHub AND writes/renames
# plugin files is a self-updater; either half alone is fine (a