feat(scan): report a fix floor when the advisory names no patched build

Adobe writes CVE-2026-48294 as "lessThanOrEqual 26.5.2.2", so no fixed build
exists to show and the finding read "not announced" — which hid something the
tester rightly pointed out twice: if 26.5.2.2 and older are affected, anything
above it is not. That is a valid statement, and an admin can act on "newer
than 26.5.2.2" even when the vendor named no build.

Stored as ">26.5.2.2" and rendered as "not announced — need newer than
26.5.2.2". The prefix carries both facts at once, which is why it is not
written as a bare version: fixed_version also drives the "patch available"
badge, and lessThanOrEqual means the CNA named no patched release, often
because none has shipped. has_fix reads the prefix and does not claim one
exists. A later record with a real lessThan replaces the floor.

No schema change: the existing column carries it, and nothing compares
fixed_version numerically (only equality, in has_fix).
This commit is contained in:
2026-08-10 14:02:24 +02:00
parent 153809a3f1
commit 2a831e4678
4 changed files with 74 additions and 2 deletions
+7
View File
@@ -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
+26 -2
View File
@@ -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,
@@ -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;
}
+34
View File
@@ -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()