fix(firefox): take the CVE list from Mozilla's MFSA, not cvelistV5 ranges
Of the 23 CVEs in mfsa2026-74 (announced 18.08.) TrueVuln had found two on 20.08. Not a cache problem — the yml was fetched 19.08. 15:39 — the MFSA index was only ever used to fill in severity and description, never to find a CVE. Detection came from cvelistV5, which states Firefox INVERSELY: no affected range, only one "unaffected" floor per maintained train (115.39 / 140.14 / 153.1 / 154). Nothing in the record marks which floor is the release train and which are ESR, so _ranges_from_affected only inverts the single-floor shape — anything else is skipped rather than flag a regular Firefox 121 for an ESR-only advisory. CVE-2026-74975 and -74989 have one floor; the other 21 have two to four and produced no range at all. Whether such a CVE was found then came down to NVD having published a CPE for it, which is why two assets on the same Firefox build reported different CVE sets. Mozilla's own advisory says it in one line — fixed_in: [Firefox 154] — so the MFSA index now decides the Firefox ranges and cvelistV5 keeps everything else: - mozilla_advisory_service: derive ff_fix (mainline, non-ESR/iOS/Thunderbird) and esr_fix per CVE; merge fixed_in across advisories instead of letting the first file listed win, so the regular-vs-ESR verdict no longer depends on alphabetical order; parse the indented " - Firefox 137.0.2" form (5 of 178 advisories, previously an empty fixed_in); never overwrite a good index with an empty one and serve it stale when a rebuild fails. - cvelistv5_scan_service: _merge_mozilla_mfsa replaces the Firefox entries for every CVE Mozilla has ruled on, drops the ESR/iOS/Thunderbird-only ones, and spares an install sitting at or above its own ESR train's fix. Index setting bumped to v28 so the cache rebuilds. Verified against all 178 announce/2025+2026 advisories and the live cvelistV5 records: all 23 mfsa2026-74 CVEs now resolve to "below 154", identically for 153.0.4 and 147.0.4. Regression test: tests/test_firefox_mfsa_source.py
This commit is contained in:
@@ -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_v27" # v27: ESXi + vCenter
|
||||
_INDEX_SETTING = "cvelistv5_product_index_v28" # v28: Firefox from MFSA
|
||||
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
|
||||
|
||||
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
|
||||
@@ -836,6 +836,31 @@ def _affected(installed: str, start: Optional[str], lt: Optional[str],
|
||||
return False
|
||||
|
||||
|
||||
def _esr_patched(installed: str, esr_fixes: List[str]) -> bool:
|
||||
"""True when the install sits on an ESR train at or above THAT train's fix.
|
||||
|
||||
An ESR build is below the mainline fix by construction — 140.14 < 154 — so
|
||||
the mainline bound alone would flag a fully patched ESR host with every CVE
|
||||
of every release since 140. The install is only spared when its own major
|
||||
train is one Mozilla named: 153.0.4 (mainline) against ESR 153.1 is still
|
||||
affected, 153.1.0 (ESR) is not.
|
||||
|
||||
resolve() already refuses installs whose NAME says ESR; this catches the
|
||||
ones where it does not (Intune / Defender report the app as plain
|
||||
"Firefox").
|
||||
"""
|
||||
it = cpe._vtuple(installed)
|
||||
if not it:
|
||||
return False
|
||||
for f in esr_fixes:
|
||||
ft = cpe._vtuple(f)
|
||||
if ft and it[0] == ft[0]:
|
||||
c = cpe._vcmp(installed, f)
|
||||
if c is not None and c >= 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------- index build + cache ----------
|
||||
def _ensure_zip(force: bool = False) -> bool:
|
||||
"""Make sure the shared cvelistV5 ZIP is on disk.
|
||||
@@ -892,6 +917,7 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
|
||||
if not _ensure_zip(force=force_fresh):
|
||||
return load_index(db) or {}
|
||||
index: Dict[str, list] = {}
|
||||
ff_meta: Dict[str, dict] = {}
|
||||
seen: set = set()
|
||||
scanned = 0
|
||||
parsed = 0
|
||||
@@ -935,6 +961,10 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
|
||||
continue
|
||||
plats = [str(p).lower().strip() for p in (aff.get("platforms") or [])]
|
||||
prod = (aff.get("product") or "").strip()
|
||||
if key == "firefox" and cve_id not in ff_meta:
|
||||
# Score + text for the MFSA merge below, taken while the
|
||||
# record is already open (Mozilla's yml carries neither).
|
||||
ff_meta[cve_id] = {"cvss": cvss, "sev": sev, "desc": desc}
|
||||
if key == "sap":
|
||||
for _v in (aff.get("versions") or []):
|
||||
if not isinstance(_v, dict):
|
||||
@@ -994,12 +1024,75 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
|
||||
{"cve": cve_id, "start": start, "lt": lt, "lte": lte,
|
||||
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod,
|
||||
"desc": desc})
|
||||
_merge_mozilla_mfsa(db, index, ff_meta)
|
||||
_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()))
|
||||
return index
|
||||
|
||||
|
||||
def _merge_mozilla_mfsa(db: Session, index: Dict[str, list],
|
||||
ff_meta: Dict[str, dict]) -> None:
|
||||
"""Let Mozilla's own MFSA advisories decide the Firefox ranges.
|
||||
|
||||
cvelistV5 states Firefox INVERSELY — no affected range at all, only a list
|
||||
of "unaffected" floors, one per maintained train. mfsa2026-74 lands as
|
||||
|
||||
unaffected 115.39 lte 115.* ESR 115
|
||||
unaffected 140.14 lte 140.* ESR 140
|
||||
unaffected 153.1 lte 153.* ESR 153
|
||||
unaffected 154 lte * release
|
||||
|
||||
and _ranges_from_affected can only invert that when there is exactly ONE
|
||||
floor, because nothing in the record says which of the four is the release
|
||||
train and which are ESR (read naively, "below 140.14" flags a regular
|
||||
Firefox 121 for an ESR-only advisory). So every multi-train record — 21 of
|
||||
the 23 CVEs in mfsa2026-74 — produced no range and was never indexed. The
|
||||
two that were are exactly the two the tester saw arrive (CVE-2026-74975,
|
||||
-74989); the rest depended on NVD publishing a CPE, which is why coverage
|
||||
looked random from one CVE to the next.
|
||||
|
||||
The MFSA yml answers it outright: one advisory, one `fixed_in` train.
|
||||
"Firefox 154" → every mainline Firefox below 154 is affected by every CVE
|
||||
the advisory lists. "Firefox ESR 140.14" / "Firefox for iOS 152.4" /
|
||||
"Thunderbird 154" → regular desktop Firefox is not affected at all, so the
|
||||
CVE is REMOVED from the Firefox index (this also retires the false-positive
|
||||
class the single-floor inversion could produce for ESR-only advisories).
|
||||
|
||||
Mozilla wins for every CVE it has ruled on; anything it has not (older CVEs,
|
||||
other CNAs) keeps whatever cvelistV5 derived. No MFSA index (offline, rate
|
||||
limited) → the index is left exactly as it was.
|
||||
"""
|
||||
try:
|
||||
from app.services import mozilla_advisory_service as mfsa
|
||||
moz = mfsa.get_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistv5-scan: MFSA merge skipped (%s) — Firefox falls "
|
||||
"back to cvelistV5 ranges", e)
|
||||
return
|
||||
if not moz:
|
||||
logger.warning("cvelistv5-scan: MFSA index empty — Firefox falls back to "
|
||||
"cvelistV5 ranges")
|
||||
return
|
||||
before = index.get("firefox") or []
|
||||
kept = [e for e in before if e.get("cve") not in moz]
|
||||
added = 0
|
||||
for cve_id, data in sorted(moz.items()):
|
||||
fix = data.get("ff_fix")
|
||||
if not fix:
|
||||
continue # ESR / iOS / Thunderbird only → not a desktop Firefox CVE
|
||||
meta = ff_meta.get(cve_id) or {}
|
||||
kept.append({"cve": cve_id, "start": None, "lt": fix, "lte": None,
|
||||
"plats": [], "cvss": meta.get("cvss"),
|
||||
"sev": meta.get("sev") or data.get("sev"),
|
||||
"prod": "Firefox", "esr": data.get("esr_fix") or [],
|
||||
"desc": meta.get("desc") or data.get("title")})
|
||||
added += 1
|
||||
index["firefox"] = kept
|
||||
logger.info("cvelistv5-scan: MFSA decided %d Firefox CVEs (was %d cvelistV5 "
|
||||
"ranges, %d kept as-is)", added, len(before), len(kept) - added)
|
||||
|
||||
|
||||
def _store_index(db: Session, index: dict) -> None:
|
||||
from app.models.setting import Setting
|
||||
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
|
||||
@@ -1613,6 +1706,8 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
|
||||
if not _affected(cver, entry.get("start"), entry.get("lt"),
|
||||
entry.get("lte"), scheme_strict=strict):
|
||||
continue
|
||||
if entry.get("esr") and _esr_patched(cver, entry["esr"]):
|
||||
continue # patched on its own ESR train (Firefox only)
|
||||
if not _platform_ok(fam, entry.get("plats")):
|
||||
continue # CVE is for a different OS platform (e.g. Teams-for-Mac)
|
||||
# Only lessThan is a real fix target; lessThanOrEqual means that
|
||||
|
||||
@@ -9,9 +9,15 @@ SEVERITY + description source, not a CVSS source. It fills the gap where fresh
|
||||
Firefox CVEs have no score yet in NVD/cvelistV5.
|
||||
|
||||
`fixed_in` is the AUTHORITATIVE regular-vs-ESR discriminator (an advisory whose
|
||||
fixed_in lists only "Firefox ESR …" does not affect regular Firefox) — stored
|
||||
here for the Firefox-scan ESR exclusion, cf. [[ghsa-unreviewed-no-version-range]]
|
||||
sibling reference on why cvelistV5 alone can't tell them apart.
|
||||
fixed_in lists only "Firefox ESR …" does not affect regular Firefox), and since
|
||||
20.08.2026 it is also the PRIMARY Firefox detection source: cvelistv5_scan_
|
||||
service asks this index for the mainline fix version of every Firefox CVE and
|
||||
indexes "below that → affected". cvelistV5 states Firefox inversely, one
|
||||
"unaffected" floor per maintained train, and cannot say which floor is the
|
||||
release train — so 21 of the 23 CVEs in mfsa2026-74 produced no range at all
|
||||
and were never found. See _merge_mozilla_mfsa there, and
|
||||
[[ghsa-unreviewed-no-version-range]] on why cvelistV5 alone can't tell the
|
||||
trains apart.
|
||||
|
||||
The parser is deliberately line-based (no PyYAML dependency): the MFSA schema is
|
||||
regular — top-level `fixed_in:` list, then an `advisories:` map of
|
||||
@@ -45,7 +51,11 @@ _IMPACT_TO_SEV = {
|
||||
_CVE_KEY = re.compile(r"^ (CVE-\d{4}-\d+):\s*$")
|
||||
_IMPACT = re.compile(r"^ impact:\s*([A-Za-z]+)")
|
||||
_TITLE = re.compile(r"^ title:\s*(.+?)\s*$")
|
||||
_LIST_ITEM = re.compile(r"^-\s*(.+?)\s*$")
|
||||
# Mozilla writes the fixed_in list both flush-left and indented two spaces
|
||||
# (" - Firefox 137.0.2"); 5 of the 178 2025/2026 advisories use the indented
|
||||
# form. Anchoring at column 0 silently produced an EMPTY fixed_in for those —
|
||||
# no train, so no regular-vs-ESR verdict and no Firefox fix version at all.
|
||||
_LIST_ITEM = re.compile(r"^\s*-\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def _gh_headers(db: Session) -> dict:
|
||||
@@ -70,6 +80,42 @@ def _is_esr_only(fixed_in: List[str]) -> bool:
|
||||
return all("esr" in f.lower() for f in ff)
|
||||
|
||||
|
||||
# "Firefox 154", "Firefox 153.0.3" → mainline. Deliberately anchored so the
|
||||
# other three shapes Mozilla writes into `fixed_in` are NOT mainline desktop:
|
||||
# "Firefox ESR 140.14" (own train, own fix), "Firefox for iOS 152.4" (WebKit —
|
||||
# different codebase, different CVEs) and "Thunderbird 154" (different product).
|
||||
# Verified against all 178 announce/2025+2026 advisories: those four forms plus
|
||||
# "Focus/Klar for iOS" are the only ones that occur, one entry per advisory.
|
||||
_FF_MAINLINE = re.compile(r"^firefox\s+(\d+(?:\.\d+)*)$", re.I)
|
||||
|
||||
|
||||
_FF_ESR = re.compile(r"^firefox\s+esr\s+(\d+(?:\.\d+)*)$", re.I)
|
||||
|
||||
|
||||
def _esr_firefox_fixes(fixed_in: List[str]) -> List[str]:
|
||||
"""The ESR builds that also carry the fix, e.g. ["115.39", "140.14",
|
||||
"153.1"]. Used to spare an ESR install the mainline verdict: 140.14 is
|
||||
below the mainline fix 154 but is not vulnerable — see _esr_patched in the
|
||||
scanner. Needed because an inventory does not always spell out "ESR"
|
||||
(Intune/Defender report the app as plain "Firefox")."""
|
||||
return [m.group(1) for m in
|
||||
(_FF_ESR.match((f or "").strip()) for f in fixed_in or []) if m]
|
||||
|
||||
|
||||
def _mainline_firefox_fix(fixed_in: List[str]) -> Optional[str]:
|
||||
"""The mainline (non-ESR, non-iOS) Firefox release that carries the fix, or
|
||||
None when the advisory only ships an ESR / Thunderbird / iOS build — in
|
||||
which case regular desktop Firefox is not affected at all.
|
||||
|
||||
Highest wins: a CVE listed in two mainline advisories is only fixed once
|
||||
the later one is installed."""
|
||||
vs = [m.group(1) for m in
|
||||
(_FF_MAINLINE.match((f or "").strip()) for f in fixed_in or []) if m]
|
||||
if not vs:
|
||||
return None
|
||||
return max(vs, key=lambda v: tuple(int(x) for x in v.split(".")))
|
||||
|
||||
|
||||
def _parse_yml(text: str) -> Tuple[List[str], Dict[str, dict]]:
|
||||
"""Line-parse one MFSA yml → (fixed_in list, {cve: {impact, title}})."""
|
||||
fixed_in: List[str] = []
|
||||
@@ -144,23 +190,41 @@ def build_index(db: Session, years: Optional[List[int]] = None) -> Dict[str, dic
|
||||
if rr.status_code != 200:
|
||||
continue
|
||||
fixed_in, cves = _parse_yml(rr.text)
|
||||
esr_only = _is_esr_only(fixed_in)
|
||||
for cve_id, data in cves.items():
|
||||
sev = _IMPACT_TO_SEV.get(data.get("impact") or "")
|
||||
# First writer wins per CVE (a CVE can appear in several
|
||||
# MFSAs for different products; the Firefox one is fine).
|
||||
if cve_id not in index:
|
||||
index[cve_id] = {
|
||||
"sev": sev,
|
||||
"title": data.get("title"),
|
||||
"fixed_in": fixed_in,
|
||||
"esr_only": esr_only,
|
||||
}
|
||||
# MERGE across advisories, don't let the first writer
|
||||
# win. One flaw is announced once per product train —
|
||||
# CVE-2026-74934 is in mfsa2026-74 (Firefox 154) AND
|
||||
# mfsa2026-75 (Firefox ESR 115.39). Keeping only the
|
||||
# first made the regular-vs-ESR verdict depend on the
|
||||
# alphabetical order of the file listing, so a CVE that
|
||||
# happened to be seen in its ESR advisory first looked
|
||||
# ESR-only and was dropped from the Firefox scan.
|
||||
e = index.setdefault(cve_id, {"sev": None, "title": None,
|
||||
"fixed_in": []})
|
||||
if e["sev"] is None:
|
||||
e["sev"] = sev
|
||||
if not e["title"]:
|
||||
e["title"] = data.get("title")
|
||||
for fx in fixed_in:
|
||||
if fx not in e["fixed_in"]:
|
||||
e["fixed_in"].append(fx)
|
||||
except Exception as e:
|
||||
logger.debug("MFSA: parse %s failed: %s", f.get("name"), e)
|
||||
|
||||
_store(db, index)
|
||||
logger.info("MFSA index built: %d CVEs across years %s", len(index), years)
|
||||
for entry in index.values():
|
||||
entry["esr_only"] = _is_esr_only(entry["fixed_in"])
|
||||
entry["ff_fix"] = _mainline_firefox_fix(entry["fixed_in"])
|
||||
entry["esr_fix"] = _esr_firefox_fixes(entry["fixed_in"])
|
||||
|
||||
if index:
|
||||
_store(db, index)
|
||||
logger.info("MFSA index built: %d CVEs across years %s", len(index), years)
|
||||
else:
|
||||
# A rate-limited or offline build must not overwrite a good index with
|
||||
# {} and then stamp it fresh for 24h — that blanks Firefox detection
|
||||
# for a whole day. Leave the cache alone; get_index serves it stale.
|
||||
logger.warning("MFSA index build returned nothing — keeping the cached index")
|
||||
return index
|
||||
|
||||
|
||||
@@ -175,13 +239,14 @@ def _store(db: Session, index: Dict[str, dict]) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def load_index(db: Session) -> Optional[Dict[str, dict]]:
|
||||
def load_index(db: Session, allow_stale: bool = False) -> Optional[Dict[str, dict]]:
|
||||
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
|
||||
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
|
||||
if not ts or not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
|
||||
if (not allow_stale
|
||||
and datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS)):
|
||||
return None
|
||||
return json.loads(row.value)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
@@ -189,15 +254,21 @@ def load_index(db: Session) -> Optional[Dict[str, dict]]:
|
||||
|
||||
|
||||
def get_index(db: Session) -> Dict[str, dict]:
|
||||
"""Cached index, lazily (re)built when absent/stale. Build failure → {}."""
|
||||
"""Cached index (TTL 24h), lazily (re)built when absent/stale.
|
||||
|
||||
A failed rebuild falls back to the STALE cache rather than to {}: yesterday's
|
||||
Firefox advisories are a far better answer than none, and {} silently turns
|
||||
the Firefox scan off."""
|
||||
idx = load_index(db)
|
||||
if idx is not None:
|
||||
return idx
|
||||
try:
|
||||
return build_index(db)
|
||||
built = build_index(db)
|
||||
if built:
|
||||
return built
|
||||
except Exception as e:
|
||||
logger.warning("MFSA index build failed: %s", e)
|
||||
return {}
|
||||
return load_index(db, allow_stale=True) or {}
|
||||
|
||||
|
||||
def apply_mozilla_severity(db: Session, cve_ids: List[str]) -> int:
|
||||
@@ -270,4 +341,17 @@ advisories:
|
||||
assert _is_esr_only(["Firefox ESR 115.13"]) is True # ESR only
|
||||
assert _is_esr_only(["Thunderbird 128"]) is False # no firefox
|
||||
assert _IMPACT_TO_SEV["moderate"] == "medium"
|
||||
# The mainline-vs-ESR verdict the Firefox scan hangs on.
|
||||
assert _mainline_firefox_fix(["Firefox 154"]) == "154"
|
||||
assert _mainline_firefox_fix(["Firefox 153.0.3"]) == "153.0.3"
|
||||
assert _mainline_firefox_fix(["Firefox ESR 140.14"]) is None # ESR train
|
||||
assert _mainline_firefox_fix(["Firefox for iOS 152.4"]) is None # WebKit
|
||||
assert _mainline_firefox_fix(["Thunderbird 154"]) is None # other product
|
||||
# Merged across advisories: the mainline fix must survive the ESR entry.
|
||||
assert _mainline_firefox_fix(["Firefox ESR 115.39", "Firefox 154"]) == "154"
|
||||
assert _mainline_firefox_fix(["Firefox 153", "Firefox 154"]) == "154" # highest wins
|
||||
assert _esr_firefox_fixes(["Firefox 154", "Firefox ESR 140.14",
|
||||
"Firefox ESR 153.1", "Thunderbird 154"]) == ["140.14", "153.1"]
|
||||
# Indented fixed_in list — 5 of the 178 2025/2026 advisories write it this way.
|
||||
assert _parse_yml("fixed_in:\n - Firefox 137.0.2\ntitle: x\n")[0] == ["Firefox 137.0.2"]
|
||||
print("mozilla_advisory_service self-check OK")
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Firefox CVEs come from Mozilla's MFSA — run: python tests/test_firefox_mfsa_source.py
|
||||
|
||||
Why this exists (tester, 20.08.2026): of the 23 CVEs in mfsa2026-74 (announced
|
||||
18.08.), TrueVuln had found two. Not a cache problem — the MFSA yml was fetched
|
||||
on 19.08. 15:39 — but a parsing one, in the OTHER source:
|
||||
|
||||
cvelistV5 states Firefox inversely. There is no affected range at all, only a
|
||||
list of "unaffected" floors, one per maintained train:
|
||||
|
||||
CVE-2026-74990 115.39 lte 115.* | 140.14 lte 140.* | 153.1 lte 153.* | 154 lte *
|
||||
CVE-2026-74989 154 lte *
|
||||
|
||||
Nothing in the record marks which floor is the release train and which are ESR,
|
||||
so `_ranges_from_affected` only inverts the single-floor shape — anything else
|
||||
is skipped rather than risk flagging a regular Firefox 121 for an ESR-only
|
||||
advisory. CVE-2026-74975 and -74989 have one floor; the other 21 have two to
|
||||
four and produced no range at all. Whether such a CVE was found at all then
|
||||
came down to NVD having published a CPE for it, which is why two assets on the
|
||||
SAME Firefox build reported different CVE sets.
|
||||
|
||||
Mozilla's own advisory says it in one line — `fixed_in: [Firefox 154]` — so the
|
||||
MFSA index now decides the Firefox ranges, and cvelistV5 keeps everything else.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.services import mozilla_advisory_service as mfsa
|
||||
from app.services import cvelistv5_scan_service as c5
|
||||
|
||||
# ---- verbatim from cvelistV5 (fetched 21.08.2026) --------------------------
|
||||
MULTI_TRAIN = { # CVE-2026-74990 and 20 siblings in mfsa2026-74
|
||||
"vendor": "Mozilla", "product": "Firefox", "versions": [
|
||||
{"status": "unaffected", "version": "115.39", "lessThanOrEqual": "115.*"},
|
||||
{"status": "unaffected", "version": "140.14", "lessThanOrEqual": "140.*"},
|
||||
{"status": "unaffected", "version": "153.1", "lessThanOrEqual": "153.*"},
|
||||
{"status": "unaffected", "version": "154", "lessThanOrEqual": "*"}]}
|
||||
SINGLE_TRAIN = { # CVE-2026-74989 — one of the two that DID arrive
|
||||
"vendor": "Mozilla", "product": "Firefox", "versions": [
|
||||
{"status": "unaffected", "version": "154", "lessThanOrEqual": "*"}]}
|
||||
ESR_ONLY = { # CVE-2026-16361, mfsa2026-70 (Firefox ESR 140.13) — no mainline fix
|
||||
"vendor": "Mozilla", "product": "Firefox", "versions": [
|
||||
{"status": "unaffected", "version": "115.38", "lessThanOrEqual": "115.*"},
|
||||
{"status": "unaffected", "version": "140.13", "lessThanOrEqual": "*"}]}
|
||||
|
||||
# ---- verbatim from the MFSA ymls ------------------------------------------
|
||||
MFSA_INDEX = {
|
||||
# mfsa2026-74 "Firefox 154" + mfsa2026-75 "Firefox ESR 115.39" (merged)
|
||||
"CVE-2026-74990": {"sev": "high", "title": "Memory safety bugs",
|
||||
"fixed_in": ["Firefox 154", "Firefox ESR 115.39"],
|
||||
"ff_fix": "154"},
|
||||
"CVE-2026-74989": {"sev": "medium", "title": "Memory safety bugs",
|
||||
"fixed_in": ["Firefox 154"], "ff_fix": "154"},
|
||||
# mfsa2026-70 — ESR only, so regular Firefox is not affected
|
||||
"CVE-2026-16361": {"sev": "critical", "title": "Invalid pointer",
|
||||
"fixed_in": ["Firefox ESR 140.13"], "ff_fix": None},
|
||||
}
|
||||
|
||||
|
||||
def _hits(aff, version):
|
||||
return any(c5._affected(version, s, lt, lte)
|
||||
for s, lt, lte in c5._ranges_from_affected(aff))
|
||||
|
||||
|
||||
def _index_after_merge(monkeypatched_moz, firefox_entries):
|
||||
"""Run the merge with a stubbed MFSA index (no network, no DB)."""
|
||||
real = mfsa.get_index
|
||||
mfsa.get_index = lambda db: monkeypatched_moz
|
||||
try:
|
||||
index = {"firefox": list(firefox_entries)}
|
||||
c5._merge_mozilla_mfsa(None, index, {})
|
||||
return index["firefox"]
|
||||
finally:
|
||||
mfsa.get_index = real
|
||||
|
||||
|
||||
def demo():
|
||||
# --- the cause, still true: cvelistV5 alone cannot bound a multi-train record
|
||||
assert c5._ranges_from_affected(MULTI_TRAIN) == [], "must stay unbounded"
|
||||
assert c5._ranges_from_affected(ESR_ONLY) == []
|
||||
assert c5._ranges_from_affected(SINGLE_TRAIN) == [(None, "154", None)]
|
||||
assert _hits(SINGLE_TRAIN, "153.0.4") and _hits(SINGLE_TRAIN, "147.0.4")
|
||||
|
||||
# --- mainline fix extraction, the whole regular-vs-ESR decision
|
||||
assert mfsa._mainline_firefox_fix(["Firefox 154"]) == "154"
|
||||
assert mfsa._mainline_firefox_fix(["Firefox ESR 140.13"]) is None
|
||||
assert mfsa._mainline_firefox_fix(["Firefox for iOS 152.4"]) is None
|
||||
assert mfsa._mainline_firefox_fix(["Thunderbird 154"]) is None
|
||||
|
||||
# --- after the merge: every mfsa2026-74 CVE is indexed with lt=154
|
||||
entries = _index_after_merge(MFSA_INDEX,
|
||||
[{"cve": "CVE-2026-74989", "start": None,
|
||||
"lt": "154", "lte": None, "plats": []}])
|
||||
by_cve = {e["cve"]: e for e in entries}
|
||||
assert set(by_cve) == {"CVE-2026-74990", "CVE-2026-74989"}, by_cve
|
||||
for cve in ("CVE-2026-74990", "CVE-2026-74989"):
|
||||
assert by_cve[cve]["lt"] == "154"
|
||||
# Same verdict on both installed builds the tester compared — this is
|
||||
# what makes two assets on one version report one identical CVE set.
|
||||
for ver in ("153.0.4", "147.0.4"):
|
||||
assert c5._affected(ver, None, "154", None), (cve, ver)
|
||||
# …and a patched build is not flagged.
|
||||
assert not c5._affected("154.0", None, "154", None)
|
||||
assert not c5._affected("154.0.1", None, "154", None)
|
||||
|
||||
# --- an ESR build is not flagged for a mainline fix it already carries
|
||||
assert c5._esr_patched("140.14.0", ["115.39", "140.14", "153.1"]) # ESR, patched
|
||||
assert c5._esr_patched("153.1.0", ["153.1"]) # ESR, patched
|
||||
assert not c5._esr_patched("153.0.4", ["153.1"]) # mainline 153 → affected
|
||||
assert not c5._esr_patched("140.0.4", ["140.14"]) # old 140 → affected
|
||||
assert not c5._esr_patched("147.0.4", ["115.39", "140.14", "153.1"]) # no ESR train
|
||||
|
||||
# --- ESR-only CVE is REMOVED, even if cvelistV5 had guessed a range for it
|
||||
stale = [{"cve": "CVE-2026-16361", "start": None, "lt": "140.13",
|
||||
"lte": None, "plats": []}]
|
||||
after = {e["cve"] for e in _index_after_merge(MFSA_INDEX, stale)}
|
||||
assert "CVE-2026-16361" not in after, "ESR-only CVE must not reach desktop Firefox"
|
||||
|
||||
# --- a CVE Mozilla has NOT ruled on keeps its cvelistV5 range untouched
|
||||
older = [{"cve": "CVE-2019-11708", "start": None, "lt": "67.0.4",
|
||||
"lte": None, "plats": []}]
|
||||
assert _index_after_merge(MFSA_INDEX, older)[0]["cve"] == "CVE-2019-11708"
|
||||
|
||||
# --- no MFSA index (offline / rate limited) → nothing is touched
|
||||
assert _index_after_merge({}, older) == older
|
||||
|
||||
print("firefox MFSA source self-check OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo()
|
||||
Reference in New Issue
Block a user