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:
2026-11-05 13:14:53 +01:00
parent f9e8464431
commit 5f5ee94ef6
6 changed files with 195 additions and 29 deletions
+14 -2
View File
@@ -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.
+66 -25
View File
@@ -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)
+11
View File
@@ -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
+89
View File
@@ -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()
+3 -2
View File
@@ -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()}