Private/AD/Checks/Invoke-ADTradecraftChecks.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-ADTradecraftChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'ADTradecraftChecks'
    $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)
}

# ── ADTRADE-001: GPP cpassword Leftovers ───────────────────────────────────
function Test-ReconADTRADE001 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )
    $tc = $AuditData.Tradecraft
    if (-not $tc) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Tradecraft data not collected.'
    }
    if (-not $tc.SysvolReadable) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'SYSVOL not readable from this host (auth/network issue).'
    }
    $hits = @($tc.CpasswordHits)
    if ($hits.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No GPP cpassword fields found in SYSVOL Policies XML files (MS14-025 cleanup verified)' `
            -Details @{ FilesScanned = 'SYSVOL Policies recursive *.xml' }
    }
    $summary = @($hits | Select-Object -First 5 | ForEach-Object { "$($_.ExposedUser) in $($_.FilePath)" }) -join '; '
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue "Found $($hits.Count) GPP cpassword leftover(s) in SYSVOL. Examples: $summary. Rotate every exposed credential — the cpassword AES key is public, anyone with SYSVOL read access can decrypt." `
        -Details @{ HitCount = $hits.Count; Hits = $hits }
}

# ── ADTRADE-002: DCShadow Indicator ────────────────────────────────────────
function Test-ReconADTRADE002 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )
    $tc = $AuditData.Tradecraft
    $dcs = $AuditData.DomainControllers
    if (-not $tc) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Tradecraft data not collected.'
    }
    $configServers = @($tc.ConfigPartitionServers)
    if ($configServers.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No server objects found under CN=Sites,CN=Configuration. This is unusual for a real domain — verify the collector had read access to the configuration partition.' `
            -Details @{ ConfigServerCount = 0 }
    }

    # Build a set of known DC hostnames (lowercased) from the DomainControllers collection.
    # Get-ADDomainControllers returns a flat array of DC hashtables — keys are Name + FQDN.
    $knownDcHosts = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    if ($dcs) {
        foreach ($d in @($dcs)) {
            if ($d.FQDN) { [void]$knownDcHosts.Add($d.FQDN) }
            if ($d.Name) { [void]$knownDcHosts.Add($d.Name) }
        }
    }
    if ($knownDcHosts.Count -eq 0) {
        # If we have no DC inventory we can't tell orphans from real DCs — SKIP rather
        # than flag every server in the config partition as a rogue.
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'DomainControllers data not available to cross-reference. Re-run with DomainForest category enabled to populate the DC inventory.'
    }

    $orphans = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($s in $configServers) {
        $sHost = $s.DNSHostName ?? $s.CN ?? ''
        if (-not $sHost) { continue }
        # The configuration partition may include short-name servers; also try just the CN.
        $isKnown = $knownDcHosts.Contains($sHost) -or $knownDcHosts.Contains($s.CN)
        if (-not $isKnown) {
            $orphans.Add(@{
                Server     = $sHost
                DN         = $s.DistinguishedName
                Created    = $s.WhenCreated
            })
        }
    }

    if ($orphans.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue "All $($configServers.Count) server objects in CN=Sites,CN=Configuration match known domain controllers — no DCShadow indicator." `
            -Details @{ ConfigServerCount = $configServers.Count; DcCount = $knownDcHosts.Count }
    }

    $summary = @($orphans | ForEach-Object { "$($_.Server) (created $($_.Created))" }) -join '; '
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue "$($orphans.Count) server object(s) under CN=Sites,CN=Configuration that do NOT match a known DC: $summary. Investigate immediately — DCShadow registers fake DCs here, but legitimate causes also exist (replicated objects from a removed DC, legacy site setup)." `
        -Details @{ OrphanCount = $orphans.Count; Orphans = @($orphans) }
}

# ── ADTRADE-003: Stale BitLocker Recovery Keys ─────────────────────────────
function Test-ReconADTRADE003 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )
    $tc = $AuditData.Tradecraft
    if (-not $tc) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Tradecraft data not collected.'
    }
    $keys = @($tc.BitLockerKeys)
    if ($keys.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No BitLocker recovery information found in AD. Either BitLocker is not deployed, or the recovery keys are stored elsewhere (Intune, MBAM, etc.).' `
            -Details @{ KeyCount = 0 }
    }

    $thresholdDays = 365
    $cutoff = [datetime]::UtcNow.AddDays(-$thresholdDays)
    $staleKeys = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($k in $keys) {
        $created = $k.WhenCreated
        if ($created -is [datetime] -and $created -lt $cutoff) {
            $staleKeys.Add(@{
                DN            = $k.DistinguishedName
                ParentComputer = $k.ParentComputer
                AgeDays        = [int]([datetime]::UtcNow - $created).TotalDays
            })
        }
    }

    if ($staleKeys.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue "$($keys.Count) BitLocker recovery key(s) found, all within $thresholdDays days" `
            -Details @{ KeyCount = $keys.Count }
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
        -CurrentValue "$($staleKeys.Count) BitLocker recovery key(s) older than $thresholdDays days (of $($keys.Count) total). Review their parent computer accounts — confirm the drives have been destroyed or wiped, then prune the AD computer objects to cascade-delete the orphan keys." `
        -Details @{ StaleCount = $staleKeys.Count; TotalCount = $keys.Count; ThresholdDays = $thresholdDays; Sample = @($staleKeys | Select-Object -First 10) }
}

# ── ADTRADE-004: RODC Password Replication Policy Hygiene ──────────────────
function Test-ReconADTRADE004 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )
    $tc = $AuditData.Tradecraft
    if (-not $tc) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Tradecraft data not collected.'
    }
    $rodcs = @($tc.Rodcs)
    if ($rodcs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No RODCs in this domain — PRP hygiene N/A.' `
            -Details @{ RodcCount = 0 }
    }
    # The PRP itself isn't a simple LDAP attribute — it's expressed via msDS-RevealOnDemandGroup
    # and msDS-NeverRevealGroup on each RODC computer object, plus the canonical Deny / Allow
    # groups. Surfacing the inventory + the manual verification command is the right call here
    # rather than half-checking with incomplete logic.
    $summary = @($rodcs | ForEach-Object { $_.DNSHostName }) -join ', '
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
        -CurrentValue "Domain has $($rodcs.Count) RODC(s): $summary. Verify each RODC's Password Replication Policy out-of-band: Get-ADDomainControllerPasswordReplicationPolicy -Identity <rodc> -Denied. Domain Admins, Enterprise Admins, Schema Admins, Account Operators, and krbtgt must be in the Denied list (typically via the 'Denied RODC Password Replication Group' builtin)." `
        -Details @{ RodcCount = $rodcs.Count; Rodcs = @($rodcs) }
}