add IMPORTANT_CONTAINERS
This commit is contained in:
1 parent
21b35a4890
commit
18a1d0e863
7 files changed
+588
-40
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user