Fix VirusTotal output parsing + use file report API

- Output is comma-separated, not newline — use tr ',' '\n' to split
- URLs are /gui/file/<sha256> permalinks — extract SHA256 hash and
  query /api/v3/files/<sha256> for last_analysis_stats
- Release notes table builder uses same comma-split fix
- Gate uses /tmp flag file to propagate failure from subshell
This commit is contained in:
Martin Vogel
2026-03-21 15:22:11 +01:00
parent 398ef68691
commit 75b5dcb897
+53 -38
View File
@@ -483,73 +483,84 @@ jobs:
assets/*.zip assets/*.zip
# ── Wait for VirusTotal results and check for detections ── # ── Wait for VirusTotal results and check for detections ──
# The action outputs comma-separated "file=URL" pairs.
# URLs are https://www.virustotal.com/gui/file/<sha256> permalinks.
# We extract the SHA256, query the API for scan stats, and gate on detections.
- name: Check VirusTotal scan results - name: Check VirusTotal scan results
env: env:
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }} VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }} VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
run: | run: |
echo "=== Waiting for VirusTotal scan results ===" echo "=== Checking VirusTotal scan results ==="
DETECTIONS=0 DETECTIONS=0
while IFS= read -r line; do # Split comma-separated output into newlines
[ -z "$line" ] && continue echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do
URL=$(echo "$line" | cut -d'=' -f2-) [ -z "$entry" ] && continue
FILE=$(echo "$entry" | cut -d'=' -f1)
URL=$(echo "$entry" | cut -d'=' -f2-)
BASENAME=$(basename "$FILE")
# Extract analysis ID from URL (last path segment) # Extract SHA256 hash from URL (last path segment of /gui/file/<hash>)
ANALYSIS_ID=$(echo "$URL" | grep -oE '[a-zA-Z0-9_-]+$') SHA256=$(echo "$URL" | grep -oE '[a-f0-9]{64}')
if [ -z "$ANALYSIS_ID" ]; then if [ -z "$SHA256" ]; then
echo "WARNING: Could not extract analysis ID from $URL" echo "WARNING: Could not extract SHA256 from $URL — skipping"
continue continue
fi fi
FILE=$(echo "$line" | cut -d'=' -f1) # Poll the file report until last_analysis_results are available
BASENAME=$(basename "$FILE")
# Poll until analysis completes (max 5 minutes per file)
for attempt in $(seq 1 30); do for attempt in $(seq 1 30); do
RESULT=$(curl -sf --max-time 10 \ RESULT=$(curl -sf --max-time 10 \
-H "x-apikey: $VT_API_KEY" \ -H "x-apikey: $VT_API_KEY" \
"https://www.virustotal.com/api/v3/analyses/$ANALYSIS_ID" 2>/dev/null || echo "{}") "https://www.virustotal.com/api/v3/files/$SHA256" 2>/dev/null || echo "")
STATUS=$(echo "$RESULT" | python3 -c " if [ -z "$RESULT" ]; then
echo " $BASENAME: waiting for scan (attempt $attempt/30)..."
sleep 10
continue
fi
MALICIOUS=$(echo "$RESULT" | python3 -c "
import json, sys import json, sys
d = json.loads(sys.stdin.read()) d = json.loads(sys.stdin.read())
print(d.get('data', {}).get('attributes', {}).get('status', 'queued')) stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
" 2>/dev/null || echo "queued")
if [ "$STATUS" = "completed" ]; then
MALICIOUS=$(echo "$RESULT" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
stats = d.get('data', {}).get('attributes', {}).get('stats', {})
print(stats.get('malicious', 0)) print(stats.get('malicious', 0))
" 2>/dev/null || echo "0") " 2>/dev/null || echo "0")
SUSPICIOUS=$(echo "$RESULT" | python3 -c " SUSPICIOUS=$(echo "$RESULT" | python3 -c "
import json, sys import json, sys
d = json.loads(sys.stdin.read()) d = json.loads(sys.stdin.read())
stats = d.get('data', {}).get('attributes', {}).get('stats', {}) stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
print(stats.get('suspicious', 0)) print(stats.get('suspicious', 0))
" 2>/dev/null || echo "0") " 2>/dev/null || echo "0")
echo "$BASENAME: malicious=$MALICIOUS suspicious=$SUSPICIOUS" TOTAL=$(echo "$RESULT" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
print(sum(stats.values()))
" 2>/dev/null || echo "0")
if [ "$TOTAL" -gt 0 ]; then
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious (of $TOTAL engines)"
if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then
echo "BLOCKED: $BASENAME flagged by $MALICIOUS malicious + $SUSPICIOUS suspicious engine(s)!" echo "BLOCKED: $BASENAME flagged! See $URL"
DETECTIONS=$((DETECTIONS + 1)) echo "FAIL" >> /tmp/vt_gate_fail
fi fi
break break
fi fi
echo " $BASENAME: scan $STATUS (attempt $attempt/30)..." echo " $BASENAME: waiting for scan results (attempt $attempt/30)..."
sleep 10 sleep 10
done done
done <<< "$VT_ANALYSIS" done
if [ "$DETECTIONS" -gt 0 ]; then if [ -f /tmp/vt_gate_fail ]; then
FAIL_COUNT=$(wc -l < /tmp/vt_gate_fail | tr -d ' ')
echo "" echo ""
echo "=== VIRUSTOTAL GATE FAILED ===" echo "=== VIRUSTOTAL GATE FAILED ==="
echo "$DETECTIONS binary(ies) flagged as malicious or suspicious." echo "$FAIL_COUNT binary(ies) flagged as malicious or suspicious."
echo "Draft release will NOT be published. Investigate before retrying." echo "Draft release will NOT be published. Investigate before retrying."
exit 1 exit 1
fi fi
@@ -596,16 +607,20 @@ jobs:
REPORT=$'---\n\n### Security Verification\n\n' REPORT=$'---\n\n### Security Verification\n\n'
REPORT+=$'All release binaries have been independently verified:\n\n' REPORT+=$'All release binaries have been independently verified:\n\n'
# VirusTotal results # VirusTotal results (comma-separated "file=URL" pairs)
REPORT+=$'**VirusTotal** — scanned by 70+ antivirus engines:\n\n' REPORT+=$'**VirusTotal** — scanned by 70+ antivirus engines:\n\n'
REPORT+=$'| Binary | Scan |\n|--------|------|\n' REPORT+=$'| Binary | Scan |\n|--------|------|\n'
while IFS= read -r line; do echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do
[ -z "$line" ] && continue [ -z "$entry" ] && continue
FILE=$(echo "$line" | cut -d'=' -f1) FILE=$(echo "$entry" | cut -d'=' -f1)
URL=$(echo "$line" | cut -d'=' -f2-) URL=$(echo "$entry" | cut -d'=' -f2-)
BASENAME=$(basename "$FILE") BASENAME=$(basename "$FILE")
REPORT+="| $BASENAME | [View Report]($URL) |"$'\n' echo "| $BASENAME | [View Report]($URL) |"
done <<< "$VT_ANALYSIS" done >> /tmp/vt_table
if [ -f /tmp/vt_table ]; then
REPORT+=$(cat /tmp/vt_table)$'\n'
rm -f /tmp/vt_table
fi
# OpenSSF Scorecard # OpenSSF Scorecard
REPORT+=$'\n**OpenSSF Scorecard** — repository security health: **'"$SCORECARD_SCORE"$'/10**\n' REPORT+=$'\n**OpenSSF Scorecard** — repository security health: **'"$SCORECARD_SCORE"$'/10**\n'