add container info

This commit is contained in:
David Duran Rosich committed 2026-10-23 22:31:56 +02:00
1 parent 6856fda22e
commit 81961f268f
3 files changed
+210 -52

No files matched your search

+113 -44
View File
@@ -217,6 +217,16 @@ def _looks_like_image_id(reference: str) -> bool:
return 12 <= len(reference) <= 64 and all(c in "0123456789abcdef" for c in reference)
def _container_name(container: dict) -> str:
"""Human readable name for a container summary entry."""
for name in container.get("Names") or []:
cleaned = str(name).strip().lstrip("/")
if cleaned:
return cleaned
container_id = str(container.get("Id") or "")
return container_id[:12] if container_id else "unknown"
def _container_image_name(socket_path: str, container: dict) -> str:
"""Best available image reference for a container summary entry."""
name = (container.get("Image") or "").strip()
@@ -237,11 +247,12 @@ def _container_image_name(socket_path: str, container: dict) -> str:
return name or (container.get("ImageID") or "").strip()
def images_in_use(socket_path: str, include_self: bool = False) -> list[str]:
"""Images referenced by running containers, in Docker's listing order.
def containers_in_use(socket_path: str, include_self: bool = False) -> dict[str, list[str]]:
"""Map running-container image references to the containers using them.
The scanner's own container is skipped unless ``include_self`` is true, so
a scheduled scan does not report on the scanner image itself.
Images keep Docker's listing order and each container is listed once. The
scanner's own container is skipped unless ``include_self`` is true, so a
scheduled scan does not report on the scanner image itself.
"""
containers = docker_api_get(socket_path, "/containers/json")
if not isinstance(containers, list):
@@ -249,7 +260,7 @@ def images_in_use(socket_path: str, include_self: bool = False) -> list[str]:
hostname = os.environ.get("HOSTNAME", "").strip()
images: list[str] = []
images: dict[str, list[str]] = {}
for container in containers:
if not isinstance(container, dict):
continue
@@ -258,51 +269,84 @@ def images_in_use(socket_path: str, include_self: bool = False) -> list[str]:
log("DEBUG", f"skipping scanner's own container {container_id[:12]}")
continue
image = _container_image_name(socket_path, container)
if image:
images.append(image)
return dedupe(images)
if not image:
continue
names = images.setdefault(image, [])
name = _container_name(container)
if name not in names:
names.append(name)
return images
def images_from_docker_cli() -> list[str]:
def images_in_use(socket_path: str, include_self: bool = False) -> list[str]:
"""Images referenced by running containers, in Docker's listing order."""
return list(containers_in_use(socket_path, include_self=include_self))
def containers_from_docker_cli() -> dict[str, list[str]]:
"""Fallback for environments where the socket is not reachable directly."""
if not shutil.which("docker"):
log("DEBUG", "docker CLI not found")
return []
return {}
try:
completed = subprocess.run(
["docker", "ps", "--format", "{{.Image}}"],
["docker", "ps", "--format", "{{.Image}}\t{{.Names}}"],
capture_output=True,
text=True,
timeout=15,
)
except OSError as exc:
log("DEBUG", f"docker CLI failed: {exc}")
return []
return {}
if completed.returncode != 0:
log("DEBUG", f"docker ps failed: {completed.stderr.strip()}")
return []
return dedupe(split_words(completed.stdout.replace("\n", " ")))
return {}
images: dict[str, list[str]] = {}
for line in completed.stdout.splitlines():
image, _, raw_names = line.partition("\t")
image = image.strip()
if not image:
continue
names = images.setdefault(image, [])
for name in raw_names.replace(",", " ").split():
if name not in names:
names.append(name)
return images
def discover_images(include_self: bool = False) -> tuple[list[str], str | None]:
"""Find images currently in use. Returns (images, source description)."""
def images_from_docker_cli() -> list[str]:
"""Images reported by the docker CLI, in listing order."""
return list(containers_from_docker_cli())
def discover_containers(
include_self: bool = False,
) -> tuple[dict[str, list[str]], str | None]:
"""Find images in use and their containers. Returns (images, source)."""
socket_path = find_docker_socket()
if socket_path is None:
log("DEBUG", "no Docker socket found")
else:
try:
images = images_in_use(socket_path, include_self=include_self)
containers = containers_in_use(socket_path, include_self=include_self)
except DockerError as exc:
log("WARN", f"could not query Docker at {socket_path}: {exc}")
else:
if images:
return images, f"running containers (via {socket_path})"
if containers:
return containers, f"running containers (via {socket_path})"
images = images_from_docker_cli()
if images:
return images, "running containers (via docker CLI)"
containers = containers_from_docker_cli()
if containers:
return containers, "running containers (via docker CLI)"
return [], None
return {}, None
def discover_images(include_self: bool = False) -> tuple[list[str], str | None]:
"""Find images currently in use. Returns (images, source description)."""
containers, source = discover_containers(include_self=include_self)
return list(containers), source
# ---------------------------------------------------------------------------
@@ -386,14 +430,33 @@ def read_images_file(path: str) -> list[str]:
return images
def collect_images(settings: Settings) -> list[str]:
if settings.explicit_images:
return list(settings.explicit_images)
@dataclass
class ScanTarget:
"""An image to scan and the running containers that use it."""
images, source = discover_images(include_self=settings.include_self)
if images:
log("INFO", f"No images configured; scanning {len(images)} image(s) from {source}")
return images
image: str
containers: list[str] = field(default_factory=list)
@property
def container_suffix(self) -> str:
if not self.containers:
return ""
if len(self.containers) == 1:
return f" (used by container {self.containers[0]})"
return f" (used by containers {', '.join(self.containers)})"
def collect_targets(settings: Settings) -> list[ScanTarget]:
if settings.explicit_images:
return [ScanTarget(image=image) for image in settings.explicit_images]
containers, source = discover_containers(include_self=settings.include_self)
if containers:
log("INFO", f"No images configured; scanning {len(containers)} image(s) from {source}")
return [
ScanTarget(image=image, containers=names)
for image, names in containers.items()
]
log(
"WARN",
@@ -404,6 +467,10 @@ def collect_images(settings: Settings) -> list[str]:
return []
def collect_images(settings: Settings) -> list[str]:
return [target.image for target in collect_targets(settings)]
# ---------------------------------------------------------------------------
# Trivy
# ---------------------------------------------------------------------------
@@ -463,7 +530,7 @@ def trivy_environment(settings: Settings) -> dict[str, str]:
return env
def run_trivy(image: str, settings: Settings) -> tuple[int, str]:
def run_trivy(image: str, settings: Settings, context: str = "") -> tuple[int, str]:
report_path = os.path.join(settings.report_dir, f"{report_slug(image)}.json")
command = [
"trivy",
@@ -486,7 +553,7 @@ def run_trivy(image: str, settings: Settings) -> tuple[int, str]:
command.append("--ignore-unfixed")
command.append(image)
log("INFO", f"Scanning {image}")
log("INFO", f"Scanning {image}{context}")
try:
completed = subprocess.run(
command,
@@ -495,14 +562,14 @@ def run_trivy(image: str, settings: Settings) -> tuple[int, str]:
env=trivy_environment(settings),
)
except OSError as exc:
log("ERROR", f"Scan failed: {image}: {exc}")
log("ERROR", f"Scan failed: {image}{context}: {exc}")
return 1, ""
if completed.returncode != 0:
detail = completed.stderr.strip().splitlines()
if detail:
log("ERROR", detail[-1])
log("ERROR", f"Scan failed: {image}")
log("ERROR", f"Scan failed: {image}{context}")
return 1, ""
return 0, report_path
@@ -586,8 +653,10 @@ def threshold_hits(result: ScanResult, rank: int) -> int:
return sum(count for severity, count in result.counts.items() if severity_rank(severity) >= rank)
def scan_image(image: str, settings: Settings, stats: RunStats) -> None:
status, report_path = run_trivy(image, settings)
def scan_image(target: ScanTarget, settings: Settings, stats: RunStats) -> None:
image = target.image
context = target.container_suffix
status, report_path = run_trivy(image, settings, context)
if status != 0:
stats.scan_errors += 1
if settings.notify_on_scan_error:
@@ -610,7 +679,7 @@ def scan_image(image: str, settings: Settings, stats: RunStats) -> None:
result = summarise(report)
log(
"INFO",
f"{image}: {result.critical} critical, {result.high} high, "
f"{image}{context}: {result.critical} critical, {result.high} high, "
f"{result.medium} medium, {result.low} low "
f"({result.fixable} fixable high/critical)",
)
@@ -627,10 +696,10 @@ def scan_image(image: str, settings: Settings, stats: RunStats) -> None:
if not notify(settings, title, body):
stats.notify_errors += 1
else:
log("INFO", f"{image}: no findings at or above {settings.notify_min_severity}")
log("INFO", f"{image}{context}: no findings at or above {settings.notify_min_severity}")
if fail_hits:
log("WARN", f"{image} meets FAIL_ON={settings.fail_on} ({fail_hits} finding(s))")
log("WARN", f"{image}{context} meets FAIL_ON={settings.fail_on} ({fail_hits} finding(s))")
stats.failed_images += 1
@@ -694,20 +763,20 @@ def main(argv: list[str] | None = None) -> int:
settings.docker_socket = find_docker_socket()
images = collect_images(settings)
if not images:
targets = collect_targets(settings)
if not targets:
return 0
os.makedirs(settings.report_dir, exist_ok=True)
os.makedirs(settings.trivy_cache_dir, exist_ok=True)
stats = RunStats()
for image in images:
scan_image(image, settings, stats)
for target in targets:
scan_image(target, settings, stats)
log(
"INFO",
f"Scanned {len(images)} image(s): {stats.failed_images} failed policy, "
f"Scanned {len(targets)} image(s): {stats.failed_images} failed policy, "
f"{stats.scan_errors} scan error(s), {stats.notify_errors} notification error(s)",
)