DevSecOps

Container Image Scanning: Trivy, Grype, and Snyk Container

A technical guide to scanning container images for vulnerabilities—understanding base layer vs application layer findings, integrating Trivy into GitHub Actions, signing images with cosign, and building a pragmatic policy for unfixable vulnerabilities.

December 1, 20258 min readShipSafer Team

Why Container Image Scanning Matters

When you build a Docker image, you inherit all the vulnerabilities of everything inside it: the base OS packages, the language runtime, every library your application depends on, and every library those libraries depend on. A Node.js application image built on node:18 contains hundreds of packages from Debian or Alpine, each with its own CVE history.

Container image scanning makes this inherited risk visible. Without it, teams routinely ship images containing dozens of known high and critical vulnerabilities—not because they wrote vulnerable code, but because they did not know what was in their base image.

The scanning problem has two parts: discovery (what is in the image?) and matching (which CVEs apply to what I found?). Modern scanners handle both, but with different databases, different package detection approaches, and different false positive rates.

Understanding Image Layers

A container image is composed of layers. Understanding which layer a vulnerability lives in determines your remediation path.

Base image layer: The first FROM instruction in your Dockerfile pulls a base image (e.g., node:20-alpine, python:3.12-slim). This layer contains the OS packages, package manager, and sometimes the language runtime. You do not control what goes into the base image—your remediation is to update to a newer base image tag or switch to a different base.

Dependency layer: The COPY package.json + RUN npm install layer installs your application's direct and transitive dependencies. Vulnerabilities here come from your package.json. Remediation is updating your dependencies.

Application layer: Your own code. Static analysis tools (SAST) are more appropriate here than CVE scanners, but misconfigurations (exposed secrets, world-readable files) can be detected by container scanners.

Understanding layer provenance helps prioritize findings: a critical CVE in a package you do not actually call at runtime is lower priority than a critical CVE in a library directly in your request path.

Trivy: The Most Widely Used Open Source Scanner

Trivy (by Aqua Security) has become the de facto standard for open source container scanning. It is fast, comprehensive, and has excellent CI/CD integration.

What Trivy scans:

  • OS packages (Alpine apk, Debian dpkg, Red Hat rpm)
  • Language ecosystems: Node.js (package-lock.json, yarn.lock), Python (pip), Go (go.sum), Java (pom.xml, gradle), Ruby, Rust, PHP
  • Infrastructure as Code: Terraform, Kubernetes manifests, Dockerfiles, Helm charts
  • Secrets: hardcoded AWS keys, GitHub tokens, passwords in environment variables
  • License compliance

Running Trivy Locally

# Install
brew install aquasecurity/trivy/trivy

# Scan a local image
trivy image myapp:latest

# Scan with severity filter (only HIGH and CRITICAL)
trivy image --severity HIGH,CRITICAL myapp:latest

# Fail if vulnerabilities above threshold
trivy image --exit-code 1 --severity CRITICAL myapp:latest

# Scan with SARIF output (for GitHub Code Scanning upload)
trivy image --format sarif --output results.sarif myapp:latest

# Scan a Dockerfile
trivy config Dockerfile

# Scan a filesystem (useful for scanning during build, before image assembly)
trivy fs --scanners vuln,secret,misconfig .

Trivy in GitHub Actions

The recommended pattern for CI integration:

name: Container Security
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write  # Required for SARIF upload

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build image
        run: docker build -t ${{ github.repository }}:${{ github.sha }} .

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ github.repository }}:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: 1
          ignore-unfixed: false

      - name: Upload Trivy results to GitHub Code Scanning
        if: always()  # Upload even if previous step failed
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif
          category: container-scanning

The exit-code: 1 setting makes the job fail when HIGH or CRITICAL vulnerabilities are found. Set ignore-unfixed: true to only fail on vulnerabilities that have available fixes.

Trivy Ignore Files

Not all findings require immediate action. Trivy supports .trivyignore files to suppress specific CVEs with justification:

# .trivyignore
# CVE-2023-12345 - Not exploitable in our configuration (no network exposure)
# Upstream fix expected in next release. Review: 2025-Q1
CVE-2023-12345

# CVE-2024-56789 - Only affects Windows, our image is Linux-only
CVE-2024-56789 until 2025-06-01

Entries should always include a comment explaining why the CVE is suppressed and optionally an expiry date. Review suppressed CVEs as part of your quarterly security review.

Grype: Anchore's Open Source Alternative

Grype (by Anchore) is another widely used open source scanner. It uses Anchore's vulnerability database (grypedb) which aggregates from NVD, GitHub Advisory Database, Alpine secdb, Ubuntu USN, Red Hat, and others.

Trivy and Grype frequently produce different results for the same image because they use different databases and different package detection logic. Running both and taking the union of findings gives better coverage.

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# Scan an image
grype myapp:latest

# Scan with severity threshold
grype myapp:latest --fail-on high

# Output in SARIF format
grype myapp:latest -o sarif > grype-results.sarif

# Scan a directory
grype dir:./myapp

Grype vs Trivy: Key Differences

FeatureTrivyGrype
Databaseghcr.io/aquasecurity/trivy-dbgrype.db (Anchore)
IaC scanningYesNo
Secret scanningYesNo
SBOM generationYes (with Syft)Pairs with Syft
SpeedFastFast
False positive rateLowLow

Snyk Container

Snyk Container offers commercial-grade scanning with additional features: fix PRs that upgrade base images, developer-friendly UX, and integration with Snyk's broader security platform.

# Authenticate
snyk auth

# Scan image
snyk container test myapp:latest

# Get a fix recommendation (suggests a less-vulnerable base image)
snyk container test myapp:latest --file=Dockerfile

# Monitor (persist results in Snyk dashboard)
snyk container monitor myapp:latest

Snyk's "upgrade base image" recommendations are particularly useful: it will suggest switching from node:18-bullseye to node:20-alpine and quantify the reduction in vulnerable packages (often 80%+ reduction in finding count from switching to a minimal base).

Image Signing with cosign

Scanning tells you whether an image is vulnerable. Signing ensures that the image deployed to production is exactly the image that was scanned—not a tampered substitute.

Sigstore's cosign tool provides keyless image signing using short-lived certificates backed by transparency logs (Rekor).

Signing in CI (Keyless)

Keyless signing in GitHub Actions uses the OIDC token from the GitHub Actions workflow as the identity:

  sign:
    runs-on: ubuntu-latest
    needs: scan
    permissions:
      contents: read
      id-token: write  # Required for keyless signing
      packages: write

    steps:
      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Sign image
        run: |
          cosign sign --yes ghcr.io/${{ github.repository }}:${{ github.sha }}

Verifying Signatures at Deploy Time

# Verify a signature before pulling/deploying
cosign verify \
  --certificate-identity-regexp "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myrepo:sha256:abc123

# In Kubernetes, use Kyverno or OPA Gatekeeper policies to enforce signing
# Example Kyverno ClusterPolicy:
# kind: ClusterPolicy
# spec:
#   rules:
#     - name: check-image-signature
#       match:
#         resources:
#           kinds: [Pod]
#       verifyImages:
#         - imageReferences: ["ghcr.io/myorg/*"]
#           attestors:
#             - entries:
#                 - keyless:
#                     issuer: "https://token.actions.githubusercontent.com"
#                     subject: "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main"

Handling Unfixable Vulnerabilities

A significant percentage of container image vulnerabilities are "unfixable"—the vulnerability has been disclosed but no patched version of the affected package is available. This is common for OS packages in base images where the distro's patching cycle lags CVE disclosure.

A pragmatic policy for unfixable vulnerabilities:

1. Verify the vulnerability is genuinely unfixable. Check if a newer base image tag has the fix. Trivy's --ignore-unfixed flag filters these out for the gate decision, but you should still review them.

2. Assess actual exploitability. Many CVEs require specific conditions: a particular configuration, a specific call path, a specific environment. EPSS (Exploit Prediction Scoring System) scores estimate the probability a CVE will be exploited in the wild within 30 days. A CVE with EPSS score of 0.002 (0.2% exploit probability) is de-prioritized relative to a CVE with EPSS score of 0.82.

3. Apply compensating controls. If a vulnerable package cannot be updated, can the attack surface be reduced? Running the container with a read-only filesystem, dropping unnecessary Linux capabilities, and using seccomp profiles all reduce exploitability of many CVEs.

4. Document and track. Use .trivyignore with expiry dates. Set a calendar reminder to re-evaluate when the package maintainer's next release is expected. Do not silently ignore unfixable vulnerabilities—make the acceptance decision explicit and time-bounded.

5. Minimize base image. The best long-term remedy is switching to a minimal base image (Alpine, Distroless, Wolfi) that has fewer packages and therefore fewer potential CVE surface area. A node:20-alpine image typically has 60–80% fewer findings than a node:20-bullseye image. A Google Distroless image (no shell, no package manager) has the fewest findings of all.

Container image scanning, combined with signing and a clear policy for handling findings, transforms your container pipeline from a black box of inherited risk into a transparent, auditable supply chain where you know exactly what you are shipping.

containers
Docker
vulnerability-scanning
Trivy
cosign
DevSecOps
supply-chain

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.