modules/ActiveDirectory/ActiveDirectory.psm1
|
<#
ActiveDirectory.psm1 - domain context and (if a DC) directory role details (read-only). Produces: DomainContext, AppliedGroupPolicy, DomainControllerDiscovery. Uses built-in methods first; uses the ActiveDirectory module only if present. #> function Get-DiscoveryModuleMetadata { [pscustomobject]@{ ModuleName='ActiveDirectory'; DisplayName='Active Directory & Migration Readiness'; Category='Identity'; Version='1.0.0' DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true ProducesDatasets=@('DomainContext','AppliedGroupPolicy','DomainControllerDiscovery','AdDomainControllers','AdFsmoRoles','AdReplicationSummary','AdHealthChecks','GpoInventory','GpoServerReferences') ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$true } } function Test-DiscoveryPrerequisites { param([object]$Context) [pscustomobject]@{ ModuleName='ActiveDirectory'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } } function ConvertTo-AdFunctionalLevelName { <# Maps the numeric domainFunctionality/forestFunctionality RootDSE attribute to its name. #> param($Level) if ($null -eq $Level) { return '' } $names = @{ 0='Windows2000'; 1='Windows2003Interim'; 2='Windows2003'; 3='Windows2008'; 4='Windows2008R2'; 5='Windows2012'; 6='Windows2012R2'; 7='Windows2016'; 10='Windows2025' } $n = 0 if ([int]::TryParse([string]$Level, [ref]$n) -and $names.ContainsKey($n)) { return $names[$n] } return ("Level{0}" -f $Level) } function Test-AdDcOsUnsupported { <# Pure: true when a DC's operatingSystem string is past Microsoft extended support as of -AsOf. Unknown text is never called unsupported. #> param([string]$OperatingSystem, [datetime]$AsOf = (Get-Date)) $ends = [ordered]@{ '2003'='2015-07-14'; '2008'='2020-01-14'; '2012'='2023-10-10'; '2016'='2027-01-12'; '2019'='2029-01-09'; '2022'='2031-10-14' } foreach ($k in $ends.Keys) { if ($OperatingSystem -match ("Server\D*{0}" -f $k)) { return ([datetime]$ends[$k] -lt $AsOf) } } return $false } function ConvertFrom-NetdomFsmo { <# Pure: netdom query fsmo text -> one row per role with whole-host-label ownership matching. #> param([string]$Text, [string[]]$MyNames = @($env:COMPUTERNAME)) $rows = [System.Collections.Generic.List[object]]::new() foreach ($ln in ([string]$Text -split "`r?`n")) { $m = [regex]::Match($ln, '^\s*(Schema master|Domain naming master|PDC|RID pool manager|Infrastructure master)\s+(\S+)\s*$', 'IgnoreCase') if (-not $m.Success) { continue } $holder = $m.Groups[2].Value $mine = $false foreach ($n in @($MyNames | Where-Object { $_ })) { if ($holder -match ('(?i)^' + [regex]::Escape($n) + '(\.|$)')) { $mine = $true } } $rows.Add([pscustomobject]@{ Role=$m.Groups[1].Value; Holder=$holder; HeldByThisServer=$mine }) } return ,@($rows) } function ConvertFrom-RepadminReplSummary { <# Pure: repadmin /replsummary text -> one row per source/destination DSA. #> param([string]$Text) $rows = [System.Collections.Generic.List[object]]::new() $direction = '' foreach ($ln in ([string]$Text -split "`r?`n")) { if ($ln -match '^\s*Source DSA') { $direction = 'Source'; continue } if ($ln -match '^\s*Destination DSA') { $direction = 'Destination'; continue } if (-not $direction) { continue } # The delta column can contain a space ('>60 days'), so it is matched lazily. $m = [regex]::Match($ln, '^\s*(\S+)\s+(.+?)\s+(\d+)\s*/\s*(\d+)\s+\d+\s*(.*)$') if ($m.Success) { $rows.Add([pscustomobject]@{ Direction=$direction; Dsa=$m.Groups[1].Value; LargestDelta=$m.Groups[2].Value; Fails=[int]$m.Groups[3].Value; Total=[int]$m.Groups[4].Value; Error=$m.Groups[5].Value.Trim() }) } } return ,@($rows) } function Get-AdDcInventory { <# Every DC in the domain via plain LDAP reads (no RSAT / ActiveDirectory module): computer objects in the DC/RODC primary groups for name + OS, then nTDSDSA objects in the configuration partition for site and Global Catalog. Returns $null when the directory cannot be read, so callers can tell "no DCs found" from "could not ask". #> param() try { $root = [ADSI]'LDAP://RootDSE' $defaultNc = [string]$root.Properties['defaultNamingContext'][0] $configNc = [string]$root.Properties['configurationNamingContext'][0] if (-not $defaultNc) { return $null } $byName = @{} $s = New-Object System.DirectoryServices.DirectorySearcher([ADSI]"LDAP://$defaultNc", '(&(objectCategory=computer)(|(primaryGroupID=516)(primaryGroupID=521)))') $s.PageSize = 200; $s.ClientTimeout = [TimeSpan]::FromSeconds(30) foreach ($p in 'name','dNSHostName','operatingSystem','operatingSystemVersion','primaryGroupID') { [void]$s.PropertiesToLoad.Add($p) } foreach ($r in $s.FindAll()) { $n = [string]$r.Properties['name'][0] $byName[$n.ToUpperInvariant()] = [pscustomobject]@{ Name=$n; DnsHostName=[string]$r.Properties['dnshostname'][0]; OperatingSystem=[string]$r.Properties['operatingsystem'][0] OperatingSystemVersion=[string]$r.Properties['operatingsystemversion'][0]; Site=''; IsGlobalCatalog=$false IsReadOnly=[bool]([int]$r.Properties['primarygroupid'][0] -eq 521); IsThisServer=[bool]($n -ieq $env:COMPUTERNAME) IsUnsupportedOs=(Test-AdDcOsUnsupported -OperatingSystem ([string]$r.Properties['operatingsystem'][0])) } } if ($configNc) { $d = New-Object System.DirectoryServices.DirectorySearcher([ADSI]"LDAP://CN=Sites,$configNc", '(objectCategory=nTDSDSA)') $d.PageSize = 200; $d.ClientTimeout = [TimeSpan]::FromSeconds(30) [void]$d.PropertiesToLoad.Add('distinguishedName'); [void]$d.PropertiesToLoad.Add('options') foreach ($r in $d.FindAll()) { $m = [regex]::Match([string]$r.Properties['distinguishedname'][0], '^CN=NTDS Settings,CN=([^,]+),CN=Servers,CN=([^,]+),') if (-not $m.Success) { continue } $dc = $byName[$m.Groups[1].Value.ToUpperInvariant()] if ($dc) { $dc.Site = $m.Groups[2].Value; $dc.IsGlobalCatalog = [bool](([int]$r.Properties['options'][0]) -band 1) } } } return ,@($byName.Values | Sort-Object Name) } catch { return $null } } #region GPO content inspection (read-only SYSVOL reads) -------------------------- # Group Policy Preference files and the setting type each represents. $script:GpoPreferenceKinds = @{ 'Drives.xml'='Drive map'; 'Printers.xml'='Printer'; 'Shortcuts.xml'='Shortcut'; 'Files.xml'='File copy'; 'Folders.xml'='Folder' 'ScheduledTasks.xml'='Scheduled task'; 'DataSources.xml'='ODBC data source'; 'Registry.xml'='Registry'; 'EnvironmentVariables.xml'='Environment variable' 'IniFiles.xml'='INI file'; 'Services.xml'='Service'; 'Groups.xml'='Local group'; 'NetworkShares.xml'='Network share' } function Get-GpoHostFromText { <# Pure: every server name referenced by a UNC path (\\host\...) or a bare host/IP value. #> param([string]$Text, [switch]$BareHost) $hosts = [System.Collections.Generic.List[string]]::new() if ([string]::IsNullOrWhiteSpace($Text)) { return ,@() } foreach ($m in [regex]::Matches($Text, '\\\\([A-Za-z0-9][A-Za-z0-9._-]*)\\')) { $h = $m.Groups[1].Value; if ($h -notmatch '^(\?|\.)$' -and -not $hosts.Contains($h)) { $hosts.Add($h) } } if ($BareHost -and $hosts.Count -eq 0) { $t = (($Text.Trim() -split '[\\,:;\s]')[0]); if ($t -match '^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$' -and $t -notmatch '^\d+$') { $hosts.Add($t) } } return ,@($hosts) } function Get-GpoFileReferences { <# Server references inside ONE policy file (pure apart from reading it). Handles Group Policy Preference XML, scripts.ini / psscripts.ini, folder-redirection fdeploy*.ini and the binary Registry.pol (scanned as UTF-16 text for UNC paths only). Credential attributes (cpassword, password, ...) are never read into a row: a non-empty cpassword only sets HasStoredPassword. Script 'Parameters' lines are skipped (they can carry credentials). #> param([Parameter(Mandatory)][string]$Path, [string]$Scope = '', [object]$Context) $refs = [System.Collections.Generic.List[object]]::new() $hasPwd = $false $name = Split-Path -Leaf $Path $add = { param($kind, $item, $value, [switch]$Bare) foreach ($h in (Get-GpoHostFromText -Text $value -BareHost:$Bare)) { $refs.Add([pscustomobject]@{ Scope=$Scope; SettingType=$kind; Item=[string]$item; Reference=(Redact-SensitiveValue -InputString ([string]$value) -Context $Context); ReferencedHost=$h; SourceFile=$name }) } } if ($name -match '(?i)\.xml$') { $kind = if ($script:GpoPreferenceKinds.ContainsKey($name)) { $script:GpoPreferenceKinds[$name] } else { 'Preference' } [xml]$x = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop foreach ($el in $x.SelectNodes('//*')) { $owner = $el; while ($owner -and -not $owner.GetAttribute('name') -and $owner.ParentNode -is [System.Xml.XmlElement]) { $owner = $owner.ParentNode } $item = if ($owner -and $owner.GetAttribute('name')) { $owner.GetAttribute('name') } else { $el.LocalName } foreach ($a in @($el.Attributes)) { if ($a.Name -match '(?i)^(cpassword|password|pwd)$' -or (Test-SensitiveKeyLabel -Name $a.Name -Context $Context)) { if ($a.Name -ieq 'cpassword' -and $a.Value) { $hasPwd = $true }; continue } if ($a.Name -match '(?i)^(server|servername|host|hostname|ipaddress)$') { & $add $kind $item $a.Value -Bare } elseif ($a.Value -match '\\\\') { & $add $kind $item $a.Value } } # DataSources.xml keeps connection attributes as <Attribute name="SERVER" value="..."/>. if ($el.LocalName -eq 'Attribute' -and $el.GetAttribute('name') -match '(?i)^(server|servername|host|dbq)$') { & $add $kind $el.GetAttribute('name') $el.GetAttribute('value') -Bare:($el.GetAttribute('name') -notmatch '(?i)dbq') } } } elseif ($name -match '(?i)^(ps)?scripts\.ini$') { foreach ($ln in (Get-Content -LiteralPath $Path -ErrorAction Stop)) { if ($ln -match '(?i)^\s*\d+CmdLine\s*=\s*(.+)$') { & $add 'Logon/startup script' $Matches[1].Trim() $Matches[1] } } } elseif ($name -match '(?i)^fdeploy\d*\.ini$') { foreach ($ln in (Get-Content -LiteralPath $Path -ErrorAction Stop)) { if ($ln -match '\\\\') { & $add 'Folder redirection' ($ln -split '=')[0] $ln } } } elseif ($name -match '(?i)^registry\.pol$') { $text = [System.Text.Encoding]::Unicode.GetString([System.IO.File]::ReadAllBytes($Path)) foreach ($m in [regex]::Matches($text, '\\\\[A-Za-z0-9][A-Za-z0-9._-]*\\[^\x00\[\];]*')) { & $add 'Administrative template' '' $m.Value } } return [pscustomobject]@{ References=@($refs); HasStoredPassword=$hasPwd } } function Get-GpoContentInspection { <# Every GPO in the domain (LDAP: name, state, links) and the server references inside its SYSVOL files. Read-only and bounded (1000 GPOs, 5 MB per file, 120 s). References are flagged when they name this server by host name, FQDN or IP. Returns $null when the domain cannot be read. #> param([object]$Context, [string[]]$MyNames = @(), [string[]]$MyAddresses = @(), [int]$TimeBudgetSeconds = 120) $sw = [System.Diagnostics.Stopwatch]::StartNew() $inv = [System.Collections.Generic.List[object]]::new(); $refs = [System.Collections.Generic.List[object]]::new(); $capped = $false try { $root = [ADSI]'LDAP://RootDSE' $nc = [string]$root.Properties['defaultNamingContext'][0] if (-not $nc) { return $null } $links = @{} $ls = New-Object System.DirectoryServices.DirectorySearcher([ADSI]"LDAP://$nc", '(gPLink=*)') $ls.PageSize = 500; $ls.ClientTimeout = [TimeSpan]::FromSeconds(30); [void]$ls.PropertiesToLoad.Add('gPLink'); [void]$ls.PropertiesToLoad.Add('distinguishedName') foreach ($r in $ls.FindAll()) { $dn = [string]$r.Properties['distinguishedname'][0] foreach ($m in [regex]::Matches([string]$r.Properties['gplink'][0], '(?i)cn=(\{[0-9a-f-]+\}),[^;]*;(\d)')) { $g = $m.Groups[1].Value.ToUpperInvariant() if (-not $links.ContainsKey($g)) { $links[$g] = [System.Collections.Generic.List[string]]::new() } # Link option bit 1 = link disabled. if (-not ([int]$m.Groups[2].Value -band 1)) { $links[$g].Add($dn) } } } $gs = New-Object System.DirectoryServices.DirectorySearcher([ADSI]"LDAP://CN=Policies,CN=System,$nc", '(objectClass=groupPolicyContainer)') $gs.PageSize = 500; $gs.ClientTimeout = [TimeSpan]::FromSeconds(30) foreach ($p in 'name','displayName','gPCFileSysPath','flags','whenChanged') { [void]$gs.PropertiesToLoad.Add($p) } $mine = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($n in @($MyNames) + @($MyAddresses)) { if ($n) { [void]$mine.Add($n) } } $n = 0 foreach ($g in $gs.FindAll()) { if (++$n -gt 1000 -or $sw.Elapsed.TotalSeconds -ge $TimeBudgetSeconds) { $capped = $true; break } $guid = ([string]$g.Properties['name'][0]).ToUpperInvariant(); $display = [string]$g.Properties['displayname'][0]; $sysvol = [string]$g.Properties['gpcfilesyspath'][0] $flags = 0; [void][int]::TryParse([string]$g.Properties['flags'][0], [ref]$flags) $linked = @(if ($links.ContainsKey($guid)) { $links[$guid] } else { @() }) $hasGppPwd = $false; $gpoRefCount = 0; $readable = $true try { foreach ($f in @(Get-ChildItem -LiteralPath $sysvol -Recurse -File -ErrorAction Stop | Where-Object { $_.Length -le 5MB -and ($_.Name -match '(?i)\.xml$|^(ps)?scripts\.ini$|^fdeploy\d*\.ini$|^registry\.pol$') })) { if ($sw.Elapsed.TotalSeconds -ge $TimeBudgetSeconds) { $capped = $true; break } $scope = if ($f.FullName -match '(?i)\\User\\') { 'User' } else { 'Computer' } try { $r = Get-GpoFileReferences -Path $f.FullName -Scope $scope -Context $Context if ($r.HasStoredPassword) { $hasGppPwd = $true } foreach ($x in $r.References) { $hostLabel = ($x.ReferencedHost -split '\.')[0] $refs.Add([pscustomobject]@{ GpoName=$display; GpoGuid=$guid; GpoLinked=[bool]$linked.Count; Scope=$x.Scope; SettingType=$x.SettingType; Item=$x.Item Reference=$x.Reference; ReferencedHost=$x.ReferencedHost; SourceFile=$x.SourceFile ReferencesThisServer=[bool]($mine.Contains($x.ReferencedHost) -or $mine.Contains($hostLabel)) }); $gpoRefCount++ } } catch { } } } catch { $readable = $false } $inv.Add([pscustomobject]@{ GpoName=$display; GpoGuid=$guid; LinkCount=$linked.Count; IsLinked=[bool]$linked.Count; LinkedTo=((@($linked | Select-Object -First 5)) -join '; ') UserSettingsDisabled=[bool]($flags -band 1); ComputerSettingsDisabled=[bool]($flags -band 2) WhenChanged=(Normalize-DateTime $g.Properties['whenchanged'][0]); SysvolReadable=$readable ServerReferenceCount=$gpoRefCount; HasGppPasswords=$hasGppPwd }) } } catch { return $null } return @{ Inventory=@($inv); References=@($refs); Capped=$capped } } #endregion function Invoke-DiscoveryCollection { param([object]$Context) $ctxRows = [System.Collections.Generic.List[object]]::new() $gpos = [System.Collections.Generic.List[object]]::new() $dcs = [System.Collections.Generic.List[object]]::new() $fsmoRows = @(); $replRows = @(); $health = [System.Collections.Generic.List[object]]::new(); $dcInventory = @() $sysvolReplication = '' $cs = Invoke-CimSafe -ClassName 'Win32_ComputerSystem' | Select-Object -First 1 $domainRole = if ($cs) { [int]$cs.DomainRole } else { -1 } $roleName = switch ($domainRole) { 0 {'Standalone Workstation'} 1 {'Member Workstation'} 2 {'Standalone Server'} 3 {'Member Server'} 4 {'Backup Domain Controller'} 5 {'Primary Domain Controller'} default {'Unknown'} } $isDc = ($domainRole -eq 4 -or $domainRole -eq 5) $partOfDomain = if ($cs) { [bool]$cs.PartOfDomain } else { $false } $domainName = if ($cs) { $cs.Domain } else { $env:USERDNSDOMAIN } $holdsFsmo = $false $fsmoDetail = '' if ($isDc) { try { $r = Invoke-CommandLineSafe -FilePath 'netdom.exe' -Arguments @('query','fsmo') -TimeoutSeconds 45 if ($r.Succeeded -and $r.StdOut) { $fsmoRows = ConvertFrom-NetdomFsmo -Text $r.StdOut -MyNames @($env:COMPUTERNAME, $cs.DNSHostName) $me = $env:COMPUTERNAME $fsmoDetail = ($r.StdOut -split "`r?`n" | Where-Object { $_.Trim() -and $_ -notmatch 'command completed' } | ForEach-Object { $_.Trim() -replace '\s{2,}',': ' } | Select-Object -First 6) -join '; ' # Whole host label, NetBIOS or DNS name: a bare substring match let DC1 "hold" DC10's roles. foreach ($n in @($me, $cs.DNSHostName) | Where-Object { $_ }) { if ($r.StdOut -match ('(?im)(^|[\s:])' + [regex]::Escape($n) + '(\.|\s|$)')) { $holdsFsmo = $true } } } } catch { } # SYSVOL / NETLOGON presence try { $sysvol = $false; $netlogon = $false if (Get-CommandAvailable -Name 'Get-SmbShare') { $sh = Get-SmbShare -ErrorAction SilentlyContinue $sysvol = [bool]($sh | Where-Object { $_.Name -eq 'SYSVOL' }) $netlogon = [bool]($sh | Where-Object { $_.Name -eq 'NETLOGON' }) } $dcs.Add([pscustomobject]@{ DcName=$env:COMPUTERNAME; Item='LocalDC'; Detail=("SYSVOL={0}; NETLOGON={1}; FSMO={2}" -f $sysvol, $netlogon, $fsmoDetail) }) } catch { } # Replication health (read-only summary verbs). try { $rr = Invoke-CommandLineSafe -FilePath 'repadmin.exe' -Arguments @('/replsummary') -TimeoutSeconds 120 if ($rr.Succeeded -or $rr.StdOut) { $replRows = ConvertFrom-RepadminReplSummary -Text $rr.StdOut } else { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'repadmin /replsummary returned nothing; replication health unknown.' | Out-Null } } catch { } # SYSVOL replication engine: 'Eliminated' = DFSR; anything earlier means FRS is still in play. try { $rm = Invoke-CommandLineSafe -FilePath 'dfsrmig.exe' -Arguments @('/getmigrationstate') -TimeoutSeconds 60 $sysvolReplication = if ($rm.StdOut -match '(?i)Eliminated') { 'DFSR' } elseif ($rm.StdOut -match '(?i)\b(Start|Prepared|Redirected)\b') { 'FRS (migration incomplete)' } elseif ($rm.StdOut -match '(?i)not.*initiated|has not') { 'FRS' } else { 'Unknown' } } catch { $sysvolReplication = 'Unknown' } # dcdiag runs every default test and can take minutes on a large forest: Deep mode only, quiet (failures only). if ($Context.Mode -eq 'Deep') { try { $rd = Invoke-CommandLineSafe -FilePath 'dcdiag.exe' -Arguments @('/q') -TimeoutSeconds 240 if ($rd.TimedOut) { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'dcdiag /q did not finish within 240 seconds.' | Out-Null } else { $lines = @(($rd.StdOut -split "`r?`n") | Where-Object { $_.Trim() }) $failed = @($lines | Where-Object { $_ -match '(?i)failed test' } | Select-Object -First 50) if ($lines.Count -eq 0) { $health.Add([pscustomobject]@{ Check='dcdiag /q'; Result='No failures reported'; Detail='' }) } elseif ($failed.Count -gt 0) { foreach ($f in $failed) { $health.Add([pscustomobject]@{ Check=($f.Trim() -replace '(?i)^.*failed test\s*',''); Result='Failed'; Detail=$f.Trim() }) } } else { $health.Add([pscustomobject]@{ Check='dcdiag /q'; Result='Errors reported (no failed test line)'; Detail=(($lines | Select-Object -First 4 | ForEach-Object { $_.Trim() }) -join ' | ') }) } } } catch { } } else { Add-Unknown -Context $Context -Unknown 'dcdiag was not run (Fast mode).' -WhyItMatters 'Latent DC health problems surface during role transfer and demotion.' -Module 'ActiveDirectory' -RecommendedValidationQuestion 'Run in Deep mode, or provide recent dcdiag output, before planning DC changes.' | Out-Null } if (-not $holdsFsmo -and -not $fsmoDetail) { Add-Unknown -Context $Context -Unknown 'FSMO role ownership could not be determined.' -WhyItMatters 'FSMO roles must be transferred before a DC is retired.' -Module 'ActiveDirectory' -RecommendedValidationQuestion 'Which DC holds the FSMO roles?' | Out-Null } } # LDAP signing / channel binding - DC-only registry keys under NTDS\Parameters, so these # simply don't exist on a member server. Deliberately short-circuited on $isDc rather than # just reading the (absent) registry value everywhere: a member server would otherwise # compute the same "not enforced" result as a genuinely unhardened DC, which would be a # real false positive on every non-DC in a fleet scan. Absent-on-a-real-DC IS flagged # (both values default to the weaker setting when unconfigured, per Microsoft's own 2020/ # 2023 LDAP hardening advisories - KB4520412), matching the same "flag the unconfigured # default, don't require proof of the weak value" contract used for LmCompatibilityLevel # in SecurityPosture.psm1's NtlmLegacyCompatibilityAllowed. $ldapSigningNotEnforced = $false $ldapChannelBindingNotEnforced = $false if ($isDc) { $ldapIntegrity = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters' -Name 'LDAPServerIntegrity' $ldapChannelBinding = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters' -Name 'LdapEnforceChannelBinding' $ldapSigningNotEnforced = ($null -eq $ldapIntegrity) -or ([int]$ldapIntegrity -lt 2) $ldapChannelBindingNotEnforced = ($null -eq $ldapChannelBinding) -or ([int]$ldapChannelBinding -lt 1) } $logonServer = $env:LOGONSERVER $site = '' try { $rs = Invoke-CommandLineSafe -FilePath 'nltest.exe' -Arguments @('/dsgetsite') -TimeoutSeconds 20; if ($rs.Succeeded) { $site = ($rs.StdOut -split "`r?`n" | Where-Object { $_.Trim() -and $_ -notmatch 'completed' } | Select-Object -First 1) } } catch { } # Domain/forest functional level via RootDSE - an LDAP bind any domain-joined machine can # do (no RSAT/ActiveDirectory module needed, unlike Get-ADDomain/Get-ADForest). $domainFuncLevel = '' $forestFuncLevel = '' if ($partOfDomain) { try { $rootDse = [ADSI]'LDAP://RootDSE' $domainFuncLevel = ConvertTo-AdFunctionalLevelName -Level $rootDse.Properties['domainFunctionality'][0] $forestFuncLevel = ConvertTo-AdFunctionalLevelName -Level $rootDse.Properties['forestFunctionality'][0] } catch { } } # DC inventory from any domain member: single-DC environments, unsupported DC operating systems. if ($partOfDomain) { $inv = Get-AdDcInventory if ($null -eq $inv) { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'Domain controller inventory could not be read over LDAP.' | Out-Null } else { $dcInventory = @($inv) } } # GPO content (SYSVOL). DCs read it locally; members only in Deep mode, since it is a network read of every GPO. $gpoInv = @(); $gpoRefs = @() if ($partOfDomain -and ($isDc -or $Context.Mode -eq 'Deep')) { $addrs = @() try { if ($Context.DataSets.Contains('IPConfiguration')) { $addrs = @($Context.DataSets['IPConfiguration'].Rows | ForEach-Object { (([string]$_.AllIPv4Addresses) -split ',') + @([string]$_.IPv4Address) } | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique) } } catch { } $gi = Get-GpoContentInspection -Context $Context -MyNames @($env:COMPUTERNAME, $cs.DNSHostName, ('{0}.{1}' -f $cs.DNSHostName, $domainName)) -MyAddresses $addrs if ($null -eq $gi) { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'Group Policy objects could not be read (LDAP/SYSVOL); GPO references to servers are unknown.' | Out-Null } else { $gpoInv = @($gi.Inventory); $gpoRefs = @($gi.References) if ($gi.Capped) { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'GPO content inspection stopped at its cap (1000 GPOs / 120 s); some GPOs were not inspected.' -Impact 'Partial GPO coverage' | Out-Null } foreach ($r in @($gpoRefs | Where-Object { $_.ReferencesThisServer })) { Add-DependencyEdge -Context $Context -SourceType 'Group Policy' -SourceName $r.GpoName -DependencyType ('References ({0})' -f $r.SettingType) -Target $r.Reference -Evidence ('{0} {1}' -f $r.Scope, $r.SourceFile) -Confidence 'Confirmed' -SourceDataset 'GpoServerReferences' -ProjectImpact 'Cutover Complexity' -ValidationQuestion 'Which users or computers receive this policy, and what replaces the path it points at?' | Out-Null } } } $dcCount = if ($dcInventory.Count -gt 0) { $dcInventory.Count } else { $null } $replFailures = (@($replRows | Where-Object { $_.Direction -eq 'Destination' }) | Measure-Object -Property Fails -Sum).Sum $ctxRows.Add([pscustomobject]@{ DomainName=$domainName; DomainRole=$roleName; PartOfDomain=$partOfDomain; IsDomainController=$isDc HoldsFsmoRole=$holdsFsmo; LogonServer=$logonServer; AdSite=($site -as [string]); DnsDomain=$env:USERDNSDOMAIN FsmoDetail=$fsmoDetail; DomainFunctionalLevel=$domainFuncLevel; ForestFunctionalLevel=$forestFuncLevel LdapSigningNotEnforced=$ldapSigningNotEnforced; LdapChannelBindingNotEnforced=$ldapChannelBindingNotEnforced DomainControllerCount=$dcCount; IsOnlyDomainController=[bool]($isDc -and $dcCount -eq 1) FsmoRolesHeld=@($fsmoRows | Where-Object { $_.HeldByThisServer }).Count; HoldsAllFsmoRoles=[bool](@($fsmoRows | Where-Object { $_.HeldByThisServer }).Count -ge 5) UnsupportedDcCount=@($dcInventory | Where-Object { $_.IsUnsupportedOs }).Count ReplicationFailures=$(if ($isDc -and $replRows.Count -gt 0) { [int]$replFailures } else { $null }); SysvolReplication=$sysvolReplication SysvolOnFrs=[bool]($sysvolReplication -match '^FRS') }) # ---- DC discovery (member or DC) ---- if ($partOfDomain) { try { $rd = Invoke-CommandLineSafe -FilePath 'nltest.exe' -Arguments @(("/dclist:" + $domainName)) -TimeoutSeconds 30 if ($rd.Succeeded) { foreach ($ln in ($rd.StdOut -split "`r?`n")) { if ($ln -match '^\s*([\w.-]+)\s+\[') { $dcs.Add([pscustomobject]@{ DcName=$Matches[1]; Item='DomainController'; Detail=$ln.Trim() }) } } } } catch { } } # ---- Applied GPOs via gpresult /r (read-only) ---- try { # Computer scope only: user-scope RSOP describes whoever runs the script (and is empty # under SYSTEM), not the server. The old no-scope call also mislabelled every GPO 'User'. $rg = Invoke-CommandLineSafe -FilePath 'gpresult.exe' -Arguments @('/scope','computer','/r') -TimeoutSeconds 60 if ($rg.Succeeded -and $rg.StdOut) { $lines = $rg.StdOut -split "`r?`n" $capture = $false; $sawHeader = $false; $scope = 'Computer' foreach ($ln in $lines) { if ($ln -match 'Applied Group Policy Objects') { $capture = $true; $sawHeader = $true; continue } if ($capture) { if ($ln.Trim() -match '^-{3,}') { continue } if ([string]::IsNullOrWhiteSpace($ln)) { $capture = $false; continue } if ($ln -match 'not have|N/A|The following') { $capture = $false; continue } $gpos.Add([pscustomobject]@{ Scope=$scope; PolicyName=$ln.Trim() }) } } if (-not $sawHeader) { # gpresult can exit 0 with no "Applied Group Policy Objects" section at all - e.g. # "does not have RSoP data" (seen on a real 2012 R2 box even right after a # successful gpupdate /force) - as distinct from a genuine zero-GPO result, which # still prints the header followed by "N/A". Surface the tool's own first line # instead of silently reporting nothing. $firstLine = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1) Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message ("gpresult produced no Applied Group Policy Objects section: {0}" -f $firstLine) | Out-Null } } else { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'gpresult did not return applied GPOs (may require user context / elevation).' | Out-Null } } catch { Add-Limitation -Context $Context -Module 'ActiveDirectory' -Message 'gpresult failed.' -Reason $_.Exception.Message | Out-Null } return ,@{ DomainContext=@($ctxRows); AppliedGroupPolicy=@($gpos); DomainControllerDiscovery=@($dcs); AdDomainControllers=@($dcInventory); AdFsmoRoles=@($fsmoRows); AdReplicationSummary=@($replRows); AdHealthChecks=@($health); GpoInventory=$gpoInv; GpoServerReferences=$gpoRefs } } 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 'DomainContext' -Description 'Domain membership and directory role context.' -Rows (& $get 'DomainContext') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'AppliedGroupPolicy' -Description 'Applied Group Policy Objects (gpresult).' -Rows (& $get 'AppliedGroupPolicy') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'DomainControllerDiscovery' -Description 'Domain controller discovery.' -Rows (& $get 'DomainControllerDiscovery') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'AdDomainControllers' -Description 'Every DC in the domain (LDAP): OS, site, GC, read-only, support status.' -Rows (& $get 'AdDomainControllers') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'AdFsmoRoles' -Description 'FSMO role holders (netdom query fsmo, DCs only).' -Rows (& $get 'AdFsmoRoles') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'AdReplicationSummary' -Description 'repadmin /replsummary per DSA (DCs only).' -Rows (& $get 'AdReplicationSummary') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'AdHealthChecks' -Description 'dcdiag /q failures (DCs, Deep mode only).' -Rows (& $get 'AdHealthChecks') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'GpoInventory' -Description 'Group Policy objects: links, state, server-reference count, stored-password (cpassword) presence - never the value.' -Rows (& $get 'GpoInventory') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null Add-DataSet -Context $Context -Name 'GpoServerReferences' -Description 'Server names and UNC paths referenced by GPO settings (drive maps, printers, scripts, folder redirection, ...).' -Rows (& $get 'GpoServerReferences') -Visibility 'Internal' -SourceModule 'ActiveDirectory' | Out-Null } Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','ConvertTo-AdFunctionalLevelName','Test-AdDcOsUnsupported','ConvertFrom-NetdomFsmo','ConvertFrom-RepadminReplSummary','Get-AdDcInventory','Get-GpoHostFromText','Get-GpoFileReferences','Get-GpoContentInspection' |