ADObjectHealthScan.psm1

$script:ModuleRoot = $PSScriptRoot
$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\ADObjectHealthScan.psd1").ModuleVersion

# Detect whether at some level dotsourcing was enforced
$script:doDotSource = Get-PSFConfigValue -FullName ADObjectHealthScan.Import.DoDotSource -Fallback $false
if ($ADObjectHealthScan_dotsourcemodule) { $script:doDotSource = $true }

<#
Note on Resolve-Path:
All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator.
This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS.
Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist.
This is important when testing for paths.
#>


# Detect whether at some level loading individual module files, rather than the compiled module was enforced
$importIndividualFiles = Get-PSFConfigValue -FullName ADObjectHealthScan.Import.IndividualFiles -Fallback $false
if ($ADObjectHealthScan_importIndividualFiles) { $importIndividualFiles = $true }
if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true }
if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true }
    
function Import-ModuleFile
{
    <#
        .SYNOPSIS
            Loads files into the module on module import.
         
        .DESCRIPTION
            This helper function is used during module initialization.
            It should always be dotsourced itself, in order to proper function.
             
            This provides a central location to react to files being imported, if later desired
         
        .PARAMETER Path
            The path to the file to load
         
        .EXAMPLE
            PS C:\> . Import-ModuleFile -File $function.FullName
     
            Imports the file stored in $function according to import policy
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path
    )
    
    $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath
    if ($doDotSource) { . $resolvedPath }
    else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) }
}

#region Load individual files
if ($importIndividualFiles)
{
    # Execute Preimport actions
    foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) {
        . Import-ModuleFile -Path $path
    }
    
    # Import all internal functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Import all public functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Execute Postimport actions
    foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) {
        . Import-ModuleFile -Path $path
    }
    
    # End it here, do not load compiled code below
    return
}
#endregion Load individual files

#region Load compiled code
[flags()]enum EncryptionType {
    DesCbcCrc = 1
    DesCbcMD5 = 2
    RRC4 = 4
    AES128 = 8
    AES256 = 16
    AES256SK = 32

    FastSupported = 65536
    CompoundIdentitySupported = 131072
    ClaimsSupported = 262144
    ResourceSIDCompressionDisabled = 524288
}

<#
This file loads the strings documents from the respective language folders.
This allows localizing messages and errors.
Load psd1 language files for each language you wish to support.
Partial translations are acceptable - when missing a current language message,
it will fallback to English or another available language.
#>

Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'ADObjectHealthScan' -Language 'en-US'

function Export-AhsHealthReport {
    <#
    .SYNOPSIS
        Generate a report over all object health findings.
     
    .DESCRIPTION
        Generate a report over all object health findings.
 
        This command also requires the powershell module "ImportExcel"
     
    .PARAMETER Path
        The path where to write the report.
        Must be an xlsx file, but need not exist already.
        Parent folder must already exist.
     
    .PARAMETER PassThru
        Return all provided scan results.
        By default, this command will not return anything and only generate its report file.
 
    .PARAMETER InputObject
        The scan results to generate a report with.
        Must be the output of Invoke-AhsCheck.
     
    .PARAMETER Server
        The server/domain to connect to for the scan.
     
    .PARAMETER Credential
        The credentials to use for scanning.
     
    .EXAMPLE
        PS C:\> Invoke-AhsCheck -ConfigFile .\adscan.config.psd1 | Export-AhsHealthReport -Path .\scanresult.xlsx
 
        Performs the configured scan and writes a report to .\scanresult.xlsx
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PsfValidatePattern('\.xlsx$', ErrorMessage = 'Path must be an xlsx file!')]
        [PsfNewFile]
        $Path,

        [switch]
        $PassThru,

        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        $InputObject,

        [string]
        $Server,

        [PSCredential]
        $Credential
    )
    begin {
        if (-not (Get-Module ImportExcel -ListAvailable)) {
            Stop-PSFFunction -String 'Export-AhsHealthReport.Error.ImportExcelNotFound' -EnableException $true -Cmdlet $PSCmdlet
        }

        $adParam = @{}
        if ($Server) { $adParam.Server = $Server }
        if ($Credential) { $adParam.Credential = $Credential }

        $results = [System.Collections.ArrayList]@()
    }
    process {
        foreach ($item in $InputObject) {
            if ($null -eq $item) { continue }
            if ($item.PSObject.TypeNames -notcontains 'ADObjectHealthScan.Finding') {
                Write-PSFMessage -Level Warning -String 'Export-AhsHealthReport.Error.NotAFinding' -StringValues $item -PSCmdlet $PSCmdlet
                continue
            }

            $null = $results.Add($item)
            if ($PassThru) { $item }
        }
    }
    end {
        #region Add Privileged Info
        $isPrivilegedData = Get-AhsPrivilegedPrincipal @adParam -IncludeGroups
        foreach ($entry in $results) {
            $privilegedEntries = $isPrivilegedData | Where-Object MemberDN -EQ $entry.DistinguishedName
            $isPrivileged = $privilegedEntries -as [bool]

            [PSFramework.Object.ObjectHost]::AddNoteProperty(
                $entry,
                @{
                    IsPrivileged = $isPrivileged
                    PrivilegedGroups = $privilegedEntries.GroupName -join ', '
                }
            )
        }
        #endregion Add Privileged Info

        #region Export
        $groupedResults = $results | Group-Object Check
        $summaryEntries = foreach ($checkGroup in $groupedResults) {
            [PSCustomObject]@{
                Check = $checkGroup.Name
                Total = $checkGroup.Count
                Enabled = @($checkGroup.Group).Where{ $_.Enabled }.Count
                Disabled = @($checkGroup.Group).Where{ -not $_.Enabled }.Count
                Privileged = @($checkGroup.Group).Where{ $_.IsPrivileged }.Count
            }
        }

        $summaryEntries | Export-Excel -Path "$Path" -WorksheetName Summary
        $results | Export-Excel -Path "$Path" -WorksheetName Global
        foreach ($checkGroup in $groupedResults) {
            $checkGroup.Group | Export-Excel -Path "$Path" -WorksheetName "_$($checkGroup.Name)"
        }
        #endregion Export
    }
}

function Get-AhsCheck {
    <#
    .SYNOPSIS
        List the available checks.
     
    .DESCRIPTION
        List the available checks.
        Checks are the tests available for scanning AD Principals for configuration health.
        Use Register-AhsCheck to provide your own custom checks.
     
    .PARAMETER Name
        Name of the check to retrieve.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-AhsCheck
 
        List all available checks.
    #>

    [CmdletBinding()]
    param (
        [PsfArgumentCompleter('ADObjectHealthScan.Check.Name')]
        [string]
        $Name = '*'
    )
    process {
        $script:ScanExtensions.Values | Where-Object Name -Like $Name
    }
}

function Get-AhsPrivilegedPrincipal {
    <#
    .SYNOPSIS
        Retrieves all privileged accounts in a domain.
     
    .DESCRIPTION
        Retrieves all privileged accounts in a domain.
        Includes nested group memberships and non-user principals.
 
        Note: This scan is ONLY scanning for membership in privileged groups.
        If you want to ensure no other escalation path exists, use a tool such as the
        Active Directory Management Framework (admf.one) to scan for unexpected delegations.
 
        List of privileged groups taken from the Protected Accounts and Groups:
        https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory
     
    .PARAMETER Name
        Name filter applied to the returned principals.
        Defaults to: *
     
    .PARAMETER Group
        Name of privileged group to consider for the result.
        Defaults to: *
     
    .PARAMETER ExcludeBuiltIn
        By default, the krbtgt and Administrator account are returned, irrespective of any other filtering.
        This disables that behavior.
     
    .PARAMETER IncludeGroups
        Include groups in the list of members of privileged groups.
        By default, groups that are members of a privileged groups are not returned, just its non-group members (recursively).
     
    .PARAMETER Server
        The server / domain to contact.
     
    .PARAMETER Credential
        The credentials to use with the request
     
    .EXAMPLE
        PS C:\> Get-AhsPrivilegedPrincipal
 
        Retrieves all privileged accounts in the current domain.
 
    .LINK
        https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*',

        [string]
        $Group = '*',

        [switch]
        $ExcludeBuiltIn,

        [switch]
        $IncludeGroups,

        [string]
        $Server,

        [PSCredential]
        $Credential
    )
    begin {
        $privilegedGroupRids = @(
            '498' # Enterprise Read-only Domain Controllers
            '512' # Domain Admins
            '516' # Domain Controllers
            '518' # Schema Admins
            '519' # Enterprise Admins
            '521' # Read-only DOmain Controllers
        )
        $privilegedBuiltinGroups = @(
            'S-1-5-32-544' # Administrators
            'S-1-5-32-548' # Account Operators
            'S-1-5-32-549' # Server Operators
            'S-1-5-32-550' # Print Operators
            'S-1-5-32-551' # Backup Operators
            'S-1-5-32-552' # Replicator
        )
        $privilegedAccountRids = @(
            '500' # Administrator
            '502' # krbtgt
        )

        $adParam = @{}
        if ($Server) { $adParam.Server = $Server }
        if ($Credential) { $adParam.Credential = $Credential }
    }
    process {
        $domain = Get-LdapObject @adParam -LdapFilter '(objectCategory=domainDNS)'

        #region Builtin Privileged Accounts
        if (-not $ExcludeBuiltIn) {
            foreach ($rid in $privilegedAccountRids) {
                $user = Get-LdapObject @adParam -LdapFilter "(objectSID=$($domain.ObjectSID)-$rid)" -Property SamAccountName, ObjectClass, ObjectSID, DistinguishedName
                if ($user.Name -notlike $Name) { continue }

                [PSCustomObject]@{
                    PSTypeName = 'ADObjectHealthScan.Privileged.GroupMember'
                    GroupName  = 'n/a'
                    GroupDN    = $null
                    GroupSID   = $null
                    MemberName = $user.SamAccountName
                    MemberType = $user.ObjectClass
                    MemberSID  = $user.ObjectSID
                    MemberDN   = $user.DistinguishedName
                }
            }
        }
        #endregion Builtin Privileged Accounts
    
        #region Resolve Groups to check
        $groupsDefault = foreach ($rid in $privilegedGroupRids) {
            Get-LdapObject @adParam -LdapFilter "(objectSID=$($domain.ObjectSID)-$rid)" -Property SamAccountName, DistinguishedName, ObjectSID -ErrorAction Stop
        }
        $groupsBuiltin = foreach ($sid in $privilegedBuiltinGroups) {
            Get-LdapObject @adParam -LdapFilter "(objectSID=$sid)" -Property SamAccountName, DistinguishedName, ObjectSID -ErrorAction Stop
        }
        $relevantGroups = @($groupsDefault) + @($groupsBuiltin) | Write-Output | Where-Object { $_ -and $_.SamAccountName -like $Group }
        #endregion Resolve Groups to check

        #region Resolve privileged entities
        foreach ($relevantGroup in $relevantGroups) {
            $members = Get-LdapObject @adParam -LDAPFilter "(&(objectSID=*)(memberof:1.2.840.113556.1.4.1941:=$($relevantGroup.DistinguishedName)))" -Properties ObjectSID, SamAccountName, ObjectClass, DistinguishedName
            foreach ($member in $members) {
                if ($member.SamAccountName -notlike $Name) { continue }
                if ($member.ObjectClass -eq 'Group' -and -not $IncludeGroups) { continue }
                [PSCustomObject]@{
                    PSTypeName = 'ADObjectHealthScan.Privileged.GroupMember'
                    GroupName  = $relevantGroup.SamAccountName
                    GroupDN    = $relevantGroup.DistinguishedName
                    GroupSID   = $relevantGroup.ObjectSID
                    MemberName = $member.SamAccountName
                    MemberType = $member.ObjectClass
                    MemberSID  = $member.ObjectSID
                    MemberDN   = $member.DistinguishedName
                }
            }
        }
        #endregion Resolve privileged entitie
    }
}

function Invoke-AhsCheck {
    <#
    .SYNOPSIS
        Verify AD object health, based on the configuration provided.
     
    .DESCRIPTION
        Verify AD object health, based on the configuration provided.
        This applies the registered and configured checks against corresponding objects in AD.
 
        For more details on how to define checks, see the help on Register-AhsCheck.
         
        The configuration - whether provided through hashtable or config file - looks like this:
 
        @{
            NameOfCheck1 = @{ NameOfParameter = 42 }
            NameOfCheck2 = @{ } # Execute with default parameter settings
        }
         
        Only configured checks will be executed, even if explicitly specifying the "-IncludeCheck" parameter.
        To apply _all_ checks no matter what, specify the "_All" option:
 
        @{
            _All = $true # Execute all checks, including those no configuration is provided for
            NameOfCheck1 = @{ NameOfParameter = 42 }
            NameOfCheck2 = @{ SomeThreshold = 128 }
        }
 
        By default, all object classes that apply to a check are used.
        To limit that, also provide the "_ObjectClasses" option:
 
        @{
            _All = $true # Execute all checks, including those no configuration is provided for
            _ObjectClasses = 'Person', 'Group' # Only execute checks against persons and groups.
            NameOfCheck1 = @{ NameOfParameter = 42 }
            NameOfCheck2 = @{ SomeThreshold = 128 }
        }
     
    .PARAMETER Configuration
        A configuration hashtable, defining how the scan should be performed.
        See the Description for notes on how this should be defined.
     
    .PARAMETER ConfigFile
        Path to a configuration file to read.
        See the Description for notes on how this should be defined.
     
    .PARAMETER All
        Rather than loading a specific config file, execute al available checks with the default configuration.
     
    .PARAMETER IncludeCheck
        Only execute these checks.
     
    .PARAMETER ExcludeCheck
        Do not execute these checks.
     
    .PARAMETER IncludeClass
        Only execute checks against these object classes.
     
    .PARAMETER ExcludeClass
        Do not execute checks against these object classes.
     
    .PARAMETER SearchRoot
        Only scan objects under this OU.
     
    .PARAMETER Server
        The server/domain to connect to for the scan.
     
    .PARAMETER Credential
        The credentials to use for scanning.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Invoke-AhsCheck -ConfigFile C:\Scripts\adobjecthealth.config.psd1
         
        Executes the configuration defined / provided.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Config')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Config')]
        [hashtable]
        $Configuration,

        [Parameter(Mandatory = $true, ParameterSetName = 'File')]
        [PSFFile]
        $ConfigFile,

        [Parameter(Mandatory = $true, ParameterSetName = 'All')]
        [switch]
        $All,

        [PsfArgumentCompleter('ADObjectHealthScan.Check.Name')]
        [string[]]
        $IncludeCheck,

        [PsfArgumentCompleter('ADObjectHealthScan.Check.Name')]
        [string[]]
        $ExcludeCheck,

        [PsfArgumentCompleter('ADObjectHealthScan.Check.Class')]
        [string[]]
        $IncludeClass,

        [PsfArgumentCompleter('ADObjectHealthScan.Check.Class')]
        [string[]]
        $ExcludeClass,

        [string]
        $SearchRoot,

        [string]
        $Server,

        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    begin {
        $adParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential, SearchRoot
        $config = $Configuration
        if ($ConfigFile) { $config = Import-PSFPowerShellDataFile -LiteralPath $ConfigFile | Microsoft.PowerShell.Utility\Select-Object -First 1 }
        if ($All) { $config = @{ _All = $true } }

        if (-not $config) { $config = @{} }

        $allClasses = (Get-AhsCheck).ObjectClass | Sort-Object -Unique
        if (-not $config._ObjectClasses) { $config._ObjectClasses = $allClasses }
        if ($IncludeClass) { $config._ObjectClasses = $config._ObjectClasses | Where-Object { $_ -in $IncludeClass } }
        if ($ExcludeClass) { $config._ObjectClasses = $config._ObjectClasses | Where-Object { $_ -notin $ExcludeClass } }

        if (-not $SearchRoot -and $config._SearchRoot) {
            $adParam.SearchRoot = $config._SearchRoot
        }
    }
    process {
        # Resolve Checks
        if ($config._All) { $checks = $script:ScanExtensions.Values }
        else {
            $checks = foreach ($checkName in $config.Keys) {
                if ($checkName -match '^_') { continue }
                if (-not $script:ScanExtensions[$checkName]) {
                    Write-PSFMessage -Level Warning -String 'Invoke-AhsCheck.Error.CheckNotFound' -StringValues $checkName
                    continue
                }
    
                $script:ScanExtensions[$checkName]
            }
        }
        $checks = $checks | Where-Object {
            (-not $IncludeCheck -or $_.Name -in $IncludeCheck) -and
            (-not $ExcludeCheck -or $_.Name -notin $ExcludeCheck)
        }

        # Resolve Check Configuration
        $effectiveConfig = @{ }
        foreach ($check in $checks) {
            $effectiveConfig[$check.Name] = $check.Parameters.Clone()
            foreach ($parameter in $config.$($check.Name).Keys) {
                $effectiveConfig[$check.Name][$parameter] = $config.$($check.Name).$parameter
            }
        }

        #region Perform all Scanning
        $objectClasses = $checks.ObjectClass | Sort-Object -Unique | Where-Object { $_ -in $config._ObjectClasses }
        foreach ($objectClass in $objectClasses) {
            $classChecks = $checks | Where-Object ObjectClass -Contains $objectClass
            # Calculate LDAP Filter
            $filterSegments = foreach ($check in $classChecks) {
                & $check.LdapFilter $effectiveConfig[$check.Name]
            }
            $ldapFilter = '(&(objectCategory={0})(|{1}))' -f $objectClass, ($filterSegments -join '')

            # Calculate Properties
            $properties = @('samAccountName', 'distinguishedName', 'userAccountControl') + $($classChecks.Properties) | Remove-PSFNull | Sort-Object -Unique

            # Collect Objects
            Write-PSFMessage -String 'Invoke-AhsCheck.Query.Send' -StringValues $objectClass, $ldapFilter
            $adObjects = Get-LdapObject @adParam -LdapFilter $ldapFilter -Property $properties

            # For Each object, generate findings
            foreach ($adObject in $adObjects) {
                foreach ($check in $classChecks) {
                    try { & $check.Check $adObject $effectiveConfig[$check.Name] $adParam }
                    catch { Write-PSFMessage -Level Error -String 'Invoke-AhsCheck.Error.CheckFailed' -StringValues $check.Name, $adObject.DistinguishedName -ErrorRecord $_ -PSCmdlet $PSCmdlet -EnableException $EnableException.ToBool() }
                }
            }
        }
        #endregion Perform all Scanning
    }
}

function New-AhsFinding {
    <#
    .SYNOPSIS
        Create a new finding result.
     
    .DESCRIPTION
        Create a new finding result.
        This command should be used only from within the code provided by a custom check.
     
    .PARAMETER Check
        Name of the check that failed.
     
    .PARAMETER Threshold
        What would have been the expected/minimum/maximum value?
     
    .PARAMETER Value
        What was the actual value found.
     
    .PARAMETER ADObject
        The AD Object that was tested.
     
    .EXAMPLE
        PS C:\> New-AhsFinding -Check NeverLoggedIn -Threshold $false -Value $true -ADObject $ADObject
 
        Creates a new finding for the check "NeverLoggedIn"
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Check,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        $Threshold,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        $Value,

        [Parameter(Mandatory = $true)]
        $ADObject
    )
    process {
        [PSCustomObject]@{
            PSTypeName        = 'ADObjectHealthScan.Finding'
            Check             = $Check
            SamAccountName    = $ADObject.SamAccountName
            Threshold         = $Threshold
            Value             = $Value
            DistinguishedName = $ADObject.DistinguishedName
            Enabled           = -not ($ADObject.userAccountControl -band 2)
        }
    }
}

function Register-AhsCheck {
    <#
    .SYNOPSIS
        Registers the logic used to validate an AD Object's health.
     
    .DESCRIPTION
        Registers the logic used to validate an AD Object's health.
        This makes the check available in subsequent validation calls.
     
    .PARAMETER Name
        Name of the check.
        Is also used during configuration and as part of the output result.
     
    .PARAMETER Check
        The checking logic, that is executed against the retrieved AD object.
        This scriptblock will receive as input:
        - The AD Object being scanned
        - The Configuration hashtable containing the settings for this check,
        In case of a finding, this scriptblock must call "New-AhsFinding" to return a finding result.
     
    .PARAMETER LdapFilter
        A scriptblock that receives the Configuration Hashtable for this check as input.
        It must then return a valid LDAP Filter string - that filter will become part of a larger LDAP Filter,
        which also already specifies the ObjectCategory, meaning this filter can skip that.
 
        Example:
        param ($Config)
        "(pwdLastSet<=$((Get-Date).AddDays(-1 * $Config.PasswordThreshold).ToFileTime()))"
     
    .PARAMETER ObjectClass
        The Object Class of the item this check applies to.
        Checks will only be applied to objects of that class.
     
    .PARAMETER Properties
        Which properties are needed for this check.
        All checks applied will pool their requried properties, allowing performance optimization.
     
    .PARAMETER Description
        A quick description of what this check is all about.
        Documentation only.
     
    .PARAMETER Parameters
        The parameters the check supports and the default values they have if not specified.
     
    .EXAMPLE
        PS C:\> Register-AhsCheck -Name OldPassword -Check $check -LdapFilter $filter -ObjectClass user -Properties 'PwdLastSet' -Parameters @{ Threshold = 180 }
 
        Registers the check "OldPassword", which applies to users, uses the property "PwdLastSet" and comes with a default threshold of 180.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PsfValidateScript('PSFramework.Validate.SafeName', ErrorString = 'PSFramework.Validate.SafeName')]
        [string]
        $Name,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $Check,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $LdapFilter,

        [Parameter(Mandatory = $true)]
        [string[]]
        $ObjectClass,

        [string[]]
        $Properties,

        [string]
        $Description,

        [hashtable]
        $Parameters
    )
    process {
        $script:ScanExtensions[$Name] = [PSCustomObject]@{
            PSTypeName  = 'ADObjectHealthScan.Check'
            Name        = $Name
            Check       = $Check
            LdapFilter  = $LdapFilter
            ObjectClass = $ObjectClass
            Properties  = $Properties
            Description = $Description
            Parameters  = $Parameters
        }
    }
}

# Storage for all scan extensions used to generate health objects
$script:ScanExtensions = @{ }

<#
This is an example configuration file
 
By default, it is enough to have a single one of them,
however if you have enough configuration settings to justify having multiple copies of it,
feel totally free to split them into multiple files.
#>


<#
# Example Configuration
Set-PSFConfig -Module 'ADObjectHealthScan' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'"
#>


Set-PSFConfig -Module 'ADObjectHealthScan' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging."
Set-PSFConfig -Module 'ADObjectHealthScan' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments."

<#
Stored scriptblocks are available in [PsfValidateScript()] attributes.
This makes it easier to centrally provide the same scriptblock multiple times,
without having to maintain it in separate locations.
 
It also prevents lengthy validation scriptblocks from making your parameter block
hard to read.
 
Set-PSFScriptblock -Name 'ADObjectHealthScan.ScriptBlockName' -Scriptblock {
     
}
#>


$param = @{
    Name = 'AccountPair'
    Check = {
        param ($ADObject, $Config, $ADParam)

        $newADParam = $ADParam | ConvertTo-PSFHashtable -Include Server, Credential
        foreach ($pair in $Config.Pairs) {
            if ($ADObject.SamAccountName -notmatch $pair.Pattern) { continue }

            $pairName = & ([PSFScriptblock]::new($pair.Pair, $true)).ToGlobal() $ADObject.SamAccountName
            $filter = '(samAccountName={0})' -f $pairName
            if (Get-LdapObject @newADParam -LdapFilter $filter) { continue }

            New-AhsFinding -Check AccountPair -Threshold $pairName -Value $null -ADObject $ADObject
        }
    }
    LdapFilter = {
        param ($Config)
        $filters = foreach ($item in $Config.Pairs) {
            if ($item.LdapFilter) { '(samAccountName={0})' -f $item.LdapFilter }
        }
        if (@($filters).Count -lt 1) { return '(SamAccountName=<null>)' } # Something that is (hopefully) never true
        
        '(|{0})' -f ($filters -join '')
    }
    ObjectClass = 'Person'
    Properties = 'SamAccountName'
    Description = 'Ensures a matching accounts exists. Use to catch accounts that should have a matching pair but don''t.'
    Parameters = @{
        # Expects entries of @{ LdapFilter = 'adm*'; Pattern = '^adm'; Pair = { $args[0] -replace '^adm' }}
        Pairs = @()
    }
}

Register-AhsCheck @param

$param = @{
    Name        = 'BadEncryptionTypes'
    Check       = {
        param ($ADObject, $Config)
        if (($ADObject.'msDS-SupportedEncryptionTypes' -band 7) -and -not ($ADObject.'msDS-SupportedEncryptionTypes' -band 56)) {
            New-AhsFinding -Check BadEncryptionTypes -Threshold ([EncryptionType]56) -Value ([EncryptionType]$ADObject.'msDS-SupportedEncryptionTypes') -ADObject $ADObject
        }
    }
    LdapFilter  = {
        param ($Config)
        $subSegments = @(
            '(msDS-SupportedEncryptionTypes:1.2.840.113556.1.4.804:=7)' # RC4 and worse
            '(!(msDS-SupportedEncryptionTypes:1.2.840.113556.1.4.804:=56))' # NOT Aes 128 or better
        )
        $filterSegments += ('(&{0})' -f ($subSegments -join ''))
    }
    ObjectClass = 'Person'
    Properties  = 'PwdLastSet'
    Description = 'Scans for users whose Encryption types prevent modern AES modes.'
    Parameters  = @{}
}

Register-AhsCheck @param

$param = @{
    Name        = 'EmptyGroup'
    Check       = {
        param ($ADObject, $Config)
        if ($ADObject.member) { return }
        if ($ADObject.AdminCount -and $Config.ExcludeAdminGroups) { return }
        if ($Config.ExcludeBuiltIn) {
            if ($ADObject.ObjectSID -match '^S-1-5-32') { return } # Builtin Groups
            if ($ADObject.ObjectSID -match '-[45]\d\d$') { return } # RID < 1000 = Default Group
            if ($ADObject.samAccountName -in 'DnsAdmins', 'DnsUpdateProxy') { return } # Builtin DNS Groups with RID > 1000
        }

        New-AhsFinding -Check EmptyGroup -Threshold 1 -Value 0 -ADObject $ADObject
    }
    LdapFilter  = {
        param ($Config)
        if ($Config.ExcludeAdminGroups) { "(!(member=*))(!(adminCount=1))" }
        else { "(!(member=*))" }
    }
    ObjectClass = 'Group'
    Properties  = 'member', 'adminCount', 'ObjectSID'
    Description = 'Scans for groups that have no members.'
    Parameters  = @{
        ExcludeAdminGroups = $true
        ExcludeBuiltIn = $true
    }
}

Register-AhsCheck @param

$param = @{
    Name = 'LastLogon'
    Check = {
        param ($ADObject, $Config)
        if ($ADObject.lastLogonTimestamp -and ([datetime]::FromFileTimeUtc($ADObject.lastLogonTimestamp)) -lt (Get-Date).AddDays(-1 * $Config.LogonThreshold)) {
            New-AhsFinding -Check LastLogon -Threshold (Get-Date).AddDays(-1 * $Config.LogonThreshold) -Value ([datetime]::FromFileTimeUtc($ADObject.lastLogonTimestamp)) -ADObject $ADObject
        }
    }
    LdapFilter = {
        param ($Config)
        "(lastLogonTimestamp<=$((Get-Date).AddDays(-1 * $Config.LogonThreshold).ToFileTime()))" <# Precise to ~14 Days #>
    }
    ObjectClass = 'Person', 'Computer'
    Properties = 'lastLogonTimestamp'
    Description = 'Scans for users who have not logged on within a given timerange'
    Parameters = @{
        LogonThreshold = 180
    }
}

Register-AhsCheck @param

$param = @{
    Name        = 'NeverLoggedIn'
    Check       = {
        param ($ADObject, $Config)
        if ($ADObject.lastLogonTimestamp) { return }
        if ($ADObject.userAccountControl -band 2048) { return } # Trust Account
        if ($ADObject.whenCreated -ge (Get-Date).AddDays(-1 * $Config.CreationGrace)) { return } # Was recently created
        if ($ADObject.SamAccountName -eq 'krbtgt') { return } # krbtgt does not log in

        New-AhsFinding -Check NeverLoggedIn -Threshold $false -Value $true -ADObject $ADObject
    }
    LdapFilter  = {
        param ($Config)
        "(&(!(lastLogonTimestamp=*))(whenCreated<=$((Get-Date).AddDays(-1 * $Config.CreationGrace).ToString('yyyyMMddHHmmss.fZ'))))" <# Will possibly also find really new accounts if not filtering for creation date #>
    }
    ObjectClass = 'Person'
    Properties  = 'lastLogonTimestamp', 'whenCreated'
    Description = 'Scans for users who have never logged in.'
    Parameters  = @{
        CreationGrace = 30
    }
}

Register-AhsCheck @param

$param = @{
    Name = 'NoPasswordNeeded'
    Check = {
        param ($ADObject, $Config)
        if (-not ($ADObject.userAccountControl -band 32)) { return }
        if ($ADObject.userAccountControl -band 2048) { return } # Trust Account
        if ($ADObject.ObjectSID -match '-501$') { return } # Guest Account has this flag and is expected to
        
        New-AhsFinding -Check NoPasswordNeeded -Threshold $false -Value $true -ADObject $ADObject
    }
    LdapFilter = {
        param ($Config)
        '(userAccountControl:1.2.840.113556.1.4.803:=32)' <# Password not required #>
        # https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/useraccountcontrol-manipulate-account-properties
    }
    ObjectClass = 'Person'
    Properties = 'userAccountControl', 'ObjectSID'
    Description = 'Scans for users who are configured to not require a password.'
    Parameters = @{}
}

Register-AhsCheck @param

$param = @{
    Name = 'OldPassword'
    Check = {
        param ($ADObject, $Config)
        if ($ADObject.PwdLastSet -and $ADObject.PwdLastSet -lt (Get-Date).AddDays(-1 * $Config.PasswordThreshold).ToFileTime()) {
            New-AhsFinding -Check OldPassword -Threshold (Get-Date).AddDays(-1 * $Config.PasswordThreshold) -Value ([datetime]::FromFileTimeUtc($ADObject.PwdlastSet)) -ADObject $ADObject
        }
    }
    LdapFilter = {
        param ($Config)
        "(pwdLastSet<=$((Get-Date).AddDays(-1 * $Config.PasswordThreshold).ToFileTime()))"
    }
    ObjectClass = 'Person'
    Properties = 'PwdLastSet'
    Description = 'Scans for users whose password has not been set within the required time.'
    Parameters = @{
        PasswordThreshold = 180
    }
}

Register-AhsCheck @param

$param = @{
    Name = 'PasswordChangeRequired'
    Check = {
        param ($ADObject, $Config)
        if ($ADObject.PwdLastSet -gt 0) { return } # Setting the flag for must change password is implemented by resetting the PwdLastSet flag
        if ($ADObject.LastLogonTimestamp -lt 1) { return } # Never logged in is handled separately
        
        New-AhsFinding -Check PasswordChangeRequired -Threshold $false -Value $true -ADObject $ADObject
    }
    LdapFilter = {
        param ($Config)
        '(&(PwdLastSet=0)(LastLogonTimestamp>=1))'
    }
    ObjectClass = 'Person'
    Properties = 'PwdLastSet', 'LastLogonTimestamp'
    Description = 'Scans for users who must change their password on next logon.'
    Parameters = @{}
}

Register-AhsCheck @param

$param = @{
    Name = 'PasswordNeverExpires'
    Check = {
        param ($ADObject, $Config)
        if (-not ($ADObject.userAccountControl -band 65536)) { return }
        
        New-AhsFinding -Check PasswordNeverExpires -Threshold $false -Value $true -ADObject $ADObject
    }
    LdapFilter = {
        param ($Config)
        '(userAccountControl:1.2.840.113556.1.4.803:=65536)' <# Password never expires #>
    }
    ObjectClass = 'Person'
    Properties = 'userAccountControl'
    Description = 'Scans for users whose password has been set to never expire.'
    Parameters = @{}
}

Register-AhsCheck @param

Register-PSFTeppScriptblock -Name 'ADObjectHealthScan.Check.Name' -ScriptBlock {
    (Get-AhsCheck).Name | Sort-Object
} -Global

Register-PSFTeppScriptblock -Name 'ADObjectHealthScan.Check.Class' -ScriptBlock {
    (Get-AhsCheck).ObjectClass | Sort-Object -Unique
} -Global

<#
# Example:
Register-PSFTeppScriptblock -Name "ADObjectHealthScan.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' }
#>


<#
# Example:
Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name ADObjectHealthScan.alcohol
#>


New-PSFLicense -Product 'ADObjectHealthScan' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2024-07-09") -Text @"
Copyright (c) 2024 Friedrich Weinmann
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@

#endregion Load compiled code