Private/AD/Core/Get-ADPasswordHashQuality.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
#
# Get-ADPasswordHashQuality
# -------------------------------------------------------------------------------
# Replicates NT password hashes via DSInternals (Get-ADReplAccount, the DCSync
# protocol) and runs Test-PasswordQuality to surface blank passwords and
# password reuse (duplicate hashes). Feeds:
# ADPWD-010 (blank passwords) <- BlankPasswordUsers
# ADPWD-011 (duplicate password hash) <- DuplicateHashGroups
# ADPRIV-016 (privileged weak pwds) <- PasswordAnalysis
#
# Honesty contract (project rule #1 — never PASS without assessing):
# * Only runs when DSInternals is available AND replication succeeds.
# * BlankPasswordUsers / DuplicateHashGroups need NO external dataset, so they
# are ALWAYS set (even to @()) once replication succeeds — empty = "checked,
# none found" → the check can PASS legitimately.
# * HIBPCompromisedUsers / DictionaryMatchUsers / CommonPasswordUsers stay
# $null UNLESS the caller supplies the corresponding dataset file. $null =
# "not assessed" → ADPWD-012/013/014 SKIP honestly (we have no dataset).
# * On ANY failure (no DCSync rights, not a DC, module load error) the whole
# function returns $null and records an error → the dependent checks SKIP.
#
# SECURITY (mandatory): only SamAccountNames and counts are persisted in the
# result. NT hashes and any cleartext are NEVER written to the result, to disk,
# or to the pipeline. The replicated account objects ($replAccounts) and the
# Test-PasswordQuality report are discarded as soon as analysis completes.
#
# References: MITRE ATT&CK T1003.006 (OS Credential Dumping: DCSync),
# T1110 (Brute Force / credential reuse); ANSSI vuln_pwd_*; CIS Microsoft AD
# benchmarks (password policy).
# -------------------------------------------------------------------------------
function Get-ADPasswordHashQuality {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Connection,

        # Optional DSInternals weak-password / pwned-hash dataset (a file of NT
        # hashes, one per line, e.g. an exported HIBP NTLM set). When supplied,
        # Test-PasswordQuality's WeakPasswordHashesSortedFile is used and
        # HIBPCompromisedUsers is populated. Absent => HIBP check stays SKIP.
        [string]$WeakPasswordHashesFile,

        # Optional cleartext dictionary file (one candidate password per line).
        # When supplied, DictionaryMatchUsers is populated. Absent => SKIP.
        [string]$DictionaryFile,

        [switch]$Quiet
    )

    $result = @{
        BlankPasswordUsers   = $null   # set to @() once replication succeeds
        DuplicateHashGroups  = $null   # set to @() once replication succeeds
        HIBPCompromisedUsers = $null   # stays $null unless dataset supplied
        DictionaryMatchUsers = $null   # stays $null unless dataset supplied
        CommonPasswordUsers  = $null   # stays $null (no built-in common list)
        PasswordAnalysis     = $null   # privileged-account weak-password subset
        Performed            = $false
        Error                = $null
    }

    # ── Gate 1: DSInternals must be importable ────────────────────────────────
    try {
        if (-not (Get-Module -ListAvailable -Name DSInternals -ErrorAction SilentlyContinue)) {
            $result.Error = 'DSInternals module not installed; NT-hash analysis not performed.'
            return $result
        }
        Import-Module DSInternals -ErrorAction Stop -Verbose:$false | Out-Null
    } catch {
        $result.Error = "DSInternals could not be loaded: $($_.Exception.Message)"
        return $result
    }

    if (-not (Get-Command -Name Get-ADReplAccount -ErrorAction SilentlyContinue) -or
        -not (Get-Command -Name Test-PasswordQuality -ErrorAction SilentlyContinue)) {
        $result.Error = 'DSInternals is present but Get-ADReplAccount / Test-PasswordQuality are unavailable.'
        return $result
    }

    # Determine the DC to replicate from and the naming context to pull.
    $domainDN = $Connection.DomainDN
    $server   = $Connection.Server
    if (-not $server) {
        # Fall back to the PDC-resolvable RootDSE host. DSInternals needs a
        # concrete server name for the DRSR bind.
        try { $server = $Connection.RootDSE.Properties['dnsHostName'][0].ToString() } catch { }
    }
    if (-not $server) {
        # Last resort: derive a DNS domain name from the DN so the DRSR locator
        # can find a DC. If this is empty too we bail honestly below.
        $server = ($domainDN -replace '^DC=', '' -replace ',DC=', '.').ToLower()
    }

    if ([string]::IsNullOrWhiteSpace($server) -or [string]::IsNullOrWhiteSpace($domainDN)) {
        $result.Error = 'Could not determine a domain controller / naming context for replication.'
        return $result
    }

    $replAccounts = $null
    try {
        if (-not $Quiet) {
            Write-ProgressLine -Phase RECON -Message "Replicating NT hashes via DSInternals from $server (DCSync)"
        }
        # The big read: pulls every account's secrets over the replication
        # protocol. Requires DCSync rights (Get-Changes + Get-Changes-All).
        $replAccounts = @(Get-ADReplAccount -All -Server $server -NamingContext $domainDN -ErrorAction Stop)
    } catch {
        # No DCSync rights, not a DC, RPC blocked, etc. — degrade honestly.
        $result.Error = "Replication failed (no DCSync rights / not a DC / RPC blocked): $($_.Exception.Message)"
        return $result
    }

    if ($null -eq $replAccounts -or @($replAccounts).Count -eq 0) {
        $result.Error = 'Replication returned no accounts; NT-hash analysis not performed.'
        return $result
    }

    # ── Run Test-PasswordQuality ──────────────────────────────────────────────
    $report = $null
    try {
        $tpqParams = @{ ErrorAction = 'Stop' }
        if ($WeakPasswordHashesFile -and (Test-Path -LiteralPath $WeakPasswordHashesFile)) {
            $tpqParams['WeakPasswordHashesSortedFile'] = $WeakPasswordHashesFile
        }
        if ($DictionaryFile -and (Test-Path -LiteralPath $DictionaryFile)) {
            $tpqParams['WeakPasswordsFile'] = $DictionaryFile
        }
        $report = $replAccounts | Test-PasswordQuality @tpqParams
    } catch {
        $result.Error = "Test-PasswordQuality failed: $($_.Exception.Message)"
        # Discard replicated secrets before returning (see SECURITY note).
        $replAccounts = $null
        [System.GC]::Collect()
        return $result
    } finally {
        # SECURITY: drop the replicated account objects (they carry NT hashes)
        # the instant we no longer need them. Nothing below touches $replAccounts.
        $replAccounts = $null
    }

    if ($null -eq $report) {
        $result.Error = 'Test-PasswordQuality produced no report.'
        [System.GC]::Collect()
        return $result
    }

    # Helper: coerce a DSInternals name list into SamAccountName-only hashtables.
    # We deliberately keep ONLY the account name — never the hash.
    $toUserList = {
        param($names)
        $list = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($n in @($names)) {
            if ([string]::IsNullOrWhiteSpace($n)) { continue }
            # DSInternals reports DOMAIN\sam — strip the domain prefix.
            $sam = ($n -split '\\')[-1]
            $list.Add(@{ SamAccountName = $sam })
        }
        return @($list)
    }

    # ── Blank passwords (no dataset required → always set) ────────────────────
    $blank = @()
    try { if ($null -ne $report.EmptyPassword) { $blank = & $toUserList $report.EmptyPassword } } catch { }
    $result.BlankPasswordUsers = @($blank)

    # ── Duplicate password hashes (no dataset required → always set) ──────────
    # DSInternals returns DuplicatePasswordGroups as a collection of arrays, each
    # array being the set of accounts that share one NT hash. We surface the
    # account NAMES and the group size only — never the hash itself.
    $dupeGroups = [System.Collections.Generic.List[hashtable]]::new()
    try {
        foreach ($grp in @($report.DuplicatePasswordGroups)) {
            $accts = @(& $toUserList $grp | ForEach-Object { $_.SamAccountName })
            if ($accts.Count -gt 1) {
                $dupeGroups.Add(@{
                    Accounts = @($accts)
                    Count    = $accts.Count
                })
            }
        }
    } catch { }
    $result.DuplicateHashGroups = @($dupeGroups)

    # ── Optional dataset-gated results (stay $null unless the dataset was given)
    if ($tpqParams.ContainsKey('WeakPasswordHashesSortedFile')) {
        $hibp = @()
        try { if ($null -ne $report.WeakPassword) { $hibp = & $toUserList $report.WeakPassword } } catch { }
        $result.HIBPCompromisedUsers = @($hibp)
    }
    if ($tpqParams.ContainsKey('WeakPasswordsFile')) {
        $dict = @()
        try { if ($null -ne $report.WeakPassword) { $dict = & $toUserList $report.WeakPassword } } catch { }
        $result.DictionaryMatchUsers = @($dict)
    }
    # CommonPasswordUsers: no built-in common-password corpus ships with the
    # module, so this stays $null (Not Assessed) — ADPWD-014 SKIPs honestly.

    # ── Privileged-account subset for ADPRIV-016 ──────────────────────────────
    # Accounts that are (a) members of a privileged group AND (b) appear in the
    # blank-password or duplicate-hash findings. Only NAMES are compared/stored.
    $privNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    if ($Connection.ContainsKey('PrivilegedSamNames')) {
        foreach ($p in @($Connection.PrivilegedSamNames)) {
            if (-not [string]::IsNullOrWhiteSpace($p)) { [void]$privNames.Add(($p -split '\\')[-1]) }
        }
    }

    $weakPrivileged = [System.Collections.Generic.List[hashtable]]::new()
    if ($privNames.Count -gt 0) {
        $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

        foreach ($u in @($result.BlankPasswordUsers)) {
            if ($privNames.Contains($u.SamAccountName) -and -not $seen.Contains($u.SamAccountName)) {
                [void]$seen.Add($u.SamAccountName)
                $weakPrivileged.Add(@{ SamAccountName = $u.SamAccountName; Reason = 'BlankPassword' })
            }
        }
        foreach ($grp in @($result.DuplicateHashGroups)) {
            foreach ($acct in @($grp.Accounts)) {
                if ($privNames.Contains($acct) -and -not $seen.Contains($acct)) {
                    [void]$seen.Add($acct)
                    $weakPrivileged.Add(@{ SamAccountName = $acct; Reason = 'DuplicatePasswordHash' })
                }
            }
        }
        if ($result.HIBPCompromisedUsers) {
            foreach ($u in @($result.HIBPCompromisedUsers)) {
                if ($privNames.Contains($u.SamAccountName) -and -not $seen.Contains($u.SamAccountName)) {
                    [void]$seen.Add($u.SamAccountName)
                    $weakPrivileged.Add(@{ SamAccountName = $u.SamAccountName; Reason = 'KnownWeak/Compromised' })
                }
            }
        }
    }
    # PasswordAnalysis is the shape ADPRIV-016 reads: { WeakPasswords = @(...) }.
    # Set it (even empty) because replication succeeded — privileged accounts WERE
    # assessed, so an empty set is a legitimate PASS, not a false one.
    $result.PasswordAnalysis = @{
        WeakPasswords         = @($weakPrivileged)
        PrivilegedAssessed    = ($privNames.Count -gt 0)
        PrivilegedNameCount   = $privNames.Count
    }

    $result.Performed = $true

    # SECURITY: drop the report (it can hold hash-keyed structures) and prompt GC
    # so no secret material lingers longer than necessary.
    $report = $null
    [System.GC]::Collect()

    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message ("NT-hash analysis: {0} blank, {1} duplicate-hash group(s), {2} weak privileged" -f `
            @($result.BlankPasswordUsers).Count, @($result.DuplicateHashGroups).Count, @($weakPrivileged).Count)
    }

    return $result
}