|
| 1 | +inputs: |
| 2 | + ARTIFACT_FILE_PATH: |
| 3 | + required: true |
| 4 | + description: Absolute path to the file to scan |
| 5 | + VIRUSTOTAL_API_KEY: |
| 6 | + required: true |
| 7 | + description: VirusTotal API key |
| 8 | + SCAN_RESULTS_PATH: |
| 9 | + required: true |
| 10 | + description: Path where scan results JSON will be saved |
| 11 | +runs: |
| 12 | + using: composite |
| 13 | + steps: |
| 14 | + - name: Calculate file hash |
| 15 | + id: file-hash |
| 16 | + working-directory: ${{ github.workspace }} |
| 17 | + run: | |
| 18 | + set -euo pipefail |
| 19 | + FILE_HASH=$(sha256sum "${{ inputs.ARTIFACT_FILE_PATH }}" | awk '{print $1}') |
| 20 | + echo "FILE_HASH=${FILE_HASH}" >> $GITHUB_OUTPUT |
| 21 | + echo "📄 File: $(basename "${{ inputs.ARTIFACT_FILE_PATH }}")" |
| 22 | + echo "🔑 SHA-256: ${FILE_HASH}" |
| 23 | + shell: bash |
| 24 | + - name: Scan with VirusTotal |
| 25 | + id: virustotal-scan |
| 26 | + uses: crazy-max/ghaction-virustotal@d34968c958ae283fe976efed637081b9f9dcf74f |
| 27 | + with: |
| 28 | + vt_api_key: ${{ inputs.VIRUSTOTAL_API_KEY }} |
| 29 | + files: ${{ inputs.ARTIFACT_FILE_PATH }} |
| 30 | + vt_monitor: false |
| 31 | + - name: Wait for VirusTotal analysis to complete |
| 32 | + run: | |
| 33 | + echo "⏳ Waiting 45 seconds for VirusTotal analysis to complete..." |
| 34 | + echo " (Respecting API rate limits: 4 lookups/min)" |
| 35 | + sleep 45 |
| 36 | + shell: bash |
| 37 | + - name: Fetch and parse VirusTotal results |
| 38 | + id: vt-results |
| 39 | + working-directory: ${{ github.workspace }} |
| 40 | + run: | |
| 41 | + set -euo pipefail |
| 42 | + |
| 43 | + VT_URL="${{ steps.virustotal-scan.outputs.analysis }}" |
| 44 | + FILE_HASH="${{ steps.file-hash.outputs.FILE_HASH }}" |
| 45 | + |
| 46 | + echo "🔍 Fetching VirusTotal results..." |
| 47 | + |
| 48 | + # Extract file ID from URL (sha256 hash) |
| 49 | + if [[ "$VT_URL" =~ /file/([a-f0-9]+) ]]; then |
| 50 | + FILE_ID="${BASH_REMATCH[1]}" |
| 51 | + else |
| 52 | + FILE_ID="$FILE_HASH" |
| 53 | + fi |
| 54 | + |
| 55 | + # Fetch analysis results from VirusTotal API |
| 56 | + VT_RESPONSE=$(curl -s --request GET \ |
| 57 | + --url "https://www.virustotal.com/api/v3/files/${FILE_ID}" \ |
| 58 | + --header "x-apikey: ${{ inputs.VIRUSTOTAL_API_KEY }}") |
| 59 | + |
| 60 | + # Extract detection stats |
| 61 | + MALICIOUS=$(echo "$VT_RESPONSE" | jq -r '.data.attributes.last_analysis_stats.malicious // 0') |
| 62 | + SUSPICIOUS=$(echo "$VT_RESPONSE" | jq -r '.data.attributes.last_analysis_stats.suspicious // 0') |
| 63 | + UNDETECTED=$(echo "$VT_RESPONSE" | jq -r '.data.attributes.last_analysis_stats.undetected // 0') |
| 64 | + HARMLESS=$(echo "$VT_RESPONSE" | jq -r '.data.attributes.last_analysis_stats.harmless // 0') |
| 65 | + |
| 66 | + # Extract individual engine results |
| 67 | + DETECTIONS=$(echo "$VT_RESPONSE" | jq -r ' |
| 68 | + .data.attributes.last_analysis_results | |
| 69 | + to_entries | |
| 70 | + map(select(.value.category == "malicious" or .value.category == "suspicious")) | |
| 71 | + map({engine: .key, category: .value.category, result: .value.result}) | |
| 72 | + .[]' | jq -s '.') |
| 73 | + |
| 74 | + echo "MALICIOUS=${MALICIOUS}" >> $GITHUB_OUTPUT |
| 75 | + echo "SUSPICIOUS=${SUSPICIOUS}" >> $GITHUB_OUTPUT |
| 76 | + echo "UNDETECTED=${UNDETECTED}" >> $GITHUB_OUTPUT |
| 77 | + echo "HARMLESS=${HARMLESS}" >> $GITHUB_OUTPUT |
| 78 | + echo "DETECTIONS<<EOF" >> $GITHUB_OUTPUT |
| 79 | + echo "$DETECTIONS" >> $GITHUB_OUTPUT |
| 80 | + echo "EOF" >> $GITHUB_OUTPUT |
| 81 | + |
| 82 | + echo "📊 Detection Stats:" |
| 83 | + echo " Malicious: ${MALICIOUS}" |
| 84 | + echo " Suspicious: ${SUSPICIOUS}" |
| 85 | + echo " Undetected: ${UNDETECTED}" |
| 86 | + echo " Harmless: ${HARMLESS}" |
| 87 | + shell: bash |
| 88 | + - name: Analyze detections for false positives |
| 89 | + id: analyze-fp |
| 90 | + working-directory: ${{ github.workspace }} |
| 91 | + run: | |
| 92 | + set -euo pipefail |
| 93 | + |
| 94 | + MALICIOUS=${{ steps.vt-results.outputs.MALICIOUS }} |
| 95 | + SUSPICIOUS=${{ steps.vt-results.outputs.SUSPICIOUS }} |
| 96 | + DETECTIONS='${{ steps.vt-results.outputs.DETECTIONS }}' |
| 97 | + |
| 98 | + # Known false positive patterns for game development tools |
| 99 | + FP_PATTERNS=( |
| 100 | + "Generic" |
| 101 | + "Heur" |
| 102 | + "Heuristic" |
| 103 | + "PUA" |
| 104 | + "PUP" |
| 105 | + "Potentially" |
| 106 | + "Unsafe" |
| 107 | + "Suspect" |
| 108 | + "BehavesLike" |
| 109 | + "Artemis" |
| 110 | + "ML\." |
| 111 | + "DDS:" |
| 112 | + "HEUR:" |
| 113 | + "Gen:" |
| 114 | + ) |
| 115 | + |
| 116 | + # Known problematic engines with high false positive rates for legitimate software |
| 117 | + FP_ENGINES=( |
| 118 | + "Cyren" |
| 119 | + "Cylance" |
| 120 | + "Sangfor" |
| 121 | + "VBA32" |
| 122 | + "MaxSecure" |
| 123 | + "Gridinsoft" |
| 124 | + "Jiangmin" |
| 125 | + ) |
| 126 | + |
| 127 | + LEGITIMATE_DETECTIONS=0 |
| 128 | + FALSE_POSITIVE_COUNT=0 |
| 129 | + |
| 130 | + if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then |
| 131 | + echo "⚠️ Analyzing $(( MALICIOUS + SUSPICIOUS )) detections..." |
| 132 | + |
| 133 | + # Check each detection |
| 134 | + echo "$DETECTIONS" | jq -c '.[]' | while read -r detection; do |
| 135 | + ENGINE=$(echo "$detection" | jq -r '.engine') |
| 136 | + RESULT=$(echo "$detection" | jq -r '.result') |
| 137 | + |
| 138 | + IS_FP=false |
| 139 | + |
| 140 | + # Check if engine is known for false positives |
| 141 | + for fp_engine in "${FP_ENGINES[@]}"; do |
| 142 | + if [[ "$ENGINE" == "$fp_engine" ]]; then |
| 143 | + IS_FP=true |
| 144 | + echo " ✓ ${ENGINE}: ${RESULT} (Known FP engine)" |
| 145 | + break |
| 146 | + fi |
| 147 | + done |
| 148 | + |
| 149 | + # Check if result matches false positive patterns |
| 150 | + if [ "$IS_FP" = false ]; then |
| 151 | + for pattern in "${FP_PATTERNS[@]}"; do |
| 152 | + if echo "$RESULT" | grep -qi "$pattern"; then |
| 153 | + IS_FP=true |
| 154 | + echo " ✓ ${ENGINE}: ${RESULT} (FP pattern: ${pattern})" |
| 155 | + break |
| 156 | + fi |
| 157 | + done |
| 158 | + fi |
| 159 | + |
| 160 | + # If not a false positive, count as legitimate threat |
| 161 | + if [ "$IS_FP" = false ]; then |
| 162 | + echo " ⚠️ ${ENGINE}: ${RESULT} (POTENTIAL THREAT)" |
| 163 | + LEGITIMATE_DETECTIONS=$((LEGITIMATE_DETECTIONS + 1)) |
| 164 | + else |
| 165 | + FALSE_POSITIVE_COUNT=$((FALSE_POSITIVE_COUNT + 1)) |
| 166 | + fi |
| 167 | + done |
| 168 | + |
| 169 | + # Save detection analysis results |
| 170 | + echo "$LEGITIMATE_DETECTIONS" > /tmp/legitimate_detections.txt |
| 171 | + echo "$FALSE_POSITIVE_COUNT" > /tmp/false_positive_count.txt |
| 172 | + else |
| 173 | + echo "✅ No detections found" |
| 174 | + echo "0" > /tmp/legitimate_detections.txt |
| 175 | + echo "0" > /tmp/false_positive_count.txt |
| 176 | + fi |
| 177 | + |
| 178 | + LEGIT=$(cat /tmp/legitimate_detections.txt 2>/dev/null || echo "0") |
| 179 | + FP=$(cat /tmp/false_positive_count.txt 2>/dev/null || echo "0") |
| 180 | + |
| 181 | + echo "LEGITIMATE_THREATS=${LEGIT}" >> $GITHUB_OUTPUT |
| 182 | + echo "FALSE_POSITIVES=${FP}" >> $GITHUB_OUTPUT |
| 183 | + |
| 184 | + # Determine verdict |
| 185 | + if [ "$LEGIT" -gt 0 ]; then |
| 186 | + echo "VERDICT=BLOCKED" >> $GITHUB_OUTPUT |
| 187 | + echo "🚨 VERDICT: BLOCKED - ${LEGIT} legitimate threat(s) detected" |
| 188 | + else |
| 189 | + echo "VERDICT=PASSED" >> $GITHUB_OUTPUT |
| 190 | + echo "✅ VERDICT: PASSED - All detections are false positives" |
| 191 | + fi |
| 192 | + shell: bash |
| 193 | + - name: Save scan results |
| 194 | + working-directory: ${{ github.workspace }} |
| 195 | + run: | |
| 196 | + set -euo pipefail |
| 197 | + |
| 198 | + FILENAME=$(basename "${{ inputs.ARTIFACT_FILE_PATH }}") |
| 199 | + RESULTS_FILE="${{ inputs.SCAN_RESULTS_PATH }}/${FILENAME}.json" |
| 200 | + |
| 201 | + mkdir -p "$(dirname "${RESULTS_FILE}")" |
| 202 | + |
| 203 | + # Create JSON structure with comprehensive scan results |
| 204 | + cat > "${RESULTS_FILE}" << EOFRESULTS |
| 205 | + { |
| 206 | + "filename": "${FILENAME}", |
| 207 | + "filepath": "${{ inputs.ARTIFACT_FILE_PATH }}", |
| 208 | + "sha256": "${{ steps.file-hash.outputs.FILE_HASH }}", |
| 209 | + "scan_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", |
| 210 | + "virustotal": { |
| 211 | + "analysis_url": "${{ steps.virustotal-scan.outputs.analysis }}", |
| 212 | + "status": "scanned", |
| 213 | + "stats": { |
| 214 | + "malicious": ${{ steps.vt-results.outputs.MALICIOUS }}, |
| 215 | + "suspicious": ${{ steps.vt-results.outputs.SUSPICIOUS }}, |
| 216 | + "undetected": ${{ steps.vt-results.outputs.UNDETECTED }}, |
| 217 | + "harmless": ${{ steps.vt-results.outputs.HARMLESS }} |
| 218 | + }, |
| 219 | + "detections": ${{ steps.vt-results.outputs.DETECTIONS }}, |
| 220 | + "analysis": { |
| 221 | + "legitimate_threats": ${{ steps.analyze-fp.outputs.LEGITIMATE_THREATS }}, |
| 222 | + "false_positives": ${{ steps.analyze-fp.outputs.FALSE_POSITIVES }}, |
| 223 | + "verdict": "${{ steps.analyze-fp.outputs.VERDICT }}" |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + EOFRESULTS |
| 228 | + |
| 229 | + echo "✅ Scan results saved to: ${RESULTS_FILE}" |
| 230 | + echo "🔗 VirusTotal Analysis: ${{ steps.virustotal-scan.outputs.analysis }}" |
| 231 | + echo "📊 Verdict: ${{ steps.analyze-fp.outputs.VERDICT }}" |
| 232 | + |
| 233 | + # Fail the action if legitimate threats are detected |
| 234 | + if [ "${{ steps.analyze-fp.outputs.VERDICT }}" = "BLOCKED" ]; then |
| 235 | + echo "❌ SCAN FAILED: Legitimate threats detected!" |
| 236 | + echo " Legitimate threats: ${{ steps.analyze-fp.outputs.LEGITIMATE_THREATS }}" |
| 237 | + echo " False positives: ${{ steps.analyze-fp.outputs.FALSE_POSITIVES }}" |
| 238 | + exit 1 |
| 239 | + fi |
| 240 | + shell: bash |
0 commit comments