feat(audit): 1.2 — board collector (dmidecode types 0, 1, 2)
- 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 <noreply@anthropic.com>
This commit is contained in:
149
audit/internal/collector/board.go
Normal file
149
audit/internal/collector/board.go
Normal file
@@ -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 <typeNum> 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
|
||||||
|
}
|
||||||
119
audit/internal/collector/board_test.go
Normal file
119
audit/internal/collector/board_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,7 +17,11 @@ func Run() schema.HardwareIngestRequest {
|
|||||||
|
|
||||||
snap := schema.HardwareSnapshot{}
|
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))
|
slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
|
|||||||
25
audit/internal/collector/testdata/dmidecode_type0.txt
vendored
Normal file
25
audit/internal/collector/testdata/dmidecode_type0.txt
vendored
Normal file
@@ -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
|
||||||
14
audit/internal/collector/testdata/dmidecode_type1.txt
vendored
Normal file
14
audit/internal/collector/testdata/dmidecode_type1.txt
vendored
Normal file
@@ -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.
|
||||||
14
audit/internal/collector/testdata/dmidecode_type1_empty_serial.txt
vendored
Normal file
14
audit/internal/collector/testdata/dmidecode_type1_empty_serial.txt
vendored
Normal file
@@ -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.
|
||||||
15
audit/internal/collector/testdata/dmidecode_type2.txt
vendored
Normal file
15
audit/internal/collector/testdata/dmidecode_type2.txt
vendored
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user