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:
76
.github/workflows/plugin-catalog-ci.yml
vendored
76
.github/workflows/plugin-catalog-ci.yml
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user