Production-grade container vulnerability scanner with enriched remediation, CI/CD integration, and runtime advisory.
Scan Docker/Podman images and LXC rootfs for CVEs · Enrich with CISA KEV, OSV.dev, and runc advisories · Output SARIF, Markdown, HTML, CSV, and CycloneDX SBOM · Gate CI/CD pipelines on severity
Web UI · Quick Start · Commands · Docker · CI/CD · Configuration · Reports · Docs
docker build -t myapp:latest .
scanner scan --image myapp:latest --output-dir ./reports --format sarif,markdown,html --fail-on-severity CRITICAL,HIGH
- Scans container images (Docker, Podman, containerd) and root filesystems (LXC)
- Finds CVEs via Trivy with
--detection-priority comprehensive(GitHub Advisory DB fallback) - Enriches every finding with CISA KEV exploit status, OSV.dev CVE back-fill, and plain-English remediation
- Detects host runc container escape CVEs that image scanners can never see (
--check-runtime) - Outputs SARIF (Azure/GitHub Security tab), Markdown, HTML, CSV, and CycloneDX SBOM
- Exits non-zero on policy violation so pipelines fail fast on Critical/High findings
- Web UI — paste or drop an image name in the browser, get live scan results with no CLI (
go run ./cmd/server)
Browser user? See 🌐 Web UI — paste an image name and get results in your browser with no CLI.
# Build the scanner image once
docker build -t scanner:latest .
# Scan any image
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD/reports:/reports" \
scanner:latest scan --image alpine:latest \
--output-dir /reports \
--format sarif,markdown,htmlReports land in ./reports/. Open report.html in a browser or report.md in any Markdown viewer.
# Install Go + Trivy in one step (runs in background)
./scripts/install-deps.sh # Linux/macOS
.\scripts\install-deps.ps1 # Windows PowerShell
# Build
go build -o scanner ./cmd/cli
# Scan
./scanner scan --image alpine:latest --output-dir ./reportsscripts\run-scan-local.bat alpine:latestUses Trivy and Go from known default install paths. Reports go to reports\.
Tip — avoid overwriting old reports: add
--timestampto write unique filenames per run, e.g.report-20060102-150405.html.
scanner scan --image <ref> [flags]
scanner scan --fs <path> [flags] # rootfs (e.g. LXC)
scanner scan --lxc <name> [flags] # Linux: /var/lib/lxc/<name>/rootfs
Use --image or --fs/--lxc, never both. --dockerfile is only valid with --image.
| Flag | Default | Description |
|---|---|---|
--image |
— | Image to scan. e.g. alpine:latest, myregistry.io/app:v1 |
--fs |
— | Path to root filesystem (e.g. LXC rootfs) |
--lxc |
— | LXC container name → /var/lib/lxc/<name>/rootfs (Linux only) |
--dockerfile |
— | Dockerfile path; scans it for misconfigurations alongside the image |
--severity |
CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN |
Comma-separated severities to include |
--format |
sarif,markdown |
Output formats: sarif, markdown, html, csv |
--output-dir |
./reports |
Directory to write reports to |
--output-name |
report |
Base filename (e.g. myapp → myapp.md, myapp.sarif) |
--timestamp |
false |
Append timestamp to base name so runs don't overwrite each other |
--fail-on-severity |
— | Exit 1 if any finding matches (e.g. CRITICAL,HIGH) |
--fail-on-count |
— | Exit 1 if count ≥ N for a severity (e.g. HIGH:5) |
--check-runtime |
false |
Check host runc version for known container escape CVEs |
--sbom |
false |
Generate CycloneDX SBOM — <name>.cdx.json (image scans only) |
--offline |
false |
Skip DB update, CISA KEV, and OSV.dev; use local cache only |
--cache-dir |
system default | Trivy DB cache directory |
--config |
auto-detected | Path to scanner.yaml; auto-detects in current directory |
# Minimal scan — SARIF + Markdown to ./reports
scanner scan --image nginx:1.25
# Full output, fail on Critical/High, with SBOM
scanner scan --image myapp:v2 \
--format sarif,markdown,html,csv \
--sbom \
--fail-on-severity CRITICAL,HIGH \
--output-dir ./reports
# Scan image and its Dockerfile together
scanner scan --image myapp:latest --dockerfile ./Dockerfile
# Scan LXC container rootfs (Linux)
scanner scan --lxc my-container
# Check host runc for container escape CVEs
scanner scan --image myapp:latest --check-runtime
# Offline scan (air-gapped environment)
scanner scan --image myapp:latest --offline --cache-dir /mnt/trivy-cache
# Unique report per run
scanner scan --image alpine:latest --timestamp| Code | Meaning |
|---|---|
0 |
Scan complete, no policy violation |
1 |
Policy violated (--fail-on-severity or --fail-on-count triggered) or scan error |
scanner db update [--cache-dir <dir>]Refreshes the Trivy vulnerability database. Schedule this once a day for fresher results:
# Linux/macOS — cron (3 AM daily)
0 3 * * * /path/to/scripts/update-trivy-db.sh
# Windows — Task Scheduler
powershell -File "C:\path\to\scripts\update-trivy-db.ps1"The scanner image ships three things:
| Component | Binary | Purpose |
|---|---|---|
| CLI scanner | scanner |
Scan images / rootfs from the command line |
| Web UI server | scanner-server |
Browser-based scanning at http://localhost:8080 |
| Trivy | trivy |
Vulnerability engine (pinned via TRIVY_VERSION build-arg) |
# Default Trivy version (0.69.1):
docker build -t scanner:latest .
# Pin a specific Trivy version:
docker build --build-arg TRIVY_VERSION=0.70.0 -t scanner:latest .
# Or via Make:
make docker-build # uses default Trivy version
make docker-build TRIVY_VERSION=0.70.0 # pinnedmkdir -p reports
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD/reports:/reports" \
scanner:latest scan \
--image myapp:latest \
--format sarif,markdown,html,csv \
--check-runtime \
--sbom \
--fail-on-severity CRITICAL,HIGH \
--output-dir /reports
# Reports: report.sarif, report.md, report.html, report.csv, report.cdx.json (SBOM)# Foreground (Ctrl-C to stop):
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 \
--entrypoint scanner-server \
scanner:latest -port 8080
# → http://localhost:8080
# Background via Make:
make docker-serve-bg # starts via docker compose
make docker-serve-stop # stops it# Start the web UI server in the background:
docker compose up -d scanner-server
# → http://localhost:8080
# One-shot CLI scan via Compose:
docker compose run --rm scanner scan \
--image myapp:latest \
--output-dir /reports \
--format sarif,markdown,html,csv \
--check-runtime --sbom --fail-on-severity CRITICAL,HIGH
# Custom port:
SCANNER_PORT=9090 docker compose up -d scanner-serverdocker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD/reports:/reports" \
-v "$HOME/.cache/trivy:/root/.cache/trivy" \
scanner:latest scan \
--image myapp:latest \
--output-dir /reports# Pre-populate cache on a connected host:
docker run --rm \
-v "$HOME/.cache/trivy:/root/.cache/trivy" \
scanner:latest db update
# Copy the cache to the air-gapped host, then:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD/reports:/reports" \
-v "/mnt/trivy-cache:/cache" \
scanner:latest scan \
--image myapp:latest \
--offline \
--cache-dir /cache \
--output-dir /reportsWindows (cmd.exe): Replace
$PWDwith%CD%and$HOMEwith%USERPROFILE%.
The scanner is a single binary (or container). Drop it into any pipeline after your build step.
Build image → docker login (secret) → Run scanner → Publish reports → [Fail on severity]
name: Container Security Scan
on:
push:
branches: [main, develop]
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
permissions:
security-events: write # required for SARIF upload
steps:
- uses: actions/checkout@v4
- name: Build app image
run: docker build -t myapp:${{ github.sha }} .
- name: Build scanner
run: docker build -t scanner:latest .
- name: Run security scan
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${{ github.workspace }}/reports:/reports \
scanner:latest scan \
--image myapp:${{ github.sha }} \
--format sarif,markdown,html,csv \
--sbom \
--check-runtime \
--fail-on-severity CRITICAL,HIGH \
--output-dir /reports
- name: Upload SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: reports/report.sarif
- name: Upload reports as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: scan-reports-${{ github.sha }}
path: reports/Full template:
ci/github/workflow.example.yml
trigger:
branches:
include: [main, develop]
pool:
vmImage: ubuntu-latest
steps:
- task: Docker@2
displayName: Build app image
inputs:
command: build
tags: myapp:$(Build.BuildId)
- script: docker build -t scanner:latest .
displayName: Build scanner
- script: |
mkdir -p $(Build.ArtifactStagingDirectory)/reports
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(Build.ArtifactStagingDirectory)/reports:/reports \
scanner:latest scan \
--image myapp:$(Build.BuildId) \
--format sarif,markdown,html,csv \
--sbom \
--fail-on-severity CRITICAL,HIGH \
--output-dir /reports
displayName: Run security scan
- task: PublishSecurityAnalysisResults@1
displayName: Publish SARIF to Security tab
inputs:
ArtifactName: CodeAnalysisLogs
ArtifactType: Container
condition: always()
- task: PublishPipelineArtifact@1
displayName: Publish scan reports
inputs:
targetPath: $(Build.ArtifactStagingDirectory)/reports
artifact: scan-reports
condition: always()Full template:
ci/azure/pipeline.example.yml
container-scan:
stage: test
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t myapp:$CI_COMMIT_SHA .
- docker build -t scanner:latest .
- mkdir -p reports
- docker run --rm
-v /var/run/docker.sock:/var/run/docker.sock
-v "$CI_PROJECT_DIR/reports:/reports"
scanner:latest scan
--image myapp:$CI_COMMIT_SHA
--format sarif,markdown,html,csv
--sbom
--fail-on-severity CRITICAL,HIGH
--output-dir /reports
artifacts:
when: always
paths:
- reports/
reports:
sast: reports/report.sarifFull template:
ci/gitlab/job.example.yml
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'docker build -t myapp:${BUILD_NUMBER} .'
sh 'docker build -t scanner:latest .'
}
}
stage('Scan') {
steps {
sh '''
mkdir -p reports
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${WORKSPACE}/reports:/reports \
scanner:latest scan \
--image myapp:${BUILD_NUMBER} \
--format sarif,markdown,html,csv \
--sbom \
--fail-on-severity CRITICAL,HIGH \
--output-dir /reports
'''
}
}
}
post {
always {
archiveArtifacts artifacts: 'reports/**', allowEmptyArchive: true
}
}
}Full template:
ci/jenkins/Jenkinsfile.example
| Goal | Flag |
|---|---|
| Fail on Critical/High findings | --fail-on-severity CRITICAL,HIGH |
| Fail when ≥ 5 High findings | --fail-on-count HIGH:5 |
| Generate CycloneDX SBOM | --sbom |
| Check host runc escape CVEs | --check-runtime |
| Skip DB update (offline runner) | --offline --cache-dir /cache |
| Unique artifact per run | --timestamp |
| All formats | --format sarif,markdown,html,csv |
Secrets: Never put registry passwords in YAML. Use
docker loginwith your pipeline's secret store (GitHub Secrets, Azure Key Vault, GitLab CI/CD Variables, Jenkins Credentials).
Drop a scanner.yaml (or .scanner.yaml) in your project root to set defaults. CLI flags always override config values.
# scanner.yaml
severity: CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN
format: sarif,markdown,html
output-dir: ./reports
output-name: report
# cache-dir: /mnt/trivy-cache # optional
# Pipeline gate — uncomment to enable:
# fail-on-severity: CRITICAL,HIGH
# fail-on-count: HIGH:5Supported keys: severity, format, output-dir, output-name, cache-dir, fail-on-severity, fail-on-count.
Copy the example: cp scanner.yaml.example scanner.yaml
# Docker Hub
docker login
# Private registry
docker login myregistry.io
# GitHub Container Registry
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
# AWS ECR
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.comThe scanner reads from Docker's credential store automatically. Never pass credentials via flags.
| Format | File | Use for |
|---|---|---|
sarif |
report.sarif |
GitHub / Azure Security tab, Code Scanning |
markdown |
report.md |
PR comments, human review |
html |
report.html |
Browser view; browser Print→PDF for compliance |
csv |
report.csv |
Spreadsheets, custom dashboards |
SBOM (--sbom) |
report.cdx.json |
Dependency-Track, GitHub Dependency Graph, DORA |
Use --format sarif,markdown,html,csv to generate all at once.
Beyond CVE, Package, and Severity, every report contains:
| Column | What it means |
|---|---|
| Exploitable | yes = in CISA Known Exploited Vulnerabilities catalog (prioritize these). no = not in KEV. unknown = offline or non-CVE. |
| Why severity | Plain-English reason: "Critical: often RCE, auth bypass…" |
| Exploit info | CISA KEV description when available, including ransomware campaign usage |
| Remediation | Exact upgrade command: "Upgrade curl from 7.88.1 to 7.88.2" |
| Links | NVD, Aqua AVD, OSV.dev advisory |
CISA Known Exploited Vulnerabilities (KEV)
Any CVE in the CISA KEV catalog is marked Exploitable = yes and promoted to CRITICAL severity so you never miss it. The catalog is fetched online and cached for 24 hours.
OSV.dev
For findings Trivy returns without a CVE ID, the scanner queries OSV.dev to back-fill the identifier and add an advisory link. Covers Go modules, npm, PyPI, Rust, Maven, and more. Results are cached in-process. Skipped with --offline.
GitHub Advisory Database (GHSA)
In online mode, Trivy runs with --detection-priority comprehensive, falling back to GHSA when NVD data is incomplete. Catches Go stdlib and Java stdlib CVEs that the default mode silently misses.
Host runc advisory (--check-runtime)
runc is the container runtime on the host — not a package inside images — so Trivy cannot detect it. --check-runtime reads the host runc version (via docker version or runc --version) and flags:
| CVE | Severity | Fixed in | Impact |
|---|---|---|---|
| CVE-2025-31133 | CRITICAL | 1.2.8 | maskedPaths bypass → arbitrary host path write |
| CVE-2025-52565 | CRITICAL | 1.2.8 | /dev/console bind-mount escape before LSM activates |
| CVE-2025-52881 | CRITICAL | 1.2.8 | LSM bypass via shared mounts → host crash or full breakout |
| CVE-2024-21626 | HIGH | 1.1.12 | LEAKY VESSELS: working directory escape via leaked fd |
Findings appear as normal rows in all report formats with Package=runc and Path=host-runtime.
No CLI required. Start the server and scan any image from your browser.
go run ./cmd/server
# → http://localhost:8080Or via Make:
make serve # port 8080
PORT=9090 make serve # custom portWhat you get:
| Feature | Detail |
|---|---|
| Drop zone input | Paste or drag-and-drop an image reference |
| Live progress log | Status messages stream in real time via SSE |
| Summary cards | Total · Critical · High · Medium · Low · Exploitable counts |
| Findings table | CVE (linked to NVD) · Package · Version · Fixed In · Severity badge · Exploitable flag · Remediation |
| Severity filter | Narrow to Critical / High / Medium / Low with one click |
| Options | Mode (image or filesystem path) · Severity filter · Check host runc · Offline mode |
| Export | Download results as CSV, JSON, or Markdown — no server round-trip |
How it works under the hood:
Browser → GET /api/scan?image=alpine:latest
← SSE: {"type":"status","message":"Running Trivy..."}
← SSE: {"type":"status","message":"Enriching findings..."}
← SSE: {"type":"complete","findings":[...],"summary":{...}}
The server runs the exact same pipeline as the CLI: Trivy scan → runc advisory (if enabled) → CISA KEV + OSV.dev enrichment → findings returned as JSON. One scan at a time is enforced server-side.
Requires: Go 1.21+ and Trivy in PATH. Docker must be running so Trivy can pull images not already cached locally.
docker-scanner/
├── cmd/
│ ├── cli/ # Main CLI (scan, db update)
│ ├── baseline/ # Parallel baseline scanner (100+ images)
│ ├── server/ # Optional HTTP server for Web UI
│ └── mcp-server/ # MCP server for AI assistants
├── pkg/
│ ├── scanner/ # Trivy invocation + JSON parsing
│ ├── remediate/ # Enrichment: CISA KEV, OSV.dev, remediation text
│ ├── kev/ # CISA KEV catalog client (24h cache)
│ ├── osv/ # OSV.dev API client (in-process cache)
│ ├── runc/ # Host runc version detection + advisory table
│ ├── report/ # SARIF, Markdown, HTML, CSV generation
│ ├── policy/ # fail-on-severity / fail-on-count evaluation
│ └── config/ # scanner.yaml loader
├── ide/
│ ├── vscode/ # VS Code / Cursor extension
│ └── jetbrains/ # IntelliJ / GoLand plugin
├── ci/ # Pipeline templates (GitHub, Azure, GitLab, Jenkins)
├── docs/ # Full documentation set
├── tests/
│ ├── integration/ # Integration tests (require Trivy + Docker)
│ └── baseline/ # Image lists for baseline runs
├── web/ # Web UI (index.html served by cmd/server; interactive scan + live results)
├── scripts/ # install-deps, update-trivy-db, run-scan, cleanup
├── scanner.yaml.example
└── Dockerfile
# Unit tests — no Trivy or Docker required
go test ./pkg/... -v
# With race detector
go test ./pkg/... -race
# Integration tests — requires Trivy in PATH + Docker for image pull
go test -tags=integration ./tests/integration/... -v
# All-in-one (Windows)
.\scripts\setup-and-test.ps1
# Sanity checklist before a PR (vet + build + unit tests)
# See docs/sanity.md| Package | Tests | Coverage |
|---|---|---|
pkg/osv |
11 — ecosystem mapping, mock HTTP, caching, error cases | EcosystemFor, Query, cache hit/miss |
pkg/runc |
10 — semver comparison, boundary versions, table integrity | AdvisoryFindings, isVulnerable |
pkg/remediate |
6 — KEV enrichment, OSV offline skip, runc finding passthrough | Enrich, whySeverityText |
pkg/report |
5 — SARIF levels, Markdown content, HTML escaping, CSV | All writers |
pkg/scanner |
4 — Trivy JSON parsing, misconfig, file paths | trivyVulnToFinding |
pkg/policy |
4 — fail-on-severity, fail-on-count, parse edge cases | EvaluateFailPolicy, ParseFailOnCount |
pkg/config |
3 — YAML load, missing file, auto-detect | Load, Find |
| Topic | Link |
|---|---|
| Plain-language help, glossary, quick start | docs/HELP.md |
| Install dependencies, first scan | docs/getting-started.md |
| Every flag and option | docs/cli-reference.md |
| Adding to CI/CD pipelines | docs/ci-cd-primer.md |
| Report columns, CISA KEV, OSV, SBOM | docs/vulnerability-reports.md |
| 100+ image baseline runs | docs/baseline.md |
| VS Code, JetBrains, MCP server | docs/ide-and-mcp.md |
| System architecture and data flow | docs/system-design.md |
| Comparison with Trivy, Grype, Snyk, Scout | docs/COMPARISON.md |
| Common errors and fixes | docs/troubleshooting.md |
| Pre-PR sanity checklist | docs/sanity.md |
| Term | Meaning |
|---|---|
| CVE | A unique ID for a known vulnerability, e.g. CVE-2024-1234 |
| Severity | How serious it is: Critical → High → Medium → Low |
| Exploitable | yes = actively exploited in the wild (CISA KEV); no = not listed; unknown = offline |
| SARIF | Standard format consumed by GitHub/Azure Security tabs |
| SBOM | Software Bill of Materials — full package inventory in CycloneDX JSON |
| CISA KEV | US government list of vulnerabilities actively exploited in the wild |
| OSV.dev | Google-maintained open vulnerability database covering 20+ ecosystems |
| runc | The low-level container runtime underneath Docker/Podman. Not inside images; audited by --check-runtime |
| Offline mode | --offline — use cached DB only, no network calls |
| Baseline | Parallel scan of 100+ images; produces timing + findings summary |
MIT or Apache-2.0 — aligned with Trivy.