2 Commits
Author SHA1 Message Date
vulncheck 0f372e36a2 fix(mobile-eol): Apple models endoflife.date names differently
Intune reports Apple's marketing name, matched against endoflife.date's
release label after normalising. Apple names the first iPhone SE, iPad
Air, iPad mini and iPad Pro of each size without a generation ("iPad Pro
(9.7-inch)"), while endoflife adds "(1st generation)", so none of them
matched. Most of these models are already EOL (9.7"/12.9" Pro and SE
2025-03-31, 10.5" Pro 2026-09-17, Air 2023, mini 2019), so they raised no
finding at all. endoflife also files the 12.9" Pro 6th generation as
"iPad Pro (6th generation)" and the iPhone Air as "17 Air".

_APPLE_ALIASES maps these exact normalised names to the endoflife release,
resolved in _resolve_device like a Samsung row. Every Apple model name
from the iPhone 3G and iPad 2 on now finds its release in the live data;
names outside the table still go through the label match.
2026-11-03 10:35:11 +01:00
vulncheck 0a081faa19 fix(mobile-eol): Galaxy Tab A9+ (2025) is not the 2023 Tab A9+
An SM-X216R showed the Tab A9+ end-of-support finding (active support
ended 2026-10-23, security 2027-10-23). SM-X216R and its Wi-Fi twin
SM-X210R are the 2025 re-release, which endoflife.date tracks as its own
release galaxy-tab-a9+2025, supported into 2031. The 2023 codes
(SM-X216B/U, SM-X210) share the base code, so the prefix match took the
2025 tablet for the 2023 one. Suffixed rows now sit above the base rows.

The wrong finding had a way out only through the detectedApps reconcile,
which does not run with detected apps off, an empty app list or a
co-managed device. A phone or tablet is one model, so the four mobile
slugs join _SINGLE_RELEASE_SLUGS, and a resolved model that raises no
finding of its own now supersedes any other model's finding on the asset.
2026-11-03 10:27:50 +01:00
5 changed files with 241 additions and 5 deletions
+10 -1
View File
@@ -2248,10 +2248,19 @@ Runs inline during the Intune sync — no extra Graph calls beyond the
device dict and `detectedApps` already fetched. device dict and `detectedApps` already fetched.
- **Device EOL/EOS** (`mobile_eol_service.py`) — Apple (iPhone/iPad) fuzzy- - **Device EOL/EOS** (`mobile_eol_service.py`) — Apple (iPhone/iPad) fuzzy-
matches endoflife.date's full release list by marketing name; Samsung matches endoflife.date's full release list by marketing name, except the
few names endoflife spells differently (`_APPLE_ALIASES`: first-generation
iPhone SE and iPad Air/mini/Pro without "(1st generation)", 12.9" Pro 6th gen, iPhone
Air; `tests/test_apple_model_names.py`). Apple announces no end dates, so
a current model gets its finding only once endoflife marks it EOL; Samsung
needs a curated SM-code → release-name table (no textual bridge exists needs a curated SM-code → release-name table (no textual bridge exists
between Intune's model code and endoflife's marketing name in either between Intune's model code and endoflife's marketing name in either
dataset) — ~120 models, all verified against the live endoflife API. dataset) — ~120 models, all verified against the live endoflife API.
A re-release that keeps the base code gets a suffixed row above it
(Tab A9+ 2025 = SM-X216R/SM-X210R; SM-X216B/U and SM-X210 are 2023).
A device is one model, so the mobile slugs supersede: a resolved model
retires any other model's finding on the asset even when it raises none
itself (`tests/test_galaxy_tab_a9plus_2025.py`).
- **OS-level CVEs** — iOS/iPadOS/macOS via NVD-CPE (`app_cve_scanner_service`), - **OS-level CVEs** — iOS/iPadOS/macOS via NVD-CPE (`app_cve_scanner_service`),
with a platform check (CPE `target_sw` token) so e.g. a Firefox-for-iOS with a platform check (CPE `target_sw` token) so e.g. a Firefox-for-iOS
CVE can't match a desktop Firefox install. NVD often has no configuration CVE can't match a desktop Firefox install. NVD often has no configuration
+3
View File
@@ -938,6 +938,9 @@ _SINGLE_RELEASE_SLUGS = {
# version, so an 11 -> 12 migration must retire the OS 11 finding instead # version, so an 11 -> 12 migration must retire the OS 11 finding instead
# of leaving it open next to the OS 12 asset it no longer describes. # of leaving it open next to the OS 12 asset it no longer describes.
"igel-os", "igel-ums", "igel-os", "igel-ums",
# A phone or tablet IS one model; a finding for any other model on the
# same asset came from an earlier, wrong model mapping.
"samsung-mobile", "samsung-galaxy-tab", "iphone", "ipad",
} }
+31 -4
View File
@@ -36,9 +36,10 @@ logger = logging.getLogger(__name__)
_PHONE = "samsung-mobile" _PHONE = "samsung-mobile"
_TAB = "samsung-galaxy-tab" _TAB = "samsung-galaxy-tab"
# SM-base-code (4 digits) → (endoflife slug, release `name`). Codes are # SM-base-code (4 digits) → (endoflife slug, release `name`). Codes are
# unique per model, so no prefix collides with another. Slug per row routes # unique per model, except a re-release that keeps the base code and only
# tablets (SM-X/T/P) to samsung-galaxy-tab. Names verified against the live # changes the suffix: its row carries the suffix and sits ABOVE the base row
# endoflife.date API. # (first prefix wins). 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]] = [ _SAMSUNG: list[Tuple[str, str, str]] = [
# --- Galaxy S25 / S24 / S23 / S22 (base / + / Ultra / FE) --- # --- 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-S938", _PHONE, "galaxy-s25-ultra"), ("SM-S936", _PHONE, "galaxy-s25+"), ("SM-S931", _PHONE, "galaxy-s25"), ("SM-S731", _PHONE, "galaxy-s25-fe"),
@@ -72,6 +73,9 @@ _SAMSUNG: list[Tuple[str, str, str]] = [
("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"), ("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) --- # --- 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-X236", _TAB, "galaxy-tab-a11+"), ("SM-X230", _TAB, "galaxy-tab-a11+"), ("SM-X135", _TAB, "galaxy-tab-a11"), ("SM-X130", _TAB, "galaxy-tab-a11"),
# Tab A9+ (2025) re-release is SM-X216R / SM-X210R; the 2023 one is
# SM-X216B/U and SM-X210 (EOS 2026, vs 2031 for the 2025 model).
("SM-X216R", _TAB, "galaxy-tab-a9+2025"), ("SM-X210R", _TAB, "galaxy-tab-a9+2025"),
("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-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"), ("SM-T350", _TAB, "galaxy-tab-a-8.0-2015"), ("SM-T280", _TAB, "galaxy-tab-a-7.0-2016"),
# --- Galaxy Tab Active (rugged tablets) --- # --- Galaxy Tab Active (rugged tablets) ---
@@ -79,6 +83,18 @@ _SAMSUNG: list[Tuple[str, str, str]] = [
("SM-T395", _TAB, "galaxy-tab-active2"), ("SM-T365", _TAB, "galaxy-tab-active-lte"), ("SM-T360", _TAB, "galaxy-tab-active"), ("SM-T395", _TAB, "galaxy-tab-active2"), ("SM-T365", _TAB, "galaxy-tab-active-lte"), ("SM-T360", _TAB, "galaxy-tab-active"),
] ]
# Apple names (normalised) that endoflife.date spells differently → release
# `name`. The first iPhone SE, iPad Air, iPad mini and iPad Pro of each size
# (pro-1-<size>) have no generation in Apple's name; endoflife adds "(1st
# generation)". Exact keys only — no fuzzy fallback.
_APPLE_ALIASES: dict[str, str] = {
"iphone-se": "se-1", "ipad-air": "air-1", "ipad-mini": "mini-1",
"ipad-pro-9-7-inch": "pro-1-9", "ipad-pro-10-5-inch": "pro-1-10",
"ipad-pro-11-inch": "pro-1-11", "ipad-pro-12-9-inch": "pro-1-12",
"ipad-pro-12-9-inch-6th-generation": "pro-6", # endoflife: "iPad Pro (6th generation)"
"iphone-air": "air", # endoflife: "17 Air"
}
_STALE_CVE_ID = "ANDROID-PATCH-LEVEL-STALE" _STALE_CVE_ID = "ANDROID-PATCH-LEVEL-STALE"
@@ -88,7 +104,8 @@ def _norm(s: Optional[str]) -> str:
def _resolve_device(manuf: str, model: str, os_name: str): def _resolve_device(manuf: str, model: str, os_name: str):
"""→ (slug, matcher) or None. matcher = {'kind':'name','name':...} for the """→ (slug, matcher) or None. matcher = {'kind':'name','name':...} for the
Samsung table, or {'kind':'apple','model':...} for Apple fuzzy match.""" Samsung table and Apple aliases, or {'kind':'apple','model':...} for Apple
fuzzy match."""
m = model or "" m = model or ""
mu = (manuf or "").lower() mu = (manuf or "").lower()
ml = m.lower() ml = m.lower()
@@ -96,6 +113,9 @@ def _resolve_device(manuf: str, model: str, os_name: str):
if "apple" in mu or ml.startswith("ipad") or ml.startswith("iphone") or "ios" in osl: 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" slug = "ipad" if ("ipad" in ml or "ipados" in osl) else "iphone"
alias = _APPLE_ALIASES.get(_norm(m))
if alias:
return slug, {"kind": "name", "name": alias}
return slug, {"kind": "apple", "model": m} return slug, {"kind": "apple", "model": m}
if "samsung" in mu or re.match(r"sm-[a-z]\d", ml): if "samsung" in mu or re.match(r"sm-[a-z]\d", ml):
@@ -282,6 +302,13 @@ def check_device(db: Session, asset, device: dict,
if row: if row:
row.package_name = f"{vendor} {rel.get('label') or model}".strip()[:255] row.package_name = f"{vendor} {rel.get('label') or model}".strip()[:255]
count += 1 count += 1
else:
# A supported model raises nothing, so the upsert's own
# supersede never runs: retire what an earlier mapping
# left here (Tab A9+ 2023 finding on the 2025 model).
eol_service._supersede_old_eol(
db, asset.id, slug,
eol_service._pseudo_cve_id(slug, status.release_name or "unknown"))
except Exception as e: except Exception as e:
logger.debug("mobile-eol device check failed for %s: %s", asset.hostname, e) logger.debug("mobile-eol device check failed for %s: %s", asset.hostname, e)
+78
View File
@@ -0,0 +1,78 @@
"""Apple model names that endoflife.date spells differently — run: python tests/test_apple_model_names.py
Intune reports Apple's marketing name and mobile_eol_service matches it
against endoflife.date's release label. Most line up after normalising
("iPad Pro (11-inch) (4th generation)" == "iPad Pro 11-inch (4th
generation)"), but not these:
- The first iPhone SE, iPad Air, iPad mini and iPad Pro of each size carry
no generation in Apple's name ("iPad Pro (9.7-inch)"); endoflife adds
"(1st generation)". Most of them are already EOL (9.7"/12.9" Pro
2025-03-31, 10.5" Pro 2026-09-17, SE 2025, Air 2023, mini 2019), so the miss hid
real findings.
- endoflife files the 12.9-inch 6th generation as "iPad Pro (6th
generation)" and the iPhone Air as "17 Air".
"""
import os
import sys
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 app.services import mobile_eol_service # noqa: E402
# endoflife.date ipad / iphone, fetched 2026-11-03 (only the fields matched on).
_IPAD = {"result": {"releases": [
{"name": "pro-1-9", "label": "iPad Pro 9.7-inch (1st generation)"},
{"name": "pro-1-10", "label": "iPad Pro 10.5-inch (1st generation)"},
{"name": "pro-1-11", "label": "iPad Pro 11-inch (1st generation)"},
{"name": "pro-1-12", "label": "iPad Pro 12.9-inch (1st generation)"},
{"name": "pro-2-12", "label": "iPad Pro 12.9-inch (2nd generation)"},
{"name": "pro-4-11", "label": "iPad Pro 11-inch (4th generation)"},
{"name": "pro-6", "label": "iPad Pro (6th generation)"},
{"name": "9", "label": "iPad (9th generation)"},
{"name": "air-1", "label": "iPad Air (1st generation)"},
{"name": "air-2", "label": "iPad Air 2"},
{"name": "mini-1", "label": "iPad Mini (1st generation)"},
]}}
_IPHONE = {"result": {"releases": [
{"name": "air", "label": "17 Air"},
{"name": "17-pro", "label": "17 Pro"},
{"name": "se-3", "label": "SE (3rd generation)"},
{"name": "se-1", "label": "SE (1st generation)"},
]}}
def _match(model):
slug, matcher = mobile_eol_service._resolve_device("Apple", model, "iOS")
rel = mobile_eol_service._find_release(_IPAD if slug == "ipad" else _IPHONE, matcher)
return rel and rel["name"]
def test_apple_names_endoflife_spells_differently():
assert _match("iPad Pro (9.7-inch)") == "pro-1-9"
assert _match("iPad Pro (10.5-inch)") == "pro-1-10"
assert _match("iPad Pro (11-inch)") == "pro-1-11"
assert _match("iPad Pro (12.9-inch)") == "pro-1-12"
assert _match("iPad Pro (12.9-inch) (6th generation)") == "pro-6"
assert _match("iPhone Air") == "air"
assert _match("iPad Air") == "air-1"
assert _match("iPad mini") == "mini-1"
assert _match("iPhone SE") == "se-1"
def test_names_that_already_matched_still_do():
assert _match("iPad Pro (12.9-inch) (2nd generation)") == "pro-2-12"
assert _match("iPad Pro (11-inch) (4th generation)") == "pro-4-11"
assert _match("iPad (9th generation)") == "9"
assert _match("iPad Air 2") == "air-2"
assert _match("iPhone 17 Pro") == "17-pro"
assert _match("iPhone SE (3rd generation)") == "se-3"
# An unknown name stays unmatched rather than guessed.
assert _match("iPad Pro (13-inch)") is None
if __name__ == "__main__":
test_apple_names_endoflife_spells_differently()
test_names_that_already_matched_still_do()
print("OK — Apple names endoflife.date spells differently still find their release")
+119
View File
@@ -0,0 +1,119 @@
"""Galaxy Tab A9+ 5G (2025) is not the 2023 Tab A9+ — run: python tests/test_galaxy_tab_a9plus_2025.py
Field report 2026-11-03: an SM-X216R showed the Tab A9+ end-of-support
finding (active support ended 2026-10-23, security 2027-10-23). SM-X216R is
the 2025 re-release (Wi-Fi twin SM-X210R) that endoflife.date tracks as its
own release `galaxy-tab-a9+2025`, supported into 2031. The 2023 codes
(SM-X216B/U, SM-X210) share the base code, so the plain prefix match took
the 2025 tablet for the 2023 one.
"""
import os
import sys
from datetime import datetime, timedelta
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")
import pytest # noqa: E402
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.asset import Asset # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus # noqa: E402
from app.services import app_cve_scanner_service, eol_service, intune_service, mobile_eol_service # noqa: E402
# endoflife.date samsung-galaxy-tab, fetched 2026-11-03.
_TABS = {"result": {"releases": [
{"name": "galaxy-tab-a9+2025", "label": "Galaxy Tab A9+(2025)",
"eolFrom": None, "eoasFrom": "2031-08-01"},
{"name": "galaxy-tab-a9+", "label": "Galaxy Tab A9+",
"eolFrom": "2027-10-23", "eoasFrom": "2026-10-23"},
]}}
def _release(model):
slug, matcher = mobile_eol_service._resolve_device("Samsung", model, "Android")
return slug, matcher["name"]
def test_model_codes_pick_their_generation():
assert _release("SM-X216R") == ("samsung-galaxy-tab", "galaxy-tab-a9+2025")
assert _release("SM-X210R") == ("samsung-galaxy-tab", "galaxy-tab-a9+2025")
assert _release("SM-X216B") == ("samsung-galaxy-tab", "galaxy-tab-a9+")
assert _release("SM-X216U") == ("samsung-galaxy-tab", "galaxy-tab-a9+")
assert _release("SM-X210") == ("samsung-galaxy-tab", "galaxy-tab-a9+")
def _device(model, dev_id):
return {
"id": dev_id, "deviceName": f"AndroidEnterprise-{dev_id}", "manufacturer": "Samsung",
"model": model, "operatingSystem": "Android", "osVersion": "15",
"serialNumber": dev_id.upper(),
"lastSyncDateTime": (datetime.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
class _FakeClient:
def get_managed_devices(self):
return [_device("SM-X216R", "tab2025"), _device("SM-X216B", "tab2023")]
def get_detected_apps(self, device_id):
return [{"name": "Microsoft Teams", "version": "1500.0.0"}]
def close(self):
pass
def test_sync_clears_the_wrong_finding_and_keeps_the_right_one():
with pytest.MonkeyPatch.context() as mp:
mp.setattr(intune_service, "_build_client", lambda cfg: _FakeClient())
mp.setattr(eol_service, "fetch_product",
lambda db, slug: _TABS if slug == "samsung-galaxy-tab" else None)
mp.setattr(app_cve_scanner_service, "scan_asset_packages", lambda *a, **kw: 0)
_sync_twice()
def _sync_twice():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
cfg = {"auto_create_assets": True, "detected_apps": True}
intune_service._run_intune_sync_locked(db, cfg)
db.commit()
# Pseudo-CVE id of the 2023 release ("+" is stored as "_").
a9_2023_id = "EOL-SAMSUNG-GALAXY-TAB-galaxy-tab-a9_"
tab2023 = db.query(Asset).filter(Asset.intune_device_id == "tab2023").one()
tab2025 = db.query(Asset).filter(Asset.intune_device_id == "tab2025").one()
old = db.query(Vulnerability).filter_by(asset_id=tab2023.id, cve_id=a9_2023_id).one()
assert old.status == VulnerabilityStatus.open
# What the old mapping left on the 2025 tablet must close on the next sync.
wrong = Vulnerability(cve_id=a9_2023_id, asset_id=tab2025.id, status=VulnerabilityStatus.open,
severity=VulnerabilitySeverity.medium, cvss_score=5.0,
detected_at=datetime.now(), title="Galaxy Tab A9+ EOS",
first_detected_by="eol_check", sources='["eol_check"]')
db.add(wrong)
db.commit()
# Without detectedApps (off, empty, or a co-managed device) nothing
# reconciles against an app list — the model check must retire it itself.
intune_service._run_intune_sync_locked(db, {**cfg, "detected_apps": False})
db.commit()
db.refresh(wrong)
db.refresh(old)
assert wrong.status == VulnerabilityStatus.patched, "2025 tablet still carries the 2023 EOS finding"
assert old.status == VulnerabilityStatus.open
open_on_2025 = [v.cve_id for v in db.query(Vulnerability).filter_by(
asset_id=tab2025.id, status=VulnerabilityStatus.open)]
assert not [c for c in open_on_2025 if c.startswith("EOL-")], open_on_2025
if __name__ == "__main__":
test_model_codes_pick_their_generation()
test_sync_clears_the_wrong_finding_and_keeps_the_right_one()
print("OK — Galaxy Tab A9+ (2025) is told apart from the 2023 model")