diff --git a/audit/internal/collector/collector.go b/audit/internal/collector/collector.go index a9f2b8c..58e47fb 100644 --- a/audit/internal/collector/collector.go +++ b/audit/internal/collector/collector.go @@ -25,7 +25,11 @@ func Run() schema.HardwareIngestRequest { snap.CPUs = cpus snap.Firmware = append(snap.Firmware, cpuFW...) - // remaining collectors added in steps 1.4 – 1.10 + snap.Memory = collectMemory() + snap.Storage = collectStorage() + snap.PCIeDevices = collectPCIe() + + // remaining collectors added in steps 1.7 – 1.10 slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond)) diff --git a/audit/internal/collector/memory.go b/audit/internal/collector/memory.go new file mode 100644 index 0000000..8f7f806 --- /dev/null +++ b/audit/internal/collector/memory.go @@ -0,0 +1,98 @@ +package collector + +import ( + "bee/audit/internal/schema" + "log/slog" + "strings" +) + +// collectMemory runs dmidecode -t 17 and returns all memory slots. +func collectMemory() []schema.HardwareMemory { + out, err := runDmidecode("17") + if err != nil { + slog.Warn("memory: dmidecode type 17 failed", "err", err) + return nil + } + dimms := parseMemory(out) + slog.Info("memory: collected", "count", len(dimms)) + return dimms +} + +func parseMemory(output string) []schema.HardwareMemory { + sections := splitDMISections(output, "Memory Device") + dimms := make([]schema.HardwareMemory, 0, len(sections)) + for _, fields := range sections { + dimm := parseMemorySection(fields) + dimms = append(dimms, dimm) + } + return dimms +} + +func parseMemorySection(fields map[string]string) schema.HardwareMemory { + dimm := schema.HardwareMemory{} + + if v := cleanDMIValue(fields["Locator"]); v != "" { + dimm.Slot = &v + } + if v := cleanDMIValue(fields["Bank Locator"]); v != "" { + dimm.Location = &v + } + + // presence: "No Module Installed" or size == 0 + present := true + rawSize := fields["Size"] + if strings.Contains(strings.ToLower(rawSize), "no module") || rawSize == "0" || rawSize == "" { + present = false + } + dimm.Present = &present + + if !present { + status := "EMPTY" + dimm.Status = &status + return dimm + } + + status := "OK" + dimm.Status = &status + + if mb := parseMemorySizeMB(rawSize); mb > 0 { + dimm.SizeMB = &mb + } + if v := cleanDMIValue(fields["Type"]); v != "" && v != "Unknown" { + dimm.Type = &v + } + if mhz := parseInt(strings.TrimSuffix(fields["Speed"], " MT/s")); mhz > 0 { + dimm.MaxSpeedMHz = &mhz + } + if mhz := parseInt(strings.TrimSuffix(fields["Configured Memory Speed"], " MT/s")); mhz > 0 { + dimm.CurrentSpeedMHz = &mhz + } + if v := cleanDMIValue(fields["Manufacturer"]); v != "" { + dimm.Manufacturer = &v + } + if v := cleanDMIValue(fields["Serial Number"]); v != "" { + dimm.SerialNumber = &v + } + if v := cleanDMIValue(fields["Part Number"]); v != "" { + p := strings.TrimSpace(v) + dimm.PartNumber = &p + } + + return dimm +} + +// parseMemorySizeMB parses DMI size strings: "8 GB", "2048 MB", "No Module Installed". +func parseMemorySizeMB(s string) int { + s = strings.TrimSpace(s) + if strings.Contains(strings.ToLower(s), "no module") { + return 0 + } + if strings.HasSuffix(s, " GB") { + n := parseInt(strings.TrimSuffix(s, " GB")) + return n * 1024 + } + if strings.HasSuffix(s, " MB") { + return parseInt(strings.TrimSuffix(s, " MB")) + } + return parseInt(s) +} diff --git a/audit/internal/collector/pcie.go b/audit/internal/collector/pcie.go new file mode 100644 index 0000000..8ea5341 --- /dev/null +++ b/audit/internal/collector/pcie.go @@ -0,0 +1,101 @@ +package collector + +import ( + "bee/audit/internal/schema" + "log/slog" + "os/exec" + "strconv" + "strings" +) + +func collectPCIe() []schema.HardwarePCIeDevice { + out, err := exec.Command("lspci", "-vmm", "-D").Output() + if err != nil { + slog.Warn("pcie: lspci failed", "err", err) + return nil + } + devs := parseLspci(string(out)) + slog.Info("pcie: collected", "count", len(devs)) + return devs +} + +func parseLspci(output string) []schema.HardwarePCIeDevice { + // lspci -vmm -D outputs blank-line separated records, each field is "Key:\tValue" + var devs []schema.HardwarePCIeDevice + for _, block := range strings.Split(output, "\n\n") { + block = strings.TrimSpace(block) + if block == "" { + continue + } + fields := map[string]string{} + for _, line := range strings.Split(block, "\n") { + idx := strings.Index(line, ":\t") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+2:]) + fields[key] = val + } + dev := parseLspciDevice(fields) + devs = append(devs, dev) + } + return devs +} + +func parseLspciDevice(fields map[string]string) schema.HardwarePCIeDevice { + dev := schema.HardwarePCIeDevice{} + present := true + dev.Present = &present + status := "OK" + dev.Status = &status + + // Slot is the BDF: "0000:00:02.0" + if bdf := fields["Slot"]; bdf != "" { + dev.BDF = &bdf + // parse vendor_id and device_id from sysfs + vendorID, deviceID := readPCIIDs(bdf) + if vendorID != 0 { + dev.VendorID = &vendorID + } + if deviceID != 0 { + dev.DeviceID = &deviceID + } + } + + if v := fields["Class"]; v != "" { + dev.DeviceClass = &v + } + if v := fields["Vendor"]; v != "" { + dev.Manufacturer = &v + } + if v := fields["Device"]; v != "" { + dev.Model = &v + } + + // SVendor/SDevice available but not in schema — skip + + return dev +} + +// readPCIIDs reads vendor and device IDs from sysfs for a given BDF. +func readPCIIDs(bdf string) (vendorID, deviceID int) { + base := "/sys/bus/pci/devices/" + bdf + if v, err := readHexFile(base + "/vendor"); err == nil { + vendorID = v + } + if v, err := readHexFile(base + "/device"); err == nil { + deviceID = v + } + return +} + +func readHexFile(path string) (int, error) { + out, err := exec.Command("cat", path).Output() + if err != nil { + return 0, err + } + s := strings.TrimSpace(strings.TrimPrefix(string(out), "0x")) + n, err := strconv.ParseInt(s, 16, 64) + return int(n), err +} diff --git a/audit/internal/collector/storage.go b/audit/internal/collector/storage.go new file mode 100644 index 0000000..1666c4c --- /dev/null +++ b/audit/internal/collector/storage.go @@ -0,0 +1,177 @@ +package collector + +import ( + "bee/audit/internal/schema" + "encoding/json" + "log/slog" + "os/exec" + "strings" +) + +func collectStorage() []schema.HardwareStorage { + devs := lsblkDevices() + result := make([]schema.HardwareStorage, 0, len(devs)) + for _, dev := range devs { + s := enrichWithSmartctl(dev) + result = append(result, s) + } + slog.Info("storage: collected", "count", len(result)) + return result +} + +// lsblkDevice is a minimal lsblk JSON record. +type lsblkDevice struct { + Name string `json:"name"` + Type string `json:"type"` + Size string `json:"size"` + Serial string `json:"serial"` + Model string `json:"model"` + Tran string `json:"tran"` + Hctl string `json:"hctl"` +} + +type lsblkRoot struct { + Blockdevices []lsblkDevice `json:"blockdevices"` +} + +func lsblkDevices() []lsblkDevice { + out, err := exec.Command("lsblk", "-J", "-d", + "-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL").Output() + if err != nil { + slog.Warn("storage: lsblk failed", "err", err) + return nil + } + var root lsblkRoot + if err := json.Unmarshal(out, &root); err != nil { + slog.Warn("storage: lsblk parse failed", "err", err) + return nil + } + var disks []lsblkDevice + for _, d := range root.Blockdevices { + if d.Type == "disk" { + disks = append(disks, d) + } + } + return disks +} + +// smartctlInfo is the subset of smartctl -j -a output we care about. +type smartctlInfo struct { + ModelFamily string `json:"model_family"` + ModelName string `json:"model_name"` + SerialNumber string `json:"serial_number"` + FirmwareVer string `json:"firmware_version"` + RotationRate int `json:"rotation_rate"` + UserCapacity struct { + Bytes int64 `json:"bytes"` + } `json:"user_capacity"` + AtaSmartAttributes struct { + Table []struct { + ID int `json:"id"` + Name string `json:"name"` + Raw struct{ Value int64 `json:"value"` } `json:"raw"` + } `json:"table"` + } `json:"ata_smart_attributes"` + PowerOnTime struct { + Hours int `json:"hours"` + } `json:"power_on_time"` + PowerCycleCount int `json:"power_cycle_count"` +} + +func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage { + present := true + s := schema.HardwareStorage{Present: &present} + + tran := strings.ToLower(dev.Tran) + devPath := "/dev/" + dev.Name + + // determine device type (refined by smartctl rotation_rate below) + var devType string + switch { + case strings.HasPrefix(dev.Name, "nvme"): + devType = "NVMe" + case tran == "usb": + devType = "USB" + case tran == "sata" || tran == "sas": + devType = "HDD" // refined to SSD below if rotation_rate==0 + default: + devType = "Unknown" + } + + iface := strings.ToUpper(tran) + if iface != "" { + s.Interface = &iface + } + + // slot from HCTL (host:channel:target:lun) + if dev.Hctl != "" { + s.Slot = &dev.Hctl + } + + // run smartctl + out, err := exec.Command("smartctl", "-j", "-a", devPath).Output() + if err != nil { + // still fill what lsblk gave us + if v := strings.TrimSpace(dev.Model); v != "" { + s.Model = &v + } + if v := strings.TrimSpace(dev.Serial); v != "" { + s.SerialNumber = &v + } + s.Type = &devType + return s + } + + var info smartctlInfo + if err := json.Unmarshal(out, &info); err == nil { + if v := cleanDMIValue(info.ModelName); v != "" { + s.Model = &v + } + if v := cleanDMIValue(info.SerialNumber); v != "" { + s.SerialNumber = &v + } + if v := cleanDMIValue(info.FirmwareVer); v != "" { + s.Firmware = &v + } + if info.UserCapacity.Bytes > 0 { + gb := int(info.UserCapacity.Bytes / 1_000_000_000) + s.SizeGB = &gb + } + + // refine type from rotation_rate + if info.RotationRate == 0 && devType != "NVMe" && devType != "USB" { + devType = "SSD" + } else if info.RotationRate > 0 { + devType = "HDD" + } + + // telemetry + tel := map[string]any{} + if info.PowerOnTime.Hours > 0 { + tel["power_on_hours"] = info.PowerOnTime.Hours + } + if info.PowerCycleCount > 0 { + tel["power_cycles"] = info.PowerCycleCount + } + for _, attr := range info.AtaSmartAttributes.Table { + switch attr.ID { + case 5: + tel["reallocated_sectors"] = attr.Raw.Value + case 177: + tel["wear_leveling_pct"] = attr.Raw.Value + case 231: + tel["life_remaining_pct"] = attr.Raw.Value + case 241: + tel["total_lba_written"] = attr.Raw.Value + } + } + if len(tel) > 0 { + s.Telemetry = tel + } + } + + s.Type = &devType + status := "OK" + s.Status = &status + return s +}