feat(android): link the ASB and SMR bulletin under External References
An Android finding showed only NVD, CVE.org, EUVD and GitHub, while TeamViewer, IGEL and MS365 findings link the vendor page they were raised from. The patch-level check never wrote a reference: _upsert set source, rating and package, but left `references` empty. Each finding now carries its bulletin. The ASB link opens the month page with a text fragment (#:~:text=CVE%2D...) that scrolls to the CVE's row, since the page has ids only on its sections. The SMR link opens the year page anchored to the month (#November); its CVE lists sit in a collapsed accordion that a text fragment cannot reach. A Samsung finding gets both when the ASB page names the CVE, including the chipset tables that detection skips, so the ASB month cache now also keeps every CVE on the page (cache key v3, so the cached months are read once more). Samsung-own fixes get the SMR link only. Existing findings get their links on the next sync. The detail page labels both buttons.
This commit is contained in:
+14
-2
@@ -2441,8 +2441,20 @@ device dict and `detectedApps` already fetched.
|
||||
negatively cached (ASB: 12h, below the nightly sync interval) so a
|
||||
not-yet-published month doesn't trigger a re-fetch per device, yet is
|
||||
read the night after Google publishes (`tests/test_asb_negative_cache.py`).
|
||||
- Samsung devices show `samsung-smr` only, by design: ASB is read for a
|
||||
month only when the SMR page has no section for it.
|
||||
- Samsung devices show `samsung-smr` only, by design: ASB detects for a
|
||||
month only when the SMR page has no section for it. It is still read
|
||||
for SMR months, but only to decide the ASB link below.
|
||||
- External References: each finding links its bulletin in `references`.
|
||||
ASB: the month page with a text fragment (`#:~:text=CVE%2D…`) that
|
||||
scrolls to the CVE's row; the page has ids only on sections. SMR: the
|
||||
year page anchored to the month (`#November`); the CVE lists sit in a
|
||||
collapsed accordion a text fragment cannot reach. An SMR finding also
|
||||
gets the ASB link when that ASB page names the CVE anywhere: the month
|
||||
cache keeps every CVE on the page (`all`) beside the detection pairs,
|
||||
so a Qualcomm/MediaTek CVE Samsung applies links its chipset table
|
||||
without that table ever driving detection. Samsung-own fixes get the
|
||||
SMR link only. Rows raised earlier get their links on the next sync
|
||||
(`tests/test_android_bulletin_refs.py`).
|
||||
- Close path: a finding whose month (from its package name,
|
||||
"Android (SMR 2026-10)") is at or below the device's patch level loses
|
||||
`android-asb`/`samsung-smr` and closes when no other scanner reports it.
|
||||
|
||||
@@ -37,10 +37,21 @@ def _asb_urls(month: str) -> List[str]:
|
||||
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.
|
||||
|
||||
|
||||
def asb_page(month: str) -> str:
|
||||
"""The one ASB URL that serves `month`, for linking (fetching tries both):
|
||||
2026 onward is year-nested, older months are flat; the other form 404s."""
|
||||
return f"{_ASB_BASE}/{month[:4]}/{month}" if month >= "2026" else f"{_ASB_BASE}/{month}"
|
||||
|
||||
|
||||
def asb_cve_link(month: str, cve: str) -> str:
|
||||
"""The ASB page scrolled to the CVE's row. The page has ids only on its
|
||||
sections, so this is a text fragment; '-' must be %2D inside one."""
|
||||
return f"{asb_page(month)}#:~:text={cve.upper().replace('-', '%2D')}"
|
||||
_CACHE_PREFIX = "asb_month_v3_" # bumped when what a month entry holds
|
||||
# changes, so every month is refetched
|
||||
# fresh (v3: "all", every CVE on the page).
|
||||
_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}")
|
||||
@@ -122,30 +133,31 @@ _NEG_TTL = timedelta(hours=12)
|
||||
|
||||
|
||||
def _cache_read(db: Session, key: str):
|
||||
"""→ (pairs, is_fresh). pairs may be []; is_fresh False means refetch."""
|
||||
"""→ (pairs, listed, is_fresh). pairs may be []; is_fresh False means
|
||||
refetch. `listed` is every CVE on the page, chipset sections included."""
|
||||
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
|
||||
return None, set(), 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
|
||||
return None, set(), False
|
||||
pairs = [tuple(x) for x in (blob.get("pairs") or [])]
|
||||
listed = set(blob.get("all") or [c for c, _ in pairs])
|
||||
if pairs:
|
||||
return pairs, True # non-empty is immutable
|
||||
return pairs, listed, True # non-empty is immutable
|
||||
try:
|
||||
ts = datetime.fromisoformat(blob.get("ts"))
|
||||
except Exception:
|
||||
return [], False
|
||||
return [], datetime.now() - ts < _NEG_TTL
|
||||
return [], set(), False
|
||||
return [], set(), datetime.now() - ts < _NEG_TTL
|
||||
|
||||
|
||||
def _cache_write(db: Session, key: str, month: str, pairs: list) -> None:
|
||||
def _cache_write(db: Session, key: str, month: str, pairs: list, listed: set) -> None:
|
||||
from app.models.setting import Setting
|
||||
payload = json.dumps({"ts": datetime.now().isoformat(), "pairs": pairs})
|
||||
payload = json.dumps({"ts": datetime.now().isoformat(), "pairs": pairs,
|
||||
"all": sorted(listed)})
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if row:
|
||||
row.value = payload
|
||||
@@ -156,15 +168,27 @@ def _cache_write(db: Session, key: str, month: str, pairs: list) -> None:
|
||||
|
||||
|
||||
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."""
|
||||
"""Cached fetch+parse of one ASB month → [(cve, severity)] to detect.
|
||||
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."""
|
||||
return _asb_month(db, month)[0]
|
||||
|
||||
|
||||
def asb_listed(db: Session, month: str) -> set:
|
||||
"""Every CVE the ASB page of `month` names, chipset sections included —
|
||||
for linking, never for detection: a Qualcomm CVE Samsung applies sits in
|
||||
the ASB's Qualcomm table, which detection skips."""
|
||||
return _asb_month(db, month)[1]
|
||||
|
||||
|
||||
def _asb_month(db: Session, month: str):
|
||||
key = _CACHE_PREFIX + month
|
||||
pairs, fresh = _cache_read(db, key)
|
||||
pairs, listed, fresh = _cache_read(db, key)
|
||||
if fresh:
|
||||
return pairs
|
||||
return pairs, listed
|
||||
import httpx
|
||||
pairs = []
|
||||
pairs, listed = [], set()
|
||||
try:
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as c:
|
||||
@@ -172,16 +196,17 @@ def fetch_asb_month(db: Session, month: str) -> List[Tuple[str, str]]:
|
||||
r = c.get(url)
|
||||
if r.status_code == 200:
|
||||
pairs = _parse_asb_html(r.text)
|
||||
listed = {c.upper() for c in _CVE_RE.findall(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
|
||||
pairs, listed = [], set()
|
||||
_cache_write(db, key, month, pairs, listed) # cache empties too (negative cache)
|
||||
return pairs, listed
|
||||
|
||||
|
||||
def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
|
||||
source: str = "android-asb", label: str = "ASB") -> None:
|
||||
source: str = "android-asb", label: str = "ASB", refs: tuple = ()) -> None:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
||||
cve_id = cve.upper()
|
||||
# Google's rating in the ASB, Samsung's in the SMR.
|
||||
@@ -192,6 +217,8 @@ def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
|
||||
if existing:
|
||||
existing.add_source(source)
|
||||
existing.set_vendor_severity(vendor, sev)
|
||||
for url in refs: # also backfills rows raised before bulletin links
|
||||
existing.add_reference(url)
|
||||
if not existing.package_name:
|
||||
existing.package_name = f"Android ({label} {month[:7]})"[:255]
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
@@ -213,6 +240,8 @@ def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
|
||||
sources=json.dumps([source]), first_detected_by=source,
|
||||
)
|
||||
row.set_vendor_severity(vendor, sev)
|
||||
for url in refs:
|
||||
row.add_reference(url)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
@@ -258,13 +287,25 @@ def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
|
||||
else:
|
||||
pairs = fetch_asb_month(db, month)
|
||||
source, label = "android-asb", "ASB"
|
||||
# Bulletin links for External References. An SMR CVE also gets its ASB
|
||||
# row when that month's ASB page names it — not Samsung's own fixes.
|
||||
smr_link = None
|
||||
in_asb = None
|
||||
if source == "samsung-smr":
|
||||
smr_link = samsung_smr_service.smr_month_link(int(month[:4]), int(month[5:7]))
|
||||
for cve, sev in pairs:
|
||||
if sev not in _WANT_SEV:
|
||||
continue
|
||||
reported.add(cve.upper())
|
||||
if smr_link:
|
||||
if in_asb is None:
|
||||
in_asb = asb_listed(db, month)
|
||||
refs = (smr_link,) + ((asb_cve_link(month, cve),) if cve.upper() in in_asb else ())
|
||||
else:
|
||||
refs = (asb_cve_link(month, cve),)
|
||||
before = len(new_ids)
|
||||
try:
|
||||
_upsert(db, asset, month, cve, sev, new_ids, source=source, label=label)
|
||||
_upsert(db, asset, month, cve, sev, new_ids, source=source, label=label, refs=refs)
|
||||
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)
|
||||
|
||||
@@ -141,6 +141,17 @@ def fetch_smr_year(db: Session, year: int) -> Optional[Dict[Tuple[int, int], Lis
|
||||
return parsed
|
||||
|
||||
|
||||
_MONTH_IDS = ("January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December")
|
||||
|
||||
|
||||
def smr_month_link(year: int, month: int) -> str:
|
||||
"""The year page scrolled to that month's SMR. Each month's header carries
|
||||
its English name as id; the CVE lists sit in a collapsed accordion, so a
|
||||
text fragment cannot reach a CVE — the month is as close as it gets."""
|
||||
return f"{_SMR_URL}?year={year}#{_MONTH_IDS[month - 1]}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -60,6 +60,18 @@ const NAMED_REFS: { match: RegExp; label: string; className: string }[] = [
|
||||
label: 'Zoom Security Bulletin',
|
||||
className: 'bg-sky-50 text-sky-700 hover:bg-sky-100',
|
||||
},
|
||||
// Written by the Android patch-level check: the ASB link jumps to the
|
||||
// CVE's row (text fragment), the SMR link to the month's section.
|
||||
{
|
||||
match: /source\.android\.com\/docs\/security\/bulletin\//i,
|
||||
label: 'Android Security Bulletin',
|
||||
className: 'bg-sky-50 text-sky-700 hover:bg-sky-100',
|
||||
},
|
||||
{
|
||||
match: /security\.samsungmobile\.com\/securityUpdate/i,
|
||||
label: 'Samsung SMR',
|
||||
className: 'bg-sky-50 text-sky-700 hover:bg-sky-100',
|
||||
},
|
||||
];
|
||||
|
||||
// Linux-kernel CVEs report their "fix" as an upstream git commit hash
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""ASB/SMR findings link the bulletin they came from — run: python tests/test_android_bulletin_refs.py
|
||||
|
||||
Found 2026-11-05: an "Android (SMR 2026-11)" finding showed only NVD/CVE.org/
|
||||
EUVD under External References, while TeamViewer, IGEL and MS365 findings link
|
||||
their vendor page. The bulletin is the evidence the finding was raised from.
|
||||
|
||||
Pinned here: an SMR finding links Samsung's month section, plus the Google ASB
|
||||
row when the ASB page lists the CVE anywhere — chipset sections included, which
|
||||
detection skips — but not for Samsung-only fixes; an ASB
|
||||
finding links the ASB row via a text fragment (the page has no per-CVE ids); a
|
||||
finding raised before this change gets its links on the next run.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402 (registers every mapper)
|
||||
from app.models.base import Base # noqa: E402
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus # noqa: E402
|
||||
from app.models.vulnerability import ( # noqa: E402
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStatus,
|
||||
)
|
||||
from app.services import android_cve_service, samsung_smr_service # noqa: E402
|
||||
|
||||
ASB = "https://source.android.com/docs/security/bulletin/2026/2026-11-01#:~:text=CVE%2D2026%2D"
|
||||
SMR = "https://security.samsungmobile.com/securityUpdate.smsb?year=2026#November"
|
||||
|
||||
|
||||
def demo():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
samsung = Asset(hostname="PHONE-S", operating_system="Android", os_version="16",
|
||||
source=AssetSource.INTUNE, status=AssetStatus.ACTIVE)
|
||||
pixel = Asset(hostname="PHONE-P", operating_system="Android", os_version="16",
|
||||
source=AssetSource.INTUNE, status=AssetStatus.ACTIVE)
|
||||
db.add_all([samsung, pixel])
|
||||
db.flush()
|
||||
# Raised before bulletin links existed.
|
||||
db.add(Vulnerability(cve_id="CVE-2026-40003", asset_id=samsung.id,
|
||||
severity=VulnerabilitySeverity.high, status=VulnerabilityStatus.open,
|
||||
title="x", detected_at=datetime.now(), sources='["samsung-smr"]',
|
||||
first_detected_by="samsung-smr", package_name="Android (SMR 2026-11)"))
|
||||
db.commit()
|
||||
|
||||
smr = [("CVE-2026-40001", "critical"), # Google fix, also in the ASB
|
||||
("CVE-2026-40002", "high"), # Samsung Semiconductor only
|
||||
("CVE-2026-40003", "high"),
|
||||
("CVE-2026-40004", "high")] # Qualcomm: ASB chipset section
|
||||
asb = [("CVE-2026-40001", "critical"), ("CVE-2026-40003", "high")]
|
||||
on_page = {"CVE-2026-40001", "CVE-2026-40003", "CVE-2026-40004"}
|
||||
real = (samsung_smr_service.get_smr_month, android_cve_service.fetch_asb_month,
|
||||
android_cve_service.asb_listed)
|
||||
samsung_smr_service.get_smr_month = lambda db, y, m: smr if (y, m) == (2026, 11) else []
|
||||
android_cve_service.fetch_asb_month = lambda db, month: asb if month == "2026-11-01" else []
|
||||
android_cve_service.asb_listed = lambda db, month: on_page if month == "2026-11-01" else set()
|
||||
try:
|
||||
android_cve_service.check_android_cves(db, samsung, "2026-10-01", manufacturer="samsung")
|
||||
android_cve_service.check_android_cves(db, pixel, "2026-10-01", manufacturer="Google")
|
||||
finally:
|
||||
(samsung_smr_service.get_smr_month, android_cve_service.fetch_asb_month,
|
||||
android_cve_service.asb_listed) = real
|
||||
db.commit()
|
||||
|
||||
refs = {(v.asset_id, v.cve_id): v.reference_list for v in db.query(Vulnerability).all()}
|
||||
assert refs[(samsung.id, "CVE-2026-40001")] == [SMR, ASB + "40001"], refs
|
||||
assert refs[(samsung.id, "CVE-2026-40002")] == [SMR], refs
|
||||
assert refs[(samsung.id, "CVE-2026-40003")] == [SMR, ASB + "40003"], refs
|
||||
assert refs[(samsung.id, "CVE-2026-40004")] == [SMR, ASB + "40004"], refs
|
||||
assert refs[(pixel.id, "CVE-2026-40001")] == [ASB + "40001"], refs
|
||||
assert android_cve_service.asb_page("2025-10-01") == \
|
||||
"https://source.android.com/docs/security/bulletin/2025-10-01"
|
||||
print("OK")
|
||||
|
||||
|
||||
def test_android_bulletin_refs():
|
||||
demo()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo()
|
||||
@@ -61,12 +61,13 @@ def demo():
|
||||
db.commit()
|
||||
|
||||
owed = [("CVE-2026-30004", "critical"), ("CVE-2026-30005", "high")]
|
||||
real = samsung_smr_service.get_smr_month
|
||||
real = samsung_smr_service.get_smr_month, android_cve_service.asb_listed
|
||||
samsung_smr_service.get_smr_month = lambda db, y, m: owed
|
||||
android_cve_service.asb_listed = lambda db, month: set() # no network
|
||||
try:
|
||||
android_cve_service.check_android_cves(db, asset, "2026-10-01", manufacturer="samsung")
|
||||
finally:
|
||||
samsung_smr_service.get_smr_month = real
|
||||
samsung_smr_service.get_smr_month, android_cve_service.asb_listed = real
|
||||
db.commit()
|
||||
|
||||
by_cve = {v.cve_id: v for v in db.query(Vulnerability).all()}
|
||||
|
||||
Reference in New Issue
Block a user