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"