diff --git a/app/services/app_cve_scanner_service.py b/app/services/app_cve_scanner_service.py index 1e415a6..4f43363 100644 --- a/app/services/app_cve_scanner_service.py +++ b/app/services/app_cve_scanner_service.py @@ -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 diff --git a/tests/test_sap_patch_level.py b/tests/test_sap_patch_level.py index 04b24c2..df7da8a 100644 --- a/tests/test_sap_patch_level.py +++ b/tests/test_sap_patch_level.py @@ -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")