Skip to content
Merged
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
89 changes: 69 additions & 20 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 @@ -67,19 +78,37 @@
$result = Invoke-Command -ScriptBlock $scriptBlock -Credential $adminCred -ComputerName localhost

return $result
error_action: continue
error_action: stop
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.error | default([]) | length > 0) or
(sync_result.output | default([]) | length == 0) or
(
sync_result.output | default([]) | 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 +122,7 @@
}

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

Import-Module ActiveDirectory
$results = @{}
Expand All @@ -108,7 +137,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 +167,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 +192,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 +224,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
125 changes: 125 additions & 0 deletions cli/internal/validate/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3312,6 +3312,131 @@ func (v *Validator) checkConfiguredGroups(ctx context.Context, w io.Writer) {
}
}

type crossDomainMembershipProbeResult struct {
okCount int
missing []string
unresolved []string
}

func parseCrossDomainMembershipOutput(output string) crossDomainMembershipProbeResult {
var result crossDomainMembershipProbeResult
for _, line := range parseOutputLines(output) {
switch {
case strings.HasPrefix(line, "MEMBER_OK="):
result.okCount++
case strings.HasPrefix(line, "MEMBER_MISSING="):
result.missing = append(result.missing, strings.TrimPrefix(line, "MEMBER_MISSING="))
case strings.HasPrefix(line, "MEMBER_LOOKUP_ERROR="):
result.unresolved = append(result.unresolved, strings.TrimPrefix(line, "MEMBER_LOOKUP_ERROR="))
case strings.HasPrefix(line, "MEMBER_INVALID="):
result.unresolved = append(result.unresolved, strings.TrimPrefix(line, "MEMBER_INVALID="))
}
}
return result
}

func (v *Validator) reportCrossDomainMembershipResult(
w io.Writer,
gf labmap.CrossDomainGroupFact,
result crossDomainMembershipProbeResult,
) {
switch {
case len(result.missing) > 0:
message := fmt.Sprintf("Group '%s' missing configured members in %s: %s", gf.Group, gf.Domain, strings.Join(result.missing, ", "))
if len(result.unresolved) > 0 {
message += fmt.Sprintf("; could not resolve: %s", strings.Join(result.unresolved, ", "))
}
v.addResult(w, "FAIL", "Groups",
message, "")
case len(result.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(result.unresolved, ", ")), "")
case result.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, result.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, result.okCount, len(gf.Members), gf.Domain), "")
}
}

// 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
}

result := parseCrossDomainMembershipOutput(output)
v.reportCrossDomainMembershipResult(w, gf, result)
}
}

// ---- Section 11: Local Admin Access Map ----

// localAdminsQuery is `net localgroup Administrators` parsed down to its
Expand Down
Loading
Loading