Four findings from one estate, and three of them are the same shape: the scan
had nothing to compare, so it said nothing, and nothing reads as clean.
* IMI GET /firmwares/ answers HTTP 404 on UMS 12 ("No endpoint GET
/umsapi/v3/firmwares/"). The documented URL has no trailing slash. The
firmware table is the ONLY place a device's version string lives, so the
slash cost every version in the estate at once.
* IGEL OS 12 states its base-system build inline — 12.6.0+2 — and
_clean_version's dotted-numeric rule threw the whole string away. No
version, no scan: eleven devices on 12.6.0+2 showed zero CVEs while
sitting inside the range of both current ISNs. Semver excludes build
metadata from precedence and every IGEL bound is written without it
(12.7.6, 12.8.3), so the suffix is dropped, not rejected. 12.9.0+3 stays
clean, which is the correct answer and not a miss — 12.9.0 IS the fix.
* The UMS server's own version came back empty and its build not at all,
so the server asset carried no version and Test read "UMS (build ?)".
Nothing failed: serverstatus answers 200 either way. The documented IMI v3
keys are tried first, then the spellings UMS 12 has been seen to use, and
a payload that carries none of them now logs the keys it did carry — the
next rename should cost one log line, not an estate.
The fourth is not a bug but the question the CVEs are a footnote to.
IGEL OS 11 stops receiving security fixes on 2026-06-30. No CVE feed will ever
state that, because "unpatchable from here on" is not a CVE, and
endoflife.date carries no IGEL product at all — so the dates are transcribed
from IGEL's own Knowledge Base.
The mapping is the part that needed care, because IGEL's two terms are not the
two this codebase already has, and taken the obvious way round they invert:
EOL - End of Life no further ENHANCEMENTS; security fixes still ship.
OS 11 hit this in April 2023 when OS 12 launched,
and stayed patched for three more years. -> eoasFrom,
informational, LOW.
EOM - End of Maintenance "no updates, no security and bug fixes." -> eolFrom,
a real finding.
The 2025-12-31 that circulates for OS 11 is IGEL's original date; the vendor
page now states 30th June, 2026. Third-party migration write-ups still carry
the old one. The vendor's page wins.
OS 12 gets an entry with no EOM, because IGEL has published none and an
invented date would be the only unsourced one on the page. It raises nothing.
UMS 6 (EOM 2023-10-31) does — that one is 1037 days past.
A migrated device has no other way out of its old finding: a thin client has
no software inventory, so the EOL sweep's own reconcile never reaches it. Both
slugs are single-release, and the pass supersedes explicitly when the line it
now runs is one that raises nothing.
Findings say "IGEL product lifecycle", not endoflife.date. A row that named a
source which has never heard of the product sends an operator to a page that
cannot confirm or refute it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""IGEL UMS client (IGEL Management Interface, IMI API v3).
|
|
|
|
Thin httpx wrapper, no DB writes — same contract as the other integration
|
|
clients. Three things are read and nothing else:
|
|
|
|
* `GET /v3/serverstatus` — the UMS server's own identity (`rmGuiServerVersion`,
|
|
`buildNumber`, `serverUUID`). The only endpoint that answers without a
|
|
login, which is what makes it a usable reachability probe.
|
|
* `GET /v3/firmwares` — every firmware registered with this UMS, as
|
|
{id: product, version, firmwareType}. Small list (one row per firmware
|
|
ever imported), and the ONLY place the version string lives.
|
|
* `GET /v3/thinclients?facets=details` — every endpoint device in one call,
|
|
with its `firmwareID`, unit ID, MAC, last IP and hardware details.
|
|
|
|
Why the firmware table is fetched separately: the device record carries a
|
|
`firmwareID`, not a version — "21", not "11.09.100.01". Joining the two is
|
|
what turns an inventory into something a CVE range can be compared against,
|
|
and it costs exactly one extra request for the whole estate. The alternative
|
|
(`/v3/thinclients/{id}` per device, then `/v3/firmwares/{fwId}` per device) is
|
|
two calls per thin client; a 3000-device estate would make 6000.
|
|
|
|
Auth: HTTP Basic on POST /v3/login, which returns a JSESSIONID cookie valid
|
|
for 30 minutes, refreshed by every request. httpx.Client keeps the cookie jar,
|
|
so the login is a single call and everything after it just works.
|
|
|
|
Read-only throughout: a UMS account with the built-in read-only permissions is
|
|
enough — this client never POSTs a command to a device. IMI has to be enabled
|
|
and licensed on the UMS server first (UMS Console → Network → IMI).
|
|
|
|
API reference: https://kb.igel.com/en/igel-management-interface/current
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Dict, List, Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class IgelError(RuntimeError):
|
|
"""Connection / authentication failure, with the IMI message kept."""
|
|
|
|
|
|
class IgelClient:
|
|
def __init__(self, host: str, username: str, password: str,
|
|
port: int = 8443, verify_ssl: bool = True):
|
|
self.host = (host or "").strip().replace("https://", "").replace("http://", "").rstrip("/")
|
|
self.username = username
|
|
self.password = password
|
|
self.port = int(port or 8443)
|
|
self.verify_ssl = verify_ssl
|
|
self.base_url = f"https://{self.host}:{self.port}/umsapi/v3"
|
|
# Short connect timeout, generous read: the same reasoning as the
|
|
# Nessus client — an unreachable UMS must fail fast instead of holding
|
|
# a worker, while `?facets=details` over a large estate is genuinely
|
|
# slow to serialise.
|
|
self._client = httpx.Client(
|
|
base_url=self.base_url,
|
|
verify=verify_ssl,
|
|
timeout=httpx.Timeout(connect=8.0, read=60.0, write=60.0, pool=5.0),
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
self._logged_in = False
|
|
|
|
# ---------- connection ----------
|
|
def login(self) -> None:
|
|
if self._logged_in:
|
|
return
|
|
try:
|
|
r = self._client.post("/login", auth=(self.username, self.password))
|
|
except httpx.HTTPError as e:
|
|
raise IgelError(f"IMI login failed for {self.host}: {e}") from e
|
|
if r.status_code == 401:
|
|
raise IgelError("IMI login rejected the credentials (HTTP 401). "
|
|
"Check the UMS account, and that it is allowed to "
|
|
"use IMI (UMS Console → Network → IMI).")
|
|
if r.status_code >= 400:
|
|
raise IgelError(f"IMI login failed (HTTP {r.status_code}): {_imi_error(r)}")
|
|
self._logged_in = True
|
|
|
|
def close(self) -> None:
|
|
if self._logged_in:
|
|
try:
|
|
self._client.post("/logout")
|
|
except Exception:
|
|
pass
|
|
self._logged_in = False
|
|
self._client.close()
|
|
|
|
def _get(self, path: str) -> object:
|
|
self.login()
|
|
try:
|
|
r = self._client.get(path)
|
|
except httpx.HTTPError as e:
|
|
raise IgelError(f"IMI GET {path} failed: {e}") from e
|
|
if r.status_code >= 400:
|
|
raise IgelError(f"IMI GET {path} failed (HTTP {r.status_code}): {_imi_error(r)}")
|
|
try:
|
|
return r.json()
|
|
except ValueError as e:
|
|
raise IgelError(f"IMI GET {path} returned no JSON") from e
|
|
|
|
# ---------- reads ----------
|
|
def get_server_status(self) -> dict:
|
|
"""The UMS server's own identity. No login required (by design)."""
|
|
try:
|
|
r = self._client.get("/serverstatus")
|
|
except httpx.HTTPError as e:
|
|
raise IgelError(f"IMI unreachable at {self.base_url}: {e}") from e
|
|
if r.status_code >= 400:
|
|
raise IgelError(f"IMI serverstatus failed (HTTP {r.status_code}): {_imi_error(r)}")
|
|
d = r.json() if r.content else {}
|
|
out = {
|
|
"version": _first(d, _STATUS_VERSION_KEYS),
|
|
"build": _first(d, _STATUS_BUILD_KEYS),
|
|
"server_uuid": _first(d, ("serverUUID", "serverUuid", "uuid")),
|
|
"server": d.get("server"),
|
|
}
|
|
if not out["version"]:
|
|
# Said out loud, with the keys the server actually sent. The field
|
|
# names below are IMI v3's documented ones and UMS answers 200
|
|
# either way, so a rename shows up as an asset with no version and
|
|
# a Test button reading "UMS (build ?)" — nothing failing, just
|
|
# nothing there. The next rename should be one log line to find.
|
|
logger.warning("IMI serverstatus carried no version; keys were: %s",
|
|
sorted(d) if isinstance(d, dict) else type(d).__name__)
|
|
return out
|
|
|
|
def get_firmwares(self) -> Dict[str, dict]:
|
|
"""{firmware id: {product, version, type}}.
|
|
|
|
Returned verbatim — deciding which firmware IS an IGEL OS is the
|
|
service's job (igel_service.os_from_firmware), not this client's.
|
|
|
|
UMS wraps the list in `FwResource`; a UMS that returns a bare list is
|
|
accepted too, because the same endpoint is documented both ways across
|
|
IMI versions and an unwrapped answer must not read as "no firmwares".
|
|
"""
|
|
# No trailing slash. UMS 12 routes "/firmwares/" to nothing and answers
|
|
# HTTP 404 "No endpoint GET /umsapi/v3/firmwares/" — the documented URL
|
|
# is /v3/firmwares, and the slash was silently costing the whole
|
|
# firmware table, which is the only place a version string lives.
|
|
data = self._get("/firmwares")
|
|
rows = data.get("FwResource", []) if isinstance(data, dict) else (data or [])
|
|
out: Dict[str, dict] = {}
|
|
for row in rows:
|
|
if not isinstance(row, dict) or not row.get("id"):
|
|
continue
|
|
out[str(row["id"])] = {
|
|
"product": (row.get("product") or "").strip(),
|
|
"version": (row.get("version") or "").strip(),
|
|
"type": (row.get("firmwareType") or "").strip(),
|
|
}
|
|
return out
|
|
|
|
def get_devices(self) -> List[dict]:
|
|
"""Every endpoint device, one call, with details.
|
|
|
|
Recycle-bin entries (`movedToBin`) are dropped here rather than by the
|
|
caller: UMS keeps a deleted device in the listing indefinitely, and a
|
|
device somebody deleted is exactly the one that should stop counting as
|
|
inventory.
|
|
"""
|
|
data = self._get("/thinclients?facets=details")
|
|
rows = data if isinstance(data, list) else (data or {}).get("TcResource", [])
|
|
return [self._device_dict(r) for r in rows
|
|
if isinstance(r, dict) and not r.get("movedToBin")]
|
|
|
|
def test_connection(self) -> dict:
|
|
"""Credential + reachability probe for the settings UI.
|
|
|
|
Server status first because it needs no login: that separates "cannot
|
|
reach the IMI service" from "reached it, credentials rejected", which
|
|
are two very different things to put in front of an operator.
|
|
"""
|
|
try:
|
|
status = self.get_server_status()
|
|
except IgelError as e:
|
|
return {"ok": False, "step": "connect", "error": str(e)}
|
|
try:
|
|
self.login()
|
|
except IgelError as e:
|
|
return {"ok": False, "step": "login", "error": str(e), "ums": status}
|
|
try:
|
|
devices = self.get_devices()
|
|
firmwares = self.get_firmwares()
|
|
except IgelError as e:
|
|
return {"ok": True, "step": "inventory", "error": str(e),
|
|
"ums": status, "device_count": None}
|
|
return {"ok": True, "ums": status, "device_count": len(devices),
|
|
"firmware_count": len(firmwares)}
|
|
|
|
@staticmethod
|
|
def _device_dict(r: dict) -> dict:
|
|
# Only what the sync writes onto an asset. `?facets=details` returns
|
|
# forty-odd fields per device (monitors, BIOS dates, uptime counters);
|
|
# carrying the ones nothing reads would be forty columns of estate
|
|
# inventory travelling through a vulnerability scanner for no purpose.
|
|
return {
|
|
"unit_id": (r.get("unitID") or "").strip(),
|
|
"firmware_id": str(r.get("firmwareID") or ""),
|
|
"name": (r.get("name") or "").strip(),
|
|
"network_name": (r.get("networkName") or "").strip(),
|
|
"ip_address": (r.get("lastIP") or "").strip() or None,
|
|
"device_type": (r.get("deviceType") or "").strip(),
|
|
"product_id": (r.get("productId") or "").strip(),
|
|
"os_type": (r.get("osType") or "").strip(),
|
|
}
|
|
|
|
|
|
# Documented IMI v3 spelling first, then the ones UMS 12 has been seen to use.
|
|
# https://kb.igel.com/en/igel-management-interface/current/get-v3-serverstatus
|
|
_STATUS_VERSION_KEYS = ("rmGuiServerVersion", "umsVersion", "serverVersion",
|
|
"version")
|
|
_STATUS_BUILD_KEYS = ("buildNumber", "build", "buildNo", "buildnumber")
|
|
|
|
|
|
def _first(d: object, keys) -> Optional[str]:
|
|
"""First key present with a non-empty value, stringified. Empty is missing:
|
|
an UMS that answers "" for its version must not read as a version of ""."""
|
|
if not isinstance(d, dict):
|
|
return None
|
|
for k in keys:
|
|
v = d.get(k)
|
|
if v is not None and str(v).strip():
|
|
return str(v).strip()
|
|
return None
|
|
|
|
|
|
def _imi_error(r: httpx.Response) -> str:
|
|
"""The IMI error message, which lives in the body, not the status line."""
|
|
try:
|
|
body = r.json()
|
|
if isinstance(body, dict):
|
|
return str(body.get("message") or body.get("error") or body)[:300]
|
|
except Exception:
|
|
pass
|
|
return (r.text or "")[:300]
|