<# .SYNOPSIS Agent GEFAudit (Gefatech) : lance PingCastle à intervalle régulier et envoie les rapports à la plateforme. .DESCRIPTION Confidentialité : seul un extrait du rapport (scores et règles déclenchées) est envoyé ; le reste du rapport PingCastle (GPO, certificats, serveurs, comptes…) reste sur ce serveur, comme le rapport HTML complet, conservé dans C:\ProgramData\GEFAudit\reports. PingCastle est mis à jour automatiquement (versions publiées depuis plus de 7 jours) et sa signature numérique est vérifiée avant chaque exécution. -Install installe l'agent (configuration chiffrée + tâche planifiée), à lancer en administrateur -Run exécute un audit et envoie le rapport (utilisé par la tâche planifiée, ou à la main pour tester) -Test vérifie la clé et la connexion à la plateforme -Uninstall supprime la tâche planifiée et la configuration La clé d'agent est chiffrée avec DPAPI (portée machine) dans C:\ProgramData\GEFAudit, dossier accessible uniquement à SYSTEM et aux administrateurs. Elle n'apparaît jamais dans la tâche planifiée. .EXAMPLE .\GEFAudit-Agent.ps1 -Install -ServerUrl "https://audit.gefatech.fr" -ApiKey "gfa_xxx_yyy" ` -PingCastlePath "C:\PingCastle\PingCastle.exe" -Schedule Weekly -At "02:00" .EXAMPLE .\GEFAudit-Agent.ps1 -Run -Verbose #> [CmdletBinding(DefaultParameterSetName = 'Run')] param( [Parameter(ParameterSetName = 'Install', Mandatory)][switch]$Install, [Parameter(ParameterSetName = 'Uninstall', Mandatory)][switch]$Uninstall, [Parameter(ParameterSetName = 'Test', Mandatory)][switch]$Test, [Parameter(ParameterSetName = 'Run')][switch]$Run, [Parameter(ParameterSetName = 'Install', Mandatory)][string]$ServerUrl, [Parameter(ParameterSetName = 'Install', Mandatory)][string]$ApiKey, [Parameter(ParameterSetName = 'Install')][string]$PingCastlePath = 'C:\PingCastle\PingCastle.exe', [Parameter(ParameterSetName = 'Install')][string[]]$Domain = @($env:USERDNSDOMAIN), [Parameter(ParameterSetName = 'Install')][ValidateSet('Daily', 'Weekly', 'Monthly')][string]$Schedule = 'Weekly', [Parameter(ParameterSetName = 'Install')][ValidatePattern('^\d{2}:\d{2}$')][string]$At = '02:00', [Parameter(ParameterSetName = 'Install')][ValidateSet('Light', 'Normal', 'Full')][string]$Level = 'Full', # Désactive la mise à jour automatique de PingCastle [Parameter(ParameterSetName = 'Install')][switch]$NoAutoUpdate, # Compte de service géré (gMSA) facultatif, ex. "CONTOSO\svc-gefaudit$" ; par défaut SYSTEM [Parameter(ParameterSetName = 'Install')][string]$ServiceAccount ) Set-StrictMode -Version 3 $ErrorActionPreference = 'Stop' $AgentVersion = '1.1.0' $TaskName = 'GEFAudit-Agent' $EventSource = 'GEFAudit' $Root = Join-Path $env:ProgramData 'GEFAudit' $ConfigPath = Join-Path $Root 'config.json' $WorkDir = Join-Path $Root 'work' $ReportDir = Join-Path $Root 'reports' # Éléments du rapport envoyés à la plateforme ; tout le reste reste sur ce serveur $KeptElements = @('EngineVersion', 'GenerationDate', 'DomainFQDN', 'NetBIOSName', 'ForestFQDN', 'MaturityLevel', 'DomainFunctionalLevel', 'ForestFunctionalLevel', 'GlobalScore', 'StaleObjectsScore', 'PrivilegiedGroupScore', 'TrustScore', 'AnomalyScore', 'RiskRules') $LogDir = Join-Path $Root 'logs' $InstalledScript = Join-Path $Root 'GEFAudit-Agent.ps1' $Entropy = [Text.Encoding]::UTF8.GetBytes('GEFAudit-Agent/v1') # ---------------------------------------------------------------- utilitaires function Write-Log { param([string]$Message, [ValidateSet('Info', 'Warning', 'Error')][string]$Level = 'Info') $line = '{0:yyyy-MM-dd HH:mm:ss} [{1}] {2}' -f (Get-Date), $Level.ToUpper(), $Message if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } Add-Content -Path (Join-Path $LogDir ('agent-{0:yyyyMM}.log' -f (Get-Date))) -Value $line -Encoding UTF8 Write-Verbose $line try { if ([Diagnostics.EventLog]::SourceExists($EventSource)) { $type = @{ Info = 'Information'; Warning = 'Warning'; Error = 'Error' }[$Level] Write-EventLog -LogName Application -Source $EventSource -EntryType $type -EventId 1000 -Message $Message } } catch { } } function Assert-Admin { $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Cette opération doit être lancée dans une console PowerShell « Exécuter en tant qu''administrateur ».' } } function Protect-Secret([string]$Plain) { Add-Type -AssemblyName System.Security $bytes = [Text.Encoding]::UTF8.GetBytes($Plain) $enc = [Security.Cryptography.ProtectedData]::Protect($bytes, $Entropy, [Security.Cryptography.DataProtectionScope]::LocalMachine) [Convert]::ToBase64String($enc) } function Unprotect-Secret([string]$Protected) { Add-Type -AssemblyName System.Security $bytes = [Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($Protected), $Entropy, [Security.Cryptography.DataProtectionScope]::LocalMachine) [Text.Encoding]::UTF8.GetString($bytes) } function Get-AgentConfig { if (-not (Test-Path $ConfigPath)) { throw "Agent non installé : $ConfigPath introuvable. Lancez d'abord -Install." } Get-Content $ConfigPath -Raw | ConvertFrom-Json } function Set-RestrictedAcl([string]$Path) { # Accès réservé à SYSTEM et aux administrateurs (la configuration contient la clé chiffrée) $acl = New-Object Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $inherit = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') { $id = (New-Object Security.Principal.SecurityIdentifier($sid)) $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($id, 'FullControl', $inherit, 'None', 'Allow'))) } Set-Acl -Path $Path -AclObject $acl } function Invoke-Api { param([string]$Method, [string]$Path, [string]$InFile, [string]$FileName, $Config, [string]$Key) [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $headers = @{ Authorization = "Bearer $Key" 'X-Agent-Host' = "$env:COMPUTERNAME.$env:USERDNSDOMAIN" 'X-Agent-Version' = $AgentVersion 'Accept-Language' = 'fr' } $params = @{ Method = $Method; Uri = ($Config.ServerUrl.TrimEnd('/') + $Path); Headers = $headers; UseBasicParsing = $true; TimeoutSec = 300 } if ($InFile) { $headers['X-File-Name'] = $FileName $params.InFile = $InFile $params.ContentType = 'application/xml' } Invoke-RestMethod @params } # Lecture tolérante d'une propriété (mode strict : une propriété absente lève une erreur) function Get-Prop($Object, [string]$Name, $Default = $null) { if ($null -ne $Object -and $Object.PSObject.Properties[$Name]) { $Object.$Name } else { $Default } } function Get-HttpStatus($ErrorRecord) { try { [int]$ErrorRecord.Exception.Response.StatusCode } catch { 0 } } function Get-MinimizedReport([string]$Path) { # Ne garde que les éléments utiles ; des domaines approuvés, seul le nom est conservé (pseudonymisation) $doc = New-Object Xml.XmlDocument $doc.PreserveWhitespace = $false $doc.Load($Path) $root = $doc.DocumentElement if ($root.Name -ne 'HealthcheckData') { throw "Rapport inattendu : racine $($root.Name)" } foreach ($node in @($root.ChildNodes)) { if ($node.NodeType -ne [Xml.XmlNodeType]::Element) { [void]$root.RemoveChild($node); continue } if ($KeptElements -contains $node.Name) { continue } if ($node.Name -eq 'Trusts') { $slim = $doc.CreateElement('Trusts') foreach ($partner in $node.SelectNodes('HealthCheckTrustData/TrustPartner')) { $t = $doc.CreateElement('HealthCheckTrustData') $p = $doc.CreateElement('TrustPartner') $p.InnerText = $partner.InnerText [void]$t.AppendChild($p) [void]$slim.AppendChild($t) } [void]$root.ReplaceChild($slim, $node) continue } [void]$root.RemoveChild($node) } $out = Join-Path $WorkDir ('upload-{0}.xml' -f [guid]::NewGuid().ToString('N')) $settings = New-Object Xml.XmlWriterSettings $settings.Encoding = New-Object Text.UTF8Encoding($false) $settings.Indent = $true $writer = [Xml.XmlWriter]::Create($out, $settings) try { $doc.Save($writer) } finally { $writer.Close() } $out } function Get-PingCastleSigner([string]$Exe) { $sig = Get-AuthenticodeSignature -FilePath $Exe [pscustomobject]@{ Valid = ($sig.Status -eq 'Valid'); Subject = $(if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' }); Status = "$($sig.Status)" } } function Assert-PingCastleSignature($Config) { # Garde-fou chaîne d'approvisionnement : PingCastle doit rester signé par le même éditeur qu'à l'installation if (-not (Get-Prop $Config 'PinnedSigner')) { return } $signer = Get-PingCastleSigner $Config.PingCastlePath if (-not $signer.Valid -or $signer.Subject -ne $Config.PinnedSigner) { throw "Signature de PingCastle.exe invalide ou inattendue (état : $($signer.Status), signataire : '$($signer.Subject)'). Exécution bloquée." } } function Update-PingCastle($Config) { if (-not (Get-Prop $Config 'AutoUpdate' $true)) { return } $updater = Join-Path (Split-Path $Config.PingCastlePath) 'PingCastleAutoUpdater.exe' if (-not (Test-Path $updater)) { Write-Log "PingCastleAutoUpdater.exe absent : mise à jour ignorée" -Level Warning; return } $before = (Get-Item $Config.PingCastlePath).VersionInfo.FileVersion $proc = Start-Process -FilePath $updater -ArgumentList @('--wait-for-days', '7') -WorkingDirectory (Split-Path $updater) ` -NoNewWindow -PassThru -RedirectStandardOutput (Join-Path $WorkDir 'updater.log') -RedirectStandardError (Join-Path $WorkDir 'updater.err') if (-not $proc.WaitForExit(600000)) { $proc.Kill(); Write-Log "Mise à jour de PingCastle interrompue (délai dépassé)" -Level Warning; return } $after = (Get-Item $Config.PingCastlePath).VersionInfo.FileVersion if ($after -ne $before) { Write-Log "PingCastle mis à jour : $before -> $after" } else { Write-Log "PingCastle à jour ($after)" } } function Send-AgentEvent($Config, [string]$Key, [string]$Status, [string]$Message, [string]$Dom) { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $body = @{ status = $Status; message = $Message; domain = $Dom } | ConvertTo-Json -Compress Invoke-RestMethod -Method POST -Uri ($Config.ServerUrl.TrimEnd('/') + '/api/agent/v1/events') -UseBasicParsing -TimeoutSec 60 ` -Headers @{ Authorization = "Bearer $Key"; 'X-Agent-Host' = "$env:COMPUTERNAME.$env:USERDNSDOMAIN"; 'X-Agent-Version' = $AgentVersion } ` -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null } catch { Write-Log "Impossible de remonter l'alerte à la plateforme : $($_.Exception.Message)" -Level Warning } } # ---------------------------------------------------------------- actions function Install-Agent { Assert-Admin if ($ServerUrl -notmatch '^https://' -and $ServerUrl -notmatch '^http://(localhost|127\.0\.0\.1)') { throw 'ServerUrl doit être en HTTPS.' } if ($ApiKey -notmatch '^gfa_[0-9a-f]{12}_[A-Za-z0-9_-]{32}$') { throw 'Clé d''agent invalide (format attendu : gfa_…).' } if (-not (Test-Path $PingCastlePath)) { throw "PingCastle introuvable : $PingCastlePath (téléchargement : https://www.pingcastle.com/download/)" } $domains = @($Domain | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() }) if ($domains.Count -eq 0) { throw 'Aucun domaine à auditer : précisez -Domain.' } foreach ($dir in $Root, $WorkDir, $LogDir, $ReportDir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $signer = Get-PingCastleSigner $PingCastlePath if (-not $signer.Valid) { Write-Warning "PingCastle.exe n'a pas de signature numérique valide ($($signer.Status)) : la vérification de signature est désactivée." } Set-RestrictedAcl $Root if (-not [Diagnostics.EventLog]::SourceExists($EventSource)) { New-EventLog -LogName Application -Source $EventSource } if ($PSCommandPath -ne $InstalledScript) { Copy-Item -Path $PSCommandPath -Destination $InstalledScript -Force } $config = [ordered]@{ ServerUrl = $ServerUrl.TrimEnd('/') PingCastlePath = (Resolve-Path $PingCastlePath).Path Domains = $domains Level = $Level Schedule = $Schedule AutoUpdate = (-not $NoAutoUpdate) PinnedSigner = $(if ($signer.Valid) { $signer.Subject } else { '' }) ApiKeyProtected = (Protect-Secret $ApiKey) InstalledAt = (Get-Date).ToString('o') AgentVersion = $AgentVersion } $config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8 # Vérification de la clé avant de planifier $ping = Invoke-Api -Method GET -Path '/api/agent/v1/ping' -Config ([pscustomobject]$config) -Key $ApiKey $plan = Get-Prop $ping 'plan' Write-Host ("Connexion OK — société : {0}, offre : {1}" -f (Get-Prop $ping 'company'), $(if ($plan) { $plan.name } else { '—' })) -ForegroundColor Green if ($plan) { $minHours = @{ Daily = 24; Weekly = 168; Monthly = 672 }[$Schedule] if ($minHours -lt $plan.minAgentIntervalHours) { Write-Warning ("Votre offre limite les envois à un toutes les {0} h : les envois plus fréquents seront refusés." -f $plan.minAgentIntervalHours) } } $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument ('-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Run' -f $InstalledScript) -WorkingDirectory $Root $time = [datetime]::ParseExact($At, 'HH:mm', $null) $trigger = switch ($Schedule) { 'Daily' { New-ScheduledTaskTrigger -Daily -At $time } 'Weekly' { New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At $time } 'Monthly' { New-ScheduledTaskTrigger -Weekly -WeeksInterval 4 -DaysOfWeek Sunday -At $time } } $trigger.RandomDelay = 'PT30M' $principal = if ($ServiceAccount) { New-ScheduledTaskPrincipal -UserId $ServiceAccount -LogonType Password -RunLevel Highest } else { New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest } $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 4) -MultipleInstances IgnoreNew Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings ` -Description 'GEFAudit (Gefatech) : audit PingCastle et envoi automatique des rapports' -Force | Out-Null Write-Log "Agent $AgentVersion installé : domaines $($domains -join ', '), fréquence $Schedule à $At" Write-Host "Tâche planifiée « $TaskName » créée ($Schedule à $At). Premier envoi : Start-ScheduledTask -TaskName $TaskName" -ForegroundColor Green } function Invoke-AgentRun { $config = Get-AgentConfig $key = Unprotect-Secret $config.ApiKeyProtected foreach ($dir in $WorkDir, $ReportDir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $failures = 0 try { Update-PingCastle $config } catch { Write-Log "Échec de la mise à jour de PingCastle : $($_.Exception.Message)" -Level Warning Send-AgentEvent $config $key 'warning' "Échec de la mise à jour de PingCastle : $($_.Exception.Message)" '' } try { Assert-PingCastleSignature $config } catch { Write-Log $_.Exception.Message -Level Error Send-AgentEvent $config $key 'error' $_.Exception.Message '' exit 2 } foreach ($dom in @($config.Domains)) { Write-Log "Audit PingCastle du domaine $dom (niveau $($config.Level))" Push-Location $WorkDir try { Get-ChildItem -Path $WorkDir -Filter "ad_hc_$dom*" -ErrorAction SilentlyContinue | Remove-Item -Force $proc = Start-Process -FilePath $config.PingCastlePath -ArgumentList @('--healthcheck', '--server', $dom, '--level', $config.Level) ` -WorkingDirectory $WorkDir -NoNewWindow -Wait -PassThru $xml = Get-ChildItem -Path $WorkDir -Filter "ad_hc_$dom.xml" -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $xml) { throw "Rapport XML introuvable après PingCastle (code de sortie $($proc.ExitCode))" } $upload = Get-MinimizedReport $xml.FullName Write-Log ("Extrait envoyé : {0:N0} Ko sur {1:N0} Ko" -f ((Get-Item $upload).Length / 1KB), ($xml.Length / 1KB)) $delays = 30, 60, 120 for ($attempt = 1; ; $attempt++) { try { $res = Invoke-Api -Method POST -Path '/api/agent/v1/reports' -InFile $upload -FileName $xml.Name -Config $config -Key $key $msg = Get-Prop $res 'message' Write-Log ("Envoi {0} : {1}{2}" -f $dom, (Get-Prop $res 'status' '?'), $(if ($msg) { " — $msg" } else { '' })) break } catch { $status = Get-HttpStatus $_ # 4xx (hors 429) : inutile de réessayer (clé, quota, fichier) if (($status -ge 400 -and $status -lt 500 -and $status -ne 429) -or $attempt -gt $delays.Count) { throw } Write-Log "Échec d'envoi (HTTP $status), nouvelle tentative dans $($delays[$attempt - 1]) s" -Level Warning Start-Sleep -Seconds $delays[$attempt - 1] } } # le rapport HTML complet reste sur ce serveur (vrais noms des objets à corriger) $html = Join-Path $WorkDir "ad_hc_$dom.html" if (Test-Path $html) { Move-Item $html (Join-Path $ReportDir ('ad_hc_{0}_{1:yyyyMMdd}.html' -f $dom, (Get-Date))) -Force } Get-ChildItem -Path $WorkDir -Filter "ad_hc_$dom*" | Remove-Item -Force Remove-Item $upload -Force -ErrorAction SilentlyContinue } catch { $failures++ $detail = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $detail = ($_.ErrorDetails.Message | ConvertFrom-Json).message } } catch { } Write-Log "Échec pour $dom : $detail" -Level Error Send-AgentEvent $config $key 'error' "Échec de l'audit ou de l'envoi : $detail" $dom } finally { Pop-Location } } # Rotation des journaux et rapports HTML locaux : 12 mois Get-ChildItem -Path $LogDir, $ReportDir -File -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMonths(-12) } | Remove-Item -Force if ($failures -gt 0) { exit 1 } } function Test-Agent { $config = Get-AgentConfig $res = Invoke-Api -Method GET -Path '/api/agent/v1/ping' -Config $config -Key (Unprotect-Secret $config.ApiKeyProtected) $res | Format-List } function Uninstall-Agent { Assert-Admin if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false } if (Test-Path $Root) { Remove-Item -Path $Root -Recurse -Force } Write-Host 'Agent GEFAudit désinstallé.' -ForegroundColor Green } switch ($PSCmdlet.ParameterSetName) { 'Install' { Install-Agent } 'Uninstall' { Uninstall-Agent } 'Test' { Test-Agent } default { Invoke-AgentRun } }