Files
vulncheck/app/services/mobile_eol_service.py
T
vulncheck 0b71453c48 chore(release): drop personal attribution from comments and docstrings
Comments across the codebase credited one individual by role and, in places,
described that person's own machines: which SQL Server versions a host ran,
which devices were enrolled, what a particular dashboard showed, how many
findings sat open on which server. In a public repository that reads as a
profile of someone's unpatched estate.

The observations are why the code looks the way it does, so they stay. Every
CVE id, version, build number, count and date is preserved, as are the
verbatim quotes that motivated specific sort and filter rules — only the
attribution changes, to "field report", "observed", "a host". A local
variable in tests/test_autodesk_year.py was renamed for the same reason; its
value and every assertion around it are byte-identical.

PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of
organisation this was built for, and a support section pointing at an
internal team, both replaced with neutral wording.

Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one
swaps. detect_changes reports 104 touched symbols and zero affected execution
flows, and all 55 test scripts pass. Nothing here needs re-testing.
2026-08-26 15:34:05 +02:00

313 lines
17 KiB
Python

"""
EOL/EOS + Android patch-level checks for MDM (Intune) mobile devices.
Reuses eol_service (endoflife.date fetch/cache/EOLStatus/upsert). The only
NEW work is mapping an Intune device model → endoflife slug + release, since
endoflife keys phones by marketing name (Galaxy S25 Ultra, iPhone 15 Pro)
while Intune reports model identifiers:
- Apple reports the marketing name ("iPhone 15 Pro") → fuzzy-match the
endoflife release label/name.
- Samsung reports an SM-code ("SM-S938B") with NO textual overlap → a
curated SM-prefix → endoflife-release table (extend as inventory grows).
Plus a cheap "Android security patch level is N months stale" finding from
Intune's androidSecurityPatchLevel — the control instance that checks whether
patches were actually applied, without scraping any vendor bulletin.
"""
from __future__ import annotations
import logging
import re
from datetime import date, datetime
from typing import Optional, Tuple
from sqlalchemy.orm import Session
from app.services import eol_service
logger = logging.getLogger(__name__)
# Curated Samsung SM-base-code → (endoflife slug, release `name`). Matched by
# prefix so the region/variant suffix (B/N/U/0/F…) is ignored: "SM-S938B"
# starts with "SM-S938". Names verified against endoflife.date.
# ponytail: curated; add a row when a new model shows up in inventory —
# unknown models are skipped (no false-positive), not guessed.
_PHONE = "samsung-mobile"
_TAB = "samsung-galaxy-tab"
# SM-base-code (4 digits) → (endoflife slug, release `name`). Codes are
# unique per model, so no prefix collides with another. Slug per row routes
# tablets (SM-X/T/P) to samsung-galaxy-tab. Names verified against the live
# endoflife.date API.
_SAMSUNG: list[Tuple[str, str, str]] = [
# --- Galaxy S25 / S24 / S23 / S22 (base / + / Ultra / FE) ---
("SM-S938", _PHONE, "galaxy-s25-ultra"), ("SM-S936", _PHONE, "galaxy-s25+"), ("SM-S931", _PHONE, "galaxy-s25"), ("SM-S731", _PHONE, "galaxy-s25-fe"),
("SM-S928", _PHONE, "galaxy-s24-ultra"), ("SM-S926", _PHONE, "galaxy-s24+"), ("SM-S921", _PHONE, "galaxy-s24"), ("SM-S721", _PHONE, "galaxy-s24-fe"),
("SM-S918", _PHONE, "galaxy-s23-ultra"), ("SM-S916", _PHONE, "galaxy-s23+"), ("SM-S911", _PHONE, "galaxy-s23"), ("SM-S711", _PHONE, "galaxy-s23-fe"),
("SM-S908", _PHONE, "galaxy-s22-ultra"), ("SM-S906", _PHONE, "galaxy-s22+"), ("SM-S901", _PHONE, "galaxy-s22"),
# --- Galaxy S21 / S20 (note the -5g suffix on S21) ---
("SM-G998", _PHONE, "galaxy-s21-ultra-5g"), ("SM-G996", _PHONE, "galaxy-s21+-5g"), ("SM-G991", _PHONE, "galaxy-s21-5g"), ("SM-G990", _PHONE, "galaxy-s21-fe-5g"),
("SM-G988", _PHONE, "galaxy-s20-ultra-5g"), ("SM-G986", _PHONE, "galaxy-s20+-5g"), ("SM-G985", _PHONE, "galaxy-s20+"),
("SM-G981", _PHONE, "galaxy-s20-5g"), ("SM-G980", _PHONE, "galaxy-s20"), ("SM-G781", _PHONE, "galaxy-s20-fe-5g"), ("SM-G780", _PHONE, "galaxy-s20-fe"),
# --- Galaxy Note 20 / 10 ---
("SM-N986", _PHONE, "galaxy-note20-ultra-5g"), ("SM-N985", _PHONE, "galaxy-note20-ultra"), ("SM-N981", _PHONE, "galaxy-note20-5g"), ("SM-N980", _PHONE, "galaxy-note20"),
("SM-N976", _PHONE, "galaxy-note10+-5g"), ("SM-N975", _PHONE, "galaxy-note10+"), ("SM-N971", _PHONE, "galaxy-note10-5g"), ("SM-N970", _PHONE, "galaxy-note10"), ("SM-N770", _PHONE, "galaxy-note10-lite"),
# --- Galaxy Z Fold / Flip ---
("SM-F966", _PHONE, "galaxy-z-fold7"), ("SM-F956", _PHONE, "galaxy-z-fold6"), ("SM-F946", _PHONE, "galaxy-z-fold5"), ("SM-F936", _PHONE, "galaxy-z-fold4"), ("SM-F926", _PHONE, "galaxy-z-fold3-5g"), ("SM-F916", _PHONE, "galaxy-z-fold2-5g"),
("SM-F766", _PHONE, "galaxy-z-flip7"), ("SM-F741", _PHONE, "galaxy-z-flip6"), ("SM-F731", _PHONE, "galaxy-z-flip5"), ("SM-F721", _PHONE, "galaxy-z-flip4"), ("SM-F711", _PHONE, "galaxy-z-flip3-5g"),
# --- Galaxy A-series (5G + LTE) ---
("SM-A576", _PHONE, "galaxy-a57-5g"), ("SM-A566", _PHONE, "galaxy-a56-5g"), ("SM-A556", _PHONE, "galaxy-a55-5g"), ("SM-A546", _PHONE, "galaxy-a54-5g"), ("SM-A536", _PHONE, "galaxy-a53-5g"),
("SM-A376", _PHONE, "galaxy-a37-5g"), ("SM-A366", _PHONE, "galaxy-a36-5g"), ("SM-A356", _PHONE, "galaxy-a35-5g"), ("SM-A346", _PHONE, "galaxy-a34-5g"), ("SM-A336", _PHONE, "galaxy-a33-5g"),
("SM-A266", _PHONE, "galaxy-a26-5g"), ("SM-A256", _PHONE, "galaxy-a25-5g"), ("SM-A166", _PHONE, "galaxy-a16-5g"), ("SM-A165", _PHONE, "galaxy-a16"),
("SM-A156", _PHONE, "galaxy-a15-5g"), ("SM-A155", _PHONE, "galaxy-a15"), ("SM-A146", _PHONE, "galaxy-a14-5g"), ("SM-A145", _PHONE, "galaxy-a14"),
# --- Galaxy XCover (rugged business) ---
("SM-G556", _PHONE, "galaxy-xcover7"), ("SM-G736", _PHONE, "galaxy-xcover6-pro"), ("SM-G525", _PHONE, "galaxy-xcover5"), ("SM-G715", _PHONE, "galaxy-xcover-pro"),
("SM-G398", _PHONE, "galaxy-xcover-4s"), ("SM-G390", _PHONE, "galaxy-xcover-4"), ("SM-G389", _PHONE, "galaxy-xcover3-g389f"), ("SM-G388", _PHONE, "galaxy-xcover-3"),
# --- Galaxy Tab S10 / S9 / S8 / S7 / S6 (SM-X newer, SM-T/P older) ---
("SM-X926", _TAB, "galaxy-tab-s10-ultra"), ("SM-X826", _TAB, "galaxy-tab-s10+"),
("SM-X916", _TAB, "galaxy-tab-s9-ultra"), ("SM-X816", _TAB, "galaxy-tab-s9+"), ("SM-X716", _TAB, "galaxy-tab-s9"), ("SM-X710", _TAB, "galaxy-tab-s9"),
("SM-X616", _TAB, "galaxy-tab-s9-fe+"), ("SM-X610", _TAB, "galaxy-tab-s9-fe+"), ("SM-X516", _TAB, "galaxy-tab-s9-fe"), ("SM-X510", _TAB, "galaxy-tab-s9-fe"),
("SM-X906", _TAB, "galaxy-tab-s8-ultra"), ("SM-X806", _TAB, "galaxy-tab-s8+"), ("SM-X706", _TAB, "galaxy-tab-s8"), ("SM-X700", _TAB, "galaxy-tab-s8"),
("SM-T976", _TAB, "galaxy-tab-s7+"), ("SM-T970", _TAB, "galaxy-tab-s7+"), ("SM-T875", _TAB, "galaxy-tab-s7"), ("SM-T870", _TAB, "galaxy-tab-s7"), ("SM-T736", _TAB, "galaxy-tab-s7-fe"), ("SM-T730", _TAB, "galaxy-tab-s7-fe"),
("SM-T866", _TAB, "galaxy-tab-s6"), ("SM-T860", _TAB, "galaxy-tab-s6"), ("SM-P625", _TAB, "galaxy-tab-s6-lite-2024"), ("SM-P620", _TAB, "galaxy-tab-s6-lite-2024"), ("SM-P619", _TAB, "galaxy-tab-s6-lite"), ("SM-P613", _TAB, "galaxy-tab-s6-lite"), ("SM-P615", _TAB, "galaxy-tab-s6-lite-2020"), ("SM-P610", _TAB, "galaxy-tab-s6-lite-2020"),
# --- Galaxy Tab A (budget) ---
("SM-X236", _TAB, "galaxy-tab-a11+"), ("SM-X230", _TAB, "galaxy-tab-a11+"), ("SM-X135", _TAB, "galaxy-tab-a11"), ("SM-X130", _TAB, "galaxy-tab-a11"),
("SM-X216", _TAB, "galaxy-tab-a9+"), ("SM-X210", _TAB, "galaxy-tab-a9+"), ("SM-X116", _TAB, "galaxy-tab-a9"), ("SM-X110", _TAB, "galaxy-tab-a9"), ("SM-X205", _TAB, "galaxy-tab-a8"), ("SM-X200", _TAB, "galaxy-tab-a8"),
("SM-T350", _TAB, "galaxy-tab-a-8.0-2015"), ("SM-T280", _TAB, "galaxy-tab-a-7.0-2016"),
# --- Galaxy Tab Active (rugged tablets) ---
("SM-X356", _TAB, "galaxy-tab-active5-pro"), ("SM-X306", _TAB, "galaxy-tab-active5"), ("SM-X300", _TAB, "galaxy-tab-active5"), ("SM-T575", _TAB, "galaxy-tab-active3"),
("SM-T395", _TAB, "galaxy-tab-active2"), ("SM-T365", _TAB, "galaxy-tab-active-lte"), ("SM-T360", _TAB, "galaxy-tab-active"),
]
_STALE_CVE_ID = "ANDROID-PATCH-LEVEL-STALE"
def _norm(s: Optional[str]) -> str:
return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")
def _resolve_device(manuf: str, model: str, os_name: str):
"""→ (slug, matcher) or None. matcher = {'kind':'name','name':...} for the
Samsung table, or {'kind':'apple','model':...} for Apple fuzzy match."""
m = model or ""
mu = (manuf or "").lower()
ml = m.lower()
osl = (os_name or "").lower()
if "apple" in mu or ml.startswith("ipad") or ml.startswith("iphone") or "ios" in osl:
slug = "ipad" if ("ipad" in ml or "ipados" in osl) else "iphone"
return slug, {"kind": "apple", "model": m}
if "samsung" in mu or re.match(r"sm-[a-z]\d", ml):
up = m.upper()
for prefix, slug, name in _SAMSUNG:
if up.startswith(prefix):
return slug, {"kind": "name", "name": name}
logger.debug("mobile-eol: unmapped Samsung model %s", m)
return None
def _find_release(data: dict, matcher: dict) -> Optional[dict]:
rels = ((data.get("result") or {}).get("releases") or []) if isinstance(data, dict) else []
if matcher["kind"] == "name":
return next((r for r in rels if r.get("name") == matcher["name"]), None)
target = _norm(matcher["model"]) # "iphone-15-pro-max" / "ipad-air-5th-generation"
for r in rels:
label = r.get("label") or ""
cands = {_norm(r.get("name")), _norm(label),
_norm("iphone " + label), _norm("ipad " + label)}
if target in cands:
return r
return None
def _parse_patch_date(raw) -> Optional[date]:
s = str(raw or "").strip()[:10]
if not s:
return None
try:
return datetime.strptime(s, "%Y-%m-%d").date()
except ValueError:
return None
def _upsert_android_stale(db: Session, asset, patch_level: str, age_days: int) -> bool:
"""Pseudo-finding: the device's Android security patch level is stale.
Stable cve_id per asset → idempotent. Returns True on create."""
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
months = age_days // 30
if age_days >= 365:
sev, cvss = VulnerabilitySeverity.high, 8.0
elif age_days >= 180:
sev, cvss = VulnerabilitySeverity.medium, 5.5
else:
sev, cvss = VulnerabilitySeverity.low, 3.0
title = f"Android security patch level {months} months behind ({patch_level})"
desc = (f"Intune reports this device's Android security patch level as {patch_level} "
f"— {age_days} days ({months} months) old. Monthly Android/OEM security "
f"patches since then have not been applied, so any CVE fixed in those "
f"bulletins remains open regardless of MDM patch policy.")
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == _STALE_CVE_ID, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.severity = sev
existing.cvss_score = cvss
existing.title = title[:500]
existing.description = desc
existing.package_version = str(patch_level)[:100]
existing.detected_at = datetime.now()
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Mobile EOL/patch-level check reports this finding again", source="mobile_eol")
try:
existing.refresh_scores()
except Exception:
pass
return False
row = Vulnerability(
cve_id=_STALE_CVE_ID, asset_id=asset.id, cvss_score=cvss, severity=sev,
status=VulnerabilityStatus.open, title=title[:500], description=desc,
package_name="Android Security Patch Level", package_version=str(patch_level)[:100],
detected_at=datetime.now(), sources='["intune"]', first_detected_by="intune",
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
return True
_ANDROID_SOURCES = ("android-asb", "samsung-smr")
def _drop_android_findings(db: Session, asset) -> int:
"""Close Android bulletin findings sitting on a device that is not Android.
They can only get there by mis-attribution — one asset that stood for two
physical devices — and nothing ever revisits them: the ASB/SMR pass runs
for Android devices only, so on an iOS asset they would stay open forever
with no scan able to clear them (observed: an iPhone showing ASB and SMR
CVEs). Same closing contract as everywhere else: drop OUR source, close
only when no other scanner still reports the CVE.
"""
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 _ANDROID_SOURCES if s in (v.source_list or [])]
# The patch-level pseudo-finding carries the sync's own source, so it
# is identified by its stable id instead.
stale_row = v.cve_id == _STALE_CVE_ID
if not mine and not stale_row:
continue
for s in mine:
v.remove_source(s)
if v.source_list and not stale_row:
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="Android bulletin finding on a device that does not run Android",
cve_id=v.cve_id, source="mobile_eol",
)
except Exception as e:
logger.warning("audit log for android-finding cleanup failed (vuln_id=%s): %s",
v.id, e)
if closed:
logger.info("mobile-eol: closed %d Android findings on non-Android asset %s",
closed, asset.hostname)
return closed
def check_device(db: Session, asset, device: dict,
seen_eol_ids: Optional[set] = None) -> int:
"""EOL/EOS for the device model + Android patch-level staleness.
Returns findings upserted. Caller commits.
`seen_eol_ids` collects the EOL findings this check confirmed, for the
caller's reconcile pass. The model finding is stored like any other EOL
row (first_detected_by=eol_check), so the detectedApps sweep that runs
right after us on the same asset retracts it as "no longer installed" —
a device model is in no app inventory. That is the observed Samsung flap:
raised and closed seconds apart, every sync, forever."""
count = 0
manuf = (device.get("manufacturer") or "").strip()
model = (device.get("model") or "").strip()
os_name = (device.get("operatingSystem") or "").strip()
os_version = (device.get("osVersion") or "").strip()
# 1) Device-model EOL/EOS via endoflife.date.
try:
res = _resolve_device(manuf, model, os_name)
if res:
slug, matcher = res
data = eol_service.fetch_product(db, slug)
rel = _find_release(data, matcher) if data else None
if rel:
status = eol_service._build_eol_status(rel, slug)
if status.is_eol or status.is_eol_soon or status.is_eoas:
# product_name is the VENDOR only — upsert_eol_vulnerability
# appends the release label itself, so passing "Samsung
# Galaxy Tab A8" would double it ("…A8 Galaxy Tab A8").
# endoflife labels: iPhone lacks the "iPhone" prefix, iPad
# and Samsung labels already carry it.
vendor = {"iphone": "Apple iPhone", "ipad": "Apple",
"samsung-mobile": "Samsung",
"samsung-galaxy-tab": "Samsung"}.get(slug, (manuf or "Device").title())
vid, _ = eol_service.upsert_eol_vulnerability(
db, asset_id=asset.id, product_name=vendor,
installed_version=(f"{os_name} {os_version}".strip() or "unknown"),
status=status,
)
# Keep the full device name in the package column.
if vid:
if seen_eol_ids is not None:
seen_eol_ids.add(vid)
from app.models.vulnerability import Vulnerability
row = db.query(Vulnerability).filter(Vulnerability.id == vid).first()
if row:
row.package_name = f"{vendor} {rel.get('label') or model}".strip()[:255]
count += 1
except Exception as e:
logger.debug("mobile-eol device check failed for %s: %s", asset.hostname, e)
# 2) Android security-patch-level staleness + per-CVE detail from ASB.
if "android" in os_name.lower():
patch = device.get("androidSecurityPatchLevel")
d = _parse_patch_date(patch)
if d:
age = (date.today() - d).days
if age >= 90: # <90d = within a normal monthly-patch window
try:
_upsert_android_stale(db, asset, str(patch)[:10], age)
count += 1
except Exception as e:
logger.debug("mobile-eol android-patch failed for %s: %s", asset.hostname, e)
# Per-CVE findings for the months the device is behind (Google ASB).
try:
from app.services import android_cve_service
count += android_cve_service.check_android_cves(db, asset, patch, manufacturer=manuf)
except Exception as e:
logger.debug("android-asb CVEs failed for %s: %s", asset.hostname, e)
elif os_name:
try:
_drop_android_findings(db, asset)
except Exception as e:
logger.debug("android-finding cleanup failed for %s: %s", asset.hostname, e)
return count