From 6bbd92a1163ce26954b6b24ada1811bd32e48b30 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 4 Apr 2026 09:41:02 -0600 Subject: [PATCH] feat: introduce dynamic lab topology parsing and generic validation via labmap **Added:** - Implemented labmap package to parse arbitrary GOAD-style lab topologies, supporting dynamic host/domain/user mappings and variant overlays - Added comprehensive tests in labmap_test.go covering multiple lab scenarios and vulnerability fact queries **Changed:** - Refactored health check, validation, and trust verification commands to use dynamic lab topology from labmap, removing hardcoded host/domain logic - Health checks, validation checks, and trust verifications are now driven by lab configuration, supporting custom/variant labs - Health check logic dynamically generates checks for DCs, servers, trusts, DNS, MSSQL, and IIS based on parsed lab state - All validation checks in validate/checks.go now enumerate lab topology and vulnerabilities via labmap queries instead of static host/user lists - Validator and infra context now require and propagate parsed lab config for all operations **Removed:** - Eliminated all hardcoded hostnames, domain names, and trust assumptions from health checks, validation, and trust verification logic - Removed static lists of GOAD hosts and replaced with lab-driven topology --- cli/cmd/health_check.go | 274 +++++++-------- cli/cmd/infra.go | 58 +++- cli/cmd/validate.go | 2 +- cli/cmd/verify_trusts.go | 131 +++---- cli/internal/labmap/labmap.go | 538 +++++++++++++++++++++++++++++ cli/internal/labmap/labmap_test.go | 145 ++++++++ cli/internal/validate/checks.go | 440 ++++++++++++++++------- cli/internal/validate/validator.go | 29 +- 8 files changed, 1266 insertions(+), 351 deletions(-) create mode 100644 cli/internal/labmap/labmap.go create mode 100644 cli/internal/labmap/labmap_test.go diff --git a/cli/cmd/health_check.go b/cli/cmd/health_check.go index 33f9e0db..e691073f 100644 --- a/cli/cmd/health_check.go +++ b/cli/cmd/health_check.go @@ -6,14 +6,15 @@ import ( "strings" "time" + "github.com/dreadnode/dreadgoad/internal/labmap" "github.com/fatih/color" "github.com/spf13/cobra" ) var healthCheckCmd = &cobra.Command{ Use: "health-check", - Short: "Verify all GOAD instances are healthy", - Long: `Runs health checks across all GOAD instances via SSM to verify: + Short: "Verify all lab instances are healthy", + Long: `Runs health checks across all lab instances via SSM to verify: - Domain controllers are responding - AD replication is working with no failures - Domain trusts are established @@ -40,7 +41,7 @@ type healthCheck struct { func runHealthCheck(cmd *cobra.Command, args []string) error { ctx := context.Background() - title := " GOAD Health Check " + title := " Lab Health Check " pad := 90 - len(title) left := pad / 2 right := pad - left @@ -54,7 +55,7 @@ func runHealthCheck(cmd *cobra.Command, args []string) error { fmt.Printf("%-40s %-10s %s\n", "CHECK", "STATUS", "DETAIL") fmt.Println(strings.Repeat("-", 90)) - checks := buildChecks() + checks := buildChecks(infra.Lab) passed := 0 failed := 0 @@ -62,8 +63,7 @@ func runHealthCheck(cmd *cobra.Command, args []string) error { for _, check := range checks { instanceID, ok := infra.HostMap[check.host] if !ok { - color.Red("%-40s %-10s %s", check.name, "SKIP", "instance not found") - failed++ + color.Yellow("%-40s %-10s %s", check.name, "SKIP", "instance not found") continue } @@ -98,129 +98,134 @@ func runHealthCheck(cmd *cobra.Command, args []string) error { return nil } -func buildChecks() []healthCheck { - return []healthCheck{ - // DC01 - AD responding - { - name: "DC01 AD Domain Controller", - host: "DC01", - command: `(Get-ADDomainController -Filter *).Name -join ','`, - eval: nonEmptyEval("no domain controllers returned"), - }, - // DC01 - Replication - { - name: "DC01 AD Replication", - host: "DC01", - command: `$r = repadmin /replsummary 2>&1 | Out-String; if ($r -match 'fails/total.*[1-9]\d*/') { Write-Output "REPL_ERRORS:$r" } else { Write-Output "REPL_OK" }`, - eval: replEval, - }, - // DC01 - Trusts - { - name: "DC01 Domain Trusts", - host: "DC01", - command: `Get-ADTrust -Filter * | ForEach-Object { "$($_.Name)|$($_.Direction)|$($_.TrustType)" }`, - eval: dc01TrustsEval, - }, - // DC02 - AD responding - { - name: "DC02 AD Domain Controller", - host: "DC02", - command: `(Get-ADDomainController -Filter *).Name -join ','`, - eval: nonEmptyEval("no domain controllers returned"), - }, - // DC02 - DNS cross-domain - { - name: "DC02 DNS (sevenkingdoms.local)", - host: "DC02", - command: `(Resolve-DnsName kingslanding.sevenkingdoms.local -ErrorAction Stop).IPAddress`, - eval: nonEmptyEval("DNS resolution failed"), - }, - { - name: "DC02 DNS (essos.local)", - host: "DC02", - command: `(Resolve-DnsName meereen.essos.local -ErrorAction Stop).IPAddress`, - eval: nonEmptyEval("DNS resolution failed"), - }, - // DC03 - AD responding - { - name: "DC03 AD Domain Controller", - host: "DC03", - command: `(Get-ADDomainController -Filter *).Name -join ','`, - eval: nonEmptyEval("no domain controllers returned"), - }, - // DC03 - Forest trust - { - name: "DC03 Forest Trust", - host: "DC03", - command: `Get-ADTrust -Filter * | ForEach-Object { "$($_.Name)|$($_.ForestTransitive)" }`, - eval: forestTrustEval, - }, - // SRV02 - Domain membership - { - name: "SRV02 Domain Membership", - host: "SRV02", - command: `(Get-WmiObject Win32_ComputerSystem).Domain`, - eval: nonEmptyEval("not domain-joined"), - }, - // SRV02 - DC reachable - { - name: "SRV02 DC Locator", - host: "SRV02", - command: `$r = nltest /dsgetdc: 2>&1 | Out-String; if ($r -match 'DC: \\\\(\S+)') { Write-Output $Matches[1] } else { Write-Output "FAIL" }`, - eval: dcLocatorEval, - }, - // SRV02 - IIS - { - name: "SRV02 IIS (W3SVC)", - host: "SRV02", - command: `(Get-Service W3SVC -ErrorAction SilentlyContinue).Status`, - eval: serviceRunningEval, - }, - // SRV02 - MSSQL - { - name: "SRV02 MSSQL", - host: "SRV02", - command: `(Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue).Status`, - eval: serviceRunningEval, - }, - // SRV03 - Domain membership - { - name: "SRV03 Domain Membership", - host: "SRV03", - command: `(Get-WmiObject Win32_ComputerSystem).Domain`, - eval: nonEmptyEval("not domain-joined"), - }, - // SRV03 - DC reachable - { - name: "SRV03 DC Locator", - host: "SRV03", - command: `$r = nltest /dsgetdc: 2>&1 | Out-String; if ($r -match 'DC: \\\\(\S+)') { Write-Output $Matches[1] } else { Write-Output "FAIL" }`, - eval: dcLocatorEval, - }, - // SRV03 - IIS - { - name: "SRV03 IIS (W3SVC)", - host: "SRV03", +func buildChecks(lab *labmap.LabMap) []healthCheck { + var checks []healthCheck + + // For each DC: AD responding + replication + for _, role := range lab.DCs() { + host := strings.ToUpper(role) + checks = append(checks, + healthCheck{ + name: fmt.Sprintf("%s AD Domain Controller", host), + host: host, + command: `(Get-ADDomainController -Filter *).Name -join ','`, + eval: nonEmptyEval("no domain controllers returned"), + }, + healthCheck{ + name: fmt.Sprintf("%s AD Replication", host), + host: host, + command: `$r = repadmin /replsummary 2>&1 | Out-String; if ($r -match 'fails/total.*[1-9]\d*/') { Write-Output "REPL_ERRORS:$r" } else { Write-Output "REPL_OK" }`, + eval: replEval, + }, + ) + } + + // Trust checks — derived from config + for _, tf := range lab.DomainTrusts() { + if tf.SourceDCRole != "" { + srcHost := strings.ToUpper(tf.SourceDCRole) + checks = append(checks, healthCheck{ + name: fmt.Sprintf("%s Trusts (%s)", srcHost, tf.TargetDomain), + host: srcHost, + command: `Get-ADTrust -Filter * | ForEach-Object { "$($_.Name)|$($_.Direction)|$($_.TrustType)" }`, + eval: trustContainsEval(tf.TargetDomain), + }) + } + if tf.TargetDCRole != "" { + tgtHost := strings.ToUpper(tf.TargetDCRole) + checks = append(checks, healthCheck{ + name: fmt.Sprintf("%s Trusts (%s)", tgtHost, tf.SourceDomain), + host: tgtHost, + command: `Get-ADTrust -Filter * | ForEach-Object { "$($_.Name)|$($_.Direction)|$($_.TrustType)" }`, + eval: trustContainsEval(tf.SourceDomain), + }) + } + } + + // Cross-domain DNS resolution between DCs on different domains + dcRoles := lab.DCs() + for i := 0; i < len(dcRoles); i++ { + for j := i + 1; j < len(dcRoles); j++ { + roleA, roleB := dcRoles[i], dcRoles[j] + domainA := lab.DomainForHost(roleA) + domainB := lab.DomainForHost(roleB) + if domainA == domainB { + continue + } + fqdnA := lab.FQDN(roleA) + fqdnB := lab.FQDN(roleB) + hostA := strings.ToUpper(roleA) + hostB := strings.ToUpper(roleB) + + if fqdnB != "" { + checks = append(checks, healthCheck{ + name: fmt.Sprintf("%s DNS (%s)", hostA, domainB), + host: hostA, + command: fmt.Sprintf(`(Resolve-DnsName %s -ErrorAction Stop).IPAddress`, fqdnB), + eval: nonEmptyEval("DNS resolution failed"), + }) + } + if fqdnA != "" { + checks = append(checks, healthCheck{ + name: fmt.Sprintf("%s DNS (%s)", hostB, domainA), + host: hostB, + command: fmt.Sprintf(`(Resolve-DnsName %s -ErrorAction Stop).IPAddress`, fqdnA), + eval: nonEmptyEval("DNS resolution failed"), + }) + } + } + } + + // Windows servers: domain membership + DC locator + services + for _, role := range lab.WindowsServers() { + host := strings.ToUpper(role) + + checks = append(checks, + healthCheck{ + name: fmt.Sprintf("%s Domain Membership", host), + host: host, + command: `(Get-WmiObject Win32_ComputerSystem).Domain`, + eval: nonEmptyEval("not domain-joined"), + }, + healthCheck{ + name: fmt.Sprintf("%s DC Locator", host), + host: host, + command: `$r = nltest /dsgetdc: 2>&1 | Out-String; if ($r -match 'DC: \\\\(\S+)') { Write-Output $Matches[1] } else { Write-Output "FAIL" }`, + eval: dcLocatorEval, + }, + ) + + // IIS check (optional — passes if not installed) + checks = append(checks, healthCheck{ + name: fmt.Sprintf("%s IIS (W3SVC)", host), + host: host, command: `(Get-Service W3SVC -ErrorAction SilentlyContinue).Status`, - eval: serviceRunningEval, - }, - // SRV03 - MSSQL - { - name: "SRV03 MSSQL", - host: "SRV03", - command: `(Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue).Status`, - eval: serviceRunningEval, - }, + eval: optionalServiceEval, + }) + } + + // MSSQL on hosts that have it configured + for _, role := range lab.HostsWithMSSQL() { + host := strings.ToUpper(role) + checks = append(checks, healthCheck{ + name: fmt.Sprintf("%s MSSQL", host), + host: host, + command: `(Get-Service 'MSSQL$SQLEXPRESS','MSSQLSERVER' -ErrorAction SilentlyContinue | Where-Object {$_.Status -eq 'Running'}).Name`, + eval: nonEmptyEval("MSSQL not running"), + }) } + + return checks } -func serviceRunningEval(stdout string) (bool, string) { +// optionalServiceEval passes if running, skips (passes) if not installed. +func optionalServiceEval(stdout string) (bool, string) { val := strings.TrimSpace(strings.ToLower(stdout)) if val == "running" { return true, "running" } if val == "" { - return false, "service not found" + return true, "not installed (OK)" } return false, val } @@ -243,29 +248,14 @@ func replEval(stdout string) (bool, string) { return false, "replication errors detected" } -func dc01TrustsEval(stdout string) (bool, string) { - lower := strings.ToLower(stdout) - hasNorth := strings.Contains(lower, "north.sevenkingdoms.local") - hasEssos := strings.Contains(lower, "essos.local") - if hasNorth && hasEssos { - return true, "north.sevenkingdoms.local + essos.local" - } - var missing []string - if !hasNorth { - missing = append(missing, "north.sevenkingdoms.local") - } - if !hasEssos { - missing = append(missing, "essos.local") - } - return false, "missing: " + strings.Join(missing, ", ") -} - -func forestTrustEval(stdout string) (bool, string) { - lower := strings.ToLower(stdout) - if strings.Contains(lower, "sevenkingdoms.local") && strings.Contains(lower, "true") { - return true, "sevenkingdoms.local (forest transitive)" +// trustContainsEval returns an eval func that checks if the trust output mentions a domain. +func trustContainsEval(expectedDomain string) func(string) (bool, string) { + return func(stdout string) (bool, string) { + if strings.Contains(strings.ToLower(stdout), strings.ToLower(expectedDomain)) { + return true, expectedDomain + } + return false, "trust to " + expectedDomain + " not found" } - return false, "forest trust to sevenkingdoms.local not found" } func dcLocatorEval(stdout string) (bool, string) { diff --git a/cli/cmd/infra.go b/cli/cmd/infra.go index cbd921d3..f9c67f13 100644 --- a/cli/cmd/infra.go +++ b/cli/cmd/infra.go @@ -3,22 +3,22 @@ package cmd import ( "context" "fmt" + "path/filepath" "strings" daws "github.com/dreadnode/dreadgoad/internal/aws" "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/labmap" "github.com/fatih/color" ) -// goadHosts is the set of expected GOAD hostnames. -var goadHosts = []string{"DC01", "DC02", "DC03", "SRV02", "SRV03"} - // infraContext holds the validated infrastructure state needed by commands. type infraContext struct { Client *daws.Client HostMap map[string]string // hostname -> instance ID Env string Region string + Lab *labmap.LabMap } // requireInfra validates that AWS credentials work, GOAD instances are discoverable, @@ -45,7 +45,34 @@ func requireInfra(ctx context.Context) (*infraContext, error) { } color.Green(" AWS credentials OK (account %s)", identity.Account) - hostMap, err := discoverHostMap(ctx, client, cfg.Env) + // Load lab config first so we know which hosts to look for. + var lab *labmap.LabMap + ec := cfg.ActiveEnvironment() + if ec.Variant { + _, target := cfg.ResolvedVariantPaths() + var loadErr error + lab, loadErr = labmap.LoadFromVariant(target) + if loadErr != nil { + return nil, fmt.Errorf("load variant mapping: %w", loadErr) + } + } else { + src := ec.VariantSource + if src == "" { + src = "ad/GOAD" + } + if !filepath.IsAbs(src) { + src = filepath.Join(cfg.ProjectRoot, src) + } + var loadErr error + lab, loadErr = labmap.LoadFromSource(src) + if loadErr != nil { + return nil, fmt.Errorf("load lab config: %w", loadErr) + } + } + + // Derive expected hosts from the lab config instead of hardcoding. + expectedHosts := lab.HostRoles() + hostMap, err := discoverHostMap(ctx, client, cfg.Env, expectedHosts) if err != nil { return nil, err } @@ -60,35 +87,38 @@ func requireInfra(ctx context.Context) (*infraContext, error) { HostMap: hostMap, Env: cfg.Env, Region: region, + Lab: lab, }, nil } -// discoverHostMap finds running GOAD instances and maps hostnames to instance IDs. -func discoverHostMap(ctx context.Context, client *daws.Client, env string) (map[string]string, error) { +// discoverHostMap finds running instances and maps host roles to instance IDs. +func discoverHostMap(ctx context.Context, client *daws.Client, env string, expectedHosts []string) (map[string]string, error) { instances, err := client.DiscoverInstances(ctx, env) if err != nil { return nil, fmt.Errorf("discover instances: %w", err) } if len(instances) == 0 { - return nil, fmt.Errorf("no running GOAD instances found for env=%s", env) + return nil, fmt.Errorf("no running instances found for env=%s", env) } hostMap := make(map[string]string) for _, inst := range instances { name := strings.ToUpper(inst.Name) - for _, h := range goadHosts { - if strings.Contains(name, h) { - hostMap[h] = inst.InstanceID + for _, h := range expectedHosts { + upper := strings.ToUpper(h) + if strings.Contains(name, upper) { + hostMap[upper] = inst.InstanceID } } } var found, missing []string - for _, h := range goadHosts { - if _, ok := hostMap[h]; ok { - found = append(found, h) + for _, h := range expectedHosts { + upper := strings.ToUpper(h) + if _, ok := hostMap[upper]; ok { + found = append(found, upper) } else { - missing = append(missing, h) + missing = append(missing, upper) } } color.Green(" Instances discovered: %s", strings.Join(found, ", ")) diff --git a/cli/cmd/validate.go b/cli/cmd/validate.go index a944813b..0d9aa022 100644 --- a/cli/cmd/validate.go +++ b/cli/cmd/validate.go @@ -56,7 +56,7 @@ func runValidate(cmd *cobra.Command, args []string) error { fmt.Printf("Environment: %s\n", infra.Env) fmt.Printf("Region: %s\n", infra.Region) - v := validate.NewValidator(infra.Client, infra.Env, verbose, slog.Default()) + v := validate.NewValidator(infra.Client, infra.Env, verbose, slog.Default(), infra.Lab) if err := v.DiscoverHosts(ctx); err != nil { return fmt.Errorf("discover hosts: %w", err) diff --git a/cli/cmd/verify_trusts.go b/cli/cmd/verify_trusts.go index 11a737e9..8b2d24f2 100644 --- a/cli/cmd/verify_trusts.go +++ b/cli/cmd/verify_trusts.go @@ -12,12 +12,13 @@ import ( var verifyTrustsCmd = &cobra.Command{ Use: "verify-trusts", - Short: "Verify domain trust relationships between all GOAD domains", + Short: "Verify domain trust relationships between all lab domains", Long: `Validates that all domain trusts are properly configured: - - sevenkingdoms.local <-> north.sevenkingdoms.local (parent-child) - - sevenkingdoms.local <-> essos.local (forest trust) + - Parent-child trusts + - Forest trusts + - Cross-domain authentication -Also tests cross-domain authentication by querying users across trusts.`, +Domain names and trust relationships are resolved from the lab config.`, Example: ` dreadgoad verify-trusts dreadgoad verify-trusts --env staging`, RunE: runVerifyTrusts, @@ -30,7 +31,7 @@ func init() { func runVerifyTrusts(cmd *cobra.Command, args []string) error { ctx := context.Background() - title := " GOAD Trust Verification " + title := " Trust Verification " pad := 90 - len(title) left := pad / 2 right := pad - left @@ -41,74 +42,86 @@ func runVerifyTrusts(cmd *cobra.Command, args []string) error { return err } - dc01ID, ok := infra.HostMap["DC01"] - if !ok { - return fmt.Errorf("DC01 not found in discovered instances") + lab := infra.Lab + trusts := lab.DomainTrusts() + if len(trusts) == 0 { + color.Yellow("No domain trusts configured for this lab") + return nil } - fmt.Printf("Using DC01 (%s) as trust verification source...\n\n", dc01ID) - - trustScript := `Write-Host "=== Domain Trusts from sevenkingdoms.local ===" -Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType, ForestTransitive, TrustAttributes -AutoSize - -Write-Host "" -Write-Host "=== Trust Validation ===" -nltest /domain_trusts /all_trusts + allGood := true -Write-Host "" -Write-Host "=== Cross-Domain Query Test ===" -Write-Host "Querying north.sevenkingdoms.local:" -Get-ADUser -Filter * -Server winterfell.north.sevenkingdoms.local | Select -First 3 Name | Format-Table -AutoSize -Write-Host "Querying essos.local:" -Get-ADUser -Filter * -Server meereen.essos.local | Select -First 3 Name | Format-Table -AutoSize - -Write-Host "" -Write-Host "=== Trust Status ===" -$trusts = Get-ADTrust -Filter * -foreach ($t in $trusts) { - Write-Host "$($t.Name): $(if (Test-ComputerSecureChannel -Server $t.Name -ErrorAction SilentlyContinue) { 'HEALTHY' } else { 'Check manually' })" -}` + for _, tf := range trusts { + // Verify from the source DC + if tf.SourceDCRole == "" { + continue + } + srcHost := strings.ToUpper(tf.SourceDCRole) + srcID, ok := infra.HostMap[srcHost] + if !ok { + color.Red(" ✗ %s not found for trust verification", srcHost) + allGood = false + continue + } - result, err := infra.Client.RunPowerShellCommand(ctx, dc01ID, trustScript, 2*time.Minute) - if err != nil { - return fmt.Errorf("run trust verification: %w", err) - } + fmt.Printf("\nVerifying trusts from %s (%s)...\n", srcHost, tf.SourceDomain) + + // Build a verification script that checks trusts and cross-domain queries + var script strings.Builder + fmt.Fprintf(&script, "Write-Host '=== Domain Trusts from %s ==='\n", tf.SourceDomain) + script.WriteString("Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType, ForestTransitive, TrustAttributes -AutoSize\n") + script.WriteString("\nWrite-Host ''\nWrite-Host '=== Trust Validation ==='\n") + script.WriteString("nltest /domain_trusts /all_trusts\n") + + // Cross-domain query if we have the target DC FQDN + if tf.TargetDCRole != "" { + tgtFQDN := lab.FQDN(tf.TargetDCRole) + if tgtFQDN != "" { + fmt.Fprintf(&script, "\nWrite-Host ''\nWrite-Host '=== Cross-Domain Query Test ==='\n") + fmt.Fprintf(&script, "Write-Host 'Querying %s:'\n", tf.TargetDomain) + fmt.Fprintf(&script, "Get-ADUser -Filter * -Server %s | Select -First 3 Name | Format-Table -AutoSize\n", tgtFQDN) + } + } - fmt.Printf("Status: %s\n\n", result.Status) + script.WriteString("\nWrite-Host ''\nWrite-Host '=== Trust Status ==='\n") + script.WriteString("$trusts = Get-ADTrust -Filter *\n") + script.WriteString("foreach ($t in $trusts) {\n") + script.WriteString(" Write-Host \"$($t.Name): $(if (Test-ComputerSecureChannel -Server $t.Name -ErrorAction SilentlyContinue) { 'HEALTHY' } else { 'Check manually' })\"\n") + script.WriteString("}\n") - if result.Stdout != "" { - fmt.Println(result.Stdout) - } - if result.Stderr != "" { - color.Yellow("STDERR: %s", result.Stderr) - } + result, err := infra.Client.RunPowerShellCommand(ctx, srcID, script.String(), 2*time.Minute) + if err != nil { + color.Red(" ✗ Trust verification failed: %v", err) + allGood = false + continue + } - if result.Status == "Success" { - // Verify expected trusts are present in output - output := strings.ToLower(result.Stdout) - allGood := true + fmt.Printf("Status: %s\n\n", result.Status) - if strings.Contains(output, "north.sevenkingdoms.local") { - color.Green(" ✓ Parent-child trust: north.sevenkingdoms.local") - } else { - color.Red(" ✗ Parent-child trust: north.sevenkingdoms.local NOT found") - allGood = false + if result.Stdout != "" { + fmt.Println(result.Stdout) + } + if result.Stderr != "" { + color.Yellow("STDERR: %s", result.Stderr) } - if strings.Contains(output, "essos.local") { - color.Green(" ✓ Forest trust: essos.local") + if result.Status == "Success" { + output := strings.ToLower(result.Stdout) + if strings.Contains(output, strings.ToLower(tf.TargetDomain)) { + color.Green(" ✓ Trust: %s -> %s", tf.SourceDomain, tf.TargetDomain) + } else { + color.Red(" ✗ Trust: %s -> %s NOT found", tf.SourceDomain, tf.TargetDomain) + allGood = false + } } else { - color.Red(" ✗ Forest trust: essos.local NOT found") allGood = false } + } - fmt.Println("\n=== Trust Verification Complete ===") + fmt.Println("\n=== Trust Verification Complete ===") - if !allGood { - return fmt.Errorf("one or more trust verifications failed") - } - return nil + if !allGood { + return fmt.Errorf("one or more trust verifications failed") } - - return fmt.Errorf("trust verification returned status: %s", result.Status) + return nil } diff --git a/cli/internal/labmap/labmap.go b/cli/internal/labmap/labmap.go new file mode 100644 index 00000000..340106fa --- /dev/null +++ b/cli/internal/labmap/labmap.go @@ -0,0 +1,538 @@ +package labmap + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// HostInfo holds hostname and domain mappings for a single host (variant support). +type HostInfo struct { + OldHostname string `json:"old_hostname"` + NewHostname string `json:"new_hostname"` + OldFQDN string `json:"old_fqdn"` + NewFQDN string `json:"new_fqdn"` + OldDomain string `json:"old_domain"` + NewDomain string `json:"new_domain"` +} + +// HostConfig represents a host from config.json lab.hosts. +type HostConfig struct { + Hostname string `json:"hostname"` + Type string `json:"type"` // "dc" or "server" + OS string `json:"os"` // empty = windows, "linux" for linux + Domain string `json:"domain"` + Path string `json:"path"` + Scripts []string `json:"scripts"` + Vulns []string `json:"vulns"` + VulnsVars map[string]json.RawMessage `json:"vulns_vars"` + Security []string `json:"security"` + MSSQL *MSSQLConfig `json:"mssql"` +} + +// MSSQLConfig holds MSSQL configuration for a host. +type MSSQLConfig struct { + SAPassword string `json:"sa_password"` + ServiceAccount string `json:"svcaccount"` + SysAdmins []string `json:"sysadmins"` +} + +// UserConfig represents a user from config.json domains[*].users[*]. +type UserConfig struct { + FirstName string `json:"firstname"` + Surname string `json:"surname"` + Password string `json:"password"` + Description string `json:"description"` + Groups []string `json:"groups"` + Path string `json:"path"` + SPNs []string `json:"spns"` +} + +// ACLConfig represents an ACL entry from config.json domains[*].acls[*]. +type ACLConfig struct { + Name string // key from the acls map + For string `json:"for"` + To string `json:"to"` + Right string `json:"right"` + Inheritance string `json:"inheritance"` +} + +// DomainConfig represents a domain from config.json lab.domains. +type DomainConfig struct { + DC string `json:"dc"` // host role key + NetBIOSName string `json:"netbios_name"` + Trust string `json:"trust"` + CAServer string `json:"ca_server"` + CAWebEnrollment *bool `json:"ca_web_enrollment"` + Users map[string]UserConfig `json:"users"` + ACLs map[string]ACLConfig `json:"acls"` +} + +// LabMap holds the resolved lab configuration for any GOAD-style lab. +type LabMap struct { + // Domain shortcuts — populated from DomainConfigs for backward compat. + // Empty if the lab doesn't have that many domains. + RootDomain string + ChildDomain string + ForestDomain string + + // Host name/domain mappings (variant-aware). + Hosts map[string]HostInfo // keyed by role (dc01, srv02, etc.) + // Variant user mapping (old -> new). Nil for non-variant. + Users map[string]string + + // Full parsed config data. + HostConfigs map[string]HostConfig // keyed by role + DomainConfigs map[string]DomainConfig // keyed by domain FQDN +} + +// FQDN returns the FQDN for a host role. +func (m *LabMap) FQDN(role string) string { + if h, ok := m.Hosts[role]; ok { + return h.NewFQDN + } + return "" +} + +// Hostname returns the hostname for a host role. +func (m *LabMap) Hostname(role string) string { + if h, ok := m.Hosts[role]; ok { + return h.NewHostname + } + return "" +} + +// User returns the mapped username, or the original if no mapping exists. +func (m *LabMap) User(original string) string { + if m.Users != nil { + if mapped, ok := m.Users[original]; ok { + return mapped + } + } + return original +} + +// HostRoles returns all host role keys sorted alphabetically. +func (m *LabMap) HostRoles() []string { + roles := make([]string, 0, len(m.HostConfigs)) + for role := range m.HostConfigs { + roles = append(roles, role) + } + sort.Strings(roles) + return roles +} + +// DCs returns host roles that are domain controllers. +func (m *LabMap) DCs() []string { + var dcs []string + for role, hc := range m.HostConfigs { + if hc.Type == "dc" { + dcs = append(dcs, role) + } + } + sort.Strings(dcs) + return dcs +} + +// WindowsServers returns host roles that are Windows servers (not DCs, not linux). +func (m *LabMap) WindowsServers() []string { + var servers []string + for role, hc := range m.HostConfigs { + if hc.Type == "server" && !strings.EqualFold(hc.OS, "linux") { + servers = append(servers, role) + } + } + sort.Strings(servers) + return servers +} + +// WindowsHosts returns all host roles that are Windows (DCs + Windows servers). +func (m *LabMap) WindowsHosts() []string { + var hosts []string + for role, hc := range m.HostConfigs { + if !strings.EqualFold(hc.OS, "linux") { + _ = hc + hosts = append(hosts, role) + } + } + sort.Strings(hosts) + return hosts +} + +// DCForDomain returns the host role that is the DC for a given domain FQDN. +func (m *LabMap) DCForDomain(domain string) string { + if dc, ok := m.DomainConfigs[domain]; ok { + return dc.DC + } + return "" +} + +// DomainForHost returns the domain FQDN for a given host role. +func (m *LabMap) DomainForHost(role string) string { + if hc, ok := m.HostConfigs[role]; ok { + return hc.Domain + } + return "" +} + +// Domains returns all domain FQDNs sorted alphabetically. +func (m *LabMap) Domains() []string { + domains := make([]string, 0, len(m.DomainConfigs)) + for d := range m.DomainConfigs { + domains = append(domains, d) + } + sort.Strings(domains) + return domains +} + +// --- Vulnerability fact queries --- + +// UserFact holds a user with context about which domain they belong to. +type UserFact struct { + Username string + Domain string + DCRole string // host role of the domain's DC + User UserConfig +} + +// TrustFact holds a trust relationship between two domains. +type TrustFact struct { + SourceDomain string + TargetDomain string + SourceDCRole string + TargetDCRole string +} + +// ACLFact holds an ACL entry with its domain context. +type ACLFact struct { + Domain string + DCRole string + ACL ACLConfig +} + +// UsersWithPasswordInDescription returns users whose description contains their password. +func (m *LabMap) UsersWithPasswordInDescription() []UserFact { + var facts []UserFact + for domain, dc := range m.DomainConfigs { + for username, user := range dc.Users { + if user.Password != "" && user.Description != "" && + strings.Contains(strings.ToLower(user.Description), strings.ToLower(user.Password)) { + facts = append(facts, UserFact{ + Username: m.User(username), + Domain: domain, + DCRole: dc.DC, + User: user, + }) + } + } + } + return facts +} + +// UsersWithSPNs returns users that have SPNs configured (kerberoastable). +func (m *LabMap) UsersWithSPNs() []UserFact { + var facts []UserFact + for domain, dc := range m.DomainConfigs { + for username, user := range dc.Users { + if len(user.SPNs) > 0 { + facts = append(facts, UserFact{ + Username: m.User(username), + Domain: domain, + DCRole: dc.DC, + User: user, + }) + } + } + } + return facts +} + +// HostsWithScript returns host roles that have the given script name in their scripts list. +func (m *LabMap) HostsWithScript(scriptPattern string) []string { + var hosts []string + pattern := strings.ToLower(scriptPattern) + for role, hc := range m.HostConfigs { + for _, script := range hc.Scripts { + if strings.Contains(strings.ToLower(script), pattern) { + hosts = append(hosts, role) + break + } + } + } + sort.Strings(hosts) + return hosts +} + +// HostsWithVuln returns host roles that have the given vuln tag. +func (m *LabMap) HostsWithVuln(vuln string) []string { + var hosts []string + for role, hc := range m.HostConfigs { + for _, v := range hc.Vulns { + if v == vuln { + hosts = append(hosts, role) + break + } + } + } + sort.Strings(hosts) + return hosts +} + +// HostsWithMSSQL returns host roles that have MSSQL configured. +func (m *LabMap) HostsWithMSSQL() []string { + var hosts []string + for role, hc := range m.HostConfigs { + if hc.MSSQL != nil { + hosts = append(hosts, role) + } + } + sort.Strings(hosts) + return hosts +} + +// ADCSHosts returns host roles that serve as ADCS CA servers, along with the domain. +func (m *LabMap) ADCSHosts() []string { + caHosts := make(map[string]bool) + // Find hosts that are CA servers for any domain + for _, dc := range m.DomainConfigs { + if dc.CAServer == "" { + continue + } + // CA server name matches a hostname + for role, hc := range m.HostConfigs { + if strings.EqualFold(hc.Hostname, dc.CAServer) { + caHosts[role] = true + } + } + } + // Also find hosts with adcs_templates in vulns_vars + for role, hc := range m.HostConfigs { + if _, ok := hc.VulnsVars["adcs_templates"]; ok { + caHosts[role] = true + } + } + var hosts []string + for role := range caHosts { + hosts = append(hosts, role) + } + sort.Strings(hosts) + return hosts +} + +// CAWebEnrollment returns true if any domain has CA web enrollment enabled. +// Defaults to true unless explicitly set to false. +func (m *LabMap) CAWebEnrollment() bool { + for _, dc := range m.DomainConfigs { + if dc.CAServer != "" { + if dc.CAWebEnrollment != nil && !*dc.CAWebEnrollment { + continue + } + return true // default is enabled + } + } + return false +} + +// DomainTrusts returns all configured trust relationships. +func (m *LabMap) DomainTrusts() []TrustFact { + seen := make(map[string]bool) + var facts []TrustFact + for domain, dc := range m.DomainConfigs { + if dc.Trust == "" { + continue + } + // Deduplicate bidirectional trusts + key := domain + "|" + dc.Trust + reverseKey := dc.Trust + "|" + domain + if seen[key] || seen[reverseKey] { + continue + } + seen[key] = true + targetDC := "" + if tdc, ok := m.DomainConfigs[dc.Trust]; ok { + targetDC = tdc.DC + } + facts = append(facts, TrustFact{ + SourceDomain: domain, + TargetDomain: dc.Trust, + SourceDCRole: dc.DC, + TargetDCRole: targetDC, + }) + } + return facts +} + +// AllACLs returns all ACL entries across all domains. +func (m *LabMap) AllACLs() []ACLFact { + var facts []ACLFact + for domain, dc := range m.DomainConfigs { + for name, acl := range dc.ACLs { + acl.Name = name + facts = append(facts, ACLFact{ + Domain: domain, + DCRole: dc.DC, + ACL: acl, + }) + } + } + return facts +} + +// --- Config parsing --- + +// rawLabConfig mirrors the full config.json structure. +type rawLabConfig struct { + Lab struct { + Hosts map[string]json.RawMessage `json:"hosts"` + Domains map[string]DomainConfig `json:"domains"` + } `json:"lab"` +} + +// variantMapping mirrors mapping.json. +type variantMapping struct { + Domains map[string]string `json:"domains"` + Hosts map[string]HostInfo `json:"hosts"` + Users map[string]string `json:"users"` +} + +// LoadFromSource reads config.json and builds a fully populated LabMap. +func LoadFromSource(sourceDir string) (*LabMap, error) { + path := filepath.Join(sourceDir, "data", "config.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read lab config: %w", err) + } + + return parseConfig(data) +} + +// LoadFromVariant reads mapping.json from a variant target directory. +// It also loads the full config from the variant's own data/config.json if it exists, +// falling back to just the mapping data. +func LoadFromVariant(variantTargetDir string) (*LabMap, error) { + // Try loading the variant's own config.json for full config data + configPath := filepath.Join(variantTargetDir, "data", "config.json") + if data, err := os.ReadFile(configPath); err == nil { + lm, parseErr := parseConfig(data) + if parseErr == nil { + // Also load mapping.json for the user mapping table + mappingPath := filepath.Join(variantTargetDir, "mapping.json") + if mData, mErr := os.ReadFile(mappingPath); mErr == nil { + var vm variantMapping + if json.Unmarshal(mData, &vm) == nil { + lm.Users = vm.Users + // Use variant host info for name mappings + if len(vm.Hosts) > 0 { + lm.Hosts = vm.Hosts + resolveDomains(lm) + } + } + } + return lm, nil + } + } + + // Fallback: mapping.json only (no full config) + mappingPath := filepath.Join(variantTargetDir, "mapping.json") + mData, err := os.ReadFile(mappingPath) + if err != nil { + return nil, fmt.Errorf("read variant mapping: %w", err) + } + + var vm variantMapping + if err := json.Unmarshal(mData, &vm); err != nil { + return nil, fmt.Errorf("parse variant mapping: %w", err) + } + + lm := &LabMap{ + Hosts: vm.Hosts, + Users: vm.Users, + HostConfigs: make(map[string]HostConfig), + DomainConfigs: make(map[string]DomainConfig), + } + resolveDomains(lm) + return lm, nil +} + +func parseConfig(data []byte) (*LabMap, error) { + var raw rawLabConfig + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse lab config: %w", err) + } + + lm := &LabMap{ + Hosts: make(map[string]HostInfo, len(raw.Lab.Hosts)), + HostConfigs: make(map[string]HostConfig, len(raw.Lab.Hosts)), + DomainConfigs: raw.Lab.Domains, + } + + if lm.DomainConfigs == nil { + lm.DomainConfigs = make(map[string]DomainConfig) + } + + // Parse each host + for role, rawHost := range raw.Lab.Hosts { + var hc HostConfig + if err := json.Unmarshal(rawHost, &hc); err != nil { + return nil, fmt.Errorf("parse host %s: %w", role, err) + } + lm.HostConfigs[role] = hc + + fqdn := hc.Hostname + "." + hc.Domain + lm.Hosts[role] = HostInfo{ + OldHostname: hc.Hostname, NewHostname: hc.Hostname, + OldFQDN: fqdn, NewFQDN: fqdn, + OldDomain: hc.Domain, NewDomain: hc.Domain, + } + } + + resolveDomains(lm) + return lm, nil +} + +// resolveDomains sets RootDomain/ChildDomain/ForestDomain from the domain configs. +// For labs with 1 domain, only RootDomain is set. +// For labs with 2 domains, RootDomain and ChildDomain are set. +// For labs with 3+ domains, all three are set. +func resolveDomains(lm *LabMap) { + // Collect domains ordered by their DC role (dc01 < dc02 < dc03) + type domainEntry struct { + role string + domain string + } + var entries []domainEntry + + // From DomainConfigs (preferred — has explicit dc mapping) + if len(lm.DomainConfigs) > 0 { + for domain, dc := range lm.DomainConfigs { + entries = append(entries, domainEntry{role: dc.DC, domain: domain}) + } + } else { + // Fallback: derive from Hosts + seen := make(map[string]bool) + for role, h := range lm.Hosts { + domain := h.NewDomain + if domain != "" && !seen[domain] { + seen[domain] = true + entries = append(entries, domainEntry{role: role, domain: domain}) + } + } + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].role < entries[j].role + }) + + if len(entries) > 0 { + lm.RootDomain = entries[0].domain + } + if len(entries) > 1 { + lm.ChildDomain = entries[1].domain + } + if len(entries) > 2 { + lm.ForestDomain = entries[2].domain + } +} diff --git a/cli/internal/labmap/labmap_test.go b/cli/internal/labmap/labmap_test.go new file mode 100644 index 00000000..61c88e40 --- /dev/null +++ b/cli/internal/labmap/labmap_test.go @@ -0,0 +1,145 @@ +package labmap + +import ( + "path/filepath" + "runtime" + "testing" +) + +func projectRoot() string { + _, f, _, _ := runtime.Caller(0) + // cli/internal/labmap/labmap_test.go -> project root is 4 levels up + return filepath.Join(filepath.Dir(f), "..", "..", "..") +} + +func loadLab(t *testing.T, name string) *LabMap { + t.Helper() + root := projectRoot() + lab, err := LoadFromSource(filepath.Join(root, "ad", name)) + if err != nil { + t.Fatalf("LoadFromSource %s: %v", name, err) + } + return lab +} + +func TestGOADTopology(t *testing.T) { + lab := loadLab(t, "GOAD") + + assertLen(t, "domains", len(lab.DomainConfigs), 3) + assertLen(t, "hosts", len(lab.HostConfigs), 5) + assertLen(t, "DCs", len(lab.DCs()), 3) + assertLen(t, "WindowsServers", len(lab.WindowsServers()), 2) + assertLen(t, "trusts", len(lab.DomainTrusts()), 1) +} + +func TestGOADVulns(t *testing.T) { + lab := loadLab(t, "GOAD") + + assertNonEmpty(t, "UsersWithSPNs", len(lab.UsersWithSPNs())) + assertNonEmpty(t, "UsersWithPasswordInDescription", len(lab.UsersWithPasswordInDescription())) + assertLen(t, "MSSQL hosts", len(lab.HostsWithMSSQL()), 2) + assertNonEmpty(t, "ADCS hosts", len(lab.ADCSHosts())) + 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())) +} + +func TestGOADPasswordInDescription(t *testing.T) { + lab := loadLab(t, "GOAD") + + pwdUsers := lab.UsersWithPasswordInDescription() + found := false + for _, u := range pwdUsers { + if u.Username == "samwell.tarly" { + found = true + } + } + if !found { + t.Error("expected samwell.tarly in password-in-description users") + } +} + +func TestGOADDomains(t *testing.T) { + lab := loadLab(t, "GOAD") + + assertNotEmpty(t, "RootDomain", lab.RootDomain) + assertNotEmpty(t, "ChildDomain", lab.ChildDomain) + assertNotEmpty(t, "ForestDomain", lab.ForestDomain) +} + +func TestDRACARYSTopology(t *testing.T) { + lab := loadLab(t, "DRACARYS") + + assertLen(t, "domains", len(lab.DomainConfigs), 1) + assertLen(t, "hosts", len(lab.HostConfigs), 3) + assertLen(t, "DCs", len(lab.DCs()), 1) + assertLen(t, "WindowsServers", len(lab.WindowsServers()), 1) + assertLen(t, "trusts", len(lab.DomainTrusts()), 0) + assertLen(t, "MSSQL hosts", len(lab.HostsWithMSSQL()), 0) + assertLen(t, "SPN users", len(lab.UsersWithSPNs()), 0) +} + +func TestDRACARYSDomains(t *testing.T) { + lab := loadLab(t, "DRACARYS") + + assertNotEmpty(t, "RootDomain", lab.RootDomain) + if lab.ChildDomain != "" { + t.Errorf("ChildDomain should be empty, got %s", lab.ChildDomain) + } + if lab.ForestDomain != "" { + t.Errorf("ForestDomain should be empty, got %s", lab.ForestDomain) + } +} + +func TestNHATopology(t *testing.T) { + lab := loadLab(t, "NHA") + + assertLen(t, "domains", len(lab.DomainConfigs), 2) + assertLen(t, "hosts", len(lab.HostConfigs), 5) + assertLen(t, "DCs", len(lab.DCs()), 2) + assertLen(t, "WindowsServers", len(lab.WindowsServers()), 3) + assertLen(t, "trusts", len(lab.DomainTrusts()), 1) + assertLen(t, "SPN users", len(lab.UsersWithSPNs()), 2) + assertLen(t, "MSSQL hosts", len(lab.HostsWithMSSQL()), 1) +} + +func TestNHADomains(t *testing.T) { + lab := loadLab(t, "NHA") + + assertNotEmpty(t, "RootDomain", lab.RootDomain) + assertNotEmpty(t, "ChildDomain", lab.ChildDomain) +} + +func TestHostRolesNeverEmpty(t *testing.T) { + labs := []string{"GOAD", "DRACARYS", "NHA", "GOAD-Light", "GOAD-Mini"} + + for _, name := range labs { + t.Run(name, func(t *testing.T) { + lab := loadLab(t, name) + assertNonEmpty(t, "HostRoles", len(lab.HostRoles())) + assertNonEmpty(t, "DCs", len(lab.DCs())) + assertNonEmpty(t, "Domains", len(lab.Domains())) + }) + } +} + +func assertLen(t *testing.T, what string, got, want int) { + t.Helper() + if got != want { + t.Errorf("%s: expected %d, got %d", what, want, got) + } +} + +func assertNonEmpty(t *testing.T, what string, got int) { + t.Helper() + if got == 0 { + t.Errorf("%s: expected non-empty", what) + } +} + +func assertNotEmpty(t *testing.T, what, val string) { + t.Helper() + if val == "" { + t.Errorf("%s should not be empty", what) + } +} diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index c3e7958e..c6ba15d7 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -7,60 +7,98 @@ import ( ) func (v *Validator) checkCredentialDiscovery(ctx context.Context) { - fmt.Println("\n== 1. Credential Discovery Vulnerabilities ==") + fmt.Println("\n== Credential Discovery Vulnerabilities ==") - output := v.runPS(ctx, "DC02", `Get-ADUser -Filter * -Properties Description | Where-Object {$_.Description -match 'password|heartsbane'} | Select-Object SamAccountName,Description | Format-Table -AutoSize | Out-String -Width 200`) - if strings.Contains(strings.ToLower(output), "samwell.tarly") { - v.addResult("PASS", "Credentials", "samwell.tarly has password in description", "") - } else { - v.addResult("FAIL", "Credentials", "samwell.tarly does NOT have password in description", "") + users := v.lab.UsersWithPasswordInDescription() + if len(users) == 0 { + v.addResult("SKIP", "Credentials", "No users with password-in-description configured", "") + return + } + + for _, uf := range users { + dcRole := strings.ToUpper(uf.DCRole) + output := v.runPS(ctx, dcRole, fmt.Sprintf( + `Get-ADUser -Identity '%s' -Properties Description | Select-Object -ExpandProperty Description`, + uf.Username)) + if strings.Contains(strings.ToLower(output), strings.ToLower(uf.User.Password)) { + v.addResult("PASS", "Credentials", fmt.Sprintf("%s has password in description", uf.Username), "") + } else { + v.addResult("FAIL", "Credentials", fmt.Sprintf("%s does NOT have password in description", uf.Username), "") + } } } func (v *Validator) checkKerberosAttacks(ctx context.Context) { - fmt.Println("\n== 2. Kerberos Attack Vectors ==") + fmt.Println("\n== Kerberos Attack Vectors ==") - // AS-REP Roasting - output := v.runPS(ctx, "DC02", `Get-ADUser brandon.stark -Properties DoesNotRequirePreAuth | Select-Object SamAccountName,DoesNotRequirePreAuth | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "true") { - v.addResult("PASS", "Kerberos", "brandon.stark has DoesNotRequirePreAuth (AS-REP roastable)", "") - } else { - v.addResult("FAIL", "Kerberos", "brandon.stark does NOT have PreAuth disabled", "") + v.checkASREPRoasting(ctx) + v.checkKerberoasting(ctx) +} + +func (v *Validator) checkASREPRoasting(ctx context.Context) { + // Find DCs that run AS-REP roasting scripts + asrepHosts := v.lab.HostsWithScript("asrep_roasting") + if len(asrepHosts) == 0 { + v.addResult("SKIP", "Kerberos", "No AS-REP roasting scripts configured", "") + return } - output = v.runPS(ctx, "DC03", `Get-ADUser missandei -Properties DoesNotRequirePreAuth | Select-Object SamAccountName,DoesNotRequirePreAuth | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "true") { - v.addResult("PASS", "Kerberos", "missandei has DoesNotRequirePreAuth (AS-REP roastable)", "") - } else { - v.addResult("FAIL", "Kerberos", "missandei does NOT have PreAuth disabled", "") + for _, role := range asrepHosts { + dcRole := strings.ToUpper(role) + output := v.runPS(ctx, dcRole, + `Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} -Properties DoesNotRequirePreAuth | Select-Object -ExpandProperty SamAccountName`) + users := parseOutputLines(output) + if len(users) > 0 { + v.addResult("PASS", "Kerberos", + fmt.Sprintf("AS-REP roastable users on %s: %s", dcRole, strings.Join(users, ", ")), "") + } else { + v.addResult("FAIL", "Kerberos", + fmt.Sprintf("No AS-REP roastable users found on %s", dcRole), "") + } } +} - // Kerberoasting - output = v.runPS(ctx, "DC02", `Get-ADUser jon.snow -Properties ServicePrincipalName | Select-Object SamAccountName,ServicePrincipalName | Format-List | Out-String`) - if strings.Contains(strings.ToLower(output), "serviceprincipalname") { - v.addResult("PASS", "Kerberos", "jon.snow has SPNs configured (Kerberoastable)", "") - } else { - v.addResult("FAIL", "Kerberos", "jon.snow does NOT have SPNs configured", "") +func (v *Validator) checkKerberoasting(ctx context.Context) { + spnUsers := v.lab.UsersWithSPNs() + if len(spnUsers) == 0 { + v.addResult("SKIP", "Kerberos", "No users with SPNs configured", "") + return } - output = v.runPS(ctx, "DC02", `Get-ADUser sql_svc -Properties ServicePrincipalName | Select-Object SamAccountName,ServicePrincipalName | Format-List | Out-String`) - if strings.Contains(strings.ToLower(output), "serviceprincipalname") { - v.addResult("PASS", "Kerberos", "sql_svc has SPNs configured (Kerberoastable)", "") - } else { - v.addResult("FAIL", "Kerberos", "sql_svc does NOT have SPNs configured", "") + for _, uf := range spnUsers { + dcRole := strings.ToUpper(uf.DCRole) + output := v.runPS(ctx, dcRole, fmt.Sprintf( + `Get-ADUser -Identity '%s' -Properties ServicePrincipalName | Select-Object -ExpandProperty ServicePrincipalName`, + uf.Username)) + if strings.TrimSpace(output) != "" { + v.addResult("PASS", "Kerberos", + fmt.Sprintf("%s has SPNs configured (Kerberoastable)", uf.Username), "") + } else { + v.addResult("FAIL", "Kerberos", + fmt.Sprintf("%s does NOT have SPNs configured", uf.Username), "") + } } } func (v *Validator) checkNetworkMisconfigs(ctx context.Context) { - fmt.Println("\n== 3. Network-Level Misconfigurations ==") + fmt.Println("\n== Network-Level Misconfigurations ==") + + // Check SMB signing on all Windows servers + servers := v.lab.WindowsServers() + if len(servers) == 0 { + v.addResult("SKIP", "Network", "No Windows servers configured", "") + return + } - for _, host := range []string{"SRV02", "SRV03"} { + for _, role := range servers { + host := strings.ToUpper(role) if !v.hasHost(host) { continue } - output := v.runPS(ctx, host, `Get-SmbServerConfiguration | Select-Object RequireSecuritySignature,EnableSecuritySignature | Format-Table -AutoSize | Out-String`) + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + output := v.runPS(ctx, host, + `Get-SmbServerConfiguration | Select-Object RequireSecuritySignature,EnableSecuritySignature | Format-Table -AutoSize | Out-String`) lower := strings.ToLower(output) - hostLabel := map[string]string{"SRV02": "CASTELBLACK", "SRV03": "BRAAVOS"}[host] switch { case strings.Contains(lower, "false") && strings.Count(lower, "false") >= 2: @@ -74,33 +112,44 @@ func (v *Validator) checkNetworkMisconfigs(ctx context.Context) { } func (v *Validator) checkAnonymousSMB(ctx context.Context) { - fmt.Println("\n== 4. Anonymous/Guest SMB Enumeration ==") + fmt.Println("\n== Anonymous/Guest SMB Enumeration ==") - // RestrictAnonymous on DC02 - output := v.runPS(ctx, "DC02", `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name RestrictAnonymous -ErrorAction SilentlyContinue | Select-Object -ExpandProperty RestrictAnonymous`) - val := strings.TrimSpace(output) - if val == "0" { - v.addResult("PASS", "SMB", "RestrictAnonymous is 0 on WINTERFELL (NULL sessions enabled)", "") - } else { - v.addResult("FAIL", "SMB", fmt.Sprintf("RestrictAnonymous is %s on WINTERFELL (expected 0)", val), "") - } + // Check RestrictAnonymous on each DC + for _, role := range v.lab.DCs() { + host := strings.ToUpper(role) + if !v.hasHost(host) { + continue + } + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + + output := v.runPS(ctx, host, + `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name RestrictAnonymous -ErrorAction SilentlyContinue | Select-Object -ExpandProperty RestrictAnonymous`) + val := strings.TrimSpace(output) + if val == "0" { + v.addResult("PASS", "SMB", fmt.Sprintf("RestrictAnonymous is 0 on %s (NULL sessions enabled)", hostLabel), "") + } else { + v.addResult("INFO", "SMB", fmt.Sprintf("RestrictAnonymous is %s on %s", val, hostLabel), "") + } - // RestrictAnonymousSAM - output = v.runPS(ctx, "DC02", `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name RestrictAnonymousSAM -ErrorAction SilentlyContinue | Select-Object -ExpandProperty RestrictAnonymousSAM`) - val = strings.TrimSpace(output) - if val == "0" { - v.addResult("PASS", "SMB", "RestrictAnonymousSAM is 0 on WINTERFELL (SAM enum enabled)", "") - } else { - v.addResult("FAIL", "SMB", fmt.Sprintf("RestrictAnonymousSAM is %s on WINTERFELL (expected 0)", val), "") + output = v.runPS(ctx, host, + `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name RestrictAnonymousSAM -ErrorAction SilentlyContinue | Select-Object -ExpandProperty RestrictAnonymousSAM`) + val = strings.TrimSpace(output) + if val == "0" { + v.addResult("PASS", "SMB", fmt.Sprintf("RestrictAnonymousSAM is 0 on %s (SAM enum enabled)", hostLabel), "") + } else { + v.addResult("INFO", "SMB", fmt.Sprintf("RestrictAnonymousSAM is %s on %s", val, hostLabel), "") + } } - // Guest accounts on member servers - for _, host := range []string{"SRV02", "SRV03"} { + // Check Guest accounts on servers + for _, role := range v.lab.WindowsServers() { + host := strings.ToUpper(role) if !v.hasHost(host) { continue } - hostLabel := map[string]string{"SRV02": "CASTELBLACK", "SRV03": "BRAAVOS"}[host] - output = v.runPS(ctx, host, `Get-LocalUser -Name Guest | Select-Object Name,Enabled | Format-Table -AutoSize | Out-String`) + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + output := v.runPS(ctx, host, + `Get-LocalUser -Name Guest | Select-Object Name,Enabled | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "true") { v.addResult("PASS", "SMB", fmt.Sprintf("Guest account enabled on %s", hostLabel), "") } else { @@ -108,56 +157,105 @@ func (v *Validator) checkAnonymousSMB(ctx context.Context) { } } - // LmCompatibilityLevel on DC03 - output = v.runPS(ctx, "DC03", `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue | Select-Object -ExpandProperty LmCompatibilityLevel`) - val = strings.TrimSpace(output) - if val == "0" || val == "1" || val == "2" { - v.addResult("PASS", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on MEEREEN (NTLM downgrade vulnerable)", val), "") - } else { - v.addResult("FAIL", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on MEEREEN (expected 0-2)", val), "") + // Check NTLM downgrade on hosts with that vuln + for _, role := range v.lab.HostsWithVuln("ntlmdowngrade") { + host := strings.ToUpper(role) + if !v.hasHost(host) { + continue + } + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + output := v.runPS(ctx, host, + `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue | Select-Object -ExpandProperty LmCompatibilityLevel`) + val := strings.TrimSpace(output) + if val == "0" || val == "1" || val == "2" { + v.addResult("PASS", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on %s (NTLM downgrade vulnerable)", val, hostLabel), "") + } else { + v.addResult("FAIL", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on %s (expected 0-2)", val, hostLabel), "") + } } } func (v *Validator) checkDelegation(ctx context.Context) { - fmt.Println("\n== 5. Delegation Configurations ==") - - output := v.runPS(ctx, "DC02", `Get-ADUser sansa.stark -Properties TrustedForDelegation | Select-Object SamAccountName,TrustedForDelegation | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "true") { - v.addResult("PASS", "Delegation", "sansa.stark has unconstrained delegation", "") - } else { - v.addResult("FAIL", "Delegation", "sansa.stark does NOT have unconstrained delegation", "") + fmt.Println("\n== Delegation Configurations ==") + + // Find DCs with delegation scripts + allHosts := v.lab.HostsWithScript("constrained_delegation") + allHosts = append(allHosts, v.lab.HostsWithScript("unconstrained_delegation")...) + if len(allHosts) == 0 { + // Fall back to checking all DCs + allHosts = v.lab.DCs() + } + if len(allHosts) == 0 { + v.addResult("SKIP", "Delegation", "No domain controllers configured", "") + return } - output = v.runPS(ctx, "DC02", `Get-ADUser jon.snow -Properties msDS-AllowedToDelegateTo | Select-Object SamAccountName,msDS-AllowedToDelegateTo | Format-List | Out-String`) - if strings.Contains(strings.ToLower(output), "msds-allowedtodelegateto") { - v.addResult("PASS", "Delegation", "jon.snow has constrained delegation configured", "") - } else { - v.addResult("FAIL", "Delegation", "jon.snow does NOT have constrained delegation", "") + checked := make(map[string]bool) + for _, role := range allHosts { + host := strings.ToUpper(role) + if checked[host] || !v.hasHost(host) { + continue + } + checked[host] = true + + // Unconstrained delegation + output := v.runPS(ctx, host, + `Get-ADUser -Filter {TrustedForDelegation -eq $true} -Properties TrustedForDelegation | Select-Object -ExpandProperty SamAccountName`) + users := parseOutputLines(output) + if len(users) > 0 { + v.addResult("PASS", "Delegation", + fmt.Sprintf("Unconstrained delegation users on %s: %s", host, strings.Join(users, ", ")), "") + } + + // Constrained delegation + output = v.runPS(ctx, host, + `Get-ADUser -Filter 'msDS-AllowedToDelegateTo -like "*"' -Properties msDS-AllowedToDelegateTo | Select-Object -ExpandProperty SamAccountName`) + users = parseOutputLines(output) + if len(users) > 0 { + v.addResult("PASS", "Delegation", + fmt.Sprintf("Constrained delegation users on %s: %s", host, strings.Join(users, ", ")), "") + } } } func (v *Validator) checkMachineAccountQuota(ctx context.Context) { - fmt.Println("\n== 6. Machine Account Quota ==") + fmt.Println("\n== Machine Account Quota ==") - output := v.runPS(ctx, "DC01", `Get-ADObject -Identity ((Get-ADDomain).distinguishedname) -Properties ms-DS-MachineAccountQuota | Select-Object -ExpandProperty ms-DS-MachineAccountQuota`) - val := strings.TrimSpace(output) - if val == "10" { - v.addResult("PASS", "MachineQuota", "Machine Account Quota is 10 (allows RBCD)", "") - } else { - v.addResult("WARN", "MachineQuota", fmt.Sprintf("Machine Account Quota is %s (expected 10)", val), "") + for _, role := range v.lab.DCs() { + host := strings.ToUpper(role) + if !v.hasHost(host) { + continue + } + output := v.runPS(ctx, host, + `Get-ADObject -Identity ((Get-ADDomain).distinguishedname) -Properties ms-DS-MachineAccountQuota | Select-Object -ExpandProperty ms-DS-MachineAccountQuota`) + val := strings.TrimSpace(output) + if val == "10" { + v.addResult("PASS", "MachineQuota", "Machine Account Quota is 10 (allows RBCD)", "") + } else { + v.addResult("WARN", "MachineQuota", fmt.Sprintf("Machine Account Quota is %s (default is 10)", val), "") + } + return // Only check first available DC } } func (v *Validator) checkMSSQL(ctx context.Context) { - fmt.Println("\n== 7. MSSQL Configurations ==") + fmt.Println("\n== MSSQL Configurations ==") - for _, host := range []string{"SRV02", "SRV03"} { + mssqlHosts := v.lab.HostsWithMSSQL() + if len(mssqlHosts) == 0 { + v.addResult("SKIP", "MSSQL", "No MSSQL configured for this lab", "") + return + } + + for _, role := range mssqlHosts { + host := strings.ToUpper(role) if !v.hasHost(host) { continue } - hostLabel := map[string]string{"SRV02": "CASTELBLACK", "SRV03": "BRAAVOS"}[host] - output := v.runPS(ctx, host, `Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "running") { + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + output := v.runPS(ctx, host, + `Get-Service 'MSSQL$SQLEXPRESS','MSSQLSERVER' -ErrorAction SilentlyContinue | Where-Object {$_.Status -eq 'Running'} | Select-Object -ExpandProperty Name`) + if strings.TrimSpace(output) != "" { v.addResult("PASS", "MSSQL", fmt.Sprintf("MSSQL running on %s", hostLabel), "") } else { v.addResult("FAIL", "MSSQL", fmt.Sprintf("MSSQL NOT running on %s", hostLabel), "") @@ -166,65 +264,137 @@ func (v *Validator) checkMSSQL(ctx context.Context) { } func (v *Validator) checkADCS(ctx context.Context) { - fmt.Println("\n== 8. ADCS Configuration ==") + fmt.Println("\n== ADCS Configuration ==") - if !v.hasHost("SRV03") { + adcsHosts := v.lab.ADCSHosts() + if len(adcsHosts) == 0 { + v.addResult("SKIP", "ADCS", "No ADCS configured for this lab", "") return } - output := v.runPS(ctx, "SRV03", `Get-WindowsFeature ADCS-Cert-Authority | Select-Object Name,InstallState | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "installed") { - v.addResult("PASS", "ADCS", "ADCS installed on BRAAVOS", "") - } else { - v.addResult("FAIL", "ADCS", "ADCS NOT installed on BRAAVOS", "") - } + for _, role := range adcsHosts { + host := strings.ToUpper(role) + if !v.hasHost(host) { + continue + } + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + + output := v.runPS(ctx, host, + `Get-WindowsFeature ADCS-Cert-Authority | Select-Object Name,InstallState | Format-Table -AutoSize | Out-String`) + if strings.Contains(strings.ToLower(output), "installed") { + v.addResult("PASS", "ADCS", fmt.Sprintf("ADCS installed on %s", hostLabel), "") + } else { + v.addResult("FAIL", "ADCS", fmt.Sprintf("ADCS NOT installed on %s", hostLabel), "") + } - output = v.runPS(ctx, "SRV03", `Get-WindowsFeature ADCS-Web-Enrollment | Select-Object Name,InstallState | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "installed") { - v.addResult("PASS", "ADCS", "ADCS Web Enrollment installed (ESC8 possible)", "") - } else { - v.addResult("WARN", "ADCS", "ADCS Web Enrollment not installed", "") + if v.lab.CAWebEnrollment() { + output = v.runPS(ctx, host, + `Get-WindowsFeature ADCS-Web-Enrollment | Select-Object Name,InstallState | Format-Table -AutoSize | Out-String`) + if strings.Contains(strings.ToLower(output), "installed") { + v.addResult("PASS", "ADCS", "ADCS Web Enrollment installed (ESC8 possible)", "") + } else { + v.addResult("WARN", "ADCS", "ADCS Web Enrollment not installed", "") + } + } } } func (v *Validator) checkACLPermissions(ctx context.Context) { - fmt.Println("\n== 9. ACL Permissions ==") + fmt.Println("\n== ACL Permissions ==") - output := v.runPS(ctx, "DC01", `$user = Get-ADUser jaime.lannister -Properties nTSecurityDescriptor; $acl = $user.nTSecurityDescriptor.Access | Where-Object { $_.IdentityReference -like '*tywin*' }; if ($acl) { Write-Output 'ACL_FOUND' } else { Write-Output 'ACL_NOT_FOUND' }`) - switch { - case strings.Contains(output, "ACL_FOUND"): - v.addResult("PASS", "ACL", "tywin.lannister has ACL rights on jaime.lannister", "") - case strings.Contains(output, "ACL_NOT_FOUND"): - v.addResult("FAIL", "ACL", "tywin.lannister does NOT have ACL rights on jaime.lannister", "") - default: - v.addResult("WARN", "ACL", "Could not verify ACL: tywin -> jaime", "") + acls := v.lab.AllACLs() + if len(acls) == 0 { + v.addResult("SKIP", "ACL", "No ACLs configured for this lab", "") + return + } + + for _, af := range acls { + // Skip ACLs targeting full DN paths (complex to verify generically) + if strings.Contains(af.ACL.To, "CN=") && strings.Contains(af.ACL.To, "DC=") { + continue + } + // Skip ACLs targeting computer accounts + if strings.HasSuffix(af.ACL.To, "$") { + continue + } + + dcRole := strings.ToUpper(af.DCRole) + if !v.hasHost(dcRole) { + continue + } + + source := v.lab.User(af.ACL.For) + target := v.lab.User(af.ACL.To) + + sourceFirst := strings.SplitN(source, ".", 2)[0] + output := v.runPS(ctx, dcRole, fmt.Sprintf( + `$user = Get-ADUser '%s' -Properties nTSecurityDescriptor -ErrorAction SilentlyContinue; if ($user) { $acl = $user.nTSecurityDescriptor.Access | Where-Object { $_.IdentityReference -like '*%s*' }; if ($acl) { Write-Output 'ACL_FOUND' } else { Write-Output 'ACL_NOT_FOUND' } } else { Write-Output 'USER_NOT_FOUND' }`, + target, sourceFirst)) + + switch { + case strings.Contains(output, "ACL_FOUND"): + v.addResult("PASS", "ACL", fmt.Sprintf("%s has %s on %s", source, af.ACL.Right, target), "") + case strings.Contains(output, "ACL_NOT_FOUND"): + v.addResult("FAIL", "ACL", fmt.Sprintf("%s does NOT have %s on %s", source, af.ACL.Right, target), "") + default: + v.addResult("WARN", "ACL", fmt.Sprintf("Could not verify ACL: %s -> %s (%s)", source, target, af.ACL.Right), "") + } } } func (v *Validator) checkDomainTrusts(ctx context.Context) { - fmt.Println("\n== 10. Domain Trusts ==") + fmt.Println("\n== Domain Trusts ==") - output := v.runPS(ctx, "DC02", `Get-ADTrust -Filter * | Select-Object Name,Direction,TrustType | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "sevenkingdoms") { - v.addResult("PASS", "Trusts", "Parent-child trust configured (north -> sevenkingdoms)", "") - } else { - v.addResult("FAIL", "Trusts", "Parent-child trust NOT found", "") + trusts := v.lab.DomainTrusts() + if len(trusts) == 0 { + v.addResult("SKIP", "Trusts", "No domain trusts configured for this lab", "") + return } - output = v.runPS(ctx, "DC01", `Get-ADTrust -Filter * | Select-Object Name,Direction,TrustType | Format-Table -AutoSize | Out-String`) - if strings.Contains(strings.ToLower(output), "essos") { - v.addResult("PASS", "Trusts", "Forest trust configured (sevenkingdoms <-> essos)", "") - } else { - v.addResult("FAIL", "Trusts", "Forest trust NOT found", "") + for _, tf := range trusts { + if tf.SourceDCRole != "" { + srcHost := strings.ToUpper(tf.SourceDCRole) + if v.hasHost(srcHost) { + output := v.runPS(ctx, srcHost, + `Get-ADTrust -Filter * | Select-Object Name,Direction,TrustType | Format-Table -AutoSize | Out-String`) + if strings.Contains(strings.ToLower(output), strings.ToLower(tf.TargetDomain)) { + v.addResult("PASS", "Trusts", + fmt.Sprintf("Trust configured: %s -> %s", tf.SourceDomain, tf.TargetDomain), "") + } else { + v.addResult("FAIL", "Trusts", + fmt.Sprintf("Trust NOT found: %s -> %s", tf.SourceDomain, tf.TargetDomain), "") + } + } + } + + if tf.TargetDCRole != "" { + tgtHost := strings.ToUpper(tf.TargetDCRole) + if v.hasHost(tgtHost) { + output := v.runPS(ctx, tgtHost, + `Get-ADTrust -Filter * | Select-Object Name,Direction,TrustType | Format-Table -AutoSize | Out-String`) + if strings.Contains(strings.ToLower(output), strings.ToLower(tf.SourceDomain)) { + v.addResult("PASS", "Trusts", + fmt.Sprintf("Trust configured: %s -> %s", tf.TargetDomain, tf.SourceDomain), "") + } else { + v.addResult("FAIL", "Trusts", + fmt.Sprintf("Trust NOT found: %s -> %s", tf.TargetDomain, tf.SourceDomain), "") + } + } + } } } func (v *Validator) checkServices(ctx context.Context) { - fmt.Println("\n== 11. Additional Services ==") + fmt.Println("\n== Additional Services ==") // Print Spooler on all DCs - for _, host := range []string{"DC01", "DC02", "DC03"} { - output := v.runPS(ctx, host, `Get-Service Spooler | Select-Object Status | Format-Table -AutoSize | Out-String`) + for _, role := range v.lab.DCs() { + host := strings.ToUpper(role) + if !v.hasHost(host) { + continue + } + output := v.runPS(ctx, host, + `Get-Service Spooler | Select-Object Status | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "running") { v.addResult("PASS", "Services", fmt.Sprintf("Print Spooler running on %s (coercion possible)", host), "") } else { @@ -232,13 +402,31 @@ func (v *Validator) checkServices(ctx context.Context) { } } - // IIS on SRV02 - if v.hasHost("SRV02") { - output := v.runPS(ctx, "SRV02", `Get-Service W3SVC -ErrorAction SilentlyContinue | Select-Object Name,Status | Format-Table -AutoSize | Out-String`) + // IIS on Windows servers (only report if found or expected) + for _, role := range v.lab.WindowsServers() { + host := strings.ToUpper(role) + if !v.hasHost(host) { + continue + } + hostLabel := strings.ToUpper(v.lab.Hostname(role)) + output := v.runPS(ctx, host, + `Get-Service W3SVC -ErrorAction SilentlyContinue | Select-Object Name,Status | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "running") { - v.addResult("PASS", "Services", "IIS running on CASTELBLACK", "") - } else { - v.addResult("FAIL", "Services", "IIS NOT running on CASTELBLACK", "") + v.addResult("PASS", "Services", fmt.Sprintf("IIS running on %s", hostLabel), "") + } else if strings.TrimSpace(output) != "" { + v.addResult("WARN", "Services", fmt.Sprintf("IIS not running on %s", hostLabel), "") + } + } +} + +// parseOutputLines splits PowerShell output into non-empty trimmed lines. +func parseOutputLines(output string) []string { + var lines []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) } } + return lines } diff --git a/cli/internal/validate/validator.go b/cli/internal/validate/validator.go index fd1ef4b0..fc931e06 100644 --- a/cli/internal/validate/validator.go +++ b/cli/internal/validate/validator.go @@ -10,12 +10,13 @@ import ( "time" daws "github.com/dreadnode/dreadgoad/internal/aws" + "github.com/dreadnode/dreadgoad/internal/labmap" "github.com/fatih/color" ) // Result represents a single check result. type Result struct { - Status string `json:"status"` // PASS, FAIL, WARN + Status string `json:"status"` // PASS, FAIL, WARN, SKIP, INFO Category string `json:"category"` Name string `json:"name"` Detail string `json:"detail,omitzero"` @@ -40,10 +41,11 @@ type Validator struct { verbose bool report Report hosts map[string]string // hostname -> instance ID + lab *labmap.LabMap } // NewValidator creates a new Validator. -func NewValidator(client *daws.Client, env string, verbose bool, log *slog.Logger) *Validator { +func NewValidator(client *daws.Client, env string, verbose bool, log *slog.Logger, lab *labmap.LabMap) *Validator { if log == nil { log = slog.Default() } @@ -53,6 +55,7 @@ func NewValidator(client *daws.Client, env string, verbose bool, log *slog.Logge env: env, verbose: verbose, hosts: make(map[string]string), + lab: lab, report: Report{ Date: time.Now().UTC().Format(time.RFC3339), Env: env, @@ -61,15 +64,18 @@ func NewValidator(client *daws.Client, env string, verbose bool, log *slog.Logge } // DiscoverHosts finds GOAD instances and maps hostnames to instance IDs. +// Host roles are derived from the lab config, not hardcoded. func (v *Validator) DiscoverHosts(ctx context.Context) error { instances, err := v.client.DiscoverInstances(ctx, v.env) if err != nil { return fmt.Errorf("discover instances: %w", err) } + // Match instances to host roles from config for _, inst := range instances { name := strings.ToUpper(inst.Name) - for _, host := range []string{"DC01", "DC02", "DC03", "SRV02", "SRV03"} { + for _, role := range v.lab.HostRoles() { + host := strings.ToUpper(role) if strings.Contains(name, host) { v.hosts[host] = inst.InstanceID v.addResult("PASS", "Discovery", fmt.Sprintf("Found %s", host), inst.InstanceID) @@ -77,17 +83,18 @@ func (v *Validator) DiscoverHosts(ctx context.Context) error { } } - // Verify required hosts - for _, required := range []string{"DC01", "DC02", "DC03"} { - if _, ok := v.hosts[required]; !ok { - v.addResult("FAIL", "Discovery", fmt.Sprintf("Missing %s", required), "not found") - return fmt.Errorf("required host %s not found", required) + // Verify DCs are found (DCs are required, servers are optional) + for _, role := range v.lab.DCs() { + host := strings.ToUpper(role) + if _, ok := v.hosts[host]; !ok { + v.addResult("FAIL", "Discovery", fmt.Sprintf("Missing %s", host), "not found") + return fmt.Errorf("required host %s not found", host) } } return nil } -// RunQuickChecks runs a subset of critical checks: credentials, services, SMB signing, and trusts. +// RunQuickChecks runs a subset of critical checks. func (v *Validator) RunQuickChecks(ctx context.Context) { v.checkCredentialDiscovery(ctx) v.checkNetworkMisconfigs(ctx) @@ -158,6 +165,10 @@ func (v *Validator) addResult(status, category, name, detail string) { case "WARN": v.report.Warnings++ color.Yellow(" ⚠ %s", name) + case "SKIP": + color.Cyan(" ⊘ %s", name) + case "INFO": + fmt.Printf(" ℹ %s\n", name) } }