Private/AD/Checks/Invoke-TierZeroChecks.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Invoke-TierZeroChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'TierZeroChecks' $findings = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($check in $checkDefs.checks) { $funcName = "Test-Recon$($check.id -replace '-', '')" if (Get-Command $funcName -ErrorAction SilentlyContinue) { try { $finding = & $funcName -AuditData $AuditData -CheckDefinition $check if ($finding) { $findings.Add($finding) } } catch { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' ` -CurrentValue "Check failed: $_")) } } else { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' ` -CurrentValue 'Check not yet implemented')) } } return @($findings) } # Substring-match helper. Compares against sAMAccountName, displayName, and description. # Keywords are intentionally distinctive brand names (veeam, vcenter, sccm) so simple # substring match is safe — no need for word-boundary regex gymnastics that fail on # things like "veeamsvc" or "VeeamAdmin". function Get-TierBleedMatchedKeyword { param( [Parameter(Mandatory)]$Member, [Parameter(Mandatory)][string[]]$Keywords ) $haystack = "$($Member.SamAccountName) $($Member.DisplayName) $($Member.Description)".ToLower() foreach ($kw in $Keywords) { if ($haystack.Contains($kw.ToLower())) { return $kw } } return $null } # Get the union of members from the highest-impact privileged groups (DA/EA/SA/BO). # The data model from Get-ADPrivilegedMembers stores per-group member lists in # $AuditData.PrivilegedAccounts.PrivilegedGroups, keyed by friendly group label — # each value is the flat array of normalized member hashtables (NOT wrapped in # @{ Members = ... }). function Get-Tier0HighPrivMembers { param([Parameter(Mandatory)][hashtable]$AuditData) $priv = $AuditData.PrivilegedAccounts if (-not $priv -or -not $priv.PrivilegedGroups) { return @() } # Group labels to scan — order matters for human-readable output (DA first). $targetGroups = @('Domain Admins', 'Enterprise Admins', 'Schema Admins', 'Backup Operators') $members = [System.Collections.Generic.List[hashtable]]::new() foreach ($g in $targetGroups) { if ($priv.PrivilegedGroups.ContainsKey($g)) { foreach ($m in @($priv.PrivilegedGroups[$g])) { $members.Add(@{ Group = $g SamAccountName = $m.SamAccountName ?? '' DistinguishedName = $m.DistinguishedName ?? '' DisplayName = $m.DisplayName ?? '' Description = $m.Description ?? '' ObjectClass = $m.ObjectClass ?? '' UserAccountControl = [int]($m.UserAccountControl ?? 0) }) } } } return @($members) } function New-TierBleedFinding { param( [Parameter(Mandatory)][hashtable]$CheckDefinition, [Parameter(Mandatory)][array]$Hits, [string]$ProductLabel ) if ($Hits.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No $ProductLabel-pattern service accounts found in highly privileged groups (DA/EA/SA/BO)" ` -Details @{ MatchCount = 0 } } $summary = @($Hits | ForEach-Object { "$($_.Group)\$($_.SamAccountName) (matched: $($_.MatchedKeyword))" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "Found $($Hits.Count) $ProductLabel-pattern account(s) in privileged groups: $summary" ` -Details @{ MatchCount = $Hits.Count; Hits = $Hits } } # ── ADTIER-001: Azure AD Connect MSOL_ Account Audit ─────────────────────── function Test-ReconADTIER001 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $tz = $AuditData.TierZero if (-not $tz) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tier-Zero signal data not collected.' } $accounts = @($tz.MsolAccounts) if ($accounts.Count -eq 0) { # Genuinely "no AAD Connect" is a PASS. Could also indicate the customer renamed # the account (which the docs explicitly permit). Make the language reflect that. return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No MSOL_ accounts found. Either AAD Connect is not deployed in this domain, or the sync account has been renamed away from the default MSOL_ pattern (verify out-of-band).' ` -Details @{ Count = 0 } } $stalePwdDays = 365 $issues = [System.Collections.Generic.List[string]]::new() foreach ($a in $accounts) { if ($a.DistinguishedName -match 'CN=Users,DC=') { $issues.Add("$($a.SamAccountName) lives in the default CN=Users container (should be in a Tier-0 OU with logon restrictions)") } if ($null -ne $a.PasswordAgeDays -and $a.PasswordAgeDays -gt $stalePwdDays) { $issues.Add("$($a.SamAccountName) password is $($a.PasswordAgeDays) days old (default expiry is 10 years; rotate periodically)") } } if ($issues.Count -gt 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue ("MSOL_ account(s) found ($($accounts.Count)): " + ($issues -join '; ')) ` -Details @{ Count = $accounts.Count; Issues = @($issues); Accounts = @($accounts) } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "MSOL_ account(s) found ($($accounts.Count)) — placement and password age are within recommended bounds. Confirm separately that the AAD Connect server itself is hardened as Tier-0." ` -Details @{ Count = $accounts.Count; Accounts = @($accounts) } } # ── ADTIER-002: Backup Software Service Accounts ─────────────────────────── function Test-ReconADTIER002 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $keywords = @('veeam', 'commvault', 'rubrik', 'cohesity', 'nakivo', 'backupexec', 'vembu', 'acronis', 'unitrends', 'arcserve') $members = Get-Tier0HighPrivMembers -AuditData $AuditData if ($members.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged-group member data not available; ADTIER-002 needs PrivilegedAccounts category to have run.' } $hits = foreach ($m in $members) { $kw = Get-TierBleedMatchedKeyword -Member $m -Keywords $keywords if ($kw) { $m + @{ MatchedKeyword = $kw } } } return New-TierBleedFinding -CheckDefinition $CheckDefinition -Hits @($hits) -ProductLabel 'backup-software' } # ── ADTIER-003: Hypervisor Service Accounts ──────────────────────────────── function Test-ReconADTIER003 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $keywords = @('vmware', 'vcenter', 'esxi', 'vsphere', 'hyperv', 'hyper-v', 'scvmm', 'citrix', 'xenserver', 'xenapp', 'proxmox', 'nutanix') $members = Get-Tier0HighPrivMembers -AuditData $AuditData if ($members.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged-group member data not available.' } $hits = foreach ($m in $members) { $kw = Get-TierBleedMatchedKeyword -Member $m -Keywords $keywords if ($kw) { $m + @{ MatchedKeyword = $kw } } } return New-TierBleedFinding -CheckDefinition $CheckDefinition -Hits @($hits) -ProductLabel 'hypervisor / virtualization' } # ── ADTIER-004: Configuration Management Service Accounts ────────────────── function Test-ReconADTIER004 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $keywords = @('sccm', 'mecm', 'configmgr', 'intune', 'jamf', 'kace', 'lansweeper', 'manageengine', 'ivanti', 'bigfix', 'tanium') $members = Get-Tier0HighPrivMembers -AuditData $AuditData if ($members.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged-group member data not available.' } $hits = foreach ($m in $members) { $kw = Get-TierBleedMatchedKeyword -Member $m -Keywords $keywords if ($kw) { $m + @{ MatchedKeyword = $kw } } } return New-TierBleedFinding -CheckDefinition $CheckDefinition -Hits @($hits) -ProductLabel 'configuration-management' } # ── ADTIER-005: SQL / Database Service Accounts ──────────────────────────── function Test-ReconADTIER005 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $keywords = @('sqlsvc', 'sqlserver', 'mssql', 'sqlagent', 'sqlbrowser', 'mysql', 'postgres', 'oracledb') $members = Get-Tier0HighPrivMembers -AuditData $AuditData if ($members.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged-group member data not available.' } $hits = foreach ($m in $members) { $kw = Get-TierBleedMatchedKeyword -Member $m -Keywords $keywords if ($kw) { $m + @{ MatchedKeyword = $kw } } } return New-TierBleedFinding -CheckDefinition $CheckDefinition -Hits @($hits) -ProductLabel 'database / SQL' } # ── ADTIER-006: Tier-0 Admins Outside Dedicated Admin OU ─────────────────── function Test-ReconADTIER006 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $members = Get-Tier0HighPrivMembers -AuditData $AuditData # Strip BO from this check — backup operators are not technically tier-0 admins. $members = @($members | Where-Object { $_.Group -in @('Domain Admins', 'Enterprise Admins', 'Schema Admins') }) # De-duplicate by DN (a user can be in multiple groups) $unique = @($members | Group-Object DistinguishedName | ForEach-Object { $_.Group[0] }) if ($unique.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No DA/EA/SA member data available.' } # Heuristic: a "Tier-0 OU" path contains the literal "tier" or "tier-0" or "tier 0" or "admin" early in the DN. # Computer/builtin accounts are excluded (those live elsewhere by design). $offenders = [System.Collections.Generic.List[hashtable]]::new() foreach ($m in $unique) { if ($m.ObjectClass -eq 'computer') { continue } if ($m.SamAccountName -eq 'Administrator') { continue } # built-in lives in Users by default $dn = $m.DistinguishedName.ToLower() $inAdminOu = $dn -match 'ou=(tier-?0|admin|t0|secure)' if (-not $inAdminOu) { $offenders.Add($m) } } if ($offenders.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'All Tier-0 admins live under an OU that names itself "Tier-0" or "Admin" (or similar)' ` -Details @{ Count = $unique.Count } } $summary = @($offenders | Select-Object -First 10 | ForEach-Object { "$($_.SamAccountName) at $($_.DistinguishedName)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "$($offenders.Count) Tier-0 admin(s) not in a dedicated admin OU: $summary" ` -Details @{ Count = $offenders.Count; Offenders = @($offenders) } } # ── ADTIER-007: Service-named accounts in privileged groups ──────────────── function Test-ReconADTIER007 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $members = Get-Tier0HighPrivMembers -AuditData $AuditData if ($members.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged-group member data not available.' } # Service-account heuristics: sAMAccountName prefix or suffix conventions. $isServiceish = { param($Name) $n = $Name.ToLower() return ($n -match '^(svc|sa|service|srv)[-_.]') -or ` ($n -match '[-_.](svc|sa|service|srv)$') -or ` ($n -match '^s_') -or ` ($n -match '_svc$') } $hits = @($members | Where-Object { $_.ObjectClass -ne 'computer' -and (& $isServiceish $_.SamAccountName) }) if ($hits.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No service-named accounts (svc-*, *-svc, s_*, *_svc) in DA/EA/SA/BO' ` -Details @{ MatchCount = 0 } } $summary = @($hits | ForEach-Object { "$($_.Group)\$($_.SamAccountName)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($hits.Count) service-named account(s) in highly privileged groups: $summary. Service accounts should never be interactive-logon-capable principals." ` -Details @{ MatchCount = $hits.Count; Hits = @($hits) } } |