Auditer ses service principals Azure en 30 minutes
Une méthode en lecture seule pour inventorier les secrets, les droits et les connexions des service principals, avec les scripts PowerShell, les requêtes KQL et les prompts à copier.
- Microsoft Entra ID
- Azure
- Identités non humaines
- PowerShell
- KQL
Pourquoi : 7 minutes, aucune faille, que des droits légitimes
En juin 2026, deux service principals compromis ont supprimé la majorité de plus de 100 comptes de stockage, un Key Vault, une Function App et un App Service plan dans un seul tenant Azure. La phase destructrice a duré environ 7 minutes, après plus de 15 heures de reconnaissance. Microsoft a publié l'analyse le 25 septembre 2026 sous le nom Storm-3168.
Aucune CVE n'a été exploitée. Chaque suppression était autorisée par les rôles déjà attribués aux service principals : Storage Account Contributor via un groupe, Contributor en direct, SQL DB Contributor en direct. L'outil a appliqué la gouvernance. Elle n'existait pas.
| Chiffre | Ce qu'il dit | Source |
|---|---|---|
| 100+ comptes de stockage ciblés en ~7 min | La réponse humaine arrive trop tard | Microsoft, WorkOS |
| 15,5 h de reconnaissance, 300+ lectures réussies | La détection avait des heures pour agir | CSA |
| 30+ clés de comptes de stockage récupérées via ListKeys | L'accès survit à la rotation du secret | WorkOS |
| 31 % des points d'entrée = NHI compromises, contre 17 % pour le phishing | Les identités non humaines sont devenues la porte n° 1 | SpyCloud, enquête 750 répondants |
| 95 % pensent avoir la visibilité, 36 % surveillent | L'écart que les attaquants exploitent | SpyCloud |
Les 3 erreurs courantes
- Croire qu'un secret supprimé est révoqué. Le secret avait été retiré de l'issue GitHub, mais il restait lisible dans l'historique des modifications. Il fonctionnait toujours.
- Donner Contributor « pour que ça marche ». Un compte qui ne fait que lire ne devrait pas pouvoir supprimer, y compris via l'appartenance à un groupe.
- Ne surveiller que les humains. Les sign-ins des service principals depuis une infrastructure inconnue étaient le signal, des heures avant les suppressions.
Le changement à opérer
Traiter chaque service principal comme une identité à part entière : un propriétaire, un besoin documenté, des droits minimaux, un secret qui expire (ou pas de secret du tout), et une surveillance de ses connexions.
3 cas d'usage qu'on retrouve dans toutes les PME et ETI
Chaque cas part d'une situation réelle de terrain, puis donne le contrôle qui l'aurait arrêtée.
Cas 1 : le secret qui a fuité « mais on l'a supprimé »
Situation. Un développeur colle un client ID, un secret et un tenant ID dans un ticket, un canal Teams ou une issue GitHub pour débloquer un collègue. Quelqu'un le remarque et efface le message.
Pourquoi c'est un problème. Effacer le message change l'endroit où le secret est visible. Pas sa validité. Entra ID l'échange contre un jeton tant qu'il n'est pas révoqué, et les jetons déjà émis restent valides jusqu'à leur expiration. Dans Storm-3168, Microsoft a vu 5 jetons distincts émis pour le service principal destructeur.
Le contrôle. Révocation du secret le jour même, puis revue des sign-ins et de l'activité Azure sur toute la période d'exposition. À terme : identité managée ou fédération d'identité de charge de travail, donc plus de secret à faire fuiter.
Cas 2 : le service principal « Contributor parce que ça marchait pas »
Situation. Un pipeline CI/CD ou un outil de sauvegarde a besoin d'écrire dans un compte de stockage. Le déploiement échoue, quelqu'un met Contributor sur l'abonnement, ça passe, on n'y touche plus.
Pourquoi c'est un problème. Le jour où ce secret fuit, l'attaquant hérite de tout le périmètre. Dans Storm-3168, les rôles en place couvraient chaque suppression, et seule une erreur de version d'API a sauvé les bases SQL.
Le contrôle. Rôle le plus restreint au plus petit périmètre (resource group ou ressource), verrous CanNotDelete et protection contre la suppression sur les comptes de stockage critiques.
Cas 3 : le service principal orphelin du prestataire
Situation. Une intégration posée par un prestataire il y a trois ans. Le prestataire est parti, le propriétaire de l'app aussi. Le secret n'expire jamais, ou dans 2 ans.
Pourquoi c'est un problème. Personne ne sait à quoi il sert, donc personne n'ose le supprimer, et personne ne regarde ses connexions.
Le contrôle. Un propriétaire nommé pour chaque app registration, une expiration de secret courte, et une désactivation test avant suppression : on coupe, on attend une semaine, on écoute qui se plaint.
La méthode : 5 étapes, 30 minutes, lecture seule
Tout se fait sans rien modifier. Rôles suffisants : Global Reader dans Entra ID, Reader sur les abonnements Azure. Les scripts et requêtes sont dans la section suivante.
- Inventorier les identités qui ont un secret ou un certificat (5 min). Lister chaque app registration avec ses credentials, leur date d'expiration et ses propriétaires. Script 1.
- Isoler les secrets à risque (5 min). Secrets valides plus de 12 mois, apps sans propriétaire, apps avec plusieurs secrets actifs. C'est votre liste de rotation. Script 1, colonnes filtrées.
- Cartographier les droits Azure (10 min). Pour chaque service principal : Owner, Contributor ou User Access Administrator au niveau abonnement, y compris via un groupe. C'est votre liste de réduction de privilèges. Script 2.
- Lire les connexions (5 min). Service principals qui se connectent depuis des IP jamais vues, et service principals sans connexion depuis 90 jours. Requêtes KQL 1 et 2.
- Vérifier les verrous (5 min). Les comptes de stockage, Key Vaults et coffres de sauvegarde critiques ont-ils un verrou CanNotDelete ? Script 3.
Avant / après
| Point de contrôle | Avant (constat type) | Après l'audit |
|---|---|---|
| Secrets | Durée de vie longue, personne ne sait où ils sont copiés | Expiration courte, rotation planifiée, secrets remplacés par identité managée ou fédération quand c'est possible |
| Propriétaires | App registrations sans propriétaire ou avec un ancien salarié | Un propriétaire nommé et joignable par app |
| Droits Azure | Contributor sur l'abonnement « pour que ça marche » | Rôle minimal sur le resource group ou la ressource |
| Ressources critiques | Supprimables par toute identité Contributor | Verrou CanNotDelete + protection contre la suppression des comptes de stockage |
| Surveillance | Sign-ins humains surveillés, service principals ignorés | Alerte sur IP inconnue et sur pic de lectures ou de suppressions |
| Incident de fuite | « On a supprimé le message » | Révocation immédiate + revue des logs sur toute la période d'exposition |
Les modèles à copier
Tous les scripts sont en lecture seule. Testez-les d'abord sur un tenant de test ou un abonnement hors production.
Script 1 : inventaire des secrets, expirations et propriétaires (Microsoft Graph PowerShell)
Connect-MgGraph -Scopes "Application.Read.All"
$apps = Get-MgApplication -All -Property Id,AppId,DisplayName,PasswordCredentials,KeyCredentials
$now = Get-Date
$report = foreach ($app in $apps) {
$owners = (Get-MgApplicationOwner -ApplicationId $app.Id -All |
ForEach-Object {
$p = $_.AdditionalProperties
if ($p.userPrincipalName) { $p.userPrincipalName } else { "$($p.displayName) (SP)" }
}) -join ";"
foreach ($cred in @($app.PasswordCredentials) + @($app.KeyCredentials)) {
if (-not $cred) { continue }
[pscustomobject]@{
App = $app.DisplayName
AppId = $app.AppId
Type = if ($cred.PSObject.Properties.Name -contains 'Hint') { 'Secret' } else { 'Certificat' }
DebutValidite = $cred.StartDateTime
Expiration = $cred.EndDateTime
DureeJours = [int]($cred.EndDateTime - $cred.StartDateTime).TotalDays
Expire = $cred.EndDateTime -lt $now
Proprietaires = if ($owners) { $owners } else { 'AUCUN' }
}
}
}
$report | Sort-Object DureeJours -Descending |
Export-Csv .\audit-spn-secrets.csv -NoTypeInformation -Encoding UTF8
À regarder en premier : DureeJours supérieur à 365, Proprietaires égal à AUCUN, plusieurs secrets actifs sur la même app.
Script 2 : rôles Azure à privilège élevé détenus par des service principals (Az PowerShell)
Connect-AzAccount
$rolesSensibles = 'Owner','Contributor','User Access Administrator'
$result = foreach ($sub in Get-AzSubscription) {
Set-AzContext -SubscriptionId $sub.Id | Out-Null
Get-AzRoleAssignment |
Where-Object { $_.RoleDefinitionName -in $rolesSensibles -and
$_.ObjectType -in 'ServicePrincipal','Group' } |
Select-Object @{n='Abonnement';e={$sub.Name}}, DisplayName, ObjectType,
RoleDefinitionName, Scope
}
$result | Export-Csv .\audit-spn-roles.csv -NoTypeInformation -Encoding UTF8
Les lignes de type Group comptent : dans Storm-3168, la suppression des comptes de stockage passait par un rôle hérité d'un groupe. Vérifiez quels service principals sont membres de ces groupes.
Script 3 : comptes de stockage et Key Vaults sans verrou CanNotDelete (Az PowerShell)
Connect-AzAccount
foreach ($sub in Get-AzSubscription) {
Set-AzContext -SubscriptionId $sub.Id | Out-Null
$locks = Get-AzResourceLock | Where-Object { $_.Properties.level -eq 'CanNotDelete' }
$scopes = $locks | ForEach-Object { ($_.ResourceId -split '/providers/Microsoft.Authorization/locks/')[0] }
Get-AzResource -ResourceType 'Microsoft.Storage/storageAccounts','Microsoft.KeyVault/vaults' |
Where-Object {
$id = $_.ResourceId
-not ($scopes | Where-Object { $id -eq $_ -or $id -like "$_/*" })
} |
Select-Object @{n='Abonnement';e={$sub.Name}}, Name, ResourceType, ResourceGroupName
}
Un verrou posé sur le resource group ou l'abonnement couvre aussi la ressource : le script en tient compte.
Requête KQL 1 : service principals qui se connectent depuis une IP jamais vue (Log Analytics / Sentinel)
Prérequis : les logs ServicePrincipalSignInLogs envoyés vers Log Analytics via les paramètres de diagnostic Entra.
let reference = AADServicePrincipalSignInLogs
| where TimeGenerated between (ago(30d) .. ago(1d))
| distinct ServicePrincipalId, IPAddress;
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(1d)
| where ResultType == "0"
| join kind=leftanti reference on ServicePrincipalId, IPAddress
| summarize Connexions = count(), Premiere = min(TimeGenerated)
by ServicePrincipalName, ServicePrincipalId, IPAddress
| order by Connexions desc
Requête KQL 2 : service principals silencieux depuis 90 jours
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(180d)
| summarize DerniereConnexion = max(TimeGenerated) by ServicePrincipalName, ServicePrincipalId
| where DerniereConnexion < ago(90d)
| order by DerniereConnexion asc
Limite : un service principal qui ne s'est jamais connecté n'apparaît pas. Croisez avec l'export du script 1.
Requête KQL 3 : le motif Storm-3168 (rafale de suppressions et de ListKeys)
AzureActivity
| where TimeGenerated > ago(1d)
| where OperationNameValue endswith "/DELETE"
or OperationNameValue =~ "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION"
| where ActivityStatusValue in ("Success", "Start")
| summarize Operations = count(), Ressources = dcount(_ResourceId)
by Caller, bin(TimeGenerated, 10m)
| where Operations > 10
| order by Operations desc
Caller contient l'identifiant de l'identité appelante. Ajustez le seuil de 10 opérations en 10 minutes à votre volume normal.
Prompt 1 : prioriser les résultats de l'audit
Tu es un ingénieur IAM senior. Voici deux exports CSV d'un tenant Microsoft Entra / Azure :
1) audit-spn-secrets.csv (secrets, expirations, propriétaires)
2) audit-spn-roles.csv (rôles Azure à privilège élevé détenus par des service principals ou des groupes)
Croise les deux fichiers par nom d'application. Classe chaque service principal en 3 niveaux :
- Critique : rôle Owner/Contributor/User Access Administrator ET (secret > 365 jours OU aucun propriétaire)
- Élevé : un seul des deux critères
- À surveiller : le reste
Pour chaque critique, propose l'action la plus simple qui réduit le risque cette semaine.
Réponds en tableau. N'invente aucune donnée absente des fichiers.
Prompt 2 : réduire les droits d'un service principal
Ce service principal a le rôle [RÔLE] sur [PORTÉE]. Il sert à : [USAGE EN UNE PHRASE].
Voici ses 30 derniers jours d'opérations AzureActivity : [COLLER L'EXPORT].
Propose le rôle intégré Azure le plus restreint qui couvre exactement ces opérations,
à la portée la plus petite possible. Si aucun rôle intégré ne convient, propose une
définition de rôle personnalisé en JSON. Liste les opérations observées que ton rôle
n'autoriserait plus, pour que je puisse les valider avant d'appliquer.
Prompt 3 : runbook « un secret a fuité »
Un secret de l'application [NOM / APP ID] a été exposé publiquement le [DATE] sur [LIEU].
Rédige un runbook d'intervention en étapes numérotées pour une équipe IT de PME :
révocation du secret, vérification des jetons déjà émis, revue des sign-ins du service
principal et de l'activité Azure sur la période d'exposition, verrouillage temporaire
des ressources critiques, communication interne. Pour chaque étape, indique le rôle
Entra ou Azure minimal requis et la commande Azure CLI ou PowerShell correspondante.
Avec quoi le faire tourner
L'audit de cette ressource tourne avec des outils que vous avez déjà. La surveillance continue des service principals, elle, demande une licence dédiée chez Microsoft.
| Niveau | Outil | Ce qu'il apporte | Coût |
|---|---|---|---|
| Natif | Microsoft Graph PowerShell, Az PowerShell, Azure CLI | Inventaire, rôles, verrous, rotation des secrets (scripts 1 à 3) | Inclus |
| Natif | Log Analytics / Microsoft Sentinel | Requêtes KQL 1 à 3, alertes sur les sign-ins des service principals | Facturé au volume ingéré |
| Natif | Microsoft Entra Workload ID Premium | Accès conditionnel pour les identités de charge de travail (blocage par IP ou par risque), ID Protection pour les service principals | Licence dédiée, essai gratuit de 90 jours |
| Open source | Maester, ROADtools, AzureHound | Tests de configuration Entra automatisés, cartographie des chemins d'attaque | Gratuit |
| Éditeurs | Plateformes de gouvernance des identités non humaines | Inventaire multi-cloud et SaaS, détection des secrets exposés | Sur devis |
Trois points sur Workload ID Premium, d'après la FAQ Microsoft :
- Une seule licence dans le tenant débloque les fonctions pour toutes les identités de charge de travail, sans attribution individuelle.
- L'accès conditionnel ne couvre que les applications mono-tenant. Les apps Microsoft et les identités managées en sont exclues.
- Sans licence, les politiques existantes continuent de fonctionner mais ne peuvent plus être modifiées (documentation).
L'outil applique la gouvernance. Il ne l'invente pas. Commencez par les scripts : la licence n'a de valeur que si vous savez déjà quels service principals protéger.
La suite
Je publie chaque semaine des cas d'usage IAM et Identity Security prêts à appliquer : Entra ID, Active Directory, identités non humaines, NIS2. Suivez-moi sur LinkedIn pour recevoir le prochain.
Sources
- Microsoft Security Blog, Storm-3168: Agentic-driven cloud attacks using compromised service principals (25 septembre 2026)
- WorkOS, Storm-3168 explained (2 octobre 2026)
- Cloud Security Alliance, Storm-3168: Agentic Cloud Attacks on Azure Identities (28 septembre 2026)
- SpyCloud, 2026 Identity Threat Report (9 septembre 2026, enquête auprès de 750 responsables sécurité)
- Microsoft Learn, FAQ Microsoft Entra Workload ID
- Microsoft Learn, Conditional Access for workload identities
Ressources
Un script qui ne passe pas sur votre tenant, un résultat à interpréter ?