Files
vulncheck/app/services/igel_service.py
T
vulncheckandClaude Opus 5 271043a206 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>
2026-09-02 11:10:03 +02:00

523 lines
22 KiB
Python

"""IGEL UMS inventory sync.
Registers the UMS server AND every endpoint device it manages as assets
(source=IGEL), then runs CVE detection on each — the same find-or-create +
lifecycle-reconcile pattern as the Intune, Nessus and vCenter syncs.
Why this exists: a thin client is the one endpoint class nothing else in this
dashboard can see. It runs no Wazuh agent (IGEL OS is a locked-down appliance,
you do not install one), Intune does not enrol it, and a Nessus scan gets an
open port and no version. So an estate of several thousand IGEL devices showed
up as zero assets and zero findings — not "clean", just unlooked-at. UMS
already holds the exact inventory needed: the firmware version per device.
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.
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
import json
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
logger = logging.getLogger(__name__)
SETTING_KEY = "igel_config"
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
raw = read_setting_value(db, SETTING_KEY)
if not raw:
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("igel_config is not valid JSON")
return None
if not all([cfg.get("host"), cfg.get("username"), cfg.get("password")]):
return None
return cfg
# Firmware types UMS uses for the Windows-based endpoints it also managed in
# its day (Windows Embedded Standard / 7 / 10). Those are not IGEL OS and must
# not be labelled as it — their patch state is a Windows question.
_WINDOWS_FW_TYPES = {"WES", "W7", "W10", "WIN10", "WINDOWS"}
def os_from_firmware(fw: Optional[dict]) -> Optional[tuple]:
"""Firmware row (from IgelClient.get_firmwares) → (os_name, os_version).
The firmware version IS the OS version — IGEL states its fixes against it
("upgrade the Base System app to 12.7.6", "upgrade to IGEL OS 11.11.150"),
and the CVE records bound the same numbers.
The OS string is deliberately the bare product line ("IGEL OS"), not the
firmware's marketing name: UMS has called the same OS "IGEL Universal
Desktop LX", "IGEL OS 11" and "IGEL OS" across its releases, and the CVE
matcher has to recognise one string. The release (11 vs 12) is decided from
the VERSION, which is the only place it is stated unambiguously.
None means "say nothing", and it has two causes that must both stay silent:
a firmware UMS did not list at all, and a Windows-based endpoint. Reporting
a Windows build as an IGEL OS version would compare it against IGEL's
11.x/12.x ranges.
"""
if not fw or not fw.get("version"):
return None
if (fw.get("type") or "").upper() in _WINDOWS_FW_TYPES:
return None
return IGEL_OS, fw["version"]
def _build_client(cfg: dict):
from app.integrations.igel_client import IgelClient
return IgelClient(
host=cfg["host"],
username=cfg["username"],
password=cfg["password"],
port=int(cfg.get("port") or 8443),
verify_ssl=cfg.get("verify_ssl", True),
)
def _resolve_ip(host: str) -> Optional[str]:
"""The UMS server's own address.
`serverstatus` reports the server as "name:port", never an address, so it
comes from resolving the host we are configured to talk to. That IS the
address in use, and an asset without one cannot be correlated with a Nessus
scan or a firewall log. An unresolvable name is not fatal.
"""
import ipaddress
import socket
h = (host or "").strip()
if not h:
return None
try:
ipaddress.ip_address(h)
return h # already an address
except ValueError:
pass
try:
return socket.gethostbyname(h)
except OSError:
logger.info("IGEL sync: could not resolve %s to an address", h)
return None
# Cross-process guard, same reasoning as the vCenter sync: the nightly job and
# a manual trigger run in different contexts and would update the same asset
# rows in different orders. A Postgres advisory lock is global to the DB.
_SYNC_ADVISORY_LOCK_KEY = 0x54560103 # "TV" + 03
def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
auto_create: bool, ip: Optional[str] = None,
match_on_ip: bool = False):
"""Match a UMS object to an asset by unit ID first, hostname second.
Unit ID first because a thin client is renamed constantly — it is named
after its desk, its user or its room, and all three change — while the unit
ID is derived from the MAC and survives a factory reset and a re-
registration. A rename must not fork the asset: the finding history hangs
off it.
"""
unit_id = (unit_id or "").strip() or None
hostname = (hostname or "").strip()
short = hostname.split(".")[0] if hostname else ""
def _pin(a):
if unit_id and a.igel_unit_id != unit_id:
a.igel_unit_id = unit_id
if unit_id:
a = db.query(Asset).filter(Asset.igel_unit_id == unit_id).first()
if a:
_pin(a)
return a, "unit"
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
if a:
_pin(a)
return a, "hostname"
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
# Matching on the address is opt-in, and OFF for endpoint devices. The
# vCenter sync does match on it, because an ESXi host holds a static
# management address for years. A thin client does not: it takes a DHCP
# lease, and a recycled lease would silently bind one device to another
# device's asset — and to its finding history. `unit_id` (MAC-derived) and
# the network name already cover the case this was for. The UMS SERVER is
# the one fixed address here, and it passes match_on_ip=True.
if ip and match_on_ip:
a = db.query(Asset).filter(Asset.ip_address == ip).first()
if a:
_pin(a)
return a, "ip"
if auto_create and hostname:
a = Asset(hostname=short or hostname, ip_address=ip,
igel_unit_id=unit_id, source=AssetSource.IGEL,
status=AssetStatus.ACTIVE)
db.add(a)
db.flush()
logger.info("IGEL sync: auto-created asset %s", a.hostname)
return a, "created"
return None, "skipped"
def run_igel_sync(db: Session) -> dict:
"""Sync the UMS server + its endpoint devices → assets and CVEs."""
cfg = load_igel_config(db)
if not cfg:
raise RuntimeError("IGEL UMS is not configured (settings.igel_config missing/incomplete).")
# The lock must NOT ride on `db`: this sync commits, and a committed
# Session gives its connection back to the pool — taking a session-scoped
# lock with it. See database.advisory_lock.
from app.database import advisory_lock
with advisory_lock(_SYNC_ADVISORY_LOCK_KEY) as got:
if not got:
logger.warning("IGEL sync skipped — another IGEL sync holds the lock")
return {"skipped": "another sync already running"}
return _run_igel_sync_locked(db, cfg)
def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids
auto_create = bool(cfg.get("auto_create_assets", True))
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()
client = _build_client(cfg)
try:
status = client.get_server_status()
firmwares = client.get_firmwares() if sync_devices else {}
devices = client.get_devices() if sync_devices else []
except Exception as e:
raise RuntimeError(f"IGEL UMS inventory fetch failed: {e}") from e
finally:
# Closed here and not after the asset loop: everything below is DB
# work, and holding an IMI session open through it would let the
# 30-minute cookie expire on a large estate for no benefit.
client.close()
# --- the UMS server itself ---
try:
own_ip = _resolve_ip(cfg["host"])
asset, how = _find_or_create_asset(
db, unit_id=status.get("server_uuid"), hostname=cfg["host"],
auto_create=auto_create, ip=own_ip, match_on_ip=True)
if asset:
stats["assets_created" if how == "created" else "assets_matched"] += 1
if own_ip:
asset.ip_address = own_ip[:45]
asset.operating_system = UMS_OS
asset.os_version = (status.get("version") or "")[:100] or None
build = status.get("build")
asset.description = (f"{UMS_OS} {status.get('version') or ''}"
+ (f" (build {build})" if build else "")).strip()
asset.last_scan = datetime.now()
asset.last_seen = datetime.now()
asset.last_seen_source = "igel"
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
except Exception as e:
stats["errors"].append(f"ums server: {e}")
# --- endpoint devices ---
for d in devices:
stats["devices"] += 1
try:
fw = firmwares.get(d["firmware_id"])
if not fw:
# UMS answered with a firmwareID its own firmware table does
# not list. Said out loud rather than silently skipped: without
# a version there is no CVE verdict, and "no findings" must not
# read the same as "could not look".
stats["unknown_firmware"] += 1
resolved = os_from_firmware(fw)
if fw and not resolved:
stats["non_igel_os"] += 1
# The device's own name is what UMS shows; networkName is the DNS
# name, and it is the one another source would also record. Prefer
# it so a Nessus or DHCP-sourced asset matches instead of forking.
hostname = d.get("network_name") or d.get("name") or ""
asset, how = _find_or_create_asset(
db, unit_id=d.get("unit_id"), hostname=hostname,
auto_create=auto_create, ip=d.get("ip_address"))
if not asset:
continue
if how == "created":
stats["assets_created"] += 1
else:
stats["assets_matched"] += 1
# Renames are the norm on a thin-client estate; matched by unit
# ID means the name UMS reports now is the current one.
if how == "unit" and hostname and asset.hostname != hostname:
asset.hostname = hostname.split(".")[0] or hostname
if d.get("ip_address"):
asset.ip_address = d["ip_address"][:45]
if resolved:
asset.operating_system, version = resolved
asset.os_version = version[:100]
elif d.get("os_type"):
# Not IGEL OS (a Windows-based endpoint UMS still manages), or
# an unknown firmware. Record what UMS reports so the asset is
# not blank, but do NOT claim an IGEL OS version — see
# os_from_firmware.
asset.operating_system = d["os_type"][:255]
model = " ".join(x for x in (d.get("device_type"), d.get("product_id")) if x)
fw_name = (fw or {}).get("product") or ""
asset.description = " — ".join(x for x in (fw_name, model) if x) or None
asset.last_scan = datetime.now()
asset.last_seen = datetime.now()
asset.last_seen_source = "igel"
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
except Exception as e:
stats["errors"].append(f"device {d.get('name')}: {e}")
db.commit()
# CVE pass over the assets this sync touched. Runs after the commit so a
# scan failure cannot lose the inventory we just collected.
try:
stats["cve_findings"] = _run_cve_scan(db, seen_asset_ids)
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,
reason="not reported by the latest IGEL UMS sync")
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
db.commit()
except Exception as e:
logger.warning("IGEL reconcile failed: %s", e)
logger.info(
"IGEL sync done: %d devices, %d matched, %d created, %d unknown firmware, "
"%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["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.
The NVD-CPE scan runs FIRST and the cvelistV5 one second, sharing a
`touched` set. That order is required, not incidental: the cvelistV5 pass
closes the findings it cannot re-confirm, and CVE-2025-47827 exists only on
the CPE side (MITRE filed it with vendor "n/a", so there is no structured
record for cvelistV5 to index). Run the other way round and every nightly
sync would close it and the next one would reopen it.
"""
if not asset_ids:
return 0
from app.services import app_cve_scanner_service as cpe
from app.services import cvelistv5_scan_service as c5
index = c5.load_index(db) or {}
if not index:
logger.info("IGEL sync: no cvelistV5 index yet — CVE pass deferred "
"to the nightly app-CVE scan")
new_ids: list = []
total = 0
for asset in db.query(Asset).filter(Asset.id.in_(asset_ids)).all():
touched: set = set()
try:
total += cpe.scan_asset_os(db, asset, new_ids, touched=touched)
except Exception as e:
logger.warning("IGEL CPE scan failed for %s: %s", asset.hostname, e)
if not index:
continue
try:
total += c5.scan_asset_igel(db, asset, index, new_ids, touched=touched)
except Exception as e:
logger.warning("IGEL CVE scan failed for %s: %s", asset.hostname, e)
db.commit()
if new_ids:
# Same tail as the app-CVE scan: audit, enrich, notify. A finding that
# never reaches EPSS/KEV enrichment or the new-CVE mail is half a
# finding.
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source="app-scan")
db.commit()
except Exception as e:
logger.debug("IGEL detected-audit failed: %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
from app.services.email_service import dispatch_new_vuln_notifications
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
if fresh:
enrich_vulnerabilities(db, fresh)
dispatch_new_vuln_notifications(db, fresh)
except Exception as e:
logger.debug("IGEL enrichment/notify failed: %s", e)
return total