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.
317 lines
12 KiB
Python
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
|