Initial commit
This commit is contained in:
commit
70dbff10b9
14 files changed
+1907
No files matched your search
@@ -0,0 +1,8 @@
|
||||
reports
|
||||
.git
|
||||
.env
|
||||
tests
|
||||
testphp
|
||||
komodo
|
||||
__pycache__
|
||||
*.pyc
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copy to .env and adjust. Do not commit real credentials.
|
||||
|
||||
# Images to scan, space separated (quote the value).
|
||||
# Leave empty/unset to scan the images used by running containers.
|
||||
# IMAGES=nginx:1.27-alpine redis:7-alpine
|
||||
|
||||
# Optional: file with one image per line ('#' comments allowed).
|
||||
# IMAGES_FILE=images.txt
|
||||
|
||||
# Host path of the Docker socket to mount. Needed for auto-discovery and for
|
||||
# scanning images that exist only in the local daemon. Rootless Docker uses
|
||||
# /run/user/<uid>/docker.sock.
|
||||
# DOCKER_SOCK=/var/run/docker.sock
|
||||
|
||||
# Also scan the scanner's own image when auto-discovering (default: false).
|
||||
# INCLUDE_SELF=false
|
||||
|
||||
# One or more Apprise URLs, space separated. Quote the value.
|
||||
# Examples:
|
||||
# mailto://user:app-password@gmail.com
|
||||
# discord://webhook_id/webhook_token
|
||||
# slack://token_a/token_b/token_c
|
||||
# tgram://bot_token/chat_id
|
||||
# gotify://hostname/token
|
||||
APPRISE_URLS=
|
||||
|
||||
# Optional: Apprise YAML config (mount it into the container).
|
||||
# APPRISE_CONFIG=/config/apprise.yml
|
||||
|
||||
APPRISE_TITLE_PREFIX=Vulnerability scan
|
||||
|
||||
# Lowest severity that sends an alert (NONE disables notifications).
|
||||
NOTIFY_MIN_SEVERITY=HIGH
|
||||
|
||||
# Lowest severity that makes the scan exit with code 1 (NONE never fails).
|
||||
FAIL_ON=HIGH
|
||||
|
||||
# Skip vulnerabilities that have no fixed version available.
|
||||
IGNORE_UNFIXED=false
|
||||
|
||||
# Optional: restrict which severities Trivy reports (empty = all).
|
||||
TRIVY_SEVERITIES=
|
||||
|
||||
# How many findings to list in the notification body.
|
||||
MAX_FINDINGS_IN_BODY=15
|
||||
|
||||
# Send an Apprise alert when Trivy itself fails (default: true).
|
||||
NOTIFY_ON_SCAN_ERROR=true
|
||||
|
||||
# Timeout for each image scan.
|
||||
TRIVY_TIMEOUT=15m
|
||||
|
||||
# Log verbosity: DEBUG, INFO, WARN or ERROR.
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Optional: credentials for private registries.
|
||||
# TRIVY_USERNAME=
|
||||
# TRIVY_PASSWORD=
|
||||
# TRIVY_INSECURE=false
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
reports/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Trivy is a single static binary, so it can be copied into a slim base image.
|
||||
ARG TRIVY_IMAGE=aquasec/trivy:latest
|
||||
FROM ${TRIVY_IMAGE} AS trivy
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL org.opencontainers.image.title="docker-vuln-scan" \
|
||||
org.opencontainers.image.description="Trivy image vulnerability scanner with Apprise alerts" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir "apprise>=1.8,<2"
|
||||
|
||||
COPY --from=trivy /usr/local/bin/trivy /usr/local/bin/trivy
|
||||
COPY scan.py /usr/local/bin/scan.py
|
||||
RUN chmod 0755 /usr/local/bin/scan.py
|
||||
|
||||
ENV REPORT_DIR=/reports \
|
||||
TRIVY_CACHE_DIR=/var/lib/trivy \
|
||||
TRIVY_NO_PROGRESS=true \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
VOLUME ["/reports"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/scan.py"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
vulnscan:
|
||||
build: .
|
||||
image: docker-vuln-scan:latest
|
||||
environment:
|
||||
# Leave IMAGES empty to scan the images used by running containers.
|
||||
IMAGES: ${IMAGES:-}
|
||||
APPRISE_URLS: ${APPRISE_URLS:-}
|
||||
APPRISE_CONFIG: ${APPRISE_CONFIG:-}
|
||||
APPRISE_TITLE_PREFIX: ${APPRISE_TITLE_PREFIX:-Vulnerability scan}
|
||||
NOTIFY_MIN_SEVERITY: ${NOTIFY_MIN_SEVERITY:-HIGH}
|
||||
FAIL_ON: ${FAIL_ON:-HIGH}
|
||||
IGNORE_UNFIXED: ${IGNORE_UNFIXED:-false}
|
||||
TRIVY_SEVERITIES: ${TRIVY_SEVERITIES:-}
|
||||
MAX_FINDINGS_IN_BODY: ${MAX_FINDINGS_IN_BODY:-15}
|
||||
NOTIFY_ON_SCAN_ERROR: ${NOTIFY_ON_SCAN_ERROR:-true}
|
||||
TRIVY_TIMEOUT: ${TRIVY_TIMEOUT:-15m}
|
||||
# Optional: also scan the scanner's own image (default: false).
|
||||
INCLUDE_SELF: ${INCLUDE_SELF:-false}
|
||||
# Optional: scan a list of images instead of IMAGES (mount the file).
|
||||
IMAGES_FILE: ${IMAGES_FILE:-}
|
||||
# Path of the Docker socket *inside* the container (same as on the host,
|
||||
# see the volume below). Empty means "auto-detect".
|
||||
DOCKER_SOCK: ${DOCKER_SOCK:-}
|
||||
# Optional: private registry credentials / TLS settings for Trivy.
|
||||
TRIVY_USERNAME: ${TRIVY_USERNAME:-}
|
||||
TRIVY_PASSWORD: ${TRIVY_PASSWORD:-}
|
||||
TRIVY_INSECURE: ${TRIVY_INSECURE:-}
|
||||
volumes:
|
||||
# Used to discover the images in use and to read images that exist only
|
||||
# in the local Docker daemon. The socket is mounted at the same path it
|
||||
# has on the host, so DOCKER_SOCK works unchanged inside the container.
|
||||
# Rootless Docker: DOCKER_SOCK=/run/user/<uid>/docker.sock
|
||||
- ${DOCKER_SOCK:-/var/run/docker.sock}:${DOCKER_SOCK:-/var/run/docker.sock}:ro
|
||||
# Rootless Docker/Podman keep the socket under /run/user/<uid>/. Mounting
|
||||
# that directory lets the scanner find it without extra configuration.
|
||||
# Remove this line if you do not want the host's runtime dirs exposed.
|
||||
# - /run/user:/run/user:ro
|
||||
- ./reports:/reports
|
||||
# Keep the Trivy DB between runs (much faster repeat scans).
|
||||
- trivy-cache:/var/lib/trivy
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
trivy-cache:
|
||||
@@ -0,0 +1,187 @@
|
||||
# Running docker-vuln-scan in Komodo
|
||||
|
||||
Two ready-to-apply Resource Sync definitions are provided. Use **one** of them,
|
||||
not both (they declare the same resource names):
|
||||
|
||||
| File | Use when |
|
||||
| --- | --- |
|
||||
| [`vuln-scan.toml`](vuln-scan.toml) | Komodo should clone this project from git and build the scanner itself. |
|
||||
| [`vuln-scan-files-on-host.toml`](vuln-scan-files-on-host.toml) | You copy this project to the Komodo server and want Komodo to use those files. |
|
||||
|
||||
Both create:
|
||||
|
||||
- a **Stack** named `vuln-scan` on the selected Komodo Server, and
|
||||
- a **Procedure** named `vuln-scan` that redeploys the stack every night,
|
||||
which rebuilds the scanner image and runs one scan.
|
||||
|
||||
Alerts for HIGH/CRITICAL findings are sent **by the scanner directly through
|
||||
Apprise**. You do not need a Komodo Alerter for that part.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Komodo 2.x with a **Server** resource (Periphery) on the Docker host whose
|
||||
images you want to scan.
|
||||
- The project available to Komodo (git repo, or copied to the host).
|
||||
- A reachable Apprise target (Discord, Slack, email, Telegram, ...).
|
||||
|
||||
### 1. Create the APPRISE_URLS variable
|
||||
|
||||
In Komodo: **Variables → New Variable**
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `APPRISE_URLS` |
|
||||
| Value | e.g. `discord://webhook_id/webhook_token` |
|
||||
| Secret | **on** |
|
||||
|
||||
For several targets, separate them with a space:
|
||||
|
||||
```text
|
||||
mailto://user:app-password@gmail.com tgram://bot_token/chat_id
|
||||
```
|
||||
|
||||
The stack reads this variable through an `[[APPRISE_URLS]]` interpolation in its
|
||||
`environment`, so do this before applying the sync.
|
||||
|
||||
> If you prefer to keep everything in the TOML file, add a `[[variable]]` block
|
||||
> with `name = "APPRISE_URLS"`, `value = "..."` and `is_secret = true`. The
|
||||
> value will then live in your sync git repo, so only do this in a private repo.
|
||||
|
||||
### 2. Apply the resources
|
||||
|
||||
#### Option A - git repo mode (recommended)
|
||||
|
||||
1. Push this project to a git repository Komodo can reach.
|
||||
2. Edit `komodo/vuln-scan.toml`:
|
||||
- `server = "server-prod"` → your Komodo Server name.
|
||||
- `repo = "your-org/docker-vuln-scan"` → your repository.
|
||||
- `IMAGES = ...` → the images you want to scan.
|
||||
3. In Komodo: **Syncs → New Resource Sync → Git Repo**, then set:
|
||||
- the repository and branch,
|
||||
- `resource_path = ["komodo/vuln-scan.toml"]`,
|
||||
- a git account if the repo is private.
|
||||
4. Refresh the sync and review/apply the pending changes.
|
||||
5. Open **Procedures → vuln-scan → Run** to start the first scan now.
|
||||
|
||||
#### Option B - files on the host
|
||||
|
||||
Komodo resolves files-on-host paths under the Periphery root directory
|
||||
(default `/etc/komodo`, mounted at the same path in the standard Komodo compose
|
||||
setup). The stack path is `/etc/komodo/stacks/<stack-name>`.
|
||||
|
||||
1. Copy the project to the Docker host:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/komodo/stacks/vuln-scan
|
||||
sudo cp -r docker-vuln-scan/. /etc/komodo/stacks/vuln-scan/
|
||||
```
|
||||
|
||||
(If you changed `PERIPHERY_ROOT_DIRECTORY`, use that path instead.)
|
||||
2. Edit `komodo/vuln-scan-files-on-host.toml` (`server`, `IMAGES`).
|
||||
3. Apply the file. It still needs to be readable by **Core**, which does not
|
||||
mount `/etc/komodo` by default. Either keep the TOML in a git-based Resource
|
||||
Sync, or create the Stack and Procedure in the UI using the values from the
|
||||
file.
|
||||
4. Run **Procedures → vuln-scan → Run**.
|
||||
|
||||
## What a run does
|
||||
|
||||
```
|
||||
Procedure (cron) -> DeployStack -> docker compose build -> docker compose up -d
|
||||
|
|
||||
v
|
||||
scanner container runs and exits
|
||||
|
|
||||
+---------------+---------------+
|
||||
v v
|
||||
Apprise alert (HIGH/CRITICAL) JSON report on disk
|
||||
```
|
||||
|
||||
- `run_build = true` rebuilds the scanner image on the Komodo server. Layers
|
||||
are cached, so only changes to `scan.py`/`Dockerfile` rebuild.
|
||||
- `auto_pull = false` is required: the image is built locally and must not be
|
||||
pulled from a registry.
|
||||
- `ignore_services = ["vulnscan"]` tells Komodo that the exited one-shot
|
||||
container is expected in status checks. Because this stack contains only that
|
||||
one service, Komodo may still show the stack as stopped/down between runs.
|
||||
That is normal for a scheduled one-shot job.
|
||||
- `send_alerts = false` avoids daily status noise. Findings are reported by
|
||||
Apprise instead.
|
||||
|
||||
`DeployStack` returns once `docker compose up -d` has started the container; it
|
||||
does not wait for the scan to finish. The Apprise notification is the result
|
||||
signal. The container exit code (`FAIL_ON`) is visible in the stack logs.
|
||||
|
||||
## Where the results are
|
||||
|
||||
On the scan server:
|
||||
|
||||
```
|
||||
/etc/komodo/stacks/vuln-scan/reports/<image>.json
|
||||
```
|
||||
|
||||
If your Periphery runs with a different root or the stack uses a `run_directory`,
|
||||
adjust the path accordingly. The Compose volume is `./reports:/reports` relative
|
||||
to the stack directory.
|
||||
|
||||
Logs:
|
||||
|
||||
- Komodo UI: **Stacks → vuln-scan → Logs**
|
||||
- Shell: `docker compose -p vuln-scan logs vulnscan`
|
||||
|
||||
## Customizing
|
||||
|
||||
Edit the stack's `environment` (in the TOML or in Komodo) and re-apply the sync:
|
||||
|
||||
| Setting | Meaning |
|
||||
| --- | --- |
|
||||
| `IMAGES` | Space separated images to scan. Leave empty to scan the images used by running containers. |
|
||||
| `DOCKER_SOCK` | Host path of the Docker socket (e.g. `/run/user/1000/docker.sock` for rootless Docker). |
|
||||
| `INCLUDE_SELF` | `true` to also scan the scanner's own image when auto-discovering. |
|
||||
| `APPRISE_URLS` | Notification target(s); usually kept in the secret variable. |
|
||||
| `NOTIFY_MIN_SEVERITY` | Lowest severity that sends an alert (default `HIGH`). |
|
||||
| `FAIL_ON` | Lowest severity that makes the container exit `1` (default `HIGH`). |
|
||||
| `IGNORE_UNFIXED` | `true` to ignore findings without a fix. |
|
||||
| `MAX_FINDINGS_IN_BODY` | Findings listed per notification. |
|
||||
|
||||
Schedule fields are on the Procedure:
|
||||
|
||||
```toml
|
||||
[procedure.config]
|
||||
schedule_format = "Cron"
|
||||
schedule = "0 0 3 * * ?" # seconds minute hour day month weekday
|
||||
schedule_timezone = "UTC"
|
||||
```
|
||||
|
||||
To scan every image on the server instead of just the images in use, write the
|
||||
list on the host and mount it:
|
||||
|
||||
```bash
|
||||
docker image ls --format '{{.Repository}}:{{.Tag}}' \
|
||||
| grep -v '<none>' > /etc/komodo/stacks/vuln-scan/images.txt
|
||||
```
|
||||
|
||||
Then add `- ./images.txt:/images.txt:ro` to the `vulnscan` service volumes and
|
||||
set `IMAGES_FILE = /images.txt` in the stack environment.
|
||||
|
||||
Private registries: add `TRIVY_USERNAME`, `TRIVY_PASSWORD` and optionally
|
||||
`TRIVY_INSECURE` to the stack environment. The Compose file already passes
|
||||
them through to the container.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| --- | --- |
|
||||
| Unresolved `[[APPRISE_URLS]]` / notification errors | Create the `APPRISE_URLS` variable (step 1) and re-apply. |
|
||||
| `docker-vuln-scan:latest` pull error | Keep `auto_pull = false`; the stack builds the image locally. |
|
||||
| Stack shows stopped/down between scans | Expected: the scanner is a one-shot container. Status alerts are disabled. |
|
||||
| No findings at all | Check the image reference is correct; Trivy pulls registry images, and the Docker socket covers local-only images. |
|
||||
| "No images configured ... nothing to scan" | The scanner cannot reach the Docker socket. With rootless Docker set `DOCKER_SOCK=/run/user/<uid>/docker.sock`, or set `IMAGES` explicitly. |
|
||||
| Procedure success but no alert | Success only means the container started. Check stack logs and the Apprise target. |
|
||||
| Need Komodo to fail on findings | Not covered by `DeployStack`; use `FAIL_ON` in CI or trigger the scanner from a Komodo Action that runs `docker run --rm` and inspects the exit code. |
|
||||
|
||||
## Security note
|
||||
|
||||
The scanner mounts `/var/run/docker.sock` and runs as root inside the container.
|
||||
That is effectively root on the scan host, so only deploy it to servers and
|
||||
Komodo users you trust.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Komodo Resource Sync - docker-vuln-scan (files on host mode)
|
||||
#
|
||||
# Use this variant if you do not want Komodo to clone the project from git.
|
||||
# Copy the project to the Komodo host at:
|
||||
#
|
||||
# /etc/komodo/stacks/vuln-scan/
|
||||
#
|
||||
# (that is the default Periphery root directory, mounted at the same path in the
|
||||
# standard Komodo compose setup; adjust if you changed PERIPHERY_ROOT_DIRECTORY)
|
||||
#
|
||||
# Then apply this file with a Komodo Resource Sync. See komodo/README.md.
|
||||
#
|
||||
# Before applying:
|
||||
# 1. Replace `server` with the name of the Komodo Server to scan on.
|
||||
# 2. Create a Komodo Variable named APPRISE_URLS (mark it Secret).
|
||||
# 3. Adjust IMAGES below or replace it with [[SCAN_IMAGES]].
|
||||
|
||||
[[stack]]
|
||||
name = "vuln-scan"
|
||||
description = "Trivy image vulnerability scan with Apprise notifications"
|
||||
tags = ["security"]
|
||||
deploy = false
|
||||
|
||||
[stack.config]
|
||||
server = "server-prod"
|
||||
files_on_host = true
|
||||
# Relative to /etc/komodo/stacks/vuln-scan/. Empty means the project root.
|
||||
run_directory = ""
|
||||
file_paths = ["docker-compose.yml"]
|
||||
run_build = true
|
||||
auto_pull = false
|
||||
ignore_services = ["vulnscan"]
|
||||
send_alerts = false
|
||||
environment = """
|
||||
IMAGES = nginx:1.27-alpine redis:7-alpine
|
||||
APPRISE_URLS = [[APPRISE_URLS]]
|
||||
APPRISE_TITLE_PREFIX = Vulnerability scan
|
||||
NOTIFY_MIN_SEVERITY = HIGH
|
||||
FAIL_ON = HIGH
|
||||
IGNORE_UNFIXED = false
|
||||
MAX_FINDINGS_IN_BODY = 15
|
||||
"""
|
||||
|
||||
[[procedure]]
|
||||
name = "vuln-scan"
|
||||
description = "Rebuild and run the image vulnerability scan"
|
||||
tags = ["security"]
|
||||
|
||||
[procedure.config]
|
||||
schedule_format = "Cron"
|
||||
schedule = "0 0 3 * * ?"
|
||||
schedule_timezone = "UTC"
|
||||
schedule_enabled = true
|
||||
schedule_alert = false
|
||||
failure_alert = true
|
||||
|
||||
[[procedure.config.stage]]
|
||||
name = "Run scanner"
|
||||
executions = [
|
||||
{ execution.type = "DeployStack", execution.params.stack = "vuln-scan" },
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Komodo Resource Sync - docker-vuln-scan (git repo mode)
|
||||
#
|
||||
# Import this file with a Komodo Resource Sync (Syncs -> New Resource Sync ->
|
||||
# Git Repo) pointing at this repo. See komodo/README.md for the full walkthrough.
|
||||
#
|
||||
# Before applying:
|
||||
# 1. Replace `server` with the name of the Komodo Server to scan on.
|
||||
# 2. Replace `repo` with the git repo that contains this project.
|
||||
# 3. Create a Komodo Variable named APPRISE_URLS (mark it Secret).
|
||||
# 4. Adjust IMAGES below or replace it with [[SCAN_IMAGES]] and create that
|
||||
# variable instead.
|
||||
|
||||
[[stack]]
|
||||
name = "vuln-scan"
|
||||
description = "Trivy image vulnerability scan with Apprise notifications"
|
||||
tags = ["security"]
|
||||
# The scan is started by the Procedure below, not during sync.
|
||||
deploy = false
|
||||
|
||||
[stack.config]
|
||||
server = "server-prod"
|
||||
repo = "your-org/docker-vuln-scan"
|
||||
branch = "main"
|
||||
file_paths = ["docker-compose.yml"]
|
||||
run_directory = ""
|
||||
# Build the scanner image on the Komodo server (no registry required).
|
||||
run_build = true
|
||||
# Do NOT try to pull docker-vuln-scan:latest from a registry.
|
||||
auto_pull = false
|
||||
# The scanner is a one-shot container, so an exited service is expected.
|
||||
ignore_services = ["vulnscan"]
|
||||
# Apprise already reports findings; avoid "stack down" noise in Komodo.
|
||||
send_alerts = false
|
||||
environment = """
|
||||
IMAGES = nginx:1.27-alpine redis:7-alpine
|
||||
APPRISE_URLS = [[APPRISE_URLS]]
|
||||
APPRISE_TITLE_PREFIX = Vulnerability scan
|
||||
NOTIFY_MIN_SEVERITY = HIGH
|
||||
FAIL_ON = HIGH
|
||||
IGNORE_UNFIXED = false
|
||||
MAX_FINDINGS_IN_BODY = 15
|
||||
"""
|
||||
|
||||
[[procedure]]
|
||||
name = "vuln-scan"
|
||||
description = "Rebuild and run the image vulnerability scan"
|
||||
tags = ["security"]
|
||||
|
||||
[procedure.config]
|
||||
schedule_format = "Cron"
|
||||
# Every day at 03:00:00. Komodo cron has 6 fields (seconds first).
|
||||
schedule = "0 0 3 * * ?"
|
||||
schedule_timezone = "UTC"
|
||||
schedule_enabled = true
|
||||
# Findings are notified directly through Apprise.
|
||||
schedule_alert = false
|
||||
# Komodo alert if the build/deploy itself fails (needs a Komodo Alerter).
|
||||
failure_alert = true
|
||||
|
||||
[[procedure.config.stage]]
|
||||
name = "Run scanner"
|
||||
executions = [
|
||||
# DeployStack (not DeployStackIfChanged) forces the one-shot scanner to run.
|
||||
{ execution.type = "DeployStack", execution.params.stack = "vuln-scan" },
|
||||
]
|
||||
@@ -0,0 +1,725 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan Docker images with Trivy and alert through Apprise.
|
||||
|
||||
Images to scan come from (in order of precedence):
|
||||
|
||||
1. command line arguments,
|
||||
2. the IMAGES environment variable,
|
||||
3. the IMAGES_FILE environment variable,
|
||||
4. the images used by running containers (via the Docker Engine API).
|
||||
|
||||
Exit codes: 0 = clean, 1 = vulnerability policy failed, 2 = scan error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROG = os.path.basename(sys.argv[0])
|
||||
|
||||
SEVERITY_RANK = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
||||
REPORTED_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW")
|
||||
|
||||
LOG_RANK = {"DEBUG": 10, "INFO": 20, "WARN": 30, "ERROR": 40}
|
||||
MIN_LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").strip().upper()
|
||||
if MIN_LOG_LEVEL not in LOG_RANK:
|
||||
MIN_LOG_LEVEL = "INFO"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging and small helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def log(level: str, message: str) -> None:
|
||||
if LOG_RANK.get(level, 20) < LOG_RANK[MIN_LOG_LEVEL]:
|
||||
return
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(f"{timestamp} [{level:<5}] {message}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def die(message: str) -> None:
|
||||
log("ERROR", message)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def severity_rank(severity: str) -> int:
|
||||
try:
|
||||
return SEVERITY_RANK[severity.upper()]
|
||||
except KeyError:
|
||||
raise ValueError(f"invalid severity {severity!r}") from None
|
||||
|
||||
|
||||
def env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
value = raw.strip().lower()
|
||||
if value in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if value in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ValueError(f"{name} must be a boolean, got {raw!r}")
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
try:
|
||||
return int(raw.strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} must be an integer, got {raw!r}") from None
|
||||
|
||||
|
||||
def split_words(raw: str) -> list[str]:
|
||||
"""Split on whitespace/newlines, dropping empty entries."""
|
||||
return [word for word in raw.replace("\n", " ").split() if word]
|
||||
|
||||
|
||||
def dedupe(items: list[str]) -> list[str]:
|
||||
"""Drop duplicates while keeping the original order."""
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
unique.append(item)
|
||||
return unique
|
||||
|
||||
|
||||
def report_slug(image: str) -> str:
|
||||
return "".join(c if c.isalnum() or c in "_.-" else "_" for c in image)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker Engine API discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DockerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
"""Minimal HTTP client that talks over a Unix domain socket."""
|
||||
|
||||
def __init__(self, socket_path: str, timeout: float = 5.0) -> None:
|
||||
super().__init__("localhost", timeout=timeout)
|
||||
self._socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
try:
|
||||
sock.connect(self._socket_path)
|
||||
except OSError:
|
||||
sock.close()
|
||||
raise
|
||||
self.sock = sock
|
||||
|
||||
|
||||
def docker_api_get(socket_path: str, path: str) -> object:
|
||||
connection = _UnixHTTPConnection(socket_path)
|
||||
try:
|
||||
connection.request("GET", path, headers={"Host": "docker"})
|
||||
response = connection.getresponse()
|
||||
body = response.read()
|
||||
if response.status >= 400:
|
||||
raise DockerError(f"GET {path} returned HTTP {response.status}: {body[:200]!r}")
|
||||
if not body:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise DockerError(f"GET {path} returned invalid JSON: {exc}") from None
|
||||
except OSError as exc:
|
||||
raise DockerError(f"cannot talk to Docker at {socket_path}: {exc}") from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def candidate_sockets() -> list[str]:
|
||||
"""Unix sockets that may host the Docker (or Podman) API, in priority order."""
|
||||
candidates: list[str] = []
|
||||
|
||||
configured = os.environ.get("DOCKER_SOCK", "").strip()
|
||||
if configured:
|
||||
candidates.append(configured)
|
||||
|
||||
docker_host = os.environ.get("DOCKER_HOST", "").strip()
|
||||
if docker_host.startswith("unix://"):
|
||||
candidates.append(docker_host[len("unix://") :])
|
||||
|
||||
candidates += ["/var/run/docker.sock", "/run/docker.sock"]
|
||||
|
||||
user_id = os.getuid()
|
||||
xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip()
|
||||
if xdg_runtime_dir:
|
||||
candidates.append(os.path.join(xdg_runtime_dir, "docker.sock"))
|
||||
|
||||
# Rootless Docker / Podman put the socket under the user runtime directory.
|
||||
candidates += [
|
||||
f"/run/user/{user_id}/docker.sock",
|
||||
f"/run/user/{user_id}/podman/podman.sock",
|
||||
"/run/podman/podman.sock",
|
||||
os.path.expanduser("~/.docker/run/docker.sock"),
|
||||
]
|
||||
|
||||
# In a container the runtime directory belongs to another uid, so also
|
||||
# consider sockets of every user (only useful if /run/user is mounted).
|
||||
candidates += sorted(glob.glob("/run/user/*/docker.sock"))
|
||||
candidates += sorted(glob.glob("/run/user/*/podman/podman.sock"))
|
||||
|
||||
return dedupe([c for c in candidates if c])
|
||||
|
||||
|
||||
def find_docker_socket() -> str | None:
|
||||
"""Return the first candidate path that is a Unix socket."""
|
||||
missing: list[str] = []
|
||||
for path in candidate_sockets():
|
||||
try:
|
||||
mode = os.stat(path).st_mode
|
||||
except OSError:
|
||||
missing.append(path)
|
||||
continue
|
||||
if stat.S_ISSOCK(mode):
|
||||
return path
|
||||
if stat.S_ISDIR(mode):
|
||||
log("DEBUG", f"{path} is a directory, not a Unix socket")
|
||||
if path == "/var/run/docker.sock":
|
||||
log(
|
||||
"WARN",
|
||||
f"{path} is a directory, not a Unix socket - the host socket was not "
|
||||
"mounted. Set DOCKER_SOCK to the real socket path "
|
||||
"(e.g. /run/user/<uid>/docker.sock for rootless Docker).",
|
||||
)
|
||||
else:
|
||||
log("DEBUG", f"{path} exists but is not a Unix socket")
|
||||
if missing:
|
||||
log("DEBUG", "Docker socket not found; checked: " + ", ".join(missing))
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_image_id(reference: str) -> bool:
|
||||
if reference.startswith("sha256:"):
|
||||
return True
|
||||
return 12 <= len(reference) <= 64 and all(c in "0123456789abcdef" for c in reference)
|
||||
|
||||
|
||||
def _container_image_name(socket_path: str, container: dict) -> str:
|
||||
"""Best available image reference for a container summary entry."""
|
||||
name = (container.get("Image") or "").strip()
|
||||
if name and not _looks_like_image_id(name):
|
||||
return name
|
||||
|
||||
container_id = container.get("Id")
|
||||
if container_id:
|
||||
try:
|
||||
details = docker_api_get(socket_path, f"/containers/{container_id}/json")
|
||||
except DockerError as exc:
|
||||
log("DEBUG", f"cannot inspect container {container_id[:12]}: {exc}")
|
||||
else:
|
||||
config_name = str((details.get("Config") or {}).get("Image") or "").strip()
|
||||
if config_name:
|
||||
return config_name
|
||||
|
||||
return name or (container.get("ImageID") or "").strip()
|
||||
|
||||
|
||||
def images_in_use(socket_path: str, include_self: bool = False) -> list[str]:
|
||||
"""Images referenced by running containers, in Docker's listing order.
|
||||
|
||||
The scanner's own container is skipped unless ``include_self`` is true, so
|
||||
a scheduled scan does not report on the scanner image itself.
|
||||
"""
|
||||
containers = docker_api_get(socket_path, "/containers/json")
|
||||
if not isinstance(containers, list):
|
||||
raise DockerError("/containers/json did not return a list")
|
||||
|
||||
hostname = os.environ.get("HOSTNAME", "").strip()
|
||||
|
||||
images: list[str] = []
|
||||
for container in containers:
|
||||
if not isinstance(container, dict):
|
||||
continue
|
||||
container_id = str(container.get("Id") or "")
|
||||
if not include_self and hostname and container_id.startswith(hostname):
|
||||
log("DEBUG", f"skipping scanner's own container {container_id[:12]}")
|
||||
continue
|
||||
image = _container_image_name(socket_path, container)
|
||||
if image:
|
||||
images.append(image)
|
||||
return dedupe(images)
|
||||
|
||||
|
||||
def images_from_docker_cli() -> list[str]:
|
||||
"""Fallback for environments where the socket is not reachable directly."""
|
||||
if not shutil.which("docker"):
|
||||
log("DEBUG", "docker CLI not found")
|
||||
return []
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Image}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except OSError as exc:
|
||||
log("DEBUG", f"docker CLI failed: {exc}")
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
log("DEBUG", f"docker ps failed: {completed.stderr.strip()}")
|
||||
return []
|
||||
return dedupe(split_words(completed.stdout.replace("\n", " ")))
|
||||
|
||||
|
||||
def discover_images(include_self: bool = False) -> tuple[list[str], str | None]:
|
||||
"""Find images currently in use. Returns (images, source description)."""
|
||||
socket_path = find_docker_socket()
|
||||
if socket_path is None:
|
||||
log("DEBUG", "no Docker socket found")
|
||||
else:
|
||||
try:
|
||||
images = images_in_use(socket_path, include_self=include_self)
|
||||
except DockerError as exc:
|
||||
log("WARN", f"could not query Docker at {socket_path}: {exc}")
|
||||
else:
|
||||
if images:
|
||||
return images, f"running containers (via {socket_path})"
|
||||
|
||||
images = images_from_docker_cli()
|
||||
if images:
|
||||
return images, "running containers (via docker CLI)"
|
||||
|
||||
return [], None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
explicit_images: list[str]
|
||||
apprise_urls: str = ""
|
||||
apprise_config: str = ""
|
||||
apprise_title_prefix: str = "Vulnerability scan"
|
||||
notify_min_severity: str = "HIGH"
|
||||
fail_on: str = "HIGH"
|
||||
ignore_unfixed: bool = False
|
||||
trivy_severities: str = ""
|
||||
notify_on_scan_error: bool = True
|
||||
report_dir: str = "/reports"
|
||||
trivy_cache_dir: str = "/var/lib/trivy"
|
||||
trivy_timeout: str = "15m"
|
||||
max_findings: int = 15
|
||||
include_self: bool = False
|
||||
docker_socket: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, argv: list[str]) -> "Settings":
|
||||
images = list(argv)
|
||||
|
||||
images_env = os.environ.get("IMAGES", "")
|
||||
if images_env.strip():
|
||||
images += split_words(images_env)
|
||||
|
||||
images_file = os.environ.get("IMAGES_FILE", "").strip()
|
||||
if images_file:
|
||||
images += read_images_file(images_file)
|
||||
|
||||
settings = cls(
|
||||
explicit_images=dedupe(images),
|
||||
apprise_urls=os.environ.get("APPRISE_URLS", "").strip(),
|
||||
apprise_config=os.environ.get("APPRISE_CONFIG", "").strip(),
|
||||
apprise_title_prefix=os.environ.get("APPRISE_TITLE_PREFIX", "Vulnerability scan"),
|
||||
notify_min_severity=os.environ.get("NOTIFY_MIN_SEVERITY", "HIGH"),
|
||||
fail_on=os.environ.get("FAIL_ON", "HIGH"),
|
||||
ignore_unfixed=env_bool("IGNORE_UNFIXED", False),
|
||||
trivy_severities=os.environ.get("TRIVY_SEVERITIES", "").strip(),
|
||||
notify_on_scan_error=env_bool("NOTIFY_ON_SCAN_ERROR", True),
|
||||
report_dir=os.environ.get("REPORT_DIR", "/reports"),
|
||||
trivy_cache_dir=os.environ.get("TRIVY_CACHE_DIR", "/var/lib/trivy"),
|
||||
trivy_timeout=os.environ.get("TRIVY_TIMEOUT", "15m"),
|
||||
max_findings=env_int("MAX_FINDINGS_IN_BODY", 15),
|
||||
include_self=env_bool("INCLUDE_SELF", False),
|
||||
)
|
||||
settings.validate()
|
||||
return settings
|
||||
|
||||
def validate(self) -> None:
|
||||
severity_rank(self.notify_min_severity)
|
||||
severity_rank(self.fail_on)
|
||||
if self.max_findings < 0:
|
||||
raise ValueError("MAX_FINDINGS_IN_BODY must be a non-negative integer")
|
||||
|
||||
@property
|
||||
def notify_rank(self) -> int:
|
||||
return severity_rank(self.notify_min_severity)
|
||||
|
||||
@property
|
||||
def fail_rank(self) -> int:
|
||||
return severity_rank(self.fail_on)
|
||||
|
||||
|
||||
def read_images_file(path: str) -> list[str]:
|
||||
if not os.path.isfile(path) or not os.access(path, os.R_OK):
|
||||
raise ValueError(f"IMAGES_FILE is not readable: {path}")
|
||||
|
||||
images: list[str] = []
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
line = line.split("#", 1)[0]
|
||||
images += line.split()
|
||||
return images
|
||||
|
||||
|
||||
def collect_images(settings: Settings) -> list[str]:
|
||||
if settings.explicit_images:
|
||||
return list(settings.explicit_images)
|
||||
|
||||
images, source = discover_images(include_self=settings.include_self)
|
||||
if images:
|
||||
log("INFO", f"No images configured; scanning {len(images)} image(s) from {source}")
|
||||
return images
|
||||
|
||||
log(
|
||||
"WARN",
|
||||
"No images configured and no running containers could be discovered; nothing to scan. "
|
||||
"Set IMAGES=... or DOCKER_SOCK to the host's Docker socket "
|
||||
"(/run/user/<uid>/docker.sock for rootless Docker).",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trivy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
critical: int = 0
|
||||
high: int = 0
|
||||
medium: int = 0
|
||||
low: int = 0
|
||||
findings: list[dict] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def counts(self) -> dict[str, int]:
|
||||
return {
|
||||
"CRITICAL": self.critical,
|
||||
"HIGH": self.high,
|
||||
"MEDIUM": self.medium,
|
||||
"LOW": self.low,
|
||||
}
|
||||
|
||||
@property
|
||||
def fixable(self) -> int:
|
||||
return sum(1 for finding in self.findings if finding.get("FixedVersion"))
|
||||
|
||||
|
||||
def summarise(report: dict) -> ScanResult:
|
||||
result = ScanResult()
|
||||
for scan in report.get("Results") or []:
|
||||
for vuln in scan.get("Vulnerabilities") or []:
|
||||
severity = str(vuln.get("Severity", "")).upper()
|
||||
if severity == "CRITICAL":
|
||||
result.critical += 1
|
||||
elif severity == "HIGH":
|
||||
result.high += 1
|
||||
elif severity == "MEDIUM":
|
||||
result.medium += 1
|
||||
elif severity == "LOW":
|
||||
result.low += 1
|
||||
if severity in ("CRITICAL", "HIGH"):
|
||||
result.findings.append(vuln)
|
||||
result.findings.sort(
|
||||
key=lambda v: (
|
||||
0 if str(v.get("Severity", "")).upper() == "CRITICAL" else 1,
|
||||
str(v.get("VulnerabilityID", "")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def trivy_environment(settings: Settings) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Trivy needs DOCKER_HOST to read images that only exist in the daemon.
|
||||
if settings.docker_socket and not env.get("DOCKER_HOST"):
|
||||
env["DOCKER_HOST"] = f"unix://{settings.docker_socket}"
|
||||
return env
|
||||
|
||||
|
||||
def run_trivy(image: str, settings: Settings) -> tuple[int, str]:
|
||||
report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json")
|
||||
command = [
|
||||
"trivy",
|
||||
"image",
|
||||
"--quiet",
|
||||
"--scanners",
|
||||
"vuln",
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
report_path,
|
||||
"--timeout",
|
||||
settings.trivy_timeout,
|
||||
"--cache-dir",
|
||||
settings.trivy_cache_dir,
|
||||
]
|
||||
if settings.trivy_severities:
|
||||
command += ["--severity", settings.trivy_severities]
|
||||
if settings.ignore_unfixed:
|
||||
command.append("--ignore-unfixed")
|
||||
command.append(image)
|
||||
|
||||
log("INFO", f"Scanning {image}")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=trivy_environment(settings),
|
||||
)
|
||||
except OSError as exc:
|
||||
log("ERROR", f"Scan failed: {image}: {exc}")
|
||||
return 1, ""
|
||||
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip().splitlines()
|
||||
if detail:
|
||||
log("ERROR", detail[-1])
|
||||
log("ERROR", f"Scan failed: {image}")
|
||||
return 1, ""
|
||||
|
||||
return 0, report_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apprise notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def notify(settings: Settings, title: str, body: str) -> bool:
|
||||
if not settings.apprise_urls and not settings.apprise_config:
|
||||
log("WARN", "No APPRISE_URLS or APPRISE_CONFIG configured - notification skipped")
|
||||
return True
|
||||
|
||||
log("INFO", f"Sending Apprise notification: {title}")
|
||||
command = ["apprise", "--title", title, "--body", body]
|
||||
if settings.apprise_config:
|
||||
command += ["--config", settings.apprise_config]
|
||||
command += split_words(settings.apprise_urls)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(command, capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
log("ERROR", f"Apprise notification failed: {title}: {exc}")
|
||||
return False
|
||||
|
||||
if completed.returncode != 0:
|
||||
log("ERROR", f"Apprise notification failed: {title}")
|
||||
if completed.stderr.strip():
|
||||
log("ERROR", completed.stderr.strip())
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_body(image: str, result: ScanResult, report_path: str, max_findings: int) -> str:
|
||||
lines = [
|
||||
f"Image: {image}",
|
||||
f"Critical: {result.critical} | High: {result.high} | "
|
||||
f"Medium: {result.medium} | Low: {result.low}",
|
||||
f"Fixable (critical/high): {result.fixable}",
|
||||
"",
|
||||
]
|
||||
|
||||
total = result.critical + result.high
|
||||
if total > max_findings:
|
||||
lines.append(f"Findings (showing {max_findings} of {total}):")
|
||||
else:
|
||||
lines.append("Findings:")
|
||||
|
||||
for vuln in result.findings[:max_findings]:
|
||||
lines.append(
|
||||
"* [{severity}] {vulnerability} - {package} {installed} -> {fixed}".format(
|
||||
severity=vuln.get("Severity", ""),
|
||||
vulnerability=vuln.get("VulnerabilityID", ""),
|
||||
package=vuln.get("PkgName", ""),
|
||||
installed=vuln.get("InstalledVersion", ""),
|
||||
fixed=vuln.get("FixedVersion") or "no fix available",
|
||||
)
|
||||
)
|
||||
|
||||
lines += ["", f"Report: {report_path}"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunStats:
|
||||
scan_errors: int = 0
|
||||
notify_errors: int = 0
|
||||
failed_images: int = 0
|
||||
|
||||
|
||||
def threshold_hits(result: ScanResult, rank: int) -> int:
|
||||
if rank <= 0:
|
||||
return 0
|
||||
return sum(count for severity, count in result.counts.items() if severity_rank(severity) >= rank)
|
||||
|
||||
|
||||
def scan_image(image: str, settings: Settings, stats: RunStats) -> None:
|
||||
status, report_path = run_trivy(image, settings)
|
||||
if status != 0:
|
||||
stats.scan_errors += 1
|
||||
if settings.notify_on_scan_error:
|
||||
body = (
|
||||
f"Image: {image}\n\n"
|
||||
"Trivy could not complete the scan. Check the scanner logs for details.\n"
|
||||
)
|
||||
if not notify(settings, f"Scan failed: {image}", body):
|
||||
stats.notify_errors += 1
|
||||
return
|
||||
|
||||
try:
|
||||
with open(report_path, encoding="utf-8") as handle:
|
||||
report = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
log("ERROR", f"Could not read Trivy report {report_path}: {exc}")
|
||||
stats.scan_errors += 1
|
||||
return
|
||||
|
||||
result = summarise(report)
|
||||
log(
|
||||
"INFO",
|
||||
f"{image}: {result.critical} critical, {result.high} high, "
|
||||
f"{result.medium} medium, {result.low} low "
|
||||
f"({result.fixable} fixable high/critical)",
|
||||
)
|
||||
|
||||
notify_hits = threshold_hits(result, settings.notify_rank)
|
||||
fail_hits = threshold_hits(result, settings.fail_rank)
|
||||
|
||||
if notify_hits:
|
||||
title = (
|
||||
f"{settings.apprise_title_prefix}: {image} "
|
||||
f"({result.critical} critical, {result.high} high)"
|
||||
)
|
||||
body = build_body(image, result, report_path, settings.max_findings)
|
||||
if not notify(settings, title, body):
|
||||
stats.notify_errors += 1
|
||||
else:
|
||||
log("INFO", f"{image}: no findings at or above {settings.notify_min_severity}")
|
||||
|
||||
if fail_hits:
|
||||
log("WARN", f"{image} meets FAIL_ON={settings.fail_on} ({fail_hits} finding(s))")
|
||||
stats.failed_images += 1
|
||||
|
||||
|
||||
def usage() -> str:
|
||||
return f"""Usage: {PROG} [IMAGE ...]
|
||||
|
||||
Scan each Docker image with Trivy. When a finding at or above
|
||||
NOTIFY_MIN_SEVERITY (default: HIGH) is detected, a notification is sent
|
||||
through Apprise.
|
||||
|
||||
Images can be passed as arguments or with IMAGES (space or newline
|
||||
separated) or IMAGES_FILE (one image per line, '#' starts a comment).
|
||||
When none are given, the images used by running containers are scanned.
|
||||
|
||||
Environment:
|
||||
DOCKER_SOCK Unix socket of the Docker daemon. Defaults to
|
||||
DOCKER_HOST, /var/run/docker.sock, /run/docker.sock
|
||||
and the rootless socket under /run/user/<uid>/.
|
||||
INCLUDE_SELF true/false - also scan the scanner's own container
|
||||
image when images are auto-discovered (default: false).
|
||||
LOG_LEVEL DEBUG, INFO, WARN or ERROR (default: INFO).
|
||||
APPRISE_URLS Space separated Apprise URLs (quote the value).
|
||||
APPRISE_CONFIG Path to an Apprise config file (YAML).
|
||||
APPRISE_TITLE_PREFIX Notification title prefix (default: "Vulnerability scan").
|
||||
NOTIFY_MIN_SEVERITY Lowest severity that triggers an alert: NONE, LOW,
|
||||
MEDIUM, HIGH, CRITICAL (default: HIGH; NONE disables).
|
||||
FAIL_ON Lowest severity that makes the script exit 1: NONE,
|
||||
LOW, MEDIUM, HIGH, CRITICAL (default: HIGH; NONE never).
|
||||
IGNORE_UNFIXED true/false - exclude vulnerabilities without a fix
|
||||
(default: false).
|
||||
TRIVY_SEVERITIES Comma separated severities Trivy should report
|
||||
(default: empty = all severities).
|
||||
NOTIFY_ON_SCAN_ERROR true/false - alert when Trivy fails (default: true).
|
||||
REPORT_DIR Directory for JSON reports (default: /reports).
|
||||
TRIVY_CACHE_DIR Trivy database/cache directory (default: /var/lib/trivy).
|
||||
TRIVY_TIMEOUT Scan timeout passed to Trivy (default: 15m).
|
||||
MAX_FINDINGS_IN_BODY Findings listed in a notification body (default: 15).
|
||||
IMAGES Images to scan, space or newline separated.
|
||||
IMAGES_FILE File containing images to scan (one per line).
|
||||
|
||||
Exit codes: 0 = clean, 1 = vulnerability policy failed, 2 = scan error.
|
||||
"""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
|
||||
if argv and argv[0] in ("-h", "--help"):
|
||||
print(usage(), end="", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
try:
|
||||
settings = Settings.from_env(argv)
|
||||
except ValueError as exc:
|
||||
die(str(exc))
|
||||
|
||||
if not shutil.which("trivy"):
|
||||
die("trivy not found in PATH")
|
||||
if (settings.apprise_urls or settings.apprise_config) and not shutil.which("apprise"):
|
||||
die("APPRISE_URLS/APPRISE_CONFIG is set but apprise is not installed")
|
||||
|
||||
settings.docker_socket = find_docker_socket()
|
||||
|
||||
images = collect_images(settings)
|
||||
if not images:
|
||||
return 0
|
||||
|
||||
os.makedirs(settings.report_dir, exist_ok=True)
|
||||
os.makedirs(settings.trivy_cache_dir, exist_ok=True)
|
||||
|
||||
stats = RunStats()
|
||||
for image in images:
|
||||
scan_image(image, settings, stats)
|
||||
|
||||
log(
|
||||
"INFO",
|
||||
f"Scanned {len(images)} image(s): {stats.failed_images} failed policy, "
|
||||
f"{stats.scan_errors} scan error(s), {stats.notify_errors} notification error(s)",
|
||||
)
|
||||
|
||||
if stats.scan_errors or stats.notify_errors:
|
||||
return 2
|
||||
if stats.failed_images:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130) from None
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM php:8.2-apache
|
||||
COPY index.php /var/www/html/index.php
|
||||
@@ -0,0 +1,5 @@
|
||||
services:
|
||||
php:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
echo "hello world";
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Tests for scan.py.
|
||||
|
||||
The Docker Engine API is faked with a real Unix socket server, so image
|
||||
discovery is exercised end to end without Docker being installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socketserver
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import scan
|
||||
|
||||
|
||||
class _FakeDockerHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - required by the base class
|
||||
if self.path == "/containers/json":
|
||||
status, payload = 200, self.server.containers # type: ignore[attr-defined]
|
||||
elif self.path.startswith("/containers/") and self.path.endswith("/json"):
|
||||
container_id = self.path.split("/")[2]
|
||||
configs = self.server.configs # type: ignore[attr-defined]
|
||||
if container_id in configs:
|
||||
status, payload = 200, configs[container_id]
|
||||
else:
|
||||
status, payload = 404, {"message": "no such container"}
|
||||
else:
|
||||
status, payload = 404, {"message": "not found"}
|
||||
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeDocker:
|
||||
"""A Docker API listening on a temporary Unix socket."""
|
||||
|
||||
def __init__(self, containers: list[dict], configs: dict[str, dict] | None = None):
|
||||
self.directory = tempfile.mkdtemp(prefix="fakedocker-")
|
||||
self.socket_path = os.path.join(self.directory, "docker.sock")
|
||||
self.server = socketserver.UnixStreamServer(self.socket_path, _FakeDockerHandler)
|
||||
self.server.containers = containers # type: ignore[attr-defined]
|
||||
self.server.configs = configs or {} # type: ignore[attr-defined]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def __enter__(self) -> "FakeDocker":
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
shutil.rmtree(self.directory, ignore_errors=True)
|
||||
|
||||
|
||||
def container(container_id: str, image: str, image_id: str = "sha256:deadbeef") -> dict:
|
||||
return {"Id": container_id, "Image": image, "ImageID": image_id}
|
||||
|
||||
|
||||
class DockerDiscoveryTests(unittest.TestCase):
|
||||
def test_images_in_use_returns_running_images(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php"), container("b" * 64, "redis:7")]) as docker:
|
||||
self.assertEqual(scan.images_in_use(docker.socket_path), ["testphp-php", "redis:7"])
|
||||
|
||||
def test_images_in_use_dedupes_shared_images(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "nginx:1.27"), container("b" * 64, "nginx:1.27")]) as docker:
|
||||
self.assertEqual(scan.images_in_use(docker.socket_path), ["nginx:1.27"])
|
||||
|
||||
def test_images_in_use_resolves_image_id_via_inspect(self) -> None:
|
||||
image_id = "sha256:" + "c" * 64
|
||||
configs = {"a" * 64: {"Config": {"Image": "ghcr.io/acme/app:1.2.3"}}}
|
||||
with FakeDocker([container("a" * 64, image_id)], configs) as docker:
|
||||
self.assertEqual(scan.images_in_use(docker.socket_path), ["ghcr.io/acme/app:1.2.3"])
|
||||
|
||||
def test_images_in_use_falls_back_to_image_id(self) -> None:
|
||||
image_id = "sha256:" + "d" * 64
|
||||
with FakeDocker([container("a" * 64, "", image_id)], {}) as docker:
|
||||
self.assertEqual(scan.images_in_use(docker.socket_path), [image_id])
|
||||
|
||||
def test_images_in_use_ignores_non_dict_entries(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "alpine:3.20"), "nonsense"]) as docker: # type: ignore[list-item]
|
||||
self.assertEqual(scan.images_in_use(docker.socket_path), ["alpine:3.20"])
|
||||
|
||||
def test_images_in_use_skips_scanner_container(self) -> None:
|
||||
scanner_id = "f" * 64
|
||||
containers = [container(scanner_id, "docker-vuln-scan:latest"), container("a" * 64, "testphp-php")]
|
||||
with FakeDocker(containers) as docker:
|
||||
with mock.patch.dict(os.environ, {"HOSTNAME": scanner_id[:12]}):
|
||||
images = scan.images_in_use(docker.socket_path)
|
||||
self.assertEqual(images, ["testphp-php"])
|
||||
|
||||
def test_images_in_use_can_include_scanner_container(self) -> None:
|
||||
scanner_id = "f" * 64
|
||||
containers = [container(scanner_id, "docker-vuln-scan:latest")]
|
||||
with FakeDocker(containers) as docker:
|
||||
with mock.patch.dict(os.environ, {"HOSTNAME": scanner_id[:12]}):
|
||||
images = scan.images_in_use(docker.socket_path, include_self=True)
|
||||
self.assertEqual(images, ["docker-vuln-scan:latest"])
|
||||
|
||||
def test_candidate_sockets_includes_sockets_of_other_users(self) -> None:
|
||||
other = "/run/user/1000/docker.sock"
|
||||
with mock.patch.object(scan.glob, "glob", return_value=[other]):
|
||||
self.assertIn(other, scan.candidate_sockets())
|
||||
|
||||
def test_docker_api_get_raises_when_socket_is_missing(self) -> None:
|
||||
with self.assertRaises(scan.DockerError):
|
||||
scan.docker_api_get("/nonexistent/docker.sock", "/containers/json")
|
||||
|
||||
def test_docker_api_get_raises_on_http_error(self) -> None:
|
||||
with FakeDocker([]) as docker:
|
||||
with self.assertRaises(scan.DockerError):
|
||||
scan.docker_api_get(docker.socket_path, "/containers/nope/json")
|
||||
|
||||
def test_find_docker_socket_honours_docker_sock(self) -> None:
|
||||
with FakeDocker([]) as docker:
|
||||
with mock.patch.dict(os.environ, {"DOCKER_SOCK": docker.socket_path, "DOCKER_HOST": ""}):
|
||||
self.assertEqual(scan.find_docker_socket(), docker.socket_path)
|
||||
|
||||
def test_find_docker_socket_honours_docker_host(self) -> None:
|
||||
with FakeDocker([]) as docker:
|
||||
env = {"DOCKER_SOCK": "", "DOCKER_HOST": f"unix://{docker.socket_path}"}
|
||||
with mock.patch.dict(os.environ, env):
|
||||
self.assertEqual(scan.find_docker_socket(), docker.socket_path)
|
||||
|
||||
def test_find_docker_socket_rejects_regular_files(self) -> None:
|
||||
path = os.path.join(tempfile.mkdtemp(prefix="notasocket-"), "docker.sock")
|
||||
Path(path).write_text("not a socket")
|
||||
try:
|
||||
with mock.patch.object(scan, "candidate_sockets", return_value=[path]):
|
||||
self.assertIsNone(scan.find_docker_socket())
|
||||
finally:
|
||||
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
||||
|
||||
def test_discover_images_uses_socket(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
with mock.patch.dict(os.environ, {"DOCKER_SOCK": docker.socket_path, "DOCKER_HOST": ""}):
|
||||
images, source = scan.discover_images()
|
||||
self.assertEqual(images, ["testphp-php"])
|
||||
self.assertIn("running containers", source or "")
|
||||
|
||||
def test_discover_images_falls_back_to_cli(self) -> None:
|
||||
with mock.patch.object(scan, "find_docker_socket", return_value=None):
|
||||
with mock.patch.object(scan, "images_from_docker_cli", return_value=["redis:7"]):
|
||||
images, source = scan.discover_images()
|
||||
self.assertEqual(images, ["redis:7"])
|
||||
self.assertEqual(source, "running containers (via docker CLI)")
|
||||
|
||||
def test_discover_images_returns_empty_without_any_source(self) -> None:
|
||||
with mock.patch.object(scan, "find_docker_socket", return_value=None):
|
||||
with mock.patch.object(scan, "images_from_docker_cli", return_value=[]):
|
||||
self.assertEqual(scan.discover_images(), ([], None))
|
||||
|
||||
|
||||
class ImageListTests(unittest.TestCase):
|
||||
def test_split_words_handles_newlines_and_spaces(self) -> None:
|
||||
self.assertEqual(scan.split_words("a:1 b:2\n c:3\n"), ["a:1", "b:2", "c:3"])
|
||||
|
||||
def test_dedupe_keeps_order(self) -> None:
|
||||
self.assertEqual(scan.dedupe(["b", "a", "b", "c", "a"]), ["b", "a", "c"])
|
||||
|
||||
def test_report_slug_is_filesystem_safe(self) -> None:
|
||||
self.assertEqual(scan.report_slug("ghcr.io/acme/app:1.2.3"), "ghcr.io_acme_app_1.2.3")
|
||||
|
||||
def test_read_images_file_ignores_comments_and_blank_lines(self) -> None:
|
||||
directory = tempfile.mkdtemp(prefix="images-")
|
||||
path = os.path.join(directory, "images.txt")
|
||||
Path(path).write_text("# comment\nnginx:1.27\n\nredis:7 # trailing\n")
|
||||
try:
|
||||
self.assertEqual(scan.read_images_file(path), ["nginx:1.27", "redis:7"])
|
||||
finally:
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
|
||||
def test_read_images_file_rejects_missing_file(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
scan.read_images_file("/nonexistent/images.txt")
|
||||
|
||||
def test_collect_images_prefers_explicit_list(self) -> None:
|
||||
settings = scan.Settings(explicit_images=["nginx:1.27"])
|
||||
with mock.patch.object(scan, "discover_images") as discover:
|
||||
self.assertEqual(scan.collect_images(settings), ["nginx:1.27"])
|
||||
discover.assert_not_called()
|
||||
|
||||
def test_collect_images_discovers_when_empty(self) -> None:
|
||||
settings = scan.Settings(explicit_images=[])
|
||||
with mock.patch.object(scan, "discover_images", return_value=(["redis:7"], "fake")):
|
||||
self.assertEqual(scan.collect_images(settings), ["redis:7"])
|
||||
|
||||
def test_collect_images_returns_empty_when_nothing_found(self) -> None:
|
||||
settings = scan.Settings(explicit_images=[])
|
||||
with mock.patch.object(scan, "discover_images", return_value=([], None)):
|
||||
self.assertEqual(scan.collect_images(settings), [])
|
||||
|
||||
|
||||
class SettingsTests(unittest.TestCase):
|
||||
def test_settings_reads_environment(self) -> None:
|
||||
env = {
|
||||
"IMAGES": "",
|
||||
"IMAGES_FILE": "",
|
||||
"NOTIFY_MIN_SEVERITY": "CRITICAL",
|
||||
"FAIL_ON": "NONE",
|
||||
"IGNORE_UNFIXED": "true",
|
||||
"MAX_FINDINGS_IN_BODY": "3",
|
||||
}
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
settings = scan.Settings.from_env([])
|
||||
self.assertEqual(settings.notify_min_severity, "CRITICAL")
|
||||
self.assertEqual(settings.fail_rank, 0)
|
||||
self.assertTrue(settings.ignore_unfixed)
|
||||
self.assertEqual(settings.max_findings, 3)
|
||||
|
||||
def test_settings_rejects_unknown_severity(self) -> None:
|
||||
with mock.patch.dict(os.environ, {"NOTIFY_MIN_SEVERITY": "SEVERE"}, clear=False):
|
||||
with self.assertRaises(ValueError):
|
||||
scan.Settings.from_env([])
|
||||
|
||||
def test_settings_rejects_negative_max_findings(self) -> None:
|
||||
with mock.patch.dict(os.environ, {"MAX_FINDINGS_IN_BODY": "-1"}, clear=False):
|
||||
with self.assertRaises(ValueError):
|
||||
scan.Settings.from_env([])
|
||||
|
||||
def test_env_bool_rejects_garbage(self) -> None:
|
||||
with mock.patch.dict(os.environ, {"SOME_FLAG": "maybe"}):
|
||||
with self.assertRaises(ValueError):
|
||||
scan.env_bool("SOME_FLAG", False)
|
||||
|
||||
def test_env_bool_accepts_common_spellings(self) -> None:
|
||||
for raw, expected in (("1", True), ("YES", True), ("off", False), ("0", False)):
|
||||
with mock.patch.dict(os.environ, {"SOME_FLAG": raw}):
|
||||
self.assertIs(scan.env_bool("SOME_FLAG", not expected), expected)
|
||||
|
||||
|
||||
REPORT = {
|
||||
"Results": [
|
||||
{
|
||||
"Vulnerabilities": [
|
||||
{"Severity": "LOW", "VulnerabilityID": "CVE-3", "PkgName": "zlib"},
|
||||
{"Severity": "HIGH", "VulnerabilityID": "CVE-2", "PkgName": "openssl",
|
||||
"InstalledVersion": "3.0.1", "FixedVersion": "3.0.2"},
|
||||
{"Severity": "CRITICAL", "VulnerabilityID": "CVE-1", "PkgName": "libc",
|
||||
"InstalledVersion": "1.0", "FixedVersion": "1.1"},
|
||||
{"Severity": "MEDIUM", "VulnerabilityID": "CVE-4", "PkgName": "curl"},
|
||||
{"Severity": "HIGH", "VulnerabilityID": "CVE-5", "PkgName": "bash"},
|
||||
]
|
||||
},
|
||||
{"Vulnerabilities": None},
|
||||
{},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class ReportTests(unittest.TestCase):
|
||||
def test_summarise_counts_each_severity(self) -> None:
|
||||
result = scan.summarise(REPORT)
|
||||
self.assertEqual(result.counts, {"CRITICAL": 1, "HIGH": 2, "MEDIUM": 1, "LOW": 1})
|
||||
|
||||
def test_summarise_counts_fixable_high_and_critical_only(self) -> None:
|
||||
self.assertEqual(scan.summarise(REPORT).fixable, 2)
|
||||
|
||||
def test_summarise_sorts_critical_first(self) -> None:
|
||||
findings = scan.summarise(REPORT).findings
|
||||
self.assertEqual([f["VulnerabilityID"] for f in findings], ["CVE-1", "CVE-2", "CVE-5"])
|
||||
|
||||
def test_summarise_handles_empty_report(self) -> None:
|
||||
result = scan.summarise({})
|
||||
self.assertEqual(result.counts, {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0})
|
||||
self.assertEqual(result.findings, [])
|
||||
|
||||
def test_threshold_hits_respects_rank(self) -> None:
|
||||
result = scan.summarise(REPORT)
|
||||
self.assertEqual(scan.threshold_hits(result, 0), 0)
|
||||
self.assertEqual(scan.threshold_hits(result, scan.severity_rank("CRITICAL")), 1)
|
||||
self.assertEqual(scan.threshold_hits(result, scan.severity_rank("HIGH")), 3)
|
||||
self.assertEqual(scan.threshold_hits(result, scan.severity_rank("LOW")), 5)
|
||||
|
||||
def test_build_body_truncates_findings(self) -> None:
|
||||
result = scan.summarise(REPORT)
|
||||
body = scan.build_body("nginx:1.27", result, "/reports/x.json", max_findings=2)
|
||||
self.assertIn("showing 2 of 3", body)
|
||||
self.assertIn("CVE-1", body)
|
||||
self.assertIn("CVE-2", body)
|
||||
self.assertNotIn("CVE-5", body)
|
||||
self.assertIn("/reports/x.json", body)
|
||||
|
||||
def test_build_body_lists_all_findings_when_under_limit(self) -> None:
|
||||
result = scan.summarise(REPORT)
|
||||
body = scan.build_body("nginx:1.27", result, "/reports/x.json", max_findings=15)
|
||||
self.assertNotIn("showing", body)
|
||||
self.assertIn("CVE-5", body)
|
||||
self.assertIn("no fix available", body)
|
||||
|
||||
|
||||
class MainFlowTests(unittest.TestCase):
|
||||
"""End-to-end runs of main() with fake trivy/apprise binaries."""
|
||||
|
||||
TRIVY_SCRIPT = """#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
if os.environ.get("FAKE_TRIVY_FAIL") == "1":
|
||||
print("boom", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
report_path = args[args.index("--output") + 1]
|
||||
image = args[-1]
|
||||
with open(os.environ["FAKE_TRIVY_CALLS"], "a") as log:
|
||||
log.write(image + "\\n")
|
||||
severity = os.environ.get("FAKE_TRIVY_SEVERITY", "CRITICAL")
|
||||
report = {"Results": [{"Vulnerabilities": [{
|
||||
"Severity": severity,
|
||||
"VulnerabilityID": "CVE-2026-0001",
|
||||
"PkgName": "libdemo",
|
||||
"InstalledVersion": "1.0.0",
|
||||
"FixedVersion": "1.0.1",
|
||||
}]}]}
|
||||
with open(report_path, "w") as handle:
|
||||
json.dump(report, handle)
|
||||
"""
|
||||
|
||||
APPRISE_SCRIPT = """#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$APPRISE_LOG"
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.workdir = tempfile.mkdtemp(prefix="scan-e2e-")
|
||||
self.bin_dir = os.path.join(self.workdir, "bin")
|
||||
os.makedirs(self.bin_dir)
|
||||
self.trivy_calls = os.path.join(self.workdir, "trivy-calls.log")
|
||||
self.apprise_log = os.path.join(self.workdir, "apprise.log")
|
||||
Path(self.apprise_log).write_text("")
|
||||
|
||||
for name, script in (
|
||||
("trivy", self.TRIVY_SCRIPT),
|
||||
("apprise", self.APPRISE_SCRIPT),
|
||||
):
|
||||
path = os.path.join(self.bin_dir, name)
|
||||
Path(path).write_text(script)
|
||||
os.chmod(path, 0o755)
|
||||
|
||||
self.env = {
|
||||
"PATH": self.bin_dir + os.pathsep + os.environ.get("PATH", ""),
|
||||
"REPORT_DIR": os.path.join(self.workdir, "reports"),
|
||||
"TRIVY_CACHE_DIR": os.path.join(self.workdir, "cache"),
|
||||
"IMAGES": "",
|
||||
"IMAGES_FILE": "",
|
||||
"APPRISE_URLS": "json://localhost",
|
||||
"APPRISE_CONFIG": "",
|
||||
"FAIL_ON": "HIGH",
|
||||
"NOTIFY_MIN_SEVERITY": "HIGH",
|
||||
"NOTIFY_ON_SCAN_ERROR": "true",
|
||||
"MAX_FINDINGS_IN_BODY": "15",
|
||||
"IGNORE_UNFIXED": "",
|
||||
"TRIVY_SEVERITIES": "",
|
||||
"FAKE_TRIVY_CALLS": self.trivy_calls,
|
||||
"APPRISE_LOG": self.apprise_log,
|
||||
"LOG_LEVEL": "WARN",
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
|
||||
def run_main(self, argv: list[str], docker_socket: str, **extra_env: str) -> int:
|
||||
env = dict(self.env, DOCKER_SOCK=docker_socket, DOCKER_HOST="", **extra_env)
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
return scan.main(argv)
|
||||
|
||||
def test_scans_images_from_running_containers(self) -> None:
|
||||
containers = [container("a" * 64, "testphp-php")]
|
||||
with FakeDocker(containers) as docker:
|
||||
code = self.run_main([], docker.socket_path)
|
||||
|
||||
self.assertEqual(code, 1) # CRITICAL finding meets FAIL_ON=HIGH
|
||||
self.assertEqual(Path(self.trivy_calls).read_text().split(), ["testphp-php"])
|
||||
self.assertTrue(os.path.exists(os.path.join(self.env["REPORT_DIR"], "testphp-php.json")))
|
||||
self.assertIn("testphp-php", Path(self.apprise_log).read_text())
|
||||
|
||||
def test_exit_code_zero_when_policy_does_not_fail(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
code = self.run_main([], docker.socket_path, FAIL_ON="NONE", NOTIFY_MIN_SEVERITY="NONE")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(Path(self.apprise_log).read_text(), "")
|
||||
|
||||
def test_explicit_image_skips_discovery(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
code = self.run_main(["nginx:1.27"], docker.socket_path, FAIL_ON="NONE")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(Path(self.trivy_calls).read_text().split(), ["nginx:1.27"])
|
||||
|
||||
def test_scan_failure_exits_two(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
code = self.run_main([], docker.socket_path, FAKE_TRIVY_FAIL="1", NOTIFY_ON_SCAN_ERROR="false")
|
||||
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(Path(self.apprise_log).read_text(), "")
|
||||
|
||||
def test_scan_failure_notifies(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
code = self.run_main([], docker.socket_path, FAKE_TRIVY_FAIL="1")
|
||||
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("testphp-php", Path(self.apprise_log).read_text())
|
||||
|
||||
def test_nothing_to_scan_exits_zero(self) -> None:
|
||||
with FakeDocker([]) as docker:
|
||||
code = self.run_main([], docker.socket_path)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertFalse(os.path.exists(self.trivy_calls))
|
||||
|
||||
def test_only_high_findings_fail_at_high_threshold(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
code = self.run_main([], docker.socket_path, FAKE_TRIVY_SEVERITY="HIGH")
|
||||
|
||||
self.assertEqual(code, 1)
|
||||
|
||||
def test_medium_findings_pass_high_threshold(self) -> None:
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
code = self.run_main([], docker.socket_path, FAKE_TRIVY_SEVERITY="MEDIUM")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(Path(self.apprise_log).read_text(), "")
|
||||
|
||||
def test_discovers_rootless_socket_without_configuration(self) -> None:
|
||||
"""No DOCKER_SOCK: the socket is found under /run/user (as Compose mounts it)."""
|
||||
env = dict(self.env, DOCKER_SOCK="", DOCKER_HOST="", FAIL_ON="NONE")
|
||||
with FakeDocker([container("a" * 64, "testphp-php")]) as docker:
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
with mock.patch.object(scan.glob, "glob", return_value=[docker.socket_path]):
|
||||
code = scan.main([])
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(Path(self.trivy_calls).read_text().split(), ["testphp-php"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user