feat(app-scan): Veeam Agent for Microsoft Windows on both paths

KB4902 (CVE-2026-58068, CVE-2025-64391) sits at NVD "Awaiting Analysis",
so cvelistV5 is the only structured source; the older agent CVEs
(CVE-2025-48982, CVE-2024-29853) are NVD's under
veeam:veeam_agent_for_windows, the only CPE NVD has for this agent.

- New veeam-agent-windows key and CPE, anchored to the agent row. The
  "Redistributable" beside it is the installer payload Veeam keeps for
  deploying the agent, not the agent that runs.
- "Veeam Agent for ..." reads as a companion of Windows and was cut down
  to "Veeam", so the name never resolved. A whole_name flag on the entry
  lets a pattern that spells out the full name skip that rule.
- The agent records write "0 .. < 13.1" for "all earlier version 13 and
  6 builds"; the 0 floor becomes 6, the oldest line Veeam names. 13.1 is
  build 13.1.0.544, the first 13.1 build, so the bound needs no build.
- CVE-2026-32996 (KB4852) is an agent flaw filed under Backup and
  Replication and fixed by B&R 13.0.2.29 with no agent build; neither
  product takes it.
- cvelistV5 index bumped to v52.
This commit is contained in:
2026-10-29 10:43:53 +01:00
parent ad2abc63d4
commit 0a73b8cee2
3 changed files with 120 additions and 12 deletions
+11 -3
View File
@@ -95,9 +95,13 @@ _PDFX_CVE_2026_2040 = {
def _cpe(name_re: str, vendor_product: str, name_ver: bool = False,
also: Optional[List[str]] = None, pkg_ver: bool = False,
ver_parts: Optional[int] = None,
advisories: Optional[List[dict]] = None) -> tuple:
advisories: Optional[List[dict]] = None, whole_name: bool = False) -> tuple:
e = {"key": f"cpe:{vendor_product}", "kind": "cpe",
"cpe": f"cpe:2.3:a:{vendor_product}"}
if whole_name:
# The pattern spells out the whole "<x> for <y>" name, so the
# companion rule does not apply — see companion_prefix.
e["whole_name"] = True
if advisories:
# Vendor-bulletin rows, read by _advisory_cves. Each decides its own
# CVE alone, NVD or not, as on the extension registry.
@@ -417,6 +421,9 @@ _REGISTRY: List[tuple] = [
# _query_nvd_cpe.
_cpe(r"^veeam backup (?:&|and) replication$", "veeam:veeam_backup_\\&_replication"),
_cpe(r"^veeam one\b(?!.*\bagent\b)", "veeam:one"),
# The agent row only, not its Redistributable (the installer payload).
_cpe(r"^veeam agent for (?:microsoft )?windows$", "veeam:veeam_agent_for_windows",
whole_name=True),
# SAP desktop clients. cvelistV5 carries no CPE for these at all, so NVD is
# the only usable source (Wazuh misses them too — wazuh/wazuh#30334). SAP
# states the patch level in the CPE's UPDATE field, one entry per level, so
@@ -864,7 +871,8 @@ def _platform_ok(tsws, plat: Optional[str]) -> bool:
# The product being described always stands FIRST, so only that part is matched
# against the registry. Names where the product itself carries the word — a
# hypothetical "Microsoft Teams for Desktop" — still resolve, because what
# precedes "for" is the real product.
# precedes "for" is the real product. "Veeam Agent for Microsoft Windows" is
# cut down to "Veeam" by the "agent" word, so its entry is marked whole_name.
_COMPANION_RE = re.compile(
r"\s+(?:plug-?in|add-?in|add-?on|extension|connector|agent|module|"
r"integration|explorer|backup|app)?\s*\bfor\b\s+", re.I)
@@ -910,7 +918,7 @@ def resolve_product(name: str) -> Optional[dict]:
for rx, entry in _REGISTRY:
m = rx.search(n)
if m:
if head != n and not rx.search(head):
if head != n and not rx.search(head) and not entry.get("whole_name"):
continue # companion product — see companion_prefix
if entry.get("key") == "cpe:mozilla:firefox" and re.search(r"\besr\b", n):
# ESR install: the plain mozilla:firefox CPE / cvelistV5 ranges
+22 -5
View File
@@ -44,7 +44,7 @@ _ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_ZIP_MIN_BYTES = 100_000_000 # sanity floor; the real ZIP is ~557 MB
_INDEX_SETTING = "cvelistv5_product_index_v51" # v51: Veeam B&R, Veeam ONE components
_INDEX_SETTING = "cvelistv5_product_index_v52" # v52: Veeam Agent for Windows
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
@@ -238,6 +238,18 @@ _REGISTRY: List[dict] = [
"pairs": [("veeam", "backup and replication"), ("veeam", "backup & replication"),
("veeam", "veeam backup & replication"),
("veeam", "veeam backup and replication")]},
# Veeam Agent for Microsoft Windows. KB4902 (CVE-2026-58068,
# CVE-2025-64391) sits at NVD "Awaiting Analysis". Only the agent row: the
# "... Redistributable" beside it is the installer payload Veeam keeps for
# deploying the agent, not the agent that runs. "whole_name": the
# companion rule would cut the name down to "Veeam", see resolve().
# "zero_floor": its records write "0 .. < 13.1" for "all earlier version
# 13 and 6 builds"; 6 is the oldest line Veeam names, see veeam_affected.
{"key": "veeam-agent-windows", "re": r"^veeam agent for (?:microsoft )?windows$",
"whole_name": True, "zero_floor": "6",
"pairs": [("veeam", "agent for windows"), ("veeam", "agent for microsoft windows"),
("veeam", "veeam agent for windows"),
("veeam", "veeam agent for microsoft windows")]},
# Wazuh's own components. Both sources are needed here as usual: NVD files
# 41 of the 54 under wazuh:wazuh, while the CNA records name the product
# per component ("wazuh", "wazuh-kibana-app") and state ranges as
@@ -945,7 +957,7 @@ def resolve(name: str) -> Optional[str]:
head = cpe.companion_prefix(n)
for rx, e in _COMPILED:
if rx.search(n):
if head != n and not rx.search(head):
if head != n and not rx.search(head) and not e.get("whole_name"):
continue
if e["key"] == "firefox" and re.search(r"\besr\b", n):
# ESR install ("Mozilla Firefox 52.2.1 ESR"): the cvelistV5
@@ -1173,7 +1185,7 @@ def intel_proset_affected(aff: dict) -> dict:
return dict(aff, versions=vers)
def veeam_affected(aff: dict) -> dict:
def veeam_affected(aff: dict, zero_floor: Optional[str] = None) -> dict:
"""Only the entries of a Veeam record that state a real build range.
Veeam's KBs name builds ("13.0.2.6723 and all earlier version 13 builds"),
@@ -1189,6 +1201,9 @@ def veeam_affected(aff: dict) -> dict:
13.1.0.7233, KB4951). The rest is left to NVD, whose analysis names the
build. Unaffected entries go too, so the inverse rule in
_ranges_from_affected cannot turn one into an open range.
zero_floor puts a "0" floor where the KB puts it, instead of dropping the
entry: the Agent's KB4902 writes "0 .. < 13.1" for "13.0.4.1341 and all
earlier version 13 and 6 builds", and nothing below 6 is named.
"""
vers = []
for v in aff.get("versions") or []:
@@ -1196,6 +1211,8 @@ def veeam_affected(aff: dict) -> dict:
continue
ver, lt, lte = (str(v.get(k) or "").strip()
for k in ("version", "lessThan", "lessThanOrEqual"))
if ver == "0" and zero_floor:
v, ver = dict(v, version=zero_floor), zero_floor
if ver in ("", "0", "*", "-") or ver in (lt, lte):
continue
if lt or len((cpe._vtuple(lte) or ())) >= 4:
@@ -1688,8 +1705,8 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
aff = aruba_affected(aff)
if key == "intel-proset-wireless":
aff = intel_proset_affected(aff)
if key in ("veeam-br", "veeam-one"):
aff = veeam_affected(aff)
if key in ("veeam-br", "veeam-one", "veeam-agent-windows"):
aff = veeam_affected(aff, _KEY_ENTRY[key].get("zero_floor"))
if key == "aruba-switch" or (key == "aruba-os" and any(
isinstance(v, dict)
and _AOSS_PROSE_RE.search(str(v.get("version") or ""))
+87 -4
View File
@@ -1,4 +1,4 @@
"""Veeam Backup & Replication + Veeam ONE — run: venv/bin/python -m pytest tests/test_veeam.py
"""Veeam Backup & Replication, Veeam ONE, Veeam Agent for Windows — run: venv/bin/python -m pytest tests/test_veeam.py
Field case (Wazuh inventory of one backup server):
@@ -21,6 +21,22 @@ Reporter Server alike) and is updated by the same P1, so the build decides.
The Veeam ONE Agent sits on the backup servers Veeam ONE watches, not on the
Veeam ONE server, and stays out.
Veeam Agent for Microsoft Windows (Checkmk inventory, one host):
Veeam Agent for Microsoft Windows 13.1.1.700 C:\\Program Files\\Veeam\\Endpoint Backup\\
Veeam Agent for Microsoft Windows Redistributable 13.1.1.700 C:\\ProgramData\\Veeam\\Agents\\
KB4902 (CVE-2026-58068, CVE-2025-64391): "13.0.4.1341 and all earlier version
13 and 6 builds", fixed in 13.1 (build 13.1.0.544); the records say "0 .. <
13.1", and 13.1.0.544 is the first 13.1 build, so both bounds split the
released builds alike. NVD has neither analysed yet. Older ones (CVE-2025-48982,
CVE-2024-29853) NVD has under veeam:veeam_agent_for_windows, while their CNA
records name no usable range. Only the agent row is the agent: the
Redistributable is the installer payload Veeam keeps for deploying it, not the
software that runs. CVE-2026-32996 (KB4852) is an agent flaw filed under Backup
and Replication and fixed by B&R 13.0.2.29; no agent build is named, so
neither product can take it.
Fixtures are the real cvelistV5 blocks and NVD cpeMatch entries (descriptions
shortened), except the one marked as the expected shape of the pending NVD
analysis.
@@ -44,8 +60,11 @@ ONE_CPE = "cpe:2.3:a:veeam:one:*:*:*:*:*:*:*:*"
ONE_NAMES = ("Veeam ONE", "Veeam ONE Client", "Veeam ONE Monitor Server",
"Veeam ONE Reporter Server", "Veeam ONE Monitor Client",
"Veeam ONE Web Client", "Veeam ONE Web Services")
AGENT = "Veeam Agent for Microsoft Windows"
AGENT_CPE = "cpe:2.3:a:veeam:veeam_agent_for_windows:*:*:*:*:*:*:*:*"
NOT_VEEAM_KEYS = ("Veeam Backup & Replication Console", "Veeam Installer Service",
"Veeam Mount Service", "Veeam Backup Transport", "Veeam Agent for Microsoft Windows",
"Veeam Mount Service", "Veeam Backup Transport",
"Veeam Agent for Microsoft Windows Redistributable", "Veeam Agent for Linux",
"Veeam Explorer for Microsoft Exchange", "Veeam Backup Enterprise Manager",
"Veeam Backup for Microsoft 365", "Veeam ONE Agent")
@@ -56,8 +75,10 @@ def test_cvelistv5_products():
assert c5._pair_key("Veeam", prod) == "veeam-br", prod
for prod in ("ONE", "Veeam ONE"):
assert c5._pair_key("Veeam", prod) == "veeam-one", prod
for prod in ("Agent for Windows", "Agent for Microsoft Windows"):
assert c5._pair_key("Veeam", prod) == "veeam-agent-windows", prod
for prod in ("Agent for Linux", "Backup for Microsoft 365", "Veeam Service Provider Console"):
assert c5._pair_key("Veeam", prod) not in ("veeam-br", "veeam-one"), prod
assert c5._pair_key("Veeam", prod) is None, prod
def test_resolves_names():
@@ -67,8 +88,13 @@ def test_resolves_names():
for name in ONE_NAMES:
assert c5.resolve(name) == "veeam-one", name
assert cpe.resolve_product(name)["key"] == "cpe:veeam:one", name
# "<x> Agent for <y>" reads as a companion of <y> (cpe.companion_prefix);
# this one IS the product.
for name in (AGENT, "Veeam Agent for Windows"):
assert c5.resolve(name) == "veeam-agent-windows", name
assert cpe.resolve_product(name)["key"] == "cpe:veeam:veeam_agent_for_windows", name
for name in NOT_VEEAM_KEYS:
assert c5.resolve(name) not in ("veeam-br", "veeam-one"), name
assert c5.resolve(name) is None, name
assert not (cpe.resolve_product(name) or {}).get("key", "").startswith("cpe:veeam:"), name
@@ -116,7 +142,33 @@ R = [
("CVE-2026-93026", {"vendor": "Veeam", "product": "Backup and Replication",
"versions": [{"version": "12", "status": "affected",
"lessThan": "12.3.2.4934", "versionType": "semver"}]}),
# Veeam Agent for Windows, verbatim. KB4902: "13.0.4.1341 and all earlier
# version 13 and 6 builds", so the 0 floor becomes 6, not "everything".
("CVE-2026-58068", {"defaultStatus": "unaffected", "vendor": "Veeam",
"product": "Agent for Windows",
"versions": [{"version": "0", "status": "affected",
"lessThan": "13.1", "versionType": "semver"}]}),
("CVE-2025-64391", {"defaultStatus": "unaffected", "vendor": "Veeam",
"product": "Agent for Windows",
"versions": [{"version": "0", "status": "affected",
"lessThan": "13.1", "versionType": "semver"}]}),
# KB4771: 6.0 .. 6.3.2.1205, fixed in 6.3.2.1302; the record says 4.7.2.
("CVE-2025-48982", {"defaultStatus": "unaffected", "vendor": "Veeam",
"product": "Agent for Microsoft Windows",
"versions": [{"version": "4.7.2", "status": "affected",
"lessThanOrEqual": "4.7.2", "versionType": "semver"}]}),
# KB4581: fixed in 6.1.2.134; the record's range is empty.
("CVE-2024-29853", {"defaultStatus": "unaffected", "vendor": "Veeam",
"product": "Agent for Windows",
"versions": [{"version": " 6.1.2.134", "status": "affected",
"lessThan": " 6.1.2.134", "versionType": "semver"}]}),
# KB4852: an agent flaw filed under B&R, see the module docstring.
("CVE-2026-32996", {"defaultStatus": "unaffected", "vendor": "Veeam",
"product": "Backup and Replication",
"versions": [{"version": "13", "status": "affected",
"lessThanOrEqual": "13.0.1", "versionType": "semver"}]}),
]
KB4902 = ["CVE-2025-64391", "CVE-2026-58068"]
KB4948 = ["CVE-2026-108140", "CVE-2026-108141", "CVE-2026-108142"]
@@ -209,6 +261,26 @@ def test_cvelistv5_one_bounds():
assert hits("Veeam ONE Agent", "13.1.0.7233") == []
AGENT_FIELD = [{"name": AGENT, "version": "13.1.1.700"},
{"name": AGENT + " Redistributable", "version": "13.1.1.700"}] * 2
def test_cvelistv5_agent():
index = _build_index()
assert sorted(e["cve"] for e in index["veeam-agent-windows"]) == KB4902
assert _scan_c5(index, AGENT_FIELD) == []
old = [dict(p, version="13.0.4.1341") for p in AGENT_FIELD]
assert _scan_c5(index, old) == [(AGENT, c, "13.1") for c in KB4902] # once per host
hits = lambda v: [c for _, c, _ in _scan_c5(index, [{"name": AGENT, "version": v}])]
assert hits("13.0.4.1341") == KB4902 # last affected build in KB4902
assert hits("6.3.2.1302") == KB4902 # "all earlier version 13 and 6 builds"
assert hits("13.1.0.544") == [] # the fix
assert hits("13.1.1.700") == []
assert hits("6.0.0.959") == KB4902
assert hits("5.0.3.4708") == [] # below every line KB4902 names
assert hits("4.7.2.0") == [] # nor CVE-2025-48982: its record's range is no range
# ---- NVD path -----------------------------------------------------------------
def _cfg(crit, start, end):
return [{"nodes": [{"cpeMatch": [{"vulnerable": True, "criteria": crit,
@@ -228,6 +300,12 @@ NVD = [
"versionEndExcluding": "13.1.2.57"}]}]}]}},
{"cve": {"id": "CVE-2024-42024", "descriptions": [{"lang": "en", "value": "RCE"}],
"configurations": _cfg(ONE_CPE, "12.0.0.2498", "12.2.0.4093")}},
{"cve": {"id": "CVE-2025-48982", "descriptions": [{"lang": "en", "value": "LPE"}],
"configurations": _cfg(AGENT_CPE, "6.0.0.959", "6.3.2.1302")}},
{"cve": {"id": "CVE-2024-29853", "descriptions": [{"lang": "en", "value": "LPE"}],
"configurations": [{"nodes": [{"cpeMatch": [
{"vulnerable": True, "criteria": AGENT_CPE,
"versionEndExcluding": "6.1.2.134"}]}]}]}},
]
@@ -281,3 +359,8 @@ def test_nvd(monkeypatch):
assert _scan_nvd(monkeypatch, "Veeam ONE Reporter Server", "12.1.0.3208") == [
("CVE-2024-42024", "12.2.0.4093")]
assert _scan_nvd(monkeypatch, "Veeam Backup & Replication Console", "13.1.0.411") == []
assert _scan_nvd(monkeypatch, AGENT, "6.1.0.349") == [
("CVE-2024-29853", "6.1.2.134"), ("CVE-2025-48982", "6.3.2.1302")]
assert _scan_nvd(monkeypatch, AGENT, "6.3.2.1302") == []
assert _scan_nvd(monkeypatch, AGENT, "13.1.1.700") == []
assert _scan_nvd(monkeypatch, AGENT + " Redistributable", "6.1.0.349") == []