From 77d064627ad982126d398fa4b6be885151bfcc36 Mon Sep 17 00:00:00 2001 From: David Duran Rosich Date: Sun, 25 Oct 2026 11:34:15 +0100 Subject: [PATCH] formatting --- README.md | 32 +++-- scan.py | 290 ++++++++++++++++++++++++++++++--------------- tests/test_scan.py | 85 +++++++++---- 3 files changed, 283 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 13cc151..20820c5 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,14 @@ 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 (showing `C X/Y ...` where X is - 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. + 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 + 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. ## Quick start @@ -255,17 +256,26 @@ leave `IMAGES` empty unless you also want to scan specific images explicitly. IMPORTANT_CONTAINERS=traefik web-frontend ``` -When the list is set, notifications are split into three groups: +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 `C X/Y H X/Y M X/Y L X/Y` and `Total: fixable/total`. + `NOTIFY_MIN_SEVERITY`, with the image/container and severity counts shown in + a 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. 3. **One summary notification for every other container**, split every ~12 - containers (with per-part and overall totals), listing image/container and - `fixable/total`. + containers, 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)`. + +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. 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 7b4b6bc..02cbffc 100755 --- a/scan.py +++ b/scan.py @@ -17,6 +17,7 @@ import http.client import json import glob import os +import re import shutil import socket import stat @@ -107,6 +108,16 @@ def report_slug(image: str) -> str: return "".join(c if c.isalnum() or c in "_.-" else "_" for c in image) +_DIGEST_RE = re.compile(r"(sha[0-9]+:)([0-9a-fA-F]{7,})") + + +def short_image(image: str) -> str: + """Shorten image digest hashes to six characters for display.""" + return _DIGEST_RE.sub( + lambda match: f"{match.group(1)}{match.group(2)[:6]}", image + ) + + # --------------------------------------------------------------------------- # Docker Engine API discovery # --------------------------------------------------------------------------- @@ -758,7 +769,7 @@ def run_trivy(image: str, settings: Settings, context: str = "") -> tuple[int, s command.append("--ignore-unfixed") command.append(image) - log("INFO", f"Scanning {image}{context}") + log("INFO", f"Scanning {short_image(image)}{context}") try: completed = subprocess.run( command, @@ -767,14 +778,14 @@ def run_trivy(image: str, settings: Settings, context: str = "") -> tuple[int, s env=trivy_environment(settings), ) except OSError as exc: - log("ERROR", f"Scan failed: {image}{context}: {exc}") + log("ERROR", f"Scan failed: {short_image(image)}{context}: {exc}") return 1, "" if completed.returncode != 0: detail = completed.stderr.strip().splitlines() if detail: log("ERROR", detail[-1]) - log("ERROR", f"Scan failed: {image}{context}") + log("ERROR", f"Scan failed: {short_image(image)}{context}") return 1, "" return 0, report_path @@ -824,7 +835,7 @@ def build_body( max_findings: int, container_detail: str = "", ) -> str: - lines = [f"Image: {image}"] + lines = [f"Image: {short_image(image)}"] if container_detail: lines.append(container_detail) lines += [ @@ -933,7 +944,7 @@ def build_aggregated_body( lines.append(f"* [{cve.severity}] {cve.cve} - {pkg_text}") for affected in cve.affected[:2]: containers = ", ".join(affected.containers) if affected.containers else "unknown" - lines.append(f" - {containers} ({affected.image})") + lines.append(f" - {containers} ({short_image(affected.image)})") if len(cve.affected) > 2: lines.append(f" - +{len(cve.affected) - 2} more") @@ -964,6 +975,46 @@ def package_text(entry: CveEntry) -> str: return "; ".join(parts) if parts else "unknown" +def severity_text( + counts: dict[str, int], + fixable_counts: dict[str, int], + severities: tuple[str, ...] | list[str] = REPORTED_SEVERITIES, +) -> str: + return " ".join( + f"{name[0]} {fixable_counts.get(name, 0)}/{counts.get(name, 0)}" + for name in severities + ) + + +def visible_severities(rank: int) -> list[str]: + """Severity columns that are not filtered out by the notify threshold.""" + if rank <= 0: + return list(REPORTED_SEVERITIES) + return [name for name in REPORTED_SEVERITIES if severity_rank(name) >= rank] + + +def summary_table( + rows: list[tuple[str, str, dict[str, int], dict[str, int]]], + severities: list[str] | tuple[str, ...], +) -> 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) + " |", + ] + 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) + " |") + lines.append("```") + return "\n".join(lines) + + def build_important_body( container: str, target: ScanTarget, @@ -978,17 +1029,12 @@ def build_important_body( 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) + severities = visible_severities(severity_rank(min_severity)) lines = [ - f"Image: {target.image}", - f"Container: {container}", - " ".join( - f"{name[0]} {fixable_counts[name]}/{counts[name]}" - for name in REPORTED_SEVERITIES + summary_table( + [(target.image, container, counts, fixable_counts)], severities ), - f"Total: {total_fixable}/{total_all}", "", "CVEs:", ] @@ -1012,38 +1058,18 @@ def build_important_summary_body( 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) + severities = visible_severities(severity_rank(min_severity)) lines = [ - f"Image: {target.image}", - f"Container: {container}", - " ".join( - f"{name[0]} {fixable_counts[name]}/{counts[name]}" - for name in REPORTED_SEVERITIES + summary_table( + [(target.image, container, counts, fixable_counts)], 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: - """Summarise containers that are not marked important. - - Each row is ``(image, container, total, fixable, unfixable)``. - """ - lines = ["Other containers with findings:", ""] - for image, container, total, fixable, unfixable in rows: - label = f"{image} [{container}]" if container else image - lines.append(f"* {label}: {fixable}/{total} (fixable/total)") - lines += ["", f"Total: {sum(row[2] for row in rows)} CVE(s) in {len(rows)} container(s)"] - return "\n".join(lines) - - def _chunk_list(items: list[object], size: int) -> list[list[object]]: if size <= 0: return [items] @@ -1052,44 +1078,88 @@ def _chunk_list(items: list[object], size: int) -> list[list[object]]: def build_other_containers_bodies( rows: list, + rank: int, ) -> list[str]: """Split the summary by container groups so each message stays small.""" if not rows: return [] + severities = visible_severities(rank) bodies: list[str] = [] for chunk in _chunk_list(rows, 12): - lines = ["Other containers with findings:", ""] + table_rows = [] for row in chunk: - if len(row) == 7: - image, container, total_all, fixable_all, unfixable_all, counts, fixable_counts = row - else: - image, container, total_all, fixable_all, unfixable_all = row - counts = {s: 0 for s in REPORTED_SEVERITIES} - fixable_counts = {s: 0 for s in REPORTED_SEVERITIES} - label = f"{image} [{container}]" if container else image - lines.append(label) - lines.append( - " ".join( - f"{name[0]} {fixable_counts.get(name, 0)}/{counts.get(name, 0)}" - for name in REPORTED_SEVERITIES - ) + image, container, _, _, _, counts, fixable_counts = row + table_rows.append((image, container, counts, fixable_counts)) + bodies.append( + "\n".join( + [ + "Other containers with findings:", + "", + summary_table(table_rows, severities), + ] ) - lines.append(f"Total: {fixable_all}/{total_all}") - lines.append("") - # Remove trailing blank line - if lines and lines[-1] == "": - lines.pop() - total_cves = sum(row[2] for row in chunk) - fixable_cves = sum(row[3] for row in chunk) - lines += ["", f"Total: {fixable_cves}/{total_cves} in {len(chunk)} container(s)"] - bodies.append("\n".join(lines)) - if len(bodies) > 1: - overall_fixable = sum(row[3] for row in rows) - overall_total = sum(row[2] for row in rows) - bodies[-1] = bodies[-1] + f"\n\nOverall: {overall_fixable}/{overall_total} in {len(rows)} container(s)" + ) return bodies +def build_final_summary_body( + important_rows: list[tuple[str, ScanTarget, ScanResult]], + all_rows: list, + min_severity: str, +) -> str: + """One notification at the end: important packages and overall totals.""" + severities = visible_severities(severity_rank(min_severity)) + table_rows: list[tuple[str, str, dict[str, int], dict[str, int]]] = [] + package_lines: list[str] = [] + for name, target, result in important_rows: + 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 + table_rows.append((target.image, name, counts, fixable_counts)) + packages: list[str] = [] + for entry in entries: + for pkg, installed, fixed in entry.packages: + if pkg: + text = f"{pkg} {installed} -> {fixed or 'no fix available'}" + elif installed or fixed: + text = f"{installed} -> {fixed or 'no fix available'}" + else: + continue + if text not in packages: + packages.append(text) + if packages: + package_lines.append(f"* {name}: " + "; ".join(packages)) + + lines = ["Important containers:", ""] + if table_rows: + lines.append(summary_table(table_rows, severities)) + else: + lines.append("* none") + if package_lines: + lines += ["", "Packages:"] + package_lines + + overall_total = sum(row[2] for row in all_rows) + overall_fixable = sum(row[3] for row in all_rows) + overall_counts = {severity: 0 for severity in REPORTED_SEVERITIES} + overall_fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES} + for row in all_rows: + counts = row[5] if len(row) > 5 else {} + fixable_counts = row[6] if len(row) > 6 else {} + for severity in REPORTED_SEVERITIES: + overall_counts[severity] += counts.get(severity, 0) + overall_fixable_counts[severity] += fixable_counts.get(severity, 0) + + 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)", + ] + return "\n".join(lines) + + def build_missing_body( missing: list[str], not_scanned: list[str] | None = None ) -> str: @@ -1135,7 +1205,7 @@ def scan_image( if status != 0: stats.scan_errors += 1 if settings.notify_on_scan_error: - lines = [f"Image: {image}"] + lines = [f"Image: {short_image(image)}"] if target.container_detail: lines.append(target.container_detail) lines += [ @@ -1146,9 +1216,9 @@ def scan_image( body = "\n".join(lines) discord = uses_discord(settings) body = _truncate_text(body, DISCORD_MAX_NOTIFICATION_CHARS if discord else MAX_NOTIFICATION_CHARS) - title = f"Scan failed: {image}{target.notification_context}" + title = f"Scan failed: {short_image(image)}{target.notification_context}" if len(title) > 200: - title = f"Scan failed: {image}" + title = f"Scan failed: {short_image(image)}" if not notify(settings, title, body): stats.notify_errors += 1 return @@ -1165,7 +1235,7 @@ def scan_image( aggregated.results[image] = result log( "INFO", - f"{image}{context}: {result.critical} critical, {result.high} high, " + f"{short_image(image)}{context}: {result.critical} critical, {result.high} high, " f"{result.medium} medium, {result.low} low " f"({result.fixable} fixable high/critical)", ) @@ -1180,12 +1250,12 @@ def scan_image( fail_hits = threshold_hits(result, settings.fail_rank) if notify_hits: - log("DEBUG", f"{image}{context}: findings above {settings.notify_min_severity} tracked for aggregated report") + log("DEBUG", f"{short_image(image)}{context}: findings above {settings.notify_min_severity} tracked for aggregated report") else: - log("INFO", f"{image}{context}: no findings at or above {settings.notify_min_severity}") + log("INFO", f"{short_image(image)}{context}: no findings at or above {settings.notify_min_severity}") if fail_hits: - log("WARN", f"{image}{context} meets FAIL_ON={settings.fail_on} ({fail_hits} finding(s))") + log("WARN", f"{short_image(image)}{context} meets FAIL_ON={settings.fail_on} ({fail_hits} finding(s))") stats.failed_images += 1 @@ -1194,8 +1264,9 @@ def usage() -> str: Scan each Docker image with Trivy. When any finding reaches NOTIFY_MIN_SEVERITY (default: HIGH), a notification is sent. With -IMPORTANT_CONTAINERS, each named container gets a full CVE list and the rest -are summarised together; otherwise one aggregated notification is sent. +IMPORTANT_CONTAINERS, each named container gets a full CVE list, the rest +are summarised together, and a final notification lists the important +packages and overall totals; otherwise one aggregated notification is sent. Images can be passed as arguments or with IMAGES (space or newline separated) or IMAGES_FILE (one image per line, '#' starts a comment). @@ -1360,6 +1431,38 @@ def send_aggregated_notification( send_notification(settings, stats, title, body, fallback=minimal) +def build_container_rows( + targets: list[ScanTarget], + aggregated: AggregatedReport, + rank: int, + skip: set[str] | None = None, + include_empty: bool = False, +) -> list: + """Build ``(image, container, total, fixable, unfixable, counts, fixable_counts)`` rows.""" + skip = skip or set() + rows: list = [] + for target in targets: + result = aggregated.results.get(target.image) + if result is None: + continue + entries = cve_entries(result, rank) + if not entries and not include_empty: + continue + 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 + for container in target.containers or [""]: + if container and container in skip: + continue + rows.append( + (target.image, container, total, fixable, unfixable, counts, fixable_counts) + ) + return rows + + def send_notifications( settings: Settings, targets: list[ScanTarget], @@ -1379,6 +1482,7 @@ def send_notifications( scanned = {target.image: target for target in targets} missing: list[str] = [] not_scanned: list[str] = [] + important_rows: list[tuple[str, ScanTarget, ScanResult]] = [] # One full notification per important container (container + image). for name in settings.important_containers: @@ -1395,6 +1499,7 @@ def send_notifications( if result is None: continue # scan failure; already reported by scan_image + 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: @@ -1404,13 +1509,13 @@ def send_notifications( else: log( "DEBUG", - f"Important container {name} ({image}) has no findings at or above " + 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 ) - title = f"{settings.apprise_title_prefix}: {name} ({image})" + title = f"{settings.apprise_title_prefix}: {name} ({short_image(image)})" log( "DEBUG", f"Preparing important-container notification for {name}: " @@ -1427,27 +1532,12 @@ def send_notifications( ) # One summary notification for every other container. - rows: list = [] - for target in targets: - result = aggregated.results.get(target.image) - if result is None: - continue - entries = cve_entries(result, settings.notify_rank) - if not entries: - continue - 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 - for container in target.containers or [""]: - if container and container in important: - continue - rows.append((target.image, container, total, fixable, unfixable, counts, fixable_counts)) + rows = build_container_rows( + targets, aggregated, settings.notify_rank, skip=important + ) if rows: - bodies = build_other_containers_bodies(rows) + bodies = build_other_containers_bodies(rows, settings.notify_rank) for index, body in enumerate(bodies, 1): if len(bodies) == 1: title = f"{settings.apprise_title_prefix}: other containers" @@ -1465,6 +1555,20 @@ def send_notifications( continue stats.notify_errors += 1 + # One final notification with the important packages and overall totals. + all_rows = build_container_rows( + targets, aggregated, settings.notify_rank, include_empty=True + ) + summary_body = build_final_summary_body( + important_rows, all_rows, settings.notify_min_severity + ) + send_notification_parts( + settings, + stats, + f"{settings.apprise_title_prefix}: summary", + summary_body, + ) + def resolve_container_images( targets: list[ScanTarget], settings: Settings diff --git a/tests/test_scan.py b/tests/test_scan.py index 612d8c9..d644485 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -332,6 +332,22 @@ class SettingsTests(unittest.TestCase): self.assertIs(scan.env_bool("SOME_FLAG", not expected), expected) +class ShortImageTests(unittest.TestCase): + def test_short_image_truncates_digest_in_reference(self) -> None: + digest = "sha256:" + "a" * 64 + self.assertEqual( + scan.short_image(f"ghcr.io/acme/app@{digest}"), + "ghcr.io/acme/app@sha256:aaaaaa", + ) + + def test_short_image_truncates_bare_digest(self) -> None: + digest = "sha256:" + "b" * 64 + self.assertEqual(scan.short_image(digest), "sha256:bbbbbb") + + def test_short_image_leaves_tags_unchanged(self) -> None: + self.assertEqual(scan.short_image("nginx:1.27"), "nginx:1.27") + + REPORT = { "Results": [ { @@ -473,10 +489,9 @@ class ReportTests(unittest.TestCase): body = scan.build_important_body( "web", target, result, "/reports/x.json", "HIGH" ) - 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", body) + self.assertIn("| Image | Container | C | H |", body) + self.assertIn("| nginx:1.27 | web | 1/1 | 1/2 |", body) + self.assertNotIn("| M |", body) self.assertIn("CVE-1", body) self.assertIn("CVE-2", body) self.assertIn("CVE-5", body) @@ -492,25 +507,52 @@ 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", body) + self.assertIn("| Image | Container | C |", body) + self.assertIn("| nginx:1.27 | web | 1/1 |", body) + self.assertNotIn("| H |", body) + self.assertNotIn("Total:", body) def test_build_other_containers_body_has_counts_per_container(self) -> None: bodies = scan.build_other_containers_bodies( [ ("nginx:1.27", "web-worker", 3, 2, 1, {"CRITICAL":1,"HIGH":2,"MEDIUM":0,"LOW":0}, {"CRITICAL":1,"HIGH":1,"MEDIUM":0,"LOW":0}), ("redis:7", "cache", 4, 0, 4, {"CRITICAL":0,"HIGH":4,"MEDIUM":0,"LOW":0}, {"CRITICAL":0,"HIGH":0,"MEDIUM":0,"LOW":0}), - ] + ], + scan.severity_rank("HIGH"), ) body = bodies[0] self.assertIn("Other containers with findings", body) - self.assertIn("nginx:1.27 [web-worker]", body) - self.assertIn("C 1/1 H 1/2 M 0/0 L 0/0", body) - self.assertIn("Total: 2/3", body) - self.assertIn("redis:7 [cache]", body) - self.assertIn("C 0/0 H 0/4 M 0/0 L 0/0", body) - self.assertIn("Total: 0/4", body) - self.assertIn("Total: 2/7 in 2 container(s)", 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.assertNotIn("Total:", body) + self.assertNotIn("Overall:", body) + + def test_build_final_summary_body_lists_packages_and_overall(self) -> None: + result = scan.summarise(REPORT) + target = scan.ScanTarget(image="nginx:1.27", containers=["web"]) + 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( + [("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.assertIn("Packages:", body) + self.assertIn("libc 1.0 -> 1.1", body) + 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.assertNotIn("Total:", body) def test_build_missing_body_separates_missing_and_unscanned(self) -> None: body = scan.build_missing_body(["gone"], ["web"]) @@ -645,8 +687,11 @@ 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) - self.assertIn("Total: 0/0", notification) + 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.assertIn("Overall: 0/0 fixable/total in 1 container(s)", notification) def test_notification_includes_container(self) -> None: containers = [container("a" * 64, "testphp-php", name="testphp")] @@ -671,13 +716,13 @@ 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("Container: web", notification) + self.assertIn("| testphp-php | web | 1/1 | 0/0 |", notification) self.assertIn("CVE-2026-0001", notification) self.assertIn("Vulnerability scan: other containers", notification) - self.assertIn("redis:7 [cache]", notification) - self.assertIn("C 0/0 H 0/1 M 0/0 L 0/0", notification) - self.assertIn("Total: 0/1", notification) - self.assertIn("1/1 (fixable/total)", notification) + self.assertIn("| redis:7 | cache | 1/1 | 0/0 |", notification) + self.assertNotIn("Total:", notification) + self.assertIn("Vulnerability scan: summary", notification) + self.assertIn("Overall: 2/2 fixable/total in 2 container(s)", notification) # The important container must not also appear in the summary. self.assertNotIn("testphp-php [web]", notification)