Vulnerability Scanning with Trivy as a Hard Gate
Vulnerability Scanning with Trivy as a Hard Gate
The Failure
The frontend shell image runs on node:20-slim. The team last updated the base image digest three weeks ago. In those three weeks, two CVEs were published affecting OpenSSL in the base image. One is rated CRITICAL: a remote code execution vulnerability triggered by malformed TLS certificates.
The pipeline has a Trivy scan step. It runs. It finds the CVE. It prints a warning table to the logs. The pipeline continues because exit-code is set to 0 (the default). The image is pushed. ArgoCD syncs it. The vulnerability is live in production.
The scan does its job. The pipeline does not.
The Mechanism
Trivy scans container images by extracting the filesystem, identifying OS packages and language dependencies, and checking them against vulnerability databases (NVD, GitHub Advisory Database, Red Hat OVAL, Alpine SecDB). It reports vulnerabilities by severity: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN.
The exit-code parameter determines whether Trivy fails the pipeline. exit-code: 0 (default) means Trivy always exits successfully, regardless of findings. exit-code: 1 means Trivy exits with code 1 when vulnerabilities at or above the specified severity are found. The pipeline treats exit code 1 as a failure, the job fails, and downstream jobs are skipped.
Severity Threshold Selection
CRITICAL only: Use for services with frequent deploys and a mature patching process. HIGH findings are reviewed weekly but do not block deploys. Appropriate for the catalog service (low risk, high deploy frequency).
CRITICAL + HIGH: Use for services that handle sensitive data or have lower deploy frequency. A HIGH vulnerability has time to accumulate risk between deploys. Appropriate for checkout and payments services.
The decision rule: if the service deploys less than once per week, gate on CRITICAL + HIGH. If the service deploys daily, gate on CRITICAL only and review HIGH findings on a weekly cadence.
The Implementation
# HARDENED: Trivy scan with SARIF upload and structured output
jobs:
scan:
runs-on: ubuntu-latest
needs: [build]
permissions:
security-events: write
steps:
# Human-readable output in logs
- name: Trivy scan (table)
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }}
exit-code: 1
severity: CRITICAL,HIGH
format: table
# SARIF for GitHub Security tab
- name: Trivy scan (SARIF)
if: always()
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }}
exit-code: 0
severity: CRITICAL,HIGH,MEDIUM
format: sarif
output: trivy-results.sarif
- name: Upload to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
category: trivy-container
# JSON for downstream automation
- name: Trivy scan (JSON)
if: always()
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }}
exit-code: 0
format: json
output: trivy-results.json
- name: Summarize findings
if: always()
run: |
echo "## Vulnerability Scan Results" >> $GITHUB_STEP_SUMMARY
echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
for sev in CRITICAL HIGH MEDIUM LOW; do
count=$(jq "[.Results[]?.Vulnerabilities[]? | select(.Severity == \"$sev\")] | length" trivy-results.json)
echo "| $sev | $count |" >> $GITHUB_STEP_SUMMARY
done
The scan runs three times with different output formats:
- Table for human review in the Actions log, with
exit-code: 1to gate the pipeline. - SARIF for the GitHub Security tab, providing a centralized view of vulnerabilities across all repos.
- JSON for summary generation and downstream automation.
The .trivyignore File
# .trivyignore
# Each entry must have a justification comment and review date
# CVE-2024-12345: OpenSSL memory corruption in X.509 parsing
# Impact: Not reachable. Service does not parse client certificates.
# Upstream fix: Expected in node:20.16.0
# Review by: 2026-07-15
CVE-2024-12345
# CVE-2024-67890: libc regex DoS
# Impact: Low. No user-supplied regex in application code.
# Upstream fix: Debian trixie package update pending
# Review by: 2026-06-30
CVE-2024-67890
Every ignored CVE requires:
- The CVE identifier
- An impact assessment (why it is not exploitable in this context)
- Reference to the upstream fix
- A review date (when to check again)
Enforcing Review Dates
# HARDENED: Fail pipeline if any .trivyignore entries have expired
- name: Check trivyignore expiration
run: |
today=$(date +%Y-%m-%d)
expired=0
while IFS= read -r line; do
if [[ "$line" =~ "Review by:" ]]; then
review_date=$(echo "$line" | grep -oP '\d{4}-\d{2}-\d{2}')
if [[ "$review_date" < "$today" || "$review_date" == "$today" ]]; then
echo "::error::Expired trivyignore entry: $line"
expired=$((expired + 1))
fi
fi
done < .trivyignore
if [ "$expired" -gt 0 ]; then
echo "::error::$expired trivyignore entries have expired and need review"
exit 1
fi
This script checks every Review by: date in the .trivyignore file. If any date has passed, the pipeline fails. The developer must either update the base image (fixing the CVE), extend the review date with a justification update, or remove the ignore entry (accepting the scan failure).
The Gate
The gate is exit-code: 1 on the Trivy scan step. It blocks the pipeline when:
- A CRITICAL or HIGH vulnerability is found in the image
- The vulnerability is not listed in
.trivyignore - Or it is listed but the review date has expired
This creates a ratchet: vulnerabilities can only be ignored with documentation, and the documentation expires. The team is forced to address or re-evaluate every ignored finding on a regular cadence.
The Recovery
When Trivy blocks the build:
- Read the finding. The table output shows the CVE ID, affected package, installed version, and fixed version.
- Check if a fix exists. If the fixed version is available, update the base image digest or the dependency version.
- If no fix exists, add the CVE to
.trivyignorewith the required documentation. - If the finding is a false positive, add it to
.trivyignorewith an explanation of why it is not applicable.
The pipeline will pass after the ignore entry is committed. The review date ensures the team revisits the decision.