fix(sap): require the release to match, not just the patch level

With the real syscollector rows in hand the level-only match turns out to be
a false-positive generator. Every SAP release carries the same patch level
NUMBERS — NVD lists gui_for_windows 7.70:patch_level17 right next to
8.0:patch_level1 — so the tester's host (SAP GUI for Windows 8.00 64bit,
Patch 17) matched every 7.70 CVE that happens to have a patch_level17 entry.

The release is now compared as well, and numerically: Wazuh reports "8.00"
where NVD writes "8.0", so a string compare would have rejected the host's
own release.

The parser is confirmed against the verbatim inventory strings, which differ
per product — Business Client states the level in the version field
("8.00 PL26") while SAP GUI states a compilation there and puts the level in
the NAME ("SAP GUI for Windows 8.00 64bit  (Patch 17)"). Both are pinned in
the test, along with "Compilation" not reading as a level despite containing
the letters p-l.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 14:57:17 +02:00
co-authored by Claude Opus 5
parent 86da96fc06
commit d1badf70ed
2 changed files with 45 additions and 16 deletions
+14 -3
View File
@@ -303,12 +303,23 @@ def _sap_patch_level(name: str, version: str) -> Optional[int]:
return None
def _sap_affected(m: dict, pl: Optional[int]) -> bool:
"""True if this cpeMatch names exactly the host's patch level."""
def _sap_affected(m: dict, installed: str, pl: Optional[int]) -> bool:
"""True if this cpeMatch names the host's release AND its patch level.
BOTH halves are required. The same patch-level number exists under every
release — NVD lists gui_for_windows 7.70:patch_level17 next to
8.0:patch_level1 — so matching on the level alone puts a 7.70 CVE on an
8.00 host. The releases are also spelled differently on the two sides
(Wazuh reports "8.00", NVD writes "8.0"), which is why this compares them
numerically instead of as strings.
"""
if pl is None:
return False
parts = (m.get("criteria") or "").split(":")
ver = parts[5] if len(parts) > 5 else "*"
upd = parts[6] if len(parts) > 6 else "*"
if ver in ("*", "-", "") or _vcmp(installed, ver) != 0:
return False
hit = _SAP_CPE_PL_RE.match(upd or "")
if hit:
return int(hit.group(1)) == pl
@@ -408,7 +419,7 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
continue
if not m.get("vulnerable", True):
continue
ok = (_sap_affected(m, sap_pl) if sap_pl is not None
ok = (_sap_affected(m, version, sap_pl) if sap_pl is not None
else _in_range(version, m))
if ok:
matched = True
+31 -13
View File
@@ -14,11 +14,18 @@ from app.services.app_cve_scanner_service import _sap_affected, _sap_patch_level
def demo():
# Inventory spellings seen in the wild (wazuh/wazuh#30334: "PL" in the
# version field, "Patch [N]" in the product name).
# VERBATIM syscollector rows from the tester's SAP host — the level lives
# in the version field for Business Client and in the NAME for SAP GUI,
# whose version field states a compilation instead.
assert _sap_patch_level("SAP Business Client 8.00", "8.00 PL26") == 26
assert _sap_patch_level("SAP GUI for Windows 8.00 64bit (Patch 17)",
"8.00 Compilation 1") == 17
assert _sap_patch_level("Adobe LiveCycle Designer 11.0 for SAP solutions",
"11.0 Patch Level 37") == 37
# "Compilation" contains the letters p-l; it must not read as a level.
assert _sap_patch_level("SAP GUI for Windows 8.00", "8.00 Compilation 1") is None
# Other spellings from wazuh/wazuh#30334.
assert _sap_patch_level("SAP GUI for Windows 7.70", "7.70 PL 12") == 12
assert _sap_patch_level("SAP GUI for Windows 7.70", "7.70 PL12") == 12
assert _sap_patch_level("SAP GUI for Windows 7.70 (Patch 4)", "7.70") == 4
assert _sap_patch_level("SAP Business Client 6.0", "6.0 Patch Level 16") == 16
# No level stated anywhere → unknown, never a guess.
assert _sap_patch_level("SAP Business Client", "6.0") is None
@@ -26,19 +33,30 @@ def demo():
# Real criteria strings from CVE-2023-32113 / CVE-2021-38150.
pl4 = {"criteria": "cpe:2.3:a:sap:gui_for_windows:7.70:patch_level4:*:*:*:*:*:*"}
pl17 = {"criteria": "cpe:2.3:a:sap:gui_for_windows:7.70:patch_level17:*:*:*:*:*:*"}
v8pl1 = {"criteria": "cpe:2.3:a:sap:gui_for_windows:8.0:patch_level1:*:*:*:*:*:*"}
pl10 = {"criteria": "cpe:2.3:a:sap:business_client:6.0:patch_level10:*:*:*:*:*:*"}
base = {"criteria": "cpe:2.3:a:sap:business_client:6.0:-:*:*:*:*:*:*"}
assert _sap_affected(pl4, 4)
assert not _sap_affected(pl4, 5) # patched past it
assert not _sap_affected(pl4, 3) # a DIFFERENT level, not "older"
assert _sap_affected(pl10, 10)
assert _sap_affected(base, 0) # ':-' is the unpatched base release
assert not _sap_affected(base, 1)
assert _sap_affected(pl4, "7.70", 4)
assert not _sap_affected(pl4, "7.70", 5) # patched past it
assert not _sap_affected(pl4, "7.70", 3) # a DIFFERENT level, not "older"
assert _sap_affected(pl10, "6.0", 10)
assert _sap_affected(base, "6.0", 0) # ':-' is the unpatched base
assert not _sap_affected(base, "6.0", 1)
# The release has to match too. Every release carries the same level
# NUMBERS, so level-only matching drops a 7.70 CVE onto an 8.00 host —
# exactly the tester's box (SAP GUI 8.00, Patch 17).
assert not _sap_affected(pl17, "8.00", 17)
assert _sap_affected(pl17, "7.70", 17)
# Wazuh says "8.00", NVD says "8.0" — same release, compared numerically.
assert _sap_affected(v8pl1, "8.00", 1)
assert not _sap_affected(v8pl1, "7.70", 1)
# The whole point: unknown level matches nothing at all.
for m in (pl4, pl10, base):
assert not _sap_affected(m, None)
for m in (pl4, pl10, base, v8pl1):
assert not _sap_affected(m, "7.70", None)
# A SAP CPE with a WILDCARD update field states no patch level, so it says
# nothing about patch state — CVE-2021-38175 lists
@@ -46,7 +64,7 @@ def demo():
# that as a hit marks every 2.8 build affected forever. Never match it.
wild = {"criteria": "cpe:2.3:a:sap:analysis_for_microsoft_office:2.8:*:*:*:*:*:*:*"}
for pl in (None, 0, 3, 99):
assert not _sap_affected(wild, pl)
assert not _sap_affected(wild, "2.8", pl)
print("sap patch level OK")