package collector import ( "bee/audit/internal/schema" "bufio" "log/slog" "os" "path/filepath" "strconv" "strings" ) // collectCPUs runs dmidecode -t 4 and enriches CPUs with microcode from sysfs. func collectCPUs() []schema.HardwareCPU { out, err := runDmidecode("4") if err != nil { slog.Warn("cpu: dmidecode type 4 failed", "err", err) return nil } cpus := parseCPUs(out) if mc := readMicrocode(); mc != "" { for i := range cpus { cpus[i].Firmware = &mc } } slog.Info("cpu: collected", "count", len(cpus)) return cpus } // parseCPUs splits dmidecode output into per-processor sections and parses each. func parseCPUs(output string) []schema.HardwareCPU { sections := splitDMISections(output, "Processor Information") cpus := make([]schema.HardwareCPU, 0, len(sections)) for _, section := range sections { cpu, ok := parseCPUSection(section) 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) (schema.HardwareCPU, bool) { status := parseCPUStatus(fields["Status"]) if status == statusEmpty { return schema.HardwareCPU{}, false } cpu := schema.HardwareCPU{} cpu.Status = &status present := true cpu.Present = &present 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 } 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 statusUnknown case strings.Contains(upper, "UNPOPULATED") || strings.Contains(upper, "NOT POPULATED"): return statusEmpty case strings.Contains(upper, "ENABLED"): return statusOK case strings.Contains(upper, "DISABLED"): return statusWarning default: return statusUnknown } } // 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(filepath.Join(cpuSysBaseDir, "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 }