fix(android): close ASB/SMR findings once the device has the patch

The Android bulletin pass only ever raised findings. A device that
installed the October patch kept every October CVE open forever, since
no scanner looks again at a month the device is no longer behind on.

check_android_cves now retracts android-asb / samsung-smr from findings
whose month (read from the package name, "Android (SMR 2026-10)") is at
or below the device's patch level, and closes them when no other scanner
still reports the CVE, with an audit entry naming the patch level. A CVE
that a month still behind lists again stays open, and so does a row
whose month cannot be read.

The gate is the month, not "not seen this run": the fetched months are
capped at 12 and a failed fetch returns nothing, and both would close
CVEs the device still lacks.
This commit is contained in:
2026-11-04 10:36:33 +01:00
parent 7b006411ff
commit f9e8464431
3 changed files with 152 additions and 0 deletions
+8
View File
@@ -2443,6 +2443,14 @@ device dict and `detectedApps` already fetched.
read the night after Google publishes (`tests/test_asb_negative_cache.py`). 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 - Samsung devices show `samsung-smr` only, by design: ASB is read for a
month only when the SMR page has no section for it. month only when the SMR page has no section for it.
- 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.
Gated on the month, not on "not seen this run": the fetched months are
capped at 12 and a failed fetch returns nothing, both would close CVEs
the device still lacks (`tests/test_android_patched_close.py`). Not
covered: a month that switched from ASB fallback to SMR keeps any CVE
only ASB listed until the device patches past it.
Dashboard: a dedicated **"Mobile Security · EOL & Patch Level"** widget, Dashboard: a dedicated **"Mobile Security · EOL & Patch Level"** widget,
kept separate from the desktop-software EOL widget, sorted so a reached kept separate from the desktop-software EOL widget, sorted so a reached
+55
View File
@@ -242,6 +242,7 @@ def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
months = _months_after(ym[0], ym[1], date.today()) months = _months_after(ym[0], ym[1], date.today())
is_samsung = "samsung" in (manufacturer or "").lower() is_samsung = "samsung" in (manufacturer or "").lower()
count = 0 count = 0
reported: set = set()
for month in months: for month in months:
pairs = None pairs = None
if is_samsung: if is_samsung:
@@ -260,10 +261,64 @@ def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
for cve, sev in pairs: for cve, sev in pairs:
if sev not in _WANT_SEV: if sev not in _WANT_SEV:
continue continue
reported.add(cve.upper())
before = len(new_ids) before = len(new_ids)
try: 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)
count += 1 if len(new_ids) > before else 0 count += 1 if len(new_ids) > before else 0
except Exception as e: except Exception as e:
logger.debug("%s upsert failed (%s on %s): %s", source, cve, asset.id, e) logger.debug("%s upsert failed (%s on %s): %s", source, cve, asset.id, e)
_close_received(db, asset, ym, reported, patch_level)
return count return count
_SOURCES = ("android-asb", "samsung-smr")
_FINDING_MONTH_RE = re.compile(r"\((?:ASB|SMR) (\d{4})-(\d{2})\)")
def _close_received(db: Session, asset, ym: Tuple[int, int], reported: set,
patch_level) -> int:
"""Retract our sources from findings of a month the device has now
installed (month <= patch level), closing those no other scanner reports.
The month comes from the package name _upsert wrote; a row whose month we
cannot read is left alone. Gated on the month, not on "not seen this run",
because the months we fetch are capped (_MAX_MONTHS) and a failed fetch
returns nothing: both would close CVEs the device still lacks. A CVE that a
month still behind lists again (`reported`) stays.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open)
.all())
closed = 0
for v in rows:
mine = [s for s in _SOURCES if s in (v.source_list or [])]
m = _FINDING_MONTH_RE.search(v.package_name or "")
if not mine or not m or v.cve_id in reported:
continue
if (int(m.group(1)), int(m.group(2))) > ym:
continue
for s in mine:
v.remove_source(s)
if v.source_list:
continue
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
closed += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"Android security patch level {str(patch_level)[:10]} includes this fix",
cve_id=v.cve_id, source="android_cve",
)
except Exception as e:
logger.warning("audit log for android patch close failed (vuln_id=%s): %s",
v.id, e)
if closed:
logger.info("android-cve: closed %d findings on %s (patch level %s)",
closed, asset.hostname, patch_level)
return closed
+89
View File
@@ -0,0 +1,89 @@
"""Bulletin CVEs close once the device reaches their month — run: python tests/test_android_patched_close.py
Found 2026-11-04: the ASB/SMR pass only ever raised findings. A device that
installed the October patch kept every October CVE open forever, since no
scanner looks at a month the device is no longer behind on.
Pinned here: a finding from a month at or below the device's patch level loses
our source and closes when no other scanner reports it; a month still behind,
a CVE that a newer month lists again, and a row whose month we cannot read are
left alone.
"""
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
def _vuln(asset, cve, sources, package):
return Vulnerability(
cve_id=cve, asset_id=asset.id, severity=VulnerabilitySeverity.high,
status=VulnerabilityStatus.open, title=cve, detected_at=datetime.now(),
sources=sources, first_detected_by="test", package_name=package,
)
def demo():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
asset = Asset(hostname="PHONE-1", operating_system="Android", os_version="16",
source=AssetSource.INTUNE, status=AssetStatus.ACTIVE)
db.add(asset)
db.flush()
db.add_all([
_vuln(asset, "CVE-2026-30001", '["samsung-smr"]', "Android (SMR 2026-09)"),
_vuln(asset, "CVE-2026-30002", '["samsung-smr"]', "Android (SMR 2026-10)"),
# Another scanner still reports it: stays open, only our claim goes.
_vuln(asset, "CVE-2026-30003", '["android-asb", "app-scan"]', "Android (ASB 2026-08)"),
# Every month after the patch level still owes it.
_vuln(asset, "CVE-2026-30004", '["samsung-smr"]', "Android (SMR 2026-11)"),
# Raised in September, listed again by a month still behind.
_vuln(asset, "CVE-2026-30005", '["samsung-smr"]', "Android (SMR 2026-09)"),
# Month unknown (another scanner named the package first).
_vuln(asset, "CVE-2026-30006", '["samsung-smr"]', "android-kernel"),
])
db.commit()
owed = [("CVE-2026-30004", "critical"), ("CVE-2026-30005", "high")]
real = samsung_smr_service.get_smr_month
samsung_smr_service.get_smr_month = lambda db, y, m: owed
try:
android_cve_service.check_android_cves(db, asset, "2026-10-01", manufacturer="samsung")
finally:
samsung_smr_service.get_smr_month = real
db.commit()
by_cve = {v.cve_id: v for v in db.query(Vulnerability).all()}
assert by_cve["CVE-2026-30001"].status == VulnerabilityStatus.patched
assert by_cve["CVE-2026-30001"].source_list == []
assert by_cve["CVE-2026-30002"].status == VulnerabilityStatus.patched
assert by_cve["CVE-2026-30003"].status == VulnerabilityStatus.open
assert by_cve["CVE-2026-30003"].source_list == ["app-scan"]
for cve in ("CVE-2026-30004", "CVE-2026-30005", "CVE-2026-30006"):
assert by_cve[cve].status == VulnerabilityStatus.open, cve
assert by_cve[cve].patched_at is None, cve
print("OK")
def test_android_patched_close():
demo()
if __name__ == "__main__":
demo()