add vex support

This commit is contained in:
David Duran Rosich committed 2026-10-23 22:37:05 +02:00
1 parent 81961f268f
commit f6e723a63e
6 files changed
+90 -1

No files matched your search

+6
View File
@@ -41,6 +41,12 @@ IGNORE_UNFIXED=false
# Optional: restrict which severities Trivy reports (empty = all). # Optional: restrict which severities Trivy reports (empty = all).
TRIVY_SEVERITIES= 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. # How many findings to list in the notification body.
MAX_FINDINGS_IN_BODY=15 MAX_FINDINGS_IN_BODY=15
+37 -1
View File
@@ -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 1. `scan.py` determines which images to scan (see
[Which images are scanned](#which-images-are-scanned)) and runs [Which images are scanned](#which-images-are-scanned)) and runs
`trivy image` for each of them, writing a JSON report to `trivy image` for each of them, writing a JSON report to
`/reports/<image>.json`. `/reports/<image>.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: 2. It counts findings per severity and checks them against two thresholds:
- `NOTIFY_MIN_SEVERITY` (default `HIGH`) – when to send an Apprise alert. - `NOTIFY_MIN_SEVERITY` (default `HIGH`) – when to send an Apprise alert.
- `FAIL_ON` (default `HIGH`) – when the script should exit with code `1` - `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. | | `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. | | `IGNORE_UNFIXED` | `false` | Skip vulnerabilities without a fixed version. |
| `TRIVY_SEVERITIES` | *(all)* | Restrict severities Trivy reports, e.g. `HIGH,CRITICAL`. | | `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). | | `NOTIFY_ON_SCAN_ERROR` | `true` | Also alert when Trivy itself fails (e.g. image pull error). |
| `REPORT_DIR` | `/reports` | Where JSON reports are written. | | `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_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 sources are combined and de-duplicated. If all of them are empty, the images in
use are scanned. 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
`APPRISE_URLS` accepts any [Apprise URL](https://github.com/caronc/apprise/wiki). `APPRISE_URLS` accepts any [Apprise URL](https://github.com/caronc/apprise/wiki).
+4
View File
@@ -12,6 +12,8 @@ services:
FAIL_ON: ${FAIL_ON:-HIGH} FAIL_ON: ${FAIL_ON:-HIGH}
IGNORE_UNFIXED: ${IGNORE_UNFIXED:-false} IGNORE_UNFIXED: ${IGNORE_UNFIXED:-false}
TRIVY_SEVERITIES: ${TRIVY_SEVERITIES:-} 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} MAX_FINDINGS_IN_BODY: ${MAX_FINDINGS_IN_BODY:-15}
NOTIFY_ON_SCAN_ERROR: ${NOTIFY_ON_SCAN_ERROR:-true} NOTIFY_ON_SCAN_ERROR: ${NOTIFY_ON_SCAN_ERROR:-true}
TRIVY_TIMEOUT: ${TRIVY_TIMEOUT:-15m} TRIVY_TIMEOUT: ${TRIVY_TIMEOUT:-15m}
@@ -37,6 +39,8 @@ services:
# Remove this line if you do not want the host's runtime dirs exposed. # Remove this line if you do not want the host's runtime dirs exposed.
# - /run/user:/run/user:ro # - /run/user:/run/user:ro
- ${KOMODO_CACHE_DIR:-.}/cves/reports:/reports - ${KOMODO_CACHE_DIR:-.}/cves/reports:/reports
# Optional: local VEX documents, referenced as /vex/<file> in TRIVY_VEX.
# - ./vex:/vex:ro
# Keep the Trivy DB between runs (much faster repeat scans). # Keep the Trivy DB between runs (much faster repeat scans).
- trivy-cache:/var/lib/trivy - trivy-cache:/var/lib/trivy
restart: "no" restart: "no"
+5
View File
@@ -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`). | | `NOTIFY_MIN_SEVERITY` | Lowest severity that sends an alert (default `HIGH`). |
| `FAIL_ON` | Lowest severity that makes the container exit `1` (default `HIGH`). | | `FAIL_ON` | Lowest severity that makes the container exit `1` (default `HIGH`). |
| `IGNORE_UNFIXED` | `true` to ignore findings without a fix. | | `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. | | `MAX_FINDINGS_IN_BODY` | Findings listed per notification. |
Schedule fields are on the Procedure: 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 `TRIVY_INSECURE` to the stack environment. The Compose file already passes
them through to the container. 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 ## Troubleshooting
| Symptom | Fix | | Symptom | Fix |
+10
View File
@@ -364,6 +364,7 @@ class Settings:
fail_on: str = "HIGH" fail_on: str = "HIGH"
ignore_unfixed: bool = False ignore_unfixed: bool = False
trivy_severities: str = "" trivy_severities: str = ""
trivy_vex: list[str] = field(default_factory=list)
notify_on_scan_error: bool = True notify_on_scan_error: bool = True
report_dir: str = "/reports" report_dir: str = "/reports"
trivy_cache_dir: str = "/var/lib/trivy" trivy_cache_dir: str = "/var/lib/trivy"
@@ -393,6 +394,7 @@ class Settings:
fail_on=os.environ.get("FAIL_ON", "HIGH"), fail_on=os.environ.get("FAIL_ON", "HIGH"),
ignore_unfixed=env_bool("IGNORE_UNFIXED", False), ignore_unfixed=env_bool("IGNORE_UNFIXED", False),
trivy_severities=os.environ.get("TRIVY_SEVERITIES", "").strip(), 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), notify_on_scan_error=env_bool("NOTIFY_ON_SCAN_ERROR", True),
report_dir=os.environ.get("REPORT_DIR", "/reports"), report_dir=os.environ.get("REPORT_DIR", "/reports"),
trivy_cache_dir=os.environ.get("TRIVY_CACHE_DIR", "/var/lib/trivy"), 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: if settings.trivy_severities:
command += ["--severity", settings.trivy_severities] command += ["--severity", settings.trivy_severities]
for vex_source in settings.trivy_vex:
command += ["--vex", vex_source]
if settings.ignore_unfixed: if settings.ignore_unfixed:
command.append("--ignore-unfixed") command.append("--ignore-unfixed")
command.append(image) command.append(image)
@@ -732,6 +736,9 @@ Environment:
(default: false). (default: false).
TRIVY_SEVERITIES Comma separated severities Trivy should report TRIVY_SEVERITIES Comma separated severities Trivy should report
(default: empty = all severities). (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). NOTIFY_ON_SCAN_ERROR true/false - alert when Trivy fails (default: true).
REPORT_DIR Directory for JSON reports (default: /reports). REPORT_DIR Directory for JSON reports (default: /reports).
TRIVY_CACHE_DIR Trivy database/cache directory (default: /var/lib/trivy). 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() 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) targets = collect_targets(settings)
if not targets: if not targets:
return 0 return 0
+28
View File
@@ -291,6 +291,7 @@ class SettingsTests(unittest.TestCase):
"FAIL_ON": "NONE", "FAIL_ON": "NONE",
"IGNORE_UNFIXED": "true", "IGNORE_UNFIXED": "true",
"MAX_FINDINGS_IN_BODY": "3", "MAX_FINDINGS_IN_BODY": "3",
"TRIVY_VEX": "/vex/app.json repo",
} }
with mock.patch.dict(os.environ, env, clear=False): with mock.patch.dict(os.environ, env, clear=False):
settings = scan.Settings.from_env([]) settings = scan.Settings.from_env([])
@@ -298,6 +299,7 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.fail_rank, 0) self.assertEqual(settings.fail_rank, 0)
self.assertTrue(settings.ignore_unfixed) self.assertTrue(settings.ignore_unfixed)
self.assertEqual(settings.max_findings, 3) self.assertEqual(settings.max_findings, 3)
self.assertEqual(settings.trivy_vex, ["/vex/app.json", "repo"])
def test_settings_rejects_unknown_severity(self) -> None: def test_settings_rejects_unknown_severity(self) -> None:
with mock.patch.dict(os.environ, {"NOTIFY_MIN_SEVERITY": "SEVERE"}, clear=False): 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) 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): class MainFlowTests(unittest.TestCase):
"""End-to-end runs of main() with fake trivy/apprise binaries.""" """End-to-end runs of main() with fake trivy/apprise binaries."""