Skip to content
Open
81 changes: 62 additions & 19 deletions ansible/roles/groups_domains/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,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 {
Expand Down Expand Up @@ -69,17 +80,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 = @{
Expand All @@ -93,7 +116,7 @@
}

$scriptBlock = {
param($groupMemberships)
param($groupMemberships, $domainServer)

Import-Module ActiveDirectory
$results = @{}
Expand All @@ -108,7 +131,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]) {
Expand Down Expand Up @@ -138,7 +161,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 {
Expand All @@ -163,10 +186,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
Expand All @@ -186,14 +218,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
2 changes: 1 addition & 1 deletion ansible/roles/laps_permissions/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 46 additions & 10 deletions cli/internal/labmap/labmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions cli/internal/labmap/labmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
104 changes: 104 additions & 0 deletions cli/internal/validate/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3312,6 +3312,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
Expand Down
1 change: 1 addition & 0 deletions cli/internal/validate/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading