Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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 {
Expand Down Expand Up @@ -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 = @{
Expand All @@ -73,7 +96,7 @@
}

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

Import-Module ActiveDirectory
$results = @{}
Expand All @@ -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]) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
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 @@ -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
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