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 / Domain Context'; Category='Identity'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('DomainContext','AppliedGroupPolicy','DomainControllerDiscovery')
        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' }
    $n = 0
    if ([int]::TryParse([string]$Level, [ref]$n) -and $names.ContainsKey($n)) { return $names[$n] }
    return ("Level{0}" -f $Level)
}

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()

    $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) {
                $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 { }
        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 { }
    }

    $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
    })

    # ---- 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) }
}

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
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','ConvertTo-AdFunctionalLevelName'