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>
1387 lines
61 KiB
Python
1387 lines
61 KiB
Python
"""
|
|
endoflife.date integration.
|
|
|
|
Source-of-truth for product end-of-life / end-of-active-support /
|
|
end-of-security-support dates. Closes the EOL detection gap that
|
|
Wazuh syscollector has (Nessus plugin 64784 shows the format we want
|
|
to emulate: pseudo-vuln per EOL finding, severity=high, points the
|
|
operator at the upgrade path).
|
|
|
|
API surface used:
|
|
GET https://endoflife.date/api/v1/products/{slug}
|
|
→ {"result": {"name": ..., "releases": [{
|
|
"name": "13.0", "label": "2016",
|
|
"isMaintained": false,
|
|
"eoasFrom": "YYYY-MM-DD", # active-support ended
|
|
"eolFrom": "YYYY-MM-DD", # security-support ended (EOL)
|
|
"eoesFrom": "YYYY-MM-DD", # extended-security ended
|
|
"latest": {"name": "13.0.6300.2", "date": "..."},
|
|
}, ...]}}
|
|
|
|
Cache: catalog per product cached in `settings` table for 24h to keep
|
|
the EOL feed responsive (changes once per quarter at most).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.setting import Setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_API_BASE = "https://endoflife.date/api/v1/products"
|
|
_CACHE_KEY_PREFIX = "eol_cache_"
|
|
_CACHE_TTL = timedelta(hours=24)
|
|
|
|
# Per-process memo so a single sweep over 1000+ syscollector packages
|
|
# doesn't re-query the settings table (and risk a races-into-duplicate-
|
|
# key insert) for the same product slug.
|
|
_PROCESS_MEMO: dict[str, dict] = {}
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Product slug mapping
|
|
# ----------------------------------------------------------------------
|
|
#
|
|
# Wazuh + Nessus report human product names ("Microsoft SQL Server",
|
|
# "Mozilla Firefox", "Google Chrome"). endoflife.date uses
|
|
# kebab/lowercase slugs. Hand-curated mapping — extend as new products
|
|
# show up. Keys are normalised (lowercase, alphanumeric only).
|
|
#
|
|
# When a name doesn't match exactly, the resolver tries best-effort
|
|
# substring matches against this map's keys before giving up.
|
|
_PRODUCT_SLUGS: dict[str, str] = {
|
|
# Microsoft
|
|
"mssqlserver": "mssqlserver",
|
|
"microsoftsqlserver": "mssqlserver",
|
|
"sqlserver": "mssqlserver",
|
|
"windowsserver": "windows-server",
|
|
"windowsserver2016": "windows-server",
|
|
"windowsserver2019": "windows-server",
|
|
"windowsserver2022": "windows-server",
|
|
"windows10": "windows",
|
|
"windows11": "windows",
|
|
"microsoftexchange": "msexchange",
|
|
"exchangeserver": "msexchange",
|
|
"microsoftoffice": "office",
|
|
"msoffice": "office",
|
|
# SharePoint — endoflife.date tracks it as `sharepoint` (2013 EOL
|
|
# 2023-04-11, 2016/2019 EOL 2026-07-14, Subscription Edition still
|
|
# supported). Neither the MS-lifecycle export nor the plain slug lookup
|
|
# caught it, so Foundation/Server installs never got an EOL finding.
|
|
# Covers the Server, Enterprise Server and Foundation flavours generically.
|
|
"sharepoint": "sharepoint",
|
|
"microsoftsharepoint": "sharepoint",
|
|
"microsoftsharepointserver": "sharepoint",
|
|
"microsoftsharepointfoundation": "sharepoint",
|
|
"microsoftsharepointenterpriseserver": "sharepoint",
|
|
"microsoftsharepointdesigner": "sharepoint",
|
|
"microsoftofficeproofing": "office",
|
|
"microsoftofficeosxmui": "office",
|
|
"microsoftofficeosxmuigerman": "office",
|
|
"microsoftofficeformac": "office",
|
|
"office": "office",
|
|
# NOT mapped, on purpose — verified against the live catalogue (462
|
|
# products) rather than assumed: endoflife.date carries no record for
|
|
# Adobe Acrobat, the Visual C++ redistributables or IIS, so every lookup
|
|
# returned 404 and filled the scan log with "not in catalog" for products
|
|
# that were never going to be there. IIS ships with Windows Server and is
|
|
# covered by that entry; the other two have no lifecycle feed at all and
|
|
# depend on the Microsoft export or a vendor page.
|
|
# Microsoft Edge is deliberately NOT mapped. endoflife.date carries no
|
|
# record for it at all — the "microsoft-edge" slug 404s, so every EOL check
|
|
# spent a request finding that out. Edge follows the Modern Lifecycle
|
|
# Policy: it has no end-of-life date as long as it stays current, so "is
|
|
# this version too old" is a patch question, which the CVE scan already
|
|
# answers. Nothing is lost by leaving it out.
|
|
"powershell": "powershell",
|
|
# .NET / .NET Framework are NOT keyed here: ".NET" normalises to "net"
|
|
# (the dot is stripped), so no "dotnet…" key can ever match an inventory
|
|
# name. They are resolved by dotnet_slug() below.
|
|
# Browsers / Mozilla
|
|
"mozillafirefox": "firefox",
|
|
"firefox": "firefox",
|
|
"firefoxesr": "firefox",
|
|
"googlechrome": "chrome",
|
|
"chrome": "chrome",
|
|
# Runtimes
|
|
"java": "oracle-jdk",
|
|
"jdk": "oracle-jdk",
|
|
"openjdk": "oracle-jdk",
|
|
"nodejs": "nodejs",
|
|
"python": "python",
|
|
"go": "go",
|
|
"ruby": "ruby",
|
|
"php": "php",
|
|
# Web / databases
|
|
# Tomcat MUST come before the "apache" key: the substring scan below walks
|
|
# this dict in insertion order and takes the first anchored hit, so
|
|
# "Apache Tomcat 9.0 …" would otherwise start with "apache" and be checked
|
|
# against the HTTP Server lifecycle — a different product with a different
|
|
# version line (2.4.x), which no Tomcat build can ever match.
|
|
# endoflife.date keys the releases by line ("9.0", "10.1", "11.0"), the
|
|
# prefix _pick_release already matches against the reported 9.0.107.
|
|
"tomcat": "tomcat",
|
|
"apachetomcat": "tomcat",
|
|
"apache": "apache-http-server",
|
|
"apachehttpserver": "apache-http-server",
|
|
"nginx": "nginx",
|
|
"mysql": "mysql",
|
|
"mariadb": "mariadb",
|
|
"postgresql": "postgresql",
|
|
"postgres": "postgresql",
|
|
"mongodb": "mongodb",
|
|
"redis": "redis",
|
|
# Microsoft Visual C++ Redistributable (all flavours — 2005/2008/2010/2012/2013/2015-2022).
|
|
# endoflife.date exposes the product as `visual-cpp`; map any sane
|
|
# spelling here. Versions are matched by endoflife.date.
|
|
# Adobe
|
|
# Nessus plugin 56213 reports "Adobe Reader" (no "Acrobat"), so the
|
|
# acrobat-prefixed keys above never substring-matched → fell back to
|
|
# EOL-NESSUS-56213. These aliases fix the slug resolution.
|
|
# Linux distros
|
|
"ubuntu": "ubuntu",
|
|
"debian": "debian",
|
|
"centos": "centos",
|
|
"rhel": "rhel",
|
|
"redhatenterpriselinux": "rhel",
|
|
"amazonlinux": "amazon-linux",
|
|
"fedora": "fedora",
|
|
}
|
|
|
|
|
|
def _normalise_name(name: str) -> str:
|
|
"""Lowercase + strip non-alphanumeric for slug lookup."""
|
|
return re.sub(r"[^a-z0-9]", "", (name or "").lower())
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# .NET / .NET Framework
|
|
# ----------------------------------------------------------------------
|
|
#
|
|
# The "dotnet" / "dotnetframework" keys in the map above could never match
|
|
# anything: _normalise_name strips the dot, so every real inventory entry
|
|
# ("Microsoft .NET Runtime - 8.0.29 (x64)") normalises to "microsoftnet…",
|
|
# never to "dotnet…". Result: no .NET and no .NET Framework install ever got
|
|
# an EOL check at all — neither the genuinely dead ones (.NET 6/7, Framework
|
|
# 4.5/4.6.1) nor the supported ones.
|
|
#
|
|
# A prefix key can't repair it either — "microsoftnet" would also swallow
|
|
# "Microsoft Network Monitor" — so the two products are matched by regex.
|
|
# The patterns stay tight on purpose: "Microsoft Visual Studio .NET 2003" is a
|
|
# different product with its own listing in the Microsoft export, and must keep
|
|
# falling through to that fallback.
|
|
_DOTNETFX_RE = re.compile(r"\.net\s+framework", re.I)
|
|
_DOTNET_RE = re.compile(
|
|
r"\.net\s+(?:core\s+)?(?:\d+\.\d|host|runtime|sdk|desktop\s+runtime)"
|
|
r"|windows\s+desktop\s+runtime",
|
|
re.I,
|
|
)
|
|
# Developer-side packs carry the runtime's name and an OLD version number
|
|
# ("Microsoft .NET Framework 4.5.2 Multi-Targeting Pack" on a box whose actual
|
|
# runtime is 4.8). They are build inputs, not an installed runtime — flagging
|
|
# them would be the same false positive the CVE scanner already excludes.
|
|
_DOTNET_DEVPACK_RE = re.compile(
|
|
r"targeting\s+pack|developer\s+pack|reference\s+assemblies|client\s+profile", re.I
|
|
)
|
|
# Modern .NET reports an MSI build in the version FIELD ("Microsoft .NET Host -
|
|
# 9.0.18 (x64)" → 72.72.55158), so the release can only come from the display
|
|
# NAME. Same trick the CVE scanner uses (app_cve_scanner_service `name_ver`).
|
|
_DOTNET_NAME_VER_RE = re.compile(r"\b(\d+\.\d+(?:\.\d+)*)")
|
|
|
|
|
|
def dotnet_slug(product_name: Optional[str]) -> Optional[str]:
|
|
""""dotnetfx" for .NET Framework, "dotnet" for modern .NET, else None.
|
|
|
|
Also the guard the MS-lifecycle resolver consults: for these two products
|
|
endoflife.date is the ONLY permitted source (see ms_lifecycle_service).
|
|
"""
|
|
name = product_name or ""
|
|
if _DOTNET_DEVPACK_RE.search(name):
|
|
return None
|
|
if _DOTNETFX_RE.search(name):
|
|
return "dotnetfx"
|
|
if _DOTNET_RE.search(name):
|
|
return "dotnet"
|
|
return None
|
|
|
|
|
|
# Third-party tools that merely *mention* a tracked product in their
|
|
# name ("Veeam Explorer for PostgreSQL", "PostgreSQL ODBC Driver",
|
|
# "MySQL Connector/NET"). These wrap/connect-to the product but are NOT
|
|
# the product itself — their version number is the tool's, not the
|
|
# product's, so an EOL match against the mentioned product is a false
|
|
# positive. If any of these tokens appears, skip the EOL check.
|
|
_WRAPPER_TOKENS = (
|
|
"veeam", "explorerfor", "backup", "connector", "odbc", "jdbc",
|
|
"driver", "clientfor", "agentfor", "pluginfor", "extensionfor",
|
|
"providerfor", "managementpack", "monitoringfor",
|
|
# Sub-components of a tracked product that have their own (different)
|
|
# lifecycle — matching the parent would give a false EOL signal.
|
|
"nativeclient", "setupsupportfiles", "setupsql", "setup",
|
|
"premium", "clicktorun", "subscription",
|
|
)
|
|
|
|
# Add-ons, filter/signature packages and prerequisite bundles that carry a
|
|
# product's name but are not the product. A host running Exchange Server
|
|
# Subscription Edition — fully supported — still lists "Microsoft Exchange
|
|
# Server 2007 Standard Anti-Spam Filter Updates" and "Microsoft Exchange 2007
|
|
# Enterprise Rules Updates" in its inventory, and every one of those matched
|
|
# the msexchange slug with the year 2007 pulled out of the name: a CRITICAL
|
|
# EOL finding for a product that is not installed.
|
|
#
|
|
# Worse than one wrong row: msexchange is a single-release slug, so the 2007,
|
|
# 2010 and 2016 add-ons superseded each other in turn, one status flip per
|
|
# package per run — the open→patched→open loop seen in the change history,
|
|
# with no change on the host at all.
|
|
#
|
|
# Same shape on the MS-lifecycle side: "Microsoft Lync Server 2013,
|
|
# Bootstrapper Prerequisites Installer Package" ships with an Exchange install
|
|
# and was read as a Lync 2013 server.
|
|
#
|
|
# The real product entry ("Microsoft Exchange Server 2016 Cumulative Update
|
|
# 23", "Microsoft Exchange Server") sits in the same inventory, so dropping the
|
|
# add-ons loses no true finding — it only stops them from voting.
|
|
_COMPONENT_TOKENS = (
|
|
"languagepack", "antispam", "filterupdates", "rulesupdates", "signatures",
|
|
"bootstrapper", "prerequisites", "speech", "managedapi", "wizard",
|
|
"updatefor",
|
|
)
|
|
|
|
|
|
# Office, SharePoint and Exchange take their release from the YEAR IN THE NAME
|
|
# (see _YEAR_KEYED_SLUGS), so any companion package that merely carries a year
|
|
# reads as that release being installed — "Microsoft Office 2007 Primary
|
|
# Interop Assemblies", "Microsoft Exchange Server 2010 MAPI Client and CDO",
|
|
# "Microsoft SharePoint 2013 Client Components SDK". Each is a CRITICAL finding
|
|
# for a product the host does not run, and since these are single-release slugs
|
|
# each one supersedes the others once per sweep — the open→patched→open loop.
|
|
#
|
|
# Naming the add-ons one by one is a race nobody wins; there is always another
|
|
# rollup. What separates the product from its companions is what FOLLOWS the
|
|
# year: the product name ends there, or continues with an edition/update
|
|
# qualifier ("2016 Cumulative Update 23", "Professional Plus 2016 - de-de").
|
|
# Any other word after the year belongs to a different product that merely
|
|
# names this one.
|
|
_YEAR_KEYED_PREFIXES = ("microsoftoffice", "msoffice", "office",
|
|
"microsoftsharepoint", "sharepoint",
|
|
"microsoftexchange", "exchangeserver", "exchange")
|
|
|
|
_RELEASE_QUALIFIERS = {
|
|
"standard", "enterprise", "datacenter", "professional", "pro", "plus",
|
|
"premium", "edition", "editions", "x64", "x86", "32-bit", "64-bit",
|
|
"rtm", "mui", "service", "pack", "sp1", "sp2", "sp3",
|
|
"cu", "cumulative", "update",
|
|
}
|
|
# "de-de", "en-us" — the locale suffix MSI installs carry.
|
|
_LOCALE_RE = re.compile(r"^[a-z]{2}([-_][a-z]{2})?$", re.IGNORECASE)
|
|
# Words, keeping hyphenated ones ("de-de", "64-bit") whole and dropping
|
|
# stray punctuation.
|
|
_WORD_RE = re.compile(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*")
|
|
|
|
|
|
def _year_keyed_addon(product_name: Optional[str]) -> bool:
|
|
"""True when the name continues past its year with something that is not
|
|
an edition/update qualifier — i.e. it is a companion, not the release."""
|
|
m = re.search(r"\b(20\d{2})\b", product_name or "")
|
|
if not m:
|
|
return False
|
|
for word in _WORD_RE.findall(product_name[m.end():]):
|
|
w = word.lower()
|
|
if w in _RELEASE_QUALIFIERS or w.isdigit() or _LOCALE_RE.match(w):
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_component_package(name: Optional[str]) -> bool:
|
|
"""True when the inventory entry is an add-on/wrapper, not the product.
|
|
|
|
Consulted by BOTH resolvers (endoflife.date slug + MS lifecycle export),
|
|
so a component is refused whichever source would have matched it.
|
|
"""
|
|
key = _normalise_name(name or "")
|
|
if not key:
|
|
return False
|
|
if any(tok in key for tok in _WRAPPER_TOKENS + _COMPONENT_TOKENS):
|
|
return True
|
|
return (any(key.startswith(p) for p in _YEAR_KEYED_PREFIXES)
|
|
and _year_keyed_addon(name))
|
|
|
|
|
|
def resolve_product_slug(product_name: Optional[str]) -> Optional[str]:
|
|
"""Map a Wazuh / Nessus product string to an endoflife.date slug.
|
|
|
|
Returns None when no match — caller should skip the EOL check
|
|
rather than guess (a wrong slug returns 404 from the API).
|
|
"""
|
|
if not product_name:
|
|
return None
|
|
key = _normalise_name(product_name)
|
|
if not key:
|
|
return None
|
|
# .NET / .NET Framework — matched on the raw name, the normalised form
|
|
# loses the dot that identifies them.
|
|
dn = dotnet_slug(product_name)
|
|
if dn:
|
|
return dn
|
|
# Exact name always wins (curated full names).
|
|
if key in _PRODUCT_SLUGS:
|
|
return _PRODUCT_SLUGS[key]
|
|
# Guard: third-party wrapper/connector tools name-drop a product
|
|
# ("Veeam Explorer for PostgreSQL") — their version is the tool's,
|
|
# not the product's. Skip rather than emit a false EOL finding.
|
|
if is_component_package(product_name):
|
|
return None
|
|
# Anchored substring scan — the product name must *start with* a
|
|
# known key (or vice-versa) so "Microsoft SQL Server 2016 Express"
|
|
# still matches "microsoftsqlserver", but "...for PostgreSQL" (key
|
|
# mentioned mid/suffix) does NOT match "postgresql".
|
|
for k, slug in _PRODUCT_SLUGS.items():
|
|
if key.startswith(k) or k.startswith(key):
|
|
return slug
|
|
return None
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# API client (with settings-table cache)
|
|
# ----------------------------------------------------------------------
|
|
|
|
def _cache_get(db: Session, slug: str) -> Optional[dict]:
|
|
row = db.query(Setting).filter(Setting.key == _CACHE_KEY_PREFIX + slug).first()
|
|
if not row or not row.value:
|
|
return None
|
|
try:
|
|
payload = json.loads(row.value)
|
|
cached_at = datetime.fromisoformat(payload.get("cached_at", ""))
|
|
if datetime.now() - cached_at > _CACHE_TTL:
|
|
return None
|
|
return payload.get("data")
|
|
except (json.JSONDecodeError, ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def _cache_put(db: Session, slug: str, data: dict) -> None:
|
|
"""Upsert the cache row via PostgreSQL ON CONFLICT so concurrent
|
|
callers (or repeated calls within a non-flushed session) don't
|
|
duplicate-key against ix_settings_key."""
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
|
|
payload = json.dumps({
|
|
"cached_at": datetime.now().isoformat(),
|
|
"data": data,
|
|
})
|
|
key = _CACHE_KEY_PREFIX + slug
|
|
now = datetime.now()
|
|
try:
|
|
stmt = pg_insert(Setting).values(
|
|
key=key,
|
|
value=payload,
|
|
description=f"endoflife.date cache for {slug}",
|
|
created_at=now,
|
|
updated_at=now,
|
|
).on_conflict_do_update(
|
|
index_elements=["key"],
|
|
set_={"value": payload, "updated_at": now},
|
|
)
|
|
db.execute(stmt)
|
|
except Exception as e:
|
|
logger.warning("eol cache upsert failed for %s: %s", slug, e)
|
|
# Roll back this savepoint-less attempt so the outer
|
|
# transaction stays usable for the rest of the loop.
|
|
try:
|
|
db.rollback()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def fetch_product(db: Session, slug: str) -> Optional[dict]:
|
|
"""Cached fetch of one product's releases. None on 404 / network error.
|
|
|
|
Three cache layers:
|
|
1. _PROCESS_MEMO (in-RAM) — survives the request, never duplicates.
|
|
2. settings table cache (24h TTL) — survives container restarts.
|
|
3. live HTTP fetch.
|
|
"""
|
|
if slug in _PROCESS_MEMO:
|
|
return _PROCESS_MEMO[slug]
|
|
cached = _cache_get(db, slug)
|
|
if cached is not None:
|
|
_PROCESS_MEMO[slug] = cached
|
|
return cached
|
|
url = f"{_API_BASE}/{slug}"
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
r = client.get(url, headers={"Accept": "application/json"})
|
|
if r.status_code == 404:
|
|
logger.info("endoflife.date: product '%s' not in catalog (404)", slug)
|
|
_PROCESS_MEMO[slug] = {}
|
|
_cache_put(db, slug, {}) # negative cache to skip repeated 404s
|
|
return {}
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
_PROCESS_MEMO[slug] = data
|
|
_cache_put(db, slug, data)
|
|
return data
|
|
except Exception as e:
|
|
logger.warning("endoflife.date fetch failed for %s: %s", slug, e)
|
|
return None
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# EOL evaluation
|
|
# ----------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class EOLStatus:
|
|
"""Result of one installed-version EOL check."""
|
|
is_eol: bool # security-support ended (real risk)
|
|
is_eoas: bool # active-support ended (still security-patched)
|
|
is_maintained: bool # endoflife flag — currently maintained
|
|
is_eol_soon: bool = False # security support ends within EOL_SOON_DAYS
|
|
days_to_eol: Optional[int] = None # signed days until eolFrom (negative = past)
|
|
release_label: Optional[str] = None # "2016" / "150" / "8.0" — human stream name
|
|
release_name: Optional[str] = None # "13.0" / "150" — internal id
|
|
eol_date: Optional[str] = None # ISO YYYY-MM-DD
|
|
eoas_date: Optional[str] = None
|
|
eoes_date: Optional[str] = None
|
|
latest_version: Optional[str] = None
|
|
latest_date: Optional[str] = None
|
|
product_slug: Optional[str] = None
|
|
|
|
|
|
# Window for the "EOL SOON" warning — security support ends within
|
|
# this many days from today. Observed: Windows Server 2016 still gets
|
|
# monthly CUs until 2027-01, shouldn't be flagged as already-EOL.
|
|
EOL_SOON_DAYS = 90
|
|
|
|
|
|
def _version_starts_with(release_name: str, installed: str) -> bool:
|
|
"""True if `installed` starts with `release_name` followed by '.' / end."""
|
|
if not release_name or not installed:
|
|
return False
|
|
if installed == release_name:
|
|
return True
|
|
return installed.startswith(release_name + ".") or installed.startswith(release_name + "-")
|
|
|
|
|
|
def _pick_release(releases: List[dict], installed: str) -> Optional[dict]:
|
|
"""Find the release entry whose `name` is the longest prefix of `installed`.
|
|
|
|
"13.0.4259.0 Express Edition" → matches release.name "13.0" over "13".
|
|
"""
|
|
candidates = [
|
|
r for r in releases
|
|
if isinstance(r, dict) and _version_starts_with(str(r.get("name") or ""), installed)
|
|
]
|
|
if not candidates:
|
|
return None
|
|
candidates.sort(key=lambda r: len(str(r.get("name") or "")), reverse=True)
|
|
return candidates[0]
|
|
|
|
|
|
# Products whose endoflife.date releases are keyed by *year* (release
|
|
# name/label "2016", "2019", ...) while the installed version reported
|
|
# by Wazuh syscollector is a build number ("16.0.4266.1001"). For these
|
|
# the year lives in the PRODUCT NAME ("Microsoft Office ... 2016"), so we
|
|
# match on the year token, not the numeric version prefix. Mirrors the
|
|
# OS path (resolve_os_to_eol), which also keys on name not version.
|
|
# Release is a YEAR ("2016") while the installed version is a build number
|
|
# ("16.0.5556.1005") — prefix-matching the version against the cycle can never
|
|
# hit, so the year comes from the product name instead. SharePoint is the same
|
|
# shape as Office, and worse: 2016, 2019 AND Subscription Edition all report
|
|
# 16.0.x, so the version alone can't even tell the releases apart.
|
|
# Exchange is the same shape: releases are named 2016 / 2019 / subscription
|
|
# while syscollector reports 15.1.2507.6, so prefix-matching never hits and
|
|
# Exchange Server 2016 came out looking supported months after its October 2025
|
|
# end of support.
|
|
_YEAR_KEYED_SLUGS = {"office", "sharepoint", "msexchange"}
|
|
|
|
|
|
def _major_minor(version: str) -> Optional[str]:
|
|
m = re.match(r"\s*(\d+\.\d+)", version or "")
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def _pick_release_by_latest(releases: List[dict], installed: str) -> Optional[dict]:
|
|
"""Match a build number against the major.minor of each release's LATEST.
|
|
|
|
The year-keyed products above take the year from the product NAME, which
|
|
works for "Microsoft Exchange Server 2016 Cumulative Update 23" and fails
|
|
for the plain "Microsoft Exchange Server" entry sitting right next to it in
|
|
the same inventory — same install, no year in the string.
|
|
|
|
endoflife.date states a latest build per release (2016 → 15.1.2507.69), and
|
|
that first pair of numbers IS the release: 15.1 is 2016, 15.0 is 2013.
|
|
|
|
Returns None when two releases share it rather than picking one — Exchange
|
|
2019 and Subscription Edition are both 15.2, and guessing there would put a
|
|
supported SE host on a release that went end-of-support in 2025.
|
|
"""
|
|
want = _major_minor(installed)
|
|
if not want:
|
|
return None
|
|
hits = [r for r in releases
|
|
if isinstance(r, dict)
|
|
and _major_minor(str((r.get("latest") or {}).get("name") or "")) == want]
|
|
return hits[0] if len(hits) == 1 else None
|
|
|
|
|
|
def _extract_year(text: Optional[str]) -> Optional[str]:
|
|
"""Pull a 4-digit product year (2000-2099) from a name string."""
|
|
if not text:
|
|
return None
|
|
m = re.search(r"\b(20\d{2})\b", text)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def _pick_release_by_year(releases: List[dict], year: str) -> Optional[dict]:
|
|
"""Match a release by its year token against release name OR label.
|
|
|
|
endoflife.date office releases expose name="2016" / label="2016".
|
|
"""
|
|
if not year:
|
|
return None
|
|
for r in releases:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
if str(r.get("name") or "") == year or str(r.get("label") or "") == year:
|
|
return r
|
|
return None
|
|
|
|
|
|
def _past(date_str: Optional[str]) -> bool:
|
|
if not date_str:
|
|
return False
|
|
try:
|
|
d = datetime.fromisoformat(str(date_str).split("T")[0])
|
|
return d.date() <= datetime.now().date()
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def _build_eol_status(rel: dict, slug: str) -> "EOLStatus":
|
|
"""Construct an EOLStatus from a release dict, computing is_eol /
|
|
is_eoas / is_eol_soon consistently for both package + OS paths.
|
|
|
|
Severity model:
|
|
- is_eol : security support already ended (no patches) AND
|
|
not covered by an active ESU window → real risk.
|
|
- is_eol_soon: security support ends within EOL_SOON_DAYS but
|
|
hasn't yet (e.g. Win Server 2016 → Jan 2027). Still
|
|
patched today, but plan the upgrade.
|
|
- is_eoas : only mainstream/active support ended; security
|
|
patches still flow. Informational, NOT a finding
|
|
on its own (avoids the Server-2016 false-positive).
|
|
"""
|
|
eol_date = rel.get("eolFrom")
|
|
eoas_date = rel.get("eoasFrom")
|
|
eoes_date = rel.get("eoesFrom")
|
|
latest = rel.get("latest") or {}
|
|
days_to_eol = _days_until(eol_date)
|
|
# endoflife may give eolFrom as bool true (= already EOL, no date).
|
|
eol_is_bool_true = isinstance(eol_date, bool) and eol_date is True
|
|
sec_ended = (_past(eol_date) or eol_is_bool_true) and not _past(eoes_date)
|
|
eol_soon = (
|
|
not sec_ended
|
|
and days_to_eol is not None
|
|
and 0 <= days_to_eol <= EOL_SOON_DAYS
|
|
)
|
|
return EOLStatus(
|
|
is_eol=sec_ended,
|
|
is_eoas=_past(eoas_date),
|
|
is_eol_soon=eol_soon,
|
|
days_to_eol=days_to_eol,
|
|
is_maintained=bool(rel.get("isMaintained")),
|
|
release_label=rel.get("label"),
|
|
release_name=rel.get("name"),
|
|
eol_date=str(eol_date) if eol_date is not None else None,
|
|
eoas_date=eoas_date,
|
|
eoes_date=eoes_date,
|
|
latest_version=latest.get("name") if isinstance(latest, dict) else None,
|
|
latest_date=latest.get("date") if isinstance(latest, dict) else None,
|
|
product_slug=slug,
|
|
)
|
|
|
|
|
|
def _days_until(date_str: Optional[str]) -> Optional[int]:
|
|
"""Signed days from today to date_str. Negative = past, None = unparseable.
|
|
endoflife.date sometimes uses a bool (true/false) for eolFrom instead
|
|
of a date — return None in that case."""
|
|
if not date_str or isinstance(date_str, bool):
|
|
return None
|
|
try:
|
|
d = datetime.fromisoformat(str(date_str).split("T")[0]).date()
|
|
return (d - datetime.now().date()).days
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# OS-level EOL (asset.operating_system + os_version)
|
|
# ----------------------------------------------------------------------
|
|
#
|
|
# Wazuh reports OS strings like:
|
|
# "Microsoft Windows Server 2008 R2" os_version "6.1.7601"
|
|
# "Microsoft Windows Server 2016 Datacenter"
|
|
# "Microsoft Windows 10 Pro" os_version "10.0.19045"
|
|
# "Ubuntu" os_version "22.04.3 LTS"
|
|
# "CentOS Linux" os_version "7"
|
|
# "Debian GNU/Linux" os_version "11"
|
|
#
|
|
# endoflife.date uses release codenames, NOT numeric versions:
|
|
# windows-server: "2008-r2", "2012", "2016", "2019", "2022", "2025"
|
|
# windows: "10", "11" (with sub-cycles 22h2 etc.)
|
|
# ubuntu: "22.04", "20.04", "18.04"
|
|
# debian: "11", "10"
|
|
#
|
|
# So OS matching is name-pattern based, not the numeric _pick_release.
|
|
|
|
# "VMware ESXi" / "VMware vCenter Server" and their spellings. Shared with the
|
|
# CVE pass (cvelistv5_scan_service) so a machine cannot be a hypervisor for its
|
|
# CVEs and something else for its lifecycle dates.
|
|
from app.services.vmware_release_service import ( # noqa: E402
|
|
ESXI_OS_RE as _VSPHERE_ESXI_RE,
|
|
VCENTER_OS_RE as _VSPHERE_VCENTER_RE,
|
|
)
|
|
|
|
|
|
def resolve_os_to_eol(os_name: Optional[str], os_version: Optional[str]) -> Optional[tuple]:
|
|
"""Map a Wazuh OS string to (endoflife_slug, release_codename).
|
|
|
|
Returns None when no confident match — caller skips rather than
|
|
guess. Codename is matched against release.name in check_os_eol.
|
|
"""
|
|
if not os_name:
|
|
return None
|
|
n = os_name.lower()
|
|
ver = (os_version or "").lower()
|
|
|
|
# --- VMware vSphere (ESXi hypervisor + vCenter Server) ---
|
|
#
|
|
# endoflife.date keys both products by the major line ("8.0", "7.0", "6.7")
|
|
# while the host reports a three-part version ("8.0.3"), so the codename is
|
|
# the first two components. That is also the honest granularity: VMware
|
|
# publishes ONE end-of-general-support date per line, not per update.
|
|
#
|
|
# Anchored, and the satellites are excluded on purpose — vCenter Converter,
|
|
# vCenter Orchestrator and the "VMware Tools" guest agent all carry the
|
|
# brand and have completely different (or no) lifecycles. Matching them
|
|
# against the hypervisor's dates is the false positive to avoid; the ESXi
|
|
# host and the vCenter appliance are the only two things this branch means.
|
|
if _VSPHERE_ESXI_RE.match(n) or _VSPHERE_VCENTER_RE.match(n):
|
|
slug = "esxi" if _VSPHERE_ESXI_RE.match(n) else "vcenter"
|
|
m = re.match(r"\s*(\d+\.\d+)", ver) or re.search(r"\b(\d+\.\d+)", n)
|
|
return (slug, m.group(1)) if m else (slug, None)
|
|
|
|
# --- Windows Server ---
|
|
if "windows server" in n or ("windows" in n and "server" in n):
|
|
# Pull the year + optional R2 from the name.
|
|
m = re.search(r"server\s+(\d{4})(\s*r2)?", n)
|
|
if m:
|
|
year = m.group(1)
|
|
r2 = "-r2" if m.group(2) else ""
|
|
return ("windows-server", f"{year}{r2}")
|
|
return ("windows-server", None)
|
|
|
|
# --- Windows client (10 / 11) ---
|
|
if "windows" in n:
|
|
m = re.search(r"windows\s+(\d{1,2})", n)
|
|
if m:
|
|
return ("windows", m.group(1))
|
|
# os_version "10.0.x" → Win10/11 distinguished by build, but
|
|
# endoflife slug "windows" release "10"/"11" — best effort: 10.
|
|
if ver.startswith("10.0."):
|
|
# build >= 22000 = Windows 11
|
|
mb = re.search(r"10\.0\.(\d+)", ver)
|
|
if mb and int(mb.group(1)) >= 22000:
|
|
return ("windows", "11")
|
|
return ("windows", "10")
|
|
return ("windows", None)
|
|
|
|
# --- Ubuntu ---
|
|
if "ubuntu" in n:
|
|
m = re.search(r"(\d{2}\.\d{2})", ver) or re.search(r"(\d{2}\.\d{2})", n)
|
|
if m:
|
|
return ("ubuntu", m.group(1))
|
|
return ("ubuntu", None)
|
|
|
|
# --- Debian ---
|
|
if "debian" in n:
|
|
m = re.search(r"(\d{1,2})", ver) or re.search(r"(\d{1,2})", n)
|
|
if m:
|
|
return ("debian", m.group(1))
|
|
return ("debian", None)
|
|
|
|
# --- RHEL / CentOS ---
|
|
if "red hat" in n or "rhel" in n:
|
|
m = re.search(r"(\d{1,2})", ver)
|
|
if m:
|
|
return ("rhel", m.group(1))
|
|
return ("rhel", None)
|
|
if "centos" in n:
|
|
m = re.search(r"(\d{1,2})", ver)
|
|
if m:
|
|
return ("centos", m.group(1))
|
|
return ("centos", None)
|
|
|
|
return None
|
|
|
|
|
|
def check_os_eol(db: Session, os_name: str, os_version: str) -> Optional[EOLStatus]:
|
|
"""EOL evaluation for an operating system. Matches the OS codename
|
|
(e.g. '2008-r2') against endoflife release.name as a prefix."""
|
|
resolved = resolve_os_to_eol(os_name, os_version)
|
|
if not resolved:
|
|
return None
|
|
slug, codename = resolved
|
|
data = fetch_product(db, slug)
|
|
if not data:
|
|
return None
|
|
result = (data.get("result") if isinstance(data, dict) else None) or {}
|
|
releases = result.get("releases") or []
|
|
rel = None
|
|
if codename:
|
|
# Prefer exact codename, then prefix.
|
|
for r in releases:
|
|
if str(r.get("name") or "").lower() == codename.lower():
|
|
rel = r
|
|
break
|
|
if rel is None:
|
|
for r in releases:
|
|
rn = str(r.get("name") or "").lower()
|
|
if rn.startswith(codename.lower()) or codename.lower().startswith(rn):
|
|
rel = r
|
|
break
|
|
if rel is None:
|
|
return None
|
|
return _build_eol_status(rel, slug)
|
|
|
|
|
|
def check_eol(db: Session, product_name: str, installed_version: str) -> Optional[EOLStatus]:
|
|
"""Evaluate EOL status for a single (product, version) pair.
|
|
|
|
Returns None when the product can't be mapped to an endoflife slug
|
|
or the API returns no data — caller should treat that as "unknown,
|
|
skip" rather than "supported".
|
|
"""
|
|
slug = resolve_product_slug(product_name)
|
|
if not slug:
|
|
return None
|
|
data = fetch_product(db, slug)
|
|
if not data:
|
|
return None
|
|
result = (data.get("result") if isinstance(data, dict) else None) or {}
|
|
releases = result.get("releases") or []
|
|
rel = None
|
|
# .NET: the version field is an MSI build number ("Microsoft .NET Host -
|
|
# 9.0.18 (x64)" ships as 72.72.55158), so the release lives in the display
|
|
# NAME. .NET Framework reports a usable 4.8.04084 but states the same 4.8
|
|
# in its name — take the name for both, keep the field as the fallback for
|
|
# the Framework only.
|
|
if slug in ("dotnet", "dotnetfx"):
|
|
m = _DOTNET_NAME_VER_RE.search(product_name or "")
|
|
if m:
|
|
installed_version = m.group(1)
|
|
elif slug == "dotnet":
|
|
return None
|
|
# Year-keyed products (Office): release name/label is "2016", but the
|
|
# installed version is a build number ("16.0.4266.1001"). Pull the year
|
|
# from the product name instead of prefix-matching the version.
|
|
if slug in _YEAR_KEYED_SLUGS:
|
|
year = _extract_year(product_name)
|
|
if year:
|
|
rel = _pick_release_by_year(releases, year)
|
|
if rel is None:
|
|
rel = _pick_release(releases, installed_version)
|
|
if rel is None:
|
|
# Year-keyed product whose inventory name carries no year — see
|
|
# _pick_release_by_latest ("Microsoft Exchange Server", 15.1.2507.6).
|
|
rel = _pick_release_by_latest(releases, installed_version)
|
|
if not rel:
|
|
return None
|
|
return _build_eol_status(rel, slug)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Pseudo-vuln upsert
|
|
# ----------------------------------------------------------------------
|
|
|
|
def _pseudo_cve_id(slug: str, release_name: str,
|
|
product_name: Optional[str] = None) -> str:
|
|
"""Stable pseudo-CVE id per (product, release) so re-scans converge.
|
|
|
|
The endoflife slugs already name the product (msexchange, chrome), so the
|
|
release alone identifies the finding. `ms-lifecycle` does not: it is one
|
|
slug for every product in Microsoft's export, and its release string is
|
|
whatever that sheet says — usually "Original Release". Every such product
|
|
on a host therefore collapsed into ONE row, EOL-MS-LIFECYCLE-Original_
|
|
Release, whose title, package and description were overwritten by whichever
|
|
package the sweep touched last. A Lync-2013 finding kept turning
|
|
into Visual C++ 2012 and back, with a status flip logged each time.
|
|
"""
|
|
safe_rel = re.sub(r"[^A-Za-z0-9._-]", "_", release_name)[:30]
|
|
if slug == "ms-lifecycle" and product_name:
|
|
# The product IS the identity here; the release moved to the title.
|
|
safe_prod = re.sub(r"[^A-Za-z0-9._-]", "", product_name)[:32]
|
|
return f"EOL-MS-LIFECYCLE-{safe_prod}"[:50]
|
|
return f"EOL-{slug.upper()}-{safe_rel}"[:50]
|
|
|
|
|
|
# Products where a device really does run exactly ONE release, so finding a
|
|
# newer one proves the older is gone. Everything else installs side by side and
|
|
# must NOT be superseded.
|
|
#
|
|
# The rule used to apply to every product, on the assumption that "a product
|
|
# runs exactly one release per asset". That is true for a browser or an OS and
|
|
# false for most Windows components: a single host can carry Visual C++ 2008,
|
|
# 2010, 2012, 2013, 2015 and 2022 redistributables at the same time, all of
|
|
# them genuinely installed and several genuinely EOL.
|
|
#
|
|
# The result was a fight inside a single scan. Processing the 2008 entry closed
|
|
# the 2013 finding as "superseded"; processing 2013 reopened it and closed
|
|
# 2008; and so on for every pair, every run. The change history filled up with
|
|
# 50 alternating entries — "moved to a newer release (…2008…)" for a 2013
|
|
# product — and the audit log with hundreds of rows a night. Nothing about the
|
|
# host had changed.
|
|
# Products on a fixed short release cycle, where "end of life" means the next
|
|
# version has shipped — not that the product is finished. See the severity
|
|
# tiers in upsert_eol_vulnerability.
|
|
_RAPID_RELEASE_SLUGS = {"chrome", "firefox"}
|
|
|
|
_SINGLE_RELEASE_SLUGS = {
|
|
"chrome", "firefox", "windows", "windows-server", "windows-embedded",
|
|
"windows-nano-server", "windows-server-core", "ios", "ipados", "macos",
|
|
"android", "msexchange", "sharepoint",
|
|
# A host runs exactly one hypervisor and a vCenter exactly one appliance
|
|
# 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",
|
|
}
|
|
|
|
|
|
def reconcile_eol_findings(db, asset_id: int, seen_vuln_ids: set,
|
|
had_inventory: bool) -> int:
|
|
"""Close EOL findings for software this scan no longer found installed.
|
|
|
|
Supersede (below) closes the OLD release when a NEWER one turns up, which
|
|
covers Chrome 150 -> 151. It cannot cover an uninstall: nothing newer
|
|
appears, so nothing supersedes the finding and it stays open forever. And
|
|
since supersede is now restricted to products a device runs one of,
|
|
side-by-side software had no route out at all.
|
|
|
|
The inventory answers it directly — a product the scan did not see is not
|
|
installed — but only when there IS an inventory. An empty package list is a
|
|
failed or not-yet-populated fetch, never proof that a machine runs no
|
|
software; closing on it is the mistake the app scan made with a
|
|
re-registered Wazuh agent.
|
|
|
|
Only findings this scan owns are retracted (first_detected_by=eol_check).
|
|
That is every EOL row there is, mobile ones included — they go through the
|
|
same upsert — so a caller that raises findings of its own outside this
|
|
sweep must hand them over in `seen_vuln_ids`, or this pass closes them.
|
|
The Intune sync raises the device-MODEL EOL finding (a phone model is in
|
|
no app inventory) and did not, which is how it closed its own finding
|
|
seconds after raising it, once per sync (see test_eol_mobile_flap).
|
|
"""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
if not had_inventory:
|
|
return 0
|
|
stale = (
|
|
db.query(Vulnerability)
|
|
.filter(Vulnerability.asset_id == asset_id,
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.first_detected_by == "eol_check",
|
|
Vulnerability.cve_id.like("EOL-%"))
|
|
.all()
|
|
)
|
|
closed = 0
|
|
for v in stale:
|
|
if v.id in seen_vuln_ids or v.first_detected_by != "eol_check":
|
|
continue
|
|
old_status = v.status
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
closed += 1
|
|
try:
|
|
from app.routers.vulnerabilities import log_vulnerability_change
|
|
log_vulnerability_change(
|
|
db, None, v.id, old_status, v.status,
|
|
reason="Software is no longer in the asset's inventory "
|
|
"(uninstalled or replaced)",
|
|
cve_id=v.cve_id, source="eol_check",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("audit log for EOL reconcile failed (vuln_id=%s): %s", v.id, e)
|
|
return closed
|
|
|
|
|
|
def _supersede_old_eol(db: "Session", asset_id: int, slug: Optional[str],
|
|
keep_cve_id: str, protect_ids: Optional[set] = None) -> None:
|
|
"""Close the EOL finding of a release the device has moved off.
|
|
|
|
Only for products that exist once per device (see _SINGLE_RELEASE_SLUGS).
|
|
For anything installed side by side, an older release still being present
|
|
is the normal state, not a leftover — so its finding stays open.
|
|
|
|
`protect_ids` are the findings THIS sweep has already confirmed from the
|
|
same inventory. Superseding one of those is self-contradictory: the sweep
|
|
just saw both products, so neither replaced the other. That is how the
|
|
open→patched→open loop starts — package A closes B's finding, package B
|
|
reopens its own and closes A's, once per run, forever, with nothing having
|
|
changed on the host."""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
if not slug or slug == "unknown":
|
|
return
|
|
if slug not in _SINGLE_RELEASE_SLUGS:
|
|
return
|
|
prefix = f"EOL-{slug.upper()}-"
|
|
stale = (
|
|
db.query(Vulnerability)
|
|
.filter(Vulnerability.asset_id == asset_id,
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.first_detected_by == "eol_check",
|
|
Vulnerability.cve_id.like(f"{prefix}%"),
|
|
Vulnerability.cve_id != keep_cve_id)
|
|
.all()
|
|
)
|
|
for v in stale:
|
|
if protect_ids and v.id in protect_ids:
|
|
continue
|
|
old_status = v.status
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
try:
|
|
from app.routers.vulnerabilities import log_vulnerability_change
|
|
log_vulnerability_change(
|
|
db, None, v.id, old_status, v.status,
|
|
reason=f"Superseded — asset moved to a newer {slug} release ({keep_cve_id})",
|
|
cve_id=v.cve_id, source="eol_supersede",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("audit log for EOL supersede failed (vuln_id=%s): %s", v.id, e)
|
|
|
|
|
|
def upsert_eol_vulnerability(
|
|
db: Session,
|
|
*,
|
|
asset_id: int,
|
|
product_name: str,
|
|
installed_version: str,
|
|
status: EOLStatus,
|
|
vendor: Optional[str] = None,
|
|
protect_ids: Optional[set] = None,
|
|
) -> Tuple[Optional[int], bool]:
|
|
"""Create or refresh an EOL pseudo-vuln on the asset.
|
|
|
|
`protect_ids` = findings already confirmed by this sweep; they are never
|
|
superseded (see _supersede_old_eol).
|
|
|
|
`vendor` is stored because the MS-lifecycle matcher reads it: the nightly
|
|
revalidation re-asks the resolver from the ROW, not from the inventory, so
|
|
a finding saved without its vendor would be re-judged under a stricter rule
|
|
than the one that created it — and closed the same night it appeared.
|
|
|
|
Returns (vuln_id, was_created).
|
|
"""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
|
|
|
cve_id = _pseudo_cve_id(status.product_slug or "unknown",
|
|
status.release_name or "unknown", product_name)
|
|
|
|
# Severity tiers:
|
|
# EOL 1000+ days → CRITICAL, cvss 9.8, title "EOL 1000d+"
|
|
# EOL (security ended) → HIGH, cvss 9.0, title "EOL"
|
|
# EOL SOON (≤90d) → MEDIUM, cvss 5.5, title "EOL SOON"
|
|
# EOAS only (still patched)→ LOW, cvss 3.0, title "end-of-active-support"
|
|
# The days-past-EOL escalation mirrors the endoflife.date/Wazuh EOL model:
|
|
# the longer a product has been unpatched, the higher the standing risk.
|
|
if status.is_eol:
|
|
# days_to_eol is signed (negative = past); guard the bool-true case
|
|
# (endoflife eolFrom=true, no date → days unknown).
|
|
days_past = (-status.days_to_eol
|
|
if status.days_to_eol is not None and status.days_to_eol < 0
|
|
else None)
|
|
if days_past is not None and days_past >= 1000:
|
|
severity = VulnerabilitySeverity.critical
|
|
cvss = 9.8
|
|
state_label = f"EOL {days_past}d"
|
|
state_desc = f"EOL for {days_past} days (no security patches — critical exposure)."
|
|
else:
|
|
severity = VulnerabilitySeverity.high
|
|
cvss = 9.0
|
|
state_label = f"EOL {days_past}d" if days_past is not None else "EOL"
|
|
state_desc = (f"EOL for {days_past} days (no further security patches)."
|
|
if days_past is not None
|
|
else "EOL (no further security patches).")
|
|
# Rapid-release products mean something different by "EOL". Firefox and
|
|
# Chrome ship every four weeks and the previous version stops getting
|
|
# patches the day the next one lands, so every install except the very
|
|
# newest is end-of-life by that definition. That is a pending update,
|
|
# not a dead product: a host moved 150 → 152 and the finding
|
|
# simply reappeared as EOL-FIREFOX-152, at CVSS 9.0, on its way to
|
|
# critical as the days counted up.
|
|
#
|
|
# The finding is still correct and still shown — an unpatched browser
|
|
# is worth knowing about — but it is not a Windows 7. Capped at medium
|
|
# so it cannot outrank a genuinely abandoned product, and the day
|
|
# counter no longer escalates it, since on a four-week cycle that
|
|
# counts release cadence rather than growing risk. The CVE scan is the
|
|
# accurate signal for these two and says WHICH hole is open.
|
|
if (status.product_slug or "") in _RAPID_RELEASE_SLUGS:
|
|
severity = VulnerabilitySeverity.medium
|
|
cvss = 5.5
|
|
state_label = "outdated release"
|
|
state_desc = (f"release {status.release_name} no longer receives "
|
|
f"security updates; current is "
|
|
f"{status.latest_version or 'a newer build'}. Short "
|
|
f"release cycle — this is a pending update, not an "
|
|
f"abandoned product.")
|
|
elif status.is_eol_soon:
|
|
severity = VulnerabilitySeverity.medium
|
|
cvss = 5.5
|
|
d = status.days_to_eol if status.days_to_eol is not None else "?"
|
|
state_label = f"EOL SOON ({d}d)"
|
|
state_desc = f"security support ends in {d} days ({status.eol_date}) — plan the upgrade."
|
|
else:
|
|
# EOAS only — still receiving security patches.
|
|
severity = VulnerabilitySeverity.low
|
|
cvss = 3.0
|
|
state_label = "end-of-active-support"
|
|
state_desc = "out of active/mainstream support (security patches still flow)."
|
|
|
|
# Which source actually made the call. The description and the source badge
|
|
# said "endoflife.date" for every finding, including the ones that came from
|
|
# 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.
|
|
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)
|
|
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset_id)
|
|
.first()
|
|
)
|
|
title = f"{product_name} {status.release_label or status.release_name} — {state_label}"
|
|
desc_lines = [
|
|
f"{source_name} reports {product_name} release {status.release_label or status.release_name} "
|
|
f"({status.release_name}) is {state_desc}",
|
|
]
|
|
if status.eol_date:
|
|
verb = "ended" if status.is_eol else "ends"
|
|
desc_lines.append(f"Security support {verb}: {status.eol_date}.")
|
|
if status.eoes_date:
|
|
desc_lines.append(f"Extended security support ends: {status.eoes_date}.")
|
|
if status.latest_version:
|
|
desc_lines.append(f"Latest supported release: {status.latest_version} ({status.latest_date or 'date unknown'}).")
|
|
desc_lines.append(f"Installed on this host: {installed_version}.")
|
|
if status.is_eol:
|
|
# Running EOL software is an explicit control failure in the major
|
|
# frameworks — surface the mapping so audits/reports can cite it.
|
|
desc_lines.append(
|
|
"Compliance: running end-of-life software violates PCI-DSS 6.3.3, "
|
|
"NIST 800-53 CM-8, and HIPAA 164.312(a)(1)."
|
|
)
|
|
description = "\n".join(desc_lines)
|
|
|
|
if existing:
|
|
existing.severity = severity
|
|
existing.title = title[:500]
|
|
existing.description = description
|
|
existing.package_version = installed_version[:100]
|
|
existing.fixed_version = (status.latest_version or None)
|
|
existing.sources = json.dumps([source_key])
|
|
if vendor:
|
|
existing.package_vendor = vendor[:255]
|
|
from app.services.audit_events import reopen_if_patched
|
|
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()
|
|
try:
|
|
existing.refresh_scores()
|
|
except Exception:
|
|
pass
|
|
_supersede_old_eol(db, asset_id, status.product_slug, cve_id, protect_ids)
|
|
return existing.id, False
|
|
|
|
vuln = Vulnerability(
|
|
cve_id=cve_id,
|
|
asset_id=asset_id,
|
|
cvss_score=cvss,
|
|
severity=severity,
|
|
status=VulnerabilityStatus.open,
|
|
title=title[:500],
|
|
description=description,
|
|
package_name=product_name[:255],
|
|
package_vendor=(vendor[:255] if vendor else None),
|
|
package_version=installed_version[:100],
|
|
fixed_version=(status.latest_version or None),
|
|
detected_at=datetime.now(),
|
|
sources=json.dumps([source_key]),
|
|
first_detected_by="eol_check",
|
|
)
|
|
db.add(vuln)
|
|
db.flush()
|
|
try:
|
|
vuln.refresh_scores()
|
|
except Exception:
|
|
pass
|
|
# Revisionssicher: initial detected-event for the new EOL finding.
|
|
try:
|
|
from app.services.audit_events import audit_new_vulnerabilities
|
|
audit_new_vulnerabilities(db, [vuln.id], source="eol_check")
|
|
except Exception:
|
|
pass
|
|
_supersede_old_eol(db, asset_id, status.product_slug, cve_id, protect_ids)
|
|
return vuln.id, True
|
|
|
|
|
|
def run_eol_for_packages(db: "Session", asset, packages: list, *,
|
|
reconcile: bool = False,
|
|
seen_ids: Optional[set] = None) -> dict:
|
|
"""Per-package EOL detection for one asset — the only sweep there is.
|
|
|
|
`packages` = list of {name, version}. endoflife.date first, then the
|
|
MS-lifecycle export / hardcoded-exotics fallback when endoflife has
|
|
nothing actionable. Caller commits. Returns the counters below.
|
|
|
|
There used to be three copies of this loop (the eol-check endpoint, the
|
|
nightly job, and this one), which is how they drifted: only one of them
|
|
filtered published-app stubs out of the inventory, and the flap guard had
|
|
to be threaded into each by hand. Now the callers differ in what they feed
|
|
it and what they ask it to do with what it does not find:
|
|
|
|
`reconcile` retracts the findings this sweep did not re-confirm. Only for
|
|
a caller holding the machine's WHOLE inventory. Intune's detectedApps is a
|
|
partial list, so it must not conclude "gone" from "not in my list" —
|
|
it would close the Wazuh sweep's findings on a co-managed host, and the
|
|
next Wazuh run would reopen them. That is the flap we just removed, one
|
|
layer up.
|
|
|
|
`seen_ids` lets a caller pre-seed findings it produced itself (the
|
|
OS-level EOL row), so `reconcile` does not close them.
|
|
"""
|
|
# Published-app stubs (vendor "Delivered by Citrix") carry a placeholder
|
|
# version ("1.0"), which reads as ancient and therefore end-of-life. The CVE
|
|
# scanner drops them from the inventory outright and this sweep used to
|
|
# reuse that filter — which cost real findings: one Intune list has
|
|
# "Microsoft Access 2010" and "Microsoft Visio 2016", both long out of
|
|
# support, both delivered by Citrix. Their release is in the NAME, so the
|
|
# Microsoft lifecycle listing dates them without consulting the version.
|
|
#
|
|
# So a stub is not dropped, it is restricted to the name-only sources. The
|
|
# version never gets a vote on one — that is where the false positives came
|
|
# from, and a stub named just "Firefox" still matches nothing.
|
|
from app.services.app_cve_scanner_service import _is_citrix_shim
|
|
packages = packages or []
|
|
counts = {"packages_checked": 0, "findings": 0, "new": 0,
|
|
"unmapped": 0, "ms_lifecycle": 0, "closed": 0}
|
|
seen: set = set()
|
|
confirmed: set = seen_ids if seen_ids is not None else set()
|
|
kept_msl: set = set() # MS-lifecycle EOL cve_ids still valid this run
|
|
for pkg in packages or []:
|
|
name = (pkg.get("name") or "").strip()
|
|
version = (pkg.get("version") or "").strip()
|
|
vendor = (pkg.get("vendor") or "").strip()
|
|
# A missing version used to skip the entry entirely — but the whole
|
|
# inventory row is still evidence the product is installed, and the
|
|
# name-only sources need no version. One host lists its engine
|
|
# as "Microsoft SQL Server 2012" with no version at all, so the one
|
|
# entry that names the EOL product outright was the one never checked.
|
|
if not name:
|
|
continue
|
|
key = (name.lower(), version)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
counts["packages_checked"] += 1
|
|
|
|
status = None
|
|
# endoflife.date resolves a release FROM the version — off limits for a
|
|
# stub whose version is made up.
|
|
if version and not _is_citrix_shim(pkg) and resolve_product_slug(name):
|
|
try:
|
|
status = check_eol(db, name, version)
|
|
except Exception as e:
|
|
logger.warning("EOL check failed for %s %s: %s", name, version, e)
|
|
status = None
|
|
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
|
|
# MS-lifecycle export / hardcoded exotics whenever endoflife.date had
|
|
# nothing actionable — this also covers products endoflife.date maps to
|
|
# a slug but can't resolve a release for (Visual C++ redistributables).
|
|
if not actionable:
|
|
try:
|
|
from app.services import ms_lifecycle_service
|
|
ms = ms_lifecycle_service.resolve_ms_lifecycle_eol(
|
|
db, name, version, vendor=vendor)
|
|
if ms and (ms.is_eol or ms.is_eol_soon):
|
|
status = ms
|
|
actionable = True
|
|
counts["ms_lifecycle"] += 1
|
|
except Exception as e:
|
|
logger.debug("MS-lifecycle fallback failed for %s: %s", name, e)
|
|
if not actionable:
|
|
counts["unmapped"] += 1
|
|
continue
|
|
if status and status.product_slug == "ms-lifecycle":
|
|
kept_msl.add(_pseudo_cve_id("ms-lifecycle",
|
|
status.release_name or "unknown", name))
|
|
try:
|
|
vid, was_created = upsert_eol_vulnerability(
|
|
db, asset_id=asset.id, product_name=name,
|
|
installed_version=version or "unknown", status=status,
|
|
vendor=vendor or None, protect_ids=confirmed,
|
|
)
|
|
if vid:
|
|
confirmed.add(vid)
|
|
counts["findings"] += 1
|
|
if was_created:
|
|
counts["new"] += 1
|
|
except Exception as e:
|
|
logger.warning("EOL-for-packages upsert failed (%s on asset %s): %s", name, asset.id, e)
|
|
|
|
# Guard on a non-empty inventory: an empty list is a transient/failed read,
|
|
# not proof the products are gone (same safety as the other reconciles).
|
|
if packages:
|
|
_resolve_stale_ms_lifecycle(db, asset.id, kept_msl)
|
|
if reconcile:
|
|
try:
|
|
counts["closed"] = reconcile_eol_findings(
|
|
db, asset.id, confirmed, had_inventory=bool(packages))
|
|
except Exception as e:
|
|
logger.warning("EOL reconcile failed for asset %s: %s", asset.id, e)
|
|
return counts
|
|
|
|
|
|
def _resolve_stale_ms_lifecycle(db: "Session", asset_id: int, kept: set) -> None:
|
|
"""Resolve open MS-lifecycle EOL findings the current run no longer produced.
|
|
|
|
Without this, fixing a bad name→product match (observed: 'Microsoft Edge'
|
|
browser mis-mapped to the 'Azure Stack Edge' listing) left the wrong finding
|
|
open forever, since _supersede_old_eol only fires when a REPLACEMENT is
|
|
created. Scoped to the EOL-MS-LIFECYCLE- prefix, which only the package path
|
|
produces — OS-level endoflife.date findings use other slugs and are
|
|
untouched. Only reconciles when at least one package was inventoried (empty
|
|
package list = nothing to conclude)."""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
stale = (
|
|
db.query(Vulnerability)
|
|
.filter(Vulnerability.asset_id == asset_id,
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.cve_id.like("EOL-MS-LIFECYCLE-%"))
|
|
.all()
|
|
)
|
|
for v in stale:
|
|
if v.cve_id in kept:
|
|
continue
|
|
old_status = v.status
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
try:
|
|
from app.routers.vulnerabilities import log_vulnerability_change
|
|
log_vulnerability_change(
|
|
db, None, v.id, old_status, v.status,
|
|
reason="MS lifecycle no longer matches this installed product "
|
|
"(re-evaluated — not end-of-life)",
|
|
cve_id=v.cve_id, source="eol_reconcile",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("audit log for MS-lifecycle reconcile failed (vuln_id=%s): %s", v.id, e)
|
|
|
|
|
|
def revalidate_ms_lifecycle_findings(db: "Session") -> int:
|
|
"""Re-check every OPEN MS-lifecycle EOL finding and close the ones that no
|
|
longer match. Returns how many were closed.
|
|
|
|
Path-independent on purpose. _resolve_stale_ms_lifecycle only runs inside
|
|
run_eol_for_packages, but the EOL-check endpoint (the button) has its own
|
|
loop and never called it — so a finding produced by a since-fixed name match
|
|
stayed open forever (observed: 'Microsoft Edge' the browser matched the
|
|
'Azure Stack Edge' listing; the match was fixed, the finding was not).
|
|
Re-asking the resolver per finding is cheap: the lifecycle rows are memoised
|
|
in-process, so this costs one fetch at most.
|
|
"""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
from app.services import ms_lifecycle_service
|
|
|
|
rows = (db.query(Vulnerability)
|
|
.filter(Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.cve_id.like("EOL-MS-LIFECYCLE-%"))
|
|
.all())
|
|
closed = 0
|
|
for v in rows:
|
|
name = (v.package_name or "").strip()
|
|
if not name:
|
|
continue
|
|
# Rows written before the vendor was stored: a name like "Service Pack 1
|
|
# for SQL Server 2008 R2" is judged by the connector rule, which needs
|
|
# the vendor. Re-judging it without one asks a question we cannot answer
|
|
# and closes a finding on the answer "unknown" — leave it to the next
|
|
# sweep, which sees the inventory and fills the vendor in.
|
|
if not (v.package_vendor or "").strip() and \
|
|
ms_lifecycle_service._CONNECTOR_RE.search(
|
|
ms_lifecycle_service._normalise(name)):
|
|
continue
|
|
try:
|
|
st = ms_lifecycle_service.resolve_ms_lifecycle_eol(
|
|
db, name, v.package_version or "",
|
|
vendor=(v.package_vendor or ""))
|
|
except Exception as e:
|
|
logger.debug("MS-lifecycle revalidate failed for %s: %s", name, e)
|
|
continue # unreachable source → leave the finding alone
|
|
stale_id = False
|
|
if st and (st.is_eol or st.is_eol_soon):
|
|
# Still EOL — but a row written under the old shared id
|
|
# (EOL-MS-LIFECYCLE-Original_Release, one per host for ALL
|
|
# products) has to go, or it keeps mixing products next to the
|
|
# per-product row the sweep now writes.
|
|
stale_id = v.cve_id != _pseudo_cve_id(
|
|
"ms-lifecycle", st.release_name or "unknown", name)
|
|
if not stale_id:
|
|
continue
|
|
old_status = v.status
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
closed += 1
|
|
try:
|
|
from app.routers.vulnerabilities import log_vulnerability_change
|
|
log_vulnerability_change(
|
|
db, None, v.id, old_status, v.status,
|
|
reason=(f"Replaced by a per-product EOL finding for '{name}' "
|
|
f"(this row pooled several products under one id)")
|
|
if stale_id else
|
|
(f"MS lifecycle no longer reports '{name}' as end-of-life "
|
|
f"(re-evaluated — earlier match was wrong)"),
|
|
cve_id=v.cve_id, source="eol_revalidate",
|
|
hostname=(v.asset.hostname if v.asset else None),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("audit log for MS-lifecycle revalidate failed (%s): %s", v.id, e)
|
|
if closed:
|
|
db.commit()
|
|
logger.info("MS-lifecycle revalidate: closed %d stale finding(s)", closed)
|
|
return closed
|