formatting
This commit is contained in:
1 parent
9cabdefdf4
commit
77d064627a
3 files changed
+283
-124
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user