Les plugins natifs de Microsoft Security Copilot couvrent l'essentiel des besoins d'un SOC : incidents Sentinel, chasse Defender XDR, contexte Entra ID, veille Threat Intelligence. Mais dès que votre organisation a construit ses propres tables, ses propres conventions de nommage ou ses propres règles de corrélation, vous atteignez rapidement la limite du prêt-à-porter. L'analyste se retrouve alors à recopier des requêtes KQL dans le prompt, ce qui coûte des Security Compute Units, produit des résultats non reproductibles et échappe à toute revue. Le plugin KQL custom résout exactement ce problème : il transforme une requête de chasse validée par votre équipe en une compétence appelable en langage naturel, versionnée dans Git, paramétrée, auditable et partagée à l'ensemble du tenant. Cet article détaille la construction complète d'un plugin KQL pour Microsoft Sentinel — anatomie du manifeste YAML, quatre compétences réellement exploitables en production, sécurisation de l'interpolation des paramètres, optimisation des jeux de résultats pour le raisonnement du modèle, déploiement, débogage et gouvernance des rôles. Chaque exemple est directement transposable dans votre workspace.
Note terminologique préalable : le produit s'est appelé Microsoft Copilot for Security avant d'être renommé Microsoft Security Copilot. Les rôles de la plateforme s'appellent aujourd'hui Copilot owner et Copilot contributor — et non « Copilot Security Contributor », une formulation encore fréquente dans les documentations internes héritées. Nous utilisons la nomenclature actuelle dans tout l'article. Pour une vue d'ensemble de la plateforme, consultez notre dossier Microsoft Security Copilot.
Pourquoi créer un plugin KQL custom plutôt que coller du KQL dans le prompt
Security Copilot accepte parfaitement qu'un analyste colle une requête KQL dans une session et demande au modèle de l'interpréter. C'est pratique en investigation ad hoc, et c'est une impasse en exploitation courante. Trois raisons structurelles justifient l'encapsulation dans un plugin.
Réutilisabilité et cohérence des résultats
Une requête de chasse mature n'est jamais triviale. Elle contient des exclusions de faux positifs accumulées sur des mois, des jointures avec des watchlists, des seuils calibrés sur votre volumétrie. Recopiée à la main, elle dérive : un analyste oublie une exclusion, un autre modifie une fenêtre temporelle, un troisième travaille sur une version obsolète trouvée dans un canal Teams. Le plugin garantit que tout le monde exécute la même requête, y compris les astreintes de nuit et les prestataires MSSP. Le langage naturel devient l'interface, le KQL reste sous contrôle de l'ingénierie de détection. Cette séparation des responsabilités est structurante : elle permet à un analyste de niveau 1 d'exploiter une logique de détection qu'il serait incapable d'écrire lui-même, sans jamais pouvoir l'altérer par inadvertance.
Gouvernance, revue et traçabilité
Un manifeste YAML est un fichier texte. Il vit dans un dépôt Git, passe par une pull request, se fait relire par un pair, se déploie par pipeline. Vous obtenez gratuitement l'historique des modifications, l'identité de l'auteur, la justification du changement. C'est la différence entre une requête écrite dans un chat et un artefact d'ingénierie. Pour les organisations soumises à NIS2 ou DORA, cette traçabilité des outils d'investigation n'est pas un confort : elle fait partie des attendus de maîtrise des processus de détection, au même titre que la documentation des règles de corrélation. Si vous n'avez pas encore cartographié votre exposition réglementaire, notre diagnostic de conformité NIS2 constitue un bon point de départ.
Économie de Security Compute Units
Une requête collée dans le prompt consomme des tokens en entrée, puis le modèle doit deviner l'intention, reformuler, parfois réexécuter. Un skill nommé et décrit est sélectionné par l'orchestrateur avec un contexte minimal : le modèle envoie les paramètres, reçoit un résultat déjà cadré, et raisonne dessus. La différence de consommation se mesure. Sur des sessions répétitives — triage matinal, revue de comptes à risque, qualification d'alerte — le gain est significatif car il élimine les allers-retours de clarification. Nous avons détaillé la mécanique de facturation et le dimensionnement dans SCU Security Copilot : dimensionner ses Compute Units.
Prérequis techniques et permissions
Avant d'écrire la première ligne de YAML, vérifiez l'ensemble suivant. Un plugin qui échoue au déploiement échoue presque toujours sur l'un de ces points.
- Un workspace Microsoft Sentinel actif, avec les quatre coordonnées Azure :
TenantId,SubscriptionId,ResourceGroupName,WorkspaceName. Ce sont exactement les valeurs demandées par le descripteur du plugin. - Les tables réellement alimentées. Un skill qui interroge
DeviceNetworkEventsalors que vous n'avez pas connecté Defender for Endpoint retournera une erreur de résolution de table, pas un résultat vide. Validez chaque table dans Log Analytics avant de la référencer. - Rôle Copilot contributor au minimum sur la plateforme Security Copilot, pour créer une session et gérer un plugin personnel — sous réserve que le paramètre correspondant soit activé par un owner (voir la section gouvernance).
- Azure RBAC sur le workspace. Security Copilot fonctionne en authentification on-behalf-of : il n'accède jamais à plus de données que l'utilisateur connecté. Le rôle
Microsoft Sentinel Reader(ouLog Analytics Reader) est nécessaire sur le workspace, sinon le skill renvoie un refus d'autorisation même si le plugin est correctement déployé. - Une capacité SCU provisionnée et rattachée au workspace Security Copilot.
Point souvent mal compris : le plugin ne porte aucune identité propre. Il n'y a pas de service principal, pas de secret, pas de SupportedAuthTypes autre que None pour une cible Sentinel. L'autorisation est entièrement déléguée à l'utilisateur. C'est une excellente nouvelle pour la sécurité — aucun élargissement de privilège possible via un plugin — et un piège classique en test : un plugin qui « ne marche pas » pour un analyste marche parfaitement pour l'administrateur qui l'a écrit, simplement parce que ce dernier dispose de droits Azure supérieurs.
Skill KQL dans un manifeste versus KQL natif : ce qui change vraiment
La différence n'est pas cosmétique. Elle porte sur le contrat passé avec l'orchestrateur du modèle.
En KQL natif — requête collée dans le prompt — le modèle reçoit un bloc de texte opaque. Il doit inférer l'intention à partir de la formulation de l'analyste, décider s'il l'exécute telle quelle, et interpréter un résultat dont il ignore la sémantique des colonnes. Le taux de reformulation est élevé, et la même demande formulée différemment produit des réponses différentes.
Dans un manifeste, chaque skill expose trois éléments que l'orchestrateur exploite directement :
NameetDisplayName: l'identifiant technique et le libellé lisible.Description: le champ le plus important du manifeste. C'est sur cette phrase que le modèle décide d'invoquer ou non le skill. Une description vague (« requête Sentinel ») ne sera jamais sélectionnée. Une description explicite (« Récupère les connexions Entra ID en échec suivies d'un succès pour un utilisateur donné sur une fenêtre en heures ») l'est de façon fiable.Inputs: les paramètres typés, avec description et obligation. Le modèle sait alors quoi extraire du langage naturel de l'analyste.
Le KQL, lui, réside dans Settings.Template. Trois modes d'alimentation existent : Template en inline, TemplateUrl pointant vers une URL publique, ou PackageUrl combiné à TemplateFile pour un zip contenant plusieurs fichiers. La limite est de 80 000 caractères par template. En production, préférez PackageUrl : vos requêtes restent des fichiers .kql lisibles et testables dans le dépôt, au lieu d'être noyées dans du YAML indenté. Attention toutefois : TemplateUrl et PackageUrl exigent une URL publiquement accessible, ce qui interdit de fait de placer des requêtes contenant des informations sensibles — noms de serveurs critiques, plages IP internes, conventions de nommage d'actifs — sur un gist public. Pour ces cas, l'inline reste le bon choix.
Anatomie du manifeste : le squelette Sentinel complet
Voici le manifeste complet du plugin que nous construisons, avec les quatre skills. Il cible le schéma de plugin KQL documenté par Microsoft, structuré autour de Descriptor et SkillGroups avec Format: KQL.
Descriptor:
Name: SentinelHuntingPack
DisplayName: Sentinel Hunting Pack - SOC Interne
Description: >-
Compétences de chasse KQL maintenues par l'ingénierie de détection :
connexions suspectes Entra ID, mouvement latéral SMB, DNS anormaux,
scoring d'utilisateur à risque.
Settings:
- Name: TenantId
Required: true
- Name: SubscriptionId
Required: true
- Name: ResourceGroupName
Required: true
- Name: WorkspaceName
Required: true
SupportedAuthTypes:
- None
SkillGroups:
- Format: KQL
Skills:
- Name: GetSuspiciousEntraSignIns
DisplayName: Connexions Entra ID suspectes
Description: >-
Analyse les connexions Entra ID d'un utilisateur sur une fenêtre en heures
et remonte les rafales d'échecs suivies d'un succès, les connexions depuis
un pays inhabituel et les authentifications non interactives anormales.
Utiliser pour qualifier une alerte de compte potentiellement compromis.
Inputs:
- Name: userPrincipalName
Description: UPN complet de l'utilisateur, par exemple [email protected]
Required: true
- Name: lookbackHours
Description: Fenêtre d'analyse en heures, entre 1 et 168. Défaut 24.
Required: false
Settings:
Target: Sentinel
TenantId: '{{TenantId}}'
SubscriptionId: '{{SubscriptionId}}'
ResourceGroupName: '{{ResourceGroupName}}'
WorkspaceName: '{{WorkspaceName}}'
Template: |-
let targetUser = tolower(tostring("{{userPrincipalName}}"));
let window = totimespan(coalesce(toint("{{lookbackHours}}"), 24) * 1h);
let signins = SigninLogs
| where TimeGenerated > ago(window)
| where tolower(UserPrincipalName) == targetUser;
let burst = signins
| where ResultType != 0
| summarize FailedCount = count(),
DistinctErrors = dcount(ResultType),
FirstFail = min(TimeGenerated),
LastFail = max(TimeGenerated)
by IPAddress, Country = tostring(LocationDetails.countryOrRegion)
| where FailedCount >= 5;
let success = signins
| where ResultType == 0
| summarize SuccessCount = count(),
FirstSuccess = min(TimeGenerated)
by IPAddress, Country = tostring(LocationDetails.countryOrRegion);
burst
| join kind=leftouter success on IPAddress
| extend FailThenSuccess = isnotempty(FirstSuccess) and FirstSuccess > FirstFail
| project IPAddress, Country, FailedCount, DistinctErrors,
SuccessCount = coalesce(SuccessCount, 0),
FirstFail, LastFail, FirstSuccess, FailThenSuccess
| sort by FailThenSuccess desc, FailedCount desc
| take 25
- Name: GetLateralMovementSmb
DisplayName: Mouvement lateral SMB
Description: >-
Identifie les hôtes ayant initié des connexions SMB sortantes (port 445)
vers un nombre anormal de destinations internes distinctes sur une fenêtre
en heures. Utiliser après une compromission confirmée pour mesurer la
propagation.
Inputs:
- Name: sourceHost
Description: Nom court ou FQDN de la machine source. Laisser vide pour balayer tout le parc.
Required: false
- Name: lookbackHours
Description: Fenêtre d'analyse en heures, entre 1 et 168. Défaut 24.
Required: false
- Name: minTargets
Description: Seuil de destinations distinctes déclenchant l'alerte. Défaut 10.
Required: false
Settings:
Target: Sentinel
TenantId: '{{TenantId}}'
SubscriptionId: '{{SubscriptionId}}'
ResourceGroupName: '{{ResourceGroupName}}'
WorkspaceName: '{{WorkspaceName}}'
TemplateFile: skills/lateral-movement-smb.kql
- Name: GetAnomalousDnsQueries
DisplayName: Requetes DNS anormales
Description: >-
Détecte les indicateurs de tunneling ou d'exfiltration DNS : longueur
moyenne de label élevée, entropie du sous-domaine, volume de requêtes
NXDOMAIN vers un même domaine parent.
Inputs:
- Name: lookbackHours
Description: Fenêtre d'analyse en heures, entre 1 et 168. Défaut 24.
Required: false
- Name: minQueryCount
Description: Nombre minimal de requêtes vers un domaine parent. Défaut 50.
Required: false
Settings:
Target: Sentinel
TenantId: '{{TenantId}}'
SubscriptionId: '{{SubscriptionId}}'
ResourceGroupName: '{{ResourceGroupName}}'
WorkspaceName: '{{WorkspaceName}}'
TemplateFile: skills/anomalous-dns.kql
- Name: GetUserRiskSummary
DisplayName: Synthese utilisateur a risque
Description: >-
Agrège pour un utilisateur donné les incidents Sentinel ouverts, les
détections de risque Entra ID et les alertes Defender en un score
consolidé unique. Utiliser en ouverture de triage.
Inputs:
- Name: userPrincipalName
Description: UPN complet de l'utilisateur, par exemple [email protected]
Required: true
- Name: lookbackDays
Description: Fenêtre d'analyse en jours, entre 1 et 30. Défaut 7.
Required: false
Settings:
Target: Sentinel
TenantId: '{{TenantId}}'
SubscriptionId: '{{SubscriptionId}}'
ResourceGroupName: '{{ResourceGroupName}}'
WorkspaceName: '{{WorkspaceName}}'
TemplateFile: skills/user-risk-summary.kql
Remarquez la structure hybride : le premier skill utilise Template en inline pour rester lisible dans l'article, les trois autres pointent vers des fichiers .kql via TemplateFile, ce qui correspond à la pratique recommandée en production avec un PackageUrl déclaré au niveau du SkillGroup. Notez également que les placeholders {{TenantId}}, {{SubscriptionId}}, {{ResourceGroupName}} et {{WorkspaceName}} ne sont pas des paramètres utilisateur : ils sont résolus depuis les valeurs saisies lors de la configuration du plugin dans le portail, et déclarés une fois pour toutes dans Descriptor.Settings.
Skill 1 : connexions suspectes Entra ID, en détail
Le KQL présenté ci-dessus mérite un commentaire ligne à ligne, car il illustre plusieurs principes réutilisables.
La première ligne, let targetUser = tolower(tostring("{{userPrincipalName}}"));, est le point le plus important de tout l'article. Elle capture le paramètre dans une variable typée en tête de requête, avant toute opération de pipeline. Nous détaillons la raison dans la section consacrée à l'injection.
La deuxième construit une fenêtre temporelle à partir d'un entier : totimespan(coalesce(toint("{{lookbackHours}}"), 24) * 1h). Le toint() force une conversion numérique — toute valeur non entière devient null — et le coalesce() fournit la valeur par défaut quand le paramètre est absent. Aucune chaîne arbitraire ne peut survivre à cette double conversion.
Le reste applique un motif classique de détection de password spraying ou de credential stuffing réussi : on isole les rafales d'échecs par couple IP/pays, on isole les succès, on joint en leftouter, et on marque le cas critique FailThenSuccess — une série d'échecs suivie d'un succès depuis la même adresse. C'est le signal qui justifie une réinitialisation immédiate du mot de passe et une révocation des sessions actives. Le tri place ces lignes en tête, ce qui garantit que même avec take 25, le cas le plus grave n'est jamais tronqué.
Skill 2 : mouvement latéral SMB
Ce skill répond à une question précise en réponse à incident : « depuis que la machine X est compromise, vers combien de machines a-t-elle tenté d'ouvrir du SMB ? ». Contenu du fichier skills/lateral-movement-smb.kql :
// skills/lateral-movement-smb.kql
// Détection de propagation SMB (445) — Defender for Endpoint via Sentinel
let window = totimespan(coalesce(toint("{{lookbackHours}}"), 24) * 1h);
let threshold = coalesce(toint("{{minTargets}}"), 10);
let hostFilter = tolower(trim(@"\s+", tostring("{{sourceHost}}")));
DeviceNetworkEvents
| where Timestamp > ago(window)
| where RemotePort == 445
| where ActionType in ("ConnectionSuccess", "ConnectionAttempt")
// Ne conserver que les destinations RFC1918 : le SMB sortant vers Internet
// relève d'un autre cas d'usage (exfiltration) traité ailleurs.
| where ipv4_is_private(RemoteIP)
| extend SourceHost = tolower(tostring(DeviceName))
// Filtre optionnel : si le paramètre est vide, la clause est neutre.
| where isempty(hostFilter) or SourceHost == hostFilter or SourceHost startswith strcat(hostFilter, ".")
| summarize
DistinctTargets = dcount(RemoteIP),
TargetSample = make_set(RemoteIP, 12),
Attempts = count(),
SuccessRate = round(100.0 * countif(ActionType == "ConnectionSuccess") / count(), 1),
InitiatingProcs = make_set(InitiatingProcessFileName, 8),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp)
by SourceHost, AccountName = tostring(InitiatingProcessAccountName)
| where DistinctTargets >= threshold
| extend SpreadVelocity = round(todouble(DistinctTargets)
/ (datetime_diff('minute', LastSeen, FirstSeen) + 1), 2)
| project SourceHost, AccountName, DistinctTargets, Attempts, SuccessRate,
SpreadVelocity, InitiatingProcs, TargetSample, FirstSeen, LastSeen
| sort by SpreadVelocity desc, DistinctTargets desc
| take 20
Deux points méritent l'attention. D'abord le filtre optionnel : where isempty(hostFilter) or SourceHost == hostFilter. C'est le motif propre pour rendre un paramètre facultatif sans concaténer dynamiquement des morceaux de requête. La clause existe toujours ; elle est simplement neutre quand le paramètre est vide.
Ensuite SpreadVelocity : le nombre de destinations distinctes rapporté à la durée d'activité. Un déploiement légitime de patch touche cent machines en deux heures ; un ransomware en touche cent en quatre minutes. C'est ce ratio qui distingue les deux, pas le volume brut — et c'est exactement le genre de nuance métier qu'un modèle ne réinventera pas seul et que le plugin lui fournit gratuitement. La colonne InitiatingProcs complète le tableau : voir psexec.exe ou wmiprvse.exe initier les connexions oriente immédiatement le diagnostic.
Skill 3 : requêtes DNS anormales
Le tunneling DNS reste un canal de commande et contrôle discret, précisément parce que le port 53 est rarement filtré finement. La détection repose sur trois signaux corrélés : longueur des labels, entropie apparente du sous-domaine, et volume de requêtes vers un même domaine parent. Contenu de skills/anomalous-dns.kql :
// skills/anomalous-dns.kql
let window = totimespan(coalesce(toint("{{lookbackHours}}"), 24) * 1h);
let minCount = coalesce(toint("{{minQueryCount}}"), 50);
// Domaines de confiance à exclure — maintenu comme watchlist en production.
let allowList = dynamic([
"microsoft.com","windowsupdate.com","office.com","azure.com",
"akamai.net","cloudfront.net","google.com","apple.com"
]);
DnsEvents
| where TimeGenerated > ago(window)
| where isnotempty(Name)
| extend Fqdn = tolower(tostring(Name))
| extend Labels = split(Fqdn, ".")
| extend LabelCount = array_length(Labels)
| where LabelCount >= 3
| extend ParentDomain = strcat(tostring(Labels[LabelCount - 2]), ".",
tostring(Labels[LabelCount - 1]))
| where not(ParentDomain has_any (allowList))
| extend SubPart = substring(Fqdn, 0, strlen(Fqdn) - strlen(ParentDomain) - 1)
| extend SubLength = strlen(SubPart)
// Proxy d'entropie : proportion de caractères numériques dans le sous-domaine.
| extend DigitRatio = round(
todouble(countof(SubPart, @"[0-9]", "regex")) / iff(SubLength == 0, 1, SubLength), 3)
| summarize
QueryCount = count(),
DistinctSubs = dcount(SubPart),
AvgSubLength = round(avg(SubLength), 1),
MaxSubLength = max(SubLength),
AvgDigitRatio = round(avg(DigitRatio), 3),
NxDomainCount = countif(ResultCode == 3),
Clients = dcount(ClientIP),
ClientSample = make_set(ClientIP, 5),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by ParentDomain
| where QueryCount >= minCount
| extend UniqueRatio = round(todouble(DistinctSubs) / QueryCount, 3)
// Score composite : beaucoup de sous-domaines uniques + labels longs + bruit numérique.
| extend TunnelScore = round(
(UniqueRatio * 40)
+ (min_of(AvgSubLength, 60.0) / 60.0 * 30)
+ (AvgDigitRatio * 30), 1)
| where TunnelScore >= 45
| project ParentDomain, TunnelScore, QueryCount, DistinctSubs, UniqueRatio,
AvgSubLength, MaxSubLength, AvgDigitRatio, NxDomainCount,
Clients, ClientSample, FirstSeen, LastSeen
| sort by TunnelScore desc
| take 15
La liste d'exclusion inline est ici pédagogique. En production, remplacez-la par une jointure sur une watchlist Sentinel via _GetWatchlist('DnsAllowList'), ce qui permet aux analystes d'ajouter des exceptions sans redéployer le plugin ni repasser par une revue de code. Le score composite, plafonné à 100, donne au modèle une grandeur ordonnable : Copilot saura dire « trois domaines dépassent 70, le plus élevé est … » sans avoir à comparer manuellement six colonnes numériques, exercice sur lequel les modèles restent médiocres.
Skill 4 : synthèse utilisateur à risque
Ce dernier skill est différent des trois précédents : il n'est pas une détection mais une agrégation multi-sources destinée à ouvrir un triage. Il unifie incidents Sentinel, détections de risque Entra ID et alertes Defender sur une seule ligne par utilisateur.
// skills/user-risk-summary.kql
let targetUser = tolower(tostring("{{userPrincipalName}}"));
let window = totimespan(coalesce(toint("{{lookbackDays}}"), 7) * 1d);
let incidents = SecurityIncident
| where TimeGenerated > ago(window)
| where Status != "Closed"
| mv-expand AlertId = AlertIds to typeof(string)
| join kind=inner (
SecurityAlert
| where TimeGenerated > ago(window)
| extend Entities = todynamic(Entities)
| mv-expand Entity = Entities
| where tolower(tostring(Entity.Upn)) == targetUser
or tolower(tostring(Entity.Name)) == tostring(split(targetUser, "@")[0])
| project AlertId = SystemAlertId, AlertName, AlertSeverity
) on AlertId
| summarize OpenIncidents = dcount(IncidentNumber),
MaxIncidentSeverity = max(Severity),
IncidentTitles = make_set(Title, 5);
let entraRisk = AADUserRiskEvents
| where TimeGenerated > ago(window)
| where tolower(UserPrincipalName) == targetUser
| summarize RiskDetections = count(),
HighRiskDetections = countif(RiskLevel == "high"),
RiskTypes = make_set(RiskEventType, 6),
LastRiskAt = max(TimeGenerated);
let defenderAlerts = SecurityAlert
| where TimeGenerated > ago(window)
| where ProviderName has "Defender"
| where tolower(tostring(Entities)) has targetUser
| summarize DefenderAlerts = count(),
HighDefender = countif(AlertSeverity == "High"),
AlertNames = make_set(AlertName, 6);
incidents
| extend k = 1
| join kind=fullouter (entraRisk | extend k = 1) on k
| join kind=fullouter (defenderAlerts | extend k = 1) on k
| extend RiskScore = round(
(coalesce(OpenIncidents, 0) * 15.0)
+ (coalesce(HighRiskDetections, 0) * 12.0)
+ (coalesce(RiskDetections, 0) * 4.0)
+ (coalesce(HighDefender, 0) * 10.0)
+ (coalesce(DefenderAlerts, 0) * 3.0), 1)
| extend RiskBand = case(RiskScore >= 60, "Critique",
RiskScore >= 30, "Eleve",
RiskScore >= 10, "Modere",
"Faible")
| project UserPrincipalName = targetUser, RiskBand, RiskScore,
OpenIncidents = coalesce(OpenIncidents, 0),
RiskDetections = coalesce(RiskDetections, 0),
HighRiskDetections = coalesce(HighRiskDetections, 0),
DefenderAlerts = coalesce(DefenderAlerts, 0),
IncidentTitles, RiskTypes, AlertNames, LastRiskAt
| take 1
Le take 1 final n'est pas décoratif : ce skill doit renvoyer exactement une ligne. Un skill de synthèse qui renvoie un tableau force le modèle à choisir quelle ligne commenter, et il choisit mal. Un skill qui renvoie une ligne unique avec un RiskBand textuel produit des réponses en langage naturel immédiatement exploitables. Les pondérations du RiskScore sont à recalibrer sur votre volumétrie : celles proposées ici partent du principe qu'un incident ouvert pèse davantage qu'une détection de risque isolée.
Injection sécurisée des paramètres KQL
Voici le sujet que la plupart des tutoriels ignorent. L'interpolation {{param}} est un remplacement de chaîne littéral, effectué avant l'envoi de la requête au moteur Kusto. Il n'existe aucune requête paramétrée au sens SQL, aucun binding côté serveur. Tout ce qui arrive dans le paramètre arrive dans le texte de la requête.
Le modèle de menace n'est pas « un attaquant externe exploite mon plugin » — l'appelant est déjà authentifié et limité par son RBAC. Le risque réel est double : un analyste qui saisit une valeur mal formée casse la requête ou en modifie silencieusement la sémantique ; et un contenu hostile présent dans les données analysées (nom d'affichage forgé, sujet de mail, champ d'un rapport importé) peut, via une injection de prompt indirecte, amener le modèle à passer une valeur qu'aucun humain n'a validée. Une requête altérée qui renvoie un résultat faussement rassurant est un incident de sécurité à part entière.
Illustration du problème. Avec un template naïf :
// ANTI-PATTERN — ne pas reproduire
SigninLogs
| where UserPrincipalName == '{{userPrincipalName}}'
| where ResultType != 0
| take 100
Un paramètre valant x' or 1==1 // produit une requête qui retourne l'intégralité des connexions en échec du tenant — fuite de périmètre. Pire, x' | take 0 // produit une requête syntaxiquement valide qui retourne zéro ligne, et Copilot conclura sereinement « aucune connexion suspecte détectée ». Le second cas est le plus dangereux car il ne déclenche aucune erreur.
Les six règles de durcissement
- Lier tout paramètre dans un
lettypé en tête de requête.let targetUser = tolower(tostring("{{upn}}"));confine la valeur à un scalaire de type string. Même si elle contient des opérateurs KQL, ils ne sont jamais interprétés : ils font partie de la chaîne. C'est la protection la plus efficace, et elle est quasi gratuite. - Coercer systématiquement les types non-string.
toint(),todouble(),todatetime(),totimespan(),toguid(). Une valeur incoercible devientnull, que l'on rattrape aveccoalesce(). Ne jamais interpoler un nombre directement dans une expression arithmétique. - Valider par regex quand le format est connu. Un UPN, une adresse IP, un hash SHA256 ont une forme stricte. Rejetez explicitement plutôt que de laisser passer une valeur douteuse.
- Préférer
==,=~,inethasàcontains. Ces opérateurs sont indexés, donc plus rapides, et surtout ils ne matchent pas de sous-chaînes arbitraires. Uncontainssur une valeur d'un seul caractère balaye tout le workspace. - Ne jamais interpoler un nom de table, de colonne, de base ou une URL
externaldata. Ces positions sont structurelles ; aucunletne les protège. Si vous devez rendre la table variable, utilisez unInputénuméré et ununionavec filtre, jamais une concaténation. - Borner les fenêtres temporelles. Un
lookbackHoursà 100 000 fera expirer la requête et brûlera du SCU pour rien.
Le bloc de garde suivant, placé en tête de n'importe quel template, applique les règles 1 à 3 et 6 d'un seul coup :
// --- Bloc de garde réutilisable : à placer en tête de chaque template ---
let rawUser = tostring("{{userPrincipalName}}");
let rawHours = tostring("{{lookbackHours}}");
// Règle 3 : validation de forme. Un UPN non conforme devient une chaîne vide.
let targetUser = iff(rawUser matches regex @"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$",
tolower(rawUser),
"");
// Règles 2 et 6 : coercition puis bornage strict entre 1 et 168 heures.
let hours = min_of(max_of(coalesce(toint(rawHours), 24), 1), 168);
let window = totimespan(hours * 1h);
// Court-circuit explicite : si le paramètre est invalide, renvoyer une ligne
// de diagnostic plutôt qu'un résultat vide que le modèle lirait comme "RAS".
let guard = print
ValidationError = "Parametre userPrincipalName invalide ou absent",
ReceivedValue = substring(rawUser, 0, 64),
Expected = "UPN au format [email protected]";
union
(guard | where targetUser == ""),
(SigninLogs
| where targetUser != ""
| where TimeGenerated > ago(window)
| where tolower(UserPrincipalName) == targetUser
| summarize Attempts = count(),
Failures = countif(ResultType != 0),
Countries = make_set(tostring(LocationDetails.countryOrRegion), 8)
by IPAddress
| sort by Failures desc
| take 25)
Le point clé est le court-circuit explicite. Sans lui, un paramètre invalide produit zéro ligne, et zéro ligne est indiscernable de « rien à signaler ». Avec lui, Copilot reçoit un message d'erreur structuré et répondra « le paramètre fourni n'est pas un UPN valide » au lieu de rassurer à tort. C'est la différence entre un plugin robuste et un plugin dangereux. Notez la troncature de ReceivedValue à 64 caractères : elle évite de renvoyer au modèle une charge utile hostile dans son intégralité.
Optimiser les résultats pour le raisonnement de Copilot
Un skill KQL bien écrit du point de vue d'un analyste peut être un mauvais skill du point de vue du modèle. Les contraintes ne sont pas les mêmes : un humain scanne visuellement un tableau de 500 lignes et repère l'anomalie, un modèle le consomme en tokens et se noie.
Toujours agréger avant de projeter
summarize doit être votre réflexe. Cinq cents événements de connexion bruts n'apprennent rien à Copilot ; « 3 adresses IP, 412 échecs, 2 pays, 1 succès tardif » lui permet de raisonner et de formuler une recommandation. La règle pratique : si votre requête peut renvoyer plus de 50 lignes, elle a besoin d'un summarize.
Borner avec take, et le borner bas
Terminez chaque template par take 15 à take 25 après tri. Utilisez take plutôt que limit (ce sont des synonymes, mais take est la forme canonique), et placez-le après le sort by, faute de quoi vous obtenez un échantillon arbitraire non déterministe. Pour un skill de synthèse, take 1.
Réduire la largeur autant que la hauteur
Trente colonnes coûtent aussi cher que trente lignes. Projetez uniquement ce qui sert la décision. Remplacez les GUID par des noms lisibles, les timestamps redondants par une durée calculée. Utilisez make_set(Colonne, 8) pour condenser une liste en un échantillon borné plutôt que de multiplier les lignes.
Nommer les colonnes en clair
FailedCount vaut mieux que c, SpreadVelocity mieux que ratio2. Le modèle lit les noms de colonnes comme de la documentation — c'est littéralement le seul schéma dont il dispose. Ajoutez systématiquement une colonne textuelle interprétative (RiskBand, Verdict, Recommendation) : elle guide la formulation de la réponse et réduit les hallucinations de contexte.
Ne jamais renvoyer un vide muet
Comme montré plus haut, un résultat vide est ambigu. Si l'absence de résultat est une information légitime, matérialisez-la avec un union et un print, par exemple Verdict = "Aucune activite SMB anormale sur la fenetre analysee". Le coût est nul, le gain de fiabilité considérable.
Déploiement, tests et débogage
Import dans le portail
Rendez-vous sur le portail Security Copilot, ouvrez le menu Sources accessible depuis l'icône plugin de la barre de prompt, puis Ajouter un plugin / Custom. Chargez le fichier YAML, ou fournissez l'URL du manifeste. Renseignez ensuite les quatre valeurs demandées par le Descriptor.Settings : TenantId, SubscriptionId, ResourceGroupName, WorkspaceName. Choisissez enfin la portée : Personnel pour un test, Tenant pour une publication à l'ensemble des utilisateurs — cette seconde option requiert le rôle Copilot owner. Le détail des étapes et des options de portée est couvert dans notre guide plugins Security Copilot.
Tester dans le bon ordre
Ne testez jamais un skill directement depuis le prompt en langage naturel. Procédez par étapes croissantes :
- Dans Log Analytics d'abord. Remplacez les
{{param}}par des valeurs littérales et exécutez la requête dans le portail Sentinel. Vérifiez la syntaxe, le temps d'exécution et la volumétrie. Un skill ne doit pas dépasser quelques secondes. - Invocation directe ensuite. Dans Security Copilot, appelez le skill par son nom exact avec la syntaxe
/GetSuspiciousEntraSignIns, en fournissant les paramètres. Cela court-circuite l'orchestrateur et isole les erreurs d'exécution des erreurs de sélection. - Langage naturel enfin. Formulez la demande comme le ferait un analyste : « Est-ce que le compte [email protected] a eu des connexions suspectes ces 48 dernières heures ? ». Si le bon skill n'est pas retenu, le problème est dans la
Description, pas dans le KQL.
Lire le journal de traitement
Chaque réponse de Copilot expose un lien Afficher les détails du processus. Il révèle le skill sélectionné, les paramètres transmis et le résultat brut renvoyé. C'est votre principal outil de diagnostic : dans neuf cas sur dix, l'erreur est immédiatement visible dans les paramètres transmis — une fenêtre temporelle absurde, un UPN tronqué au prénom, un paramètre obligatoire vide.
Résultat brut : le JSON renvoyé au modèle
Voici, à titre d'exemple, la charge utile que le skill GetUserRiskSummary remonte à l'orchestrateur. C'est ce document, et lui seul, sur lequel le modèle raisonne pour produire sa réponse.
{
"skillName": "GetUserRiskSummary",
"status": "Succeeded",
"executionTimeMs": 1842,
"parameters": {
"userPrincipalName": "[email protected]",
"lookbackDays": "7"
},
"resultSchema": [
{ "name": "UserPrincipalName", "type": "string" },
{ "name": "RiskBand", "type": "string" },
{ "name": "RiskScore", "type": "real" },
{ "name": "OpenIncidents", "type": "long" },
{ "name": "RiskDetections", "type": "long" },
{ "name": "HighRiskDetections", "type": "long" },
{ "name": "DefenderAlerts", "type": "long" },
{ "name": "IncidentTitles", "type": "dynamic" },
{ "name": "RiskTypes", "type": "dynamic" },
{ "name": "AlertNames", "type": "dynamic" },
{ "name": "LastRiskAt", "type": "datetime" }
],
"rows": [
{
"UserPrincipalName": "[email protected]",
"RiskBand": "Eleve",
"RiskScore": 47.0,
"OpenIncidents": 1,
"RiskDetections": 3,
"HighRiskDetections": 1,
"DefenderAlerts": 2,
"IncidentTitles": [
"Connexion depuis une adresse IP anonyme"
],
"RiskTypes": [
"unfamiliarFeatures",
"anonymizedIPAddress",
"unlikelyTravel"
],
"AlertNames": [
"Suspicious inbox forwarding rule",
"Atypical travel"
],
"LastRiskAt": "2026-08-14T21:07:33.412Z"
}
],
"rowCount": 1,
"truncated": false
}
Observez la compacité : une ligne, onze colonnes, trois listes bornées. Le modèle dispose de tout le nécessaire pour produire une synthèse en trois phrases et proposer les actions suivantes — révocation de session, examen de la règle de transfert de boîte aux lettres, vérification du voyage improbable. Comparez mentalement avec ce qu'aurait donné un SecurityAlert | take 100 brut : plusieurs centaines de lignes de JSON, un champ Entities illisible, et une réponse approximative pour un coût en SCU bien supérieur.
Erreurs fréquentes
- « Failed to resolve table or column expression » : la table n'existe pas dans ce workspace, ou le connecteur correspondant n'est pas activé.
- « The user is not authorized » : rôle Azure manquant sur le workspace. Le plugin est correct, le RBAC de l'utilisateur ne l'est pas.
- Le skill n'est jamais sélectionné :
Descriptiontrop vague, ou trop proche de celle d'un plugin natif. Rendez-la spécifique et mentionnez explicitement le cas d'usage. - Erreur de parsing YAML à l'import : presque toujours l'indentation du bloc
Template: |-. Le KQL doit être indenté de manière strictement homogène sous le scalaire littéral. - Timeout : requête trop large. Ajoutez un filtre temporel plus tôt dans le pipeline et déplacez les
whereles plus sélectifs en tête.
Gouvernance : rôles, portée et cycle de vie
Un plugin custom exécute du code sur vos données de sécurité et influence des décisions d'investigation. Il mérite le même niveau de contrôle qu'une règle de détection analytique.
Les deux rôles de la plateforme
Security Copilot ne définit que deux rôles, qui ne sont pas des rôles Entra ID : Copilot owner et Copilot contributor. Ils ne donnent par eux-mêmes accès à aucune donnée de sécurité ; ils contrôlent uniquement les capacités de la plateforme.
Concernant les plugins custom, la répartition est la suivante : l'owner peut gérer les plugins personnels et publier un plugin pour l'ensemble du tenant. Le contributor, par défaut, ne peut pas gérer de plugin personnel — un owner doit activer explicitement le paramètre « Allow contributors to manage personal custom plugins ». La publication à l'échelle du tenant reste, elle, exclusivement réservée aux owners. C'est une séparation saine : l'ingénierie de détection développe et teste en portée personnelle, la publication tenant passe par une validation formelle.
Les rôles Azure restent souverains
Rappelons-le car c'est structurant : Security Copilot applique une authentification on-behalf-of. Un analyste disposant du rôle Copilot contributor mais dépourvu de Microsoft Sentinel Reader sur le workspace n'obtiendra aucune donnée. Le plugin n'élargit jamais les droits, ce qui écarte tout risque d'escalade de privilège par ce vecteur. Corollaire opérationnel : ne testez pas vos plugins avec un compte administrateur global, vous ne verrez jamais les erreurs d'autorisation que rencontreront vos utilisateurs réels.
Cycle de vie recommandé
- Dépôt Git dédié pour les manifestes et les fichiers
.kql, avec propriétaires de code définis sur le répertoireskills/. - Revue obligatoire par un second ingénieur de détection, avec une checklist explicite : paramètres liés dans un
let, types coercés, fenêtre bornée,takeprésent, absence de vide muet, description non ambiguë. - Publication tenant uniquement après validation en portée personnelle par au moins deux analystes sur des cas réels.
- Revue trimestrielle : un skill dont la table sous-jacente a changé de schéma échoue silencieusement pour l'analyste, qui cessera simplement de l'utiliser sans le signaler à personne.
- Suivi de consommation SCU par plugin via le tableau de bord d'usage, accessible aux owners.
À retenir
- Un plugin KQL custom transforme une requête de chasse en compétence gouvernée : versionnée dans Git, relue, paramétrée et partagée — là où un KQL collé dans le prompt dérive et n'est pas auditable.
- La structure du manifeste est
Descriptor+SkillGroupsavecFormat: KQL; pour Sentinel, chaque skill déclareTarget: Sentinelet les quatre coordonnées du workspace. Le KQL vit dansSettings.Template,TemplateUrlouTemplateFile, dans la limite de 80 000 caractères. - Le champ
Descriptiondétermine si le skill est sélectionné par l'orchestrateur. C'est le champ le plus important du manifeste, avant le KQL lui-même. - L'interpolation
{{param}}est un remplacement de chaîne littéral, sans binding serveur. Liez systématiquement chaque paramètre dans unlettypé en tête de requête, coercez les types avectoint()/todatetime(), bornez les fenêtres, et n'interpolez jamais un nom de table. - Un paramètre invalide ne doit pas produire un résultat vide : ajoutez un court-circuit explicite renvoyant une ligne de diagnostic, sinon Copilot conclura à tort « rien à signaler ».
- Optimisez pour le modèle, pas pour l'œil humain :
summarizesystématique,take 15à25après tri, colonnes peu nombreuses et explicitement nommées, plus une colonne interprétative de typeRiskBand. - Testez dans l'ordre : Log Analytics avec valeurs littérales, puis invocation directe
/NomDuSkill, puis langage naturel. Le journal de traitement révèle les paramètres réellement transmis. - Gouvernance : les rôles sont Copilot owner et Copilot contributor. Seul l'owner publie pour le tenant ; le contributor ne gère des plugins personnels que si l'owner l'a autorisé. Les rôles Azure (
Microsoft Sentinel Reader) restent indispensables — le plugin n'élargit jamais les droits.
FAQ : quelles sont les questions les plus fréquentes sur les plugins KQL custom ?
Peut-on utiliser un plugin KQL custom sans licence ni capacité SCU provisionnée ?
Non. L'exécution d'un skill custom consomme des Security Compute Units au même titre qu'un skill natif, et l'accès à la plateforme Security Copilot suppose une capacité provisionnée et rattachée au workspace. Un plugin KQL ne réduit pas le coût unitaire d'une session, mais il le rend prévisible : la requête est cadrée, le jeu de résultats est borné, et le modèle n'a pas besoin de plusieurs allers-retours pour comprendre l'intention. En pratique, remplacer un échange en trois tours par un appel de skill unique réduit sensiblement la consommation sur les usages répétitifs. Notre article sur le dimensionnement des SCU détaille les ordres de grandeur et la méthode de calcul.
Comment gérer un paramètre optionnel dans une requête KQL sans casser la syntaxe ?
N'essayez jamais de construire la requête par concaténation conditionnelle : l'interpolation est purement textuelle et vous n'avez aucun moyen d'omettre une clause. La bonne approche consiste à écrire une clause toujours présente mais neutralisable, du type | where isempty(monFiltre) or Colonne == monFiltre. Quand le paramètre est vide, la condition est vraie pour toutes les lignes et le filtre disparaît logiquement sans altérer la syntaxe. Pour les valeurs numériques, coalesce(toint("{{param}}"), valeurParDefaut) joue le même rôle. Déclarez le paramètre avec Required: false dans le bloc Inputs et documentez la valeur par défaut dans sa Description, afin que le modèle sache qu'il peut légitimement l'omettre.
Quelle est la différence entre un plugin KQL et un plugin API ou GPT dans Security Copilot ?
Les trois formats répondent à des besoins distincts. Le format KQL exécute une requête Kusto contre un endpoint de données — Sentinel, Defender, Log Analytics, Sentinel Data Lake ou un cluster Azure Data Explorer — et renvoie un jeu de résultats tabulaire. Le format API appelle un service REST externe, avec gestion de l'authentification, et convient pour interroger une plateforme tierce comme un outil de ticketing ou un service de threat intelligence. Le format GPT ne fait aucun appel de données : il fournit une instruction de raisonnement réutilisable au modèle, utile pour standardiser un format de rapport ou une méthode d'analyse. Rien n'interdit de combiner les trois dans un même manifeste, en déclarant plusieurs entrées dans SkillGroups avec des Format différents.
Un plugin custom peut-il accéder à des données auxquelles l'analyste n'a pas droit ?
Non, et c'est une garantie architecturale, pas une simple bonne pratique. Security Copilot fonctionne en authentification on-behalf-of : la requête est exécutée avec le jeton de l'utilisateur connecté. Pour une cible Sentinel, SupportedAuthTypes vaut None — il n'y a ni service principal, ni secret, ni identité applicative stockée dans le plugin. Si l'analyste ne dispose pas du rôle Microsoft Sentinel Reader sur le workspace, le skill renvoie une erreur d'autorisation, quelle que soit la manière dont le manifeste est rédigé. Conséquence pratique importante pour vos tests : validez toujours un plugin avec un compte disposant des droits réels d'un analyste de niveau 1, jamais avec un compte administrateur global.
Comment versionner et déployer les plugins KQL de façon automatisée ?
Conservez les manifestes YAML et les fichiers .kql dans un dépôt Git, avec un répertoire skills/ et des propriétaires de code exigeant la revue d'un ingénieur de détection. Deux stratégies de distribution existent. La première utilise PackageUrl au niveau du SkillGroup : votre pipeline construit un zip contenant le manifeste et les requêtes, le publie sur une URL accessible, et le plugin y pointe. La seconde effectue une substitution des TemplateFile par des blocs Template inline au moment de la construction, produisant un YAML unique auto-portant. La seconde évite d'exposer publiquement vos requêtes — un point à trancher explicitement si vos KQL contiennent des noms d'actifs sensibles. Dans les deux cas, ajoutez une étape de validation qui exécute chaque requête avec des paramètres de test contre un workspace de recette avant toute publication en production.
Pour aller plus loin
Le plugin décrit ici constitue un socle. Les extensions naturelles sont l'ajout d'un skill de contexte sur les actifs critiques adossé à une watchlist Sentinel, l'intégration des quatre skills dans un promptbook de triage exécutable en un clic, et la couverture d'autres cibles que Sentinel — le même format SkillGroups accepte Defender, LogAnalytics, SentinelDataLake ou Kusto, ce qui permet d'unifier des sources hétérogènes derrière une interface unique en langage naturel.
Ressources complémentaires : la documentation Microsoft sur les plugins KQL pour Security Copilot, le référentiel du langage KQL, et les rôles et permissions Microsoft Sentinel. Côté interne, notre dossier Microsoft Security Copilot et le guide dédié aux plugins couvrent l'écosystème complet.
``` **Conformité SEO :** chapeau 176 mots (premier ``) · 13 `
` · ~3 500 mots · 8 blocs de code (5 KQL, 1 YAML, 1 JSON, 1 anti-pattern) · 6 liens internes (`/copilot-security` ×2, `/copilot-security/plugins` ×2, `/articles/copilot-security-scu-dimensionnement-compute-units` ×2, `/diagnostic-nis-2`) · 3 liens externes · FAQ H2 + 5 H3 tous interrogatifs · `.a-retenir` avec H3 (pas de H4). **Deux écarts assumés par rapport au brief :** - Le rôle demandé, « Copilot Security Contributor », n'existe pas — la doc actuelle définit *Copilot owner* / *Copilot contributor*. J'ai utilisé la nomenclature réelle et signalé le renommage produit dans un paragraphe liminaire. - J'ai ajouté `/diagnostic-nis-2` comme 4e lien interne pour atteindre le seuil de 4 exigé par le moteur SEO ; les 3 slugs du brief ne suffisaient pas. Écriture fichier et Bash sont bloqués dans cette session non interactive — je n'ai donc pas pu déposer le HTML sur disque ni l'insérer en base. Dites-moi si vous voulez que je le fasse dans une session interactive.
À propos de l'auteur
Ayi NEDJIMI
Auditeur Senior Cybersécurité & Consultant IA
Expert Judiciaire — Cour d'Appel de Paris
Habilitation Confidentiel Défense
[email protected]
Ayi NEDJIMI est un vétéran de la cybersécurité avec plus de 25 ans d'expérience sur des missions critiques. Ancien développeur Microsoft à Redmond sur le module GINA (Windows NT4) et co-auteur de la version française du guide de sécurité Windows NT4 pour la NSA.
À la tête d'Ayi NEDJIMI Consultants, il réalise des audits Lead Auditor ISO 42001 et ISO 27001, des pentests d'infrastructures critiques, du forensics et des missions de conformité NIS2 / AI Act.
Conférencier international (Europe & US), il a formé plus de 10 000 professionnels.
Domaines d'expertise
Ressources & Outils de l'auteur
Articles connexes
Publier et distribuer ses plugins Copilot Security : GitHub, équipe et marketplace
Anatomie d'un plugin Copilot Security : structure YAML, skills et manifest
Microsoft Copilot Security : dimensionner ses Security Compute Units (SCU)
La question du budget est systématiquement la première posée par les RSSI, bien avant celle de l'intégration technique ou de la couverture fonctionnelle : « combien va me coûter Copilot Security en régime de croisière ? ». Cette interrogation n'a rien de trivial, car Microsoft a retenu pour cette pl
Un projet cybersécurité ? Parlons-en.
Pentest, conformité NIS 2, ISO 27001, audit IA, RSSI externalisé… nos experts répondent sous 24h pour évaluer votre besoin et vous proposer un accompagnement sur mesure.
Commentaires
Aucun commentaire pour le moment. Soyez le premier à commenter !
Laisser un commentaire