Compare commits

2 Commits
Author SHA1 Message Date
vulncheck b8be1c4af8 fix(intune): record the device check-in, not our sync time
asset.last_scan was stamped with datetime.now() on every sync, so the
dashboard reported fresh data while the underlying detectedApps snapshot
could be seven days old (Discovered apps refreshes weekly per device,
Win32/IME every 24h). An Edge reading 151.0.4129.59 here was already .78 on
the device, and the app-CVE scan ran on the stale version without a warning.

Store lastSyncDateTime — already selected, never used — in last_seen and
count devices whose check-in is >= 7 days old in the sync stats.

Add tools/probe_intune_app_inventory.py: Intune's App inventory replaces
Discovered apps and collects several times a day, but its Graph surface is
not in the beta reference yet, so the payload shape has to come from a real
tenant before any mapping is written.
2026-08-12 14:13:34 +02:00
vulncheck 1d7f647ec3 fix(gui): make URS rows open the asset like the compliance table
The hostname was styled as a link (blue, bold) but carried no handler, so
clicking it did nothing — the identical table below already routes through
openAsset.
2026-08-12 14:13:34 +02:00
3 changed files with 115 additions and 3 deletions
+27 -1
View File
@@ -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)
+2 -2
View File
@@ -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>
+86
View File
@@ -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())