aggregate by cve

This commit is contained in:
David Duran Rosich committed 2026-10-23 23:59:30 +02:00
1 parent 3003585a04
commit 9d0fd2ba44
3 files changed
+224 -30

No files matched your search

+11 -9
View File
@@ -19,12 +19,14 @@ host only needs Docker. JSON reports are kept on disk for auditing.
`/reports/<image>.json`. Optional VEX sources are applied through Trivy's `/reports/<image>.json`. Optional VEX sources are applied through Trivy's
`--vex` flag, so findings marked as not affected are filtered out before `--vex` flag, so findings marked as not affected are filtered out before
counting and notifying (see [VEX](#vex)). counting and notifying (see [VEX](#vex)).
2. It counts findings per severity and checks them against two thresholds: 2. Findings are aggregated by CVE across all scanned images. Two thresholds
- `NOTIFY_MIN_SEVERITY` (default `HIGH`) – when to send an Apprise alert. are checked against the aggregate counts:
- `NOTIFY_MIN_SEVERITY` (default `HIGH`) – when to send a single 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`
(useful for CI). (useful for CI).
3. On a match it sends one notification per image listing the most relevant 3. If any CVE reaches `NOTIFY_MIN_SEVERITY`, one notification is sent listing
CVEs, the affected package, the installed version, and the fixed version. each CVE with the affected packages and the containers/images that use them.
## Quick start ## Quick start
@@ -64,11 +66,11 @@ 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 `/containers/json` and scans each `Image` reference, de-duplicated and in
listing order. Stopped containers are not included. The scanner's own container 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 image is skipped so a scheduled run does not report on itself; set
`INCLUDE_SELF=true` to include it. Scan log lines and Apprise notifications name the running container(s) `INCLUDE_SELF=true` to include it. Scan logs still name the running container(s) that use each image (e.g.
that use each image, for example `Scanning nginx:1.27 (used by container web)`). Notifications are aggregated
`Scanning nginx:1.27 (used by container web)` and by CVE after all images are scanned: a single report lists each CVE with the
`Vulnerability scan: nginx:1.27 [container: web]`. Explicitly passed images containers and images it affects. Scan failures still notify per failed image
omit the container context. with container context. Explicitly passed images omit container context.
The socket is looked up in this order: The socket is looked up in this order:
+184 -19
View File
@@ -518,6 +518,107 @@ class ScanResult:
return sum(1 for finding in self.findings if finding.get("FixedVersion")) return sum(1 for finding in self.findings if finding.get("FixedVersion"))
@dataclass
class AffectedTarget:
image: str
containers: list[str]
package: str = ""
installed: str = ""
fixed: str = ""
@dataclass
class CveAggregation:
cve: str
severity: str
affected: list[AffectedTarget] = field(default_factory=list)
packages: list[tuple[str, str, str]] = field(default_factory=list)
fixable: bool = False
@dataclass
class AggregatedReport:
critical: int = 0
high: int = 0
medium: int = 0
low: int = 0
cves: dict[str, CveAggregation] = field(default_factory=dict)
reports: list[str] = 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 cve in self.cves.values() if cve.fixable and severity_rank(cve.severity) >= 3)
def add_vulnerability(
self, vuln: dict, image: str, containers: list[str]
) -> None:
severity = str(vuln.get("Severity", "")).upper()
if severity == "CRITICAL":
self.critical += 1
elif severity == "HIGH":
self.high += 1
elif severity == "MEDIUM":
self.medium += 1
elif severity == "LOW":
self.low += 1
else:
return
cve_id = str(vuln.get("VulnerabilityID", ""))
if not cve_id:
return
pkg = str(vuln.get("PkgName", ""))
installed = str(vuln.get("InstalledVersion", ""))
fixed = str(vuln.get("FixedVersion") or "")
fixable = bool(fixed)
if cve_id not in self.cves:
self.cves[cve_id] = CveAggregation(
cve=cve_id, severity=severity, fixable=fixable
)
else:
existing = self.cves[cve_id]
if severity_rank(severity) > severity_rank(existing.severity):
existing.severity = severity
if fixable:
existing.fixable = True
agg = self.cves[cve_id]
pkg_key = (pkg, installed, fixed)
if pkg_key not in agg.packages:
agg.packages.append(pkg_key)
affected_key = (image, tuple(sorted(containers)))
for affected in agg.affected:
if affected.image == image and tuple(sorted(affected.containers)) == tuple(
sorted(containers)
):
if pkg and not affected.package:
affected.package = pkg
affected.installed = installed
affected.fixed = fixed
break
else:
agg.affected.append(
AffectedTarget(
image=image,
containers=list(containers),
package=pkg,
installed=installed,
fixed=fixed,
)
)
def summarise(report: dict) -> ScanResult: def summarise(report: dict) -> ScanResult:
result = ScanResult() result = ScanResult()
for scan in report.get("Results") or []: for scan in report.get("Results") or []:
@@ -665,6 +766,51 @@ def build_body(
return "\n".join(lines) return "\n".join(lines)
def build_aggregated_body(
aggregated: AggregatedReport, max_findings: int
) -> str:
lines = [
f"Critical: {aggregated.critical} | High: {aggregated.high} | "
f"Medium: {aggregated.medium} | Low: {aggregated.low}",
f"Fixable (critical/high): {aggregated.fixable}",
"",
]
cves = sorted(
aggregated.cves.values(),
key=lambda item: (
-severity_rank(item.severity),
item.cve,
),
)
total = len(cves)
if total > max_findings:
lines.append(f"CVEs (showing {max_findings} of {total}):")
else:
lines.append("CVEs:")
for cve in cves[:max_findings]:
packages = []
for pkg, installed, fixed in cve.packages[:3]:
if pkg:
packages.append(f"{pkg} {installed} -> {fixed or 'no fix available'}")
elif installed or fixed:
packages.append(f"{installed} -> {fixed or 'no fix available'}")
pkg_text = "; ".join(packages) if packages else "package unknown"
lines.append(
f"* [{cve.severity}] {cve.cve} - {pkg_text}"
)
for affected in cve.affected:
containers = ", ".join(affected.containers) if affected.containers else "unknown"
lines.append(f" - containers: {containers} (image: {affected.image})")
if aggregated.reports:
lines += ["", "Reports:"]
lines += [f"- {path}" for path in aggregated.reports[:20]]
return "\n".join(lines)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -683,7 +829,12 @@ def threshold_hits(result: ScanResult, rank: int) -> int:
return sum(count for severity, count in result.counts.items() if severity_rank(severity) >= rank) return sum(count for severity, count in result.counts.items() if severity_rank(severity) >= rank)
def scan_image(target: ScanTarget, settings: Settings, stats: RunStats) -> None: def scan_image(
target: ScanTarget,
settings: Settings,
stats: RunStats,
aggregated: AggregatedReport,
) -> None:
image = target.image image = target.image
context = target.container_suffix context = target.container_suffix
status, report_path = run_trivy(image, settings, context) status, report_path = run_trivy(image, settings, context)
@@ -720,23 +871,17 @@ def scan_image(target: ScanTarget, settings: Settings, stats: RunStats) -> None:
f"({result.fixable} fixable high/critical)", f"({result.fixable} fixable high/critical)",
) )
if report_path and report_path not in aggregated.reports:
aggregated.reports.append(report_path)
for vuln in result.findings:
aggregated.add_vulnerability(vuln, image, target.containers)
notify_hits = threshold_hits(result, settings.notify_rank) notify_hits = threshold_hits(result, settings.notify_rank)
fail_hits = threshold_hits(result, settings.fail_rank) fail_hits = threshold_hits(result, settings.fail_rank)
if notify_hits: if notify_hits:
title = ( log("DEBUG", f"{image}{context}: findings above {settings.notify_min_severity} tracked for aggregated report")
f"{settings.apprise_title_prefix}: {image}{target.notification_context} "
f"({result.critical} critical, {result.high} high)"
)
body = build_body(
image,
result,
report_path,
settings.max_findings,
target.container_detail,
)
if not notify(settings, title, body):
stats.notify_errors += 1
else: else:
log("INFO", f"{image}{context}: no findings at or above {settings.notify_min_severity}") log("INFO", f"{image}{context}: no findings at or above {settings.notify_min_severity}")
@@ -747,10 +892,11 @@ def scan_image(target: ScanTarget, settings: Settings, stats: RunStats) -> None:
def usage() -> str: def usage() -> str:
return f"""Usage: {PROG} [IMAGE ...] return f"""Usage: {PROG} [IMAGE ...]
Scan each Docker image with Trivy. When a finding at or above Scan each Docker image with Trivy. Findings are aggregated by CVE across all
NOTIFY_MIN_SEVERITY (default: HIGH) is detected, a notification is sent images and a single notification is sent when any reach
through Apprise. NOTIFY_MIN_SEVERITY (default: HIGH). Each CVE lists the containers and images
that are affected.
Images can be passed as arguments or with IMAGES (space or newline Images can be passed as arguments or with IMAGES (space or newline
separated) or IMAGES_FILE (one image per line, '#' starts a comment). separated) or IMAGES_FILE (one image per line, '#' starts a comment).
@@ -819,8 +965,17 @@ def main(argv: list[str] | None = None) -> int:
os.makedirs(settings.trivy_cache_dir, exist_ok=True) os.makedirs(settings.trivy_cache_dir, exist_ok=True)
stats = RunStats() stats = RunStats()
aggregated = AggregatedReport()
for target in targets: for target in targets:
scan_image(target, settings, stats) scan_image(target, settings, stats, aggregated)
if aggregated.cves and threshold_hits_from_counts(aggregated.counts, settings.notify_rank):
title = f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s) across {len(targets)} image(s)"
body = build_aggregated_body(aggregated, settings.max_findings)
if not notify(settings, title, body):
stats.notify_errors += 1
elif aggregated.cves:
log("INFO", f"{len(aggregated.cves)} CVE(s) tracked but below NOTIFY_MIN_SEVERITY={settings.notify_min_severity}")
log( log(
"INFO", "INFO",
@@ -835,6 +990,16 @@ def main(argv: list[str] | None = None) -> int:
return 0 return 0
def threshold_hits_from_counts(counts: dict[str, int], rank: int) -> int:
if rank <= 0:
return 0
total = 0
for severity, count in counts.items():
if severity_rank(severity) >= rank:
total += count
return total
if __name__ == "__main__": if __name__ == "__main__":
try: try:
raise SystemExit(main()) raise SystemExit(main())
+29 -2
View File
@@ -399,6 +399,31 @@ class ReportTests(unittest.TestCase):
self.assertIn("Image: nginx:1.27", body) self.assertIn("Image: nginx:1.27", body)
self.assertIn("Containers: web, web-worker", body) self.assertIn("Containers: web, web-worker", body)
def test_build_aggregated_body_groups_by_cve(self) -> None:
aggregated = scan.AggregatedReport()
vuln1 = {
"Severity": "CRITICAL",
"VulnerabilityID": "CVE-1",
"PkgName": "libc",
"InstalledVersion": "1.0",
"FixedVersion": "1.1",
}
vuln2 = {
"Severity": "HIGH",
"VulnerabilityID": "CVE-1",
"PkgName": "libc",
"InstalledVersion": "1.0",
"FixedVersion": "1.1",
}
aggregated.add_vulnerability(vuln1, "nginx:1.27", ["web"])
aggregated.add_vulnerability(vuln2, "redis:7", ["cache"])
body = scan.build_aggregated_body(aggregated, 15)
self.assertIn("CVE-1", body)
self.assertIn("containers: web", body)
self.assertIn("image: nginx:1.27", body)
self.assertIn("containers: cache", body)
self.assertIn("image: redis:7", body)
class TrivyCommandTests(unittest.TestCase): class TrivyCommandTests(unittest.TestCase):
def test_run_trivy_passes_vex_sources_in_order(self) -> None: def test_run_trivy_passes_vex_sources_in_order(self) -> None:
@@ -528,8 +553,9 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
self.assertEqual(code, 1) self.assertEqual(code, 1)
notification = Path(self.apprise_log).read_text() notification = Path(self.apprise_log).read_text()
self.assertIn("[container: testphp]", notification) self.assertIn("CVE-2026-0001", notification)
self.assertIn("Container: testphp", notification) self.assertIn("containers: testphp", notification)
self.assertIn("image: testphp-php", notification)
def test_scan_failure_notification_includes_container(self) -> None: def test_scan_failure_notification_includes_container(self) -> None:
containers = [container("a" * 64, "testphp-php", name="testphp")] containers = [container("a" * 64, "testphp-php", name="testphp")]
@@ -540,6 +566,7 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
notification = Path(self.apprise_log).read_text() notification = Path(self.apprise_log).read_text()
self.assertIn("Scan failed: testphp-php [container: testphp]", notification) self.assertIn("Scan failed: testphp-php [container: testphp]", notification)
self.assertIn("Container: testphp", notification) self.assertIn("Container: testphp", notification)
self.assertIn("Trivy could not complete the scan", notification)
def test_exit_code_zero_when_policy_does_not_fail(self) -> None: def test_exit_code_zero_when_policy_does_not_fail(self) -> None:
with FakeDocker([container("a" * 64, "testphp-php")]) as docker: with FakeDocker([container("a" * 64, "testphp-php")]) as docker: