commit 70dbff10b9c4de5092125f69ce5ac921a37f0e13 Author: David Duran Rosich Date: Fri Oct 23 22:05:26 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0843e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +reports +.git +.env +tests +testphp +komodo +__pycache__ +*.pyc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa11925 --- /dev/null +++ b/.env.example @@ -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//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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98de5bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +reports/ +__pycache__/ +*.pyc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..855da3a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..883da19 --- /dev/null +++ b/README.md @@ -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/.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//docker.sock` and `/run/user//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 '')" \ +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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ec876e3 --- /dev/null +++ b/docker-compose.yml @@ -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//docker.sock + - ${DOCKER_SOCK:-/var/run/docker.sock}:${DOCKER_SOCK:-/var/run/docker.sock}:ro + # Rootless Docker/Podman keep the socket under /run/user//. 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: diff --git a/komodo/README.md b/komodo/README.md new file mode 100644 index 0000000..e121518 --- /dev/null +++ b/komodo/README.md @@ -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/`. + +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/.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 '' > /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//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. diff --git a/komodo/vuln-scan-files-on-host.toml b/komodo/vuln-scan-files-on-host.toml new file mode 100644 index 0000000..6c88b0c --- /dev/null +++ b/komodo/vuln-scan-files-on-host.toml @@ -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" }, +] diff --git a/komodo/vuln-scan.toml b/komodo/vuln-scan.toml new file mode 100644 index 0000000..12e2427 --- /dev/null +++ b/komodo/vuln-scan.toml @@ -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" }, +] diff --git a/scan.py b/scan.py new file mode 100755 index 0000000..59ad64a --- /dev/null +++ b/scan.py @@ -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//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//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//. + 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 diff --git a/testphp/Dockerfile b/testphp/Dockerfile new file mode 100644 index 0000000..e0e5e95 --- /dev/null +++ b/testphp/Dockerfile @@ -0,0 +1,2 @@ +FROM php:8.2-apache +COPY index.php /var/www/html/index.php diff --git a/testphp/docker-compose.yml b/testphp/docker-compose.yml new file mode 100644 index 0000000..bc30aed --- /dev/null +++ b/testphp/docker-compose.yml @@ -0,0 +1,5 @@ +services: + php: + build: . + ports: + - "8080:80" diff --git a/testphp/index.php b/testphp/index.php new file mode 100644 index 0000000..2eabb2d --- /dev/null +++ b/testphp/index.php @@ -0,0 +1,2 @@ + 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) \ No newline at end of file