Every container image you deploy is a collection of dependencies you didn’t write. Some of those dependencies have known vulnerabilities. The question isn’t whether your images have CVEs — it’s whether you know about them before attackers do.

The Problem

A typical container image includes:

  • A base OS (Alpine, Debian, Ubuntu)
  • Language runtime (Python, Node, Go)
  • Application dependencies (npm packages, pip modules)
  • Your actual code

Each layer can introduce vulnerabilities. That innocent FROM python:3.11 pulls in hundreds of packages you’ve never audited.

1
2
3
# How many packages in a "simple" Python image?
$ docker run --rm python:3.11 dpkg -l | wc -l
253

253 packages. Each one a potential attack surface.

Strategy 1: Scan in CI

Don’t ship what you haven’t scanned. Add scanning to your pipeline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# GitHub Actions with Trivy
name: Build and Scan
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .
      
      - name: Scan for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: table
          exit-code: 1           # Fail the build
          severity: CRITICAL,HIGH
          ignore-unfixed: true   # Skip CVEs without patches

Key settings:

  • exit-code: 1 — Fail builds with vulnerabilities
  • severity: CRITICAL,HIGH — Focus on what matters
  • ignore-unfixed: true — No point blocking on issues you can’t fix

Strategy 2: Multi-Stage Builds

Reduce attack surface by shipping less:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# ❌ BAD: Build tools in production image
FROM python:3.11
RUN pip install poetry
COPY . .
RUN poetry install
CMD ["python", "app.py"]

# ✅ GOOD: Multi-stage, minimal runtime
FROM python:3.11 AS builder
RUN pip install poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt > requirements.txt
RUN pip wheel -r requirements.txt -w /wheels

FROM python:3.11-slim
COPY --from=builder /wheels /wheels
RUN pip install --no-index /wheels/*.whl
COPY src/ /app/
USER nonroot
CMD ["python", "/app/main.py"]

The production image has:

  • No build tools (poetry, gcc, make)
  • No package caches
  • Slim base image
  • Non-root user

Fewer packages = fewer vulnerabilities = smaller attack surface.

Strategy 3: Distroless Images

Go further — remove the OS entirely:

1
2
3
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /app /app
CMD ["/app/main.py"]

Distroless images contain only your application and its runtime dependencies. No shell, no package manager, no coreutils. An attacker who gets code execution can’t curl their malware because curl doesn’t exist.

1
2
3
4
# Compare image sizes
python:3.11          # 1.01 GB
python:3.11-slim     # 155 MB
distroless/python3   # 52 MB

Smaller image, faster deploys, fewer CVEs.

Strategy 4: Base Image Policies

Not all base images are equal. Enforce standards:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# OPA/Conftest policy
package main

deny[msg] {
  input.image.base == "ubuntu:latest"
  msg := "Don't use 'latest' tag for base images"
}

deny[msg] {
  not startswith(input.image.base, "gcr.io/")
  not startswith(input.image.base, "registry.company.com/")
  msg := sprintf("Untrusted base image: %s", [input.image.base])
}

deny[msg] {
  input.user == "root"
  msg := "Containers must not run as root"
}

Run in CI:

1
conftest test Dockerfile --policy security.rego

Strategy 5: Registry Scanning

Images can become vulnerable after deployment. New CVEs are published daily. Scan your registry continuously:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Harbor (or Trivy Operator) continuous scanning
apiVersion: aquasecurity.github.io/v1alpha1
kind: VulnerabilityReport
metadata:
  name: myapp-scan
spec:
  schedule: "0 */6 * * *"  # Every 6 hours
  image:
    registry: registry.company.com
    repository: myapp
    tag: production
  notifyOn:
    - severity: CRITICAL
      channels:
        - slack
        - pagerduty

When a new critical CVE is published for a package in your production image, you’ll know within 6 hours — not when a security researcher tweets about it.

Strategy 6: Admission Control

The last line of defense. Block vulnerable images from deploying:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Kyverno policy
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-scan
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-vulnerability-scan
      match:
        resources:
          kinds:
            - Pod
      verifyImages:
        - imageReferences:
            - "registry.company.com/*"
          attestations:
            - type: https://cosign.sigstore.dev/attestation/vuln/v1
              predicateType: cosign.sigstore.dev/attestation/vuln/v1
              conditions:
                - all:
                    - key: "{{ scanner.result.criticalCount }}"
                      operator: Equals
                      value: 0

No vulnerability attestation, no deployment. This catches images that bypassed CI or were built before scanning was mandatory.

Practical Workflow

Here’s how it fits together:

1234567.......DCTIRAPeIrmedrviagmoebvgiidluyessuoitscplspritedcuyoirsasnonhrnpiseucumdnosaisnhgmtteeaocrsgooernlcetlogiedinreFsuAtovIrueLysrii(sffwciiaeCtnsRhsIaTstIctCaeAnsLt/aaHttItiGeoHsntabteifoonr)edeploy

Each layer catches what the previous missed.

Handling False Positives

Not every CVE affects you. A vulnerability in libxml2 doesn’t matter if your code never parses XML.

1
2
3
4
5
6
# .trivyignore
# CVE-2023-XXXXX: We don't use the affected function
CVE-2023-XXXXX

# Suppress until we can upgrade (time-limited)
CVE-2024-YYYYY exp:2024-03-01

Document why you’re ignoring each CVE. Review suppressions regularly.

Metrics That Matter

Track your security posture over time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- Average time from CVE publish to patch in production
SELECT 
  AVG(patched_at - cve_published_at) as mean_time_to_patch
FROM vulnerability_patches
WHERE severity = 'CRITICAL'
  AND patched_at > NOW() - INTERVAL '90 days';

-- Images in production with unpatched criticals
SELECT COUNT(DISTINCT image) 
FROM running_containers c
JOIN vulnerability_scans v ON c.image = v.image
WHERE v.critical_count > 0;

If your mean time to patch is measured in weeks, you have a process problem. If it’s measured in months, you have a culture problem.

The Minimum Viable Security

If you’re starting from zero:

  1. Add Trivy to CI — 10 minutes, catches most issues
  2. Use slim base images — One line change in Dockerfile
  3. Run as non-root — Add USER nonroot to Dockerfile
  4. Pin your base image tagspython:3.11.7, not python:3.11

That’s 30 minutes of work for 80% of the security benefit. Everything else is refinement.

Container security isn’t about achieving zero CVEs — that’s impossible. It’s about knowing what vulnerabilities exist in your stack, understanding which ones are exploitable, and fixing the critical ones before someone else finds them.