formatting

This commit is contained in:
David Duran Rosich committed 2026-10-25 11:34:15 +01:00
1 parent 9cabdefdf4
commit 77d064627a
3 files changed
+283 -124

No files matched your search

+21 -11
View File
@@ -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: <container> (<image>)`. 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
+197 -93
View File
@@ -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
+65 -20
View File
@@ -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)