Files
vulncheck/app/services/android_cve_service.py
T
vulncheck dbad9a365e chore(rebrand): VulnCheck → TrueVuln
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).

Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).

Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
  README lines derived from it (git clone target dir, tree listing) — a real
  repo rename is a manual Gitea-side step (Settings → repository name) the
  user would need to do themselves, and existing clones would need
  `git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
  README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
  accounts that still exist under the old name; renaming the text alone
  wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
  vulncheck/...) — tied to GitNexus's own index name for this repo, not our
  branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
  (vulnmanager-*) — explicit user decision: infra naming carries real
  deploy/data risk on an already-running instance and isn't part of the
  product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
  — invisible internal CSS variable name, renaming it would touch ~270
  className occurrences for zero user-visible benefit.

Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
2026-07-07 16:34:40 +02:00

266 lines
10 KiB
Python

"""
Android per-CVE detection from the Google Android Security Bulletin (ASB).
For an Intune-managed Android device we know its security patch level
(androidSecurityPatchLevel, e.g. "2025-03-01"). Every monthly ASB published
AFTER that level lists CVEs the device has NOT yet received. We fetch those
months from source.android.com (stable, static, per-month URLs), extract the
CVEs + severity, and raise real-CVE findings (source 'android-asb').
Why ASB and not Samsung's SMR page: Samsung's securityUpdate.smsb ignores the
year/month query param and loads the month via JS, so a plain fetch can't get
a historical month. ASB is the upstream source for the Google CVEs Samsung
ships (the security-critical bulk) and is cleanly scrapeable. Samsung-
proprietary SVE CVEs are not covered (their page is unscrapeable).
Scope guards (volume): Critical + High only, last _MAX_MONTHS months.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, datetime
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_ASB_BASE = "https://source.android.com/docs/security/bulletin"
def _asb_urls(month: str) -> List[str]:
"""Candidate ASB URLs for a month slug. From 2026 Google nests the page
under a year segment (/bulletin/2026/2026-01-01); older months are flat
(/bulletin/2025-10-01). Try the year-nested form first, then flat, so we
survive whichever format applies (and future shifts)."""
year = month[:4]
return [f"{_ASB_BASE}/{year}/{month}", f"{_ASB_BASE}/{month}"]
_CACHE_PREFIX = "asb_month_v2_" # v2: bumped when the URL/section-filter
# logic changed, so stale cache entries
# from before those fixes are ignored
# and every month is refetched fresh.
_MAX_MONTHS = 12 # cap lookback so a very stale device can't flood
_WANT_SEV = {"critical", "high"} # actionable severities only
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
_SEV_RE = re.compile(r">(Critical|High|Moderate|Low)<")
# Section headers + rows, in document order, so each CVE is scoped to its
# ASB section.
_TOKEN_RE = re.compile(r"<h[23][^>]*>(.*?)</h[23]>|<tr[^>]*>(.*?)</tr>", re.S)
# SoC / third-party vendor sections — those CVEs only affect devices with that
# chipset (Qualcomm/MediaTek/etc.), so importing them onto every Android device
# produces false positives (Samsung's own SMR lists many as "Not applicable").
# We keep only the AOSP sections (Framework/System/Kernel/Runtime/Media/Play/
# Widevine) that apply to any Android device at that patch level.
_SOC_SECTION = re.compile(
r"qualcomm|mediatek|unisoc|spreadtrum|imagination|arm component|"
r"broadcom|nvidia|marvell|kryo|adreno", re.I)
def _parse_patch_month(raw) -> Optional[Tuple[int, int]]:
"""androidSecurityPatchLevel 'YYYY-MM-DD' → (year, month)."""
m = re.match(r"(\d{4})-(\d{2})", str(raw or ""))
if not m:
return None
return int(m.group(1)), int(m.group(2))
def _months_after(year: int, month: int, today: date) -> List[str]:
"""ASB month slugs ('YYYY-MM-01') strictly after (year,month) up to today,
newest first, capped at _MAX_MONTHS."""
out = []
y, mo = year, month
while True:
mo += 1
if mo > 12:
mo = 1
y += 1
if (y, mo) > (today.year, today.month):
break
out.append(f"{y:04d}-{mo:02d}-01")
return out[-_MAX_MONTHS:][::-1]
def _parse_asb_html(html: str) -> List[Tuple[str, str]]:
"""→ [(cve, severity)] from one ASB page, walking section headers + rows in
order. CVEs under SoC/vendor sections (chipset-specific) are skipped so we
don't false-positive them onto devices with a different SoC. Severity
carries forward across rowspan rows (Android merges the severity cell)."""
out: List[Tuple[str, str]] = []
seen: set = set()
last_sev = "High"
skip = False
for m in _TOKEN_RE.finditer(html):
if m.group(1) is not None: # section header
last_sev = "High"
skip = bool(_SOC_SECTION.search(re.sub(r"<[^>]+>", "", m.group(1))))
continue
cell = m.group(2)
cm = _CVE_RE.search(cell)
if not cm:
continue
sm = _SEV_RE.search(cell)
if sm:
last_sev = sm.group(1)
if skip:
continue # SoC/vendor section → chipset-specific, not universal
cve = cm.group(0).upper()
if cve in seen:
continue
seen.add(cve)
out.append((cve, last_sev.lower()))
return out
# A month with CVEs is immutable → cache forever. An empty/404 month (a
# future month Google hasn't published yet) is negatively cached for this long
# so we don't re-fetch it on every device sync, but still pick it up once it
# goes live.
_NEG_TTL_DAYS = 3
def _cache_read(db: Session, key: str):
"""→ (pairs, is_fresh). pairs may be []; is_fresh False means refetch."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == key).first()
if not row or not row.value:
return None, False
try:
blob = json.loads(row.value)
except Exception:
return None, False
if isinstance(blob, list): # legacy positive entry
return [tuple(x) for x in blob], True
pairs = [tuple(x) for x in (blob.get("pairs") or [])]
if pairs:
return pairs, True # non-empty is immutable
try:
ts = datetime.fromisoformat(blob.get("ts"))
except Exception:
return [], False
return [], (datetime.now() - ts).days < _NEG_TTL_DAYS
def _cache_write(db: Session, key: str, month: str, pairs: list) -> None:
from app.models.setting import Setting
payload = json.dumps({"ts": datetime.now().isoformat(), "pairs": pairs})
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = payload
else:
db.add(Setting(key=key, value=payload,
description=f"Android Security Bulletin {month} (cve,severity)"))
db.commit()
def fetch_asb_month(db: Session, month: str) -> List[Tuple[str, str]]:
"""Cached fetch+parse of one ASB month. Empty/404 months are negatively
cached (short TTL) so a future month Google hasn't published yet doesn't
trigger a re-fetch on every device sync."""
key = _CACHE_PREFIX + month
pairs, fresh = _cache_read(db, key)
if fresh:
return pairs
import httpx
pairs = []
try:
with httpx.Client(timeout=30.0, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as c:
for url in _asb_urls(month):
r = c.get(url)
if r.status_code == 200:
pairs = _parse_asb_html(r.text)
break
except Exception as e:
logger.debug("ASB fetch failed for %s: %s", month, e)
pairs = []
_cache_write(db, key, month, pairs) # cache empties too (negative cache)
return pairs
def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
source: str = "android-asb", label: str = "ASB") -> None:
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
cve_id = cve.upper()
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.add_source(source)
if not existing.package_name:
existing.package_name = f"Android ({label} {month[:7]})"[:255]
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
try:
existing.refresh_scores()
except Exception:
pass
return
sevmap = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
"moderate": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low}
row = Vulnerability(
cve_id=cve_id, asset_id=asset.id, severity=sevmap.get(sev, VulnerabilitySeverity.high),
status=VulnerabilityStatus.open,
title=f"Android {month[:7]} security patch — {cve_id}"[:500],
package_name=f"Android ({label} {month[:7]})"[:255],
package_version=(asset.os_version or "")[:100] or None,
detected_at=datetime.now(),
sources=json.dumps([source]), first_detected_by=source,
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
new_ids.append(row.id)
def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
new_ids: Optional[list] = None) -> int:
"""Raise findings for the months the device is behind on. Critical+High
only, last _MAX_MONTHS months.
Samsung devices: prefer security.samsungmobile.com's own SMR page per
month (excludes chipset CVEs Samsung says don't apply — avoids false
positives the raw ASB would produce, e.g. a Qualcomm-only CVE on a
Samsung device with a different SoC). Falls back to raw ASB (source
'android-asb') for any month the SMR page doesn't cover or fails to
fetch, and for all non-Samsung Android devices.
"""
if new_ids is None:
new_ids = []
ym = _parse_patch_month(patch_level)
if not ym:
return 0
months = _months_after(ym[0], ym[1], date.today())
is_samsung = "samsung" in (manufacturer or "").lower()
count = 0
for month in months:
pairs = None
if is_samsung:
try:
from app.services import samsung_smr_service
y, m = int(month[:4]), int(month[5:7])
pairs = samsung_smr_service.get_smr_month(db, y, m)
except Exception as e:
logger.debug("Samsung SMR lookup failed for %s: %s", month, e)
pairs = None
if pairs is not None:
source, label = "samsung-smr", "SMR"
else:
pairs = fetch_asb_month(db, month)
source, label = "android-asb", "ASB"
for cve, sev in pairs:
if sev not in _WANT_SEV:
continue
before = len(new_ids)
try:
_upsert(db, asset, month, cve, sev, new_ids, source=source, label=label)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("%s upsert failed (%s on %s): %s", source, cve, asset.id, e)
return count