Files
vulncheck/app/services/samsung_smr_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

152 lines
6.0 KiB
Python

"""
Samsung Security Maintenance Release (SMR) per-CVE detection.
For Samsung Android devices, security.samsungmobile.com's yearly page is a
MORE PRECISE source than the raw Google Android Security Bulletin (ASB):
Samsung explicitly excludes CVEs that don't apply to its own devices/chipsets
("Not applicable to Samsung devices") and adds Samsung Semiconductor-specific
fixes. Using raw ASB for a Samsung device produces false positives on
chipset-specific CVEs Samsung's own page says don't apply
(e.g. CVE-2025-59604 — Qualcomm-only, explicitly not-applicable to Samsung).
The page ignores its own year/month query params in the sense that it always
serves the FULL requested year's content server-side (all ~12 SMR sections
are present in the raw HTML — the accordion UI is pure client-side CSS/JS,
it doesn't gate what's delivered), so ?year=YYYY is fetched once and cached,
covering every month of that year.
Falls back to the raw-ASB scanner (android_cve_service) for months this page
doesn't cover (very old dates, or a fetch failure).
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_SMR_URL = "https://security.samsungmobile.com/securityUpdate.smsb"
_CACHE_PREFIX = "smr_year_v1_"
_CACHE_TTL = timedelta(hours=24) # the current year gains a new SMR monthly
_MONTHS = {"JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6,
"JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12}
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
_SEV_LIST_RE = {
sev: re.compile(rf'<strong><font[^>]*>{sev}</font></strong><br\s*/?>([^<]*)', re.I)
for sev in ("Critical", "High")
}
_NA_RE = re.compile(r"Not applicable to Samsung devices</font></strong><br\s*/?>([^<]*)", re.I)
_SEM_HEADER_RE = re.compile(r"Samsung Semiconductor patch is also included", re.I)
def _parse_smr_html(html: str) -> Dict[Tuple[int, int], List[Tuple[str, str]]]:
"""→ {(year, month): [(cve, severity)]}. severity is 'critical'|'high'.
Google Critical/High minus the "Not applicable" list, plus the Samsung
Semiconductor Critical/High list (Samsung's own chipset-fix scope)."""
positions = [(m.start(), m.group(1), m.group(2))
for m in re.finditer(r"SMR-([A-Z]{3})-(\d{4})", html)]
out: Dict[Tuple[int, int], List[Tuple[str, str]]] = {}
for i, (pos, mon, yr) in enumerate(positions):
month = _MONTHS.get(mon)
if not month:
continue
end = positions[i + 1][0] if i + 1 < len(positions) else len(html)
block = html[pos:end]
def sev_cves(label: str, text: str) -> List[str]:
m = _SEV_LIST_RE[label].search(text)
return _CVE_RE.findall(m.group(1)) if m else []
critical = sev_cves("Critical", block)
high = sev_cves("High", block)
na_m = _NA_RE.search(block)
not_applicable = set(_CVE_RE.findall(na_m.group(1))) if na_m else set()
sem_critical: List[str] = []
sem_high: List[str] = []
sem_m = _SEM_HEADER_RE.search(block)
if sem_m:
sem_block = block[sem_m.start():sem_m.start() + 2000]
sem_critical = sev_cves("Critical", sem_block)
sem_high = sev_cves("High", sem_block)
pairs: List[Tuple[str, str]] = []
seen: set = set()
for cve in critical:
if cve in not_applicable or cve in seen:
continue
seen.add(cve)
pairs.append((cve, "critical"))
for cve in high:
if cve in not_applicable or cve in seen:
continue
seen.add(cve)
pairs.append((cve, "high"))
for cve in sem_critical:
if cve in seen:
continue
seen.add(cve)
pairs.append((cve, "critical"))
for cve in sem_high:
if cve in seen:
continue
seen.add(cve)
pairs.append((cve, "high"))
out[(int(yr), month)] = pairs
return out
def fetch_smr_year(db: Session, year: int) -> Optional[Dict[Tuple[int, int], List[Tuple[str, str]]]]:
"""Cached fetch+parse of one year's SMR page (covers all its months).
None on fetch failure (caller should fall back to ASB)."""
from app.models.setting import Setting
key = f"{_CACHE_PREFIX}{year}"
row = db.query(Setting).filter(Setting.key == key).first()
if row and row.value:
try:
blob = json.loads(row.value)
ts = datetime.fromisoformat(blob["ts"])
if datetime.now() - ts < _CACHE_TTL:
return {tuple(map(int, k.split("-"))): [tuple(p) for p in v]
for k, v in blob["months"].items()}
except Exception:
pass
import httpx
try:
with httpx.Client(timeout=30.0, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as c:
r = c.get(_SMR_URL, params={"year": year})
if r.status_code != 200:
return None
parsed = _parse_smr_html(r.text)
except Exception as e:
logger.debug("SMR fetch failed for year %s: %s", year, e)
return None
if not parsed:
return None
payload = json.dumps({
"ts": datetime.now().isoformat(),
"months": {f"{y}-{m}": pairs for (y, m), pairs in parsed.items()},
})
if row:
row.value = payload
else:
db.add(Setting(key=key, value=payload, description=f"Samsung SMR {year} (cve,severity) per month"))
db.commit()
return parsed
def get_smr_month(db: Session, year: int, month: int) -> Optional[List[Tuple[str, str]]]:
"""CVEs Samsung's own SMR page attributes as applicable for (year, month),
or None if that year's page couldn't be fetched or doesn't cover the
month (caller should fall back to raw ASB)."""
parsed = fetch_smr_year(db, year)
if parsed is None:
return None
return parsed.get((year, month))