An operator who wants only Defender TVM CVEs on Intune devices had no way to get that. Intune devices reach the app-CVE scan three ways: the Intune sync's pass over detectedApps, the Defender sync feeding its software inventory into the same pipeline, and the App-Scan job (nightly, data job, asset button) fetching detectedApps for every Intune-inventory asset. Only the first read a setting (`detected_apps`); the other two had none, so unticking it still left app-scan findings on those devices. New Intune setting `app_scan_intune` (default on, behaviour unchanged) gates all three. Off: no detectedApps fetch in the App-Scan job, no app-CVE pass in either sync. EOL, M365 and the iOS OS passes keep running, and a host with a live Wazuh agent is still scanned from syscollector. Each run retracts the app-scan source from package findings on ACTIVE Intune-inventory assets, so the findings it left behind can close; a finding with another source (e.g. defender) stays open, and the audit reason names the setting.
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""
|
|
Microsoft Intune / Graph integration HTTP endpoints.
|
|
|
|
- POST /api/v1/integrations/intune/test auth/connectivity probe
|
|
- POST /api/v1/integrations/intune/sync trigger device+inventory sync
|
|
|
|
Config lives in settings table key `intune_config` (encrypted JSON:
|
|
tenant_id, client_id, client_secret, verify_ssl, auto_create_assets,
|
|
detected_apps, defender_tvm, app_scan_intune).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
|
|
from app.services.sync_run_service import record_sync_run
|
|
from app.database import get_db, SessionLocal
|
|
from app.models.user import User
|
|
from app.services.intune_service import load_intune_config, run_intune_sync, _build_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/integrations/intune", tags=["Intune"])
|
|
|
|
# Last/current manual-sync state so the GUI can poll for completion (the sync
|
|
# itself is fire-and-forget 202). ponytail: module-level state assumes the
|
|
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
|
|
_INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
|
|
"started_at": None, "finished_at": None}
|
|
|
|
|
|
@router.post("/test", dependencies=[audited("Intune connection test")])
|
|
def test_intune(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireAdmin),
|
|
):
|
|
"""Verify the Intune/Graph credentials: acquire a token + read 1 device."""
|
|
cfg = load_intune_config(db)
|
|
if not cfg:
|
|
raise HTTPException(400, "Intune is not configured (tenant_id/client_id/client_secret missing).")
|
|
client = _build_client(cfg)
|
|
try:
|
|
return client.test_connection()
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def _run_intune_sync_threaded() -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
with record_sync_run("intune", "manual") as run:
|
|
stats = run_intune_sync(db)
|
|
run.stats.update(stats)
|
|
result = {k: v for k, v in stats.items() if k != "errors"}
|
|
_INTUNE_SYNC["result"] = result
|
|
_INTUNE_SYNC["error"] = None
|
|
logger.info("Intune sync (manual) done: %s", result)
|
|
except Exception as e:
|
|
_INTUNE_SYNC["result"] = None
|
|
_INTUNE_SYNC["error"] = str(e)
|
|
logger.error("Intune sync (manual) failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
_INTUNE_SYNC["running"] = False
|
|
_INTUNE_SYNC["finished_at"] = time.time()
|
|
|
|
|
|
@router.post("/sync", status_code=202, dependencies=[audited("Intune sync")])
|
|
def sync_intune(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireEditor),
|
|
):
|
|
"""Kick off an Intune device/inventory sync in the background (202).
|
|
|
|
Fire-and-forget so a large tenant can't trip the reverse-proxy request
|
|
timeout. Poll GET /sync/status for completion."""
|
|
if not load_intune_config(db):
|
|
raise HTTPException(400, "Intune is not configured.")
|
|
if _INTUNE_SYNC["running"]:
|
|
return {"status": "already_running", "detail": "An Intune sync is already in progress."}
|
|
_INTUNE_SYNC.update({"running": True, "result": None, "error": None,
|
|
"started_at": time.time(), "finished_at": None})
|
|
threading.Thread(target=_run_intune_sync_threaded, daemon=True).start()
|
|
return {"status": "started", "detail": "Intune sync started in the background."}
|
|
|
|
|
|
@router.get("/sync/status")
|
|
def sync_intune_status(current_user: User = Depends(RequireEditor)):
|
|
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
|
s = _INTUNE_SYNC
|
|
state = ("running" if s["running"]
|
|
else "error" if s["error"]
|
|
else "done" if s["result"]
|
|
else "idle")
|
|
return {"state": state, "running": s["running"], "result": s["result"],
|
|
"error": s["error"], "finished_at": s["finished_at"]}
|