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
+282 -123

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` - `FAIL_ON` (default `HIGH`) – when the script should exit with code `1`
(useful for CI). (useful for CI).
3. Notifications are sent. When `IMPORTANT_CONTAINERS` is set, each named 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 container gets its own complete CVE list plus a fenced monospaced table of
fixable and Y is total per severity, plus `Total: fixable/total`), even if image/container severity counts (`C`, `H`, `M`, `L`), even if there are no
there are no CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important containers are
containers are reported, and every other container is summarised in chunks reported, every other container is summarised in chunks using the same
as `fixable/total`. Without `IMPORTANT_CONTAINERS`, a single aggregated table, and one final notification lists the important packages plus the
notification lists each CVE with the affected packages, containers, and overall totals. Columns below `NOTIFY_MIN_SEVERITY` are omitted. Without
images. `IMPORTANT_CONTAINERS`, a single aggregated notification lists each CVE with
the affected packages, containers, and images.
## Quick start ## Quick start
@@ -255,17 +256,26 @@ leave `IMAGES` empty unless you also want to scan specific images explicitly.
IMPORTANT_CONTAINERS=traefik web-frontend 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 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 `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. 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.
3. **One summary notification for every other container**, split every ~12 3. **One summary notification for every other container**, split every ~12
containers (with per-part and overall totals), listing image/container and containers, showing the same fenced table of image/container and
`fixable/total`. `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. 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 If an important container shares its image with other containers, the others
+196 -92
View File
@@ -17,6 +17,7 @@ import http.client
import json import json
import glob import glob
import os import os
import re
import shutil import shutil
import socket import socket
import stat 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) 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 # 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("--ignore-unfixed")
command.append(image) command.append(image)
log("INFO", f"Scanning {image}{context}") log("INFO", f"Scanning {short_image(image)}{context}")
try: try:
completed = subprocess.run( completed = subprocess.run(
command, command,
@@ -767,14 +778,14 @@ def run_trivy(image: str, settings: Settings, context: str = "") -> tuple[int, s
env=trivy_environment(settings), env=trivy_environment(settings),
) )
except OSError as exc: except OSError as exc:
log("ERROR", f"Scan failed: {image}{context}: {exc}") log("ERROR", f"Scan failed: {short_image(image)}{context}: {exc}")
return 1, "" return 1, ""
if completed.returncode != 0: if completed.returncode != 0:
detail = completed.stderr.strip().splitlines() detail = completed.stderr.strip().splitlines()
if detail: if detail:
log("ERROR", detail[-1]) log("ERROR", detail[-1])
log("ERROR", f"Scan failed: {image}{context}") log("ERROR", f"Scan failed: {short_image(image)}{context}")
return 1, "" return 1, ""
return 0, report_path return 0, report_path
@@ -824,7 +835,7 @@ def build_body(
max_findings: int, max_findings: int,
container_detail: str = "", container_detail: str = "",
) -> str: ) -> str:
lines = [f"Image: {image}"] lines = [f"Image: {short_image(image)}"]
if container_detail: if container_detail:
lines.append(container_detail) lines.append(container_detail)
lines += [ lines += [
@@ -933,7 +944,7 @@ def build_aggregated_body(
lines.append(f"* [{cve.severity}] {cve.cve} - {pkg_text}") lines.append(f"* [{cve.severity}] {cve.cve} - {pkg_text}")
for affected in cve.affected[:2]: for affected in cve.affected[:2]:
containers = ", ".join(affected.containers) if affected.containers else "unknown" 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: if len(cve.affected) > 2:
lines.append(f" - +{len(cve.affected) - 2} more") 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" 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( def build_important_body(
container: str, container: str,
target: ScanTarget, target: ScanTarget,
@@ -978,17 +1029,12 @@ def build_important_body(
for entry in entries: for entry in entries:
if entry.fixable: if entry.fixable:
fixable_counts[entry.severity] += 1 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 = [ lines = [
f"Image: {target.image}", summary_table(
f"Container: {container}", [(target.image, container, counts, fixable_counts)], severities
" ".join(
f"{name[0]} {fixable_counts[name]}/{counts[name]}"
for name in REPORTED_SEVERITIES
), ),
f"Total: {total_fixable}/{total_all}",
"", "",
"CVEs:", "CVEs:",
] ]
@@ -1012,38 +1058,18 @@ def build_important_summary_body(
for entry in entries: for entry in entries:
if entry.fixable: if entry.fixable:
fixable_counts[entry.severity] += 1 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 = [ lines = [
f"Image: {target.image}", summary_table(
f"Container: {container}", [(target.image, container, counts, fixable_counts)], severities
" ".join(
f"{name[0]} {fixable_counts[name]}/{counts[name]}"
for name in REPORTED_SEVERITIES
), ),
f"Total: {total_fixable}/{total_all}",
"", "",
"No CVEs at or above the notification threshold.", "No CVEs at or above the notification threshold.",
] ]
return "\n".join(lines) 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]]: def _chunk_list(items: list[object], size: int) -> list[list[object]]:
if size <= 0: if size <= 0:
return [items] return [items]
@@ -1052,44 +1078,88 @@ def _chunk_list(items: list[object], size: int) -> list[list[object]]:
def build_other_containers_bodies( def build_other_containers_bodies(
rows: list, rows: list,
rank: int,
) -> list[str]: ) -> list[str]:
"""Split the summary by container groups so each message stays small.""" """Split the summary by container groups so each message stays small."""
if not rows: if not rows:
return [] return []
severities = visible_severities(rank)
bodies: list[str] = [] bodies: list[str] = []
for chunk in _chunk_list(rows, 12): for chunk in _chunk_list(rows, 12):
lines = ["Other containers with findings:", ""] table_rows = []
for row in chunk: for row in chunk:
if len(row) == 7: image, container, _, _, _, counts, fixable_counts = row
image, container, total_all, fixable_all, unfixable_all, counts, fixable_counts = row table_rows.append((image, container, counts, fixable_counts))
else: bodies.append(
image, container, total_all, fixable_all, unfixable_all = row "\n".join(
counts = {s: 0 for s in REPORTED_SEVERITIES} [
fixable_counts = {s: 0 for s in REPORTED_SEVERITIES} "Other containers with findings:",
label = f"{image} [{container}]" if container else image "",
lines.append(label) summary_table(table_rows, severities),
lines.append( ]
" ".join(
f"{name[0]} {fixable_counts.get(name, 0)}/{counts.get(name, 0)}"
for name in REPORTED_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 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( def build_missing_body(
missing: list[str], not_scanned: list[str] | None = None missing: list[str], not_scanned: list[str] | None = None
) -> str: ) -> str:
@@ -1135,7 +1205,7 @@ def scan_image(
if status != 0: if status != 0:
stats.scan_errors += 1 stats.scan_errors += 1
if settings.notify_on_scan_error: if settings.notify_on_scan_error:
lines = [f"Image: {image}"] lines = [f"Image: {short_image(image)}"]
if target.container_detail: if target.container_detail:
lines.append(target.container_detail) lines.append(target.container_detail)
lines += [ lines += [
@@ -1146,9 +1216,9 @@ def scan_image(
body = "\n".join(lines) body = "\n".join(lines)
discord = uses_discord(settings) discord = uses_discord(settings)
body = _truncate_text(body, DISCORD_MAX_NOTIFICATION_CHARS if discord else MAX_NOTIFICATION_CHARS) 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: if len(title) > 200:
title = f"Scan failed: {image}" title = f"Scan failed: {short_image(image)}"
if not notify(settings, title, body): if not notify(settings, title, body):
stats.notify_errors += 1 stats.notify_errors += 1
return return
@@ -1165,7 +1235,7 @@ def scan_image(
aggregated.results[image] = result aggregated.results[image] = result
log( log(
"INFO", "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.medium} medium, {result.low} low "
f"({result.fixable} fixable high/critical)", f"({result.fixable} fixable high/critical)",
) )
@@ -1180,12 +1250,12 @@ def scan_image(
fail_hits = threshold_hits(result, settings.fail_rank) fail_hits = threshold_hits(result, settings.fail_rank)
if notify_hits: 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: 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: 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 stats.failed_images += 1
@@ -1194,8 +1264,9 @@ def usage() -> str:
Scan each Docker image with Trivy. When any finding reaches Scan each Docker image with Trivy. When any finding reaches
NOTIFY_MIN_SEVERITY (default: HIGH), a notification is sent. With NOTIFY_MIN_SEVERITY (default: HIGH), a notification is sent. With
IMPORTANT_CONTAINERS, each named container gets a full CVE list and the rest IMPORTANT_CONTAINERS, each named container gets a full CVE list, the rest
are summarised together; otherwise one aggregated notification is sent. 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 Images can be passed as arguments or with IMAGES (space or newline
separated) or IMAGES_FILE (one image per line, '#' starts a comment). 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) 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( def send_notifications(
settings: Settings, settings: Settings,
targets: list[ScanTarget], targets: list[ScanTarget],
@@ -1379,6 +1482,7 @@ def send_notifications(
scanned = {target.image: target for target in targets} scanned = {target.image: target for target in targets}
missing: list[str] = [] missing: list[str] = []
not_scanned: list[str] = [] not_scanned: list[str] = []
important_rows: list[tuple[str, ScanTarget, ScanResult]] = []
# One full notification per important container (container + image). # One full notification per important container (container + image).
for name in settings.important_containers: for name in settings.important_containers:
@@ -1395,6 +1499,7 @@ def send_notifications(
if result is None: if result is None:
continue # scan failure; already reported by scan_image continue # scan failure; already reported by scan_image
important_rows.append((name, target, result))
entries = cve_entries(result, settings.notify_rank) entries = cve_entries(result, settings.notify_rank)
report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json") report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json")
if entries: if entries:
@@ -1404,13 +1509,13 @@ def send_notifications(
else: else:
log( log(
"DEBUG", "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}", f"{settings.notify_min_severity}",
) )
body = build_important_summary_body( body = build_important_summary_body(
name, target, result, settings.notify_min_severity 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( log(
"DEBUG", "DEBUG",
f"Preparing important-container notification for {name}: " f"Preparing important-container notification for {name}: "
@@ -1427,27 +1532,12 @@ def send_notifications(
) )
# One summary notification for every other container. # One summary notification for every other container.
rows: list = [] rows = build_container_rows(
for target in targets: targets, aggregated, settings.notify_rank, skip=important
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))
if rows: if rows:
bodies = build_other_containers_bodies(rows) bodies = build_other_containers_bodies(rows, settings.notify_rank)
for index, body in enumerate(bodies, 1): for index, body in enumerate(bodies, 1):
if len(bodies) == 1: if len(bodies) == 1:
title = f"{settings.apprise_title_prefix}: other containers" title = f"{settings.apprise_title_prefix}: other containers"
@@ -1465,6 +1555,20 @@ def send_notifications(
continue continue
stats.notify_errors += 1 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( def resolve_container_images(
targets: list[ScanTarget], settings: Settings 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) 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 = { REPORT = {
"Results": [ "Results": [
{ {
@@ -473,10 +489,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: nginx:1.27", body) self.assertIn("| Image | Container | C | H |", body)
self.assertIn("Container: web", body) self.assertIn("| nginx:1.27 | web | 1/1 | 1/2 |", body)
self.assertIn("C 1/1 H 1/2 M 0/0 L 0/0", body) self.assertNotIn("| M |", body)
self.assertIn("Total: 2/3", body)
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)
@@ -492,25 +507,52 @@ 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("C 1/1 H 0/0 M 0/0 L 0/0", body) self.assertIn("| Image | Container | C |", body)
self.assertIn("Total: 1/1", 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: def test_build_other_containers_body_has_counts_per_container(self) -> None:
bodies = scan.build_other_containers_bodies( 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}), ("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}), ("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] body = bodies[0]
self.assertIn("Other containers with findings", body) self.assertIn("Other containers with findings", body)
self.assertIn("nginx:1.27 [web-worker]", body) self.assertIn("| Image | Container | C | H |", body)
self.assertIn("C 1/1 H 1/2 M 0/0 L 0/0", body) self.assertIn("| nginx:1.27 | web-worker | 1/1 | 1/2 |", body)
self.assertIn("Total: 2/3", body) self.assertIn("| redis:7 | cache | 0/0 | 0/4 |", body)
self.assertIn("redis:7 [cache]", body) self.assertNotIn("| M |", body)
self.assertIn("C 0/0 H 0/4 M 0/0 L 0/0", body) self.assertNotIn("Total:", body)
self.assertIn("Total: 0/4", body) self.assertNotIn("Overall:", body)
self.assertIn("Total: 2/7 in 2 container(s)", 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: 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"])
@@ -645,8 +687,11 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
self.assertEqual(code, 0) self.assertEqual(code, 0)
notification = Path(self.apprise_log).read_text() notification = Path(self.apprise_log).read_text()
self.assertIn("Vulnerability scan: web (alpine:3.20)", notification) 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("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: def test_notification_includes_container(self) -> None:
containers = [container("a" * 64, "testphp-php", name="testphp")] containers = [container("a" * 64, "testphp-php", name="testphp")]
@@ -671,13 +716,13 @@ 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("Container: web", notification) self.assertIn("| testphp-php | web | 1/1 | 0/0 |", notification)
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]", notification) self.assertIn("| redis:7 | cache | 1/1 | 0/0 |", notification)
self.assertIn("C 0/0 H 0/1 M 0/0 L 0/0", notification) self.assertNotIn("Total:", notification)
self.assertIn("Total: 0/1", notification) self.assertIn("Vulnerability scan: summary", notification)
self.assertIn("1/1 (fixable/total)", notification) self.assertIn("Overall: 2/2 fixable/total in 2 container(s)", notification)
# The important container must not also appear in the summary. # The important container must not also appear in the summary.
self.assertNotIn("testphp-php [web]", notification) self.assertNotIn("testphp-php [web]", notification)