more changes!
This commit is contained in:
1 parent
beb2f544d9
commit
66808d77f0
3 files changed
+63
-29
No files matched your search
@@ -27,9 +27,10 @@ host only needs Docker. JSON reports are kept on disk for auditing.
|
|||||||
(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 (showing `C X/Y ...` where X is
|
||||||
fixable and Y is total per severity, plus `Total: fixable/total`), missing
|
fixable and Y is total per severity, plus `Total: fixable/total`), even if
|
||||||
important containers are reported, and every other container is summarised as
|
there are no CVEs at or above `NOTIFY_MIN_SEVERITY`. Missing important
|
||||||
`fixable/total`. Without `IMPORTANT_CONTAINERS`, a single aggregated
|
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
|
notification lists each CVE with the affected packages, containers, and
|
||||||
images.
|
images.
|
||||||
|
|
||||||
@@ -258,11 +259,13 @@ When the list is set, notifications are split into three 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 a total split into fixable and unfixable.
|
`NOTIFY_MIN_SEVERITY` with `C X/Y H X/Y M X/Y L X/Y` and `Total: fixable/total`.
|
||||||
|
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**, listing the image,
|
3. **One summary notification for every other container**, split every ~12
|
||||||
container, total CVE count, and the fixable/unfixable split.
|
containers (with per-part and overall totals), listing image/container and
|
||||||
|
`fixable/total`.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -973,21 +973,22 @@ def build_important_body(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Full CVE list for one important container, with fixable/unfixable counts."""
|
"""Full CVE list for one important container, with fixable/unfixable counts."""
|
||||||
entries = cve_entries(result, severity_rank(min_severity))
|
entries = cve_entries(result, severity_rank(min_severity))
|
||||||
total, fixable, unfixable = split_counts(entries)
|
|
||||||
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}
|
||||||
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)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"Image: {target.image}",
|
f"Image: {target.image}",
|
||||||
f"Container: {container}",
|
f"Container: {container}",
|
||||||
" | ".join(
|
" ".join(
|
||||||
f"{name[0]} {fixable_counts[name]}/{counts[name]}"
|
f"{name[0]} {fixable_counts[name]}/{counts[name]}"
|
||||||
for name in REPORTED_SEVERITIES
|
for name in REPORTED_SEVERITIES
|
||||||
),
|
),
|
||||||
f"Total: {fixable}/{total} (fixable/total)",
|
f"Total: {total_fixable}/{total_all}",
|
||||||
"",
|
"",
|
||||||
"CVEs:",
|
"CVEs:",
|
||||||
]
|
]
|
||||||
@@ -1001,6 +1002,33 @@ 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
|
||||||
|
total_fixable = sum(1 for entry in entries if entry.fixable)
|
||||||
|
total_all = len(entries)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Image: {target.image}",
|
||||||
|
f"Container: {container}",
|
||||||
|
" ".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.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def build_other_containers_body(
|
def build_other_containers_body(
|
||||||
rows: list[tuple[str, str, int, int, int]],
|
rows: list[tuple[str, str, int, int, int]],
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -1348,22 +1376,25 @@ def send_notifications(
|
|||||||
continue # scan failure; already reported by scan_image
|
continue # scan failure; already reported by scan_image
|
||||||
|
|
||||||
entries = cve_entries(result, settings.notify_rank)
|
entries = cve_entries(result, settings.notify_rank)
|
||||||
if not entries:
|
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:
|
||||||
log(
|
log(
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
f"Important container {name} ({image}) has no findings at or above "
|
f"Important container {name} ({image}) has no findings at or above "
|
||||||
f"{settings.notify_min_severity}",
|
f"{settings.notify_min_severity}",
|
||||||
)
|
)
|
||||||
continue
|
body = build_important_summary_body(
|
||||||
report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json")
|
name, target, result, settings.notify_min_severity
|
||||||
body = build_important_body(
|
|
||||||
name, target, result, report_path, settings.notify_min_severity
|
|
||||||
)
|
)
|
||||||
title = f"{settings.apprise_title_prefix}: {name} ({image})"
|
title = f"{settings.apprise_title_prefix}: {name} ({image})"
|
||||||
log(
|
log(
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
f"Preparing important-container notification for {name}: "
|
f"Preparing important-container notification for {name}: "
|
||||||
f"{len(entries)} CVE(s)",
|
f"{len(entries)} CVE(s) at or above threshold",
|
||||||
)
|
)
|
||||||
send_notification_parts(settings, stats, title, body)
|
send_notification_parts(settings, stats, title, body)
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -475,8 +475,8 @@ class ReportTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("Image: nginx:1.27", body)
|
self.assertIn("Image: nginx:1.27", body)
|
||||||
self.assertIn("Container: web", body)
|
self.assertIn("Container: web", body)
|
||||||
self.assertIn("C 1/1 | H 1/2 | M 0/0 | L 0/0", body)
|
self.assertIn("C 1/1 H 1/2 M 0/0 L 0/0", body)
|
||||||
self.assertIn("Total: 2/3 (fixable/total)", 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,8 +492,8 @@ 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("C 1/1 H 0/0 M 0/0 L 0/0", body)
|
||||||
self.assertIn("Total: 1/1 (fixable/total)", body)
|
self.assertIn("Total: 1/1", 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(
|
||||||
@@ -631,19 +631,19 @@ 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_scan_logs_containers_using_the_image(self) -> None:
|
def test_important_container_always_notified_when_clean(self) -> None:
|
||||||
containers = [container("a" * 64, "testphp-php", name="testphp")]
|
containers = [container("a" * 64, "alpine:3.20", name="web")]
|
||||||
with FakeDocker(containers) as docker:
|
with FakeDocker(containers) as docker:
|
||||||
stderr = io.StringIO()
|
code = self.run_main(
|
||||||
with mock.patch.object(scan, "MIN_LOG_LEVEL", "INFO"):
|
[], docker.socket_path, IMPORTANT_CONTAINERS="web", FAKE_TRIVY_SEVERITY="LOW"
|
||||||
with redirect_stderr(stderr):
|
|
||||||
code = self.run_main([], docker.socket_path)
|
|
||||||
|
|
||||||
self.assertEqual(code, 1)
|
|
||||||
self.assertIn(
|
|
||||||
"Scanning testphp-php (used by container testphp)", stderr.getvalue()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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.assertIn("No CVEs at or above", 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")]
|
||||||
with FakeDocker(containers) as docker:
|
with FakeDocker(containers) as docker:
|
||||||
|
|||||||
Reference in new issue
Block a user