fix: harden Defender threat risk audit
This commit is contained in:
1 parent
0f39b54826
commit
7be3105d68
5 files changed
+836
-157
No files matched your search
@@ -5,6 +5,7 @@
|
||||
from setuphelpers import * # noqa: F401,F403 - WAPT injects the audit API here.
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
@@ -14,7 +15,9 @@ from datetime import datetime, timedelta, timezone
|
||||
AUDIT_CATEGORY = "Microsoft Defender"
|
||||
HISTORY_DAYS = 30
|
||||
AUDIT_HISTORY_DAYS = 90
|
||||
AUDIT_HISTORY_MAX_COUNT = 512
|
||||
AUDIT_SUMMARY_HISTORY_MAX_COUNT = 128
|
||||
AUDIT_CORRELATION_HISTORY_MAX_COUNT = 32
|
||||
AUDIT_EVENT_HISTORY_MAX_COUNT = 16
|
||||
POWERSHELL_TIMEOUT_SECONDS = 90
|
||||
|
||||
# MPTHREAT_SEVERITY values used by the Defender threat API. SeverityID on the
|
||||
@@ -52,23 +55,28 @@ THREAT_STATUSES = {
|
||||
}
|
||||
RESOLVED_STATUS_IDS = {2, 4, 6}
|
||||
FAILED_STATUS_IDS = {102, 103, 104, 107}
|
||||
THREAT_EXECUTION_STATUSES = {
|
||||
0: "Unknown",
|
||||
1: "Blocked",
|
||||
2: "Allowed",
|
||||
3: "Executing",
|
||||
4: "NotExecuting",
|
||||
}
|
||||
|
||||
DEFAULT_RULES = {
|
||||
"window_hours": 6,
|
||||
"signature_max_age_days": 3,
|
||||
"active_modifier": 2,
|
||||
"repeated_modifier": 2,
|
||||
"multiple_threats_modifier": 2,
|
||||
"failed_remediation_modifier": 3,
|
||||
"failed_remediation_forces_error": True,
|
||||
"status_modifiers": {
|
||||
2: -3, # cleaned
|
||||
3: -3, # quarantined, but still unresolved
|
||||
4: -3, # removed
|
||||
5: 3, # explicitly allowed
|
||||
6: -2, # blocked
|
||||
102: 3,
|
||||
103: 3,
|
||||
104: 3,
|
||||
107: 3,
|
||||
},
|
||||
"protection_modifiers": {
|
||||
"service_disabled": 2,
|
||||
@@ -83,7 +91,9 @@ DEFAULT_RULES = {
|
||||
"remediation_failure_threshold": 2,
|
||||
"multiple_threats_threshold": 2,
|
||||
"audit_history_days": AUDIT_HISTORY_DAYS,
|
||||
"audit_history_max_count": AUDIT_HISTORY_MAX_COUNT,
|
||||
"audit_summary_history_max_count": AUDIT_SUMMARY_HISTORY_MAX_COUNT,
|
||||
"audit_correlation_history_max_count": AUDIT_CORRELATION_HISTORY_MAX_COUNT,
|
||||
"audit_event_history_max_count": AUDIT_EVENT_HISTORY_MAX_COUNT,
|
||||
"expected_active_modes": ("Normal",),
|
||||
"passive_modes": ("Passive", "SxS Passive Mode", "EDR Block Mode"),
|
||||
"correlations": {
|
||||
@@ -195,10 +205,14 @@ $catalog = @(
|
||||
)
|
||||
|
||||
$activeThreatIds = @{}
|
||||
$investigationThreatIds = @{}
|
||||
foreach ($item in $catalog) {
|
||||
if ($item.threat_id -and $item.is_active -eq $true) {
|
||||
$activeThreatIds[[string]$item.threat_id] = $true
|
||||
}
|
||||
if ($item.threat_id -and $item.did_threat_execute -eq $true) {
|
||||
$investigationThreatIds[[string]$item.threat_id] = $true
|
||||
}
|
||||
}
|
||||
|
||||
$allDetections = @(Get-MpThreatDetection -ErrorAction Stop)
|
||||
@@ -213,7 +227,8 @@ $selectedDetections = @(
|
||||
$statusId = Get-SafeInt $_.ThreatStatusID
|
||||
$isKnownResolved = $statusId -in @(2, 4, 6)
|
||||
$knownActive = $activeThreatIds.ContainsKey($id)
|
||||
$withinHistory -or $knownActive -or -not $isKnownResolved
|
||||
$knownInvestigation = $investigationThreatIds.ContainsKey($id)
|
||||
$withinHistory -or $knownActive -or $knownInvestigation -or -not $isKnownResolved
|
||||
} | ForEach-Object {
|
||||
$resourceSummary = Get-ResourceSummary $_.Resources
|
||||
[PSCustomObject]@{
|
||||
@@ -377,31 +392,75 @@ def _detection_identity(detection, index):
|
||||
return "%s:%s" % (threat_id, detection_id), detection_id, True
|
||||
|
||||
# DetectionID is the documented unique detection key. If unavailable, use
|
||||
# all stable fields we have and surface the weaker identity in the summary.
|
||||
# the initial time and a resource fingerprint so a status update keeps its
|
||||
# incident key. The weaker identity remains explicitly flagged.
|
||||
initial = _iso(_first(detection, "detected_at", "initial_detection_time", "InitialDetectionTime")) or "?"
|
||||
changed = _iso(_first(detection, "changed_at", "last_status_change_at", "LastThreatStatusChangeTime")) or "?"
|
||||
status_id = _to_int(_first(detection, "status_id", "threat_status_id", "ThreatStatusID"))
|
||||
resources = _as_list(_first(detection, "resources", "Resources"))
|
||||
resource_signature = "|".join(sorted(str(value) for value in resources))
|
||||
identity = "%s:%s:%s:%s:%s" % (threat_id, initial, changed, status_id, resource_signature)
|
||||
if initial != "?":
|
||||
identity = "%s:%s:%s" % (threat_id, initial, resource_signature)
|
||||
else:
|
||||
identity = "%s:%s:%s:%s" % (threat_id, changed, status_id, resource_signature)
|
||||
return identity, None, False
|
||||
|
||||
|
||||
def _is_active(status_id, is_active, action_success, did_threat_execute=False,
|
||||
execution_status_id=None):
|
||||
if action_success is False:
|
||||
def _stable_event_id(detection):
|
||||
identity = detection.get("_detection_key")
|
||||
if not identity:
|
||||
identity = _detection_identity(detection, 0)[0]
|
||||
return hashlib.sha256(str(identity).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _remediation_state(status_id, action_success, error_code=None):
|
||||
if status_id in FAILED_STATUS_IDS or (action_success is False and error_code not in (None, 0)):
|
||||
return "failed"
|
||||
states = {
|
||||
0: "unknown",
|
||||
1: "detected",
|
||||
2: "cleaned",
|
||||
3: "quarantined",
|
||||
4: "removed",
|
||||
5: "allowed",
|
||||
6: "blocked",
|
||||
105: "abandoned",
|
||||
}
|
||||
return states.get(status_id, "unknown")
|
||||
|
||||
|
||||
def _execution_observed(did_threat_execute, execution_status_id):
|
||||
# DidThreatExecute is historical; CurrentThreatExecutionStatusID=3 is the
|
||||
# only enum value that expressly says the threat is executing now.
|
||||
return did_threat_execute is True or execution_status_id == 3
|
||||
|
||||
|
||||
def _is_active(status_id, remediation_state, execution_status_id=None):
|
||||
"""Return current unresolved state; past execution alone is not active."""
|
||||
if remediation_state == "failed":
|
||||
return True
|
||||
if did_threat_execute is True or execution_status_id in (2, 3):
|
||||
if execution_status_id == 3: # Executing
|
||||
return True
|
||||
if status_id in RESOLVED_STATUS_IDS:
|
||||
return is_active is True
|
||||
# In particular, Quarantined and Allowed remain unresolved for scoring.
|
||||
# Threat-level IsActive is a roll-up and may belong to a different
|
||||
# DetectionID with the same ThreatID. An explicit resolved status wins.
|
||||
return False
|
||||
# Quarantined, Allowed, Abandoned and unknown states remain unresolved.
|
||||
return True
|
||||
|
||||
|
||||
def classify_detection(detection, repeated=False, realtime_expected=True,
|
||||
protection_context=None, rules=None):
|
||||
"""Return a deterministic 0..16 risk level for one Defender detection."""
|
||||
def _investigation_required(remediation_state, is_active, did_threat_execute,
|
||||
execution_status_id):
|
||||
return bool(
|
||||
did_threat_execute is True
|
||||
or execution_status_id in (2, 3) # Allowed or Executing
|
||||
or (is_active is True and remediation_state in ("cleaned", "removed", "blocked"))
|
||||
or remediation_state in ("failed", "quarantined", "allowed", "unknown", "abandoned")
|
||||
)
|
||||
|
||||
|
||||
def _event_level_details(detection, rules=None):
|
||||
"""Score one detection from its own Defender fields, never host posture."""
|
||||
config = _copy_rules(rules)
|
||||
severity_id = _to_int(_first(detection, "threat_severity_id", "ThreatSeverityID"))
|
||||
if severity_id is None:
|
||||
@@ -410,26 +469,34 @@ def classify_detection(detection, repeated=False, realtime_expected=True,
|
||||
severity_id = {0: 0, 1: 1, 2: 2, 3: 4, 4: 5}.get(severity_id)
|
||||
base = config.get("base_levels", SEVERITY_BASE_LEVELS).get(severity_id, SEVERITY_BASE_LEVELS[0])
|
||||
status_id = _to_int(_first(detection, "status_id", "threat_status_id", "ThreatStatusID"))
|
||||
is_active = _to_bool(_first(detection, "is_active", "IsActive"))
|
||||
action_success = _to_bool(_first(detection, "action_success", "ActionSuccess"))
|
||||
did_threat_execute = _to_bool(_first(detection, "did_threat_execute", "DidThreatExecute"))
|
||||
execution_status_id = _to_int(_first(detection, "execution_status_id", "CurrentThreatExecutionStatusID"))
|
||||
active = _is_active(status_id, is_active, action_success, did_threat_execute, execution_status_id)
|
||||
error_code = _to_int(_first(detection, "error_code", "threat_status_error_code", "ThreatStatusErrorCode"))
|
||||
remediation = _remediation_state(status_id, action_success, error_code)
|
||||
|
||||
level = base + config["status_modifiers"].get(status_id, 0)
|
||||
if active:
|
||||
reasons = [{"rule": "severity_base", "modifier": base}]
|
||||
level = base
|
||||
status_modifier = config["status_modifiers"].get(status_id, 0)
|
||||
if status_modifier:
|
||||
level += status_modifier
|
||||
reasons.append({"rule": "remediation_status", "modifier": status_modifier})
|
||||
|
||||
unresolved_status = status_id not in RESOLVED_STATUS_IDS
|
||||
if unresolved_status:
|
||||
level += config["active_modifier"]
|
||||
if repeated:
|
||||
level += config["repeated_modifier"]
|
||||
if status_id not in FAILED_STATUS_IDS and action_success is False:
|
||||
reasons.append({"rule": "unresolved_remediation", "modifier": config["active_modifier"]})
|
||||
if remediation == "failed":
|
||||
level += config["failed_remediation_modifier"]
|
||||
if protection_context is None:
|
||||
protection_context = []
|
||||
if realtime_expected and _to_bool(_first(detection, "realtime_enabled")) is False:
|
||||
protection_context.append("realtime_disabled")
|
||||
for modifier in protection_context:
|
||||
level += config["protection_modifiers"].get(modifier, 0)
|
||||
return max(0, min(16, int(level)))
|
||||
reasons.append({"rule": "remediation_failed", "modifier": config["failed_remediation_modifier"]})
|
||||
bounded = max(0, min(16, int(level)))
|
||||
if bounded != level:
|
||||
reasons.append({"rule": "score_clamped", "modifier": bounded - level})
|
||||
return bounded, reasons
|
||||
|
||||
|
||||
def classify_detection(detection, repeated=False, realtime_expected=True,
|
||||
protection_context=None, rules=None):
|
||||
"""Return the stable event score; correlation and host posture are separate."""
|
||||
return _event_level_details(detection, rules=rules)[0]
|
||||
|
||||
|
||||
def level_category(level):
|
||||
@@ -514,25 +581,32 @@ def _deduplicate_detections(detections):
|
||||
return list(deduped.values()), identity_incomplete
|
||||
|
||||
|
||||
def _normalize_detection(item, catalog, now, window, history_cutoff, rules, repeated=False,
|
||||
realtime_expected=True, realtime_enabled=None,
|
||||
protection_context=None):
|
||||
def _normalize_detection(item, catalog, now, window, history_cutoff, rules):
|
||||
threat_id = _threat_id(_first(item, "threat_id", "ThreatID"))
|
||||
metadata = catalog.get(threat_id, {})
|
||||
severity_id = _catalog_severity(metadata)
|
||||
status_id = _to_int(_first(item, "status_id", "threat_status_id", "ThreatStatusID"))
|
||||
is_active_value = _to_bool(_first(metadata, "is_active", "IsActive"))
|
||||
action_success = _to_bool(_first(item, "action_success", "ActionSuccess"))
|
||||
error_code = _to_int(_first(item, "error_code", "threat_status_error_code", "ThreatStatusErrorCode"))
|
||||
did_threat_execute = _to_bool(_first(metadata, "did_threat_execute", "DidThreatExecute"))
|
||||
execution_status_id = _to_int(_first(item, "execution_status_id", "CurrentThreatExecutionStatusID"))
|
||||
remediation_state = _remediation_state(status_id, action_success, error_code)
|
||||
execution_observed = _execution_observed(did_threat_execute, execution_status_id)
|
||||
active = _is_active(
|
||||
status_id, is_active_value, action_success, did_threat_execute, execution_status_id,
|
||||
status_id, remediation_state, execution_status_id,
|
||||
)
|
||||
requires_investigation = _investigation_required(
|
||||
remediation_state, is_active_value, did_threat_execute, execution_status_id,
|
||||
)
|
||||
detected = _parse_datetime(_first(item, "detected_at", "initial_detection_time", "InitialDetectionTime"))
|
||||
changed = _parse_datetime(_first(item, "changed_at", "last_status_change_at", "LastThreatStatusChangeTime"))
|
||||
remediation = _parse_datetime(_first(item, "remediation_at", "RemediationTime"))
|
||||
recent = bool((detected and detected >= window) or (changed and changed >= window))
|
||||
retained = bool(recent or active or ((detected and detected >= history_cutoff) or (changed and changed >= history_cutoff)))
|
||||
retained = bool(
|
||||
recent or active or requires_investigation
|
||||
or ((detected and detected >= history_cutoff) or (changed and changed >= history_cutoff))
|
||||
)
|
||||
resource_kinds, resource_count = _resource_summary(item)
|
||||
enriched = dict(item)
|
||||
enriched.update({
|
||||
@@ -540,19 +614,25 @@ def _normalize_detection(item, catalog, now, window, history_cutoff, rules, repe
|
||||
"status_id": status_id,
|
||||
"is_active": active,
|
||||
"action_success": action_success,
|
||||
"error_code": error_code,
|
||||
"did_threat_execute": did_threat_execute,
|
||||
"execution_status_id": execution_status_id,
|
||||
"realtime_enabled": realtime_enabled,
|
||||
})
|
||||
level = classify_detection(
|
||||
enriched, repeated=repeated, realtime_expected=realtime_expected,
|
||||
protection_context=protection_context, rules=rules,
|
||||
level, level_reasons = _event_level_details(enriched, rules=rules)
|
||||
investigation_level = level if requires_investigation else 0
|
||||
investigation_reasons = (
|
||||
[{"rule": "event_level", "modifier": level},
|
||||
{"rule": "requires_investigation", "modifier": 0}]
|
||||
if requires_investigation else []
|
||||
)
|
||||
return {
|
||||
"event_id": _stable_event_id(item),
|
||||
"detection_id": item.get("_detection_id") or _first(item, "detection_id", "DetectionID"),
|
||||
"threat_id": threat_id,
|
||||
"threat_name": _first(metadata, "threat_name", "name", "ThreatName") or "Unknown threat",
|
||||
"did_threat_execute": _to_bool(_first(metadata, "did_threat_execute", "DidThreatExecute")),
|
||||
"execution_observed": execution_observed,
|
||||
"requires_investigation": requires_investigation,
|
||||
"severity_id": severity_id,
|
||||
"severity": SEVERITY_NAMES.get(severity_id, "Unknown"),
|
||||
"status_id": status_id,
|
||||
@@ -563,13 +643,14 @@ def _normalize_detection(item, catalog, now, window, history_cutoff, rules, repe
|
||||
"error_code": _to_int(_first(item, "error_code", "threat_status_error_code", "ThreatStatusErrorCode")),
|
||||
"action_success": action_success,
|
||||
"execution_status_id": _to_int(_first(item, "execution_status_id", "CurrentThreatExecutionStatusID")),
|
||||
"execution_status": THREAT_EXECUTION_STATUSES.get(execution_status_id, "Unknown"),
|
||||
"remediation_state": remediation_state,
|
||||
"resource_kinds": resource_kinds,
|
||||
"resource_count": resource_count,
|
||||
"active": active,
|
||||
"active_reason": (
|
||||
"remediation_failed" if action_success is False or status_id in FAILED_STATUS_IDS
|
||||
else "execution_observed" if did_threat_execute is True
|
||||
else "execution_allowed_or_running" if execution_status_id in (2, 3)
|
||||
"remediation_failed" if remediation_state == "failed"
|
||||
else "currently_executing" if execution_status_id == 3
|
||||
else "explicitly_allowed" if status_id == 5
|
||||
else "quarantined_unresolved" if status_id == 3
|
||||
else "defender_active" if is_active_value is True
|
||||
@@ -579,6 +660,9 @@ def _normalize_detection(item, catalog, now, window, history_cutoff, rules, repe
|
||||
"in_window_6h": recent,
|
||||
"event_level": level,
|
||||
"event_category": level_category(level),
|
||||
"event_level_reasons": level_reasons,
|
||||
"investigation_level": investigation_level,
|
||||
"investigation_level_reasons": investigation_reasons,
|
||||
"identity_complete": bool(item.get("_identity_complete", False)),
|
||||
"_retained": retained,
|
||||
}
|
||||
@@ -589,6 +673,19 @@ def _append_correlation(result, enabled, key, evidence):
|
||||
result.append({"rule": key, **evidence})
|
||||
|
||||
|
||||
def _correlation_record(key, items, modifier=0, reason=None, count=None):
|
||||
base_level = max((item["event_level"] for item in items), default=0)
|
||||
level = max(0, min(16, base_level + modifier))
|
||||
return {
|
||||
"rule": key,
|
||||
"count": len(items) if count is None else count,
|
||||
"correlation_level": level,
|
||||
"scoring_reasons": [
|
||||
{"rule": "highest_related_event", "modifier": base_level},
|
||||
] + ([{"rule": reason, "modifier": modifier}] if modifier else []),
|
||||
}
|
||||
|
||||
|
||||
def _build_correlations(detections, rules, realtime_disabled):
|
||||
enabled = rules["correlations"]
|
||||
recent = [item for item in detections if item["in_window_6h"]]
|
||||
@@ -597,7 +694,7 @@ def _build_correlations(detections, rules, realtime_disabled):
|
||||
for item in recent:
|
||||
if item["threat_id"]:
|
||||
by_threat.setdefault(item["threat_id"], []).append(item)
|
||||
if item["status_id"] in FAILED_STATUS_IDS or item["action_success"] is False:
|
||||
if item["remediation_state"] == "failed":
|
||||
failures[item["threat_id"]] += 1
|
||||
|
||||
repeated = {
|
||||
@@ -605,48 +702,54 @@ def _build_correlations(detections, rules, realtime_disabled):
|
||||
if len(items) >= rules["repeated_threshold"]
|
||||
}
|
||||
correlations = []
|
||||
repeated_ids = sorted(repeated)
|
||||
_append_correlation(correlations, enabled, "same_threat_repeated", {
|
||||
"count": sum(len(items) for items in repeated.values()),
|
||||
"threat_ids": repeated_ids,
|
||||
} if repeated else None)
|
||||
repeated_items = [item for items in repeated.values() for item in items]
|
||||
_append_correlation(correlations, enabled, "same_threat_repeated", _correlation_record(
|
||||
"same_threat_repeated", repeated_items, rules["repeated_modifier"], "same_threat_repeated",
|
||||
) if repeated else None)
|
||||
|
||||
unique_recent_threats = sorted(set(item["threat_id"] for item in recent if item["threat_id"]))
|
||||
_append_correlation(correlations, enabled, "multiple_threats", {
|
||||
"count": len(unique_recent_threats),
|
||||
"threat_ids": unique_recent_threats,
|
||||
} if len(unique_recent_threats) >= rules["multiple_threats_threshold"] else None)
|
||||
multiple_items = [item for item in recent if item["threat_id"] in unique_recent_threats]
|
||||
_append_correlation(correlations, enabled, "multiple_threats", _correlation_record(
|
||||
"multiple_threats", multiple_items, rules["multiple_threats_modifier"],
|
||||
"multiple_threats", count=len(unique_recent_threats),
|
||||
) if len(unique_recent_threats) >= rules["multiple_threats_threshold"] else None)
|
||||
|
||||
high_or_active = [item for item in detections if item["in_window_6h"] or item["active"]]
|
||||
severe_ids = sorted(set(
|
||||
item["threat_id"] for item in high_or_active
|
||||
if item["threat_id"] and item["severity_id"] in (4, 5)
|
||||
))
|
||||
_append_correlation(correlations, enabled, "severe_with_protection_disabled", {
|
||||
"threat_ids": severe_ids,
|
||||
"realtime_disabled": True,
|
||||
} if severe_ids and realtime_disabled else None)
|
||||
severe_items = [item for item in high_or_active if item["severity_id"] in (4, 5)]
|
||||
realtime_modifier = rules["protection_modifiers"].get("realtime_disabled", 0)
|
||||
_append_correlation(correlations, enabled, "severe_with_protection_disabled", _correlation_record(
|
||||
"severe_with_protection_disabled", severe_items, realtime_modifier, "realtime_disabled",
|
||||
) if severe_items and realtime_disabled else None)
|
||||
|
||||
repeated_failure_ids = sorted(
|
||||
threat_id for threat_id, count in failures.items()
|
||||
if count >= rules["remediation_failure_threshold"]
|
||||
)
|
||||
_append_correlation(correlations, enabled, "repeated_remediation_failures", {
|
||||
"threat_ids": repeated_failure_ids,
|
||||
"count": sum(failures[threat_id] for threat_id in repeated_failure_ids),
|
||||
} if repeated_failure_ids else None)
|
||||
failure_items = [
|
||||
item for item in recent
|
||||
if item["threat_id"] in repeated_failure_ids
|
||||
and item["remediation_state"] == "failed"
|
||||
]
|
||||
_append_correlation(correlations, enabled, "repeated_remediation_failures", _correlation_record(
|
||||
"repeated_remediation_failures", failure_items,
|
||||
rules["failed_remediation_modifier"], "repeated_remediation_failures",
|
||||
) if repeated_failure_ids else None)
|
||||
|
||||
allowed = [item for item in recent if item["status_id"] == 5]
|
||||
_append_correlation(correlations, enabled, "explicitly_allowed", {
|
||||
"threat_ids": sorted(set(item["threat_id"] for item in allowed if item["threat_id"])),
|
||||
"count": len(allowed),
|
||||
} if allowed else None)
|
||||
_append_correlation(correlations, enabled, "explicitly_allowed", _correlation_record(
|
||||
"explicitly_allowed", allowed,
|
||||
) if allowed else None)
|
||||
return correlations
|
||||
|
||||
|
||||
def _wapt_status(current_level, protection_issues, expected_active, is_passive, collection_complete, identity_incomplete, unknown_severity):
|
||||
def _wapt_status(current_level, protection_issues, expected_active, is_passive,
|
||||
collection_complete, identity_incomplete, unknown_severity,
|
||||
remediation_failed=False, rules=None):
|
||||
config = _copy_rules(rules)
|
||||
if not collection_complete:
|
||||
return "ERROR"
|
||||
if remediation_failed and config["failed_remediation_forces_error"]:
|
||||
return "ERROR"
|
||||
if expected_active and any(
|
||||
issue in protection_issues
|
||||
for issue in ("Defender service disabled", "Antivirus disabled", "Realtime protection disabled")
|
||||
@@ -664,7 +767,7 @@ def _wapt_status(current_level, protection_issues, expected_active, is_passive,
|
||||
|
||||
|
||||
def build_audit_snapshot(data, now=None, rules=None):
|
||||
"""Normalize Defender data, classify events, and build the v2 WAPT payload."""
|
||||
"""Normalize Defender data, classify events, and build the v3 WAPT payload."""
|
||||
config = _copy_rules(rules)
|
||||
now = _parse_datetime(now) or datetime.now(timezone.utc)
|
||||
window = now - timedelta(hours=config["window_hours"])
|
||||
@@ -706,33 +809,13 @@ def build_audit_snapshot(data, now=None, rules=None):
|
||||
catalog[threat_id] = threat
|
||||
|
||||
deduped, identity_incomplete = _deduplicate_detections(raw_detections)
|
||||
preliminary = []
|
||||
for item in deduped:
|
||||
threat_id = _threat_id(_first(item, "threat_id", "ThreatID"))
|
||||
preliminary.append({
|
||||
"threat_id": threat_id,
|
||||
"in_window_6h": bool(
|
||||
(_parse_datetime(_first(item, "detected_at", "initial_detection_time", "InitialDetectionTime")) or datetime.min.replace(tzinfo=timezone.utc)) >= window
|
||||
or (_parse_datetime(_first(item, "changed_at", "last_status_change_at", "LastThreatStatusChangeTime")) or datetime.min.replace(tzinfo=timezone.utc)) >= window
|
||||
),
|
||||
})
|
||||
recent_counts = Counter(item["threat_id"] for item in preliminary if item["in_window_6h"] and item["threat_id"])
|
||||
|
||||
protection_level, protection_issues, expected_active, is_passive, protection_context = _protection_state(defender, config)
|
||||
protection_level, protection_issues, expected_active, is_passive, _ = _protection_state(defender, config)
|
||||
realtime_disabled = expected_active and defender["realtime_enabled"] is False
|
||||
detections = []
|
||||
for item in deduped:
|
||||
threat_id = _threat_id(_first(item, "threat_id", "ThreatID"))
|
||||
repeated = bool(threat_id and recent_counts.get(threat_id, 0) >= config["repeated_threshold"])
|
||||
normalized = _normalize_detection(
|
||||
item, catalog, now, window, history_cutoff, config,
|
||||
repeated=repeated, realtime_expected=expected_active,
|
||||
realtime_enabled=defender["realtime_enabled"],
|
||||
protection_context=protection_context,
|
||||
)
|
||||
if normalized["in_window_6h"] and realtime_disabled:
|
||||
# classify_detection already applies this configured modifier.
|
||||
pass
|
||||
detections.append(normalized)
|
||||
|
||||
detections = [item for item in detections if item["_retained"]]
|
||||
@@ -741,22 +824,29 @@ def build_audit_snapshot(data, now=None, rules=None):
|
||||
|
||||
recent = [item for item in detections if item["in_window_6h"]]
|
||||
active = [item for item in detections if item["active"]]
|
||||
investigations = [item for item in detections if item["requires_investigation"]]
|
||||
max_event = max((item["event_level"] for item in recent), default=0)
|
||||
max_active = max((item["event_level"] for item in active), default=0)
|
||||
current_risk = max(max_active, protection_level)
|
||||
max_investigation = max((item["investigation_level"] for item in investigations), default=0)
|
||||
correlations = _build_correlations(detections, config, realtime_disabled)
|
||||
correlation_level = max((item["correlation_level"] for item in correlations), default=0)
|
||||
remediation_failed = any(
|
||||
item["active"] and item["remediation_state"] == "failed" for item in detections
|
||||
)
|
||||
current_risk = max(max_active, max_investigation, protection_level, correlation_level)
|
||||
if not collection_complete:
|
||||
current_risk = 16
|
||||
unknown_severity_count = sum(1 for item in detections if item["severity_id"] is None)
|
||||
correlations = _build_correlations(detections, config, realtime_disabled)
|
||||
status = _wapt_status(
|
||||
current_risk, protection_issues, expected_active, is_passive,
|
||||
collection_complete, identity_incomplete, unknown_severity_count,
|
||||
remediation_failed=remediation_failed, rules=config,
|
||||
)
|
||||
|
||||
# Keep the payload stable between audits; WAPT stores a new historical value
|
||||
# only when an actual status, detection, or correlation changes.
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"collection_complete": bool(collection_complete),
|
||||
"history_detection_count": _to_int(data.get("history_detection_count")),
|
||||
"returned_detection_count": _to_int(data.get("returned_detection_count"), len(_as_list(raw_detections))),
|
||||
@@ -764,12 +854,16 @@ def build_audit_snapshot(data, now=None, rules=None):
|
||||
"summary": {
|
||||
"max_event_level_6h": max_event,
|
||||
"max_active_level": max_active,
|
||||
"max_investigation_level": max_investigation,
|
||||
"correlation_level": correlation_level,
|
||||
"current_risk_level": current_risk,
|
||||
"current_risk_category": level_category(current_risk),
|
||||
"audit_status": status,
|
||||
"collection_complete": bool(collection_complete),
|
||||
"detection_count_6h": len(recent),
|
||||
"active_count": len(active),
|
||||
"investigation_count": len(investigations),
|
||||
"remediation_failed": remediation_failed,
|
||||
"resolved_count_6h": sum(1 for item in recent if not item["active"]),
|
||||
"identity_incomplete_count": identity_incomplete,
|
||||
"unknown_severity_count": unknown_severity_count,
|
||||
@@ -785,7 +879,7 @@ def build_audit_snapshot(data, now=None, rules=None):
|
||||
def build_collection_error_snapshot(code):
|
||||
safe_code = code if code in ("powershell_timeout", "powershell_failed", "powershell_unavailable", "empty_output", "invalid_json", "collection_failed") else "collection_failed"
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"collection_complete": False,
|
||||
"history_detection_count": None,
|
||||
"returned_detection_count": 0,
|
||||
@@ -804,12 +898,16 @@ def build_collection_error_snapshot(code):
|
||||
"summary": {
|
||||
"max_event_level_6h": 0,
|
||||
"max_active_level": 0,
|
||||
"max_investigation_level": 0,
|
||||
"correlation_level": 0,
|
||||
"current_risk_level": 16,
|
||||
"current_risk_category": level_category(16),
|
||||
"audit_status": "ERROR",
|
||||
"collection_complete": False,
|
||||
"detection_count_6h": 0,
|
||||
"active_count": 0,
|
||||
"investigation_count": 0,
|
||||
"remediation_failed": False,
|
||||
"resolved_count_6h": 0,
|
||||
"identity_incomplete_count": 0,
|
||||
"unknown_severity_count": 0,
|
||||
@@ -822,6 +920,70 @@ def build_collection_error_snapshot(code):
|
||||
}
|
||||
|
||||
|
||||
def build_webhook_event(detection, previous=None):
|
||||
"""Build a provider-neutral event envelope; this function makes no I/O."""
|
||||
state = detection.get("remediation_state", "unknown")
|
||||
previous_state = previous.get("remediation_state") if isinstance(previous, dict) else None
|
||||
resolved = state in ("cleaned", "removed", "blocked")
|
||||
if previous_state is None:
|
||||
event_type = "threat.resolved" if resolved else "threat.detected"
|
||||
elif previous_state != state:
|
||||
event_type = "threat.resolved" if resolved else "threat.status_changed"
|
||||
else:
|
||||
event_type = previous.get("event_type", "threat.detected")
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"event_id": detection["event_id"],
|
||||
"source": "comi-audit-defender",
|
||||
"event_type": event_type,
|
||||
"event_level": detection["event_level"],
|
||||
"remediation_state": state,
|
||||
"execution_observed": bool(detection.get("execution_observed")),
|
||||
"requires_investigation": bool(detection.get("requires_investigation")),
|
||||
"active": bool(detection.get("active")),
|
||||
"timestamp": detection.get("changed_at") or detection.get("detected_at"),
|
||||
}
|
||||
|
||||
|
||||
def _compact_audit_summary(snapshot):
|
||||
defender = snapshot["defender"]
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"collection_complete": snapshot["collection_complete"],
|
||||
"defender": {
|
||||
"running_mode": defender.get("running_mode"),
|
||||
"antivirus_enabled": defender.get("antivirus_enabled"),
|
||||
"realtime_enabled": defender.get("realtime_enabled"),
|
||||
"signature_age": defender.get("signature_age"),
|
||||
},
|
||||
"summary": snapshot["summary"],
|
||||
}
|
||||
|
||||
|
||||
def _compact_audit_correlations(snapshot):
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"correlation_level": snapshot["summary"]["correlation_level"],
|
||||
"correlations": snapshot["correlations"],
|
||||
}
|
||||
|
||||
|
||||
def build_audit_records(snapshot, previous_events=None):
|
||||
"""Create compact WAPT values without repeating detection arrays in snapshots."""
|
||||
previous_events = previous_events or {}
|
||||
records = {
|
||||
"summary": _compact_audit_summary(snapshot),
|
||||
"correlations": _compact_audit_correlations(snapshot),
|
||||
}
|
||||
for detection in snapshot["detections"]:
|
||||
event_id = detection["event_id"]
|
||||
records["event." + event_id] = build_webhook_event(
|
||||
detection, previous=previous_events.get(event_id),
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def powershell_json(script=POWERSHELL_SCRIPT, timeout=POWERSHELL_TIMEOUT_SECONDS):
|
||||
powershell = os.path.join(
|
||||
os.environ.get("SystemRoot", r"C:\Windows"),
|
||||
@@ -866,24 +1028,38 @@ def collect_defender():
|
||||
return powershell_json()
|
||||
|
||||
|
||||
def save_audit(key, value, rules=None):
|
||||
def save_audit(key, value, rules=None, max_count=None, keep_days=None):
|
||||
config = _copy_rules(rules)
|
||||
if max_count is None:
|
||||
if key.startswith("event."):
|
||||
max_count = config["audit_event_history_max_count"]
|
||||
elif key == "correlations":
|
||||
max_count = config["audit_correlation_history_max_count"]
|
||||
else:
|
||||
max_count = config["audit_summary_history_max_count"]
|
||||
if keep_days is None:
|
||||
keep_days = config["audit_history_days"]
|
||||
return WAPT.write_audit_data_if_changed(
|
||||
AUDIT_CATEGORY,
|
||||
key,
|
||||
value,
|
||||
max_count=config["audit_history_max_count"],
|
||||
keep_days=config["audit_history_days"],
|
||||
max_count=max_count,
|
||||
keep_days=keep_days,
|
||||
)
|
||||
|
||||
|
||||
def _save_snapshot(snapshot):
|
||||
save_audit("snapshot", snapshot)
|
||||
# Keep established Reporting keys available while adding the v2 aggregate.
|
||||
save_audit("summary", snapshot["summary"])
|
||||
save_audit("detections", snapshot["detections"])
|
||||
save_audit("active_threats", snapshot["active_threats"])
|
||||
save_audit("correlations", snapshot["correlations"])
|
||||
def _save_snapshot(snapshot, rules=None):
|
||||
config = _copy_rules(rules)
|
||||
save_audit("summary", _compact_audit_summary(snapshot), rules=config)
|
||||
save_audit("correlations", _compact_audit_correlations(snapshot), rules=config)
|
||||
for detection in snapshot["detections"]:
|
||||
event_id = detection["event_id"]
|
||||
key = "event." + event_id
|
||||
previous = WAPT.read_audit_data(
|
||||
AUDIT_CATEGORY, key, default=None, include_expired_data=False,
|
||||
)
|
||||
event = build_webhook_event(detection, previous=previous)
|
||||
save_audit(key, event, rules=config)
|
||||
|
||||
|
||||
def audit():
|
||||
|
||||
Reference in new issue
Block a user