Skip to content

Commit 398ef68

Browse files
committed
Add VirusTotal zero-tolerance gate: any detection blocks publish
The verify job polls VirusTotal API for completed scan results. If ANY engine flags ANY binary as malicious or suspicious, the release stays as draft and the pipeline fails. Zero tolerance — investigate and fix before retrying.
1 parent e1b106c commit 398ef68

File tree

1 file changed

+74
-0
lines changed

1 file changed

+74
-0
lines changed

.github/workflows/release.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,80 @@ jobs:
482482
assets/*.tar.gz
483483
assets/*.zip
484484
485+
# ── Wait for VirusTotal results and check for detections ──
486+
- name: Check VirusTotal scan results
487+
env:
488+
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
489+
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
490+
run: |
491+
echo "=== Waiting for VirusTotal scan results ==="
492+
DETECTIONS=0
493+
494+
while IFS= read -r line; do
495+
[ -z "$line" ] && continue
496+
URL=$(echo "$line" | cut -d'=' -f2-)
497+
498+
# Extract analysis ID from URL (last path segment)
499+
ANALYSIS_ID=$(echo "$URL" | grep -oE '[a-zA-Z0-9_-]+$')
500+
if [ -z "$ANALYSIS_ID" ]; then
501+
echo "WARNING: Could not extract analysis ID from $URL"
502+
continue
503+
fi
504+
505+
FILE=$(echo "$line" | cut -d'=' -f1)
506+
BASENAME=$(basename "$FILE")
507+
508+
# Poll until analysis completes (max 5 minutes per file)
509+
for attempt in $(seq 1 30); do
510+
RESULT=$(curl -sf --max-time 10 \
511+
-H "x-apikey: $VT_API_KEY" \
512+
"https://www.virustotal.com/api/v3/analyses/$ANALYSIS_ID" 2>/dev/null || echo "{}")
513+
514+
STATUS=$(echo "$RESULT" | python3 -c "
515+
import json, sys
516+
d = json.loads(sys.stdin.read())
517+
print(d.get('data', {}).get('attributes', {}).get('status', 'queued'))
518+
" 2>/dev/null || echo "queued")
519+
520+
if [ "$STATUS" = "completed" ]; then
521+
MALICIOUS=$(echo "$RESULT" | python3 -c "
522+
import json, sys
523+
d = json.loads(sys.stdin.read())
524+
stats = d.get('data', {}).get('attributes', {}).get('stats', {})
525+
print(stats.get('malicious', 0))
526+
" 2>/dev/null || echo "0")
527+
528+
SUSPICIOUS=$(echo "$RESULT" | python3 -c "
529+
import json, sys
530+
d = json.loads(sys.stdin.read())
531+
stats = d.get('data', {}).get('attributes', {}).get('stats', {})
532+
print(stats.get('suspicious', 0))
533+
" 2>/dev/null || echo "0")
534+
535+
echo "$BASENAME: malicious=$MALICIOUS suspicious=$SUSPICIOUS"
536+
537+
if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then
538+
echo "BLOCKED: $BASENAME flagged by $MALICIOUS malicious + $SUSPICIOUS suspicious engine(s)!"
539+
DETECTIONS=$((DETECTIONS + 1))
540+
fi
541+
break
542+
fi
543+
544+
echo " $BASENAME: scan $STATUS (attempt $attempt/30)..."
545+
sleep 10
546+
done
547+
done <<< "$VT_ANALYSIS"
548+
549+
if [ "$DETECTIONS" -gt 0 ]; then
550+
echo ""
551+
echo "=== VIRUSTOTAL GATE FAILED ==="
552+
echo "$DETECTIONS binary(ies) flagged as malicious or suspicious."
553+
echo "Draft release will NOT be published. Investigate before retrying."
554+
exit 1
555+
fi
556+
557+
echo "=== All binaries clean ==="
558+
485559
# ── OpenSSF Scorecard ────────────────────────────────────
486560
- name: Run OpenSSF Scorecard
487561
uses: ossf/scorecard-action@v2

0 commit comments

Comments
 (0)