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
@@ -27,79 +27,95 @@ Règles par défaut, définies dans `DEFAULT_RULES` et surchargeables par les fo
|
||||
| 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 |
|
||||
| Répétition de la même menace dans la fenêtre | +2 à `correlation_level` |
|
||||
| Menaces distinctes corrélées dans la fenêtre | +2 à `correlation_level` |
|
||||
| Temps réel désactivé en mode Normal | +2 au risque courant; +2 à la corrélation si menace grave |
|
||||
| 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.
|
||||
Le résultat est borné à `[0,16]`. `event_level` utilise la gravité et l'état de remédiation de cette détection; ni la configuration actuelle du poste ni le nombre d'événements ne la réécrivent. `correlation_level` agrège les événements associés sur six heures. `current_risk_level` combine les menaces non résolues, les investigations requises, les corrélations et l'état de protection. Ces poids sont des règles métier Comitari inspirées de Wazuh; ils ne reproduisent pas son moteur. Le statut WAPT utilise le risque courant : 0–6 `OK`, 7–9 `WARNING`, 10–16 `ERROR`. Un échec explicite de remédiation force aussi `ERROR`, même si le niveau numérique est inférieur à 10. 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.
|
||||
`Quarantined` reste non résolu et distinct de `Cleaned` ou `Removed`. `DidThreatExecute` est un signal historique : il ne maintient pas seul une détection correctement supprimée dans `active_threats`; il positionne `execution_observed` et `requires_investigation`. Pour `CurrentThreatExecutionStatusID`, seuls `3 = Executing` signifie une exécution en cours; `2 = Allowed` signifie que l'exécution a été autorisée et exige une investigation, mais ne prouve pas qu'elle est en cours. `1 = Blocked` n'est pas une preuve de compromission. Le `IsActive` de `Get-MpThreat` est une valeur agrégée par `ThreatID`; il ne rouvre pas un enregistrement `DetectionID` explicitement résolu, mais le conflit est signalé par `requires_investigation`. Aucun nom de menace (y compris « ransomware ») n'est interprété comme preuve d'exécution.
|
||||
|
||||
## 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.
|
||||
- `max_event_level_6h` porte sur les événements récents, `max_active_level` sur les menaces non résolues, `max_investigation_level` sur les incidents demandant une investigation, `correlation_level` sur les règles de corrélation et `current_risk_level` sur le risque actuel du poste.
|
||||
- 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.
|
||||
Le paquet utilise `WAPT.write_audit_data_if_changed(section, key, value, ptype=None, value_date=None, expiration_date=None, max_count=2, keep_days=None)` et `WAPT.read_audit_data(section, key, default=None, ptype=None, include_expired_data=True)`, documentées pour WAPT 2.6. Le schéma v3 évite un grand snapshot dupliqué : la clé `summary` contient le dernier résumé léger (historique limité à 128 changements/90 jours), `correlations` contient le dernier agrégat de corrélations, et chaque incident a sa clé `event.<sha256>` avec jusqu'à 16 évolutions/90 jours. Les écritures identiques ne créent pas une nouvelle valeur WAPT. Les événements utilisent un format JSON v1 indépendant de Reporting; aucune requête HTTP ou intégration Vector n'est incluse. Les anciennes clés v2 cessent d'être mises à jour et expirent selon leur rétention d'origine.
|
||||
|
||||
Exemple synthétique de structure :
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"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
|
||||
"signature_age": 0
|
||||
},
|
||||
"summary": {
|
||||
"max_event_level_6h": 0,
|
||||
"max_active_level": 0,
|
||||
"max_investigation_level": 0,
|
||||
"correlation_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,
|
||||
"investigation_count": 0,
|
||||
"remediation_failed": false,
|
||||
"resolved_count_6h": 0,
|
||||
"identity_incomplete_count": 0,
|
||||
"unknown_severity_count": 0,
|
||||
"protection_issues": [],
|
||||
"correlation_count": 0
|
||||
},
|
||||
"detections": [],
|
||||
"active_threats": [],
|
||||
"correlations": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Chaque clé `event.<sha256>` conserve un événement compact, par exemple :
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"event_id": "sha256-stable-par-DetectionID",
|
||||
"source": "comi-audit-defender",
|
||||
"event_type": "threat.resolved",
|
||||
"event_level": 9,
|
||||
"remediation_state": "removed",
|
||||
"execution_observed": true,
|
||||
"requires_investigation": true,
|
||||
"active": false,
|
||||
"timestamp": "2026-10-27T11:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
- `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. Le booléen `ActionSuccess=False` seul n'est pas un échec : la cmdlet documente sa valeur par défaut à `false`; le paquet exige un statut d'échec explicite ou un code d'erreur non nul.
|
||||
- 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.
|
||||
- Defender ne livre pas une chronologie complète des actions humaines ni une preuve d'exécution malveillante. `DidThreatExecute` est agrégé au niveau `ThreatID` et peut s'appliquer à plusieurs `DetectionID` joints au même ThreatID; les enregistrements restent distincts, mais cette attribution agrégée peut nécessiter une investigation. Sans `DetectionID`, l'identifiant d'événement utilise les dates/ressources disponibles et le résumé signale l'identité incomplète.
|
||||
- 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.
|
||||
- La table WAPT garde les transitions par clé d'incident. Les rapports doivent sélectionner la dernière valeur de chaque `event.<sha256>`; `value_date` donne l'âge de la dernière observation enregistrée.
|
||||
|
||||
## Tests locaux
|
||||
|
||||
Les exemples SQL pour le Reporting WAPT sont dans [docs/WAPT-REPORTING.md](docs/WAPT-REPORTING.md).
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
package : comit-audit-defender
|
||||
version : 2.0-1
|
||||
version : 2.0-2
|
||||
architecture : all
|
||||
section : base
|
||||
priority : optional
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Exemples SQL pour WAPT Reporting
|
||||
|
||||
Ces requêtes de lecture ciblent l'onglet **Reporting de WAPT Enterprise 2.6** et la table `HostAuditData`. Elles utilisent le schéma v3 du paquet `comit-audit-defender`, dans la section `Microsoft Defender`.
|
||||
|
||||
Le schéma v3 ne duplique plus chaque détection dans un snapshot complet. Il conserve :
|
||||
|
||||
- `value_key = 'summary'` : état léger et historisé, `schema_version: 3`;
|
||||
- `value_key = 'correlations'` : corrélations courantes et `correlation_level`;
|
||||
- `value_key LIKE 'event.%'` : derniers états et transitions de chaque incident, chacun avec un `event_id` stable et `schema_version: 1`.
|
||||
|
||||
Les requêtes sélectionnent le dernier état par poste pour `summary`, et le dernier état par incident pour `event.%`. La requête d'événements graves conserve les anciennes transitions récentes afin qu'une résolution ultérieure ne masque pas la détection grave initiale.
|
||||
|
||||
## 1. Tableau de bord — tous les postes
|
||||
|
||||
```sql
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (host_id)
|
||||
host_id,
|
||||
value::jsonb AS data,
|
||||
value_date
|
||||
FROM HostAuditData
|
||||
WHERE value_section = 'Microsoft Defender'
|
||||
AND value_key = 'summary'
|
||||
ORDER BY host_id, value_date DESC, ctid DESC
|
||||
)
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
l.data #>> '{summary,audit_status}' AS audit_status,
|
||||
(l.data #>> '{summary,current_risk_level}')::int AS risk_level,
|
||||
l.data #>> '{summary,current_risk_category}' AS risk_category,
|
||||
(l.data #>> '{summary,active_count}')::int AS active_threats,
|
||||
(l.data #>> '{summary,detection_count_6h}')::int AS detections_6h,
|
||||
(l.data #>> '{summary,investigation_count}')::int AS investigations,
|
||||
l.value_date AS last_data_change
|
||||
FROM latest l
|
||||
JOIN hosts h ON h.uuid = l.host_id
|
||||
ORDER BY risk_level DESC NULLS LAST, h.computer_fqdn;
|
||||
```
|
||||
|
||||
## 2. Postes en statut `ERROR`
|
||||
|
||||
```sql
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (host_id)
|
||||
host_id,
|
||||
value::jsonb AS data,
|
||||
value_date
|
||||
FROM HostAuditData
|
||||
WHERE value_section = 'Microsoft Defender'
|
||||
AND value_key = 'summary'
|
||||
ORDER BY host_id, value_date DESC, ctid DESC
|
||||
)
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
(l.data #>> '{summary,current_risk_level}')::int AS risk_level,
|
||||
(l.data #>> '{summary,active_count}')::int AS active_count,
|
||||
(l.data #>> '{collection_complete}')::boolean AS collection_complete,
|
||||
l.data #> '{summary,protection_issues}' AS protection_issues,
|
||||
l.value_date AS last_data_change
|
||||
FROM latest l
|
||||
JOIN hosts h ON h.uuid = l.host_id
|
||||
WHERE l.data #>> '{summary,audit_status}' = 'ERROR'
|
||||
ORDER BY risk_level DESC NULLS LAST, h.computer_fqdn;
|
||||
```
|
||||
|
||||
## 3. Menaces non résolues ou demandant une investigation
|
||||
|
||||
Les enregistrements sont dédupliqués au dernier état par incident. Une exécution historique peut exiger une investigation sans faire apparaître la détection comme active.
|
||||
|
||||
```sql
|
||||
WITH latest_events AS (
|
||||
SELECT DISTINCT ON (host_id, value_key)
|
||||
host_id,
|
||||
value_key,
|
||||
value::jsonb AS data,
|
||||
value_date
|
||||
FROM HostAuditData
|
||||
WHERE value_section = 'Microsoft Defender'
|
||||
AND value_key LIKE 'event.%'
|
||||
ORDER BY host_id, value_key, value_date DESC, ctid DESC
|
||||
)
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
e.data ->> 'event_id' AS event_id,
|
||||
e.data ->> 'event_type' AS event_type,
|
||||
(e.data ->> 'event_level')::int AS event_level,
|
||||
e.data ->> 'remediation_state' AS remediation_state,
|
||||
(e.data ->> 'active')::boolean AS active,
|
||||
(e.data ->> 'execution_observed')::boolean AS execution_observed,
|
||||
(e.data ->> 'requires_investigation')::boolean AS requires_investigation,
|
||||
e.data ->> 'timestamp' AS event_time,
|
||||
e.value_date AS last_reported
|
||||
FROM latest_events e
|
||||
JOIN hosts h ON h.uuid = e.host_id
|
||||
WHERE COALESCE((e.data ->> 'active')::boolean, false)
|
||||
OR COALESCE((e.data ->> 'requires_investigation')::boolean, false)
|
||||
ORDER BY event_level DESC NULLS LAST, h.computer_fqdn;
|
||||
```
|
||||
|
||||
La colonne `last_reported` permet d'identifier un poste dont l'état n'a pas été actualisé récemment.
|
||||
|
||||
## 4. Événements de niveau élevé observés dans les six dernières heures
|
||||
|
||||
La requête conserve une ligne par incident parmi les transitions dont l'heure d'événement est récente. Une transition résolue de faible score ne masque donc pas une transition grave précédente dans la fenêtre.
|
||||
|
||||
```sql
|
||||
WITH recent_events AS (
|
||||
SELECT DISTINCT ON (host_id, value::jsonb ->> 'event_id')
|
||||
host_id,
|
||||
value::jsonb AS data,
|
||||
value_date
|
||||
FROM HostAuditData
|
||||
WHERE value_section = 'Microsoft Defender'
|
||||
AND value_key LIKE 'event.%'
|
||||
AND (value::jsonb ->> 'event_level')::int >= 10
|
||||
AND NULLIF(value::jsonb ->> 'timestamp', '')::timestamptz
|
||||
>= NOW() - INTERVAL '6 hours'
|
||||
ORDER BY host_id, value::jsonb ->> 'event_id', value_date DESC, ctid DESC
|
||||
)
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
e.data ->> 'event_id' AS event_id,
|
||||
e.data ->> 'event_type' AS event_type,
|
||||
(e.data ->> 'event_level')::int AS event_level,
|
||||
e.data ->> 'remediation_state' AS remediation_state,
|
||||
(e.data ->> 'active')::boolean AS active,
|
||||
(e.data ->> 'timestamp')::timestamptz AS event_time,
|
||||
e.value_date AS recorded_at
|
||||
FROM recent_events e
|
||||
JOIN hosts h ON h.uuid = e.host_id
|
||||
ORDER BY event_level DESC, event_time DESC;
|
||||
```
|
||||
|
||||
L'heure de fenêtre est celle du serveur PostgreSQL. Le paquet conserve les événements sans nom de menace ni chemin utilisateur afin de limiter les données transmises.
|
||||
|
||||
## 5. État des protections
|
||||
|
||||
```sql
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (host_id)
|
||||
host_id,
|
||||
value::jsonb AS data,
|
||||
value_date
|
||||
FROM HostAuditData
|
||||
WHERE value_section = 'Microsoft Defender'
|
||||
AND value_key = 'summary'
|
||||
ORDER BY host_id, value_date DESC, ctid DESC
|
||||
)
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
l.data #>> '{defender,running_mode}' AS running_mode,
|
||||
(l.data #>> '{defender,antivirus_enabled}')::boolean AS antivirus_enabled,
|
||||
(l.data #>> '{defender,realtime_enabled}')::boolean AS realtime_enabled,
|
||||
(l.data #>> '{defender,signature_age}')::int AS signature_age_days,
|
||||
l.data #> '{summary,protection_issues}' AS protection_issues,
|
||||
l.value_date AS last_data_change
|
||||
FROM latest l
|
||||
JOIN hosts h ON h.uuid = l.host_id
|
||||
WHERE jsonb_array_length(COALESCE(l.data #> '{summary,protection_issues}', '[]'::jsonb)) > 0
|
||||
ORDER BY h.computer_fqdn;
|
||||
```
|
||||
|
||||
Le paquet ne signale pas à tort le mode passif comme une panne, notamment lorsqu'un autre antivirus assure la protection.
|
||||
|
||||
## 6. Corrélations courantes
|
||||
|
||||
```sql
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (host_id)
|
||||
host_id,
|
||||
value::jsonb AS data,
|
||||
value_date
|
||||
FROM HostAuditData
|
||||
WHERE value_section = 'Microsoft Defender'
|
||||
AND value_key = 'correlations'
|
||||
ORDER BY host_id, value_date DESC, ctid DESC
|
||||
)
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
l.data ->> 'correlation_level' AS correlation_level,
|
||||
c.item ->> 'rule' AS correlation_rule,
|
||||
(c.item ->> 'count')::int AS related_count,
|
||||
(c.item ->> 'correlation_level')::int AS rule_level,
|
||||
l.value_date AS last_data_change
|
||||
FROM latest l
|
||||
JOIN hosts h ON h.uuid = l.host_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(l.data -> 'correlations', '[]'::jsonb)
|
||||
) AS c(item)
|
||||
ORDER BY (l.data ->> 'correlation_level')::int DESC NULLS LAST,
|
||||
h.computer_fqdn;
|
||||
```
|
||||
|
||||
Règles possibles : `same_threat_repeated`, `multiple_threats`, `severe_with_protection_disabled`, `repeated_remediation_failures` et `explicitly_allowed`.
|
||||
|
||||
## 7. Historique du risque d'un poste
|
||||
|
||||
Remplacer `PC01.example.local` par le nom DNS complet du poste.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
h.uuid AS host_id,
|
||||
h.computer_fqdn,
|
||||
a.value_date AS change_date,
|
||||
a.value #>> '{summary,audit_status}' AS audit_status,
|
||||
(a.value #>> '{summary,current_risk_level}')::int AS risk_level,
|
||||
(a.value #>> '{summary,max_event_level_6h}')::int AS max_event_level_6h,
|
||||
(a.value #>> '{summary,active_count}')::int AS active_count,
|
||||
(a.value #>> '{summary,correlation_level}')::int AS correlation_level
|
||||
FROM HostAuditData a
|
||||
JOIN hosts h ON h.uuid = a.host_id
|
||||
WHERE a.value_section = 'Microsoft Defender'
|
||||
AND a.value_key = 'summary'
|
||||
AND h.computer_fqdn = 'PC01.example.local'
|
||||
ORDER BY a.value_date DESC
|
||||
LIMIT 128;
|
||||
```
|
||||
|
||||
L'historique utilise `write_audit_data_if_changed()` : il contient les changements de résumé, pas chaque exécution de l'audit. Le paquet conserve au plus 128 valeurs de résumé et 32 valeurs de corrélation pendant 90 jours; chaque clé d'événement conserve au plus 16 transitions pendant 90 jours.
|
||||
|
||||
## Notes et validation
|
||||
|
||||
- Les requêtes sont en lecture seule et supposent les colonnes `HostAuditData.value_section`, `value_key`, `value`, `value_date`, `host_id` et les champs `hosts.uuid`, `computer_fqdn` utilisés par l'exemple fourni.
|
||||
- Les identifiants d'hôte dynamiques utilisent `hosts.uuid`.
|
||||
- Le schéma v2 historique n'est pas interrogé par ces exemples. Les anciennes clés v2 cessent d'être alimentées par le paquet v3 et expirent selon la rétention précédemment définie.
|
||||
- Les requêtes SQL sont des exemples construits à partir du schéma du paquet et des colonnes communiquées; elles n'ont pas été exécutées sur une instance WAPT réelle.
|
||||
|
||||
Références : [WAPT 2.6 — audit et Reporting](https://www.wapt.fr/fr/doc-2.6/wapt-create-packages-code.html), [WAPT Reporting avancé](https://www.wapt.fr/en/doc-2.7/wapt-console-usage-advanced-reporting.html).
|
||||
@@ -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():
|
||||
|
||||
+280
-27
@@ -88,6 +88,27 @@ def make_data(detections=None, threats=None, defender=None, collection_complete=
|
||||
}
|
||||
|
||||
|
||||
class MemoryWaptAudit(object):
|
||||
"""Small fake implementing the WAPT 2.6 audit read/write contract."""
|
||||
|
||||
def __init__(self):
|
||||
self.values = {}
|
||||
self.history = []
|
||||
|
||||
def read_audit_data(self, section, key, default=None, ptype=None, include_expired_data=True):
|
||||
return self.values.get((section, key), default)
|
||||
|
||||
def write_audit_data_if_changed(self, section, key, value, ptype=None,
|
||||
value_date=None, expiration_date=None,
|
||||
max_count=2, keep_days=None):
|
||||
storage_key = (section, key)
|
||||
previous = self.values.get(storage_key)
|
||||
if previous != value:
|
||||
self.values[storage_key] = value
|
||||
self.history.append((storage_key, value, max_count, keep_days))
|
||||
return previous
|
||||
|
||||
|
||||
class TestDefenderClassification(unittest.TestCase):
|
||||
def build(self, data, rules=None):
|
||||
return defender_setup.build_audit_snapshot(data, now=NOW, rules=rules)
|
||||
@@ -123,21 +144,52 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
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):
|
||||
def test_removed_threat_with_execution_observed_is_resolved_but_requires_investigation(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")
|
||||
item = snapshot["detections"][0]
|
||||
self.assertEqual(snapshot["active_threats"], [])
|
||||
self.assertEqual(item["remediation_state"], "removed")
|
||||
self.assertFalse(item["active"])
|
||||
self.assertTrue(item["execution_observed"])
|
||||
self.assertTrue(item["requires_investigation"])
|
||||
self.assertEqual(item["event_level"], 9) # event severity excludes execution/investigation context
|
||||
self.assertEqual(item["investigation_level"], 9)
|
||||
self.assertEqual(snapshot["summary"]["max_active_level"], 0)
|
||||
self.assertEqual(snapshot["summary"]["max_investigation_level"], 9)
|
||||
self.assertEqual(snapshot["summary"]["audit_status"], "WARNING")
|
||||
|
||||
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")
|
||||
self.assertEqual(snapshot["active_threats"][0]["active_reason"], "currently_executing")
|
||||
self.assertTrue(snapshot["active_threats"][0]["execution_observed"])
|
||||
|
||||
def test_allowed_execution_is_not_active_but_requires_investigation(self):
|
||||
event = make_detection(status_id=4)
|
||||
event["execution_status_id"] = 2
|
||||
snapshot = self.build(make_data([event], [make_threat(severity_id=5, is_active=False)]))
|
||||
item = snapshot["detections"][0]
|
||||
self.assertFalse(item["active"])
|
||||
self.assertFalse(item["execution_observed"])
|
||||
self.assertTrue(item["requires_investigation"])
|
||||
self.assertEqual(item["execution_status"], "Allowed")
|
||||
|
||||
def test_blocked_detection_does_not_imply_execution_or_compromise(self):
|
||||
event = make_detection(status_id=6)
|
||||
event["execution_status_id"] = 1
|
||||
snapshot = self.build(make_data([event], [make_threat(severity_id=5, is_active=False)]))
|
||||
item = snapshot["detections"][0]
|
||||
self.assertEqual(item["remediation_state"], "blocked")
|
||||
self.assertFalse(item["active"])
|
||||
self.assertFalse(item["execution_observed"])
|
||||
self.assertFalse(item["requires_investigation"])
|
||||
self.assertEqual(snapshot["summary"]["current_risk_level"], 0)
|
||||
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
|
||||
|
||||
def test_severe_detected_threat_is_error(self):
|
||||
data = make_data(
|
||||
@@ -155,16 +207,43 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
[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")
|
||||
self.assertEqual(snapshot["active_threats"][0]["event_level"], 9) # 4 + 2 active + 3 failure
|
||||
self.assertEqual(snapshot["summary"]["remediation_failed"], True)
|
||||
self.assertEqual(snapshot["summary"]["current_risk_level"], 9)
|
||||
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
|
||||
|
||||
def test_action_success_false_adds_failure_modifier_even_before_failed_status_rollup(self):
|
||||
def test_action_success_false_without_error_is_not_assumed_to_be_failed_remediation(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
|
||||
self.assertEqual(snapshot["detections"][0]["remediation_state"], "detected")
|
||||
self.assertEqual(snapshot["detections"][0]["event_level"], 6) # 4 + 2 unresolved
|
||||
self.assertFalse(snapshot["summary"]["remediation_failed"])
|
||||
self.assertNotEqual(snapshot["summary"]["audit_status"], "ERROR")
|
||||
|
||||
def test_nonzero_error_with_failed_action_forces_error(self):
|
||||
data = make_data(
|
||||
[make_detection(status_id=1, action_success=False, error_code=2142207991)],
|
||||
[make_threat(severity_id=1, is_active=True)],
|
||||
)
|
||||
snapshot = self.build(data)
|
||||
self.assertEqual(snapshot["detections"][0]["remediation_state"], "failed")
|
||||
self.assertEqual(snapshot["detections"][0]["event_level"], 9)
|
||||
self.assertEqual(snapshot["summary"]["audit_status"], "ERROR")
|
||||
|
||||
def test_action_success_default_false_does_not_create_failure_correlation(self):
|
||||
events = [
|
||||
make_detection("default-false-a", status_id=1, action_success=False),
|
||||
make_detection("default-false-b", status_id=1, action_success=False),
|
||||
]
|
||||
snapshot = self.build(make_data(
|
||||
events, [make_threat(severity_id=1, is_active=True)],
|
||||
))
|
||||
self.assertTrue(all(item["remediation_state"] == "detected" for item in snapshot["detections"]))
|
||||
rules = {item["rule"] for item in snapshot["correlations"]}
|
||||
self.assertNotIn("repeated_remediation_failures", rules)
|
||||
|
||||
def test_explicitly_allowed_threat_is_scored_and_correlated(self):
|
||||
data = make_data(
|
||||
@@ -180,7 +259,10 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
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.assertTrue(all(item["event_level"] == 6 for item in snapshot["detections"]))
|
||||
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 6)
|
||||
self.assertEqual(snapshot["summary"]["correlation_level"], 8)
|
||||
self.assertEqual(snapshot["summary"]["current_risk_level"], 8)
|
||||
self.assertIn("same_threat_repeated", [item["rule"] for item in snapshot["correlations"]])
|
||||
self.assertEqual(snapshot["summary"]["audit_status"], "WARNING")
|
||||
|
||||
@@ -193,7 +275,7 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
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"])
|
||||
self.assertEqual(failures[0]["count"], 2)
|
||||
|
||||
def test_old_active_threat_remains_visible_outside_six_hour_window(self):
|
||||
event = make_detection(detected_hours_ago=10, changed_hours_ago=10)
|
||||
@@ -257,6 +339,33 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
self.assertEqual(snapshot["summary"]["detection_count_6h"], 2)
|
||||
self.assertIn("same_threat_repeated", [item["rule"] for item in snapshot["correlations"]])
|
||||
|
||||
def test_threat_rollup_isactive_does_not_reopen_a_resolved_detectionid(self):
|
||||
events = [
|
||||
make_detection("still-detected", status_id=1),
|
||||
make_detection("already-removed", status_id=4, detected_hours_ago=2),
|
||||
]
|
||||
snapshot = self.build(make_data(
|
||||
events, [make_threat(severity_id=1, is_active=True)],
|
||||
))
|
||||
active_ids = [item["detection_id"] for item in snapshot["active_threats"]]
|
||||
removed = next(item for item in snapshot["detections"] if item["detection_id"] == "already-removed")
|
||||
self.assertEqual(active_ids, ["still-detected"])
|
||||
self.assertFalse(removed["active"])
|
||||
self.assertEqual(removed["remediation_state"], "removed")
|
||||
self.assertTrue(removed["requires_investigation"]) # aggregated IsActive is a conflict signal
|
||||
|
||||
def test_fallback_event_identity_survives_status_change_and_is_flagged(self):
|
||||
first = make_detection(detection_id=None, status_id=1)
|
||||
first.pop("detection_id")
|
||||
first_snapshot = self.build(make_data([first], [make_threat(severity_id=1, is_active=True)]))
|
||||
second = dict(first)
|
||||
second["status_id"] = 4
|
||||
second["changed_at"] = NOW.isoformat().replace("+00:00", "Z")
|
||||
second_snapshot = self.build(make_data([second], [make_threat(severity_id=1, is_active=False)]))
|
||||
self.assertEqual(first_snapshot["detections"][0]["event_id"], second_snapshot["detections"][0]["event_id"])
|
||||
self.assertFalse(second_snapshot["detections"][0]["identity_complete"])
|
||||
self.assertGreater(second_snapshot["summary"]["identity_incomplete_count"], 0)
|
||||
|
||||
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)]))
|
||||
@@ -291,7 +400,7 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
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("100", severity_id=5, is_active=True, did_threat_execute=False),
|
||||
make_threat("200", severity_id=1, is_active=True),
|
||||
]
|
||||
snapshot = self.build(make_data(events, threats, defender={"realtime_enabled": False}))
|
||||
@@ -299,8 +408,10 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
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"])
|
||||
self.assertEqual(severe["event_level"], 14) # host protection is not part of event score
|
||||
self.assertEqual(snapshot["summary"]["correlation_level"], 16)
|
||||
self.assertEqual(snapshot["summary"]["current_risk_level"], 16)
|
||||
self.assertFalse(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"])
|
||||
@@ -320,6 +431,78 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 9)
|
||||
self.assertEqual(snapshot["summary"]["audit_status"], "OK")
|
||||
|
||||
def test_current_protection_change_does_not_rewrite_event_level(self):
|
||||
event = make_detection(status_id=4)
|
||||
threat = make_threat(severity_id=5, is_active=False)
|
||||
healthy = self.build(make_data([event], [threat]))
|
||||
degraded = self.build(make_data([event], [threat], defender={"realtime_enabled": False}))
|
||||
self.assertEqual(healthy["detections"][0]["event_level"], 9)
|
||||
self.assertEqual(degraded["detections"][0]["event_level"], 9)
|
||||
self.assertEqual(healthy["summary"]["current_risk_level"], 0)
|
||||
self.assertGreater(degraded["summary"]["current_risk_level"], 0)
|
||||
|
||||
def test_old_resolved_event_with_execution_remains_investigation_record(self):
|
||||
event = make_detection(status_id=4, detected_hours_ago=24 * 45, changed_hours_ago=24 * 45)
|
||||
snapshot = self.build(make_data(
|
||||
[event], [make_threat(severity_id=5, is_active=False, did_threat_execute=True)],
|
||||
))
|
||||
self.assertEqual(len(snapshot["detections"]), 1)
|
||||
self.assertFalse(snapshot["detections"][0]["active"])
|
||||
self.assertTrue(snapshot["detections"][0]["requires_investigation"])
|
||||
self.assertEqual(snapshot["summary"]["max_active_level"], 0)
|
||||
self.assertGreater(snapshot["summary"]["max_investigation_level"], 0)
|
||||
|
||||
def test_distinct_event_and_correlation_levels_have_scoring_reasons(self):
|
||||
events = [
|
||||
make_detection("corr-a", threat_id="100"),
|
||||
make_detection("corr-b", threat_id="200", detected_hours_ago=2),
|
||||
]
|
||||
snapshot = self.build(make_data(
|
||||
events,
|
||||
[make_threat("100", severity_id=1, is_active=True), make_threat("200", severity_id=1, is_active=True)],
|
||||
))
|
||||
self.assertEqual(snapshot["summary"]["max_event_level_6h"], 6)
|
||||
self.assertEqual(snapshot["summary"]["correlation_level"], 8)
|
||||
self.assertEqual(snapshot["summary"]["max_active_level"], 6)
|
||||
self.assertEqual(snapshot["summary"]["current_risk_level"], 8)
|
||||
self.assertTrue(snapshot["detections"][0]["event_level_reasons"])
|
||||
self.assertTrue(snapshot["correlations"][0]["scoring_reasons"])
|
||||
|
||||
def test_investigation_state_is_retained_beyond_collection_history(self):
|
||||
event = make_detection(status_id=4, detected_hours_ago=24 * 45, changed_hours_ago=24 * 45)
|
||||
snapshot = self.build(make_data(
|
||||
[event], [make_threat(severity_id=5, is_active=False, did_threat_execute=True)],
|
||||
))
|
||||
self.assertEqual(len(snapshot["detections"]), 1)
|
||||
self.assertFalse(snapshot["detections"][0]["active"])
|
||||
self.assertTrue(snapshot["detections"][0]["requires_investigation"])
|
||||
self.assertEqual(snapshot["summary"]["max_active_level"], 0)
|
||||
self.assertGreater(snapshot["summary"]["max_investigation_level"], 0)
|
||||
|
||||
def test_serialized_audit_values_shrink_for_small_medium_and_large_sets(self):
|
||||
for size in (1, 100, 1000):
|
||||
with self.subTest(detections=size):
|
||||
events = [
|
||||
make_detection("size-%d" % index, threat_id=str(index + 100))
|
||||
for index in range(size)
|
||||
]
|
||||
threats = [
|
||||
make_threat(str(index + 100), severity_id=1, is_active=True)
|
||||
for index in range(size)
|
||||
]
|
||||
snapshot = self.build(make_data(events, threats))
|
||||
legacy = {
|
||||
"snapshot": snapshot,
|
||||
"summary": snapshot["summary"],
|
||||
"detections": snapshot["detections"],
|
||||
"active_threats": snapshot["active_threats"],
|
||||
"correlations": snapshot["correlations"],
|
||||
}
|
||||
compact = defender_setup.build_audit_records(snapshot)
|
||||
old_size = len(json.dumps(legacy, separators=(",", ":")).encode("utf-8"))
|
||||
new_size = len(json.dumps(compact, separators=(",", ":")).encode("utf-8"))
|
||||
self.assertLess(new_size, old_size)
|
||||
|
||||
def test_classifier_modifiers_are_configurable_and_clamped(self):
|
||||
rules = {
|
||||
"active_modifier": 20,
|
||||
@@ -334,6 +517,17 @@ class TestDefenderClassification(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(
|
||||
defender_setup._wapt_status(9, [], True, False, True, 0, 0, remediation_failed=True),
|
||||
"ERROR",
|
||||
)
|
||||
self.assertEqual(
|
||||
defender_setup._wapt_status(
|
||||
9, [], True, False, True, 0, 0, remediation_failed=True,
|
||||
rules={"failed_remediation_forces_error": False},
|
||||
),
|
||||
"WARNING",
|
||||
)
|
||||
|
||||
|
||||
class TestDefenderCollectionAndWaptAdapter(unittest.TestCase):
|
||||
@@ -354,7 +548,7 @@ class TestDefenderCollectionAndWaptAdapter(unittest.TestCase):
|
||||
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):
|
||||
def test_audit_timeout_returns_error_and_stores_failed_summary(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")
|
||||
@@ -362,31 +556,89 @@ class TestDefenderCollectionAndWaptAdapter(unittest.TestCase):
|
||||
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")
|
||||
summary_call = next(args for args in stored if args[1] == "summary")
|
||||
self.assertFalse(summary_call[2]["collection_complete"])
|
||||
self.assertEqual(summary_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": []}
|
||||
value = {"schema_version": 3, "summary": {}}
|
||||
with patch.object(defender_setup, "WAPT", wapt, create=True):
|
||||
defender_setup.save_audit("snapshot", value)
|
||||
defender_setup.save_audit("summary", value)
|
||||
wapt.write_audit_data_if_changed.assert_called_once_with(
|
||||
"Microsoft Defender", "snapshot", value,
|
||||
max_count=512, keep_days=90,
|
||||
"Microsoft Defender", "summary", value,
|
||||
max_count=defender_setup.AUDIT_SUMMARY_HISTORY_MAX_COUNT, keep_days=90,
|
||||
)
|
||||
|
||||
def test_audit_hook_returns_wapt_status_and_writes_v2_snapshot(self):
|
||||
def test_audit_hook_returns_wapt_status_and_writes_compact_v3_records(self):
|
||||
wapt = Mock()
|
||||
with patch.object(defender_setup, "WAPT", wapt, create=True), patch.object(
|
||||
defender_setup, "collect_defender", return_value=make_data()
|
||||
defender_setup, "collect_defender", return_value=make_data(
|
||||
[make_detection()], [make_threat(severity_id=1, is_active=True)],
|
||||
)
|
||||
):
|
||||
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)
|
||||
self.assertEqual(written_keys[:2], ["summary", "correlations"])
|
||||
self.assertTrue(written_keys[2].startswith("event."))
|
||||
summary_call = wapt.write_audit_data_if_changed.call_args_list[0]
|
||||
self.assertEqual(summary_call.args[2]["schema_version"], 3)
|
||||
correlations_call = wapt.write_audit_data_if_changed.call_args_list[1]
|
||||
self.assertEqual(correlations_call.args[2]["schema_version"], 3)
|
||||
wapt.read_audit_data.assert_called_once_with(
|
||||
"Microsoft Defender", written_keys[2], default=None, include_expired_data=False,
|
||||
)
|
||||
|
||||
def test_event_envelope_is_provider_neutral_and_tracks_resolution(self):
|
||||
initial = defender_setup.build_audit_snapshot(make_data(
|
||||
[make_detection("stable-detection", status_id=1)],
|
||||
[make_threat(severity_id=5, is_active=True)],
|
||||
), now=NOW)
|
||||
first = defender_setup.build_audit_records(initial)
|
||||
event_key = next(key for key in first if key.startswith("event."))
|
||||
event = first[event_key]
|
||||
self.assertEqual(event["schema_version"], 1)
|
||||
self.assertEqual(event["source"], "comi-audit-defender")
|
||||
self.assertEqual(event["event_type"], "threat.detected")
|
||||
self.assertNotIn("threat_name", event)
|
||||
|
||||
resolved_input = make_detection("stable-detection", status_id=4, changed_hours_ago=0)
|
||||
resolved = defender_setup.build_audit_snapshot(make_data(
|
||||
[resolved_input], [make_threat(severity_id=5, is_active=False)],
|
||||
), now=NOW)
|
||||
previous = {event["event_id"]: event}
|
||||
second = defender_setup.build_audit_records(resolved, previous_events=previous)
|
||||
resolved_event = second[event_key]
|
||||
self.assertEqual(resolved_event["event_id"], event["event_id"])
|
||||
self.assertEqual(resolved_event["event_type"], "threat.resolved")
|
||||
self.assertEqual(resolved_event["remediation_state"], "removed")
|
||||
|
||||
def test_repeated_audit_writes_only_changed_values_and_historizes_event_transition(self):
|
||||
fake_wapt = MemoryWaptAudit()
|
||||
detection = make_detection("audit-transition", status_id=1)
|
||||
first_snapshot = defender_setup.build_audit_snapshot(make_data(
|
||||
[detection], [make_threat(severity_id=1, is_active=True)],
|
||||
), now=NOW)
|
||||
with patch.object(defender_setup, "WAPT", fake_wapt, create=True):
|
||||
defender_setup._save_snapshot(first_snapshot)
|
||||
first_write_count = len(fake_wapt.history)
|
||||
defender_setup._save_snapshot(first_snapshot)
|
||||
self.assertEqual(len(fake_wapt.history), first_write_count)
|
||||
|
||||
resolved = make_detection("audit-transition", status_id=4, changed_hours_ago=0)
|
||||
second_snapshot = defender_setup.build_audit_snapshot(make_data(
|
||||
[resolved], [make_threat(severity_id=1, is_active=False)],
|
||||
), now=NOW)
|
||||
defender_setup._save_snapshot(second_snapshot)
|
||||
|
||||
event_key = next(key for section, key in fake_wapt.values if key.startswith("event."))
|
||||
stored_event = fake_wapt.values[("Microsoft Defender", event_key)]
|
||||
self.assertEqual(stored_event["event_type"], "threat.resolved")
|
||||
self.assertEqual(stored_event["remediation_state"], "removed")
|
||||
self.assertNotIn(("Microsoft Defender", "snapshot"), fake_wapt.values)
|
||||
self.assertNotIn(("Microsoft Defender", "detections"), fake_wapt.values)
|
||||
self.assertEqual(fake_wapt.values[("Microsoft Defender", "summary")]["schema_version"], 3)
|
||||
|
||||
def test_control_declares_windows_wapt_26_and_15_minute_audit(self):
|
||||
fields = {}
|
||||
@@ -395,6 +647,7 @@ class TestDefenderCollectionAndWaptAdapter(unittest.TestCase):
|
||||
key, value = line.split(":", 1)
|
||||
fields[key.strip()] = value.strip()
|
||||
self.assertEqual(fields["package"], "comit-audit-defender")
|
||||
self.assertEqual(fields["version"], "2.0-2")
|
||||
self.assertEqual(fields["min_wapt_version"], "2.6")
|
||||
self.assertEqual(fields["audit_schedule"], "15m")
|
||||
self.assertEqual(fields["target_os"], "windows")
|
||||
|
||||
Reference in new issue
Block a user