more changes

This commit is contained in:
David Duran Rosich committed 2026-10-28 00:38:34 +01:00
1 parent 0fe06f2ba5
commit be5d4dd97f
3 files changed
+148 -51

No files matched your search

+14 -9
View File
@@ -26,11 +26,12 @@ 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 plus a fenced monospaced table of container with findings at or above `NOTIFY_MIN_SEVERITY` gets its own
image/container severity counts (`C`, `H`, `M`, `L`), even if there are no complete CVE list plus a fenced monospaced table of image/container
CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important containers are severity counts (`C`, `H`, `M`, `L`); clean containers appear only in the
reported, every other container is summarised in chunks using the same final summary. Missing important containers are reported, every other
table, and one final notification lists the important packages plus the container is summarised in chunks using the same table (one row per
image), and one final notification lists the important packages plus the
overall totals. Columns below `NOTIFY_MIN_SEVERITY` are omitted. Without overall totals. Columns below `NOTIFY_MIN_SEVERITY` are omitted. Without
`IMPORTANT_CONTAINERS`, a single aggregated notification lists each CVE with `IMPORTANT_CONTAINERS`, a single aggregated notification lists each CVE with
the affected packages, containers, and images. the affected packages, containers, and images.
@@ -262,20 +263,24 @@ When the list is set, notifications are split into four groups:
`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 padded 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. Containers with no CVEs at or above the threshold get no individual
notification; they still appear with 0/0 totals in the final summary.
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, showing the same fenced table of image/container and images, showing the same fenced table of image/container and
`fixable/total` severity counts. `fixable/total` severity counts.
4. **One final summary notification** listing the important containers in the 4. **One final summary notification** listing the important containers in the
same table, their packages, followed by the overall severity counts and same table, their packages, followed by the overall severity counts and
`fixable/total in N container(s)`. `fixable/total in N image(s)`.
Severity columns below `NOTIFY_MIN_SEVERITY` are omitted from the tables (for 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 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 shortened to their first six characters in notification text; report filenames
keep the full reference. keep the full reference. Table rows are sorted alphabetically by container,
then by image, and there is one row per image: containers that share an image
are merged into a single row listing all of them, so their counts are not
repeated.
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
+38 -36
View File
@@ -1000,7 +1000,9 @@ def summary_table(
"""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]
table_rows: list[list[str]] = [] table_rows: list[list[str]] = []
for image, container, counts, fixable_counts in rows: for image, container, counts, fixable_counts in sorted(
rows, key=lambda row: (row[1].casefold(), row[0].casefold())
):
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)}"
@@ -1061,28 +1063,6 @@ def build_important_body(
return "\n".join(lines) return "\n".join(lines)
def build_important_summary_body(
container: str, target: ScanTarget, result: ScanResult, min_severity: str
) -> str:
"""Summary for an important container with no findings above threshold."""
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
severities = visible_severities(severity_rank(min_severity))
lines = [
summary_table(
[(target.image, container, counts, fixable_counts)], severities
),
"",
"No CVEs at or above the notification threshold.",
]
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]
@@ -1093,7 +1073,7 @@ def build_other_containers_bodies(
rows: list, rows: list,
rank: int, rank: int,
) -> list[str]: ) -> list[str]:
"""Split the summary by container groups so each message stays small.""" """Split the summary by image groups so each message stays small."""
if not rows: if not rows:
return [] return []
severities = visible_severities(rank) severities = visible_severities(rank)
@@ -1124,7 +1104,21 @@ def build_final_summary_body(
severities = visible_severities(severity_rank(min_severity)) severities = visible_severities(severity_rank(min_severity))
table_rows: list[tuple[str, str, dict[str, int], dict[str, int]]] = [] table_rows: list[tuple[str, str, dict[str, int], dict[str, int]]] = []
package_lines: list[str] = [] package_lines: list[str] = []
# Merge important containers that share an image into one row/package line.
by_image: dict[str, tuple[list[str], ScanTarget, ScanResult]] = {}
for name, target, result in important_rows: for name, target, result in important_rows:
if target.image in by_image:
by_image[target.image][0].append(name)
else:
by_image[target.image] = ([name], target, result)
groups = sorted(
(
(", ".join(sorted(names, key=str.casefold)), target, result)
for names, target, result in by_image.values()
),
key=lambda group: (group[0].casefold(), group[1].image.casefold()),
)
for name, target, result in groups:
entries = cve_entries(result, severity_rank(min_severity)) entries = cve_entries(result, severity_rank(min_severity))
counts = severity_counts(entries) counts = severity_counts(entries)
fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES} fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES}
@@ -1168,7 +1162,7 @@ def build_final_summary_body(
lines += [ lines += [
"", "",
f"Overall severity: {severity_text(overall_counts, overall_fixable_counts, severities)}", 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)", f"Overall: {overall_fixable}/{overall_total} fixable/total in {len(all_rows)} image(s)",
] ]
return "\n".join(lines) return "\n".join(lines)
@@ -1451,7 +1445,11 @@ def build_container_rows(
skip: set[str] | None = None, skip: set[str] | None = None,
include_empty: bool = False, include_empty: bool = False,
) -> list: ) -> list:
"""Build ``(image, container, total, fixable, unfixable, counts, fixable_counts)`` rows.""" """Build ``(image, containers, total, fixable, unfixable, counts, fixable_counts)`` rows.
One row per image: containers that share the image are merged into a single
row so its counts are not repeated.
"""
skip = skip or set() skip = skip or set()
rows: list = [] rows: list = []
for target in targets: for target in targets:
@@ -1467,12 +1465,17 @@ def build_container_rows(
for entry in entries: for entry in entries:
if entry.fixable: if entry.fixable:
fixable_counts[entry.severity] += 1 fixable_counts[entry.severity] += 1
for container in target.containers or [""]: if target.containers:
if container and container in skip: containers = [name for name in target.containers if name not in skip]
if not containers:
continue continue
container = ", ".join(sorted(containers, key=str.casefold))
else:
container = ""
rows.append( rows.append(
(target.image, container, total, fixable, unfixable, counts, fixable_counts) (target.image, container, total, fixable, unfixable, counts, fixable_counts)
) )
rows.sort(key=lambda row: (row[1].casefold(), row[0].casefold()))
return rows return rows
@@ -1514,19 +1517,18 @@ def send_notifications(
important_rows.append((name, target, result)) 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") if not entries:
if entries: # No individual notification for clean containers; the final
body = build_important_body( # summary table already lists them with 0/0 totals.
name, target, result, report_path, settings.notify_min_severity
)
else:
log( log(
"DEBUG", "DEBUG",
f"Important container {name} ({short_image(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( continue
name, target, result, settings.notify_min_severity report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json")
body = build_important_body(
name, target, result, report_path, settings.notify_min_severity
) )
title = f"{settings.apprise_title_prefix}: {name} ({short_image(image)})" title = f"{settings.apprise_title_prefix}: {name} ({short_image(image)})"
log( log(
+96 -6
View File
@@ -559,9 +559,28 @@ class ReportTests(unittest.TestCase):
self.assertIn("openssl 3.0.1 -> 3.0.2", body) self.assertIn("openssl 3.0.1 -> 3.0.2", body)
self.assertIn("Overall severity: C 1/1 H 1/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.assertNotIn("Overall severity: C 1/1 H 1/2 M", body)
self.assertIn("Overall: 2/3 fixable/total in 1 container(s)", body) self.assertIn("Overall: 2/3 fixable/total in 1 image(s)", body)
self.assertNotIn("Total:", body) self.assertNotIn("Total:", body)
def test_build_final_summary_body_merges_important_containers_per_image(self) -> None:
result = scan.summarise(REPORT)
target = scan.ScanTarget(image="nginx:1.27", containers=["web", "worker"])
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(
[("worker", target, result), ("web", target, result)], all_rows, "HIGH"
)
# Both important containers share one image, so one table row and one
# package line, listing both names.
self.assertRegex(body, table_row_pattern(["nginx:1.27", "web, worker", "1/1", "1/2"]))
self.assertIn("* web, worker: ", body)
self.assertNotIn("* worker: ", body)
self.assertNotIn("* web: ", body)
self.assertIn("Overall: 2/3 fixable/total in 1 image(s)", body)
def test_summary_table_pads_every_column(self) -> None: def test_summary_table_pads_every_column(self) -> None:
table = scan.summary_table( table = scan.summary_table(
[ [
@@ -587,6 +606,54 @@ class ReportTests(unittest.TestCase):
), ),
) )
def test_summary_table_sorts_by_container_then_image(self) -> None:
counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
table = scan.summary_table(
[
("nginx:1.27", "web", counts, counts),
("redis:7", "cache", counts, counts),
("nginx:1.25", "web", counts, counts),
],
["CRITICAL", "HIGH"],
)
self.assertEqual(
table,
"\n".join(
[
"```",
"| Image | Container | C | H |",
"| ---------- | --------- | --- | --- |",
"| redis:7 | cache | 0/0 | 0/0 |",
"| nginx:1.25 | web | 0/0 | 0/0 |",
"| nginx:1.27 | web | 0/0 | 0/0 |",
"```",
]
),
)
def test_build_container_rows_merges_containers_per_image(self) -> None:
aggregated = scan.AggregatedReport()
for image in ("redis:7", "nginx:1.27", "alpine:3.20"):
aggregated.results[image] = scan.summarise(REPORT)
targets = [
scan.ScanTarget(image="redis:7", containers=["cache"]),
scan.ScanTarget(image="nginx:1.27", containers=["web", "cache", "worker"]),
scan.ScanTarget(image="alpine:3.20"),
]
rows = scan.build_container_rows(
targets, aggregated, scan.severity_rank("HIGH"), skip={"worker"}
)
# One row per image, containers joined and sorted; images without
# containers keep an empty container cell.
self.assertEqual(
[(row[0], row[1]) for row in rows],
[
("alpine:3.20", ""),
("redis:7", "cache"),
("nginx:1.27", "cache, web"),
],
)
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)
@@ -710,7 +777,7 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
self.assertTrue(os.path.exists(os.path.join(self.env["REPORT_DIR"], "testphp-php.json"))) self.assertTrue(os.path.exists(os.path.join(self.env["REPORT_DIR"], "testphp-php.json")))
self.assertIn("testphp-php", Path(self.apprise_log).read_text()) self.assertIn("testphp-php", Path(self.apprise_log).read_text())
def test_important_container_always_notified_when_clean(self) -> None: def test_clean_important_container_only_in_final_summary(self) -> None:
containers = [container("a" * 64, "alpine:3.20", name="web")] containers = [container("a" * 64, "alpine:3.20", name="web")]
with FakeDocker(containers) as docker: with FakeDocker(containers) as docker:
code = self.run_main( code = self.run_main(
@@ -719,12 +786,14 @@ 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) # No individual notification for a clean important container.
self.assertNotIn("Vulnerability scan: web (alpine:3.20)", notification)
self.assertNotIn("No CVEs at or above", notification)
self.assertNotIn("Total:", notification) self.assertNotIn("Total:", notification)
self.assertIn("No CVEs at or above", notification) # The final summary still lists it with 0/0 totals.
self.assertIn("Vulnerability scan: summary", notification) self.assertIn("Vulnerability scan: summary", notification)
self.assertRegex(notification, table_row_pattern(["alpine:3.20", "web", "0/0", "0/0"])) 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 image(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")]
@@ -755,10 +824,31 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
self.assertRegex(notification, table_row_pattern(["redis:7", "cache", "1/1", "0/0"])) 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 image(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)
def test_containers_sharing_an_image_share_one_table_row(self) -> None:
containers = [
container("a" * 64, "testphp-php", name="web"),
container("b" * 64, "testphp-php", name="web2"),
container("c" * 64, "redis:7", name="cache"),
]
with FakeDocker(containers) as docker:
code = self.run_main([], docker.socket_path, IMPORTANT_CONTAINERS="cache")
self.assertEqual(code, 1)
notification = Path(self.apprise_log).read_text()
self.assertIn("Vulnerability scan: cache (redis:7)", notification)
self.assertIn("Vulnerability scan: other containers", notification)
# Both containers on the same image are merged into a single row.
self.assertRegex(
notification, table_row_pattern(["testphp-php", "web, web2", "1/1", "0/0"])
)
self.assertIn("Vulnerability scan: summary", notification)
self.assertRegex(notification, table_row_pattern(["redis:7", "cache", "1/1", "0/0"]))
self.assertIn("Overall: 2/2 fixable/total in 2 image(s)", notification)
def test_missing_important_container_is_reported(self) -> None: def test_missing_important_container_is_reported(self) -> None:
containers = [container("a" * 64, "testphp-php", name="web")] containers = [container("a" * 64, "testphp-php", name="web")]
with FakeDocker(containers) as docker: with FakeDocker(containers) as docker: