Skip to main content
Back to blogs
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." That's not security — it's checkbox compliance. 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 — but 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