Files
vulncheck/app/integrations/dockhand_client.py
T
vulncheck 8fe0cd3caa feat(dockhand): show on which Docker host an image runs
One asset per image is deliberate (env A must not retract what env B still
reports), but with the same image on a local and a Hawser-connected host
nothing said where it ran: the asset was named after the image and carried
no host at all.

The sync now writes the asset description per Dockhand environment: the
environment name, the Docker host's own hostname from GET /api/host
(docker info Name) when it differs, connection type, host and public IP
when set, the short image id, the containers (name + 12-char id as docker
ps shows them) and stacks. Different image ids on two hosts show which one
is still on the old build. Container ids change on every recreate and are
shown, never matched on; Dockhand exposes no Docker daemon id and Podman
has none. A failing /api/host only drops the hostname. ingest_image takes
an optional description that replaces the digest line; the push path is
unchanged. No schema change.
2026-09-18 09:34:33 +02:00

112 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dockhand client (Docker management UI with built-in Grype / Trivy scans).
Thin httpx wrapper, no DB writes — same contract as the other integration
clients. Read-only endpoints over Dockhand's cached scan results, plus one
that rescans:
* `GET /api/environments` — the Docker hosts Dockhand manages;
* `GET /api/host?env=<id>` — the Docker host's real hostname, for display;
* `GET /api/images?env=<id>` — only to tell a reachable host from one
Dockhand cannot reach (see dockhand_service);
* `GET /api/vulnerabilities/export?env=<id>&format=json` — that host's
findings, one row per (image, vulnerability id, package, version), for
images still present on the host (Dockhand ≥ 1.0.37, #415);
* `POST /api/vulnerabilities/scan-all?env=<id>` — rescans every image of
the host, sequentially. Dockhand has no scan schedule of its own (it scans
on pull / update / by hand), so the nightly sync calls this first.
Auth: `Authorization: Bearer dh_…`, an API token created in Dockhand under
the user's profile. The user needs `images:view` (and `environments:view` to
list the hosts; `images:inspect` for the rescan). An instance with auth
disabled needs no token.
"""
from __future__ import annotations
from typing import List
import httpx
# One image takes 10–60 s, a Grype DB update on the first scan longer. Bounded
# so a hung Dockhand cannot hold the nightly job (and its lock) forever.
SCAN_ALL_READ_TIMEOUT = 3 * 3600.0
class DockhandError(RuntimeError):
"""Connection / authentication / API failure, with Dockhand's message kept."""
class DockhandClient:
def __init__(self, base_url: str, token: str = "", verify_ssl: bool = True):
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token.strip()}"
# Short connect, generous read: the export flattens every scan of a
# host in one response and is slow on a big one.
self._client = httpx.Client(
base_url=base_url.rstrip("/"), verify=verify_ssl, headers=headers,
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
)
def _request(self, method: str, path: str, timeout=None, **params) -> object:
try:
r = self._client.request(method, path, params=params,
timeout=timeout or self._client.timeout)
except httpx.HTTPError as e:
raise DockhandError(f"Dockhand unreachable: {e}") from e
if r.status_code in (401, 403):
raise DockhandError(f"Dockhand refused the token (HTTP {r.status_code}) — it needs "
"images:view and environments:view (images:inspect to rescan).")
if r.status_code >= 400:
raise DockhandError(f"Dockhand {path} failed (HTTP {r.status_code}): {r.text[:300]}")
try:
return r.json()
except ValueError as e:
# A reverse proxy's login page answers 200 with HTML.
raise DockhandError(f"Dockhand {path} returned no JSON — wrong URL?") from e
def _get(self, path: str, **params) -> object:
return self._request("GET", path, **params)
def environments(self) -> List[dict]:
got = self._get("/api/environments")
if not isinstance(got, list):
raise DockhandError("Dockhand /api/environments returned no list")
return got
def images(self, env_id: int) -> List[dict]:
"""The host's images. Dockhand answers `[]` — not an error — when it
cannot reach the Docker host."""
got = self._get("/api/images", env=env_id)
if not isinstance(got, list):
raise DockhandError(f"Dockhand /api/images for environment {env_id} returned no list")
return got
def host(self, env_id: int) -> dict:
"""The Docker daemon's host: `hostname` is `docker info` Name, the real
host's name even behind a socket or a Hawser agent."""
got = self._get("/api/host", env=env_id)
return got if isinstance(got, dict) else {}
def findings(self, env_id: int) -> List[dict]:
got = self._get("/api/vulnerabilities/export", env=env_id, format="json")
if not isinstance(got, dict) or not isinstance(got.get("findings"), list):
raise DockhandError(f"Dockhand export for environment {env_id} has no findings list "
"— Dockhand older than 1.0.37?")
return got["findings"]
def scan_all(self, env_id: int) -> dict:
"""Rescan every image of the host and wait for it. `Accept: application/json`
makes Dockhand answer synchronously with the summary
`{scanned, failed, total, cancelled}` (or `{success: false, error}`)
instead of a job id."""
got = self._request("POST", "/api/vulnerabilities/scan-all", env=env_id,
timeout=httpx.Timeout(connect=8.0, read=SCAN_ALL_READ_TIMEOUT,
write=30.0, pool=5.0))
if not isinstance(got, dict):
raise DockhandError(f"Dockhand scan-all for environment {env_id} returned no object")
return got
def close(self) -> None:
self._client.close()