Files
docker-trivy/README.md
T

304 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 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 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:
```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`. |
| `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](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
```
## 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 '<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.