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:
@@ -2443,6 +2443,14 @@ device dict and `detectedApps` already fetched.
|
||||
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.
|
||||
- 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,
|
||||
kept separate from the desktop-software EOL widget, sorted so a reached
|
||||
|
||||
@@ -242,6 +242,7 @@ def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
|
||||
months = _months_after(ym[0], ym[1], date.today())
|
||||
is_samsung = "samsung" in (manufacturer or "").lower()
|
||||
count = 0
|
||||
reported: set = set()
|
||||
for month in months:
|
||||
pairs = None
|
||||
if is_samsung:
|
||||
@@ -260,10 +261,64 @@ def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
|
||||
for cve, sev in pairs:
|
||||
if sev not in _WANT_SEV:
|
||||
continue
|
||||
reported.add(cve.upper())
|
||||
before = len(new_ids)
|
||||
try:
|
||||
_upsert(db, asset, month, cve, sev, new_ids, source=source, label=label)
|
||||
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)
|
||||
_close_received(db, asset, ym, reported, patch_level)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user