Answers 'is vendor readable via the Intune/Defender API?' — yes: - Intune detectedApps.publisher was being dropped in _map_apps; now kept as the package vendor. - Defender softwareVendor was folded into the package label; now stored separately. - Wazuh syscollector already carries vendor; now threaded through. New vulnerabilities.package_vendor column (migration 038, idempotent), populated at the package-finding chokepoints (app_cve_scanner._upsert + cvelistv5 scan_asset via pkg vendor, defender _upsert_cve) and shown in the Affected Package card on the CVE detail page. Scope: single-package findings. Per-package (vulnerability_packages) and Nessus/m365 vendor left as follow-up — Nessus rolls vendor into the plugin name and m365/OS vendor is implicit (Microsoft).
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""
|
|
Microsoft Defender for Endpoint (TVM) → real per-device CVEs.
|
|
|
|
Phase 3 of the Intune integration. Reuses the Entra app from intune_config
|
|
(toggle `defender_tvm`) but talks to the Defender API. Maps each Defender
|
|
machine to an existing asset (by computerDnsName) and upserts REAL CVE
|
|
rows (source='defender'), which then enrich via the normal EPSS/KEV/
|
|
cvelistV5 + multi-source-remediation paths.
|
|
"""
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.asset import Asset
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SOURCE_NAME = "defender"
|
|
|
|
_SEV_MAP = {
|
|
"critical": "critical", "high": "high", "medium": "medium",
|
|
"low": "low", "informational": "none", "none": "none",
|
|
}
|
|
|
|
|
|
def _severity(raw: Optional[str]):
|
|
from app.models.vulnerability import VulnerabilitySeverity
|
|
return {
|
|
"critical": VulnerabilitySeverity.critical,
|
|
"high": VulnerabilitySeverity.high,
|
|
"medium": VulnerabilitySeverity.medium,
|
|
"low": VulnerabilitySeverity.low,
|
|
"none": VulnerabilitySeverity.none,
|
|
}.get(_SEV_MAP.get((raw or "").lower(), "medium"), VulnerabilitySeverity.medium)
|
|
|
|
|
|
def _match_asset(db: Session, machine: dict):
|
|
"""Match a Defender machine to an asset by stable id
|
|
(defender_machine_id → aad_device_id → computerDnsName). Matching on the
|
|
Entra/AAD device id first merges the machine onto the SAME asset the Intune
|
|
sync created (usually the cleaner name), instead of forking a second asset
|
|
off Defender's management-name computerDnsName."""
|
|
mid = (machine.get("id") or "").strip() or None
|
|
aad_id = (machine.get("aadDeviceId") or "").strip() or None
|
|
dns = (machine.get("computerDnsName") or "").strip()
|
|
|
|
def _pin(a):
|
|
if mid and not a.defender_machine_id:
|
|
a.defender_machine_id = mid
|
|
if aad_id and not a.aad_device_id:
|
|
a.aad_device_id = aad_id
|
|
|
|
if mid:
|
|
a = db.query(Asset).filter(Asset.defender_machine_id == mid).first()
|
|
if a:
|
|
_pin(a)
|
|
return a
|
|
if aad_id:
|
|
a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
|
|
if a:
|
|
_pin(a)
|
|
return a
|
|
short = dns.split(".")[0] if dns else ""
|
|
for cand in [c for c in (dns, short) if c]:
|
|
a = db.query(Asset).filter(Asset.hostname.ilike(cand)).first()
|
|
if a:
|
|
_pin(a)
|
|
return a
|
|
if short:
|
|
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
|
if a:
|
|
_pin(a)
|
|
return a
|
|
return None
|
|
|
|
|
|
def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None,
|
|
vendor: Optional[str] = None) -> None:
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
cve_id = (vuln.get("id") or "").strip().upper()
|
|
if not cve_id.startswith("CVE-"):
|
|
return
|
|
cvss = vuln.get("cvssV3")
|
|
try:
|
|
cvss = float(cvss) if cvss is not None else None
|
|
except (TypeError, ValueError):
|
|
cvss = None
|
|
sev = _severity(vuln.get("severity"))
|
|
|
|
existing = (
|
|
db.query(Vulnerability)
|
|
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
|
.first()
|
|
)
|
|
if existing:
|
|
existing.add_source(SOURCE_NAME)
|
|
# Fill the affected-software/package column if it was empty.
|
|
if software and not existing.package_name:
|
|
existing.package_name = software[:255]
|
|
if vendor and not existing.package_vendor:
|
|
existing.package_vendor = vendor[:255]
|
|
if existing.status == VulnerabilityStatus.patched:
|
|
existing.status = VulnerabilityStatus.open
|
|
existing.patched_at = None
|
|
try:
|
|
existing.refresh_scores()
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
row = Vulnerability(
|
|
cve_id=cve_id,
|
|
asset_id=asset.id,
|
|
cvss_score=cvss,
|
|
severity=sev,
|
|
status=VulnerabilityStatus.open,
|
|
title=(vuln.get("name") or cve_id)[:500],
|
|
description=(vuln.get("description") or None),
|
|
package_name=(software[:255] if software else None),
|
|
package_vendor=(vendor[:255] if vendor else None),
|
|
detected_at=datetime.now(),
|
|
sources=json.dumps([SOURCE_NAME]),
|
|
first_detected_by=SOURCE_NAME,
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
try:
|
|
row.refresh_scores()
|
|
except Exception:
|
|
pass
|
|
new_ids.append(row.id)
|
|
|
|
|
|
def _resolve_stale(db: Session, asset, seen_cves: set) -> int:
|
|
"""Mark defender-only OPEN findings on this asset patched when Defender no
|
|
longer reports them (device remediated). Leaves findings any other scanner
|
|
still reports. Writes a revisionssicher status-change row per resolve."""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
rows = (db.query(Vulnerability)
|
|
.filter(Vulnerability.asset_id == asset.id,
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.sources.contains('"defender"'))
|
|
.all())
|
|
resolved = 0
|
|
for v in rows:
|
|
if v.cve_id in seen_cves:
|
|
continue
|
|
# Drop OUR source; close only when nobody else still reports it (same
|
|
# contract as the Nessus backfill — the old skip-if-cross-confirmed
|
|
# rule deadlocked with the app-scan reconcile and left patched hosts
|
|
# with permanently open cross-confirmed findings).
|
|
v.remove_source(SOURCE_NAME)
|
|
if v.source_list:
|
|
continue
|
|
old_status = v.status
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
resolved += 1
|
|
try:
|
|
from app.routers.vulnerabilities import log_vulnerability_change
|
|
log_vulnerability_change(
|
|
db, None, v.id, old_status, v.status,
|
|
reason=f"Defender TVM no longer reports this CVE on {asset.hostname} (device remediated)",
|
|
cve_id=v.cve_id, source="defender_sync",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("audit log for defender auto-resolve failed (vuln_id=%s): %s", v.id, e)
|
|
return resolved
|
|
|
|
|
|
def run_defender_sync(db: Session) -> dict:
|
|
"""Pull Defender TVM CVEs and upsert per matched asset. Returns stats."""
|
|
from app.services.intune_service import load_intune_config
|
|
from app.integrations.defender_client import DefenderClient
|
|
|
|
cfg = load_intune_config(db)
|
|
if not cfg or not cfg.get("defender_tvm"):
|
|
return {"skipped": "defender_tvm disabled"}
|
|
|
|
client = DefenderClient(cfg["tenant_id"], cfg["client_id"], cfg["client_secret"],
|
|
verify_ssl=cfg.get("verify_ssl", True))
|
|
stats = {"machines": 0, "matched": 0, "unmatched": 0, "cve_rows": 0, "new": 0, "errors": []}
|
|
new_ids: list = []
|
|
try:
|
|
machines = client.get_machines()
|
|
except Exception as e:
|
|
client.close()
|
|
raise RuntimeError(f"Defender machines fetch failed: {e}") from e
|
|
|
|
# (machineId, CVE) → affected software string. One tenant-wide export
|
|
# call; the per-machine /vulnerabilities endpoint omits software.
|
|
sw_map: dict = {}
|
|
try:
|
|
for r in client.get_software_vulnerabilities_by_machine():
|
|
mid = (r.get("deviceId") or r.get("machineId") or "").strip()
|
|
cve = (r.get("cveId") or "").strip().upper()
|
|
if not mid or not cve:
|
|
continue
|
|
vendor = (r.get("softwareVendor") or r.get("productVendor") or "").strip()
|
|
name = (r.get("softwareName") or r.get("productName") or "").strip()
|
|
ver = (r.get("softwareVersion") or r.get("productVersion") or "").strip()
|
|
label = " ".join(x for x in (vendor, name, ver) if x).strip()
|
|
if label and (mid, cve) not in sw_map:
|
|
sw_map[(mid, cve)] = {"label": label, "vendor": vendor or None}
|
|
except Exception as e:
|
|
logger.debug("defender software map build failed: %s", e)
|
|
|
|
for m in machines:
|
|
stats["machines"] += 1
|
|
asset = _match_asset(db, m)
|
|
if not asset:
|
|
stats["unmatched"] += 1
|
|
continue
|
|
stats["matched"] += 1
|
|
seen_cves: set = set()
|
|
try:
|
|
vulns = client.get_machine_vulnerabilities(m["id"])
|
|
for v in vulns:
|
|
cve = (v.get("id") or "").strip().upper()
|
|
if cve:
|
|
seen_cves.add(cve)
|
|
sw = sw_map.get((m.get("id", ""), cve)) or {}
|
|
_upsert_cve(db, asset, v, new_ids,
|
|
software=sw.get("label"), vendor=sw.get("vendor"))
|
|
stats["cve_rows"] += 1
|
|
# Auto-resolve defender-only findings this machine no longer reports.
|
|
# Guarded to non-empty responses so a transient/clean read can't
|
|
# mass-close (same safety as the Nessus/app-scan backfills).
|
|
if seen_cves:
|
|
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale(db, asset, seen_cves)
|
|
except Exception as e:
|
|
stats["errors"].append(f"machine {m.get('computerDnsName')}: {e}")
|
|
db.commit()
|
|
|
|
client.close()
|
|
stats["new"] = len(new_ids)
|
|
|
|
# Initial detected-audit + metric/date enrichment for the new CVEs.
|
|
if new_ids:
|
|
try:
|
|
from app.services.audit_events import audit_new_vulnerabilities
|
|
audit_new_vulnerabilities(db, new_ids, source="defender")
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.debug("defender detected-audit failed: %s", e)
|
|
try:
|
|
from app.models.vulnerability import Vulnerability
|
|
from app.services.enrichment_service import enrich_vulnerabilities
|
|
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
|
|
if fresh:
|
|
enrich_vulnerabilities(db, fresh)
|
|
# New-CVE email notifications (was Wazuh/Nessus-only). Same path.
|
|
from app.services.email_service import dispatch_new_vuln_notifications
|
|
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
|
|
except Exception as e:
|
|
logger.debug("defender enrichment/notify failed: %s", e)
|
|
|
|
logger.info("Defender TVM sync: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
return stats
|