From b67401a03bb866a6115d82bbaf5f42cb8111e835 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 7 Aug 2026 17:59:50 +0200 Subject: [PATCH] ci(dry-run): scan the archive artifacts with VirusTotal as the final step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparation for the next dry-run cycle. The release gate scans every shipped artifact with VirusTotal and blocks on any detection; the dry run did not, so the one question most likely to stop a release was the one a dry run could not answer. v0.9.1-rc found that out the expensive way. The scan mirrors the release job. The only difference is where the archives come from: the release pulls them off a published release, a dry run has none, so they come from the build job's artifacts — the same files, produced by the same canonical package-release.sh, extracted the same way and scanned as the same bytes a user would download and run. Same zero-tolerance gate (scripts/ci/check-virustotal.sh), so a dry run now fails where the release would. It runs LAST, after smoke and soak. It is the slowest job — polling can take two hours — and its verdict does not depend on our code at all: the same unchanged bytes can come back clean one day and flagged the next. That must not delay the feedback that IS about our code. Two independent skips, because they answer different questions: skip_builds no artifacts at all "do lint and tests pass?" skip_virustotal build + smoke + soak, no scan "do the archives pass CI?" The second is the one asked for: during a debug cycle, when the question is whether the archives survive smoke and soak, a two-hour scan burning API quota is pure noise. skip_builds implies no scan, since there is nothing to scan. Extraction lives in scripts/ci/extract-release-archives.sh rather than inline in the YAML because the venue-parity contract requires it — a venue may provision, plumb artifacts, or call a canonical leg script, and this is leg logic. The contract caught the inline version. Two defects found by testing the script instead of trusting it: * The empty-input guard did not guard. It counted files in the output directory, but the install scripts are copied in unconditionally, so the count was never zero and an input with no archives reported success — a clean VirusTotal run over nothing, the exact false green the check exists to prevent. It now counts binaries recovered from archives, and exits 1 on zero. * The script was committed 100644 while the workflow invokes it directly as a command, which is the "Permission denied" failure test_script_exec_bit_contract.sh was written for. That contract scans scripts/ but not .github/workflows/, so it did not catch it here. Signed-off-by: Martin Vogel --- .github/workflows/dry-run.yml | 74 ++++++++++++++++++++++++-- scripts/ci/extract-release-archives.sh | 65 ++++++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100755 scripts/ci/extract-release-archives.sh diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 604d13c94b..49f363d51f 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -1,11 +1,23 @@ # Manual trigger: test everything before pushing a release. # Each step can be skipped for faster iteration. # -# Pipeline: lint → test → build → smoke/soak (sequential chain) -# Security: security-static + codeql-gate (independent island) +# Pipeline: lint → test → build → smoke/soak → virustotal (sequential chain) +# Security: security-static + codeql-gate (independent island) # # Security does NOT block lint/test/build/smoke/soak. All jobs must pass # for the overall workflow to be green. +# +# VirusTotal runs LAST and scans the same artifacts the release scans, so a +# detection surfaces here instead of mid-release. It is the one dry-run job +# whose verdict does not depend on our code: the same unchanged bytes can come +# back clean one day and flagged the next, which is precisely why finding out +# during a dry run is worth the wait. +# +# Two independent skips, because they answer different questions: +# skip_builds no artifacts at all — "do lint and tests pass?" +# skip_virustotal build + smoke + soak, no scan — "do the archives pass CI?" +# The second exists because the scan costs API quota and polls for up to two +# hours, which is pure noise while iterating on a build or smoke failure. name: Dry Run on: @@ -20,7 +32,11 @@ on: type: boolean default: false skip_builds: - description: 'Skip build + smoke' + description: 'Skip build + smoke (also skips VirusTotal — nothing to scan)' + type: boolean + default: false + skip_virustotal: + description: 'Skip VirusTotal scan (still builds + smokes the archives)' type: boolean default: false soak_level: @@ -84,3 +100,55 @@ jobs: with: duration_minutes: 10 run_asan: ${{ inputs.soak_level == 'full' }} + + # ── VirusTotal (final step) ──────────────────────────────────── + # Mirrors the release workflow's scan so a dry run answers the question the + # release will ask, before we tag anything. The only difference is where the + # archives come from: the release pulls them off a published release, a dry + # run has none, so they come from the build job's artifacts — the SAME files, + # produced by the same canonical package-release.sh. + # + # Runs after smoke and soak deliberately. It is the slowest job (polling can + # take up to two hours) and the one most likely to be waiting on someone + # else's queue, so it must not delay the feedback that is actually about our + # code. + virustotal: + if: ${{ inputs.skip_builds != true && inputs.skip_virustotal != true && !cancelled() && needs.build.result == 'success' && needs.smoke.result != 'failure' && needs.soak.result != 'failure' }} + needs: [build, smoke, soak] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: binaries-* + merge-multiple: true + path: assets + + # Same extraction the release verify job performs, so the bytes handed to + # VirusTotal here are the bytes a user would download and run. + - name: Extract archive artifacts + run: scripts/ci/extract-release-archives.sh assets binaries + + - name: VirusTotal scan + uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0 + id: virustotal + with: + vt_api_key: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }} + files: binaries/* + + # Same ZERO-tolerance gate as the release. A detection here is a real + # finding even when it is a false positive: it is what the release gate + # will do, so the dry run should say so now. Resolve it upstream (vendor + # false-positive submission for the exact hashes, then re-run) — never by + # loosening this gate. + - name: Wait for VirusTotal results + env: + VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }} + VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }} + run: scripts/ci/check-virustotal.sh diff --git a/scripts/ci/extract-release-archives.sh b/scripts/ci/extract-release-archives.sh new file mode 100755 index 0000000000..bc6e430561 --- /dev/null +++ b/scripts/ci/extract-release-archives.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Extract the release archives into a flat directory of scannable files. +# +# Turns the per-platform artifacts (codebase-memory-mcp--.tar.gz / +# .zip, produced by the canonical package-release.sh) into one directory holding +# the actual executables, each renamed to its platform so a scan report names +# something a human can act on. The install scripts are copied in beside them: +# they ship in the release too, and a compromised installer matters as much as a +# compromised binary. +# +# Usage: extract-release-archives.sh +# +# Lives in scripts/ rather than inline in the workflow because the venue-parity +# contract requires it: a venue may provision, plumb artifacts, or call a +# canonical leg script, and this is leg logic. It also means the dry run and the +# release can extract identically instead of drifting apart in two YAML copies. +set -euo pipefail + +ARCHIVE_DIR="${1:?extract-release-archives: missing }" +OUT_DIR="${2:?extract-release-archives: missing }" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +mkdir -p "$OUT_DIR" +shopt -s nullglob + +# Counts BINARIES recovered from archives, deliberately not "files in OUT_DIR". +# The install scripts below are copied in unconditionally, so a whole-directory +# count is never zero and would have reported success on an empty input — a +# clean VirusTotal run over nothing, which is the precise false green this +# script exists to prevent. Caught by running it against an empty directory. +extracted=0 + +for archive in "$ARCHIVE_DIR"/*.tar.gz; do + name="$(basename "$archive" .tar.gz)" + tar -xzf "$archive" -C "$OUT_DIR" 2>/dev/null || true + if [ -f "$OUT_DIR/codebase-memory-mcp" ]; then + mv "$OUT_DIR/codebase-memory-mcp" "$OUT_DIR/${name}" + extracted=$((extracted + 1)) + fi +done + +for archive in "$ARCHIVE_DIR"/*.zip; do + name="$(basename "$archive" .zip)" + unzip -o "$archive" -d "$OUT_DIR" >/dev/null 2>&1 || true + if [ -f "$OUT_DIR/codebase-memory-mcp.exe" ]; then + mv "$OUT_DIR/codebase-memory-mcp.exe" "$OUT_DIR/${name}.exe" + extracted=$((extracted + 1)) + fi +done + +if [ "$extracted" -eq 0 ]; then + echo "FAIL: no binaries extracted from '$ARCHIVE_DIR' — a scan here would have" + echo " reported clean without examining a single byte" + ls -la "$ARCHIVE_DIR" 2>/dev/null || true + exit 1 +fi + +# Shipped alongside the binaries, so scanned alongside them: a compromised +# installer matters as much as a compromised executable. +cp "$ROOT/install.sh" "$OUT_DIR/" 2>/dev/null || true +cp "$ROOT/install.ps1" "$OUT_DIR/" 2>/dev/null || true + +ls -la "$OUT_DIR" +total="$(find "$OUT_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')" +echo "extracted $extracted binarie(s); $total file(s) queued for scanning"