add IMPORTANT_CONTAINERS
This commit is contained in:
1 parent
21b35a4890
commit
18a1d0e863
7 files changed
+588
-40
No files matched your search
@@ -15,6 +15,12 @@
|
|||||||
# Also scan the scanner's own image when auto-discovering (default: false).
|
# Also scan the scanner's own image when auto-discovering (default: false).
|
||||||
# INCLUDE_SELF=false
|
# INCLUDE_SELF=false
|
||||||
|
|
||||||
|
# Containers that face the internet. Each one gets its own notification with
|
||||||
|
# every CVE at/above NOTIFY_MIN_SEVERITY, split into fixable and unfixable.
|
||||||
|
# All other containers are summarised in one notification. Space or comma
|
||||||
|
# separated. Leave empty to keep the single aggregated notification.
|
||||||
|
# IMPORTANT_CONTAINERS=traefik web-frontend
|
||||||
|
|
||||||
# One or more Apprise URLs, space separated. Quote the value.
|
# One or more Apprise URLs, space separated. Quote the value.
|
||||||
# Examples:
|
# Examples:
|
||||||
# mailto://user:app-password@gmail.com
|
# mailto://user:app-password@gmail.com
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ host only needs Docker. JSON reports are kept on disk for auditing.
|
|||||||
alert.
|
alert.
|
||||||
- `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. If any CVE reaches `NOTIFY_MIN_SEVERITY`, one notification is sent listing
|
3. If any CVE reaches `NOTIFY_MIN_SEVERITY`, notifications are sent. When
|
||||||
each CVE with the affected packages and the containers/images that use them.
|
`IMPORTANT_CONTAINERS` is set, each named container gets its own complete
|
||||||
|
CVE list (split into fixable and unfixable), missing important containers
|
||||||
|
are reported, and every other container is summarised in one notification.
|
||||||
|
Without `IMPORTANT_CONTAINERS`, a single aggregated notification lists each
|
||||||
|
CVE with the affected packages, containers, and images.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -67,10 +71,15 @@ For the last one the scanner asks the Docker Engine API on the Unix socket for
|
|||||||
listing order. Stopped containers are not included. The scanner's own container
|
listing order. Stopped containers are not included. The scanner's own container
|
||||||
image is skipped so a scheduled run does not report on itself; set
|
image is skipped so a scheduled run does not report on itself; set
|
||||||
`INCLUDE_SELF=true` to include it. Scan logs still name the running container(s) that use each image (e.g.
|
`INCLUDE_SELF=true` to include it. Scan logs still name the running container(s) that use each image (e.g.
|
||||||
`Scanning nginx:1.27 (used by container web)`). Notifications are aggregated
|
`Scanning nginx:1.27 (used by container web)`). Without `IMPORTANT_CONTAINERS`,
|
||||||
by CVE after all images are scanned: a single report lists each CVE with the
|
notifications are aggregated by CVE after all images are scanned: a single
|
||||||
containers and images it affects. Scan failures still notify per failed image
|
report lists each CVE with the containers and images it affects. With
|
||||||
with container context. Explicitly passed images omit container context.
|
`IMPORTANT_CONTAINERS`, each named container gets its own full CVE list and the
|
||||||
|
remaining containers are summarised together (see
|
||||||
|
[Important containers](#important-containers)). Scan failures still notify per
|
||||||
|
failed image with container context. Explicitly passed images omit container
|
||||||
|
context unless `IMPORTANT_CONTAINERS` is set, in which case running containers
|
||||||
|
are looked up to match them to the scanned images.
|
||||||
|
|
||||||
The socket is looked up in this order:
|
The socket is looked up in this order:
|
||||||
|
|
||||||
@@ -141,6 +150,7 @@ All configuration is done through environment variables.
|
|||||||
| `DOCKER_SOCK` | auto | Unix socket of the Docker daemon (or Podman). Also the Compose mount source. |
|
| `DOCKER_SOCK` | auto | Unix socket of the Docker daemon (or Podman). Also the Compose mount source. |
|
||||||
| `DOCKER_HOST` | – | Standard Docker variable; `unix://` paths are used for discovery. |
|
| `DOCKER_HOST` | – | Standard Docker variable; `unix://` paths are used for discovery. |
|
||||||
| `INCLUDE_SELF` | `false` | Also scan the scanner's own image when auto-discovering. |
|
| `INCLUDE_SELF` | `false` | Also scan the scanner's own image when auto-discovering. |
|
||||||
|
| `IMPORTANT_CONTAINERS` | – | Container names (space/comma separated) that get a full per-container CVE notification. Others are summarised together. |
|
||||||
| `APPRISE_URLS` | – | Space separated Apprise URLs (quote the value). |
|
| `APPRISE_URLS` | – | Space separated Apprise URLs (quote the value). |
|
||||||
| `APPRISE_CONFIG` | – | Alternative: path to an Apprise YAML config. |
|
| `APPRISE_CONFIG` | – | Alternative: path to an Apprise YAML config. |
|
||||||
| `APPRISE_TITLE_PREFIX` | `Vulnerability scan` | Prefix of notification titles. |
|
| `APPRISE_TITLE_PREFIX` | `Vulnerability scan` | Prefix of notification titles. |
|
||||||
@@ -153,7 +163,7 @@ All configuration is done through environment variables.
|
|||||||
| `REPORT_DIR` | `/reports` | Where JSON reports are written. |
|
| `REPORT_DIR` | `/reports` | Where JSON reports are written. |
|
||||||
| `TRIVY_CACHE_DIR` | `/var/lib/trivy` | Trivy DB cache (mount a volume to avoid re-downloading). |
|
| `TRIVY_CACHE_DIR` | `/var/lib/trivy` | Trivy DB cache (mount a volume to avoid re-downloading). |
|
||||||
| `TRIVY_TIMEOUT` | `15m` | Passed to `trivy --timeout`. |
|
| `TRIVY_TIMEOUT` | `15m` | Passed to `trivy --timeout`. |
|
||||||
| `MAX_FINDINGS_IN_BODY` | `15` | How many findings are listed per notification. |
|
| `MAX_FINDINGS_IN_BODY` | `15` | How many findings the single aggregated notification lists. Important-container notifications are not capped. |
|
||||||
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARN` or `ERROR`. |
|
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARN` or `ERROR`. |
|
||||||
|
|
||||||
Images can be passed as arguments, via `IMAGES`, or via `IMAGES_FILE`; the
|
Images can be passed as arguments, via `IMAGES`, or via `IMAGES_FILE`; the
|
||||||
@@ -233,7 +243,35 @@ docker run --rm \
|
|||||||
docker-vuln-scan:latest nginx:1.27-alpine
|
docker-vuln-scan:latest nginx:1.27-alpine
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exit codes
|
## Important containers
|
||||||
|
|
||||||
|
Set `IMPORTANT_CONTAINERS` to the containers that are reachable from outside
|
||||||
|
(your public entry points). Names are matched against running containers, so
|
||||||
|
leave `IMAGES` empty unless you also want to scan specific images explicitly.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
IMPORTANT_CONTAINERS=traefik web-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
When the list is set, notifications are split into three groups:
|
||||||
|
|
||||||
|
1. **One notification per important container**, titled
|
||||||
|
`Vulnerability scan: <container> (<image>)`. It lists every CVE at or above
|
||||||
|
`NOTIFY_MIN_SEVERITY`, with a total split into fixable and unfixable.
|
||||||
|
2. **One notification for important containers that are missing** – either not
|
||||||
|
running, or running an image that was not part of the scan.
|
||||||
|
3. **One summary notification for every other container**, listing the image,
|
||||||
|
container, total CVE count, and the fixable/unfixable split.
|
||||||
|
|
||||||
|
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
|
||||||
|
still appear in the summary. Important-container lists are never truncated: if
|
||||||
|
a list is longer than the Apprise/Discord message limit it is sent in numbered
|
||||||
|
parts. `FAIL_ON` and the exit codes are unchanged.
|
||||||
|
|
||||||
|
Without `IMPORTANT_CONTAINERS`, the previous single aggregated notification is
|
||||||
|
sent.
|
||||||
|
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ services:
|
|||||||
TRIVY_TIMEOUT: ${TRIVY_TIMEOUT:-15m}
|
TRIVY_TIMEOUT: ${TRIVY_TIMEOUT:-15m}
|
||||||
# Optional: also scan the scanner's own image (default: false).
|
# Optional: also scan the scanner's own image (default: false).
|
||||||
INCLUDE_SELF: ${INCLUDE_SELF:-false}
|
INCLUDE_SELF: ${INCLUDE_SELF:-false}
|
||||||
|
# Optional: containers that get a full per-container CVE notification.
|
||||||
|
IMPORTANT_CONTAINERS: ${IMPORTANT_CONTAINERS:-}
|
||||||
# Optional: scan a list of images instead of IMAGES (mount the file).
|
# Optional: scan a list of images instead of IMAGES (mount the file).
|
||||||
IMAGES_FILE: ${IMAGES_FILE:-}
|
IMAGES_FILE: ${IMAGES_FILE:-}
|
||||||
# Path of the Docker socket *inside* the container (same as on the host,
|
# Path of the Docker socket *inside* the container (same as on the host,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ APPRISE_TITLE_PREFIX = Vulnerability scan
|
|||||||
NOTIFY_MIN_SEVERITY = HIGH
|
NOTIFY_MIN_SEVERITY = HIGH
|
||||||
FAIL_ON = HIGH
|
FAIL_ON = HIGH
|
||||||
IGNORE_UNFIXED = false
|
IGNORE_UNFIXED = false
|
||||||
|
# Containers with outside access that get a full per-container notification.
|
||||||
|
# IMPORTANT_CONTAINERS = traefik web-frontend
|
||||||
MAX_FINDINGS_IN_BODY = 15
|
MAX_FINDINGS_IN_BODY = 15
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ APPRISE_TITLE_PREFIX = Vulnerability scan
|
|||||||
NOTIFY_MIN_SEVERITY = HIGH
|
NOTIFY_MIN_SEVERITY = HIGH
|
||||||
FAIL_ON = HIGH
|
FAIL_ON = HIGH
|
||||||
IGNORE_UNFIXED = false
|
IGNORE_UNFIXED = false
|
||||||
|
# Containers with outside access that get a full per-container notification.
|
||||||
|
# IMPORTANT_CONTAINERS = traefik web-frontend
|
||||||
MAX_FINDINGS_IN_BODY = 15
|
MAX_FINDINGS_IN_BODY = 15
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ def split_words(raw: str) -> list[str]:
|
|||||||
return [word for word in raw.replace("\n", " ").split() if word]
|
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]:
|
def dedupe(items: list[str]) -> list[str]:
|
||||||
"""Drop duplicates while keeping the original order."""
|
"""Drop duplicates while keeping the original order."""
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -370,6 +375,7 @@ class Settings:
|
|||||||
trivy_cache_dir: str = "/var/lib/trivy"
|
trivy_cache_dir: str = "/var/lib/trivy"
|
||||||
trivy_timeout: str = "15m"
|
trivy_timeout: str = "15m"
|
||||||
max_findings: int = 15
|
max_findings: int = 15
|
||||||
|
important_containers: list[str] = field(default_factory=list)
|
||||||
include_self: bool = False
|
include_self: bool = False
|
||||||
docker_socket: str | None = None
|
docker_socket: str | None = None
|
||||||
|
|
||||||
@@ -400,6 +406,7 @@ class Settings:
|
|||||||
trivy_cache_dir=os.environ.get("TRIVY_CACHE_DIR", "/var/lib/trivy"),
|
trivy_cache_dir=os.environ.get("TRIVY_CACHE_DIR", "/var/lib/trivy"),
|
||||||
trivy_timeout=os.environ.get("TRIVY_TIMEOUT", "15m"),
|
trivy_timeout=os.environ.get("TRIVY_TIMEOUT", "15m"),
|
||||||
max_findings=env_int("MAX_FINDINGS_IN_BODY", 15),
|
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),
|
include_self=env_bool("INCLUDE_SELF", False),
|
||||||
)
|
)
|
||||||
settings.validate()
|
settings.validate()
|
||||||
@@ -503,6 +510,7 @@ class ScanResult:
|
|||||||
medium: int = 0
|
medium: int = 0
|
||||||
low: int = 0
|
low: int = 0
|
||||||
findings: list[dict] = field(default_factory=list)
|
findings: list[dict] = field(default_factory=list)
|
||||||
|
all_findings: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def counts(self) -> dict[str, int]:
|
def counts(self) -> dict[str, int]:
|
||||||
@@ -536,6 +544,16 @@ class CveAggregation:
|
|||||||
fixable: bool = False
|
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
|
@dataclass
|
||||||
class AggregatedReport:
|
class AggregatedReport:
|
||||||
critical: int = 0
|
critical: int = 0
|
||||||
@@ -544,6 +562,7 @@ class AggregatedReport:
|
|||||||
low: int = 0
|
low: int = 0
|
||||||
cves: dict[str, CveAggregation] = field(default_factory=dict)
|
cves: dict[str, CveAggregation] = field(default_factory=dict)
|
||||||
reports: list[str] = field(default_factory=list)
|
reports: list[str] = field(default_factory=list)
|
||||||
|
results: dict[str, ScanResult] = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def counts(self) -> dict[str, int]:
|
def counts(self) -> dict[str, int]:
|
||||||
@@ -632,17 +651,80 @@ def summarise(report: dict) -> ScanResult:
|
|||||||
result.medium += 1
|
result.medium += 1
|
||||||
elif severity == "LOW":
|
elif severity == "LOW":
|
||||||
result.low += 1
|
result.low += 1
|
||||||
|
if severity in REPORTED_SEVERITIES:
|
||||||
|
result.all_findings.append(vuln)
|
||||||
if severity in ("CRITICAL", "HIGH"):
|
if severity in ("CRITICAL", "HIGH"):
|
||||||
result.findings.append(vuln)
|
result.findings.append(vuln)
|
||||||
result.findings.sort(
|
|
||||||
key=lambda v: (
|
def sort_key(vuln: dict) -> tuple[int, str]:
|
||||||
0 if str(v.get("Severity", "")).upper() == "CRITICAL" else 1,
|
return (
|
||||||
str(v.get("VulnerabilityID", "")),
|
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
|
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]:
|
def trivy_environment(settings: Settings) -> dict[str, str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
# Trivy needs DOCKER_HOST to read images that only exist in the daemon.
|
# 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)
|
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
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -923,6 +1084,7 @@ def scan_image(
|
|||||||
return
|
return
|
||||||
|
|
||||||
result = summarise(report)
|
result = summarise(report)
|
||||||
|
aggregated.results[image] = result
|
||||||
log(
|
log(
|
||||||
"INFO",
|
"INFO",
|
||||||
f"{image}{context}: {result.critical} critical, {result.high} high, "
|
f"{image}{context}: {result.critical} critical, {result.high} high, "
|
||||||
@@ -952,10 +1114,10 @@ def scan_image(
|
|||||||
def usage() -> str:
|
def usage() -> str:
|
||||||
return f"""Usage: {PROG} [IMAGE ...]
|
return f"""Usage: {PROG} [IMAGE ...]
|
||||||
|
|
||||||
Scan each Docker image with Trivy. Findings are aggregated by CVE across all
|
Scan each Docker image with Trivy. When any finding reaches
|
||||||
images and a single notification is sent when any reach
|
NOTIFY_MIN_SEVERITY (default: HIGH), a notification is sent. With
|
||||||
NOTIFY_MIN_SEVERITY (default: HIGH). Each CVE lists the containers and images
|
IMPORTANT_CONTAINERS, each named container gets a full CVE list and the rest
|
||||||
that are affected.
|
are summarised together; 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).
|
||||||
@@ -987,6 +1149,9 @@ Environment:
|
|||||||
TRIVY_CACHE_DIR Trivy database/cache directory (default: /var/lib/trivy).
|
TRIVY_CACHE_DIR Trivy database/cache directory (default: /var/lib/trivy).
|
||||||
TRIVY_TIMEOUT Scan timeout passed to Trivy (default: 15m).
|
TRIVY_TIMEOUT Scan timeout passed to Trivy (default: 15m).
|
||||||
MAX_FINDINGS_IN_BODY Findings listed in a notification body (default: 15).
|
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 Images to scan, space or newline separated.
|
||||||
IMAGES_FILE File containing images to scan (one per line).
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
argv = list(sys.argv[1:] if argv is None else argv)
|
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)
|
targets = collect_targets(settings)
|
||||||
if not targets:
|
if not targets:
|
||||||
return 0
|
return 0
|
||||||
|
container_images, discovery_known = resolve_container_images(targets, settings)
|
||||||
|
|
||||||
os.makedirs(settings.report_dir, exist_ok=True)
|
os.makedirs(settings.report_dir, exist_ok=True)
|
||||||
os.makedirs(settings.trivy_cache_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:
|
for target in targets:
|
||||||
scan_image(target, settings, stats, aggregated)
|
scan_image(target, settings, stats, aggregated)
|
||||||
|
|
||||||
if aggregated.cves and threshold_hits_from_counts(aggregated.counts, settings.notify_rank):
|
send_notifications(
|
||||||
title = f"{settings.apprise_title_prefix}: {len(aggregated.cves)} CVE(s) across {len(targets)} image(s)"
|
settings, targets, aggregated, stats, container_images, discovery_known
|
||||||
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}")
|
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"INFO",
|
"INFO",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ class SettingsTests(unittest.TestCase):
|
|||||||
"IGNORE_UNFIXED": "true",
|
"IGNORE_UNFIXED": "true",
|
||||||
"MAX_FINDINGS_IN_BODY": "3",
|
"MAX_FINDINGS_IN_BODY": "3",
|
||||||
"TRIVY_VEX": "/vex/app.json repo",
|
"TRIVY_VEX": "/vex/app.json repo",
|
||||||
|
"IMPORTANT_CONTAINERS": "traefik, web-frontend\nworker",
|
||||||
}
|
}
|
||||||
with mock.patch.dict(os.environ, env, clear=False):
|
with mock.patch.dict(os.environ, env, clear=False):
|
||||||
settings = scan.Settings.from_env([])
|
settings = scan.Settings.from_env([])
|
||||||
@@ -306,6 +307,9 @@ class SettingsTests(unittest.TestCase):
|
|||||||
self.assertTrue(settings.ignore_unfixed)
|
self.assertTrue(settings.ignore_unfixed)
|
||||||
self.assertEqual(settings.max_findings, 3)
|
self.assertEqual(settings.max_findings, 3)
|
||||||
self.assertEqual(settings.trivy_vex, ["/vex/app.json", "repo"])
|
self.assertEqual(settings.trivy_vex, ["/vex/app.json", "repo"])
|
||||||
|
self.assertEqual(
|
||||||
|
settings.important_containers, ["traefik", "web-frontend", "worker"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_settings_rejects_unknown_severity(self) -> None:
|
def test_settings_rejects_unknown_severity(self) -> None:
|
||||||
with mock.patch.dict(os.environ, {"NOTIFY_MIN_SEVERITY": "SEVERE"}, clear=False):
|
with mock.patch.dict(os.environ, {"NOTIFY_MIN_SEVERITY": "SEVERE"}, clear=False):
|
||||||
@@ -452,6 +456,68 @@ class ReportTests(unittest.TestCase):
|
|||||||
self.assertIn("cache", body)
|
self.assertIn("cache", body)
|
||||||
self.assertIn("redis:7", body)
|
self.assertIn("redis:7", body)
|
||||||
|
|
||||||
|
def test_cve_entries_split_fixable_and_unfixable(self) -> None:
|
||||||
|
entries = scan.cve_entries(scan.summarise(REPORT), scan.severity_rank("HIGH"))
|
||||||
|
self.assertEqual(scan.split_counts(entries), (3, 2, 1))
|
||||||
|
self.assertEqual([entry.cve for entry in entries], ["CVE-1", "CVE-2", "CVE-5"])
|
||||||
|
self.assertEqual(scan.severity_counts(entries), {
|
||||||
|
"CRITICAL": 1,
|
||||||
|
"HIGH": 2,
|
||||||
|
"MEDIUM": 0,
|
||||||
|
"LOW": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_build_important_body_lists_every_cve(self) -> None:
|
||||||
|
result = scan.summarise(REPORT)
|
||||||
|
target = scan.ScanTarget(image="nginx:1.27", containers=["web"])
|
||||||
|
body = scan.build_important_body(
|
||||||
|
"web", target, result, "/reports/x.json", "HIGH"
|
||||||
|
)
|
||||||
|
self.assertIn("Image: nginx:1.27", body)
|
||||||
|
self.assertIn("Container: web", body)
|
||||||
|
self.assertIn("Total: 3 (fixable: 2, unfixable: 1)", body)
|
||||||
|
self.assertIn("CVE-1", body)
|
||||||
|
self.assertIn("CVE-2", body)
|
||||||
|
self.assertIn("CVE-5", body)
|
||||||
|
self.assertIn("no fix available", body)
|
||||||
|
self.assertIn("/reports/x.json", body)
|
||||||
|
|
||||||
|
def test_build_important_body_respects_notification_threshold(self) -> None:
|
||||||
|
result = scan.summarise(REPORT)
|
||||||
|
target = scan.ScanTarget(image="nginx:1.27", containers=["web"])
|
||||||
|
body = scan.build_important_body(
|
||||||
|
"web", target, result, "/reports/x.json", "CRITICAL"
|
||||||
|
)
|
||||||
|
self.assertIn("CVE-1", body)
|
||||||
|
self.assertNotIn("CVE-2", body)
|
||||||
|
self.assertNotIn("CVE-5", body)
|
||||||
|
self.assertIn("Total: 1 (fixable: 1, unfixable: 0)", body)
|
||||||
|
|
||||||
|
def test_build_other_containers_body_has_counts_per_container(self) -> None:
|
||||||
|
body = scan.build_other_containers_body(
|
||||||
|
[
|
||||||
|
("nginx:1.27", "web-worker", 3, 2, 1),
|
||||||
|
("redis:7", "cache", 4, 0, 4),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertIn("* nginx:1.27 [web-worker]: 3 total (2 fixable, 1 unfixable)", body)
|
||||||
|
self.assertIn("* redis:7 [cache]: 4 total (0 fixable, 4 unfixable)", body)
|
||||||
|
self.assertIn("Total: 7 CVE(s) in 2 container(s)", body)
|
||||||
|
|
||||||
|
def test_build_missing_body_separates_missing_and_unscanned(self) -> None:
|
||||||
|
body = scan.build_missing_body(["gone"], ["web"])
|
||||||
|
self.assertIn("not running", body)
|
||||||
|
self.assertIn("* gone", body)
|
||||||
|
self.assertIn("image was not scanned", body)
|
||||||
|
self.assertIn("* web", body)
|
||||||
|
|
||||||
|
def test_split_body_keeps_every_line(self) -> None:
|
||||||
|
body = "\n".join(f"CVE-{index}" for index in range(10))
|
||||||
|
parts = scan._split_body(body, 20)
|
||||||
|
self.assertGreater(len(parts), 1)
|
||||||
|
self.assertTrue(all(len(part) <= 20 for part in parts))
|
||||||
|
self.assertEqual("\n".join(parts), body)
|
||||||
|
|
||||||
|
|
||||||
class TrivyCommandTests(unittest.TestCase):
|
class TrivyCommandTests(unittest.TestCase):
|
||||||
def test_run_trivy_passes_vex_sources_in_order(self) -> None:
|
def test_run_trivy_passes_vex_sources_in_order(self) -> None:
|
||||||
@@ -584,6 +650,52 @@ printf '%s\\n' "$*" >> "$APPRISE_LOG"
|
|||||||
self.assertIn("CVE-2026-0001", notification)
|
self.assertIn("CVE-2026-0001", notification)
|
||||||
self.assertIn("testphp", notification)
|
self.assertIn("testphp", notification)
|
||||||
|
|
||||||
|
def test_important_container_gets_full_list_and_others_get_summary(self) -> None:
|
||||||
|
containers = [
|
||||||
|
container("a" * 64, "testphp-php", name="web"),
|
||||||
|
container("b" * 64, "redis:7", name="cache"),
|
||||||
|
]
|
||||||
|
with FakeDocker(containers) as docker:
|
||||||
|
code = self.run_main(
|
||||||
|
[], docker.socket_path, IMPORTANT_CONTAINERS="web"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
notification = Path(self.apprise_log).read_text()
|
||||||
|
self.assertIn("Vulnerability scan: web (testphp-php)", notification)
|
||||||
|
self.assertIn("Container: web", notification)
|
||||||
|
self.assertIn("CVE-2026-0001", notification)
|
||||||
|
self.assertIn("Vulnerability scan: other containers", notification)
|
||||||
|
self.assertIn("redis:7 [cache]", notification)
|
||||||
|
self.assertIn("(1 fixable, 0 unfixable)", notification)
|
||||||
|
# The important container must not also appear in the summary.
|
||||||
|
self.assertNotIn("testphp-php [web]", notification)
|
||||||
|
|
||||||
|
def test_missing_important_container_is_reported(self) -> None:
|
||||||
|
containers = [container("a" * 64, "testphp-php", name="web")]
|
||||||
|
with FakeDocker(containers) as docker:
|
||||||
|
code = self.run_main(
|
||||||
|
[], docker.socket_path, IMPORTANT_CONTAINERS="web stale"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
notification = Path(self.apprise_log).read_text()
|
||||||
|
self.assertIn("Vulnerability scan: important containers", notification)
|
||||||
|
self.assertIn("Important containers are not running:", notification)
|
||||||
|
self.assertIn("* stale", notification)
|
||||||
|
|
||||||
|
def test_important_container_with_unscanned_image_is_reported(self) -> None:
|
||||||
|
containers = [container("a" * 64, "other:1", name="web")]
|
||||||
|
with FakeDocker(containers) as docker:
|
||||||
|
code = self.run_main(
|
||||||
|
["nginx:1.27"], docker.socket_path, IMPORTANT_CONTAINERS="web"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
notification = Path(self.apprise_log).read_text()
|
||||||
|
self.assertIn("image was not scanned", notification)
|
||||||
|
self.assertIn("* web", notification)
|
||||||
|
|
||||||
def test_scan_failure_notification_includes_container(self) -> None:
|
def test_scan_failure_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