diff --git a/ansible/roles/groups_domains/tasks/main.yml b/ansible/roles/groups_domains/tasks/main.yml index 3f2e8dbb..d3e5abb1 100644 --- a/ansible/roles/groups_domains/tasks/main.yml +++ b/ansible/roles/groups_domains/tasks/main.yml @@ -6,17 +6,28 @@ - name: Synchronize all domains with proper credentials ansible.windows.win_powershell: + parameters: + DomainUsername: "{{ domain_username }}" + DomainPassword: "{{ domain_password }}" script: | + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$DomainUsername, + [Parameter(Mandatory)][string]$DomainPassword + ) + # Create credentials for domain admin - $adminUser = "{{ domain_username }}" - $adminPassword = ConvertTo-SecureString "{{ domain_password }}" -AsPlainText -Force - $adminCred = New-Object System.Management.Automation.PSCredential($adminUser, $adminPassword) + $adminPassword = ConvertTo-SecureString $DomainPassword -AsPlainText -Force + $adminCred = New-Object System.Management.Automation.PSCredential($DomainUsername, $adminPassword) # Create script block to run with elevated privileges $scriptBlock = { # Try using repadmin with the Domain Admin privileges try { - $output = repadmin /syncall /APeD + $output = repadmin /syncall /APeD 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "repadmin exited with code $LASTEXITCODE: $output" + } Write-Output "Replication initiated with repadmin: $output" return $true } catch { @@ -49,17 +60,29 @@ return $result error_action: continue register: sync_result - failed_when: sync_result.output is defined and sync_result.output | length > 0 and sync_result.output[0] == false + failed_when: sync_result.output is defined and sync_result.output | length > 0 and (sync_result.output | last) == false + until: sync_result is succeeded + retries: 3 + delay: 30 vars: ansible_become: false - name: Add cross-domain users/groups using PowerShell Direct ansible.windows.win_powershell: + parameters: + DomainServer: "{{ domain_server }}" + DomainUsername: "{{ domain_username }}" + DomainPassword: "{{ domain_password }}" script: | - $domainServer = "{{ domain_server }}" - $domainUsername = "{{ domain_username }}" - $domainPassword = ConvertTo-SecureString "{{ domain_password }}" -AsPlainText -Force - $domainCred = New-Object System.Management.Automation.PSCredential($domainUsername, $domainPassword) + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$DomainServer, + [Parameter(Mandatory)][string]$DomainUsername, + [Parameter(Mandatory)][string]$DomainPassword + ) + + $domainPasswordSecure = ConvertTo-SecureString $DomainPassword -AsPlainText -Force + $domainCred = New-Object System.Management.Automation.PSCredential($DomainUsername, $domainPasswordSecure) # Convert dictionary to PowerShell object $groupMemberships = @{ @@ -73,7 +96,7 @@ } $scriptBlock = { - param($groupMemberships) + param($groupMemberships, $domainServer) Import-Module ActiveDirectory $results = @{} @@ -88,7 +111,7 @@ try { # Get the group - $group = Get-ADGroup -Identity $groupName -ErrorAction Stop + $group = Get-ADGroup -Identity $groupName -Server $domainServer -ErrorAction Stop Write-Output "Processing group: $groupName" foreach ($member in $groupMemberships[$groupName]) { @@ -118,7 +141,7 @@ } # Add the member to the group - Add-ADGroupMember -Identity $group -Members $adObject -ErrorAction Stop + Add-ADGroupMember -Identity $group -Members $adObject -Server $domainServer -ErrorAction Stop Write-Output "Successfully added $member to group $groupName" $groupResult.members_added += $member } else { @@ -143,10 +166,19 @@ return $results | ConvertTo-Json -Depth 5 } - $jsonResults = Invoke-Command -ScriptBlock $scriptBlock -ArgumentList $groupMemberships -Credential $domainCred -ComputerName localhost + $scriptOutput = @(Invoke-Command -ScriptBlock $scriptBlock -ArgumentList $groupMemberships, $DomainServer -Credential $domainCred -ComputerName localhost) try { - $resultsObj = $jsonResults | ConvertFrom-Json + if ($scriptOutput.Count -eq 0) { + throw "Cross-domain membership script returned no result payload" + } + + if ($scriptOutput.Count -gt 1) { + $scriptOutput[0..($scriptOutput.Count - 2)] | ForEach-Object { Write-Output $_ } + } + + $jsonPayload = $scriptOutput[-1] + $resultsObj = $jsonPayload | ConvertFrom-Json -ErrorAction Stop # Check if any group failed $success = $true @@ -166,14 +198,25 @@ } } - return $success + $Ansible.Changed = $success + $Ansible.Result = $resultsObj + if (-not $success) { + $Ansible.Failed = $true + } } catch { $errorMsg = $_.Exception.Message Write-Output "Error processing results: $errorMsg" - Write-Output "Raw results: $jsonResults" - return $false + Write-Output "Raw results: $scriptOutput" + $Ansible.Changed = $false + $Ansible.Failed = $true + $Ansible.Result = @{ + error = $errorMsg + raw_results = $scriptOutput + } } - error_action: continue + error_action: stop register: cross_domain_result - failed_when: cross_domain_result.output is defined and cross_domain_result.output | length > 0 and cross_domain_result.output[0] == false + until: cross_domain_result is succeeded + retries: 3 + delay: 60 when: domain_groups_members is defined and domain_groups_members | length > 0 diff --git a/ansible/roles/laps_permissions/tasks/main.yml b/ansible/roles/laps_permissions/tasks/main.yml index 898d16a2..03716617 100644 --- a/ansible/roles/laps_permissions/tasks/main.yml +++ b/ansible/roles/laps_permissions/tasks/main.yml @@ -138,7 +138,7 @@ # Try using the built-in LAPS cmdlet first try { # Try to use the Set-AdmPwdReadPasswordPermission cmdlet - Set-AdmPwdReadPasswordPermission -OrgUnit $lapsOU.DistinguishedName -Identity $adObject.SID + Set-AdmPwdReadPasswordPermission -OrgUnit $lapsOU.DistinguishedName -AllowedPrincipals $principal Write-Output "Successfully granted LAPS read permission to $principal on $($lapsOU.DistinguishedName) using Set-AdmPwdReadPasswordPermission" return $true } catch { diff --git a/cli/internal/labmap/labmap.go b/cli/internal/labmap/labmap.go index fb9ddde9..bbf4671c 100644 --- a/cli/internal/labmap/labmap.go +++ b/cli/internal/labmap/labmap.go @@ -83,16 +83,17 @@ type GMSAConfig struct { // It includes the DC host role, trust relationships, ADCS settings, and // all users, groups, OUs, and ACLs defined for the domain. type DomainConfig struct { - DC string `json:"dc"` // host role key - DomainPassword string `json:"domain_password"` - NetBIOSName string `json:"netbios_name"` - Trust string `json:"trust"` - CAServer string `json:"ca_server"` - CAWebEnrollment *bool `json:"ca_web_enrollment"` - LAPSReaders []string `json:"laps_readers"` - GMSA map[string]GMSAConfig `json:"gmsa"` - Users map[string]UserConfig `json:"users"` - ACLs map[string]ACLConfig `json:"acls"` + DC string `json:"dc"` // host role key + DomainPassword string `json:"domain_password"` + NetBIOSName string `json:"netbios_name"` + Trust string `json:"trust"` + CAServer string `json:"ca_server"` + CAWebEnrollment *bool `json:"ca_web_enrollment"` + LAPSReaders []string `json:"laps_readers"` + MultiDomainGroupsMembers map[string][]string `json:"multi_domain_groups_member"` + GMSA map[string]GMSAConfig `json:"gmsa"` + Users map[string]UserConfig `json:"users"` + ACLs map[string]ACLConfig `json:"acls"` } // LabMap holds the resolved lab configuration for any GOAD-style lab. @@ -547,6 +548,16 @@ type GroupFact struct { DCRole string } +// CrossDomainGroupFact holds a configured domain-local group membership set. +// Members use the config's "domain\\principal" form and may include both +// same-domain and foreign principals. +type CrossDomainGroupFact struct { + Group string + Domain string + DCRole string + Members []string +} + // AllConfiguredUsers returns every user defined in any DomainConfig's Users // map, with domain context. Used by checks that must verify the full user set // exists in AD. @@ -602,6 +613,31 @@ func (m *LabMap) AllConfiguredGroups() []GroupFact { return facts } +// CrossDomainGroupMemberships returns every configured +// multi_domain_groups_member relation with the local domain/DC context. +func (m *LabMap) CrossDomainGroupMemberships() []CrossDomainGroupFact { + var facts []CrossDomainGroupFact + for domain, dc := range m.DomainConfigs { + for group, members := range dc.MultiDomainGroupsMembers { + copied := make([]string, len(members)) + copy(copied, members) + facts = append(facts, CrossDomainGroupFact{ + Group: group, + Domain: domain, + DCRole: dc.DC, + Members: copied, + }) + } + } + sort.Slice(facts, func(i, j int) bool { + if facts[i].Domain != facts[j].Domain { + return facts[i].Domain < facts[j].Domain + } + return facts[i].Group < facts[j].Group + }) + return facts +} + // LocalAdminsForHost returns the configured local Administrators members for a // host role, derived from HostConfig.LocalGroups["Administrators"]. Returns // nil if the host has no local_groups config or no Administrators entry. diff --git a/cli/internal/labmap/labmap_test.go b/cli/internal/labmap/labmap_test.go index 3e8a7293..cdffdccc 100644 --- a/cli/internal/labmap/labmap_test.go +++ b/cli/internal/labmap/labmap_test.go @@ -42,6 +42,7 @@ func TestGOADVulns(t *testing.T) { assertNonEmpty(t, "asrep_roasting hosts", len(lab.HostsWithScript("asrep_roasting"))) assertNonEmpty(t, "constrained_delegation hosts", len(lab.HostsWithScript("constrained_delegation"))) assertNonEmpty(t, "ACLs", len(lab.AllACLs())) + assertLen(t, "cross-domain group memberships", len(lab.CrossDomainGroupMemberships()), 3) } func TestGOADPasswordInDescription(t *testing.T) { diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index 791fcd0c..7d645e3b 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -2964,6 +2964,110 @@ func (v *Validator) checkConfiguredGroups(ctx context.Context, w io.Writer) { } } +// checkCrossDomainGroupMemberships verifies every configured +// multi_domain_groups_member relation. Foreign members are represented in the +// local domain as ForeignSecurityPrincipal objects, so the probe resolves each +// configured principal's SID in its source domain and accepts either the +// principal DN or the local FSP DN as proof. +func (v *Validator) checkCrossDomainGroupMemberships(ctx context.Context, w io.Writer) { + printHeader(w, "Configured Cross-Domain Group Memberships") + + facts := v.lab.CrossDomainGroupMemberships() + if len(facts) == 0 { + v.addResult(w, "SKIP", "Groups", "No cross-domain group memberships configured", "") + return + } + + adminUser := v.lab.AdminUser + if adminUser == "" { + adminUser = "administrator" + } + + for _, gf := range facts { + dcRole := strings.ToUpper(gf.DCRole) + if !v.hasHost(dcRole) { + continue + } + domainConfig, ok := v.lab.DomainConfigs[gf.Domain] + if !ok { + v.addResult(w, "WARN", "Groups", + fmt.Sprintf("Could not verify cross-domain members of '%s': domain %s is not configured", gf.Group, gf.Domain), "") + continue + } + domainUsernamePrefix := domainConfig.NetBIOSName + if domainUsernamePrefix == "" { + domainUsernamePrefix = gf.Domain + } + + output, err := runScriptText(ctx, v, dcRole, + `$ErrorActionPreference = 'Stop'; `+ + `$localDomain = {{psq .Domain}}; `+ + `$domainUsername = {{psq .DomainUsername}}; `+ + `$password = ConvertTo-SecureString {{psq .DomainPassword}} -AsPlainText -Force; `+ + `$credential = New-Object System.Management.Automation.PSCredential($domainUsername, $password); `+ + `$group = Get-ADGroup -Identity {{psq .Group}} -Properties Members -Server $localDomain -Credential $credential -ErrorAction Stop; `+ + `$localBase = (Get-ADDomain -Server $localDomain -Credential $credential -ErrorAction Stop).DistinguishedName; `+ + `foreach ($member in {{psarr .Members}}) { `+ + ` $parts = $member -split '\\', 2; `+ + ` if ($parts.Count -ne 2) { Write-Output "MEMBER_INVALID=$member"; continue }; `+ + ` $memberDomain = $parts[0]; $memberName = $parts[1]; $adObject = $null; `+ + ` try { $adObject = Get-ADUser -Identity $memberName -Server $memberDomain -Credential $credential -ErrorAction Stop } catch { `+ + ` try { $adObject = Get-ADGroup -Identity $memberName -Server $memberDomain -Credential $credential -ErrorAction Stop } catch { `+ + ` Write-Output "MEMBER_LOOKUP_ERROR=$member"; continue `+ + ` } `+ + ` }; `+ + ` $expectedDNs = @($adObject.DistinguishedName, "CN=$($adObject.SID.Value),CN=ForeignSecurityPrincipals,$localBase"); `+ + ` if (@($group.Members | Where-Object { $expectedDNs -contains $_ }).Count -gt 0) { `+ + ` Write-Output "MEMBER_OK=$member" `+ + ` } else { `+ + ` Write-Output "MEMBER_MISSING=$member" `+ + ` } `+ + `}`, + map[string]any{ + "Domain": gf.Domain, + "DomainUsername": domainUsernamePrefix + `\` + adminUser, + "DomainPassword": domainConfig.DomainPassword, + "Group": gf.Group, + "Members": gf.Members, + }) + if err != nil { + v.addResult(w, "WARN", "Groups", + fmt.Sprintf("Could not verify cross-domain members of '%s' in %s: %v", gf.Group, gf.Domain, err), "") + continue + } + + var missing, unresolved []string + okCount := 0 + for _, line := range parseOutputLines(output) { + switch { + case strings.HasPrefix(line, "MEMBER_OK="): + okCount++ + case strings.HasPrefix(line, "MEMBER_MISSING="): + missing = append(missing, strings.TrimPrefix(line, "MEMBER_MISSING=")) + case strings.HasPrefix(line, "MEMBER_LOOKUP_ERROR="): + unresolved = append(unresolved, strings.TrimPrefix(line, "MEMBER_LOOKUP_ERROR=")) + case strings.HasPrefix(line, "MEMBER_INVALID="): + unresolved = append(unresolved, strings.TrimPrefix(line, "MEMBER_INVALID=")) + } + } + + switch { + case len(unresolved) > 0: + v.addResult(w, "WARN", "Groups", + fmt.Sprintf("Could not resolve configured members of '%s' in %s: %s", gf.Group, gf.Domain, strings.Join(unresolved, ", ")), "") + case len(missing) > 0: + v.addResult(w, "FAIL", "Groups", + fmt.Sprintf("Group '%s' missing configured members in %s: %s", gf.Group, gf.Domain, strings.Join(missing, ", ")), "") + case okCount != len(gf.Members): + v.addResult(w, "WARN", "Groups", + fmt.Sprintf("Could not confirm all configured members of '%s' in %s: got %d/%d proofs", gf.Group, gf.Domain, okCount, len(gf.Members)), "") + default: + v.addResult(w, "PASS", "Groups", + fmt.Sprintf("Group '%s' has %d/%d configured cross-domain members in %s", gf.Group, okCount, len(gf.Members), gf.Domain), "") + } + } +} + // ---- Section 11: Local Admin Access Map ---- // localAdminsQuery is `net localgroup Administrators` parsed down to its diff --git a/cli/internal/validate/validator.go b/cli/internal/validate/validator.go index 3fdb4b29..e64b23c7 100644 --- a/cli/internal/validate/validator.go +++ b/cli/internal/validate/validator.go @@ -198,6 +198,7 @@ func (v *Validator) RunAllChecks(ctx context.Context) { v.checkConfiguredUsers, // Section 3 — Configured Groups v.checkConfiguredGroups, + v.checkCrossDomainGroupMemberships, // Section 5 — Credential Discovery v.checkCredentialDiscovery, v.checkUsernamePasswordEqual,