Compare commits
2
Commits
d87d781a64
...
b8be1c4af8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8be1c4af8 | ||
|
|
1d7f647ec3 |
@@ -39,6 +39,23 @@ def load_intune_config(db: Session) -> Optional[dict]:
|
||||
return cfg
|
||||
|
||||
|
||||
# A device that hasn't checked in for this long carries an app inventory the
|
||||
# app-CVE scan should not be trusted on. ponytail: reported, not enforced —
|
||||
# suppressing findings needs a product decision.
|
||||
_STALE_CHECKIN_DAYS = 7
|
||||
|
||||
|
||||
def _parse_graph_dt(value) -> Optional[datetime]:
|
||||
"""Graph ISO-8601 UTC ('2026-08-12T07:37:00Z') → naive local-ish datetime."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.astimezone().replace(tzinfo=None) if dt.tzinfo else dt
|
||||
|
||||
|
||||
def _build_client(cfg: dict):
|
||||
from app.integrations.graph_client import GraphClient
|
||||
return GraphClient(
|
||||
@@ -202,7 +219,7 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
client = _build_client(cfg)
|
||||
stats = {
|
||||
"devices": 0, "assets_matched": 0, "assets_created": 0,
|
||||
"os_eol_findings": 0, "app_findings": 0,
|
||||
"os_eol_findings": 0, "app_findings": 0, "stale_devices": 0,
|
||||
"assets_inactivated": 0, "assets_reactivated": 0, "errors": [],
|
||||
}
|
||||
seen_asset_ids: set = set()
|
||||
@@ -233,6 +250,15 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
if device.get("osVersion"):
|
||||
asset.os_version = str(device["osVersion"])[:100]
|
||||
asset.last_scan = datetime.now()
|
||||
# Data freshness: Intune's inventory is only as fresh as the last
|
||||
# device check-in (detectedApps refreshes every 7 days, Win32/IME
|
||||
# every 24h) — record it so a stale app list is visible, not hidden
|
||||
# behind our own sync timestamp.
|
||||
checkin = _parse_graph_dt(device.get("lastSyncDateTime"))
|
||||
if checkin:
|
||||
asset.last_seen = checkin
|
||||
if (datetime.now() - checkin).days >= _STALE_CHECKIN_DAYS:
|
||||
stats["stale_devices"] = stats.get("stale_devices", 0) + 1
|
||||
db.flush()
|
||||
if asset.id:
|
||||
seen_asset_ids.add(asset.id)
|
||||
|
||||
@@ -358,8 +358,8 @@ export default function CompliancePage() {
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No URS data — recompute first.</td></tr>
|
||||
)}
|
||||
{ursRows.map(r => (
|
||||
<tr key={r.asset_id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 text-truevuln-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
|
||||
<tr key={r.asset_id} className="hover:bg-gray-50 cursor-pointer" onClick={() => openAsset(r.asset_id)}>
|
||||
<td className="px-4 py-2 text-truevuln-blue font-bold hover:underline">{r.hostname || `#${r.asset_id}`}</td>
|
||||
<td className="px-4 py-2 text-gray-700 uppercase text-xs">{r.criticality || 'normal'}<span className="text-gray-400 ml-1">×{r.criticality_factor ?? 1.0}</span></td>
|
||||
<td className="px-4 py-2 text-gray-700">{r.avs !== null ? r.avs.toFixed(1) : '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-700">{r.ass !== null ? r.ass.toFixed(1) : '—'}</td>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Is Intune's new App inventory reachable for this tenant — and what shape?
|
||||
|
||||
We read software inventory from `detectedApps` (Discovered apps). That report
|
||||
refreshes only every 7 days per device (Win32/IME apps every 24h), so the
|
||||
app-CVE scan can run against a week-old version string — an Edge that reads
|
||||
151.0.4129.59 here while the device already runs .78.
|
||||
|
||||
Microsoft's replacement is App inventory (multiple collections per day, richer
|
||||
metadata). Its Graph surface is NOT in the beta API reference yet; the only
|
||||
known path is community-reported:
|
||||
|
||||
GET /beta/deviceManagement/managedDevices/{id}/deviceInventories('ApplicationProperties')
|
||||
|
||||
and it needs the app-inventory device configuration policy assigned (Windows
|
||||
10/11, Entra joined) or it returns nothing. This probe answers three things
|
||||
before we write any mapping code: does it authorise, does it return data, and
|
||||
what are the field names.
|
||||
|
||||
docker compose exec backend python tools/probe_intune_app_inventory.py [deviceName]
|
||||
|
||||
Prints the raw first page (truncated) plus the detectedApps count for the same
|
||||
device, so old and new can be compared side by side.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
BETA = "https://graph.microsoft.com/beta"
|
||||
|
||||
|
||||
def main():
|
||||
from app.database import SessionLocal
|
||||
from app.services.intune_service import load_intune_config, _build_client
|
||||
|
||||
wanted = sys.argv[1].lower() if len(sys.argv) > 1 else None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cfg = load_intune_config(db)
|
||||
finally:
|
||||
db.close()
|
||||
if not cfg:
|
||||
print("Intune is not configured (intune_config missing/incomplete).")
|
||||
return 2
|
||||
|
||||
client = _build_client(cfg)
|
||||
try:
|
||||
devices = client.get_managed_devices()
|
||||
windows = [d for d in devices
|
||||
if "windows" in (d.get("operatingSystem") or "").lower()]
|
||||
if wanted:
|
||||
windows = [d for d in windows
|
||||
if wanted in (d.get("deviceName") or "").lower()]
|
||||
if not windows:
|
||||
print("No matching Windows device found.")
|
||||
return 1
|
||||
|
||||
dev = windows[0]
|
||||
print(f"device : {dev.get('deviceName')} ({dev.get('id')})")
|
||||
print(f"osVersion : {dev.get('osVersion')}")
|
||||
print(f"lastSync : {dev.get('lastSyncDateTime')} <- age of ALL inventory below")
|
||||
|
||||
old = client.get_detected_apps(dev["id"])
|
||||
print(f"\ndetectedApps: {len(old)} apps (Discovered apps, <=7d old)")
|
||||
for a in old[:5]:
|
||||
print(f" - {a['name']} {a['version']}")
|
||||
|
||||
url = (f"{BETA}/deviceManagement/managedDevices/{dev['id']}"
|
||||
f"/deviceInventories('ApplicationProperties')")
|
||||
print(f"\nApp inventory: GET {url}")
|
||||
try:
|
||||
data = client._get(url)
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
print(" 403 -> app registration lacks the permission;"
|
||||
" 404/empty -> policy not assigned or endpoint not in this tenant.")
|
||||
return 1
|
||||
print(json.dumps(data, indent=2)[:4000])
|
||||
return 0
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user