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 |