feat(defender): classify audit threat risk

This commit is contained in:
PierreCsn committed 2026-10-27 13:09:48 +01:00
1 parent 2d3d316741
commit d402d5f541
5 files changed
+1428 -270

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
# Do not commit binaries:
*.exe
*.msu
*.msi
*.zip
*.rpm
*.deb
*.yum
*.dmg
*.vsix
*.appx
*.appxbundle
*.msix
*.msixbundle
*.iso
*.cab
*.pkg
*.jar
*.msp
*.part
*.bak
# Do not commit generated WAPT files:
*.pyc
*.crt
*.sha256
*.wapt
.env
*.psproj
**/.vscode/
signature
*.sha1
# Do not commit template part:
template.py
.vscode/*.json
code_template.cmd
/template
package_template.py
# Specific to this package (use ! as prefix to allow commit):
!README.md
!setup.py
!WAPT/control
!tests/
!tests/**
+107
View File
@@ -0,0 +1,107 @@
# comit-audit-defender
Paquet WAPT Enterprise 2.6 d'audit **en lecture seule** de Microsoft Defender. Il ne déploie pas d'agent supplémentaire, ne modifie aucune préférence Defender, ne supprime aucune quarantaine et n'effectue aucun accès réseau externe. Le hook natif `audit()` retourne exclusivement `OK`, `WARNING` ou `ERROR`.
Le paquet est planifié toutes les 15 minutes par `WAPT/control`. La collecte PowerShell est locale et bornée à 90 secondes; elle utilise `Get-MpComputerStatus`, `Get-MpThreat` et `Get-MpThreatDetection`. Une erreur PowerShell, un timeout, une réponse vide ou un JSON invalide produit `ERROR` et un état `collection_complete=false`, jamais un poste sain.
## Classification 0–16
La gravité Defender intrinsèque est conservée dans `severity_id` et le risque calculé dans `event_level`. Les identifiants canoniques `MPTHREAT_SEVERITY` sont `0 Unknown`, `1 Low`, `2 Moderate`, `4 High`, `5 Severe`; WMI `SeverityID` (`0..4`) est normalisé (`3 High` vers `4`, `4 Severe` vers `5`) avant classification. Les niveaux de base sont respectivement 5, 4, 6, 9 et 12.
| Niveau | Catégorie |
|---:|---|
| 0 | Aucun événement |
| 1–3 | Information |
| 4–6 | Faible |
| 7–9 | Modéré |
| 10–12 | Élevé |
| 13–14 | Critique |
| 15–16 | Urgence |
Règles par défaut, définies dans `DEFAULT_RULES` et surchargeables par les fonctions pures pour les tests :
| Contexte Defender | Modificateur |
|---|---:|
| Cleaned, Quarantined, Removed | -3 |
| Blocked | -2 |
| Detected / état non résolu | +2 |
| Failed remediation | +3 |
| Allowed | +3 |
| Répétition de la même menace dans la fenêtre | +2 |
| Temps réel désactivé en mode Normal | +2 |
| Antivirus / service désactivé en mode Normal (chaque signal) | +2 |
| Surveillance comportementale ou IOAV désactivée | +1 chacun |
| Signatures anciennes de plus de 3 jours / âge inconnu | +1 |
Le résultat est borné à `[0,16]`. Ces poids et les corrélations sont des règles métier Comitari, inspirées de Wazuh; ils ne reproduisent pas son moteur. L'échelle WAPT dépend de `current_risk_level`, jamais du maximum historique : 0–6 `OK`, 7–9 `WARNING`, 10–16 `ERROR`. En mode `Normal`, antivirus, service ou temps réel désactivé force `ERROR` indépendamment du score. `Passive`, `SxS Passive Mode` et `EDR Block Mode` ne déclenchent pas à eux seuls de faux incident; les menaces restent classifiées.
`Quarantined` reçoit une réduction de score mais reste dans `active_threats`, car la quarantaine ne prouve pas l'absence de compromission. `Cleaned`, `Removed` et `Blocked` ne maintiennent pas seuls le risque courant; toutefois `IsActive`, `DidThreatExecute` ou un état d'exécution `Allowed`/`Executing` les maintient comme risque non résolu. Les événements récents restent aussi dans `detections` et l'historique WAPT. `DidThreatExecute` et `CurrentThreatExecutionStatusID` sont des signaux explicites, séparés du nom et de la sévérité de signature; aucun nom (y compris « ransomware ») n'est interprété comme preuve d'exécution ou de comportement.
## Fenêtres et corrélations
- Fenêtre glissante par défaut : six heures; une détection y entre si sa première détection **ou** son dernier changement de statut se trouve dans cette fenêtre.
- Les événements résolus sont conservés dans la collecte courante pendant 30 jours. Toute menace non résolue est conservée sans limite d'ancienneté, donc une menace active de plus de six heures ne disparaît pas.
- `max_event_level_6h` porte sur les événements récents, `max_active_level` sur toutes les menaces non résolues et `current_risk_level` sur le maximum du risque actif et de l'état de protection.
- Corrélations sur six heures : répétition d'une même menace, menaces distinctes multiples, menace High/Severe avec temps réel désactivé, échecs de remédiation répétés et menace explicitement autorisée.
- `DetectionID` est la clé de déduplication documentée, combinée à `ThreatID`. En son absence, un composite stable de `ThreatID`, dates, statut et empreinte interne des ressources est utilisé; l'identité incomplète est signalée dans le résumé. Les événements distincts avec des DetectionID différents ne sont pas fusionnés parce qu'ils partagent un ThreatID.
- Il n'y a plus de limite arbitraire à 100 détections. La collecte transmet tous les événements de moins de 30 jours ainsi que les états non résolus plus anciens. Les données étant complètes ou la collecte échouant explicitement, aucun incident critique n'est silencieusement tronqué.
## Audit WAPT et Reporting
L'API utilisée est `WAPT.write_audit_data_if_changed(section, key, value, ptype=None, value_date=None, expiration_date=None, max_count=2, keep_days=None)`, documentée dans l'API Setuphelpers WAPT 2.6. Les valeurs sont des dictionnaires/listes JSON natifs, consultables dans Audit Data / Reporting. Le paquet écrit un agrégat `snapshot` version 2 et conserve aussi les clés historiques `summary`, `detections` et `active_threats`, plus `correlations`. La collecte n'ajoute pas d'horodatage courant qui rendrait chaque valeur différente. WAPT conserve jusqu'à 512 changements pendant 90 jours, selon la première limite atteinte.
Exemple synthétique de structure :
```json
{
"schema_version": 2,
"collection_complete": true,
"defender": {
"running_mode": "Normal",
"antivirus_enabled": true,
"realtime_enabled": true,
"behavior_monitor_enabled": true,
"ioav_enabled": true,
"signature_age": 0,
"signature_version": "1.000.0.0",
"signature_last_updated": "2026-10-27T09:00:00Z",
"engine_version": "1.1.0.0",
"service_enabled": true
},
"summary": {
"max_event_level_6h": 0,
"max_active_level": 0,
"current_risk_level": 0,
"current_risk_category": "Aucun événement",
"audit_status": "OK",
"collection_complete": true,
"detection_count_6h": 0,
"active_count": 0,
"resolved_count_6h": 0,
"identity_incomplete_count": 0,
"unknown_severity_count": 0,
"protection_issues": [],
"correlation_count": 0
},
"detections": [],
"active_threats": [],
"correlations": []
}
```
## Données et limites de collecte
- `Get-MpThreatDetection` fournit le ThreatID, DetectionID, ThreatStatusID, dates de détection/changement/remédiation, code d'erreur, réussite d'action, état d'exécution et ressources. `Get-MpThreat` est joint en Python par `ThreatID` pour ThreatName, sévérité, IsActive et DidThreatExecute.
- Les chemins complets des ressources, noms d'utilisateurs et noms de processus ne sont ni sérialisés ni transmis. Seuls le nombre et les catégories `file`, `process`, `registry`, `service`, `url` ou `other` sont conservés.
- Defender peut ne pas exposer une propriété sur une version donnée. Les valeurs absentes sont traitées comme inconnues; une détection de sévérité inconnue reçoit le niveau de base `Unknown`. Une panne de la cmdlet entière force `ERROR`.
- Les valeurs de `ThreatStatusID` utilisées viennent de `MSFT_MpThreatDetection` : 0 Unknown, 1 Detected, 2 Cleaned, 3 Quarantined, 4 Removed, 5 Allowed, 6 Blocked, 102 QuarantineFailed, 103 RemoveFailed, 104 AllowFailed, 105 Abandoned, 107 BlockedFailed. Les différents enums Microsoft peuvent différer selon l'API; ce paquet cible les objets des cmdlets Defender.
- Defender ne livre pas une chronologie complète des actions humaines ni une preuve d'exécution malveillante. `DidThreatExecute` et `CurrentThreatExecutionStatusID` sont exposés lorsque disponibles, sans déduction comportementale sur le nom.
- La réponse PowerShell contient l'ensemble des événements récents et actifs pertinents; le timeout de 90 s provoque un état `ERROR`, sans limite de 100 qui masquerait une détection.
## Tests locaux
Les tests utilisent uniquement des données synthétiques et des mocks Python; aucune commande Defender réelle ni WAPT n'est invoquée :
```shell
python -m unittest discover -s tests -v
```
+11 -37
View File
@@ -1,43 +1,17 @@
package : comit-audit-defender package : comit-audit-defender
version : 0-2 version : 2.0-1
architecture : all architecture : all
section : base section : base
priority : optional priority : optional
name :
categories :
maintainer : Comitari,Pierre Cosson maintainer : Comitari,Pierre Cosson
description : Monitors Microsoft Defender security status, threat detections, and remediation actions through WAPT audits. Classifies threats using a Wazuh-inspired severity scale (0–16), correlates security events over a rolling 6-hour window, and reports active threats, protection issues, and security risks to the WAPT console. description : Monitors Microsoft Defender security status, detections, and remediation through WAPT audits. Classifies risk on a Wazuh-inspired 0-16 scale and correlates events over six hours.
depends : description_fr : Audite l’état de Microsoft Defender via WAPT, classe le risque de 0 à 16 et corrèle les événements sur six heures.
conflicts : depends :
maturity : conflicts :
locale : maturity : DEV
locale : fr
target_os : windows target_os : windows
min_wapt_version : min_wapt_version : 2.6
sources : audit_schedule : 15m
installed_size : keywords : defender,audit,security
impacted_process : homepage : https://git.comitari.fr/paquets-publiques/comi-audit-defender
description_fr :
description_pl :
description_de :
description_es :
description_pt :
description_it :
description_nl :
description_ru :
audit_schedule :
editor :
keywords :
licence :
homepage :
package_uuid :
valid_from :
valid_until :
forced_install_on :
changelog :
min_os_version :
max_os_version :
icon_sha256sum :
signer :
signer_fingerprint:
signature_date :
signed_attributes :
+849 -233
View File
File diff suppressed because it is too large. Load diff
+415
View File
@@ -0,0 +1,415 @@
import importlib.util
import json
import sys
import types
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.modules.setdefault("setuphelpers", types.ModuleType("setuphelpers"))
SPEC = importlib.util.spec_from_file_location("defender_setup", ROOT / "setup.py")
defender_setup = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(defender_setup)
NOW = datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc)
def defender_state(**overrides):
state = {
"running_mode": "Normal",
"antivirus_enabled": True,
"realtime_enabled": True,
"behavior_monitor_enabled": True,
"ioav_enabled": True,
"signature_age": 0,
"signature_version": "synthetic-signature-version",
"signature_last_updated": "2026-10-27T11:00:00Z",
"engine_version": "synthetic-engine-version",
"service_enabled": True,
}
state.update(overrides)
return state
def make_threat(threat_id="100", severity_id=1, name="Synthetic threat", is_active=False,
source="ThreatSeverityID", did_threat_execute=False):
return {
"threat_id": str(threat_id),
"threat_name": name,
"threat_severity_id": severity_id,
"severity_source": source,
"is_active": is_active,
"did_threat_execute": did_threat_execute,
}
def make_detection(
detection_id="detection-1",
threat_id="100",
status_id=1,
detected_hours_ago=1,
changed_hours_ago=None,
error_code=0,
action_success=True,
resources=None,
):
detected = NOW - timedelta(hours=detected_hours_ago)
changed = NOW - timedelta(hours=changed_hours_ago) if changed_hours_ago is not None else detected
return {
"detection_id": detection_id,
"threat_id": str(threat_id),
"status_id": status_id,
"detected_at": detected.isoformat().replace("+00:00", "Z"),
"changed_at": changed.isoformat().replace("+00:00", "Z"),
"remediation_at": None,
"error_code": error_code,
"action_success": action_success,
"execution_status_id": 0,
"resources": resources or [],
}
def make_data(detections=None, threats=None, defender=None, collection_complete=True, **extra):
detection_rows = detections if isinstance(detections, list) else ([] if detections is None else [detections])
threat_rows = threats if isinstance(threats, list) else ([] if threats is None else [threats])
return {
"collection_complete": collection_complete,
"history_detection_count": len(detection_rows),
"returned_detection_count": len(detection_rows),
"defender": defender_state(**(defender or {})),
"threats": threat_rows,
"detections": detection_rows,
**extra,
}
class TestDefenderClassification(unittest.TestCase):
def build(self, data, rules=None):
return defender_setup.build_audit_snapshot(data, now=NOW, rules=rules)
def test_no_events_with_operational_defender_is_ok(self):
snapshot = self.build(make_data())
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 0)
self.assertEqual(snapshot["summary"]["max_active_level"], 0)
self.assertEqual(snapshot["summary"]["current_risk_level"], 0)
self.assertEqual(snapshot["detections"], [])
def test_low_quarantined_threat_has_residual_risk_and_remains_active(self):
data = make_data(
[make_detection(status_id=3)],
[make_threat(severity_id=1, is_active=False)],
)
snapshot = self.build(data)
item = snapshot["active_threats"][0]
self.assertEqual(item["event_level"], 3) # 4 - 3 + 2 active
self.assertEqual(item["status"], "Quarantined")
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
def test_severe_removed_is_historical_event_not_current_error(self):
data = make_data(
[make_detection(status_id=4)],
[make_threat(severity_id=5, is_active=False)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 9)
self.assertEqual(snapshot["summary"]["max_active_level"], 0)
self.assertEqual(snapshot["summary"]["current_risk_level"], 0)
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
self.assertEqual(snapshot["summary"]["resolved_count_6h"], 1)
def test_removed_threat_with_execution_observed_remains_unresolved_risk(self):
data = make_data(
[make_detection(status_id=4)],
[make_threat(severity_id=5, is_active=False, did_threat_execute=True)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["active_threats"][0]["active_reason"], "execution_observed")
self.assertEqual(snapshot["active_threats"][0]["event_level"], 11) # 12 - 3 removed + 2 observed
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
def test_current_executing_state_keeps_removed_detection_active(self):
event = make_detection(status_id=4)
event["execution_status_id"] = 3
snapshot = self.build(make_data([event], [make_threat(severity_id=2, is_active=False)]))
self.assertEqual(snapshot["active_threats"][0]["active_reason"], "execution_allowed_or_running")
def test_severe_detected_threat_is_error(self):
data = make_data(
[make_detection(status_id=1)],
[make_threat(severity_id=5, is_active=True)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["summary"]["max_active_level"], 14)
self.assertEqual(snapshot["summary"]["current_risk_level"], 14)
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
def test_failed_remediation_adds_failure_and_active_modifiers(self):
data = make_data(
[make_detection(status_id=103, action_success=False)],
[make_threat(severity_id=1, is_active=False)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["active_threats"][0]["event_level"], 9) # 4 + 3 + 2
self.assertEqual(snapshot["summary"]["audit_status"], "WARNING")
def test_action_success_false_adds_failure_modifier_even_before_failed_status_rollup(self):
data = make_data(
[make_detection(status_id=1, action_success=False)],
[make_threat(severity_id=1, is_active=True)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["detections"][0]["event_level"], 9) # 4 + 2 active + 3 failure
def test_explicitly_allowed_threat_is_scored_and_correlated(self):
data = make_data(
[make_detection(status_id=5)],
[make_threat(severity_id=2, is_active=False)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["active_threats"][0]["event_level"], 11) # 6 + 3 + 2
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
self.assertIn("explicitly_allowed", [item["rule"] for item in snapshot["correlations"]])
def test_repeated_detections_raise_score_and_correlation(self):
events = [make_detection("detection-a"), make_detection("detection-b", detected_hours_ago=2)]
snapshot = self.build(make_data(events, [make_threat(severity_id=1, is_active=True)]))
self.assertEqual(len(snapshot["detections"]), 2)
self.assertTrue(all(item["event_level"] == 8 for item in snapshot["detections"]))
self.assertIn("same_threat_repeated", [item["rule"] for item in snapshot["correlations"]])
self.assertEqual(snapshot["summary"]["audit_status"], "WARNING")
def test_repeated_remediation_failures_correlate_once_per_detection(self):
events = [
make_detection("failure-a", status_id=103, detected_hours_ago=1, action_success=False),
make_detection("failure-b", status_id=103, detected_hours_ago=2, action_success=False),
]
snapshot = self.build(make_data(events, [make_threat(severity_id=1, is_active=False)]))
failures = [item for item in snapshot["correlations"] if item["rule"] == "repeated_remediation_failures"]
self.assertEqual(len(failures), 1)
self.assertEqual(failures[0]["count"], 2)
self.assertEqual(failures[0]["threat_ids"], ["100"])
def test_old_active_threat_remains_visible_outside_six_hour_window(self):
event = make_detection(detected_hours_ago=10, changed_hours_ago=10)
snapshot = self.build(make_data([event], [make_threat(severity_id=5, is_active=True)]))
self.assertEqual(snapshot["summary"]["detection_count_6h"], 0)
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 0)
self.assertEqual(snapshot["summary"]["active_count"], 1)
self.assertEqual(snapshot["summary"]["current_risk_level"], 14)
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
def test_realtime_disabled_in_normal_mode_forces_error(self):
snapshot = self.build(make_data(defender={"realtime_enabled": False}))
self.assertEqual(snapshot["summary"]["current_risk_level"], 2)
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
self.assertIn("Realtime protection disabled", snapshot["summary"]["protection_issues"])
def test_passive_mode_does_not_create_protection_false_positive(self):
snapshot = self.build(make_data(defender={"running_mode": "Passive", "realtime_enabled": False}))
self.assertEqual(snapshot["summary"]["current_risk_level"], 0)
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
def test_collection_error_is_fail_closed(self):
snapshot = defender_setup.build_collection_error_snapshot("powershell_timeout")
self.assertFalse(snapshot["collection_complete"])
self.assertEqual(snapshot["summary"]["current_risk_level"], 16)
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
def test_missing_history_arrays_is_incomplete_and_error(self):
snapshot = self.build({"defender": defender_state()})
self.assertFalse(snapshot["collection_complete"])
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
self.assertEqual(snapshot["summary"]["current_risk_level"], 16)
def test_singleton_powershell_objects_are_normalized_as_one_row(self):
data = make_data(
detections=make_detection(status_id=1),
threats=make_threat(severity_id=1, is_active=True),
)
snapshot = self.build(data)
self.assertTrue(snapshot["collection_complete"])
self.assertEqual(len(snapshot["detections"]), 1)
self.assertEqual(snapshot["detections"][0]["threat_id"], "100")
def test_null_empty_collections_are_treated_as_empty_not_failed(self):
data = make_data()
data["detections"] = None
data["threats"] = None
snapshot = self.build(data)
self.assertTrue(snapshot["collection_complete"])
self.assertEqual(snapshot["detections"], [])
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
def test_same_threatid_with_distinct_detectionids_remains_two_incidents(self):
detections = [
make_detection("distinct-a", detected_hours_ago=1),
make_detection("distinct-b", detected_hours_ago=2),
make_detection("distinct-a", detected_hours_ago=1), # duplicate record of same detection
]
snapshot = self.build(make_data(detections, [make_threat(severity_id=1, is_active=True)]))
self.assertEqual(len(snapshot["detections"]), 2)
self.assertEqual(snapshot["summary"]["detection_count_6h"], 2)
self.assertIn("same_threat_repeated", [item["rule"] for item in snapshot["correlations"]])
def test_old_detection_with_recent_status_change_enters_window(self):
event = make_detection(status_id=6, detected_hours_ago=20, changed_hours_ago=1)
snapshot = self.build(make_data([event], [make_threat(severity_id=5, is_active=False)]))
self.assertEqual(snapshot["summary"]["detection_count_6h"], 1)
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 10)
self.assertEqual(snapshot["summary"]["current_risk_level"], 0)
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
def test_legacy_catalog_severityid_is_normalized_to_documented_enum(self):
data = make_data(
[make_detection(status_id=1)],
[make_threat(severity_id=3, source="SeverityID", is_active=True)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["detections"][0]["severity_id"], 4)
self.assertEqual(snapshot["detections"][0]["severity"], "High")
self.assertEqual(snapshot["summary"]["max_active_level"], 11)
def test_severe_status_mapping_uses_threatseverityid_five(self):
self.assertEqual(
defender_setup.classify_detection({"threat_severity_id": 5, "status_id": 2}),
9,
)
self.assertEqual(
defender_setup.classify_detection({"threat_severity_id": 4, "status_id": 2}),
6,
)
def test_two_distinct_threats_correlate_and_protection_disabled_is_combined(self):
events = [
make_detection("threat-a", threat_id="100", status_id=1),
make_detection("threat-b", threat_id="200", status_id=1, detected_hours_ago=2),
]
threats = [
make_threat("100", severity_id=5, is_active=True, did_threat_execute=True),
make_threat("200", severity_id=1, is_active=True),
]
snapshot = self.build(make_data(events, threats, defender={"realtime_enabled": False}))
rules = [item["rule"] for item in snapshot["correlations"]]
self.assertIn("multiple_threats", rules)
self.assertIn("severe_with_protection_disabled", rules)
severe = next(item for item in snapshot["detections"] if item["threat_id"] == "100")
self.assertEqual(severe["event_level"], 16) # 12 + 2 active + 2 realtime disabled
self.assertTrue(severe["did_threat_execute"])
def test_resources_are_reduced_to_types_and_counts(self):
event = make_detection(resources=[r"file:_C:\Users\synthetic\private.txt", "registry:synthetic"])
snapshot = self.build(make_data([event], [make_threat(is_active=True)]))
serialized = json.dumps(snapshot)
self.assertEqual(snapshot["detections"][0]["resource_count"], 2)
self.assertEqual(snapshot["detections"][0]["resource_kinds"], ["file", "registry"])
self.assertNotIn("synthetic\\private.txt", serialized)
self.assertNotIn("Users", serialized)
def test_wapt_status_uses_current_risk_not_historical_event_maximum(self):
data = make_data(
[make_detection(status_id=4)],
[make_threat(severity_id=5, is_active=False)],
)
snapshot = self.build(data)
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 9)
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
def test_classifier_modifiers_are_configurable_and_clamped(self):
rules = {
"active_modifier": 20,
"status_modifiers": {1: 20},
}
self.assertEqual(
defender_setup.classify_detection({"threat_severity_id": 5, "status_id": 1}, rules=rules),
16,
)
def test_wapt_thresholds_are_ok_warning_error(self):
self.assertEqual(defender_setup._wapt_status(6, [], True, False, True, 0, 0), "OK")
self.assertEqual(defender_setup._wapt_status(7, [], True, False, True, 0, 0), "WARNING")
self.assertEqual(defender_setup._wapt_status(10, [], True, False, True, 0, 0), "ERROR")
class TestDefenderCollectionAndWaptAdapter(unittest.TestCase):
def test_powershell_timeout_is_a_safe_collection_error(self):
with patch.object(defender_setup.subprocess, "run", side_effect=defender_setup.subprocess.TimeoutExpired("powershell", 90)):
with self.assertRaises(defender_setup.DefenderCollectionError) as raised:
defender_setup.powershell_json("synthetic script", timeout=90)
self.assertEqual(raised.exception.code, "powershell_timeout")
def test_powershell_json_accepts_bom_and_never_uses_executionpolicy_bypass(self):
payload = {"collection_complete": True, "defender": {}, "threats": [], "detections": []}
completed = types.SimpleNamespace(returncode=0, stdout=("\ufeff" + json.dumps(payload)).encode("utf-8"), stderr=b"")
with patch.object(defender_setup.subprocess, "run", return_value=completed) as run:
result = defender_setup.powershell_json("synthetic script")
args = run.call_args.args[0]
self.assertEqual(result, payload)
self.assertNotIn("-ExecutionPolicy", args)
self.assertNotIn("Bypass", args)
self.assertEqual(run.call_args.kwargs["timeout"], defender_setup.POWERSHELL_TIMEOUT_SECONDS)
def test_audit_timeout_returns_error_and_stores_failed_snapshot(self):
wapt = Mock()
with patch.object(defender_setup, "WAPT", wapt, create=True), patch.object(
defender_setup, "collect_defender", side_effect=defender_setup.DefenderCollectionError("powershell_timeout")
):
result = defender_setup.audit()
self.assertEqual(result, "ERROR")
stored = [call.args for call in wapt.write_audit_data_if_changed.call_args_list]
snapshot_call = next(args for args in stored if args[1] == "snapshot")
self.assertFalse(snapshot_call[2]["collection_complete"])
self.assertEqual(snapshot_call[2]["summary"]["audit_status"], "ERROR")
def test_wapt_audit_api_uses_official_signature_and_json_value(self):
wapt = Mock()
value = {"schema_version": 2, "detections": [], "correlations": []}
with patch.object(defender_setup, "WAPT", wapt, create=True):
defender_setup.save_audit("snapshot", value)
wapt.write_audit_data_if_changed.assert_called_once_with(
"Microsoft Defender", "snapshot", value,
max_count=512, keep_days=90,
)
def test_audit_hook_returns_wapt_status_and_writes_v2_snapshot(self):
wapt = Mock()
with patch.object(defender_setup, "WAPT", wapt, create=True), patch.object(
defender_setup, "collect_defender", return_value=make_data()
):
result = defender_setup.audit()
self.assertEqual(result, "OK")
written_keys = [call.args[1] for call in wapt.write_audit_data_if_changed.call_args_list]
self.assertEqual(written_keys, ["snapshot", "summary", "detections", "active_threats", "correlations"])
snapshot_call = wapt.write_audit_data_if_changed.call_args_list[0]
self.assertEqual(snapshot_call.args[2]["schema_version"], 2)
def test_control_declares_windows_wapt_26_and_15_minute_audit(self):
fields = {}
for line in (ROOT / "WAPT" / "control").read_text(encoding="utf-8").splitlines():
if line.strip() and not line.lstrip().startswith("#"):
key, value = line.split(":", 1)
fields[key.strip()] = value.strip()
self.assertEqual(fields["package"], "comit-audit-defender")
self.assertEqual(fields["min_wapt_version"], "2.6")
self.assertEqual(fields["audit_schedule"], "15m")
self.assertEqual(fields["target_os"], "windows")
def test_powershell_collection_is_local_read_only_and_does_not_emit_raw_resources(self):
script = defender_setup.POWERSHELL_SCRIPT
self.assertIn("Get-MpComputerStatus", script)
self.assertIn("Get-MpThreatDetection", script)
self.assertIn("Get-MpThreat -ErrorAction Stop", script)
self.assertNotIn("Invoke-WebRequest", script)
self.assertNotIn("Remove-MpThreat", script)
self.assertNotIn("Set-MpPreference", script)
self.assertNotIn("resources = @($_.Resources)", script)
self.assertNotIn("Select-Object -First 100", script)
if __name__ == "__main__":
unittest.main()