906 lines
36 KiB
Python
906 lines
36 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
"""Read-only Microsoft Defender audit for WAPT Enterprise 2.6."""
|
|
|
|
from setuphelpers import * # noqa: F401,F403 - WAPT injects the audit API here.
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from collections import Counter
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
AUDIT_CATEGORY = "Microsoft Defender"
|
|
HISTORY_DAYS = 30
|
|
AUDIT_HISTORY_DAYS = 90
|
|
AUDIT_HISTORY_MAX_COUNT = 512
|
|
POWERSHELL_TIMEOUT_SECONDS = 90
|
|
|
|
# MPTHREAT_SEVERITY values used by the Defender threat API. SeverityID on the
|
|
# WMI catalog uses 0..4 instead; PowerShell normalizes that representation.
|
|
SEVERITY_BASE_LEVELS = {
|
|
0: 5, # Unknown
|
|
1: 4, # Low
|
|
2: 6, # Moderate
|
|
4: 9, # High
|
|
5: 12, # Severe
|
|
}
|
|
SEVERITY_NAMES = {
|
|
0: "Unknown",
|
|
1: "Low",
|
|
2: "Moderate",
|
|
4: "High",
|
|
5: "Severe",
|
|
}
|
|
|
|
# Values exposed by the MSFT_MpThreatDetection class used by the Defender
|
|
# PowerShell cmdlet. Quarantine remains unresolved; it is never called clean.
|
|
THREAT_STATUSES = {
|
|
0: "Unknown",
|
|
1: "Detected",
|
|
2: "Cleaned",
|
|
3: "Quarantined",
|
|
4: "Removed",
|
|
5: "Allowed",
|
|
6: "Blocked",
|
|
102: "QuarantineFailed",
|
|
103: "RemoveFailed",
|
|
104: "AllowFailed",
|
|
105: "Abandoned",
|
|
107: "BlockedFailed",
|
|
}
|
|
RESOLVED_STATUS_IDS = {2, 4, 6}
|
|
FAILED_STATUS_IDS = {102, 103, 104, 107}
|
|
|
|
DEFAULT_RULES = {
|
|
"window_hours": 6,
|
|
"signature_max_age_days": 3,
|
|
"active_modifier": 2,
|
|
"repeated_modifier": 2,
|
|
"failed_remediation_modifier": 3,
|
|
"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,
|
|
"antivirus_disabled": 2,
|
|
"realtime_disabled": 2,
|
|
"behavior_monitor_disabled": 1,
|
|
"ioav_disabled": 1,
|
|
"signatures_stale": 1,
|
|
"signature_age_unknown": 1,
|
|
},
|
|
"repeated_threshold": 2,
|
|
"remediation_failure_threshold": 2,
|
|
"multiple_threats_threshold": 2,
|
|
"audit_history_days": AUDIT_HISTORY_DAYS,
|
|
"audit_history_max_count": AUDIT_HISTORY_MAX_COUNT,
|
|
"expected_active_modes": ("Normal",),
|
|
"passive_modes": ("Passive", "SxS Passive Mode", "EDR Block Mode"),
|
|
"correlations": {
|
|
"same_threat_repeated": True,
|
|
"multiple_threats": True,
|
|
"severe_with_protection_disabled": True,
|
|
"repeated_remediation_failures": True,
|
|
"explicitly_allowed": True,
|
|
},
|
|
}
|
|
|
|
|
|
class DefenderCollectionError(RuntimeError):
|
|
"""Collection error with a safe, non-sensitive reason code."""
|
|
|
|
def __init__(self, code):
|
|
self.code = code
|
|
RuntimeError.__init__(self, code)
|
|
|
|
|
|
def install():
|
|
"""Keep the existing package install hook; no Defender changes are made."""
|
|
print("Microsoft Defender audit package installed")
|
|
|
|
|
|
POWERSHELL_SCRIPT = r'''
|
|
$ErrorActionPreference = 'Stop'
|
|
$WarningPreference = 'SilentlyContinue'
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
|
|
function Get-SafeInt($Value) {
|
|
if ($null -eq $Value -or "$Value" -eq '') { return $null }
|
|
try { return [int]$Value } catch { return $null }
|
|
}
|
|
|
|
function Format-Utc($Value) {
|
|
if ($null -eq $Value -or "$Value" -eq '') { return $null }
|
|
try {
|
|
if ($Value -is [datetime]) { $date = $Value }
|
|
else {
|
|
$date = [datetime]::MinValue
|
|
if (-not [datetime]::TryParse([string]$Value, [ref]$date)) {
|
|
return $null
|
|
}
|
|
}
|
|
return $date.ToUniversalTime().ToString('o')
|
|
} catch { return $null }
|
|
}
|
|
|
|
function Get-ResourceSummary($Values) {
|
|
$items = @($Values | Where-Object { $null -ne $_ -and "$($_)" -ne '' })
|
|
$kinds = @(
|
|
$items | ForEach-Object {
|
|
$text = [string]$_
|
|
$kind = ($text -split ':', 2)[0].ToLowerInvariant()
|
|
if ($kind -notin @('file', 'process', 'registry', 'service', 'url')) {
|
|
$kind = 'other'
|
|
}
|
|
$kind
|
|
} | Sort-Object -Unique
|
|
)
|
|
return [PSCustomObject]@{
|
|
resource_count = [int]$items.Count
|
|
resource_kinds = $kinds
|
|
}
|
|
}
|
|
|
|
$now = [datetime]::UtcNow
|
|
$historyCutoff = $now.AddDays(-30)
|
|
$status = Get-MpComputerStatus -ErrorAction Stop
|
|
|
|
$catalog = @(
|
|
Get-MpThreat -ErrorAction Stop | ForEach-Object {
|
|
$severityId = $null
|
|
$severitySource = $null
|
|
$severityProperty = $_.PSObject.Properties['ThreatSeverityID']
|
|
if ($null -ne $severityProperty -and $null -ne $severityProperty.Value) {
|
|
$severityId = Get-SafeInt $severityProperty.Value
|
|
$severitySource = 'ThreatSeverityID'
|
|
} else {
|
|
$severityProperty = $_.PSObject.Properties['SeverityID']
|
|
if ($null -ne $severityProperty -and $null -ne $severityProperty.Value) {
|
|
$rawSeverity = Get-SafeInt $severityProperty.Value
|
|
# MSFT_MpThreatCatalog uses 3=High and 4=Severe. Normalize to
|
|
# MPTHREAT_SEVERITY's documented 4=High and 5=Severe values.
|
|
$catalogSeverityMap = @{ 0 = 0; 1 = 1; 2 = 2; 3 = 4; 4 = 5 }
|
|
if ($null -ne $rawSeverity -and $catalogSeverityMap.ContainsKey($rawSeverity)) {
|
|
$severityId = $catalogSeverityMap[$rawSeverity]
|
|
}
|
|
$severitySource = 'SeverityID'
|
|
}
|
|
}
|
|
|
|
$active = $null
|
|
$activeProperty = $_.PSObject.Properties['IsActive']
|
|
if ($null -ne $activeProperty -and $null -ne $activeProperty.Value) {
|
|
$active = [bool]$activeProperty.Value
|
|
}
|
|
|
|
[PSCustomObject]@{
|
|
threat_id = if ($null -ne $_.ThreatID) { [string]$_.ThreatID } else { $null }
|
|
threat_name = if ($_.ThreatName) { ([string]$_.ThreatName).Substring(0, [Math]::Min(256, ([string]$_.ThreatName).Length)) } else { $null }
|
|
threat_severity_id = $severityId
|
|
severity_source = $severitySource
|
|
is_active = $active
|
|
did_threat_execute = if ($null -ne $_.DidThreatExecute) { [bool]$_.DidThreatExecute } else { $null }
|
|
}
|
|
}
|
|
)
|
|
|
|
$activeThreatIds = @{}
|
|
foreach ($item in $catalog) {
|
|
if ($item.threat_id -and $item.is_active -eq $true) {
|
|
$activeThreatIds[[string]$item.threat_id] = $true
|
|
}
|
|
}
|
|
|
|
$allDetections = @(Get-MpThreatDetection -ErrorAction Stop)
|
|
$selectedDetections = @(
|
|
$allDetections | Where-Object {
|
|
$initial = $_.InitialDetectionTime
|
|
$changed = $_.LastThreatStatusChangeTime
|
|
$withinHistory = $false
|
|
if ($initial -and $initial.ToUniversalTime() -ge $historyCutoff) { $withinHistory = $true }
|
|
if ($changed -and $changed.ToUniversalTime() -ge $historyCutoff) { $withinHistory = $true }
|
|
$id = if ($null -ne $_.ThreatID) { [string]$_.ThreatID } else { '' }
|
|
$statusId = Get-SafeInt $_.ThreatStatusID
|
|
$isKnownResolved = $statusId -in @(2, 4, 6)
|
|
$knownActive = $activeThreatIds.ContainsKey($id)
|
|
$withinHistory -or $knownActive -or -not $isKnownResolved
|
|
} | ForEach-Object {
|
|
$resourceSummary = Get-ResourceSummary $_.Resources
|
|
[PSCustomObject]@{
|
|
detection_id = if ($_.DetectionID) { [string]$_.DetectionID } else { $null }
|
|
threat_id = if ($null -ne $_.ThreatID) { [string]$_.ThreatID } else { $null }
|
|
status_id = Get-SafeInt $_.ThreatStatusID
|
|
detected_at = Format-Utc $_.InitialDetectionTime
|
|
changed_at = Format-Utc $_.LastThreatStatusChangeTime
|
|
remediation_at = Format-Utc $_.RemediationTime
|
|
error_code = Get-SafeInt $_.ThreatStatusErrorCode
|
|
action_success = if ($null -ne $_.ActionSuccess) { [bool]$_.ActionSuccess } else { $null }
|
|
execution_status_id = Get-SafeInt $_.CurrentThreatExecutionStatusID
|
|
resource_count = $resourceSummary.resource_count
|
|
resource_kinds = @($resourceSummary.resource_kinds)
|
|
}
|
|
}
|
|
)
|
|
|
|
$result = [PSCustomObject]@{
|
|
collection_complete = $true
|
|
history_detection_count = [int]$allDetections.Count
|
|
returned_detection_count = [int]$selectedDetections.Count
|
|
defender = [PSCustomObject]@{
|
|
running_mode = $status.AMRunningMode
|
|
antivirus_enabled = $status.AntivirusEnabled
|
|
realtime_enabled = $status.RealTimeProtectionEnabled
|
|
behavior_monitor_enabled = $status.BehaviorMonitorEnabled
|
|
ioav_enabled = $status.IoavProtectionEnabled
|
|
signature_age = Get-SafeInt $status.AntivirusSignatureAge
|
|
signature_version = $status.AntivirusSignatureVersion
|
|
signature_last_updated = Format-Utc $status.AntivirusSignatureLastUpdated
|
|
engine_version = $status.AMEngineVersion
|
|
service_enabled = $status.AMServiceEnabled
|
|
}
|
|
threats = @($catalog)
|
|
detections = @($selectedDetections)
|
|
}
|
|
|
|
ConvertTo-Json -InputObject $result -Depth 8 -Compress
|
|
'''
|
|
|
|
|
|
def _copy_rules(rules=None):
|
|
result = dict(DEFAULT_RULES)
|
|
for key in ("status_modifiers", "protection_modifiers", "correlations"):
|
|
result[key] = dict(DEFAULT_RULES[key])
|
|
if rules:
|
|
for key, value in rules.items():
|
|
if key in ("status_modifiers", "protection_modifiers", "correlations"):
|
|
result[key].update(value)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _as_list(value):
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, (list, tuple)):
|
|
return list(value)
|
|
return [value]
|
|
|
|
|
|
def _first(mapping, *keys):
|
|
if not isinstance(mapping, dict):
|
|
return None
|
|
for key in keys:
|
|
if key in mapping:
|
|
return mapping[key]
|
|
return None
|
|
|
|
|
|
def _to_int(value, default=None):
|
|
if value is None or isinstance(value, bool):
|
|
return default
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return default
|
|
|
|
|
|
def _to_bool(value):
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value in (0, 1):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in ("true", "yes", "1"):
|
|
return True
|
|
if lowered in ("false", "no", "0"):
|
|
return False
|
|
return None
|
|
|
|
|
|
def _threat_id(value):
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _parse_datetime(value):
|
|
if isinstance(value, datetime):
|
|
parsed = value
|
|
elif isinstance(value, str) and value.strip():
|
|
text = value.strip()
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
else:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _iso(value):
|
|
parsed = _parse_datetime(value)
|
|
return parsed.isoformat().replace("+00:00", "Z") if parsed else None
|
|
|
|
|
|
def _catalog_severity(item):
|
|
severity_id = _to_int(_first(item, "threat_severity_id", "ThreatSeverityID"))
|
|
source = _first(item, "severity_source", "SeveritySource")
|
|
if severity_id is None:
|
|
severity_id = _to_int(_first(item, "severity_id", "SeverityID"))
|
|
source = source or "SeverityID"
|
|
# WMI catalog values differ from MPTHREAT_SEVERITY for High and Severe.
|
|
if source == "SeverityID":
|
|
severity_id = {0: 0, 1: 1, 2: 2, 3: 4, 4: 5}.get(severity_id)
|
|
return severity_id if severity_id in SEVERITY_BASE_LEVELS else None
|
|
|
|
|
|
def _resource_summary(detection):
|
|
kinds = _as_list(_first(detection, "resource_kinds", "ResourcesKinds"))
|
|
count = _to_int(_first(detection, "resource_count", "ResourceCount"))
|
|
if not kinds:
|
|
raw_resources = _as_list(_first(detection, "resources", "Resources"))
|
|
kinds = []
|
|
for resource in raw_resources:
|
|
prefix = str(resource).split(":", 1)[0].lower()
|
|
kinds.append(prefix if prefix in ("file", "process", "registry", "service", "url") else "other")
|
|
if count is None:
|
|
count = len(raw_resources)
|
|
safe_kinds = sorted(set(
|
|
kind for kind in (str(value).lower() for value in kinds)
|
|
if kind in ("file", "process", "registry", "service", "url", "other")
|
|
))
|
|
return safe_kinds, max(0, count or 0)
|
|
|
|
|
|
def _detection_identity(detection, index):
|
|
threat_id = _threat_id(_first(detection, "threat_id", "ThreatID")) or "unknown"
|
|
detection_id = _first(detection, "detection_id", "DetectionID")
|
|
if detection_id is not None and str(detection_id).strip():
|
|
detection_id = str(detection_id).strip()
|
|
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.
|
|
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)
|
|
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:
|
|
return True
|
|
if did_threat_execute is True or execution_status_id in (2, 3):
|
|
return True
|
|
if status_id in RESOLVED_STATUS_IDS:
|
|
return is_active is True
|
|
# In particular, Quarantined and Allowed remain unresolved for scoring.
|
|
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."""
|
|
config = _copy_rules(rules)
|
|
severity_id = _to_int(_first(detection, "threat_severity_id", "ThreatSeverityID"))
|
|
if severity_id is None:
|
|
severity_id = _to_int(_first(detection, "severity_id", "SeverityID"))
|
|
if _first(detection, "severity_source", "SeveritySource") == "SeverityID":
|
|
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)
|
|
|
|
level = base + config["status_modifiers"].get(status_id, 0)
|
|
if active:
|
|
level += config["active_modifier"]
|
|
if repeated:
|
|
level += config["repeated_modifier"]
|
|
if status_id not in FAILED_STATUS_IDS and action_success is False:
|
|
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)))
|
|
|
|
|
|
def level_category(level):
|
|
level = max(0, min(16, _to_int(level, 0)))
|
|
if level == 0:
|
|
return "Aucun événement"
|
|
if level <= 3:
|
|
return "Information"
|
|
if level <= 6:
|
|
return "Faible"
|
|
if level <= 9:
|
|
return "Modéré"
|
|
if level <= 12:
|
|
return "Élevé"
|
|
if level <= 14:
|
|
return "Critique"
|
|
return "Urgence"
|
|
|
|
|
|
def _protection_state(defender, rules):
|
|
mode = defender.get("running_mode")
|
|
mode_text = str(mode or "").strip()
|
|
expected_modes = {str(value).lower() for value in rules["expected_active_modes"]}
|
|
passive_modes = {str(value).lower() for value in rules["passive_modes"]}
|
|
is_expected_active = mode_text.lower() in expected_modes
|
|
is_passive = mode_text.lower() in passive_modes
|
|
|
|
issues = []
|
|
modifiers = []
|
|
level = 0
|
|
if is_expected_active:
|
|
checks = (
|
|
("service_enabled", "Defender service disabled", "service_disabled"),
|
|
("antivirus_enabled", "Antivirus disabled", "antivirus_disabled"),
|
|
("realtime_enabled", "Realtime protection disabled", "realtime_disabled"),
|
|
("behavior_monitor_enabled", "Behavior monitoring disabled", "behavior_monitor_disabled"),
|
|
("ioav_enabled", "IOAV protection disabled", "ioav_disabled"),
|
|
)
|
|
for key, label, modifier in checks:
|
|
value = _to_bool(defender.get(key))
|
|
if value is False:
|
|
issues.append(label)
|
|
modifiers.append(modifier)
|
|
level += rules["protection_modifiers"].get(modifier, 0)
|
|
elif value is None:
|
|
issues.append("Unknown %s" % key)
|
|
|
|
age = _to_int(defender.get("signature_age"))
|
|
max_age = rules["signature_max_age_days"]
|
|
if age is None:
|
|
issues.append("Signature age unknown")
|
|
modifiers.append("signature_age_unknown")
|
|
level += rules["protection_modifiers"].get("signature_age_unknown", 0)
|
|
elif age > max_age:
|
|
issues.append("Signatures outdated: %s days" % age)
|
|
modifiers.append("signatures_stale")
|
|
level += rules["protection_modifiers"].get("signatures_stale", 0)
|
|
elif not is_passive and not mode_text:
|
|
issues.append("Defender running mode unknown")
|
|
|
|
return max(0, min(16, level)), issues, is_expected_active, is_passive, modifiers
|
|
|
|
|
|
def _deduplicate_detections(detections):
|
|
deduped = {}
|
|
identity_incomplete = 0
|
|
for index, item in enumerate(_as_list(detections)):
|
|
if not isinstance(item, dict):
|
|
identity_incomplete += 1
|
|
continue
|
|
key, detection_id, has_detection_id = _detection_identity(item, index)
|
|
if not has_detection_id:
|
|
identity_incomplete += 1
|
|
previous = deduped.get(key)
|
|
candidate_changed = _parse_datetime(_first(item, "changed_at", "last_status_change_at", "LastThreatStatusChangeTime"))
|
|
previous_changed = _parse_datetime(_first(previous or {}, "changed_at", "last_status_change_at", "LastThreatStatusChangeTime"))
|
|
if previous is None or (candidate_changed and (not previous_changed or candidate_changed >= previous_changed)):
|
|
deduped[key] = dict(item)
|
|
deduped[key]["_detection_key"] = key
|
|
deduped[key]["_detection_id"] = detection_id
|
|
deduped[key]["_identity_complete"] = has_detection_id
|
|
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):
|
|
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"))
|
|
did_threat_execute = _to_bool(_first(metadata, "did_threat_execute", "DidThreatExecute"))
|
|
execution_status_id = _to_int(_first(item, "execution_status_id", "CurrentThreatExecutionStatusID"))
|
|
active = _is_active(
|
|
status_id, is_active_value, action_success, 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)))
|
|
resource_kinds, resource_count = _resource_summary(item)
|
|
enriched = dict(item)
|
|
enriched.update({
|
|
"threat_severity_id": severity_id,
|
|
"status_id": status_id,
|
|
"is_active": active,
|
|
"action_success": action_success,
|
|
"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,
|
|
)
|
|
return {
|
|
"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")),
|
|
"severity_id": severity_id,
|
|
"severity": SEVERITY_NAMES.get(severity_id, "Unknown"),
|
|
"status_id": status_id,
|
|
"status": THREAT_STATUSES.get(status_id, "Unknown"),
|
|
"detected_at": _iso(_first(item, "detected_at", "initial_detection_time", "InitialDetectionTime")),
|
|
"changed_at": _iso(_first(item, "changed_at", "last_status_change_at", "LastThreatStatusChangeTime")),
|
|
"remediation_at": _iso(_first(item, "remediation_at", "RemediationTime")),
|
|
"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")),
|
|
"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)
|
|
else "explicitly_allowed" if status_id == 5
|
|
else "quarantined_unresolved" if status_id == 3
|
|
else "defender_active" if is_active_value is True
|
|
else "unresolved_status" if status_id not in RESOLVED_STATUS_IDS
|
|
else None
|
|
),
|
|
"in_window_6h": recent,
|
|
"event_level": level,
|
|
"event_category": level_category(level),
|
|
"identity_complete": bool(item.get("_identity_complete", False)),
|
|
"_retained": retained,
|
|
}
|
|
|
|
|
|
def _append_correlation(result, enabled, key, evidence):
|
|
if enabled.get(key) and evidence:
|
|
result.append({"rule": key, **evidence})
|
|
|
|
|
|
def _build_correlations(detections, rules, realtime_disabled):
|
|
enabled = rules["correlations"]
|
|
recent = [item for item in detections if item["in_window_6h"]]
|
|
by_threat = {}
|
|
failures = Counter()
|
|
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:
|
|
failures[item["threat_id"]] += 1
|
|
|
|
repeated = {
|
|
threat_id: items for threat_id, items in by_threat.items()
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
return correlations
|
|
|
|
|
|
def _wapt_status(current_level, protection_issues, expected_active, is_passive, collection_complete, identity_incomplete, unknown_severity):
|
|
if not collection_complete:
|
|
return "ERROR"
|
|
if expected_active and any(
|
|
issue in protection_issues
|
|
for issue in ("Defender service disabled", "Antivirus disabled", "Realtime protection disabled")
|
|
):
|
|
return "ERROR"
|
|
if current_level >= 10:
|
|
return "ERROR"
|
|
if current_level >= 7:
|
|
return "WARNING"
|
|
if protection_issues or identity_incomplete or unknown_severity:
|
|
return "WARNING"
|
|
if not expected_active and not is_passive:
|
|
return "WARNING"
|
|
return "OK"
|
|
|
|
|
|
def build_audit_snapshot(data, now=None, rules=None):
|
|
"""Normalize Defender data, classify events, and build the v2 WAPT payload."""
|
|
config = _copy_rules(rules)
|
|
now = _parse_datetime(now) or datetime.now(timezone.utc)
|
|
window = now - timedelta(hours=config["window_hours"])
|
|
history_cutoff = now - timedelta(days=HISTORY_DAYS)
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
|
|
defender_raw = data.get("defender") if isinstance(data.get("defender"), dict) else {}
|
|
defender = {
|
|
"running_mode": defender_raw.get("running_mode"),
|
|
"antivirus_enabled": _to_bool(defender_raw.get("antivirus_enabled")),
|
|
"realtime_enabled": _to_bool(defender_raw.get("realtime_enabled")),
|
|
"behavior_monitor_enabled": _to_bool(defender_raw.get("behavior_monitor_enabled")),
|
|
"ioav_enabled": _to_bool(defender_raw.get("ioav_enabled")),
|
|
"signature_age": _to_int(defender_raw.get("signature_age")),
|
|
"signature_version": defender_raw.get("signature_version"),
|
|
"signature_last_updated": _iso(defender_raw.get("signature_last_updated")),
|
|
"engine_version": defender_raw.get("engine_version"),
|
|
"service_enabled": _to_bool(defender_raw.get("service_enabled")),
|
|
}
|
|
raw_detections = data.get("detections")
|
|
raw_threats = data.get("threats")
|
|
supported_collection_types = (list, tuple, dict)
|
|
arrays_valid = (
|
|
"detections" in data and "threats" in data
|
|
and (raw_detections is None or isinstance(raw_detections, supported_collection_types))
|
|
and (raw_threats is None or isinstance(raw_threats, supported_collection_types))
|
|
)
|
|
collection_complete = data.get("collection_complete") is not False and arrays_valid
|
|
if not defender or not defender.get("running_mode"):
|
|
collection_complete = False
|
|
if defender["antivirus_enabled"] is None or defender["realtime_enabled"] is None:
|
|
collection_complete = False
|
|
|
|
catalog = {}
|
|
for threat in _as_list(raw_threats):
|
|
threat_id = _threat_id(_first(threat, "threat_id", "ThreatID"))
|
|
if threat_id:
|
|
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)
|
|
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"]]
|
|
for item in detections:
|
|
item.pop("_retained", None)
|
|
|
|
recent = [item for item in detections if item["in_window_6h"]]
|
|
active = [item for item in detections if item["active"]]
|
|
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)
|
|
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,
|
|
)
|
|
|
|
# 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,
|
|
"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))),
|
|
"defender": defender,
|
|
"summary": {
|
|
"max_event_level_6h": max_event,
|
|
"max_active_level": max_active,
|
|
"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),
|
|
"resolved_count_6h": sum(1 for item in recent if not item["active"]),
|
|
"identity_incomplete_count": identity_incomplete,
|
|
"unknown_severity_count": unknown_severity_count,
|
|
"protection_issues": protection_issues,
|
|
"correlation_count": len(correlations),
|
|
},
|
|
"detections": detections,
|
|
"active_threats": active,
|
|
"correlations": correlations,
|
|
}
|
|
|
|
|
|
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,
|
|
"collection_complete": False,
|
|
"history_detection_count": None,
|
|
"returned_detection_count": 0,
|
|
"defender": {
|
|
"running_mode": None,
|
|
"antivirus_enabled": None,
|
|
"realtime_enabled": None,
|
|
"behavior_monitor_enabled": None,
|
|
"ioav_enabled": None,
|
|
"signature_age": None,
|
|
"signature_version": None,
|
|
"signature_last_updated": None,
|
|
"engine_version": None,
|
|
"service_enabled": None,
|
|
},
|
|
"summary": {
|
|
"max_event_level_6h": 0,
|
|
"max_active_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,
|
|
"resolved_count_6h": 0,
|
|
"identity_incomplete_count": 0,
|
|
"unknown_severity_count": 0,
|
|
"protection_issues": [safe_code],
|
|
"correlation_count": 0,
|
|
},
|
|
"detections": [],
|
|
"active_threats": [],
|
|
"correlations": [],
|
|
}
|
|
|
|
|
|
def powershell_json(script=POWERSHELL_SCRIPT, timeout=POWERSHELL_TIMEOUT_SECONDS):
|
|
powershell = os.path.join(
|
|
os.environ.get("SystemRoot", r"C:\Windows"),
|
|
"System32",
|
|
"WindowsPowerShell",
|
|
"v1.0",
|
|
"powershell.exe",
|
|
)
|
|
try:
|
|
result = subprocess.run(
|
|
[powershell, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
raise DefenderCollectionError("powershell_timeout")
|
|
except OSError:
|
|
raise DefenderCollectionError("powershell_unavailable")
|
|
|
|
if result.returncode != 0:
|
|
raise DefenderCollectionError("powershell_failed")
|
|
raw_output = result.stdout or b""
|
|
if isinstance(raw_output, bytes):
|
|
output = raw_output.decode("utf-8-sig", errors="replace").strip()
|
|
else:
|
|
output = str(raw_output).lstrip("\ufeff").strip()
|
|
if not output:
|
|
raise DefenderCollectionError("empty_output")
|
|
try:
|
|
decoded = json.loads(output)
|
|
except (TypeError, ValueError):
|
|
raise DefenderCollectionError("invalid_json")
|
|
if not isinstance(decoded, dict):
|
|
raise DefenderCollectionError("invalid_json")
|
|
return decoded
|
|
|
|
|
|
def collect_defender():
|
|
"""Read Defender state only; no network or remediation cmdlets are used."""
|
|
return powershell_json()
|
|
|
|
|
|
def save_audit(key, value, rules=None):
|
|
config = _copy_rules(rules)
|
|
return WAPT.write_audit_data_if_changed(
|
|
AUDIT_CATEGORY,
|
|
key,
|
|
value,
|
|
max_count=config["audit_history_max_count"],
|
|
keep_days=config["audit_history_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 audit():
|
|
try:
|
|
data = collect_defender()
|
|
snapshot = build_audit_snapshot(data)
|
|
except Exception as exc:
|
|
code = getattr(exc, "code", "collection_failed")
|
|
snapshot = build_collection_error_snapshot(code)
|
|
|
|
try:
|
|
_save_snapshot(snapshot)
|
|
except Exception:
|
|
# Keep the status fail-closed if WAPT audit persistence itself fails.
|
|
print("Defender audit data could not be stored by WAPT")
|
|
return "ERROR"
|
|
|
|
print(json.dumps(snapshot["summary"], indent=2, ensure_ascii=False, sort_keys=True))
|
|
return snapshot["summary"]["audit_status"]
|