An iPhone got CVE-2021-22879 (desktop client < 3.1.3) for Intune's "Nextcloud 0 (35.2.1)". Two causes together: the desktop row matches the plain name "Nextcloud", which Intune also gives the iOS and Android apps, and Intune states iOS app versions as "build (version)", so the scan read build 0, which sits below every bound. The desktop CVEs (NVD nextcloud:desktop and the nextcloud/security-advisories feed) now apply on Windows and macOS only, as FileZilla's do. Intune versions of the form "<integer> (<dotted version>)" are read as the version in brackets. Open findings on mobile devices close on the next app scan.
272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""
|
|
Microsoft Graph API client (app-only / client-credentials).
|
|
|
|
Used to pull Intune (MDM/UEM) inventory — managed devices and their
|
|
detected apps — as a third asset/software source next to Wazuh and Nessus.
|
|
|
|
Auth: client-credentials. A bearer token is fetched from
|
|
login.microsoftonline.com and cached until shortly before expiry, mirroring
|
|
the token-cache pattern in app/integrations/wazuh_client.py. No msal /
|
|
azure-identity dependency — plain httpx.
|
|
|
|
Required Entra app (Application) permissions + admin consent:
|
|
DeviceManagementManagedDevices.Read.All (devices + detected apps)
|
|
"""
|
|
import logging
|
|
import re
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
from tenacity import (
|
|
retry, stop_after_attempt, wait_exponential, retry_if_exception_type,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
|
GRAPH_BETA = "https://graph.microsoft.com/beta"
|
|
LOGIN_BASE = "https://login.microsoftonline.com"
|
|
_DETECTED_APPS_MIN_GAP = 0.3 # seconds between get_detected_apps calls on one client
|
|
GRAPH_SCOPE = "https://graph.microsoft.com/.default"
|
|
_TRANSIENT_5XX = (500, 502, 503, 504)
|
|
|
|
|
|
class GraphAPIError(Exception):
|
|
"""Base exception for Microsoft Graph errors. `status` is the HTTP code
|
|
when Graph answered with one."""
|
|
|
|
def __init__(self, message: str = "", status: Optional[int] = None):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
class GraphAuthError(GraphAPIError):
|
|
"""Token acquisition / authentication failure."""
|
|
|
|
|
|
class GraphClient:
|
|
def __init__(
|
|
self,
|
|
tenant_id: str,
|
|
client_id: str,
|
|
client_secret: str,
|
|
verify_ssl: bool = True,
|
|
):
|
|
if not all([tenant_id, client_id, client_secret]):
|
|
raise ValueError("Intune/Graph requires tenant_id, client_id and client_secret.")
|
|
self.tenant_id = tenant_id
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.verify_ssl = verify_ssl
|
|
|
|
self._token: Optional[str] = None
|
|
self._token_expires_at: Optional[datetime] = None
|
|
self._v1_detected_apps_ok = True # flips off after the first v1.0 400
|
|
self._last_detected_apps_call = 0.0 # paces get_detected_apps across a device loop
|
|
|
|
# connect fails fast (unreachable), read generous for paged calls.
|
|
self.client = httpx.Client(
|
|
verify=verify_ssl,
|
|
timeout=httpx.Timeout(30.0, connect=8.0),
|
|
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
)
|
|
|
|
# ---- auth ----------------------------------------------------------
|
|
def _authenticate(self) -> str:
|
|
url = f"{LOGIN_BASE}/{self.tenant_id}/oauth2/v2.0/token"
|
|
data = {
|
|
"grant_type": "client_credentials",
|
|
"client_id": self.client_id,
|
|
"client_secret": self.client_secret,
|
|
"scope": GRAPH_SCOPE,
|
|
}
|
|
try:
|
|
r = self.client.post(url, data=data)
|
|
if r.status_code in (400, 401):
|
|
# AAD returns error_description with the real cause.
|
|
detail = ""
|
|
try:
|
|
detail = r.json().get("error_description", "")[:200]
|
|
except Exception:
|
|
detail = r.text[:200]
|
|
raise GraphAuthError(f"token request rejected ({r.status_code}): {detail}")
|
|
r.raise_for_status()
|
|
payload = r.json()
|
|
except httpx.HTTPError as e:
|
|
raise GraphAuthError(f"token request failed: {e}") from e
|
|
|
|
token = payload.get("access_token")
|
|
if not token:
|
|
raise GraphAuthError("no access_token in token response")
|
|
expires_in = int(payload.get("expires_in", 3600))
|
|
self._token = token
|
|
# refresh 2 min before expiry
|
|
self._token_expires_at = datetime.now() + timedelta(seconds=max(60, expires_in - 120))
|
|
return token
|
|
|
|
def _ensure_token(self) -> str:
|
|
if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
|
|
return self._authenticate()
|
|
return self._token
|
|
|
|
# ---- requests ------------------------------------------------------
|
|
@retry(
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=8),
|
|
retry=retry_if_exception_type((httpx.ReadTimeout, httpx.WriteTimeout)),
|
|
)
|
|
def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
token = self._ensure_token()
|
|
for attempt in range(4):
|
|
r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json"}, params=params)
|
|
if r.status_code == 429:
|
|
# Honour Retry-After (Graph sets it); cap so a bad header can't hang us.
|
|
wait = r.headers.get("Retry-After")
|
|
delay = min(int(wait), 60) if (wait or "").isdigit() else (attempt + 1) * 5
|
|
logger.debug("Graph 429, waiting %ss (try %d)", delay, attempt + 1)
|
|
time.sleep(delay)
|
|
continue
|
|
if r.status_code in _TRANSIENT_5XX and attempt < 3:
|
|
# A per-device detectedApps call answers 500/503 now and then
|
|
# and the same call succeeds seconds later. Not retrying it
|
|
# dropped that device out of the whole run.
|
|
# ponytail: no circuit breaker — a full Graph outage costs ~12s
|
|
# per device; add one if a tenant's device count makes that hurt.
|
|
logger.info("Graph %s on %s, retrying (try %d)", r.status_code, url, attempt + 1)
|
|
time.sleep(2 * (attempt + 1))
|
|
continue
|
|
break
|
|
if r.status_code == 429:
|
|
raise GraphAPIError("Graph rate limit (429) after retries", status=429)
|
|
if r.status_code >= 400:
|
|
raise GraphAPIError(f"Graph GET {url} -> {r.status_code}: {r.text[:200]}",
|
|
status=r.status_code)
|
|
return r.json()
|
|
|
|
def _get_all(self, path: str, params: Optional[Dict[str, Any]] = None,
|
|
max_pages: int = 200, base: str = GRAPH_BASE) -> List[dict]:
|
|
"""Follow @odata.nextLink pagination, return the flattened value list."""
|
|
url = f"{base}{path}"
|
|
out: List[dict] = []
|
|
pages = 0
|
|
while url and pages < max_pages:
|
|
data = self._get(url, params=params)
|
|
out.extend(data.get("value", []) or [])
|
|
url = data.get("@odata.nextLink")
|
|
params = None # nextLink already carries the query
|
|
pages += 1
|
|
return out
|
|
|
|
# ---- API surface ---------------------------------------------------
|
|
def get_managed_devices(self) -> List[dict]:
|
|
"""All Intune managed devices.
|
|
|
|
Read from beta: only beta carries skuNumber (the Windows edition, which
|
|
decides the end of support — 11 24H2 Pro ended a year before
|
|
Enterprise). It is a list property, so it costs no extra call. Should
|
|
beta refuse the query (400: property gone or renamed), the same list
|
|
from v1.0 keeps the sync running without the edition — one extra call,
|
|
only then. Throttling, 5xx and auth errors are not retried on v1.0: a
|
|
second full read under quota pressure is the thing to avoid.
|
|
"""
|
|
fields = ("id,deviceName,operatingSystem,osVersion,complianceState,"
|
|
"lastSyncDateTime,enrolledDateTime,manufacturer,model,serialNumber,"
|
|
"managedDeviceOwnerType,azureADDeviceId,androidSecurityPatchLevel,"
|
|
"managementAgent")
|
|
try:
|
|
return self._get_all("/deviceManagement/managedDevices",
|
|
params={"$select": fields + ",skuNumber"},
|
|
base=GRAPH_BETA)
|
|
except GraphAPIError as e:
|
|
if e.status != 400:
|
|
raise
|
|
logger.warning("Graph beta managedDevices refused the query, reading "
|
|
"v1.0 without the Windows edition: %s", e)
|
|
return self._get_all("/deviceManagement/managedDevices",
|
|
params={"$select": fields})
|
|
|
|
def get_detected_apps(self, device_id: str) -> List[dict]:
|
|
"""Detected (installed) apps for one managed device → {name, version}.
|
|
|
|
Phase 2: feeds the existing EOL / M365 per-package detection.
|
|
|
|
detectedApps is not expandable on managedDevices in the v1.0 Graph
|
|
(returns HTTP 400). The reliable path is the dedicated navigation
|
|
property on the beta endpoint; we try v1.0 $expand once, then fall
|
|
back to beta's /detectedApps collection — and once v1.0 has 400'd we
|
|
skip it for the rest of this client's life (no 400 per device).
|
|
|
|
Paced: a device-by-device loop calling this in a tight sequence was
|
|
sustaining 429s from Graph (no per-call delay was enough on its own).
|
|
Enforce a minimum gap since the last call on this client.
|
|
"""
|
|
gap = time.monotonic() - self._last_detected_apps_call
|
|
if gap < _DETECTED_APPS_MIN_GAP:
|
|
time.sleep(_DETECTED_APPS_MIN_GAP - gap)
|
|
self._last_detected_apps_call = time.monotonic()
|
|
|
|
# 1) v1.0 $expand (works on some tenants) — only until it 400s once.
|
|
if self._v1_detected_apps_ok:
|
|
try:
|
|
data = self._get(
|
|
f"{GRAPH_BASE}/deviceManagement/managedDevices/{device_id}",
|
|
params={"$expand": "detectedApps"},
|
|
)
|
|
apps = data.get("detectedApps")
|
|
if apps is not None:
|
|
return self._map_apps(apps)
|
|
except GraphAPIError as e:
|
|
self._v1_detected_apps_ok = False
|
|
logger.debug("v1.0 detectedApps $expand unsupported, using beta: %s", e)
|
|
|
|
# 2) beta dedicated navigation collection (paginated). A failure
|
|
# raises: [] would read as "this device has no software", and every
|
|
# caller would skip the device without recording why.
|
|
out: List[dict] = []
|
|
url = f"{GRAPH_BETA}/deviceManagement/managedDevices/{device_id}/detectedApps"
|
|
pages = 0
|
|
while url and pages < 50:
|
|
data = self._get(url)
|
|
out.extend(self._map_apps(data.get("value", []) or []))
|
|
url = data.get("@odata.nextLink")
|
|
pages += 1
|
|
return out
|
|
|
|
@staticmethod
|
|
def _map_apps(apps: list) -> List[dict]:
|
|
out = []
|
|
for a in apps or []:
|
|
name = (a.get("displayName") or "").strip()
|
|
if name:
|
|
ver = (a.get("version") or "").strip()
|
|
# iOS apps come as "build (version)" — "0 (35.2.1)", "5771 (3.7.1)".
|
|
# The bare build is no version; read as one it sat below every bound.
|
|
m = re.fullmatch(r"\d+ \((\d+(?:\.\d+)+)\)", ver)
|
|
out.append({"name": name, "version": m.group(1) if m else ver,
|
|
"vendor": (a.get("publisher") or "").strip() or None})
|
|
return out
|
|
|
|
def test_connection(self) -> dict:
|
|
"""Acquire a token + read one device → connectivity check."""
|
|
try:
|
|
self._ensure_token()
|
|
except GraphAuthError as e:
|
|
return {"ok": False, "step": "auth", "error": str(e)}
|
|
try:
|
|
data = self._get(f"{GRAPH_BASE}/deviceManagement/managedDevices",
|
|
params={"$top": 1, "$select": "id,deviceName"})
|
|
# $count needs ConsistencyLevel; cheap proxy: did we get a page.
|
|
sample = len(data.get("value", []) or [])
|
|
return {"ok": True, "tenant": self.tenant_id, "device_sample": sample}
|
|
except GraphAPIError as e:
|
|
return {"ok": False, "step": "devices", "error": str(e)}
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
pass
|