Skip to main content
Security·5 min read

Fixing 500+ Vulnerabilities in Container Images Without Breaking Dependencies

Reduce container image CVEs from 500+ to near-zero by migrating to Distroless and Chainguard images, implementing multi-stage builds, CI gate policies, and managing false positives with Trivy and Grype.

DT

DevOps Engineer & Technical Writer

TL;DR — Quick Fix

Scan your images and see the immediate impact of switching base images:

# Scan current image for vulnerabilities

trivy image --severity HIGH,CRITICAL your-app:latest

# Compare CVE counts across base images

echo "=== Ubuntu ===" && trivy image --quiet ubuntu:22.04 | tail -3

echo "=== Distroless ===" && trivy image --quiet gcr.io/distroless/base-debian12 | tail -3

echo "=== Chainguard ===" && trivy image --quiet cgr.dev/chainguard/static:latest | tail -3

# Rebuild with multi-stage + distroless

docker build -t your-app:secure -f Dockerfile.distroless .

trivy image your-app:secure

---

Why Scanners Report Hundreds of CVEs

Most vulnerabilities come from the OS layer, not your application code.

Container Image Vulnerability Layers

Typical Image (500+ CVEs)

OS packages: 400+ CVEs

Runtime libs: 80+ CVEs

Build tools left behind: 30+

App deps: 10-20 CVEs

Your code: 0-5 CVEs

Distroless (0-5 CVEs)

Minimal OS: 0 CVEs

No shell, no pkg manager

App dependencies only

Your compiled binary

Image: 20-50MB vs 500MB+

---

Multi-Stage Builds for Minimal Attack Surface

Go Application

# Dockerfile.distroless — Go app

FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./

RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /app/server /server

USER nonroot:nonroot

EXPOSE 8080

ENTRYPOINT ["/server"]

Node.js Application

# Dockerfile.distroless — Node.js app

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production && npm cache clean --force

COPY . .

RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12:nonroot

WORKDIR /app

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/node_modules ./node_modules

COPY --from=builder /app/package.json ./

USER nonroot:nonroot

EXPOSE 3000

CMD ["dist/index.js"]

Python Application

# Dockerfile.chainguard — Python app

FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir --target=/deps -r requirements.txt

COPY . .

FROM cgr.dev/chainguard/python:latest-dev

WORKDIR /app

COPY --from=builder /deps /deps

COPY --from=builder /app .

ENV PYTHONPATH=/deps

USER nonroot

EXPOSE 8000

ENTRYPOINT ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]

---

CI Gate Policies

# .github/workflows/image-scan.yml

name: Container Security Scan

on:

pull_request:

paths: ['/Dockerfile', '/requirements.txt', '*/package-lock.json']

jobs:

scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- name: Build image

run: docker build -t scan-target:${{ github.sha }} .

- name: Run Trivy scan

uses: aquasecurity/trivy-action@master

with:

image-ref: scan-target:${{ github.sha }}

format: 'sarif'

output: 'trivy-results.sarif'

severity: 'CRITICAL,HIGH'

exit-code: '1'

ignore-unfixed: true

- name: Upload SARIF

if: always()

uses: github/codeql-action/upload-sarif@v3

with:

sarif_file: trivy-results.sarif

# .trivyignore — Suppress known false positives

# CVE-2023-44487: Not exploitable (no network exposure in this context)

# CVE-2024-21626: Fixed in next upstream release, risk accepted

---

Comparing Scanner Tools

# Trivy — fast, broad coverage

trivy image --format json --output report.json your-app:latest

# Grype — good SBOM integration

grype your-app:latest --output json > grype-report.json

# Compare results

echo "Trivy:" && trivy image --quiet your-app:latest 2>/dev/null | grep -c "HIGH\|CRITICAL"

echo "Grype:" && grype your-app:latest 2>/dev/null | grep -c "High\|Critical"

---

Migration Strategy

#!/bin/bash

# scripts/migrate-base-images.sh — Audit all services

SERVICES=$(find . -name "Dockerfile" -not -path "/node_modules/")

for dockerfile in $SERVICES; do

SERVICE_DIR=$(dirname "$dockerfile")

SERVICE_NAME=$(basename "$SERVICE_DIR")

BASE=$(grep "^FROM" "$dockerfile" | tail -1 | awk '{print $2}')

IMAGE_TAG="${SERVICE_NAME}:scan-test"

docker build -t "$IMAGE_TAG" "$SERVICE_DIR" 2>/dev/null

VULN_COUNT=$(trivy image --quiet --severity HIGH,CRITICAL "$IMAGE_TAG" 2>/dev/null | wc -l)

echo "$SERVICE_NAME | Base: $BASE | Vulns: $VULN_COUNT"

done | sort -t'|' -k3 -rn

---

FAQ

Q: My app needs a shell for debugging. Can I still use distroless?

A: Use distroless debug variants in non-production. For production, use ephemeral containers: kubectl debug -it pod/my-pod --image=busybox.

Q: How do I handle CVEs in transitive dependencies I can't upgrade?

A: Check if the CVE is exploitable in your context. Document risk decisions in .trivyignore. Use --ignore-unfixed for unpatched CVEs. Set reminders to revisit.

Q: Won't minimal images break my CI/CD?

A: Use multi-stage builds — install everything in a builder stage, copy only runtime artifacts to the final stage. Test in staging first.

Q: How often should I rebuild base images?

A: At least weekly to pick up patches. Automate with Renovate Bot or Dependabot tracking base image digests.

Q: What about FIPS compliance?

A: Chainguard offers FIPS-validated images. For Distroless, compile with FIPS-compliant crypto libraries. Common in regulated industries.

---