Initial commit
This commit is contained in:
commit
70dbff10b9
14 files changed
+1907
No files matched your search
@@ -0,0 +1,263 @@
|
||||
# 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`.
|
||||
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.
|
||||
|
||||
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`. |
|
||||
| `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 '<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.
|
||||
Reference in new issue
Block a user