Intune lists published apps as "Microsoft Access 2010 / 1.0 / Delivered by Citrix". The CVE scanner drops those rows because the version is a placeholder, and the EOL sweep reused that same filter — so two products years past end-of-support produced no finding at all. The version is the untrustworthy half, not the name. A stub is now kept and restricted to the name-only sources (the Microsoft lifecycle export and the hardcoded exotics); the endoflife.date path, which derives the release from the installed version, is skipped for it. A stub named just "Firefox" still matches nothing, which is what the filter was written for.
1306 lines
56 KiB
Python
1306 lines
56 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
|
|
"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 in the tester's 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. Tester: 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.
|
|
|
|
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()
|
|
|
|
# --- 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. The tester's 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: the tester's host carries 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",
|
|
}
|
|
|
|
|
|
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),
|
|
so mobile/Intune EOL findings, which come from an inventory this scan never
|
|
looked at, are left alone.
|
|
"""
|
|
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,
|
|
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).
|
|
|
|
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: the tester's 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 the tester could not tell
|
|
# which source to check the date against.
|
|
from_msl = (status.product_slug or "") == "ms-lifecycle"
|
|
source_name = "Microsoft product lifecycle" if from_msl else "endoflife.date"
|
|
source_key = "ms-lifecycle" if from_msl else "endoflife.date"
|
|
|
|
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])
|
|
from app.services.audit_events import reopen_if_patched
|
|
reopen_if_patched(db, existing, reason="endoflife.date check reports this product as EOL again", source="eol_check")
|
|
# 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_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: the tester's 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()
|
|
if not name or not version:
|
|
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 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)
|
|
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, status=status,
|
|
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 (tester: '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 (tester: '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
|
|
try:
|
|
st = ms_lifecycle_service.resolve_ms_lifecycle_eol(
|
|
db, name, v.package_version 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
|