From 00bb2fdaceeea2d4cde148bbe08b874273e7e192 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 5 Mar 2026 10:37:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(audit):=201.3=20=E2=80=94=20CPU=20collecto?= =?UTF-8?q?r=20(dmidecode=20type=204,=20microcode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cpu.go: collectCPUs(), parseCPUs(), parseCPUSection() - splitDMISections(): splits multi-section dmidecode output generically - parseFieldLines(): reusable key→value parser for DMI sections - parseCPUStatus(): Populated/Unpopulated → OK/WARNING/EMPTY/UNKNOWN - parseSocketIndex(): CPU0/Processor 1/Socket 2 → integer - cleanManufacturer(): strips (R), Corporation, Inc. suffixes - parseMHz(), parseInt(): field value parsers - Serial fallback: -CPU- when DMI serial absent - readMicrocode(): /sys/devices/system/cpu/cpu0/microcode/version - cpu_test.go: dual-socket, unpopulated skipped, status, socket, manufacturer, MHz Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/collector/collector.go | 6 +- audit/internal/collector/cpu.go | 239 ++++++++++++++++++ audit/internal/collector/cpu_test.go | 143 +++++++++++ .../collector/testdata/dmidecode_type4.txt | 66 +++++ .../testdata/dmidecode_type4_disabled.txt | 31 +++ 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 audit/internal/collector/cpu.go create mode 100644 audit/internal/collector/cpu_test.go create mode 100644 audit/internal/collector/testdata/dmidecode_type4.txt create mode 100644 audit/internal/collector/testdata/dmidecode_type4_disabled.txt diff --git a/audit/internal/collector/collector.go b/audit/internal/collector/collector.go index 3df4a36..a9f2b8c 100644 --- a/audit/internal/collector/collector.go +++ b/audit/internal/collector/collector.go @@ -21,7 +21,11 @@ func Run() schema.HardwareIngestRequest { snap.Board = board snap.Firmware = append(snap.Firmware, biosFW...) - // remaining collectors added in steps 1.3 – 1.10 + cpus, cpuFW := collectCPUs(snap.Board.SerialNumber) + snap.CPUs = cpus + snap.Firmware = append(snap.Firmware, cpuFW...) + + // remaining collectors added in steps 1.4 – 1.10 slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond)) diff --git a/audit/internal/collector/cpu.go b/audit/internal/collector/cpu.go new file mode 100644 index 0000000..9104524 --- /dev/null +++ b/audit/internal/collector/cpu.go @@ -0,0 +1,239 @@ +package collector + +import ( + "bee/audit/internal/schema" + "bufio" + "fmt" + "log/slog" + "os" + "strconv" + "strings" +) + +// collectCPUs runs dmidecode -t 4 and reads microcode version from sysfs. +func collectCPUs(boardSerial string) ([]schema.HardwareCPU, []schema.HardwareFirmwareRecord) { + out, err := runDmidecode("4") + if err != nil { + slog.Warn("cpu: dmidecode type 4 failed", "err", err) + return nil, nil + } + + cpus := parseCPUs(out, boardSerial) + + var firmware []schema.HardwareFirmwareRecord + if mc := readMicrocode(); mc != "" { + firmware = append(firmware, schema.HardwareFirmwareRecord{ + DeviceName: "CPU Microcode", + Version: mc, + }) + } + + slog.Info("cpu: collected", "count", len(cpus)) + return cpus, firmware +} + +// parseCPUs splits dmidecode output into per-processor sections and parses each. +func parseCPUs(output, boardSerial string) []schema.HardwareCPU { + sections := splitDMISections(output, "Processor Information") + cpus := make([]schema.HardwareCPU, 0, len(sections)) + + for _, section := range sections { + cpu, ok := parseCPUSection(section, boardSerial) + if !ok { + continue + } + cpus = append(cpus, cpu) + } + return cpus +} + +// parseCPUSection parses one "Processor Information" block into a HardwareCPU. +// Returns false if the socket is unpopulated. +func parseCPUSection(fields map[string]string, boardSerial string) (schema.HardwareCPU, bool) { + status := parseCPUStatus(fields["Status"]) + if status == "EMPTY" { + return schema.HardwareCPU{}, false + } + + cpu := schema.HardwareCPU{} + cpu.Status = &status + + if socket, ok := parseSocketIndex(fields["Socket Designation"]); ok { + cpu.Socket = &socket + } + + if v := cleanDMIValue(fields["Version"]); v != "" { + cpu.Model = &v + } + if v := cleanManufacturer(fields["Manufacturer"]); v != "" { + cpu.Manufacturer = &v + } + if v := cleanDMIValue(fields["Serial Number"]); v != "" { + cpu.SerialNumber = &v + } else if boardSerial != "" && cpu.Socket != nil { + // Intel Xeon never exposes serial via DMI — generate stable fallback + // matching core's generateCPUVendorSerial() logic + fb := fmt.Sprintf("%s-CPU-%d", boardSerial, *cpu.Socket) + cpu.SerialNumber = &fb + } + + if v := parseMHz(fields["Max Speed"]); v > 0 { + cpu.MaxFrequencyMHz = &v + } + if v := parseMHz(fields["Current Speed"]); v > 0 { + cpu.FrequencyMHz = &v + } + if v := parseInt(fields["Core Count"]); v > 0 { + cpu.Cores = &v + } + if v := parseInt(fields["Thread Count"]); v > 0 { + cpu.Threads = &v + } + + return cpu, true +} + +// parseCPUStatus maps dmidecode Status field to our status vocabulary. +func parseCPUStatus(raw string) string { + raw = strings.TrimSpace(raw) + upper := strings.ToUpper(raw) + switch { + case upper == "" || upper == "UNKNOWN": + return "UNKNOWN" + case strings.Contains(upper, "UNPOPULATED") || strings.Contains(upper, "NOT POPULATED"): + return "EMPTY" + case strings.Contains(upper, "ENABLED"): + return "OK" + case strings.Contains(upper, "DISABLED"): + return "WARNING" + default: + return "UNKNOWN" + } +} + +// parseSocketIndex extracts the integer socket index from strings like +// "CPU0", "CPU1", "Processor 1", "Socket 0", etc. +func parseSocketIndex(raw string) (int, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, false + } + // strip leading non-digit prefix and parse the first integer found + digits := "" + for _, r := range raw { + if r >= '0' && r <= '9' { + digits += string(r) + } else if digits != "" { + break + } + } + if digits == "" { + return 0, false + } + n, err := strconv.Atoi(digits) + if err != nil { + return 0, false + } + return n, true +} + +// cleanManufacturer normalises CPU manufacturer strings. +// "Intel(R) Corporation" → "Intel", "AMD" → "AMD". +func cleanManufacturer(v string) string { + v = cleanDMIValue(v) + if v == "" { + return "" + } + // strip "(R)" and "(TM)" suffixes, trim "Corporation" / "Inc." + v = strings.ReplaceAll(v, "(R)", "") + v = strings.ReplaceAll(v, "(TM)", "") + v = strings.ReplaceAll(v, " Corporation", "") + v = strings.ReplaceAll(v, " Inc.", "") + v = strings.ReplaceAll(v, " Inc", "") + return strings.TrimSpace(v) +} + +// parseMHz parses "4000 MHz" → 4000. Returns 0 on failure. +func parseMHz(v string) int { + v = strings.TrimSpace(v) + v = strings.TrimSuffix(v, " MHz") + v = strings.TrimSpace(v) + n, err := strconv.Atoi(v) + if err != nil { + return 0 + } + return n +} + +// parseInt parses a plain integer string. Returns 0 on failure or "N/A". +func parseInt(v string) int { + v = strings.TrimSpace(v) + n, err := strconv.Atoi(v) + if err != nil { + return 0 + } + return n +} + +// readMicrocode reads the CPU microcode revision from sysfs. +// Returns empty string if unavailable. +func readMicrocode() string { + data, err := os.ReadFile("/sys/devices/system/cpu/cpu0/microcode/version") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// splitDMISections splits dmidecode output into sections by a given section title. +// Returns a slice of field maps, one per matching section. +func splitDMISections(output, sectionTitle string) []map[string]string { + var sections []map[string]string + var current []string + inSection := false + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + + if strings.TrimSpace(line) == sectionTitle { + if inSection && len(current) > 0 { + sections = append(sections, parseFieldLines(current)) + } + current = nil + inSection = true + continue + } + + if inSection { + if strings.HasPrefix(line, "Handle ") { + sections = append(sections, parseFieldLines(current)) + current = nil + inSection = false + continue + } + current = append(current, line) + } + } + if inSection && len(current) > 0 { + sections = append(sections, parseFieldLines(current)) + } + + return sections +} + +// parseFieldLines converts raw section lines into a key→value map. +// Skips sub-list items (double-tab indented lines). +func parseFieldLines(lines []string) map[string]string { + fields := make(map[string]string) + for _, line := range lines { + if strings.HasPrefix(line, "\t\t") { + continue + } + trimmed := strings.TrimPrefix(line, "\t") + if idx := strings.Index(trimmed, ": "); idx >= 0 { + fields[trimmed[:idx]] = trimmed[idx+2:] + } + } + return fields +} diff --git a/audit/internal/collector/cpu_test.go b/audit/internal/collector/cpu_test.go new file mode 100644 index 0000000..e2d4448 --- /dev/null +++ b/audit/internal/collector/cpu_test.go @@ -0,0 +1,143 @@ +package collector + +import ( + "testing" +) + +func TestParseCPUs_dual_socket(t *testing.T) { + out := mustReadFile(t, "testdata/dmidecode_type4.txt") + cpus := parseCPUs(out, "CAR315KA0803B90") + + if len(cpus) != 2 { + t.Fatalf("expected 2 CPUs, got %d", len(cpus)) + } + + cpu0 := cpus[0] + if cpu0.Socket == nil || *cpu0.Socket != 0 { + t.Errorf("cpu0 socket: got %v, want 0", cpu0.Socket) + } + if cpu0.Model == nil || *cpu0.Model != "Intel(R) Xeon(R) Gold 6530" { + t.Errorf("cpu0 model: got %v", cpu0.Model) + } + if cpu0.Manufacturer == nil || *cpu0.Manufacturer != "Intel" { + t.Errorf("cpu0 manufacturer: got %v, want Intel", cpu0.Manufacturer) + } + if cpu0.Cores == nil || *cpu0.Cores != 32 { + t.Errorf("cpu0 cores: got %v, want 32", cpu0.Cores) + } + if cpu0.Threads == nil || *cpu0.Threads != 64 { + t.Errorf("cpu0 threads: got %v, want 64", cpu0.Threads) + } + if cpu0.MaxFrequencyMHz == nil || *cpu0.MaxFrequencyMHz != 4000 { + t.Errorf("cpu0 max_frequency_mhz: got %v, want 4000", cpu0.MaxFrequencyMHz) + } + if cpu0.FrequencyMHz == nil || *cpu0.FrequencyMHz != 2100 { + t.Errorf("cpu0 frequency_mhz: got %v, want 2100", cpu0.FrequencyMHz) + } + if cpu0.Status == nil || *cpu0.Status != "OK" { + t.Errorf("cpu0 status: got %v, want OK", cpu0.Status) + } + // Intel Xeon serial not available → fallback + if cpu0.SerialNumber == nil || *cpu0.SerialNumber != "CAR315KA0803B90-CPU-0" { + t.Errorf("cpu0 serial fallback: got %v, want CAR315KA0803B90-CPU-0", cpu0.SerialNumber) + } + + cpu1 := cpus[1] + if cpu1.Socket == nil || *cpu1.Socket != 1 { + t.Errorf("cpu1 socket: got %v, want 1", cpu1.Socket) + } + if cpu1.SerialNumber == nil || *cpu1.SerialNumber != "CAR315KA0803B90-CPU-1" { + t.Errorf("cpu1 serial fallback: got %v, want CAR315KA0803B90-CPU-1", cpu1.SerialNumber) + } +} + +func TestParseCPUs_unpopulated_skipped(t *testing.T) { + out := mustReadFile(t, "testdata/dmidecode_type4_disabled.txt") + cpus := parseCPUs(out, "BOARD-001") + + if len(cpus) != 1 { + t.Fatalf("expected 1 CPU (unpopulated skipped), got %d", len(cpus)) + } + if cpus[0].Socket == nil || *cpus[0].Socket != 0 { + t.Errorf("expected socket 0, got %v", cpus[0].Socket) + } +} + +func TestParseCPUStatus(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Populated, Enabled", "OK"}, + {"Populated, Disabled By User", "WARNING"}, + {"Populated, Disabled By BIOS", "WARNING"}, + {"Unpopulated", "EMPTY"}, + {"Not Populated", "EMPTY"}, + {"Unknown", "UNKNOWN"}, + {"", "UNKNOWN"}, + } + for _, tt := range tests { + got := parseCPUStatus(tt.input) + if got != tt.want { + t.Errorf("parseCPUStatus(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseSocketIndex(t *testing.T) { + tests := []struct { + input string + want int + ok bool + }{ + {"CPU0", 0, true}, + {"CPU1", 1, true}, + {"Processor 1", 1, true}, + {"Socket 2", 2, true}, + {"", 0, false}, + {"No digits here", 0, false}, + } + for _, tt := range tests { + got, ok := parseSocketIndex(tt.input) + if ok != tt.ok || got != tt.want { + t.Errorf("parseSocketIndex(%q) = (%d, %v), want (%d, %v)", tt.input, got, ok, tt.want, tt.ok) + } + } +} + +func TestCleanManufacturer(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Intel(R) Corporation", "Intel"}, + {"AMD", "AMD"}, + {"To Be Filled By O.E.M.", ""}, + {" Intel(R) Corporation ", "Intel"}, + } + for _, tt := range tests { + got := cleanManufacturer(tt.input) + if got != tt.want { + t.Errorf("cleanManufacturer(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseMHz(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"4000 MHz", 4000}, + {"2100 MHz", 2100}, + {"Unknown", 0}, + {"", 0}, + {"N/A", 0}, + } + for _, tt := range tests { + got := parseMHz(tt.input) + if got != tt.want { + t.Errorf("parseMHz(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} diff --git a/audit/internal/collector/testdata/dmidecode_type4.txt b/audit/internal/collector/testdata/dmidecode_type4.txt new file mode 100644 index 0000000..29d586f --- /dev/null +++ b/audit/internal/collector/testdata/dmidecode_type4.txt @@ -0,0 +1,66 @@ +# dmidecode 3.5 +Getting SMBIOS data from sysfs. +SMBIOS 3.1.1 present. + +Handle 0x0004, DMI type 4, 48 bytes +Processor Information + Socket Designation: CPU0 + Type: Central Processor + Family: Xeon + Manufacturer: Intel(R) Corporation + ID: F7 06 08 00 FF FB EB BF + Signature: Type 0, Family 6, Model 143, Stepping 7 + Flags: + FPU (Floating-point unit on-chip) + VME (Virtual mode extension) + DE (Debugging extension) + Version: Intel(R) Xeon(R) Gold 6530 + Voltage: 1.6 V + External Clock: 100 MHz + Max Speed: 4000 MHz + Current Speed: 2100 MHz + Status: Populated, Enabled + Upgrade: Other + L1 Cache Handle: 0x0006 + L2 Cache Handle: 0x0007 + L3 Cache Handle: 0x0008 + Serial Number: Not Specified + Asset Tag: Not Specified + Part Number: Not Specified + Core Count: 32 + Core Enabled: 32 + Thread Count: 64 + Characteristics: + 64-bit capable + Multi-Core + Hardware Thread + +Handle 0x0005, DMI type 4, 48 bytes +Processor Information + Socket Designation: CPU1 + Type: Central Processor + Family: Xeon + Manufacturer: Intel(R) Corporation + ID: F7 06 08 00 FF FB EB BF + Signature: Type 0, Family 6, Model 143, Stepping 7 + Flags: + FPU (Floating-point unit on-chip) + Version: Intel(R) Xeon(R) Gold 6530 + Voltage: 1.6 V + External Clock: 100 MHz + Max Speed: 4000 MHz + Current Speed: 2100 MHz + Status: Populated, Enabled + Upgrade: Other + L1 Cache Handle: 0x000A + L2 Cache Handle: 0x000B + L3 Cache Handle: 0x000C + Serial Number: Not Specified + Asset Tag: Not Specified + Part Number: Not Specified + Core Count: 32 + Core Enabled: 32 + Thread Count: 64 + Characteristics: + 64-bit capable + Multi-Core diff --git a/audit/internal/collector/testdata/dmidecode_type4_disabled.txt b/audit/internal/collector/testdata/dmidecode_type4_disabled.txt new file mode 100644 index 0000000..6d79000 --- /dev/null +++ b/audit/internal/collector/testdata/dmidecode_type4_disabled.txt @@ -0,0 +1,31 @@ +# dmidecode 3.5 +Getting SMBIOS data from sysfs. +SMBIOS 3.1.1 present. + +Handle 0x0004, DMI type 4, 48 bytes +Processor Information + Socket Designation: CPU0 + Type: Central Processor + Family: Xeon + Manufacturer: Intel(R) Corporation + Version: Intel(R) Xeon(R) Gold 6530 + Max Speed: 4000 MHz + Current Speed: 2100 MHz + Status: Populated, Enabled + Serial Number: Not Specified + Core Count: 32 + Thread Count: 64 + +Handle 0x0005, DMI type 4, 48 bytes +Processor Information + Socket Designation: CPU1 + Type: Central Processor + Family: Unknown + Manufacturer: Unknown + Version: Unknown + Max Speed: Unknown + Current Speed: Unknown + Status: Unpopulated + Serial Number: Not Specified + Core Count: N/A + Thread Count: N/A