fix(scan): reject commit-hash bounds on the CPE path too

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.
This commit is contained in:
2026-08-11 08:56:50 +02:00
parent eef8f5d2d0
commit 4927a1e2ac
3 changed files with 47 additions and 23 deletions
+29 -1
View File
@@ -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)
+4 -22
View File
@@ -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):
+14
View File
@@ -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"