Private/AD/Core/Get-ADTransitiveAttackPath.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
#
# Transitive attack-path engine. Where Get-ADAttackPath (ADPATH-001) reports single-hop
# control of a Tier-0 object, this chains control + group-membership edges of ARBITRARY
# length to find the shortest path from a non-privileged principal to Tier-0:
# HelpDesk --[WriteDacl]--> CORP-Admins --[MemberOf]--> Domain Admins
# The graph is directed and every edge points "toward more privilege": A -> B means
# controlling (or being) A lets an attacker reach B's position. BFS gives the shortest chain.
#
# Path DEPTH is bounded by the collected ACL coverage: today PSGuerrilla collects ACLs on the
# six critical Tier-0 objects only, so most chains are 1 hop. The full-domain ACL collector
# (roadmap, live-gated) populates control edges over arbitrary objects and unlocks deep chains;
# this engine consumes them unchanged. The resolver below is validated for arbitrary depth.

function Resolve-AttackPathGraph {
    <#
    .SYNOPSIS
        Pure transitive shortest-path resolver over a directed privilege graph.
    .DESCRIPTION
        BFS from each source to the nearest Tier-0 target. Returns one shortest chain per source
        (fewest hops). Cycle-safe (visited set) and depth-bounded (MaxDepth).
    .PARAMETER Adjacency
        Hashtable: nodeKey -> @( @{ To=<nodeKey>; Edge=<label>; Technique=<text> }, ... ).
    .PARAMETER Targets
        Hashtable: nodeKey -> <target label>. Reaching any of these completes a path.
    .PARAMETER Sources
        Node keys to compute paths FROM (targets among them are skipped).
    .PARAMETER MaxDepth
        Maximum chain length to search. Default 10.
    #>

    [CmdletBinding()]
    param(
        [hashtable]$Adjacency = @{},
        [hashtable]$Targets = @{},
        [string[]]$Sources = @(),
        [int]$MaxDepth = 10
    )
    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($src in (@($Sources) | Select-Object -Unique)) {
        if (-not $src -or $Targets.ContainsKey($src)) { continue }
        $visited = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        [void]$visited.Add($src)
        $queue = [System.Collections.Generic.Queue[object]]::new()
        $queue.Enqueue([PSCustomObject]@{ Node = $src; Hops = @() })
        $found = $null
        while ($queue.Count -gt 0 -and -not $found) {
            $cur = $queue.Dequeue()
            if (@($cur.Hops).Count -ge $MaxDepth) { continue }
            foreach ($edge in @($Adjacency[$cur.Node])) {
                $to = [string]$edge.To
                if (-not $to) { continue }
                $newHops = @($cur.Hops) + @([PSCustomObject]@{ From = $cur.Node; To = $to; Edge = "$($edge.Edge)"; Technique = "$($edge.Technique)" })
                if ($Targets.ContainsKey($to)) {
                    $found = [PSCustomObject]@{ Source = $src; Target = $to; TargetLabel = $Targets[$to]; Hops = @($newHops); Length = @($newHops).Count }
                    break
                }
                if ($visited.Add($to)) {
                    $queue.Enqueue([PSCustomObject]@{ Node = $to; Hops = @($newHops) })
                }
            }
        }
        if ($found) { $results.Add($found) }
    }
    return @($results)
}

function Get-ADTransitiveAttackPath {
    <#
    .SYNOPSIS
        Builds the AD privilege graph from collected recon data and returns transitive paths to Tier-0.
    .DESCRIPTION
        Edge sources: dangerous ACEs (principal -> controlled object) and privileged-group
        membership (member -> group). Targets: the six critical Tier-0 objects + the Tier-0 groups.
        Default/by-design principals are excluded as path sources. Returns { DataAvailable; Paths }.
    #>

    [CmdletBinding()]
    param([hashtable]$AuditData)

    $notCollected = [PSCustomObject]@{ DataAvailable = $false; Paths = @() }
    $acl  = $AuditData.ACLs
    $priv = $AuditData.PrivilegedAccounts
    $haveAcl  = [bool]($acl -and (-not ($acl -is [System.Collections.IDictionary]) -or $acl.Contains('DangerousACEs')))
    $havePriv = [bool]($priv -and $priv.PrivilegedGroups)
    if (-not $haveAcl -and -not $havePriv) { return $notCollected }

    # Final-hop impact per critical Tier-0 object (reused intent from Get-ADAttackPath).
    $impactByObject = @{
        'domain root'             = 'the domain (DCSync every hash) — Domain Admin equivalent'
        'adminsdholder'           = 'all protected groups via SDProp — persistent Tier-0 control'
        'domain controllers ou'   = 'every Domain Controller (malicious GPO -> SYSTEM)'
        'gpo container'           = 'any host where a controlled GPO is linked'
        'configuration container' = 'the forest configuration partition — forest compromise'
        'schema container'        = 'the AD schema — forest-wide persistent control'
    }
    $tier0Groups = @('domain admins', 'enterprise admins', 'schema admins', 'administrators')

    $norm = { param($s) (("$s" -split '\\')[-1]).Trim().ToLower() }

    $adjacency = @{}
    $addEdge = {
        param($from, $to, $edge, $tech)
        if (-not $adjacency.ContainsKey($from)) { $adjacency[$from] = [System.Collections.Generic.List[object]]::new() }
        $adjacency[$from].Add(@{ To = $to; Edge = $edge; Technique = $tech })
    }

    $targets = @{}
    foreach ($o in $impactByObject.Keys) { $targets["obj:$o"] = $impactByObject[$o] }
    foreach ($g in $tier0Groups) { $targets["grp:$g"] = "$g (Tier-0 group)" }

    # Privileged membership: which principals are already Tier-0 (to flag non-privileged sources).
    $privSids = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $privNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

    $sourceSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $displayOf = @{}

    # ── Control edges from dangerous ACEs ──
    if ($haveAcl) {
        foreach ($ace in @($acl.DangerousACEs)) {
            $principal = "$($ace.IdentityReference ?? $ace.IdentitySID)"
            if (-not $principal) { continue }
            $sid = "$($ace.IdentitySID)"
            if (Test-DefaultControlPrincipal -Sid $sid -IdentityReference $principal) { continue }
            $right = if ($ace.ObjectType -and "$($ace.ActiveDirectoryRights)" -match 'ExtendedRight|WriteProperty') { "$($ace.ObjectType)" } else { "$($ace.ActiveDirectoryRights)" }
            $objName = (& $norm $ace.ObjectName)
            $fromKey = "prn:$(& $norm $principal)"
            $displayOf[$fromKey] = $principal
            if ($impactByObject.ContainsKey($objName)) {
                $toKey = "obj:$objName"
            } elseif ("$($ace.ObjectClass)" -match '(?i)group') {
                $toKey = "grp:$objName"          # control over an (arbitrary) group — full-domain-collector edge
            } else {
                $toKey = "node:$objName"          # control over some other object
            }
            & $addEdge $fromKey $toKey $right $null
            [void]$sourceSet.Add($fromKey)
        }
    }

    # ── Membership edges (member -> group); Tier-0 groups are targets ──
    if ($havePriv) {
        foreach ($entry in $priv.PrivilegedGroups.GetEnumerator()) {
            $gName = (& $norm $entry.Key)
            foreach ($m in @($entry.Value)) {
                $sam = "$($m.SamAccountName)"
                if (-not $sam) { continue }
                if ($m.SID) { [void]$privSids.Add("$($m.SID)") }
                [void]$privNames.Add((& $norm $sam))
                $mKey = if ($m.IsGroup) { "grp:$(& $norm $sam)" } else { "prn:$(& $norm $sam)" }
                $displayOf[$mKey] = $sam
                & $addEdge $mKey "grp:$gName" 'MemberOf' "member of $($entry.Key)"
                # A nested non-default group is itself an escalation surface -> consider it a source.
                if ($m.IsGroup -and -not (Test-DefaultControlPrincipal -Sid "$($m.SID)" -IdentityReference $sam)) {
                    [void]$sourceSet.Add($mKey)
                }
            }
        }
    }

    if ($sourceSet.Count -eq 0) { return [PSCustomObject]@{ DataAvailable = $true; Paths = @() } }

    $raw = Resolve-AttackPathGraph -Adjacency $adjacency -Targets $targets -Sources @($sourceSet)

    $paths = foreach ($r in $raw) {
        $srcKey = $r.Source
        $srcName = $displayOf[$srcKey] ?? ($srcKey -replace '^(prn|grp|obj|node):', '')
        $isPriv = ($privSids.Count -gt 0 -and $false) -or $privNames.Contains(($srcName -split '\\')[-1].ToLower()) -or (Test-DefaultPrivilegedPrincipal -Sid '' -IdentityReference $srcName)
        $chain = @($r.Hops | ForEach-Object {
            $f = $displayOf[$_.From] ?? ($_.From -replace '^(prn|grp|obj|node):', '')
            $t = $displayOf[$_.To] ?? ($_.To -replace '^(prn|grp|obj|node):', '')
            "$f --[$($_.Edge)]--> $t"
        }) -join ' ==> '
        [PSCustomObject]@{
            PSTypeName         = 'PSGuerrilla.TransitiveAttackPath'
            Source             = $srcName
            SourceIsPrivileged = [bool]$isPriv
            Length             = $r.Length
            ReachesTier0       = $r.TargetLabel
            Hops               = $r.Hops
            PathType           = if ($r.Length -gt 1) { 'Transitive' } else { 'Object control' }
            Severity           = 'Critical'
            Path               = "$chain => reaches $($r.TargetLabel)"
        }
    }

    # Non-privileged sources first, then by shortest chain.
    $sorted = @($paths | Sort-Object `
        @{ Expression = { if ($_.SourceIsPrivileged) { 1 } else { 0 } } }, `
        @{ Expression = { $_.Length } }, `
        Source)
    return [PSCustomObject]@{ DataAvailable = $true; Paths = $sorted }
}