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 }