add IMPORTANT_CONTAINERS

This commit is contained in:
David Duran Rosich committed 2026-10-24 21:59:12 +02:00
1 parent 21b35a4890
commit 18a1d0e863
7 files changed
+588 -40

No files matched your search

+418 -32
View File
@@ -87,6 +87,11 @@ def split_words(raw: str) -> list[str]:
return [word for word in raw.replace("\n", " ").split() if word]
def split_list(raw: str) -> list[str]:
"""Split a whitespace/comma separated list, dropping empty and duplicate entries."""
return dedupe(split_words(raw.replace(",", " ")))
def dedupe(items: list[str]) -> list[str]:
"""Drop duplicates while keeping the original order."""
seen: set[str] = set()
@@ -370,6 +375,7 @@ class Settings:
trivy_cache_dir: str = "/var/lib/trivy"
trivy_timeout: str = "15m"
max_findings: int = 15
important_containers: list[str] = field(default_factory=list)
include_self: bool = False
docker_socket: str | None = None
@@ -400,6 +406,7 @@ class Settings:
trivy_cache_dir=os.environ.get("TRIVY_CACHE_DIR", "/var/lib/trivy"),
trivy_timeout=os.environ.get("TRIVY_TIMEOUT", "15m"),
max_findings=env_int("MAX_FINDINGS_IN_BODY", 15),
important_containers=split_list(os.environ.get("IMPORTANT_CONTAINERS", "")),
include_self=env_bool("INCLUDE_SELF", False),
)
settings.validate()
@@ -503,6 +510,7 @@ class ScanResult:
medium: int = 0
low: int = 0
findings: list[dict] = field(default_factory=list)
all_findings: list[dict] = field(default_factory=list)
@property
def counts(self) -> dict[str, int]:
@@ -536,6 +544,16 @@ class CveAggregation:
fixable: bool = False
@dataclass
class CveEntry:
"""A single CVE in one image, with every package that reports it."""
cve: str
severity: str
fixable: bool = False
packages: list[tuple[str, str, str]] = field(default_factory=list)
@dataclass
class AggregatedReport:
critical: int = 0
@@ -544,6 +562,7 @@ class AggregatedReport:
low: int = 0
cves: dict[str, CveAggregation] = field(default_factory=dict)
reports: list[str] = field(default_factory=list)
results: dict[str, ScanResult] = field(default_factory=dict)
@property
def counts(self) -> dict[str, int]:
@@ -632,17 +651,80 @@ def summarise(report: dict) -> ScanResult:
result.medium += 1
elif severity == "LOW":
result.low += 1
if severity in REPORTED_SEVERITIES:
result.all_findings.append(vuln)
if severity in ("CRITICAL", "HIGH"):
result.findings.append(vuln)
result.findings.sort(
key=lambda v: (
0 if str(v.get("Severity", "")).upper() == "CRITICAL" else 1,
str(v.get("VulnerabilityID", "")),
def sort_key(vuln: dict) -> tuple[int, str]:
return (
0 if str(vuln.get("Severity", "")).upper() == "CRITICAL" else 1,
str(vuln.get("VulnerabilityID", "")),
)
)
result.findings.sort(key=sort_key)
result.all_findings.sort(key=sort_key)
return result
def findings_at_rank(result: ScanResult, rank: int) -> list[dict]:
"""Every finding at or above the requested severity rank."""
if rank <= 0:
return []
return [
vuln
for vuln in result.all_findings
if severity_rank(str(vuln.get("Severity", ""))) >= rank
]
def group_cves(findings: list[dict]) -> list[CveEntry]:
"""Collapse package-level findings into one entry per CVE."""
entries: dict[str, CveEntry] = {}
for vuln in findings:
cve = str(vuln.get("VulnerabilityID", ""))
if not cve:
continue
severity = str(vuln.get("Severity", "")).upper()
entry = entries.get(cve)
if entry is None:
entry = CveEntry(cve=cve, severity=severity)
entries[cve] = entry
elif severity_rank(severity) > severity_rank(entry.severity):
entry.severity = severity
pkg = str(vuln.get("PkgName", ""))
installed = str(vuln.get("InstalledVersion", ""))
fixed = str(vuln.get("FixedVersion") or "")
if fixed:
entry.fixable = True
package = (pkg, installed, fixed)
if package not in entry.packages:
entry.packages.append(package)
return sorted(
entries.values(),
key=lambda item: (-severity_rank(item.severity), item.cve),
)
def cve_entries(result: ScanResult, rank: int) -> list[CveEntry]:
return group_cves(findings_at_rank(result, rank))
def split_counts(entries: list[CveEntry]) -> tuple[int, int, int]:
"""Return (total, fixable, unfixable) for a list of CVEs."""
fixable = sum(1 for entry in entries if entry.fixable)
return len(entries), fixable, len(entries) - fixable
def severity_counts(entries: list[CveEntry]) -> dict[str, int]:
counts = {severity: 0 for severity in REPORTED_SEVERITIES}
for entry in entries:
counts[entry.severity] += 1
return counts
def trivy_environment(settings: Settings) -> dict[str, str]:
env = os.environ.copy()
# Trivy needs DOCKER_HOST to read images that only exist in the daemon.
@@ -866,6 +948,85 @@ def build_aggregated_body(
return _truncate_text(body, MAX_NOTIFICATION_CHARS)
def notification_limit(settings: Settings) -> int:
return DISCORD_MAX_NOTIFICATION_CHARS if uses_discord(settings) else MAX_NOTIFICATION_CHARS
def package_text(entry: CveEntry) -> str:
parts = []
for pkg, installed, fixed in entry.packages:
if pkg:
parts.append(f"{pkg} {installed} -> {fixed or 'no fix available'}")
elif installed or fixed:
parts.append(f"{installed} -> {fixed or 'no fix available'}")
return "; ".join(parts) if parts else "unknown"
def build_important_body(
container: str,
target: ScanTarget,
result: ScanResult,
report_path: str,
min_severity: str,
) -> str:
"""Full CVE list for one important container, with fixable/unfixable counts."""
entries = cve_entries(result, severity_rank(min_severity))
total, fixable, unfixable = split_counts(entries)
counts = severity_counts(entries)
lines = [
f"Image: {target.image}",
f"Container: {container}",
" | ".join(f"{name.title()}: {counts[name]}" for name in REPORTED_SEVERITIES),
f"Total: {total} (fixable: {fixable}, unfixable: {unfixable})",
"",
"CVEs:",
]
for entry in entries:
lines.append(f"* [{entry.severity}] {entry.cve} - {package_text(entry)}")
if not entries:
lines.append("None at or above the notification threshold.")
if report_path:
lines += ["", f"Report: {report_path}"]
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:", ""]
total_all = 0
for image, container, total, fixable, unfixable in rows:
total_all += total
label = f"{image} [{container}]" if container else image
lines.append(
f"* {label}: {total} total "
f"({fixable} fixable, {unfixable} unfixable)"
)
lines += ["", f"Total: {total_all} CVE(s) in {len(rows)} container(s)"]
return "\n".join(lines)
def build_missing_body(
missing: list[str], not_scanned: list[str] | None = None
) -> str:
lines = []
if missing:
lines.append("Important containers are not running:")
lines += [f"* {name}" for name in missing]
if not_scanned:
if lines:
lines.append("")
lines.append("Important containers are running but their image was not scanned:")
lines += [f"* {name}" for name in not_scanned]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@@ -923,6 +1084,7 @@ def scan_image(
return
result = summarise(report)
aggregated.results[image] = result
log(
"INFO",
f"{image}{context}: {result.critical} critical, {result.high} high, "
@@ -952,10 +1114,10 @@ def scan_image(
def usage() -> str:
return f"""Usage: {PROG} [IMAGE ...]
Scan each Docker image with Trivy. Findings are aggregated by CVE across all
images and a single notification is sent when any reach
NOTIFY_MIN_SEVERITY (default: HIGH). Each CVE lists the containers and images
that are affected.
Scan each Docker image with Trivy. When any finding reaches
NOTIFY_MIN_SEVERITY (default: HIGH), a notification is sent. With
IMPORTANT_CONTAINERS, each named container gets a full CVE list and the rest
are summarised together; otherwise one aggregated notification is sent.
Images can be passed as arguments or with IMAGES (space or newline
separated) or IMAGES_FILE (one image per line, '#' starts a comment).
@@ -987,6 +1149,9 @@ Environment:
TRIVY_CACHE_DIR Trivy database/cache directory (default: /var/lib/trivy).
TRIVY_TIMEOUT Scan timeout passed to Trivy (default: 15m).
MAX_FINDINGS_IN_BODY Findings listed in a notification body (default: 15).
IMPORTANT_CONTAINERS Container names (space or comma separated) that get a
full per-container CVE notification. Every other
container is summarised in one notification.
IMAGES Images to scan, space or newline separated.
IMAGES_FILE File containing images to scan (one per line).
@@ -994,6 +1159,246 @@ Exit codes: 0 = clean, 1 = vulnerability policy failed, 2 = scan error.
"""
def _split_body(body: str, max_chars: int) -> list[str]:
"""Split a body on line boundaries so every part fits the channel limit."""
if max_chars <= 0 or len(body) <= max_chars:
return [body]
parts: list[str] = []
current: list[str] = []
size = 0
for line in body.splitlines():
while len(line) > max_chars:
if current:
parts.append("\n".join(current))
current = []
size = 0
parts.append(line[:max_chars])
line = line[max_chars:]
line_size = len(line) + 1
if current and size + line_size > max_chars:
parts.append("\n".join(current))
current = []
size = 0
current.append(line)
size += line_size
if current:
parts.append("\n".join(current))
return parts
def send_notification(
settings: Settings,
stats: RunStats,
title: str,
body: str,
fallback: str | None = None,
) -> bool:
if len(title) > 200:
title = title[:197] + "..."
body = _truncate_text(body, notification_limit(settings))
if notify(settings, title, body):
return True
if fallback is not None:
log("WARN", "Retrying notification with minimal summary")
fallback = _truncate_text(fallback, notification_limit(settings))
if notify(settings, title, fallback):
return True
stats.notify_errors += 1
return False
def send_notification_parts(
settings: Settings, stats: RunStats, title: str, body: str
) -> bool:
"""Send a body in as many messages as needed, keeping every line."""
if len(title) > 200:
title = title[:197] + "..."
parts = _split_body(body, notification_limit(settings))
if len(parts) > 1:
log("INFO", f"Sending {title} in {len(parts)} parts")
ok = True
for index, part in enumerate(parts, 1):
part_title = title if len(parts) == 1 else f"{title} [{index}/{len(parts)}]"
if not notify(settings, part_title, part):
ok = False
if not ok:
stats.notify_errors += 1
return ok
def send_aggregated_notification(
settings: Settings,
targets: list[ScanTarget],
aggregated: AggregatedReport,
stats: RunStats,
) -> None:
if not aggregated.cves or not threshold_hits_from_counts(
aggregated.counts, settings.notify_rank
):
if aggregated.cves:
log(
"INFO",
f"{len(aggregated.cves)} CVE(s) tracked but below "
f"NOTIFY_MIN_SEVERITY={settings.notify_min_severity}",
)
return
title = (
f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s) "
f"across {len(targets)} image(s)"
)
if len(title) > 200:
title = f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s)"
discord = uses_discord(settings)
body = build_aggregated_body(aggregated, settings.max_findings, compact=discord)
if discord:
body = _truncate_text(body, DISCORD_MAX_NOTIFICATION_CHARS)
else:
body = _truncate_text(body, MAX_NOTIFICATION_CHARS)
log(
"DEBUG",
f"Preparing aggregated notification: title {len(title)} chars, "
f"body {len(body)} chars, {len(aggregated.cves)} CVEs",
)
minimal = (
f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s) "
f"across {len(targets)} image(s)\n"
f"C:{aggregated.critical} H:{aggregated.high} "
f"M:{aggregated.medium} L:{aggregated.low}\n"
f"Full details in reports in {settings.report_dir}"
)
send_notification(settings, stats, title, body, fallback=minimal)
def send_notifications(
settings: Settings,
targets: list[ScanTarget],
aggregated: AggregatedReport,
stats: RunStats,
container_images: dict[str, str],
discovery_known: bool,
) -> None:
if settings.notify_rank <= 0:
return
if not settings.important_containers:
send_aggregated_notification(settings, targets, aggregated, stats)
return
important = set(settings.important_containers)
scanned = {target.image: target for target in targets}
missing: list[str] = []
not_scanned: list[str] = []
# One full notification per important container (container + image).
for name in settings.important_containers:
image = container_images.get(name)
if image is None:
if discovery_known:
missing.append(name)
continue
target = scanned.get(image)
if target is None:
not_scanned.append(name)
continue
result = aggregated.results.get(image)
if result is None:
continue # scan failure; already reported by scan_image
entries = cve_entries(result, settings.notify_rank)
if not entries:
log(
"DEBUG",
f"Important container {name} ({image}) has no findings at or above "
f"{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} ({image})"
log(
"DEBUG",
f"Preparing important-container notification for {name}: "
f"{len(entries)} CVE(s)",
)
send_notification_parts(settings, stats, title, body)
if missing or not_scanned:
send_notification(
settings,
stats,
f"{settings.apprise_title_prefix}: important containers",
build_missing_body(missing, not_scanned),
)
# One summary notification for every other container.
rows: list[tuple[str, str, int, int, int]] = []
for target in targets:
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)
for container in target.containers or [""]:
if container and container in important:
continue
rows.append((target.image, container, total, fixable, unfixable))
if rows:
send_notification(
settings,
stats,
f"{settings.apprise_title_prefix}: other containers",
build_other_containers_body(rows),
)
def resolve_container_images(
targets: list[ScanTarget], settings: Settings
) -> tuple[dict[str, str], bool]:
"""Map container names to images and enrich explicit targets.
Auto-discovery already attaches containers to targets. When images are
given explicitly, discovery is still used (only when important containers
are configured) so those names can be matched. The boolean reports whether
discovery actually worked, so a failed lookup is not mistaken for "container
not running".
"""
container_images = {
container: target.image
for target in targets
for container in target.containers
}
if not settings.important_containers:
return container_images, True
if settings.explicit_images:
discovered, source = discover_containers(include_self=settings.include_self)
if source is None:
log(
"WARN",
"IMPORTANT_CONTAINERS is set but running containers could not be "
"discovered; important-container notifications are disabled",
)
return {}, False
container_images = {
name: image for image, names in discovered.items() for name in names
}
for target in targets:
target.containers = [
name
for name, image in container_images.items()
if image == target.image
]
return container_images, True
return container_images, True
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
@@ -1019,6 +1424,7 @@ def main(argv: list[str] | None = None) -> int:
targets = collect_targets(settings)
if not targets:
return 0
container_images, discovery_known = resolve_container_images(targets, settings)
os.makedirs(settings.report_dir, exist_ok=True)
os.makedirs(settings.trivy_cache_dir, exist_ok=True)
@@ -1028,29 +1434,9 @@ def main(argv: list[str] | None = None) -> int:
for target in targets:
scan_image(target, settings, stats, aggregated)
if aggregated.cves and threshold_hits_from_counts(aggregated.counts, settings.notify_rank):
title = f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s) across {len(targets)} image(s)"
if len(title) > 200:
title = f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s)"
discord = uses_discord(settings)
body = build_aggregated_body(aggregated, settings.max_findings, compact=discord)
if discord:
body = _truncate_text(body, DISCORD_MAX_NOTIFICATION_CHARS)
else:
body = _truncate_text(body, MAX_NOTIFICATION_CHARS)
log("DEBUG", f"Preparing aggregated notification: title {len(title)} chars, body {len(body)} chars, {len(aggregated.cves)} CVEs")
if not notify(settings, title, body):
log("WARN", "Retrying aggregated notification with minimal summary")
minimal = (
f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s) across {len(targets)} image(s)\n"
f"C:{aggregated.critical} H:{aggregated.high} M:{aggregated.medium} L:{aggregated.low}\n"
f"Full details in reports in {settings.report_dir}"
)
minimal = _truncate_text(minimal, DISCORD_MAX_NOTIFICATION_CHARS if discord else MAX_NOTIFICATION_CHARS)
if not notify(settings, title, minimal):
stats.notify_errors += 1
elif aggregated.cves:
log("INFO", f"{len(aggregated.cves)} CVE(s) tracked but below NOTIFY_MIN_SEVERITY={settings.notify_min_severity}")
send_notifications(
settings, targets, aggregated, stats, container_images, discovery_known
)
log(
"INFO",