Skip to content

Security Scanning - Street-Level Ops

Real-world workflows for scanning images, dependencies, and infrastructure for vulnerabilities.

Quick Image Scan

# Scan a local image for HIGH and CRITICAL vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:latest

# Output:
# myapp:latest (debian 12.4)
# Total: 8 (HIGH: 6, CRITICAL: 2)
#
# ┌──────────────┬────────────────┬──────────┬────────────────┬───────────────┐
# │   Library    │ Vulnerability  │ Severity │ Installed Ver  │  Fixed Ver    │
# ├──────────────┼────────────────┼──────────┼────────────────┼───────────────┤
# │ libssl3      │ CVE-2024-0727  │ CRITICAL │ 3.0.11-1       │ 3.0.13-1      │
# │ curl         │ CVE-2023-46218 │ HIGH     │ 7.88.1-10      │ 7.88.1-10+deb │
# └──────────────┴────────────────┴──────────┴────────────────┴───────────────┘

# Scan and fail CI if CRITICAL found (exit code 1)
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Add --ignore-unfixed to skip CVEs with no available patch

# JSON output for automation
trivy image --format json --output scan-results.json myapp:latest

One-liner: --ignore-unfixed is your best friend in CI pipelines. Without it, Trivy fails on CVEs that have no available patch, blocking deploys for issues you cannot fix. Use it alongside --severity CRITICAL to keep the gate meaningful without causing false blocks.

Scanning All Running Images in a Cluster

# Extract unique images from all pods
kubectl get pods -A -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' | sort -u

# Scan each running image
kubectl get pods -A -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' | \
  sort -u | while read -r img; do
    echo "=== ${img} ==="
    trivy image --severity CRITICAL --quiet "${img}" 2>/dev/null
  done

# Count criticals per image
kubectl get pods -A -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' | \
  sort -u | while read -r img; do
    count=$(trivy image --severity CRITICAL --quiet --format json "${img}" 2>/dev/null | \
      jq '[.Results[]?.Vulnerabilities // [] | length] | add // 0')
    echo "${count} CRITICAL: ${img}"
  done | sort -rn

Default trap: Trivy scans the OS package database inside the image. If your app bundles dependencies via vendoring, static linking, or a language-specific package manager (pip, npm, go modules), those are only caught with --scanners vuln (the default) plus a lock file present. Missing requirements.txt or go.sum inside the image means language-level vulnerabilities go undetected.

Dockerfile and IaC Scanning

# Scan Dockerfile for misconfigurations
trivy config Dockerfile

# Output:
# Dockerfile (dockerfile)
# Tests: 23 (SUCCESSES: 20, FAILURES: 3)
# Failures: 3 (LOW: 1, MEDIUM: 1, HIGH: 1)
#
# HIGH: Specify a tag in the 'FROM' statement
# MEDIUM: Add HEALTHCHECK instruction
# LOW: Use COPY instead of ADD

# Scan Terraform files
trivy config --severity HIGH,CRITICAL ./terraform/

# Scan Kubernetes manifests
trivy config ./k8s/manifests/

# Scan for secrets in the filesystem
trivy fs --scanners secret .

Remember: Trivy scanning modes mnemonic: V-M-S — Vulnerabilities (CVEs in packages), Misconfigurations (IaC and Dockerfile issues), Secrets (leaked credentials). Pass --scanners vuln,misconfig,secret to run all three in one shot.

SBOM Generation and Scanning

# Generate SBOM for an image (CycloneDX format)
trivy image --format cyclonedx --output sbom.json myapp:v1.2.3

# Scan an existing SBOM when a new CVE drops (no image pull needed)
trivy sbom sbom.json --severity CRITICAL

# Generate SBOM with syft (alternative)
syft myapp:v1.2.3 -o spdx-json > sbom-spdx.json

# Quick check: which of my images contain libssl3?
for sbom in sboms/*.json; do
    if jq -e '.components[] | select(.name == "libssl3")' "${sbom}" >/dev/null 2>&1; then
        echo "AFFECTED: ${sbom}"
    fi
done

Secret Detection in Code

# Scan git history with gitleaks
gitleaks detect --source . --verbose

# Output:
# Finding: AWS Access Key
# Secret:  AKIA...
# File:    deploy/config.env
# Line:    12
# Commit:  a3b4c5d

# Scan staged files only (pre-commit hook)
gitleaks protect --staged

# Scan with trufflehog (deeper git history analysis)
trufflehog git file://. --only-verified

# Trivy secret scanning
trivy fs --scanners secret --severity HIGH,CRITICAL .

War story: A team ran gitleaks detect and found zero results. They assumed the repo was clean. Later, a leaked key was exploited. The problem: .gitleaksignore had a wildcard entry added by a frustrated developer six months earlier that suppressed all findings. Always audit your ignore files — cat .gitleaksignore — before trusting a clean scan result.

Image Signing and Verification

# Generate a cosign key pair
cosign generate-key-pair

# Sign an image after build
cosign sign --key cosign.key myregistry.io/myapp:v1.2.3

# Verify signature before deployment
cosign verify --key cosign.pub myregistry.io/myapp:v1.2.3

> **Remember:** Image signing workflow mnemonic: **G-S-V**  Generate key pair (once), Sign after every build (CI), Verify before every deploy (admission controller). Use Kyverno or OPA Gatekeeper as a Kubernetes admission controller to enforce signature verification automatically  no unsigned image runs in production.

# Output:
# Verification for myregistry.io/myapp:v1.2.3 --
# The following checks were performed on each of these signatures:
#   - The cosign claims were validated
#   - The signatures were verified against the specified public key

CI Pipeline Gate

# Full CI scan script
#!/usr/bin/env bash
set -euo pipefail

IMAGE="${1:?Usage: scan.sh IMAGE:TAG}"

echo "--- Scanning ${IMAGE} ---"

# 1. Vulnerability scan (fail on CRITICAL)
trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed "${IMAGE}"

# 2. Misconfiguration scan
trivy image --exit-code 1 --scanners misconfig "${IMAGE}"

# 3. Secret scan
trivy image --exit-code 1 --scanners secret "${IMAGE}"

# 4. Generate SBOM for audit trail
trivy image --format cyclonedx --output "sbom-$(date +%Y%m%d).json" "${IMAGE}"

echo "--- All scans passed ---"

Trivy Ignore File for Accepted Risks

# .trivyignore — document why each CVE is accepted
cat .trivyignore

# CVE-2023-44487  # HTTP/2 rapid reset — mitigated at LB level
# CVE-2023-39325  # Go stdlib — function not reachable in our code
# CVE-2024-1234   # Fix not available yet, tracked in JIRA-5678

# Scan with ignore file
trivy image --ignorefile .trivyignore --severity HIGH,CRITICAL myapp:latest

Gotcha: Suppressing a CVE in .trivyignore silences it forever — even after the fix becomes available. Set a calendar reminder to review your ignore list quarterly. Better yet, add an expiration comment and grep for stale entries: # expires: 2026-06-01.

Comparing Base Image Vulnerability Counts

# Quick comparison of base image options
for img in ubuntu:22.04 python:3.11-slim alpine:3.19 gcr.io/distroless/python3-debian12; do
    count=$(trivy image --severity HIGH,CRITICAL --quiet --format json "${img}" 2>/dev/null | \
      jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH")] | length')
    echo "${count} HIGH+CRITICAL: ${img}"
done

# Output:
# 5 HIGH+CRITICAL: ubuntu:22.04
# 1 HIGH+CRITICAL: python:3.11-slim
# 0 HIGH+CRITICAL: alpine:3.19
# 0 HIGH+CRITICAL: gcr.io/distroless/python3-debian12

Under the hood: Distroless and Alpine images have fewer vulnerabilities because they contain fewer packages. Alpine uses musl libc (~1 MB) instead of glibc (~8 MB), eliminating a major CVE source. The tradeoff: musl has subtle behavior differences (DNS resolution, threading, locale handling) that can cause application bugs. Test thoroughly before switching production images to Alpine.