Compare commits
2
Commits
dc17419824
...
0f372e36a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f372e36a2 | ||
|
|
0a081faa19 |
+10
-1
@@ -2248,10 +2248,19 @@ Runs inline during the Intune sync — no extra Graph calls beyond the
|
||||
device dict and `detectedApps` already fetched.
|
||||
|
||||
- **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
|
||||
between Intune's model code and endoflife's marketing name in either
|
||||
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`),
|
||||
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
|
||||
|
||||
@@ -938,6 +938,9 @@ _SINGLE_RELEASE_SLUGS = {
|
||||
# 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.
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,9 +36,10 @@ logger = logging.getLogger(__name__)
|
||||
_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.
|
||||
# unique per model, except a re-release that keeps the base code and only
|
||||
# changes the suffix: its row carries the suffix and sits ABOVE the base row
|
||||
# (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]] = [
|
||||
# --- 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"),
|
||||
@@ -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"),
|
||||
# --- 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"),
|
||||
# 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-T350", _TAB, "galaxy-tab-a-8.0-2015"), ("SM-T280", _TAB, "galaxy-tab-a-7.0-2016"),
|
||||
# --- 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"),
|
||||
]
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -88,7 +104,8 @@ def _norm(s: Optional[str]) -> str:
|
||||
|
||||
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."""
|
||||
Samsung table and Apple aliases, or {'kind':'apple','model':...} for Apple
|
||||
fuzzy match."""
|
||||
m = model or ""
|
||||
mu = (manuf or "").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:
|
||||
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}
|
||||
|
||||
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:
|
||||
row.package_name = f"{vendor} {rel.get('label') or model}".strip()[:255]
|
||||
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:
|
||||
logger.debug("mobile-eol device check failed for %s: %s", asset.hostname, e)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user