diff --git a/README.md b/README.md index 20820c5..5fc557c 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ When the list is set, notifications are split into four groups: 1. **One notification per important container**, titled `Vulnerability scan: ()`. It lists every CVE at or above `NOTIFY_MIN_SEVERITY`, with the image/container and severity counts shown in - a fenced monospaced table (`Image`, `Container`, `C`, `H`, `M`, `L`). + 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. 2. **One notification for important containers that are missing** – either not running, or running an image that was not part of the scan. diff --git a/scan.py b/scan.py index 02cbffc..e1c2da1 100755 --- a/scan.py +++ b/scan.py @@ -999,18 +999,31 @@ def summary_table( ) -> str: """Render image/container/severity counts as a monospaced markdown table.""" headers = ["Image", "Container"] + [name[0] for name in severities] - lines = [ - "```", - "| " + " | ".join(headers) + " |", - "| " + " | ".join("---" for _ in headers) + " |", - ] + table_rows: list[list[str]] = [] for image, container, counts, fixable_counts in rows: cells = [short_image(image), container or "-"] cells += [ f"{fixable_counts.get(name, 0)}/{counts.get(name, 0)}" for name in severities ] - lines.append("| " + " | ".join(cells) + " |") + table_rows.append(cells) + + widths = [max(3, len(header)) for header in headers] + for cells in table_rows: + for index, cell in enumerate(cells): + widths[index] = max(widths[index], len(cell)) + + def render(cells: list[str]) -> str: + return "| " + " | ".join( + cell.ljust(widths[index]) for index, cell in enumerate(cells) + ) + " |" + + lines = [ + "```", + render(headers), + "| " + " | ".join("-" * width for width in widths) + " |", + ] + lines += [render(cells) for cells in table_rows] lines.append("```") return "\n".join(lines) diff --git a/tests/test_scan.py b/tests/test_scan.py index d644485..b786ba6 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -9,6 +9,7 @@ from __future__ import annotations import io import json import os +import re import shutil import socketserver import subprocess @@ -85,6 +86,11 @@ def container( return entry +def table_row_pattern(cells: list[str]) -> str: + """Regex matching a table row regardless of column padding.""" + return r"\|\s*" + r"\s*\|\s*".join(re.escape(cell) for cell in cells) + r"\s*\|" + + class DockerDiscoveryTests(unittest.TestCase): def test_images_in_use_returns_running_images(self) -> None: with FakeDocker([container("a" * 64, "testphp-php"), container("b" * 64, "redis:7")]) as docker: @@ -489,9 +495,9 @@ class ReportTests(unittest.TestCase): body = scan.build_important_body( "web", target, result, "/reports/x.json", "HIGH" ) - self.assertIn("| Image | Container | C | H |", body) - self.assertIn("| nginx:1.27 | web | 1/1 | 1/2 |", body) - self.assertNotIn("| M |", body) + self.assertRegex(body, table_row_pattern(["Image", "Container", "C", "H"])) + self.assertRegex(body, table_row_pattern(["nginx:1.27", "web", "1/1", "1/2"])) + self.assertNotRegex(body, r"\|\s*M\s*\|") self.assertIn("CVE-1", body) self.assertIn("CVE-2", body) self.assertIn("CVE-5", body) @@ -507,9 +513,9 @@ class ReportTests(unittest.TestCase): self.assertIn("CVE-1", body) self.assertNotIn("CVE-2", body) self.assertNotIn("CVE-5", body) - self.assertIn("| Image | Container | C |", body) - self.assertIn("| nginx:1.27 | web | 1/1 |", body) - self.assertNotIn("| H |", body) + self.assertRegex(body, table_row_pattern(["Image", "Container", "C"])) + self.assertRegex(body, table_row_pattern(["nginx:1.27", "web", "1/1"])) + self.assertNotRegex(body, r"\|\s*H\s*\|") self.assertNotIn("Total:", body) def test_build_other_containers_body_has_counts_per_container(self) -> None: @@ -522,10 +528,12 @@ class ReportTests(unittest.TestCase): ) body = bodies[0] self.assertIn("Other containers with findings", body) - self.assertIn("| Image | Container | C | H |", body) - self.assertIn("| nginx:1.27 | web-worker | 1/1 | 1/2 |", body) - self.assertIn("| redis:7 | cache | 0/0 | 0/4 |", body) - self.assertNotIn("| M |", body) + self.assertRegex(body, table_row_pattern(["Image", "Container", "C", "H"])) + self.assertRegex( + body, table_row_pattern(["nginx:1.27", "web-worker", "1/1", "1/2"]) + ) + self.assertRegex(body, table_row_pattern(["redis:7", "cache", "0/0", "0/4"])) + self.assertNotRegex(body, r"\|\s*M\s*\|") self.assertNotIn("Total:", body) self.assertNotIn("Overall:", body) @@ -544,8 +552,8 @@ class ReportTests(unittest.TestCase): [("web", target, result)], all_rows, "HIGH" ) self.assertIn("Important containers:", body) - self.assertIn("| Image | Container | C | H |", body) - self.assertIn("| nginx:1.27 | web | 1/1 | 1/2 |", body) + self.assertRegex(body, table_row_pattern(["Image", "Container", "C", "H"])) + self.assertRegex(body, table_row_pattern(["nginx:1.27", "web", "1/1", "1/2"])) self.assertIn("Packages:", body) self.assertIn("libc 1.0 -> 1.1", body) self.assertIn("openssl 3.0.1 -> 3.0.2", body) @@ -554,6 +562,31 @@ class ReportTests(unittest.TestCase): self.assertIn("Overall: 2/3 fixable/total in 1 container(s)", body) self.assertNotIn("Total:", body) + def test_summary_table_pads_every_column(self) -> None: + table = scan.summary_table( + [ + ( + "nginx:1.27", + "web", + {"CRITICAL": 1, "HIGH": 2, "MEDIUM": 0, "LOW": 0}, + {"CRITICAL": 1, "HIGH": 1, "MEDIUM": 0, "LOW": 0}, + ) + ], + ["CRITICAL", "HIGH"], + ) + self.assertEqual( + table, + "\n".join( + [ + "```", + "| Image | Container | C | H |", + "| ---------- | --------- | --- | --- |", + "| nginx:1.27 | web | 1/1 | 1/2 |", + "```", + ] + ), + ) + def test_build_missing_body_separates_missing_and_unscanned(self) -> None: body = scan.build_missing_body(["gone"], ["web"]) self.assertIn("not running", body) @@ -690,7 +723,7 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG" self.assertNotIn("Total:", notification) self.assertIn("No CVEs at or above", notification) self.assertIn("Vulnerability scan: summary", notification) - self.assertIn("| alpine:3.20 | web | 0/0 | 0/0 |", 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) def test_notification_includes_container(self) -> None: @@ -716,10 +749,10 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG" self.assertEqual(code, 1) notification = Path(self.apprise_log).read_text() self.assertIn("Vulnerability scan: web (testphp-php)", notification) - self.assertIn("| testphp-php | web | 1/1 | 0/0 |", notification) + self.assertRegex(notification, table_row_pattern(["testphp-php", "web", "1/1", "0/0"])) self.assertIn("CVE-2026-0001", notification) self.assertIn("Vulnerability scan: other containers", notification) - self.assertIn("| redis:7 | cache | 1/1 | 0/0 |", notification) + 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)