From 24efdf5e088fb5c89d1ec3613c6faea8cbd4b777 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 1 Apr 2026 10:25:50 -0600 Subject: [PATCH 1/4] feat: add GOAD variant generator with entity randomization and tests **Added:** - Introduced a `variant` subcommand to generate randomized GOAD lab variants with preserved structure and attack paths (`cli/cmd/variant.go`) - Added a `diagnose` command to run domain controller diagnostics (`cli/cmd/diagnose.go`) - Implemented a full variant generator with entity mapping, transformation, and mapping documentation (`cli/internal/variant/generator.go`) - Added a name generator for unique, realistic usernames, hostnames, groups, OUs, and passwords (`cli/internal/variant/namegen.go`) - Comprehensive unit and end-to-end tests for generator logic and name generation (`cli/internal/variant/generator_test.go`, `cli/internal/variant/namegen_test.go`) **Changed:** - No changes to existing files; all functionality introduced in new files **Removed:** - Nothing removed --- cli/cmd/diagnose.go | 87 ++ cli/cmd/variant.go | 61 ++ cli/internal/variant/generator.go | 1150 ++++++++++++++++++++++++ cli/internal/variant/generator_test.go | 235 +++++ cli/internal/variant/namegen.go | 313 +++++++ cli/internal/variant/namegen_test.go | 133 +++ 6 files changed, 1979 insertions(+) create mode 100644 cli/cmd/diagnose.go create mode 100644 cli/cmd/variant.go create mode 100644 cli/internal/variant/generator.go create mode 100644 cli/internal/variant/generator_test.go create mode 100644 cli/internal/variant/namegen.go create mode 100644 cli/internal/variant/namegen_test.go diff --git a/cli/cmd/diagnose.go b/cli/cmd/diagnose.go new file mode 100644 index 00000000..2125ff94 --- /dev/null +++ b/cli/cmd/diagnose.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/dreadnode/dreadgoad/internal/ansible" + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/spf13/cobra" +) + +var diagnoseCmd = &cobra.Command{ + Use: "diagnose", + Short: "Run diagnostic checks against domain controllers", + Long: `Runs the diagnose-dc01 playbook from an independent host to verify +network connectivity, LDAP, WinRM, and DNS for the primary domain controller. + +Diagnostics run from dc03/srv03 (vortexindustries domain) to test dc01 +(deltasystems domain) connectivity with detailed troubleshooting output.`, + Example: ` dreadgoad diagnose + dreadgoad diagnose --dc01-ip 10.0.1.10 + dreadgoad diagnose --env staging --debug`, + RunE: runDiagnose, +} + +func init() { + rootCmd.AddCommand(diagnoseCmd) + + diagnoseCmd.Flags().String("dc01-ip", "", "Override dc01 IP address (skips AWS lookup)") +} + +func runDiagnose(cmd *cobra.Command, args []string) error { + cfg := config.Get() + ctx := context.Background() + + dc01IP, _ := cmd.Flags().GetString("dc01-ip") + + // Ensure log directory + _ = os.MkdirAll(cfg.LogDir, 0o755) + logFile := filepath.Join(cfg.LogDir, fmt.Sprintf("%s-diagnose-%s.log", + cfg.Env, time.Now().Format("20060102_150405"))) + + fmt.Println("===============================================") + fmt.Printf("DreadGOAD DC01 Diagnostics - %s\n", time.Now().Format(time.RFC3339)) + fmt.Printf("Environment: %s\n", cfg.Env) + fmt.Printf("Log file: %s\n", logFile) + fmt.Println("===============================================") + + opts := ansible.RunOptions{ + Playbook: "diagnose-dc01.yml", + Env: cfg.Env, + Debug: cfg.Debug, + LogFile: logFile, + } + + if dc01IP != "" { + opts.ExtraVars = map[string]string{ + "dc01_ip_override": dc01IP, + } + fmt.Printf("Using dc01 IP override: %s\n", dc01IP) + } + + fmt.Println("Running diagnostics...") + fmt.Println("-----------------------------------------------") + + result := ansible.RunPlaybook(ctx, opts) + + fmt.Println("===============================================") + if result.Success { + fmt.Println("Diagnostics completed successfully.") + } else { + fmt.Println("Diagnostics detected issues. Review output above for details.") + if result.TimedOut { + fmt.Println("WARNING: Diagnostic playbook timed out.") + } + } + fmt.Printf("Full log: %s\n", logFile) + fmt.Println("===============================================") + + if !result.Success { + return fmt.Errorf("diagnostics failed (exit code %d)", result.ExitCode) + } + return nil +} diff --git a/cli/cmd/variant.go b/cli/cmd/variant.go new file mode 100644 index 00000000..5ea51a05 --- /dev/null +++ b/cli/cmd/variant.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/variant" + "github.com/spf13/cobra" +) + +var variantCmd = &cobra.Command{ + Use: "variant", + Short: "Generate GOAD variants with randomized entity names", + Long: `Creates a graph-isomorphic copy of GOAD with randomized names while +preserving structure, relationships, vulnerabilities, and attack paths. + +All entity names (domains, users, hosts, groups, OUs, passwords) are replaced +with realistic random alternatives. The resulting variant is deployable +exactly like the original GOAD.`, + Example: ` dreadgoad variant generate + dreadgoad variant generate --source ad/GOAD --target ad/GOAD-variant-2 --name variant-2`, +} + +var variantGenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate a new GOAD variant", + RunE: runVariantGenerate, +} + +func init() { + rootCmd.AddCommand(variantCmd) + variantCmd.AddCommand(variantGenerateCmd) + + variantGenerateCmd.Flags().String("source", "ad/GOAD", "Source GOAD directory") + variantGenerateCmd.Flags().String("target", "ad/GOAD-variant-1", "Target variant directory") + variantGenerateCmd.Flags().String("name", "variant-1", "Variant name") +} + +func runVariantGenerate(cmd *cobra.Command, args []string) error { + cfg := config.Get() + + source, _ := cmd.Flags().GetString("source") + target, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + + // Resolve paths relative to project root + if !filepath.IsAbs(source) { + source = filepath.Join(cfg.ProjectRoot, source) + } + if !filepath.IsAbs(target) { + target = filepath.Join(cfg.ProjectRoot, target) + } + + gen := variant.NewGenerator(source, target, name) + if err := gen.Run(); err != nil { + return fmt.Errorf("variant generation failed: %w", err) + } + + return nil +} diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go new file mode 100644 index 00000000..c4c8695f --- /dev/null +++ b/cli/internal/variant/generator.go @@ -0,0 +1,1150 @@ +package variant + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Mappings holds all entity-to-entity name mappings. +type Mappings struct { + Domains map[string]string `json:"domains"` + NetBIOS map[string]string `json:"netbios"` + Hosts map[string]HostMapping `json:"hosts"` + Users map[string]string `json:"users"` + Passwords map[string]string `json:"passwords"` + Groups map[string]string `json:"groups"` + OUs map[string]string `json:"ous"` + ACLs map[string]string `json:"acls"` + Misc map[string]string `json:"misc"` +} + +// HostMapping holds old/new hostname info for a single host. +type HostMapping 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"` +} + +// replacement is an ordered old->new string replacement. +type replacement struct { + Old string + New string +} + +// Generator creates GOAD variants with randomized entity names. +type Generator struct { + SourcePath string + TargetPath string + VariantName string + + nameGen *NameGenerator + mappings Mappings + replacements []replacement + userPasswordMap map[string]string // new_username -> new_password + preservedUsers map[string]bool +} + +// hostnameAliases maps canonical hostnames to known typos/aliases in upstream GOAD. +var hostnameAliases = map[string][]string{ + "braavos": {"Bravos"}, + "meereen": {"Meren"}, +} + +// NewGenerator creates a new variant generator. +func NewGenerator(source, target, name string) *Generator { + return &Generator{ + SourcePath: source, + TargetPath: target, + VariantName: name, + nameGen: NewNameGenerator(), + mappings: Mappings{ + Domains: make(map[string]string), + NetBIOS: make(map[string]string), + Hosts: make(map[string]HostMapping), + Users: make(map[string]string), + Passwords: make(map[string]string), + Groups: make(map[string]string), + OUs: make(map[string]string), + ACLs: make(map[string]string), + Misc: make(map[string]string), + }, + userPasswordMap: make(map[string]string), + preservedUsers: map[string]bool{"sql_svc": true}, + } +} + +// Run executes the full variant generation process. +func (g *Generator) Run() error { + fmt.Printf("\n%s\n", strings.Repeat("=", 60)) + fmt.Printf("GOAD Variant Generator - %s\n", g.VariantName) + fmt.Printf("%s\n", strings.Repeat("=", 60)) + fmt.Printf("Source: %s\n", g.SourcePath) + fmt.Printf("Target: %s\n", g.TargetPath) + fmt.Printf("%s\n\n", strings.Repeat("=", 60)) + + config, err := g.loadConfig() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + g.generateMappings(config) + g.buildOrderedReplacements() + + if err := g.copyAndTransform(); err != nil { + return fmt.Errorf("transform: %w", err) + } + + if err := g.saveMappings(); err != nil { + return fmt.Errorf("save mappings: %w", err) + } + + valid := g.validate() + g.createDocumentation() + + fmt.Printf("\n%s\n", strings.Repeat("=", 60)) + if valid { + fmt.Println("Variant generation complete and validated!") + } else { + fmt.Println("Variant generated but validation found issues") + } + fmt.Printf("%s\n\n", strings.Repeat("=", 60)) + + return nil +} + +// loadConfig reads the source GOAD config.json. +func (g *Generator) loadConfig() (map[string]any, error) { + data, err := os.ReadFile(filepath.Join(g.SourcePath, "data", "config.json")) + if err != nil { + return nil, err + } + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + return config, nil +} + +// generateMappings extracts entities and creates all mappings. +func (g *Generator) generateMappings(config map[string]any) { + fmt.Println("\n=== Generating Mappings ===") + + fmt.Println("\nMapping domains...") + g.mapDomains() + + fmt.Println("\nMapping hosts...") + g.mapHosts(config) + + fmt.Println("\nMapping users...") + g.mapUsers(config) + + fmt.Println("\nMapping groups...") + g.mapGroups(config) + + fmt.Println("\nMapping OUs...") + g.mapOUs(config) + + fmt.Println("\nMapping passwords...") + g.mapPasswords(config) + + fmt.Println("\nMapping gMSA accounts...") + g.mapGMSAAccounts(config) + + fmt.Println("\nMapping cities...") + g.mapCities(config) + + fmt.Println("\n=== Mapping Generation Complete ===") +} + +func (g *Generator) mapDomains() { + rootNew := g.nameGen.GenerateDomainName() + rootFull := rootNew + ".local" + + childPrefix := g.nameGen.GenerateSubdomainName() + childFull := childPrefix + "." + rootFull + + externalNew := g.nameGen.GenerateDomainName() + externalFull := externalNew + ".local" + + g.mappings.Domains["sevenkingdoms.local"] = rootFull + g.mappings.Domains["north.sevenkingdoms.local"] = childFull + g.mappings.Domains["essos.local"] = externalFull + + // NetBIOS mappings (max 15 chars) with case variants + rootNB := rootNew[:min(len(rootNew), maxNetBIOSLength)] + childNB := childPrefix[:min(len(childPrefix), maxNetBIOSLength)] + extNB := externalNew[:min(len(externalNew), maxNetBIOSLength)] + + g.mappings.NetBIOS["SEVENKINGDOMS"] = strings.ToUpper(rootNB) + g.mappings.NetBIOS["NORTH"] = strings.ToUpper(childNB) + g.mappings.NetBIOS["ESSOS"] = strings.ToUpper(extNB) + + g.mappings.NetBIOS["sevenkingdoms"] = strings.ToLower(rootNB) + g.mappings.NetBIOS["north"] = strings.ToLower(childNB) + g.mappings.NetBIOS["essos"] = strings.ToLower(extNB) + + g.mappings.NetBIOS["Sevenkingdoms"] = capitalize(rootNB) + g.mappings.NetBIOS["North"] = capitalize(childNB) + g.mappings.NetBIOS["Essos"] = capitalize(extNB) + + fmt.Printf(" sevenkingdoms.local -> %s\n", rootFull) + fmt.Printf(" north.sevenkingdoms.local -> %s\n", childFull) + fmt.Printf(" essos.local -> %s\n", externalFull) +} + +func (g *Generator) mapHosts(config map[string]any) { + hosts := jsonPath[map[string]any](config, "lab", "hosts") + if hosts == nil { + return + } + + for hostID, hostData := range hosts { + info, ok := hostData.(map[string]any) + if !ok { + continue + } + + oldHostname := jsonStr(info, "hostname") + oldDomain := jsonStr(info, "domain") + newHostname := g.nameGen.GenerateHostname() + newDomain := g.mappings.Domains[oldDomain] + + g.mappings.Hosts[hostID] = HostMapping{ + OldHostname: oldHostname, + NewHostname: newHostname, + OldFQDN: oldHostname + "." + oldDomain, + NewFQDN: newHostname + "." + newDomain, + OldDomain: oldDomain, + NewDomain: newDomain, + } + + // Computer accounts, case variants, and known typos + g.mappings.Misc[oldHostname+"$"] = newHostname + "$" + g.mappings.Misc[strings.ToUpper(oldHostname)] = strings.ToUpper(newHostname) + g.mappings.Misc[capitalize(oldHostname)] = capitalize(newHostname) + + for _, alias := range hostnameAliases[oldHostname] { + g.mappings.Misc[alias] = capitalize(newHostname) + } + + fmt.Printf(" %s: %s -> %s\n", hostID, oldHostname, newHostname) + } +} + +func (g *Generator) mapUsers(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + for _, domainData := range domains { + info, ok := domainData.(map[string]any) + if !ok { + continue + } + users := jsonPath[map[string]any](info, "users") + if users == nil { + continue + } + + for username, userData := range users { + if g.preservedUsers[username] { + g.mappings.Users[username] = username + fmt.Printf(" %s -> %s (preserved)\n", username, username) + continue + } + + newUsername := g.nameGen.GenerateUsername() + g.mappings.Users[username] = newUsername + + userInfo, _ := userData.(map[string]any) + if userInfo != nil { + if firstname, ok := userInfo["firstname"].(string); ok { + newFirst := strings.Split(newUsername, ".")[0] + g.mappings.Misc[firstname] = newFirst + if !isAllLower(firstname) && firstname != "sql" { + g.mappings.Misc[strings.ToLower(firstname)] = strings.ToLower(newFirst) + } + if isAllLower(firstname) && firstname != "sql" { + g.mappings.Misc[capitalize(firstname)] = capitalize(newFirst) + } + } + + if surname, ok := userInfo["surname"].(string); ok && surname != "-" { + parts := strings.SplitN(newUsername, ".", 2) + newSurname := parts[0] + if len(parts) > 1 { + newSurname = parts[1] + } + g.mappings.Misc[surname] = newSurname + if isAllLower(surname) { + g.mappings.Misc[capitalize(surname)] = capitalize(newSurname) + } + } + } + + fmt.Printf(" %s -> %s\n", username, newUsername) + } + } +} + +func (g *Generator) mapGroups(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + builtins := map[string]bool{"Domain Admins": true, "Protected Users": true} + + for _, domainData := range domains { + info, ok := domainData.(map[string]any) + if !ok { + continue + } + groups := jsonPath[map[string]any](info, "groups") + if groups == nil { + continue + } + + for _, groupType := range []string{"universal", "global", "domainlocal"} { + typeGroups := jsonPath[map[string]any](groups, groupType) + if typeGroups == nil { + continue + } + for groupName := range typeGroups { + if builtins[groupName] { + g.mappings.Groups[groupName] = groupName + continue + } + newName := g.nameGen.GenerateGroupName() + g.mappings.Groups[groupName] = newName + fmt.Printf(" %s -> %s\n", groupName, newName) + } + } + } +} + +func (g *Generator) mapOUs(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + for _, domainData := range domains { + info, ok := domainData.(map[string]any) + if !ok { + continue + } + ous := jsonPath[map[string]any](info, "organisation_units") + if ous == nil { + continue + } + for ouName := range ous { + newName := g.nameGen.GenerateOUName() + g.mappings.OUs[ouName] = newName + fmt.Printf(" %s -> %s\n", ouName, newName) + } + } +} + +func (g *Generator) mapPasswords(config map[string]any) { + passwords := make(map[string]bool) + + domains := jsonPath[map[string]any](config, "lab", "domains") + hosts := jsonPath[map[string]any](config, "lab", "hosts") + + // Domain passwords + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + if pw, ok := info["domain_password"].(string); ok { + passwords[pw] = true + } + + // User passwords + users := jsonPath[map[string]any](info, "users") + for _, userData := range users { + userInfo, _ := userData.(map[string]any) + if userInfo == nil { + continue + } + if pw, ok := userInfo["password"].(string); ok { + passwords[pw] = true + } + } + } + + // Host local admin passwords + for _, hostData := range hosts { + info, _ := hostData.(map[string]any) + if info == nil { + continue + } + if pw, ok := info["local_admin_password"].(string); ok { + passwords[pw] = true + } + + // MSSQL passwords + mssql := jsonPath[map[string]any](info, "mssql") + if mssql != nil { + if pw, ok := mssql["sa_password"].(string); ok { + passwords[pw] = true + } + linkedServers := jsonPath[map[string]any](mssql, "linked_servers") + for _, lsData := range linkedServers { + lsInfo, _ := lsData.(map[string]any) + if lsInfo == nil { + continue + } + if mappingsArr, ok := lsInfo["users_mapping"].([]any); ok { + for _, m := range mappingsArr { + mapping, _ := m.(map[string]any) + if mapping == nil { + continue + } + if pw, ok := mapping["remote_password"].(string); ok { + passwords[pw] = true + } + } + } + } + } + + // Vulnerability passwords + vulnsVars := jsonPath[map[string]any](info, "vulns_vars") + if vulnsVars != nil { + creds := jsonPath[map[string]any](vulnsVars, "credentials") + for _, credData := range creds { + credInfo, _ := credData.(map[string]any) + if credInfo == nil { + continue + } + if pw, ok := credInfo["secret"].(string); ok { + passwords[pw] = true + } + if pw, ok := credInfo["runas_password"].(string); ok { + passwords[pw] = true + } + } + autologon := jsonPath[map[string]any](vulnsVars, "autologon") + for _, autoData := range autologon { + autoInfo, _ := autoData.(map[string]any) + if autoInfo == nil { + continue + } + if pw, ok := autoInfo["password"].(string); ok { + passwords[pw] = true + } + } + } + } + + // Generate new passwords + for pw := range passwords { + newPW := g.nameGen.GeneratePassword(pw) + g.mappings.Passwords[pw] = newPW + truncOld := pw + truncNew := newPW + if len(truncOld) > 20 { + truncOld = truncOld[:20] + } + if len(truncNew) > 20 { + truncNew = truncNew[:20] + } + fmt.Printf(" %s... -> %s...\n", truncOld, truncNew) + } + + // Build user->password lookup for collision fixing + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + users := jsonPath[map[string]any](info, "users") + for username, userData := range users { + userInfo, _ := userData.(map[string]any) + if userInfo == nil { + continue + } + if pw, ok := userInfo["password"].(string); ok { + newUsername := g.mappings.Users[username] + if newUsername == "" { + newUsername = username + } + newPW := g.mappings.Passwords[pw] + if newPW == "" { + newPW = pw + } + g.userPasswordMap[newUsername] = newPW + } + } + } +} + +func (g *Generator) mapGMSAAccounts(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + gmsa := jsonPath[map[string]any](info, "gmsa") + for _, gmsaData := range gmsa { + gmsaInfo, _ := gmsaData.(map[string]any) + if gmsaInfo == nil { + continue + } + if oldName, ok := gmsaInfo["gMSA_Name"].(string); ok { + newName := g.nameGen.GenerateGMSAName() + g.mappings.Misc[oldName] = newName + g.mappings.Misc[oldName+"$"] = newName + "$" + fmt.Printf(" %s -> %s\n", oldName, newName) + } + } + } +} + +func (g *Generator) mapCities(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + cities := make(map[string]bool) + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + users := jsonPath[map[string]any](info, "users") + for _, userData := range users { + userInfo, _ := userData.(map[string]any) + if userInfo == nil { + continue + } + if city, ok := userInfo["city"].(string); ok && city != "" && city != "-" { + cities[city] = true + } + } + } + + for city := range cities { + newCity := g.nameGen.GenerateCityName() + g.mappings.Misc[city] = newCity + fmt.Printf(" %s -> %s\n", city, newCity) + } +} + +// buildOrderedReplacements builds the ordered replacement list (longest first). +func (g *Generator) buildOrderedReplacements() { + fmt.Println("\n=== Building Ordered Replacements ===") + + var repls []replacement + + // 1. Host FQDNs + for _, hm := range g.mappings.Hosts { + repls = append(repls, replacement{hm.OldFQDN, hm.NewFQDN}) + } + + // 1b. Bare hostnames + for _, hm := range g.mappings.Hosts { + repls = append(repls, replacement{hm.OldHostname, hm.NewHostname}) + } + + // 2. Domain-qualified usernames + netbiosUpperMap := make(map[string]string) + for old, new := range g.mappings.NetBIOS { + if strings.ToUpper(old) == old { + netbiosUpperMap[old] = new + } + } + + for oldDomain, newDomain := range g.mappings.Domains { + oldNB := strings.ToUpper(strings.Split(oldDomain, ".")[0]) + newNB := netbiosUpperMap[oldNB] + if newNB == "" { + newNB = strings.ToUpper(strings.Split(newDomain, ".")[0]) + } + + for oldUser, newUser := range g.mappings.Users { + repls = append(repls, + replacement{oldNB + "\\\\" + oldUser, newNB + "\\\\" + newUser}, + replacement{oldDomain + "\\\\" + oldUser, newDomain + "\\\\" + newUser}, + ) + } + } + + // 3. DN paths + for oldDomain, newDomain := range g.mappings.Domains { + oldParts := strings.Split(strings.TrimSuffix(oldDomain, ".local"), ".") + newParts := strings.Split(strings.TrimSuffix(newDomain, ".local"), ".") + + var oldDCs, newDCs []string + for _, p := range oldParts { + oldDCs = append(oldDCs, "DC="+p) + } + for _, p := range newParts { + newDCs = append(newDCs, "DC="+p) + } + oldDN := strings.Join(oldDCs, ",") + ",DC=local" + newDN := strings.Join(newDCs, ",") + ",DC=local" + repls = append(repls, replacement{oldDN, newDN}) + } + + // 5. Computer accounts + for old, new := range g.mappings.Misc { + if strings.HasSuffix(old, "$") { + repls = append(repls, replacement{old, new}) + } + } + + // 6. Domain names (child before parent = longest first) + type domainPair struct{ old, new string } + var domainPairs []domainPair + for old, new := range g.mappings.Domains { + domainPairs = append(domainPairs, domainPair{old, new}) + } + sort.Slice(domainPairs, func(i, j int) bool { + return len(domainPairs[i].old) > len(domainPairs[j].old) + }) + for _, dp := range domainPairs { + repls = append(repls, replacement{dp.old, dp.new}) + } + + // 7. Usernames + for old, new := range g.mappings.Users { + repls = append(repls, replacement{old, new}) + } + + // 8. Groups + for old, new := range g.mappings.Groups { + repls = append(repls, replacement{old, new}) + } + + // 9. OUs + for old, new := range g.mappings.OUs { + repls = append(repls, replacement{old, new}) + } + + // 10. Passwords + for old, new := range g.mappings.Passwords { + repls = append(repls, replacement{old, new}) + } + + // 11. NetBIOS names + for old, new := range g.mappings.NetBIOS { + repls = append(repls, replacement{old, new}) + } + + // 12. Misc (non-computer-account) + for old, new := range g.mappings.Misc { + if !strings.HasSuffix(old, "$") { + repls = append(repls, replacement{old, new}) + } + } + + // Sort longest first + sort.Slice(repls, func(i, j int) bool { + return len(repls[i].Old) > len(repls[j].Old) + }) + + // Deduplicate + seen := make(map[string]bool) + var unique []replacement + for _, r := range repls { + key := r.Old + "\x00" + r.New + if !seen[key] { + seen[key] = true + unique = append(unique, r) + } + } + + g.replacements = unique + fmt.Printf("Built %d ordered replacements\n", len(g.replacements)) +} + +// applyReplacements applies all replacements to content. +func (g *Generator) applyReplacements(content string) string { + for _, r := range g.replacements { + if r.Old == r.New { + continue + } + + if g.isNameComponent(r.Old) { + pattern := `\b` + regexp.QuoteMeta(r.Old) + `\b` + re, err := regexp.Compile(pattern) + if err == nil { + content = re.ReplaceAllString(content, r.New) + } + } else { + content = strings.ReplaceAll(content, r.Old, r.New) + } + } + return content +} + +// isNameComponent returns true if old is a firstname/surname component needing word-boundary protection. +func (g *Generator) isNameComponent(old string) bool { + if _, ok := g.mappings.Misc[old]; !ok { + return false + } + if strings.HasSuffix(old, "$") || strings.Contains(old, ".") || strings.Contains(old, "\\") { + return false + } + if len(old) >= 50 { + return false + } + cleaned := strings.ReplaceAll(strings.ReplaceAll(old, "-", ""), "'", "") + for _, c := range cleaned { + if !('a' <= c && c <= 'z') && !('A' <= c && c <= 'Z') { + return false + } + } + return true +} + +// fixUserFirstnameSurname corrects firstname/surname fields to match generated usernames. +func (g *Generator) fixUserFirstnameSurname(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + users := jsonPath[map[string]any](info, "users") + for username, userData := range users { + if g.preservedUsers[username] { + continue + } + userInfo, _ := userData.(map[string]any) + if userInfo == nil { + continue + } + if strings.Contains(username, ".") { + parts := strings.SplitN(username, ".", 2) + userInfo["firstname"] = parts[0] + if len(parts) > 1 { + userInfo["surname"] = parts[1] + } + if _, ok := userInfo["description"]; ok { + userInfo["description"] = capitalize(parts[0]) + " " + capitalize(parts[1]) + } + } + } + } +} + +// fixPasswords corrects password fields corrupted by global text replacement. +func (g *Generator) fixPasswords(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + users := jsonPath[map[string]any](info, "users") + for username, userData := range users { + userInfo, _ := userData.(map[string]any) + if userInfo == nil { + continue + } + if newPW, ok := g.userPasswordMap[username]; ok { + userInfo["password"] = newPW + } + } + } +} + +// rebuildACLKeys rebuilds ACL dictionary keys using new entity names. +func (g *Generator) rebuildACLKeys(config map[string]any) { + domains := jsonPath[map[string]any](config, "lab", "domains") + if domains == nil { + return + } + + for _, domainData := range domains { + info, _ := domainData.(map[string]any) + if info == nil { + continue + } + acls := jsonPath[map[string]any](info, "acls") + if acls == nil { + continue + } + + newACLs := make(map[string]any) + for oldKey, aclData := range acls { + aclInfo, _ := aclData.(map[string]any) + if aclInfo == nil { + newACLs[oldKey] = aclData + continue + } + + forEntity, _ := aclInfo["for"].(string) + toEntity, _ := aclInfo["to"].(string) + + forSimple := simplifyEntity(forEntity) + toSimple := simplifyEntity(toEntity) + + keyParts := strings.SplitN(oldKey, "_", 3) + var newKey string + if len(keyParts) >= 3 { + newKey = keyParts[0] + "_" + forSimple + "_" + toSimple + } else { + newKey = oldKey + } + + newACLs[newKey] = aclData + } + + info["acls"] = newACLs + } +} + +// simplifyEntity extracts a simplified name from an LDAP entity string. +func simplifyEntity(entity string) string { + // Take last component of backslash-separated + parts := strings.Split(entity, "\\") + s := parts[len(parts)-1] + // Take first component of comma-separated + s = strings.Split(s, ",")[0] + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "_") + // Remove LDAP prefixes + for _, prefix := range []string{"cn=", "ou=", "dc="} { + s = strings.ReplaceAll(s, prefix, "") + } + return s +} + +var textExtensions = map[string]bool{ + ".json": true, ".yml": true, ".yaml": true, ".ps1": true, ".tf": true, + ".txt": true, ".md": true, ".sh": true, ".py": true, ".rb": true, + ".cfg": true, ".conf": true, ".ini": true, +} + +var textFilenames = map[string]bool{ + "Vagrantfile": true, "inventory": true, "Makefile": true, +} + +// transformFile transforms a single file with replacements and writes to target. +func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { + ext := filepath.Ext(srcPath) + base := filepath.Base(srcPath) + targetFile := filepath.Join(g.TargetPath, relPath) + + if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { + fmt.Printf("Warning: mkdir failed for %s: %v\n", relPath, err) + return false + } + + if textExtensions[ext] || textFilenames[base] { + content, err := os.ReadFile(srcPath) + if err != nil { + fmt.Printf("Warning: Could not read %s: %v\n", relPath, err) + copyFile(srcPath, targetFile) + return false + } + + newContent := g.applyReplacements(string(content)) + + // Special handling for config.json files + if base == "config.json" || strings.HasSuffix(base, "-config.json") { + var configData map[string]any + if err := json.Unmarshal([]byte(newContent), &configData); err == nil { + g.fixUserFirstnameSurname(configData) + g.fixPasswords(configData) + g.rebuildACLKeys(configData) + if pretty, err := json.MarshalIndent(configData, "", " "); err == nil { + newContent = string(pretty) + } + } + } + + if err := os.WriteFile(targetFile, []byte(newContent), 0o644); err != nil { + fmt.Printf("Warning: Could not write %s: %v\n", relPath, err) + return false + } + return true + } + + // Copy non-text files as-is + copyFile(srcPath, targetFile) + return false +} + +// copyAndTransform copies the source directory, transforming text files. +func (g *Generator) copyAndTransform() error { + fmt.Println("\n=== Copying and Transforming Files ===") + + if err := os.MkdirAll(g.TargetPath, 0o755); err != nil { + return err + } + + var total, transformed, copied int + + err := filepath.WalkDir(g.SourcePath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + + // Skip .git files + rel, _ := filepath.Rel(g.SourcePath, path) + if strings.Contains(rel, ".git") { + return nil + } + + total++ + if g.transformFile(path, rel) { + transformed++ + } else { + copied++ + } + + if total%10 == 0 { + fmt.Printf("Processed %d files...\n", total) + } + return nil + }) + + fmt.Printf("\nTransformation complete:\n") + fmt.Printf(" Total files: %d\n", total) + fmt.Printf(" Transformed: %d\n", transformed) + fmt.Printf(" Copied as-is: %d\n", copied) + + return err +} + +// saveMappings writes the mapping file to target/mapping.json. +func (g *Generator) saveMappings() error { + data, err := json.MarshalIndent(g.mappings, "", " ") + if err != nil { + return err + } + outPath := filepath.Join(g.TargetPath, "mapping.json") + fmt.Printf("Mappings saved to %s\n", outPath) + return os.WriteFile(outPath, data, 0o644) +} + +// validate checks that no original GOAD names appear in variant files. +func (g *Generator) validate() bool { + fmt.Println("\n=== Validating Variant ===") + + originalNames := []string{ + "sevenkingdoms", "essos", + "kingslanding", "winterfell", "meereen", "castelblack", "braavos", + "stark", "lannister", "baratheon", "targaryen", "drogo", "snow", + "tywin", "jaime", "cersei", "tyron", "robert", "joffrey", + "arya", "eddard", "catelyn", "robb", "sansa", "brandon", + "daenerys", "viserys", "khal", "jorah", "mormont", + } + + type violation struct { + file string + name string + } + var violations []violation + filesChecked := 0 + + skipFiles := map[string]bool{"mapping.json": true, "README.md": true} + + _ = filepath.WalkDir(g.TargetPath, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if skipFiles[d.Name()] { + return nil + } + + ext := filepath.Ext(path) + if !textExtensions[ext] && !textFilenames[d.Name()] { + return nil + } + + filesChecked++ + content, err := os.ReadFile(path) + if err != nil { + return nil + } + lower := strings.ToLower(string(content)) + rel, _ := filepath.Rel(g.TargetPath, path) + + for _, name := range originalNames { + if strings.Contains(lower, name) { + re, err := regexp.Compile(`\b` + regexp.QuoteMeta(name) + `\b`) + if err == nil && re.MatchString(lower) { + violations = append(violations, violation{rel, name}) + } + } + } + return nil + }) + + fmt.Printf("Checked %d text files\n", filesChecked) + + if len(violations) > 0 { + fmt.Printf("\nFound %d potential issues:\n", len(violations)) + limit := len(violations) + if limit > 20 { + limit = 20 + } + for _, v := range violations[:limit] { + fmt.Printf(" %s: contains '%s'\n", v.file, v.name) + } + if len(violations) > 20 { + fmt.Printf(" ... and %d more\n", len(violations)-20) + } + } else { + fmt.Println("No original names found in variant files") + } + + // Validate structure counts + fmt.Println("\nValidating structure...") + origConfig, err := g.loadConfig() + if err == nil { + varData, err := os.ReadFile(filepath.Join(g.TargetPath, "data", "config.json")) + if err == nil { + var varConfig map[string]any + if json.Unmarshal(varData, &varConfig) == nil { + origHosts := len(jsonPath[map[string]any](origConfig, "lab", "hosts")) + varHosts := len(jsonPath[map[string]any](varConfig, "lab", "hosts")) + origDomains := len(jsonPath[map[string]any](origConfig, "lab", "domains")) + varDomains := len(jsonPath[map[string]any](varConfig, "lab", "domains")) + + checkMark := func(a, b int) string { + if a == b { + return "OK" + } + return "MISMATCH" + } + fmt.Printf(" Hosts: %d -> %d %s\n", origHosts, varHosts, checkMark(origHosts, varHosts)) + fmt.Printf(" Domains: %d -> %d %s\n", origDomains, varDomains, checkMark(origDomains, varDomains)) + } + } + } + + return len(violations) == 0 +} + +// createDocumentation generates a README for the variant. +func (g *Generator) createDocumentation() { + readme := fmt.Sprintf(`# GOAD %s + +This is a graph-isomorphic variant of the GOAD (Game of Active Directory) lab environment. + +## About This Variant + +- **All entity names have been randomized** while preserving the complete structure +- **Attack paths remain identical** to the original GOAD +- **All vulnerabilities preserved** with the same relationships +- **All 7 provider configs included**: VirtualBox, VMware, VMware ESXi, Proxmox, AWS, Azure, Ludus + +## Structure + +- **3 domains** with parent-child and trust relationships +- **5 VMs**: 3 Domain Controllers, 2 Servers +- **40+ users** with randomized names +- **18+ groups**, **8 OUs**, **20+ ACLs** + +## Usage + +Deploy exactly like the original GOAD: + + # Navigate to provider directory + cd providers/virtualbox # or vmware, proxmox, aws, azure, ludus + + # Follow provider-specific setup instructions + # Provisioning works identically to GOAD + +## Mapping Reference + +See mapping.json for the complete entity mapping from GOAD to this variant. + +## Notes + +- Service account sql_svc preserved for MSSQL functionality +- gMSA account randomized to gmsa format +- All passwords changed with equivalent complexity +- VM identifiers (dc01, dc02, srv02, etc.) unchanged for compatibility +- Directory structure identical to original GOAD + +--- + +Generated by GOAD Variant Generator +`, strings.ToUpper(g.VariantName)) + + readmePath := filepath.Join(g.TargetPath, "README.md") + _ = os.WriteFile(readmePath, []byte(readme), 0o644) + fmt.Printf("Documentation created at %s\n", readmePath) +} + +// --- helpers --- + +func copyFile(src, dst string) { + data, err := os.ReadFile(src) + if err != nil { + return + } + _ = os.WriteFile(dst, data, 0o644) +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +func isAllLower(s string) bool { + return s == strings.ToLower(s) +} + +// jsonPath traverses a nested map[string]any by keys and returns the result as type T. +func jsonPath[T any](m map[string]any, keys ...string) T { + var zero T + current := any(m) + for _, k := range keys { + cm, ok := current.(map[string]any) + if !ok { + return zero + } + current = cm[k] + } + result, _ := current.(T) + return result +} + +// jsonStr returns a string value from a map. +func jsonStr(m map[string]any, key string) string { + s, _ := m[key].(string) + return s +} diff --git a/cli/internal/variant/generator_test.go b/cli/internal/variant/generator_test.go new file mode 100644 index 00000000..34f785ef --- /dev/null +++ b/cli/internal/variant/generator_test.go @@ -0,0 +1,235 @@ +package variant + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGeneratorEndToEnd(t *testing.T) { + // Create a minimal GOAD source structure + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + targetDir := filepath.Join(tmpDir, "target") + + // Create source data directory + if err := os.MkdirAll(filepath.Join(sourceDir, "data"), 0o755); err != nil { + t.Fatal(err) + } + + // Minimal config.json matching GOAD structure + config := map[string]any{ + "lab": map[string]any{ + "hosts": map[string]any{ + "dc01": map[string]any{ + "hostname": "kingslanding", + "type": "dc", + "domain": "sevenkingdoms.local", + "local_admin_password": "TestPass123!", + }, + "dc03": map[string]any{ + "hostname": "meereen", + "type": "dc", + "domain": "essos.local", + "local_admin_password": "TestPass456!", + }, + }, + "domains": map[string]any{ + "sevenkingdoms.local": map[string]any{ + "domain_password": "DomainPass1!", + "users": map[string]any{ + "arya.stark": map[string]any{ + "firstname": "arya", + "surname": "stark", + "password": "NeedleIsMySword!", + "city": "Winterfell", + }, + "sql_svc": map[string]any{ + "firstname": "sql", + "surname": "-", + "password": "SqlSvcPass1!", + }, + }, + "groups": map[string]any{ + "global": map[string]any{ + "Stark": map[string]any{}, + "Domain Admins": map[string]any{}, + }, + }, + "organisation_units": map[string]any{ + "Vale": map[string]any{}, + }, + "acls": map[string]any{ + "GenericAll_arya_stark": map[string]any{ + "for": "arya.stark", + "to": "CN=SomeObject", + "right": "GenericAll", + }, + }, + "gmsa": map[string]any{ + "gmsa1": map[string]any{ + "gMSA_Name": "gmsaDragon", + }, + }, + }, + "essos.local": map[string]any{ + "domain_password": "EssosPass1!", + "users": map[string]any{}, + "groups": map[string]any{}, + }, + }, + }, + } + + configData, _ := json.MarshalIndent(config, "", " ") + if err := os.WriteFile(filepath.Join(sourceDir, "data", "config.json"), configData, 0o644); err != nil { + t.Fatal(err) + } + + // Create a test script file + if err := os.MkdirAll(filepath.Join(sourceDir, "scripts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(sourceDir, "scripts", "test.ps1"), + []byte("# Connect to kingslanding.sevenkingdoms.local\n$dc = 'SEVENKINGDOMS\\arya.stark'\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + + // Run generator + gen := NewGenerator(sourceDir, targetDir, "test-variant") + if err := gen.Run(); err != nil { + t.Fatalf("generator failed: %v", err) + } + + // Verify target exists + if _, err := os.Stat(filepath.Join(targetDir, "data", "config.json")); err != nil { + t.Fatal("config.json not created in target") + } + + // Verify mapping.json exists + if _, err := os.Stat(filepath.Join(targetDir, "mapping.json")); err != nil { + t.Fatal("mapping.json not created") + } + + // Read transformed config + transformedData, err := os.ReadFile(filepath.Join(targetDir, "data", "config.json")) + if err != nil { + t.Fatal(err) + } + + content := string(transformedData) + + // Verify original names are gone + for _, name := range []string{"sevenkingdoms", "essos", "kingslanding", "meereen", "arya", "stark"} { + if strings.Contains(strings.ToLower(content), name) { + t.Errorf("original name %q still found in transformed config", name) + } + } + + // Verify sql_svc is preserved + if !strings.Contains(content, "sql_svc") { + t.Error("sql_svc should be preserved") + } + + // Verify script was transformed + scriptData, err := os.ReadFile(filepath.Join(targetDir, "scripts", "test.ps1")) + if err != nil { + t.Fatal(err) + } + scriptContent := string(scriptData) + if strings.Contains(strings.ToLower(scriptContent), "kingslanding") { + t.Error("original hostname found in transformed script") + } + if strings.Contains(strings.ToLower(scriptContent), "sevenkingdoms") { + t.Error("original domain found in transformed script") + } + + // Verify README exists + if _, err := os.Stat(filepath.Join(targetDir, "README.md")); err != nil { + t.Fatal("README.md not created") + } +} + +func TestApplyReplacements(t *testing.T) { + gen := NewGenerator("", "", "test") + gen.mappings.Misc["robert"] = "james" + gen.replacements = []replacement{ + {"sevenkingdoms.local", "deltasystems.local"}, + {"robert", "james"}, + } + + content := "domain: sevenkingdoms.local, user: robert" + result := gen.applyReplacements(content) + + if strings.Contains(result, "sevenkingdoms") { + t.Error("sevenkingdoms not replaced") + } + if !strings.Contains(result, "deltasystems.local") { + t.Error("deltasystems.local not present") + } +} + +func TestIsNameComponent(t *testing.T) { + gen := NewGenerator("", "", "test") + gen.mappings.Misc["robert"] = "james" + gen.mappings.Misc["meereen$"] = "beacon$" + gen.mappings.Misc["winterfell.domain"] = "cascade.domain" + + tests := []struct { + name string + want bool + }{ + {"robert", true}, + {"meereen$", false}, + {"winterfell.domain", false}, + {"notinmisc", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gen.isNameComponent(tt.name) + if got != tt.want { + t.Errorf("isNameComponent(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestCapitalize(t *testing.T) { + tests := []struct { + input, want string + }{ + {"hello", "Hello"}, + {"HELLO", "Hello"}, + {"", ""}, + {"a", "A"}, + } + + for _, tt := range tests { + if got := capitalize(tt.input); got != tt.want { + t.Errorf("capitalize(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestSimplifyEntity(t *testing.T) { + tests := []struct { + input, want string + }{ + {`DOMAIN\user`, "user"}, + {`CN=SomeObject,OU=Test`, "someobject"}, + {`admin`, "admin"}, + } + + for _, tt := range tests { + got := simplifyEntity(tt.input) + if got != tt.want { + t.Errorf("simplifyEntity(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/cli/internal/variant/namegen.go b/cli/internal/variant/namegen.go new file mode 100644 index 00000000..c373dcc7 --- /dev/null +++ b/cli/internal/variant/namegen.go @@ -0,0 +1,313 @@ +package variant + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + "unicode" +) + +// NameGenerator generates unique, pronounceable names for GOAD entities. +type NameGenerator struct { + usedNames map[string]bool + + domainPrefixes []string + domainSuffixes []string + firstNames []string + lastNames []string + hostnamePrefixes []string + hostnameSuffixes []string + groupThemes []string + groupSuffixes []string + ouRegions []string + ouDivisions []string + animals []string + subdomainWords []string + cityNames []string +} + +// NewNameGenerator creates a new NameGenerator with default word lists. +func NewNameGenerator() *NameGenerator { + return &NameGenerator{ + usedNames: make(map[string]bool), + domainPrefixes: []string{ + "zenith", "apex", "nexus", "vertex", "prism", "quantum", + "stellar", "fusion", "titan", "phoenix", "omega", "delta", + "sigma", "vector", "matrix", "vortex", "cipher", "atlas", + }, + domainSuffixes: []string{ + "corp", "tech", "systems", "solutions", "global", "industries", + "ventures", "enterprises", "group", "labs", "dynamics", "works", + }, + firstNames: []string{ + "James", "Michael", "Robert", "John", "David", "William", + "Richard", "Joseph", "Thomas", "Charles", "Christopher", "Daniel", + "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", + "Andrew", "Joshua", "Kenneth", "Kevin", "Brian", "George", + "Timothy", "Ronald", "Edward", "Jason", "Jeffrey", "Ryan", + "Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", + "Larry", "Justin", "Scott", "Brandon", "Benjamin", "Samuel", + "Raymond", "Gregory", "Alexander", "Patrick", "Frank", "Dennis", + "Mary", "Patricia", "Jennifer", "Linda", "Barbara", "Elizabeth", + "Susan", "Jessica", "Sarah", "Karen", "Nancy", "Lisa", + "Betty", "Margaret", "Sandra", "Ashley", "Kimberly", "Emily", + "Donna", "Michelle", "Dorothy", "Carol", "Amanda", "Melissa", + "Deborah", "Stephanie", "Rebecca", "Sharon", "Laura", "Cynthia", + "Kathleen", "Amy", "Angela", "Shirley", "Anna", "Brenda", + "Pamela", "Emma", "Nicole", "Helen", "Samantha", "Katherine", + "Christine", "Debra", "Rachel", "Carolyn", "Janet", "Catherine", + }, + lastNames: []string{ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", + "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", + "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", + "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", + "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", + "Walker", "Young", "Allen", "King", "Wright", "Scott", + "Torres", "Nguyen", "Hill", "Flores", "Green", "Adams", + "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", + "Carter", "Roberts", "Gomez", "Phillips", "Evans", "Turner", + "Diaz", "Parker", "Cruz", "Edwards", "Collins", "Reyes", + "Stewart", "Morris", "Morales", "Murphy", "Cook", "Rogers", + "Gutierrez", "Ortiz", "Morgan", "Cooper", "Peterson", "Bailey", + "Reed", "Kelly", "Howard", "Ramos", "Kim", "Cox", + "Ward", "Richardson", "Watson", "Brooks", "Chavez", "Wood", + "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes", + "Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", + }, + hostnamePrefixes: []string{ + "aurora", "phoenix", "summit", "cascade", "horizon", "alpine", + "delta", "echo", "nova", "terra", "luna", "solar", + "atlas", "titan", "nexus", "zenith", "vertex", "apex", + "quantum", "cipher", "vector", "matrix", "prism", "vortex", + "beacon", "sentinel", "guardian", "fortress", "citadel", "bastion", + }, + hostnameSuffixes: []string{ + "srv", "node", "host", "sys", "hub", "core", + "prod", "dev", "test", "app", "db", "web", + }, + groupThemes: []string{ + "Operations", "Engineering", "Security", "Analytics", "Development", + "Infrastructure", "Platform", "Services", "Systems", "Management", + "Administration", "Executive", "Leadership", "Research", "Support", + }, + groupSuffixes: []string{ + "Team", "Group", "Unit", "Squad", "Staff", + }, + ouRegions: []string{ + "Americas", "EMEA", "APAC", "Europe", "Pacific", "Atlantic", + "Northern", "Southern", "Eastern", "Western", "Central", + }, + ouDivisions: []string{ + "Operations", "Engineering", "Sales", "Marketing", "Finance", + "HR", "IT", "Legal", "Corporate", "Research", + }, + animals: []string{ + "Phoenix", "Griffin", "Falcon", "Eagle", "Hawk", "Raven", + "Wolf", "Bear", "Lion", "Tiger", "Panther", "Leopard", + "Cobra", "Viper", "Python", "Raptor", "Condor", "Vulture", + }, + subdomainWords: []string{ + "ops", "dev", "prod", "test", "stage", "corp", "hq", + "services", "apps", "data", "cloud", "platform", + }, + cityNames: []string{ + "Boston", "Chicago", "Dallas", "Denver", "Houston", + "Phoenix", "Seattle", "Portland", "Austin", "Atlanta", + "Miami", "Philadelphia", "San Diego", "San Francisco", "New York", + }, + } +} + +const maxNetBIOSLength = 15 + +// ensureUnique adds a counter suffix if name is already used. +func (ng *NameGenerator) ensureUnique(name string) string { + original := name + counter := 2 + for ng.usedNames[strings.ToLower(name)] { + name = fmt.Sprintf("%s%d", original, counter) + counter++ + } + ng.usedNames[strings.ToLower(name)] = true + return name +} + +// secureChoice returns a cryptographically random element from a slice. +func secureChoice(items []string) string { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(items)))) + return items[n.Int64()] +} + +// secureBool returns true with the given probability (0.0-1.0). +func secureBool(probability float64) bool { + n, _ := rand.Int(rand.Reader, big.NewInt(1000)) + return float64(n.Int64()) < probability*1000 +} + +// GenerateDomainName generates a corporate-style domain name fitting NetBIOS limits. +func (ng *NameGenerator) GenerateDomainName() string { + for range 1000 { + prefix := secureChoice(ng.domainPrefixes) + suffix := secureChoice(ng.domainSuffixes) + domain := prefix + suffix + if len(domain) <= maxNetBIOSLength { + return ng.ensureUnique(domain) + } + } + return ng.ensureUnique(secureChoice(ng.domainPrefixes)) +} + +// GenerateSubdomainName generates a subdomain name for child domains. +func (ng *NameGenerator) GenerateSubdomainName() string { + return ng.ensureUnique(secureChoice(ng.subdomainWords)) +} + +// GenerateUsername generates a username in firstname.lastname format. +func (ng *NameGenerator) GenerateUsername() string { + for range 1000 { + first := secureChoice(ng.firstNames) + last := secureChoice(ng.lastNames) + username := strings.ToLower(first) + "." + strings.ToLower(last) + if !ng.usedNames[username] { + ng.usedNames[username] = true + return username + } + } + // Fallback with counter + first := secureChoice(ng.firstNames) + last := secureChoice(ng.lastNames) + username := strings.ToLower(first) + "." + strings.ToLower(last) + return ng.ensureUnique(username) +} + +// GenerateGroupName generates a group name with thematic words. +func (ng *NameGenerator) GenerateGroupName() string { + var name string + if secureBool(0.5) { + name = secureChoice(ng.groupThemes) + secureChoice(ng.groupSuffixes) + } else { + name = secureChoice(ng.groupThemes) + } + return ng.ensureUnique(name) +} + +// GenerateOUName generates an OU name in region/division style. +func (ng *NameGenerator) GenerateOUName() string { + var name string + if secureBool(0.5) { + name = secureChoice(ng.ouRegions) + } else { + name = secureChoice(ng.ouDivisions) + } + return ng.ensureUnique(name) +} + +// GenerateHostname generates a realistic hostname. +func (ng *NameGenerator) GenerateHostname() string { + var name string + if secureBool(0.33) { + name = secureChoice(ng.hostnamePrefixes) + "-" + secureChoice(ng.hostnameSuffixes) + } else { + name = secureChoice(ng.hostnamePrefixes) + } + return ng.ensureUnique(strings.ToLower(name)) +} + +// GenerateGMSAName generates a gMSA account name like "gmsaPhoenix". +func (ng *NameGenerator) GenerateGMSAName() string { + return ng.ensureUnique("gmsa" + secureChoice(ng.animals)) +} + +// GeneratePassword generates a password matching the complexity of the original. +func (ng *NameGenerator) GeneratePassword(original string) string { + length := len(original) + if length == 0 { + length = 16 + } + + hasUpper := false + hasLower := false + hasDigit := false + hasSpecial := false + + for _, c := range original { + switch { + case unicode.IsUpper(c): + hasUpper = true + case unicode.IsLower(c): + hasLower = true + case unicode.IsDigit(c): + hasDigit = true + case !unicode.IsLetter(c) && !unicode.IsDigit(c): + hasSpecial = true + } + } + + const ( + lowerChars = "abcdefghijklmnopqrstuvwxyz" + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digitChars = "0123456789" + specialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?" + ) + + var chars string + if hasLower { + chars += lowerChars + } + if hasUpper { + chars += upperChars + } + if hasDigit { + chars += digitChars + } + if hasSpecial { + chars += specialChars + } + if chars == "" { + chars = lowerChars + } + + // Ensure at least one of each required type + var password []byte + if hasUpper { + password = append(password, secureChoiceByte(upperChars)) + } + if hasLower { + password = append(password, secureChoiceByte(lowerChars)) + } + if hasDigit { + password = append(password, secureChoiceByte(digitChars)) + } + if hasSpecial { + password = append(password, secureChoiceByte("!@#$%^&*()-_=+")) + } + + // Fill remaining + for len(password) < length { + password = append(password, secureChoiceByte(chars)) + } + + // Shuffle + secureShuffle(password) + return string(password) +} + +// GenerateCityName returns a unique city name. +func (ng *NameGenerator) GenerateCityName() string { + return ng.ensureUnique(secureChoice(ng.cityNames)) +} + +func secureChoiceByte(s string) byte { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(s)))) + return s[n.Int64()] +} + +func secureShuffle(b []byte) { + for i := len(b) - 1; i > 0; i-- { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + j := n.Int64() + b[i], b[j] = b[j], b[i] + } +} diff --git a/cli/internal/variant/namegen_test.go b/cli/internal/variant/namegen_test.go new file mode 100644 index 00000000..d72b690d --- /dev/null +++ b/cli/internal/variant/namegen_test.go @@ -0,0 +1,133 @@ +package variant + +import ( + "strings" + "testing" +) + +func TestGenerateDomainName(t *testing.T) { + ng := NewNameGenerator() + name := ng.GenerateDomainName() + + if name == "" { + t.Fatal("expected non-empty domain name") + } + if len(name) > maxNetBIOSLength { + t.Errorf("domain name %q exceeds NetBIOS limit of %d", name, maxNetBIOSLength) + } +} + +func TestGenerateDomainNameUniqueness(t *testing.T) { + ng := NewNameGenerator() + seen := make(map[string]bool) + for range 10 { + name := ng.GenerateDomainName() + if seen[strings.ToLower(name)] { + t.Errorf("duplicate domain name: %s", name) + } + seen[strings.ToLower(name)] = true + } +} + +func TestGenerateUsername(t *testing.T) { + ng := NewNameGenerator() + username := ng.GenerateUsername() + + if !strings.Contains(username, ".") { + t.Errorf("expected firstname.lastname format, got %q", username) + } + + parts := strings.SplitN(username, ".", 2) + if parts[0] != strings.ToLower(parts[0]) || parts[1] != strings.ToLower(parts[1]) { + t.Errorf("expected lowercase username, got %q", username) + } +} + +func TestGenerateUsernameUniqueness(t *testing.T) { + ng := NewNameGenerator() + seen := make(map[string]bool) + for range 50 { + name := ng.GenerateUsername() + if seen[name] { + t.Errorf("duplicate username: %s", name) + } + seen[name] = true + } +} + +func TestGenerateHostname(t *testing.T) { + ng := NewNameGenerator() + name := ng.GenerateHostname() + if name == "" { + t.Fatal("expected non-empty hostname") + } + if name != strings.ToLower(name) { + t.Errorf("expected lowercase hostname, got %q", name) + } +} + +func TestGeneratePassword(t *testing.T) { + ng := NewNameGenerator() + + tests := []struct { + name string + original string + wantLen int + }{ + {"lowercase", "password", 8}, + {"mixed", "Password1!", 10}, + {"long", "averylongpasswordthatiscomplex", 30}, + {"empty", "", 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pw := ng.GeneratePassword(tt.original) + if len(pw) != tt.wantLen { + t.Errorf("expected length %d, got %d for %q", tt.wantLen, len(pw), pw) + } + if pw == tt.original { + t.Errorf("password should differ from original") + } + }) + } +} + +func TestGenerateGroupName(t *testing.T) { + ng := NewNameGenerator() + name := ng.GenerateGroupName() + if name == "" { + t.Fatal("expected non-empty group name") + } +} + +func TestGenerateOUName(t *testing.T) { + ng := NewNameGenerator() + name := ng.GenerateOUName() + if name == "" { + t.Fatal("expected non-empty OU name") + } +} + +func TestGenerateGMSAName(t *testing.T) { + ng := NewNameGenerator() + name := ng.GenerateGMSAName() + if !strings.HasPrefix(name, "gmsa") { + t.Errorf("expected gmsa prefix, got %q", name) + } +} + +func TestEnsureUnique(t *testing.T) { + ng := NewNameGenerator() + ng.usedNames["test"] = true + + result := ng.ensureUnique("test") + if result != "test2" { + t.Errorf("expected test2, got %q", result) + } + + result = ng.ensureUnique("test") + if result != "test3" { + t.Errorf("expected test3, got %q", result) + } +} From cd12197b73cccf355243ba1680373727c086f7e3 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 1 Apr 2026 10:32:12 -0600 Subject: [PATCH 2/4] docs: reorganize mermaid diagram into categorized role groups Replaced flat list of 94 individual role nodes with 8 high-level categories (Active Directory, Server Roles, LAPS, Vulnerabilities, SCCM, Security, Settings, Playbooks) for improved readability. --- README.md | 153 +++++++++++++++++++----------------------------------- 1 file changed, 54 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 8d824e32..c400f7ec 100644 --- a/README.md +++ b/README.md @@ -12,105 +12,60 @@ by Orange Cyberdefense. ```mermaid graph TD - Collection[Ansible Collection] - Collection --> Roles[⚙️ Roles] - Roles --> R0[vulns_credentials] - Roles --> R1[sccm_install_wsus] - Roles --> R2[sccm_pxe] - Roles --> R3[sccm_install_iis] - Roles --> R4[vulns_ntlmdowngrade] - Roles --> R5[trusts] - Roles --> R6[mssql_reporting] - Roles --> R7[domain_controller_slave] - Roles --> R8[disable_user] - Roles --> R9[settings_copy_files] - Roles --> R10[vulns_mssql] - Roles --> R11[laps_verify] - Roles --> R12[ad] - Roles --> R13[vulns_enable_credssp_server] - Roles --> R14[sccm_install_mecm] - Roles --> R15[dc_audit_sacl] - Roles --> R16[security_ensure_kb_not_installed] - Roles --> R17[vulns_openshares] - Roles --> R18[sync_domains] - Roles --> R19[sccm_config_client_push] - Roles --> R20[vulns_schedule] - Roles --> R21[sccm_config_pxe] - Roles --> R22[vulns_shares] - Roles --> R23[laps_dc] - Roles --> R24[settings_updates] - Roles --> R25[groups_domains] - Roles --> R26[vulns_anonymous_enum] - Roles --> R27[sccm_config_client_install] - Roles --> R28[mssql_audit] - Roles --> R29[vulns_enable_llmnr] - Roles --> R30[sccm_config_accounts] - Roles --> R31[settings_admin_password] - Roles --> R32[vulns_acls] - Roles --> R33[security_enable_run_as_ppl] - Roles --> R34[gmsa_hosts] - Roles --> R35[onlyusers] - Roles --> R36[child_domain] - Roles --> R37[sccm_install_adk] - Roles --> R38[mssql_link] - Roles --> R39[vulns_files] - Roles --> R40[parent_child_dns] - Roles --> R41[adcs_templates] - Roles --> R42[laps_server] - Roles --> R43[settings_enable_nat_adapter] - Roles --> R44[elk] - Roles --> R45[sccm_install_prerequisites] - Roles --> R46[vulns_permissions] - Roles --> R47[sccm_config_discovery] - Roles --> R48[settings_windows_defender] - Roles --> R49[member_server] - Roles --> R50[dc_dns_conditional_forwarder] - Roles --> R51[common] - Roles --> R52[sccm_config_boundary] - Roles --> R53[ps] - Roles --> R54[adcs] - Roles --> R55[enable_user] - Roles --> R56[laps_permissions] - Roles --> R57[dns_conditional_forwarder] - Roles --> R58[sccm_config_users] - Roles --> R59[vulns_smbv1] - Roles --> R60[ldap_diagnostic_logging] - Roles --> R61[vulns_enable_credssp_client] - Roles --> R62[dhcp] - Roles --> R63[localusers] - Roles --> R64[sccm_config_naa] - Roles --> R65[password_policy] - Roles --> R66[security_powershell_restrict] - Roles --> R67[settings_keyboard] - Roles --> R68[vulns_autologon] - Roles --> R69[settings_user_rights] - Roles --> R70[commonwkstn] - Roles --> R71[vulns_enable_nbt_ns] - Roles --> R72[mssql_ssms] - Roles --> R73[webdav] - Roles --> R74[settings_gpo_remove] - Roles --> R75[settings_adjust_rights] - Roles --> R76[vulns_disable_firewall] - Roles --> R77[vulns_adcs_templates] - Roles --> R78[gmsa] - Roles --> R79[settings_gpmc] - Roles --> R80[settings_disable_nat_adapter] - Roles --> R81[security_account_is_sensitive] - Roles --> R82[domain_controller] - Roles --> R83[fix_dns] - Roles --> R84[vulns_administrator_folder] - Roles --> R85[iis] - Roles --> R86[move_to_ou] - Roles --> R87[vulns_directory] - Roles --> R88[mssql] - Roles --> R89[acl] - Roles --> R90[settings_no_updates] - Roles --> R91[logs_windows] - Roles --> R92[security_audit_policy] - Roles --> R93[security_asr] - Roles --> R94[settings_hostname] - Collection --> Playbooks[📚 Playbooks] - Playbooks --> PB0[base] + Collection[dreadnode.goad] + + Collection --> AD[Active Directory] + Collection --> Server[Server Roles] + Collection --> LAPS[LAPS] + Collection --> Vulns[Vulnerabilities] + Collection --> SCCM[SCCM] + Collection --> Security[Security] + Collection --> Settings[Settings] + Collection --> Playbooks[Playbooks] + + AD --> ad & acl & adcs & adcs_templates + AD --> domain_controller & domain_controller_slave + AD --> child_domain & member_server & trusts + AD --> gmsa & gmsa_hosts & password_policy + AD --> move_to_ou & groups_domains & onlyusers + AD --> dns_conditional_forwarder & dc_dns_conditional_forwarder + AD --> parent_child_dns & sync_domains + AD --> disable_user & enable_user + + Server --> common & commonwkstn & localusers + Server --> mssql & mssql_link & mssql_ssms & mssql_reporting & mssql_audit + Server --> iis & elk & webdav & dhcp + Server --> logs_windows & ldap_diagnostic_logging + Server --> fix_dns & ps + + LAPS --> laps_dc & laps_server & laps_permissions & laps_verify + + Vulns --> vulns_credentials & vulns_acls & vulns_permissions + Vulns --> vulns_shares & vulns_openshares & vulns_files & vulns_directory + Vulns --> vulns_smbv1 & vulns_disable_firewall & vulns_anonymous_enum + Vulns --> vulns_autologon & vulns_ntlmdowngrade & vulns_schedule + Vulns --> vulns_mssql & vulns_adcs_templates & vulns_administrator_folder + Vulns --> vulns_enable_llmnr & vulns_enable_nbt_ns + Vulns --> vulns_enable_credssp_server & vulns_enable_credssp_client + + SCCM --> sccm_install_prerequisites & sccm_install_adk & sccm_install_mecm + SCCM --> sccm_install_wsus & sccm_install_iis & sccm_pxe + SCCM --> sccm_config_accounts & sccm_config_boundary & sccm_config_discovery + SCCM --> sccm_config_client_push & sccm_config_client_install + SCCM --> sccm_config_naa & sccm_config_pxe & sccm_config_users + + Security --> security_audit_policy & security_asr + Security --> security_enable_run_as_ppl & security_account_is_sensitive + Security --> security_powershell_restrict & security_ensure_kb_not_installed + Security --> dc_audit_sacl + + Settings --> settings_hostname & settings_keyboard & settings_admin_password + Settings --> settings_updates & settings_no_updates & settings_windows_defender + Settings --> settings_copy_files & settings_user_rights & settings_adjust_rights + Settings --> settings_enable_nat_adapter & settings_disable_nat_adapter + Settings --> settings_gpo_remove & settings_gpmc + + Playbooks --> base ``` ## Requirements From 364264989c6992a915ce5216f71bdc574ee3ea06 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 1 Apr 2026 10:36:20 -0600 Subject: [PATCH 3/4] refactor: modularize generator logic and improve password generation **Added:** - Introduced helper functions for collecting passwords from domains, hosts, MSSQL, and vulnerabilities to modularize password mapping logic - Added helper methods to build ordered replacements, such as `appendHostReplacements`, `appendQualifiedUserReplacements`, `appendDNReplacements`, and `appendDomainReplacements` - Created `mapUserNameComponents` to encapsulate user name mapping logic - Added `findNameViolations` and `printViolations` functions for clearer validation reporting - Defined a `charClasses` type with helpers for password character analysis in password generation logic **Changed:** - Refactored `mapPasswords` to delegate password collection to new helper functions, improving clarity and maintainability - Refactored the construction of ordered replacements to use new modular methods, reducing code repetition and improving logical grouping - Refactored validation logic to use new helper functions for violation finding and reporting, and extracted structure count validation to a dedicated method - Improved password generation logic to more accurately match the character classes of the original password, ensuring complexity is preserved and code is clearer - Extracted and reused test configuration setup in generator tests, reducing duplication and clarifying test intent **Removed:** - Eliminated repetitive code for iterating and mapping over various password sources and replacement categories - Removed inline logic in favor of calling newly added modular helper functions throughout generator and test code --- cli/internal/variant/generator.go | 458 +++++++++++++------------ cli/internal/variant/generator_test.go | 61 ++-- cli/internal/variant/namegen.go | 86 +++-- 3 files changed, 315 insertions(+), 290 deletions(-) diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index c4c8695f..35e905cc 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -13,15 +13,15 @@ import ( // Mappings holds all entity-to-entity name mappings. type Mappings struct { - Domains map[string]string `json:"domains"` - NetBIOS map[string]string `json:"netbios"` + Domains map[string]string `json:"domains"` + NetBIOS map[string]string `json:"netbios"` Hosts map[string]HostMapping `json:"hosts"` - Users map[string]string `json:"users"` - Passwords map[string]string `json:"passwords"` - Groups map[string]string `json:"groups"` - OUs map[string]string `json:"ous"` - ACLs map[string]string `json:"acls"` - Misc map[string]string `json:"misc"` + Users map[string]string `json:"users"` + Passwords map[string]string `json:"passwords"` + Groups map[string]string `json:"groups"` + OUs map[string]string `json:"ous"` + ACLs map[string]string `json:"acls"` + Misc map[string]string `json:"misc"` } // HostMapping holds old/new hostname info for a single host. @@ -268,28 +268,7 @@ func (g *Generator) mapUsers(config map[string]any) { userInfo, _ := userData.(map[string]any) if userInfo != nil { - if firstname, ok := userInfo["firstname"].(string); ok { - newFirst := strings.Split(newUsername, ".")[0] - g.mappings.Misc[firstname] = newFirst - if !isAllLower(firstname) && firstname != "sql" { - g.mappings.Misc[strings.ToLower(firstname)] = strings.ToLower(newFirst) - } - if isAllLower(firstname) && firstname != "sql" { - g.mappings.Misc[capitalize(firstname)] = capitalize(newFirst) - } - } - - if surname, ok := userInfo["surname"].(string); ok && surname != "-" { - parts := strings.SplitN(newUsername, ".", 2) - newSurname := parts[0] - if len(parts) > 1 { - newSurname = parts[1] - } - g.mappings.Misc[surname] = newSurname - if isAllLower(surname) { - g.mappings.Misc[capitalize(surname)] = capitalize(newSurname) - } - } + g.mapUserNameComponents(userInfo, newUsername) } fmt.Printf(" %s -> %s\n", username, newUsername) @@ -297,6 +276,31 @@ func (g *Generator) mapUsers(config map[string]any) { } } +func (g *Generator) mapUserNameComponents(userInfo map[string]any, newUsername string) { + if firstname, ok := userInfo["firstname"].(string); ok { + newFirst := strings.Split(newUsername, ".")[0] + g.mappings.Misc[firstname] = newFirst + if !isAllLower(firstname) && firstname != "sql" { + g.mappings.Misc[strings.ToLower(firstname)] = strings.ToLower(newFirst) + } + if isAllLower(firstname) && firstname != "sql" { + g.mappings.Misc[capitalize(firstname)] = capitalize(newFirst) + } + } + + if surname, ok := userInfo["surname"].(string); ok && surname != "-" { + parts := strings.SplitN(newUsername, ".", 2) + newSurname := parts[0] + if len(parts) > 1 { + newSurname = parts[1] + } + g.mappings.Misc[surname] = newSurname + if isAllLower(surname) { + g.mappings.Misc[capitalize(surname)] = capitalize(newSurname) + } + } +} + func (g *Generator) mapGroups(config map[string]any) { domains := jsonPath[map[string]any](config, "lab", "domains") if domains == nil { @@ -362,7 +366,27 @@ func (g *Generator) mapPasswords(config map[string]any) { domains := jsonPath[map[string]any](config, "lab", "domains") hosts := jsonPath[map[string]any](config, "lab", "hosts") - // Domain passwords + collectDomainPasswords(domains, passwords) + collectHostPasswords(hosts, passwords) + + for pw := range passwords { + newPW := g.nameGen.GeneratePassword(pw) + g.mappings.Passwords[pw] = newPW + truncOld := pw + truncNew := newPW + if len(truncOld) > 20 { + truncOld = truncOld[:20] + } + if len(truncNew) > 20 { + truncNew = truncNew[:20] + } + fmt.Printf(" %s... -> %s...\n", truncOld, truncNew) + } + + g.buildUserPasswordMap(domains) +} + +func collectDomainPasswords(domains map[string]any, passwords map[string]bool) { for _, domainData := range domains { info, _ := domainData.(map[string]any) if info == nil { @@ -371,8 +395,6 @@ func (g *Generator) mapPasswords(config map[string]any) { if pw, ok := info["domain_password"].(string); ok { passwords[pw] = true } - - // User passwords users := jsonPath[map[string]any](info, "users") for _, userData := range users { userInfo, _ := userData.(map[string]any) @@ -384,8 +406,9 @@ func (g *Generator) mapPasswords(config map[string]any) { } } } +} - // Host local admin passwords +func collectHostPasswords(hosts map[string]any, passwords map[string]bool) { for _, hostData := range hosts { info, _ := hostData.(map[string]any) if info == nil { @@ -394,78 +417,70 @@ func (g *Generator) mapPasswords(config map[string]any) { if pw, ok := info["local_admin_password"].(string); ok { passwords[pw] = true } + collectMSSQLPasswords(info, passwords) + collectVulnPasswords(info, passwords) + } +} - // MSSQL passwords - mssql := jsonPath[map[string]any](info, "mssql") - if mssql != nil { - if pw, ok := mssql["sa_password"].(string); ok { - passwords[pw] = true - } - linkedServers := jsonPath[map[string]any](mssql, "linked_servers") - for _, lsData := range linkedServers { - lsInfo, _ := lsData.(map[string]any) - if lsInfo == nil { - continue - } - if mappingsArr, ok := lsInfo["users_mapping"].([]any); ok { - for _, m := range mappingsArr { - mapping, _ := m.(map[string]any) - if mapping == nil { - continue - } - if pw, ok := mapping["remote_password"].(string); ok { - passwords[pw] = true - } - } - } - } +func collectMSSQLPasswords(hostInfo map[string]any, passwords map[string]bool) { + mssql := jsonPath[map[string]any](hostInfo, "mssql") + if mssql == nil { + return + } + if pw, ok := mssql["sa_password"].(string); ok { + passwords[pw] = true + } + linkedServers := jsonPath[map[string]any](mssql, "linked_servers") + for _, lsData := range linkedServers { + lsInfo, _ := lsData.(map[string]any) + if lsInfo == nil { + continue } - - // Vulnerability passwords - vulnsVars := jsonPath[map[string]any](info, "vulns_vars") - if vulnsVars != nil { - creds := jsonPath[map[string]any](vulnsVars, "credentials") - for _, credData := range creds { - credInfo, _ := credData.(map[string]any) - if credInfo == nil { - continue - } - if pw, ok := credInfo["secret"].(string); ok { - passwords[pw] = true - } - if pw, ok := credInfo["runas_password"].(string); ok { - passwords[pw] = true - } - } - autologon := jsonPath[map[string]any](vulnsVars, "autologon") - for _, autoData := range autologon { - autoInfo, _ := autoData.(map[string]any) - if autoInfo == nil { + if mappingsArr, ok := lsInfo["users_mapping"].([]any); ok { + for _, m := range mappingsArr { + mapping, _ := m.(map[string]any) + if mapping == nil { continue } - if pw, ok := autoInfo["password"].(string); ok { + if pw, ok := mapping["remote_password"].(string); ok { passwords[pw] = true } } } } +} - // Generate new passwords - for pw := range passwords { - newPW := g.nameGen.GeneratePassword(pw) - g.mappings.Passwords[pw] = newPW - truncOld := pw - truncNew := newPW - if len(truncOld) > 20 { - truncOld = truncOld[:20] +func collectVulnPasswords(hostInfo map[string]any, passwords map[string]bool) { + vulnsVars := jsonPath[map[string]any](hostInfo, "vulns_vars") + if vulnsVars == nil { + return + } + creds := jsonPath[map[string]any](vulnsVars, "credentials") + for _, credData := range creds { + credInfo, _ := credData.(map[string]any) + if credInfo == nil { + continue } - if len(truncNew) > 20 { - truncNew = truncNew[:20] + if pw, ok := credInfo["secret"].(string); ok { + passwords[pw] = true + } + if pw, ok := credInfo["runas_password"].(string); ok { + passwords[pw] = true } - fmt.Printf(" %s... -> %s...\n", truncOld, truncNew) } + autologon := jsonPath[map[string]any](vulnsVars, "autologon") + for _, autoData := range autologon { + autoInfo, _ := autoData.(map[string]any) + if autoInfo == nil { + continue + } + if pw, ok := autoInfo["password"].(string); ok { + passwords[pw] = true + } + } +} - // Build user->password lookup for collision fixing +func (g *Generator) buildUserPasswordMap(domains map[string]any) { for _, domainData := range domains { info, _ := domainData.(map[string]any) if info == nil { @@ -556,17 +571,65 @@ func (g *Generator) buildOrderedReplacements() { var repls []replacement - // 1. Host FQDNs + repls = g.appendHostReplacements(repls) + repls = g.appendQualifiedUserReplacements(repls) + repls = g.appendDNReplacements(repls) + repls = appendMapReplacements(repls, g.mappings.Misc, withSuffix("$")) + repls = g.appendDomainReplacements(repls) + repls = appendMapReplacements(repls, g.mappings.Users, nil) + repls = appendMapReplacements(repls, g.mappings.Groups, nil) + repls = appendMapReplacements(repls, g.mappings.OUs, nil) + repls = appendMapReplacements(repls, g.mappings.Passwords, nil) + repls = appendMapReplacements(repls, g.mappings.NetBIOS, nil) + repls = appendMapReplacements(repls, g.mappings.Misc, withoutSuffix("$")) + + sort.Slice(repls, func(i, j int) bool { + return len(repls[i].Old) > len(repls[j].Old) + }) + + seen := make(map[string]bool) + var unique []replacement + for _, r := range repls { + key := r.Old + "\x00" + r.New + if !seen[key] { + seen[key] = true + unique = append(unique, r) + } + } + + g.replacements = unique + fmt.Printf("Built %d ordered replacements\n", len(g.replacements)) +} + +func withSuffix(s string) func(string) bool { + return func(key string) bool { return strings.HasSuffix(key, s) } +} + +func withoutSuffix(s string) func(string) bool { + return func(key string) bool { return !strings.HasSuffix(key, s) } +} + +func appendMapReplacements(repls []replacement, m map[string]string, filter func(string) bool) []replacement { + for old, new := range m { + if filter != nil && !filter(old) { + continue + } + repls = append(repls, replacement{old, new}) + } + return repls +} + +func (g *Generator) appendHostReplacements(repls []replacement) []replacement { for _, hm := range g.mappings.Hosts { repls = append(repls, replacement{hm.OldFQDN, hm.NewFQDN}) } - - // 1b. Bare hostnames for _, hm := range g.mappings.Hosts { repls = append(repls, replacement{hm.OldHostname, hm.NewHostname}) } + return repls +} - // 2. Domain-qualified usernames +func (g *Generator) appendQualifiedUserReplacements(repls []replacement) []replacement { netbiosUpperMap := make(map[string]string) for old, new := range g.mappings.NetBIOS { if strings.ToUpper(old) == old { @@ -580,7 +643,6 @@ func (g *Generator) buildOrderedReplacements() { if newNB == "" { newNB = strings.ToUpper(strings.Split(newDomain, ".")[0]) } - for oldUser, newUser := range g.mappings.Users { repls = append(repls, replacement{oldNB + "\\\\" + oldUser, newNB + "\\\\" + newUser}, @@ -588,8 +650,10 @@ func (g *Generator) buildOrderedReplacements() { ) } } + return repls +} - // 3. DN paths +func (g *Generator) appendDNReplacements(repls []replacement) []replacement { for oldDomain, newDomain := range g.mappings.Domains { oldParts := strings.Split(strings.TrimSuffix(oldDomain, ".local"), ".") newParts := strings.Split(strings.TrimSuffix(newDomain, ".local"), ".") @@ -601,81 +665,27 @@ func (g *Generator) buildOrderedReplacements() { for _, p := range newParts { newDCs = append(newDCs, "DC="+p) } - oldDN := strings.Join(oldDCs, ",") + ",DC=local" - newDN := strings.Join(newDCs, ",") + ",DC=local" - repls = append(repls, replacement{oldDN, newDN}) - } - - // 5. Computer accounts - for old, new := range g.mappings.Misc { - if strings.HasSuffix(old, "$") { - repls = append(repls, replacement{old, new}) - } + repls = append(repls, replacement{ + strings.Join(oldDCs, ",") + ",DC=local", + strings.Join(newDCs, ",") + ",DC=local", + }) } + return repls +} - // 6. Domain names (child before parent = longest first) +func (g *Generator) appendDomainReplacements(repls []replacement) []replacement { type domainPair struct{ old, new string } - var domainPairs []domainPair + var pairs []domainPair for old, new := range g.mappings.Domains { - domainPairs = append(domainPairs, domainPair{old, new}) + pairs = append(pairs, domainPair{old, new}) } - sort.Slice(domainPairs, func(i, j int) bool { - return len(domainPairs[i].old) > len(domainPairs[j].old) + sort.Slice(pairs, func(i, j int) bool { + return len(pairs[i].old) > len(pairs[j].old) }) - for _, dp := range domainPairs { + for _, dp := range pairs { repls = append(repls, replacement{dp.old, dp.new}) } - - // 7. Usernames - for old, new := range g.mappings.Users { - repls = append(repls, replacement{old, new}) - } - - // 8. Groups - for old, new := range g.mappings.Groups { - repls = append(repls, replacement{old, new}) - } - - // 9. OUs - for old, new := range g.mappings.OUs { - repls = append(repls, replacement{old, new}) - } - - // 10. Passwords - for old, new := range g.mappings.Passwords { - repls = append(repls, replacement{old, new}) - } - - // 11. NetBIOS names - for old, new := range g.mappings.NetBIOS { - repls = append(repls, replacement{old, new}) - } - - // 12. Misc (non-computer-account) - for old, new := range g.mappings.Misc { - if !strings.HasSuffix(old, "$") { - repls = append(repls, replacement{old, new}) - } - } - - // Sort longest first - sort.Slice(repls, func(i, j int) bool { - return len(repls[i].Old) > len(repls[j].Old) - }) - - // Deduplicate - seen := make(map[string]bool) - var unique []replacement - for _, r := range repls { - key := r.Old + "\x00" + r.New - if !seen[key] { - seen[key] = true - unique = append(unique, r) - } - } - - g.replacements = unique - fmt.Printf("Built %d ordered replacements\n", len(g.replacements)) + return repls } // applyReplacements applies all replacements to content. @@ -955,26 +965,37 @@ func (g *Generator) saveMappings() error { return os.WriteFile(outPath, data, 0o644) } +var originalNames = []string{ + "sevenkingdoms", "essos", + "kingslanding", "winterfell", "meereen", "castelblack", "braavos", + "stark", "lannister", "baratheon", "targaryen", "drogo", "snow", + "tywin", "jaime", "cersei", "tyron", "robert", "joffrey", + "arya", "eddard", "catelyn", "robb", "sansa", "brandon", + "daenerys", "viserys", "khal", "jorah", "mormont", +} + +type violation struct { + file string + name string +} + // validate checks that no original GOAD names appear in variant files. func (g *Generator) validate() bool { fmt.Println("\n=== Validating Variant ===") - originalNames := []string{ - "sevenkingdoms", "essos", - "kingslanding", "winterfell", "meereen", "castelblack", "braavos", - "stark", "lannister", "baratheon", "targaryen", "drogo", "snow", - "tywin", "jaime", "cersei", "tyron", "robert", "joffrey", - "arya", "eddard", "catelyn", "robb", "sansa", "brandon", - "daenerys", "viserys", "khal", "jorah", "mormont", - } + violations, filesChecked := g.findNameViolations() + fmt.Printf("Checked %d text files\n", filesChecked) + printViolations(violations) - type violation struct { - file string - name string - } + fmt.Println("\nValidating structure...") + g.validateStructureCounts() + + return len(violations) == 0 +} + +func (g *Generator) findNameViolations() ([]violation, int) { var violations []violation filesChecked := 0 - skipFiles := map[string]bool{"mapping.json": true, "README.md": true} _ = filepath.WalkDir(g.TargetPath, func(path string, d fs.DirEntry, err error) error { @@ -984,12 +1005,10 @@ func (g *Generator) validate() bool { if skipFiles[d.Name()] { return nil } - ext := filepath.Ext(path) if !textExtensions[ext] && !textFilenames[d.Name()] { return nil } - filesChecked++ content, err := os.ReadFile(path) if err != nil { @@ -997,7 +1016,6 @@ func (g *Generator) validate() bool { } lower := strings.ToLower(string(content)) rel, _ := filepath.Rel(g.TargetPath, path) - for _, name := range originalNames { if strings.Contains(lower, name) { re, err := regexp.Compile(`\b` + regexp.QuoteMeta(name) + `\b`) @@ -1008,51 +1026,53 @@ func (g *Generator) validate() bool { } return nil }) + return violations, filesChecked +} - fmt.Printf("Checked %d text files\n", filesChecked) - - if len(violations) > 0 { - fmt.Printf("\nFound %d potential issues:\n", len(violations)) - limit := len(violations) - if limit > 20 { - limit = 20 - } - for _, v := range violations[:limit] { - fmt.Printf(" %s: contains '%s'\n", v.file, v.name) - } - if len(violations) > 20 { - fmt.Printf(" ... and %d more\n", len(violations)-20) - } - } else { +func printViolations(violations []violation) { + if len(violations) == 0 { fmt.Println("No original names found in variant files") + return } + fmt.Printf("\nFound %d potential issues:\n", len(violations)) + limit := len(violations) + if limit > 20 { + limit = 20 + } + for _, v := range violations[:limit] { + fmt.Printf(" %s: contains '%s'\n", v.file, v.name) + } + if len(violations) > 20 { + fmt.Printf(" ... and %d more\n", len(violations)-20) + } +} - // Validate structure counts - fmt.Println("\nValidating structure...") +func (g *Generator) validateStructureCounts() { origConfig, err := g.loadConfig() - if err == nil { - varData, err := os.ReadFile(filepath.Join(g.TargetPath, "data", "config.json")) - if err == nil { - var varConfig map[string]any - if json.Unmarshal(varData, &varConfig) == nil { - origHosts := len(jsonPath[map[string]any](origConfig, "lab", "hosts")) - varHosts := len(jsonPath[map[string]any](varConfig, "lab", "hosts")) - origDomains := len(jsonPath[map[string]any](origConfig, "lab", "domains")) - varDomains := len(jsonPath[map[string]any](varConfig, "lab", "domains")) - - checkMark := func(a, b int) string { - if a == b { - return "OK" - } - return "MISMATCH" - } - fmt.Printf(" Hosts: %d -> %d %s\n", origHosts, varHosts, checkMark(origHosts, varHosts)) - fmt.Printf(" Domains: %d -> %d %s\n", origDomains, varDomains, checkMark(origDomains, varDomains)) - } + if err != nil { + return + } + varData, err := os.ReadFile(filepath.Join(g.TargetPath, "data", "config.json")) + if err != nil { + return + } + var varConfig map[string]any + if json.Unmarshal(varData, &varConfig) != nil { + return + } + origHosts := len(jsonPath[map[string]any](origConfig, "lab", "hosts")) + varHosts := len(jsonPath[map[string]any](varConfig, "lab", "hosts")) + origDomains := len(jsonPath[map[string]any](origConfig, "lab", "domains")) + varDomains := len(jsonPath[map[string]any](varConfig, "lab", "domains")) + + checkMark := func(a, b int) string { + if a == b { + return "OK" } + return "MISMATCH" } - - return len(violations) == 0 + fmt.Printf(" Hosts: %d -> %d %s\n", origHosts, varHosts, checkMark(origHosts, varHosts)) + fmt.Printf(" Domains: %d -> %d %s\n", origDomains, varDomains, checkMark(origDomains, varDomains)) } // createDocumentation generates a README for the variant. diff --git a/cli/internal/variant/generator_test.go b/cli/internal/variant/generator_test.go index 34f785ef..ca2d16a4 100644 --- a/cli/internal/variant/generator_test.go +++ b/cli/internal/variant/generator_test.go @@ -8,19 +8,37 @@ import ( "testing" ) -func TestGeneratorEndToEnd(t *testing.T) { - // Create a minimal GOAD source structure +func setupTestSource(t *testing.T) (sourceDir, targetDir string) { + t.Helper() tmpDir := t.TempDir() - sourceDir := filepath.Join(tmpDir, "source") - targetDir := filepath.Join(tmpDir, "target") + sourceDir = filepath.Join(tmpDir, "source") + targetDir = filepath.Join(tmpDir, "target") - // Create source data directory if err := os.MkdirAll(filepath.Join(sourceDir, "data"), 0o755); err != nil { t.Fatal(err) } - // Minimal config.json matching GOAD structure - config := map[string]any{ + config := testConfig() + configData, _ := json.MarshalIndent(config, "", " ") + if err := os.WriteFile(filepath.Join(sourceDir, "data", "config.json"), configData, 0o644); err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Join(sourceDir, "scripts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(sourceDir, "scripts", "test.ps1"), + []byte("# Connect to kingslanding.sevenkingdoms.local\n$dc = 'SEVENKINGDOMS\\arya.stark'\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + return sourceDir, targetDir +} + +func testConfig() map[string]any { + return map[string]any{ "lab": map[string]any{ "hosts": map[string]any{ "dc01": map[string]any{ @@ -82,61 +100,38 @@ func TestGeneratorEndToEnd(t *testing.T) { }, }, } +} - configData, _ := json.MarshalIndent(config, "", " ") - if err := os.WriteFile(filepath.Join(sourceDir, "data", "config.json"), configData, 0o644); err != nil { - t.Fatal(err) - } - - // Create a test script file - if err := os.MkdirAll(filepath.Join(sourceDir, "scripts"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile( - filepath.Join(sourceDir, "scripts", "test.ps1"), - []byte("# Connect to kingslanding.sevenkingdoms.local\n$dc = 'SEVENKINGDOMS\\arya.stark'\n"), - 0o644, - ); err != nil { - t.Fatal(err) - } +func TestGeneratorEndToEnd(t *testing.T) { + sourceDir, targetDir := setupTestSource(t) - // Run generator gen := NewGenerator(sourceDir, targetDir, "test-variant") if err := gen.Run(); err != nil { t.Fatalf("generator failed: %v", err) } - // Verify target exists if _, err := os.Stat(filepath.Join(targetDir, "data", "config.json")); err != nil { t.Fatal("config.json not created in target") } - - // Verify mapping.json exists if _, err := os.Stat(filepath.Join(targetDir, "mapping.json")); err != nil { t.Fatal("mapping.json not created") } - // Read transformed config transformedData, err := os.ReadFile(filepath.Join(targetDir, "data", "config.json")) if err != nil { t.Fatal(err) } - content := string(transformedData) - // Verify original names are gone for _, name := range []string{"sevenkingdoms", "essos", "kingslanding", "meereen", "arya", "stark"} { if strings.Contains(strings.ToLower(content), name) { t.Errorf("original name %q still found in transformed config", name) } } - - // Verify sql_svc is preserved if !strings.Contains(content, "sql_svc") { t.Error("sql_svc should be preserved") } - // Verify script was transformed scriptData, err := os.ReadFile(filepath.Join(targetDir, "scripts", "test.ps1")) if err != nil { t.Fatal(err) diff --git a/cli/internal/variant/namegen.go b/cli/internal/variant/namegen.go index c373dcc7..e7794d73 100644 --- a/cli/internal/variant/namegen.go +++ b/cli/internal/variant/namegen.go @@ -220,76 +220,86 @@ func (ng *NameGenerator) GenerateGMSAName() string { return ng.ensureUnique("gmsa" + secureChoice(ng.animals)) } -// GeneratePassword generates a password matching the complexity of the original. -func (ng *NameGenerator) GeneratePassword(original string) string { - length := len(original) - if length == 0 { - length = 16 - } +const ( + lowerChars = "abcdefghijklmnopqrstuvwxyz" + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digitChars = "0123456789" + specialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?" +) - hasUpper := false - hasLower := false - hasDigit := false - hasSpecial := false +type charClasses struct { + upper, lower, digit, special bool +} - for _, c := range original { +func analyzeCharClasses(s string) charClasses { + var cc charClasses + for _, c := range s { switch { case unicode.IsUpper(c): - hasUpper = true + cc.upper = true case unicode.IsLower(c): - hasLower = true + cc.lower = true case unicode.IsDigit(c): - hasDigit = true + cc.digit = true case !unicode.IsLetter(c) && !unicode.IsDigit(c): - hasSpecial = true + cc.special = true } } + return cc +} - const ( - lowerChars = "abcdefghijklmnopqrstuvwxyz" - upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - digitChars = "0123456789" - specialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?" - ) - +func (cc charClasses) charPool() string { var chars string - if hasLower { + if cc.lower { chars += lowerChars } - if hasUpper { + if cc.upper { chars += upperChars } - if hasDigit { + if cc.digit { chars += digitChars } - if hasSpecial { + if cc.special { chars += specialChars } if chars == "" { chars = lowerChars } + return chars +} - // Ensure at least one of each required type - var password []byte - if hasUpper { - password = append(password, secureChoiceByte(upperChars)) +func (cc charClasses) seedRequired() []byte { + var seed []byte + if cc.upper { + seed = append(seed, secureChoiceByte(upperChars)) + } + if cc.lower { + seed = append(seed, secureChoiceByte(lowerChars)) } - if hasLower { - password = append(password, secureChoiceByte(lowerChars)) + if cc.digit { + seed = append(seed, secureChoiceByte(digitChars)) } - if hasDigit { - password = append(password, secureChoiceByte(digitChars)) + if cc.special { + seed = append(seed, secureChoiceByte("!@#$%^&*()-_=+")) } - if hasSpecial { - password = append(password, secureChoiceByte("!@#$%^&*()-_=+")) + return seed +} + +// GeneratePassword generates a password matching the complexity of the original. +func (ng *NameGenerator) GeneratePassword(original string) string { + length := len(original) + if length == 0 { + length = 16 } - // Fill remaining + cc := analyzeCharClasses(original) + chars := cc.charPool() + password := cc.seedRequired() + for len(password) < length { password = append(password, secureChoiceByte(chars)) } - // Shuffle secureShuffle(password) return string(password) } From 12b40f1d70ae181caacf0128a7808ee1fa180c3a Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 1 Apr 2026 10:43:21 -0600 Subject: [PATCH 4/4] fix: correct logic for name component character validation **Changed:** - Fixed character validation logic in the name component check to properly identify valid alphabetic characters and reject invalid ones in the `isNameComponent` method of the generator --- cli/internal/variant/generator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 35e905cc..d1d1a75d 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -721,7 +721,7 @@ func (g *Generator) isNameComponent(old string) bool { } cleaned := strings.ReplaceAll(strings.ReplaceAll(old, "-", ""), "'", "") for _, c := range cleaned { - if !('a' <= c && c <= 'z') && !('A' <= c && c <= 'Z') { + if ('a' > c || c > 'z') && ('A' > c || c > 'Z') { return false } }