Files
docker-trivy/README.md
T

11 KiB
Raw Blame History

docker-vuln-scan

Scan Docker images for vulnerabilities with Trivy and get an Apprise notification whenever a HIGH or CRITICAL finding is detected.

By default it scans the images used by running containers, so a scheduled run covers whatever is actually deployed. Specific images can still be passed explicitly.

Everything runs from one container image (Trivy + Apprise + Python), so the host only needs Docker. JSON reports are kept on disk for auditing.

How it works

  1. scan.py determines which images to scan (see Which images are scanned) and runs trivy image for each of them, writing a JSON report to /reports/<image>.json. Optional VEX sources are applied through Trivy's --vex flag, so findings marked as not affected are filtered out before counting and notifying (see VEX).
  2. It counts findings per severity and checks them against two thresholds:
    • NOTIFY_MIN_SEVERITY (default HIGH) – when to send an Apprise alert.
    • FAIL_ON (default HIGH) – when the script should exit with code 1 (useful for CI).
  3. On a match it sends one notification per image listing the most relevant CVEs, the affected package, the installed version, and the fixed version.

Quick start

cd docker-vuln-scan
cp .env.example .env        # set APPRISE_URLS; IMAGES is optional
docker compose build
docker compose run --rm vulnscan

Or without Compose:

docker build -t docker-vuln-scan:latest .

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v "$PWD/reports:/reports" \
  -v trivy-cache:/var/lib/trivy \
  -e APPRISE_URLS='discord://webhook_id/webhook_token' \
  docker-vuln-scan:latest

Reports land in ./reports/.

Which images are scanned

The list is built from these sources, in order:

  1. image arguments: scan.py nginx:1.27-alpine redis:7-alpine
  2. the IMAGES environment variable (space or newline separated)
  3. the IMAGES_FILE environment variable (one image per line, # comments)
  4. the images used by running containers (the default when nothing is configured)

For the last one the scanner asks the Docker Engine API on the Unix socket for /containers/json and scans each Image reference, de-duplicated and in listing order. Stopped containers are not included. The scanner's own container image is skipped so a scheduled run does not report on itself; set INCLUDE_SELF=true to include it. Scan log lines and Apprise notifications name the running container(s) that use each image, for example Scanning nginx:1.27 (used by container web) and Vulnerability scan: nginx:1.27 [container: web]. Explicitly passed images omit the container context.

The socket is looked up in this order:

  • DOCKER_SOCK (explicit path)
  • DOCKER_HOST when it is a unix:// URL
  • /var/run/docker.sock and /run/docker.sock
  • $XDG_RUNTIME_DIR/docker.sock
  • /run/user/<uid>/docker.sock and /run/user/<uid>/podman/podman.sock (rootless Docker and Podman), plus any /run/user/*/docker.sock if /run/user is mounted into the container
  • ~/.docker/run/docker.sock (Docker Desktop)

If no socket is reachable, the docker CLI is used as a fallback. When neither works and no explicit images were given, the run logs a warning and exits 0 with nothing to scan.

Rootless Docker

Rootless Docker keeps its socket under the user runtime directory, so /var/run/docker.sock does not exist on the host. The Compose file mounts /run/user (read-only), so the scanner finds the socket automatically. If your socket lives elsewhere, set DOCKER_SOCK to its path; Compose mounts it at the same path inside the container:

# Find the socket path (strip the leading unix://)
docker context inspect --format '{{.Endpoints.docker.Host}}'
# unix:///run/user/1000/docker.sock

export DOCKER_SOCK=/run/user/1000/docker.sock
docker compose run --rm vulnscan

To make it permanent, add the same line to .env:

DOCKER_SOCK=/run/user/1000/docker.sock

Without Compose:

docker run --rm \
  -v /run/user:/run/user:ro \
  -v "$PWD/reports:/reports" \
  docker-vuln-scan:latest

Scanning a registry image works without the Docker socket; the socket is needed for auto-discovery and for images that exist only in the local daemon. When no socket is reachable and IMAGES is empty, the scanner logs a warning explaining exactly that and exits 0.

Komodo

Ready-to-apply Komodo Resource Sync definitions live in komodo/: a Stack that builds and runs the scanner, plus a nightly Procedure schedule. Findings still go through Apprise; Komodo only triggers the job.

Configuration

All configuration is done through environment variables.

Variable Default Description
IMAGES (images in use) Space/newline separated image references. When empty, running containers are scanned.
IMAGES_FILE – File with one image per line (# comments allowed).
DOCKER_SOCK auto Unix socket of the Docker daemon (or Podman). Also the Compose mount source.
DOCKER_HOST – Standard Docker variable; unix:// paths are used for discovery.
INCLUDE_SELF false Also scan the scanner's own image when auto-discovering.
APPRISE_URLS – Space separated Apprise URLs (quote the value).
APPRISE_CONFIG – Alternative: path to an Apprise YAML config.
APPRISE_TITLE_PREFIX Vulnerability scan Prefix of notification titles.
NOTIFY_MIN_SEVERITY HIGH NONE, LOW, MEDIUM, HIGH, CRITICAL. NONE disables alerts.
FAIL_ON HIGH Exit 1 when a finding at/above this severity exists. NONE never fails.
IGNORE_UNFIXED false Skip vulnerabilities without a fixed version.
TRIVY_SEVERITIES (all) Restrict severities Trivy reports, e.g. HIGH,CRITICAL.
TRIVY_VEX – VEX sources passed to Trivy: repo, oci, sbom-ref, or file paths.
NOTIFY_ON_SCAN_ERROR true Also alert when Trivy itself fails (e.g. image pull error).
REPORT_DIR /reports Where JSON reports are written.
TRIVY_CACHE_DIR /var/lib/trivy Trivy DB cache (mount a volume to avoid re-downloading).
TRIVY_TIMEOUT 15m Passed to trivy --timeout.
MAX_FINDINGS_IN_BODY 15 How many findings are listed per notification.
LOG_LEVEL INFO DEBUG, INFO, WARN or ERROR.

Images can be passed as arguments, via IMAGES, or via IMAGES_FILE; the sources are combined and de-duplicated. If all of them are empty, the images in use are scanned.

VEX

Trivy can filter findings with VEX (Vulnerability Exploitability eXchange) documents. Set TRIVY_VEX to one or more space-separated sources; each is passed to trivy image --vex:

# VEX Hub / repository lookup
TRIVY_VEX=repo

# VEX attestations attached to the image in an OCI registry
TRIVY_VEX=oci

# Local OpenVEX or CycloneDX VEX documents (mount them into the container)
TRIVY_VEX=/vex/app.json /vex/base.json

# Several methods; the first source has the highest priority
TRIVY_VEX="/vex/app.json repo"

For local files with Compose, mount the directory read-only:

volumes:
  - ./vex:/vex:ro
environment:
  TRIVY_VEX: /vex/app.json

Findings suppressed by VEX are removed from the Trivy JSON report and are therefore not counted, notified, or considered by FAIL_ON. The report still reflects the filtered result.

Apprise URLs

APPRISE_URLS accepts any Apprise URL. Common examples:

# Email (use an app password, not your account password)
APPRISE_URLS='mailto://user:app-password@gmail.com'

# Discord webhook
APPRISE_URLS='discord://webhook_id/webhook_token'

# Slack
APPRISE_URLS='slack://token_a/token_b/token_c/#security'

# Telegram
APPRISE_URLS='tgram://bot_token/chat_id'

# Several targets at once (quote the whole value)
APPRISE_URLS='mailto://user:app-password@gmail.com tgram://bot_token/chat_id'

For many targets or reusable credentials, mount an Apprise config file and set APPRISE_CONFIG:

# apprise.yml
urls:
  - mailto://user:app-password@gmail.com
  - discord://webhook_id/webhook_token
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v "$PWD/apprise.yml:/config/apprise.yml:ro" \
  -e APPRISE_CONFIG=/config/apprise.yml \
  docker-vuln-scan:latest nginx:1.27-alpine

Exit codes

Code Meaning
0 All images scanned, no finding reached FAIL_ON (or nothing to scan).
1 At least one image reached FAIL_ON.
2 A scan or notification failed (bad image, registry error, Apprise error).

Scheduling and CI

Nightly scan of the images in use:

0 3 * * * cd /opt/docker-vuln-scan && docker compose run --rm vulnscan >> scan.log 2>&1

Scan every local image instead, but do not fail the job:

IMAGES="$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>')" \
FAIL_ON=NONE \
APPRISE_URLS='mailto://user:app-password@gmail.com' \
docker compose run --rm vulnscan

GitLab CI job:

container_scan:
  image: docker:27
  services: [docker:27-dind]
  script:
    - docker build -t docker-vuln-scan:latest .
    - >
      docker run --rm
      -e APPRISE_URLS="$APPRISE_URLS"
      -e FAIL_ON=HIGH
      -v /var/run/docker.sock:/var/run/docker.sock:ro
      docker-vuln-scan:latest "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"

Development

scan.py uses only the Python standard library; tests need neither Docker nor Trivy nor Apprise (a fake Docker socket and fake binaries are used):

python3 -m unittest discover -s tests -v

tests/test_scan.py covers socket discovery (including rootless paths and self-skip), report parsing, thresholds, notification bodies, and full runs of main() with a fake trivy and apprise.

Notes and limitations

  • The container mounts the Docker socket read-only, but the process still runs as root inside the container. That is equivalent to root on the host – run it only on trusted machines / CI runners.
  • Rootless Docker uses a different socket path. The Compose file mounts /run/user so it is found automatically; for sockets elsewhere, set DOCKER_SOCK as shown above, otherwise the scanner sees no containers.
  • Private registries: Trivy reads the standard variables (TRIVY_USERNAME, TRIVY_PASSWORD, TRIVY_INSECURE, DOCKER_CONFIG). Pass them with -e.
  • The first scan downloads the Trivy vulnerability database; keep /var/lib/trivy on a volume and it is reused.
  • aquasec/trivy:latest is used as the source of the Trivy binary. For reproducible builds pin it: docker build --build-arg TRIVY_IMAGE=aquasec/trivy:0.XX.Y .
  • Notifications are informational: a failed Apprise delivery makes the run exit with 2 so monitoring can catch a broken alerting path.