fix(igel): a trailing slash cost the version, and no version costs everything

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>
This commit is contained in:
2026-09-02 11:10:03 +02:00
co-authored by Claude Opus 5
parent 978d4f3cc7
commit 271043a206
7 changed files with 284 additions and 20 deletions
+38 -6
View File
@@ -32,7 +32,7 @@ API reference: https://kb.igel.com/en/igel-management-interface/current
from __future__ import annotations
import logging
from typing import Dict, List
from typing import Dict, List, Optional
import httpx
@@ -112,12 +112,21 @@ class IgelClient:
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 {}
return {
"version": d.get("rmGuiServerVersion"),
"build": d.get("buildNumber"),
"server_uuid": d.get("serverUUID"),
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}}.
@@ -129,7 +138,11 @@ class IgelClient:
accepted too, because the same endpoint is documented both ways across
IMI versions and an unwrapped answer must not read as "no firmwares".
"""
data = self._get("/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:
@@ -197,6 +210,25 @@ class IgelClient:
}
# 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:
+8 -1
View File
@@ -540,7 +540,14 @@ def _clean_version(v: str) -> Optional[str]:
v = (v or "").strip()
if not v or ":" in v:
return None
core = re.split(r"[ (]", v, 1)[0]
# "+build" is dropped, not rejected. IGEL OS 12 states its base-system
# build that way — 12.6.0+2 — and so does anything else using semver build
# metadata, which the spec explicitly excludes from precedence. Every
# advisory bound is written without it (IGEL's fixes are 12.7.6 and
# 12.8.3), so keeping the suffix could only ever fail to match. It used to
# fail the dotted-numeric rule outright, which meant no version, which
# meant no scan: a whole IGEL OS 12 fleet showed zero CVEs.
core = re.split(r"[ (+]", v, 1)[0]
# Checkmk numbers its patches inline — 2.4.0p12 — and both NVD and
# cvelistV5 state their bounds the same way (lessThan "2.4.0p13"). The
# dotted-numeric rule threw the whole version away, so the agent was
+16 -4
View File
@@ -865,6 +865,10 @@ _SINGLE_RELEASE_SLUGS = {
# version, so an upgrade off a dead line must retire the old finding
# (7.0 → 8.0 leaves no 7.0 install behind).
"esxi", "vcenter",
# A thin client boots exactly one IGEL OS and a UMS server runs one suite
# version, so an 11 -> 12 migration must retire the OS 11 finding instead
# of leaving it open next to the OS 12 asset it no longer describes.
"igel-os", "igel-ums",
}
@@ -1063,9 +1067,17 @@ def upsert_eol_vulnerability(
# Microsoft's lifecycle export — so a row whose id read EOL-MS-LIFECYCLE-…
# claimed endoflife.date as its evidence, and operators could not tell
# which source to check the date against.
from_msl = (status.product_slug or "") == "ms-lifecycle"
source_name = "Microsoft product lifecycle" if from_msl else "endoflife.date"
source_key = "ms-lifecycle" if from_msl else "endoflife.date"
slug_ = status.product_slug or ""
if slug_ == "ms-lifecycle":
source_name, source_key = "Microsoft product lifecycle", "ms-lifecycle"
elif slug_.startswith("igel-"):
# IGEL publishes its dates as prose in the Knowledge Base and nowhere
# else — endoflife.date carries no IGEL product at all. A finding that
# named endoflife.date as its evidence would point an operator at a
# page that has never heard of the product.
source_name, source_key = "IGEL product lifecycle", "igel-lifecycle"
else:
source_name, source_key = "endoflife.date", "endoflife.date"
existing = (
db.query(Vulnerability)
@@ -1104,7 +1116,7 @@ def upsert_eol_vulnerability(
if vendor:
existing.package_vendor = vendor[:255]
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="endoflife.date check reports this product as EOL again", source="eol_check")
reopen_if_patched(db, existing, reason=f"{source_name} reports this product as EOL again", source="eol_check")
# Resync bumps detected_at so the Newly EOL/EOS widget ranks the
# freshest finding first.
existing.detected_at = datetime.now()
+139 -7
View File
@@ -15,11 +15,11 @@ The firmware version is the point, and it needs a join: the device record
carries a `firmwareID`, and the version lives in the firmware table. One extra
request for the whole estate — see igel_client.
Deliberately NOT here: an EOL check. endoflife.date carries no IGEL product at
all (verified against its full product list), and IGEL publishes its lifecycle
dates only as prose in the Knowledge Base. Inventing dates for the most
consequential lifecycle question in this estate — IGEL OS 11 is end-of-
maintenance — would be worse than saying nothing. The CVE findings still land.
Lifecycle: endoflife.date carries no IGEL product at all (verified against its
full product list), so the dates are transcribed from IGEL's own Knowledge Base
into _IGEL_LIFECYCLE below. That is the most consequential question this estate
has — IGEL OS 11 stops getting security fixes on 2026-06-30 — and no CVE feed
will ever state it, because "unpatchable from here on" is not a CVE.
"""
from __future__ import annotations
@@ -40,6 +40,90 @@ UMS_OS = "IGEL Universal Management Suite"
IGEL_OS = "IGEL OS"
# ------------------------------------------------------------------
# Product lifecycle
# ------------------------------------------------------------------
# IGEL publishes lifecycle dates as an HTML table in the Knowledge Base and
# nowhere machine-readable, so they are transcribed here. Source, both the
# table and the term definitions:
# https://kb.igel.com/en/igel-product-information/current/igel-product-lifecycle
#
# IGEL's two terms are NOT the two endoflife.date ones, and mapping them the
# obvious way round gets the severity backwards:
#
# EOL - End of Life no further ENHANCEMENTS; security fixes still
# ship. IGEL OS 11 hit this in April 2023, when
# OS 12 launched, and stayed patched for 3 years.
# -> eoasFrom (informational, LOW).
# EOM - End of Maintenance "no updates, no security and bug fixes. The
# product is no longer supported." THIS is the
# date that matters. -> eolFrom (a real finding).
#
# Month-granularity entries are stored as the LAST day of that month: the
# reading that never calls a product dead earlier than it is.
#
# The widely-circulated 2025-12-31 for OS 11 EOM is stale — it was IGEL's
# original date and the vendor page now states 30th June, 2026. Third-party
# migration write-ups still carry the old one; the vendor's page wins.
_IGEL_LIFECYCLE: dict = {
# (slug, release major): (eoas = IGEL "EOL", eol = IGEL "EOM", successor)
("igel-os", 11): ("2023-04-30", "2026-06-30", "IGEL OS 12"),
# OS 12 reaches IGEL-EOL in December 2029; no EOM is published yet, and an
# invented one would be the only unsourced date on this page. None here
# means "still maintained" and raises nothing.
("igel-os", 12): ("2029-12-31", None, None),
("igel-ums", 6): ("2023-04-30", "2023-10-31", "IGEL UMS 12"),
("igel-ums", 12): (None, None, None),
}
# Not modelled, deliberately: IGEL OS for Raspberry Pi 4 (EOM 2024-03-31) is a
# separate line whose version string is indistinguishable from the x86 one —
# only the device model tells them apart, and reading a model string to age a
# device would misjudge every estate that has none.
def igel_lifecycle(os_name: Optional[str], os_version: Optional[str]):
"""(slug, release, EOLStatus | None) for an IGEL asset, or None.
The release comes from the VERSION, never from the product name — UMS has
called the same OS three different things over its life. Returns a status
of None for a release line that is still maintained (or unknown), and the
caller still needs the slug+release in that case: that is what retires the
finding of the line a migrated device has left.
"""
from app.services import cvelistv5_scan_service as c5
from app.services import eol_service
name = (os_name or "").strip()
if c5.IGEL_OS_RE.match(name):
slug = "igel-os"
elif name.lower().startswith(UMS_OS.lower()):
slug = "igel-ums"
else:
return None
release = c5.igel_release(os_version or "")
if release is None:
return None
dates = _IGEL_LIFECYCLE.get((slug, release))
if not dates:
# A release line IGEL has not published dates for. Unknown, not
# supported — say nothing rather than guess.
return (slug, release, None)
eoas, eom, successor = dates
if not eoas and not eom:
return (slug, release, None)
rel = {
"name": str(release),
"label": str(release),
"eoasFrom": eoas,
"eolFrom": eom,
"isMaintained": not eol_service._past(eom),
}
if successor:
rel["latest"] = {"name": successor}
return (slug, release, eol_service._build_eol_status(rel, slug))
def load_igel_config(db: Session) -> Optional[dict]:
"""Decrypt + parse igel_config, or None when not configured."""
from app.auth.setting_crypto import read_setting_value
@@ -211,6 +295,7 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
sync_devices = bool(cfg.get("sync_devices", True))
stats = {"devices": 0, "assets_matched": 0, "assets_created": 0,
"unknown_firmware": 0, "non_igel_os": 0, "cve_findings": 0,
"eol_findings": 0,
"assets_inactivated": 0, "assets_reactivated": 0, "errors": []}
seen_asset_ids: set = set()
@@ -315,6 +400,11 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
except Exception as e:
stats["errors"].append(f"cve scan: {e}")
try:
stats["eol_findings"] = _run_eol_scan(db, seen_asset_ids)
except Exception as e:
stats["errors"].append(f"eol scan: {e}")
try:
recon = reconcile_igel_by_seen_ids(
db, seen_asset_ids=seen_asset_ids,
@@ -327,13 +417,55 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
logger.info(
"IGEL sync done: %d devices, %d matched, %d created, %d unknown firmware, "
"%d non-IGEL-OS, %d CVE findings, %d inactivated, %d reactivated",
"%d non-IGEL-OS, %d CVE findings, %d EOL findings, %d inactivated, "
"%d reactivated",
stats["devices"], stats["assets_matched"], stats["assets_created"],
stats["unknown_firmware"], stats["non_igel_os"], stats["cve_findings"],
stats["assets_inactivated"], stats["assets_reactivated"])
stats["eol_findings"], stats["assets_inactivated"],
stats["assets_reactivated"])
return stats
def _run_eol_scan(db: Session, asset_ids: set) -> int:
"""Lifecycle pass over the assets this sync touched.
Separate from the CVE pass because it answers a different question. A CVE
says one hole is open and names the version that closes it; EOM says no
version will ever close the next one. On an estate sitting on IGEL OS 11
that is the finding every individual CVE is a footnote to.
"""
if not asset_ids:
return 0
from app.services import eol_service
total = 0
for asset in db.query(Asset).filter(Asset.id.in_(asset_ids)).all():
try:
resolved = igel_lifecycle(asset.operating_system, asset.os_version)
if not resolved:
continue
slug, release, status = resolved
if status and (status.is_eol or status.is_eol_soon or status.is_eoas):
eol_service.upsert_eol_vulnerability(
db, asset_id=asset.id,
product_name=(asset.operating_system or IGEL_OS).strip(),
installed_version=(asset.os_version or str(release)),
status=status, vendor="IGEL")
total += 1
else:
# This line is maintained (or IGEL states no dates for it), so
# there is nothing to raise — but the device may have got here
# by migrating off one that was not, and that finding has no
# other way out. A thin client has no software inventory, so
# the EOL sweep's own reconcile never reaches it.
eol_service._supersede_old_eol(
db, asset.id, slug,
eol_service._pseudo_cve_id(slug, str(release)))
except Exception as e:
logger.warning("IGEL EOL check failed for %s: %s", asset.hostname, e)
db.commit()
return total
def _run_cve_scan(db: Session, asset_ids: set) -> int:
"""CVE pass over the assets this sync touched — both IGEL paths.
+1 -1
View File
@@ -683,7 +683,7 @@ function IgelCard() {
const r = await api.post('/api/v1/integrations/igel/test');
const u = r.data?.ums;
setStatus(r.data?.ok && !r.data?.error
? { message: `OK — UMS ${u?.version ?? '?'} (build ${u?.build ?? '?'}), ${r.data.device_count ?? '?'} devices, ${r.data.firmware_count ?? '?'} firmwares`, type: 'success' }
? { message: `OK — UMS ${u?.version || '?'} (build ${u?.build || '?'}), ${r.data.device_count ?? '?'} devices, ${r.data.firmware_count ?? '?'} firmwares`, type: 'success' }
: { message: `Failed at ${r.data?.step}: ${r.data?.error}`, type: 'error' });
} catch (e: any) {
setStatus({ message: e?.response?.data?.detail || 'Test failed', type: 'error' });
+59
View File
@@ -211,8 +211,67 @@ def demo():
assert cpe._os_family("IGEL OS") == "linux"
assert cpe._os_family("IGEL Universal Management Suite") is None
# --- IGEL OS 12's "+build" suffix ------------------------------------
# COSMOS states the base-system build inline: 12.6.0+2. _clean_version's
# dotted-numeric rule threw the whole string away, so the version was
# None, so no path scanned the device at all — a fleet on 12.6.0+2 read as
# zero findings. Semver says build metadata is not part of precedence and
# every IGEL bound is written without it, so it is dropped.
assert cpe._clean_version("12.6.0+2") == "12.6.0"
assert cpe._clean_version("12.9.0+3") == "12.9.0"
assert [c for c, *_ in _scan(index, "12.6.0+2")] == [
"CVE-2026-82017", "CVE-2026-82018"]
# 12.9.0+3 is above both fixes (12.7.6 / 12.8.3). Clean is the right
# answer here, not a miss — the build suffix must not change that either.
assert _scan(index, "12.9.0+3") == []
print("IGEL OS detection OK")
def demo_lifecycle():
"""IGEL's own lifecycle terms, mapped onto the EOL model.
IGEL publishes no machine-readable dates, so _IGEL_LIFECYCLE transcribes
the Knowledge Base table. The mapping is the part worth pinning: IGEL's
"EOL" still ships security fixes and IGEL's "EOM" is where they stop, so
EOM — and only EOM — may raise a real finding.
"""
from app.services.igel_service import igel_lifecycle
slug, rel, st = igel_lifecycle("IGEL OS", "11.08.100.01")
assert (slug, rel) == ("igel-os", 11), (slug, rel)
assert st.is_eol and st.eol_date == "2026-06-30", st # EOM, not EOL
assert st.is_eoas and st.eoas_date == "2023-04-30", st # IGEL's "EOL"
# The finding has to name the way out, and for OS 11 that is not a version.
assert st.latest_version == "IGEL OS 12", st
# OS 12: IGEL states an end-of-life (Dec 2029) and no EOM at all. An
# invented EOM would be the one unsourced date on the page, so a device on
# 12 raises nothing — including one whose version carries a build suffix.
for v in ("12.7.5", "12.6.0+2", "12.9.0+3"):
slug, rel, st = igel_lifecycle("IGEL OS", v)
assert (slug, rel) == ("igel-os", 12), (v, slug, rel)
assert st is None or not (st.is_eol or st.is_eol_soon or st.is_eoas), (v, st)
# The UMS server is its own product on its own dates. UMS 6 went EOM in
# 2023 and the estate's UMS 12 is current.
_, _, st6 = igel_lifecycle("IGEL Universal Management Suite", "6.10.130")
assert st6.is_eol and st6.eol_date == "2023-10-31", st6
assert igel_lifecycle("IGEL Universal Management Suite", "12.12.100")[2] is None
# Neither an unrelated OS nor a versionless asset gets a verdict.
assert igel_lifecycle("Windows 10", "10.0.19045") is None
assert igel_lifecycle("IGEL OS", "") is None
# The finding must not claim endoflife.date as its evidence — it has never
# heard of IGEL, which is the whole reason the table exists.
from app.services import eol_service
assert "igel-os" in eol_service._SINGLE_RELEASE_SLUGS
assert "igel-ums" in eol_service._SINGLE_RELEASE_SLUGS
print("IGEL lifecycle OK")
if __name__ == "__main__":
demo()
demo_lifecycle()
+23 -1
View File
@@ -69,7 +69,10 @@ def _handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"message": "Logout successful"})
if path.endswith("/serverstatus"):
return httpx.Response(200, json=SERVERSTATUS)
if path.endswith("/firmwares/"):
# No trailing slash — UMS 12 answers "/firmwares/" with a 404, and this
# stub does the same. The slash cost the whole firmware table, and with it
# every version string in the estate.
if path.endswith("/firmwares"):
return httpx.Response(200, json=FIRMWARES)
if path.endswith("/thinclients"):
return httpx.Response(200, json=DEVICES)
@@ -95,11 +98,30 @@ def demo():
fws = c.get_firmwares()
assert set(fws) == {"2", "7", "9"}
assert c._client.base_url.path.rstrip("/").endswith("/umsapi/v3")
assert fws["2"]["version"] == "11.08.230.01" and fws["2"]["type"] == "LX"
assert fws["7"]["type"] == "OS12"
# Returned verbatim: the client does not decide what IGEL OS is.
assert fws["9"]["type"] == "W10"
# A UMS that spells the version differently still answers. The keys
# below are IMI v3's documented ones, UMS returns 200 either way, and
# the only symptom of a rename is an asset with no version at all.
c2 = _client()
c2._client = httpx.Client(
base_url=c2.base_url,
transport=httpx.MockTransport(lambda r: httpx.Response(
200, json={"umsVersion": "12.12.100", "build": "45123"})))
alt = c2.get_server_status()
assert (alt["version"], alt["build"]) == ("12.12.100", "45123"), alt
# An empty string is missing, not a version of "".
c3 = _client()
c3._client = httpx.Client(
base_url=c3.base_url,
transport=httpx.MockTransport(lambda r: httpx.Response(
200, json={"rmGuiServerVersion": "", "buildNumber": ""})))
assert c3.get_server_status()["version"] is None
devices = c.get_devices()
# The recycle-bin entry is gone; the three live ones remain.
assert [d["unit_id"] for d in devices] == [