From f1e392a7fee10647a47f3b050475b68a668c5ef8 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 5 Mar 2026 10:35:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(audit):=201.2=20=E2=80=94=20board=20collec?= =?UTF-8?q?tor=20(dmidecode=20types=200,=201,=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - board.go: collectBoard(), parseBoard(), parseBIOSFirmware(), parseDMIFields(), cleanDMIValue() - Reads System Information (type 1): serial, manufacturer, product_name, uuid - Reads Base Board Information (type 2): part_number - Reads BIOS Information (type 0): firmware version record - cleanDMIValue strips vendor placeholders (O.E.M., Not Specified, Unknown, etc.) - board_test.go: 6 table/case tests with dmidecode fixtures in testdata/ - collector.go: wired board + BIOS firmware into snapshot Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/collector/board.go | 149 ++++++++++++++++++ audit/internal/collector/board_test.go | 119 ++++++++++++++ audit/internal/collector/collector.go | 6 +- .../collector/testdata/dmidecode_type0.txt | 25 +++ .../collector/testdata/dmidecode_type1.txt | 14 ++ .../testdata/dmidecode_type1_empty_serial.txt | 14 ++ .../collector/testdata/dmidecode_type2.txt | 15 ++ 7 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 audit/internal/collector/board.go create mode 100644 audit/internal/collector/board_test.go create mode 100644 audit/internal/collector/testdata/dmidecode_type0.txt create mode 100644 audit/internal/collector/testdata/dmidecode_type1.txt create mode 100644 audit/internal/collector/testdata/dmidecode_type1_empty_serial.txt create mode 100644 audit/internal/collector/testdata/dmidecode_type2.txt diff --git a/audit/internal/collector/board.go b/audit/internal/collector/board.go new file mode 100644 index 0000000..f5e870b --- /dev/null +++ b/audit/internal/collector/board.go @@ -0,0 +1,149 @@ +package collector + +import ( + "bee/audit/internal/schema" + "bufio" + "log/slog" + "os/exec" + "strings" +) + +// 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 +} + +// 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.", + "NOT SPECIFIED", + "NOT SETTABLE", + "NOT PRESENT", + "UNKNOWN", + "N/A", + "NONE", + "NULL", + "DEFAULT STRING", + "0", + } + for _, p := range placeholders { + if upper == p { + return "" + } + } + return v +} + +// runDmidecode executes dmidecode -t and returns its stdout. +func runDmidecode(typeNum string) (string, error) { + out, err := exec.Command("dmidecode", "-t", typeNum).Output() + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/audit/internal/collector/board_test.go b/audit/internal/collector/board_test.go new file mode 100644 index 0000000..a6cb0ed --- /dev/null +++ b/audit/internal/collector/board_test.go @@ -0,0 +1,119 @@ +package collector + +import ( + "os" + "testing" +) + +func TestParseBoard(t *testing.T) { + type1 := mustReadFile(t, "testdata/dmidecode_type1.txt") + type2 := mustReadFile(t, "testdata/dmidecode_type2.txt") + + board := parseBoard(type1, type2) + + if board.SerialNumber != "CAR315KA0803B90" { + t.Errorf("serial_number: got %q, want %q", board.SerialNumber, "CAR315KA0803B90") + } + if board.Manufacturer == nil || *board.Manufacturer != "Inspur" { + t.Errorf("manufacturer: got %v, want Inspur", board.Manufacturer) + } + if board.ProductName == nil || *board.ProductName != "NF5468M7" { + t.Errorf("product_name: got %v, want NF5468M7", board.ProductName) + } + if board.PartNumber == nil || *board.PartNumber != "YZCA-02758-105" { + t.Errorf("part_number: got %v, want YZCA-02758-105", board.PartNumber) + } + if board.UUID == nil || *board.UUID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { + t.Errorf("uuid: got %v, want a1b2c3d4-e5f6-7890-abcd-ef1234567890", board.UUID) + } +} + +func TestParseBoard_emptySerial(t *testing.T) { + type1 := mustReadFile(t, "testdata/dmidecode_type1_empty_serial.txt") + + board := parseBoard(type1, "") + + if board.SerialNumber != "" { + t.Errorf("expected empty serial for placeholder value, got %q", board.SerialNumber) + } + if board.Manufacturer != nil { + t.Errorf("expected nil manufacturer for placeholder, got %q", *board.Manufacturer) + } + if board.UUID != nil { + t.Errorf("expected nil UUID for 'Not Settable', got %q", *board.UUID) + } +} + +func TestParseBIOSFirmware(t *testing.T) { + type0 := mustReadFile(t, "testdata/dmidecode_type0.txt") + fw := parseBIOSFirmware(type0) + + if len(fw) != 1 { + t.Fatalf("expected 1 firmware record, got %d", len(fw)) + } + if fw[0].DeviceName != "BIOS" { + t.Errorf("device_name: got %q, want BIOS", fw[0].DeviceName) + } + if fw[0].Version != "06.08.05" { + t.Errorf("version: got %q, want 06.08.05", fw[0].Version) + } +} + +func TestParseBIOSFirmware_empty(t *testing.T) { + fw := parseBIOSFirmware("") + if len(fw) != 0 { + t.Errorf("expected no firmware records for empty input, got %d", len(fw)) + } +} + +func TestCleanDMIValue(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"CAR315KA0803B90", "CAR315KA0803B90"}, + {"To Be Filled By O.E.M.", ""}, + {"to be filled by o.e.m.", ""}, + {"Not Specified", ""}, + {"Not Settable", ""}, + {"Unknown", ""}, + {"N/A", ""}, + {"None", ""}, + {"NULL", ""}, + {"Default String", ""}, + {" Inspur ", "Inspur"}, + {"", ""}, + {"0", ""}, + } + for _, tt := range tests { + got := cleanDMIValue(tt.input) + if got != tt.want { + t.Errorf("cleanDMIValue(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseDMIFields(t *testing.T) { + type1 := mustReadFile(t, "testdata/dmidecode_type1.txt") + fields := parseDMIFields(type1, "System Information") + + if fields["Manufacturer"] != "Inspur" { + t.Errorf("Manufacturer: got %q", fields["Manufacturer"]) + } + if fields["Serial Number"] != "CAR315KA0803B90" { + t.Errorf("Serial Number: got %q", fields["Serial Number"]) + } + // sub-list items must not be included + if _, ok := fields["PCI is supported"]; ok { + t.Error("sub-list item should not appear in fields") + } +} + +func mustReadFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture %s: %v", path, err) + } + return string(b) +} diff --git a/audit/internal/collector/collector.go b/audit/internal/collector/collector.go index 0f2587b..3df4a36 100644 --- a/audit/internal/collector/collector.go +++ b/audit/internal/collector/collector.go @@ -17,7 +17,11 @@ func Run() schema.HardwareIngestRequest { snap := schema.HardwareSnapshot{} - // collectors are added here in subsequent steps (1.2 – 1.10) + board, biosFW := collectBoard() + snap.Board = board + snap.Firmware = append(snap.Firmware, biosFW...) + + // remaining collectors added in steps 1.3 – 1.10 slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond)) diff --git a/audit/internal/collector/testdata/dmidecode_type0.txt b/audit/internal/collector/testdata/dmidecode_type0.txt new file mode 100644 index 0000000..dcc63d0 --- /dev/null +++ b/audit/internal/collector/testdata/dmidecode_type0.txt @@ -0,0 +1,25 @@ +# dmidecode 3.5 +Getting SMBIOS data from sysfs. +SMBIOS 3.1.1 present. + +Handle 0x0000, DMI type 0, 26 bytes +BIOS Information + Vendor: American Megatrends Inc. + Version: 06.08.05 + Release Date: 12/20/2023 + Address: 0xF0000 + Runtime Size: 64 kB + ROM Size: 32 MB + Characteristics: + PCI is supported + PCI Express is supported + BIOS is upgradeable + BIOS shadowing is allowed + Selectable boot is supported + ACPI is supported + USB legacy is supported + BIOS boot specification is supported + Targeted content distribution is supported + UEFI is supported + BIOS Revision: 5.22 + Firmware Revision: 4.1 diff --git a/audit/internal/collector/testdata/dmidecode_type1.txt b/audit/internal/collector/testdata/dmidecode_type1.txt new file mode 100644 index 0000000..be30063 --- /dev/null +++ b/audit/internal/collector/testdata/dmidecode_type1.txt @@ -0,0 +1,14 @@ +# dmidecode 3.5 +Getting SMBIOS data from sysfs. +SMBIOS 3.1.1 present. + +Handle 0x0001, DMI type 1, 27 bytes +System Information + Manufacturer: Inspur + Product Name: NF5468M7 + Version: To Be Filled By O.E.M. + Serial Number: CAR315KA0803B90 + UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + Wake-up Type: Power Switch + SKU Number: To Be Filled By O.E.M. + Family: To Be Filled By O.E.M. diff --git a/audit/internal/collector/testdata/dmidecode_type1_empty_serial.txt b/audit/internal/collector/testdata/dmidecode_type1_empty_serial.txt new file mode 100644 index 0000000..328e2e6 --- /dev/null +++ b/audit/internal/collector/testdata/dmidecode_type1_empty_serial.txt @@ -0,0 +1,14 @@ +# dmidecode 3.5 +Getting SMBIOS data from sysfs. +SMBIOS 3.1.1 present. + +Handle 0x0001, DMI type 1, 27 bytes +System Information + Manufacturer: To Be Filled By O.E.M. + Product Name: To Be Filled By O.E.M. + Version: To Be Filled By O.E.M. + Serial Number: To Be Filled By O.E.M. + UUID: Not Settable + Wake-up Type: Power Switch + SKU Number: To Be Filled By O.E.M. + Family: To Be Filled By O.E.M. diff --git a/audit/internal/collector/testdata/dmidecode_type2.txt b/audit/internal/collector/testdata/dmidecode_type2.txt new file mode 100644 index 0000000..98eb06f --- /dev/null +++ b/audit/internal/collector/testdata/dmidecode_type2.txt @@ -0,0 +1,15 @@ +# dmidecode 3.5 +Getting SMBIOS data from sysfs. +SMBIOS 3.1.1 present. + +Handle 0x0002, DMI type 2, 15 bytes +Base Board Information + Manufacturer: Inspur + Product Name: YZCA-02758-105 + Version: To Be Filled By O.E.M. + Serial Number: CAR315KA0803B90 + Asset Tag: To Be Filled By O.E.M. + Features: + Board is a hosting board + Location In Chassis: To Be Filled By O.E.M. + Type: Motherboard