diff --git a/app/models/vulnerability_package.py b/app/models/vulnerability_package.py index 782a47c..f5289f2 100644 --- a/app/models/vulnerability_package.py +++ b/app/models/vulnerability_package.py @@ -81,6 +81,13 @@ class VulnerabilityPackage(Base, TimestampMixin): is the affected version itself.""" if not self.fixed_version: return False + # ">26.5.2.2" is a floor, not a build: it comes from a lessThanOrEqual + # bound, which says "this and older are affected" and names no patched + # release — often because none has shipped. The value is worth showing + # (an admin can act on "newer than 26.5.2.2"), but claiming a patch is + # available would send them looking for something that may not exist. + if self.fixed_version.startswith(">"): + return False if not self.package_version: return True # fix known, install unknown — best to show "patch available" return self.fixed_version != self.package_version diff --git a/app/services/cvelistv5_scan_service.py b/app/services/cvelistv5_scan_service.py index 4126923..6141bd9 100644 --- a/app/services/cvelistv5_scan_service.py +++ b/app/services/cvelistv5_scan_service.py @@ -446,7 +446,30 @@ def _wildcard_floor(raw: str) -> Optional[str]: # iOS bounds — same shape — so nothing genuine is lost. _STRICT_SCHEME_KEYS = {"adobe-acrobat", "teams"} -def _fix_target(lt): +def _fix_target(lt, lte=None): + """The fix to show. A lessThan bound names a real build; a lessThanOrEqual + only bounds the damage, so it is reported as a floor: ">26.5.2.2". + + The tester asked for this twice and he is right: if "26.5.2.2 and earlier" + are affected, then anything above it is not, and that is a valid, useful + statement — an admin can act on "newer than 26.5.2.2" even when the vendor + named no build. Adobe writes CVE-2026-48294 exactly that way. + + It is kept out of the plain fixed_version because that field also drives + the "patch available" badge, and lessThanOrEqual means the CNA named no + patched build — often because none had shipped yet. The ">" prefix carries + both facts: the actionable floor is visible, and has_fix (see + VulnerabilityPackage) reads the prefix and does not claim a patch exists. + A later record with a real lessThan replaces it. + """ + real = _fix_build(lt) + if real: + return real + floor = _fix_build(lte) + return f">{floor}" if floor else None + + +def _fix_build(lt): """A lessThan bound only becomes a fixed_version if it names a real build. CVE-2026-21710 lists Node 20.20.1, 22.22.1, 24.14.0 and 25.8.1 as concrete @@ -1276,7 +1299,8 @@ def scan_asset(db: Session, asset, packages: list, index: dict, # Only lessThan is a real fix target; lessThanOrEqual means that # version is still affected (no published fix) → leave fixed empty. c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"), - "fixed": _fix_target(entry.get("lt")), "desc": entry.get("desc")} + "fixed": _fix_target(entry.get("lt"), entry.get("lte")), + "desc": entry.get("desc")} try: before = len(new_ids) cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched, diff --git a/frontend/app/vulnerabilities/[id]/page.tsx b/frontend/app/vulnerabilities/[id]/page.tsx index 8656687..70ac5b8 100644 --- a/frontend/app/vulnerabilities/[id]/page.tsx +++ b/frontend/app/vulnerabilities/[id]/page.tsx @@ -31,6 +31,13 @@ function formatFixedVersion(v?: string | null): string { if (/^[0-9a-f]{12,64}$/i.test(s) && !/[.:~_+]/.test(s)) { return `upstream commit ${s.slice(0, 12)}`; } + // ">26.5.2.2" is a floor, not a build. The advisory bounded the damage + // ("this version and older are affected") without naming a patched + // release, so say what can actually be acted on instead of printing a + // version number nobody can look up. + if (s.startsWith('>')) { + return `not announced — need newer than ${s.slice(1).trim()}`; + } return s; } diff --git a/tests/test_rvtools_veeam.py b/tests/test_rvtools_veeam.py index ccf613c..b2ad247 100644 --- a/tests/test_rvtools_veeam.py +++ b/tests/test_rvtools_veeam.py @@ -70,5 +70,39 @@ def demo(): print("ok RVTools and Veeam ONE (server only) resolve and bound correctly") +def fix_floor(): + """lessThanOrEqual names no patched build — report it as a floor. + + Adobe writes CVE-2026-48294 as "lessThanOrEqual 26.5.2.2", so no build is + named. "not announced" alone hid an actionable fact: anything above + 26.5.2.2 is not affected. It is shown as ">26.5.2.2" without claiming a + patch exists. + """ + from app.services.cvelistv5_scan_service import _fix_target, _fix_build + from app.models.vulnerability_package import VulnerabilityPackage + + # A real lessThan still wins and is unchanged. + assert _fix_target("4.8.1", None) == "4.8.1" + assert _fix_target("13.1.0.7034", "13.0") == "13.1.0.7034" + # Only a lessThanOrEqual → floor. + assert _fix_target(None, "26.5.2.2") == ">26.5.2.2" + # Wildcards are not builds on either side (Node's "4.*"). + assert _fix_target("4.*", None) is None + assert _fix_target(None, "12.x.x") is None + assert _fix_build("4.*") is None + assert _fix_target(None, None) is None + + # The floor must NOT read as "patch available". + p = VulnerabilityPackage(package_name="Adobe Acrobat extension (Chrome)", + package_version="23.8.0.1", fixed_version=">26.5.2.2") + assert p.has_fix is False + real = VulnerabilityPackage(package_name="RVTools", package_version="4.7.1", + fixed_version="4.8.1") + assert real.has_fix is True + + print("ok lessThanOrEqual reported as a floor, without a patch claim") + + if __name__ == "__main__": demo() + fix_floor()