La permission d'écriture dans `/tmp` n'a pas été accordée — je livre donc le guide directement ici, en HTML pur prêt à coller. ```html

KQL, ou Kusto Query Language, est le dénominateur commun de tout l'écosystème de sécurité Microsoft : Sentinel, Defender XDR, Defender for Endpoint, Defender for Cloud Apps, Azure Data Explorer et même Azure Resource Graph reposent sur cette même syntaxe. Un analyste SOC qui maîtrise KQL passe d'une alerte brute à une chronologie d'incident complète en quelques minutes ; celui qui ne la maîtrise pas reste prisonnier des tableaux de bord préfabriqués et des règles de détection livrées par l'éditeur. Le problème, c'est que la courbe d'apprentissage de KQL est réputée sévère : la syntaxe pipeline déroute les analystes venus du SQL, le schéma des tables varie selon les connecteurs activés, et le nombre de colonnes disponibles dans un tenant Sentinel moyen dépasse largement ce qu'un humain peut mémoriser. Microsoft Security Copilot change concrètement la donne avec sa capacité NL2KQL — la traduction du langage naturel en requête Kusto valide. Ce guide détaille les fondamentaux du langage, cartographie les tables essentielles de Sentinel, fournit trente prompts NL2KQL réels avec les requêtes générées correspondantes, puis aborde les techniques avancées de threat hunting et d'optimisation que Copilot ne produira jamais seul.

Fondamentaux KQL : ce que tout analyste SOC doit savoir

Pipeline NL2KQL — Du langage naturel à la règle de détection 1. Prompt naturel "Montre les connexions depuis 2 pays en <2h" Template KQL Request 2. Copilot Security GPT-4 + contexte tenant + tables Plugins actifs 3. Requête KQL SigninLogs | where TimeGenerated | summarize by User Validée + testée 4. Règle de détection Sentinel Analytics Rule Seuil + Fréquence Alertes + MITRE ATT&CK Playbook automatisé Tables Microsoft Sentinel utilisées SigninLogs Connexions Azure AD SecurityEvent Événements Windows DeviceEvents Endpoints Defender EmailEvents Defender for Office AuditLogs Activités Azure AD ThreatIntelIndicator IOC ingérés (MDTI) 10 tables clés couvertes dans le guide — chacune avec colonnes importantes et exemples de requêtes
Pipeline complet NL2KQL : du prompt en langage naturel à la règle de détection Sentinel

Structure d'une requête KQL

KQL fonctionne sur un modèle de pipeline unidirectionnel, à l'inverse du SQL où la clause SELECT précède le FROM. En Kusto, on part toujours d'une source de données — une table — et on lui applique une succession de transformations séparées par le caractère pipe. Chaque opérateur reçoit en entrée le jeu de résultats produit par l'opérateur précédent et émet un nouveau jeu de résultats. Cette logique de flux se lit de haut en bas et rend les requêtes complexes bien plus faciles à déboguer que leurs équivalents SQL : il suffit de commenter les lignes du bas pour observer l'état intermédiaire des données.

La conséquence pratique la plus importante de ce modèle est que l'ordre des opérateurs détermine la performance. Une requête qui filtre sur la plage temporelle en première ligne ne lira que les partitions concernées ; la même requête qui filtre en dernière ligne aura déjà scanné plusieurs téraoctets. Voici une requête canonique de détection de brute force, commentée ligne par ligne :

SecurityEvent                                    // Source : journaux d'événements Windows
| where TimeGenerated > ago(24h)                 // Filtre temporel EN PREMIER : réduit la partition lue
| where EventID == 4625                          // 4625 = échec d'ouverture de session
| summarize FailedLogins = count() by Account, Computer   // Agrégation par couple compte/machine
| where FailedLogins > 10                        // Filtre post-agrégation : seuil de bruit
| order by FailedLogins desc                     // Tri décroissant pour prioriser le triage

Trois points méritent l'attention. Premièrement, TimeGenerated est indexé et partitionné dans Log Analytics : c'est toujours le premier filtre à poser. Deuxièmement, le second where — celui qui porte sur FailedLogins — s'applique après le summarize et ne peut donc pas être remonté ; c'est une exception légitime à la règle « filtrer tôt ». Troisièmement, KQL est sensible à la casse pour les opérateurs comme pour les noms de colonnes, et l'opérateur == appliqué à des chaînes l'est également : utiliser =~ pour une comparaison insensible à la casse évite des faux négatifs redoutables sur les noms de comptes.

Opérateurs fondamentaux

Une douzaine d'opérateurs couvre 95 % des besoins d'un analyste SOC au quotidien. Les connaître par cœur permet de relire et de corriger instantanément une requête produite par Security Copilot, ce qui reste la compétence critique : la génération automatique ne dispense jamais de la validation humaine.

OpérateurRôleExemple
whereFiltre les lignes selon un prédicat booléen. Opérateur le plus utilisé, à poser le plus tôt possible.| where AccountType == "User" and ResultType != 0
projectSélectionne, renomme et réordonne les colonnes retournées. Réduit fortement le volume transféré.| project TimeGenerated, User = UserPrincipalName, IPAddress
extendAjoute une colonne calculée sans supprimer les colonnes existantes.| extend Heure = hourofday(TimeGenerated)
summarizeAgrège les lignes par groupe. Se combine avec count(), dcount(), make_set(), min(), max(), arg_max().| summarize Tentatives = count(), Pays = make_set(Location) by UserPrincipalName
joinCorrèle deux jeux de résultats sur une clé commune. Types : inner, leftouter, leftanti, rightsemi, fullouter.| join kind=inner (ThreatIntelligenceIndicator) on $left.IPAddress == $right.NetworkIP
mv-expandÉclate un tableau ou un objet dynamique en une ligne par élément. Indispensable pour les entités JSON.| mv-expand Entite = todynamic(Entities)
letDéclare une variable, une liste ou une fonction réutilisable en tête de requête.let Fenetre = 7d;
renderImpose un mode de visualisation au résultat : timechart, barchart, piechart, columnchart.| render timechart
unionFusionne plusieurs tables au schéma hétérogène. Le paramètre isfuzzy=true évite l'échec si une table est absente du tenant.union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
parseExtrait des champs d'une chaîne non structurée selon un motif positionnel. Très utile sur Syslog.| parse SyslogMessage with * "user=" Utilisateur " " *
extractExtrait un groupe de capture d'une expression régulière. Plus souple que parse, mais plus coûteux.| extend Domaine = extract(@"@([\w\.-]+)", 1, SenderFromAddress)
top / taketop N by Col trie puis limite ; take N échantillonne sans ordre garanti — utile uniquement pour explorer un schéma.| top 20 by NbEvenements desc

Deux erreurs récurrentes valent d'être signalées. La première est l'usage de take à la place de top pour produire un classement : take ne trie rien et retourne des lignes arbitraires, ce qui donne des résultats non reproductibles d'une exécution à l'autre. La seconde est l'oubli du paramètre kind= dans un join : la valeur par défaut est innerunique, qui déduplique silencieusement la table de gauche et fait disparaître des événements que l'analyste croyait présents. Sur une chasse, toujours expliciter kind=inner ou kind=leftouter.

Fonctions temporelles essentielles

La sécurité est une discipline temporelle : presque toute détection revient à comparer un comportement à sa fenêtre de référence. KQL propose un jeu de fonctions dédiées qu'il faut manipuler avec aisance.

// ago() : décalage relatif par rapport à maintenant. Unités : d, h, m, s
SigninLogs | where TimeGenerated > ago(7d)

// now() : horodatage courant en UTC, utile pour calculer un delta
| extend AgeEnJours = datetime_diff('day', now(), CreatedDateTime)

// startofday() / startofweek() / startofmonth() : troncature de période
SecurityEvent
| where TimeGenerated >= startofday(ago(1d)) and TimeGenerated < startofday(now())

// bin() : regroupement en intervalles réguliers — le cœur de toute série temporelle
SigninLogs
| where TimeGenerated > ago(24h)
| summarize Echecs = countif(ResultType != 0) by bin(TimeGenerated, 1h)
| render timechart

// format_datetime() : mise en forme lisible pour un rapport d'incident
| extend Horodatage = format_datetime(TimeGenerated, 'yyyy-MM-dd HH:mm:ss')

// datetime_diff() : écart signé entre deux dates, dans l'unité demandée
| extend DeltaMinutes = datetime_diff('minute', SecondeConnexion, PremiereConnexion)

Un piège classique : toutes les données de Log Analytics sont stockées en UTC. Un analyste basé en France qui chasse une activité « nocturne » doit convertir explicitement, sinon il décale son créneau d'une à deux heures selon la saison. La conversion se fait avec datetime_add ou, plus proprement, en travaillant directement en UTC et en documentant le fuseau dans le rapport. Autre piège : bin() aligne les intervalles sur l'époque Unix, pas sur l'heure de lancement de la requête ; un bin(TimeGenerated, 1h) produira donc toujours des seaux calés sur l'heure ronde, ce qui est souhaitable pour comparer plusieurs journées entre elles.

Tables clés dans Microsoft Sentinel

La difficulté principale pour un analyste débutant n'est pas la syntaxe mais la connaissance du schéma. Savoir que les échecs d'authentification cloud vivent dans SigninLogs avec un ResultType numérique, tandis que leurs équivalents on-premise vivent dans SecurityEvent avec un EventID 4625, représente l'essentiel du travail. Voici la cartographie des tables que rencontre quotidiennement un ingénieur Sentinel.

SigninLogs — connexions Microsoft Entra ID

Alimentée par le connecteur Microsoft Entra ID, cette table contient chaque tentative d'authentification interactive contre l'annuaire cloud. C'est la table la plus précieuse du tenant pour la détection d'attaques d'identité. Colonnes importantes : UserPrincipalName, ResultType (0 = succès, 50126 = mauvais mot de passe, 50053 = compte verrouillé, 50074 = MFA non satisfaite, 53003 = bloqué par accès conditionnel), IPAddress, LocationDetails, DeviceDetail, AppDisplayName, ConditionalAccessStatus, AuthenticationRequirement et RiskLevelDuringSignIn. Cas d'usage typique : détection de password spray, d'impossible travel et de contournement MFA. Attention, les connexions non interactives — jetons rafraîchis, principaux de service — résident dans les tables séparées AADNonInteractiveUserSignInLogs et AADServicePrincipalSignInLogs, qu'il faut fusionner par union pour obtenir une vue complète de la surface d'authentification.

AuditLogs — activités de l'annuaire Entra

Journalise toute modification de configuration de l'annuaire : création et suppression de comptes, attributions de rôles, consentements applicatifs, modifications de politiques d'accès conditionnel, ajouts de méthodes d'authentification. Colonnes clés : OperationName, Category, Result, InitiatedBy (objet dynamique contenant soit un user, soit une app), TargetResources (tableau dynamique décrivant les objets modifiés). Cas d'usage typique : détecter une escalade de privilèges après compromission, ou tracer un consentement OAuth malveillant sur une application tierce. La structure dynamique de TargetResources impose presque toujours un mv-expand pour être exploitée correctement, ce qui est la première source d'erreur des requêtes générées automatiquement sur cette table.

SecurityEvent — journaux d'événements Windows

Collectée par l'agent Azure Monitor sur les serveurs et contrôleurs de domaine, cette table reste la référence pour tout ce qui touche à l'Active Directory on-premise. Colonnes clés : EventID, Account, Computer, LogonType, IpAddress, LogonProcessName, AuthenticationPackageName, SubjectUserName et TargetUserName. Les identifiants à connaître : 4624 (ouverture de session réussie), 4625 (échec), 4672 (privilèges spéciaux attribués), 4688 (création de processus), 4720 (création de compte), 4728 et 4732 (ajout à un groupe privilégié), 4768 et 4769 (tickets Kerberos, base de la détection de Kerberoasting). Cas d'usage typique : mouvement latéral, Pass-the-Hash, persistance par création de compte. Cette table est souvent la plus volumineuse du workspace : le filtrage précoce y est vital pour le coût comme pour la latence.

DeviceEvents et DeviceProcessEvents — Defender for Endpoint

DeviceProcessEvents enregistre chaque création de processus vue par le capteur EDR, avec un niveau de détail que SecurityEvent n'atteint pas : ProcessCommandLine, InitiatingProcessCommandLine, InitiatingProcessParentFileName, ProcessIntegrityLevel, AccountName, SHA256. La chaîne parent-enfant complète permet de reconstituer un arbre d'exécution sans instrumentation supplémentaire. DeviceEvents est une table fourre-tout regroupant les autres télémétries du capteur, discriminées par la colonne ActionType : ScheduledTaskCreated, CreateRemoteThreadApiCall, AntivirusDetection, UsbDriveMounted, PowerShellCommand. Cas d'usage typique : chasse LOLBAS, injection mémoire, persistance. Les tables sœurs DeviceFileEvents, DeviceNetworkEvents, DeviceRegistryEvents, DeviceLogonEvents et DeviceImageLoadEvents complètent la couverture endpoint.

DeviceNetworkEvents — connexions réseau des endpoints

Souvent négligée, cette table est pourtant la plus riche pour le mouvement latéral et l'exfiltration, car elle relie chaque flux réseau au processus qui l'a initié — information qu'aucun pare-feu ne fournit. Colonnes clés : RemoteIP, RemotePort, RemoteUrl, LocalIP, InitiatingProcessFileName, InitiatingProcessCommandLine, ActionType (ConnectionSuccess, ConnectionFailed, InboundConnectionAccepted, ConnectionRequest). Cas d'usage typique : détection de scan de ports interne, de tunneling DNS, de balise C2, de connexion RDP ou SMB anormale entre postes utilisateurs.

EmailEvents et EmailAttachmentInfo — Defender for Office 365

EmailEvents contient un enregistrement par message traité : SenderFromAddress, SenderMailFromDomain, RecipientEmailAddress, Subject, DeliveryAction (Delivered, Blocked, Replaced), DeliveryLocation (Inbox/folder, Junk, Quarantine), ThreatTypes, AuthenticationDetails qui agrège SPF, DKIM et DMARC, et NetworkMessageId qui sert de clé de jointure universelle entre toutes les tables e-mail. EmailAttachmentInfo détaille les pièces jointes : FileName, FileType, SHA256, ThreatTypes. EmailUrlInfo et UrlClickEvents ferment la boucle en traçant les URL contenues et les clics effectivement réalisés par les utilisateurs via Safe Links. Cas d'usage typique : investigation de campagne de phishing, mesure du taux de clic réel, chasse aux pièces jointes exécutables passées au travers du filtrage.

CloudAppEvents — Defender for Cloud Apps

Agrège l'activité des applications SaaS connectées au broker CASB, y compris les applications non Microsoft. Colonnes clés : Application, ActionType, AccountDisplayName, IPAddress, ISP, UserAgent, ActivityObjects, RawEventData. Cas d'usage typique : détecter l'usage de shadow IT, l'accès à des applications non sanctionnées, ou des téléversements vers un service de stockage grand public. RawEventData est un objet dynamique dont le schéma varie selon l'application source : son exploitation demande systématiquement un mv-expand ou un accès par notation pointée, jamais un simple where textuel.

AzureActivity — plan de contrôle Azure

Trace chaque opération Azure Resource Manager : création, modification, suppression de ressources, attribution de rôles RBAC, accès aux coffres de clés. Colonnes clés : OperationNameValue, Caller, CallerIpAddress, ActivityStatusValue, ResourceGroup, ResourceId, Properties. Cas d'usage typique : détecter un cryptominage via déploiement de machines virtuelles dans une région inhabituelle, une exfiltration par snapshot de disque, ou la désactivation d'un paramètre de diagnostic — technique classique d'anti-forensique cloud qu'il faut impérativement alerter en temps réel.

OfficeActivity — SharePoint, Teams, OneDrive, Exchange

Journal unifié Microsoft 365. Colonnes clés : Operation, UserId, OfficeWorkload (SharePoint, OneDrive, Exchange, MicrosoftTeams), ClientIP, SourceFileName, SiteUrl, Parameters. Les opérations à surveiller en priorité : FileDownloaded, FileDeleted, FileSyncDownloadedFull, Add-MailboxPermission, New-InboxRule, Set-Mailbox, AnonymousLinkCreated. Cas d'usage typique : exfiltration de documents avant un départ, règle de transfert créée par un attaquant après compromission de boîte aux lettres, suppression massive évoquant un ransomware cloud.

ThreatIntelligenceIndicator — IOC ingérés

Reçoit les indicateurs de compromission provenant de Microsoft Defender Threat Intelligence, de flux TAXII ou d'une intégration personnalisée. Colonnes clés : NetworkIP, NetworkSourceIP, Url, DomainName, EmailSenderAddress, FileHashValue, ConfidenceScore, ThreatType, Active, ExpirationDateTime. Cas d'usage typique : enrichir n'importe quelle table de télémétrie par jointure. Deux précautions permanentes : filtrer sur Active == true et sur ExpirationDateTime > now(), sinon la jointure remonte des indicateurs périmés ; et dédupliquer avec summarize arg_max(TimeGenerated, *) by IndicatorId, car chaque mise à jour d'un indicateur crée une nouvelle ligne dans la table.

Syslog et CommonSecurityLog — sources tierces

Syslog accueille les messages bruts des équipements Linux et réseau, avec les colonnes Facility, SeverityLevel, ProcessName et surtout SyslogMessage, chaîne non structurée qu'il faut découper avec parse ou extract. CommonSecurityLog reçoit les événements au format CEF — pare-feu Palo Alto, Fortinet, Check Point, proxies Zscaler — avec un schéma normalisé : DeviceVendor, DeviceProduct, Activity, SourceIP, DestinationIP, DestinationPort, RequestURL, DeviceAction. Cas d'usage typique : corréler un blocage pare-feu avec une activité endpoint, ou détecter une exfiltration DNS vue depuis le résolveur d'entreprise. Ces deux tables étant les plus bruyantes du workspace, elles justifient presque toujours une règle de transformation à l'ingestion pour maîtriser les coûts.

NL2KQL avec Copilot Security : transformer le langage naturel en requêtes

La capacité NL2KQL de Microsoft Security Copilot repose sur un plugin dédié qui connaît le schéma des tables Sentinel et Defender XDR. Concrètement, l'analyste décrit son intention en français ou en anglais, et Copilot produit une requête Kusto exécutable, accompagnée d'une explication ligne par ligne. Le gain n'est pas seulement de vitesse : il est aussi pédagogique, puisque l'analyste voit se construire des motifs qu'il réutilisera ensuite manuellement. Mais la qualité du résultat dépend entièrement de la qualité du prompt — un prompt vague produit une requête vague, souvent syntaxiquement correcte et sémantiquement inutile.

Le template KQL Request Promptbook

Microsoft publie un template officiel dans son promptbook « KQL Request », qui structure la demande en quatre paramètres explicites. Ce cadrage élimine l'essentiel des hallucinations de noms de tables et de colonnes :

Use the following information to generate a proper KQL query for [product]:
Table: [table name]
Time range: [e.g. last 7 days]
Query: [what you want to find]
Display: [how to show results]

Chacun des quatre champs a une fonction précise. Product désigne le moteur cible — Microsoft Sentinel, Defender XDR, Defender for Endpoint — et détermine le dialecte : la syntaxe est identique, mais les tables disponibles diffèrent, et une requête DeviceProcessEvents écrite pour Advanced Hunting ne s'exécutera dans Sentinel que si le connecteur Defender XDR est activé. Table est le champ le plus important : le renseigner explicitement supprime le principal mode d'échec, qui est l'invention d'un nom de table plausible mais inexistant. Time range force la présence du filtre TimeGenerated, faute de quoi la requête scanne toute la rétention. Display détermine la forme de sortie — tableau trié, série temporelle, agrégation — et évite la production d'une requête retournant des milliers de lignes brutes ininterprétables.

Un prompt complet ressemble donc à ceci : « Use the following information to generate a proper KQL query for Microsoft Sentinel: Table: SigninLogs / Time range: last 24 hours / Query: find accounts with more than 10 failed sign-ins from a single IP address / Display: table sorted by failure count descending, showing account, IP, country and count ». La différence de qualité avec un simple « montre-moi les connexions échouées » est considérable et se mesure directement en temps de correction évité.

30 exemples de prompts NL2KQL avec les requêtes générées

Authentification et identité

1. « Montre les connexions échouées avec plus de 10 tentatives en 1 heure »

SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType != 0
| summarize Tentatives = count(),
            Erreurs = make_set(ResultType, 10),
            Pays = make_set(tostring(LocationDetails.countryOrRegion), 5)
        by UserPrincipalName, IPAddress, bin(TimeGenerated, 1h)
| where Tentatives > 10
| order by Tentatives desc

Cette requête détecte le brute force classique contre un compte unique. Le levier de réglage est le seuil de 10 combiné à la fenêtre bin(TimeGenerated, 1h) : dans un tenant de plus de 5 000 comptes, monter à 25 est souvent nécessaire. Faux positif principal : un client de messagerie mobile conservant un mot de passe obsolète génère des dizaines d'échecs ResultType 50126 par heure, sans aucune intention malveillante.

2. « Identifie les comptes connectés depuis 2 pays différents en moins de 2 heures (Impossible Travel) »

let Fenetre = 2h;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| extend Pays = tostring(LocationDetails.countryOrRegion),
         Ville = tostring(LocationDetails.city)
| where isnotempty(Pays)
| summarize NbPays = dcount(Pays),
            Pays = make_set(Pays),
            IPs = make_set(IPAddress, 10),
            Premiere = min(TimeGenerated),
            Derniere = max(TimeGenerated)
        by UserPrincipalName, bin(TimeGenerated, Fenetre)
| where NbPays >= 2
| extend DeltaMinutes = datetime_diff('minute', Derniere, Premiere)
| project UserPrincipalName, Pays, IPs, Premiere, Derniere, DeltaMinutes
| order by DeltaMinutes asc

Le principe est la vitesse de déplacement implicite entre deux géolocalisations. Le paramètre à ajuster est Fenetre : à 2 heures on capte les VPN commerciaux, à 30 minutes on ne garde que les cas physiquement impossibles. Faux positif dominant : les VPN d'entreprise à sortie multi-régions et les applications mobiles routant via un datacenter étranger. Exclure les plages IP corporate connues avant mise en production.

3. « Liste les comptes admin Entra qui ne se sont pas connectés depuis 30 jours »

let Admins =
    AuditLogs
    | where TimeGenerated > ago(180d)
    | where OperationName has "Add member to role"
    | mv-expand TargetResources
    | extend Compte = tolower(tostring(TargetResources.userPrincipalName))
    | where isnotempty(Compte)
    | distinct Compte;
let Actifs =
    SigninLogs
    | where TimeGenerated > ago(30d)
    | where ResultType == 0
    | distinct Compte = tolower(UserPrincipalName);
Admins
| join kind=leftanti (Actifs) on Compte
| project CompteAdminDormant = Compte

Un compte à privilèges inutilisé est une cible idéale : personne ne remarquera son usage. La logique repose sur un leftanti, seule façon d'exprimer une absence en KQL. Le paramètre à régler est la profondeur d'historique 180d pour reconstituer la liste des porteurs de rôle. Faux positif : les comptes break-glass, volontairement dormants, à exclure nommément.

4. « Détecte les connexions MFA bypassées »

SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| where AuthenticationRequirement == "singleFactorAuthentication"
| where ConditionalAccessStatus in ("notApplied", "failure")
| extend Methodes = tostring(AuthenticationDetails)
| summarize Connexions = count(),
            Apps = make_set(AppDisplayName, 10),
            IPs = make_set(IPAddress, 10)
        by UserPrincipalName, ClientAppUsed
| order by Connexions desc

On cherche ici les authentifications réussies sans second facteur alors que la politique l'impose. Le champ discriminant est AuthenticationRequirement couplé à ConditionalAccessStatus == "notApplied", signature d'une exclusion de politique ou d'un protocole legacy. Faux positif fréquent : les comptes explicitement exclus des politiques d'accès conditionnel pour raisons opérationnelles — les identifier une fois, puis les lister en exception documentée.

5. « Montre toutes les nouvelles attributions de rôles admin sur les 7 derniers jours »

AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Add member to role",
                          "Add eligible member to role",
                          "Add member to role in PIM requested (permanent)")
| where Result == "success"
| mv-expand TargetResources
| extend Cible = tostring(TargetResources.userPrincipalName),
         Role = tostring(TargetResources.modifiedProperties[1].newValue),
         Auteur = tostring(InitiatedBy.user.userPrincipalName)
| project TimeGenerated, Auteur, Cible, Role, OperationName
| order by TimeGenerated desc

L'attribution de rôle est la technique de persistance privilégiée après compromission d'un compte administrateur. L'indice le plus fort n'est pas l'événement lui-même mais l'auteur : une attribution effectuée par un compte qui n'en fait jamais est bien plus suspecte qu'un pic de volume. Faux positif : les campagnes d'onboarding qui produisent des dizaines d'attributions légitimes en quelques heures.

6. « Trouve les connexions depuis des adresses IP dans la liste de threat intelligence »

let IOCs =
    ThreatIntelligenceIndicator
    | where TimeGenerated > ago(14d)
    | where Active == true and ExpirationDateTime > now()
    | where isnotempty(NetworkIP)
    | summarize arg_max(TimeGenerated, *) by IndicatorId
    | project NetworkIP, ThreatType, ConfidenceScore, Description;
SigninLogs
| where TimeGenerated > ago(7d)
| join kind=inner (IOCs) on $left.IPAddress == $right.NetworkIP
| project TimeGenerated, UserPrincipalName, IPAddress, ResultType,
          ThreatType, ConfidenceScore, AppDisplayName
| order by ConfidenceScore desc

Corrélation d'enrichissement classique. Les deux réglages critiques sont le filtre de fraîcheur des indicateurs et le seuil ConfidenceScore, qu'il faut monter au-dessus de 70 en production. Faux positif majeur : les flux TI grand public listent massivement des nœuds de sortie Tor et des IP de VPN commerciaux, qui apparaîtront pour tout utilisateur légitime soucieux de sa vie privée.

7. « Identifie les comptes de service avec des connexions interactives inhabituelles »

let ComptesService = SigninLogs
    | where TimeGenerated > ago(30d)
    | where UserPrincipalName startswith "svc" or UserPrincipalName startswith "sa-"
    | distinct UserPrincipalName;
SigninLogs
| where TimeGenerated > ago(7d)
| where UserPrincipalName in (ComptesService)
| where ClientAppUsed in ("Browser", "Mobile Apps and Desktop clients")
| where ResultType == 0
| summarize Connexions = count(),
            IPs = make_set(IPAddress, 20),
            Apps = make_set(AppDisplayName, 10)
        by UserPrincipalName
| order by Connexions desc

Un compte de service authentifié depuis un navigateur signale presque toujours un usage humain — soit un administrateur qui contourne les procédures, soit un attaquant en possession du secret. Le levier de réglage est la convention de nommage, à adapter au tenant. Faux positif : les scripts d'exploitation qui utilisent réellement un flux navigateur pour un consentement initial.

8. « Détecte les tentatives de password spray »

SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType in (50126, 50053, 50055, 50056)
| summarize ComptesCibles = dcount(UserPrincipalName),
            Tentatives = count(),
            Comptes = make_set(UserPrincipalName, 50),
            Succes = countif(ResultType == 0)
        by IPAddress, bin(TimeGenerated, 1h)
| extend RatioParCompte = round(todouble(Tentatives) / ComptesCibles, 2)
| where ComptesCibles >= 20 and RatioParCompte < 3
| order by ComptesCibles desc

Le password spray est l'inverse topologique du brute force : beaucoup de comptes, peu d'essais chacun, pour rester sous les seuils de verrouillage. La signature est donc le ratio RatioParCompte maintenu bas alors que ComptesCibles est élevé. Faux positif : un proxy sortant unique concentrant l'ensemble du trafic d'un site distant peut agréger artificiellement des échecs indépendants sur une seule IP.

Endpoints et processus

9. « Montre les processus PowerShell encodés lancés sur les endpoints ce mois-ci »

DeviceProcessEvents
| where TimeGenerated > ago(30d)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| where ProcessCommandLine has_any ("-enc", "-EncodedCommand", "-e ", "FromBase64String")
| extend Payload = extract(@"(?i)-e(?:nc|ncodedcommand)?\s+([A-Za-z0-9+/=]{20,})", 1, ProcessCommandLine)
| project TimeGenerated, DeviceName, AccountName,
          InitiatingProcessFileName, ProcessCommandLine, Payload
| order by TimeGenerated desc

L'encodage Base64 des commandes PowerShell est le marqueur le plus rentable de la chasse endpoint : peu utilisé légitimement, omniprésent dans les chaînes d'attaque. Le levier de tri est InitiatingProcessFileName — un parent winword.exe ou outlook.exe est critique, un parent SCCM ne l'est pas. Faux positif : les outils de déploiement logiciel encodent couramment leurs commandes.

10. « Identifie les exécutions de certutil, mshta, regsvr32 (LOLBAS) »

let LOLBAS = dynamic(["certutil.exe","mshta.exe","regsvr32.exe","rundll32.exe",
                      "bitsadmin.exe","wmic.exe","msbuild.exe","installutil.exe",
                      "cscript.exe","wscript.exe","forfiles.exe","odbcconf.exe"]);
DeviceProcessEvents
| where TimeGenerated > ago(7d)
| where FileName in~ (LOLBAS)
| where ProcessCommandLine has_any ("http", "\\\\", "-urlcache", "-decode",
                                    "javascript:", "scrobj.dll", "/transfer")
| summarize Occurrences = count(),
            Commandes = make_set(ProcessCommandLine, 5),
            Machines = dcount(DeviceName)
        by FileName, AccountName
| order by Occurrences desc

Les binaires signés Microsoft détournés restent la technique de contournement applicatif la plus employée. La clé de la précision n'est pas le nom du binaire — trop courant — mais la combinaison avec des arguments réseau ou de décodage. Faux positif : rundll32.exe et wmic.exe sont massivement utilisés par les outils d'administration ; établir une liste de commandes de référence sur 30 jours avant d'alerter.

11. « Trouve les connexions réseau sortantes vers des IP non catégorisées depuis des processus Office »

let ProcessusOffice = dynamic(["winword.exe","excel.exe","powerpnt.exe",
                               "outlook.exe","onenote.exe","msaccess.exe"]);
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where InitiatingProcessFileName in~ (ProcessusOffice)
| where ActionType == "ConnectionSuccess"
| where RemotePort in (80, 443, 8080, 8443)
| where not(ipv4_is_private(RemoteIP))
| where isempty(RemoteUrl) or RemoteUrl !endswith "microsoft.com"
| summarize Connexions = count(),
            Destinations = make_set(strcat(RemoteIP, ":", RemotePort), 20)
        by DeviceName, InitiatingProcessFileName, AccountName
| order by Connexions desc

Une suite bureautique qui ouvre une connexion sortante vers une IP inconnue signe presque toujours l'exécution d'une macro ou d'un objet embarqué. Le filtre déterminant est ipv4_is_private() associé à l'exclusion des domaines éditeur. Faux positif : les compléments tiers légitimes — signature électronique, gestion documentaire — génèrent ce motif ; les recenser en liste d'exclusion par éditeur.

12. « Détecte les créations de tâches planifiées inhabituelles »

DeviceEvents
| where TimeGenerated > ago(7d)
| where ActionType == "ScheduledTaskCreated"
| extend NomTache = tostring(parse_json(AdditionalFields).TaskName)
| where InitiatingProcessFileName !in~ ("msiexec.exe", "trustedinstaller.exe",
                                        "ccmexec.exe", "svchost.exe")
| project TimeGenerated, DeviceName, NomTache, AccountName,
          InitiatingProcessFileName, InitiatingProcessCommandLine
| order by TimeGenerated desc

La tâche planifiée reste le mécanisme de persistance le plus utilisé, tous niveaux de sophistication confondus. La discrimination utile porte sur le processus créateur : une tâche créée par powershell.exe ou cmd.exe mérite un examen, une tâche créée par l'agent de gestion de parc non. Faux positif : les mises à jour applicatives créent des tâches en masse lors des campagnes de déploiement.

13. « Liste les fichiers .ps1 créés dans %temp% et exécutés dans les 5 minutes »

let Creations =
    DeviceFileEvents
    | where TimeGenerated > ago(7d)
    | where ActionType == "FileCreated"
    | where FileName endswith ".ps1"
    | where FolderPath has_any (@"\Temp\", @"\AppData\Local\Temp")
    | project DeviceId, DeviceName, Script = tolower(FileName),
              HeureCreation = TimeGenerated,
              CreePar = InitiatingProcessFileName;
let Executions =
    DeviceProcessEvents
    | where TimeGenerated > ago(7d)
    | where FileName in~ ("powershell.exe", "pwsh.exe")
    | extend Script = tolower(extract(@"([^\\\s""]+\.ps1)", 1, ProcessCommandLine))
    | where isnotempty(Script)
    | project DeviceId, Script, HeureExecution = TimeGenerated, ProcessCommandLine;
Creations
| join kind=inner (Executions) on DeviceId, Script
| extend DelaiSecondes = datetime_diff('second', HeureExecution, HeureCreation)
| where DelaiSecondes between (0 .. 300)
| project DeviceName, Script, CreePar, HeureCreation, HeureExecution,
          DelaiSecondes, ProcessCommandLine
| order by DelaiSecondes asc

Le motif écriture-puis-exécution rapide dans un répertoire temporaire est une signature de dropper à très faible taux de faux positifs. Le levier est la fenêtre de 300 secondes : l'élargir à 3 600 capte les chaînes différées mais dégrade la précision. Point de vigilance : la jointure porte sur DeviceId et non DeviceName, plus fiable en cas de renommage de poste.

14. « Identifie les processus avec injection mémoire (CreateRemoteThread) »

DeviceEvents
| where TimeGenerated > ago(7d)
| where ActionType == "CreateRemoteThreadApiCall"
| extend Cible = tostring(parse_json(AdditionalFields).TargetProcessFileName)
| where InitiatingProcessFileName !in~ ("msmpeng.exe", "csrss.exe", "wininit.exe")
| summarize Injections = count(),
            Cibles = make_set(Cible, 10),
            Machines = dcount(DeviceName)
        by InitiatingProcessFileName, InitiatingProcessCommandLine
| where Injections > 2
| order by Injections desc

L'injection de thread distant est la primitive de base du process hollowing et de la plupart des chargeurs en mémoire. La combinaison à surveiller en priorité est un processus utilisateur injectant dans lsass.exe, explorer.exe ou un navigateur. Faux positif : les solutions EDR concurrentes, les débogueurs et certains outils d'accessibilité utilisent légitimement cette API et doivent être exclus.

15. « Trouve les exécutions de PsExec ou d'outils d'administration à distance non autorisés »

let OutilsDistants = dynamic(["psexec.exe","psexesvc.exe","paexec.exe",
                              "anydesk.exe","teamviewer.exe","screenconnect.exe",
                              "atera_agent.exe","splashtop.exe","ngrok.exe"]);
DeviceProcessEvents
| where TimeGenerated > ago(30d)
| where FileName in~ (OutilsDistants) or ProcessCommandLine has_any ("-accepteula", "\\\\ADMIN$")
| summarize Premiere = min(TimeGenerated),
            Derniere = max(TimeGenerated),
            Executions = count(),
            Comptes = make_set(AccountName, 10)
        by DeviceName, FileName
| order by Premiere desc

Les outils de prise en main à distance grand public sont devenus le vecteur d'accès initial privilégié des groupes ransomware, précisément parce qu'ils sont signés et souvent tolérés. Le signal exploitable est la première apparition sur une machine, pas le volume. Faux positif : le support informatique utilise légitimement ces outils — comparer contre l'inventaire logiciel autorisé avant d'alerter.

E-mail et phishing

16. « Montre les e-mails avec pièces jointes .exe ou .zip reçus cette semaine »

EmailAttachmentInfo
| where TimeGenerated > ago(7d)
| where FileType in~ ("exe","zip","rar","7z","iso","img","vhd","lnk","js","hta")
| join kind=inner (
    EmailEvents
    | where TimeGenerated > ago(7d)
    | where EmailDirection == "Inbound"
    | project NetworkMessageId, SenderFromAddress, RecipientEmailAddress,
              Subject, DeliveryAction, DeliveryLocation
) on NetworkMessageId
| where DeliveryAction == "Delivered"
| project TimeGenerated, SenderFromAddress, RecipientEmailAddress,
          Subject, FileName, FileType, SHA256, DeliveryLocation
| order by TimeGenerated desc

Les conteneurs ISO, IMG et VHD méritent une attention particulière : ils contournent le marquage Mark-of-the-Web et neutralisent l'avertissement de sécurité Windows. Le filtre décisif est DeliveryAction == "Delivered", qui écarte le bruit de tout ce que le filtrage a déjà bloqué. Faux positif : les archives ZIP légitimes échangées avec des partenaires — affiner par domaine expéditeur.

17. « Identifie les e-mails de domaines nouvellement enregistrés (moins de 30 jours) »

let DomainesConnus =
    EmailEvents
    | where TimeGenerated between (ago(90d) .. ago(30d))
    | where EmailDirection == "Inbound"
    | distinct SenderMailFromDomain;
EmailEvents
| where TimeGenerated > ago(30d)
| where EmailDirection == "Inbound"
| where SenderMailFromDomain !in (DomainesConnus)
| summarize Messages = count(),
            Destinataires = dcount(RecipientEmailAddress),
            Sujets = make_set(Subject, 5),
            Livres = countif(DeliveryAction == "Delivered")
        by SenderMailFromDomain
| where Livres > 0
| order by Destinataires desc

Faute d'accès direct aux données WHOIS depuis Sentinel, on approxime la nouveauté d'un domaine par son absence de l'historique de communication du tenant — une heuristique qui capte l'essentiel des campagnes ciblées. Le levier est la fenêtre de référence de 90 jours. Faux positif : tout nouveau partenaire commercial légitime apparaîtra dans cette liste lors de son premier échange.

18. « Trouve les clics sur des URL signalées comme malveillantes par MDTI »

let UrlMalveillantes =
    ThreatIntelligenceIndicator
    | where TimeGenerated > ago(30d)
    | where Active == true and ExpirationDateTime > now()
    | where isnotempty(Url)
    | summarize arg_max(TimeGenerated, *) by IndicatorId
    | project UrlIOC = tolower(Url), ThreatType, ConfidenceScore;
UrlClickEvents
| where TimeGenerated > ago(7d)
| extend UrlNorm = tolower(Url)
| join kind=inner (UrlMalveillantes) on $left.UrlNorm == $right.UrlIOC
| project TimeGenerated, AccountUpn, Url, ActionType,
          IsClickedThrough, ThreatType, ConfidenceScore, NetworkMessageId
| order by TimeGenerated desc

Le champ décisif est IsClickedThrough : il distingue l'utilisateur qui a vu la page de blocage de celui qui a cliqué au travers de l'avertissement — seul le second justifie une réinitialisation d'identifiants immédiate. La jointure exige une normalisation de casse. Faux positif : les analystes de la cellule de réponse qui détonnent volontairement des URL depuis leur poste.

19. « Détecte les délégations de boîte mail créées récemment »

OfficeActivity
| where TimeGenerated > ago(30d)
| where OfficeWorkload == "Exchange"
| where Operation in ("Add-MailboxPermission", "Add-RecipientPermission",
                      "Set-Mailbox", "Add-MailboxFolderPermission")
| extend Parametres = tostring(Parameters)
| where Parametres has_any ("FullAccess", "SendAs", "SendOnBehalf")
| project TimeGenerated, UserId, Operation, OfficeObjectId,
          ClientIP, Parametres
| order by TimeGenerated desc

L'octroi de permission FullAccess ou SendAs sur une boîte est une persistance discrète : l'attaquant conserve l'accès même après réinitialisation du mot de passe de la victime. Le signal à isoler est une délégation accordée par un compte qui n'administre pas habituellement Exchange. Faux positif : les boîtes partagées et les assistants de direction génèrent ces opérations légitimement.

Cloud et exfiltration

20. « Identifie les téléchargements massifs SharePoint (plus de 100 fichiers en 1 heure) »

OfficeActivity
| where TimeGenerated > ago(7d)
| where OfficeWorkload in ("SharePoint", "OneDrive")
| where Operation in ("FileDownloaded", "FileSyncDownloadedFull")
| summarize Fichiers = count(),
            FichiersUniques = dcount(SourceFileName),
            Sites = make_set(SiteUrl, 10),
            IPs = make_set(ClientIP, 5)
        by UserId, bin(TimeGenerated, 1h)
| where Fichiers > 100
| order by Fichiers desc

Le motif d'exfiltration documentaire le plus courant, typique d'un départ de collaborateur ou d'une compromission de compte. Le réglage principal est le couple seuil/fenêtre : 100 fichiers par heure convient à un tenant de taille moyenne, il faut monter nettement au-dessus dans une organisation utilisant intensivement le partage. Faux positif dominant : la synchronisation initiale du client OneDrive sur un poste neuf.

21. « Trouve les nouvelles règles de transfert d'e-mail créées »

OfficeActivity
| where TimeGenerated > ago(30d)
| where Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules")
| extend Regle = tostring(Parameters)
| where Regle has_any ("ForwardTo", "ForwardAsAttachmentTo", "RedirectTo",
                       "DeleteMessage", "MoveToFolder")
| project TimeGenerated, UserId, Operation, ClientIP, Regle
| order by TimeGenerated desc

La règle de transfert automatique est la signature canonique du Business Email Compromise : l'attaquant redirige la correspondance financière vers une boîte externe, souvent couplée à un déplacement vers un dossier peu consulté pour masquer les réponses. Le marqueur à haute valeur est la combinaison d'un ForwardTo externe et d'un MoveToFolder. Faux positif : les règles de tri personnelles sans redirection externe.

22. « Détecte les créations de ressources Azure dans des régions inhabituelles »

let RegionsAutorisees = dynamic(["westeurope","northeurope","francecentral","francesouth"]);
AzureActivity
| where TimeGenerated > ago(30d)
| where OperationNameValue has_any ("WRITE", "virtualMachines/write",
                                    "Microsoft.Compute", "Microsoft.ContainerService")
| where ActivityStatusValue == "Success"
| extend Region = tostring(parse_json(Properties).resourceLocation)
| where isnotempty(Region) and Region !in (RegionsAutorisees)
| summarize Operations = count(),
            Ressources = make_set(ResourceId, 10)
        by Caller, CallerIpAddress, Region, ResourceGroup
| order by Operations desc

Le déploiement de machines virtuelles dans une région non utilisée par l'entreprise est le marqueur classique du cryptojacking sur abonnement compromis. Le levier de réglage est la liste RegionsAutorisees, à aligner sur la politique Azure Policy en vigueur. Faux positif : les tests de continuité d'activité et les déploiements de recherche produisent légitimement des ressources hors région.

23. « Montre les connexions à des applications cloud non sanctionnées »

CloudAppEvents
| where TimeGenerated > ago(7d)
| where ActionType in ("Login", "FileUploaded", "FileDownloaded")
| summarize Activites = count(),
            Utilisateurs = dcount(AccountDisplayName),
            Comptes = make_set(AccountDisplayName, 20),
            IPs = dcount(IPAddress)
        by Application
| where Application !has_any ("Microsoft", "Office 365", "Azure")
| order by Utilisateurs desc

Cartographie du shadow IT à partir de la télémétrie CASB. Le signal à privilégier est le nombre d'utilisateurs distincts : une application employée par une seule personne relève de l'usage isolé, une application adoptée par vingt personnes constitue un canal de données parallèle non gouverné. Faux positif : les applications métier légitimes non encore référencées dans le catalogue interne.

24. « Identifie les suppressions massives de fichiers OneDrive (ransomware potentiel) »

OfficeActivity
| where TimeGenerated > ago(24h)
| where OfficeWorkload in ("OneDrive", "SharePoint")
| where Operation in ("FileDeleted", "FileRecycled", "FileDeletedFirstStageRecycleBin")
| summarize Suppressions = count(),
            Extensions = make_set(tostring(split(SourceFileName, ".")[-1]), 15),
            Sites = make_set(SiteUrl, 5)
        by UserId, ClientIP, bin(TimeGenerated, 15m)
| where Suppressions > 50
| order by Suppressions desc

Le ransomware qui atteint un poste synchronisé propage ses suppressions et renommages vers le cloud. La fenêtre courte de 15 minutes est délibérée : le chiffrement est rapide, et une fenêtre horaire noierait le pic dans l'activité normale. Faux positif : les opérations de purge documentaire planifiées et les migrations de site, à corréler avec le calendrier de changement.

Réseau et mouvement latéral

25. « Détecte les scans de ports internes (connexions vers plus de 20 ports différents en 5 minutes) »

DeviceNetworkEvents
| where TimeGenerated > ago(24h)
| where ActionType in ("ConnectionFailed", "ConnectionRequest", "ConnectionSuccess")
| where ipv4_is_private(RemoteIP)
| summarize PortsDistincts = dcount(RemotePort),
            CiblesDistinctes = dcount(RemoteIP),
            Ports = make_set(RemotePort, 30),
            Echecs = countif(ActionType == "ConnectionFailed")
        by DeviceName, InitiatingProcessFileName, bin(TimeGenerated, 5m)
| where PortsDistincts > 20
| order by PortsDistincts desc

La reconnaissance interne précède systématiquement le mouvement latéral. Le discriminant le plus fort n'est pas le nombre de ports mais le ratio d'échecs : un scan produit une majorité de ConnectionFailed, contrairement à une application distribuée légitime. Faux positif : les scanners de vulnérabilité autorisés et les outils de découverte d'inventaire — les exclure par nom de machine source.

26. « Identifie les connexions RDP vers des serveurs inhabituels »

let RdpHabituel =
    DeviceNetworkEvents
    | where TimeGenerated between (ago(30d) .. ago(7d))
    | where RemotePort == 3389 and ActionType == "ConnectionSuccess"
    | distinct DeviceName, RemoteIP;
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where RemotePort == 3389
| where ActionType == "ConnectionSuccess"
| join kind=leftanti (RdpHabituel) on DeviceName, RemoteIP
| project TimeGenerated, DeviceName, RemoteIP,
          InitiatingProcessFileName, InitiatingProcessAccountName
| order by TimeGenerated desc

On applique ici un profilage comportemental : la ligne de base des couples source-destination RDP observés sur 30 jours devient le filtre. Toute nouvelle paire est remontée. Le levier est la profondeur de la fenêtre de référence, à porter à 60 jours pour absorber les cycles mensuels. Faux positif : les rebonds d'administration ponctuels et les interventions de maintenance planifiées.

27. « Trouve les utilisations de Pass-the-Hash (connexions NTLM anormales) »

SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4624
| where LogonType == 3
| where AuthenticationPackageName =~ "NTLM"
| where LogonProcessName =~ "NtLmSsp"
| where TargetUserName !endswith "$"
| where isnotempty(IpAddress) and IpAddress != "-"
| summarize Connexions = count(),
            Machines = dcount(Computer),
            Cibles = make_set(Computer, 20)
        by TargetUserName, IpAddress, bin(TimeGenerated, 1h)
| where Machines > 3
| order by Machines desc

La signature de Pass-the-Hash est une ouverture de session réseau NTLM sans domaine renseigné, propagée vers plusieurs machines depuis une source unique. Le levier de réglage est le seuil Machines > 3, à ajuster selon la taille du parc. Faux positif : les scanners d'inventaire authentifiés et les sauvegardes centralisées produisent exactement ce motif — les exclure par compte de service.

28. « Montre les volumes de transfert DNS anormaux (DNS tunneling) »

DeviceNetworkEvents
| where TimeGenerated > ago(24h)
| where RemotePort == 53
| where InitiatingProcessFileName !in~ ("svchost.exe", "dns.exe", "systemd-resolved")
| summarize Requetes = count(),
            DestinationsDistinctes = dcount(RemoteIP),
            Duree = datetime_diff('minute', max(TimeGenerated), min(TimeGenerated))
        by DeviceName, InitiatingProcessFileName, RemoteIP
| where Requetes > 500 and Duree > 10
| extend RequetesParMinute = round(todouble(Requetes) / Duree, 1)
| order by RequetesParMinute desc

Le tunneling DNS se caractérise par un débit de requêtes soutenu et régulier vers un résolveur unique, souvent depuis un processus qui n'a aucune raison de parler DNS directement. Les leviers sont le seuil de 500 requêtes et la durée minimale, qui écarte les rafales légitimes courtes. Faux positif : les navigateurs configurés en DNS-over-HTTPS et certains agents de sécurité.

29. « Détecte les connexions SMB vers des IP de la liste noire »

let IOCs =
    ThreatIntelligenceIndicator
    | where TimeGenerated > ago(14d)
    | where Active == true and ExpirationDateTime > now()
    | where isnotempty(NetworkIP)
    | summarize arg_max(TimeGenerated, *) by IndicatorId
    | project NetworkIP, ThreatType, ConfidenceScore;
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where RemotePort in (445, 139)
| where not(ipv4_is_private(RemoteIP))
| join kind=inner (IOCs) on $left.RemoteIP == $right.NetworkIP
| project TimeGenerated, DeviceName, RemoteIP, RemotePort,
          InitiatingProcessFileName, ThreatType, ConfidenceScore
| order by ConfidenceScore desc

Toute connexion SMB sortant vers Internet est anormale en soi, indépendamment de la correspondance TI : le protocole n'a aucune raison légitime de franchir la bordure. Le filtre not(ipv4_is_private()) est donc au moins aussi important que la jointure. Faux positif quasi nul sur ce motif ; une occurrence justifie un examen immédiat du poste concerné.

30. « Identifie les nouvelles connexions WinRM/PSRemoting entre endpoints »

let LigneDeBase =
    DeviceNetworkEvents
    | where TimeGenerated between (ago(30d) .. ago(7d))
    | where RemotePort in (5985, 5986)
    | distinct DeviceName, RemoteIP;
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where RemotePort in (5985, 5986)
| where ActionType == "ConnectionSuccess"
| join kind=leftanti (LigneDeBase) on DeviceName, RemoteIP
| project TimeGenerated, DeviceName, RemoteIP, RemotePort,
          InitiatingProcessFileName, InitiatingProcessCommandLine,
          InitiatingProcessAccountName
| order by TimeGenerated desc

PowerShell Remoting est un canal de mouvement latéral silencieux car chiffré, signé et natif. Le signal exploitable est la nouveauté du couple source-destination, non le volume. Le processus initiateur affine fortement le tri : un powershell.exe lancé sous un compte utilisateur standard est autrement plus suspect qu'une connexion issue de l'agent de gestion de configuration. Faux positif : les scripts d'administration nouvellement déployés.

KQL avancé : techniques d'expert pour le threat hunting

Jointures cross-table

La valeur d'un SIEM tient à sa capacité de corrélation entre sources hétérogènes. L'exemple le plus parlant consiste à rapprocher une authentification cloud d'une authentification on-premise pour le même utilisateur dans une fenêtre courte : si l'utilisateur s'authentifie simultanément depuis une IP publique étrangère et depuis un poste du réseau interne, l'une des deux sessions n'est pas la sienne.

let Fenetre = 30m;
let ConnexionsCloud =
    SigninLogs
    | where TimeGenerated > ago(7d)
    | where ResultType == 0
    | extend Compte = tolower(split(UserPrincipalName, "@")[0])
    | project HeureCloud = TimeGenerated, Compte,
              IPCloud = IPAddress,
              PaysCloud = tostring(LocationDetails.countryOrRegion);
let ConnexionsAD =
    SecurityEvent
    | where TimeGenerated > ago(7d)
    | where EventID == 4624
    | where LogonType in (2, 10, 11)
    | extend Compte = tolower(TargetUserName)
    | where Compte !endswith "$"
    | project HeureAD = TimeGenerated, Compte,
              Machine = Computer, IPInterne = IpAddress;
ConnexionsCloud
| join kind=inner (ConnexionsAD) on Compte
| extend DeltaMinutes = abs(datetime_diff('minute', HeureCloud, HeureAD))
| where DeltaMinutes <= 30
| where PaysCloud !in ("FR", "France")
| project Compte, HeureCloud, IPCloud, PaysCloud,
          HeureAD, Machine, IPInterne, DeltaMinutes
| order by DeltaMinutes asc

Trois précautions techniques rendent cette requête fiable. La normalisation des identifiants est indispensable : SigninLogs expose un UPN complet quand SecurityEvent expose un sAMAccountName, d'où le split sur l'arobase et la mise en minuscules des deux côtés. Le filtre sur LogonType in (2, 10, 11) restreint aux ouvertures de session console, RDP et par identifiants mis en cache, seules réellement attribuables à une présence humaine. Enfin, l'exclusion des comptes machine par !endswith "$" supprime le volume écrasant du trafic Kerberos inter-machines.

Fonctions let et requêtes modulaires

L'opérateur let ne sert pas seulement à nommer une constante : il permet de déclarer des listes d'IOC, des jeux de résultats intermédiaires et de véritables fonctions paramétrées, réutilisables dans toute la requête. C'est le mécanisme de factorisation qui transforme un script jetable en brique de détection maintenable.

// 1. Constantes de configuration, modifiables en un point unique
let Fenetre = 7d;
let SeuilConfiance = 70;

// 2. Liste d'IOC statique — issue d'un rapport de threat intelligence
let IPsMalveillantes = dynamic(["185.220.101.45","91.219.236.12","45.153.160.140"]);
let HashesMalveillants = dynamic([
    "b7e2f8c1a934d5e6f0182b3c4d5e6f708192a3b4c5d6e7f80192a3b4c5d6e7f8"
]);

// 3. Fonction paramétrée réutilisable
let EnrichirIP = (TableSource: (IP: string)) {
    TableSource
    | extend EstPrive = ipv4_is_private(IP),
             EstIOCStatique = IP in (IPsMalveillantes)
};

// 4. Jeu de résultats intermédiaire, matérialisé pour éviter un recalcul
let IOCsDynamiques = materialize(
    ThreatIntelligenceIndicator
    | where TimeGenerated > ago(30d)
    | where Active == true and ExpirationDateTime > now()
    | where ConfidenceScore >= SeuilConfiance
    | summarize arg_max(TimeGenerated, *) by IndicatorId
    | project NetworkIP, ThreatType, ConfidenceScore
);

// 5. Requête principale, lisible car toute la complexité est déportée
DeviceNetworkEvents
| where TimeGenerated > ago(Fenetre)
| where ActionType == "ConnectionSuccess"
| project TimeGenerated, DeviceName, IP = RemoteIP,
          RemotePort, InitiatingProcessFileName
| invoke EnrichirIP()
| where EstPrive == false
| join kind=leftouter (IOCsDynamiques) on $left.IP == $right.NetworkIP
| where EstIOCStatique or isnotempty(ThreatType)
| order by TimeGenerated desc

Ce découpage a une conséquence opérationnelle directe : lorsqu'un nouveau rapport de menace paraît, l'analyste modifie uniquement la liste IPsMalveillantes en tête de fichier, sans toucher à la logique de corrélation. Les fonctions déclarées avec let peuvent également être promues en fonctions sauvegardées au niveau du workspace Log Analytics, devenant alors invocables comme des tables par n'importe quelle requête du tenant — c'est ainsi que se construit une bibliothèque de détection partagée entre les analystes d'une même équipe.

mv-expand pour les données complexes

Une grande partie des données de sécurité les plus utiles est stockée sous forme d'objets dynamiques : les entités d'un incident Sentinel, les TargetResources d'un événement d'audit Entra, les AdditionalFields d'un événement Defender. Ces structures sont invisibles à un simple where textuel et nécessitent un aplatissement explicite.

SecurityIncident
| where TimeGenerated > ago(30d)
| where Status != "Closed"
| where Severity in ("High", "Medium")
| mv-expand AlerteId = AlertIds
| extend AlerteId = tostring(AlerteId)
| join kind=inner (
    SecurityAlert
    | where TimeGenerated > ago(30d)
    | project SystemAlertId, AlertName, Tactics, Entities
) on $left.AlerteId == $right.SystemAlertId
| mv-expand Entite = todynamic(Entities)
| extend TypeEntite = tostring(Entite.Type),
         NomEntite = coalesce(tostring(Entite.HostName),
                              tostring(Entite.Name),
                              tostring(Entite.Address),
                              tostring(Entite.AadUserId))
| where isnotempty(NomEntite)
| summarize IncidentsLies = dcount(IncidentNumber),
            Incidents = make_set(IncidentNumber, 20),
            Tactiques = make_set(Tactics, 10),
            Alertes = make_set(AlertName, 15)
        by TypeEntite, NomEntite
| where IncidentsLies > 1
| order by IncidentsLies desc

Le résultat est un pivot par entité : quels hôtes, comptes et adresses IP apparaissent dans plusieurs incidents ouverts simultanément. C'est l'une des requêtes les plus rentables du threat hunting, car elle révèle les incidents que la corrélation automatique n'a pas fusionnés alors qu'ils partagent un point commun. La fonction coalesce() est indispensable ici : le schéma des entités varie selon leur type, et un accès direct à un champ inexistant retournerait une valeur vide sans erreur, faisant disparaître silencieusement des lignes. Deux points de vigilance : un double mv-expand multiplie le nombre de lignes intermédiaires — filtrer en amont est impératif — et l'usage de mv-apply plutôt que mv-expand permet de filtrer pendant l'expansion, ce qui divise souvent le coût par un ordre de grandeur sur les gros volumes.

Optimisation des requêtes KQL

Une requête de chasse qui expire au bout de dix minutes n'est pas une requête de chasse. L'optimisation en KQL n'est pas une préoccupation d'esthète : elle conditionne la faisabilité même de l'investigation, et elle a un impact financier direct puisque Sentinel facture au volume de données analysé.

Filtrer tôt, et d'abord sur le temps. TimeGenerated détermine les partitions physiquement lues sur le stockage. Toute autre optimisation est secondaire face à celle-ci. Placer systématiquement le filtre temporel en première ligne, y compris à l'intérieur des sous-requêtes déclarées par let — c'est l'erreur d'optimisation la plus fréquente dans les requêtes générées automatiquement, qui filtrent souvent la table principale mais oublient la sous-requête jointe.

Limiter les colonnes avec project. Log Analytics est un moteur en colonnes : ne projeter que les six colonnes nécessaires au lieu des soixante disponibles réduit proportionnellement les données lues. Placer le project avant les join et les summarize, jamais après. project-away permet d'écarter quelques colonnes volumineuses tout en conservant le reste du schéma.

Ordonner les jointures correctement. KQL diffuse la table de droite vers tous les nœuds d'exécution : il faut donc y placer le plus petit jeu de résultats. Une jointure SecurityEvent à gauche et ThreatIntelligenceIndicator filtré à droite s'exécute rapidement ; l'inverse peut ne jamais terminer. Sur de très gros volumes, hint.strategy=broadcast ou hint.strategy=shuffle guident explicitement le planificateur.

Agréger avant de joindre. Joindre dix millions de lignes brutes pour n'en garder que quelques centaines après agrégation est un gaspillage pur. Réduire chaque côté par summarize ou distinct avant le join divise couramment le temps d'exécution par dix.

Matérialiser les sous-requêtes réutilisées. Une expression let référencée plusieurs fois est réévaluée à chaque référence. L'encapsuler dans materialize() force un calcul unique dont le résultat est conservé en cache pour la durée de la requête. Le gain est spectaculaire sur les requêtes de chasse qui croisent un même jeu d'IOC contre quatre ou cinq tables de télémétrie.

Deux réflexes complémentaires : préférer has à containshas exploite l'index de termes quand contains impose un balayage de sous-chaîne — et éviter les expressions régulières dans un where de premier niveau, en les réservant à un extend appliqué après réduction du volume. Ces deux ajustements suffisent souvent à faire passer une requête de plusieurs minutes à quelques secondes.

Créer des règles de détection KQL dans Sentinel avec Copilot

Le passage d'une requête de chasse à une règle analytique en production suit un enchaînement en cinq étapes qu'il ne faut pas raccourcir, sous peine d'inonder le SOC d'alertes ingérables.

Étape 1 — Formuler le prompt NL2KQL. Utiliser le template du promptbook officiel en renseignant les quatre champs. Nommer explicitement la table cible reste la précaution la plus efficace contre l'invention de schéma. Formuler l'intention de détection en une phrase, sans conjonction : une règle analytique ne doit détecter qu'un comportement.

Étape 2 — Relire la requête générée. Vérifier trois choses systématiquement : que les noms de tables et de colonnes existent réellement dans le workspace, que le filtre temporel est présent et cohérent avec la future fréquence d'exécution, et que la logique métier correspond bien à l'intention. Security Copilot produit une syntaxe presque toujours valide, mais peut se tromper sur des colonnes propres à un connecteur particulier, ou proposer une colonne dépréciée.

Étape 3 — Tester en mode Logs sur données réelles. Exécuter la requête sur trente jours d'historique dans le panneau Logs. Trois résultats possibles : zéro ligne, ce qui signale soit une détection saine, soit une erreur de logique — vérifier en relâchant le filtre le plus restrictif ; un volume raisonnable de quelques dizaines de lignes, ce qui est le cas nominal ; ou des milliers de lignes, ce qui impose un travail de réglage de seuil et de liste d'exclusion avant toute mise en production.

Étape 4 — Convertir en règle analytique. Depuis Sentinel, créer une règle planifiée et coller la requête. Configurer la fréquence d'exécution et la période de recherche en veillant à ce que la seconde soit légèrement supérieure à la première — cinq minutes de recouvrement — afin de ne pas perdre d'événements en bordure de fenêtre à cause de la latence d'ingestion. Renseigner ensuite le mappage d'entités, étape trop souvent négligée : sans mappage Account, Host et IP, l'alerte générée ne sera pas corrélable par le moteur de fusion, ne s'agrégera pas en incident cohérent et ne bénéficiera pas de l'analyse UEBA.

Étape 5 — Configurer le regroupement et la réponse. Activer le regroupement d'alertes en incidents sur les entités pertinentes pour éviter qu'une campagne touchant cinquante utilisateurs ne produise cinquante incidents distincts. Associer une règle d'automatisation déclenchant un playbook Logic Apps — notification Teams, enrichissement, isolation de l'endpoint. Surveiller la règle pendant deux semaines et documenter chaque faux positif en exclusion explicite, plutôt qu'en relevant le seuil global qui dégraderait la sensibilité pour tous les cas. Cette démarche complète est détaillée dans notre guide sur l'investigation d'incidents Sentinel avec Security Copilot, qui couvre également le triage post-alerte.

Ressources pour progresser en KQL

La progression en KQL passe par la pratique répétée bien plus que par la lecture. Quelques ressources structurent utilement cet apprentissage.

La documentation officielle du langage de requête Kusto reste la référence normative : elle documente chaque opérateur et chaque fonction avec des exemples exécutables, et constitue le seul arbitre fiable en cas de doute sur un comportement. Le parcours d'apprentissage KQL sur Microsoft Learn propose une progression guidée en quelques heures, adaptée à un analyste qui débute.

Le playground Log Analytics — un workspace de démonstration prérempli accessible sans abonnement Azure — permet de s'exercer sur des données réalistes sans risque et sans coût. C'est l'outil idéal pour tester une requête trouvée dans un article avant de l'adapter à son propre tenant.

Le dépôt rod-trent/Security-Copilot sur GitHub rassemble plusieurs centaines de prompts et de promptbooks maintenus par la communauté, avec une section spécifiquement dédiée aux requêtes KQL générées. Le dépôt Azure/Azure-Sentinel officiel contient quant à lui l'intégralité des règles de détection livrées par Microsoft : lire ces requêtes est probablement le meilleur exercice de progression disponible, car elles illustrent les motifs d'optimisation et de mappage d'entités en conditions réelles.

Côté certification, l'examen SC-200 « Microsoft Security Operations Analyst » consacre une part significative de son contenu à KQL et à la configuration de Sentinel. Sa préparation constitue un cadre structurant pour couvrir méthodiquement les tables, les règles analytiques et la chasse avancée. Pour aller plus loin sur l'exploitation de Security Copilot en chasse, notre article sur les prompts de threat hunting alignés MITRE ATT&CK et notre bibliothèque complète de promptbooks prolongent directement ce guide.

En pratique

NL2KQL divise par 3 le temps de rédaction des requêtes de hunting. Mais attention : les requêtes générées doivent toujours être testées sur un jeu de données réel avant déploiement en règle de détection — Copilot peut faire des erreurs sur les noms de tables ou colonnes spécifiques à votre tenant.

Audit de votre infrastructure Microsoft Sentinel | Formation KQL pour votre équipe SOC

Questions fréquentes

Faut-il encore apprendre KQL si Security Copilot génère les requêtes ?

Oui, et davantage qu'avant. Security Copilot produit un premier jet ; l'analyste doit vérifier que les colonnes existent, que les seuils sont pertinents pour son tenant et que la logique correspond réellement à l'intention. Un analyste incapable de lire une requête Kusto ne peut ni valider ni corriger ce qui lui est proposé, et déploiera des règles qui alertent sur le mauvais comportement. La compétence utile s'est déplacée de la rédaction vers la relecture critique et l'optimisation.

Quelle différence entre KQL dans Sentinel et dans Defender XDR Advanced Hunting ?

Le langage est strictement identique, mais les schémas diffèrent. Advanced Hunting expose les tables Device*, Email*, Identity* et Cloud* avec une rétention de trente jours. Sentinel expose l'ensemble des tables du workspace Log Analytics, avec une rétention configurable jusqu'à douze ans. Lorsque le connecteur Defender XDR est activé, les tables Advanced Hunting deviennent également disponibles dans Sentinel, ce qui permet de les joindre à SigninLogs ou CommonSecurityLog — corrélation impossible depuis le portail Defender seul.

Comment réduire le coût des requêtes KQL dans Sentinel ?

Quatre leviers, par ordre d'impact décroissant. Restreindre la fenêtre TimeGenerated le plus tôt possible, y compris dans les sous-requêtes. Projeter uniquement les colonnes nécessaires, avant tout join. Agréger avant de joindre. Utiliser materialize() pour les jeux de résultats référencés plusieurs fois. En amont, les règles de transformation à l'ingestion et le niveau Basic Logs pour les tables verbeuses comme CommonSecurityLog réduisent structurellement la facture, indépendamment des requêtes exécutées.

Security Copilot peut-il se tromper sur les noms de tables ou de colonnes ?

Oui, c'est son mode d'échec principal. Le modèle connaît le schéma générique de Sentinel, pas les particularités d'un tenant : tables issues de connecteurs personnalisés, colonnes ajoutées par une règle de transformation, tables absentes faute de connecteur activé. Il peut également proposer une colonne dépréciée encore présente dans sa base de connaissances. Renseigner explicitement le champ Table du promptbook réduit fortement ce risque, et l'exécution en mode Logs le détecte immédiatement.

Quelle est la meilleure façon de tester une requête KQL avant de la mettre en production ?

Exécuter la requête sur trente jours d'historique dans le panneau Logs, en observant le volume retourné. Un résultat vide doit être vérifié en relâchant progressivement les filtres, pour distinguer une détection saine d'une erreur de logique. Un volume élevé impose un réglage de seuil et des exclusions documentées. Ensuite, activer la règle en mode observation pendant deux semaines avant de brancher toute automatisation de réponse : une règle qui isole automatiquement des endpoints sur la base d'une logique non éprouvée cause plus de dommages que l'attaque qu'elle prétend contrer.

À retenir

  • La syntaxe pipeline détermine la performance. Filtrer sur TimeGenerated en première ligne, projeter tôt les colonnes utiles, agréger avant de joindre : ces trois réflexes font la différence entre une requête de trois secondes et une requête qui expire.
  • Connaître le schéma prime sur connaître la syntaxe. Savoir que les échecs cloud vivent dans SigninLogs avec un ResultType et les échecs on-premise dans SecurityEvent avec l'EventID 4625 constitue l'essentiel du travail d'un analyste.
  • Le template du promptbook KQL Request structure la génération. Renseigner les quatre champs — produit, table, plage temporelle, format d'affichage — supprime la majorité des erreurs de schéma des requêtes générées automatiquement.
  • Toute requête générée doit être exécutée avant d'être déployée. Un test sur trente jours d'historique révèle immédiatement les colonnes inexistantes, les seuils inadaptés au tenant et les faux positifs structurels.
  • Le mappage d'entités conditionne la valeur de l'alerte. Une règle analytique sans mappage Account, Host et IP ne se corrèle pas, ne s'agrège pas en incident cohérent et ne bénéficie pas de l'analyse comportementale UEBA.
``` **Volume** : ~4 350 mots de prose hors blocs de code (~6 800 avec le code). HTML pur, aucun markdown, tous les `<` et `>` échappés en `<`/`>` dans les blocs KQL. **Deux ajustements par rapport à la spec** : - Requête #3 — la spec indiquait `AuditLogs` seule, mais cette table ne peut pas exprimer une *absence* de connexion. La requête extrait les porteurs de rôle depuis `AuditLogs` puis applique un `join kind=leftanti` contre `SigninLogs`, ce qui préserve l'intention et donne une requête réellement exécutable. - `DeviceNetworkEvents` et `UrlClickEvents` étaient utilisées dans les requêtes 11, 18 et 25-30 sans figurer dans la liste des tables clés — j'ai ajouté une sous-section `DeviceNetworkEvents` dans le H2 « Tables clés » et documenté `UrlClickEvents` dans la section Defender for Office, pour la cohérence interne de l'article. Rien n'a été écrit sur disque (la permission d'écrire dans `/tmp` a été refusée) ni inséré en base ; le répertoire `/var/www/dev.ayinedjimi-consultants.fr/` n'a pas été touché.