fix(audit): log auto-resolve status changes for app-scan/defender/verify

Automated patch transitions were silently flipping status to 'patched' with no
audit trail — no global audit-log row and no per-CVE Change History entry, so
the remediation was invisible (Revisionssicherheit gap the tester flagged on
CVE-2026-15131). log_vulnerability_change already writes one AuditLog row that
feeds BOTH surfaces; Wazuh/Nessus sync already called it, three paths did not:

- app-scan auto-resolve (_resolve_stale_app_findings): now logs source=app_scan
- Defender TVM had NO auto-resolve at all — add one (defender-only, guarded to
  non-empty machine responses) that logs source=defender_sync
- verify_patch_with_rescan flipped patched/patch_failed without logging — now
  records the outcome (source=verify_patch_rescan)

All automated (user_id=None) with a WHO/WHEN/WHY reason string.
This commit is contained in:
2026-07-10 08:03:33 +02:00
parent 572b6fe217
commit ebea13f740
3 changed files with 69 additions and 0 deletions
+15
View File
@@ -1277,12 +1277,27 @@ def verify_patch_with_rescan(
# 4. Prüfe ob CVE noch in Wazuh-Daten
cve_still_exists = any(v.get("cve") == vuln.cve_id for v in current_vulns)
old_status = vuln.status
if cve_still_exists:
# Patch fehlgeschlagen
vuln.status = VulnerabilityStatus.patch_failed
_reason = f"Patch verification rescan: CVE still reported by Wazuh on agent {agent_id}"
else:
# Patch erfolgreich verifiziert
vuln.status = VulnerabilityStatus.patched
vuln.patched_at = datetime.now()
_reason = f"Patch verification rescan: CVE no longer reported by Wazuh on agent {agent_id}"
# Revisionssicher: record the verify outcome (feeds audit log +
# per-CVE Change History). Background task → user_id=None.
if vuln.status != old_status:
try:
log_vulnerability_change(
db, None, vuln.id, old_status, vuln.status,
reason=_reason, cve_id=vuln.cve_id, source="verify_patch_rescan",
)
except Exception as e:
logger.warning("audit log for patch-verify failed (vuln_id=%s): %s", vuln.id, e)
db.commit()
+14
View File
@@ -545,9 +545,23 @@ def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
continue
if set(v.source_list or []) - {"app-scan"}:
continue # another scanner still reports it → not ours to close
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
# Revisionssicher: one status-change row (feeds both the global audit
# log AND the per-CVE Change History). user_id=None = automated.
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"App CVE scan no longer detects this CVE on {asset.hostname} "
f"(software updated/removed past the vulnerable version)",
cve_id=v.cve_id,
source="app_scan",
)
except Exception as e:
logger.warning("audit log for app-scan auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
+40
View File
@@ -130,6 +130,38 @@ def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optiona
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.first_detected_by == SOURCE_NAME)
.all())
resolved = 0
for v in rows:
if v.cve_id in seen_cves:
continue
if set(v.source_list or []) - {SOURCE_NAME}:
continue # another scanner still reports it → not ours to close
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
@@ -174,13 +206,21 @@ def run_defender_sync(db: Session) -> dict:
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)
software = sw_map.get((m.get("id", ""), cve))
_upsert_cve(db, asset, v, new_ids, software=software)
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()