Cybersecurity · Container Security
Container Security in 2026: Image Scanning, SBOMs, and What Teams Actually Do
Running containers in production without scanning them is the equivalent of shipping code without running tests. Here's how teams scan images, generate SBOMs, and add runtime protection, from the CI step to the cluster.
Anurag Verma
7 min read
Sponsored
A container image is a packaged operating system layer, a language runtime, your application dependencies, and your code. Each of those layers can carry vulnerabilities. The OS base image might have an unpatched OpenSSL. Your Node.js version might have a known CVE. A transitive npm dependency might have a prototype pollution issue.
Without scanning, you ship those into production without knowing. With scanning, you catch them in CI before the image is deployed, or at least get a list of what you’re accepting risk on.
Image scanning has become a standard part of deployment pipelines in 2026. Here’s the practical setup.
Trivy: The Open Source Standard
Trivy (by Aqua Security) is the most widely adopted open-source image scanner. It’s fast, accurate, and handles more than containers:
- Container images (Docker, OCI)
- Filesystems and git repositories
- Infrastructure as code (Terraform, Kubernetes manifests, CloudFormation)
- SBOMs
Install it:
# macOS
brew install trivy
# Linux
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Docker
docker run aquasec/trivy image <your-image>
Scan an image:
trivy image node:20-alpine
Output shows vulnerabilities by severity (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN) with CVE IDs, affected package versions, and fixed versions when available.
node:20-alpine (alpine 3.19.1)
=================================
Total: 4 (CRITICAL: 0, HIGH: 2, MEDIUM: 2, LOW: 0)
┌──────────────┬────────────────┬──────────┬──────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed → Fixed Version │
├──────────────┼────────────────┼──────────┼──────────────────────────────────┤
│ libssl3 │ CVE-2024-XXXX │ HIGH │ 3.1.4-r5 → 3.1.4-r6 │
│ libcrypto3 │ CVE-2024-XXXX │ HIGH │ 3.1.4-r5 → 3.1.4-r6 │
└──────────────┴────────────────┴──────────┴──────────────────────────────────┘
Integrating Into CI
The goal is to fail the build on critical and high severity vulnerabilities, with a policy for what to do about accepted exceptions.
# .github/workflows/build.yml
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: '1' # fail the build on findings
severity: 'CRITICAL,HIGH' # only block on these severities
ignore-unfixed: true # skip CVEs with no fix yet
ignore-unfixed: true is important. Many CVEs in base images have no available fix because the upstream maintainer hasn’t released a patch. Blocking on those means you can’t build until they do, which you can’t control. Focus enforcement on vulnerabilities where a fixed version exists.
For vulnerabilities you’ve reviewed and accepted (or that are false positives), Trivy supports a .trivyignore file:
# CVE-2024-12345 - affects crypto/tls, which we don't use in this service
# Reviewed 2026-03-01 by security team, accepted risk
CVE-2024-12345
# False positive - this package is not used at runtime
CVE-2024-67890
Keep this file in your repository and review it periodically. An ignore file that grows unchecked is a liability, not a policy.
Generating SBOMs
An SBOM (Software Bill of Materials) is a structured list of every component in your software: packages, libraries, their versions, and their licenses. The EU Cyber Resilience Act and US Executive Order 14028 both mandate SBOMs for software sold to government entities. Even if you’re not directly affected by those requirements now, your enterprise clients may start requiring SBOMs from their vendors.
Trivy generates SBOMs in CycloneDX or SPDX format:
# Generate SBOM in CycloneDX JSON format
trivy image --format cyclonedx --output sbom.json myapp:latest
# Generate in SPDX format
trivy image --format spdx-json --output sbom.spdx.json myapp:latest
Add SBOM generation to your CI pipeline alongside scanning:
- name: Generate SBOM
run: |
trivy image \
--format cyclonedx \
--output sbom-${{ github.sha }}.json \
myapp:${{ github.sha }}
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: sbom-${{ github.sha }}.json
retention-days: 90
Store SBOMs alongside releases. When a new CVE drops, you can check your historical SBOMs to determine which deployed versions are affected without re-scanning everything.
Writing Smaller, Safer Images
Scanning tells you about vulnerabilities. Reducing attack surface prevents them. The two techniques that matter most:
Use distroless or Alpine base images. A standard Debian or Ubuntu base image includes hundreds of packages you don’t need, each a potential vulnerability source. Alpine is 5MB and includes almost nothing. Distroless images (from Google) include only the language runtime and your application, not a shell or package manager.
# Before: full Debian base (~200MB, many packages)
FROM node:20
# After: Alpine base (~50MB, minimal packages)
FROM node:20-alpine
# After: distroless (smallest attack surface, no shell)
FROM gcr.io/distroless/nodejs20-debian12
Distroless images have no shell, which means docker exec doesn’t work for debugging. Plan your observability before switching.
Multi-stage builds separate build tools from runtime images. Build tools (compilers, package managers, test frameworks) don’t belong in production images:
# Build stage — includes everything needed to build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage — only what's needed to run
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node # don't run as root
EXPOSE 3000
CMD ["node", "dist/index.js"]
The USER node line matters: running as root in a container is a significant privilege escalation risk if the container is compromised.
Kubernetes-Level Policy
For teams running Kubernetes, image scanning in CI isn’t enough on its own. Nothing stops someone from deploying an unscanned image directly via kubectl. Admission controllers close this gap.
Kyverno (open source, CNCF) can enforce that all pods use images from approved registries and that images have been scanned:
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: check-registry
match:
resources:
kinds: [Pod]
validate:
message: "Images must come from our approved registry"
pattern:
spec:
containers:
- image: "registry.company.com/*"
- name: disallow-root
match:
resources:
kinds: [Pod]
validate:
message: "Containers must not run as root"
pattern:
spec:
containers:
- securityContext:
runAsNonRoot: true
This blocks any Pod that tries to pull from Docker Hub or run as root from being scheduled.
Runtime Security With Falco
Image scanning is static analysis: it looks at what’s in the image before it runs. Runtime security monitors what containers actually do while running: which system calls they make, which files they access, which network connections they open.
Falco (CNCF project) runs as a privileged DaemonSet and detects anomalous behavior:
# Example Falco rules
- rule: Detect shell spawned in container
desc: Container spawned a shell (unusual for most production apps)
condition: >
container and
proc.name in (bash, sh, ash, zsh) and
not proc.pname in (systemd, supervisor)
output: "Shell spawned in container (container=%container.name user=%user.name)"
priority: WARNING
- rule: Detect network tool in container
desc: Network tool (curl, wget) running in production container
condition: container and proc.name in (curl, wget, nc, nmap)
output: "Network tool in container (container=%container.name command=%proc.cmdline)"
priority: WARNING
These rules catch a common attack pattern: exploiting a web app to get code execution, then using that shell or curl to download a second-stage payload. A legitimate production Node.js app doesn’t spawn bash.
What Most Teams Actually Implement
In practice, the setup that covers most of the risk without becoming a full-time project:
- Trivy in CI on every build, blocking on CRITICAL/HIGH with fixes available
.trivyignorefor reviewed exceptions, reviewed quarterly- Distroless or Alpine base images for all new services
- Multi-stage builds with
USER nonroot - SBOM generation stored as CI artifacts alongside releases
Runtime security (Falco) and admission controllers (Kyverno) are step two, worth adding once you have the image scanning baseline working. They’re more operationally complex to maintain and tune, and a team that does none of the above gains more from starting with CI scanning than from jumping to runtime monitoring.
Container security is one of those areas where 80% of the benefit comes from 20% of the work. Image scanning in CI is the 20%.
Sponsored
More from this category
More from Cybersecurity
Secrets Management in 2026: Vault, Doppler, AWS Secrets Manager, and When .env Is Fine
Passkeys Are Ready: Implementing Passwordless Auth in Your Web App
Prompt Injection in 2026: The Attack Your AI App Probably Isn't Defending Against
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored