Le blog technique

Toutes les astuces #tech des collaborateurs de PI Services.

#openblogPI

Retrouvez les articles à la une

Powershell – Script custom d’application des MAJ Windows avec gestion du reboot

J’ai eu a creer un script un peu customisé pour gerer l’application de patch windows, pour des machines qui ne peuvent pas etre géré automatiquement. Dans mon contexte cette action devait en plus etre associé a la creation d’un snapshot de machine virtuelle, apres la mise a jour de cette machine.

Le script:

  • vérifie si des mises à jour sont réellement en attente ou si un reboot est déjà nécessaire.
  • lance le processus d’installation en arrière-plan (via un Job) pour ne pas bloquer votre session.
  • Si l’option -reboot est donnée, il redémarre la machine et attend qu’elle soit vraiment prête. Apres le reboot Il attends a la fois la disponibilité de la connexion winrm et l’occurence de l’event ID 6009 (validant que la machine viens de redemarrer) avant de valider le fait que la machine a bien redémarré.

#####################################################################################################################
#
# check_and_apply_windows_updates.ps1
# 
# Ce script:
# - Verifie sur un serveur cible si il y a des mise a jour Windows Update en attente ou un reboot en attente
# - Execute ces mise a jour 
# - Gere ou pas le reboot s'il doit avoir lieu (option -reboot) (attente que la machine cible soit joignable)
########################################################################################################################

<# Exemples

Sans reboot :
.\check_and_apply_windows_updates.ps1 -Target MyServer

Avec reboot si requis suite au patching :
.\check_and_apply_windows_updates.ps1 -Target MyServer -reboot

#>

################# Declaration des variables et fonctions ###############################################

Param(
[ValidateNotNullOrEmpty()]$Target,
[switch]$reboot,
$SecTimeOutForReboot=300,
$SecRetryIntervalReboot=10
)


$Log = "$PSScriptRoot\Launch_Maj.log"
$ScriptMaj = "$env:LOCALAPPDATA\check_and_apply_windows_updates.ps1"


$getUpdateParam = @{            
            NameSpace = 'root/ccm/ClientSDK'
            ClassName = 'CCM_SoftwareUpdate'
            Filter = 'EvaluationState < 8'
            }   

$ContScriptMaj = @'
$installUpdateParam = @{
        NameSpace = 'root/ccm/ClientSDK'
        ClassName = 'CCM_SoftwareUpdatesManager'
        MethodName = 'InstallUpdates'
    }

    $getUpdateParam = @{            
        NameSpace = 'root/ccm/ClientSDK'
        ClassName = 'CCM_SoftwareUpdate'
        Filter = 'EvaluationState < 8'
    }       

    [ciminstance[]]$updates = Get-CimInstance @getUpdateParam
    
    if ($updates) {
        Invoke-CimMethod @installUpdateParam  -Arguments @{ CCMUpdates = $updates } 
        
        while(Get-CimInstance @getUpdateParam){
            Start-Sleep -Seconds 30
        }
    }

'@

Function Add-ToLog {
Param ([string]$Text,
[string]$Couleur="Green"
 )
 #Write-Output "[LOG] $Text"
 $Text = "[$([DateTime]::Now)] - $Text"
 Add-Content -Path $Log -Value $Text
 Write-Host $Text -ForegroundColor $Couleur
}


Function Check-IfPendingUpdates($Target)
{
[ciminstance[]]$global:updates = Invoke-Command -ComputerName $Target -ScriptBlock {$getUpdateParam = $using:getUpdateParam;Get-CimInstance @getUpdateParam}
if ($updates)
    {
    return $True
    }
else
    {
    return $false
    }
}


Function Check-IfRebootPending($Target)
{
$RebootPending = (Invoke-Command -ComputerName $Target -ScriptBlock {Invoke-CimMethod -Namespace root/ccm/ClientSDK -ClassName CCM_ClientUtilities -MethodName DetermineIfRebootPending}).Rebootpending
if ($RebootPending)
    {
    return $True
    }
else
    {
    return $false
    }
}



function Wait-ForWinRMAndEvent {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)] [string] $ComputerName,
        [Parameter()] [int] $TimeoutMinutes = 15,
        [Parameter()] [int] $PollSeconds = 5,
        [Parameter()] [datetime] $RebootStartTime = ((Get-Date).ToUniversalTime()),
        [Parameter()] [int] $ToleranceSeconds = 60,
        [Parameter()] [int] $global:EventToFind = 6009
    )

    $deadline = (Get-Date).AddMinutes($TimeoutMinutes)
    Write-Verbose "Waiting for $ComputerName WinRM and EventID $EventToFind (timeout $TimeoutMinutes min, poll every $PollSeconds s)"

    while ((Get-Date) -lt $deadline) {
        # 1) Test WinRM reachability
        $winrmOk = $false
        try {
            # Test-WSMan uses WinRM; returns successfully when WSMan endpoint responsive
            Test-WSMan -ComputerName $ComputerName -ErrorAction Stop | Out-Null
            $winrmOk = $true
        } catch {
            $winrmOk = $false
        }

        if ($winrmOk) {
            Write-Verbose "WinRM reachable on $ComputerName, checking EventID $EventToFind..."
            try {
                # Le ScriptBlock utilise $using:EventToFind pour transmettre la variable dans la session distante
                $script = {
                    # Récupère la dernière occurrence de l'EventID $using:EventToFind dans System
                    $evt = Get-WinEvent -FilterHashtable @{ LogName = 'System'; Id = $using:EventToFind } -MaxEvents 1 -ErrorAction SilentlyContinue
                    if ($null -eq $evt) { return $null }
                    # Retourner le TimeCreated en UTC (format ISO)
                    return $evt.TimeCreated.ToUniversalTime().ToString("o")
                }

                $lastIso = Invoke-Command -ComputerName $ComputerName -ScriptBlock $script -ErrorAction Stop

                # Invoke-Command peut renvoyer un tableau ; prendre la première valeur utile
                if ($lastIso -is [array] -and $lastIso.Length -gt 0) { $lastIso = $lastIso[0] }

                if ($lastIso) {
                    # Parse en datetime UTC
                    try {
                        $evtDt = [datetime]::Parse($lastIso).ToUniversalTime()
                    } catch {
                        Write-Verbose "Impossible de parser l'ISO renvoye: $lastIso"
                        $evtDt = $null
                    }

                    if ($evtDt) {
                        $threshold = $RebootStartTime.AddSeconds(-1 * [int]$ToleranceSeconds)
                        if ($evtDt -ge $threshold) {
                            Write-Verbose "EventID $EventToFind trouve a $evtDt (>= $threshold). Condition satisfaite."
                            return $true
                        } else {
                            Write-Verbose "EventID $EventToFind trouve mais antérieur au démarrage attendu: $evtDt < $threshold"
                        }
                    } else {
                        Write-Verbose "Aucune date d'evenement valide retournee."
                    }
                } else {
                    Write-Verbose "Aucun EventID $EventToFind trouve encore."
                }

            } catch {
                Write-Verbose "Erreur lors de la requete d'evenements sur $ComputerName : $_"
                # On continue (peut-être WinRM venait juste de s'ouvrir et l'auth a échoué)
            }
        } else {
            Write-Verbose "WinRM non joignable sur $ComputerName (probablement toujours en reboot)."
        }

        Start-Sleep -Seconds $PollSeconds
    }

    throw "Timeout: $ComputerName n'a pas repondu à WinRM ET/OU n'a pas loggue l'EventID $EventToFind dans les $TimeoutMinutes minutes."
}

################# Debut du script ###############################################

#Test d'acces au serveur cible
 
    Try
    {
    Test-WSMan -ComputerName $Target -ErrorAction Stop | Out-Null
    }
    catch
    {
    Add-Tolog "$Target est injoignable" -Couleur Red
    exit 1
    }


 
    # Message de connexion
    $message = "Connexion au serveur $Target..."
    Add-ToLog -text $message -Couleur White

    # Si il y a des updates en attente on les applique 
    If (Check-IfPendingUpdates -Target $Target)
            {
            $message = "Il y a $($updates.count) mises a jour a faire sur $Target"
            Add-ToLog -Text $message -Couleur White
            $message = "Installation des mises a jour sur le serveur $Target..." 
            Add-ToLog -text $message -Couleur White
            Set-Content -Value $ContScriptMaj -Path $ScriptMaj -Force
            Invoke-Command -ComputerName $Target -FilePath "$ScriptMaj" -AsJob -JobName "$($Target)_job"

            # On attend la fin de l'execution des mise a jours
            Add-Tolog "En attente de la fin de la mise a jour de $Target, cette etape peut prendre plus d'une heure..." -couleur Yellow
            $job = Get-Job -Name "$($Target)_job"
            Wait-Job $job | Out-Null
            Remove-Job $job | Out-Null
            # Si le job est est OK - Fin de l'installation des updates
            If ($job[-1].state -like "Completed")
                {
                $message = "$Target - Fin de l'installation des mises a jour"
                Add-ToLog -Text $message -Couleur White
                }
            # Si le job est est KO - On affiche le probleme et Fin du script
            Else
                {
                $message = "$Target - Erreur pendant l'installation de une ou plusieurs mise a jour - Etat du job: $($job[-1].state) - Verifier l'Etat de la machine"
                Add-ToLog $message -Couleur Red
                Exit 1
                #$MajJobState = "Error"
                }
        
            # On verifie si les updates appliques requiert une redemarrage
            If (Check-IfRebootPending -Target $Target)
                {
                $message = "$Target : Il y a des mises a jour installees qui necessitent un redemarrage du serveur !"
                #write-host $message -F Yellow
                Add-ToLog $message -Couleur Yellow
                If ($reboot.IsPresent -eq $true)
                    {
                    $message = "Redemarrage du serveur $Target pour integrer les mises a jours...Le script attendra que le serveur $Target soit a nouveau joignable"
                    Add-ToLog $message -Couleur Yellow
                    #write-host $message -F Yellow
                    # LE RESTART-COMPUTER NE FONCTIONNE PAS!!!
                    # Restart-Computer -ComputerName $Target -Force -Wait -For PowerShell -Delay 5 -Timeout 300 -WhatIf
                    # Execution du reboot
                    Invoke-Command -ComputerName $target -ScriptBlock {shutdown.exe /f /r /c \"Reboot suite a installation de Mise a jour"}
                    # On attend que la machine ne soit plus joignable
                    Start-Sleep -Seconds 60

                # On attend que ça reboot
                $endTime = (Get-Date).AddSeconds($SecTimeOutForReboot)
                # tant que la date actuelle est inferieure a la date de fin
                while ((Get-Date) -lt $endTime)
                {
                
                try {
                    Wait-ForWinRMAndEvent -ComputerName $target -RebootStartTime $((Get-Date).ToUniversalTime()) -TimeoutMinutes 10 -PollSeconds 10 -Verbose
                    Write-Host "Machine $target de retour et EventID $EventToFind detecte."
                    # sortie immédiate de la boucle dès que la condition est satisfaite
                    break
                    } 
                catch 
                    {
                    Write-Warning $_
                    exit 1
                            }
                        }
                    }
                Else
                    {
                    $message = "Le redemarrage du serveur $Target n'a pas ete demande, reexecuter le script avec l'option -reboot pour que le serveur ne reste pas dans l'etat REBOOTPENDING"
                    Add-ToLog $message -Couleur Yellow
                    #write-host $message -F Yellow
                    }

                }


       }
 



    # Si il y a un reboot en attente Meme sans MAJ ET si l'option reboot est precisee on redemarre
    ElseIf (Check-IfRebootPending -Target $Target)
        {
        $message = "$Target : Il y a des mises a jour installees qui necessitent un redemarrage du serveur !"
        #write-host $message -F Yellow
        Add-ToLog $message -Couleur Yellow
            If ($reboot.IsPresent -eq $true)
                {
                $message = "Redemarrage du serveur $Target...Le script attendra que le serveur $Target soit a nouveau joignable"
                Add-ToLog $message -Couleur Yellow
                #write-host $message -F Yellow
                # LE RESTART-COMPUTER NE FONCTIONNE PAS!!!
                # Restart-Computer -ComputerName $Target -Force -Wait -For PowerShell -Delay 5 -Timeout 300 -WhatIf
                # Execution du reboot
                Invoke-Command -ComputerName $target -ScriptBlock {shutdown.exe /f /r /c \"Reboot suite a installation de Mise a jour"}
                # On attend que la machine ne soit plus joignable
                Start-Sleep -Seconds 60

                # On attend le reboot
                $endTime = (Get-Date).AddSeconds($SecTimeOutForReboot)
                # tant que la date actuelle est inferieure a la date de fin
                while ((Get-Date) -lt $endTime)
                {
                
                try {
                    Wait-ForWinRMAndEvent -ComputerName $target -RebootStartTime $((Get-Date).ToUniversalTime()) -TimeoutMinutes 10 -PollSeconds 10 -Verbose
                    Write-Host "Machine $target de retour et EventID $EventToFind detecte."
                    # sortie immédiate de la boucle dès que la condition est satisfaite
                    break
                    } 
                catch 
                    {
                    Write-Warning $_
                    exit 1
                            }
                        }
            
            Else
                {
                $message = "Le redemarrage du serveur $Target n'a pas ete demande, reexecuter le script avec l'option -reboot pour que le serveur ne reste pas dans l'etat REBOOTPENDING"
                Add-ToLog $message -Couleur Yellow
                #write-host $message -F Yellow
                }

        }
        }

    # Si il n'y a pas d'update en attente et pas de reboot en attente
    Else {
        $message = "OK - Pas de mise a jour en attente et pas de reboot en attente"
        Add-ToLog -text $message -Couleur White
        start-sleep -Seconds 60
         Exit 0
         }







[Entra ID] Récupération des différents types de sign-in pour un audit de sécurité

Lors d’un audit de sécurité Entra ID (Azure AD), il est essentiel de distinguer les différents types de connexions afin d’avoir une vision claire des usages et d’identifier les connexions suspectes ou inutiles. Microsoft Graph centralise ces informations, mais elles ne sont pas toujours triviales à exploiter directement.

En pratique, quatre types principaux de sign-ins sont à connaître :

  • Interactive user
    Une connexion initiée par un utilisateur réel, typiquement via le portail Microsoft 365, Outlook, Teams ou un autre service nécessitant l’authentification interactive.
    Ces sign-ins sont soumis aux politiques MFA et aux conditions d’accès.

  • Non-interactive user
    Une connexion réalisée par un utilisateur, mais sans interaction directe, souvent pour des scripts, applications ou services automatisés utilisant un mot de passe ou un token stocké.
    Exemples : tâches planifiées, scripts PowerShell ou outils tiers accédant à Exchange ou SharePoint via un compte utilisateur.

  • Service Principal (SPN)
    Une authentification effectuée par une application enregistrée dans Entra ID. Les SPN sont utilisés par des services et scripts qui se connectent au nom de l’application, indépendamment d’un utilisateur.
    Exemples : intégrations SaaS, scripts automatisés via une App Registration, pipelines CI/CD.

  • Managed Identity
    Utilisée par les ressources Azure (VM, Functions, App Service, etc.) pour s’authentifier automatiquement auprès d’Azure AD sans gérer de credentials.
    Ces identités sont sécurisées, éphémères et ne nécessitent pas de mot de passe stocké.

Microsoft Graph ne propose pas un filtre unique pour tous les distinguer facilement. Pour obtenir une vue précise, il faut filtrer les sign-ins selon l’attribut signInEventTypes.

Prérequis

Commandes PowerShell par type de sign-in

Pour récupérer les sign-ins d’une application ou d’un utilisateur, on utilise la cmdlet Get-MgBetaAuditLogSignIn avec un filtre sur signInEventTypes :

$StartDate = (Get-Date).AddDays(-30)
$Application = Get-MgApplication -Filter "DisplayName eq 'MonApp'"

# Interactive
Get-MgBetaAuditLogSignIn -Filter "AppId eq '$($Application.AppId)' and createdDateTime ge $StartDate and signInEventTypes/any(t: t eq 'interactiveUser')"

# Non-interactive
Get-MgBetaAuditLogSignIn -Filter "AppId eq '$($Application.AppId)' and createdDateTime ge $StartDate and signInEventTypes/any(t: t eq 'nonInteractiveUser')"

# Service Principal
Get-MgBetaAuditLogSignIn -Filter "AppId eq '$($Application.AppId)' and createdDateTime ge $StartDate and signInEventTypes/any(t: t eq 'servicePrincipal')"

# Managed Identity
Get-MgBetaAuditLogSignIn -Filter "AppId eq '$($Application.AppId)' and createdDateTime ge $StartDate and signInEventTypes/any(t: t eq 'managedIdentity')"

[REX] Erreur lors de l’import d’un module : gérer les versions multiples

PowerShell repose largement sur des modules pour étendre ses fonctionnalités, que ce soit pour l’administration système, la gestion Microsoft 365 ou des scripts internes. Dans de nombreux environnements, il arrive qu’un même module soit présent en plusieurs versions sur la machine, ce qui peut générer des conflits lors de l’import dans un script.

Ce problème se manifeste souvent lorsque l’on écrit ou exécute des scripts sur différentes machines ou environnements, par exemple :

  • un script utilisant Exchange Online ou Microsoft.Graph sur des serveurs différents
  • plusieurs versions d’un module interne développé en interne pour automatiser l’inventaire ou la configuration
  • des mises à jour partielles qui laissent des versions anciennes et nouvelles sur la machine

Dans ces cas, l’exécution d’un Import-Module peut échouer ou charger la mauvaise version, entraînant des erreurs inattendues ou des comportements incompatibles.

Exemples typiques :

  • Import-Module : Module 'PnP.PowerShell' is already loaded.
  • Des cmdlets manquantes ou modifiées entre versions
  • Conflits de dépendances entre modules
  • Scripts fonctionnant en test mais échouant en production

Ces erreurs surviennent car PowerShell charge par défaut la première version trouvée dans le $env:PSModulePath, sans vérifier la compatibilité de version avec le script.

La gestion des versions de module est un élément souvent sous-estimé :

  • Chaque version peut contenir des cmdlets différentes, ou modifier le comportement existant
  • Certains modules ne supportent pas la cohabitation de plusieurs versions dans la même session
  • Les scripts automatisés peuvent être sensibles à la version exacte pour des raisons de compatibilité ou de sécurité

Solutions et bonnes pratiques

  1. Spécifier la version souhaitée lors de l’import

Pour éviter les conflits, utilisez -RequiredVersion pour charger exactement la version nécessaire :

Import-Module -Name PnP.PowerShell -RequiredVersion 1.12.0

Si le script peut tolérer plusieurs versions récentes, utilisez -MinimumVersion et/ou -MaximumVersion pour définir une plage compatible :

Import-Module -Name PnP.PowerShell -MinimumVersion 1.10.0 -MaximumVersion 1.12.0
  1. Vérifier les versions installées avant import
Get-InstalledModule -Name PnP.PowerShell -AllVersions

Cela permet d’identifier les versions présentes et de planifier une mise à jour ou un nettoyage si nécessaire.

  1. Forcer le rechargement si un module est déjà chargé

Lorsque PowerShell détecte une version chargée, certaines cmdlets peuvent provoquer des erreurs. -Force permet de recharger la version spécifiée :

Import-Module -Name PnP.PowerShell -RequiredVersion 1.12.0 -Force
  1. Importer via le chemin complet du module

Pour un contrôle absolu et éviter tout conflit avec d’autres versions présentes dans $env:PSModulePath :

Import-Module "C:\Program Files\WindowsPowerShell\Modules\PnP.PowerShell\1.12.0\PnP.PowerShell.psd1"
  1. Limiter le scope de l’import

En utilisant -Scope CurrentUser ou -Scope LocalSession, on peut isoler le module à la session ou à l’utilisateur, réduisant les conflits avec d’autres scripts ou administrateurs :

Import-Module -Name PnP.PowerShell -RequiredVersion 1.12.0 -Scope CurrentUser