Container Vulnerability Scanning in CI/CD Pipelines: Trivy, Grype, and Policy Enforcement
Problem
Most teams add container scanning at the registry level — a post-push scan that runs after the image is already stored, ready to be pulled, and potentially being deployed. Registry scanning is better than nothing, but it has a fundamental ordering problem: the vulnerable image reaches the registry before any policy is applied. By the time a registry scanner fires an alert, a deployment may already be in flight.
Build-time scanning changes the order of operations. The scan runs inside the CI pipeline, after the image is built but before it is pushed. A CRITICAL CVE fails the build. The image never reaches the registry. No deployment occurs. The developer sees the failure immediately, in the same workflow where they are already focused on the change.
Common failures without build-time scanning:
- Base image CVEs slip through. A
FROM python:3.11pulls in dozens of OS packages. A new CVE inlibsslis not noticed until a registry scanner fires a week later, by which point the image is running in production. - Application dependency CVEs are invisible.
pip installandnpm cibring in transitive dependencies. Without scanning the final image layer, these CVEs are never surfaced in the pipeline. - No blocking policy. Scanning tools exist in the pipeline but are configured with
exit-code: 0— they report findings without failing the build. Developers see warnings they learn to ignore. - False positives block releases. Overcorrection in the other direction: every CVE blocks the build, including CVEs with no fix available, CVEs in packages that are not reachable at runtime, and CVEs in the build stage that do not appear in the final image.
- IaC is not scanned. Kubernetes manifests and Terraform configurations contain misconfigurations (privileged pods, unrestricted
allowPrivilegeEscalation) that are distinct from CVEs but equally deployable via the same pipeline.
Target systems: Trivy 0.50+; Grype 0.74+; Syft 1.0+; GitHub Actions; GitLab CI; Dependency Track 4.x; Anchore Enterprise (optional).
Threat Model
- Adversary 1 — Known CVE in base image OS packages: An attacker exploits a published CVE (e.g., a remote code execution in
glibc) in a production container. The base image was pulled six weeks ago; the CVE was published four weeks ago. No pipeline check detected it. - Adversary 2 — Compromised transitive dependency: A supply chain attack injects malicious code into a transitive npm/pip/Maven package. The application image is built with the compromised package. Without layer-level scanning, the compromise is not detected before deployment.
- Adversary 3 — Misconfigured Kubernetes manifest: A developer deploys a pod with
securityContext.privileged: trueandhostNetwork: true. The manifest passes YAML linting but is never checked for security misconfigurations. - Adversary 4 — Stale base image in long-lived branch: A feature branch is opened, a base image is selected, and the branch sits unmerged for three weeks. The main branch updates its base image digest; the feature branch does not. The merged image contains a regressed CVE profile.
- Access level: Adversary 1 and 2 exploit the running application; no pipeline access required. Adversary 3 has developer access and can merge manifests. Adversary 4 is an accidental configuration drift.
- Objective: Execute code in the container; escape to the node; exfiltrate data.
- Blast radius: A single unscanned image in production can expose all workloads sharing the same node if the CVE enables container escape.
Configuration
Step 1: Trivy in GitHub Actions
Trivy is the most widely deployed open-source container scanner. It scans OS packages, language ecosystem files (go.sum, package-lock.json, requirements.txt, pom.xml), and Kubernetes manifests in a single binary.
# .github/workflows/build-and-scan.yml
name: Build, Scan, Push
on:
push:
branches: [main]
pull_request:
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # Required for uploading SARIF to GitHub Security tab.
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image (local, not pushed yet)
uses: docker/build-push-action@v5
with:
context: .
load: true # Load into local daemon; do not push.
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Scan the base image separately to distinguish base CVEs from app CVEs.
- name: Scan base image layer
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: python:3.12-slim # Match your FROM line.
format: table
severity: CRITICAL,HIGH
exit-code: 0 # Warn only; base image may have no-fix CVEs.
ignore-unfixed: true
# Scan the full application image (base + app layers).
- name: Scan application image
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1 # FAIL the build on CRITICAL or HIGH.
ignore-unfixed: true # Skip CVEs with no upstream fix.
trivyignores: .trivyignore # Project-level suppression file.
- name: Upload SARIF to GitHub Security tab
if: always() # Upload even on scan failure.
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
- name: Push image (only if scan passed)
if: github.ref == 'refs/heads/main' && success()
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
Scanning the base image separately before scanning the full application image gives operators visibility into which CVEs originate in the upstream base image versus which were introduced by the application’s own dependencies. This separation matters for prioritisation: a CVE in the base image OS may have no fix yet, while a CVE in an application package often does.
Step 2: Managing False Positives with .trivyignore
Not every CVE warrants a blocked build. .trivyignore suppresses specific CVE IDs with a brief comment explaining the justification. Without a comment, suppressed CVEs become invisible technical debt.
# .trivyignore
# Format: CVE-ID [optional comment]
# CVE-2023-44487 (HTTP/2 Rapid Reset) — mitigated by NGINX ingress rate limiting.
# Tracked in: https://linear.app/myorg/issue/SEC-142
CVE-2023-44487
# CVE-2022-1664 — dpkg path traversal; only exploitable if arbitrary packages
# are installed at runtime. Our images are immutable and run read-only.
# No fix available in debian:bookworm as of 2026-04-01.
CVE-2022-1664
# CVE-2024-3094 (xz-utils backdoor) — not present in our build; confirmed by
# checking liblzma version pinned to 5.4.1 pre-backdoor.
CVE-2024-3094
Review .trivyignore quarterly. CVE suppressions that have aged beyond 90 days without a tracking issue or fix should be re-evaluated.
Step 3: Trivy in GitLab CI
# .gitlab-ci.yml — container scanning integrated into the pipeline.
stages:
- build
- scan
- push
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
build:
stage: build
image: docker:26
services:
- docker:26-dind
script:
- docker build -t $IMAGE_TAG .
- docker save $IMAGE_TAG -o image.tar
artifacts:
paths:
- image.tar
expire_in: 1 hour
scan:trivy:
stage: scan
image:
name: aquasec/trivy:0.50.0
entrypoint: [""]
script:
# Scan the saved image tarball — no need to push first.
- trivy image
--input image.tar
--exit-code 1
--severity CRITICAL,HIGH
--ignore-unfixed
--ignorefile .trivyignore
--format json
--output trivy-report.json
# Also produce a human-readable table for the job log.
- trivy image
--input image.tar
--exit-code 0
--severity CRITICAL,HIGH,MEDIUM
--ignore-unfixed
--format table
artifacts:
when: always
paths:
- trivy-report.json
reports:
# GitLab Security Dashboard integration (GitLab Ultimate).
container_scanning: trivy-report.json
expire_in: 7 days
allow_failure: false
# Scan Kubernetes manifests and Terraform in the same pipeline.
scan:iac:
stage: scan
image:
name: aquasec/trivy:0.50.0
entrypoint: [""]
script:
- trivy config
--exit-code 1
--severity HIGH,CRITICAL
./deploy/k8s/
- trivy config
--exit-code 1
--severity HIGH,CRITICAL
./terraform/
allow_failure: false
push:
stage: push
image: docker:26
services:
- docker:26-dind
script:
- docker load -i image.tar
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $IMAGE_TAG
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Step 4: Grype as an Alternative — Syft SBOM + Grype Scan
Grype (by Anchore) separates SBOM generation from vulnerability matching, which enables two distinct use cases: scanning during the build, and re-scanning an existing SBOM against a new vulnerability database without rebuilding.
# Install Syft (SBOM generator) and Grype (vulnerability matcher).
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# .github/workflows/grype-scan.yml
# Generate an SBOM using Syft (CycloneDX or SPDX format).
- name: Generate SBOM with Syft
run: |
syft myapp:${{ github.sha }} \
--output cyclonedx-json=sbom.cyclonedx.json \
--output spdx-json=sbom.spdx.json
- name: Upload SBOM as artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: |
sbom.cyclonedx.json
sbom.spdx.json
# Scan the SBOM with Grype — or scan the image directly.
- name: Scan with Grype
run: |
grype sbom:./sbom.cyclonedx.json \
--fail-on high \
--output sarif \
> grype-results.sarif
- name: Upload Grype SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: grype-results.sarif
The key advantage of the SBOM-first workflow: the sbom.cyclonedx.json artifact can be stored alongside the image in the registry (as an OCI attestation via cosign attach sbom) and re-scanned at any time. When a new CVE is published that matches a package in an already-deployed image, the stored SBOM can be re-matched against the updated Grype vulnerability database without rebuilding or re-pulling the image.
# Grype suppress file — equivalent to .trivyignore.
# ~/.grype.yaml or .grype.yaml at project root.
ignore:
- vulnerability: CVE-2023-44487
reason: "Mitigated at the ingress layer; not reachable in this container."
fix-state: not-fixed
- package:
name: xz-utils
version: "5.4.1"
vulnerability: CVE-2024-3094
reason: "Confirmed: 5.4.1 predates the backdoor introduction."
Step 5: Blocking Policy — CRITICAL/HIGH vs Warn-Only
The policy question is not technical; it is organisational. Blocking builds on CRITICAL and HIGH CVEs produces safe deployments but requires operational discipline to handle the case where no fix exists.
Recommended tiered policy:
| Severity | Default action | Override mechanism | Review cadence |
|---|---|---|---|
| CRITICAL | Block build | .trivyignore entry with tracking issue |
Monthly |
| HIGH | Block build | .trivyignore entry with tracking issue |
Quarterly |
| MEDIUM | Warn; create issue | None required | Quarterly |
| LOW / UNKNOWN | Warn; optional | None required | Ad hoc |
For ignore-unfixed: true vs blocking everything: CVEs with no available fix cannot be remediated by the application team — only by the upstream package maintainer. Blocking on these creates build failures that developers cannot resolve, causing alert fatigue and teaching teams to disable the scanner. Set ignore-unfixed: true and track unfixed CVEs in a separate dashboard (Dependency Track is the right tool here).
# GitHub Actions: two-threshold pattern.
# First pass: fail on CRITICAL immediately.
- name: Scan — CRITICAL (blocking)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL
exit-code: 1
ignore-unfixed: false # Block even if no fix — CRITICAL is urgent.
# Second pass: fail on HIGH only when a fix is available.
- name: Scan — HIGH (blocking with fix available)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:${{ github.sha }}
severity: HIGH
exit-code: 1
ignore-unfixed: true # Only block if a patched version exists.
# Third pass: warn on MEDIUM/LOW; results go to Security tab.
- name: Scan — MEDIUM/LOW (warn only)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:${{ github.sha }}
severity: MEDIUM,LOW
exit-code: 0
format: sarif
output: trivy-warn.sarif
Step 6: Distroless and Minimal Base Images
The fastest way to reduce CVE surface is to ship fewer packages. Distroless images contain only the application runtime — no shell, no package manager, no OS utilities. Fewer installed packages means fewer CVEs to scan, triage, and patch.
# Before: debian-based image with ~200 OS packages.
FROM python:3.12
COPY . /app
RUN pip install -r /app/requirements.txt
CMD ["python", "/app/main.py"]
# After: distroless Python runtime.
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12:nonroot
COPY --from=builder /root/.local /root/.local
COPY --chown=nonroot:nonroot app/ /app/
USER nonroot
ENTRYPOINT ["python3", "/app/main.py"]
Trivy scan results with distroless typically show 90%+ reduction in OS-level CVE count compared to the full base image. The remaining CVEs are primarily in the language runtime itself and are shared across all distroless users, which increases upstream pressure to fix them.
Minimal base image options by runtime:
| Runtime | Minimal base | CVE surface vs full base |
|---|---|---|
| Go (static binary) | scratch |
Zero OS packages |
| Go / C | gcr.io/distroless/static-debian12 |
~10 packages |
| Python | gcr.io/distroless/python3-debian12 |
~30 packages |
| Java | gcr.io/distroless/java21-debian12 |
~30 packages |
| Node.js | gcr.io/distroless/nodejs20-debian12 |
~30 packages |
| General Linux | alpine:3.19 |
~20 packages (musl libc; not glibc) |
Step 7: Scanning IaC — Kubernetes Manifests and Terraform
Trivy’s config subcommand scans Kubernetes manifests for security misconfigurations using the same binary used for image scanning:
# Scan a directory of Kubernetes manifests.
trivy config \
--severity HIGH,CRITICAL \
--exit-code 1 \
./deploy/
# Example findings Trivy checks for:
# KSV001: Process can elevate its own privileges (allowPrivilegeEscalation: true)
# KSV003: Default capabilities not dropped (capabilities.drop not set)
# KSV012: Runs as root user (runAsNonRoot: false)
# KSV016: Memory not limited (resources.limits.memory missing)
# KSV030: Runtime/Default Seccomp profile not applied
# Scan Terraform.
trivy config \
--severity HIGH,CRITICAL \
--exit-code 1 \
./terraform/
# Kubernetes manifest scan in GitLab CI.
scan:k8s-manifests:
stage: scan
image:
name: aquasec/trivy:0.50.0
entrypoint: [""]
script:
- trivy config
--format json
--output k8s-findings.json
--severity HIGH,CRITICAL
--exit-code 1
./deploy/k8s/
artifacts:
when: always
paths:
- k8s-findings.json
IaC scanning in the pipeline closes a gap that image scanning cannot address: a perfectly clean container image can still be deployed insecurely if the Kubernetes manifest grants it hostPID: true or mounts sensitive host paths.
Step 8: VEX — Vulnerability Exploitability eXchange
VEX is a structured format for documenting that a specific CVE, while present in a package, is not exploitable in a given product due to deployment context or mitigating controls. It gives .trivyignore a standardised, machine-readable equivalent that can be attached to the SBOM and understood by multiple tools.
// vex.json — OpenVEX format (openvex.dev).
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/myapp-v2.1.0",
"author": "security@example.com",
"timestamp": "2026-05-07T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"@id": "https://www.cve.org/CVERecord?id=CVE-2023-44487"
},
"products": [
{
"@id": "pkg:oci/myapp@sha256:abc123..."
}
],
"status": "not_affected",
"justification": "protected_by_mitigating_control",
"impact_statement": "HTTP/2 Rapid Reset is mitigated by NGINX ingress rate limiting configured at the cluster level. The application container does not directly terminate TLS or accept HTTP/2 from untrusted clients."
},
{
"vulnerability": {
"@id": "https://www.cve.org/CVERecord?id=CVE-2022-1664"
},
"products": [
{
"@id": "pkg:oci/myapp@sha256:abc123..."
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "dpkg path traversal requires ability to install packages at runtime. Image is immutable; no package manager is invoked post-build."
}
]
}
# Use the VEX document with Trivy.
trivy image \
--vex vex.json \
--severity CRITICAL,HIGH \
--exit-code 1 \
myapp:latest
# Use with Grype.
grype myapp:latest \
--vex vex.json \
--fail-on high
VEX produces an auditable justification trail for every suppressed CVE. Unlike .trivyignore entries (which are opaque to external parties), a VEX document can be attached to the image attestation in the registry and verified by downstream consumers.
Step 9: Integrating with Dependency Track
Dependency Track (OWASP) is a centralised vulnerability management platform that ingests CycloneDX SBOMs, continuously matches them against the NVD/OSV/GitHub Advisory databases, and tracks remediation over time.
# .github/workflows/dependency-track.yml
- name: Generate SBOM
run: |
syft myapp:${{ github.sha }} \
--output cyclonedx-json=sbom.cyclonedx.json
- name: Upload SBOM to Dependency Track
run: |
# Encode SBOM as base64 for the API call.
SBOM_BASE64=$(base64 -w 0 sbom.cyclonedx.json)
curl -s -X PUT \
-H "X-Api-Key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }}" \
-H "Content-Type: application/json" \
-d "{
\"projectName\": \"myapp\",
\"projectVersion\": \"${{ github.sha }}\",
\"autoCreate\": true,
\"bom\": \"$SBOM_BASE64\"
}" \
"https://dtrack.internal/api/v1/bom"
Dependency Track continuously re-evaluates all uploaded SBOMs against its vulnerability feeds. When a new CVE is published that affects a package in any stored SBOM, Dependency Track raises a finding without requiring a new build. This fills the gap between pipeline scans: an image built three months ago can be re-evaluated against today’s CVE feed.
Policy violations configured in Dependency Track can also drive pipeline gates. The API endpoint /api/v1/metrics/project/{uuid}/current returns the current violation count, which a pipeline step can query to gate deployment:
# Query Dependency Track for current policy violations before deployment.
VIOLATIONS=$(curl -s \
-H "X-Api-Key: $DT_API_KEY" \
"https://dtrack.internal/api/v1/metrics/project/$DT_PROJECT_UUID/current" | \
jq '.policyViolationsTotal')
if [ "$VIOLATIONS" -gt 0 ]; then
echo "ERROR: $VIOLATIONS policy violations in Dependency Track. Aborting deployment."
exit 1
fi
Step 10: Scanning Frequency — Every Build vs Scheduled
| Scan trigger | What it catches | When to use |
|---|---|---|
| Every build (PR + merge) | CVEs introduced by this change; regressions from base image updates | Always; non-negotiable |
| Scheduled (nightly/weekly) | New CVEs published against already-built images in registry | For images that are not rebuilt on every commit |
| Pre-deployment gate | CVEs introduced since last build; policy drift | Mature pipelines; high-risk environments |
| Post-deploy (continuous) | Runtime behavioural anomalies; zero-days | Requires separate runtime scanner (Falco, etc.) |
Scheduled scanning matters for long-lived images. A production image built on Monday may have a new CRITICAL CVE published on Wednesday. Without scheduled scanning or Dependency Track continuous monitoring, this CVE is invisible until the next rebuild.
# .github/workflows/scheduled-scan.yml
name: Scheduled vulnerability scan
on:
schedule:
- cron: '0 2 * * *' # Nightly at 02:00 UTC.
jobs:
scan-production-images:
runs-on: ubuntu-latest
strategy:
matrix:
image:
- ghcr.io/myorg/myapp:latest
- ghcr.io/myorg/worker:latest
- ghcr.io/myorg/api:latest
steps:
- name: Scan ${{ matrix.image }}
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ matrix.image }}
format: sarif
output: trivy-${{ strategy.job-index }}.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
trivyignores: .trivyignore
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-${{ strategy.job-index }}.sarif
Telemetry
container_scan_duration_seconds{scanner, image, stage} histogram
container_scan_cve_count{severity, scanner, image, fixable} gauge
container_scan_build_blocked_total{reason, severity} counter
container_scan_false_positive_suppressed_total{cve_id, justification_type} counter
iac_scan_finding_count{severity, check_id, manifest_path} gauge
dependency_track_policy_violations_total{project, policy_name} gauge
dependency_track_cve_new_since_build_total{project, severity} gauge
Alert on:
container_scan_cve_count{severity="CRITICAL", fixable="true"}> 0 on a production image — a deployed image has a patchable critical CVE; trigger emergency rebuild.dependency_track_cve_new_since_build_total{severity="CRITICAL"}> 0 — new critical CVE published against a deployed image’s packages; initiate scheduled rebuild.container_scan_false_positive_suppressed_totalgrowing faster than one per week — suppression file is accumulating entries without remediation; review and clean up.iac_scan_finding_count{severity="CRITICAL"}> 0 — a Kubernetes manifest or Terraform configuration has a critical misconfiguration that reached the scan stage; indicates a gap in earlier review.
Expected Behaviour
| Signal | Without build-time scanning | With build-time scanning |
|---|---|---|
| CRITICAL CVE in base image | Deployed; detected by registry scanner post-push | Build fails; image never pushed; developer alerted |
| High CVE in app dependency with fix | Deployed; detected if registry scanner is configured | Build fails; fix indicated in scan output |
| High CVE in app dependency, no fix available | Deployed | Build passes with ignore-unfixed: true; tracked in Dependency Track |
| False positive CVE | N/A | Suppressed via .trivyignore or VEX document with justification |
| Privileged Kubernetes manifest | Deployed | IaC scan fails the build |
| New CVE published against deployed image | Undetected until next build | Dependency Track alerts within hours |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Blocking on CRITICAL/HIGH | Prevents known-vulnerable deploys | Breaks builds developers cannot fix (no-fix CVEs) | ignore-unfixed: true for HIGH; track in Dependency Track |
| Distroless base images | Eliminates 90%+ of base OS CVEs | No shell for debugging; harder kubectl exec |
Use kubectl debug with ephemeral containers for investigation |
| VEX suppression | Machine-readable, auditable justification | Additional document to maintain | Attach VEX to image attestation; review with SBOM quarterly |
| Dependency Track integration | Continuous monitoring between builds | Infrastructure to host and maintain | Deploy on Kubernetes; resource requirements are modest |
| Scheduled scans | Catches CVEs between builds | Extra pipeline runs; noise if threshold is not tuned | Apply same ignore-unfixed and .trivyignore policy as build scans |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
.trivyignore entry without tracking issue |
Suppressed CVE with no record of justification | trivyignore entries growing; no linked issues |
Require PR approval for all .trivyignore changes; add issue URL as comment |
| Scanner rate-limited on vulnerability DB pull | Scan passes with stale DB; new CVEs missed | trivy image --download-db-only fails in CI |
Cache Trivy DB as a CI artifact; refresh daily |
| Grype/Syft version mismatch between SBOM generation and scan | False negatives; packages not matched | SBOM has packages Grype does not match | Pin Syft and Grype to the same major version; update together |
| IaC scan check too noisy | Developers disable --exit-code 1 |
Scan step always passes despite findings | Start with HIGH/CRITICAL only; tune down to MEDIUM after baseline is clean |
| Dependency Track API key rotation | SBOM upload fails; no new findings after rotation | Upload step returns 401 | Rotate key in CI secrets and DT simultaneously; alert on upload failures |
| Base image scan separate from app scan confusion | Team doesn’t know which layer owns a CVE | CVEs attributed to wrong team | Tag findings by layer in scan output; add --layer flag to Trivy |