more changes
This commit is contained in:
1 parent
0fe06f2ba5
commit
be5d4dd97f
3 files changed
+152
-55
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`
|
- `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
|
||||||
|
|||||||
@@ -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
|
||||||
rows.append(
|
container = ", ".join(sorted(containers, key=str.casefold))
|
||||||
(target.image, container, total, fixable, unfixable, counts, fixable_counts)
|
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
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -1514,20 +1517,19 @@ 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(
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
|
|||||||
+96
-6
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user