Adds board and memory parser test fixtures based on real dmidecode output from Dell PowerEdge R740xd, HPE ProLiant DL380 Gen10, and Supermicro SYS-6028R-WTR sourced from the linuxhw/DMI dataset. Extends cleanDMIValue with four additional vendor placeholder strings found in the dataset: "0123456789", "1234567890", "NOT AVAILABLE", and "TO BE FILLED BY O.E.M" (without trailing dot). Adds memory_test.go covering mixed populated/empty DIMM slots and both GB and MB size formats. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
203 lines
5.2 KiB
Go
203 lines
5.2 KiB
Go
package collector
|
|
|
|
import (
|
|
"bee/audit/internal/schema"
|
|
"bufio"
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
var execDmidecode = func(typeNum string) (string, error) {
|
|
out, err := exec.Command("dmidecode", "-t", typeNum).Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// collectBoard runs dmidecode for types 0, 1, 2 and returns the board record
|
|
// plus the BIOS firmware entry. Any failure is logged and returns zero values.
|
|
func collectBoard() (schema.HardwareBoard, []schema.HardwareFirmwareRecord) {
|
|
type1, err := runDmidecode("1")
|
|
if err != nil {
|
|
slog.Warn("board: dmidecode type 1 failed", "err", err)
|
|
return schema.HardwareBoard{}, nil
|
|
}
|
|
|
|
type2, err := runDmidecode("2")
|
|
if err != nil {
|
|
slog.Warn("board: dmidecode type 2 failed", "err", err)
|
|
}
|
|
|
|
type0, err := runDmidecode("0")
|
|
if err != nil {
|
|
slog.Warn("board: dmidecode type 0 failed", "err", err)
|
|
}
|
|
|
|
board := parseBoard(type1, type2)
|
|
firmware := parseBIOSFirmware(type0)
|
|
|
|
slog.Info("board: collected", "serial", board.SerialNumber)
|
|
return board, firmware
|
|
}
|
|
|
|
// parseBoard extracts HardwareBoard from dmidecode type 1 (System) and type 2 (Baseboard) output.
|
|
func parseBoard(type1, type2 string) schema.HardwareBoard {
|
|
sys := parseDMIFields(type1, "System Information")
|
|
base := parseDMIFields(type2, "Base Board Information")
|
|
|
|
board := schema.HardwareBoard{}
|
|
|
|
if v := cleanDMIValue(sys["Manufacturer"]); v != "" {
|
|
board.Manufacturer = &v
|
|
}
|
|
if v := cleanDMIValue(sys["Product Name"]); v != "" {
|
|
board.ProductName = &v
|
|
}
|
|
if v := cleanDMIValue(sys["Serial Number"]); v != "" {
|
|
board.SerialNumber = v
|
|
}
|
|
if v := cleanDMIValue(sys["UUID"]); v != "" {
|
|
board.UUID = &v
|
|
}
|
|
// part number comes from baseboard Product Name
|
|
if v := cleanDMIValue(base["Product Name"]); v != "" {
|
|
board.PartNumber = &v
|
|
}
|
|
|
|
return board
|
|
}
|
|
|
|
// collectBMCFirmware collects BMC firmware version via ipmitool mc info.
|
|
// Returns nil if ipmitool is missing, /dev/ipmi0 is absent, or any error occurs.
|
|
func collectBMCFirmware(manufacturer string) []schema.HardwareFirmwareRecord {
|
|
if _, err := exec.LookPath("ipmitool"); err != nil {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat("/dev/ipmi0"); err != nil {
|
|
return nil
|
|
}
|
|
profile := selectIPMIProfile(manufacturer)
|
|
ctx, cancel := context.WithTimeout(context.Background(), profile.mcInfoTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "ipmitool", "mc", "info")
|
|
raw, err := cmd.Output()
|
|
if err != nil {
|
|
slog.Info("bmc: ipmitool mc info unavailable", "err", err)
|
|
return nil
|
|
}
|
|
version := parseBMCFirmwareRevision(string(raw))
|
|
if version == "" {
|
|
return nil
|
|
}
|
|
slog.Info("bmc: collected", "version", version)
|
|
return []schema.HardwareFirmwareRecord{
|
|
{DeviceName: "BMC", Version: version},
|
|
}
|
|
}
|
|
|
|
// parseBMCFirmwareRevision extracts the "Firmware Revision" field from ipmitool mc info output.
|
|
func parseBMCFirmwareRevision(out string) string {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(key) == "Firmware Revision" {
|
|
return strings.TrimSpace(val)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseBIOSFirmware extracts BIOS version from dmidecode type 0 output.
|
|
func parseBIOSFirmware(type0 string) []schema.HardwareFirmwareRecord {
|
|
fields := parseDMIFields(type0, "BIOS Information")
|
|
|
|
version := cleanDMIValue(fields["Version"])
|
|
if version == "" {
|
|
return nil
|
|
}
|
|
return []schema.HardwareFirmwareRecord{
|
|
{DeviceName: "BIOS", Version: version},
|
|
}
|
|
}
|
|
|
|
// parseDMIFields parses the key-value pairs from a dmidecode section.
|
|
// sectionTitle is the section header line to find (e.g. "System Information").
|
|
// Returns a map of trimmed field names to raw values.
|
|
func parseDMIFields(output, sectionTitle string) map[string]string {
|
|
fields := make(map[string]string)
|
|
inSection := false
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
if strings.TrimSpace(line) == sectionTitle {
|
|
inSection = true
|
|
continue
|
|
}
|
|
|
|
if inSection {
|
|
// blank line or new Handle line = end of section
|
|
if line == "" || strings.HasPrefix(line, "Handle ") {
|
|
break
|
|
}
|
|
// skip sub-list items (double tab indent)
|
|
if strings.HasPrefix(line, "\t\t") {
|
|
continue
|
|
}
|
|
// key: value line (single tab indent)
|
|
trimmed := strings.TrimPrefix(line, "\t")
|
|
if idx := strings.Index(trimmed, ": "); idx >= 0 {
|
|
key := trimmed[:idx]
|
|
val := trimmed[idx+2:]
|
|
fields[key] = val
|
|
}
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// cleanDMIValue returns empty string for known placeholder values that vendors
|
|
// use when a field is unpopulated.
|
|
func cleanDMIValue(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
upper := strings.ToUpper(v)
|
|
placeholders := []string{
|
|
"TO BE FILLED BY O.E.M.",
|
|
"TO BE FILLED BY O.E.M",
|
|
"NOT SPECIFIED",
|
|
"NOT SETTABLE",
|
|
"NOT PRESENT",
|
|
"NOT AVAILABLE",
|
|
"UNKNOWN",
|
|
"N/A",
|
|
"NONE",
|
|
"NULL",
|
|
"DEFAULT STRING",
|
|
"0",
|
|
"0123456789",
|
|
"1234567890",
|
|
}
|
|
for _, p := range placeholders {
|
|
if upper == p {
|
|
return ""
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// runDmidecode executes dmidecode -t <typeNum> and returns its stdout.
|
|
func runDmidecode(typeNum string) (string, error) {
|
|
return execDmidecode(typeNum)
|
|
}
|