Files
vulncheck/app/services/defender_service.py
T
vulncheck 97562abed0 feat(vendor): capture + show software vendor from Intune/Defender/Wazuh
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).
2026-07-23 14:06:05 +02:00

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