From bd70ffeeefc08e9739f94a2d4bbe0b55cf893dc5 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 6 May 2026 18:29:14 +0530 Subject: [PATCH] feat(device): collect static machine resource info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CPU model / physical+logical cores / architecture, total RAM, and root volume capacity to the device snapshot reported on every scan. Answers "how much resource does this machine have" without including volatile signals like free memory or per-process load. Linux reads /proc/cpuinfo and /proc/meminfo (no subprocess); macOS uses sysctl; Windows uses native Win32 APIs (registry CPU name, GetLogicalProcessorInformation, GlobalMemoryStatusEx, GetDiskFreeSpaceEx) with PowerShell Get-CimInstance as fallback — wmic is unavailable on Win11 / Server 2025. Disk capacity routes through a new Executor.DiskCapacityBytes method so it stays mockable like everything else. Surfaced in pretty + HTML output and the enterprise telemetry payload. Verified end-to-end against ground truth on macOS, Fedora 42 EC2, and Server 2025 EC2. --- internal/device/device.go | 1 + internal/device/resources.go | 225 ++++++++++++++++++ internal/device/resources_other.go | 57 +++++ internal/device/resources_test.go | 330 ++++++++++++++++++++++++++ internal/device/resources_windows.go | 168 +++++++++++++ internal/executor/executor.go | 3 + internal/executor/executor_unix.go | 9 + internal/executor/executor_windows.go | 15 ++ internal/executor/mock.go | 43 +++- internal/executor/user_aware.go | 3 + internal/model/model.go | 23 +- internal/output/html.go | 5 + internal/output/pretty.go | 81 +++++++ internal/telemetry/telemetry.go | 22 +- 14 files changed, 957 insertions(+), 28 deletions(-) create mode 100644 internal/device/resources.go create mode 100644 internal/device/resources_other.go create mode 100644 internal/device/resources_test.go create mode 100644 internal/device/resources_windows.go 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",