Initial commit

This commit is contained in:
David Duran Rosich committed 2026-10-23 22:05:26 +02:00
commit 70dbff10b9
14 files changed
+1907

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
reports
.git
.env
tests
testphp
komodo
__pycache__
*.pyc
+59
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.env
reports/
__pycache__/
*.pyc
+29
View File
@@ -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"]
+263
View File
@@ -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.
+45
View File
@@ -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:
+187
View File
@@ -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.
+61
View File
@@ -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" },
]
+65
View File
@@ -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" },
]
Executable
+725
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
FROM php:8.2-apache
COPY index.php /var/www/html/index.php
+5
View File
@@ -0,0 +1,5 @@
services:
php:
build: .
ports:
- "8080:80"
+2
View File
@@ -0,0 +1,2 @@
<?php
echo "hello world";
+452
View File
@@ -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)