Le poste a disparu, mais Defender le montre encore
Vous avez réinstallé un ordinateur ou supprimé son ancien objet Active Directory. Pourtant, sa fiche reste dans Microsoft Defender for Endpoint (MDE). Avant de chercher un bouton Supprimer, posez la bonne question : voulez-vous retirer un appareil du service, corriger le périmètre des vulnérabilités ou simplement comprendre pourquoi deux fiches portent le même nom? Ce sont trois opérations différentes.
Ce guide propose un inventaire PowerShell en lecture seule, une comparaison prudente avec AD et un marquage facultatif après approbation. Aucun exemple ne supprime un objet AD, ne désactive automatiquement la protection ou ne prétend forcer le statut Inactive. Les noms et tickets utilisés sont fictifs; ce n’est pas un récit client.
La règle de départ est simple : absent d’AD et silencieux depuis 30 jours signifie à examiner, pas à supprimer. Un portable en réparation, un poste migré vers Entra ID ou un capteur qui ne communique plus peut produire le même résultat qu’un appareil retiré définitivement.
Inactif, exclu et offboardé : choisir la bonne action
L’exclusion d’un appareil des vues de vulnérabilités n’est ni une exclusion antivirus de fichier ou de processus, ni une correction de la vulnérabilité. Elle ne fait pas disparaître la fiche de l’inventaire. N’utilisez pas une exclusion pour améliorer artificiellement un indicateur sur un appareil encore exploité.
| Action ou état | Ce que cela signifie | Usage prudent |
|---|---|---|
| Inactive | État de santé lié à l’absence de communication | Rechercher la cause; ne pas le traiter comme une preuve de retrait. |
| Étiquette personnalisée | Classement et filtrage des appareils | Identifier une file de révision sans masquer les risques. |
| Exclude | Retrait du périmètre de gestion des vulnérabilités | Réserver aux fiches réellement obsolètes ou hors périmètre, avec justification. |
| Offboarding | Arrêt de la remontée des données MDE pour l’appareil | Retrait autorisé d’un appareil encore joignable; suivre le résultat. |
| Suppression dans AD, Entra ou Intune | Changement dans un autre inventaire | Procédure distincte; ne remplace pas l’offboarding MDE. |
Pourquoi les anciennes fiches restent visibles
Microsoft indique un passage à Inactive après sept jours sans signaux et une conservation pouvant atteindre 180 jours. La gestion des vulnérabilités a une autre fenêtre : après 30 jours sans remontée, les vulnérabilités associées cessent d’être affichées; les appareils sans activité depuis 30 jours ne contribuent plus au score d’exposition. Une fiche visible ne signifie donc pas qu’elle pénalise encore ce score.
Le champ lastSeen de l’API Machines correspond au dernier rapport complet reçu, généralement quotidien, et ne correspond pas nécessairement à la valeur Dernière activité du portail. Comparez des dates UTC et conservez une marge. Ne déclenchez pas une action à quelques minutes près autour d’un seuil.
Un même nom peut désigner plusieurs installations successives. Le DeviceId MDE, l’aadDeviceId et l’objectGUID AD ne sont pas interchangeables. Gardez le DeviceId de chaque fiche dans le dossier de changement; ne ciblez jamais une opération d’écriture uniquement avec le nom court ou l’adresse IP.
Définir le périmètre et séparer les permissions
Exécutez l’inventaire depuis un poste d’administration Windows avec PowerShell 7, le module ActiveDirectory de RSAT et une version récente d’Az.Accounts. Le compte Windows doit pouvoir lire tous les domaines AD concernés. L’identité de l’application MDE est distincte de ce compte Windows.
Dans Entra ID, créez une application dédiée et ajoutez les permissions d’application de WindowsDefenderATP, avec consentement administrateur. Commencez par Machine.Read.All. Si un marquage est approuvé, utilisez une identité séparée avec Machine.ReadWrite.All; réservez Machine.Offboard à une procédure de retrait distincte. Les droits Azure sur un abonnement ne remplacent pas les permissions de cette API.
Utilisez un certificat dont la clé privée est protégée et dont la partie publique est inscrite sur l’application. Pour une exécution hébergée dans Azure, une identité managée correctement autorisée est une autre option. Ne stockez pas de secret dans un script ou un dépôt et ne journalisez jamais les jetons. Les exemples ciblent le nuage commercial; adaptez les autorités et points de terminaison pour un nuage souverain.
Obtenir le jeton sans exposer de secret
Les API sont appelées sur api.security.microsoft.com, mais Microsoft précise que certaines attendent encore un jeton destiné à la ressource historique api.securitycenter.microsoft.com. Le bloc ci-dessous utilise cette audience. Un jeton Microsoft Graph n’est pas un remplacement.
Remplacez les trois valeurs entre chevrons. Le certificat doit être accessible au compte qui exécute PowerShell. Get-AzAccessToken retourne un SecureString dans les versions récentes; il est transmis directement à Invoke-RestMethod. Renouvelez le jeton si le traitement dépasse sa validité, sans enregistrer son contenu dans le rapport.
# PowerShell 7 on Windows; recent Az.Accounts module.
# Register the public certificate on the app; protect its private key locally.
Import-Module Az.Accounts -ErrorAction Stop
Disable-AzContextAutosave -Scope Process | Out-Null
$connection = @{
ServicePrincipal = $true
Tenant = '<TENANT-ID>'
ApplicationId = '<READ-ONLY-APP-ID>'
CertificateThumbprint = '<CERTIFICATE-THUMBPRINT>'
SkipContextPopulation = $true
Scope = 'Process'
ErrorAction = 'Stop'
}
Connect-AzAccount @connection | Out-Null
$tokenRequest = @{
ResourceUrl = 'https://api.securitycenter.microsoft.com'
TenantId = $connection.Tenant
ErrorAction = 'Stop'
}
$token = (Get-AzAccessToken @tokenRequest).Token
if ($token -isnot [securestring]) {
throw 'Update Az.Accounts: a SecureString token is required.'
}
# Never print the token or include it in a transcript.Inventorier MDE et comparer avec AD sans rien modifier
Exécutez ce bloc après l’authentification, dans la même session. Il lit tous les appareils accessibles par l’API Machines avec pagination explicite, puis produit un CSV. Il conserve les ordinateurs AD désactivés comme présents et traite une correspondance de nom court comme un motif de conservation : c’est volontairement conservateur.
AdServers doit couvrir chaque domaine pertinent, sans restriction à une seule OU. DnsSuffixes limite les candidats aux noms complets attendus. MinimumAdCount doit refléter un plancher approuvé pour votre parc, pas une valeur copiée aveuglément. Une collecte vide, un échec de domaine ou une page API invalide interrompt le rapport plutôt que de faire passer des appareils pour absents.
L’exemple utilise des pages de 1 000 appareils, alors que l’API accepte jusqu’à 10 000. Il espace les pages et borne les reprises HTTP. Les limites documentées sont de 100 appels par minute et 1 500 par heure pour cette API. En cas de 429, PowerShell tient compte de Retry-After. Une erreur persistante doit faire échouer le traitement; ne poursuivez pas avec un inventaire partiel.
function Get-MdeRetirementReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)][securestring]$Token,
[Parameter(Mandatory)][string[]]$AdServers,
[Parameter(Mandatory)][string[]]$DnsSuffixes,
[ValidateRange(7,180)][int]$InactiveDays = 30,
[ValidateRange(1,10000000)][int]$MinimumAdCount = 1
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory -ErrorAction Stop
$now = [datetimeoffset]::UtcNow
$cutoff = $now.AddDays(-$InactiveDays)
$adNames = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::OrdinalIgnoreCase)
$adShortNames = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::OrdinalIgnoreCase)
$adCount = 0
# Query the WHOLE domain for every declared domain, including disabled PCs.
foreach ($server in $AdServers) {
$domainDn = (Get-ADDomain -Server $server).DistinguishedName
if (-not $domainDn) { throw "Missing AD domain root: $server" }
$items = @(Get-ADComputer -Server $server -SearchBase $domainDn -SearchScope Subtree -Filter * -Properties DNSHostName)
if ($items.Count -eq 0) { throw "Empty AD inventory: $server" }
$adCount += $items.Count
foreach ($item in $items) {
if ($item.DNSHostName) {
[void]$adNames.Add($item.DNSHostName.Trim().TrimEnd('.'))
}
[void]$adShortNames.Add($item.Name)
}
}
if ($adCount -lt $MinimumAdCount) { throw 'AD inventory below approved baseline.' }
$machines = [Collections.Generic.List[object]]::new()
$ids = [Collections.Generic.HashSet[string]]::new()
$pageSize = 1000
$skip = 0
do {
if ($skip -ge 200000) { throw 'Inventory cap reached; review paging strategy.' }
$uri = 'https://api.security.microsoft.com/api/machines?$top={0}&$skip={1}' -f $pageSize,$skip
$request = @{
Uri = $uri; Method = 'Get'; Authentication = 'Bearer'; Token = $Token
MaximumRetryCount = 3; RetryIntervalSec = 5; TimeoutSec = 60
MaximumRedirection = 0; ErrorAction = 'Stop'
}
$page = Invoke-RestMethod @request
if ($null -eq $page.value) { throw 'Missing value collection; report aborted.' }
$batch = @($page.value)
foreach ($machine in $batch) {
if (-not $machine.id) { throw 'Missing MDE device ID.' }
if (-not $ids.Add($machine.id)) {
throw 'Repeated device ID while paging; rerun the inventory.'
}
$machines.Add($machine)
}
$skip += $pageSize
if ($batch.Count -eq $pageSize) { Start-Sleep -Seconds 3 }
} while ($batch.Count -eq $pageSize)
if ($machines.Count -eq 0) { throw 'Empty MDE inventory; do not infer retirement.' }
$nameCounts = @{}
foreach ($machine in $machines) {
$name = ([string]$machine.computerDnsName).Trim().TrimEnd('.').ToLowerInvariant()
if (-not $nameCounts.ContainsKey($name)) { $nameCounts[$name] = 0 }
$nameCounts[$name]++
}
$report = foreach ($machine in $machines) {
$name = ([string]$machine.computerDnsName).Trim().TrimEnd('.').ToLowerInvariant()
$shortName = ($name -split '\.')[0]
$inScope = $false
foreach ($suffix in $DnsSuffixes) {
$domain = $suffix.Trim().Trim('.').ToLowerInvariant()
if ($domain -and $name.EndsWith('.' + $domain)) { $inScope = $true }
}
$last = [datetimeoffset]::MinValue
$validDate = [datetimeoffset]::TryParse([string]$machine.lastSeen, [ref]$last)
$adPresent = $adNames.Contains($name) -or $adShortNames.Contains($shortName)
$decision = if ($machine.osPlatform -notlike 'Windows*' -or -not $inScope) {
'ReviewOutsideScope'
} elseif (-not $validDate) {
'ReviewMissingTimestamp'
} elseif ($adPresent) {
'KeepADPresent'
} elseif ($last -ge $cutoff -or $machine.healthStatus -ne 'Inactive') {
'ReviewSensorState'
} elseif ($machine.riskScore -notin @('None','Informational','Low')) {
'ReviewRisk'
} else {
'CandidateReview'
}
[pscustomobject]@{
DeviceId = $machine.id
ComputerDnsName = $name
AadDeviceId = $machine.aadDeviceId
LastSeenUtc = if ($validDate) { $last.ToUniversalTime().ToString('o') } else { '' }
HealthStatus = $machine.healthStatus
RiskScore = $machine.riskScore
ADPresent = $adPresent
SameNameRecords = $nameCounts[$name]
Decision = $decision
CutoffUtc = $cutoff.ToString('o')
CollectedAtUtc = $now.ToString('o')
}
}
# No partial output if a domain lookup or API page fails.
$report
}
# Replace with one reachable DC per relevant domain and explicit DNS suffixes.
$inventoryOptions = @{
Token = $token
AdServers = @('dc01.corp.example.com')
DnsSuffixes = @('corp.example.com')
InactiveDays = 30
MinimumAdCount = 100 # Replace with an approved floor for YOUR environment.
}
$report = @(Get-MdeRetirementReport @inventoryOptions)
$report | Group-Object Decision | Select-Object Name,Count
# Inspect CSV fields as text; don't enable formulas from imported inventory data.
$report | Export-Csv './MDE-retirement-review.csv' -NoTypeInformation -Encoding utf8BOMInterpréter le rapport : un candidat n’est pas une autorisation
Scénario fictif : PC-042.corp.example.com a été réinstallé et possède deux DeviceId MDE. Son compte AD existe toujours. Le rapport conservera les deux fiches; l’équipe devra identifier l’installation active avant d’exclure l’ancienne. Ce faux négatif est préférable à l’exclusion automatique d’un poste en service.
Autre scénario : un portable rejoint uniquement à Entra ID garde un ancien suffixe DNS mais n’a plus d’objet AD. Il pourrait devenir CandidateReview. Vérifiez donc Entra, Intune, l’inventaire matériel, le propriétaire et le ticket de retrait. Une absence dans une seule source ne suffit pas. Le rapport ne fusionne pas les identités et n’établit pas une preuve de destruction.
Avant chaque lot, vérifiez les incidents et alertes ouverts, les machines de secours, les appareils en congé prolongé, les serveurs critiques et les fiches fusionnées. Même un risque Low ne vaut pas autorisation. SameNameRecords signale une ambiguïté, pas une relation certaine entre deux fiches. Protégez le CSV comme un inventaire interne sensible et conservez-le avec le ticket.
| Décision | Suite à donner |
|---|---|
| KeepADPresent | L’objet ou un nom court correspondant existe dans AD. Conserver et vérifier les doublons séparément. |
| ReviewOutsideScope | Système non Windows, nom incomplet ou suffixe hors périmètre. Consulter l’inventaire responsable. |
| ReviewMissingTimestamp | Date inexploitable. Corriger ou vérifier les données avant toute décision. |
| ReviewSensorState | Rapport récent ou état autre qu’Inactive. Vérifier la connectivité et la santé du capteur. |
| ReviewRisk | Risque moyen, élevé ou non reconnu. Révision de sécurité avant le nettoyage. |
| CandidateReview | Absence d’AD, ancien rapport et état Inactive. Valider le retrait dans les autres sources. |
Marquer une fiche approuvée avec l’API de tags
L’étiquette RetirementReview sert à retrouver les appareils à examiner; elle ne les exclut pas. Vérifiez d’abord qu’aucun groupe d’appareils, règle d’automatisation ou politique ne se déclenche sur ce nom. Un tag peut avoir des effets indirects dans un environnement qui l’utilise pour le ciblage.
Le bloc suivant exige un rapport récent, un DeviceId choisi manuellement et un ticket. Il relit la fiche avant l’écriture, refuse les états modifiés et ne remplace pas les autres tags. Obtenez writeToken avec l’identité de marquage distincte, selon la méthode précédente. L’appel montré reste en simulation avec -WhatIf; la fonction sans -Apply n’écrit rien non plus.
Commencez par une fiche test approuvée. Pour un lot, conservez un journal par DeviceId, limitez le volume et prévoyez une reprise qui relit l’état après une réponse incertaine. Ce code de démonstration n’est pas un ordonnanceur de production ni une transaction entre AD et MDE.
function Add-MdeRetirementReviewTag {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)][securestring]$Token,
[Parameter(Mandatory)][psobject]$Row,
[Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Ticket,
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$id = [string]$Row.DeviceId
if ($id -notmatch '^[a-fA-F0-9]{40}$') { throw 'Unexpected MDE ID format; review manually.' }
if ($Row.Decision -ne 'CandidateReview') { throw 'Not a review candidate.' }
$age = [datetimeoffset]::UtcNow - [datetimeoffset]$Row.CollectedAtUtc
if ($age.TotalMinutes -lt 0 -or $age.TotalMinutes -gt 60) {
throw 'Rerun the full inventory before proceeding.'
}
$read = @{
Uri = "https://api.security.microsoft.com/api/machines/$id"
Method = 'Get'; Authentication = 'Bearer'; Token = $Token
MaximumRedirection = 0; ErrorAction = 'Stop'
}
$current = Invoke-RestMethod @read
$name = ([string]$current.computerDnsName).Trim().TrimEnd('.').ToLowerInvariant()
$last = [datetimeoffset]::MinValue
$valid = [datetimeoffset]::TryParse([string]$current.lastSeen, [ref]$last)
if ($current.id -ne $id -or $name -ne $Row.ComputerDnsName -or -not $valid -or
$last -ge [datetimeoffset]$Row.CutoffUtc -or $current.healthStatus -ne 'Inactive' -or
$current.riskScore -notin @('None','Informational','Low')) {
throw 'Device state changed or is incomplete; stop and investigate.'
}
$tag = 'RetirementReview'
if (@($current.machineTags) -contains $tag) {
return [pscustomobject]@{ DeviceId=$id; Result='AlreadyTagged'; Ticket=$Ticket }
}
if (-not $Apply) {
return [pscustomobject]@{ DeviceId=$id; Result='PreviewOnly'; Ticket=$Ticket }
}
if ($PSCmdlet.ShouldProcess("$name [$id] / $Ticket", "Add $tag tag only")) {
$write = @{
Uri = "https://api.security.microsoft.com/api/machines/$id/tags"
Method = 'Post'; Authentication = 'Bearer'; Token = $Token
ContentType = 'application/json'
Body = (@{ Value=$tag; Action='Add' } | ConvertTo-Json -Compress)
MaximumRedirection = 0; ErrorAction = 'Stop'
}
# No automatic POST retries: re-read the device if the outcome is uncertain.
$null = Invoke-RestMethod @write
[pscustomobject]@{ DeviceId=$id; Result='Tagged'; Ticket=$Ticket }
}
}
# After human approval, select ONE exact ID from a freshly generated report.
$approvedId = '<APPROVED-40-CHARACTER-MDE-ID>'
$selected = @($report | Where-Object DeviceId -eq $approvedId)
if ($selected.Count -ne 1) { throw 'Expected exactly one approved record.' }
$tagOptions = @{
Token = $writeToken # SecureString from a separate, authorized writer identity.
Row = $selected[0]
Ticket = 'CHG-EXAMPLE-001'
}
Add-MdeRetirementReviewTag @tagOptions -Apply -WhatIf
# Only after approval, replace -WhatIf with -Confirm to perform the tag operation.Exclure les fiches réellement obsolètes, sans API inventée
Dans Assets → Devices, sélectionnez les fiches validées, choisissez Exclude, puis indiquez une justification comme Duplicate device ou Device doesn’t exist et une note avec le ticket. L’exclusion peut se faire en lot; Microsoft annonce jusqu’à 10 heures de propagation. Pour revenir en arrière, ouvrez Exclusion details → Stop exclusion; le retour des données peut prendre jusqu’à 8 heures.
La documentation publique de Microsoft décrit cette exclusion dans le portail, mais pas un point de terminaison public stable équivalent à ce bouton. L’API Update machine ne documente pas d’écriture de healthStatus ou IsExcluded. Ce guide n’utilise donc ni PATCH improvisé, ni API interne récupérée dans les outils du navigateur. Si Microsoft publie une API dédiée, vérifiez son contrat et ses permissions avant d’automatiser cette étape.
Offboarding : une opération de sécurité distincte
Pour un poste encore joignable et dont le retrait est approuvé, l’API d’offboarding évite de distribuer un paquet local. Elle exige Machine.Offboard. L’exemple HTTP ci-dessous illustre le contrat; ne l’intégrez pas à la boucle de candidats du rapport. Un appareil physiquement absent ne peut pas exécuter une commande simplement parce que son ancienne fiche existe encore.
Sur Windows, Microsoft précise que l’API arrête le service du capteur sans retirer du registre les informations d’onboarding comme le fait le script local. Vérifiez les stratégies GPO, Intune et les autres mécanismes de déploiement applicables. N’appliquez pas simultanément des consignes contradictoires d’onboarding et d’offboarding.
La réponse contient une MachineAction : conservez son identifiant et vérifiez son état final. Une requête acceptée n’est pas la preuve que l’action a réussi. Ne relancez pas aveuglément un POST après un délai réseau. Dans une procédure complète, vérifiez aussi récupération des données, mot de passe administrateur local, retrait de gestion et suppression des objets, selon les autorisations et dépendances; ces étapes ne sont pas exécutées par cet article.
POST https://api.security.microsoft.com/api/machines/{approved-device-id}/offboard
Authorization: Bearer {token-with-Machine.Offboard-permission}
Content-Type: application/json
{"Comment":"Approved retirement - CHG-EXAMPLE-001"}Contrôler le résultat avec KQL, sans confondre les rétentions
Cette requête Advanced Hunting montre le dernier événement disponible par DeviceId pour les appareils exclus ou marqués. Elle expose aussi les identifiants de fusion, utiles après une réinstallation. Elle nécessite les droits et la licence Advanced Hunting appropriés; cette capacité n’est pas incluse dans Defender for Business.
La recherche porte seulement sur la fenêtre de données disponible, normalement 30 jours dans l’expérience Advanced Hunting native. Une ancienne fiche sans événement dans cette fenêtre sera absente du résultat. Utilisez donc l’inventaire et les détails du portail pour la validation finale des appareils plus anciens; KQL n’est pas un inventaire garanti sur 180 jours.
// Select an appropriate time range in Advanced Hunting.
DeviceInfo
| summarize arg_max(Timestamp, *) by DeviceId
| where IsExcluded == true
or DeviceManualTags contains "RetirementReview"
| project Timestamp, DeviceId, DeviceName, AadDeviceId,
SensorHealthState, IsExcluded, ExclusionReason,
MergedToDeviceId, MergedDeviceIds
| order by Timestamp descPasser à une routine fiable plutôt qu’à une suppression massive
Les délais de 30 jours et d’une heure utilisés par les scripts sont des garde-fous proposés, pas des exigences Microsoft. Ajustez-les à votre cycle de prêt, de réparation et de remplacement. Les exemples sont à valider dans un environnement de test : leur publication ne constitue pas une validation sur votre locataire, vos permissions ou vos domaines.
Le résultat recherché n’est pas un tableau de bord vide. C’est un inventaire où chaque fiche conservée, exclue ou retirée a une raison compréhensible, une preuve et un responsable. Pour intégrer cette démarche à un contrôle plus large, poursuivez avec le guide d’audit AD ou faites cadrer les dépendances avant d’automatiser les écritures.
- Première exécution : rapport uniquement, contrôle des domaines, du nombre d’objets et des faux positifs.
- Première semaine : validation par le propriétaire des actifs; sélection de quelques DeviceId et tickets de changement.
- Après approbation : tag, exclusion ou offboarding selon l’objectif, jamais les trois automatiquement.
- À chaque exécution : alerte si un domaine est inaccessible, si un inventaire chute anormalement ou si une API échoue. Un résultat vide n’est pas un feu vert.
- Après le changement : vérifier les fiches actives, les exclusions et les éventuels appareils qui reviennent. Réintégrer les appareils redevenus pertinents.