more changes
This commit is contained in:
1 parent
0fe06f2ba5
commit
be5d4dd97f
3 files changed
+148
-51
No files matched your search
@@ -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`
|
||||
(useful for CI).
|
||||
3. Notifications are sent. When `IMPORTANT_CONTAINERS` is set, each named
|
||||
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
|
||||
container with findings at or above `NOTIFY_MIN_SEVERITY` gets its own
|
||||
complete CVE list plus a fenced monospaced table of image/container
|
||||
severity counts (`C`, `H`, `M`, `L`); clean containers appear only in the
|
||||
final summary. Missing important containers are reported, every other
|
||||
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
|
||||
`IMPORTANT_CONTAINERS`, a single aggregated notification lists each CVE with
|
||||
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
|
||||
`NOTIFY_MIN_SEVERITY`, with the image/container and severity counts shown in
|
||||
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
|
||||
running, or running an image that was not part of the scan.
|
||||
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.
|
||||
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)`.
|
||||
`fixable/total in N image(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.
|
||||
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.
|
||||
If an important container shares its image with other containers, the others
|
||||
|
||||
@@ -1000,7 +1000,9 @@ def summary_table(
|
||||
"""Render image/container/severity counts as a monospaced markdown table."""
|
||||
headers = ["Image", "Container"] + [name[0] for name in severities]
|
||||
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 += [
|
||||
f"{fixable_counts.get(name, 0)}/{counts.get(name, 0)}"
|
||||
@@ -1061,28 +1063,6 @@ def build_important_body(
|
||||
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]]:
|
||||
if size <= 0:
|
||||
return [items]
|
||||
@@ -1093,7 +1073,7 @@ def build_other_containers_bodies(
|
||||
rows: list,
|
||||
rank: int,
|
||||
) -> 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:
|
||||
return []
|
||||
severities = visible_severities(rank)
|
||||
@@ -1124,7 +1104,21 @@ def build_final_summary_body(
|
||||
severities = visible_severities(severity_rank(min_severity))
|
||||
table_rows: list[tuple[str, str, dict[str, int], dict[str, int]]] = []
|
||||
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:
|
||||
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))
|
||||
counts = severity_counts(entries)
|
||||
fixable_counts = {severity: 0 for severity in REPORTED_SEVERITIES}
|
||||
@@ -1168,7 +1162,7 @@ def build_final_summary_body(
|
||||
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)",
|
||||
f"Overall: {overall_fixable}/{overall_total} fixable/total in {len(all_rows)} image(s)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1451,7 +1445,11 @@ def build_container_rows(
|
||||
skip: set[str] | None = None,
|
||||
include_empty: bool = False,
|
||||
) -> 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()
|
||||
rows: list = []
|
||||
for target in targets:
|
||||
@@ -1467,12 +1465,17 @@ def build_container_rows(
|
||||
for entry in entries:
|
||||
if entry.fixable:
|
||||
fixable_counts[entry.severity] += 1
|
||||
for container in target.containers or [""]:
|
||||
if container and container in skip:
|
||||
if target.containers:
|
||||
containers = [name for name in target.containers if name not in skip]
|
||||
if not containers:
|
||||
continue
|
||||
container = ", ".join(sorted(containers, key=str.casefold))
|
||||
else:
|
||||
container = ""
|
||||
rows.append(
|
||||
(target.image, container, total, fixable, unfixable, counts, fixable_counts)
|
||||
)
|
||||
rows.sort(key=lambda row: (row[1].casefold(), row[0].casefold()))
|
||||
return rows
|
||||
|
||||
|
||||
@@ -1514,19 +1517,18 @@ def send_notifications(
|
||||
|
||||
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:
|
||||
body = build_important_body(
|
||||
name, target, result, report_path, settings.notify_min_severity
|
||||
)
|
||||
else:
|
||||
if not entries:
|
||||
# No individual notification for clean containers; the final
|
||||
# summary table already lists them with 0/0 totals.
|
||||
log(
|
||||
"DEBUG",
|
||||
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
|
||||
continue
|
||||
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)})"
|
||||
log(
|
||||
|
||||
+96
-6
@@ -559,9 +559,28 @@ class ReportTests(unittest.TestCase):
|
||||
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.assertIn("Overall: 2/3 fixable/total in 1 image(s)", 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:
|
||||
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:
|
||||
body = scan.build_missing_body(["gone"], ["web"])
|
||||
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.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")]
|
||||
with FakeDocker(containers) as docker:
|
||||
code = self.run_main(
|
||||
@@ -719,12 +786,14 @@ 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)
|
||||
# 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.assertIn("No CVEs at or above", notification)
|
||||
# The final summary still lists it with 0/0 totals.
|
||||
self.assertIn("Vulnerability scan: summary", notification)
|
||||
self.assertRegex(notification, table_row_pattern(["alpine:3.20", "web", "0/0", "0/0"]))
|
||||
self.assertIn("Overall: 0/0 fixable/total in 1 container(s)", notification)
|
||||
self.assertIn("Overall: 0/0 fixable/total in 1 image(s)", notification)
|
||||
|
||||
def test_notification_includes_container(self) -> None:
|
||||
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.assertNotIn("Total:", 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.
|
||||
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:
|
||||
containers = [container("a" * 64, "testphp-php", name="web")]
|
||||
with FakeDocker(containers) as docker:
|
||||
|
||||
Reference in new issue
Block a user