fix(scan): carry the CVE description, and keep iOS browsers out

App-scan findings showed only the title we generate — "Adobe Acrobat (64-bit)
26.001.21691 — CVE-2026-48294" — which says what MATCHED, not what the
vulnerability is. The CNA's own text was sitting unused in both sources. It is
now taken from the cvelistV5 record and from NVD's descriptions block,
whichever path found the CVE, and backfilled onto existing rows that have none
(fill-only, so a source with better text keeps the last word).

Second: iOS browser builds were resolving to the desktop CPEs.
org.mozilla.ios.Firefox matched mozilla:firefox, which is wrong for the same
reason iOS Chrome was already excluded — Apple mandates WebKit there, so the
Gecko and Blink CVEs behind those ranges do not apply to it. Android is
unaffected by this: org.mozilla.firefox and com.android.chrome share engine,
version numbers and fixes with desktop, and NVD keeps them under one CPE with
a neutral target_sw, so they stay mapped as before. Verified: no separate
firefox_for_android CPE exists (0 CVEs).

Firefox ESR keeps its existing exclusion — NVD tracks it as its own
firefox_esr CPE.

Index key bumped to v18; cached entries carry no description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:54:32 +02:00
co-authored by Claude Opus 5
parent c88eb45733
commit 3fd27d46e5
3 changed files with 91 additions and 10 deletions
+23 -2
View File
@@ -86,7 +86,13 @@ _REGISTRY: List[tuple] = [
# reported as bare "Chrome" — deliberately NOT matched (Blink CVEs N/A).
_cpe(r"google chrome|com\.android\.chrome", "google:chrome"),
_cpe(r"microsoft edge(?!.*webview)", "microsoft:edge_chromium"),
_cpe(r"mozilla firefox|(?<!\w)firefox", "mozilla:firefox"),
# Desktop + Android ("org.mozilla.firefox"), which share Gecko, version
# numbers and security fixes — NVD keeps them under one CPE with a neutral
# target_sw, so no separate entry is needed. iOS Firefox is EXCLUDED for
# the same reason as iOS Chrome: Apple mandates WebKit there, so the Gecko
# CVEs behind these ranges do not apply to it.
_cpe(r"(mozilla firefox|(?<!\w)firefox)(?!.*\bios\b)(?<!ios\.firefox)",
"mozilla:firefox"),
_cpe(r"thunderbird", "mozilla:thunderbird"),
# Adobe renamed these products and NVD kept BOTH CPE names in use, each
# carrying its own CVEs — the "_dc" suffix is the older spelling. Wazuh
@@ -525,8 +531,18 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
if not matched:
continue
cvss, sev = _nvd_cvss(cve_obj)
# NVD carries the same CNA text; without it a finding shows only the
# title we generated, which says what matched rather than what the
# flaw is.
desc = None
for d in cve_obj.get("descriptions") or []:
if (d or {}).get("lang", "en").lower().startswith("en"):
desc = ((d.get("value") or "").strip() or None)
if desc:
desc = desc[:2000]
break
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
"fixed": fixed, "tsw": sorted(tsws)})
"fixed": fixed, "tsw": sorted(tsws), "desc": desc})
return out
@@ -621,6 +637,10 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
.first())
if existing:
existing.add_source("app-scan")
# Backfill the flaw description onto rows created before we carried
# it — fill-only, so a source with a better text keeps the last word.
if c.get("desc") and not existing.description:
existing.description = c["desc"]
# Keep EVERY affected product visible: one finding is unique per
# (cve, asset), so a CVE hitting two products on the same host (Chrome
# AND Edge — different build schemes entirely) would otherwise show
@@ -658,6 +678,7 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
cvss_score=c.get("cvss"), severity=sev,
status=VulnerabilityStatus.open,
title=f"{pkg_name} {version}{cve_id}"[:500],
description=(c.get("desc") or None),
package_name=pkg_name[:255], package_version=version[:100],
package_vendor=(vendor[:255] if vendor else None),
fixed_version=(c.get("fixed") or None),
+28 -8
View File
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_INDEX_SETTING = "cvelistv5_product_index_v17" # v17: + autodesk family
_INDEX_SETTING = "cvelistv5_product_index_v18" # v18: + CVE descriptions
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
@@ -313,6 +313,22 @@ def resolve(name: str) -> Optional[str]:
# ---------- CVSS base score ----------
def _description_from_record(data: dict) -> Optional[str]:
"""The CNA's own English description of the flaw.
Findings showed only the generated title ("Adobe Acrobat (64-bit)
26.001.21691 — CVE-2026-48294"), which says what we matched, not what the
vulnerability IS. The text is right there in the record and answers the
first question anyone opening a finding has.
"""
for d in ((data.get("containers") or {}).get("cna") or {}).get("descriptions") or []:
if (d or {}).get("lang", "en").lower().startswith("en"):
v = (d.get("value") or "").strip()
if v:
return v[:2000]
return None
def _cvss_from_record(data: dict) -> Tuple[Optional[float], Optional[str]]:
"""Pull (baseScore, baseSeverity) from a CVE-5 record. Prefers CVSS 3.1 >
3.0 > 4.0, and the CNA's own metrics over the CISA-ADP block. Returns
@@ -565,6 +581,7 @@ def build_product_index(db: Session) -> dict:
if not cve_id.startswith("CVE-"):
continue
cvss, sev = _cvss_from_record(data)
desc = _description_from_record(data)
for aff in affected:
key = _pair_key(aff.get("vendor") or "", aff.get("product") or "")
if not key:
@@ -585,7 +602,8 @@ def build_product_index(db: Session) -> dict:
if _sig in seen:
continue
seen.add(_sig)
ent.update({"cve": cve_id, "prod": prod, "cvss": cvss, "sev": sev})
ent.update({"cve": cve_id, "prod": prod, "cvss": cvss, "sev": sev,
"desc": desc})
index.setdefault(key, []).append(ent)
continue
if key == "oracle-java":
@@ -609,7 +627,8 @@ def build_product_index(db: Session) -> dict:
index.setdefault(key, []).append(
{"cve": cve_id, "start": None, "lt": _v.get("lessThan"),
"lte": _v.get("lessThanOrEqual"), "ver": _raw,
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod})
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod,
"desc": desc})
continue
for start, lt, lte in _ranges_from_affected(aff):
sig = (key, cve_id, start, lt, lte, prod.lower())
@@ -618,7 +637,8 @@ def build_product_index(db: Session) -> dict:
seen.add(sig)
index.setdefault(key, []).append(
{"cve": cve_id, "start": start, "lt": lt, "lte": lte,
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod})
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod,
"desc": desc})
_store_index(db, index)
logger.info("cvelistv5-scan: index built (%d files scanned, %d parsed) → %d products, %d ranges",
scanned, parsed, len(index), sum(len(v) for v in index.values()))
@@ -962,7 +982,7 @@ def scan_asset_os_apple(db: Session, asset, index: dict,
if not _affected(cver, entry.get("start"), lt, entry.get("lte")):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": lt}
"severity": entry.get("sev"), "fixed": lt, "desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids,
@@ -1017,7 +1037,7 @@ def scan_asset_os(db: Session, asset, index: dict,
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
"fixed": entry.get("lt")}
"fixed": entry.get("lt"), "desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids, touched=touched)
@@ -1118,7 +1138,7 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
if not _java_is_affected(jver, entry):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": None}
"severity": entry.get("sev"), "fixed": None, "desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, f"{jver[0]}u{jver[1]}", c, new_ids,
@@ -1143,7 +1163,7 @@ 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": entry.get("lt")}
"fixed": entry.get("lt"), "desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
+40
View File
@@ -0,0 +1,40 @@
"""Mobile browser identity — run: python tests/test_mobile_browsers.py
Android browsers ship the same engine, version numbers and security fixes as
their desktop builds, so they resolve to the same CPE. iOS builds do NOT:
Apple mandates WebKit, so the Blink and Gecko CVEs behind these ranges do not
apply there at all.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.app_cve_scanner_service import resolve_product
def _key(name):
return (resolve_product(name) or {}).get("key")
def demo():
# Android package ids resolve to the desktop product.
assert _key("org.mozilla.firefox") == "cpe:mozilla:firefox"
assert _key("org.mozilla.firefox_beta") == "cpe:mozilla:firefox"
assert _key("com.android.chrome") == "cpe:google:chrome"
# iOS builds are WebKit-backed — neither engine's CVEs apply.
assert _key("org.mozilla.ios.Firefox") is None
assert _key("Firefox for iOS") is None
# Desktop unchanged.
assert _key("Mozilla Firefox") == "cpe:mozilla:firefox"
# ESR stays excluded on purpose: NVD tracks it as its own firefox_esr CPE,
# so the plain firefox ranges would flag CVEs that have no ESR fix.
assert _key("Mozilla Firefox ESR") is None
print("mobile browsers OK")
if __name__ == "__main__":
demo()