344 lines
13 KiB
Markdown
344 lines
13 KiB
Markdown
# 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/<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](#vex)).
|
||
2. Findings are aggregated by CVE across all scanned images. Two thresholds
|
||
are checked against the aggregate counts:
|
||
- `NOTIFY_MIN_SEVERITY` (default `HIGH`) – when to send a single Apprise
|
||
alert.
|
||
- `FAIL_ON` (default `HIGH`) – when the script should exit with code `1`
|
||
(useful for CI).
|
||
3. If any CVE reaches `NOTIFY_MIN_SEVERITY`, notifications are sent. When
|
||
`IMPORTANT_CONTAINERS` is set, each named container gets its own complete
|
||
CVE list (split into fixable and unfixable), missing important containers
|
||
are reported, and every other container is summarised in one notification.
|
||
Without `IMPORTANT_CONTAINERS`, a single aggregated notification lists each
|
||
CVE with the affected packages, containers, and images.
|
||
|
||
## 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 logs still name the running container(s) that use each image (e.g.
|
||
`Scanning nginx:1.27 (used by container web)`). Without `IMPORTANT_CONTAINERS`,
|
||
notifications are aggregated by CVE after all images are scanned: a single
|
||
report lists each CVE with the containers and images it affects. With
|
||
`IMPORTANT_CONTAINERS`, each named container gets its own full CVE list and the
|
||
remaining containers are summarised together (see
|
||
[Important containers](#important-containers)). Scan failures still notify per
|
||
failed image with container context. Explicitly passed images omit container
|
||
context unless `IMPORTANT_CONTAINERS` is set, in which case running containers
|
||
are looked up to match them to the scanned images.
|
||
|
||
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:
|
||
|
||
```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. |
|
||
| `IMPORTANT_CONTAINERS` | – | Container names (space/comma separated) that get a full per-container CVE notification. Others are summarised together. |
|
||
| `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 the single aggregated notification lists. Important-container notifications are not capped. |
|
||
| `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](https://trivy.dev/docs/latest/supply-chain/vex/)
|
||
(Vulnerability Exploitability eXchange) documents. Set `TRIVY_VEX` to one or
|
||
more space-separated sources; each is passed to `trivy image --vex`:
|
||
|
||
```dotenv
|
||
# 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:
|
||
|
||
```yaml
|
||
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](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
|
||
```
|
||
|
||
## Important containers
|
||
|
||
Set `IMPORTANT_CONTAINERS` to the containers that are reachable from outside
|
||
(your public entry points). Names are matched against running containers, so
|
||
leave `IMAGES` empty unless you also want to scan specific images explicitly.
|
||
|
||
```dotenv
|
||
IMPORTANT_CONTAINERS=traefik web-frontend
|
||
```
|
||
|
||
When the list is set, notifications are split into three groups:
|
||
|
||
1. **One notification per important container**, titled
|
||
`Vulnerability scan: <container> (<image>)`. It lists every CVE at or above
|
||
`NOTIFY_MIN_SEVERITY`, with a total split into fixable and unfixable.
|
||
2. **One notification for important containers that are missing** – either not
|
||
running, or running an image that was not part of the scan.
|
||
3. **One summary notification for every other container**, listing the image,
|
||
container, total CVE count, and the fixable/unfixable split.
|
||
|
||
A container can appear in only the important list or the summary, never both.
|
||
If an important container shares its image with other containers, the others
|
||
still appear in the summary. Important-container lists are never truncated: if
|
||
a list is longer than the Apprise/Discord message limit it is sent in numbered
|
||
parts. `FAIL_ON` and the exit codes are unchanged.
|
||
|
||
Without `IMPORTANT_CONTAINERS`, the previous single aggregated notification is
|
||
sent.
|
||
|
||
|
||
| 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 '<none>')" \
|
||
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.
|