diff --git a/.env.example b/.env.example index fa11925..a77125e 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,12 @@ IGNORE_UNFIXED=false # Optional: restrict which severities Trivy reports (empty = all). TRIVY_SEVERITIES= +# Optional: VEX sources passed to `trivy image --vex`, space separated. +# Use "repo", "oci", "sbom-ref", local file paths, or a combination. +# Mount local files into the container first (e.g. ./vex:/vex:ro). +# TRIVY_VEX=repo +# TRIVY_VEX=/vex/app.json + # How many findings to list in the notification body. MAX_FINDINGS_IN_BODY=15 diff --git a/README.md b/README.md index df8455f..75ff7b1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ host only needs Docker. JSON reports are kept on disk for auditing. 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`. + `/reports/.json`. Optional VEX sources are applied through Trivy's + `--vex` flag, so findings marked as not affected are filtered out before + counting and notifying (see [VEX](#vex)). 2. It counts findings per severity and checks them against two thresholds: - `NOTIFY_MIN_SEVERITY` (default `HIGH`) – when to send an Apprise alert. - `FAIL_ON` (default `HIGH`) – when the script should exit with code `1` @@ -142,6 +144,7 @@ All configuration is done through environment variables. | `FAIL_ON` | `HIGH` | Exit `1` when a finding at/above this severity exists. `NONE` never fails. | | `IGNORE_UNFIXED` | `false` | Skip vulnerabilities without a fixed version. | | `TRIVY_SEVERITIES` | *(all)* | Restrict severities Trivy reports, e.g. `HIGH,CRITICAL`. | +| `TRIVY_VEX` | – | VEX sources passed to Trivy: `repo`, `oci`, `sbom-ref`, or file paths. | | `NOTIFY_ON_SCAN_ERROR` | `true` | Also alert when Trivy itself fails (e.g. image pull error). | | `REPORT_DIR` | `/reports` | Where JSON reports are written. | | `TRIVY_CACHE_DIR` | `/var/lib/trivy` | Trivy DB cache (mount a volume to avoid re-downloading). | @@ -153,6 +156,39 @@ Images can be passed as arguments, via `IMAGES`, or via `IMAGES_FILE`; the sources are combined and de-duplicated. If all of them are empty, the images in use are scanned. +### VEX + +Trivy can filter findings with [VEX](https://trivy.dev/docs/latest/supply-chain/vex/) +(Vulnerability Exploitability eXchange) documents. Set `TRIVY_VEX` to one or +more space-separated sources; each is passed to `trivy image --vex`: + +```dotenv +# VEX Hub / repository lookup +TRIVY_VEX=repo + +# VEX attestations attached to the image in an OCI registry +TRIVY_VEX=oci + +# Local OpenVEX or CycloneDX VEX documents (mount them into the container) +TRIVY_VEX=/vex/app.json /vex/base.json + +# Several methods; the first source has the highest priority +TRIVY_VEX="/vex/app.json repo" +``` + +For local files with Compose, mount the directory read-only: + +```yaml +volumes: + - ./vex:/vex:ro +environment: + TRIVY_VEX: /vex/app.json +``` + +Findings suppressed by VEX are removed from the Trivy JSON report and are +therefore not counted, notified, or considered by `FAIL_ON`. The report still +reflects the filtered result. + ### Apprise URLs `APPRISE_URLS` accepts any [Apprise URL](https://github.com/caronc/apprise/wiki). diff --git a/docker-compose.yml b/docker-compose.yml index 7d90c7e..8f38e39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: FAIL_ON: ${FAIL_ON:-HIGH} IGNORE_UNFIXED: ${IGNORE_UNFIXED:-false} TRIVY_SEVERITIES: ${TRIVY_SEVERITIES:-} + # Optional: "repo", "oci", "sbom-ref" or local VEX file paths. + TRIVY_VEX: ${TRIVY_VEX:-} MAX_FINDINGS_IN_BODY: ${MAX_FINDINGS_IN_BODY:-15} NOTIFY_ON_SCAN_ERROR: ${NOTIFY_ON_SCAN_ERROR:-true} TRIVY_TIMEOUT: ${TRIVY_TIMEOUT:-15m} @@ -37,6 +39,8 @@ services: # Remove this line if you do not want the host's runtime dirs exposed. # - /run/user:/run/user:ro - ${KOMODO_CACHE_DIR:-.}/cves/reports:/reports + # Optional: local VEX documents, referenced as /vex/ in TRIVY_VEX. + # - ./vex:/vex:ro # Keep the Trivy DB between runs (much faster repeat scans). - trivy-cache:/var/lib/trivy restart: "no" diff --git a/komodo/README.md b/komodo/README.md index e121518..21b280c 100644 --- a/komodo/README.md +++ b/komodo/README.md @@ -142,6 +142,7 @@ Edit the stack's `environment` (in the TOML or in Komodo) and re-apply the sync: | `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. | +| `TRIVY_VEX` | VEX sources: `repo`, `oci`, `sbom-ref`, or local file paths (space separated). | | `MAX_FINDINGS_IN_BODY` | Findings listed per notification. | Schedule fields are on the Procedure: @@ -168,6 +169,10 @@ 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. +VEX: set `TRIVY_VEX = repo`, `oci` or a combination in the stack environment. +For local files, add `- ./vex:/vex:ro` to the `vulnscan` service volumes in +`docker-compose.yml` and use a path such as `TRIVY_VEX = /vex/app.json`. + ## Troubleshooting | Symptom | Fix | diff --git a/scan.py b/scan.py index cb4cda3..0356e35 100755 --- a/scan.py +++ b/scan.py @@ -364,6 +364,7 @@ class Settings: fail_on: str = "HIGH" ignore_unfixed: bool = False trivy_severities: str = "" + trivy_vex: list[str] = field(default_factory=list) notify_on_scan_error: bool = True report_dir: str = "/reports" trivy_cache_dir: str = "/var/lib/trivy" @@ -393,6 +394,7 @@ class Settings: fail_on=os.environ.get("FAIL_ON", "HIGH"), ignore_unfixed=env_bool("IGNORE_UNFIXED", False), trivy_severities=os.environ.get("TRIVY_SEVERITIES", "").strip(), + trivy_vex=split_words(os.environ.get("TRIVY_VEX", "")), 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"), @@ -549,6 +551,8 @@ def run_trivy(image: str, settings: Settings, context: str = "") -> tuple[int, s ] if settings.trivy_severities: command += ["--severity", settings.trivy_severities] + for vex_source in settings.trivy_vex: + command += ["--vex", vex_source] if settings.ignore_unfixed: command.append("--ignore-unfixed") command.append(image) @@ -732,6 +736,9 @@ Environment: (default: false). TRIVY_SEVERITIES Comma separated severities Trivy should report (default: empty = all severities). + TRIVY_VEX Space separated VEX sources passed to Trivy as + --vex: "repo", "oci", "sbom-ref" or file paths. + Earlier sources take priority (default: none). 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). @@ -763,6 +770,9 @@ def main(argv: list[str] | None = None) -> int: settings.docker_socket = find_docker_socket() + if settings.trivy_vex: + log("INFO", f"Applying VEX source(s): {', '.join(settings.trivy_vex)}") + targets = collect_targets(settings) if not targets: return 0 diff --git a/tests/test_scan.py b/tests/test_scan.py index b9bf508..b42ac3d 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -291,6 +291,7 @@ class SettingsTests(unittest.TestCase): "FAIL_ON": "NONE", "IGNORE_UNFIXED": "true", "MAX_FINDINGS_IN_BODY": "3", + "TRIVY_VEX": "/vex/app.json repo", } with mock.patch.dict(os.environ, env, clear=False): settings = scan.Settings.from_env([]) @@ -298,6 +299,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual(settings.fail_rank, 0) self.assertTrue(settings.ignore_unfixed) self.assertEqual(settings.max_findings, 3) + self.assertEqual(settings.trivy_vex, ["/vex/app.json", "repo"]) def test_settings_rejects_unknown_severity(self) -> None: with mock.patch.dict(os.environ, {"NOTIFY_MIN_SEVERITY": "SEVERE"}, clear=False): @@ -380,6 +382,32 @@ class ReportTests(unittest.TestCase): self.assertIn("no fix available", body) +class TrivyCommandTests(unittest.TestCase): + def test_run_trivy_passes_vex_sources_in_order(self) -> None: + settings = scan.Settings( + explicit_images=[], trivy_vex=["/vex/app.json", "repo"] + ) + completed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + with mock.patch.object(scan.subprocess, "run", return_value=completed) as run: + status, _ = scan.run_trivy("nginx:1.27", settings) + + command = run.call_args.args[0] + self.assertEqual(status, 0) + positions = [index for index, arg in enumerate(command) if arg == "--vex"] + self.assertEqual( + [command[index + 1] for index in positions], + ["/vex/app.json", "repo"], + ) + + def test_run_trivy_omits_vex_when_not_configured(self) -> None: + settings = scan.Settings(explicit_images=[]) + completed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + with mock.patch.object(scan.subprocess, "run", return_value=completed) as run: + scan.run_trivy("nginx:1.27", settings) + + self.assertNotIn("--vex", run.call_args.args[0]) + + class MainFlowTests(unittest.TestCase): """End-to-end runs of main() with fake trivy/apprise binaries."""