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 & 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') 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 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 { } return ,@{ HybridIdentity=@($hybrid); AzureAttachment=@($attach) } } 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 } 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' |