modules/AzureHybrid/AzureHybrid.psm1
|
<#
AzureHybrid.psm1 - hybrid identity and cloud-attachment discovery (read-only). Produces: HybridIdentity, AzureAttachment. Why this module exists: "is this server doing anything for Microsoft 365?" is a question every migration, refresh and decommission conversation reaches within about ten minutes, and the toolkit had no answer. Entra Connect (Azure AD Connect) in particular is the classic decommission landmine - it looks like an ordinary member server, it holds a role nothing else holds, and turning it off stops directory synchronisation for the whole tenant with no error anyone notices until password changes stop replicating. Detects: Entra Connect / Azure AD Connect (incl. the lightweight sync agents), AD FS, Entra Connect Health, Azure Arc, Azure App Proxy connectors, Entra hybrid join state via 'dsregcmd /status', and Azure/M365 endpoints already recorded by other collectors. Reads only. dsregcmd is invoked with /status, which is a read verb. No tenant IDs, device certificates, or tokens are exported - only whether a state is configured. #> function Get-DiscoveryModuleMetadata { [pscustomobject]@{ ModuleName='AzureHybrid'; DisplayName='Hybrid Identity, Entra Connect & Cloud Attachment'; Category='Identity'; Version='1.0.0' DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true ProducesDatasets=@('HybridIdentity','AzureAttachment','EntraConnectDetail') ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$true } } function Test-DiscoveryPrerequisites { param([object]$Context) [pscustomobject]@{ ModuleName='AzureHybrid'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } } function Test-AhEvidence { <# Looks for a component across the Services and InstalledApplications datasets that earlier collectors already produced, plus an optional registry path. Returns @{ Found; Evidence; Running } so every row carries why it fired and whether the strongest evidence was an actually-running service, as opposed to a role/feature that was installed (service present, registry key present) but never configured into a working instance - a real and common gap (e.g. AD FS installed for lab/test coverage with no farm ever created). #> param([object]$Context, [string]$Pattern, [string[]]$RegistryPaths = @()) $ev = @() $running = $false try { if ($Context.DataSets.Contains('Services')) { $hit = @($Context.DataSets['Services'].Rows | Where-Object { $_.DisplayName -match $Pattern -or $_.Name -match $Pattern }) if ($hit.Count -gt 0) { $ev += ('service:' + $hit[0].Name) $running = [bool]($hit | Where-Object { [string]$_.Status -eq 'Running' }) } } if ($Context.DataSets.Contains('InstalledApplications')) { $hit = @($Context.DataSets['InstalledApplications'].Rows | Where-Object { $_.DisplayName -match $Pattern }) if ($hit.Count -gt 0) { $ev += ('app:' + $hit[0].DisplayName) } } } catch { } foreach ($rp in $RegistryPaths) { if ($rp -and (Test-RegistryPathSafe $rp)) { $ev += ('registry:' + $rp) } } return @{ Found = [bool]($ev.Count -gt 0); Evidence = ($ev -join '; '); Running = $running } } function Get-EntraConnectDetail { <# One row describing the Entra Connect (Azure AD Connect) sync server, or $null when there is no sign of one. Reads only: service state, installed version, the ADSync module's Get-* cmdlets (scheduler/staging, last run results) in a time-bounded Windows PowerShell child, an LDAP lookup for the Seamless SSO computer account, and Application-log error counts. Connector configuration, credentials and the sync database are never touched. #> param([object]$Context) $svcRows = @(if ($Context.DataSets.Contains('Services')) { @($Context.DataSets['Services'].Rows) } else { @() }) $appRows = @(if ($Context.DataSets.Contains('InstalledApplications')) { @($Context.DataSets['InstalledApplications'].Rows) } else { @() }) $sync = @($svcRows | Where-Object { $_.Name -eq 'ADSync' } | Select-Object -First 1) $app = @($appRows | Where-Object { $_.DisplayName -match '(?i)^Microsoft (Azure AD|Entra) Connect( Sync)?$|^Microsoft Azure AD Connect synchronization services$' } | Sort-Object DisplayVersion -Descending | Select-Object -First 1) $pta = @($svcRows | Where-Object { $_.Name -match '(?i)^AzureADConnectAuthenticationAgent' }) $updater = @($svcRows | Where-Object { $_.Name -match '(?i)^AzureADConnectAgentUpdater' }) $health = @($svcRows | Where-Object { $_.Name -match '(?i)^AzureADConnectHealth' } | ForEach-Object { [string]$_.Name }) if ($sync.Count -eq 0 -and $app.Count -eq 0 -and $pta.Count -eq 0) { return $null } $row = [ordered]@{ SyncEngineInstalled=[bool]($sync.Count -gt 0 -or $app.Count -gt 0) ProductName=$(if ($app.Count) { [string]$app[0].DisplayName } else { '' }); Version=$(if ($app.Count) { [string]$app[0].DisplayVersion } else { '' }) SyncServiceState=$(if ($sync.Count) { [string]$sync[0].Status } else { 'NotInstalled' }); SyncServiceStartMode=$(if ($sync.Count) { [string]$sync[0].StartMode } else { '' }) SyncServiceAccount=$(if ($sync.Count) { [string]$sync[0].StartName } else { '' }) StagingModeEnabled='Unknown'; SchedulerEnabled='Unknown'; NextSyncUtc=''; LastRunProfile=''; LastRunResult=''; LastRunTime='' PassThroughAgentPresent=[bool]($pta.Count -gt 0); AgentUpdaterPresent=[bool]($updater.Count -gt 0); HealthAgents=($health -join ', ') SeamlessSsoAccountFound='Unknown'; SyncErrorEventsInWindow=$null; EventWindowDays=$null; DetailSource='' } if ($sync.Count -gt 0) { $res = Invoke-WindowsPowerShellJson -TimeoutSeconds 90 -Script @' Import-Module ADSync -ErrorAction Stop $s = Get-ADSyncScheduler $r = $null; try { $r = Get-ADSyncRunProfileResult -NumberRequested 1 | Select-Object -First 1 } catch { } [pscustomobject]@{ Staging=[bool]$s.StagingModeEnabled; Enabled=[bool]$s.SyncCycleEnabled; Next=[string]$s.NextSyncCycleStartTimeInUTC Profile=$(if ($r) { [string]$r.RunProfileName } else { '' }); Result=$(if ($r) { [string]$r.Result } else { '' }); Ended=$(if ($r) { [string]$r.EndDate } else { '' }) } '@ if ($res) { $row.StagingModeEnabled = [string]$res.Staging; $row.SchedulerEnabled = [string]$res.Enabled; $row.NextSyncUtc = [string]$res.Next $row.LastRunProfile = [string]$res.Profile; $row.LastRunResult = [string]$res.Result; $row.LastRunTime = [string]$res.Ended $row.DetailSource = 'ADSync module (Get-ADSyncScheduler)' } else { Add-Limitation -Context $Context -Module 'AzureHybrid' -Message 'Entra Connect is installed but the ADSync module could not be read (needs elevation and ADSyncAdmins membership); staging mode and last sync are unknown.' | Out-Null $row.DetailSource = 'Service and registry only' } } # Seamless SSO creates the AZUREADSSOACC computer account in AD; an LDAP read any domain member can do. try { $searcher = [adsisearcher]'(&(objectCategory=computer)(sAMAccountName=AZUREADSSOACC$))' $searcher.PageSize = 1; $searcher.ClientTimeout = [TimeSpan]::FromSeconds(15) $row.SeamlessSsoAccountFound = [string][bool]$searcher.FindOne() } catch { } try { $days = [int]$Context.Parameters['EventLogDays']; if ($days -le 0) { $days = 14 } $row.EventWindowDays = $days $ev = @() try { $ev = @(Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName=@('ADSync','Directory Synchronization'); Level=@(1,2); StartTime=(Get-Date).AddDays(-$days) } -MaxEvents 2000 -ErrorAction Stop) } catch { if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } } $row.SyncErrorEventsInWindow = $ev.Count } catch { } return [pscustomobject]$row } function Invoke-DiscoveryCollection { param([object]$Context) $hybrid = [System.Collections.Generic.List[object]]::new() $attach = [System.Collections.Generic.List[object]]::new() $catalog = @( @{ Name='Entra Connect (Azure AD Connect)'; Role='Directory synchronisation' Pattern='(?i)Microsoft Azure AD Sync|ADSync|Azure AD Connect|Entra Connect' Reg=@('HKLM:\SOFTWARE\Microsoft\Azure AD Connect','HKLM:\SYSTEM\CurrentControlSet\Services\ADSync') WhyItMatters='This server synchronises on-premises identities to the cloud tenant. Only one active sync server exists per tenant; stopping it silently stops synchronisation, including password writeback and hash sync.' Question='Is this the active Entra Connect / Azure AD Connect server for the tenant, is staging mode in use, and where does synchronisation move to?' } @{ Name='Entra Connect Health agent'; Role='Monitoring' Pattern='(?i)Azure AD Connect Health|AzureADConnectHealth' Reg=@(); WhyItMatters='Health reporting for sync/AD FS; re-registers against the tenant when the host changes.' Question='Who owns the Entra Connect Health registration for this server?' } @{ Name='Entra Connect cloud sync / provisioning agent'; Role='Directory synchronisation' Pattern='(?i)Microsoft Azure AD Connect Provisioning Agent|AADConnectProvisioningAgent' Reg=@(); WhyItMatters='Lightweight cloud sync agent; the same tenant-wide dependency as Entra Connect but far easier to overlook.' Question='Is cloud sync provisioning running from this server, and is there a second agent for redundancy?' } @{ Name='Active Directory Federation Services (AD FS)'; Role='Federated authentication' Pattern='(?i)^adfssrv$|Active Directory Federation Services' Reg=@('HKLM:\SOFTWARE\Microsoft\ADFS') WhyItMatters='AD FS authenticates federated sign-in. Retiring or moving it breaks sign-in for every relying party, and its token-signing certificates and farm configuration must move with it.' Question='Which applications rely on this AD FS farm, and is a migration to managed authentication planned?' } @{ Name='Entra Application Proxy connector'; Role='Published application access' Pattern='(?i)Microsoft AAD Application Proxy Connector|WAPCSvc|ApplicationProxyConnector' Reg=@(); WhyItMatters='Publishes internal applications to the internet through the tenant; removing the connector takes those applications offline externally.' Question='Which internal applications are published through the Application Proxy connector on this server?' } @{ Name='Web Application Proxy (WAP)'; Role='Reverse proxy' Pattern='(?i)Web Application Proxy|appproxysvc' Reg=@(); WhyItMatters='Edge reverse proxy, usually paired with AD FS; part of the same authentication path.' Question='Is this WAP server paired with AD FS, and does it sit in a DMZ with firewall rules that must be reproduced?' } @{ Name='Azure Arc connected machine agent'; Role='Cloud management' Pattern='(?i)Azure Connected Machine|himds|GCArcService' Reg=@('HKLM:\SOFTWARE\Microsoft\AzureConnectedMachineAgent') WhyItMatters='The server is projected into Azure for policy, monitoring or update management; the Arc resource does not follow a rebuild automatically.' Question='What is this server enrolled in Azure Arc for, and who owns the Arc resource?' } @{ Name='Microsoft Entra Private Access / Global Secure Access connector'; Role='Network access' Pattern='(?i)Global Secure Access|Microsoft Entra Private Access' Reg=@(); WhyItMatters='Network access broker; removing it cuts the access path it brokers.' Question='Which access paths depend on this connector?' } @{ Name='Microsoft 365 / Exchange hybrid components'; Role='Mail hybrid' Pattern='(?i)Microsoft Exchange|Hybrid Configuration' Reg=@('HKLM:\SOFTWARE\Microsoft\ExchangeServer') WhyItMatters='An on-premises Exchange presence, even a management-only hybrid server, holds recipient management authority and cannot simply be powered off.' Question='Is Exchange here a full mailbox server or a hybrid management server, and what is the decommission plan?' } ) foreach ($c in $catalog) { $res = Test-AhEvidence -Context $Context -Pattern $c.Pattern -RegistryPaths $c.Reg if (-not $res.Found) { continue } # A service that's merely present (installed but stopped/disabled, or evidence limited # to a registry key/app entry) means the role exists on the box, not that it's an active, # configured instance - those are two very different things to hand a client as fact. $confidence = if ($res.Running) { 'Likely' } else { 'Possible' } $hybrid.Add([pscustomobject]@{ Component = $c.Name Role = $c.Role Evidence = $res.Evidence Confidence = $confidence WhyItMatters = $c.WhyItMatters ValidationQuestion = $c.Question }) Add-DependencyEdge -Context $Context -SourceType 'HybridIdentity' -SourceName $c.Name ` -DependencyType 'ConnectsTo' -Target 'Microsoft cloud tenant' -Evidence $res.Evidence ` -Confidence $confidence -SourceDataset 'HybridIdentity' -ProjectImpact 'Architecture Decision' ` -ValidationQuestion $c.Question | Out-Null Add-Unknown -Context $Context -Unknown ("{0} is present on this server; the tenant-side configuration behind it is not discoverable locally." -f $c.Name) ` -WhyItMatters $c.WhyItMatters -Module 'AzureHybrid' -RecommendedValidationQuestion $c.Question | Out-Null } # ---- Device join state via dsregcmd (read verb) ------------------------- # Booleans only. The tenant ID, device ID and certificate thumbprints that # dsregcmd also prints are deliberately not collected - join STATE is the # scoping fact; the identifiers are tenant data with no scoping value. try { if (Get-CommandAvailable -Name 'dsregcmd.exe') { $r = Invoke-CommandLineSafe -FilePath 'dsregcmd.exe' -Arguments @('/status') -TimeoutSeconds 45 if ($r.Succeeded -and $r.StdOut) { $get = { param($label) $m = [regex]::Match($r.StdOut, ('(?im)^\s*' + [regex]::Escape($label) + '\s*:\s*(\S+)\s*$')) if ($m.Success) { return $m.Groups[1].Value } else { return '' } } $attach.Add([pscustomobject]@{ Item='Entra joined'; Value=(& $get 'AzureAdJoined'); Evidence='dsregcmd /status' WhyItMatters='Indicates the device identity lives in the cloud tenant rather than only in AD.' }) $attach.Add([pscustomobject]@{ Item='Domain joined'; Value=(& $get 'DomainJoined'); Evidence='dsregcmd /status' WhyItMatters='Combined with Entra joined, distinguishes hybrid join from pure on-premises.' }) $attach.Add([pscustomobject]@{ Item='Enterprise joined'; Value=(& $get 'EnterpriseJoined'); Evidence='dsregcmd /status' WhyItMatters='On-premises DRS join, relevant to conditional access paths.' }) $attach.Add([pscustomobject]@{ Item='Workplace joined'; Value=(& $get 'WorkplaceJoined'); Evidence='dsregcmd /status' WhyItMatters='Registered rather than joined; affects how policy applies.' }) } else { Add-Limitation -Context $Context -Module 'AzureHybrid' -Message 'dsregcmd /status returned no usable output.' | Out-Null } } } catch { Add-Limitation -Context $Context -Module 'AzureHybrid' -Message 'dsregcmd query failed.' -Reason $_.Exception.Message | Out-Null } # ---- Cloud endpoints already seen by the config scan ------------------- # Reuses ConfigDependencyHints rather than rescanning: if a config file points at # a Microsoft cloud endpoint, that is a cloud attachment worth surfacing here. try { if ($Context.DataSets.Contains('ConfigDependencyHints')) { $cloudRx = '(?i)(login\.microsoftonline\.com|\.onmicrosoft\.com|graph\.microsoft\.com|\.blob\.core\.windows\.net|\.azurewebsites\.net|\.database\.windows\.net|outlook\.office365\.com)' $hits = @($Context.DataSets['ConfigDependencyHints'].Rows | Where-Object { $_.RedactedLine -match $cloudRx }) if ($hits.Count -gt 0) { $attach.Add([pscustomobject]@{ Item='Microsoft cloud endpoints referenced in configuration files' Value=("{0} configuration line(s) across {1} file(s)" -f $hits.Count, (@($hits | Select-Object -ExpandProperty FilePath -Unique)).Count) Evidence='ConfigDependencyHints' WhyItMatters='An application on this server talks to a cloud service directly; that integration has to be re-pointed or re-authorised after a move.' }) } } } catch { } $entra = @() try { $d = Get-EntraConnectDetail -Context $Context; if ($d) { $entra = @($d) } } catch { Add-Limitation -Context $Context -Module 'AzureHybrid' -Message 'Entra Connect detail collection failed.' -Reason $_.Exception.Message | Out-Null } return ,@{ HybridIdentity=@($hybrid); AzureAttachment=@($attach); EntraConnectDetail=@($entra) } } function ConvertTo-DiscoveryDatasets { param([object]$Context, $RawData) if (-not $RawData) { $RawData = @{} } $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } } Add-DataSet -Context $Context -Name 'HybridIdentity' -Description 'Hybrid identity components (Entra Connect, AD FS, connectors).' -Rows (& $get 'HybridIdentity') -Visibility 'Both' -SourceModule 'AzureHybrid' | Out-Null Add-DataSet -Context $Context -Name 'AzureAttachment' -Description 'Cloud join state and cloud endpoint attachment indicators.' -Rows (& $get 'AzureAttachment') -Visibility 'Internal' -SourceModule 'AzureHybrid' | Out-Null Add-DataSet -Context $Context -Name 'EntraConnectDetail' -Description 'Entra Connect sync server detail (version, service, staging mode, agents, Seamless SSO indicator).' -Rows (& $get 'EntraConnectDetail') -Visibility 'Internal' -SourceModule 'AzureHybrid' | Out-Null } function Get-DiscoveryFollowUpQuestions { param([object]$Context) try { if ($Context.DataSets.Contains('HybridIdentity') -and @($Context.DataSets['HybridIdentity'].Rows).Count -gt 0) { Add-FollowUpQuestion -Context $Context -Category 'Critical systems' -Module 'AzureHybrid' -Audience 'Both' ` -Question 'This server appears to connect your on-premises environment to Microsoft 365 / Azure. Who administers the cloud tenant, and has anyone confirmed what stops working if this server is changed or retired?' | Out-Null } } catch { } } Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-DiscoveryFollowUpQuestions','Test-AhEvidence','Get-EntraConnectDetail' |