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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user