# docker-vuln-scan Scan Docker images for vulnerabilities with [Trivy](https://trivy.dev) and get an [Apprise](https://github.com/caronc/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](#which-images-are-scanned)) and runs `trivy image` for each of them, writing a JSON report to `/reports/.json`. 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 ```bash 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: ```bash 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 name the running container(s) that use each image, for example `Scanning nginx:1.27 (used by container web)`. 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//docker.sock` and `/run/user//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: ```bash # 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`: ```dotenv DOCKER_SOCK=/run/user/1000/docker.sock ``` Without Compose: ```bash 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/`](komodo/README.md): 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`. | | `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. ### Apprise URLs `APPRISE_URLS` accepts any [Apprise URL](https://github.com/caronc/apprise/wiki). Common examples: ```bash # 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`: ```yaml # apprise.yml urls: - mailto://user:app-password@gmail.com - discord://webhook_id/webhook_token ``` ```bash 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: ```cron 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: ```bash IMAGES="$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -v '')" \ FAIL_ON=NONE \ APPRISE_URLS='mailto://user:app-password@gmail.com' \ docker compose run --rm vulnscan ``` GitLab CI job: ```yaml 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): ```bash 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.