SyncCheck.psm1
|
# SyncCheck - Active Directory readiness check for Microsoft Entra ID sync # Copyright (c) 2026 Wilson Neves de Almeida Junior - MIT License # https://github.com/wjralmeida/SyncCheck # # Observacao: este arquivo e 100% ASCII de proposito. Textos acentuados ficam como # entidades HTML e sao convertidos em tempo de execucao pela funcao D, para o modulo # funcionar em qualquer codificacao/idioma do Windows PowerShell 5.1. function Invoke-SyncCheck { <# .SYNOPSIS Avalia a prontidao do Active Directory para sincronizacao com o Microsoft Entra ID (Entra Connect Sync ou Entra Cloud Sync). .DESCRIPTION Ferramenta somente leitura: nao altera nenhum objeto do AD e nao envia dados para fora do ambiente. Avalia usuarios, grupos e contatos contra regras que causam erro de sincronizacao ou perda de dados (UPN, mail, proxyAddresses, duplicidades, caracteres invalidos e limites de tamanho) e gera um relatorio CSV e um HTML. Documentacao completa: https://github.com/wjralmeida/SyncCheck .PARAMETER Cliente Nome usado no relatorio e no nome dos arquivos. Padrao: NetBIOS do dominio. .PARAMETER UpnSuffix Sufixos UPN considerados validos para a nuvem. Padrao: sufixos roteaveis cadastrados no AD. .PARAMETER Server Controlador de dominio ou dominio a consultar. Padrao: dominio da maquina atual. .PARAMETER Credential Credencial alternativa para consultar o AD. .PARAMETER SearchBase Limita a avaliacao a uma OU (DN completo). .PARAMETER GroupName Limita a avaliacao aos membros diretos de um grupo, como no filtro por grupo do Entra Connect. .PARAMETER IncludeDisabled Inclui usuarios desabilitados na avaliacao. .PARAMETER OutputFolder Pasta de saida dos relatorios. Padrao: C:\Temp\SyncCheck .PARAMETER OpenReport Abre o relatorio HTML ao final. .PARAMETER PassThru Devolve os itens avaliados como objetos no pipeline. .EXAMPLE Invoke-SyncCheck Detecta dominio, nome e sufixos UPN roteaveis automaticamente. .EXAMPLE Invoke-SyncCheck -UpnSuffix empresa.com.br,empresa.net -OpenReport .EXAMPLE Invoke-SyncCheck -GroupName G_Sync_Piloto .EXAMPLE Invoke-SyncCheck -SearchBase "OU=Usuarios,DC=empresa,DC=local" -IncludeDisabled .EXAMPLE Invoke-SyncCheck -Server dc01.empresa.local -Credential (Get-Credential) .EXAMPLE Invoke-SyncCheck -PassThru | Where-Object Regra -eq 'DUP-001' .LINK https://github.com/wjralmeida/SyncCheck #> [CmdletBinding()] param( [string] $Cliente, [string[]] $UpnSuffix, [string] $Server, [pscredential] $Credential, [string] $SearchBase, [string] $GroupName, [switch] $IncludeDisabled, [string] $OutputFolder = 'C:\Temp\SyncCheck', [switch] $OpenReport, [switch] $PassThru ) $ErrorActionPreference = 'Stop' # Textos acentuados ficam como entidades HTML para o script ser 100% ASCII function D([string]$s) { [System.Net.WebUtility]::HtmlDecode($s) } if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) { Write-Host (D "Módulo ActiveDirectory não encontrado.") -ForegroundColor Red Write-Host "Servidor: Install-WindowsFeature RSAT-AD-PowerShell" -ForegroundColor Yellow Write-Host "Windows 10/11: Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0" -ForegroundColor Yellow return } Import-Module ActiveDirectory $ToolVersion = if ($MyInvocation.MyCommand.Module) { $MyInvocation.MyCommand.Module.Version.ToString() } else { 'dev' } $StartTime = Get-Date $Stamp = $StartTime.ToString('yyyyMMdd_HHmm') # Parametros de conexao repassados a todos os cmdlets do AD $AD = @{} if ($Server) { $AD.Server = $Server } if ($Credential) { $AD.Credential = $Credential } # ============================================================================ # Catalogo de regras # ============================================================================ $Rules = [ordered]@{ 'UPN-001' = @{ Sev = 'Erro'; Desc = 'UPN vazio' } 'UPN-002' = @{ Sev = 'Erro'; Desc = 'Sufixo do UPN não roteável ou fora dos sufixos válidos para a nuvem' } 'UPN-003' = @{ Sev = 'Erro'; Desc = 'UPN com caractere inválido (acento, espaço ou símbolo)' } 'UPN-004' = @{ Sev = 'Erro'; Desc = 'UPN com ponto no início, no fim ou duplicado antes do @' } 'UPN-005' = @{ Sev = 'Erro'; Desc = 'Prefixo do UPN com mais de 64 caracteres' } 'MAIL-001'= @{ Sev = 'Aviso'; Desc = 'Atributo mail vazio' } 'MAIL-002'= @{ Sev = 'Erro'; Desc = 'Atributo mail com caractere ou formato inválido' } 'MAIL-003'= @{ Sev = 'Aviso'; Desc = 'Atributo mail diferente do UPN' } 'PRX-001' = @{ Sev = 'Aviso'; Desc = 'proxyAddresses vazio: aliases existentes na nuvem podem ser perdidos' } 'PRX-002' = @{ Sev = 'Erro'; Desc = 'proxyAddresses sem SMTP primário' } 'PRX-003' = @{ Sev = 'Erro'; Desc = 'proxyAddresses com mais de um SMTP primário' } 'PRX-004' = @{ Sev = 'Aviso'; Desc = 'SMTP primário diferente do atributo mail' } 'PRX-005' = @{ Sev = 'Erro'; Desc = 'proxyAddresses com espaço ou caractere inválido' } 'PRX-006' = @{ Sev = 'Aviso'; Desc = 'proxyAddresses com domínio não roteável (será descartado na nuvem)' } 'PRX-007' = @{ Sev = 'Aviso'; Desc = 'Endereço repetido dentro do próprio objeto' } 'DUP-001' = @{ Sev = 'Erro'; Desc = 'Endereço duplicado com outro objeto do AD' } 'SAM-001' = @{ Sev = 'Erro'; Desc = 'sAMAccountName com caractere inválido' } 'SAM-002' = @{ Sev = 'Aviso'; Desc = 'sAMAccountName com mais de 20 caracteres' } 'DSP-001' = @{ Sev = 'Aviso'; Desc = 'displayName vazio' } 'DSP-002' = @{ Sev = 'Erro'; Desc = 'displayName com mais de 256 caracteres' } 'NAM-001' = @{ Sev = 'Erro'; Desc = 'givenName ou sn com mais de 64 caracteres' } 'NCK-001' = @{ Sev = 'Erro'; Desc = 'mailNickname com espaço, @ ou caractere inválido' } 'NCK-002' = @{ Sev = 'Erro'; Desc = 'mailNickname com mais de 64 caracteres' } 'WSP-001' = @{ Sev = 'Aviso'; Desc = 'Espaço no início ou no fim do valor' } 'EXE-001' = @{ Sev = 'Aviso'; Desc = 'Falha ao avaliar o objeto (verificar manualmente)' } } # Padroes $rxLocalInvalid = '[^a-zA-Z0-9''.\-_!#^~]' # caracteres aceitos no prefixo do UPN/SMTP $rxAddress = '^[^@\s]+@[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)+$' $rxNonRoutable = '(\.(local|lan|internal|intra|corp|localdomain|home)$)|^[^.]+$' $rxSamInvalid = '["/\\\[\]:;|=,+*?<>@]' $rxNickInvalid = '[^a-zA-Z0-9!#$%&''*+\-/=?^_`{|}~.]' $rxSkipAccounts = '^(MSOL_|AAD_|Sync_|HealthMailbox|SystemMailbox|FederatedEmail|DiscoverySearchMailbox|Migration\.|SUPPORT_388945a0|CAS_|krbtgt$|Guest$|Convidado$|DefaultAccount$|WDAGUtilityAccount$)' # ============================================================================ # Funcoes auxiliares # ============================================================================ $Issues = New-Object System.Collections.Generic.List[object] function Add-Issue { param($Rule, $Name, $Type, $DN, $Attr, $Value, $Fix = '') $r = $Rules[$Rule] $Issues.Add([pscustomobject]@{ Severidade = $r.Sev Regra = $Rule Descricao = D $r.Desc TipoObjeto = D $Type Objeto = $Name Atributo = D $Attr Valor = [string]$Value Sugestao = D ([string]$Fix) DistinguishedName = $DN }) } function ConvertTo-Ascii([string]$s) { if (-not $s) { return $s } $n = $s.Normalize([Text.NormalizationForm]::FormD) $sb = New-Object System.Text.StringBuilder foreach ($c in $n.ToCharArray()) { if ([Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne [Globalization.UnicodeCategory]::NonSpacingMark) { [void]$sb.Append($c) } } $sb.ToString().Normalize([Text.NormalizationForm]::FormC) } function Get-CleanLocal([string]$s) { if (-not $s) { return $s } $x = (ConvertTo-Ascii $s.Trim()) -replace '\s+', '.' $x = $x -replace $rxLocalInvalid, '' -replace '\.{2,}', '.' $x.Trim('.') } function Test-InScope([string]$dn) { (-not $ScopeDNs) -or $ScopeDNs.Contains($dn) } function Test-Addresses { param($Name, $Type, $DN, $Mail, $Proxy, $Upn) if ($Mail) { if ($Mail -ne $Mail.Trim()) { Add-Issue 'WSP-001' $Name $Type $DN 'mail' $Mail $Mail.Trim() } $m = $Mail.Trim() if ($m -notmatch $rxAddress -or $m.Split('@')[0] -match $rxLocalInvalid) { $fix = if ($m -match '@') { "$(Get-CleanLocal $m.Split('@')[0])@$($m.Split('@')[1])" } else { '' } Add-Issue 'MAIL-002' $Name $Type $DN 'mail' $Mail $fix } if ($Upn -and $m -ne $Upn) { Add-Issue 'MAIL-003' $Name $Type $DN 'mail' $Mail "UPN atual: $Upn" } } # proxyAddresses $smtp = @($Proxy | Where-Object { $_ -match '^smtp:' }) if ($smtp.Count -eq 0) { if ($Mail) { Add-Issue 'PRX-001' $Name $Type $DN 'proxyAddresses' '(vazio)' "SMTP:$($Mail.Trim())" } } else { $primary = @($smtp | Where-Object { $_ -cmatch '^SMTP:' }) if ($primary.Count -eq 0) { $fix = if ($Mail) { "SMTP:$($Mail.Trim())" } else { 'Definir um endereço como SMTP: (maiúsculo)' } Add-Issue 'PRX-002' $Name $Type $DN 'proxyAddresses' ($smtp -join '; ') $fix } elseif ($primary.Count -gt 1) { Add-Issue 'PRX-003' $Name $Type $DN 'proxyAddresses' ($primary -join '; ') 'Manter apenas um SMTP: em maiúsculo' } elseif ($Mail -and $primary[0].Substring(5) -ne $Mail.Trim()) { Add-Issue 'PRX-004' $Name $Type $DN 'proxyAddresses' $primary[0] "SMTP:$($Mail.Trim()) ou ajustar o mail" } $seen = @{} foreach ($p in $smtp) { $addr = $p.Substring(5) if ($addr -match '\s' -or $addr -notmatch $rxAddress -or $addr.Split('@')[0] -match $rxLocalInvalid) { Add-Issue 'PRX-005' $Name $Type $DN 'proxyAddresses' $p '' } elseif ($addr.Split('@')[1] -match $rxNonRoutable) { Add-Issue 'PRX-006' $Name $Type $DN 'proxyAddresses' $p 'Remover o endereço' } $k = $addr.Trim().ToLower() if ($seen.ContainsKey($k)) { Add-Issue 'PRX-007' $Name $Type $DN 'proxyAddresses' $p 'Remover a entrada repetida' } else { $seen[$k] = 1 } } } # Duplicidade com outros objetos $mine = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($p in $smtp) { [void]$mine.Add($p.Substring(5).Trim()) } if ($Mail) { [void]$mine.Add($Mail.Trim()) } if ($Upn) { [void]$mine.Add($Upn.Trim()) } foreach ($a in $mine) { $k = $a.ToLower() if ($AddrIndex.ContainsKey($k)) { $others = @($AddrIndex[$k] | Where-Object { $_ -ne $DN }) if ($others.Count -gt 0) { Add-Issue 'DUP-001' $Name $Type $DN 'endereço' $a ("Também em: " + ($others -join ' | ')) } } } } function Test-Nickname($Name, $Type, $DN, $Nick) { if (-not $Nick) { return } if ($Nick -match '[\s@]' -or $Nick -match $rxNickInvalid -or $Nick -match '^\.|\.$') { Add-Issue 'NCK-001' $Name $Type $DN 'mailNickname' $Nick (Get-CleanLocal $Nick) } if ($Nick.Length -gt 64) { Add-Issue 'NCK-002' $Name $Type $DN 'mailNickname' $Nick $Nick.Substring(0, 64) } } function Get-HtmlSafe([object]$s) { [System.Net.WebUtility]::HtmlEncode([string]$s) } # ============================================================================ # Coleta # ============================================================================ Write-Host "SyncCheck $ToolVersion" -ForegroundColor Cyan Write-Host (D "Coletando informações do ambiente...") -ForegroundColor Gray try { $Domain = Get-ADDomain @AD } catch { Write-Host (D "Não foi possível consultar o domínio: $($_.Exception.Message)") -ForegroundColor Red Write-Host (D "Verifique se a máquina está no domínio ou use -Server e -Credential.") -ForegroundColor Yellow return } try { $Forest = Get-ADForest @AD } catch { $Forest = [pscustomobject]@{ Name = $Domain.Forest; ForestMode = '(sem acesso)'; UPNSuffixes = @() } } if (-not $Cliente) { $Cliente = $Domain.NetBIOSName } $SafeName = $Cliente -replace '[\\/:*?"<>|\s]', '_' New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null $CsvPath = Join-Path $OutputFolder "SyncCheck_${SafeName}_$Stamp.csv" $HtmlPath = Join-Path $OutputFolder "SyncCheck_${SafeName}_$Stamp.html" # Sufixos UPN: cadastrados na floresta + nome DNS do dominio $ForestSuffixes = @(@($Forest.UPNSuffixes) + @($Domain.DNSRoot) | Where-Object { $_ } | Select-Object -Unique) if ($UpnSuffix) { $SuffixSource = D 'Informados via -UpnSuffix' } else { $UpnSuffix = @($ForestSuffixes | Where-Object { $_ -notmatch $rxNonRoutable }) $SuffixSource = D 'Detectados automaticamente (sufixos roteáveis do AD)' } $UpnSuffix = @($UpnSuffix | Where-Object { $_ }) $TargetSuffix = if ($UpnSuffix.Count) { $UpnSuffix[0] } else { '<sufixo-roteavel>' } $MissingSuffixes = @($UpnSuffix | Where-Object { $ForestSuffixes -notcontains $_ }) Write-Host (D "Cliente: $Cliente | Domínio: $($Domain.DNSRoot)") -ForegroundColor Cyan # Atributos opcionais: so consulta o que existe no esquema # (mailNickname so existe com o esquema do Exchange estendido) $SchemaNC = (Get-ADRootDSE @AD).schemaNamingContext function Test-SchemaAttr([string]$name) { [bool](Get-ADObject -SearchBase $SchemaNC -LDAPFilter "(lDAPDisplayName=$name)" @AD) } $HasProxy = Test-SchemaAttr 'proxyAddresses' $HasNick = Test-SchemaAttr 'mailNickname' $optProps = @() if ($HasProxy) { $optProps += 'proxyAddresses' } if ($HasNick) { $optProps += 'mailNickname' } $ExchangeSchema = if ($HasNick) { 'Estendido (atributos do Exchange presentes)' } else { 'Não estendido: mailNickname não avaliado' } # Escopo por grupo (membros diretos, igual ao filtro do Entra Connect) $ScopeDNs = $null if ($GroupName) { $grp = Get-ADGroup -Identity $GroupName -Properties member @AD $ScopeDNs = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($m in $grp.member) { [void]$ScopeDNs.Add($m) } } $sbParams = $AD.Clone() if ($SearchBase) { $sbParams.SearchBase = $SearchBase } # Indice global de enderecos (dominio inteiro) para detectar duplicidade Write-Host (D "Indexando endereços do domínio...") -ForegroundColor Gray $AddrIndex = @{} $idxFilter = if ($HasProxy) { '(|(proxyAddresses=*)(mail=*)(userPrincipalName=*))' } else { '(|(mail=*)(userPrincipalName=*))' } Get-ADObject -LDAPFilter $idxFilter ` -Properties (@('mail', 'userPrincipalName') + $optProps) -ResultSetSize $null @AD | ForEach-Object { $o = $_ $set = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($p in $o.proxyAddresses) { if ($p -match '^smtp:') { [void]$set.Add($p.Substring(5).Trim()) } } if ($o.mail) { [void]$set.Add($o.mail.Trim()) } if ($o.userPrincipalName) { [void]$set.Add($o.userPrincipalName.Trim()) } foreach ($a in $set) { $k = $a.ToLower() if (-not $AddrIndex.ContainsKey($k)) { $AddrIndex[$k] = New-Object System.Collections.Generic.List[string] } $AddrIndex[$k].Add($o.DistinguishedName) } } Write-Host (D "Coletando usuários, grupos e contatos...") -ForegroundColor Gray $userFilter = if ($IncludeDisabled) { '*' } else { 'Enabled -eq $true' } $userProps = @('UserPrincipalName','SamAccountName','mail','DisplayName','GivenName', 'Surname','isCriticalSystemObject') + $optProps $AllUsers = @(Get-ADUser -Filter $userFilter -Properties $userProps @sbParams) $Skipped = @($AllUsers | Where-Object { $_.isCriticalSystemObject -or $_.SamAccountName -match $rxSkipAccounts }).Count $Users = @($AllUsers | Where-Object { -not ($_.isCriticalSystemObject -or $_.SamAccountName -match $rxSkipAccounts) -and (Test-InScope $_.DistinguishedName) }) $Groups = @(Get-ADGroup -Filter * -Properties (@('mail', 'displayName', 'isCriticalSystemObject') + $optProps) @sbParams | Where-Object { -not $_.isCriticalSystemObject -and (Test-InScope $_.DistinguishedName) }) $Contacts = @(Get-ADObject -LDAPFilter '(objectClass=contact)' -Properties (@('mail', 'displayName') + $optProps) @sbParams | Where-Object { Test-InScope $_.DistinguishedName }) # ============================================================================ # Avaliacao # ============================================================================ Write-Host (D "Avaliando $($Users.Count) usuários, $($Groups.Count) grupos e $($Contacts.Count) contatos...") -ForegroundColor Gray foreach ($u in $Users) { $n = $u.SamAccountName; $dn = $u.DistinguishedName; $t = 'Usuário' try { $upn = $u.UserPrincipalName if (-not $upn) { Add-Issue 'UPN-001' $n $t $dn 'userPrincipalName' '(vazio)' "$(Get-CleanLocal $n)@$TargetSuffix" } else { $parts = $upn.Split('@') if ($parts.Count -ne 2) { Add-Issue 'UPN-003' $n $t $dn 'userPrincipalName' $upn '' } else { $local = $parts[0]; $dom = $parts[1] if (($UpnSuffix -notcontains $dom) -or ($dom -match $rxNonRoutable)) { Add-Issue 'UPN-002' $n $t $dn 'userPrincipalName' $upn "$(Get-CleanLocal $local)@$TargetSuffix" } if ($local -match $rxLocalInvalid) { Add-Issue 'UPN-003' $n $t $dn 'userPrincipalName' $upn "$(Get-CleanLocal $local)@$dom" } elseif ($local -match '^\.|\.$|\.\.') { Add-Issue 'UPN-004' $n $t $dn 'userPrincipalName' $upn "$(Get-CleanLocal $local)@$dom" } if ($local.Length -gt 64) { Add-Issue 'UPN-005' $n $t $dn 'userPrincipalName' $upn '' } } } if (-not $u.mail) { Add-Issue 'MAIL-001' $n $t $dn 'mail' '(vazio)' $(if ($upn -and $upn -match '@') { "$(Get-CleanLocal $upn.Split('@')[0])@$TargetSuffix" }) } Test-Addresses -Name $n -Type $t -DN $dn -Mail $u.mail -Proxy $u.proxyAddresses -Upn $upn if ($n -match $rxSamInvalid) { Add-Issue 'SAM-001' $n $t $dn 'sAMAccountName' $n '' } if ($n.Length -gt 20) { Add-Issue 'SAM-002' $n $t $dn 'sAMAccountName' $n '' } if (-not $u.DisplayName) { Add-Issue 'DSP-001' $n $t $dn 'displayName' '(vazio)' (("$($u.GivenName) $($u.Surname)").Trim()) } elseif ($u.DisplayName.Length -gt 256) { Add-Issue 'DSP-002' $n $t $dn 'displayName' $u.DisplayName '' } elseif ($u.DisplayName -ne $u.DisplayName.Trim()) { Add-Issue 'WSP-001' $n $t $dn 'displayName' $u.DisplayName $u.DisplayName.Trim() } foreach ($attr in 'GivenName', 'Surname') { $v = $u.$attr if ($v) { if ($v.Length -gt 64) { Add-Issue 'NAM-001' $n $t $dn $attr $v $v.Substring(0, 64) } elseif ($v -ne $v.Trim()) { Add-Issue 'WSP-001' $n $t $dn $attr $v $v.Trim() } } } Test-Nickname $n $t $dn $u.mailNickname } catch { Add-Issue 'EXE-001' $n $t $dn '' $_.Exception.Message '' } } foreach ($g in $Groups) { $n = $g.SamAccountName; $dn = $g.DistinguishedName; $t = 'Grupo' try { Test-Addresses -Name $n -Type $t -DN $dn -Mail $g.mail -Proxy $g.proxyAddresses -Upn $null if ($n -match $rxSamInvalid) { Add-Issue 'SAM-001' $n $t $dn 'sAMAccountName' $n '' } if ($g.displayName -and $g.displayName.Length -gt 256) { Add-Issue 'DSP-002' $n $t $dn 'displayName' $g.displayName '' } Test-Nickname $n $t $dn $g.mailNickname } catch { Add-Issue 'EXE-001' $n $t $dn '' $_.Exception.Message '' } } foreach ($c in $Contacts) { $n = $c.Name; $dn = $c.DistinguishedName; $t = 'Contato' try { Test-Addresses -Name $n -Type $t -DN $dn -Mail $c.mail -Proxy $c.proxyAddresses -Upn $null if (-not $c.displayName) { Add-Issue 'DSP-001' $n $t $dn 'displayName' '(vazio)' $n } Test-Nickname $n $t $dn $c.mailNickname } catch { Add-Issue 'EXE-001' $n $t $dn '' $_.Exception.Message '' } } # ============================================================================ # Consolidacao # ============================================================================ $Evaluated = $Users.Count + $Groups.Count + $Contacts.Count $ErrDNs = @($Issues | Where-Object Severidade -eq 'Erro' | Select-Object -ExpandProperty DistinguishedName -Unique) $AllDNs = @($Issues | Select-Object -ExpandProperty DistinguishedName -Unique) $ObjErr = $ErrDNs.Count $ObjWarn = $AllDNs.Count - $ObjErr $ObjOk = $Evaluated - $AllDNs.Count $Sorted = $Issues | Sort-Object @{ Expression = { if ($_.Severidade -eq 'Erro') { 0 } else { 1 } } }, Regra, Objeto if ($Issues.Count -gt 0) { $Sorted | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 -Delimiter ';' } $ByRule = $Issues | Group-Object Regra | ForEach-Object { [pscustomobject]@{ Regra = $_.Name Severidade = $Rules[$_.Name].Sev Descricao = D $Rules[$_.Name].Desc Ocorrencias= $_.Count Objetos = @($_.Group | Select-Object -ExpandProperty DistinguishedName -Unique).Count } } | Sort-Object @{ Expression = { if ($_.Severidade -eq 'Erro') { 0 } else { 1 } } }, @{ Expression = 'Ocorrencias'; Descending = $true } # ============================================================================ # Relatorio HTML # ============================================================================ $Scope = D $(if ($GroupName) { "Membros diretos do grupo $GroupName" } elseif ($SearchBase) { "OU $SearchBase" } else { 'Domínio inteiro' }) if ($ObjErr -gt 0) { $Headline = D "$ObjErr de $Evaluated objetos precisam de ajuste antes da sincronização" } elseif ($ObjWarn -gt 0) { $Headline = D "Nenhum erro bloqueante. $ObjWarn objetos têm pontos de melhoria a revisar" } else { $Headline = D "Todos os $Evaluated objetos avaliados estão prontos para sincronizar" } $pctOk = if ($Evaluated) { [math]::Round(100 * $ObjOk / $Evaluated, 1) } else { 0 } $pctWarn = if ($Evaluated) { [math]::Round(100 * $ObjWarn / $Evaluated, 1) } else { 0 } $pctErr = if ($Evaluated) { [math]::Round(100 * $ObjErr / $Evaluated, 1) } else { 0 } $inv = [Globalization.CultureInfo]::InvariantCulture $allSuffixes = @(@($ForestSuffixes) + @($UpnSuffix) | Select-Object -Unique) $suffixRows = foreach ($s in $allSuffixes) { $routable = $s -notmatch $rxNonRoutable $inForest = $ForestSuffixes -contains $s $used = $UpnSuffix -contains $s $tipo = if ($routable) { 'Roteável' } else { 'Não roteável' } $st = if (-not $inForest) { '<span class="pill Erro">Não cadastrado no AD</span>' } elseif ($used) { '<span class="pill ok">Válido para a nuvem</span>' } elseif (-not $routable) { '<span class="pill Aviso">Não sincroniza como UPN</span>' } else { '<span class="pill Aviso">Não incluído na avaliação</span>' } "<tr><td>$(Get-HtmlSafe $s)</td><td>$tipo</td><td>$st</td></tr>" } if ($UpnSuffix.Count -eq 0) { $suffixRows = @('<tr><td colspan="3"><span class="pill Erro">Nenhum sufixo roteável cadastrado no AD</span> Cadastre o domínio público em Active Directory Domains and Trusts ou informe -UpnSuffix.</td></tr>') + @($suffixRows) } $ruleRows = foreach ($r in $ByRule) { "<tr><td><span class=""pill $($r.Severidade)"">$($r.Severidade)</span></td><td class=""code"">$($r.Regra)</td><td>$(Get-HtmlSafe $r.Descricao)</td><td class=""num"">$($r.Objetos)</td><td class=""num"">$($r.Ocorrencias)</td></tr>" } $detailRows = foreach ($i in $Sorted) { "<tr data-sev=""$($i.Severidade)""><td><span class=""pill $($i.Severidade)"">$($i.Severidade)</span></td><td class=""code"">$($i.Regra)</td><td>$(Get-HtmlSafe $i.TipoObjeto)</td><td title=""$(Get-HtmlSafe $i.DistinguishedName)""><strong>$(Get-HtmlSafe $i.Objeto)</strong></td><td>$(Get-HtmlSafe $i.Descricao)</td><td>$(Get-HtmlSafe $i.Atributo)</td><td class=""code"">$(Get-HtmlSafe $i.Valor)</td><td class=""code"">$(Get-HtmlSafe $i.Sugestao)</td></tr>" } $css = @' :root{--ink:#14324a;--paper:#f6f7f5;--sheet:#fff;--line:#d8ddd8;--text:#1e2a33;--muted:#5c6a74;--err:#b3261e;--errbg:#fbe9e7;--warn:#8a5a00;--warnbg:#fff3d6;--ok:#2e7d4f;--okbg:#e6f3eb} *{box-sizing:border-box} body{margin:0;background:var(--paper);color:var(--text);font:15px/1.5 "Segoe UI",Calibri,Arial,sans-serif} header{background:var(--ink);color:#fff;padding:32px 40px 28px} header .meta{opacity:.75;font-size:13px;margin:0 0 14px} header h1{margin:0;font-size:30px;line-height:1.2;font-weight:600;max-width:900px} .bar{display:flex;height:10px;border-radius:5px;overflow:hidden;margin:22px 0 10px;max-width:900px;background:rgba(255,255,255,.15)} .bar span{display:block}.bar .e{background:#e5675e}.bar .w{background:#e8b04b}.bar .o{background:#6cc08f} .legend{display:flex;gap:24px;flex-wrap:wrap;font-size:13px;opacity:.9} .legend b{font-size:18px;font-weight:600;margin-right:4px} main{max-width:1400px;margin:0 auto;padding:28px 40px 40px} section{background:var(--sheet);border:1px solid var(--line);border-radius:6px;padding:22px 24px;margin-bottom:22px} h2{font-size:18px;margin:0 0 4px;color:var(--ink)} .lead{color:var(--muted);margin:0 0 16px;font-size:14px} .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:22px} .tw{overflow-x:auto} table{width:100%;border-collapse:collapse;font-size:14px} th{text-align:left;font-weight:600;color:var(--muted);font-size:13px;padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap} td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top} tr:last-child td{border-bottom:0} td.num{text-align:right;font-variant-numeric:tabular-nums} td.code{font-family:Consolas,"Cascadia Mono",monospace;font-size:13px;word-break:break-all} .kv td:first-child{color:var(--muted);width:42%} .pill{display:inline-block;padding:1px 9px;border-radius:9px;font-size:12px;font-weight:600;white-space:nowrap} .pill.Erro{background:var(--errbg);color:var(--err)}.pill.Aviso{background:var(--warnbg);color:var(--warn)}.pill.ok{background:var(--okbg);color:var(--ok)} .tools{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px} .tools input,.tools select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:5px;background:#fff} .tools input{flex:1;min-width:240px} .tools input:focus,.tools select:focus{outline:2px solid var(--ink);outline-offset:1px} .count{color:var(--muted);font-size:13px;align-self:center} .empty{color:var(--muted);padding:12px 0} footer{color:var(--muted);font-size:12px;text-align:center;padding:0 0 28px} @media (max-width:700px){header{padding:24px 20px}main{padding:20px}header h1{font-size:24px}} @media print{body{background:#fff}.tools{display:none}header,.bar span,.pill{-webkit-print-color-adjust:exact;print-color-adjust:exact}section{break-inside:avoid-page}} '@ $js = @' function filtrar(){ var q=document.getElementById('q').value.toLowerCase(); var s=document.getElementById('sev').value; var n=0; document.querySelectorAll('#det tbody tr').forEach(function(r){ var show=r.textContent.toLowerCase().indexOf(q)>-1 && (!s || r.getAttribute('data-sev')===s); r.style.display=show?'':'none'; if(show){n++;} }); document.getElementById('cnt').textContent=n+' itens exibidos'; } '@ $detailSection = if ($Issues.Count -gt 0) { @" <div class="tools"> <input id="q" type="search" placeholder="Filtrar por objeto, regra, atributo ou valor" oninput="filtrar()" aria-label="Filtrar itens"> <select id="sev" onchange="filtrar()" aria-label="Severidade"> <option value="">Todas as severidades</option><option value="Erro">Somente erros</option><option value="Aviso">Somente avisos</option> </select> <span class="count" id="cnt">$($Issues.Count) itens exibidos</span> </div> <div class="tw"><table id="det"> <thead><tr><th>Severidade</th><th>Regra</th><th>Tipo</th><th>Objeto</th><th>Descrição</th><th>Atributo</th><th>Valor atual</th><th>Sugestão</th></tr></thead> <tbody> $($detailRows -join "`n") </tbody></table></div> "@ } else { '<p class="empty">Nenhum ponto de melhoria identificado no escopo avaliado.</p>' } $ruleSection = if ($ByRule) { @" <div class="tw"><table> <thead><tr><th>Severidade</th><th>Regra</th><th>Descrição</th><th>Objetos</th><th>Ocorrências</th></tr></thead> <tbody>$($ruleRows -join "`n")</tbody></table></div> "@ } else { '<p class="empty">Nenhuma regra disparada.</p>' } $html = @" <!DOCTYPE html> <html lang="pt-BR"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>SyncCheck - $(Get-HtmlSafe $Cliente)</title> <style>$css</style> </head> <body> <header> <p class="meta">Prontidão para Microsoft Entra Connect | $(Get-HtmlSafe $Cliente) | $($Domain.DNSRoot) | $($StartTime.ToString('dd/MM/yyyy HH:mm'))</p> <h1>$(Get-HtmlSafe $Headline)</h1> <div class="bar" role="img" aria-label="Distribuição dos objetos avaliados"> <span class="e" style="width:$($pctErr.ToString($inv))%"></span><span class="w" style="width:$($pctWarn.ToString($inv))%"></span><span class="o" style="width:$($pctOk.ToString($inv))%"></span> </div> <div class="legend"> <span><b>$ObjErr</b> com erro</span> <span><b>$ObjWarn</b> somente com aviso</span> <span><b>$ObjOk</b> prontos</span> <span><b>$Evaluated</b> objetos avaliados</span> </div> </header> <main> <div class="grid"> <section> <h2>Ambiente</h2> <p class="lead">Escopo e parâmetros usados nesta avaliação.</p> <table class="kv"> <tr><td>Floresta</td><td>$($Forest.Name) ($($Forest.ForestMode))</td></tr> <tr><td>Domínio</td><td>$($Domain.DNSRoot) / $($Domain.NetBIOSName) ($($Domain.DomainMode))</td></tr> <tr><td>Escopo</td><td>$(Get-HtmlSafe $Scope)</td></tr> <tr><td>Usuários / grupos / contatos</td><td>$($Users.Count) / $($Groups.Count) / $($Contacts.Count)</td></tr> <tr><td>Contas de sistema ignoradas</td><td>$Skipped</td></tr> <tr><td>Esquema do Exchange</td><td>$ExchangeSchema</td></tr> <tr><td>Usuários desabilitados</td><td>$(if ($IncludeDisabled) { 'Incluídos' } else { 'Não avaliados' })</td></tr> </table> </section> <section> <h2>Sufixos UPN</h2> <p class="lead">$(Get-HtmlSafe $SuffixSource).</p> <table><thead><tr><th>Sufixo</th><th>Tipo</th><th>Situação</th></tr></thead><tbody>$($suffixRows -join "`n")</tbody></table> </section> </div> <section> <h2>Resumo por regra</h2> <p class="lead">Erros impedem ou corrompem a sincronização do objeto. Avisos indicam risco de perda de dados ou inconsistência.</p> $ruleSection </section> <section> <h2>Itens avaliados com pontos de melhoria</h2> <p class="lead">Passe o mouse sobre o objeto para ver o DN completo. As sugestões são indicativas e devem ser revisadas antes de qualquer alteração.</p> $detailSection </section> </main> <footer>Gerado por SyncCheck $ToolVersion em $($StartTime.ToString('dd/MM/yyyy HH:mm')) | github.com/wjralmeida/SyncCheck | Ferramenta somente leitura</footer> <script>$js</script> </body> </html> "@ $html | Out-File -FilePath $HtmlPath -Encoding UTF8 # ============================================================================ # Resumo no console # ============================================================================ Write-Host "" Write-Host $Headline -ForegroundColor $(if ($ObjErr) { 'Red' } elseif ($ObjWarn) { 'Yellow' } else { 'Green' }) Write-Host (" Com erro: {0} Somente aviso: {1} Prontos: {2} Avaliados: {3}" -f $ObjErr, $ObjWarn, $ObjOk, $Evaluated) Write-Host (D " Sufixos válidos: $(if ($UpnSuffix.Count) { $UpnSuffix -join ', ' } else { '(nenhum)' }) [$SuffixSource]") -ForegroundColor Gray if ($UpnSuffix.Count -eq 0) { Write-Host (D " ATENÇÃO: nenhum sufixo roteável no AD. Cadastre o domínio público ou rode com -UpnSuffix dominio.com.br") -ForegroundColor Red } if ($MissingSuffixes.Count) { Write-Host (D " ATENÇÃO: sufixo(s) informado(s) e não cadastrado(s) no AD: $($MissingSuffixes -join ', ')") -ForegroundColor Red } if ($ByRule) { Write-Host "" $ByRule | Format-Table Severidade, Regra, Descricao, Objetos, Ocorrencias -AutoSize | Out-String -Width 220 | Write-Host } Write-Host (D "Relatório HTML: $HtmlPath") -ForegroundColor Green if ($Issues.Count -gt 0) { Write-Host "CSV: $CsvPath" -ForegroundColor Green } if ($OpenReport) { Invoke-Item $HtmlPath } if ($PassThru) { $Sorted } } Export-ModuleMember -Function Invoke-SyncCheck |