From c5e934c4df72ee8d6959b6e5f985a764a70720b6 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 21 Apr 2026 09:52:01 -0600 Subject: [PATCH 1/6] refactor: migrate variant generator to typed config and improve resource handling **Added:** - Introduced strongly typed LabConfig, HostConfig, DomainConfig, and related struct types to represent GOAD config.json structure in the variant generator - Added fileExists utility for robust file existence checks in config package - Implemented additional test coverage for variant and config logic, especially around variant config resolution and new struct-based generator operations **Changed:** - Refactored generator to operate directly on typed config structures instead of generic map[string]any, improving code safety and maintainability - Updated all transformation, mapping, and fixing functions in the generator to use typed access (e.g., config.Lab.Hosts) rather than dynamic map traversal - Improved error handling for file and resource closing throughout the codebase, now logging or propagating close errors for log files, inventory, and builders - Enhanced variant config resolution logic to properly prioritize variant configs if present, and fall back to base config otherwise - Generator now validates structure counts using strongly typed config for consistency checks **Removed:** - Removed legacy dynamic JSON traversal helpers (jsonPath, jsonStr) from the generator in favor of direct typed field access --- cli/cmd/ami.go | 12 +- cli/internal/ansible/retry.go | 4 + cli/internal/ansible/runner.go | 6 +- cli/internal/config/config.go | 17 + cli/internal/config/config_test.go | 51 +++ cli/internal/inventory/parser.go | 8 +- cli/internal/terragrunt/runner.go | 6 +- cli/internal/variant/generator.go | 572 ++++++++++++------------- cli/internal/variant/generator_test.go | 107 +++-- 9 files changed, 412 insertions(+), 371 deletions(-) diff --git a/cli/cmd/ami.go b/cli/cmd/ami.go index 552fc815..25f6f793 100644 --- a/cli/cmd/ami.go +++ b/cli/cmd/ami.go @@ -239,7 +239,7 @@ type amiBuildResult struct { err error } -func buildSingleAMI(ctx context.Context, cfg *config.Config, templatePath string, bf buildFlags, bar *progress.Bar, verbose bool) (*amiBuildResult, error) { +func buildSingleAMI(ctx context.Context, cfg *config.Config, templatePath string, bf buildFlags, bar *progress.Bar, verbose bool) (_ *amiBuildResult, err error) { tmplName := templateName(templatePath) buildCfg, err := loadWarpgateTemplate(templatePath, cfg.ProjectRoot) if err != nil { @@ -297,7 +297,11 @@ func buildSingleAMI(ctx context.Context, cfg *config.Config, templatePath string bar.Fail() return nil, fmt.Errorf("create AMI builder for %s: %w", tmplName, err) } - defer func() { _ = imgBuilder.Close() }() + defer func() { + if cerr := imgBuilder.Close(); cerr != nil && err == nil { + err = fmt.Errorf("close AMI builder: %w", cerr) + } + }() result, err := imgBuilder.Build(ctx, *buildCfg) if err != nil { @@ -653,7 +657,9 @@ func loadWarpgateTemplate(path, projectRoot string) (*builder.Config, error) { } var tmpl templateWithVars - _ = yaml.Unmarshal(data, &tmpl) + if err := yaml.Unmarshal(data, &tmpl); err != nil { + return nil, fmt.Errorf("parse template variables from %s: %w", path, err) + } content := string(data) diff --git a/cli/internal/ansible/retry.go b/cli/internal/ansible/retry.go index 87f958a3..860d2118 100644 --- a/cli/internal/ansible/retry.go +++ b/cli/internal/ansible/retry.go @@ -317,6 +317,8 @@ func fixSSMUsers(ctx context.Context, env string, failedHosts []string, log *slo if err := client.EnableSSMUserLocal(ctx, host.InstanceID); err != nil { log.Info("local enable failed, trying domain account fix", "host", hostName) + // Brief pause to avoid SSM SendCommand throttling on the same instance. + time.Sleep(5 * time.Second) if err := client.FixSSMUserViaDomainAccount(ctx, host.InstanceID); err != nil { log.Warn("ssm-user fix failed", "host", hostName, "error", err) } @@ -326,6 +328,8 @@ func fixSSMUsers(ctx context.Context, env string, failedHosts []string, log *slo // EnableSSMUserLocal doesn't restart the SSM Agent, but a restart is // needed to refresh S3 credentials that cause 403 transfer errors. + // Brief pause to avoid SSM SendCommand throttling on the same instance. + time.Sleep(5 * time.Second) log.Info("restarting SSM Agent to refresh credentials", "host", hostName) if err := client.RestartSSMAgent(ctx, host.InstanceID); err != nil { log.Warn("SSM Agent restart failed", "host", hostName, "error", err) diff --git a/cli/internal/ansible/runner.go b/cli/internal/ansible/runner.go index a332f646..64c76496 100644 --- a/cli/internal/ansible/runner.go +++ b/cli/internal/ansible/runner.go @@ -75,7 +75,11 @@ func RunPlaybook(ctx context.Context, opts RunOptions) *RunResult { if opts.LogFile != "" { if f, err := os.OpenFile(opts.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { writers = append(writers, f) - defer func() { _ = f.Close() }() + defer func() { + if err := f.Close(); err != nil { + slog.Warn("failed to close log file", "path", opts.LogFile, "error", err) + } + }() } } diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index fdacc571..9c179c92 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -136,7 +136,19 @@ func (c *Config) InventoryPath() string { } // LabConfigPath returns the path to the environment's lab config JSON. +// When the active environment has variant enabled, it returns the config +// from the variant target directory instead of the base GOAD directory. func (c *Config) LabConfigPath() string { + ec := c.ActiveEnvironment() + if ec.Variant { + _, target := c.ResolvedVariantPaths() + if target != "" { + variantConfig := filepath.Join(target, "data", c.Env+"-config.json") + if fileExists(variantConfig) { + return variantConfig + } + } + } return filepath.Join(c.ProjectRoot, "ad", "GOAD", "data", c.Env+"-config.json") } @@ -292,6 +304,11 @@ func (c *Config) InfraModulePath(module string) (string, error) { return filepath.Join(workDir, module), nil } +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + func findProjectRoot() (string, error) { cwd, err := os.Getwd() if err != nil { diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 1bcb4c0f..aad564f7 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -134,6 +134,57 @@ func TestConfigAnsibleEnv(t *testing.T) { } } +func TestLabConfigPath(t *testing.T) { + t.Run("non-variant returns base GOAD path", func(t *testing.T) { + c := &Config{ProjectRoot: "/opt/goad", Env: "staging"} + got := c.LabConfigPath() + want := filepath.Join("/opt/goad", "ad", "GOAD", "data", "staging-config.json") + if got != want { + t.Errorf("LabConfigPath() = %q, want %q", got, want) + } + }) + + t.Run("variant returns variant path when config exists", func(t *testing.T) { + dir := resolveSymlinks(t, t.TempDir()) + variantData := filepath.Join(dir, "ad", "GOAD-variant-1", "data") + if err := os.MkdirAll(variantData, 0o755); err != nil { + t.Fatal(err) + } + configFile := filepath.Join(variantData, "dev-config.json") + if err := os.WriteFile(configFile, []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + c := &Config{ + ProjectRoot: dir, + Env: "dev", + Environments: map[string]EnvironmentConfig{ + "dev": {Variant: true, VariantTarget: "ad/GOAD-variant-1"}, + }, + } + got := c.LabConfigPath() + if got != configFile { + t.Errorf("LabConfigPath() = %q, want %q", got, configFile) + } + }) + + t.Run("variant falls back to base when variant config missing", func(t *testing.T) { + dir := resolveSymlinks(t, t.TempDir()) + c := &Config{ + ProjectRoot: dir, + Env: "dev", + Environments: map[string]EnvironmentConfig{ + "dev": {Variant: true, VariantTarget: "ad/GOAD-variant-1"}, + }, + } + got := c.LabConfigPath() + want := filepath.Join(dir, "ad", "GOAD", "data", "dev-config.json") + if got != want { + t.Errorf("LabConfigPath() = %q, want %q", got, want) + } + }) +} + func TestConfigInventoryPathDifferentEnvs(t *testing.T) { tests := []struct { env string diff --git a/cli/internal/inventory/parser.go b/cli/internal/inventory/parser.go index 00042c20..35ff09cf 100644 --- a/cli/internal/inventory/parser.go +++ b/cli/internal/inventory/parser.go @@ -33,12 +33,16 @@ var ( ) // Parse reads and parses an Ansible INI-style inventory file. -func Parse(path string) (*Inventory, error) { +func Parse(path string) (_ *Inventory, err error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open inventory %s: %w", path, err) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = fmt.Errorf("close inventory %s: %w", path, cerr) + } + }() inv := &Inventory{ Hosts: make(map[string]*Host), diff --git a/cli/internal/terragrunt/runner.go b/cli/internal/terragrunt/runner.go index 17c7a920..0903af99 100644 --- a/cli/internal/terragrunt/runner.go +++ b/cli/internal/terragrunt/runner.go @@ -215,5 +215,9 @@ func outputWriter(logFile string) (io.Writer, func(), error) { } mw := io.MultiWriter(os.Stdout, f) - return mw, func() { _ = f.Close() }, nil + return mw, func() { + if err := f.Close(); err != nil { + slog.Warn("failed to close log file", "path", logFile, "error", err) + } + }, nil } diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 492d01cd..8d8c9545 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -11,6 +11,129 @@ import ( "strings" ) +// LabConfig is the top-level structure of a GOAD config.json. +// All known fields are modeled; if a config adds new top-level keys they +// must be added here to survive the transform round-trip in transformFile. +type LabConfig struct { + Lab struct { + Hosts map[string]*HostConfig `json:"hosts"` + Domains map[string]*DomainConfig `json:"domains"` + } `json:"lab"` +} + +// HostConfig represents a single host entry in the lab config. +type HostConfig struct { + Hostname string `json:"hostname"` + Type string `json:"type"` + LocalAdminPassword string `json:"local_admin_password"` + Domain string `json:"domain"` + Path string `json:"path"` + UseLaps *bool `json:"use_laps,omitempty"` + LocalGroups map[string][]string `json:"local_groups,omitempty"` + Scripts []string `json:"scripts,omitempty"` + Vulns []string `json:"vulns,omitempty"` + VulnsVars map[string]any `json:"vulns_vars,omitempty"` + Security []string `json:"security,omitempty"` + SecurityVars map[string]any `json:"security_vars,omitempty"` + MSSQL *MSSQLConfig `json:"mssql,omitempty"` + // RemoteDesktopUsers appears at host top-level in some upstream GOAD configs. + RemoteDesktopUsers []string `json:"Remote Desktop Users,omitempty"` +} + +// MSSQLConfig holds MSSQL server configuration for a host. +type MSSQLConfig struct { + SAPassword string `json:"sa_password"` + SVCAccount string `json:"svcaccount"` + SysAdmins []string `json:"sysadmins"` + ExecuteAsLogin map[string]string `json:"executeaslogin,omitempty"` + ExecuteAsUser map[string]ExecuteAsUserEntry `json:"executeasuser,omitempty"` + LinkedServers map[string]LinkedServerConfig `json:"linked_servers,omitempty"` +} + +// ExecuteAsUserEntry describes an impersonation mapping in MSSQL. +type ExecuteAsUserEntry struct { + User string `json:"user"` + DB string `json:"db"` + Impersonate string `json:"impersonate"` +} + +// LinkedServerConfig describes a linked MSSQL server. +type LinkedServerConfig struct { + DataSrc string `json:"data_src"` + UsersMapping []LinkedServerMapping `json:"users_mapping"` +} + +// LinkedServerMapping maps a local login to a remote login on a linked server. +type LinkedServerMapping struct { + LocalLogin string `json:"local_login"` + RemoteLogin string `json:"remote_login"` + RemotePassword string `json:"remote_password"` +} + +// DomainConfig represents a single domain entry in the lab config. +type DomainConfig struct { + DC string `json:"dc"` + DomainPassword string `json:"domain_password"` + NetBIOSName string `json:"netbios_name"` + CAServer string `json:"ca_server,omitempty"` + Trust string `json:"trust"` + LapsPath string `json:"laps_path,omitempty"` + OrganisationUnits map[string]OUConfig `json:"organisation_units"` + LapsReaders []string `json:"laps_readers,omitempty"` + Groups GroupsConfig `json:"groups"` + MultiDomainGroupsMember map[string][]string `json:"multi_domain_groups_member,omitempty"` + GMSA map[string]GMSAConfig `json:"gmsa,omitempty"` + ACLs map[string]ACLConfig `json:"acls"` + Users map[string]*UserConfig `json:"users"` +} + +// OUConfig represents an organisational unit. +type OUConfig struct { + Path string `json:"path"` +} + +// GroupsConfig holds groups categorized by scope. +type GroupsConfig struct { + Universal map[string]GroupConfig `json:"universal"` + Global map[string]GroupConfig `json:"global"` + DomainLocal map[string]GroupConfig `json:"domainlocal"` +} + +// GroupConfig represents a single AD group. +type GroupConfig struct { + ManagedBy string `json:"managed_by,omitempty"` + Path string `json:"path"` + Members []string `json:"members,omitempty"` +} + +// GMSAConfig represents a group Managed Service Account. +type GMSAConfig struct { + Name string `json:"gMSA_Name"` + FQDN string `json:"gMSA_FQDN"` + SPNs []string `json:"gMSA_SPNs"` + HostNames []string `json:"gMSA_HostNames"` +} + +// ACLConfig represents a single ACL entry. +type ACLConfig struct { + For string `json:"for"` + To string `json:"to"` + Right string `json:"right"` + Inheritance string `json:"inheritance"` +} + +// UserConfig represents a single AD user. +type UserConfig struct { + Firstname string `json:"firstname"` + Surname string `json:"surname"` + Password string `json:"password"` + City string `json:"city"` + Description string `json:"description"` + Groups []string `json:"groups"` + Path string `json:"path"` + SPNs []string `json:"spns,omitempty"` +} + // Mappings holds all entity-to-entity name mappings. type Mappings struct { Domains map[string]string `json:"domains"` @@ -122,20 +245,20 @@ func (g *Generator) Run() error { } // loadConfig reads the source GOAD config.json. -func (g *Generator) loadConfig() (map[string]any, error) { +func (g *Generator) loadConfig() (*LabConfig, error) { data, err := os.ReadFile(filepath.Join(g.SourcePath, "data", "config.json")) if err != nil { return nil, err } - var config map[string]any + var config LabConfig if err := json.Unmarshal(data, &config); err != nil { return nil, err } - return config, nil + return &config, nil } // generateMappings extracts entities and creates all mappings. -func (g *Generator) generateMappings(config map[string]any) { +func (g *Generator) generateMappings(config *LabConfig) { fmt.Println("\n=== Generating Mappings ===") fmt.Println("\nMapping domains...") @@ -200,20 +323,10 @@ func (g *Generator) mapDomains() { 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") +func (g *Generator) mapHosts(config *LabConfig) { + for hostID, host := range config.Lab.Hosts { + oldHostname := host.Hostname + oldDomain := host.Domain newHostname := g.nameGen.GenerateHostname() newDomain := g.mappings.Domains[oldDomain] @@ -238,23 +351,9 @@ func (g *Generator) mapHosts(config map[string]any) { } } -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 { +func (g *Generator) mapUsers(config *LabConfig) { + for _, domain := range config.Lab.Domains { + for username, user := range domain.Users { if g.preservedUsers[username] { g.mappings.Users[username] = username fmt.Printf(" %s -> %s (preserved)\n", username, username) @@ -264,18 +363,19 @@ func (g *Generator) mapUsers(config map[string]any) { newUsername := g.nameGen.GenerateUsername() g.mappings.Users[username] = newUsername - userInfo, _ := userData.(map[string]any) - if userInfo != nil { - g.mapUserNameComponents(userInfo, newUsername) - } + g.mapUserNameComponents(user, newUsername) fmt.Printf(" %s -> %s\n", username, newUsername) } } } -func (g *Generator) mapUserNameComponents(userInfo map[string]any, newUsername string) { - if firstname, ok := userInfo["firstname"].(string); ok { +func (g *Generator) mapUserNameComponents(user *UserConfig, newUsername string) { + if user == nil { + return + } + if user.Firstname != "" { + firstname := user.Firstname newFirst := strings.Split(newUsername, ".")[0] g.mappings.Misc[firstname] = newFirst if !isAllLower(firstname) && firstname != "sql" { @@ -286,7 +386,8 @@ func (g *Generator) mapUserNameComponents(userInfo map[string]any, newUsername s } } - if surname, ok := userInfo["surname"].(string); ok && surname != "-" { + if user.Surname != "" && user.Surname != "-" { + surname := user.Surname parts := strings.SplitN(newUsername, ".", 2) newSurname := parts[0] if len(parts) > 1 { @@ -299,29 +400,17 @@ func (g *Generator) mapUserNameComponents(userInfo map[string]any, newUsername s } } -func (g *Generator) mapGroups(config map[string]any) { - domains := jsonPath[map[string]any](config, "lab", "domains") - if domains == nil { - return - } - +func (g *Generator) mapGroups(config *LabConfig) { 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 _, domain := range config.Lab.Domains { + allGroups := []map[string]GroupConfig{ + domain.Groups.Universal, + domain.Groups.Global, + domain.Groups.DomainLocal, } - for _, groupType := range []string{"universal", "global", "domainlocal"} { - typeGroups := jsonPath[map[string]any](groups, groupType) - if typeGroups == nil { - continue - } + for _, typeGroups := range allGroups { for groupName := range typeGroups { if builtins[groupName] { g.mappings.Groups[groupName] = groupName @@ -335,22 +424,9 @@ func (g *Generator) mapGroups(config map[string]any) { } } -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 { +func (g *Generator) mapOUs(config *LabConfig) { + for _, domain := range config.Lab.Domains { + for ouName := range domain.OrganisationUnits { newName := g.nameGen.GenerateOUName() g.mappings.OUs[ouName] = newName fmt.Printf(" %s -> %s\n", ouName, newName) @@ -358,14 +434,11 @@ func (g *Generator) mapOUs(config map[string]any) { } } -func (g *Generator) mapPasswords(config map[string]any) { +func (g *Generator) mapPasswords(config *LabConfig) { passwords := make(map[string]bool) - domains := jsonPath[map[string]any](config, "lab", "domains") - hosts := jsonPath[map[string]any](config, "lab", "hosts") - - collectDomainPasswords(domains, passwords) - collectHostPasswords(hosts, passwords) + collectDomainPasswords(config.Lab.Domains, passwords) + collectHostPasswords(config.Lab.Hosts, passwords) for pw := range passwords { newPW := g.nameGen.GeneratePassword(pw) @@ -381,177 +454,118 @@ func (g *Generator) mapPasswords(config map[string]any) { fmt.Printf(" %s... -> %s...\n", truncOld, truncNew) } - g.buildUserPasswordMap(domains) + g.buildUserPasswordMap(config.Lab.Domains) } -func collectDomainPasswords(domains map[string]any, passwords map[string]bool) { - for _, domainData := range domains { - info, _ := domainData.(map[string]any) - if info == nil { - continue - } - if pw, ok := info["domain_password"].(string); ok { - passwords[pw] = true +func collectDomainPasswords(domains map[string]*DomainConfig, passwords map[string]bool) { + for _, domain := range domains { + if domain.DomainPassword != "" { + passwords[domain.DomainPassword] = true } - 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 + for _, user := range domain.Users { + if user != nil && user.Password != "" { + passwords[user.Password] = true } } } } -func collectHostPasswords(hosts map[string]any, passwords map[string]bool) { - for _, hostData := range hosts { - info, _ := hostData.(map[string]any) - if info == nil { - continue +func collectHostPasswords(hosts map[string]*HostConfig, passwords map[string]bool) { + for _, host := range hosts { + if host.LocalAdminPassword != "" { + passwords[host.LocalAdminPassword] = true } - if pw, ok := info["local_admin_password"].(string); ok { - passwords[pw] = true - } - collectMSSQLPasswords(info, passwords) - collectVulnPasswords(info, passwords) + collectMSSQLPasswords(host.MSSQL, passwords) + collectVulnPasswords(host.VulnsVars, passwords) } } -func collectMSSQLPasswords(hostInfo map[string]any, passwords map[string]bool) { - mssql := jsonPath[map[string]any](hostInfo, "mssql") +func collectMSSQLPasswords(mssql *MSSQLConfig, passwords map[string]bool) { if mssql == nil { return } - if pw, ok := mssql["sa_password"].(string); ok { - passwords[pw] = true + if mssql.SAPassword != "" { + passwords[mssql.SAPassword] = 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 - } + for _, ls := range mssql.LinkedServers { + for _, mapping := range ls.UsersMapping { + if mapping.RemotePassword != "" { + passwords[mapping.RemotePassword] = true } } } } -func collectVulnPasswords(hostInfo map[string]any, passwords map[string]bool) { - vulnsVars := jsonPath[map[string]any](hostInfo, "vulns_vars") +// collectVulnPasswords extracts passwords from the variable-schema vulns_vars map. +func collectVulnPasswords(vulnsVars map[string]any, passwords map[string]bool) { 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 pw, ok := credInfo["secret"].(string); ok { - passwords[pw] = true - } - if pw, ok := credInfo["runas_password"].(string); ok { - passwords[pw] = true + if creds, ok := vulnsVars["credentials"].(map[string]any); ok { + for _, credData := range creds { + credInfo, ok := credData.(map[string]any) + if !ok { + 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 + if autologon, ok := vulnsVars["autologon"].(map[string]any); ok { + for _, autoData := range autologon { + autoInfo, ok := autoData.(map[string]any) + if !ok { + continue + } + if pw, ok := autoInfo["password"].(string); ok { + passwords[pw] = true + } } } } -func (g *Generator) buildUserPasswordMap(domains map[string]any) { - 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 { +func (g *Generator) buildUserPasswordMap(domains map[string]*DomainConfig) { + for _, domain := range domains { + for username, user := range domain.Users { + if user == nil || user.Password == "" { 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 + newUsername := g.mappings.Users[username] + if newUsername == "" { + newUsername = username } + newPW := g.mappings.Passwords[user.Password] + if newPW == "" { + newPW = user.Password + } + 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 { +func (g *Generator) mapGMSAAccounts(config *LabConfig) { + for _, domain := range config.Lab.Domains { + for _, gmsa := range domain.GMSA { + if gmsa.Name != "" { newName := g.nameGen.GenerateGMSAName() - g.mappings.Misc[oldName] = newName - g.mappings.Misc[oldName+"$"] = newName + "$" - fmt.Printf(" %s -> %s\n", oldName, newName) + g.mappings.Misc[gmsa.Name] = newName + g.mappings.Misc[gmsa.Name+"$"] = newName + "$" + fmt.Printf(" %s -> %s\n", gmsa.Name, newName) } } } } -func (g *Generator) mapCities(config map[string]any) { - domains := jsonPath[map[string]any](config, "lab", "domains") - if domains == nil { - return - } - +func (g *Generator) mapCities(config *LabConfig) { 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 _, domain := range config.Lab.Domains { + for _, user := range domain.Users { + if user != nil && user.City != "" && user.City != "-" { + cities[user.City] = true } } } @@ -727,34 +741,20 @@ func (g *Generator) isNameComponent(old string) bool { } // 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 { +func (g *Generator) fixUserFirstnameSurname(config *LabConfig) { + for _, domain := range config.Lab.Domains { + for username, user := range domain.Users { + if g.preservedUsers[username] || user == nil { continue } if strings.Contains(username, ".") { parts := strings.SplitN(username, ".", 2) - userInfo["firstname"] = parts[0] + user.Firstname = parts[0] if len(parts) > 1 { - userInfo["surname"] = parts[1] + user.Surname = parts[1] } - if _, ok := userInfo["description"]; ok { - userInfo["description"] = capitalize(parts[0]) + " " + capitalize(parts[1]) + if user.Description != "" { + user.Description = capitalize(parts[0]) + " " + capitalize(parts[1]) } } } @@ -762,60 +762,30 @@ func (g *Generator) fixUserFirstnameSurname(config map[string]any) { } // 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 { +func (g *Generator) fixPasswords(config *LabConfig) { + for _, domain := range config.Lab.Domains { + for username, user := range domain.Users { + if user == nil { continue } if newPW, ok := g.userPasswordMap[username]; ok { - userInfo["password"] = newPW + user.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 { +func (g *Generator) rebuildACLKeys(config *LabConfig) { + for _, domain := range config.Lab.Domains { + if domain.ACLs == 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) + newACLs := make(map[string]ACLConfig) + for oldKey, acl := range domain.ACLs { + forSimple := simplifyEntity(acl.For) + toSimple := simplifyEntity(acl.To) keyParts := strings.SplitN(oldKey, "_", 3) var newKey string @@ -825,10 +795,10 @@ func (g *Generator) rebuildACLKeys(config map[string]any) { newKey = oldKey } - newACLs[newKey] = aclData + newACLs[newKey] = acl } - info["acls"] = newACLs + domain.ACLs = newACLs } } @@ -877,11 +847,11 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { newContent := g.applyReplacements(string(content)) if base == "config.json" || strings.HasSuffix(base, "-config.json") { - var configData map[string]any + var configData LabConfig if err := json.Unmarshal([]byte(newContent), &configData); err == nil { - g.fixUserFirstnameSurname(configData) - g.fixPasswords(configData) - g.rebuildACLKeys(configData) + g.fixUserFirstnameSurname(&configData) + g.fixPasswords(&configData) + g.rebuildACLKeys(&configData) if pretty, err := json.MarshalIndent(configData, "", " "); err == nil { newContent = string(pretty) } @@ -991,7 +961,7 @@ func (g *Generator) findNameViolations() ([]violation, int) { 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 := filepath.WalkDir(g.TargetPath, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } @@ -1018,7 +988,9 @@ func (g *Generator) findNameViolations() ([]violation, int) { } } return nil - }) + }); err != nil { + fmt.Printf("Warning: error walking variant directory: %v\n", err) + } return violations, filesChecked } @@ -1049,14 +1021,14 @@ func (g *Generator) validateStructureCounts() { if err != nil { return } - var varConfig map[string]any + var varConfig LabConfig 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")) + origHosts := len(origConfig.Lab.Hosts) + varHosts := len(varConfig.Lab.Hosts) + origDomains := len(origConfig.Lab.Domains) + varDomains := len(varConfig.Lab.Domains) checkMark := func(a, b int) string { if a == b { @@ -1116,7 +1088,10 @@ Generated by GOAD Variant Generator `, strings.ToUpper(g.VariantName)) readmePath := filepath.Join(g.TargetPath, "README.md") - _ = os.WriteFile(readmePath, []byte(readme), 0o644) + if err := os.WriteFile(readmePath, []byte(readme), 0o644); err != nil { + fmt.Printf("Warning: failed to write documentation %s: %v\n", readmePath, err) + return + } fmt.Printf("Documentation created at %s\n", readmePath) } @@ -1141,24 +1116,3 @@ func capitalize(s string) string { 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 index 796a2c47..eac02b58 100644 --- a/cli/internal/variant/generator_test.go +++ b/cli/internal/variant/generator_test.go @@ -37,69 +37,66 @@ func setupTestSource(t *testing.T) (sourceDir, targetDir string) { return sourceDir, targetDir } -func testConfig() map[string]any { - return 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!", +func testConfig() *LabConfig { + config := &LabConfig{} + config.Lab.Hosts = map[string]*HostConfig{ + "dc01": { + Hostname: "kingslanding", + Type: "dc", + Domain: "sevenkingdoms.local", + LocalAdminPassword: "TestPass123!", + }, + "dc03": { + Hostname: "meereen", + Type: "dc", + Domain: "essos.local", + LocalAdminPassword: "TestPass456!", + }, + } + config.Lab.Domains = map[string]*DomainConfig{ + "sevenkingdoms.local": { + DomainPassword: "DomainPass1!", + Users: map[string]*UserConfig{ + "arya.stark": { + Firstname: "arya", + Surname: "stark", + Password: "NeedleIsMySword!", + City: "Winterfell", + }, + "sql_svc": { + Firstname: "sql", + Surname: "-", + Password: "SqlSvcPass1!", }, - "dc03": map[string]any{ - "hostname": "meereen", - "type": "dc", - "domain": "essos.local", - "local_admin_password": "TestPass456!", + }, + Groups: GroupsConfig{ + Global: map[string]GroupConfig{ + "Stark": {}, + "Domain Admins": {}, }, }, - "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", - }, - }, + OrganisationUnits: map[string]OUConfig{ + "Vale": {}, + }, + ACLs: map[string]ACLConfig{ + "GenericAll_arya_stark": { + For: "arya.stark", + To: "CN=SomeObject", + Right: "GenericAll", }, - "essos.local": map[string]any{ - "domain_password": "EssosPass1!", - "users": map[string]any{}, - "groups": map[string]any{}, + }, + GMSA: map[string]GMSAConfig{ + "gmsa1": { + Name: "gmsaDragon", }, }, }, + "essos.local": { + DomainPassword: "EssosPass1!", + Users: map[string]*UserConfig{}, + }, } + return config } func TestGeneratorEndToEnd(t *testing.T) { From 2bf24d0f30470806cf5580c95d9963ee30957845 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 21 Apr 2026 11:54:26 -0600 Subject: [PATCH 2/6] feat: automate pre-flight domain cleanup before Windows hostname change **Added:** - Automated detection of current hostname, domain join state, and DC status - Automated removal of ADCS features before DC demotion, including conditional reboots and timeout handling - Automated domain controller demotion with secure admin password usage and conditional reboot - Automated domain unjoin process with PowerShell and WMI fallback, plus conditional reboot before hostname change **Changed:** - Updated role documentation to reflect new pre-flight and cleanup automation steps in the hostname change workflow - Reorganized main task file to sequence pre-flight, ADCS removal, DC demotion, domain unjoin, and required reboots before changing the hostname --- ansible/roles/settings_hostname/README.md | 7 ++ .../roles/settings_hostname/tasks/main.yml | 102 ++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/ansible/roles/settings_hostname/README.md b/ansible/roles/settings_hostname/README.md index 7ad07ec3..e373a947 100644 --- a/ansible/roles/settings_hostname/README.md +++ b/ansible/roles/settings_hostname/README.md @@ -15,6 +15,13 @@ Configure Windows hostname and scheduled maintenance tasks ### main.yml +- **Check current hostname and domain state** (ansible.windows.win_powershell) +- **Remove ADCS features before DC demotion** (ansible.windows.win_powershell) - Conditional +- **Reboot after ADCS removal (required before demotion)** (ansible.windows.win_reboot) - Conditional +- **Demote domain controller before hostname change** (ansible.windows.win_powershell) - Conditional +- **Reboot after DC demotion** (ansible.windows.win_reboot) - Conditional +- **Unjoin from domain before hostname change** (ansible.windows.win_powershell) - Conditional +- **Reboot after domain unjoin** (ansible.windows.win_reboot) - Conditional - **Create scheduled task to keep ssm-user enabled (survives GPO refresh)** (ansible.windows.win_powershell) - **Change the hostname** (ansible.windows.win_hostname) - **Reboot if needed** (ansible.windows.win_reboot) - Conditional diff --git a/ansible/roles/settings_hostname/tasks/main.yml b/ansible/roles/settings_hostname/tasks/main.yml index 42eac613..359e742c 100644 --- a/ansible/roles/settings_hostname/tasks/main.yml +++ b/ansible/roles/settings_hostname/tasks/main.yml @@ -1,4 +1,105 @@ --- +# -- Pre-flight: detect domain state so we can clean up before renaming ------ +- name: Check current hostname and domain state + ansible.windows.win_powershell: + script: | + $cs = Get-WmiObject Win32_ComputerSystem + $Ansible.Result = @{ + current_hostname = $env:COMPUTERNAME + is_domain_joined = [bool]$cs.PartOfDomain + is_dc = ($cs.DomainRole -ge 4) + domain = $cs.Domain + } + $Ansible.Changed = $false + register: host_state + +# -- ADCS removal: Certificate Services block DC demotion ------------------- +- name: Remove ADCS features before DC demotion + ansible.windows.win_powershell: + script: | + $adcs = Get-WindowsFeature ADCS* | Where-Object { $_.Installed } + if ($adcs) { + Write-Output "Removing ADCS features: $($adcs.Name -join ', ')" + # Remove in reverse-dependency order (sub-features first, CA last) + $adcs | Sort-Object -Property Name -Descending | ForEach-Object { + Write-Output "Removing $($_.Name)..." + Uninstall-WindowsFeature $_.Name -ErrorAction Continue + } + $Ansible.Changed = $true + } else { + Write-Output "No ADCS features installed" + $Ansible.Changed = $false + } + vars: + ansible_command_timeout: 600 + when: + - host_state.result.is_dc + - host_state.result.current_hostname | upper != hostname | upper + register: adcs_removal + +- name: Reboot after ADCS removal (required before demotion) + ansible.windows.win_reboot: + reboot_timeout: 600 + post_reboot_delay: 60 + when: adcs_removal is changed + +# -- DC demotion: a promoted DC cannot be safely renamed in-place ------------ +- name: Demote domain controller before hostname change + ansible.windows.win_powershell: + script: | + Import-Module ADDSDeployment + $pw = ConvertTo-SecureString '{{ local_admin_password }}' -AsPlainText -Force + Uninstall-ADDSDomainController ` + -LocalAdministratorPassword $pw ` + -ForceRemoval ` + -DemoteOperationMasterRole ` + -NoRebootOnCompletion ` + -Force ` + -Confirm:$false + Write-Output "DC demotion completed - reboot required" + vars: + ansible_command_timeout: 600 + when: + - host_state.result.is_dc + - host_state.result.current_hostname | upper != hostname | upper + register: dc_demote + +- name: Reboot after DC demotion + ansible.windows.win_reboot: + reboot_timeout: 900 + post_reboot_delay: 120 + when: dc_demote is changed + +# -- Domain unjoin: domain-joined servers cannot be renamed by a local user -- +- name: Unjoin from domain before hostname change + ansible.windows.win_powershell: + script: | + try { + Remove-Computer -WorkgroupName WORKGROUP -Force -ErrorAction Stop + Write-Output "Unjoined from domain" + } catch { + Write-Output "Remove-Computer failed ($_), trying WMI fallback..." + $cs = Get-WmiObject Win32_ComputerSystem + $rc = $cs.UnjoinDomainOrWorkgroup($null, $null, 0).ReturnValue + if ($rc -ne 0) { + Write-Error "WMI unjoin failed (rc=$rc)" + exit 1 + } + Write-Output "Unjoined via WMI" + } + when: + - host_state.result.is_domain_joined + - not host_state.result.is_dc + - host_state.result.current_hostname | upper != hostname | upper + register: domain_unjoin + +- name: Reboot after domain unjoin + ansible.windows.win_reboot: + reboot_timeout: 600 + post_reboot_delay: 60 + when: domain_unjoin is changed + +# -- SSM user persistence ---------------------------------------------------- - name: Create scheduled task to keep ssm-user enabled (survives GPO refresh) ansible.windows.win_powershell: script: | @@ -17,6 +118,7 @@ } failed_when: false +# -- Hostname change ---------------------------------------------------------- - name: Change the hostname ansible.windows.win_hostname: name: "{{ hostname }}" From 965ea1c616e98fb2e63095f164102e3e5819b3ad Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 21 Apr 2026 12:09:09 -0600 Subject: [PATCH 3/6] refactor: replace custom powershell tasks with native ansible modules for dc demotion **Added:** - Introduced `domain`, `domain_username`, and `domain_password` variables in the AD servers playbook to support module authentication **Changed:** - Switched ADCS feature removal from a custom PowerShell script to the `ansible.windows.win_feature` module for clearer intent and reliability - Replaced DC demotion PowerShell script with `microsoft.ad.domain_controller` module, allowing use of domain credentials and improved idempotency - Updated domain unjoin logic to use `microsoft.ad.membership` instead of PowerShell/WMI, providing better error handling and integration - Enhanced reboot logic after DC demotion and domain unjoin to account for module-reported reboot requirements - Updated `settings_hostname` role documentation to reflect use of native Ansible modules instead of PowerShell scripts **Removed:** - Eliminated custom PowerShell scripts for ADCS removal, DC demotion, and domain unjoin, reducing complexity and risk of script errors --- ansible/playbooks/ad-servers.yml | 3 + ansible/roles/settings_hostname/README.md | 6 +- .../roles/settings_hostname/tasks/main.yml | 71 ++++++------------- 3 files changed, 27 insertions(+), 53 deletions(-) diff --git a/ansible/playbooks/ad-servers.yml b/ansible/playbooks/ad-servers.yml index 08b736a2..8f3084b6 100644 --- a/ansible/playbooks/ad-servers.yml +++ b/ansible/playbooks/ad-servers.yml @@ -11,3 +11,6 @@ vars: local_admin_password: "{{ lab.hosts[inventory_hostname].local_admin_password }}" hostname: "{{ lab.hosts[inventory_hostname].hostname }}" + domain: "{{ lab.hosts[inventory_hostname].domain }}" + domain_username: "{{ domain }}\\{{ admin_user }}" + domain_password: "{{ lab.domains[domain].domain_password }}" diff --git a/ansible/roles/settings_hostname/README.md b/ansible/roles/settings_hostname/README.md index e373a947..8a8867f0 100644 --- a/ansible/roles/settings_hostname/README.md +++ b/ansible/roles/settings_hostname/README.md @@ -16,11 +16,11 @@ Configure Windows hostname and scheduled maintenance tasks ### main.yml - **Check current hostname and domain state** (ansible.windows.win_powershell) -- **Remove ADCS features before DC demotion** (ansible.windows.win_powershell) - Conditional +- **Remove ADCS features before DC demotion** (ansible.windows.win_feature) - Conditional - **Reboot after ADCS removal (required before demotion)** (ansible.windows.win_reboot) - Conditional -- **Demote domain controller before hostname change** (ansible.windows.win_powershell) - Conditional +- **Demote domain controller before hostname change** (microsoft.ad.domain_controller) - Conditional - **Reboot after DC demotion** (ansible.windows.win_reboot) - Conditional -- **Unjoin from domain before hostname change** (ansible.windows.win_powershell) - Conditional +- **Unjoin from domain before hostname change** (microsoft.ad.membership) - Conditional - **Reboot after domain unjoin** (ansible.windows.win_reboot) - Conditional - **Create scheduled task to keep ssm-user enabled (survives GPO refresh)** (ansible.windows.win_powershell) - **Change the hostname** (ansible.windows.win_hostname) diff --git a/ansible/roles/settings_hostname/tasks/main.yml b/ansible/roles/settings_hostname/tasks/main.yml index 359e742c..f1396a05 100644 --- a/ansible/roles/settings_hostname/tasks/main.yml +++ b/ansible/roles/settings_hostname/tasks/main.yml @@ -15,23 +15,11 @@ # -- ADCS removal: Certificate Services block DC demotion ------------------- - name: Remove ADCS features before DC demotion - ansible.windows.win_powershell: - script: | - $adcs = Get-WindowsFeature ADCS* | Where-Object { $_.Installed } - if ($adcs) { - Write-Output "Removing ADCS features: $($adcs.Name -join ', ')" - # Remove in reverse-dependency order (sub-features first, CA last) - $adcs | Sort-Object -Property Name -Descending | ForEach-Object { - Write-Output "Removing $($_.Name)..." - Uninstall-WindowsFeature $_.Name -ErrorAction Continue - } - $Ansible.Changed = $true - } else { - Write-Output "No ADCS features installed" - $Ansible.Changed = $false - } - vars: - ansible_command_timeout: 600 + ansible.windows.win_feature: + name: AD-Certificate + state: absent + include_sub_features: true + include_management_tools: true when: - host_state.result.is_dc - host_state.result.current_hostname | upper != hostname | upper @@ -45,59 +33,42 @@ # -- DC demotion: a promoted DC cannot be safely renamed in-place ------------ - name: Demote domain controller before hostname change - ansible.windows.win_powershell: - script: | - Import-Module ADDSDeployment - $pw = ConvertTo-SecureString '{{ local_admin_password }}' -AsPlainText -Force - Uninstall-ADDSDomainController ` - -LocalAdministratorPassword $pw ` - -ForceRemoval ` - -DemoteOperationMasterRole ` - -NoRebootOnCompletion ` - -Force ` - -Confirm:$false - Write-Output "DC demotion completed - reboot required" - vars: - ansible_command_timeout: 600 + microsoft.ad.domain_controller: + domain_admin_user: "{{ domain_username }}" + domain_admin_password: "{{ domain_password }}" + local_admin_password: "{{ local_admin_password }}" + state: member_server + register: dc_demote + async: 3600 + poll: 60 when: - host_state.result.is_dc - host_state.result.current_hostname | upper != hostname | upper - register: dc_demote - name: Reboot after DC demotion ansible.windows.win_reboot: reboot_timeout: 900 post_reboot_delay: 120 - when: dc_demote is changed + when: dc_demote is changed or (dc_demote.reboot_required | default(false)) # -- Domain unjoin: domain-joined servers cannot be renamed by a local user -- - name: Unjoin from domain before hostname change - ansible.windows.win_powershell: - script: | - try { - Remove-Computer -WorkgroupName WORKGROUP -Force -ErrorAction Stop - Write-Output "Unjoined from domain" - } catch { - Write-Output "Remove-Computer failed ($_), trying WMI fallback..." - $cs = Get-WmiObject Win32_ComputerSystem - $rc = $cs.UnjoinDomainOrWorkgroup($null, $null, 0).ReturnValue - if ($rc -ne 0) { - Write-Error "WMI unjoin failed (rc=$rc)" - exit 1 - } - Write-Output "Unjoined via WMI" - } + microsoft.ad.membership: + state: workgroup + workgroup_name: WORKGROUP + domain_admin_user: "{{ domain_username }}" + domain_admin_password: "{{ domain_password }}" + register: domain_unjoin when: - host_state.result.is_domain_joined - not host_state.result.is_dc - host_state.result.current_hostname | upper != hostname | upper - register: domain_unjoin - name: Reboot after domain unjoin ansible.windows.win_reboot: reboot_timeout: 600 post_reboot_delay: 60 - when: domain_unjoin is changed + when: domain_unjoin is changed or (domain_unjoin.reboot_required | default(false)) # -- SSM user persistence ---------------------------------------------------- - name: Create scheduled task to keep ssm-user enabled (survives GPO refresh) From bcb1f0e8ab08f723dd041b241b8a2ca871d86ba2 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 21 Apr 2026 22:14:37 -0600 Subject: [PATCH 4/6] ``` refactor: switch to win_powershell for LAPS and GPO tasks, improve reliability **Added:** - Introduced a dedicated task to wait for LAPS CSE to process GPO after refresh in the `laps_server` role, ensuring password is set properly **Changed:** - Replaced `win_shell` with `win_powershell` for moving servers to LAPS OU, adding explicit error handling and idempotency to improve reliability in `laps_dc` role - Updated documentation in `laps_dc/README.md` and `laps_server/README.md` to reflect use of `win_powershell` instead of `win_shell` for relevant tasks - Refined GPO refresh logic in `laps_server` install workflow to use `win_powershell` and added a wait step for LAPS CSE processing, improving clarity and idempotency - Changed WMI query to use `Get-CimInstance` instead of `Get-WmiObject` for checking computer system state in hostname settings role, aligning with modern PowerShell best practices - Simplified reboot conditionals in hostname settings role by removing checks for `reboot_required` and relying solely on change detection **Removed:** - Removed use of deprecated `Get-WmiObject` cmdlet in favor of `Get-CimInstance` - Eliminated unnecessary or redundant reboot conditional logic in hostname settings tasks ``` --- ansible/roles/laps_dc/README.md | 2 +- .../roles/laps_dc/tasks/move_server_to_ou.yml | 49 +++++++++++++++---- ansible/roles/laps_server/README.md | 3 +- ansible/roles/laps_server/tasks/install.yml | 25 +++++----- .../roles/settings_hostname/tasks/main.yml | 6 +-- 5 files changed, 58 insertions(+), 27 deletions(-) diff --git a/ansible/roles/laps_dc/README.md b/ansible/roles/laps_dc/README.md index c00ef07c..66e8e9b6 100644 --- a/ansible/roles/laps_dc/README.md +++ b/ansible/roles/laps_dc/README.md @@ -67,7 +67,7 @@ Install and configure LAPS on Domain Controllers ### move_server_to_ou.yml -- **Move server to Laps OU** (ansible.windows.win_shell) - Conditional +- **Move server to Laps OU** (ansible.windows.win_powershell) - Conditional ## Example Playbook diff --git a/ansible/roles/laps_dc/tasks/move_server_to_ou.yml b/ansible/roles/laps_dc/tasks/move_server_to_ou.yml index e9672b82..7abdef4c 100644 --- a/ansible/roles/laps_dc/tasks/move_server_to_ou.yml +++ b/ansible/roles/laps_dc/tasks/move_server_to_ou.yml @@ -1,13 +1,44 @@ - name: Move server to Laps OU - ansible.windows.win_shell: | - try { - Get-ADOrganizationalUnit -Identity "{{ laps_path }}" > $null - $server=Get-AdComputer -Identity "{{ hostname }}" - Move-ADObject -Identity $server.DistinguishedName -TargetPath "{{ laps_path }}" - $true - } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { - $false - } + ansible.windows.win_powershell: + script: | + $lapsPath = '{{ laps_path }}' + $hostname = '{{ hostname }}' + + # Verify LAPS OU exists + try { + Get-ADOrganizationalUnit -Identity $lapsPath -ErrorAction Stop | Out-Null + } catch { + Write-Error "LAPS OU not found: $lapsPath - $_" + $Ansible.Failed = $true + return + } + + # Find the computer object + try { + $server = Get-ADComputer -Identity $hostname -ErrorAction Stop + } catch { + Write-Error "Computer object not found for $hostname - $_" + $Ansible.Failed = $true + return + } + + # Check if already in LAPS OU + $currentParent = ($server.DistinguishedName -split ',', 2)[1] + if ($currentParent -eq $lapsPath) { + Write-Output "$hostname is already in LAPS OU" + $Ansible.Changed = $false + return + } + + # Move to LAPS OU + try { + Move-ADObject -Identity $server.DistinguishedName -TargetPath $lapsPath -ErrorAction Stop + Write-Output "Moved $hostname to LAPS OU" + $Ansible.Changed = $true + } catch { + Write-Error "Failed to move $hostname to LAPS OU: $_" + $Ansible.Failed = $true + } vars: hostname: "{{ item.value.hostname }}" when: item.value.use_laps is defined and item.value.use_laps == true and item.value.domain == domain diff --git a/ansible/roles/laps_server/README.md b/ansible/roles/laps_server/README.md index 37dfbe64..df95eeef 100644 --- a/ansible/roles/laps_server/README.md +++ b/ansible/roles/laps_server/README.md @@ -19,7 +19,8 @@ Install LAPS client on member servers - **Download LAPS Package** (ansible.windows.win_get_url) - **Install to Servers** (ansible.windows.win_package) - **Reboot after installing LAPS (if required)** (ansible.windows.win_reboot) - Conditional -- **Refresh GPO on the Clients** (ansible.windows.win_shell) +- **Refresh GPO on the Clients** (ansible.windows.win_powershell) +- **Wait for LAPS CSE to process GPO** (ansible.windows.win_powershell) ### main.yml diff --git a/ansible/roles/laps_server/tasks/install.yml b/ansible/roles/laps_server/tasks/install.yml index fbb0228b..a1da0dec 100644 --- a/ansible/roles/laps_server/tasks/install.yml +++ b/ansible/roles/laps_server/tasks/install.yml @@ -30,16 +30,15 @@ when: pri_laps_install.reboot_required - name: Refresh GPO on the Clients - ansible.windows.win_shell: | - try { - Write-Output "Attempting to update group policy..." - $result = gpupdate /force - Write-Output "Group policy update command executed." - exit 0 - } catch { - Write-Output "Error during GPUpdate: $_" - # Return success even if GPUpdate failed - # This makes the task idempotent - exit 0 - } - failed_when: false + ansible.windows.win_powershell: + script: | + gpupdate /force 2>&1 | Out-Null + $Ansible.Changed = $false + +- name: Wait for LAPS CSE to process GPO + ansible.windows.win_powershell: + script: | + # LAPS CSE may need a second gpupdate cycle to set the password + Start-Sleep -Seconds 10 + gpupdate /force 2>&1 | Out-Null + $Ansible.Changed = $false diff --git a/ansible/roles/settings_hostname/tasks/main.yml b/ansible/roles/settings_hostname/tasks/main.yml index f1396a05..498d319f 100644 --- a/ansible/roles/settings_hostname/tasks/main.yml +++ b/ansible/roles/settings_hostname/tasks/main.yml @@ -3,7 +3,7 @@ - name: Check current hostname and domain state ansible.windows.win_powershell: script: | - $cs = Get-WmiObject Win32_ComputerSystem + $cs = Get-CimInstance Win32_ComputerSystem $Ansible.Result = @{ current_hostname = $env:COMPUTERNAME is_domain_joined = [bool]$cs.PartOfDomain @@ -49,7 +49,7 @@ ansible.windows.win_reboot: reboot_timeout: 900 post_reboot_delay: 120 - when: dc_demote is changed or (dc_demote.reboot_required | default(false)) + when: dc_demote is changed # -- Domain unjoin: domain-joined servers cannot be renamed by a local user -- - name: Unjoin from domain before hostname change @@ -68,7 +68,7 @@ ansible.windows.win_reboot: reboot_timeout: 600 post_reboot_delay: 60 - when: domain_unjoin is changed or (domain_unjoin.reboot_required | default(false)) + when: domain_unjoin is changed # -- SSM user persistence ---------------------------------------------------- - name: Create scheduled task to keep ssm-user enabled (survives GPO refresh) From c081de64c4322886bc5e723c52fcd99493fa0fd9 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 22 Apr 2026 10:53:26 -0600 Subject: [PATCH 5/6] refactor: switch lab config to overlay/merge system and add JSON merge utilities **Added:** - Introduced overlay-based environment config system using RFC 7386 JSON Merge Patch - Added `.dreadgoad/` and `.envrc` to `.gitignore` for improved local dev ergonomics - Implemented `cli/internal/jsonmerge/` package for JSON Merge Patch diff and merge - Added comprehensive unit tests for JSON merge/diff utilities - Created new overlay files (`*-overlay.json`) for dev, staging, and test in GOAD lab data - Documented overlay system and merge semantics in `docs/cli.md`, `add_lab.md`, and `provisioning.md` - Updated `env create` to generate overlay files instead of full config copies **Changed:** - Switched CLI and labmap resolution logic to prefer `{env}-overlay.json` merged with `config.json`, falling back to legacy `{env}-config.json` if needed - Updated `LabConfigPath` logic to cache merged configs in `.dreadgoad/cache/` and auto-invalidate on source change - Refactored CLI env listing, creation, and variant generation to use overlay system - Updated variant generator to preserve "password in description" for users during transformation - Enhanced `ansible/roles/ad/tasks/users.yml` to update user descriptions if changed **Removed:** - Deleted full per-environment lab config files (`dev-config.json`, `staging-config.json`, `test-config.json`) in favor of overlays - Removed legacy logic in env scaffolding that copied entire config files for each environment --- .gitignore | 2 + ad/GOAD-variant-1/data/config.json | 2 +- ad/GOAD-variant-1/data/dev-config.json | 936 ------------------------ ad/GOAD-variant-1/data/dev-overlay.json | 46 ++ ad/GOAD/data/dev-config.json | 681 ----------------- ad/GOAD/data/dev-overlay.json | 87 +++ ad/GOAD/data/staging-config.json | 691 ----------------- ad/GOAD/data/staging-overlay.json | 74 ++ ad/GOAD/data/test-config.json | 691 ----------------- ad/GOAD/data/test-overlay.json | 60 ++ ansible/roles/ad/tasks/users.yml | 12 +- cli/cmd/env_cmd.go | 63 +- cli/internal/config/config.go | 106 ++- cli/internal/jsonmerge/diff.go | 73 ++ cli/internal/jsonmerge/diff_test.go | 148 ++++ cli/internal/jsonmerge/merge.go | 56 ++ cli/internal/jsonmerge/merge_test.go | 159 ++++ cli/internal/labmap/labmap.go | 53 +- cli/internal/variant/generator.go | 22 +- cli/internal/variant/generator_test.go | 44 ++ docs/cli.md | 57 ++ docs/mkdocs/docs/developers/add_lab.md | 7 + docs/mkdocs/docs/provisioning.md | 4 +- 23 files changed, 1017 insertions(+), 3057 deletions(-) delete mode 100644 ad/GOAD-variant-1/data/dev-config.json create mode 100644 ad/GOAD-variant-1/data/dev-overlay.json delete mode 100644 ad/GOAD/data/dev-config.json create mode 100644 ad/GOAD/data/dev-overlay.json delete mode 100644 ad/GOAD/data/staging-config.json create mode 100644 ad/GOAD/data/staging-overlay.json delete mode 100644 ad/GOAD/data/test-config.json create mode 100644 ad/GOAD/data/test-overlay.json create mode 100644 cli/internal/jsonmerge/diff.go create mode 100644 cli/internal/jsonmerge/diff_test.go create mode 100644 cli/internal/jsonmerge/merge.go create mode 100644 cli/internal/jsonmerge/merge_test.go diff --git a/.gitignore b/.gitignore index 3b32b8be..6a1ce97b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ __pycache__/ # Build artifacts dreadgoad +.dreadgoad/ coverage.out ansible/roles/adcs_templates/files/ADCSTemplate.zip ansible/roles/vulns_adcs_templates/files/ADCSTemplate.zip @@ -35,6 +36,7 @@ ad/MINILAB ad/*/providers/*/ssh_keys/*id_rsa* ad/*/providers/*/ssh_keys/*.pub ad/*/providers/*/extensions/*.rb +.envrc # Terraform / Terragrunt .terraform/ diff --git a/ad/GOAD-variant-1/data/config.json b/ad/GOAD-variant-1/data/config.json index 78c4b6a4..45fe6813 100644 --- a/ad/GOAD-variant-1/data/config.json +++ b/ad/GOAD-variant-1/data/config.json @@ -622,7 +622,7 @@ "surname": "williams", "password": "JUHTgaxCdT", "city": "Denver", - "description": "Stephanie Williams", + "description": "Stephanie Williams (Password : JUHTgaxCdT)", "groups": [ "Systems" ], diff --git a/ad/GOAD-variant-1/data/dev-config.json b/ad/GOAD-variant-1/data/dev-config.json deleted file mode 100644 index cf02bb17..00000000 --- a/ad/GOAD-variant-1/data/dev-config.json +++ /dev/null @@ -1,936 +0,0 @@ -{ - "lab": { - "hosts": { - "dc01": { - "hostname": "guardian-app", - "type": "dc", - "local_admin_password": "m798_WN0mbDFKFv)>szo", - "domain": "deltasystems.local", - "path": "DC=deltasystems,DC=local", - "local_groups": { - "Administrators": [ - "deltasystems\\eric.flores", - "deltasystems\\stephanie2.hughes", - "deltasystems\\AdministrationSquad2" - ], - "Remote Desktop Users": [ - "deltasystems\\ServicesTeam", - "deltasystems\\ExecutiveUnit" - ] - }, - "scripts": [ - "sidhistory.ps1" - ], - "vulns": [ - "disable_firewall" - ], - "security": [ - "account_is_sensitive", - "audit_policy" - ], - "security_vars": { - "account_is_sensitive": { - "michelle": { - "account": "michelle.mitchell" - } - } - } - }, - "dc02": { - "hostname": "beacon", - "type": "dc", - "local_admin_password": "xYK7tDxi:+PSp(AS;>=%", - "domain": "hq.deltasystems.local", - "path": "DC=hq,DC=deltasystems,DC=local", - "local_groups": { - "Administrators": [ - "hq\\william.wood", - "hq\\anna.erics", - "hq\\catherine2.ramos" - ], - "Remote Desktop Users": [ - "hq\\Operations" - ] - }, - "scripts": [ - "asrep_roasting.ps1", - "constrained_delegation_use_any.ps1", - "constrained_delegation_kerb_only.ps1", - "ntlm_relay.ps1", - "responder.ps1", - "gpo_abuse.ps1", - "rdp_scheduler.ps1", - "unconstrained_delegation_user.ps1" - ], - "vulns": [ - "disable_firewall", - "directory", - "credentials", - "autologon", - "files", - "enable_llmnr", - "enable_nbt_ns", - "shares", - "anonymous_enum" - ], - "vulns_vars": { - "directory": { - "setup": "C:\\setup" - }, - "credentials": { - "TERMSRV/summit": { - "username": "hq\\catherine2.ramos", - "secret": "plyfvjuqn", - "runas": "hq\\catherine2.ramos", - "runas_password": "plyfvjuqn" - } - }, - "autologon": { - "catherine2.ramos": { - "username": "hq\\catherine2.ramos", - "password": "plyfvjuqn" - } - }, - "files": { - "rdp": { - "src": "dc02/bot_rdp.ps1", - "dest": "C:\\setup\\bot_rdp.ps1" - }, - "sysvol_fake_script": { - "src": "dc02/sysvol_scripts/script.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\script.ps1" - }, - "sysvol_secret": { - "src": "dc02/sysvol_scripts/secret.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\secret.ps1" - } - } - }, - "security": [ - "audit_policy" - ] - }, - "srv02": { - "hostname": "summit", - "type": "server", - "local_admin_password": "xYK7tDxi:+PSp(AS;>=%", - "domain": "hq.deltasystems.local", - "path": "DC=hq,DC=deltasystems,DC=local", - "use_laps": false, - "local_groups": { - "Administrators": [ - "hq\\brenda.lee" - ], - "Remote Desktop Users": [ - "hq\\Systems", - "hq\\LeadershipSquad", - "hq\\Operations" - ] - }, - "scripts": [], - "vulns": [ - "directory", - "disable_firewall", - "openshares", - "files", - "permissions" - ], - "vulns_vars": { - "directory": { - "shares": "C:\\shares", - "all": "C:\\shares\\all" - }, - "files": { - "website": { - "src": "srv02/wwwroot", - "dest": "C:\\inetpub\\" - }, - "letter_in_shares": { - "src": "srv02/all/pamela2.txt", - "dest": "C:\\shares\\all\\pamela2.txt" - } - }, - "permissions": { - "IIS_IUSRS_upload": { - "path": "C:\\inetpub\\wwwroot\\upload", - "user": "IIS_IUSRS", - "rights": "FullControl" - } - } - }, - "mssql": { - "sa_password": "6q_E@9Bk]^|LolaX9", - "svcaccount": "sql_svc", - "sysadmins": [ - "HQ\\christine.martin" - ], - "executeaslogin": { - "HQ\\stephanie.williams": "sa", - "HQ\\alexander.peterson": "HQ\\christine.martin" - }, - "executeasuser": { - "pamela2_master_dbo": { - "user": "HQ\\pamela2.rogers", - "db": "master", - "impersonate": "dbo" - }, - "pamela2_dbms_dbo": { - "user": "HQ\\pamela2.rogers", - "db": "msdb", - "impersonate": "dbo" - } - }, - "linked_servers": { - "TITAN": { - "data_src": "titan.vortexindustries.local", - "users_mapping": [ - { - "local_login": "HQ\\christine.martin", - "remote_login": "sa", - "remote_password": "ZX8HtJQcV!kom[5L]" - } - ] - } - } - } - }, - "dc03": { - "hostname": "beacon-app", - "type": "dc", - "local_admin_password": "^$1N+]GIm4s1smahoB_]", - "domain": "vortexindustries.local", - "path": "DC=vortexindustries,DC=local", - "local_groups": { - "Administrators": [ - "vortexind\\kenneth.carter" - ], - "Remote Desktop Users": [ - "vortexind\\OperationsStaff" - ] - }, - "scripts": [ - "asrep_roasting2.ps1" - ], - "vulns": [ - "ntlmdowngrade", - "disable_firewall" - ], - "security": [ - "audit_policy" - ] - }, - "srv03": { - "hostname": "titan", - "type": "server", - "local_admin_password": "^$1N+]GIm4s1smahoB_]", - "domain": "vortexindustries.local", - "path": "DC=vortexindustries,DC=local", - "use_laps": true, - "local_groups": { - "Administrators": [ - "vortexind\\pamela.clark" - ] - }, - "Remote Desktop Users": [ - "vortexind\\SupportGroup" - ], - "scripts": [], - "vulns": [ - "openshares", - "disable_firewall" - ], - "security": [ - "enable_run_as_ppl" - ], - "mssql": { - "sa_password": "ZX8HtJQcV!kom[5L]", - "svcaccount": "sql_svc", - "sysadmins": [ - "VORTEXIND\\pamela.clark" - ], - "executeaslogin": { - "VORTEXIND\\catherine.turner": "sa" - }, - "executeasuser": {}, - "linked_servers": { - "SUMMIT": { - "data_src": "summit.hq.deltasystems.local", - "users_mapping": [ - { - "local_login": "VORTEXIND\\pamela.clark", - "remote_login": "sa", - "remote_password": "6q_E@9Bk]^|LolaX9" - } - ] - } - } - } - } - }, - "domains": { - "vortexindustries.local": { - "dc": "dc03", - "domain_password": "^$1N+]GIm4s1smahoB_]", - "netbios_name": "VORTEXIND", - "ca_server": "Titan", - "trust": "deltasystems.local", - "laps_path": "OU=Laps,DC=vortexindustries,DC=local", - "organisation_units": {}, - "laps_readers": [ - "catherine.turner", - "SupportSquad" - ], - "groups": { - "universal": {}, - "global": { - "OperationsStaff": { - "managed_by": "charles.walker", - "path": "CN=Users,DC=vortexindustries,DC=local" - }, - "SupportGroup": { - "managed_by": "pamela.clark", - "path": "CN=Users,DC=vortexindustries,DC=local" - }, - "AdministrationGroup": { - "managed_by": "Administrator", - "path": "CN=Users,DC=vortexindustries,DC=local" - }, - "Services": { - "managed_by": "Administrator", - "path": "CN=Users,DC=vortexindustries,DC=local", - "members": [ - "VORTEXIND\\AdministrationGroup" - ] - }, - "Domain Admins": { - "managed_by": "Administrator", - "path": "CN=Users,DC=vortexindustries,DC=local", - "members": [ - "VORTEXIND\\Services" - ] - } - }, - "domainlocal": { - "AdministrationSquad": { - "managed_by": "kenneth.carter", - "path": "CN=Users,DC=vortexindustries,DC=local" - }, - "SupportSquad": { - "path": "CN=Users,DC=vortexindustries,DC=local" - } - } - }, - "multi_domain_groups_member": { - "AdministrationSquad": [ - "deltasystems.local\\christine2.martin2", - "vortexindustries.local\\kenneth.carter" - ], - "SupportSquad": [ - "deltasystems.local\\ServicesTeam" - ] - }, - "gmsa": { - "gmsa_account": { - "gMSA_Name": "gmsaFalcon", - "gMSA_FQDN": "gmsaFalcon.vortexindustries.local", - "gMSA_SPNs": [ - "HTTP/titan", - "HTTP/titan.vortexindustries.local" - ], - "gMSA_HostNames": [ - "titan" - ] - } - }, - "acls": { - "GenericAll_pamela.clark_charles.walker": { - "for": "pamela.clark", - "to": "charles.walker", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_supportsquad_catherine.turner": { - "for": "SupportSquad", - "to": "catherine.turner", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_pamela.clark_esc4": { - "for": "pamela.clark", - "to": "CN=ESC4,CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,DC=vortexindustries,DC=local", - "right": "GenericAll", - "inheritance": "None" - }, - "WriteProperty_charles.walker_catherine.turner": { - "for": "charles.walker", - "to": "catherine.turner", - "right": "WriteProperty", - "inheritance": "All" - }, - "GenericWrite_administrationsquad_titan$": { - "for": "AdministrationSquad", - "to": "titan$", - "right": "GenericWrite", - "inheritance": "None" - }, - "GenericAll_susan.white_pamela.clark": { - "for": "susan.white", - "to": "pamela.clark", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_gmsafalcon$_shirley.gonzalez": { - "for": "gmsaFalcon$", - "to": "shirley.gonzalez", - "right": "GenericAll", - "inheritance": "None" - } - }, - "users": { - "kenneth.carter": { - "firstname": "kenneth", - "surname": "carter", - "password": "Av^MO$q>t)=%", - "netbios_name": "HQ", - "trust": "", - "laps_path": "OU=Laps,DC=hq,DC=deltasystems,DC=local", - "organisation_units": {}, - "groups": { - "universal": {}, - "global": { - "Operations": { - "managed_by": "william.wood", - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "Systems": { - "managed_by": "brenda.lee", - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "LeadershipSquad": { - "managed_by": "brenda.lee", - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - } - }, - "domainlocal": { - "PlatformUnit": { - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - } - } - }, - "multi_domain_groups_member": {}, - "acls": { - "anonymous_rpc": { - "for": "NT AUTHORITY\\ANONYMOUS LOGON", - "to": "DC=Hq,DC=deltasystems,DC=local", - "right": "ReadProperty", - "inheritance": "All" - }, - "anonymous_rpc2": { - "for": "NT AUTHORITY\\ANONYMOUS LOGON", - "to": "DC=Hq,DC=deltasystems,DC=local", - "right": "GenericExecute", - "inheritance": "All" - } - }, - "users": { - "pamela2.rogers": { - "firstname": "pamela2", - "surname": "rogers", - "password": "heNvyj", - "city": "Dallas", - "description": "Pamela2 Rogers", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "william.wood": { - "firstname": "william", - "surname": "wood", - "password": "*x-Iz", - "city": "Phoenix", - "description": "William Wood", - "groups": [ - "Operations", - "Domain Admins" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "anna.erics": { - "firstname": "anna", - "surname": "erics", - "password": "uejpqnidxtnoehjdwbtsqaztl", - "city": "Phoenix", - "description": "Anna Erics", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "catherine2.ramos": { - "firstname": "catherine2", - "surname": "ramos", - "password": "plyfvjuqn", - "city": "Dallas", - "description": "Catherine2 Ramos", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "ryan.myers": { - "firstname": "ryan", - "surname": "myers", - "password": "si4q5iagz", - "city": "Dallas", - "description": "Ryan Myers", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local", - "spns": [ - "HTTP/eyrie.hq.deltasystems.local" - ] - }, - "alexander.peterson": { - "firstname": "alexander", - "surname": "peterson", - "password": "wlrucscdadzooz", - "city": "Dallas", - "description": "Alexander Peterson", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "laura.campbell": { - "firstname": "laura", - "surname": "campbell", - "password": "MTmya1uW0b", - "city": "Dallas", - "description": "Laura Campbell", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "emily.baker": { - "firstname": "emily", - "surname": "baker", - "password": "jqfay", - "city": "Dallas", - "description": "Emily Baker", - "groups": [ - "Operations" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "christine.martin": { - "firstname": "christine", - "surname": "martin", - "password": "ddlfwkwdemov", - "city": "Denver", - "description": "Christine Martin", - "groups": [ - "Operations", - "Systems" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local", - "spns": [ - "HTTP/thewall.hq.deltasystems.local" - ] - }, - "stephanie.williams": { - "firstname": "stephanie", - "surname": "williams", - "password": "JUHTgaxCdT", - "city": "Denver", - "description": "Stephanie Williams", - "groups": [ - "Systems" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "brenda.lee": { - "firstname": "brenda", - "surname": "lee", - "password": "60)XJ*11Sm", - "city": "Denver", - "description": "Brenda Lee", - "groups": [ - "Systems", - "LeadershipSquad" - ], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local" - }, - "sql_svc": { - "firstname": "sql", - "surname": "service", - "password": "g0JGPuQBYkLNtB60YJwNoclpn8FCyI", - "city": "-", - "description": "sql service", - "groups": [], - "path": "CN=Users,DC=Hq,DC=deltasystems,DC=local", - "spns": [ - "MSSQLSvc/summit.hq.deltasystems.local:1433", - "MSSQLSvc/summit.hq.deltasystems.local" - ] - } - } - }, - "deltasystems.local": { - "dc": "dc01", - "domain_password": "m798_WN0mbDFKFv)>szo", - "netbios_name": "DELTASYSTEMS", - "trust": "vortexindustries.local", - "laps_path": "OU=Laps,DC=deltasystems,DC=local", - "organisation_units": { - "Legal": { - "path": "DC=deltasystems,DC=local" - }, - "Europe": { - "path": "DC=deltasystems,DC=local" - }, - "APAC": { - "path": "DC=deltasystems,DC=local" - }, - "Southern": { - "path": "DC=deltasystems,DC=local" - }, - "Western": { - "path": "DC=deltasystems,DC=local" - }, - "HR": { - "path": "DC=deltasystems,DC=local" - }, - "Americas": { - "path": "DC=deltasystems,DC=local" - }, - "Corporate": { - "path": "DC=deltasystems,DC=local" - } - }, - "groups": { - "universal": {}, - "global": { - "Systems2": { - "managed_by": "brian.johnson", - "path": "OU=HR,DC=deltasystems,DC=local" - }, - "ExecutiveUnit": { - "managed_by": "eric.flores", - "path": "OU=Western,DC=deltasystems,DC=local" - }, - "ServicesTeam": { - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "Administration": { - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "Security": { - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "AdministrationSquad2": { - "path": "OU=Southern,DC=deltasystems,DC=local" - } - }, - "domainlocal": { - "InfrastructureTeam": { - "path": "CN=Users,DC=deltasystems,DC=local" - } - } - }, - "multi_domain_groups_member": { - "InfrastructureTeam": [ - "vortexindustries.local\\kenneth.carter" - ] - }, - "acls": { - "forcechangepassword_brian.johnson_george.parker": { - "for": "brian.johnson", - "to": "george.parker", - "right": "Ext-User-Force-Change-Password", - "inheritance": "None" - }, - "GenericWrite_george.parker_karen.moore": { - "for": "george.parker", - "to": "karen.moore", - "right": "GenericWrite", - "inheritance": "None" - }, - "Writedacl_karen.moore_christine2.martin2": { - "for": "karen.moore", - "to": "christine2.martin2", - "right": "WriteDacl", - "inheritance": "None" - }, - "self-self-membership-on-group_christine2.martin2_servicesteam": { - "for": "christine2.martin2", - "to": "ServicesTeam", - "right": "Ext-Self-Self-Membership", - "inheritance": "None" - }, - "addmember_servicesteam_administration": { - "for": "ServicesTeam", - "to": "Administration", - "right": "Ext-Write-Self-Membership", - "inheritance": "All" - }, - "write_administration_security": { - "for": "Administration", - "to": "Security", - "right": "WriteOwner", - "inheritance": "None" - }, - "GenericAll_security_charles2.parker2": { - "for": "Security", - "to": "charles2.parker2", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_charles2.parker2_guardian-app$": { - "for": "charles2.parker2", - "to": "guardian-app$", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_infrastructureteam_guardian-app$": { - "for": "InfrastructureTeam", - "to": "guardian-app$", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_pamela3.diaz_domain_admins": { - "for": "pamela3.diaz", - "to": "Domain Admins", - "right": "GenericAll", - "inheritance": "None" - }, - "GenericAll_pamela3.diaz_adminsdholder": { - "for": "pamela3.diaz", - "to": "CN=AdminSDHolder,CN=System,DC=deltasystems,DC=local", - "right": "GenericAll", - "inheritance": "None" - }, - "WriteDACL_michelle.mitchell_southern": { - "for": "michelle.mitchell", - "to": "OU=Southern,DC=deltasystems,DC=local", - "right": "WriteDacl", - "inheritance": "None" - } - }, - "users": { - "brian.johnson": { - "firstname": "brian", - "surname": "johnson", - "password": "f5ql8xzwbco69kd", - "city": "San Francisco", - "description": "Brian Johnson", - "groups": [ - "Systems2" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "george.parker": { - "firstname": "george", - "surname": "parker", - "password": "bpyhct", - "city": "Phoenix", - "description": "George Parker", - "groups": [ - "Systems2" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "stephanie2.hughes": { - "firstname": "stephanie2", - "surname": "hughes", - "password": "3jivwfkcxr", - "city": "Phoenix", - "description": "Stephanie2 Hughes", - "groups": [ - "Systems2", - "ExecutiveUnit", - "Domain Admins", - "ServicesTeam" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "christine2.martin2": { - "firstname": "christine2", - "surname": "martin2", - "password": "@U#7L^SKww", - "city": "Phoenix", - "description": "Christine2 Martin2", - "groups": [ - "Systems2" - ], - "path": "OU=HR,DC=deltasystems,DC=local" - }, - "eric.flores": { - "firstname": "eric", - "surname": "flores", - "password": "mcnkpmyufebebibtdmcc", - "city": "Phoenix", - "description": "Eric Flores", - "groups": [ - "ExecutiveUnit", - "Domain Admins", - "ServicesTeam", - "Protected Users" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "karen.moore": { - "firstname": "karen", - "surname": "moore", - "password": "zzseh2865o2", - "city": "Phoenix", - "description": "Karen Moore", - "groups": [ - "ExecutiveUnit", - "Systems2" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "michelle.mitchell": { - "firstname": "michelle", - "surname": "mitchell", - "password": "yuddrrlgxpv", - "city": "Phoenix", - "description": "Michelle Mitchell", - "groups": [ - "ExecutiveUnit", - "ServicesTeam" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "charles2.parker2": { - "firstname": "charles2", - "surname": "parker2", - "password": "ra4QyzbTFQD", - "city": "Phoenix", - "description": "Charles2 Parker2", - "groups": [ - "ExecutiveUnit", - "ServicesTeam" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "sharon.wilson": { - "firstname": "sharon", - "surname": "wilson", - "password": "<+p*d<,vg<*-hx", - "city": "Phoenix", - "description": "Sharon Wilson", - "groups": [ - "ServicesTeam" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "pamela3.diaz": { - "firstname": "pamela3", - "surname": "diaz", - "password": "6&BeB8*+M", - "city": "Phoenix", - "description": "Pamela3 Diaz", - "groups": [ - "ServicesTeam" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - }, - "deborah.edwards": { - "firstname": "deborah", - "surname": "edwards", - "password": "WFqrVsLcNEFirMwxV", - "city": "Phoenix", - "description": "Deborah Edwards", - "groups": [ - "ServicesTeam" - ], - "path": "OU=Southern,DC=deltasystems,DC=local" - } - } - } - } - } -} diff --git a/ad/GOAD-variant-1/data/dev-overlay.json b/ad/GOAD-variant-1/data/dev-overlay.json new file mode 100644 index 00000000..b8e139a3 --- /dev/null +++ b/ad/GOAD-variant-1/data/dev-overlay.json @@ -0,0 +1,46 @@ +{ + "lab": { + "domains": { + "vortexindustries.local": { + "groups": { + "global": { + "AdministrationGroup": { + "managed_by": "Administrator" + }, + "Domain Admins": { + "managed_by": "Administrator" + }, + "Services": { + "managed_by": "Administrator" + } + } + } + } + }, + "hosts": { + "dc02": { + "scripts": [ + "asrep_roasting.ps1", + "constrained_delegation_use_any.ps1", + "constrained_delegation_kerb_only.ps1", + "ntlm_relay.ps1", + "responder.ps1", + "gpo_abuse.ps1", + "rdp_scheduler.ps1", + "unconstrained_delegation_user.ps1" + ], + "vulns": [ + "disable_firewall", + "directory", + "credentials", + "autologon", + "files", + "enable_llmnr", + "enable_nbt_ns", + "shares", + "anonymous_enum" + ] + } + } + } +} diff --git a/ad/GOAD/data/dev-config.json b/ad/GOAD/data/dev-config.json deleted file mode 100644 index 773aba24..00000000 --- a/ad/GOAD/data/dev-config.json +++ /dev/null @@ -1,681 +0,0 @@ -{ -"lab" : { - "hosts" : { - "dc01" : { - "hostname" : "kingslanding", - "type" : "dc", - "local_admin_password": "qjQ!bcwXKU!3yBrDM2VU", - "domain" : "sevenkingdoms.local", - "path" : "DC=sevenkingdoms,DC=local", - "local_groups" : { - "Administrators" : [ - "sevenkingdoms\\robert.baratheon", - "sevenkingdoms\\cersei.lannister", - "sevenkingdoms\\DragonRider" - ], - "Remote Desktop Users" : [ - "sevenkingdoms\\Small Council", - "sevenkingdoms\\Baratheon" - ] - }, - "scripts" : ["sidhistory.ps1"], - "vulns" : ["disable_firewall"], - "security": ["account_is_sensitive", "audit_policy"], - "security_vars": { - "account_is_sensitive" : { "renly": {"account" : "renly.baratheon"} } - } - }, - "dc02" : { - "hostname" : "winterfell", - "type" : "dc", - "local_admin_password": "VExkHyfsKTW_HMNA7fQy", - "domain" : "north.sevenkingdoms.local", - "path" : "DC=north,DC=sevenkingdoms,DC=local", - "local_groups" : { - "Administrators" : [ - "north\\eddard.stark", - "north\\catelyn.stark", - "north\\robb.stark" - ], - "Remote Desktop Users" : [ - "north\\Stark" - ] - }, - "scripts" : [ - "asrep_roasting.ps1", - "constrained_delegation_use_any.ps1", - "constrained_delegation_kerb_only.ps1", - "ntlm_relay.ps1", - "responder.ps1", - "gpo_abuse.ps1", - "rdp_scheduler.ps1", - "unconstrained_delegation_user.ps1" - ], - "vulns" : ["disable_firewall","directory", "credentials", "autologon", "files", "enable_llmnr", "enable_nbt_ns", "shares", "anonymous_enum"], - "vulns_vars" : { - "directory": { - "setup": "C:\\setup" - }, - "credentials" : { - "TERMSRV/castelblack": { - "username" : "north\\robb.stark", - "secret" : "sexywolfy", - "runas" : "north\\robb.stark", - "runas_password" : "sexywolfy" - } - }, - "autologon" : { - "robb.stark" : { - "username" : "north\\robb.stark", - "password" : "sexywolfy" - } - }, - "files" : { - "rdp" : { - "src" : "dc02/bot_rdp.ps1", - "dest" : "C:\\setup\\bot_rdp.ps1" - }, - "sysvol_fake_script": { - "src" : "dc02/sysvol_scripts/script.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\script.ps1" - }, - "sysvol_secret": { - "src" : "dc02/sysvol_scripts/secret.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\secret.ps1" - } - } - }, - "security": ["audit_policy"] - }, - "srv02" : { - "hostname" : "castelblack", - "type" : "server", - "local_admin_password": "VExkHyfsKTW_HMNA7fQy", - "domain" : "north.sevenkingdoms.local", - "path" : "DC=north,DC=sevenkingdoms,DC=local", - "use_laps": false, - "local_groups" : { - "Administrators" : [ - "north\\jeor.mormont" - ], - "Remote Desktop Users" : [ - "north\\Night Watch", - "north\\Mormont", - "north\\Stark" - ] - }, - "scripts" : [], - "vulns" : ["directory", "disable_firewall", "openshares", "files", "permissions"], - "vulns_vars" : { - "directory": { - "shares": "C:\\shares", - "all": "C:\\shares\\all" - }, - "files" : { - "website" : { - "src" : "srv02/wwwroot", - "dest" : "C:\\inetpub\\" - }, - "letter_in_shares": { - "src" : "srv02/all/arya.txt", - "dest": "C:\\shares\\all\\arya.txt" - } - }, - "permissions" : { - "IIS_IUSRS_upload": { - "path" : "C:\\inetpub\\wwwroot\\upload", - "user" : "IIS_IUSRS", - "rights" : "FullControl" - } - } - }, - "mssql":{ - "sa_password": "Sup1_sa_P@ssw0rd!", - "svcaccount" : "sql_svc", - "sysadmins" : [ - "NORTH\\jon.snow" - ], - "executeaslogin" : { - "NORTH\\samwell.tarly" : "sa", - "NORTH\\brandon.stark" : "NORTH\\jon.snow" - }, - "executeasuser" : { - "arya_master_dbo": { - "user": "NORTH\\arya.stark", - "db" : "master", - "impersonate" : "dbo" - }, - "arya_dbms_dbo": { - "user": "NORTH\\arya.stark", - "db" : "msdb", - "impersonate" : "dbo" - } - }, - "linked_servers": { - "BRAAVOS" : { - "data_src": "braavos.essos.local", - "users_mapping": [ - {"local_login": "NORTH\\jon.snow","remote_login": "sa", "remote_password": "sa_P@ssw0rd!Ess0s"} - ] - } - } - } - }, - "dc03" : { - "hostname" : "meereen", - "type" : "dc", - "local_admin_password": "M!BbXzL48D9mH9dQzp*e", - "domain" : "essos.local", - "path" : "DC=essos,DC=local", - "local_groups" : { - "Administrators" : [ - "essos\\daenerys.targaryen" - ], - "Remote Desktop Users" : [ - "essos\\Targaryen" - ] - }, - "scripts" : ["asrep_roasting2.ps1"], - "vulns" : ["ntlmdowngrade", "disable_firewall"], - "security": ["audit_policy"] - }, - "srv03" : { - "hostname" : "braavos", - "type" : "server", - "local_admin_password": "M!BbXzL48D9mH9dQzp*e", - "domain" : "essos.local", - "path" : "DC=essos,DC=local", - "use_laps": true, - "local_groups" : { - "Administrators" : [ - "essos\\khal.drogo" - ] - }, - "Remote Desktop Users" : [ - "essos\\Dothraki" - ], - "scripts" : [], - "vulns" : ["openshares","disable_firewall"], - "security": ["enable_run_as_ppl"], - "mssql":{ - "sa_password": "sa_P@ssw0rd!Ess0s", - "svcaccount" : "sql_svc", - "sysadmins" : [ - "ESSOS\\khal.drogo" - ], - "executeaslogin" : { - "ESSOS\\jorah.mormont" : "sa" - }, - "executeasuser" : {}, - "linked_servers": { - "CASTELBLACK" : { - "data_src": "castelblack.north.sevenkingdoms.local", - "users_mapping": [ - {"local_login": "ESSOS\\khal.drogo","remote_login": "sa", "remote_password": "Sup1_sa_P@ssw0rd!"} - ] - } - } - } - } - }, - "domains" : { - "essos.local" : { - "dc": "dc03", - "domain_password" : "M!BbXzL48D9mH9dQzp*e", - "netbios_name": "ESSOS", - "ca_server": "Braavos", - "trust" : "sevenkingdoms.local", - "laps_path": "OU=Laps,DC=essos,DC=local", - "organisation_units" : { - }, - "laps_readers": [ - "jorah.mormont", - "Spys" - ], - "groups" : { - "universal" : {}, - "global" : { - "Targaryen" : { - "managed_by" : "viserys.targaryen", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Dothraki" : { - "managed_by" : "khal.drogo", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Dragons":{ - "managed_by" : "Administrator", - "path" : "CN=Users,DC=essos,DC=local" - }, - "QueenProtector":{ - "managed_by" : "Administrator", - "path" : "CN=Users,DC=essos,DC=local", - "members" : ["ESSOS\\Dragons"] - }, - "Domain Admins":{ - "managed_by" : "Administrator", - "path" : "CN=Users,DC=essos,DC=local", - "members" : ["ESSOS\\QueenProtector"] - } - }, - "domainlocal" : { - "DragonsFriends" : { - "managed_by" : "daenerys.targaryen", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Spys" : { - "path" : "CN=Users,DC=essos,DC=local" - } - } - }, - "multi_domain_groups_member" : { - "DragonsFriends" : [ - "sevenkingdoms.local\\tyron.lannister", - "essos.local\\daenerys.targaryen" - ], - "Spys" : [ - "sevenkingdoms.local\\Small Council" - ] - }, - "gmsa" : { - "gmsa_account": { - "gMSA_Name" : "gmsaDragon", - "gMSA_FQDN" : "gmsaDragon.essos.local", - "gMSA_SPNs" : ["HTTP/braavos", "HTTP/braavos.essos.local"], - "gMSA_HostNames" : ["braavos"] - } - }, - "acls" : { - "GenericAll_khal_viserys" : {"for": "khal.drogo", "to": "viserys.targaryen", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_spy_jorah" : {"for": "Spys", "to": "jorah.mormont", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_khal_esc4" : {"for": "khal.drogo", "to": "CN=ESC4,CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,DC=essos,DC=local", "right": "GenericAll", "inheritance": "None"}, - "WriteProperty_petyer_domadmin" : {"for": "viserys.targaryen", "to": "jorah.mormont", "right": "WriteProperty", "inheritance": "All"}, - "GenericWrite_DragonsFriends_braavos" : {"for": "DragonsFriends", "to": "braavos$", "right": "GenericWrite", "inheritance": "None"}, - "GenericAll_missandei_khal" : {"for": "missandei", "to": "khal.drogo", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_gmsaDragon_drogo" : {"for": "gmsaDragon$", "to": "drogon", "right": "GenericAll", "inheritance": "None"} - }, - "users" : { - "daenerys.targaryen" : { - "firstname" : "daenerys", - "surname" : "targaryen", - "password" : "BurnThemAll!", - "city" : "-", - "description" : "Darnerys Targaryen", - "groups" : ["Targaryen", "Domain Admins"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "viserys.targaryen" : { - "firstname" : "viserys", - "surname" : "targaryen", - "password" : "GoldCrown", - "city" : "-", - "description" : "Viserys Targaryen", - "groups" : ["Targaryen"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "khal.drogo" : { - "firstname" : "khal", - "surname" : "drogo", - "password" : "horse", - "city" : "-", - "description" : "Khal Drogo", - "groups" : ["Dothraki"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "jorah.mormont" : { - "firstname" : "jorah", - "surname" : "mormont", - "password" : "H0nnor!", - "city" : "-", - "description" : "Jorah Mormont", - "groups" : ["Targaryen"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "missandei" : { - "firstname" : "missandei", - "surname" : "-", - "password" : "fr3edom", - "city" : "-", - "description" : "missandei", - "groups" : [], - "path" : "CN=Users,DC=essos,DC=local" - }, - "drogon" : { - "firstname" : "drogon", - "surname" : "-", - "password" : "Dracarys", - "city" : "-", - "description" : "drogon", - "groups" : ["Dragons"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "sql_svc" : { - "firstname" : "sql", - "surname" : "service", - "password" : "YouWillNotKerboroast1ngMeeeeee", - "city" : "-", - "description" : "sql service", - "groups" : [], - "path" : "CN=Users,DC=essos,DC=local", - "spns" : ["MSSQLSvc/braavos.essos.local:1433","MSSQLSvc/braavos.essos.local"] - } - } - }, - "north.sevenkingdoms.local" : { - "dc": "dc02", - "domain_password" : "VExkHyfsKTW_HMNA7fQy", - "netbios_name": "NORTH", - "trust" : "", - "laps_path": "OU=Laps,DC=north,DC=sevenkingdoms,DC=local", - "organisation_units" : { - }, - "groups" : { - "universal" : {}, - "global" : { - "Stark" : { - "managed_by" : "eddard.stark", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "Night Watch" : { - "managed_by" : "jeor.mormont", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "Mormont" : { - "managed_by" : "jeor.mormont", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - } - }, - "domainlocal" : { - "AcrossTheSea" : { - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - } - } - }, - "multi_domain_groups_member" : {}, - "acls" : { - "anonymous_rpc" : {"for": "NT AUTHORITY\\ANONYMOUS LOGON", "to": "DC=North,DC=sevenkingdoms,DC=local", "right": "ReadProperty", "inheritance": "All"}, - "anonymous_rpc2" : {"for": "NT AUTHORITY\\ANONYMOUS LOGON", "to": "DC=North,DC=sevenkingdoms,DC=local", "right": "GenericExecute", "inheritance": "All"} - }, - "users" : { - "arya.stark" : { - "firstname" : "Arya", - "surname" : "Stark", - "password" : "Needle", - "city" : "Winterfell", - "description" : "Arya Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "eddard.stark" : { - "firstname" : "Eddard", - "surname" : "Stark", - "password" : "FightP3aceAndHonor!", - "city" : "King's Landing", - "description" : "Eddard Stark", - "groups" : ["Stark", "Domain Admins"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "catelyn.stark" : { - "firstname" : "Catelyn", - "surname" : "Stark", - "password" : "robbsansabradonaryarickon", - "city" : "King's Landing", - "description" : "Catelyn Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "robb.stark" : { - "firstname" : "Robb", - "surname" : "Stark", - "password" : "sexywolfy", - "city" : "Winterfell", - "description" : "Robb Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "sansa.stark" : { - "firstname" : "Sansa", - "surname" : "Stark", - "password" : "345ertdfg", - "city" : "Winterfell", - "description" : "Sansa Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["HTTP/eyrie.north.sevenkingdoms.local"] - }, - "brandon.stark" : { - "firstname" : "Brandon", - "surname" : "Stark", - "password" : "iseedeadpeople", - "city" : "Winterfell", - "description" : "Brandon Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "rickon.stark" : { - "firstname" : "Rickon", - "surname" : "Stark", - "password" : "Winter2022", - "city" : "Winterfell", - "description" : "Rickon Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "hodor" : { - "firstname" : "hodor", - "surname" : "hodor", - "password" : "hodor", - "city" : "Winterfell", - "description" : "Brainless Giant", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "jon.snow" : { - "firstname" : "Jon", - "surname" : "Snow", - "password" : "iknownothing", - "city" : "Castel Black", - "description" : "Jon Snow", - "groups" : ["Stark", "Night Watch"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["HTTP/thewall.north.sevenkingdoms.local"] - }, - "samwell.tarly" : { - "firstname" : "Samwell", - "surname" : "Tarly", - "password" : "Heartsbane", - "city" : "Castel Black", - "description" : "Samwell Tarly (Password : Heartsbane)", - "groups" : ["Night Watch"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "jeor.mormont" : { - "firstname" : "Jeor", - "surname" : "Mormont", - "password" : "_L0ngCl@w_", - "city" : "Castel Black", - "description" : "Jeor Mormont", - "groups" : ["Night Watch", "Mormont"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "sql_svc" : { - "firstname" : "sql", - "surname" : "service", - "password" : "YouWillNotKerboroast1ngMeeeeee", - "city" : "-", - "description" : "sql service", - "groups" : [], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["MSSQLSvc/castelblack.north.sevenkingdoms.local:1433","MSSQLSvc/castelblack.north.sevenkingdoms.local"] - } - } - }, - "sevenkingdoms.local" : { - "dc": "dc01", - "domain_password" : "qjQ!bcwXKU!3yBrDM2VU", - "netbios_name": "SEVENKINGDOMS", - "trust" : "essos.local", - "laps_path": "OU=Laps,DC=sevenkingdoms,DC=local", - "organisation_units" : { - "Vale" : { "path" : "DC=sevenkingdoms,DC=local"}, - "IronIslands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Riverlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Crownlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Stormlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Westerlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Reach" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Dorne" : { "path" : "DC=sevenkingdoms,DC=local"} - }, - "groups" : { - "universal" : {}, - "global" : { - "Lannister" : { - "managed_by" : "tywin.lannister", - "path" : "OU=Westerlands,DC=sevenkingdoms,DC=local" - }, - "Baratheon" : { - "managed_by" : "robert.baratheon", - "path" : "OU=Stormlands,DC=sevenkingdoms,DC=local" - }, - "Small Council" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "DragonStone" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "KingsGuard" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "DragonRider" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - } - }, - "domainlocal" : { - "AcrossTheNarrowSea" : { - "path" : "CN=Users,DC=sevenkingdoms,DC=local" - } - } - }, - "multi_domain_groups_member" : { - "AcrossTheNarrowSea" : [ - "essos.local\\daenerys.targaryen" - ] - }, - "acls" : { - "forcechangepassword_tywin_jaime" : {"for": "tywin.lannister", "to": "jaime.lannister", "right": "Ext-User-Force-Change-Password", "inheritance": "None"}, - "GenericWrite_on_user_jaimie_joffrey" : {"for": "jaime.lannister", "to": "joffrey.baratheon", "right": "GenericWrite", "inheritance": "None"}, - "Writedacl_joffrey_tyron" : {"for": "joffrey.baratheon", "to": "tyron.lannister", "right": "WriteDacl", "inheritance": "None"}, - "self-self-membership-on-group_tyron_small_council" : {"for": "tyron.lannister", "to": "Small Council", "right": "Ext-Self-Self-Membership", "inheritance": "None"}, - "addmember_smallcouncil_DragonStone" : {"for": "Small Council", "to": "DragonStone", "right": "Ext-Write-Self-Membership", "inheritance": "All"}, - "write_owner_dragonstone_kingsguard" : {"for": "DragonStone", "to": "KingsGuard", "right": "WriteOwner", "inheritance": "None"}, - "GenericAll_kingsguard_stanis" : {"for": "KingsGuard", "to": "stannis.baratheon", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_stanis_dc" : {"for": "stannis.baratheon", "to": "kingslanding$", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_group_acrrosdom_dc" : {"for": "AcrossTheNarrowSea", "to": "kingslanding$", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_varys_domadmin" : {"for": "lord.varys", "to": "Domain Admins", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_varys_domadmin_holder" : {"for": "lord.varys", "to": "CN=AdminSDHolder,CN=System,DC=sevenkingdoms,DC=local", "right": "GenericAll", "inheritance": "None"}, - "WriteDACL_renly_Crownlands" : {"for": "renly.baratheon", "to": "OU=Crownlands,DC=sevenkingdoms,DC=local", "right": "WriteDacl", "inheritance": "None"} - }, - "users" : { - "tywin.lannister" : { - "firstname" : "Tywin", - "surname" : "Lanister", - "password" : "powerkingftw135", - "city" : "Casterly Rock", - "description" : "Tywin Lanister", - "groups" : ["Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "jaime.lannister" : { - "firstname" : "Jaime", - "surname" : "Lanister", - "password" : "cersei", - "city" : "King's Landing", - "description" : "Jaime Lanister", - "groups" : ["Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "cersei.lannister" : { - "firstname" : "Cersei", - "surname" : "Lanister", - "password" : "il0vejaime", - "city" : "King's Landing", - "description" : "Cersei Lanister", - "groups" : ["Lannister","Baratheon","Domain Admins","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "tyron.lannister" : { - "firstname" : "Tyron", - "surname" : "Lanister", - "password" : "Alc00L&S3x", - "city" : "King's Landing", - "description" : "Tyron Lanister", - "groups" : ["Lannister"], - "path" : "OU=Westerlands,DC=sevenkingdoms,DC=local" - }, - "robert.baratheon" : { - "firstname" : "Robert", - "surname" : "Baratheon", - "password" : "iamthekingoftheworld", - "city" : "King's Landing", - "description" : "Robert Lanister", - "groups" : ["Baratheon","Domain Admins","Small Council","Protected Users"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "joffrey.baratheon" : { - "firstname" : "Joffrey", - "surname" : "Baratheon", - "password" : "1killerlion", - "city" : "King's Landing", - "description" : "Joffrey Baratheon", - "groups" : ["Baratheon","Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "renly.baratheon" : { - "firstname" : "Renly", - "surname" : "Baratheon", - "password" : "lorastyrell", - "city" : "King's Landing", - "description" : "Renly Baratheon", - "groups" : ["Baratheon","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "stannis.baratheon" : { - "firstname" : "Stannis", - "surname" : "Baratheon", - "password" : "Drag0nst0ne", - "city" : "King's Landing", - "description" : "Stannis Baratheon", - "groups" : ["Baratheon","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "petyer.baelish" : { - "firstname" : "Petyer", - "surname" : "Baelish", - "password" : "@littlefinger@", - "city" : "King's Landing", - "description" : "Petyer Baelish", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "lord.varys" : { - "firstname" : "Lord", - "surname" : "Varys", - "password" : "_W1sper_$", - "city" : "King's Landing", - "description" : "Lord Varys", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "maester.pycelle" : { - "firstname" : "Maester", - "surname" : "Pycelle", - "password" : "MaesterOfMaesters", - "city" : "King's Landing", - "description" : "Maester Pycelle", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - } - } - } - } -}} diff --git a/ad/GOAD/data/dev-overlay.json b/ad/GOAD/data/dev-overlay.json new file mode 100644 index 00000000..803df15d --- /dev/null +++ b/ad/GOAD/data/dev-overlay.json @@ -0,0 +1,87 @@ +{ + "lab": { + "domains": { + "essos.local": { + "acls": { + "GenericWrite_missandei_viserys": null + }, + "groups": { + "global": { + "Domain Admins": { + "managed_by": "Administrator" + }, + "Dragons": { + "managed_by": "Administrator" + }, + "QueenProtector": { + "managed_by": "Administrator" + } + }, + "universal": { + "greatmaster": null + } + } + } + }, + "hosts": { + "dc01": { + "vulns": [ + "disable_firewall" + ] + }, + "dc02": { + "scripts": [ + "asrep_roasting.ps1", + "constrained_delegation_use_any.ps1", + "constrained_delegation_kerb_only.ps1", + "ntlm_relay.ps1", + "responder.ps1", + "gpo_abuse.ps1", + "rdp_scheduler.ps1", + "unconstrained_delegation_user.ps1" + ], + "vulns": [ + "disable_firewall", + "directory", + "credentials", + "autologon", + "files", + "enable_llmnr", + "enable_nbt_ns", + "shares", + "anonymous_enum" + ] + }, + "dc03": { + "local_groups": { + "Administrators": [ + "essos\\daenerys.targaryen" + ] + }, + "vulns": [ + "ntlmdowngrade", + "disable_firewall" + ], + "vulns_vars": null + }, + "srv02": { + "vulns": [ + "directory", + "disable_firewall", + "openshares", + "files", + "permissions" + ], + "vulns_vars": { + "shares": null + } + }, + "srv03": { + "vulns": [ + "openshares", + "disable_firewall" + ] + } + } + } +} diff --git a/ad/GOAD/data/staging-config.json b/ad/GOAD/data/staging-config.json deleted file mode 100644 index 44ac2522..00000000 --- a/ad/GOAD/data/staging-config.json +++ /dev/null @@ -1,691 +0,0 @@ -{ -"lab" : { - "hosts" : { - "dc01" : { - "hostname" : "kingslanding", - "type" : "dc", - "local_admin_password": "ykRXQ@rWNV4znesz-h!c", - "domain" : "sevenkingdoms.local", - "path" : "DC=sevenkingdoms,DC=local", - "local_groups" : { - "Administrators" : [ - "sevenkingdoms\\robert.baratheon", - "sevenkingdoms\\cersei.lannister", - "sevenkingdoms\\DragonRider" - ], - "Remote Desktop Users" : [ - "sevenkingdoms\\Small Council", - "sevenkingdoms\\Baratheon" - ] - }, - "scripts" : ["sidhistory.ps1"], - "vulns" : ["disable_firewall"], - "security": ["account_is_sensitive", "audit_policy"], - "security_vars": { - "account_is_sensitive" : { "renly": {"account" : "renly.baratheon"} } - } - }, - "dc02" : { - "hostname" : "winterfell", - "type" : "dc", - "local_admin_password": "moydNed_wEKuP8KN6rUx", - "domain" : "north.sevenkingdoms.local", - "path" : "DC=north,DC=sevenkingdoms,DC=local", - "local_groups" : { - "Administrators" : [ - "north\\eddard.stark", - "north\\catelyn.stark", - "north\\robb.stark" - ], - "Remote Desktop Users" : [ - "north\\Stark" - ] - }, - "scripts" : [ - "asrep_roasting.ps1", - "constrained_delegation_use_any.ps1", - "constrained_delegation_kerb_only.ps1", - "ntlm_relay.ps1", - "responder.ps1", - "gpo_abuse.ps1", - "rdp_scheduler.ps1", - "unconstrained_delegation_user.ps1" - ], - "vulns" : ["disable_firewall","directory", "credentials", "autologon", "files", "enable_llmnr", "enable_nbt_ns", "anonymous_enum"], - "vulns_vars" : { - "directory": { - "setup": "C:\\setup" - }, - "credentials" : { - "TERMSRV/castelblack": { - "username" : "north\\robb.stark", - "secret" : "sexywolfy", - "runas" : "north\\robb.stark", - "runas_password" : "sexywolfy" - } - }, - "autologon" : { - "robb.stark" : { - "username" : "north\\robb.stark", - "password" : "sexywolfy" - } - }, - "files" : { - "rdp" : { - "src" : "dc02/bot_rdp.ps1", - "dest" : "C:\\setup\\bot_rdp.ps1" - }, - "sysvol_fake_script": { - "src" : "dc02/sysvol_scripts/script.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\script.ps1" - }, - "sysvol_secret": { - "src" : "dc02/sysvol_scripts/secret.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\secret.ps1" - } - } - }, - "security": ["audit_policy"] - }, - "srv02" : { - "hostname" : "castelblack", - "type" : "server", - "local_admin_password": "moydNed_wEKuP8KN6rUx", - "domain" : "north.sevenkingdoms.local", - "path" : "DC=north,DC=sevenkingdoms,DC=local", - "use_laps": false, - "local_groups" : { - "Administrators" : [ - "north\\jeor.mormont" - ], - "Remote Desktop Users" : [ - "north\\Night Watch", - "north\\Mormont", - "north\\Stark" - ] - }, - "scripts" : [], - "vulns" : ["directory", "disable_firewall", "openshares", "shares", "files", "permissions"], - "vulns_vars" : { - "directory": { - "shares": "C:\\shares", - "all": "C:\\shares\\all" - }, - "files" : { - "website" : { - "src" : "srv02/wwwroot", - "dest" : "C:\\inetpub\\" - }, - "letter_in_shares": { - "src" : "srv02/all/arya.txt", - "dest": "C:\\shares\\all\\arya.txt" - } - }, - "permissions" : { - "IIS_IUSRS_upload": { - "path" : "C:\\inetpub\\wwwroot\\upload", - "user" : "IIS_IUSRS", - "rights" : "FullControl" - } - }, - "shares" : { - "thewall" : { - "path" : "C:\\thewall", - "list" : "yes", - "full" : "NORTH\\Stark", - "change" : "NORTH\\jon.snow,NORTH\\samwell.tarly", - "read" : "Users" - } - } - }, - "mssql":{ - "sa_password": "Sup1_sa_P@ssw0rd!", - "svcaccount" : "sql_svc", - "sysadmins" : [ - "NORTH\\jon.snow" - ], - "executeaslogin" : { - "NORTH\\samwell.tarly" : "sa", - "NORTH\\brandon.stark" : "NORTH\\jon.snow" - }, - "executeasuser" : { - "arya_master_dbo": { - "user": "NORTH\\arya.stark", - "db" : "master", - "impersonate" : "dbo" - }, - "arya_dbms_dbo": { - "user": "NORTH\\arya.stark", - "db" : "msdb", - "impersonate" : "dbo" - } - }, - "linked_servers": { - "BRAAVOS" : { - "data_src": "braavos.essos.local", - "users_mapping": [ - {"local_login": "NORTH\\jon.snow","remote_login": "sa", "remote_password": "sa_P@ssw0rd!Ess0s"} - ] - } - } - } - }, - "dc03" : { - "hostname" : "meereen", - "type" : "dc", - "local_admin_password": "-cwuyGW494yZnC_M8wLN", - "domain" : "essos.local", - "path" : "DC=essos,DC=local", - "local_groups" : { - "Administrators" : [ - "essos\\daenerys.targaryen" - ], - "Remote Desktop Users" : [ - "essos\\Targaryen" - ] - }, - "scripts" : ["asrep_roasting2.ps1"], - "vulns" : ["ntlmdowngrade", "disable_firewall"], - "security": ["audit_policy"] - }, - "srv03" : { - "hostname" : "braavos", - "type" : "server", - "local_admin_password": "-cwuyGW494yZnC_M8wLN", - "domain" : "essos.local", - "path" : "DC=essos,DC=local", - "use_laps": true, - "local_groups" : { - "Administrators" : [ - "essos\\khal.drogo" - ] - }, - "Remote Desktop Users" : [ - "essos\\Dothraki" - ], - "scripts" : [], - "vulns" : ["openshares","disable_firewall"], - "security": ["enable_run_as_ppl"], - "mssql":{ - "sa_password": "sa_P@ssw0rd!Ess0s", - "svcaccount" : "sql_svc", - "sysadmins" : [ - "ESSOS\\khal.drogo" - ], - "executeaslogin" : { - "ESSOS\\jorah.mormont" : "sa" - }, - "executeasuser" : {}, - "linked_servers": { - "CASTELBLACK" : { - "data_src": "castelblack.north.sevenkingdoms.local", - "users_mapping": [ - {"local_login": "ESSOS\\khal.drogo","remote_login": "sa", "remote_password": "Sup1_sa_P@ssw0rd!"} - ] - } - } - } - } - }, - "domains" : { - "essos.local" : { - "dc": "dc03", - "domain_password" : "-cwuyGW494yZnC_M8wLN", - "netbios_name": "ESSOS", - "ca_server": "Braavos", - "trust" : "sevenkingdoms.local", - "laps_path": "OU=Laps,DC=essos,DC=local", - "organisation_units" : { - }, - "laps_readers": [ - "jorah.mormont", - "Spys" - ], - "groups" : { - "universal" : {}, - "global" : { - "Targaryen" : { - "managed_by" : "viserys.targaryen", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Dothraki" : { - "managed_by" : "khal.drogo", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Dragons":{ - "managed_by" : "goadmin", - "path" : "CN=Users,DC=essos,DC=local" - }, - "QueenProtector":{ - "managed_by" : "goadmin", - "path" : "CN=Users,DC=essos,DC=local", - "members" : ["ESSOS\\Dragons"] - }, - "Domain Admins":{ - "managed_by" : "goadmin", - "path" : "CN=Users,DC=essos,DC=local", - "members" : ["ESSOS\\QueenProtector"] - } - }, - "domainlocal" : { - "DragonsFriends" : { - "managed_by" : "daenerys.targaryen", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Spys" : { - "path" : "CN=Users,DC=essos,DC=local" - } - } - }, - "multi_domain_groups_member" : { - "DragonsFriends" : [ - "sevenkingdoms.local\\tyron.lannister", - "essos.local\\daenerys.targaryen" - ], - "Spys" : [ - "sevenkingdoms.local\\Small Council" - ] - }, - "gmsa" : { - "gmsa_account": { - "gMSA_Name" : "gmsaDragon", - "gMSA_FQDN" : "gmsaDragon.essos.local", - "gMSA_SPNs" : ["HTTP/braavos", "HTTP/braavos.essos.local"], - "gMSA_HostNames" : ["braavos"] - } - }, - "acls" : { - "GenericAll_khal_viserys" : {"for": "khal.drogo", "to": "viserys.targaryen", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_spy_jorah" : {"for": "Spys", "to": "jorah.mormont", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_khal_esc4" : {"for": "khal.drogo", "to": "CN=ESC4,CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,DC=essos,DC=local", "right": "GenericAll", "inheritance": "None"}, - "WriteProperty_petyer_domadmin" : {"for": "viserys.targaryen", "to": "jorah.mormont", "right": "WriteProperty", "inheritance": "All"}, - "GenericWrite_DragonsFriends_braavos" : {"for": "DragonsFriends", "to": "braavos$", "right": "GenericWrite", "inheritance": "None"}, - "GenericAll_missandei_khal" : {"for": "missandei", "to": "khal.drogo", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_gmsaDragon_drogo" : {"for": "gmsaDragon$", "to": "drogon", "right": "GenericAll", "inheritance": "None"}, - "GenericWrite_missandei_viserys" : {"for": "missandei", "to": "viserys.targaryen", "right": "GenericWrite", "inheritance": "None"} - }, - "users" : { - "daenerys.targaryen" : { - "firstname" : "daenerys", - "surname" : "targaryen", - "password" : "BurnThemAll!", - "city" : "-", - "description" : "Darnerys Targaryen", - "groups" : ["Targaryen", "Domain Admins"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "viserys.targaryen" : { - "firstname" : "viserys", - "surname" : "targaryen", - "password" : "GoldCrown", - "city" : "-", - "description" : "Viserys Targaryen", - "groups" : ["Targaryen"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "khal.drogo" : { - "firstname" : "khal", - "surname" : "drogo", - "password" : "horse", - "city" : "-", - "description" : "Khal Drogo", - "groups" : ["Dothraki"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "jorah.mormont" : { - "firstname" : "jorah", - "surname" : "mormont", - "password" : "H0nnor!", - "city" : "-", - "description" : "Jorah Mormont", - "groups" : ["Targaryen"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "missandei" : { - "firstname" : "missandei", - "surname" : "-", - "password" : "fr3edom", - "city" : "-", - "description" : "missandei", - "groups" : [], - "path" : "CN=Users,DC=essos,DC=local" - }, - "drogon" : { - "firstname" : "drogon", - "surname" : "-", - "password" : "Dracarys", - "city" : "-", - "description" : "drogon", - "groups" : ["Dragons"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "sql_svc" : { - "firstname" : "sql", - "surname" : "service", - "password" : "YouWillNotKerboroast1ngMeeeeee", - "city" : "-", - "description" : "sql service", - "groups" : [], - "path" : "CN=Users,DC=essos,DC=local", - "spns" : ["MSSQLSvc/braavos.essos.local:1433","MSSQLSvc/braavos.essos.local"] - } - } - }, - "north.sevenkingdoms.local" : { - "dc": "dc02", - "domain_password" : "moydNed_wEKuP8KN6rUx", - "netbios_name": "NORTH", - "trust" : "", - "laps_path": "OU=Laps,DC=north,DC=sevenkingdoms,DC=local", - "organisation_units" : { - }, - "groups" : { - "universal" : {}, - "global" : { - "Stark" : { - "managed_by" : "eddard.stark", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "Night Watch" : { - "managed_by" : "jeor.mormont", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "Mormont" : { - "managed_by" : "jeor.mormont", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - } - }, - "domainlocal" : { - "AcrossTheSea" : { - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - } - } - }, - "multi_domain_groups_member" : {}, - "acls" : { - "anonymous_rpc" : {"for": "NT AUTHORITY\\ANONYMOUS LOGON", "to": "DC=North,DC=sevenkingdoms,DC=local", "right": "ReadProperty", "inheritance": "All"}, - "anonymous_rpc2" : {"for": "NT AUTHORITY\\ANONYMOUS LOGON", "to": "DC=North,DC=sevenkingdoms,DC=local", "right": "GenericExecute", "inheritance": "All"} - }, - "users" : { - "arya.stark" : { - "firstname" : "Arya", - "surname" : "Stark", - "password" : "Needle", - "city" : "Winterfell", - "description" : "Arya Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "eddard.stark" : { - "firstname" : "Eddard", - "surname" : "Stark", - "password" : "FightP3aceAndHonor!", - "city" : "King's Landing", - "description" : "Eddard Stark", - "groups" : ["Stark", "Domain Admins"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "catelyn.stark" : { - "firstname" : "Catelyn", - "surname" : "Stark", - "password" : "robbsansabradonaryarickon", - "city" : "King's Landing", - "description" : "Catelyn Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "robb.stark" : { - "firstname" : "Robb", - "surname" : "Stark", - "password" : "sexywolfy", - "city" : "Winterfell", - "description" : "Robb Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "sansa.stark" : { - "firstname" : "Sansa", - "surname" : "Stark", - "password" : "345ertdfg", - "city" : "Winterfell", - "description" : "Sansa Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["HTTP/eyrie.north.sevenkingdoms.local"] - }, - "brandon.stark" : { - "firstname" : "Brandon", - "surname" : "Stark", - "password" : "iseedeadpeople", - "city" : "Winterfell", - "description" : "Brandon Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "rickon.stark" : { - "firstname" : "Rickon", - "surname" : "Stark", - "password" : "Winter2022", - "city" : "Winterfell", - "description" : "Rickon Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "hodor" : { - "firstname" : "hodor", - "surname" : "hodor", - "password" : "hodor", - "city" : "Winterfell", - "description" : "Brainless Giant", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "jon.snow" : { - "firstname" : "Jon", - "surname" : "Snow", - "password" : "iknownothing", - "city" : "Castel Black", - "description" : "Jon Snow", - "groups" : ["Stark", "Night Watch"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["HTTP/thewall.north.sevenkingdoms.local"] - }, - "samwell.tarly" : { - "firstname" : "Samwell", - "surname" : "Tarly", - "password" : "Heartsbane", - "city" : "Castel Black", - "description" : "Samwell Tarly (Password : Heartsbane)", - "groups" : ["Night Watch"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "jeor.mormont" : { - "firstname" : "Jeor", - "surname" : "Mormont", - "password" : "_L0ngCl@w_", - "city" : "Castel Black", - "description" : "Jeor Mormont", - "groups" : ["Night Watch", "Mormont"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "sql_svc" : { - "firstname" : "sql", - "surname" : "service", - "password" : "YouWillNotKerboroast1ngMeeeeee", - "city" : "-", - "description" : "sql service", - "groups" : [], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["MSSQLSvc/castelblack.north.sevenkingdoms.local:1433","MSSQLSvc/castelblack.north.sevenkingdoms.local"] - } - } - }, - "sevenkingdoms.local" : { - "dc": "dc01", - "domain_password" : "ykRXQ@rWNV4znesz-h!c", - "netbios_name": "SEVENKINGDOMS", - "trust" : "essos.local", - "laps_path": "OU=Laps,DC=sevenkingdoms,DC=local", - "organisation_units" : { - "Vale" : { "path" : "DC=sevenkingdoms,DC=local"}, - "IronIslands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Riverlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Crownlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Stormlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Westerlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Reach" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Dorne" : { "path" : "DC=sevenkingdoms,DC=local"} - }, - "groups" : { - "universal" : {}, - "global" : { - "Lannister" : { - "managed_by" : "tywin.lannister", - "path" : "OU=Westerlands,DC=sevenkingdoms,DC=local" - }, - "Baratheon" : { - "managed_by" : "robert.baratheon", - "path" : "OU=Stormlands,DC=sevenkingdoms,DC=local" - }, - "Small Council" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "DragonStone" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "KingsGuard" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "DragonRider" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - } - }, - "domainlocal" : { - "AcrossTheNarrowSea" : { - "path" : "CN=Users,DC=sevenkingdoms,DC=local" - } - } - }, - "multi_domain_groups_member" : { - "AcrossTheNarrowSea" : [ - "essos.local\\daenerys.targaryen" - ] - }, - "acls" : { - "forcechangepassword_tywin_jaime" : {"for": "tywin.lannister", "to": "jaime.lannister", "right": "Ext-User-Force-Change-Password", "inheritance": "None"}, - "GenericWrite_on_user_jaimie_joffrey" : {"for": "jaime.lannister", "to": "joffrey.baratheon", "right": "GenericWrite", "inheritance": "None"}, - "Writedacl_joffrey_tyron" : {"for": "joffrey.baratheon", "to": "tyron.lannister", "right": "WriteDacl", "inheritance": "None"}, - "self-self-membership-on-group_tyron_small_council" : {"for": "tyron.lannister", "to": "Small Council", "right": "Ext-Self-Self-Membership", "inheritance": "None"}, - "addmember_smallcouncil_DragonStone" : {"for": "Small Council", "to": "DragonStone", "right": "Ext-Write-Self-Membership", "inheritance": "All"}, - "write_owner_dragonstone_kingsguard" : {"for": "DragonStone", "to": "KingsGuard", "right": "WriteOwner", "inheritance": "None"}, - "GenericAll_kingsguard_stanis" : {"for": "KingsGuard", "to": "stannis.baratheon", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_stanis_dc" : {"for": "stannis.baratheon", "to": "kingslanding$", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_group_acrrosdom_dc" : {"for": "AcrossTheNarrowSea", "to": "kingslanding$", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_varys_domadmin" : {"for": "lord.varys", "to": "Domain Admins", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_varys_domadmin_holder" : {"for": "lord.varys", "to": "CN=AdminSDHolder,CN=System,DC=sevenkingdoms,DC=local", "right": "GenericAll", "inheritance": "None"}, - "WriteDACL_renly_Crownlands" : {"for": "renly.baratheon", "to": "OU=Crownlands,DC=sevenkingdoms,DC=local", "right": "WriteDacl", "inheritance": "None"} - }, - "users" : { - "tywin.lannister" : { - "firstname" : "Tywin", - "surname" : "Lanister", - "password" : "powerkingftw135", - "city" : "Casterly Rock", - "description" : "Tywin Lanister", - "groups" : ["Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "jaime.lannister" : { - "firstname" : "Jaime", - "surname" : "Lanister", - "password" : "cersei", - "city" : "King's Landing", - "description" : "Jaime Lanister", - "groups" : ["Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "cersei.lannister" : { - "firstname" : "Cersei", - "surname" : "Lanister", - "password" : "il0vejaime", - "city" : "King's Landing", - "description" : "Cersei Lanister", - "groups" : ["Lannister","Baratheon","Domain Admins","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "tyron.lannister" : { - "firstname" : "Tyron", - "surname" : "Lanister", - "password" : "Alc00L&S3x", - "city" : "King's Landing", - "description" : "Tyron Lanister", - "groups" : ["Lannister"], - "path" : "OU=Westerlands,DC=sevenkingdoms,DC=local" - }, - "robert.baratheon" : { - "firstname" : "Robert", - "surname" : "Baratheon", - "password" : "iamthekingoftheworld", - "city" : "King's Landing", - "description" : "Robert Lanister", - "groups" : ["Baratheon","Domain Admins","Small Council","Protected Users"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "joffrey.baratheon" : { - "firstname" : "Joffrey", - "surname" : "Baratheon", - "password" : "1killerlion", - "city" : "King's Landing", - "description" : "Joffrey Baratheon", - "groups" : ["Baratheon","Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "renly.baratheon" : { - "firstname" : "Renly", - "surname" : "Baratheon", - "password" : "lorastyrell", - "city" : "King's Landing", - "description" : "Renly Baratheon", - "groups" : ["Baratheon","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "stannis.baratheon" : { - "firstname" : "Stannis", - "surname" : "Baratheon", - "password" : "Drag0nst0ne", - "city" : "King's Landing", - "description" : "Stannis Baratheon", - "groups" : ["Baratheon","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "petyer.baelish" : { - "firstname" : "Petyer", - "surname" : "Baelish", - "password" : "@littlefinger@", - "city" : "King's Landing", - "description" : "Petyer Baelish", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "lord.varys" : { - "firstname" : "Lord", - "surname" : "Varys", - "password" : "_W1sper_$", - "city" : "King's Landing", - "description" : "Lord Varys", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "maester.pycelle" : { - "firstname" : "Maester", - "surname" : "Pycelle", - "password" : "MaesterOfMaesters", - "city" : "King's Landing", - "description" : "Maester Pycelle", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - } - } - } - } -}} diff --git a/ad/GOAD/data/staging-overlay.json b/ad/GOAD/data/staging-overlay.json new file mode 100644 index 00000000..c05f5afa --- /dev/null +++ b/ad/GOAD/data/staging-overlay.json @@ -0,0 +1,74 @@ +{ + "lab": { + "domains": { + "essos.local": { + "domain_password": "-cwuyGW494yZnC_M8wLN", + "groups": { + "universal": { + "greatmaster": null + } + } + }, + "north.sevenkingdoms.local": { + "domain_password": "moydNed_wEKuP8KN6rUx" + }, + "sevenkingdoms.local": { + "domain_password": "ykRXQ@rWNV4znesz-h!c" + } + }, + "hosts": { + "dc01": { + "local_admin_password": "ykRXQ@rWNV4znesz-h!c", + "vulns": [ + "disable_firewall" + ] + }, + "dc02": { + "local_admin_password": "moydNed_wEKuP8KN6rUx", + "scripts": [ + "asrep_roasting.ps1", + "constrained_delegation_use_any.ps1", + "constrained_delegation_kerb_only.ps1", + "ntlm_relay.ps1", + "responder.ps1", + "gpo_abuse.ps1", + "rdp_scheduler.ps1", + "unconstrained_delegation_user.ps1" + ], + "vulns": [ + "disable_firewall", + "directory", + "credentials", + "autologon", + "files", + "enable_llmnr", + "enable_nbt_ns", + "anonymous_enum" + ] + }, + "dc03": { + "local_admin_password": "-cwuyGW494yZnC_M8wLN", + "local_groups": { + "Administrators": [ + "essos\\daenerys.targaryen" + ] + }, + "vulns": [ + "ntlmdowngrade", + "disable_firewall" + ], + "vulns_vars": null + }, + "srv02": { + "local_admin_password": "moydNed_wEKuP8KN6rUx" + }, + "srv03": { + "local_admin_password": "-cwuyGW494yZnC_M8wLN", + "vulns": [ + "openshares", + "disable_firewall" + ] + } + } + } +} diff --git a/ad/GOAD/data/test-config.json b/ad/GOAD/data/test-config.json deleted file mode 100644 index 5ceea8fc..00000000 --- a/ad/GOAD/data/test-config.json +++ /dev/null @@ -1,691 +0,0 @@ -{ -"lab" : { - "hosts" : { - "dc01" : { - "hostname" : "kingslanding", - "type" : "dc", - "local_admin_password": "qjQ!bcwXKU!3yBrDM2VU", - "domain" : "sevenkingdoms.local", - "path" : "DC=sevenkingdoms,DC=local", - "local_groups" : { - "Administrators" : [ - "sevenkingdoms\\robert.baratheon", - "sevenkingdoms\\cersei.lannister", - "sevenkingdoms\\DragonRider" - ], - "Remote Desktop Users" : [ - "sevenkingdoms\\Small Council", - "sevenkingdoms\\Baratheon" - ] - }, - "scripts" : ["sidhistory.ps1"], - "vulns" : ["disable_firewall"], - "security": ["account_is_sensitive", "audit_policy"], - "security_vars": { - "account_is_sensitive" : { "renly": {"account" : "renly.baratheon"} } - } - }, - "dc02" : { - "hostname" : "winterfell", - "type" : "dc", - "local_admin_password": "VExkHyfsKTW_HMNA7fQy", - "domain" : "north.sevenkingdoms.local", - "path" : "DC=north,DC=sevenkingdoms,DC=local", - "local_groups" : { - "Administrators" : [ - "north\\eddard.stark", - "north\\catelyn.stark", - "north\\robb.stark" - ], - "Remote Desktop Users" : [ - "north\\Stark" - ] - }, - "scripts" : [ - "asrep_roasting.ps1", - "constrained_delegation_use_any.ps1", - "constrained_delegation_kerb_only.ps1", - "ntlm_relay.ps1", - "responder.ps1", - "gpo_abuse.ps1", - "rdp_scheduler.ps1", - "unconstrained_delegation_user.ps1" - ], - "vulns" : ["disable_firewall","directory", "credentials", "autologon", "files", "enable_llmnr", "enable_nbt_ns", "anonymous_enum"], - "vulns_vars" : { - "directory": { - "setup": "C:\\setup" - }, - "credentials" : { - "TERMSRV/castelblack": { - "username" : "north\\robb.stark", - "secret" : "sexywolfy", - "runas" : "north\\robb.stark", - "runas_password" : "sexywolfy" - } - }, - "autologon" : { - "robb.stark" : { - "username" : "north\\robb.stark", - "password" : "sexywolfy" - } - }, - "files" : { - "rdp" : { - "src" : "dc02/bot_rdp.ps1", - "dest" : "C:\\setup\\bot_rdp.ps1" - }, - "sysvol_fake_script": { - "src" : "dc02/sysvol_scripts/script.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\script.ps1" - }, - "sysvol_secret": { - "src" : "dc02/sysvol_scripts/secret.ps1", - "dest": "C:\\Windows\\SYSVOL\\domain\\scripts\\secret.ps1" - } - } - }, - "security": ["audit_policy"] - }, - "srv02" : { - "hostname" : "castelblack", - "type" : "server", - "local_admin_password": "VExkHyfsKTW_HMNA7fQy", - "domain" : "north.sevenkingdoms.local", - "path" : "DC=north,DC=sevenkingdoms,DC=local", - "use_laps": false, - "local_groups" : { - "Administrators" : [ - "north\\jeor.mormont" - ], - "Remote Desktop Users" : [ - "north\\Night Watch", - "north\\Mormont", - "north\\Stark" - ] - }, - "scripts" : [], - "vulns" : ["directory", "disable_firewall", "openshares", "shares", "files", "permissions"], - "vulns_vars" : { - "directory": { - "shares": "C:\\shares", - "all": "C:\\shares\\all" - }, - "files" : { - "website" : { - "src" : "srv02/wwwroot", - "dest" : "C:\\inetpub\\" - }, - "letter_in_shares": { - "src" : "srv02/all/arya.txt", - "dest": "C:\\shares\\all\\arya.txt" - } - }, - "permissions" : { - "IIS_IUSRS_upload": { - "path" : "C:\\inetpub\\wwwroot\\upload", - "user" : "IIS_IUSRS", - "rights" : "FullControl" - } - }, - "shares" : { - "thewall" : { - "path" : "C:\\thewall", - "list" : "yes", - "full" : "NORTH\\Stark", - "change" : "NORTH\\jon.snow,NORTH\\samwell.tarly", - "read" : "Users" - } - } - }, - "mssql":{ - "sa_password": "Sup1_sa_P@ssw0rd!", - "svcaccount" : "sql_svc", - "sysadmins" : [ - "NORTH\\jon.snow" - ], - "executeaslogin" : { - "NORTH\\samwell.tarly" : "sa", - "NORTH\\brandon.stark" : "NORTH\\jon.snow" - }, - "executeasuser" : { - "arya_master_dbo": { - "user": "NORTH\\arya.stark", - "db" : "master", - "impersonate" : "dbo" - }, - "arya_dbms_dbo": { - "user": "NORTH\\arya.stark", - "db" : "msdb", - "impersonate" : "dbo" - } - }, - "linked_servers": { - "BRAAVOS" : { - "data_src": "braavos.essos.local", - "users_mapping": [ - {"local_login": "NORTH\\jon.snow","remote_login": "sa", "remote_password": "sa_P@ssw0rd!Ess0s"} - ] - } - } - } - }, - "dc03" : { - "hostname" : "meereen", - "type" : "dc", - "local_admin_password": "M!BbXzL48D9mH9dQzp*e", - "domain" : "essos.local", - "path" : "DC=essos,DC=local", - "local_groups" : { - "Administrators" : [ - "essos\\daenerys.targaryen" - ], - "Remote Desktop Users" : [ - "essos\\Targaryen" - ] - }, - "scripts" : ["asrep_roasting2.ps1"], - "vulns" : ["ntlmdowngrade", "disable_firewall"], - "security": ["audit_policy"] - }, - "srv03" : { - "hostname" : "braavos", - "type" : "server", - "local_admin_password": "M!BbXzL48D9mH9dQzp*e", - "domain" : "essos.local", - "path" : "DC=essos,DC=local", - "use_laps": true, - "local_groups" : { - "Administrators" : [ - "essos\\khal.drogo" - ] - }, - "Remote Desktop Users" : [ - "essos\\Dothraki" - ], - "scripts" : [], - "vulns" : ["openshares","disable_firewall"], - "security": ["enable_run_as_ppl"], - "mssql":{ - "sa_password": "sa_P@ssw0rd!Ess0s", - "svcaccount" : "sql_svc", - "sysadmins" : [ - "ESSOS\\khal.drogo" - ], - "executeaslogin" : { - "ESSOS\\jorah.mormont" : "sa" - }, - "executeasuser" : {}, - "linked_servers": { - "CASTELBLACK" : { - "data_src": "castelblack.north.sevenkingdoms.local", - "users_mapping": [ - {"local_login": "ESSOS\\khal.drogo","remote_login": "sa", "remote_password": "Sup1_sa_P@ssw0rd!"} - ] - } - } - } - } - }, - "domains" : { - "essos.local" : { - "dc": "dc03", - "domain_password" : "M!BbXzL48D9mH9dQzp*e", - "netbios_name": "ESSOS", - "ca_server": "Braavos", - "trust" : "sevenkingdoms.local", - "laps_path": "OU=Laps,DC=essos,DC=local", - "organisation_units" : { - }, - "laps_readers": [ - "jorah.mormont", - "Spys" - ], - "groups" : { - "universal" : {}, - "global" : { - "Targaryen" : { - "managed_by" : "viserys.targaryen", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Dothraki" : { - "managed_by" : "khal.drogo", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Dragons":{ - "managed_by" : "goadmin", - "path" : "CN=Users,DC=essos,DC=local" - }, - "QueenProtector":{ - "managed_by" : "goadmin", - "path" : "CN=Users,DC=essos,DC=local", - "members" : ["ESSOS\\Dragons"] - }, - "Domain Admins":{ - "managed_by" : "goadmin", - "path" : "CN=Users,DC=essos,DC=local", - "members" : ["ESSOS\\QueenProtector"] - } - }, - "domainlocal" : { - "DragonsFriends" : { - "managed_by" : "daenerys.targaryen", - "path" : "CN=Users,DC=essos,DC=local" - }, - "Spys" : { - "path" : "CN=Users,DC=essos,DC=local" - } - } - }, - "multi_domain_groups_member" : { - "DragonsFriends" : [ - "sevenkingdoms.local\\tyron.lannister", - "essos.local\\daenerys.targaryen" - ], - "Spys" : [ - "sevenkingdoms.local\\Small Council" - ] - }, - "gmsa" : { - "gmsa_account": { - "gMSA_Name" : "gmsaDragon", - "gMSA_FQDN" : "gmsaDragon.essos.local", - "gMSA_SPNs" : ["HTTP/braavos", "HTTP/braavos.essos.local"], - "gMSA_HostNames" : ["braavos"] - } - }, - "acls" : { - "GenericAll_khal_viserys" : {"for": "khal.drogo", "to": "viserys.targaryen", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_spy_jorah" : {"for": "Spys", "to": "jorah.mormont", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_khal_esc4" : {"for": "khal.drogo", "to": "CN=ESC4,CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,DC=essos,DC=local", "right": "GenericAll", "inheritance": "None"}, - "WriteProperty_petyer_domadmin" : {"for": "viserys.targaryen", "to": "jorah.mormont", "right": "WriteProperty", "inheritance": "All"}, - "GenericWrite_DragonsFriends_braavos" : {"for": "DragonsFriends", "to": "braavos$", "right": "GenericWrite", "inheritance": "None"}, - "GenericAll_missandei_khal" : {"for": "missandei", "to": "khal.drogo", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_gmsaDragon_drogo" : {"for": "gmsaDragon$", "to": "drogon", "right": "GenericAll", "inheritance": "None"}, - "GenericWrite_missandei_viserys" : {"for": "missandei", "to": "viserys.targaryen", "right": "GenericWrite", "inheritance": "None"} - }, - "users" : { - "daenerys.targaryen" : { - "firstname" : "daenerys", - "surname" : "targaryen", - "password" : "BurnThemAll!", - "city" : "-", - "description" : "Darnerys Targaryen", - "groups" : ["Targaryen", "Domain Admins"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "viserys.targaryen" : { - "firstname" : "viserys", - "surname" : "targaryen", - "password" : "GoldCrown", - "city" : "-", - "description" : "Viserys Targaryen", - "groups" : ["Targaryen"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "khal.drogo" : { - "firstname" : "khal", - "surname" : "drogo", - "password" : "horse", - "city" : "-", - "description" : "Khal Drogo", - "groups" : ["Dothraki"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "jorah.mormont" : { - "firstname" : "jorah", - "surname" : "mormont", - "password" : "H0nnor!", - "city" : "-", - "description" : "Jorah Mormont", - "groups" : ["Targaryen"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "missandei" : { - "firstname" : "missandei", - "surname" : "-", - "password" : "fr3edom", - "city" : "-", - "description" : "missandei", - "groups" : [], - "path" : "CN=Users,DC=essos,DC=local" - }, - "drogon" : { - "firstname" : "drogon", - "surname" : "-", - "password" : "Dracarys", - "city" : "-", - "description" : "drogon", - "groups" : ["Dragons"], - "path" : "CN=Users,DC=essos,DC=local" - }, - "sql_svc" : { - "firstname" : "sql", - "surname" : "service", - "password" : "YouWillNotKerboroast1ngMeeeeee", - "city" : "-", - "description" : "sql service", - "groups" : [], - "path" : "CN=Users,DC=essos,DC=local", - "spns" : ["MSSQLSvc/braavos.essos.local:1433","MSSQLSvc/braavos.essos.local"] - } - } - }, - "north.sevenkingdoms.local" : { - "dc": "dc02", - "domain_password" : "VExkHyfsKTW_HMNA7fQy", - "netbios_name": "NORTH", - "trust" : "", - "laps_path": "OU=Laps,DC=north,DC=sevenkingdoms,DC=local", - "organisation_units" : { - }, - "groups" : { - "universal" : {}, - "global" : { - "Stark" : { - "managed_by" : "eddard.stark", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "Night Watch" : { - "managed_by" : "jeor.mormont", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "Mormont" : { - "managed_by" : "jeor.mormont", - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - } - }, - "domainlocal" : { - "AcrossTheSea" : { - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - } - } - }, - "multi_domain_groups_member" : {}, - "acls" : { - "anonymous_rpc" : {"for": "NT AUTHORITY\\ANONYMOUS LOGON", "to": "DC=North,DC=sevenkingdoms,DC=local", "right": "ReadProperty", "inheritance": "All"}, - "anonymous_rpc2" : {"for": "NT AUTHORITY\\ANONYMOUS LOGON", "to": "DC=North,DC=sevenkingdoms,DC=local", "right": "GenericExecute", "inheritance": "All"} - }, - "users" : { - "arya.stark" : { - "firstname" : "Arya", - "surname" : "Stark", - "password" : "Needle", - "city" : "Winterfell", - "description" : "Arya Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "eddard.stark" : { - "firstname" : "Eddard", - "surname" : "Stark", - "password" : "FightP3aceAndHonor!", - "city" : "King's Landing", - "description" : "Eddard Stark", - "groups" : ["Stark", "Domain Admins"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "catelyn.stark" : { - "firstname" : "Catelyn", - "surname" : "Stark", - "password" : "robbsansabradonaryarickon", - "city" : "King's Landing", - "description" : "Catelyn Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "robb.stark" : { - "firstname" : "Robb", - "surname" : "Stark", - "password" : "sexywolfy", - "city" : "Winterfell", - "description" : "Robb Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "sansa.stark" : { - "firstname" : "Sansa", - "surname" : "Stark", - "password" : "345ertdfg", - "city" : "Winterfell", - "description" : "Sansa Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["HTTP/eyrie.north.sevenkingdoms.local"] - }, - "brandon.stark" : { - "firstname" : "Brandon", - "surname" : "Stark", - "password" : "iseedeadpeople", - "city" : "Winterfell", - "description" : "Brandon Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "rickon.stark" : { - "firstname" : "Rickon", - "surname" : "Stark", - "password" : "Winter2022", - "city" : "Winterfell", - "description" : "Rickon Stark", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "hodor" : { - "firstname" : "hodor", - "surname" : "hodor", - "password" : "hodor", - "city" : "Winterfell", - "description" : "Brainless Giant", - "groups" : ["Stark"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "jon.snow" : { - "firstname" : "Jon", - "surname" : "Snow", - "password" : "iknownothing", - "city" : "Castel Black", - "description" : "Jon Snow", - "groups" : ["Stark", "Night Watch"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["HTTP/thewall.north.sevenkingdoms.local"] - }, - "samwell.tarly" : { - "firstname" : "Samwell", - "surname" : "Tarly", - "password" : "Heartsbane", - "city" : "Castel Black", - "description" : "Samwell Tarly (Password : Heartsbane)", - "groups" : ["Night Watch"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "jeor.mormont" : { - "firstname" : "Jeor", - "surname" : "Mormont", - "password" : "_L0ngCl@w_", - "city" : "Castel Black", - "description" : "Jeor Mormont", - "groups" : ["Night Watch", "Mormont"], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local" - }, - "sql_svc" : { - "firstname" : "sql", - "surname" : "service", - "password" : "YouWillNotKerboroast1ngMeeeeee", - "city" : "-", - "description" : "sql service", - "groups" : [], - "path" : "CN=Users,DC=North,DC=sevenkingdoms,DC=local", - "spns" : ["MSSQLSvc/castelblack.north.sevenkingdoms.local:1433","MSSQLSvc/castelblack.north.sevenkingdoms.local"] - } - } - }, - "sevenkingdoms.local" : { - "dc": "dc01", - "domain_password" : "qjQ!bcwXKU!3yBrDM2VU", - "netbios_name": "SEVENKINGDOMS", - "trust" : "essos.local", - "laps_path": "OU=Laps,DC=sevenkingdoms,DC=local", - "organisation_units" : { - "Vale" : { "path" : "DC=sevenkingdoms,DC=local"}, - "IronIslands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Riverlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Crownlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Stormlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Westerlands" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Reach" : { "path" : "DC=sevenkingdoms,DC=local"}, - "Dorne" : { "path" : "DC=sevenkingdoms,DC=local"} - }, - "groups" : { - "universal" : {}, - "global" : { - "Lannister" : { - "managed_by" : "tywin.lannister", - "path" : "OU=Westerlands,DC=sevenkingdoms,DC=local" - }, - "Baratheon" : { - "managed_by" : "robert.baratheon", - "path" : "OU=Stormlands,DC=sevenkingdoms,DC=local" - }, - "Small Council" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "DragonStone" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "KingsGuard" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "DragonRider" : { - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - } - }, - "domainlocal" : { - "AcrossTheNarrowSea" : { - "path" : "CN=Users,DC=sevenkingdoms,DC=local" - } - } - }, - "multi_domain_groups_member" : { - "AcrossTheNarrowSea" : [ - "essos.local\\daenerys.targaryen" - ] - }, - "acls" : { - "forcechangepassword_tywin_jaime" : {"for": "tywin.lannister", "to": "jaime.lannister", "right": "Ext-User-Force-Change-Password", "inheritance": "None"}, - "GenericWrite_on_user_jaimie_joffrey" : {"for": "jaime.lannister", "to": "joffrey.baratheon", "right": "GenericWrite", "inheritance": "None"}, - "Writedacl_joffrey_tyron" : {"for": "joffrey.baratheon", "to": "tyron.lannister", "right": "WriteDacl", "inheritance": "None"}, - "self-self-membership-on-group_tyron_small_council" : {"for": "tyron.lannister", "to": "Small Council", "right": "Ext-Self-Self-Membership", "inheritance": "None"}, - "addmember_smallcouncil_DragonStone" : {"for": "Small Council", "to": "DragonStone", "right": "Ext-Write-Self-Membership", "inheritance": "All"}, - "write_owner_dragonstone_kingsguard" : {"for": "DragonStone", "to": "KingsGuard", "right": "WriteOwner", "inheritance": "None"}, - "GenericAll_kingsguard_stanis" : {"for": "KingsGuard", "to": "stannis.baratheon", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_stanis_dc" : {"for": "stannis.baratheon", "to": "kingslanding$", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_group_acrrosdom_dc" : {"for": "AcrossTheNarrowSea", "to": "kingslanding$", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_varys_domadmin" : {"for": "lord.varys", "to": "Domain Admins", "right": "GenericAll", "inheritance": "None"}, - "GenericAll_varys_domadmin_holder" : {"for": "lord.varys", "to": "CN=AdminSDHolder,CN=System,DC=sevenkingdoms,DC=local", "right": "GenericAll", "inheritance": "None"}, - "WriteDACL_renly_Crownlands" : {"for": "renly.baratheon", "to": "OU=Crownlands,DC=sevenkingdoms,DC=local", "right": "WriteDacl", "inheritance": "None"} - }, - "users" : { - "tywin.lannister" : { - "firstname" : "Tywin", - "surname" : "Lanister", - "password" : "powerkingftw135", - "city" : "Casterly Rock", - "description" : "Tywin Lanister", - "groups" : ["Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "jaime.lannister" : { - "firstname" : "Jaime", - "surname" : "Lanister", - "password" : "cersei", - "city" : "King's Landing", - "description" : "Jaime Lanister", - "groups" : ["Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "cersei.lannister" : { - "firstname" : "Cersei", - "surname" : "Lanister", - "password" : "il0vejaime", - "city" : "King's Landing", - "description" : "Cersei Lanister", - "groups" : ["Lannister","Baratheon","Domain Admins","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "tyron.lannister" : { - "firstname" : "Tyron", - "surname" : "Lanister", - "password" : "Alc00L&S3x", - "city" : "King's Landing", - "description" : "Tyron Lanister", - "groups" : ["Lannister"], - "path" : "OU=Westerlands,DC=sevenkingdoms,DC=local" - }, - "robert.baratheon" : { - "firstname" : "Robert", - "surname" : "Baratheon", - "password" : "iamthekingoftheworld", - "city" : "King's Landing", - "description" : "Robert Lanister", - "groups" : ["Baratheon","Domain Admins","Small Council","Protected Users"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "joffrey.baratheon" : { - "firstname" : "Joffrey", - "surname" : "Baratheon", - "password" : "1killerlion", - "city" : "King's Landing", - "description" : "Joffrey Baratheon", - "groups" : ["Baratheon","Lannister"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "renly.baratheon" : { - "firstname" : "Renly", - "surname" : "Baratheon", - "password" : "lorastyrell", - "city" : "King's Landing", - "description" : "Renly Baratheon", - "groups" : ["Baratheon","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "stannis.baratheon" : { - "firstname" : "Stannis", - "surname" : "Baratheon", - "password" : "Drag0nst0ne", - "city" : "King's Landing", - "description" : "Stannis Baratheon", - "groups" : ["Baratheon","Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "petyer.baelish" : { - "firstname" : "Petyer", - "surname" : "Baelish", - "password" : "@littlefinger@", - "city" : "King's Landing", - "description" : "Petyer Baelish", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "lord.varys" : { - "firstname" : "Lord", - "surname" : "Varys", - "password" : "_W1sper_$", - "city" : "King's Landing", - "description" : "Lord Varys", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - }, - "maester.pycelle" : { - "firstname" : "Maester", - "surname" : "Pycelle", - "password" : "MaesterOfMaesters", - "city" : "King's Landing", - "description" : "Maester Pycelle", - "groups" : ["Small Council"], - "path" : "OU=Crownlands,DC=sevenkingdoms,DC=local" - } - } - } - } -}} diff --git a/ad/GOAD/data/test-overlay.json b/ad/GOAD/data/test-overlay.json new file mode 100644 index 00000000..d7b2d03f --- /dev/null +++ b/ad/GOAD/data/test-overlay.json @@ -0,0 +1,60 @@ +{ + "lab": { + "domains": { + "essos.local": { + "groups": { + "universal": { + "greatmaster": null + } + } + } + }, + "hosts": { + "dc01": { + "vulns": [ + "disable_firewall" + ] + }, + "dc02": { + "scripts": [ + "asrep_roasting.ps1", + "constrained_delegation_use_any.ps1", + "constrained_delegation_kerb_only.ps1", + "ntlm_relay.ps1", + "responder.ps1", + "gpo_abuse.ps1", + "rdp_scheduler.ps1", + "unconstrained_delegation_user.ps1" + ], + "vulns": [ + "disable_firewall", + "directory", + "credentials", + "autologon", + "files", + "enable_llmnr", + "enable_nbt_ns", + "anonymous_enum" + ] + }, + "dc03": { + "local_groups": { + "Administrators": [ + "essos\\daenerys.targaryen" + ] + }, + "vulns": [ + "ntlmdowngrade", + "disable_firewall" + ], + "vulns_vars": null + }, + "srv03": { + "vulns": [ + "openshares", + "disable_firewall" + ] + } + } + } +} diff --git a/ansible/roles/ad/tasks/users.yml b/ansible/roles/ad/tasks/users.yml index 85629078..1706cfeb 100644 --- a/ansible/roles/ad/tasks/users.yml +++ b/ansible/roles/ad/tasks/users.yml @@ -45,8 +45,16 @@ Write-Output "Created user $Username" $Ansible.Changed = $true } else { - Write-Output "User $Username already exists" - $Ansible.Changed = $false + # Update description if it changed + $currentDesc = $userExists.Description + if ($Description -and $currentDesc -ne $Description) { + Set-ADUser -Identity $Username -Description $Description + Write-Output "Updated description for $Username" + $Ansible.Changed = $true + } else { + Write-Output "User $Username already exists" + $Ansible.Changed = $false + } } # Add user to groups diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go index e9c567af..21ae8a88 100644 --- a/cli/cmd/env_cmd.go +++ b/cli/cmd/env_cmd.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "io/fs" "os" @@ -31,11 +30,11 @@ Creates: - infra/goad-deployment/{env}/{region}/region.hcl - infra/goad-deployment/{env}/{region}/network/terragrunt.hcl - infra/goad-deployment/{env}/{region}/goad/{host}/terragrunt.hcl + templates - - ad/GOAD/data/{env}-config.json + - ad/GOAD/data/{env}-overlay.json (or variant directory) - {env}-inventory (Ansible inventory with PENDING instance IDs) -Use --variant to generate randomized entity names for the environment config. -Without --variant, the base config (dev-config.json) is copied as-is.`, +Use --variant to generate a full randomized variant in ad/GOAD-{env}/. +Without --variant, an overlay config is created from the dev overlay.`, Args: cobra.ExactArgs(1), RunE: runEnvCreate, } @@ -124,17 +123,19 @@ func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference string, } color.Green(" Copied infrastructure from %s", reference) - configPath := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", envName+"-config.json") + var configPath string if useVariant { if err := generateVariantConfig(cfg.ProjectRoot, envName); err != nil { return fmt.Errorf("generate variant config: %w", err) } - color.Green(" Generated variant config: %s-config.json", envName) + configPath = filepath.Join(cfg.ProjectRoot, "ad", "GOAD-"+envName, "data") + color.Green(" Generated variant config in %s", configPath) } else { if err := copyBaseConfig(cfg.ProjectRoot, envName); err != nil { return fmt.Errorf("copy base config: %w", err) } - color.Green(" Created config: %s-config.json", envName) + configPath = filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", envName+"-overlay.json") + color.Green(" Created overlay: %s-overlay.json", envName) } invPath := filepath.Join(cfg.ProjectRoot, envName+"-inventory") @@ -195,10 +196,20 @@ func runEnvList(cmd *cobra.Command, args []string) error { } } - configFile := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", name+"-config.json") + goadData := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data") hasConfig := false - if _, err := os.Stat(configFile); err == nil { - hasConfig = true + for _, suffix := range []string{"-overlay.json", "-config.json"} { + if _, err := os.Stat(filepath.Join(goadData, name+suffix)); err == nil { + hasConfig = true + break + } + } + // Also check variant target directory. + if !hasConfig { + variantData := filepath.Join(cfg.ProjectRoot, "ad", "GOAD-"+name, "data") + if _, err := os.Stat(filepath.Join(variantData, "config.json")); err == nil { + hasConfig = true + } } marker := " " @@ -299,20 +310,16 @@ func copyInfrastructure(srcRegionDir, dstRegionDir string) error { } func copyBaseConfig(projectRoot, envName string) error { - srcPath := filepath.Join(projectRoot, "ad", "GOAD", "data", "dev-config.json") - dstPath := filepath.Join(projectRoot, "ad", "GOAD", "data", envName+"-config.json") + dstPath := filepath.Join(projectRoot, "ad", "GOAD", "data", envName+"-overlay.json") - data, err := os.ReadFile(srcPath) - if err != nil { - return fmt.Errorf("read base config: %w", err) - } - - var parsed interface{} - if err := json.Unmarshal(data, &parsed); err != nil { - return fmt.Errorf("invalid base config JSON: %w", err) + // Copy dev-overlay.json as starting template if it exists. + devOverlay := filepath.Join(projectRoot, "ad", "GOAD", "data", "dev-overlay.json") + if data, err := os.ReadFile(devOverlay); err == nil { + return os.WriteFile(dstPath, data, 0o644) } - return os.WriteFile(dstPath, data, 0o644) + // Otherwise create an empty overlay (inherits base config as-is). + return os.WriteFile(dstPath, []byte("{}\n"), 0o644) } func generateInventory(projectRoot, envName, region, reference string) error { @@ -387,17 +394,5 @@ func generateVariantConfig(projectRoot, envName string) error { target := filepath.Join(projectRoot, "ad", "GOAD-"+envName) gen := variant.NewGenerator(source, target, envName) - if err := gen.Run(); err != nil { - return fmt.Errorf("variant generation: %w", err) - } - - srcConfig := filepath.Join(target, "data", "config.json") - dstConfig := filepath.Join(projectRoot, "ad", "GOAD", "data", envName+"-config.json") - - data, err := os.ReadFile(srcConfig) - if err != nil { - return fmt.Errorf("read generated variant config: %w", err) - } - - return os.WriteFile(dstConfig, data, 0o644) + return gen.Run() } diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 9c179c92..7efbbd73 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "sync" + "time" "github.com/dreadnode/dreadgoad/internal/inventory" + "github.com/dreadnode/dreadgoad/internal/jsonmerge" "github.com/spf13/viper" ) @@ -136,20 +138,112 @@ func (c *Config) InventoryPath() string { } // LabConfigPath returns the path to the environment's lab config JSON. -// When the active environment has variant enabled, it returns the config -// from the variant target directory instead of the base GOAD directory. +// It delegates to ResolvedLabConfigPath (which supports overlay merging) +// and falls back to the legacy direct path on error. func (c *Config) LabConfigPath() string { + if p, err := c.ResolvedLabConfigPath(); err == nil { + return p + } + return filepath.Join(c.labConfigDataDir(), c.Env+"-config.json") +} + +// ResolvedLabConfigPath returns the path to a ready-to-use lab config JSON. +// When an overlay file ({env}-overlay.json) exists alongside the base +// config.json, it merges them using RFC 7386 JSON Merge Patch semantics +// and caches the result under .dreadgoad/cache/. Falls back to a legacy +// {env}-config.json if present, then to the base config.json. +func (c *Config) ResolvedLabConfigPath() (string, error) { + dataDir := c.labConfigDataDir() + + overlayPath := filepath.Join(dataDir, c.Env+"-overlay.json") + basePath := filepath.Join(dataDir, "config.json") + + if fileExists(overlayPath) && fileExists(basePath) { + return c.mergedConfigPath(basePath, overlayPath) + } + + // Legacy: full {env}-config.json exists. + legacyPath := filepath.Join(dataDir, c.Env+"-config.json") + if fileExists(legacyPath) { + return legacyPath, nil + } + + // Fallback: base config.json. + if fileExists(basePath) { + return basePath, nil + } + + return "", fmt.Errorf("no lab config found in %s", dataDir) +} + +// labConfigDataDir returns the data directory for the active environment's +// lab config (variant target or base GOAD). +func (c *Config) labConfigDataDir() string { ec := c.ActiveEnvironment() if ec.Variant { _, target := c.ResolvedVariantPaths() if target != "" { - variantConfig := filepath.Join(target, "data", c.Env+"-config.json") - if fileExists(variantConfig) { - return variantConfig + d := filepath.Join(target, "data") + if info, err := os.Stat(d); err == nil && info.IsDir() { + return d } } } - return filepath.Join(c.ProjectRoot, "ad", "GOAD", "data", c.Env+"-config.json") + return filepath.Join(c.ProjectRoot, "ad", "GOAD", "data") +} + +// mergedConfigPath merges base + overlay and caches the result. Returns +// the cached file path. The cache is invalidated when either source file +// is newer than the cached output. +func (c *Config) mergedConfigPath(basePath, overlayPath string) (string, error) { + cacheDir := filepath.Join(c.ProjectRoot, ".dreadgoad", "cache") + cachePath := filepath.Join(cacheDir, c.Env+"-config.json") + + // Check if cache is fresh. + if cacheInfo, err := os.Stat(cachePath); err == nil { + cacheMod := cacheInfo.ModTime() + if cacheMod.After(fileMtime(basePath)) && cacheMod.After(fileMtime(overlayPath)) { + return cachePath, nil + } + } + + base, err := os.ReadFile(basePath) + if err != nil { + return "", fmt.Errorf("read base config: %w", err) + } + overlay, err := os.ReadFile(overlayPath) + if err != nil { + return "", fmt.Errorf("read overlay: %w", err) + } + + merged, err := jsonmerge.MergePatchBytes(base, overlay) + if err != nil { + return "", fmt.Errorf("merge config: %w", err) + } + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + // Atomic write: temp file + rename. + tmp := cachePath + ".tmp" + if err := os.WriteFile(tmp, merged, 0o644); err != nil { + return "", fmt.Errorf("write cache: %w", err) + } + if err := os.Rename(tmp, cachePath); err != nil { + os.Remove(tmp) + return "", fmt.Errorf("rename cache: %w", err) + } + + return cachePath, nil +} + +func fileMtime(path string) time.Time { + info, err := os.Stat(path) + if err != nil { + return time.Time{} + } + return info.ModTime() } // AnsibleCfgPath returns the path to the ansible.cfg file. diff --git a/cli/internal/jsonmerge/diff.go b/cli/internal/jsonmerge/diff.go new file mode 100644 index 00000000..bf730956 --- /dev/null +++ b/cli/internal/jsonmerge/diff.go @@ -0,0 +1,73 @@ +package jsonmerge + +import ( + "encoding/json" + "fmt" +) + +// Diff computes an RFC 7386 JSON Merge Patch that, when applied to base +// via MergePatch, produces target. Only differing paths appear in the +// result; removed keys are represented as null. +func Diff(base, target any) any { + baseMap, baseIsObj := base.(map[string]any) + targetMap, targetIsObj := target.(map[string]any) + + if !baseIsObj || !targetIsObj { + return target + } + + patch := make(map[string]any) + + // Keys in target that differ from base. + for k, tv := range targetMap { + bv, exists := baseMap[k] + if !exists { + patch[k] = tv + continue + } + _, bIsObj := bv.(map[string]any) + _, tIsObj := tv.(map[string]any) + if bIsObj && tIsObj { + sub := Diff(bv, tv) + if subMap, ok := sub.(map[string]any); ok && len(subMap) > 0 { + patch[k] = sub + } + } else if !jsonEqual(bv, tv) { + patch[k] = tv + } + } + + // Keys removed in target. + for k := range baseMap { + if _, exists := targetMap[k]; !exists { + patch[k] = nil + } + } + + return patch +} + +// DiffBytes computes an overlay patch from base to target JSON bytes. +func DiffBytes(base, target []byte) ([]byte, error) { + var baseVal, targetVal any + if err := json.Unmarshal(base, &baseVal); err != nil { + return nil, fmt.Errorf("unmarshal base: %w", err) + } + if err := json.Unmarshal(target, &targetVal); err != nil { + return nil, fmt.Errorf("unmarshal target: %w", err) + } + + patch := Diff(baseVal, targetVal) + + out, err := json.MarshalIndent(patch, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal patch: %w", err) + } + return append(out, '\n'), nil +} + +func jsonEqual(a, b any) bool { + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + return string(aj) == string(bj) +} diff --git a/cli/internal/jsonmerge/diff_test.go b/cli/internal/jsonmerge/diff_test.go new file mode 100644 index 00000000..ada1f28a --- /dev/null +++ b/cli/internal/jsonmerge/diff_test.go @@ -0,0 +1,148 @@ +package jsonmerge + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestDiffRoundTrip(t *testing.T) { + tests := []struct { + name string + base string + target string + }{ + { + name: "scalar change", + base: `{"a": 1, "b": 2}`, + target: `{"a": 1, "b": 99}`, + }, + { + name: "key removal", + base: `{"a": 1, "b": 2}`, + target: `{"a": 1}`, + }, + { + name: "key addition", + base: `{"a": 1}`, + target: `{"a": 1, "b": 2}`, + }, + { + name: "nested change", + base: `{"a": {"b": 1, "c": 2}}`, + target: `{"a": {"b": 99, "c": 2}}`, + }, + { + name: "array replacement", + base: `{"a": [1, 2]}`, + target: `{"a": [3, 4, 5]}`, + }, + { + name: "identical", + base: `{"a": 1}`, + target: `{"a": 1}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var base, target any + if err := json.Unmarshal([]byte(tt.base), &base); err != nil { + t.Fatalf("unmarshal base: %v", err) + } + if err := json.Unmarshal([]byte(tt.target), &target); err != nil { + t.Fatalf("unmarshal target: %v", err) + } + + patch := Diff(base, target) + result := MergePatch(base, patch) + + gotJSON, _ := json.Marshal(result) + wantJSON, _ := json.Marshal(target) + if string(gotJSON) != string(wantJSON) { + patchJSON, _ := json.Marshal(patch) + t.Errorf("round-trip failed:\n base: %s\n target: %s\n patch: %s\n got: %s", tt.base, tt.target, patchJSON, gotJSON) + } + }) + } +} + +func TestDiffBytesRealConfigs(t *testing.T) { + // This test validates that Diff + MergePatch round-trips against + // the actual GOAD config files when they exist. + projectRoot := findProjectRoot(t) + if projectRoot == "" { + t.Skip("project root not found") + } + + goadData := filepath.Join(projectRoot, "ad", "GOAD", "data") + baseConfig := filepath.Join(goadData, "config.json") + if _, err := os.Stat(baseConfig); err != nil { + t.Skipf("base config not found: %v", err) + } + + base, err := os.ReadFile(baseConfig) + if err != nil { + t.Fatalf("read base: %v", err) + } + + for _, env := range []string{"dev", "staging", "test"} { + envConfig := filepath.Join(goadData, env+"-config.json") + if _, err := os.Stat(envConfig); err != nil { + continue + } + + t.Run(env, func(t *testing.T) { + target, err := os.ReadFile(envConfig) + if err != nil { + t.Fatalf("read %s-config.json: %v", env, err) + } + + // Compute the overlay. + overlay, err := DiffBytes(base, target) + if err != nil { + t.Fatalf("DiffBytes: %v", err) + } + + // Apply overlay to base and compare with target. + merged, err := MergePatchBytes(base, overlay) + if err != nil { + t.Fatalf("MergePatchBytes: %v", err) + } + + // Compare as parsed JSON to ignore formatting differences. + var mergedVal, targetVal any + if err := json.Unmarshal(merged, &mergedVal); err != nil { + t.Fatalf("unmarshal merged: %v", err) + } + if err := json.Unmarshal(target, &targetVal); err != nil { + t.Fatalf("unmarshal target: %v", err) + } + + mergedJSON, _ := json.Marshal(mergedVal) + targetJSON, _ := json.Marshal(targetVal) + if string(mergedJSON) != string(targetJSON) { + t.Errorf("round-trip mismatch for %s-config.json\n overlay size: %d bytes", env, len(overlay)) + } else { + t.Logf("%s overlay: %d bytes (vs %d byte full config)", env, len(overlay), len(target)) + } + }) + } +} + +func findProjectRoot(t *testing.T) string { + t.Helper() + // Walk up from the test file to find the project root (contains go.mod). + dir, _ := os.Getwd() + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return filepath.Dir(dir) // go.mod is in cli/, project root is parent + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/cli/internal/jsonmerge/merge.go b/cli/internal/jsonmerge/merge.go new file mode 100644 index 00000000..58405c4c --- /dev/null +++ b/cli/internal/jsonmerge/merge.go @@ -0,0 +1,56 @@ +package jsonmerge + +import ( + "encoding/json" + "fmt" +) + +// MergePatch applies an RFC 7386 JSON Merge Patch to a base document. +// Objects merge recursively; null in the patch deletes keys; arrays and +// scalars in the patch replace the base value wholesale. +func MergePatch(base, patch any) any { + patchMap, patchIsObj := patch.(map[string]any) + if !patchIsObj { + return patch + } + + baseMap, baseIsObj := base.(map[string]any) + if !baseIsObj { + baseMap = make(map[string]any) + } else { + // Shallow-copy so we don't mutate the caller's map. + cp := make(map[string]any, len(baseMap)) + for k, v := range baseMap { + cp[k] = v + } + baseMap = cp + } + + for k, v := range patchMap { + if v == nil { + delete(baseMap, k) + } else { + baseMap[k] = MergePatch(baseMap[k], v) + } + } + return baseMap +} + +// MergePatchBytes merges base JSON with a patch document and returns +// the result as pretty-printed JSON bytes. +func MergePatchBytes(base, patch []byte) ([]byte, error) { + var baseVal, patchVal any + if err := json.Unmarshal(base, &baseVal); err != nil { + return nil, fmt.Errorf("unmarshal base: %w", err) + } + if err := json.Unmarshal(patch, &patchVal); err != nil { + return nil, fmt.Errorf("unmarshal patch: %w", err) + } + + merged := MergePatch(baseVal, patchVal) + out, err := json.MarshalIndent(merged, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal merged: %w", err) + } + return append(out, '\n'), nil +} diff --git a/cli/internal/jsonmerge/merge_test.go b/cli/internal/jsonmerge/merge_test.go new file mode 100644 index 00000000..b00a64d3 --- /dev/null +++ b/cli/internal/jsonmerge/merge_test.go @@ -0,0 +1,159 @@ +package jsonmerge + +import ( + "encoding/json" + "testing" +) + +func TestMergePatch(t *testing.T) { + tests := []struct { + name string + base string + patch string + want string + }{ + { + name: "scalar replacement", + base: `{"a": 1}`, + patch: `{"a": 2}`, + want: `{"a": 2}`, + }, + { + name: "add key", + base: `{"a": 1}`, + patch: `{"b": 2}`, + want: `{"a": 1, "b": 2}`, + }, + { + name: "delete key with null", + base: `{"a": 1, "b": 2}`, + patch: `{"b": null}`, + want: `{"a": 1}`, + }, + { + name: "array replacement", + base: `{"a": [1, 2, 3]}`, + patch: `{"a": [4, 5]}`, + want: `{"a": [4, 5]}`, + }, + { + name: "nested merge", + base: `{"a": {"b": 1, "c": 2}}`, + patch: `{"a": {"b": 3}}`, + want: `{"a": {"b": 3, "c": 2}}`, + }, + { + name: "nested delete", + base: `{"a": {"b": 1, "c": 2}}`, + patch: `{"a": {"c": null}}`, + want: `{"a": {"b": 1}}`, + }, + { + name: "replace object with scalar", + base: `{"a": {"b": 1}}`, + patch: `{"a": "hello"}`, + want: `{"a": "hello"}`, + }, + { + name: "replace scalar with object", + base: `{"a": "hello"}`, + patch: `{"a": {"b": 1}}`, + want: `{"a": {"b": 1}}`, + }, + { + name: "empty patch is noop", + base: `{"a": 1}`, + patch: `{}`, + want: `{"a": 1}`, + }, + { + name: "patch non-object base", + base: `"hello"`, + patch: `{"a": 1}`, + want: `{"a": 1}`, + }, + { + name: "deep nested merge", + base: `{"a": {"b": {"c": 1, "d": 2}, "e": 3}}`, + patch: `{"a": {"b": {"c": 99}}}`, + want: `{"a": {"b": {"c": 99, "d": 2}, "e": 3}}`, + }, + { + name: "replace nested object with empty object", + base: `{"a": {"b": {"c": 1}}}`, + patch: `{"a": {"b": {}}}`, + want: `{"a": {"b": {"c": 1}}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var base, patch, want any + if err := json.Unmarshal([]byte(tt.base), &base); err != nil { + t.Fatalf("bad base: %v", err) + } + if err := json.Unmarshal([]byte(tt.patch), &patch); err != nil { + t.Fatalf("bad patch: %v", err) + } + if err := json.Unmarshal([]byte(tt.want), &want); err != nil { + t.Fatalf("bad want: %v", err) + } + + got := MergePatch(base, patch) + + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("got %s, want %s", gotJSON, wantJSON) + } + }) + } +} + +func TestMergePatchBytes(t *testing.T) { + base := []byte(`{"a": {"b": 1, "c": 2}, "d": 3}`) + patch := []byte(`{"a": {"b": 99, "c": null}, "e": 4}`) + + got, err := MergePatchBytes(base, patch) + if err != nil { + t.Fatalf("MergePatchBytes: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + a := result["a"].(map[string]any) + if a["b"] != float64(99) { + t.Errorf("a.b = %v, want 99", a["b"]) + } + if _, exists := a["c"]; exists { + t.Error("a.c should have been deleted") + } + if result["d"] != float64(3) { + t.Errorf("d = %v, want 3", result["d"]) + } + if result["e"] != float64(4) { + t.Errorf("e = %v, want 4", result["e"]) + } +} + +func TestMergePatchDoesNotMutateBase(t *testing.T) { + var base, patch any + if err := json.Unmarshal([]byte(`{"a": {"b": 1}}`), &base); err != nil { + t.Fatalf("unmarshal base: %v", err) + } + if err := json.Unmarshal([]byte(`{"a": {"b": 2}}`), &patch); err != nil { + t.Fatalf("unmarshal patch: %v", err) + } + + MergePatch(base, patch) + + // Original base should be unchanged. + baseMap := base.(map[string]any) + inner := baseMap["a"].(map[string]any) + if inner["b"] != float64(1) { + t.Error("MergePatch mutated the base map") + } +} diff --git a/cli/internal/labmap/labmap.go b/cli/internal/labmap/labmap.go index 6b46c553..ddb4dac3 100644 --- a/cli/internal/labmap/labmap.go +++ b/cli/internal/labmap/labmap.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/dreadnode/dreadgoad/internal/jsonmerge" ) // HostInfo holds hostname and domain mappings for a single host (variant support). @@ -484,28 +486,55 @@ type variantMapping struct { } // LoadFromSource reads the lab config and builds a fully populated LabMap. -// If env is non-empty, it first tries {env}-config.json (matching the Ansible -// vars plugin behaviour) and falls back to config.json. +// If env is non-empty, it tries (in order): merging config.json with +// {env}-overlay.json, then a legacy {env}-config.json, then config.json. func LoadFromSource(sourceDir, env string) (*LabMap, error) { dataDir := filepath.Join(sourceDir, "data") - var path string - if env != "" { - envPath := filepath.Join(dataDir, env+"-config.json") - if _, err := os.Stat(envPath); err == nil { - path = envPath - } - } - if path == "" { - path = filepath.Join(dataDir, "config.json") + data, err := resolveConfigData(dataDir, env) + if err != nil { + return nil, fmt.Errorf("read lab config: %w", err) } + return parseConfig(data) +} + +// LoadFromPath reads a lab config JSON file directly and builds a LabMap. +func LoadFromPath(path string) (*LabMap, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read lab config: %w", err) } - return parseConfig(data) } +// resolveConfigData returns the final config JSON for a given environment. +func resolveConfigData(dataDir, env string) ([]byte, error) { + basePath := filepath.Join(dataDir, "config.json") + + if env != "" { + // Prefer overlay merge. + overlayPath := filepath.Join(dataDir, env+"-overlay.json") + if _, err := os.Stat(overlayPath); err == nil { + base, err := os.ReadFile(basePath) + if err != nil { + return nil, fmt.Errorf("read base config: %w", err) + } + overlay, err := os.ReadFile(overlayPath) + if err != nil { + return nil, fmt.Errorf("read overlay: %w", err) + } + return jsonmerge.MergePatchBytes(base, overlay) + } + + // Legacy full env config. + envPath := filepath.Join(dataDir, env+"-config.json") + if _, err := os.Stat(envPath); err == nil { + return os.ReadFile(envPath) + } + } + + return os.ReadFile(basePath) +} + // 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. diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 8d8c9545..629ccd5d 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -174,6 +174,7 @@ type Generator struct { replacements []replacement userPasswordMap map[string]string // new_username -> new_password preservedUsers map[string]bool + pwdInDescUsers map[string]bool // new_username -> has password in description } // hostnameAliases maps canonical hostnames to known typos/aliases in upstream GOAD. @@ -202,6 +203,7 @@ func NewGenerator(source, target, name string) *Generator { }, userPasswordMap: make(map[string]string), preservedUsers: map[string]bool{"sql_svc": true}, + pwdInDescUsers: make(map[string]bool), } } @@ -363,6 +365,11 @@ func (g *Generator) mapUsers(config *LabConfig) { newUsername := g.nameGen.GenerateUsername() g.mappings.Users[username] = newUsername + if user != nil && user.Password != "" && user.Description != "" && + strings.Contains(strings.ToLower(user.Description), strings.ToLower(user.Password)) { + g.pwdInDescUsers[newUsername] = true + } + g.mapUserNameComponents(user, newUsername) fmt.Printf(" %s -> %s\n", username, newUsername) @@ -754,7 +761,16 @@ func (g *Generator) fixUserFirstnameSurname(config *LabConfig) { user.Surname = parts[1] } if user.Description != "" { - user.Description = capitalize(parts[0]) + " " + capitalize(parts[1]) + displayName := capitalize(parts[0]) + " " + capitalize(parts[1]) + if g.pwdInDescUsers[username] { + if pw, ok := g.userPasswordMap[username]; ok { + user.Description = displayName + " (Password : " + pw + ")" + } else { + user.Description = displayName + } + } else { + user.Description = displayName + } } } } @@ -846,7 +862,9 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { newContent := g.applyReplacements(string(content)) - if base == "config.json" || strings.HasSuffix(base, "-config.json") { + isFullConfig := (base == "config.json" || strings.HasSuffix(base, "-config.json")) && + !strings.HasSuffix(base, "-overlay.json") + if isFullConfig { var configData LabConfig if err := json.Unmarshal([]byte(newContent), &configData); err == nil { g.fixUserFirstnameSurname(&configData) diff --git a/cli/internal/variant/generator_test.go b/cli/internal/variant/generator_test.go index eac02b58..44a99b1f 100644 --- a/cli/internal/variant/generator_test.go +++ b/cli/internal/variant/generator_test.go @@ -63,6 +63,12 @@ func testConfig() *LabConfig { Password: "NeedleIsMySword!", City: "Winterfell", }, + "samwell.tarly": { + Firstname: "samwell", + Surname: "tarly", + Password: "Heartsbane", + Description: "Samwell Tarly (Password : Heartsbane)", + }, "sql_svc": { Firstname: "sql", Surname: "-", @@ -146,6 +152,44 @@ func TestGeneratorEndToEnd(t *testing.T) { } } +func TestPasswordInDescriptionPreserved(t *testing.T) { + sourceDir, targetDir := setupTestSource(t) + + gen := NewGenerator(sourceDir, targetDir, "test-pwd-desc") + if err := gen.Run(); err != nil { + t.Fatalf("generator failed: %v", err) + } + + transformedData, err := os.ReadFile(filepath.Join(targetDir, "data", "config.json")) + if err != nil { + t.Fatal(err) + } + + var config LabConfig + if err := json.Unmarshal(transformedData, &config); err != nil { + t.Fatal(err) + } + + // Find the transformed user that was samwell.tarly + newUsername := gen.mappings.Users["samwell.tarly"] + if newUsername == "" { + t.Fatal("samwell.tarly not found in user mappings") + } + + for _, domain := range config.Lab.Domains { + if user, ok := domain.Users[newUsername]; ok { + if !strings.Contains(user.Description, "(Password :") { + t.Errorf("password-in-description pattern lost for %s: got %q", newUsername, user.Description) + } + if !strings.Contains(user.Description, user.Password) { + t.Errorf("description should contain the new password for %s: desc=%q password=%q", newUsername, user.Description, user.Password) + } + return + } + } + t.Errorf("transformed user %s not found in any domain", newUsername) +} + func TestApplyReplacements(t *testing.T) { gen := NewGenerator("", "", "test") gen.mappings.Misc["robert"] = "james" diff --git a/docs/cli.md b/docs/cli.md index 79b5a7a6..0c7958a1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -118,6 +118,63 @@ that preserve all structural relationships and vulnerabilities. `dreadgoad provision` or `dreadgoad variant generate` to get fresh randomized names. +## Lab Config Overlays + +Each lab stores its canonical configuration in a single `config.json` file +(e.g. `ad/GOAD/data/config.json`). Per-environment differences are captured +in small **overlay** files rather than full copies: + +```text +ad/GOAD/data/ +├── config.json # Single source of truth (~32 KB) +├── dev-overlay.json # Only the dev-specific diffs (~1.8 KB) +├── staging-overlay.json # Only the staging-specific diffs +└── test-overlay.json # Only the test-specific diffs +``` + +Overlays use [RFC 7386 JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7386) +semantics: + +- **Objects** merge recursively — only changed keys need to appear. +- **Arrays and scalars** in the overlay replace the base value wholesale. +- **`null`** removes a key from the base. + +For example, a `dev-overlay.json` that removes ADCS vulns from `dc01` and +adds a script to `dc02`: + +```json +{ + "lab": { + "hosts": { + "dc01": { "vulns": ["disable_firewall"] }, + "dc02": { "scripts": ["...", "unconstrained_delegation_user.ps1"] } + } + } +} +``` + +### Resolution order + +At runtime `dreadgoad` resolves the lab config for the active environment as: + +1. `{env}-overlay.json` exists → merge `config.json` + overlay, cache result + in `.dreadgoad/cache/{env}-config.json` +2. Legacy `{env}-config.json` exists → use it directly (backward compatible) +3. Neither exists → use `config.json` as-is + +Variant environments follow the same logic but read from the variant target +directory (e.g. `ad/GOAD-variant-1/data/`). + +### Creating overlays for a new environment + +`dreadgoad env create ` creates an overlay file automatically: + +- **Without `--variant`**: copies `dev-overlay.json` as a starting template + (or creates an empty `{}` overlay). +- **With `--variant`**: generates a full randomized variant; overlay files + in the source are also transformed through the variant's replacement + pipeline. + ## Environment Variables All config keys can be set via environment variables with the diff --git a/docs/mkdocs/docs/developers/add_lab.md b/docs/mkdocs/docs/developers/add_lab.md index 4efd129b..2a830d47 100644 --- a/docs/mkdocs/docs/developers/add_lab.md +++ b/docs/mkdocs/docs/developers/add_lab.md @@ -71,6 +71,13 @@ Key fields per host: See `ad/GOAD/data/config.json` for a complete reference example. +### Environment overlays + +Rather than maintaining full config copies per environment, create small +overlay files (`{env}-overlay.json`) that contain only the fields that +differ from the base `config.json`. The CLI merges them at runtime using +RFC 7386 JSON Merge Patch. See `docs/cli.md` in the repository for the overlay format and resolution order. + ## Inventory files The `data/inventory` file is an Ansible inventory that defines host groups and connection variables (WinRM settings, credentials). Each provider also has its own `inventory` file under `providers//` that overrides connection-specific values (IP addresses, ports) for that provider. diff --git a/docs/mkdocs/docs/provisioning.md b/docs/mkdocs/docs/provisioning.md index 4f52be9c..9e25fe6c 100644 --- a/docs/mkdocs/docs/provisioning.md +++ b/docs/mkdocs/docs/provisioning.md @@ -7,7 +7,9 @@ The provisioning of the LABS is done with Ansible for all providers. ## Lab data -The data of each lab are stored in the json file : `ad//data/config.json`, this file is loaded by each playbook to get all the lab variables (this is done by the data.yml playbook call by all the over playbooks) +Each lab stores its canonical configuration in `ad//data/config.json`. This file is loaded by each playbook to get all the lab variables (via the `data.yml` playbook called by all other playbooks). + +Per-environment differences are stored as small **overlay** files (`{env}-overlay.json`) alongside `config.json`, rather than full duplicate copies. At runtime the CLI merges `config.json` + the overlay using RFC 7386 JSON Merge Patch semantics and passes the merged result to Ansible. See `docs/cli.md` in the repository for overlay format details and resolution order. ## Extension data From 65be8cdfc8f958a6ae0a6f9df2128031fe8ce541 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 22 Apr 2026 11:44:20 -0600 Subject: [PATCH 6/6] fix: improve error handling when renaming cache file in config merging **Changed:** - Enhance error handling for cache file renaming by returning combined error if both the rename and cleanup (temporary file removal) fail, providing more context for debugging in `mergedConfigPath` function --- cli/internal/config/config.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 7efbbd73..7504d9c9 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -231,7 +231,9 @@ func (c *Config) mergedConfigPath(basePath, overlayPath string) (string, error) return "", fmt.Errorf("write cache: %w", err) } if err := os.Rename(tmp, cachePath); err != nil { - os.Remove(tmp) + if rmErr := os.Remove(tmp); rmErr != nil { + return "", fmt.Errorf("rename cache: %w; cleanup: %w", err, rmErr) + } return "", fmt.Errorf("rename cache: %w", err) }