|
| 1 | +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +name: 'ClamAV Malware Scan' |
| 16 | +description: 'Run ClamAV malware scan on release artifacts and upload SARIF results to GitHub Security' |
| 17 | + |
| 18 | +inputs: |
| 19 | + scan_path: |
| 20 | + description: 'Path to scan for malware (e.g. dist/ for release binaries)' |
| 21 | + required: false |
| 22 | + default: '.' |
| 23 | + output_file: |
| 24 | + description: 'Output SARIF file name' |
| 25 | + required: false |
| 26 | + default: 'clamav-results.sarif' |
| 27 | + category: |
| 28 | + description: 'GitHub Security category for SARIF upload' |
| 29 | + required: false |
| 30 | + default: 'clamav' |
| 31 | + fail_on_finding: |
| 32 | + description: 'Fail the step if malware is detected (true/false)' |
| 33 | + required: false |
| 34 | + default: 'true' |
| 35 | + |
| 36 | +outputs: |
| 37 | + infected_count: |
| 38 | + description: 'Number of infected files found' |
| 39 | + value: ${{ steps.scan.outputs.infected_count }} |
| 40 | + scanned_count: |
| 41 | + description: 'Number of files scanned' |
| 42 | + value: ${{ steps.scan.outputs.scanned_count }} |
| 43 | + outcome: |
| 44 | + description: 'Scan outcome (clean, infected, error)' |
| 45 | + value: ${{ steps.scan.outputs.outcome }} |
| 46 | + |
| 47 | +runs: |
| 48 | + using: 'composite' |
| 49 | + steps: |
| 50 | + - name: Install ClamAV |
| 51 | + shell: bash |
| 52 | + run: | |
| 53 | + set -euo pipefail |
| 54 | + sudo apt-get update -qq |
| 55 | + sudo apt-get install -y -qq clamav > /dev/null 2>&1 |
| 56 | + echo "Installed: $(clamscan --version)" |
| 57 | +
|
| 58 | + - name: Update virus definitions |
| 59 | + shell: bash |
| 60 | + run: | |
| 61 | + set -euo pipefail |
| 62 | + # Stop freshclam daemon if running (it holds the lock) |
| 63 | + sudo systemctl stop clamav-freshclam 2>/dev/null || true |
| 64 | + sudo freshclam --quiet |
| 65 | + echo "Virus definitions updated" |
| 66 | +
|
| 67 | + - name: Run ClamAV scan |
| 68 | + id: scan |
| 69 | + shell: bash |
| 70 | + run: | |
| 71 | + set -euo pipefail |
| 72 | +
|
| 73 | + SCAN_PATH="${{ inputs.scan_path }}" |
| 74 | + RAW_OUTPUT="clamav-raw.log" |
| 75 | +
|
| 76 | + echo "::group::ClamAV scan of ${SCAN_PATH}" |
| 77 | +
|
| 78 | + # clamscan exit codes: 0=clean, 1=infected found, 2=error |
| 79 | + SCAN_EXIT=0 |
| 80 | + clamscan \ |
| 81 | + --recursive \ |
| 82 | + --infected \ |
| 83 | + --suppress-ok-results \ |
| 84 | + --max-filesize=100M \ |
| 85 | + --max-scansize=400M \ |
| 86 | + "${SCAN_PATH}" > "${RAW_OUTPUT}" 2>&1 || SCAN_EXIT=$? |
| 87 | +
|
| 88 | + cat "${RAW_OUTPUT}" |
| 89 | + echo "::endgroup::" |
| 90 | +
|
| 91 | + # Parse summary line: "Infected files: N" |
| 92 | + INFECTED=$(grep -oP 'Infected files:\s*\K\d+' "${RAW_OUTPUT}" || echo "0") |
| 93 | + SCANNED=$(grep -oP 'Scanned files:\s*\K\d+' "${RAW_OUTPUT}" || echo "0") |
| 94 | +
|
| 95 | + echo "infected_count=${INFECTED}" >> "$GITHUB_OUTPUT" |
| 96 | + echo "scanned_count=${SCANNED}" >> "$GITHUB_OUTPUT" |
| 97 | +
|
| 98 | + if [[ "${SCAN_EXIT}" -eq 2 ]]; then |
| 99 | + echo "outcome=error" >> "$GITHUB_OUTPUT" |
| 100 | + echo "::error::ClamAV encountered an error during scanning" |
| 101 | + exit 1 |
| 102 | + elif [[ "${INFECTED}" -gt 0 ]]; then |
| 103 | + echo "outcome=infected" >> "$GITHUB_OUTPUT" |
| 104 | + echo "::error::ClamAV detected ${INFECTED} infected file(s)" |
| 105 | + else |
| 106 | + echo "outcome=clean" >> "$GITHUB_OUTPUT" |
| 107 | + echo "Scan clean: ${SCANNED} files scanned, 0 infected" |
| 108 | + fi |
| 109 | +
|
| 110 | + - name: Convert results to SARIF |
| 111 | + id: convert |
| 112 | + shell: bash |
| 113 | + run: | |
| 114 | + set -euo pipefail |
| 115 | +
|
| 116 | + RAW_OUTPUT="clamav-raw.log" |
| 117 | + SARIF_FILE="${{ inputs.output_file }}" |
| 118 | + INFECTED="${{ steps.scan.outputs.infected_count }}" |
| 119 | +
|
| 120 | + # Build SARIF results array from infected lines |
| 121 | + # ClamAV infected output format: "/path/to/file: SignatureName FOUND" |
| 122 | + RESULTS="[]" |
| 123 | + RULES="[]" |
| 124 | +
|
| 125 | + if [[ "${INFECTED}" -gt 0 ]]; then |
| 126 | + while IFS= read -r line; do |
| 127 | + # Parse: /path/to/file: MalwareName FOUND |
| 128 | + FILE_PATH=$(echo "${line}" | sed 's/: .* FOUND$//') |
| 129 | + SIGNATURE=$(echo "${line}" | sed 's/^.*: //; s/ FOUND$//') |
| 130 | +
|
| 131 | + # Check if rule already exists |
| 132 | + RULE_ID="clamav/${SIGNATURE}" |
| 133 | + RULE_EXISTS=$(echo "${RULES}" | jq --arg id "${RULE_ID}" 'map(.id) | index($id)') |
| 134 | +
|
| 135 | + if [[ "${RULE_EXISTS}" == "null" ]]; then |
| 136 | + RULES=$(echo "${RULES}" | jq \ |
| 137 | + --arg id "${RULE_ID}" \ |
| 138 | + --arg name "${SIGNATURE}" \ |
| 139 | + '. += [{"id": $id, "name": $name, "shortDescription": {"text": ("Malware detected: " + $name)}, "properties": {"tags": ["security", "malware"]}}]') |
| 140 | + fi |
| 141 | +
|
| 142 | + RESULTS=$(echo "${RESULTS}" | jq \ |
| 143 | + --arg rule_id "${RULE_ID}" \ |
| 144 | + --arg uri "${FILE_PATH}" \ |
| 145 | + --arg msg "ClamAV detected malware signature: ${SIGNATURE}" \ |
| 146 | + '. += [{"ruleId": $rule_id, "level": "error", "message": {"text": $msg}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": $uri}}}]}]') |
| 147 | +
|
| 148 | + done < <(grep "FOUND$" "${RAW_OUTPUT}" || true) |
| 149 | + fi |
| 150 | +
|
| 151 | + # Emit SARIF v2.1.0 |
| 152 | + jq -n \ |
| 153 | + --argjson results "${RESULTS}" \ |
| 154 | + --argjson rules "${RULES}" \ |
| 155 | + --arg version "$(clamscan --version)" \ |
| 156 | + '{ |
| 157 | + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", |
| 158 | + "version": "2.1.0", |
| 159 | + "runs": [{ |
| 160 | + "tool": { |
| 161 | + "driver": { |
| 162 | + "name": "ClamAV", |
| 163 | + "informationUri": "https://www.clamav.net", |
| 164 | + "semanticVersion": $version, |
| 165 | + "rules": $rules |
| 166 | + } |
| 167 | + }, |
| 168 | + "results": $results |
| 169 | + }] |
| 170 | + }' > "${SARIF_FILE}" |
| 171 | +
|
| 172 | + echo "SARIF written to ${SARIF_FILE}" |
| 173 | + echo "exists=true" >> "$GITHUB_OUTPUT" |
| 174 | +
|
| 175 | + - name: Upload SARIF to GitHub Security |
| 176 | + if: steps.convert.outputs.exists == 'true' |
| 177 | + id: upload_sarif |
| 178 | + uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 |
| 179 | + with: |
| 180 | + sarif_file: ${{ inputs.output_file }} |
| 181 | + category: ${{ inputs.category }} |
| 182 | + continue-on-error: true |
| 183 | + |
| 184 | + - name: SARIF upload status |
| 185 | + if: steps.convert.outputs.exists == 'true' |
| 186 | + shell: bash |
| 187 | + run: | |
| 188 | + if [[ "${{ steps.upload_sarif.outcome }}" == "failure" ]]; then |
| 189 | + echo "::notice::SARIF upload skipped - GitHub Advanced Security may not be enabled for this repository" |
| 190 | + fi |
| 191 | +
|
| 192 | + - name: Enforce scan result |
| 193 | + if: inputs.fail_on_finding == 'true' && steps.scan.outputs.outcome == 'infected' |
| 194 | + shell: bash |
| 195 | + run: | |
| 196 | + echo "::error::Malware scan failed: ${{ steps.scan.outputs.infected_count }} infected file(s) detected" |
| 197 | + exit 1 |
0 commit comments