diff --git a/internal/datagen/applications.go b/internal/datagen/applications.go new file mode 100644 index 0000000..41e7a23 --- /dev/null +++ b/internal/datagen/applications.go @@ -0,0 +1,11 @@ +package datagen + +// ApplicationIdentity represents installed software on a system. +type ApplicationIdentity struct { + Name string // "Microsoft SQL Server 2019" + Version string // "15.0.4322.2" + Vendor string // "Microsoft Corporation" + InstallPath string // "C:\Program Files\Microsoft SQL Server" + InstallDate string // "2024-06-15" + SystemRef string // back-reference: hostname of owning system +} diff --git a/internal/datagen/services.go b/internal/datagen/services.go new file mode 100644 index 0000000..38834bd --- /dev/null +++ b/internal/datagen/services.go @@ -0,0 +1,22 @@ +package datagen + +// ServiceStartType represents the startup type of a Windows/Linux service. +type ServiceStartType string + +const ( + StartAutomatic ServiceStartType = "Automatic" + StartManual ServiceStartType = "Manual" + StartDisabled ServiceStartType = "Disabled" + StartBoot ServiceStartType = "Boot" + StartSystem ServiceStartType = "System" +) + +// ServiceIdentity represents a service running on a system. +type ServiceIdentity struct { + Name string // "wuauserv" + DisplayName string // "Windows Update" + BinaryPath string // "C:\Windows\System32\svchost.exe -k netsvcs" + StartType ServiceStartType // Automatic, Manual, etc. + Account string // "LocalSystem", "NT AUTHORITY\NETWORK SERVICE", or a UPN + SystemRef string // back-reference: hostname of owning system +} diff --git a/internal/datagen/systems.go b/internal/datagen/systems.go new file mode 100644 index 0000000..2fc3f4d --- /dev/null +++ b/internal/datagen/systems.go @@ -0,0 +1,255 @@ +package datagen + +import ( + "fmt" + "math/rand" + "strings" + "time" +) + +// OSType represents an operating system type. +type OSType string + +const ( + OSLinux OSType = "linux" + OSWindows OSType = "windows" + OSMacOS OSType = "macos" +) + +// Arch represents a CPU architecture. +type Arch string + +const ( + ArchAMD64 Arch = "amd64" + ArchARM64 Arch = "arm64" + ArchX86 Arch = "x86" +) + +// SystemRole represents a machine's role in the environment. +type SystemRole string + +const ( + RoleServer SystemRole = "server" + RoleWorkstation SystemRole = "workstation" + RoleDC SystemRole = "dc" + RoleRouter SystemRole = "router" +) + +// OS version pools. +var ( + LinuxVersions = NewPool( + "5.15.0-91-generic", // Ubuntu 22.04 + "6.1.0-18-amd64", // Debian 12 + "5.14.0-362.el9", // RHEL 9 + "6.6.9-200.fc39", // Fedora 39 + "5.10.0-27-amd64", // Debian 11 + ) + + WindowsVersions = NewPool( + "10.0.20348", // Server 2022 + "10.0.17763", // Server 2019 + "10.0.14393", // Server 2016 + "10.0.22631", // Windows 11 23H2 + "10.0.19045", // Windows 10 22H2 + ) + + MacOSVersions = NewPool( + "14.2.1", // Sonoma + "13.6.3", // Ventura + "12.7.2", // Monterey + ) +) + +// SystemIdentity represents a machine in the simulated environment. +type SystemIdentity struct { + Hostname string + FQDN string // hostname + domain + OS OSType + OSVersion string + Arch Arch + Role SystemRole + Domain string // back-reference to DomainIdentity.Name + OUPath string // "OU=Servers,DC=contoso,DC=com" + + // Hardware + CPUCores int + MemoryMB int + DiskGB int + + // Network interfaces (populated by environment generation) + Interfaces []NetworkInterface + + // TLS cert issued by the domain CA + Cert *CertInfo + + // Sub-identities (populated by environment generation) + Services []*ServiceIdentity + Applications []*ApplicationIdentity +} + +// NetworkInterface represents a NIC bound to a network subnet. +type NetworkInterface struct { + Name string // "eth0", "Ethernet0" + IPv4 string + IPv6 string + MACAddress string + SubnetID string // references NetworkIdentity.ID + VLAN int +} + +// CertInfo represents a TLS certificate issued by the domain CA. +type CertInfo struct { + SubjectCN string // = FQDN + Issuer string // = DomainIdentity.CA.CommonName + SerialNumber string // hex serial + Thumbprint string // SHA1 hex (40 chars) + ValidFrom time.Time + ValidTo time.Time + SANs []string // [FQDN, hostname, IPv4] +} + +// GenerateSystemIdentity creates a system with the given OS, role, and domain context. +// +// domain must be non-nil and have a populated CA — those fields are used +// unconditionally to construct the system's FQDN, OU path, and TLS cert. +// Passing a nil domain or a domain with a nil CA panics with a clear +// message; this is a developer-error class of failure (the same input +// would have been caught at the first test run). PIPE-1003 tracks +// converting these panics to error returns ahead of the embed seam split. +func GenerateSystemIdentity(r *rand.Rand, os OSType, role SystemRole, domain *DomainIdentity, names *Pool[string]) *SystemIdentity { + if domain == nil { + panic("datagen: GenerateSystemIdentity: domain must not be nil") + } + if domain.CA == nil { + panic("datagen: GenerateSystemIdentity: domain.CA must not be nil") + } + + // Pick hostname style based on OS + var style HostnameStyle + switch { + case role == RoleDC: + style = StyleDC + case os == OSWindows: + style = StyleWindows + default: + style = StyleLinux + } + + hostname := GenerateHostname(r, style, names) + fqdn := hostname + "." + domain.Name + if os == OSWindows || role == RoleDC { + fqdn = strings.ToLower(hostname) + "." + domain.Name + } + + // Pick OS version + var osVersion string + switch os { + case OSLinux: + osVersion = LinuxVersions.Random(r) + case OSWindows: + osVersion = WindowsVersions.Random(r) + case OSMacOS: + osVersion = MacOSVersions.Random(r) + } + + // Pick arch + arch := ArchAMD64 + if r.Float64() < 0.1 { // #nosec G404 + arch = ArchARM64 + } + + // Generate resource specs based on role + cpu, mem, disk := generateResourceSpecs(r, role) + + // Generate OU path + ouPath := generateOUPath(role, domain.Name) + + // Generate TLS cert + cert := generateCertInfo(r, fqdn, hostname, domain.CA) + + return &SystemIdentity{ + Hostname: hostname, + FQDN: fqdn, + OS: os, + OSVersion: osVersion, + Arch: arch, + Role: role, + Domain: domain.Name, + OUPath: ouPath, + CPUCores: cpu, + MemoryMB: mem, + DiskGB: disk, + Cert: cert, + } +} + +// generateResourceSpecs returns CPU, memory, disk based on role. +func generateResourceSpecs(r *rand.Rand, role SystemRole) (cpu, mem, disk int) { + switch role { + case RoleWorkstation: + cpu = randRange(r, 4, 16) + mem = randRange(r, 8192, 32768) + disk = randRange(r, 256, 1024) + case RoleServer: + cpu = randRange(r, 4, 64) + mem = randRange(r, 16384, 131072) + disk = randRange(r, 500, 4096) + case RoleDC: + cpu = randRange(r, 4, 16) + mem = randRange(r, 16384, 65536) + disk = randRange(r, 500, 2048) + case RoleRouter: + cpu = randRange(r, 2, 4) + mem = randRange(r, 2048, 8192) + disk = randRange(r, 64, 256) + default: + cpu = randRange(r, 4, 16) + mem = randRange(r, 8192, 32768) + disk = randRange(r, 256, 1024) + } + return +} + +// randRange returns a random int in [min, max] inclusive. +func randRange(r *rand.Rand, min, max int) int { + return min + r.Intn(max-min+1) // #nosec G404 +} + +// generateOUPath creates an AD OU path based on role. +func generateOUPath(role SystemRole, domainName string) string { + parts := strings.Split(domainName, ".") + dcParts := make([]string, len(parts)) + for i, p := range parts { + dcParts[i] = "DC=" + p + } + dcSuffix := strings.Join(dcParts, ",") + + var ou string + switch role { + case RoleDC: + ou = "OU=Domain Controllers" + case RoleServer: + ou = "OU=Servers" + case RoleWorkstation: + ou = "OU=Workstations" + case RoleRouter: + ou = "OU=Network Devices" + default: + ou = "OU=Computers" + } + return fmt.Sprintf("%s,%s", ou, dcSuffix) +} + +// generateCertInfo creates a TLS cert for a system. +func generateCertInfo(r *rand.Rand, fqdn, hostname string, ca *CertAuthority) *CertInfo { + now := time.Now() + return &CertInfo{ + SubjectCN: fqdn, + Issuer: ca.CommonName, + SerialNumber: randomHex(r, 8), + Thumbprint: randomHex(r, 20), + ValidFrom: now.AddDate(-1, 0, 0), + ValidTo: now.AddDate(1, 0, 0), + SANs: []string{fqdn, hostname}, + } +} diff --git a/internal/datagen/systems_test.go b/internal/datagen/systems_test.go new file mode 100644 index 0000000..6f8778c --- /dev/null +++ b/internal/datagen/systems_test.go @@ -0,0 +1,172 @@ +package datagen + +import ( + "math/rand" + "strings" + "testing" + "time" +) + +func TestOSTypes(t *testing.T) { + types := []OSType{OSLinux, OSWindows, OSMacOS} + for _, os := range types { + if os == "" { + t.Error("OSType should not be empty") + } + } +} + +func TestArchTypes(t *testing.T) { + types := []Arch{ArchAMD64, ArchARM64, ArchX86} + for _, a := range types { + if a == "" { + t.Error("Arch should not be empty") + } + } +} + +func TestSystemRoles(t *testing.T) { + roles := []SystemRole{RoleServer, RoleWorkstation, RoleDC, RoleRouter} + for _, r := range roles { + if r == "" { + t.Error("SystemRole should not be empty") + } + } +} + +func TestVersionPools(t *testing.T) { + if LinuxVersions.Len() < 3 { + t.Errorf("LinuxVersions has %d items, want at least 3", LinuxVersions.Len()) + } + if WindowsVersions.Len() < 3 { + t.Errorf("WindowsVersions has %d items, want at least 3", WindowsVersions.Len()) + } + if MacOSVersions.Len() < 3 { + t.Errorf("MacOSVersions has %d items, want at least 3", MacOSVersions.Len()) + } +} + +func TestGenerateSystemIdentity(t *testing.T) { + r := rand.New(rand.NewSource(42)) + domain := GenerateDomainIdentity(42, "contoso.com", time.Now()) + + t.Run("linux server", func(t *testing.T) { + sys := GenerateSystemIdentity(r, OSLinux, RoleServer, domain, NorseNames) + if sys.OS != OSLinux { + t.Errorf("expected OS %q, got %q", OSLinux, sys.OS) + } + if sys.Role != RoleServer { + t.Errorf("expected Role %q, got %q", RoleServer, sys.Role) + } + if sys.Domain != "contoso.com" { + t.Errorf("expected Domain 'contoso.com', got %q", sys.Domain) + } + if !strings.HasSuffix(sys.FQDN, ".contoso.com") { + t.Errorf("FQDN %q should end with '.contoso.com'", sys.FQDN) + } + if sys.CPUCores < 4 || sys.CPUCores > 64 { + t.Errorf("server CPUCores %d out of range [4,64]", sys.CPUCores) + } + if sys.MemoryMB < 16384 { + t.Errorf("server MemoryMB %d should be >= 16384", sys.MemoryMB) + } + }) + + t.Run("windows workstation", func(t *testing.T) { + sys := GenerateSystemIdentity(r, OSWindows, RoleWorkstation, domain, RomanNames) + if sys.OS != OSWindows { + t.Errorf("expected OS %q, got %q", OSWindows, sys.OS) + } + if sys.Hostname != strings.ToUpper(sys.Hostname) { + t.Errorf("windows hostname %q should be uppercase", sys.Hostname) + } + }) + + t.Run("domain controller", func(t *testing.T) { + sys := GenerateSystemIdentity(r, OSWindows, RoleDC, domain, GreekNames) + if sys.Role != RoleDC { + t.Errorf("expected Role %q, got %q", RoleDC, sys.Role) + } + if !strings.Contains(sys.Hostname, "-DC") { + t.Errorf("DC hostname %q should contain '-DC'", sys.Hostname) + } + }) + + t.Run("has cert", func(t *testing.T) { + sys := GenerateSystemIdentity(r, OSLinux, RoleServer, domain, NorseNames) + if sys.Cert == nil { + t.Fatal("expected system to have a cert") + } + if sys.Cert.SubjectCN != sys.FQDN { + t.Errorf("cert SubjectCN %q should equal FQDN %q", sys.Cert.SubjectCN, sys.FQDN) + } + if sys.Cert.Issuer != domain.CA.CommonName { + t.Errorf("cert Issuer %q should equal CA CommonName %q", sys.Cert.Issuer, domain.CA.CommonName) + } + }) + + t.Run("has OU path", func(t *testing.T) { + sys := GenerateSystemIdentity(r, OSWindows, RoleServer, domain, RomanNames) + if !strings.HasPrefix(sys.OUPath, "OU=") { + t.Errorf("OUPath %q should start with 'OU='", sys.OUPath) + } + if !strings.Contains(sys.OUPath, "DC=contoso") { + t.Errorf("OUPath %q should contain 'DC=contoso'", sys.OUPath) + } + }) +} + +func TestGenerateSystemIdentityPanicsOnNilInputs(t *testing.T) { + r := rand.New(rand.NewSource(1)) + domain := GenerateDomainIdentity(1, "", time.Now()) + + t.Run("nil domain panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("expected panic on nil domain, got none") + } + }() + GenerateSystemIdentity(r, OSLinux, RoleServer, nil, NorseNames) + }) + + t.Run("domain with nil CA panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("expected panic on nil domain.CA, got none") + } + }() + brokenDomain := &DomainIdentity{Name: domain.Name, DomainSID: domain.DomainSID, CA: nil} + GenerateSystemIdentity(r, OSLinux, RoleServer, brokenDomain, NorseNames) + }) +} + +func TestSystemResourceRanges(t *testing.T) { + r := rand.New(rand.NewSource(1)) + domain := GenerateDomainIdentity(1, "", time.Now()) + + specs := map[SystemRole]struct { + minCPU, maxCPU int + minMem, maxMem int + minDisk, maxDisk int + }{ + RoleWorkstation: {4, 16, 8192, 32768, 256, 1024}, + RoleServer: {4, 64, 16384, 131072, 500, 4096}, + RoleDC: {4, 16, 16384, 65536, 500, 2048}, + RoleRouter: {2, 4, 2048, 8192, 64, 256}, + } + + for role, spec := range specs { + for i := 0; i < 50; i++ { + sys := GenerateSystemIdentity(r, OSLinux, role, domain, NorseNames) + if sys.CPUCores < spec.minCPU || sys.CPUCores > spec.maxCPU { + t.Errorf("role %s: CPUCores %d out of [%d,%d]", role, sys.CPUCores, spec.minCPU, spec.maxCPU) + } + if sys.MemoryMB < spec.minMem || sys.MemoryMB > spec.maxMem { + t.Errorf("role %s: MemoryMB %d out of [%d,%d]", role, sys.MemoryMB, spec.minMem, spec.maxMem) + } + if sys.DiskGB < spec.minDisk || sys.DiskGB > spec.maxDisk { + t.Errorf("role %s: DiskGB %d out of [%d,%d]", role, sys.DiskGB, spec.minDisk, spec.maxDisk) + } + } + } +}