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.
152 lines
6.0 KiB
Python
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))
|