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`).
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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