Skip to main content
Back to writing
Cloud Native

Container Security Scanning in CI/CD: Beyond the Basics

Image scanning alone isn't enough. This post walks through a multi-layer container security pipeline that catches vulnerabilities before they reach production.

January 5, 20265 min read
containerssecuritydockerci-cddevops

Most teams add a Trivy scan to their CI pipeline, see a wall of CVEs, ignore most of them, and call it "container security." Checkbox compliance is not security. Real container security is a multi-layer pipeline that filters noise, enforces policies, and blocks deployments that don't meet your standards.

The Layers of Container Security

Container security isn't one thing. It's at least five:

  1. Base image selection: which OS and runtime you start from
  2. Dependency scanning: CVEs in your application dependencies
  3. Image scanning: CVEs in the final built image
  4. Configuration analysis: Dockerfile best practices and misconfigurations
  5. Runtime policies: what the container is allowed to do when it runs

Layer 1: Base Image Selection

Your base image choice determines 80% of your vulnerability surface. Alpine has fewer packages (and fewer CVEs) than Ubuntu. Distroless has even fewer.

Dockerfile
# Bad: full Ubuntu image — 400+ packages, many unnecessary
FROM ubuntu:22.04
 
# Better: Alpine — minimal package set
FROM node:20-alpine
 
# Best: Distroless — only your app and its runtime
FROM gcr.io/distroless/nodejs20-debian12

Tracking base image CVE counts as a metric is a good baseline:

base-image-audit.sh
#!/bin/bash
for image in "ubuntu:22.04" "node:20-alpine" "gcr.io/distroless/nodejs20-debian12"; do
  COUNT=$(trivy image --quiet --severity HIGH,CRITICAL "$image" 2>/dev/null | grep "Total:" | awk '{print $2}')
  echo "$image: $COUNT high/critical CVEs"
done

Layer 2: Dependency Scanning

Before you even build the image, scan your application dependencies:

.github/workflows/security.yml
jobs:
  dependency-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Audit npm dependencies
        run: npm audit --audit-level=high
 
      - name: Check for known vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: fs
          scan-ref: .
          severity: HIGH,CRITICAL
          exit-code: 1

Layer 3: Image Scanning

After building the image, scan it for OS-level and application-level vulnerabilities:

image-scan.yml
  image-scan:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Scan image
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.IMAGE }}:${{ github.sha }}
          severity: HIGH,CRITICAL
          exit-code: 1
          format: sarif
          output: trivy-results.sarif
 
      - name: Upload to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

Handling the CVE Noise

The raw scan output is overwhelming. Most CVEs have no fix available, or they're in packages the app doesn't use. A .trivyignore file handles acknowledged risks:

.trivyignore
# No fix available, not exploitable in our context
CVE-2023-44487
 
# Fixed in next base image update, scheduled for March
CVE-2024-21626
 
# False positive — we don't use the affected function
CVE-2024-34156

Every ignored CVE must have a comment explaining why. This file is reviewed in every security audit.

Layer 4: Dockerfile Analysis

Static analysis catches misconfigurations before the image is even built:

# Hadolint — Dockerfile linter
hadolint Dockerfile

Common issues Hadolint catches:

bad-practices.dockerfile
# DL3007: Using latest is prone to errors
FROM node:latest
 
# DL3003: Use WORKDIR instead of cd
RUN cd /app && npm install
 
# DL3009: Delete apt-get lists after installing
RUN apt-get update && apt-get install -y curl
 
# DL3018: Pin versions in apk add
RUN apk add curl

The fixed version:

good-practices.dockerfile
FROM node:20-alpine@sha256:abc123
 
WORKDIR /app
 
RUN apk add --no-cache curl=8.5.0-r0
 
COPY package*.json ./
RUN npm ci --production
 
COPY . .
 
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Layer 5: Runtime Policies

Scanning the image isn't enough. You also need to restrict what it can do at runtime:

security-context.yml
apiVersion: v1
kind: Pod
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL
      resources:
        limits:
          memory: 512Mi
          cpu: 500m

The Complete Pipeline

Putting it all together:

PR Created
  ├── Dependency scan (npm audit + Trivy filesystem)
  ├── Dockerfile lint (Hadolint)
  └── Unit tests
 
PR Merged
  ├── Build image
  ├── Image scan (Trivy)
  │   ├── CRITICAL CVEs → Block deployment
  │   ├── HIGH CVEs → Warn, require approval
  │   └── MEDIUM/LOW → Log, proceed
  ├── Sign image (Cosign)
  └── Push to registry
 
Deploy
  ├── Verify image signature
  ├── Check admission policies (OPA/Kyverno)
  └── Apply with security contexts

Key Takeaways

  1. Start with the base image. Alpine or Distroless eliminates most CVEs before you write any code.
  2. Pin base image digests. Reproducible builds prevent surprise vulnerabilities.
  3. Filter scan noise with .trivyignore. Require justification for every entry.
  4. Lint Dockerfiles. Misconfigurations are as dangerous as CVEs.
  5. Enforce runtime security contexts. Scanning without runtime restrictions is half the picture.
Share