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.
313 lines
17 KiB
Python
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
|