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>
523 lines
22 KiB
Python
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
|