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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user