Files
vulncheck/app/services/intune_service.py
T
vulncheck ee4e1d9c8b fix(intune): serialise sync with a Postgres advisory lock (deadlock fix)
Two overlapping Intune/Defender syncs updated the same asset rows in different
orders -> 'deadlock detected' during flush. The router's _INTUNE_SYNC_RUNNING
flag is per-worker (Gunicorn has several) and the nightly scheduler runs in its
own context, so neither guards cross-process. Wrap the whole sync in
pg_try_advisory_lock: a second concurrent caller is now cleanly skipped instead
of deadlocking. Lock is released (rollback-safe) in finally; the nested Defender
sync shares the same locked session.
2026-07-09 13:40:50 +02:00

317 lines
12 KiB
Python

"""
Microsoft Intune (MDM/UEM) inventory sync.
Pulls Intune managed devices via Microsoft Graph (app-only) and registers
them as assets (source=INTUNE), then runs OS-level EOL detection on each —
the same find-or-create + lifecycle-reconcile pattern as the Nessus sync.
Phase 2 (detectedApps → EOL/M365 per installed app) hooks in here too.
"""
import json
import logging
import re
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
logger = logging.getLogger(__name__)
SOURCE_NAME = "intune"
SETTING_KEY = "intune_config"
def load_intune_config(db: Session) -> Optional[dict]:
"""Decrypt + parse intune_config, or None when not configured."""
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, SETTING_KEY)
if not raw:
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("intune_config is not valid JSON")
return None
if not all([cfg.get("tenant_id"), cfg.get("client_id"), cfg.get("client_secret")]):
return None
return cfg
def _build_client(cfg: dict):
from app.integrations.graph_client import GraphClient
return GraphClient(
tenant_id=cfg["tenant_id"],
client_id=cfg["client_id"],
client_secret=cfg["client_secret"],
verify_ssl=cfg.get("verify_ssl", True),
)
# Intune's `deviceName` is the Entra/management name for supervised / userless
# / ABM iOS devices — a "<enrollment-GUID>_<Model>_<M/D/YYYY>_<time>" blob.
# Detect it so we can show a readable, stable name instead.
_MGMT_NAME_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_", re.I)
def _clean_device_name(device: dict) -> str:
"""Readable hostname. Graph fills `deviceName` with the management-name
GUID blob for supervised/userless iOS; compose a stable name from
model + serial instead. Falls back to whatever's there."""
name = (device.get("deviceName") or "").strip()
if name and not _MGMT_NAME_RE.match(name):
return name
model = (device.get("model") or "").strip()
serial = (device.get("serialNumber") or "").strip()
if model and serial:
return f"{model}-{serial}"[:255]
if model and device.get("id"):
return f"{model}-{device['id'][:8]}"[:255]
return name # nothing better available
def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
"""Match an Intune device to an asset by stable id
(intune_device_id → aad_device_id → hostname), else auto-create. Matching
on ids first makes device renames (very common in autodeployments) a no-op
— the asset is found by id and its hostname refreshed."""
device_id = (device.get("id") or "").strip() or None
aad_id = (device.get("azureADDeviceId") or "").strip() or None
hostname = _clean_device_name(device)
def _pin(a):
# Backfill both ids on whatever we matched, so future syncs (and the
# Defender sync) converge on this one asset.
if device_id and not a.intune_device_id:
a.intune_device_id = device_id
if aad_id and not a.aad_device_id:
a.aad_device_id = aad_id
if device_id:
a = db.query(Asset).filter(Asset.intune_device_id == device_id).first()
if a:
_pin(a)
return a, "intune_id"
if aad_id:
a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
if a:
_pin(a)
return a, "aad_id"
short = hostname.split(".")[0] if hostname else ""
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
if a:
_pin(a)
return a, "hostname"
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
if auto_create and hostname:
a = Asset(
hostname=short or hostname,
intune_device_id=device_id,
aad_device_id=aad_id,
source=AssetSource.INTUNE,
status=AssetStatus.ACTIVE,
)
db.add(a)
db.flush()
logger.info("Intune sync: auto-created asset %s", a.hostname)
return a, "created"
return None, "skipped"
def _os_string(device: dict) -> str:
"""Intune operatingSystem is short ('Windows'/'macOS'); prefix so the
EOL OS resolver recognises it like a Wazuh OS string."""
os_name = (device.get("operatingSystem") or "").strip()
if os_name.lower() == "windows":
return "Microsoft Windows"
return os_name
# Cross-process guard: only one Intune/Defender sync may touch the asset rows
# at a time. The router's module-level flag is per-worker, and the nightly
# scheduler runs in yet another context — two overlapping syncs update the same
# assets in different orders → Postgres deadlock. A Postgres advisory lock is
# global to the DB, so it serialises every caller.
_SYNC_ADVISORY_LOCK_KEY = 0x54560101 # arbitrary constant ("TV" + 01)
def _try_sync_lock(db: Session) -> bool:
from sqlalchemy import text
return bool(db.execute(text("SELECT pg_try_advisory_lock(:k)"),
{"k": _SYNC_ADVISORY_LOCK_KEY}).scalar())
def _release_sync_lock(db: Session) -> None:
from sqlalchemy import text
try:
db.rollback() # unlock must run outside a failed (deadlocked) txn
except Exception:
pass
try:
db.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": _SYNC_ADVISORY_LOCK_KEY})
db.commit()
except Exception:
pass
def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
"""Sync Intune managed devices → assets + OS-EOL. Returns stats."""
cfg = load_intune_config(db)
if not cfg:
raise RuntimeError("Intune is not configured (settings.intune_config missing/incomplete).")
if not _try_sync_lock(db):
logger.warning("Intune sync skipped — another Intune/Defender sync holds the lock")
return {"skipped": "another sync already running"}
try:
return _run_intune_sync_locked(db, cfg)
finally:
_release_sync_lock(db)
def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
from app.services import eol_service
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids
auto_create = bool(cfg.get("auto_create_assets", True))
detected_apps_enabled = bool(cfg.get("detected_apps", True))
client = _build_client(cfg)
stats = {
"devices": 0, "assets_matched": 0, "assets_created": 0,
"os_eol_findings": 0, "app_findings": 0,
"assets_inactivated": 0, "assets_reactivated": 0, "errors": [],
}
seen_asset_ids: set = set()
try:
devices = client.get_managed_devices()
except Exception as e:
raise RuntimeError(f"Graph managedDevices fetch failed: {e}") from e
for device in devices:
stats["devices"] += 1
try:
asset, how = _find_or_create_asset(db, device, auto_create)
if not asset:
continue
if how == "created":
stats["assets_created"] += 1
else:
stats["assets_matched"] += 1
# Rename tracking: matched by a stable id → adopt the current
# (cleaned) device name so autodeploy renames propagate.
new_name = _clean_device_name(device)
if how in ("intune_id", "aad_id") and new_name and asset.hostname != new_name:
asset.hostname = new_name.split(".")[0] or new_name
# refresh inventory fields
os_name = _os_string(device)
if os_name:
asset.operating_system = os_name[:255]
if device.get("osVersion"):
asset.os_version = str(device["osVersion"])[:100]
asset.last_scan = datetime.now()
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
# OS-level EOL
try:
os_status = eol_service.check_os_eol(db, asset.operating_system or "", asset.os_version or "")
if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
eol_service.upsert_eol_vulnerability(
db, asset_id=asset.id,
product_name=(asset.operating_system or "Operating System").strip(),
installed_version=(asset.os_version or os_status.release_name or "unknown"),
status=os_status,
)
stats["os_eol_findings"] += 1
except Exception as e:
logger.warning("Intune OS-EOL failed for %s: %s", asset.hostname, e)
# Mobile device EOL/EOS (model) + Android patch-level staleness.
try:
from app.services import mobile_eol_service
stats["mobile_eol_findings"] = (
stats.get("mobile_eol_findings", 0)
+ mobile_eol_service.check_device(db, asset, device)
)
except Exception as e:
logger.debug("Intune mobile-EOL failed for %s: %s", asset.hostname, e)
# Phase 2 — detected apps → existing EOL/M365 per-package detection
if detected_apps_enabled and device.get("id"):
try:
pkgs = client.get_detected_apps(device["id"])
if pkgs:
stats["app_findings"] += _run_app_inventory(db, asset, pkgs)
except Exception as e:
logger.debug("Intune detectedApps failed for %s: %s", asset.hostname, e)
except Exception as e:
stats["errors"].append(f"device {device.get('deviceName')}: {e}")
db.commit()
# Lifecycle reconcile (event-driven, id-keyed).
try:
recon = reconcile_intune_by_seen_ids(
db, seen_asset_ids=seen_asset_ids,
reason="not reported by the latest Intune sync",
)
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
db.commit()
except Exception as e:
logger.warning("Intune reconcile failed: %s", e)
client.close()
# Phase 3 — Defender for Endpoint TVM real CVEs (opt-in, separate API).
if cfg.get("defender_tvm"):
try:
from app.services.defender_service import run_defender_sync
dstats = run_defender_sync(db)
stats["defender"] = {k: v for k, v in dstats.items() if k != "errors"}
except Exception as e:
logger.warning("Defender TVM sync failed (non-fatal): %s", e)
logger.info(
"Intune sync done: %d devices, %d matched, %d created, %d OS-EOL, "
"%d app findings, %d inactivated, %d reactivated",
stats["devices"], stats["assets_matched"], stats["assets_created"],
stats["os_eol_findings"], stats["app_findings"],
stats["assets_inactivated"], stats["assets_reactivated"],
)
return stats
def _run_app_inventory(db: Session, asset, packages: list) -> int:
"""Feed Intune detectedApps into the existing EOL + M365 detection.
Returns number of findings upserted (best-effort)."""
count = 0
try:
from app.services import eol_service
count += eol_service.run_eol_for_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import m365_service
count += m365_service.run_m365_for_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import app_cve_scanner_service
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
return count