From 66808d77f0452623404a601cafa419deef751b77 Mon Sep 17 00:00:00 2001 From: David Duran Rosich Date: Sun, 25 Oct 2026 00:06:13 +0200 Subject: [PATCH] more changes! --- README.md | 15 ++++++++------ scan.py | 51 +++++++++++++++++++++++++++++++++++++--------- tests/test_scan.py | 28 ++++++++++++------------- 3 files changed, 64 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 6655aa9..13cc151 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ host only needs Docker. JSON reports are kept on disk for auditing. (useful for CI). 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 - fixable and Y is total per severity, plus `Total: fixable/total`), missing - important containers are reported, and every other container is summarised as - `fixable/total`. Without `IMPORTANT_CONTAINERS`, a single aggregated + fixable and Y is total per severity, plus `Total: fixable/total`), even if + there are no CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important + 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 images. @@ -258,11 +259,13 @@ When the list is set, notifications are split into three groups: 1. **One notification per important container**, titled `Vulnerability scan: ()`. 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 running, or running an image that was not part of the scan. -3. **One summary notification for every other container**, listing the image, - container, total CVE count, and the fixable/unfixable split. +3. **One summary notification for every other container**, split every ~12 + 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. If an important container shares its image with other containers, the others diff --git a/scan.py b/scan.py index a068273..5847259 100755 --- a/scan.py +++ b/scan.py @@ -973,21 +973,22 @@ def build_important_body( ) -> str: """Full CVE list for one important container, with fixable/unfixable counts.""" entries = cve_entries(result, severity_rank(min_severity)) - total, fixable, unfixable = split_counts(entries) 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( + " ".join( f"{name[0]} {fixable_counts[name]}/{counts[name]}" for name in REPORTED_SEVERITIES ), - f"Total: {fixable}/{total} (fixable/total)", + f"Total: {total_fixable}/{total_all}", "", "CVEs:", ] @@ -1001,6 +1002,33 @@ def build_important_body( 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( rows: list[tuple[str, str, int, int, int]], ) -> str: @@ -1348,22 +1376,25 @@ def send_notifications( continue # scan failure; already reported by scan_image 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( "DEBUG", f"Important container {name} ({image}) has no findings at or above " f"{settings.notify_min_severity}", ) - continue - report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json") - body = build_important_body( - name, target, result, report_path, settings.notify_min_severity - ) + body = build_important_summary_body( + name, target, result, settings.notify_min_severity + ) title = f"{settings.apprise_title_prefix}: {name} ({image})" log( "DEBUG", 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) diff --git a/tests/test_scan.py b/tests/test_scan.py index d52bc5a..96ec5c7 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -475,8 +475,8 @@ class ReportTests(unittest.TestCase): ) self.assertIn("Image: nginx:1.27", body) self.assertIn("Container: web", 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("C 1/1 H 1/2 M 0/0 L 0/0", body) + self.assertIn("Total: 2/3", body) self.assertIn("CVE-1", body) self.assertIn("CVE-2", body) self.assertIn("CVE-5", body) @@ -492,8 +492,8 @@ class ReportTests(unittest.TestCase): self.assertIn("CVE-1", body) self.assertNotIn("CVE-2", body) self.assertNotIn("CVE-5", 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("C 1/1 H 0/0 M 0/0 L 0/0", body) + self.assertIn("Total: 1/1", body) def test_build_other_containers_body_has_counts_per_container(self) -> None: 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.assertIn("testphp-php", Path(self.apprise_log).read_text()) - def test_scan_logs_containers_using_the_image(self) -> None: - containers = [container("a" * 64, "testphp-php", name="testphp")] + def test_important_container_always_notified_when_clean(self) -> None: + containers = [container("a" * 64, "alpine:3.20", name="web")] with FakeDocker(containers) as docker: - stderr = io.StringIO() - with mock.patch.object(scan, "MIN_LOG_LEVEL", "INFO"): - with redirect_stderr(stderr): - code = self.run_main([], docker.socket_path) + code = self.run_main( + [], docker.socket_path, IMPORTANT_CONTAINERS="web", FAKE_TRIVY_SEVERITY="LOW" + ) - self.assertEqual(code, 1) - self.assertIn( - "Scanning testphp-php (used by container testphp)", stderr.getvalue() - ) + self.assertEqual(code, 0) + notification = Path(self.apprise_log).read_text() + 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: containers = [container("a" * 64, "testphp-php", name="testphp")]