diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 738e3860..b8a7f6f1 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -40,6 +40,12 @@ type HostConfig struct { MSSQL *MSSQLConfig `json:"mssql,omitempty"` // RemoteDesktopUsers appears at host top-level in some upstream GOAD configs. RemoteDesktopUsers []string `json:"Remote Desktop Users,omitempty"` + // OS marks non-Windows hosts (DRACARYS uses "linux" for lx01). + OS string `json:"os,omitempty"` + // VulnsADCSTemplates lists the certificate templates published for this + // host; read by cli/internal/ansible/prepare.go to drive the + // vulns_adcs_templates role. + VulnsADCSTemplates []string `json:"vulns_adcs_templates,omitempty"` } // MSSQLConfig holds MSSQL server configuration for a host. @@ -87,6 +93,14 @@ type DomainConfig struct { GMSA map[string]GMSAConfig `json:"gmsa,omitempty"` ACLs map[string]ACLConfig `json:"acls"` Users map[string]*UserConfig `json:"users"` + // CAWebEnrollment is a pointer so an explicit false survives the round-trip. + // ansible/playbooks/adcs.yml treats absent as true, so dropping a false here + // silently turns CA web enrollment back on in the generated variant. + CAWebEnrollment *bool `json:"ca_web_enrollment,omitempty"` + // SCCM holds the MECM/SCCM site configuration (SCCM lab only). Kept as a raw + // map because the generator has no reason to reshape it; entity names inside + // are still randomized by the text-level replacement pass. + SCCM map[string]any `json:"sccm,omitempty"` } // OUConfig represents an organisational unit. diff --git a/cli/internal/variant/roundtrip_test.go b/cli/internal/variant/roundtrip_test.go new file mode 100644 index 00000000..80050cfd --- /dev/null +++ b/cli/internal/variant/roundtrip_test.go @@ -0,0 +1,100 @@ +package variant + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +// collectKeys walks a decoded JSON object and records every key path it reaches, +// skipping paths whose value is an empty string, slice, or map. Empty values are +// excluded because `omitempty` drops them on re-marshal, and absent is +// semantically identical to empty for every field in the lab schema. +func collectKeys(m map[string]any, prefix string, out map[string]bool) { + for k, v := range m { + path := prefix + "." + k + switch val := v.(type) { + case map[string]any: + if len(val) == 0 { + continue + } + out[path] = true + collectKeys(val, path, out) + case []any: + if len(val) == 0 { + continue + } + out[path] = true + case string: + if val == "" { + continue + } + out[path] = true + default: + out[path] = true + } + } +} + +// TestConfigRoundTripPreservesKeys guards against the generator silently +// dropping lab config keys. `variant generate` decodes config.json into +// LabConfig, mutates it, and re-encodes it, so any key the structs don't model +// is lost. That produced a real defect: GOAD-variant-1 was generated without +// the `vulns_adcs_templates` scoreboard annotation, and a generated SCCM variant +// would have lost its entire `sccm` block. Every lab config is checked so that +// adding a key to any config without adding a struct field fails here. +func TestConfigRoundTripPreservesKeys(t *testing.T) { + configs, err := filepath.Glob("../../../ad/*/data/config.json") + if err != nil { + t.Fatal(err) + } + if len(configs) == 0 { + t.Fatal("no lab configs found; check the glob path") + } + + for _, path := range configs { + lab := filepath.Base(filepath.Dir(filepath.Dir(path))) + t.Run(lab, func(t *testing.T) { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + var before map[string]any + if err := json.Unmarshal(raw, &before); err != nil { + t.Fatalf("decode as generic map: %v", err) + } + + var cfg LabConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("decode as LabConfig: %v", err) + } + encoded, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("re-encode LabConfig: %v", err) + } + var after map[string]any + if err := json.Unmarshal(encoded, &after); err != nil { + t.Fatalf("decode round-tripped output: %v", err) + } + + want := map[string]bool{} + collectKeys(before, "", want) + got := map[string]bool{} + collectKeys(after, "", got) + + var lost []string + for k := range want { + if !got[k] { + lost = append(lost, k) + } + } + sort.Strings(lost) + for _, k := range lost { + t.Errorf("key dropped by LabConfig round-trip: %s", k) + } + }) + } +}