feat(audit): 1.1 — project scaffold, schema types, collector stub, updater trust
- go.mod: module bee/audit - schema/hardware.go: HardwareIngestRequest types (compatible with core) - collector/collector.go: Run() stub, logs start/finish, returns empty snapshot - updater/trust.go: Ed25519 multi-key verification via ldflags injection - updater/trust_test.go: valid sig, tampered, multi-key any-match, dev build - cmd/audit/main.go: --output stdout|file:<path>|usb, --version flag - Version = "dev" by default, injected via ldflags at release Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "bible"]
|
||||||
|
path = bible
|
||||||
|
url = https://git.mchus.pro/mchus/bible.git
|
||||||
78
audit/cmd/audit/main.go
Normal file
78
audit/cmd/audit/main.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bee/audit/internal/collector"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the audit binary version.
|
||||||
|
// Injected at release build time via:
|
||||||
|
//
|
||||||
|
// -ldflags "-X main.Version=1.2"
|
||||||
|
//
|
||||||
|
// Defaults to "dev" in local builds.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
output := flag.String("output", "stdout", `output destination:
|
||||||
|
stdout — print JSON to stdout (default)
|
||||||
|
file:<path> — write JSON to file
|
||||||
|
usb — auto-detect removable media, write JSON there`)
|
||||||
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println(Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
})))
|
||||||
|
|
||||||
|
result := collector.Run()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("marshal result", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOutput(*output, data); err != nil {
|
||||||
|
slog.Error("write output", "destination", *output, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOutput(dest string, data []byte) error {
|
||||||
|
switch {
|
||||||
|
case dest == "stdout":
|
||||||
|
_, err := os.Stdout.Write(append(data, '\n'))
|
||||||
|
return err
|
||||||
|
|
||||||
|
case strings.HasPrefix(dest, "file:"):
|
||||||
|
path := strings.TrimPrefix(dest, "file:")
|
||||||
|
return os.WriteFile(path, append(data, '\n'), 0644)
|
||||||
|
|
||||||
|
case dest == "usb":
|
||||||
|
return writeToUSB(data)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown output destination %q — use stdout, file:<path>, or usb", dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeToUSB auto-detects the first removable block device, mounts it,
|
||||||
|
// and writes the audit JSON. Falls back to /tmp on any failure.
|
||||||
|
func writeToUSB(data []byte) error {
|
||||||
|
// implemented in step 1.11
|
||||||
|
slog.Warn("usb output not yet implemented, falling back to stdout")
|
||||||
|
_, err := os.Stdout.Write(append(data, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
3
audit/go.mod
Normal file
3
audit/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module bee/audit
|
||||||
|
|
||||||
|
go 1.23
|
||||||
33
audit/internal/collector/collector.go
Normal file
33
audit/internal/collector/collector.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Package collector runs all hardware collectors and merges results
|
||||||
|
// into a single HardwareSnapshot. Each sub-collector is independent:
|
||||||
|
// a failure in one does not abort the others.
|
||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bee/audit/internal/schema"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes all collectors and returns the combined snapshot.
|
||||||
|
// Partial failures are logged as warnings; collection always completes.
|
||||||
|
func Run() schema.HardwareIngestRequest {
|
||||||
|
start := time.Now()
|
||||||
|
slog.Info("audit started")
|
||||||
|
|
||||||
|
snap := schema.HardwareSnapshot{}
|
||||||
|
|
||||||
|
// collectors are added here in subsequent steps (1.2 – 1.10)
|
||||||
|
|
||||||
|
slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
|
sourceType := "livcd"
|
||||||
|
protocol := "os-direct"
|
||||||
|
|
||||||
|
return schema.HardwareIngestRequest{
|
||||||
|
SourceType: &sourceType,
|
||||||
|
Protocol: &protocol,
|
||||||
|
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Hardware: snap,
|
||||||
|
}
|
||||||
|
}
|
||||||
113
audit/internal/schema/hardware.go
Normal file
113
audit/internal/schema/hardware.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Package schema defines the HardwareIngestRequest types compatible with
|
||||||
|
// core/internal/ingest/parser_hardware.go. No import dependency on core.
|
||||||
|
package schema
|
||||||
|
|
||||||
|
// HardwareIngestRequest is the top-level output document produced by the audit binary.
|
||||||
|
// It is accepted as-is by the core /api/ingest/hardware endpoint.
|
||||||
|
type HardwareIngestRequest struct {
|
||||||
|
Filename *string `json:"filename"`
|
||||||
|
SourceType *string `json:"source_type"`
|
||||||
|
Protocol *string `json:"protocol"`
|
||||||
|
TargetHost string `json:"target_host"`
|
||||||
|
CollectedAt string `json:"collected_at"`
|
||||||
|
Hardware HardwareSnapshot `json:"hardware"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwareSnapshot struct {
|
||||||
|
Board HardwareBoard `json:"board"`
|
||||||
|
Firmware []HardwareFirmwareRecord `json:"firmware,omitempty"`
|
||||||
|
CPUs []HardwareCPU `json:"cpus,omitempty"`
|
||||||
|
Memory []HardwareMemory `json:"memory,omitempty"`
|
||||||
|
Storage []HardwareStorage `json:"storage,omitempty"`
|
||||||
|
PCIeDevices []HardwarePCIeDevice `json:"pcie_devices,omitempty"`
|
||||||
|
PowerSupplies []HardwarePowerSupply `json:"power_supplies,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwareBoard struct {
|
||||||
|
Manufacturer *string `json:"manufacturer"`
|
||||||
|
ProductName *string `json:"product_name"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
PartNumber *string `json:"part_number"`
|
||||||
|
UUID *string `json:"uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwareFirmwareRecord struct {
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwareCPU struct {
|
||||||
|
Socket *int `json:"socket"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
Manufacturer *string `json:"manufacturer"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
SerialNumber *string `json:"serial_number"`
|
||||||
|
Firmware *string `json:"firmware"`
|
||||||
|
Cores *int `json:"cores"`
|
||||||
|
Threads *int `json:"threads"`
|
||||||
|
FrequencyMHz *int `json:"frequency_mhz"`
|
||||||
|
MaxFrequencyMHz *int `json:"max_frequency_mhz"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwareMemory struct {
|
||||||
|
Slot *string `json:"slot"`
|
||||||
|
Location *string `json:"location"`
|
||||||
|
Present *bool `json:"present"`
|
||||||
|
SizeMB *int `json:"size_mb"`
|
||||||
|
Type *string `json:"type"`
|
||||||
|
MaxSpeedMHz *int `json:"max_speed_mhz"`
|
||||||
|
CurrentSpeedMHz *int `json:"current_speed_mhz"`
|
||||||
|
Manufacturer *string `json:"manufacturer"`
|
||||||
|
SerialNumber *string `json:"serial_number"`
|
||||||
|
PartNumber *string `json:"part_number"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwareStorage struct {
|
||||||
|
Slot *string `json:"slot"`
|
||||||
|
Type *string `json:"type"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
SizeGB *int `json:"size_gb"`
|
||||||
|
SerialNumber *string `json:"serial_number"`
|
||||||
|
Manufacturer *string `json:"manufacturer"`
|
||||||
|
Firmware *string `json:"firmware"`
|
||||||
|
Interface *string `json:"interface"`
|
||||||
|
Present *bool `json:"present"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Telemetry map[string]any `json:"telemetry,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwarePCIeDevice struct {
|
||||||
|
Slot *string `json:"slot"`
|
||||||
|
VendorID *int `json:"vendor_id"`
|
||||||
|
DeviceID *int `json:"device_id"`
|
||||||
|
BDF *string `json:"bdf"`
|
||||||
|
DeviceClass *string `json:"device_class"`
|
||||||
|
Manufacturer *string `json:"manufacturer"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
LinkWidth *int `json:"link_width"`
|
||||||
|
LinkSpeed *string `json:"link_speed"`
|
||||||
|
MaxLinkWidth *int `json:"max_link_width"`
|
||||||
|
MaxLinkSpeed *string `json:"max_link_speed"`
|
||||||
|
SerialNumber *string `json:"serial_number"`
|
||||||
|
Firmware *string `json:"firmware"`
|
||||||
|
Present *bool `json:"present"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Telemetry map[string]any `json:"telemetry,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HardwarePowerSupply struct {
|
||||||
|
Slot *string `json:"slot"`
|
||||||
|
Present *bool `json:"present"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
Vendor *string `json:"vendor"`
|
||||||
|
WattageW *int `json:"wattage_w"`
|
||||||
|
SerialNumber *string `json:"serial_number"`
|
||||||
|
PartNumber *string `json:"part_number"`
|
||||||
|
Firmware *string `json:"firmware"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
InputType *string `json:"input_type"`
|
||||||
|
InputPowerW *float64 `json:"input_power_w"`
|
||||||
|
OutputPowerW *float64 `json:"output_power_w"`
|
||||||
|
InputVoltage *float64 `json:"input_voltage"`
|
||||||
|
}
|
||||||
59
audit/internal/updater/trust.go
Normal file
59
audit/internal/updater/trust.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Package updater handles binary self-update with Ed25519 signature verification.
|
||||||
|
// See bible/rules/patterns/release-signing/contract.md for the full contract.
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trustedKeysRaw is injected at release build time via:
|
||||||
|
//
|
||||||
|
// -ldflags "-X bee/audit/internal/updater.trustedKeysRaw=base64key1:base64key2"
|
||||||
|
//
|
||||||
|
// Empty in dev builds — updates are disabled when empty.
|
||||||
|
var trustedKeysRaw string
|
||||||
|
|
||||||
|
// TrustedKeys decodes the embedded public keys.
|
||||||
|
// Returns an error if the binary was built without key injection (dev build).
|
||||||
|
func TrustedKeys() ([]ed25519.PublicKey, error) {
|
||||||
|
if strings.TrimSpace(trustedKeysRaw) == "" {
|
||||||
|
return nil, fmt.Errorf("dev build: trusted keys not embedded, updates disabled")
|
||||||
|
}
|
||||||
|
var keys []ed25519.PublicKey
|
||||||
|
for _, enc := range strings.Split(trustedKeysRaw, ":") {
|
||||||
|
enc = strings.TrimSpace(enc)
|
||||||
|
if enc == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := base64.StdEncoding.DecodeString(enc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode trusted key: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) != ed25519.PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("trusted key has wrong size: got %d, want %d", len(b), ed25519.PublicKeySize)
|
||||||
|
}
|
||||||
|
keys = append(keys, ed25519.PublicKey(b))
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid trusted keys found in build")
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySignature returns nil if sig was produced by any trusted key over data.
|
||||||
|
// Returns an error (never panics) on any failure — caller logs and continues.
|
||||||
|
func VerifySignature(data, sig []byte) error {
|
||||||
|
keys, err := TrustedKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if ed25519.Verify(key, data, sig) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("signature verification failed: no trusted key matched")
|
||||||
|
}
|
||||||
72
audit/internal/updater/trust_test.go
Normal file
72
audit/internal/updater/trust_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifySignature_valid(t *testing.T) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte("release binary content")
|
||||||
|
sig := ed25519.Sign(priv, data)
|
||||||
|
|
||||||
|
trustedKeysRaw = base64.StdEncoding.EncodeToString(pub)
|
||||||
|
t.Cleanup(func() { trustedKeysRaw = "" })
|
||||||
|
|
||||||
|
if err := VerifySignature(data, sig); err != nil {
|
||||||
|
t.Fatalf("expected valid signature to pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_tampered(t *testing.T) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = pub
|
||||||
|
|
||||||
|
data := []byte("original content")
|
||||||
|
sig := ed25519.Sign(priv, data)
|
||||||
|
|
||||||
|
// different key embedded
|
||||||
|
pub2, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
trustedKeysRaw = base64.StdEncoding.EncodeToString(pub2)
|
||||||
|
t.Cleanup(func() { trustedKeysRaw = "" })
|
||||||
|
|
||||||
|
if err := VerifySignature(data, sig); err == nil {
|
||||||
|
t.Fatal("expected tampered signature to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_multiKey_anyMatches(t *testing.T) {
|
||||||
|
pub1, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
pub2, priv2, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
|
||||||
|
data := []byte("signed by developer 2")
|
||||||
|
sig := ed25519.Sign(priv2, data)
|
||||||
|
|
||||||
|
trustedKeysRaw = base64.StdEncoding.EncodeToString(pub1) + ":" +
|
||||||
|
base64.StdEncoding.EncodeToString(pub2)
|
||||||
|
t.Cleanup(func() { trustedKeysRaw = "" })
|
||||||
|
|
||||||
|
if err := VerifySignature(data, sig); err != nil {
|
||||||
|
t.Fatalf("expected any-key match to pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_devBuild(t *testing.T) {
|
||||||
|
trustedKeysRaw = ""
|
||||||
|
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
data := []byte("content")
|
||||||
|
sig := ed25519.Sign(priv, data)
|
||||||
|
|
||||||
|
if err := VerifySignature(data, sig); err == nil {
|
||||||
|
t.Fatal("expected dev build (no keys) to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
bible
Submodule
1
bible
Submodule
Submodule bible added at 34b457d654
Reference in New Issue
Block a user