diff --git a/internal/device/device.go b/internal/device/device.go index cc287eb..58f76b1 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -33,6 +33,7 @@ func Gather(ctx context.Context, exec executor.Executor) model.Device { OSVersion: osVersion, Platform: platform, UserIdentity: userIdentity, + Resources: gatherResources(ctx, exec), } } diff --git a/internal/device/resources.go b/internal/device/resources.go new file mode 100644 index 0000000..def74d3 --- /dev/null +++ b/internal/device/resources.go @@ -0,0 +1,225 @@ +package device + +import ( + "context" + "runtime" + "strconv" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// gatherResources collects static hardware capacity (CPU model/cores, RAM, +// disk capacity, architecture). Best-effort: missing values are zero/empty +// rather than fatal — a partial answer is still useful. +func gatherResources(ctx context.Context, exec executor.Executor) model.MachineResources { + res := model.MachineResources{ + CPUArchitecture: runtime.GOARCH, + LogicalCores: runtime.NumCPU(), + } + + switch exec.GOOS() { + case model.PlatformWindows: + cpuModel, physical, logical := getCPUInfoWindows(ctx, exec) + res.CPUModel = cpuModel + if physical > 0 { + res.PhysicalCores = physical + } + if logical > 0 { + res.LogicalCores = logical + } + res.MemoryBytes = getMemoryBytesWindows(ctx, exec) + res.DiskTotalBytes = getDiskTotalBytesWindows(exec) + case model.PlatformDarwin: + res.CPUModel = getCPUModelDarwin(ctx, exec) + if n := sysctlInt(ctx, exec, "hw.physicalcpu"); n > 0 { + res.PhysicalCores = n + } + if n := sysctlInt(ctx, exec, "hw.logicalcpu"); n > 0 { + res.LogicalCores = n + } + if n := sysctlUint64(ctx, exec, "hw.memsize"); n > 0 { + res.MemoryBytes = n + } + res.DiskTotalBytes = exec.DiskCapacityBytes("/") + default: // linux and other unix + cpuModel, physicalCores := parseProcCPUInfo(readFileOrEmpty(exec, "/proc/cpuinfo")) + res.CPUModel = cpuModel + if physicalCores > 0 { + res.PhysicalCores = physicalCores + } + if mem := parseProcMemInfo(readFileOrEmpty(exec, "/proc/meminfo")); mem > 0 { + res.MemoryBytes = mem + } + res.DiskTotalBytes = exec.DiskCapacityBytes("/") + } + + return res +} + +func readFileOrEmpty(exec executor.Executor, path string) []byte { + data, err := exec.ReadFile(path) + if err != nil { + return nil + } + return data +} + +// parseProcCPUInfo extracts the human-readable CPU model and the physical core +// count from /proc/cpuinfo on Linux. Returns ("", 0) if not parseable. +// +// On x86 the file contains repeated blocks separated by blank lines, with +// "model name" and "cpu cores" keys. On ARM there is typically no "model name"; +// callers should treat an empty model as best-effort missing data. +func parseProcCPUInfo(data []byte) (model string, physicalCores int) { + if len(data) == 0 { + return "", 0 + } + // Track unique (physical id, core id) pairs to count physical cores + // across multi-socket systems. Falls back to first "cpu cores" value + // when physical id is absent (single-socket). + seenCores := make(map[string]struct{}) + var firstCPUCores int + var currentPhysID string + + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := splitCPUInfoLine(line) + if !ok { + if strings.TrimSpace(line) == "" { + currentPhysID = "" + } + continue + } + switch key { + case "model name": + if model == "" { + model = value + } + case "Hardware", "Model": // ARM fallbacks + if model == "" { + model = value + } + case "physical id": + currentPhysID = value + case "core id": + seenCores[currentPhysID+":"+value] = struct{}{} + case "cpu cores": + if firstCPUCores == 0 { + if n, err := strconv.Atoi(value); err == nil { + firstCPUCores = n + } + } + } + } + + switch { + case len(seenCores) > 0: + physicalCores = len(seenCores) + case firstCPUCores > 0: + physicalCores = firstCPUCores + } + return model, physicalCores +} + +func splitCPUInfoLine(line string) (key, value string, ok bool) { + idx := strings.Index(line, ":") + if idx < 0 { + return "", "", false + } + return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), true +} + +// parseProcMemInfo returns total memory in bytes from /proc/meminfo on Linux. +// The MemTotal line is "MemTotal: 16277124 kB". Returns 0 if missing. +func parseProcMemInfo(data []byte) uint64 { + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "MemTotal:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return 0 + } + kb, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0 + } + return kb * 1024 + } + return 0 +} + +func getCPUModelDarwin(ctx context.Context, exec executor.Executor) string { + stdout, _, _, err := exec.Run(ctx, "sysctl", "-n", "machdep.cpu.brand_string") + if err != nil { + return "" + } + return strings.TrimSpace(stdout) +} + +func sysctlInt(ctx context.Context, exec executor.Executor, key string) int { + stdout, _, _, err := exec.Run(ctx, "sysctl", "-n", key) + if err != nil { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(stdout)) + if err != nil { + return 0 + } + return n +} + +func sysctlUint64(ctx context.Context, exec executor.Executor, key string) uint64 { + stdout, _, _, err := exec.Run(ctx, "sysctl", "-n", key) + if err != nil { + return 0 + } + n, err := strconv.ParseUint(strings.TrimSpace(stdout), 10, 64) + if err != nil { + return 0 + } + return n +} + +// parseCIMProcessorList parses one-field-per-line CIM/WMI output for the +// Win32_Processor class. Accepts both PowerShell Format-List ("Key : Value") +// and legacy wmic /format:list ("Key=Value") shapes. Multi-socket systems +// sum NumberOfCores / NumberOfLogicalProcessors across CPUs. +func parseCIMProcessorList(out string) (cpuModel string, physical, logical int) { + for _, line := range strings.Split(out, "\n") { + key, value := splitKVLine(line) + if key == "" { + continue + } + switch strings.ToLower(key) { + case "name": + if cpuModel == "" { + cpuModel = value + } + case "numberofcores": + if n, err := strconv.Atoi(value); err == nil { + physical += n + } + case "numberoflogicalprocessors": + if n, err := strconv.Atoi(value); err == nil { + logical += n + } + } + } + return cpuModel, physical, logical +} + +func splitKVLine(line string) (key, value string) { + line = strings.TrimSpace(line) + if line == "" { + return "", "" + } + if idx := strings.Index(line, "="); idx > 0 { + return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]) + } + if idx := strings.Index(line, ":"); idx > 0 { + return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]) + } + return "", "" +} diff --git a/internal/device/resources_other.go b/internal/device/resources_other.go new file mode 100644 index 0000000..21e2423 --- /dev/null +++ b/internal/device/resources_other.go @@ -0,0 +1,57 @@ +//go:build !windows + +package device + +import ( + "context" + "strconv" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// Non-Windows tests exercise the Windows code path via SetGOOS("windows"). +// This file provides command-stubbed implementations matching that pattern. + + +// Non-Windows builds of the agent never gather Windows resources at runtime +// (the dispatcher in gatherResources uses GOOS), but tests on a Linux/macOS +// host exercise the Windows code path by calling SetGOOS("windows") on the +// mock. The stubs below let those tests run by routing through exec.Run with +// command stubs for wmic / PowerShell, mirroring the pattern in +// device_other.go for serial/OS version. + +func getCPUInfoWindows(ctx context.Context, exec executor.Executor) (cpuModel string, physical, logical int) { + stdout, _, _, err := exec.Run(ctx, "powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Processor | Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | Format-List") + if err != nil { + return "", 0, 0 + } + return parseCIMProcessorList(stdout) +} + +func getMemoryBytesWindows(ctx context.Context, exec executor.Executor) uint64 { + stdout, _, _, err := exec.Run(ctx, "powershell", "-NoProfile", "-Command", + "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory") + if err != nil { + return 0 + } + n, err := strconv.ParseUint(strings.TrimSpace(stdout), 10, 64) + if err != nil { + return 0 + } + return n +} + +func getDiskTotalBytesWindows(exec executor.Executor) uint64 { + return exec.DiskCapacityBytes(windowsSystemDrive(exec)) +} + +func windowsSystemDrive(exec executor.Executor) string { + drive := strings.TrimSpace(exec.Getenv("SystemDrive")) + if drive == "" { + drive = "C:" + } + return drive + `\` +} + diff --git a/internal/device/resources_test.go b/internal/device/resources_test.go new file mode 100644 index 0000000..522f7cb --- /dev/null +++ b/internal/device/resources_test.go @@ -0,0 +1,330 @@ +package device + +import ( + "context" + "runtime" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestParseProcCPUInfo_X86(t *testing.T) { + // Two-physical-core x86 cpuinfo with hyperthreading (4 logical processors). + // Both physical cores share physical id 0; core ids are 0 and 1. + cpuinfo := []byte(`processor : 0 +vendor_id : GenuineIntel +model name : Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz +physical id : 0 +core id : 0 +cpu cores : 2 + +processor : 1 +vendor_id : GenuineIntel +model name : Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz +physical id : 0 +core id : 1 +cpu cores : 2 + +processor : 2 +vendor_id : GenuineIntel +model name : Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz +physical id : 0 +core id : 0 +cpu cores : 2 + +processor : 3 +vendor_id : GenuineIntel +model name : Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz +physical id : 0 +core id : 1 +cpu cores : 2 +`) + + model, physical := parseProcCPUInfo(cpuinfo) + if model != "Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz" { + t.Errorf("model: got %q", model) + } + if physical != 2 { + t.Errorf("physical cores: expected 2, got %d", physical) + } +} + +func TestParseProcCPUInfo_FallbackToCPUCores(t *testing.T) { + // Single-socket cpuinfo with no "physical id"/"core id" but with "cpu cores". + // We fall back to that count. + cpuinfo := []byte(`processor : 0 +model name : AMD Ryzen 5 3600 6-Core Processor +cpu cores : 6 +`) + model, physical := parseProcCPUInfo(cpuinfo) + if model != "AMD Ryzen 5 3600 6-Core Processor" { + t.Errorf("model: got %q", model) + } + if physical != 6 { + t.Errorf("physical cores: expected 6, got %d", physical) + } +} + +func TestParseProcCPUInfo_ARM(t *testing.T) { + // ARM /proc/cpuinfo has no "model name". Hardware/Model lines are best-effort. + cpuinfo := []byte(`processor : 0 +BogoMIPS : 50.00 +Features : fp asimd +CPU implementer : 0x41 +CPU architecture: 8 +CPU variant : 0x0 +CPU part : 0xd03 + +Hardware : BCM2835 +Model : Raspberry Pi 4 Model B Rev 1.4 +`) + model, physical := parseProcCPUInfo(cpuinfo) + // Expect Hardware or Model picked up; physical cores = 0 since there's no + // "cpu cores" or distinct (physical id, core id) pairs. + if model != "BCM2835" { + t.Errorf("model: expected BCM2835 (first Hardware line), got %q", model) + } + if physical != 0 { + t.Errorf("physical cores: expected 0 (unknown), got %d", physical) + } +} + +func TestParseProcCPUInfo_Empty(t *testing.T) { + model, physical := parseProcCPUInfo(nil) + if model != "" || physical != 0 { + t.Errorf("empty input: got %q, %d", model, physical) + } +} + +func TestParseProcMemInfo(t *testing.T) { + tests := []struct { + name string + input []byte + expected uint64 + }{ + { + name: "typical", + input: []byte("MemTotal: 16277124 kB\nMemFree: 12000000 kB\n"), + expected: 16277124 * 1024, + }, + { + name: "missing", + input: []byte("MemFree: 12000000 kB\n"), + expected: 0, + }, + { + name: "empty", + input: nil, + expected: 0, + }, + { + name: "garbage", + input: []byte("MemTotal: not-a-number kB\n"), + expected: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseProcMemInfo(tt.input) + if got != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, got) + } + }) + } +} + +func TestParseCIMProcessorList_PowerShellFormatList(t *testing.T) { + // PowerShell Format-List uses "Key : Value" with spaces. + psOut := ` + +Name : AMD EPYC 7763 64-Core Processor +NumberOfCores : 64 +NumberOfLogicalProcessors : 128 + +` + model, physical, logical := parseCIMProcessorList(psOut) + if model != "AMD EPYC 7763 64-Core Processor" { + t.Errorf("model: got %q", model) + } + if physical != 64 || logical != 128 { + t.Errorf("cores: expected 64/128, got %d/%d", physical, logical) + } +} + +func TestParseCIMProcessorList_LegacyWmic(t *testing.T) { + // Older Windows hosts that still ship wmic emit "Key=Value" lines. + wmic := "\r\n\r\nName=Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz\r\nNumberOfCores=8\r\nNumberOfLogicalProcessors=16\r\n\r\n" + model, physical, logical := parseCIMProcessorList(wmic) + if model != "Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz" { + t.Errorf("model: got %q", model) + } + if physical != 8 || logical != 16 { + t.Errorf("cores: expected 8/16, got %d/%d", physical, logical) + } +} + +func TestGather_LinuxResources(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHostname("linux-dev") + mock.SetUsername("dev") + mock.SetFile("/sys/class/dmi/id/product_serial", []byte("SERIAL\n")) + mock.SetFile("/etc/os-release", []byte(`PRETTY_NAME="Fedora Linux 42 (Cloud Edition)"`+"\n")) + mock.SetFile("/proc/sys/kernel/osrelease", []byte("6.19.12-100.fc42.x86_64\n")) + mock.SetFile("/proc/cpuinfo", []byte(`processor : 0 +model name : Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz +physical id : 0 +core id : 0 +cpu cores : 4 + +processor : 1 +model name : Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz +physical id : 0 +core id : 1 +cpu cores : 4 + +processor : 2 +model name : Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz +physical id : 0 +core id : 2 +cpu cores : 4 + +processor : 3 +model name : Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz +physical id : 0 +core id : 3 +cpu cores : 4 +`)) + mock.SetFile("/proc/meminfo", []byte("MemTotal: 16277124 kB\n")) + mock.SetDiskCapacityBytes("/", 500*1024*1024*1024) // 500 GiB + + dev := Gather(context.Background(), mock) + + if dev.Resources.CPUModel != "Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz" { + t.Errorf("cpu_model: got %q", dev.Resources.CPUModel) + } + if dev.Resources.PhysicalCores != 4 { + t.Errorf("physical_cores: expected 4, got %d", dev.Resources.PhysicalCores) + } + // LogicalCores defaults to runtime.NumCPU(); just verify it's populated. + if dev.Resources.LogicalCores != runtime.NumCPU() { + t.Errorf("logical_cores: expected runtime.NumCPU()=%d, got %d", runtime.NumCPU(), dev.Resources.LogicalCores) + } + if dev.Resources.MemoryBytes != 16277124*1024 { + t.Errorf("memory_bytes: expected %d, got %d", uint64(16277124)*1024, dev.Resources.MemoryBytes) + } + if dev.Resources.DiskTotalBytes != 500*1024*1024*1024 { + t.Errorf("disk_total_bytes: expected 500GiB, got %d", dev.Resources.DiskTotalBytes) + } + if dev.Resources.CPUArchitecture != runtime.GOARCH { + t.Errorf("cpu_architecture: expected %s, got %q", runtime.GOARCH, dev.Resources.CPUArchitecture) + } +} + +func TestGather_DarwinResources(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHostname("mbp") + mock.SetUsername("dev") + // device.Gather() also calls ioreg/system_profiler/sw_vers — stub them out + // minimally so the existing path completes without error. + mock.SetCommand(` "IOPlatformSerialNumber" = "ABCD1234"`+"\n", "", 0, "ioreg", "-l") + mock.SetCommand("15.1\n", "", 0, "sw_vers", "-productVersion") + + // Resource sysctls + mock.SetCommand("Apple M3 Pro\n", "", 0, "sysctl", "-n", "machdep.cpu.brand_string") + mock.SetCommand("12\n", "", 0, "sysctl", "-n", "hw.physicalcpu") + mock.SetCommand("16\n", "", 0, "sysctl", "-n", "hw.logicalcpu") + mock.SetCommand("38654705664\n", "", 0, "sysctl", "-n", "hw.memsize") // 36 GB + mock.SetDiskCapacityBytes("/", 994*1000*1000*1000) + + dev := Gather(context.Background(), mock) + + if dev.Resources.CPUModel != "Apple M3 Pro" { + t.Errorf("cpu_model: got %q", dev.Resources.CPUModel) + } + if dev.Resources.PhysicalCores != 12 { + t.Errorf("physical_cores: expected 12, got %d", dev.Resources.PhysicalCores) + } + if dev.Resources.LogicalCores != 16 { + t.Errorf("logical_cores: expected 16, got %d", dev.Resources.LogicalCores) + } + if dev.Resources.MemoryBytes != 38654705664 { + t.Errorf("memory_bytes: got %d", dev.Resources.MemoryBytes) + } + if dev.Resources.DiskTotalBytes == 0 { + t.Errorf("disk_total_bytes: expected non-zero") + } +} + +func TestGather_WindowsResources(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHostname("WIN-DESKTOP") + mock.SetUsername("dev") + + // device.Gather() calls these for Windows serial/OS version. + mock.SetCommand("SerialNumber\nWIN-SERIAL-1\n", "", 0, "wmic", "bios", "get", "serialnumber") + mock.SetCommand("10.0.22631.0\n", "", 0, + "powershell", "-NoProfile", "-Command", + "[System.Environment]::OSVersion.Version.ToString()") + + // Resource queries on the non-Windows test path use PowerShell CIM — + // wmic was removed in Windows 11 / Server 2025, so the agent only ships + // PowerShell fallbacks. + mock.SetCommand( + "\r\nName : Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz\r\nNumberOfCores : 24\r\nNumberOfLogicalProcessors : 32\r\n\r\n", + "", 0, + "powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Processor | Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | Format-List") + mock.SetCommand("68719476736\n", "", 0, + "powershell", "-NoProfile", "-Command", + "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory") + mock.SetEnv("SystemDrive", "C:") + mock.SetDiskCapacityBytes(`C:\`, 953*1000*1000*1000) + + dev := Gather(context.Background(), mock) + + if dev.Resources.CPUModel != "Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz" { + t.Errorf("cpu_model: got %q", dev.Resources.CPUModel) + } + if dev.Resources.PhysicalCores != 24 { + t.Errorf("physical_cores: expected 24, got %d", dev.Resources.PhysicalCores) + } + if dev.Resources.LogicalCores != 32 { + t.Errorf("logical_cores: expected 32, got %d", dev.Resources.LogicalCores) + } + if dev.Resources.MemoryBytes != 68719476736 { + t.Errorf("memory_bytes: got %d", dev.Resources.MemoryBytes) + } + if dev.Resources.DiskTotalBytes == 0 { + t.Errorf("disk_total_bytes: expected non-zero") + } +} + +func TestGather_LinuxResourcesMissingFiles(t *testing.T) { + // When /proc/cpuinfo and /proc/meminfo are unavailable, Gather should not + // fail — Resources just degrades to zero/empty fields except runtime-derived + // ones (LogicalCores, CPUArchitecture). + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHostname("minimal") + mock.SetUsername("user") + mock.SetFile("/etc/machine-id", []byte("abc\n")) + mock.SetFile("/proc/sys/kernel/osrelease", []byte("6.0.0\n")) + + dev := Gather(context.Background(), mock) + + if dev.Resources.CPUModel != "" { + t.Errorf("cpu_model: expected empty, got %q", dev.Resources.CPUModel) + } + if dev.Resources.MemoryBytes != 0 { + t.Errorf("memory_bytes: expected 0, got %d", dev.Resources.MemoryBytes) + } + if dev.Resources.LogicalCores == 0 { + t.Errorf("logical_cores: expected runtime.NumCPU() > 0, got 0") + } + if dev.Resources.CPUArchitecture == "" { + t.Errorf("cpu_architecture: expected runtime.GOARCH, got empty") + } +} diff --git a/internal/device/resources_windows.go b/internal/device/resources_windows.go new file mode 100644 index 0000000..3c8dc29 --- /dev/null +++ b/internal/device/resources_windows.go @@ -0,0 +1,168 @@ +//go:build windows + +package device + +import ( + "context" + "strconv" + "strings" + "unsafe" + + "github.com/step-security/dev-machine-guard/internal/executor" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// getCPUInfoWindows reads CPU model from the registry (no subprocess) and +// counts cores via GetLogicalProcessorInformation. Falls back to wmic if +// either step fails — VMs and stripped Server Core images occasionally lack +// one or the other. +func getCPUInfoWindows(ctx context.Context, exec executor.Executor) (cpuModel string, physical, logical int) { + cpuModel = readCPUNameRegistry() + physical, logical = countCoresFromAPI() + + if cpuModel != "" && physical > 0 && logical > 0 { + return cpuModel, physical, logical + } + + // Fill any blanks via PowerShell CIM. wmic was removed in Windows 11 / + // Server 2025, so PowerShell is the only viable fallback going forward. + // Format-List output ("Key : Value", one field per line) is parsed by the + // same helper we use for the non-Windows test stub. + stdout, _, _, err := exec.Run(ctx, "powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Processor | Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | Format-List") + if err == nil { + fbModel, fbPhysical, fbLogical := parseCIMProcessorList(stdout) + if cpuModel == "" { + cpuModel = fbModel + } + if physical == 0 { + physical = fbPhysical + } + if logical == 0 { + logical = fbLogical + } + } + return cpuModel, physical, logical +} + +func readCPUNameRegistry() string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE) + if err != nil { + return "" + } + defer func() { _ = k.Close() }() + name, _, err := k.GetStringValue("ProcessorNameString") + if err != nil { + return "" + } + return strings.TrimSpace(name) +} + +// countCoresFromAPI uses GetLogicalProcessorInformation to derive both the +// physical core count (entries with Relationship == RelationProcessorCore) +// and the logical-processor count (popcount of each ProcessorMask). The +// kernel32 export is wrapped via syscall.NewLazyDLL so we don't need a build- +// time binding for it. +func countCoresFromAPI() (physical, logical int) { + const relationProcessorCore = 0 + + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + proc := kernel32.NewProc("GetLogicalProcessorInformation") + + var buf []byte + var returnedLen uint32 + + // First call with nil to discover required size. + r1, _, _ := proc.Call(0, uintptr(unsafe.Pointer(&returnedLen))) + if r1 == 0 && returnedLen == 0 { + return 0, 0 + } + buf = make([]byte, returnedLen) + r1, _, _ = proc.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&returnedLen))) + if r1 == 0 { + return 0, 0 + } + + // SYSTEM_LOGICAL_PROCESSOR_INFORMATION layout: + // ProcessorMask uintptr + // Relationship uint32 (followed by 4 bytes padding on amd64) + // union[16]byte (cache/numa/etc — we only inspect the mask) + type sysLogicalProcInfo struct { + ProcessorMask uintptr + Relationship uint32 + _ [4]byte + _ [16]byte + } + + stride := int(unsafe.Sizeof(sysLogicalProcInfo{})) + count := int(returnedLen) / stride + for i := 0; i < count; i++ { + entry := (*sysLogicalProcInfo)(unsafe.Pointer(&buf[i*stride])) + if entry.Relationship == relationProcessorCore { + physical++ + logical += popcount(uint64(entry.ProcessorMask)) + } + } + return physical, logical +} + +func popcount(x uint64) int { + n := 0 + for x != 0 { + n += int(x & 1) + x >>= 1 + } + return n +} + +// memoryStatusEx mirrors the Windows MEMORYSTATUSEX struct. Field order and +// sizes are load-bearing — GlobalMemoryStatusEx fills the buffer by offset. +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +func getMemoryBytesWindows(ctx context.Context, exec executor.Executor) uint64 { + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + proc := kernel32.NewProc("GlobalMemoryStatusEx") + var statex memoryStatusEx + statex.Length = uint32(unsafe.Sizeof(statex)) + r1, _, _ := proc.Call(uintptr(unsafe.Pointer(&statex))) + if r1 != 0 && statex.TotalPhys > 0 { + return statex.TotalPhys + } + + // Fallback: PowerShell CIM if the syscall failed (extremely rare). + // wmic is unavailable on Windows 11 / Server 2025. + stdout, _, _, err := exec.Run(ctx, "powershell", "-NoProfile", "-Command", + "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory") + if err != nil { + return 0 + } + n, err := strconv.ParseUint(strings.TrimSpace(stdout), 10, 64) + if err != nil { + return 0 + } + return n +} + +func getDiskTotalBytesWindows(exec executor.Executor) uint64 { + return exec.DiskCapacityBytes(windowsSystemDrive(exec)) +} + +func windowsSystemDrive(exec executor.Executor) string { + drive := strings.TrimSpace(exec.Getenv("SystemDrive")) + if drive == "" { + drive = "C:" + } + return drive + `\` +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 0068625..1d802f8 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -59,6 +59,9 @@ type Executor interface { LoggedInUser() (*user.User, error) // GOOS returns the runtime operating system. GOOS() string + // DiskCapacityBytes returns the total bytes on the filesystem containing + // path. Returns 0 on any error (lookup failures shouldn't block a scan). + DiskCapacityBytes(path string) uint64 } // Real implements Executor using actual OS calls. diff --git a/internal/executor/executor_unix.go b/internal/executor/executor_unix.go index 1ec8241..b382e2f 100644 --- a/internal/executor/executor_unix.go +++ b/internal/executor/executor_unix.go @@ -8,12 +8,21 @@ import ( "os" "runtime" "strings" + "syscall" ) func (r *Real) IsRoot() bool { return os.Getuid() == 0 } +func (r *Real) DiskCapacityBytes(path string) uint64 { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0 + } + return uint64(stat.Blocks) * uint64(stat.Bsize) +} + // resolveUserShell returns the given user's configured login shell on macOS by // consulting Directory Services (dscl). Returns "" on non-darwin platforms, if // the lookup fails, or if the resolved path isn't an executable file — in which diff --git a/internal/executor/executor_windows.go b/internal/executor/executor_windows.go index 5065c0d..04a6382 100644 --- a/internal/executor/executor_windows.go +++ b/internal/executor/executor_windows.go @@ -17,3 +17,18 @@ func (r *Real) RunAsUser(ctx context.Context, _ string, command string) (string, stdout, _, _, err := r.Run(ctx, "cmd", "/c", command) return strings.TrimSpace(stdout), err } + +func (r *Real) DiskCapacityBytes(path string) uint64 { + if path == "" { + return 0 + } + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0 + } + var freeBytesAvail, totalBytes, totalFreeBytes uint64 + if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvail, &totalBytes, &totalFreeBytes); err != nil { + return 0 + } + return totalBytes +} diff --git a/internal/executor/mock.go b/internal/executor/mock.go index 6d27d94..529a261 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -39,6 +39,9 @@ type Mock struct { // Symlink stubs: path -> resolved target symlinks map[string]string + + // Disk capacity stubs: path -> total bytes + diskCapacities map[string]uint64 } type cmdResult struct { @@ -50,19 +53,20 @@ type cmdResult struct { func NewMock() *Mock { return &Mock{ - commands: make(map[string]cmdResult), - files: make(map[string][]byte), - dirs: make(map[string]bool), - dirEnts: make(map[string][]os.DirEntry), - fileInfos: make(map[string]os.FileInfo), - paths: make(map[string]string), - env: make(map[string]string), - globs: make(map[string][]string), - symlinks: make(map[string]string), - hostname: "test-host", - username: "testuser", - homeDir: "/Users/testuser", - goos: "darwin", + commands: make(map[string]cmdResult), + files: make(map[string][]byte), + dirs: make(map[string]bool), + dirEnts: make(map[string][]os.DirEntry), + fileInfos: make(map[string]os.FileInfo), + paths: make(map[string]string), + env: make(map[string]string), + globs: make(map[string][]string), + symlinks: make(map[string]string), + diskCapacities: make(map[string]uint64), + hostname: "test-host", + username: "testuser", + homeDir: "/Users/testuser", + goos: "darwin", } } @@ -164,6 +168,13 @@ func (m *Mock) SetGOOS(goos string) { m.goos = goos } +// SetDiskCapacityBytes stubs the result of DiskCapacityBytes(path). +func (m *Mock) SetDiskCapacityBytes(path string, bytes uint64) { + m.mu.Lock() + defer m.mu.Unlock() + m.diskCapacities[path] = bytes +} + // --- Executor interface --- func (m *Mock) Run(_ context.Context, name string, args ...string) (string, string, int, error) { @@ -304,6 +315,12 @@ func (m *Mock) GOOS() string { return m.goos } +func (m *Mock) DiskCapacityBytes(path string) uint64 { + m.mu.RLock() + defer m.mu.RUnlock() + return m.diskCapacities[path] +} + // --- helpers --- func cmdKey(name string, args ...string) string { diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 41cb2e4..0195df0 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -108,3 +108,6 @@ func (e *UserAwareExecutor) EvalSymlinks(path string) (string, error) { } func (e *UserAwareExecutor) LoggedInUser() (*user.User, error) { return e.inner.LoggedInUser() } func (e *UserAwareExecutor) GOOS() string { return e.inner.GOOS() } +func (e *UserAwareExecutor) DiskCapacityBytes(path string) uint64 { + return e.inner.DiskCapacityBytes(path) +} diff --git a/internal/model/model.go b/internal/model/model.go index adfe5c4..dfecbd3 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -30,11 +30,24 @@ type ScanResult struct { } type Device struct { - Hostname string `json:"hostname"` - SerialNumber string `json:"serial_number"` - OSVersion string `json:"os_version"` - Platform string `json:"platform"` - UserIdentity string `json:"user_identity"` + Hostname string `json:"hostname"` + SerialNumber string `json:"serial_number"` + OSVersion string `json:"os_version"` + Platform string `json:"platform"` + UserIdentity string `json:"user_identity"` + Resources MachineResources `json:"resources"` +} + +// MachineResources captures the static hardware capacity of the machine — +// what's there, not what's currently in use. Answers "how much resource +// does this machine have?". +type MachineResources struct { + CPUModel string `json:"cpu_model"` // e.g. "Apple M3 Pro", "Intel(R) Core(TM) i9-13900K" + CPUArchitecture string `json:"cpu_architecture"` // "arm64", "amd64" + PhysicalCores int `json:"physical_cores"` // 0 if undeterminable + LogicalCores int `json:"logical_cores"` // includes SMT/hyperthreads + MemoryBytes uint64 `json:"memory_bytes"` // total installed RAM + DiskTotalBytes uint64 `json:"disk_total_bytes"` // capacity of the system/root volume } // AITool represents a detected AI agent, CLI tool, framework, or general agent. diff --git a/internal/output/html.go b/internal/output/html.go index 554a295..1b5c3a0 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -76,6 +76,8 @@ func HTML(outputFile string, result *model.ScanResult) error { "typeLabel": typeLabel, "platformDisplayName": model.PlatformDisplayName, "add": func(a, b int) int { return a + b }, + "formatBytes": formatBytes, + "formatCPU": formatCPU, } tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate) @@ -203,6 +205,9 @@ const htmlTemplate = `
Serial{{.Device.SerialNumber}}
{{platformDisplayName .Device.Platform}}{{.Device.OSVersion}}
User{{.Device.UserIdentity}}
+ {{with formatCPU .Device.Resources}}
CPU{{.}}
{{end}} + {{if .Device.Resources.MemoryBytes}}
Memory{{formatBytes .Device.Resources.MemoryBytes}}
{{end}} + {{if .Device.Resources.DiskTotalBytes}}
Disk{{formatBytes .Device.Resources.DiskTotalBytes}}
{{end}}
diff --git a/internal/output/pretty.go b/internal/output/pretty.go index dd4aee5..308bbbf 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -41,6 +41,15 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { osLabel := model.PlatformDisplayName(result.Device.Platform) fmt.Fprintf(w, " %-16s %s\n", osLabel, result.Device.OSVersion) fmt.Fprintf(w, " %-16s %s\n", "User", result.Device.UserIdentity) + if cpu := formatCPU(result.Device.Resources); cpu != "" { + fmt.Fprintf(w, " %-16s %s\n", "CPU", cpu) + } + if result.Device.Resources.MemoryBytes > 0 { + fmt.Fprintf(w, " %-16s %s\n", "Memory", formatBytes(result.Device.Resources.MemoryBytes)) + } + if result.Device.Resources.DiskTotalBytes > 0 { + fmt.Fprintf(w, " %-16s %s\n", "Disk", formatBytes(result.Device.Resources.DiskTotalBytes)) + } fmt.Fprintln(w) // SUMMARY @@ -316,6 +325,78 @@ func truncate(s string, max int) string { return s } +// formatCPU renders the CPU summary as +// "Apple M3 Pro (12c / 16t, arm64)" +// Each piece is omitted gracefully when the underlying field is missing +// (e.g. ARM Linux where /proc/cpuinfo has no "model name"). Returns "" when +// nothing is known. +func formatCPU(res model.MachineResources) string { + parts := []string{} + if res.CPUModel != "" { + parts = append(parts, res.CPUModel) + } + var detail []string + if res.PhysicalCores > 0 { + detail = append(detail, fmt.Sprintf("%dc", res.PhysicalCores)) + } + if res.LogicalCores > 0 { + detail = append(detail, fmt.Sprintf("%dt", res.LogicalCores)) + } + if res.CPUArchitecture != "" { + detail = append(detail, res.CPUArchitecture) + } + if len(detail) == 0 { + if len(parts) == 0 { + return "" + } + return parts[0] + } + if len(parts) == 0 { + return "(" + strings.Join(detail, " / ") + ")" + } + return parts[0] + " (" + joinCPUDetail(detail) + ")" +} + +func joinCPUDetail(detail []string) string { + // Cores joined with " / "; arch separated by ", " for readability. + switch len(detail) { + case 1: + return detail[0] + case 2: + return detail[0] + " / " + detail[1] + default: + return detail[0] + " / " + detail[1] + ", " + strings.Join(detail[2:], ", ") + } +} + +// formatBytes renders a byte count as a human-readable size using binary +// units (GiB), but labels them in the more familiar "GB" form. Examples: +// 17179869184 -> "16 GB" +// 494384795648 -> "460 GB" +func formatBytes(b uint64) string { + if b == 0 { + return "0 B" + } + const ( + kib = 1024 + mib = 1024 * kib + gib = 1024 * mib + tib = 1024 * gib + ) + switch { + case b >= tib: + return fmt.Sprintf("%.1f TB", float64(b)/float64(tib)) + case b >= gib: + return fmt.Sprintf("%d GB", b/gib) + case b >= mib: + return fmt.Sprintf("%d MB", b/mib) + case b >= kib: + return fmt.Sprintf("%d KB", b/kib) + default: + return fmt.Sprintf("%d B", b) + } +} + func ideDisplayName(ideType string) string { switch ideType { case "vscode": diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 82a9e72..515bf92 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -27,16 +27,17 @@ import ( // Payload is the enterprise telemetry JSON structure. type Payload struct { - CustomerID string `json:"customer_id"` - DeviceID string `json:"device_id"` - SerialNumber string `json:"serial_number"` - UserIdentity string `json:"user_identity"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` - OSVersion string `json:"os_version"` - AgentVersion string `json:"agent_version"` - CollectedAt int64 `json:"collected_at"` - NoUserLoggedIn bool `json:"no_user_logged_in"` + CustomerID string `json:"customer_id"` + DeviceID string `json:"device_id"` + SerialNumber string `json:"serial_number"` + UserIdentity string `json:"user_identity"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + Resources model.MachineResources `json:"resources"` + AgentVersion string `json:"agent_version"` + CollectedAt int64 `json:"collected_at"` + NoUserLoggedIn bool `json:"no_user_logged_in"` IDEExtensions []model.Extension `json:"ide_extensions"` IDEInstallations []model.IDE `json:"ide_installations"` @@ -521,6 +522,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err Hostname: dev.Hostname, Platform: dev.Platform, OSVersion: dev.OSVersion, + Resources: dev.Resources, AgentVersion: buildinfo.Version, CollectedAt: endTime.Unix(), NoUserLoggedIn: dev.UserIdentity == "" || dev.UserIdentity == "unknown",