diff --git a/README.md b/README.md index 5fc557c..1436b77 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ host only needs Docker. JSON reports are kept on disk for auditing. - `FAIL_ON` (default `HIGH`) – when the script should exit with code `1` (useful for CI). 3. Notifications are sent. When `IMPORTANT_CONTAINERS` is set, each named - container gets its own complete CVE list plus a fenced monospaced table of - image/container severity counts (`C`, `H`, `M`, `L`), even if there are no - CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important containers are - reported, every other container is summarised in chunks using the same - table, and one final notification lists the important packages plus the + container with findings at or above `NOTIFY_MIN_SEVERITY` gets its own + complete CVE list plus a fenced monospaced table of image/container + severity counts (`C`, `H`, `M`, `L`); clean containers appear only in the + final summary. Missing important containers are reported, every other + container is summarised in chunks using the same table (one row per + image), and one final notification lists the important packages plus the overall totals. Columns below `NOTIFY_MIN_SEVERITY` are omitted. Without `IMPORTANT_CONTAINERS`, a single aggregated notification lists each CVE with the affected packages, containers, and images. @@ -262,20 +263,24 @@ When the list is set, notifications are split into four groups: `Vulnerability scan: ()`. It lists every CVE at or above `NOTIFY_MIN_SEVERITY`, with the image/container and severity counts shown in a padded fenced monospaced table (`Image`, `Container`, `C`, `H`, `M`, `L`). - A notification is still sent if there are none, showing totals at 0/0. + Containers with no CVEs at or above the threshold get no individual + notification; they still appear with 0/0 totals in the final summary. 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**, split every ~12 - containers, showing the same fenced table of image/container and + images, showing the same fenced table of image/container and `fixable/total` severity counts. 4. **One final summary notification** listing the important containers in the same table, their packages, followed by the overall severity counts and - `fixable/total in N container(s)`. + `fixable/total in N image(s)`. Severity columns below `NOTIFY_MIN_SEVERITY` are omitted from the tables (for example, only `C` and `H` are shown when it is `HIGH`). Image digests are shortened to their first six characters in notification text; report filenames -keep the full reference. +keep the full reference. Table rows are sorted alphabetically by container, +then by image, and there is one row per image: containers that share an image +are merged into a single row listing all of them, so their counts are not +repeated. 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 e1c2da1..04fddb2 100755 --- a/scan.py +++ b/scan.py @@ -1000,7 +1000,9 @@ def summary_table( """Render image/container/severity counts as a monospaced markdown table.""" headers = ["Image", "Container"] + [name[0] for name in severities] table_rows: list[list[str]] = [] - for image, container, counts, fixable_counts in rows: + for image, container, counts, fixable_counts in sorted( + rows, key=lambda row: (row[1].casefold(), row[0].casefold()) + ): cells = [short_image(image), container or "-"] cells += [ f"{fixable_counts.get(name, 0)}/{counts.get(name, 0)}" @@ -1061,28 +1063,6 @@ 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 - - severities = visible_severities(severity_rank(min_severity)) - lines = [ - summary_table( - [(target.image, container, counts, fixable_counts)], severities - ), - "", - "No CVEs at or above the notification threshold.", - ] - return "\n".join(lines) - - def _chunk_list(items: list[object], size: int) -> list[list[object]]: if size <= 0: return [items] @@ -1093,7 +1073,7 @@ def build_other_containers_bodies( rows: list, rank: int, ) -> list[str]: - """Split the summary by container groups so each message stays small.""" + """Split the summary by image groups so each message stays small.""" if not rows: return [] severities = visible_severities(rank) @@ -1124,7 +1104,21 @@ def build_final_summary_body( severities = visible_severities(severity_rank(min_severity)) table_rows: list[tuple[str, str, dict[str, int], dict[str, int]]] = [] package_lines: list[str] = [] + # Merge important containers that share an image into one row/package line. + by_image: dict[str, tuple[list[str], ScanTarget, ScanResult]] = {} for name, target, result in important_rows: + if target.image in by_image: + by_image[target.image][0].append(name) + else: + by_image[target.image] = ([name], target, result) + groups = sorted( + ( + (", ".join(sorted(names, key=str.casefold)), target, result) + for names, target, result in by_image.values() + ), + key=lambda group: (group[0].casefold(), group[1].image.casefold()), + ) + for name, target, result in groups: entries = cve_entries(result, severity_rank(min_severity)) counts = severity_counts(entries) fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES} @@ -1168,7 +1162,7 @@ def build_final_summary_body( lines += [ "", f"Overall severity: {severity_text(overall_counts, overall_fixable_counts, severities)}", - f"Overall: {overall_fixable}/{overall_total} fixable/total in {len(all_rows)} container(s)", + f"Overall: {overall_fixable}/{overall_total} fixable/total in {len(all_rows)} image(s)", ] return "\n".join(lines) @@ -1451,7 +1445,11 @@ def build_container_rows( skip: set[str] | None = None, include_empty: bool = False, ) -> list: - """Build ``(image, container, total, fixable, unfixable, counts, fixable_counts)`` rows.""" + """Build ``(image, containers, total, fixable, unfixable, counts, fixable_counts)`` rows. + + One row per image: containers that share the image are merged into a single + row so its counts are not repeated. + """ skip = skip or set() rows: list = [] for target in targets: @@ -1467,12 +1465,17 @@ def build_container_rows( for entry in entries: if entry.fixable: fixable_counts[entry.severity] += 1 - for container in target.containers or [""]: - if container and container in skip: + if target.containers: + containers = [name for name in target.containers if name not in skip] + if not containers: continue - rows.append( - (target.image, container, total, fixable, unfixable, counts, fixable_counts) - ) + container = ", ".join(sorted(containers, key=str.casefold)) + else: + container = "" + rows.append( + (target.image, container, total, fixable, unfixable, counts, fixable_counts) + ) + rows.sort(key=lambda row: (row[1].casefold(), row[0].casefold())) return rows @@ -1514,20 +1517,19 @@ def send_notifications( important_rows.append((name, target, result)) entries = cve_entries(result, settings.notify_rank) - 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: + if not entries: + # No individual notification for clean containers; the final + # summary table already lists them with 0/0 totals. log( "DEBUG", f"Important container {name} ({short_image(image)}) has no findings at or above " f"{settings.notify_min_severity}", ) - body = build_important_summary_body( - name, target, result, 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 + ) title = f"{settings.apprise_title_prefix}: {name} ({short_image(image)})" log( "DEBUG", diff --git a/tests/test_scan.py b/tests/test_scan.py index b786ba6..5aa9c86 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -559,9 +559,28 @@ class ReportTests(unittest.TestCase): self.assertIn("openssl 3.0.1 -> 3.0.2", body) self.assertIn("Overall severity: C 1/1 H 1/2", body) self.assertNotIn("Overall severity: C 1/1 H 1/2 M", body) - self.assertIn("Overall: 2/3 fixable/total in 1 container(s)", body) + self.assertIn("Overall: 2/3 fixable/total in 1 image(s)", body) self.assertNotIn("Total:", body) + def test_build_final_summary_body_merges_important_containers_per_image(self) -> None: + result = scan.summarise(REPORT) + target = scan.ScanTarget(image="nginx:1.27", containers=["web", "worker"]) + aggregated = scan.AggregatedReport() + aggregated.results["nginx:1.27"] = result + all_rows = scan.build_container_rows( + [target], aggregated, scan.severity_rank("HIGH"), include_empty=True + ) + body = scan.build_final_summary_body( + [("worker", target, result), ("web", target, result)], all_rows, "HIGH" + ) + # Both important containers share one image, so one table row and one + # package line, listing both names. + self.assertRegex(body, table_row_pattern(["nginx:1.27", "web, worker", "1/1", "1/2"])) + self.assertIn("* web, worker: ", body) + self.assertNotIn("* worker: ", body) + self.assertNotIn("* web: ", body) + self.assertIn("Overall: 2/3 fixable/total in 1 image(s)", body) + def test_summary_table_pads_every_column(self) -> None: table = scan.summary_table( [ @@ -587,6 +606,54 @@ class ReportTests(unittest.TestCase): ), ) + def test_summary_table_sorts_by_container_then_image(self) -> None: + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + table = scan.summary_table( + [ + ("nginx:1.27", "web", counts, counts), + ("redis:7", "cache", counts, counts), + ("nginx:1.25", "web", counts, counts), + ], + ["CRITICAL", "HIGH"], + ) + self.assertEqual( + table, + "\n".join( + [ + "```", + "| Image | Container | C | H |", + "| ---------- | --------- | --- | --- |", + "| redis:7 | cache | 0/0 | 0/0 |", + "| nginx:1.25 | web | 0/0 | 0/0 |", + "| nginx:1.27 | web | 0/0 | 0/0 |", + "```", + ] + ), + ) + + def test_build_container_rows_merges_containers_per_image(self) -> None: + aggregated = scan.AggregatedReport() + for image in ("redis:7", "nginx:1.27", "alpine:3.20"): + aggregated.results[image] = scan.summarise(REPORT) + targets = [ + scan.ScanTarget(image="redis:7", containers=["cache"]), + scan.ScanTarget(image="nginx:1.27", containers=["web", "cache", "worker"]), + scan.ScanTarget(image="alpine:3.20"), + ] + rows = scan.build_container_rows( + targets, aggregated, scan.severity_rank("HIGH"), skip={"worker"} + ) + # One row per image, containers joined and sorted; images without + # containers keep an empty container cell. + self.assertEqual( + [(row[0], row[1]) for row in rows], + [ + ("alpine:3.20", ""), + ("redis:7", "cache"), + ("nginx:1.27", "cache, web"), + ], + ) + def test_build_missing_body_separates_missing_and_unscanned(self) -> None: body = scan.build_missing_body(["gone"], ["web"]) self.assertIn("not running", body) @@ -710,7 +777,7 @@ 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_important_container_always_notified_when_clean(self) -> None: + def test_clean_important_container_only_in_final_summary(self) -> None: containers = [container("a" * 64, "alpine:3.20", name="web")] with FakeDocker(containers) as docker: code = self.run_main( @@ -719,12 +786,14 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG" self.assertEqual(code, 0) notification = Path(self.apprise_log).read_text() - self.assertIn("Vulnerability scan: web (alpine:3.20)", notification) + # No individual notification for a clean important container. + self.assertNotIn("Vulnerability scan: web (alpine:3.20)", notification) + self.assertNotIn("No CVEs at or above", notification) self.assertNotIn("Total:", notification) - self.assertIn("No CVEs at or above", notification) + # The final summary still lists it with 0/0 totals. self.assertIn("Vulnerability scan: summary", notification) self.assertRegex(notification, table_row_pattern(["alpine:3.20", "web", "0/0", "0/0"])) - self.assertIn("Overall: 0/0 fixable/total in 1 container(s)", notification) + self.assertIn("Overall: 0/0 fixable/total in 1 image(s)", notification) def test_notification_includes_container(self) -> None: containers = [container("a" * 64, "testphp-php", name="testphp")] @@ -755,10 +824,31 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG" self.assertRegex(notification, table_row_pattern(["redis:7", "cache", "1/1", "0/0"])) self.assertNotIn("Total:", notification) self.assertIn("Vulnerability scan: summary", notification) - self.assertIn("Overall: 2/2 fixable/total in 2 container(s)", notification) + self.assertIn("Overall: 2/2 fixable/total in 2 image(s)", notification) # The important container must not also appear in the summary. self.assertNotIn("testphp-php [web]", notification) + def test_containers_sharing_an_image_share_one_table_row(self) -> None: + containers = [ + container("a" * 64, "testphp-php", name="web"), + container("b" * 64, "testphp-php", name="web2"), + container("c" * 64, "redis:7", name="cache"), + ] + with FakeDocker(containers) as docker: + code = self.run_main([], docker.socket_path, IMPORTANT_CONTAINERS="cache") + + self.assertEqual(code, 1) + notification = Path(self.apprise_log).read_text() + self.assertIn("Vulnerability scan: cache (redis:7)", notification) + self.assertIn("Vulnerability scan: other containers", notification) + # Both containers on the same image are merged into a single row. + self.assertRegex( + notification, table_row_pattern(["testphp-php", "web, web2", "1/1", "0/0"]) + ) + self.assertIn("Vulnerability scan: summary", notification) + self.assertRegex(notification, table_row_pattern(["redis:7", "cache", "1/1", "0/0"])) + self.assertIn("Overall: 2/2 fixable/total in 2 image(s)", notification) + def test_missing_important_container_is_reported(self) -> None: containers = [container("a" * 64, "testphp-php", name="web")] with FakeDocker(containers) as docker: