|
| 1 | +#!/usr/bin/env bash |
| 2 | +# av-endpoint-verify.sh — verify release binaries on a REAL Microsoft Defender |
| 3 | +# endpoint (the local Windows test VM) and produce the hash-pinned evidence |
| 4 | +# file that scripts/ci/check-virustotal.sh accepts for pre-release ML false |
| 5 | +# positives. |
| 6 | +# |
| 7 | +# VirusTotal's "Microsoft" engine and the shipping Defender product can |
| 8 | +# disagree: VT runs a differently-configured engine instance, and for our |
| 9 | +# large binaries it sits at an unstable ML decision boundary (Wacatac!ml came |
| 10 | +# and went across four release cycles with no code-side cause). This script |
| 11 | +# asks the authoritative source — Defender itself, signatures updated minutes |
| 12 | +# before the scan, real-time protection on — about the exact bytes VT flagged, |
| 13 | +# and records the answer: |
| 14 | +# |
| 15 | +# defender-endpoint-verification.txt |
| 16 | +# # release/engine/signature header lines |
| 17 | +# <sha256> <asset-name> CLEAN|DETECTED |
| 18 | +# |
| 19 | +# The release gate downgrades a pre-release BLOCK to TOLERATED only when every |
| 20 | +# flagged binary's sha256 appears here as CLEAN. |
| 21 | +# |
| 22 | +# Usage: scripts/ci/av-endpoint-verify.sh <version> <asset-name...> [--upload] |
| 23 | +# version release tag holding the draft assets (e.g. v0.9.1-rc.1) |
| 24 | +# asset-name archive basename without extension, as printed by the gate |
| 25 | +# (e.g. codebase-memory-mcp-ui-linux-amd64) |
| 26 | +# --upload attach/replace the evidence file on the (draft) release |
| 27 | +# |
| 28 | +# Runs on the dev machine: needs gh (authenticated) and the local Windows VM |
| 29 | +# (test-infrastructure/vm/win.sh), plus ~300 MB VM disk per binary. |
| 30 | +set -euo pipefail |
| 31 | + |
| 32 | +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" |
| 33 | +cd "$ROOT" |
| 34 | +WIN=test-infrastructure/vm/win.sh |
| 35 | +[ -x "$WIN" ] || { echo "av-endpoint-verify: $WIN not found — this runs on the dev machine" >&2; exit 2; } |
| 36 | + |
| 37 | +VERSION="${1:-}" |
| 38 | +[ -n "$VERSION" ] || { echo "usage: scripts/ci/av-endpoint-verify.sh <version> <asset-name...> [--upload]" >&2; exit 2; } |
| 39 | +shift |
| 40 | +UPLOAD=false |
| 41 | +NAMES=() |
| 42 | +for arg in "$@"; do |
| 43 | + case "$arg" in |
| 44 | + --upload) UPLOAD=true ;; |
| 45 | + *) NAMES+=("$arg") ;; |
| 46 | + esac |
| 47 | +done |
| 48 | +[ "${#NAMES[@]}" -ge 1 ] || { echo "av-endpoint-verify: no asset names given" >&2; exit 2; } |
| 49 | + |
| 50 | +TMP="$(mktemp -d "${TMPDIR:-/tmp}/av-endpoint.XXXXXX")" |
| 51 | +trap 'rm -rf "$TMP"' EXIT |
| 52 | + |
| 53 | +# ── Fetch + extract the exact release bytes ───────────────────────────────── |
| 54 | +declare -a SHAS |
| 55 | +for name in "${NAMES[@]}"; do |
| 56 | + if gh release download "$VERSION" --pattern "$name.tar.gz" --dir "$TMP" 2>/dev/null; then |
| 57 | + mkdir -p "$TMP/x-$name" |
| 58 | + tar -xzf "$TMP/$name.tar.gz" -C "$TMP/x-$name" |
| 59 | + BIN="$TMP/x-$name/codebase-memory-mcp" |
| 60 | + elif gh release download "$VERSION" --pattern "$name.zip" --dir "$TMP" 2>/dev/null; then |
| 61 | + mkdir -p "$TMP/x-$name" |
| 62 | + unzip -oq "$TMP/$name.zip" -d "$TMP/x-$name" |
| 63 | + BIN="$TMP/x-$name/codebase-memory-mcp.exe" |
| 64 | + else |
| 65 | + echo "av-endpoint-verify: no $name.tar.gz or $name.zip on release $VERSION" >&2 |
| 66 | + exit 2 |
| 67 | + fi |
| 68 | + [ -f "$BIN" ] || { echo "av-endpoint-verify: archive $name held no binary" >&2; exit 2; } |
| 69 | + mv "$BIN" "$TMP/probe-$name" |
| 70 | + SHAS+=("$(shasum -a 256 "$TMP/probe-$name" | awk '{print $1}')") |
| 71 | + echo "=== fetched $name (${SHAS[${#SHAS[@]}-1]}) ===" |
| 72 | +done |
| 73 | + |
| 74 | +# ── Build the scan driver (pure ASCII: PowerShell 5.1 decodes BOM-less .ps1 |
| 75 | +# as ANSI, and a stray non-ASCII byte corrupts the parse) ──────────────────── |
| 76 | +PS1="$TMP/av-verify.ps1" |
| 77 | +{ |
| 78 | + echo '$mp = Join-Path $env:ProgramFiles "Windows Defender\MpCmdRun.exe"' |
| 79 | + echo '& $mp -SignatureUpdate | Out-Null' |
| 80 | + echo '$s = Get-MpComputerStatus' |
| 81 | + echo 'Write-Output ("ENGINE=" + $s.AMEngineVersion)' |
| 82 | + echo 'Write-Output ("SIGNATURES=" + $s.AntivirusSignatureVersion)' |
| 83 | + echo 'Write-Output ("SIG_UPDATED=" + $s.AntivirusSignatureLastUpdated.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))' |
| 84 | + echo 'Write-Output ("RTP=" + $s.RealTimeProtectionEnabled)' |
| 85 | + for name in "${NAMES[@]}"; do |
| 86 | + echo '$f = "C:\Users\test\av-verify-'"$name"'"' |
| 87 | + echo 'if (-not (Test-Path $f)) { Write-Output ("RESULT '"$name"' MISSING") } else {' |
| 88 | + echo ' & $mp -Scan -ScanType 3 -File $f | Out-Null' |
| 89 | + echo ' $code = $LASTEXITCODE' |
| 90 | + # RTP quarantining the file between push and scan is itself a detection. |
| 91 | + echo ' if ($code -eq 0 -and (Test-Path $f)) { Write-Output ("RESULT '"$name"' CLEAN") }' |
| 92 | + echo ' else { Write-Output ("RESULT '"$name"' DETECTED code=" + $code) }' |
| 93 | + echo ' Remove-Item $f -Force -ErrorAction SilentlyContinue' |
| 94 | + echo '}' |
| 95 | + done |
| 96 | +} > "$PS1" |
| 97 | +LC_ALL=C grep -q '[^ -~]' "$PS1" && { echo "av-endpoint-verify: driver is not pure ASCII" >&2; exit 2; } |
| 98 | + |
| 99 | +# ── Push and scan ─────────────────────────────────────────────────────────── |
| 100 | +for name in "${NAMES[@]}"; do |
| 101 | + echo "=== pushing $name to the VM ===" |
| 102 | + "$WIN" push-file "$TMP/probe-$name" "C:/Users/test/av-verify-$name" |
| 103 | +done |
| 104 | +"$WIN" push-file "$PS1" "C:/Users/test/av-verify.ps1" |
| 105 | +echo "=== updating signatures + scanning on the endpoint ===" |
| 106 | +SCAN_OUT="$TMP/scan-out.txt" |
| 107 | +"$WIN" sh "powershell -NoProfile -ExecutionPolicy Bypass -File C:/Users/test/av-verify.ps1" | tee "$SCAN_OUT" |
| 108 | + |
| 109 | +ENGINE=$(sed -n 's/^ENGINE=//p' "$SCAN_OUT" | tr -d '\r' | tail -1) |
| 110 | +SIGS=$(sed -n 's/^SIGNATURES=//p' "$SCAN_OUT" | tr -d '\r' | tail -1) |
| 111 | +SIG_UPDATED=$(sed -n 's/^SIG_UPDATED=//p' "$SCAN_OUT" | tr -d '\r' | tail -1) |
| 112 | +RTP=$(sed -n 's/^RTP=//p' "$SCAN_OUT" | tr -d '\r' | tail -1) |
| 113 | +[ -n "$ENGINE" ] && [ -n "$SIGS" ] || { echo "av-endpoint-verify: endpoint did not report engine/signature versions" >&2; exit 1; } |
| 114 | +[ "$RTP" = "True" ] || { echo "av-endpoint-verify: real-time protection is OFF on the VM — evidence would be meaningless" >&2; exit 1; } |
| 115 | + |
| 116 | +# ── Write the evidence file ───────────────────────────────────────────────── |
| 117 | +OUT="defender-endpoint-verification.txt" |
| 118 | +{ |
| 119 | + echo "# defender-endpoint-verification" |
| 120 | + echo "# release: $VERSION" |
| 121 | + echo "# generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 122 | + echo "# venue: local Windows 11 ARM64 VM, Microsoft Defender endpoint, real-time protection on" |
| 123 | + echo "# engine: $ENGINE" |
| 124 | + echo "# signatures: $SIGS (updated $SIG_UPDATED)" |
| 125 | +} > "$OUT" |
| 126 | + |
| 127 | +FAILED=false |
| 128 | +for i in "${!NAMES[@]}"; do |
| 129 | + name="${NAMES[$i]}" |
| 130 | + verdict=$(sed -n "s/^RESULT $name //p" "$SCAN_OUT" | tr -d '\r' | tail -1) |
| 131 | + case "$verdict" in |
| 132 | + CLEAN) printf '%s %s CLEAN\n' "${SHAS[$i]}" "$name" >> "$OUT" ;; |
| 133 | + *) |
| 134 | + printf '%s %s DETECTED\n' "${SHAS[$i]}" "$name" >> "$OUT" |
| 135 | + echo "av-endpoint-verify: $name verdict '$verdict' — NOT clean" >&2 |
| 136 | + FAILED=true |
| 137 | + ;; |
| 138 | + esac |
| 139 | +done |
| 140 | + |
| 141 | +echo "=== evidence written: $OUT ===" |
| 142 | +cat "$OUT" |
| 143 | + |
| 144 | +if [ "$FAILED" = "true" ]; then |
| 145 | + echo "av-endpoint-verify: Defender itself reports a detection — this is NOT" >&2 |
| 146 | + echo "the false-positive pattern; do not release these binaries." >&2 |
| 147 | + exit 1 |
| 148 | +fi |
| 149 | + |
| 150 | +if [ "$UPLOAD" = "true" ]; then |
| 151 | + gh release upload "$VERSION" "$OUT" --clobber |
| 152 | + echo "=== uploaded $OUT to release $VERSION ===" |
| 153 | +fi |
0 commit comments