Container Image Scanning#

Every container image you deploy carries an operating system, libraries, and application dependencies. Each of those components can have known vulnerabilities. Image scanning compares the packages in your image against databases of CVEs (Common Vulnerabilities and Exposures) and tells you what is exploitable.

Scanning is not optional. It is a baseline hygiene practice that belongs in every CI pipeline.

How CVE Databases Work#

Scanners pull vulnerability data from multiple sources: the National Vulnerability Database (NVD), vendor-specific feeds (Red Hat, Debian, Alpine, Ubuntu security trackers), and language-specific advisory databases (GitHub Advisory Database for npm/pip/go). Each CVE has a severity rating based on CVSS scores:

  • Critical (9.0-10.0) – Remote code execution, unauthenticated access. Fix immediately.
  • High (7.0-8.9) – Significant impact with some exploitation constraints. Fix within days.
  • Medium (4.0-6.9) – Requires specific conditions to exploit. Fix in your next release cycle.
  • Low (0.1-3.9) – Minimal impact. Track and fix when convenient.

Severity alone does not tell the full story. A Critical CVE in a library your application never calls is lower risk than a Medium CVE in your authentication path. Context matters.

Trivy#

Trivy is the most widely adopted open-source scanner. It scans container images, filesystems, git repositories, and Kubernetes clusters.

Basic image scan:

trivy image myapp:1.2.0

Filter by severity:

trivy image --severity CRITICAL,HIGH myapp:1.2.0

Output as JSON for CI parsing:

trivy image --format json --output results.json myapp:1.2.0

Exit with error code on findings (for CI gates):

trivy image --exit-code 1 --severity CRITICAL myapp:1.2.0

This returns exit code 1 if any Critical CVE is found, which fails the CI step.

Scan a Dockerfile before building:

trivy config --policy-bundle-repository ghcr.io/aquasecurity/trivy-policies Dockerfile

Ignore unfixable vulnerabilities:

trivy image --ignore-unfixed myapp:1.2.0

This filters out CVEs where no patched version of the affected package exists. Useful for reducing noise in base image vulnerabilities you cannot resolve by updating packages.

Grype#

Grype from Anchore is another strong open-source option with a similar interface:

# Scan an image
grype myapp:1.2.0

# Filter by severity
grype myapp:1.2.0 --only-fixed --fail-on critical

# Scan a directory (useful for pre-build scanning)
grype dir:/path/to/project

Grype tends to be faster on initial scans because its database is a single downloadable file rather than multiple feeds.

Integrating Scans into CI Pipelines#

GitHub Actions with Trivy#

name: Image Security Scan
on:
  push:
    branches: [main]
  pull_request:

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Run Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: table
          exit-code: 1
          severity: CRITICAL,HIGH
          ignore-unfixed: true

      - name: Run Trivy scan (full report)
        if: always()
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif

      - name: Upload scan results
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

This pipeline builds the image, fails the build on Critical or High fixable vulnerabilities, and uploads the full scan report to GitHub’s Security tab regardless of pass/fail.

GitLab CI#

scan_image:
  stage: security
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image --exit-code 1 --severity CRITICAL
        --ignore-unfixed ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}
  allow_failure: false

Dealing with Base Image Vulnerabilities#

You will find CVEs in your base image that have no available fix. The upstream OS vendor has not released a patch yet. Your options:

  1. Switch to a smaller base image. Moving from ubuntu to slim or distroless eliminates hundreds of packages and their associated CVEs. You cannot have a vulnerability in curl if curl is not in the image.

  2. Use --ignore-unfixed in CI to avoid blocking on vulnerabilities outside your control. Track them separately.

  3. Create a .trivyignore file for specific CVEs you have assessed and accepted:

# Base image openssl CVE -- no fix available, not exploitable in our context
CVE-2024-12345
# Disputed CVE, false positive for our usage
CVE-2024-67890
  1. Rebuild on a newer base image version. Sometimes a CVE has a fix in a newer point release of the base image that you have not pulled yet.

Admission Controllers: Enforcing Policy at Deploy Time#

CI scanning catches vulnerabilities at build time. Admission controllers enforce policy at deploy time, preventing unscanned or vulnerable images from running in the cluster.

Kyverno Policy#

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-scan
spec:
  validationFailureAction: Enforce
  rules:
    - name: block-latest-tag
      match:
        any:
          - resources:
              kinds: ["Pod"]
      validate:
        message: "Images must use a specific tag, not :latest"
        pattern:
          spec:
            containers:
              - image: "!*:latest"
    - name: require-signed-images
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences: ["myregistry.io/*"]
          attestors:
            - entries:
                - keyless:
                    url: https://fulcio.sigstore.dev
                    roots: https://tuf-repo-cdn.sigstore.dev

OPA Gatekeeper Constraint#

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: require-trusted-registry
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
  parameters:
    repos:
      - "myregistry.io/"
      - "gcr.io/distroless/"

This prevents pods from running images pulled from untrusted registries.

Distroless as a Mitigation Strategy#

Distroless images typically have zero or near-zero CVEs because they contain almost nothing. A comparison:

$ trivy image python:3.12 --severity CRITICAL,HIGH -q
# Total: 12 (CRITICAL: 3, HIGH: 9)

$ trivy image python:3.12-slim --severity CRITICAL,HIGH -q
# Total: 2 (CRITICAL: 0, HIGH: 2)

$ trivy image gcr.io/distroless/static --severity CRITICAL,HIGH -q
# Total: 0

The tradeoff is debuggability. You cannot exec into a distroless container and run ls or curl because neither exists. For debugging, use ephemeral containers:

kubectl debug -it pod/myapp --image=busybox --target=myapp

Scanning Running Clusters#

Trivy can also scan all images currently running in a cluster:

# Scan all images in a namespace
trivy k8s --namespace production --report summary

# Full vulnerability report for the entire cluster
trivy k8s --report all -o cluster-scan.json --format json

Run this periodically (daily or weekly) to catch newly disclosed CVEs affecting images already deployed.

Key Takeaways#

  • Scan every image in CI. Fail builds on Critical (and ideally High) fixable vulnerabilities.
  • Use --ignore-unfixed to separate actionable findings from base image noise.
  • Distroless images eliminate entire categories of vulnerabilities by removing unnecessary packages.
  • Admission controllers (Kyverno, Gatekeeper) enforce image policies at deploy time, catching anything that bypasses CI.
  • Scan running clusters regularly. Vulnerabilities are disclosed after you deploy, not just before.