This commit is contained in:
David Duran Rosich committed 2026-10-25 11:44:58 +01:00
1 parent 77d064627a
commit 0fe06f2ba5
3 files changed
+68 -22

No files matched your search

+1 -1
View File
@@ -261,7 +261,7 @@ When the list is set, notifications are split into four 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 the image/container and severity counts shown in `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. 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.
+19 -6
View File
@@ -999,18 +999,31 @@ def summary_table(
) -> str: ) -> str:
"""Render image/container/severity counts as a monospaced markdown table.""" """Render image/container/severity counts as a monospaced markdown table."""
headers = ["Image", "Container"] + [name[0] for name in severities] headers = ["Image", "Container"] + [name[0] for name in severities]
lines = [ table_rows: list[list[str]] = []
"```",
"| " + " | ".join(headers) + " |",
"| " + " | ".join("---" for _ in headers) + " |",
]
for image, container, counts, fixable_counts in rows: for image, container, counts, fixable_counts in rows:
cells = [short_image(image), container or "-"] cells = [short_image(image), container or "-"]
cells += [ cells += [
f"{fixable_counts.get(name, 0)}/{counts.get(name, 0)}" f"{fixable_counts.get(name, 0)}/{counts.get(name, 0)}"
for name in severities 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("```") lines.append("```")
return "\n".join(lines) return "\n".join(lines)
+48 -15
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import io import io
import json import json
import os import os
import re
import shutil import shutil
import socketserver import socketserver
import subprocess import subprocess
@@ -85,6 +86,11 @@ def container(
return entry 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): class DockerDiscoveryTests(unittest.TestCase):
def test_images_in_use_returns_running_images(self) -> None: def test_images_in_use_returns_running_images(self) -> None:
with FakeDocker([container("a" * 64, "testphp-php"), container("b" * 64, "redis:7")]) as docker: 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( body = scan.build_important_body(
"web", target, result, "/reports/x.json", "HIGH" "web", target, result, "/reports/x.json", "HIGH"
) )
self.assertIn("| Image | Container | C | H |", body) self.assertRegex(body, table_row_pattern(["Image", "Container", "C", "H"]))
self.assertIn("| nginx:1.27 | web | 1/1 | 1/2 |", body) self.assertRegex(body, table_row_pattern(["nginx:1.27", "web", "1/1", "1/2"]))
self.assertNotIn("| M |", body) self.assertNotRegex(body, r"\|\s*M\s*\|")
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)
@@ -507,9 +513,9 @@ 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("| Image | Container | C |", body) self.assertRegex(body, table_row_pattern(["Image", "Container", "C"]))
self.assertIn("| nginx:1.27 | web | 1/1 |", body) self.assertRegex(body, table_row_pattern(["nginx:1.27", "web", "1/1"]))
self.assertNotIn("| H |", body) self.assertNotRegex(body, r"\|\s*H\s*\|")
self.assertNotIn("Total:", body) self.assertNotIn("Total:", 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:
@@ -522,10 +528,12 @@ class ReportTests(unittest.TestCase):
) )
body = bodies[0] body = bodies[0]
self.assertIn("Other containers with findings", body) self.assertIn("Other containers with findings", body)
self.assertIn("| Image | Container | C | H |", body) self.assertRegex(body, table_row_pattern(["Image", "Container", "C", "H"]))
self.assertIn("| nginx:1.27 | web-worker | 1/1 | 1/2 |", body) self.assertRegex(
self.assertIn("| redis:7 | cache | 0/0 | 0/4 |", body) body, table_row_pattern(["nginx:1.27", "web-worker", "1/1", "1/2"])
self.assertNotIn("| M |", body) )
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("Total:", body)
self.assertNotIn("Overall:", body) self.assertNotIn("Overall:", body)
@@ -544,8 +552,8 @@ class ReportTests(unittest.TestCase):
[("web", target, result)], all_rows, "HIGH" [("web", target, result)], all_rows, "HIGH"
) )
self.assertIn("Important containers:", body) self.assertIn("Important containers:", body)
self.assertIn("| Image | Container | C | H |", body) self.assertRegex(body, table_row_pattern(["Image", "Container", "C", "H"]))
self.assertIn("| nginx:1.27 | web | 1/1 | 1/2 |", body) self.assertRegex(body, table_row_pattern(["nginx:1.27", "web", "1/1", "1/2"]))
self.assertIn("Packages:", body) self.assertIn("Packages:", body)
self.assertIn("libc 1.0 -> 1.1", body) self.assertIn("libc 1.0 -> 1.1", body)
self.assertIn("openssl 3.0.1 -> 3.0.2", 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.assertIn("Overall: 2/3 fixable/total in 1 container(s)", body)
self.assertNotIn("Total:", 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: def test_build_missing_body_separates_missing_and_unscanned(self) -> None:
body = scan.build_missing_body(["gone"], ["web"]) body = scan.build_missing_body(["gone"], ["web"])
self.assertIn("not running", body) self.assertIn("not running", body)
@@ -690,7 +723,7 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
self.assertNotIn("Total:", notification) self.assertNotIn("Total:", notification)
self.assertIn("No CVEs at or above", notification) self.assertIn("No CVEs at or above", notification)
self.assertIn("Vulnerability scan: summary", 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) self.assertIn("Overall: 0/0 fixable/total in 1 container(s)", notification)
def test_notification_includes_container(self) -> None: def test_notification_includes_container(self) -> None:
@@ -716,10 +749,10 @@ 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("Vulnerability scan: web (testphp-php)", notification) 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("CVE-2026-0001", notification)
self.assertIn("Vulnerability scan: other containers", 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.assertNotIn("Total:", notification)
self.assertIn("Vulnerability scan: summary", 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 container(s)", notification)