Files
vulncheck/app/services/mozilla_advisory_service.py
T
vulncheck 0ba07131cb feat(enrich): Mozilla MFSA severity source for fresh Firefox CVEs
Mozilla publishes a per-CVE impact rating (critical/high/moderate/low)
in foundation-security-advisories before NVD/cvelistV5 have a score — the
gap on brand-new Firefox CVEs. New mozilla_advisory_service builds a
{cve: severity/title/fixed_in/esr_only} index from the MFSA repo (line
parser, no PyYAML dep; 24h cache; github_pat lifts the rate limit) and
apply_mozilla_severity fills the placeholder severity in enrich_vulnerabilities
when the vuln has no CVSS-derived value. Mozilla carries no numeric CVSS,
so it only sets severity/description, never clobbers a real score.

fixed_in / esr_only (e.g. only 'Firefox ESR 115.13' → regular Firefox
unaffected) is the authoritative ESR discriminator, stored for the
upcoming Firefox-scan ESR exclusion.
2026-07-23 13:57:57 +02:00

267 lines
10 KiB
Python

"""
Mozilla Foundation Security Advisories (MFSA) — per-CVE severity + fix train.
Source: github.com/mozilla/foundation-security-advisories (announce/YYYY/*.yml).
Each MFSA yml maps CVE → {impact, title} plus an advisory-level `fixed_in`
(e.g. ["Firefox 128", "Firefox ESR 115.13"]). Mozilla publishes an `impact`
rating (critical/high/moderate/low) but NO numeric CVSS — so this is a
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.
The parser is deliberately line-based (no PyYAML dependency): the MFSA schema is
regular — top-level `fixed_in:` list, then an `advisories:` map of
` CVE-…:` → ` impact:` / ` title:`.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
INDEX_SETTING = "mozilla_mfsa_index"
INDEX_TS_SETTING = "mozilla_mfsa_index_ts"
TTL_HOURS = 24
_API = "https://api.github.com/repos/mozilla/foundation-security-advisories"
_IMPACT_TO_SEV = {
"critical": "critical",
"high": "high",
"moderate": "medium",
"low": "low",
"none": "none",
}
_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*$")
def _gh_headers(db: Session) -> dict:
h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
try:
from app.auth.setting_crypto import read_setting_value
pat = (read_setting_value(db, "github_pat") or "").strip()
if pat:
h["Authorization"] = f"Bearer {pat}"
except Exception:
pass
return h
def _is_esr_only(fixed_in: List[str]) -> bool:
"""True when every Firefox entry is an ESR build — the advisory does not
affect regular Firefox. 'Firefox ESR 115.13' → ESR; 'Firefox 128' → regular.
Thunderbird/other entries are ignored for the Firefox decision."""
ff = [f for f in fixed_in if "firefox" in f.lower()]
if not ff:
return False
return all("esr" in f.lower() for f in ff)
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] = []
cves: Dict[str, dict] = {}
section = None # "fixed_in" | "advisories" | None
cur = None
for line in text.splitlines():
if line.startswith("fixed_in:"):
section = "fixed_in"
continue
if line.startswith("advisories:"):
section = "advisories"
cur = None
continue
# A non-indented, non-list line ends the current top-level block.
if line and not line[0].isspace() and not line.startswith("-"):
section = None
cur = None
if section == "fixed_in":
m = _LIST_ITEM.match(line)
if m:
fixed_in.append(m.group(1))
elif section == "advisories":
mc = _CVE_KEY.match(line)
if mc:
cur = mc.group(1).upper()
cves[cur] = {}
continue
if cur:
mi = _IMPACT.match(line)
if mi:
cves[cur]["impact"] = mi.group(1).lower()
continue
mt = _TITLE.match(line)
if mt and "title" not in cves[cur]:
cves[cur]["title"] = mt.group(1)
return fixed_in, cves
def build_index(db: Session, years: Optional[List[int]] = None) -> Dict[str, dict]:
"""Walk the MFSA repo for the given years, build {cve: {sev, title,
fixed_in, esr_only}}, cache it. Defaults to current + previous year (the
window where CVEs are fresh enough that NVD may still lag)."""
import httpx
if years is None:
y = datetime.now().year
years = [y, y - 1]
index: Dict[str, dict] = {}
headers = _gh_headers(db)
with httpx.Client(timeout=20.0, follow_redirects=True, headers=headers) as client:
for year in years:
try:
r = client.get(f"{_API}/contents/announce/{year}")
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
logger.warning("MFSA: GitHub rate limit hit — set github_pat for 5000/h")
break
if r.status_code != 200:
continue
files = [f for f in (r.json() or [])
if isinstance(f, dict) and str(f.get("name", "")).endswith(".yml")]
except Exception as e:
logger.debug("MFSA: listing %s failed: %s", year, e)
continue
for f in files:
url = f.get("download_url")
if not url:
continue
try:
rr = client.get(url)
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,
}
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)
return index
def _store(db: Session, index: Dict[str, dict]) -> None:
for key, val in ((INDEX_SETTING, json.dumps(index)),
(INDEX_TS_SETTING, datetime.now().isoformat())):
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = val
else:
db.add(Setting(key=key, value=val))
db.commit()
def load_index(db: Session) -> 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):
return None
return json.loads(row.value)
except (ValueError, json.JSONDecodeError):
return None
def get_index(db: Session) -> Dict[str, dict]:
"""Cached index, lazily (re)built when absent/stale. Build failure → {}."""
idx = load_index(db)
if idx is not None:
return idx
try:
return build_index(db)
except Exception as e:
logger.warning("MFSA index build failed: %s", e)
return {}
def apply_mozilla_severity(db: Session, cve_ids: List[str]) -> int:
"""Fill severity + description for Firefox CVEs from Mozilla's authoritative
impact rating. Only overrides severity when the vuln has NO CVSS-derived
value (cvss_score is None → severity is a default placeholder); Mozilla has
no CVSS number so it must not clobber a real score-derived severity."""
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity
wanted = [c.upper() for c in cve_ids if c]
if not wanted:
return 0
idx = get_index(db)
if not idx:
return 0
hits = [c for c in wanted if c in idx]
if not hits:
return 0
updated = 0
rows = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(hits)).all()
for v in rows:
data = idx.get((v.cve_id or "").upper())
if not data:
continue
sev = data.get("sev")
if sev and v.cvss_score is None:
new_sev = getattr(VulnerabilitySeverity, sev, None)
if new_sev is not None and v.severity != new_sev:
v.severity = new_sev
updated += 1
if not v.description and data.get("title"):
v.description = data["title"]
if updated:
db.commit()
return updated
if __name__ == "__main__":
# ponytail: one self-check for the line parser + ESR discriminator — the two
# non-trivial bits. Run: python -m app.services.mozilla_advisory_service
sample = """announced: July 9th, 2024
impact: high
fixed_in:
- Firefox 128
- Firefox ESR 115.13
title: Security Vulnerabilities fixed in Firefox 128
advisories:
CVE-2024-6601:
title: Race condition in permission assignment
impact: moderate
reporter: Andreas Farre
CVE-2024-6602:
title: Memory corruption in NSS
impact: critical
"""
fixed_in, cves = _parse_yml(sample)
assert fixed_in == ["Firefox 128", "Firefox ESR 115.13"], fixed_in
assert cves["CVE-2024-6601"] == {"title": "Race condition in permission assignment",
"impact": "moderate"}, cves["CVE-2024-6601"]
assert cves["CVE-2024-6602"]["impact"] == "critical"
assert _is_esr_only(["Firefox 128", "Firefox ESR 115.13"]) is False # has regular
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"
print("mozilla_advisory_service self-check OK")