Automating Malware Scanning in CI/CD with VirusTotal

Supply chain attacks are on the rise. Compromised dependencies, malicious build artifacts, and infected container images can slip into production undetected. Adding automated malware scanning to your CI/CD pipeline creates a security gate that catches threats before deployment.
This guide shows how to integrate VirusTotal into your build pipeline using the API and CLI.
Why Scan in CI/CD?
Traditional security scans happen post-deployment or during scheduled audits. By then, malicious code may already be running in production. CI/CD integration provides:
- Shift-left security - Catch threats before they reach production
- Automated enforcement - No manual steps to forget
- Audit trail - Every build has a security scan record
- Fast feedback - Developers know immediately if something's wrong
Prerequisites
- VirusTotal API key - Get one free at virustotal.com
- vt-cli installed - See our VirusTotal CLI guide for setup
- CI/CD platform - Examples for GitHub Actions, GitLab CI, and Jenkins
API Rate Limits:
| Plan | Requests/min | Requests/day |
|---|---|---|
| Free | 4 | 500 |
| Premium | 30 | 5,000+ |
For active CI/CD, you'll likely need a premium key.
GitHub Actions Integration
Basic File Scan
name: Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
malware-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: |
npm ci
npm run build
tar -czf build.tar.gz dist/
- name: Install VirusTotal CLI
run: |
curl -LO https://github.com/VirusTotal/vt-cli/releases/download/1.0.0/vt_1.0.0_linux_amd64.deb
sudo dpkg -i vt_1.0.0_linux_amd64.deb
- name: Scan build artifact
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
vt scan file build.tar.gz --wait
RESULT=$(vt file "$(sha256sum build.tar.gz | cut -d' ' -f1)" --format json)
MALICIOUS=$(echo "$RESULT" | jq '.data.attributes.last_analysis_stats.malicious')
if [ "$MALICIOUS" -gt 0 ]; then
echo "::error::Malware detected! $MALICIOUS engines flagged this file."
exit 1
fi
echo "Scan passed: No malware detected"
Scan Multiple Files
- name: Scan all artifacts
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
for file in dist/*.js dist/*.css; do
echo "Scanning $file..."
HASH=$(sha256sum "$file" | cut -d' ' -f1)
# Check if already scanned
if ! vt file "$HASH" &>/dev/null; then
vt scan file "$file" --wait
fi
MALICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.malicious')
if [ "$MALICIOUS" -gt 0 ]; then
echo "::error::Malware detected in $file"
exit 1
fi
done
Scan Container Images
- name: Build and scan Docker image
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
docker build -t myapp:${{ github.sha }} .
docker save myapp:${{ github.sha }} | gzip > image.tar.gz
vt scan file image.tar.gz --wait
HASH=$(sha256sum image.tar.gz | cut -d' ' -f1)
MALICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.malicious')
SUSPICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.suspicious')
if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 2 ]; then
echo "::error::Security issues detected in container image"
exit 1
fi
GitLab CI Integration
stages:
- build
- security
- deploy
build:
stage: build
script:
- npm ci && npm run build
- tar -czf build.tar.gz dist/
artifacts:
paths:
- build.tar.gz
malware_scan:
stage: security
image: ubuntu:latest
before_script:
- apt-get update && apt-get install -y curl jq
- curl -LO https://github.com/VirusTotal/vt-cli/releases/download/1.0.0/vt_1.0.0_linux_amd64.deb
- dpkg -i vt_1.0.0_linux_amd64.deb
script:
- export VT_API_KEY=$VIRUSTOTAL_API_KEY
- vt scan file build.tar.gz --wait
- |
HASH=$(sha256sum build.tar.gz | cut -d' ' -f1)
MALICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.malicious')
if [ "$MALICIOUS" -gt 0 ]; then
echo "Malware detected!"
exit 1
fi
dependencies:
- build
deploy:
stage: deploy
script:
- ./deploy.sh
only:
- main
needs:
- malware_scan
Jenkins Pipeline
pipeline {
agent any
environment {
VT_API_KEY = credentials('virustotal-api-key')
}
stages {
stage('Build') {
steps {
sh 'npm ci && npm run build'
sh 'tar -czf build.tar.gz dist/'
}
}
stage('Security Scan') {
steps {
sh '''
curl -LO https://github.com/VirusTotal/vt-cli/releases/download/1.0.0/vt_1.0.0_linux_amd64.deb
dpkg -i vt_1.0.0_linux_amd64.deb || apt-get install -f -y
vt scan file build.tar.gz --wait
HASH=$(sha256sum build.tar.gz | cut -d' ' -f1)
MALICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.malicious')
if [ "$MALICIOUS" -gt 0 ]; then
echo "MALWARE DETECTED"
exit 1
fi
'''
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
sh './deploy.sh'
}
}
}
post {
failure {
slackSend channel: '#security-alerts',
message: "Security scan failed for ${env.JOB_NAME} #${env.BUILD_NUMBER}"
}
}
}
Scanning Dependencies
Scan downloaded packages before using them:
- name: Scan npm dependencies
run: |
# Create tarball of node_modules
tar -czf dependencies.tar.gz node_modules/
# Scan the bundle
vt scan file dependencies.tar.gz --wait
# Or scan individual suspicious packages
for pkg in node_modules/.package-lock.json; do
# Extract and scan packages with native bindings
...
done
Best Practices
1. Cache Scan Results
Don't re-scan files you've already checked:
- name: Check cache before scanning
run: |
HASH=$(sha256sum build.tar.gz | cut -d' ' -f1)
# Try to get existing report first
if vt file "$HASH" --format json 2>/dev/null; then
echo "Using cached scan result"
else
echo "Uploading for fresh scan"
vt scan file build.tar.gz --wait
fi
2. Set Appropriate Thresholds
Not every detection is malware. Set sensible thresholds:
MALICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.malicious')
SUSPICIOUS=$(vt file "$HASH" --format json | jq '.data.attributes.last_analysis_stats.suspicious')
# Fail on any malicious detection
if [ "$MALICIOUS" -gt 0 ]; then
exit 1
fi
# Warn but don't fail on suspicious (could be false positive)
if [ "$SUSPICIOUS" -gt 3 ]; then
echo "::warning::Multiple suspicious detections - review manually"
fi
3. Handle Rate Limits
scan_with_retry() {
local file=$1
local retries=3
for i in $(seq 1 $retries); do
if vt scan file "$file" --wait 2>&1; then
return 0
fi
echo "Rate limited, waiting 60s... (attempt $i/$retries)"
sleep 60
done
return 1
}
4. Store Reports for Audit
- name: Save scan report
run: |
HASH=$(sha256sum build.tar.gz | cut -d' ' -f1)
vt file "$HASH" --format json > scan-report.json
- name: Upload scan report
uses: actions/upload-artifact@v4
with:
name: virustotal-report
path: scan-report.json
retention-days: 90
Limitations to Consider
| Limitation | Workaround |
|---|---|
| File size limit (650MB free) | Split large files or use premium |
| Rate limits | Cache results, batch scans |
| Scan time (up to 5 min) | Run scans in parallel jobs |
| False positives | Set thresholds, review manually |
| No real-time protection | Combine with other security tools |
Related Security Tools
- VirusTotal CLI Guide - Basic setup and commands
- Nuclei Vulnerability Scanner - Scan for CVEs and misconfigs
- Falco Runtime Security - Runtime threat detection
- GitHub Actions Performance Tips - Speed up your pipelines
Conclusion
Adding VirusTotal to your CI/CD pipeline creates an automated security gate that catches malware before it reaches production. Combined with dependency scanning and container image verification, you build defense in depth against supply chain attacks.
Start with scanning your final build artifacts, then expand to dependencies and container images as your security posture matures.
Want to automate security incident response when threats are detected? Akmatori helps SRE teams build AI-powered runbooks that can automatically respond to security alerts and coordinate remediation.
