From 4927a1e2ac97b8cf7b44b3fae6a00ae3bee2c60a Mon Sep 17 00:00:00 2001 From: vulncheck Date: Tue, 11 Aug 2026 08:56:50 +0200 Subject: [PATCH] fix(scan): reject commit-hash bounds on the CPE path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash rule went into cvelistV5 only, so the same CVE could still arrive through NVD after the other path had stopped producing it — the tester still saw CVE-2026-67308 on a Wazuh agent, bounded by "before 44bf114d2f49" and advising an upgrade to an upstream commit. The rule now lives in the CPE scanner, where both paths use it, and applies to every NVD bound as well as to the fix version. cvelistV5 delegates rather than keeping its own copy, so the two cannot drift apart and let a CVE back in through whichever is laxer. Pinned by a test asserting both agree. Note for the tester's case: this particular CVE is about Wazuh's GitHub Actions workflows, so the manager-only filter does NOT catch it — that one keys on cluster and manager wording, and this text mentions neither. It is the hash rule that removes it, on every host. --- app/services/app_cve_scanner_service.py | 30 ++++++++++++++++++++++++- app/services/cvelistv5_scan_service.py | 26 ++++----------------- tests/test_wazuh_components.py | 14 ++++++++++++ 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/app/services/app_cve_scanner_service.py b/app/services/app_cve_scanner_service.py index 2bdc8a1..28e9a0d 100644 --- a/app/services/app_cve_scanner_service.py +++ b/app/services/app_cve_scanner_service.py @@ -437,6 +437,27 @@ def _clean_version(v: str) -> Optional[str]: _ADOBE_YEAR_RE = re.compile(r"20(\d\d)\.(\d{3})\.(\d{4,5})$") +_HASH_RE = re.compile(r"^[0-9a-f]{7,40}$", re.I) + + +def is_version(v) -> bool: + """False for a git commit hash used where a version belongs. + + Some CNAs bound a flaw with the commit that fixed it — Wazuh writes + CVE-2026-67308 as "before 44bf114d2f49". Read as digits that becomes + (44, 114, 2, 49), which every 4.x install sits below, so it matched + everything. A hash is hex, seven characters or more, no separator; + versions that carry letters keep theirs, because "2.4.0p12" and + "5.0.0-beta1" both have separators and all-digit builds are versions. + """ + t = str(v or "").strip() + if not t: + return False + if "." in t or "-" in t or "_" in t or t.isdigit(): + return True + return not _HASH_RE.match(t) + + def _vtuple(v: str) -> Optional[tuple]: if not v: return None @@ -544,6 +565,11 @@ def _in_range(installed: str, m: dict) -> bool: """True if installed version satisfies one NVD cpeMatch entry.""" sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding") eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding") + # A commit hash bounds a source tree, not a release — see is_version. The + # cvelistV5 path dropped these already; this one did not, so the same CVE + # could still arrive through NVD. + if any(b is not None and not is_version(b) for b in (sI, sE, eI, eE)): + return False if _is_adobe_acrobat(m.get("criteria", "")): for b in (sI, sE, eI, eE): if b and not _same_version_scheme(installed, b): @@ -654,7 +680,9 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List # CVEs (tester: CVE-2026-48294, CVE-2024-39379, # CVE-2024-20721, CVE-2024-20709). tsws.add(parts[10] if len(parts) > 10 else "*") - fixed = fixed or m.get("versionEndExcluding") + end_excl = m.get("versionEndExcluding") + if end_excl and is_version(end_excl): + fixed = fixed or end_excl if not matched: continue cvss, sev = _nvd_cvss(cve_obj) diff --git a/app/services/cvelistv5_scan_service.py b/app/services/cvelistv5_scan_service.py index e4509e2..a6e9677 100644 --- a/app/services/cvelistv5_scan_service.py +++ b/app/services/cvelistv5_scan_service.py @@ -509,29 +509,11 @@ def _fix_build(lt): _BOUND_PROSE_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*(?:or later|and later|\+)\s*$", re.I) -_HASH_RE = re.compile(r"^[0-9a-f]{7,40}$", re.I) - - def _is_version(v) -> bool: - """False for a git commit hash used where a version belongs. - - CVE-2026-67308 is a shell-injection flaw in Wazuh's GitHub Actions - workflows — the CI, not the product — and the record bounds it with the - commit that fixed it: "before 44bf114d2f49". Read as digits that becomes - (44, 114, 2, 49), which any installed 4.x sits below, so it landed on every - Wazuh host as a critical finding about a vulnerability in a pull-request - workflow they do not run. - - A hash is hex, seven characters or more, and has no separator. Versions - that legitimately carry letters keep theirs — Checkmk's "2.4.0p12" and - "5.0.0-beta1" both have separators, so neither matches. - """ - s = str(v or "").strip() - if not s: - return False - if "." in s or "-" in s or "_" in s or s.isdigit(): - return True # digits alone are a build number (Teams' 25060212643) - return not _HASH_RE.match(s) + """Shared with the CPE scanner — see cpe.is_version. Kept as a name here + because both paths reject the same thing: a commit hash where a version + belongs (Wazuh bounds CVE-2026-67308 with "before 44bf114d2f49").""" + return cpe.is_version(v) def _clean_bound(v): diff --git a/tests/test_wazuh_components.py b/tests/test_wazuh_components.py index 6f42948..252f70f 100644 --- a/tests/test_wazuh_components.py +++ b/tests/test_wazuh_components.py @@ -181,6 +181,20 @@ def commit_hash_bounds(): assert not any(_affected("4.14.3", s, lt, lte) for s, lt, lte in rg) assert not any(_affected("3.8.0", s, lt, lte) for s, lt, lte in rg) + # The CPE path rejects the same bound — it did not at first, so the CVE + # could still arrive through NVD after cvelistV5 had stopped producing it. + from app.services.app_cve_scanner_service import _in_range, is_version + hash_bound = {"criteria": "cpe:2.3:a:wazuh:wazuh:*:*:*:*:*:*:*:*", + "versionEndExcluding": "44bf114d2f49"} + assert not _in_range("4.14.1", hash_bound) + real_bound = {"criteria": "cpe:2.3:a:wazuh:wazuh:*:*:*:*:*:*:*:*", + "versionStartIncluding": "4.0.0", "versionEndExcluding": "4.14.3"} + assert _in_range("4.14.1", real_bound) + assert not _in_range("4.14.3", real_bound) + # Both paths have to agree, or a CVE reappears through whichever is laxer. + for v in ("4.14.3", "44bf114d2f49", "2.4.0p12", "25060212643", "deadbeef"): + assert is_version(v) == _is_version(v), v + # And a hash never becomes the fix to upgrade to. assert _fix_build("44bf114d2f49") is None assert _fix_build("4.14.3") == "4.14.3"