Skip to content

Commit 6881812

Browse files
committed
VirusTotal gate: wait for 100% scan completion, no compromises
- Wait until at least 60 engines have actually scanned (not just reported type-unsupported/failure/timeout) - 20 minute timeout per binary (was 5 minutes) - If scan doesn't complete in time → FAIL (incomplete scan is not clean) - Engines that can't scan (type-unsupported, failure, timeout) are excluded from the completion count
1 parent b5490cf commit 6881812

File tree

1 file changed

+42
-34
lines changed

1 file changed

+42
-34
lines changed

.github/workflows/release.yml

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -530,90 +530,98 @@ jobs:
530530
assets/*.tar.gz
531531
assets/*.zip
532532
533-
# ── Wait for VirusTotal results and check for detections ──
533+
# ── Wait for ALL VirusTotal engines to complete, then check ──
534534
# The action outputs comma-separated "file=URL" pairs.
535-
# URLs are https://www.virustotal.com/gui/file/<sha256> permalinks.
536-
# We extract the SHA256, query the API for scan stats, and gate on detections.
537-
- name: Check VirusTotal scan results
535+
# We wait until every engine has reported (not just the first few),
536+
# then check for any malicious or suspicious detection.
537+
# Minimum 60 engines required — below that we consider the scan incomplete.
538+
- name: Check VirusTotal scan results (wait for 100% completion)
538539
env:
539540
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
540541
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
541542
run: |
542-
echo "=== Checking VirusTotal scan results ==="
543-
DETECTIONS=0
543+
echo "=== Waiting for VirusTotal scans to fully complete ==="
544+
MIN_ENGINES=60
544545
545-
# Split comma-separated output into newlines
546546
echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do
547547
[ -z "$entry" ] && continue
548548
FILE=$(echo "$entry" | cut -d'=' -f1)
549549
URL=$(echo "$entry" | cut -d'=' -f2-)
550550
BASENAME=$(basename "$FILE")
551551
552-
# Extract SHA256 hash from URL (last path segment of /gui/file/<hash>)
553552
SHA256=$(echo "$URL" | grep -oE '[a-f0-9]{64}')
554553
if [ -z "$SHA256" ]; then
555554
echo "WARNING: Could not extract SHA256 from $URL — skipping"
556555
continue
557556
fi
558557
559-
# Poll the file report until last_analysis_results are available
560-
for attempt in $(seq 1 30); do
558+
# Poll until ALL engines have completed (max 20 minutes per binary)
559+
SCAN_COMPLETE=false
560+
for attempt in $(seq 1 120); do
561561
RESULT=$(curl -sf --max-time 10 \
562562
-H "x-apikey: $VT_API_KEY" \
563563
"https://www.virustotal.com/api/v3/files/$SHA256" 2>/dev/null || echo "")
564564
565565
if [ -z "$RESULT" ]; then
566-
echo " $BASENAME: waiting for scan (attempt $attempt/30)..."
566+
echo " $BASENAME: waiting for file to be processed (attempt $attempt)..."
567567
sleep 10
568568
continue
569569
fi
570570
571-
MALICIOUS=$(echo "$RESULT" | python3 -c "
571+
# Parse all stats in one python call
572+
STATS=$(echo "$RESULT" | python3 -c "
572573
import json, sys
573574
d = json.loads(sys.stdin.read())
574575
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
575-
print(stats.get('malicious', 0))
576-
" 2>/dev/null || echo "0")
577-
578-
SUSPICIOUS=$(echo "$RESULT" | python3 -c "
579-
import json, sys
580-
d = json.loads(sys.stdin.read())
581-
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
582-
print(stats.get('suspicious', 0))
583-
" 2>/dev/null || echo "0")
584-
585-
TOTAL=$(echo "$RESULT" | python3 -c "
586-
import json, sys
587-
d = json.loads(sys.stdin.read())
588-
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
589-
print(sum(stats.values()))
590-
" 2>/dev/null || echo "0")
591-
592-
if [ "$TOTAL" -gt 0 ]; then
593-
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious (of $TOTAL engines)"
576+
malicious = stats.get('malicious', 0)
577+
suspicious = stats.get('suspicious', 0)
578+
undetected = stats.get('undetected', 0)
579+
harmless = stats.get('harmless', 0)
580+
failure = stats.get('failure', 0)
581+
timeout = stats.get('timeout', 0)
582+
unsupported = stats.get('type-unsupported', 0)
583+
total = sum(stats.values())
584+
# 'confirmed-timeout' may also exist
585+
completed = malicious + suspicious + undetected + harmless
586+
print(f'{malicious},{suspicious},{completed},{total}')
587+
" 2>/dev/null || echo "0,0,0,0")
588+
589+
MALICIOUS=$(echo "$STATS" | cut -d',' -f1)
590+
SUSPICIOUS=$(echo "$STATS" | cut -d',' -f2)
591+
COMPLETED=$(echo "$STATS" | cut -d',' -f3)
592+
TOTAL=$(echo "$STATS" | cut -d',' -f4)
593+
594+
if [ "$TOTAL" -ge "$MIN_ENGINES" ] && [ "$COMPLETED" -ge "$MIN_ENGINES" ]; then
595+
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious ($COMPLETED completed, $TOTAL total engines)"
594596
595597
if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then
596598
echo "BLOCKED: $BASENAME flagged! See $URL"
597599
echo "FAIL" >> /tmp/vt_gate_fail
598600
fi
601+
SCAN_COMPLETE=true
599602
break
600603
fi
601604
602-
echo " $BASENAME: waiting for scan results (attempt $attempt/30)..."
605+
echo " $BASENAME: $COMPLETED/$TOTAL engines completed (attempt $attempt, need $MIN_ENGINES)..."
603606
sleep 10
604607
done
608+
609+
if [ "$SCAN_COMPLETE" != "true" ]; then
610+
echo "BLOCKED: $BASENAME scan did not complete within 20 minutes!"
611+
echo "FAIL" >> /tmp/vt_gate_fail
612+
fi
605613
done
606614
607615
if [ -f /tmp/vt_gate_fail ]; then
608616
FAIL_COUNT=$(wc -l < /tmp/vt_gate_fail | tr -d ' ')
609617
echo ""
610618
echo "=== VIRUSTOTAL GATE FAILED ==="
611-
echo "$FAIL_COUNT binary(ies) flagged as malicious or suspicious."
619+
echo "$FAIL_COUNT binary(ies) flagged or scan incomplete."
612620
echo "Draft release will NOT be published. Investigate before retrying."
613621
exit 1
614622
fi
615623
616-
echo "=== All binaries clean ==="
624+
echo "=== All binaries clean (all engines completed) ==="
617625
618626
# ── OpenSSF Scorecard ────────────────────────────────────
619627
- name: Run OpenSSF Scorecard

0 commit comments

Comments
 (0)