more changes!

This commit is contained in:
David Duran Rosich committed 2026-10-25 00:06:13 +02:00
1 parent beb2f544d9
commit 66808d77f0
3 files changed
+64 -30

No files matched your search

+9 -6
View File
@@ -27,9 +27,10 @@ host only needs Docker. JSON reports are kept on disk for auditing.
(useful for CI). (useful for CI).
3. Notifications are sent. When `IMPORTANT_CONTAINERS` is set, each named 3. Notifications are sent. When `IMPORTANT_CONTAINERS` is set, each named
container gets its own complete CVE list (showing `C X/Y ...` where X is container gets its own complete CVE list (showing `C X/Y ...` where X is
fixable and Y is total per severity, plus `Total: fixable/total`), missing fixable and Y is total per severity, plus `Total: fixable/total`), even if
important containers are reported, and every other container is summarised as there are no CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important
`fixable/total`. Without `IMPORTANT_CONTAINERS`, a single aggregated containers are reported, and every other container is summarised in chunks
as `fixable/total`. Without `IMPORTANT_CONTAINERS`, a single aggregated
notification lists each CVE with the affected packages, containers, and notification lists each CVE with the affected packages, containers, and
images. images.
@@ -258,11 +259,13 @@ When the list is set, notifications are split into three groups:
1. **One notification per important container**, titled 1. **One notification per important container**, titled
`Vulnerability scan: <container> (<image>)`. It lists every CVE at or above `Vulnerability scan: <container> (<image>)`. It lists every CVE at or above
`NOTIFY_MIN_SEVERITY`, with a total split into fixable and unfixable. `NOTIFY_MIN_SEVERITY` with `C X/Y H X/Y M X/Y L X/Y` and `Total: fixable/total`.
A notification is still sent if there are none, showing totals at 0/0.
2. **One notification for important containers that are missing** – either not 2. **One notification for important containers that are missing** – either not
running, or running an image that was not part of the scan. running, or running an image that was not part of the scan.
3. **One summary notification for every other container**, listing the image, 3. **One summary notification for every other container**, split every ~12
container, total CVE count, and the fixable/unfixable split. containers (with per-part and overall totals), listing image/container and
`fixable/total`.
A container can appear in only the important list or the summary, never both. A container can appear in only the important list or the summary, never both.
If an important container shares its image with other containers, the others If an important container shares its image with other containers, the others
+41 -10
View File
@@ -973,21 +973,22 @@ def build_important_body(
) -> str: ) -> str:
"""Full CVE list for one important container, with fixable/unfixable counts.""" """Full CVE list for one important container, with fixable/unfixable counts."""
entries = cve_entries(result, severity_rank(min_severity)) entries = cve_entries(result, severity_rank(min_severity))
total, fixable, unfixable = split_counts(entries)
counts = severity_counts(entries) counts = severity_counts(entries)
fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES} fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES}
for entry in entries: for entry in entries:
if entry.fixable: if entry.fixable:
fixable_counts[entry.severity] += 1 fixable_counts[entry.severity] += 1
total_fixable = sum(1 for entry in entries if entry.fixable)
total_all = len(entries)
lines = [ lines = [
f"Image: {target.image}", f"Image: {target.image}",
f"Container: {container}", f"Container: {container}",
" | ".join( " ".join(
f"{name[0]} {fixable_counts[name]}/{counts[name]}" f"{name[0]} {fixable_counts[name]}/{counts[name]}"
for name in REPORTED_SEVERITIES for name in REPORTED_SEVERITIES
), ),
f"Total: {fixable}/{total} (fixable/total)", f"Total: {total_fixable}/{total_all}",
"", "",
"CVEs:", "CVEs:",
] ]
@@ -1001,6 +1002,33 @@ def build_important_body(
return "\n".join(lines) return "\n".join(lines)
def build_important_summary_body(
container: str, target: ScanTarget, result: ScanResult, min_severity: str
) -> str:
"""Summary for an important container with no findings above threshold."""
entries = cve_entries(result, severity_rank(min_severity))
counts = severity_counts(entries)
fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES}
for entry in entries:
if entry.fixable:
fixable_counts[entry.severity] += 1
total_fixable = sum(1 for entry in entries if entry.fixable)
total_all = len(entries)
lines = [
f"Image: {target.image}",
f"Container: {container}",
" ".join(
f"{name[0]} {fixable_counts[name]}/{counts[name]}"
for name in REPORTED_SEVERITIES
),
f"Total: {total_fixable}/{total_all}",
"",
"No CVEs at or above the notification threshold.",
]
return "\n".join(lines)
def build_other_containers_body( def build_other_containers_body(
rows: list[tuple[str, str, int, int, int]], rows: list[tuple[str, str, int, int, int]],
) -> str: ) -> str:
@@ -1348,22 +1376,25 @@ def send_notifications(
continue # scan failure; already reported by scan_image continue # scan failure; already reported by scan_image
entries = cve_entries(result, settings.notify_rank) entries = cve_entries(result, settings.notify_rank)
if not entries: report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json")
if entries:
body = build_important_body(
name, target, result, report_path, settings.notify_min_severity
)
else:
log( log(
"DEBUG", "DEBUG",
f"Important container {name} ({image}) has no findings at or above " f"Important container {name} ({image}) has no findings at or above "
f"{settings.notify_min_severity}", f"{settings.notify_min_severity}",
) )
continue body = build_important_summary_body(
report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json") name, target, result, settings.notify_min_severity
body = build_important_body( )
name, target, result, report_path, settings.notify_min_severity
)
title = f"{settings.apprise_title_prefix}: {name} ({image})" title = f"{settings.apprise_title_prefix}: {name} ({image})"
log( log(
"DEBUG", "DEBUG",
f"Preparing important-container notification for {name}: " f"Preparing important-container notification for {name}: "
f"{len(entries)} CVE(s)", f"{len(entries)} CVE(s) at or above threshold",
) )
send_notification_parts(settings, stats, title, body) send_notification_parts(settings, stats, title, body)
+14 -14
View File
@@ -475,8 +475,8 @@ class ReportTests(unittest.TestCase):
) )
self.assertIn("Image: nginx:1.27", body) self.assertIn("Image: nginx:1.27", body)
self.assertIn("Container: web", body) self.assertIn("Container: web", body)
self.assertIn("C 1/1 | H 1/2 | M 0/0 | L 0/0", body) self.assertIn("C 1/1 H 1/2 M 0/0 L 0/0", body)
self.assertIn("Total: 2/3 (fixable/total)", body) self.assertIn("Total: 2/3", body)
self.assertIn("CVE-1", body) self.assertIn("CVE-1", body)
self.assertIn("CVE-2", body) self.assertIn("CVE-2", body)
self.assertIn("CVE-5", body) self.assertIn("CVE-5", body)
@@ -492,8 +492,8 @@ class ReportTests(unittest.TestCase):
self.assertIn("CVE-1", body) self.assertIn("CVE-1", body)
self.assertNotIn("CVE-2", body) self.assertNotIn("CVE-2", body)
self.assertNotIn("CVE-5", body) self.assertNotIn("CVE-5", body)
self.assertIn("C 1/1 | H 0/0 | M 0/0 | L 0/0", body) self.assertIn("C 1/1 H 0/0 M 0/0 L 0/0", body)
self.assertIn("Total: 1/1 (fixable/total)", body) self.assertIn("Total: 1/1", body)
def test_build_other_containers_body_has_counts_per_container(self) -> None: def test_build_other_containers_body_has_counts_per_container(self) -> None:
bodies = scan.build_other_containers_bodies( bodies = scan.build_other_containers_bodies(
@@ -631,18 +631,18 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
self.assertTrue(os.path.exists(os.path.join(self.env["REPORT_DIR"], "testphp-php.json"))) 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()) self.assertIn("testphp-php", Path(self.apprise_log).read_text())
def test_scan_logs_containers_using_the_image(self) -> None: def test_important_container_always_notified_when_clean(self) -> None:
containers = [container("a" * 64, "testphp-php", name="testphp")] containers = [container("a" * 64, "alpine:3.20", name="web")]
with FakeDocker(containers) as docker: with FakeDocker(containers) as docker:
stderr = io.StringIO() code = self.run_main(
with mock.patch.object(scan, "MIN_LOG_LEVEL", "INFO"): [], docker.socket_path, IMPORTANT_CONTAINERS="web", FAKE_TRIVY_SEVERITY="LOW"
with redirect_stderr(stderr): )
code = self.run_main([], docker.socket_path)
self.assertEqual(code, 1) self.assertEqual(code, 0)
self.assertIn( notification = Path(self.apprise_log).read_text()
"Scanning testphp-php (used by container testphp)", stderr.getvalue() self.assertIn("Vulnerability scan: web (alpine:3.20)", notification)
) self.assertIn("Total: 0/0", notification)
self.assertIn("No CVEs at or above", notification)
def test_notification_includes_container(self) -> None: def test_notification_includes_container(self) -> None:
containers = [container("a" * 64, "testphp-php", name="testphp")] containers = [container("a" * 64, "testphp-php", name="testphp")]