7cb5d8d8c4
---ci--- project: orca phase: 25 milestone: v0.12 status: execute ---/ci--- VerifyEventSignature: per-peer HMAC-SHA256 via HKDF(masterKey, peerID, 'orca-drift-event-hmac'). Aggregator rejects unsigned/forged events. Test: valid/wrong-key/wrong-peer/tampered/empty cases. Build + tests green. Per-peer key deployment at /etc/orca/keys/drift-hmac.key (0600, orca user) is handled by peer-setup (documented).
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package drift
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/hkdf"
|
|
)
|
|
|
|
type mockTransport struct {
|
|
execOut []byte
|
|
execErr error
|
|
execFn func(ctx context.Context, peer string, cmd string) ([]byte, error)
|
|
readOut []byte
|
|
readErr error
|
|
writes []writeEntry
|
|
}
|
|
|
|
type writeEntry struct {
|
|
peer string
|
|
path string
|
|
content []byte
|
|
mode os.FileMode
|
|
}
|
|
|
|
func (m *mockTransport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
|
if m.execFn != nil {
|
|
return m.execFn(ctx, peer, cmd)
|
|
}
|
|
return m.execOut, m.execErr
|
|
}
|
|
|
|
func (m *mockTransport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
|
|
m.writes = append(m.writes, writeEntry{peer, path, content, mode})
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockTransport) ReadFile(ctx context.Context, peer string, path string) ([]byte, error) {
|
|
return m.readOut, m.readErr
|
|
}
|
|
|
|
func setupDriftTestEnv(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("ORCA_LEAD_STATE_DIR", dir)
|
|
return dir
|
|
}
|
|
|
|
func TestParseEvent(t *testing.T) {
|
|
raw := []byte(`{
|
|
"event_id":"EVT-1",
|
|
"ts":"2026-01-01T00:00:00Z",
|
|
"host":"peer1",
|
|
"path":"/etc/traefik/dynamic/orca.yml",
|
|
"status":"modified",
|
|
"new_sha256":"abc",
|
|
"latest_txn":"T-1234567890abcdef",
|
|
"expected_sha256":"def",
|
|
"drift_confirmed":true,
|
|
"action":"reported",
|
|
"action_result":"skipped",
|
|
"operator_id":"op1"
|
|
}`)
|
|
e, err := ParseEvent(raw)
|
|
if err != nil {
|
|
t.Fatalf("ParseEvent: %v", err)
|
|
}
|
|
if e.EventID != "EVT-1" {
|
|
t.Errorf("EventID = %q, want EVT-1", e.EventID)
|
|
}
|
|
if e.Host != "peer1" {
|
|
t.Errorf("Host = %q, want peer1", e.Host)
|
|
}
|
|
if e.Path != "/etc/traefik/dynamic/orca.yml" {
|
|
t.Errorf("Path = %q", e.Path)
|
|
}
|
|
if e.Status != StatusModified {
|
|
t.Errorf("Status = %q, want modified", e.Status)
|
|
}
|
|
if !e.DriftConfirmed {
|
|
t.Errorf("DriftConfirmed = false, want true")
|
|
}
|
|
if e.Action != ActionReported {
|
|
t.Errorf("Action = %q, want reported", e.Action)
|
|
}
|
|
if e.ActionResult != ActionResultSkipped {
|
|
t.Errorf("ActionResult = %q, want skipped", e.ActionResult)
|
|
}
|
|
}
|
|
|
|
func TestParseEventInvalid(t *testing.T) {
|
|
_, err := ParseEvent([]byte(`{not json`))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON, got nil")
|
|
}
|
|
}
|
|
|
|
func TestMarshalEventRoundTrip(t *testing.T) {
|
|
e := Event{EventID: "E1", Host: "h", Path: "/p", Status: StatusCreated, DriftConfirmed: true}
|
|
raw, err := MarshalEvent(e)
|
|
if err != nil {
|
|
t.Fatalf("MarshalEvent: %v", err)
|
|
}
|
|
out, err := ParseEvent(raw)
|
|
if err != nil {
|
|
t.Fatalf("ParseEvent: %v", err)
|
|
}
|
|
if out.EventID != e.EventID || out.Host != e.Host || out.Path != e.Path || out.Status != e.Status {
|
|
t.Errorf("round-trip mismatch: %+v vs %+v", out, e)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigDefault(t *testing.T) {
|
|
cfg, err := LoadConfig("")
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig empty: %v", err)
|
|
}
|
|
if !cfg.Polling.Enabled {
|
|
t.Errorf("Polling.Enabled = false, want true")
|
|
}
|
|
if cfg.Polling.DefaultInterval != 60*time.Second {
|
|
t.Errorf("DefaultInterval = %v, want 60s", cfg.Polling.DefaultInterval)
|
|
}
|
|
if len(cfg.Paths.Critical) == 0 {
|
|
t.Errorf("Critical paths empty")
|
|
}
|
|
found := false
|
|
for _, c := range cfg.Paths.Critical {
|
|
if c.Pattern == "/etc/traefik/dynamic/orca.yml" {
|
|
found = true
|
|
if c.Interval != 5*time.Second {
|
|
t.Errorf("critical interval = %v, want 5s", c.Interval)
|
|
}
|
|
if !c.SystemdPathUnit {
|
|
t.Errorf("SystemdPathUnit = false, want true")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("traefik critical path missing")
|
|
}
|
|
found = false
|
|
for _, s := range cfg.Paths.Standard {
|
|
if s.Pattern == "/etc/orca/actual/*" {
|
|
found = true
|
|
if s.Interval != 30*time.Second {
|
|
t.Errorf("standard interval = %v, want 30s", s.Interval)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/etc/orca/actual/* standard path missing")
|
|
}
|
|
if !cfg.Remediate.Auto {
|
|
t.Errorf("Remediate.Auto = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "drift.json")
|
|
cfgJSON := `{
|
|
"polling": {"enabled": true, "default_interval": 90000000000, "max_concurrent_peers": 4},
|
|
"paths": {
|
|
"critical": [{"tier":"critical","pattern":"/etc/critical.yml","interval":5000000000,"systemd_path_unit":true}],
|
|
"standard": [{"tier":"standard","pattern":"/etc/standard.yml","interval":30000000000}],
|
|
"excluded": ["/run/orca/*"]
|
|
},
|
|
"remediate": {"auto": false, "auto_paths": [], "require_approval": ["/etc/critical.yml"], "notify_on_remediation": false}
|
|
}`
|
|
if err := os.WriteFile(path, []byte(cfgJSON), 0o644); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
cfg, err := LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
}
|
|
if cfg.Polling.MaxConcurrentPeers != 4 {
|
|
t.Errorf("MaxConcurrentPeers = %d, want 4", cfg.Polling.MaxConcurrentPeers)
|
|
}
|
|
if cfg.Polling.DefaultInterval != 90*time.Second {
|
|
t.Errorf("DefaultInterval = %v, want 90s", cfg.Polling.DefaultInterval)
|
|
}
|
|
if cfg.Remediate.Auto {
|
|
t.Errorf("Auto = true, want false")
|
|
}
|
|
if len(cfg.Paths.Critical) != 1 || cfg.Paths.Critical[0].Pattern != "/etc/critical.yml" {
|
|
t.Errorf("critical = %+v", cfg.Paths.Critical)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfigValid(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
if err := ValidateConfig(cfg); err != nil {
|
|
t.Fatalf("ValidateConfig(default): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfigEmptyPattern(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Paths.Critical[0].Pattern = ""
|
|
if err := ValidateConfig(cfg); err == nil {
|
|
t.Fatal("expected error for empty critical pattern")
|
|
}
|
|
}
|
|
|
|
func TestValidateConfigPollingZero(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Polling.Enabled = true
|
|
cfg.Polling.DefaultInterval = 0
|
|
if err := ValidateConfig(cfg); err == nil {
|
|
t.Fatal("expected error for zero default_interval with polling enabled")
|
|
}
|
|
}
|
|
|
|
func TestValidateConfigOverlapCriticalExcluded(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Paths.Excluded = append(cfg.Paths.Excluded, "/etc/traefik/dynamic/orca.yml")
|
|
err := ValidateConfig(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected overlap error")
|
|
}
|
|
if !errors.Is(err, ErrOverlapCritical) {
|
|
t.Errorf("expected ErrOverlapCritical, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfigNil(t *testing.T) {
|
|
if err := ValidateConfig(nil); err == nil {
|
|
t.Fatal("expected error for nil config")
|
|
}
|
|
}
|
|
|
|
func TestAggregateLocalMissing(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
d := NewDefaultDetector(&mockTransport{})
|
|
events, err := d.Aggregate(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("Aggregate missing: %v", err)
|
|
}
|
|
if events != nil {
|
|
t.Errorf("events = %v, want nil", events)
|
|
}
|
|
}
|
|
|
|
func TestAggregateLocal(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}`
|
|
if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil {
|
|
t.Fatalf("write aggregated: %v", err)
|
|
}
|
|
d := NewDefaultDetector(&mockTransport{})
|
|
events, err := d.Aggregate(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("Aggregate: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("events len = %d, want 1", len(events))
|
|
}
|
|
if events[0].EventID != "E1" {
|
|
t.Errorf("EventID = %q, want E1", events[0].EventID)
|
|
}
|
|
}
|
|
|
|
func TestAggregateRemote(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E2","host":"p","path":"/etc/orca/actual/ns-a/x","status":"created","drift_confirmed":true}]}`
|
|
mt := &mockTransport{readOut: []byte(doc)}
|
|
d := NewDefaultDetector(mt)
|
|
events, err := d.Aggregate(context.Background(), "lead:22")
|
|
if err != nil {
|
|
t.Fatalf("Aggregate remote: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("events len = %d, want 1", len(events))
|
|
}
|
|
if events[0].EventID != "E2" {
|
|
t.Errorf("EventID = %q, want E2", events[0].EventID)
|
|
}
|
|
}
|
|
|
|
func TestWatchCancels(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}`
|
|
if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil {
|
|
t.Fatalf("write aggregated: %v", err)
|
|
}
|
|
d := NewDefaultDetector(&mockTransport{})
|
|
d.SetCooldown(50 * time.Millisecond)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
gotEvents := 0
|
|
for e, err := range d.Watch(ctx, []PathSpec{{Pattern: "/etc/traefik/dynamic/orca.yml", Interval: 10 * time.Millisecond}}) {
|
|
if err != nil {
|
|
t.Fatalf("Watch err: %v", err)
|
|
}
|
|
gotEvents++
|
|
_ = e
|
|
cancel()
|
|
break
|
|
}
|
|
if gotEvents != 1 {
|
|
t.Errorf("gotEvents = %d, want 1", gotEvents)
|
|
}
|
|
}
|
|
|
|
func TestWatchStreamsAndDedupes(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}`
|
|
if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil {
|
|
t.Fatalf("write aggregated: %v", err)
|
|
}
|
|
d := NewDefaultDetector(&mockTransport{})
|
|
d.SetCooldown(50 * time.Millisecond)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
|
defer cancel()
|
|
got := 0
|
|
for _, err := range d.Watch(ctx, nil) {
|
|
if err != nil {
|
|
t.Fatalf("Watch err: %v", err)
|
|
}
|
|
got++
|
|
}
|
|
if got != 1 {
|
|
t.Errorf("got = %d, want 1 (dedup)", got)
|
|
}
|
|
}
|
|
|
|
func TestRemediateCooldownOnSuccess(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
mt := &mockTransport{execOut: []byte("ok")}
|
|
d := NewDefaultDetector(mt)
|
|
d.SetCooldown(1 * time.Hour)
|
|
if err := d.Remediate(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml", false); err != nil {
|
|
t.Fatalf("first Remediate: %v", err)
|
|
}
|
|
if err := d.Remediate(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml", false); err == nil {
|
|
t.Fatal("second Remediate should hit cooldown")
|
|
} else if !errors.Is(err, ErrCooldown) {
|
|
t.Errorf("expected ErrCooldown, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemediateForceBypassesCooldown(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
mt := &mockTransport{execOut: []byte("ok")}
|
|
d := NewDefaultDetector(mt)
|
|
d.SetCooldown(1 * time.Hour)
|
|
if err := d.Remediate(context.Background(), "lead:22", "/etc/p", false); err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
if err := d.Remediate(context.Background(), "lead:22", "/etc/p", true); err != nil {
|
|
t.Fatalf("force bypass: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemediateTransientNoCooldown(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
mt := &mockTransport{execErr: fmt.Errorf("connection refused")}
|
|
d := NewDefaultDetector(mt)
|
|
d.SetCooldown(1 * time.Hour)
|
|
if err := d.Remediate(context.Background(), "lead:22", "/etc/p", false); err == nil {
|
|
t.Fatal("expected transient error")
|
|
}
|
|
if d.inCooldown("/etc/p") {
|
|
t.Errorf("cooldown entered after transient failure")
|
|
}
|
|
}
|
|
|
|
func TestRemediateRequiresLead(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
d := NewDefaultDetector(&mockTransport{})
|
|
if err := d.Remediate(context.Background(), "", "/etc/p", true); err == nil {
|
|
t.Fatal("expected error for empty lead")
|
|
}
|
|
}
|
|
|
|
func TestAcknowledgeWrites(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
mt := &mockTransport{}
|
|
d := NewDefaultDetector(mt)
|
|
if err := d.Acknowledge(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml"); err != nil {
|
|
t.Fatalf("Acknowledge: %v", err)
|
|
}
|
|
if len(mt.writes) != 1 {
|
|
t.Fatalf("writes = %d, want 1", len(mt.writes))
|
|
}
|
|
w := mt.writes[0]
|
|
if w.peer != "lead:22" {
|
|
t.Errorf("peer = %q, want lead:22", w.peer)
|
|
}
|
|
if w.path != AcknowledgmentsPath() {
|
|
t.Errorf("path = %q, want %q", w.path, AcknowledgmentsPath())
|
|
}
|
|
}
|
|
|
|
func TestAcknowledgeRequiresLead(t *testing.T) {
|
|
setupDriftTestEnv(t)
|
|
d := NewDefaultDetector(&mockTransport{})
|
|
if err := d.Acknowledge(context.Background(), "", "/etc/p"); err == nil {
|
|
t.Fatal("expected error for empty lead")
|
|
}
|
|
}
|
|
|
|
func TestNamespaceScopeClusterWide(t *testing.T) {
|
|
events := []Event{
|
|
{EventID: "E1", Path: "/etc/traefik/dynamic/orca.yml", DriftConfirmed: true},
|
|
{EventID: "E2", Path: "/etc/orca/actual/ns-a/x", DriftConfirmed: true},
|
|
{EventID: "E3", Path: "/etc/orca/actual/ns-b/y", DriftConfirmed: true, Action: ActionAcknowledged},
|
|
}
|
|
out := NamespaceScope(events, "")
|
|
if len(out) != 2 {
|
|
t.Errorf("cluster-wide scope len = %d, want 2 (ack excluded)", len(out))
|
|
}
|
|
}
|
|
|
|
func TestNamespaceScopePerNamespace(t *testing.T) {
|
|
events := []Event{
|
|
{EventID: "E1", Path: "/etc/orca/actual/ns-a/x", DriftConfirmed: true},
|
|
{EventID: "E2", Path: "/etc/orca/actual/ns-b/y", DriftConfirmed: true},
|
|
{EventID: "E3", Path: "/etc/traefik/dynamic/orca.yml", DriftConfirmed: true},
|
|
}
|
|
nsA := NamespaceScope(events, "ns-a")
|
|
if len(nsA) != 1 || nsA[0].EventID != "E1" {
|
|
t.Errorf("ns-a scope = %+v, want only E1", nsA)
|
|
}
|
|
nsB := NamespaceScope(events, "ns-b")
|
|
if len(nsB) != 1 || nsB[0].EventID != "E2" {
|
|
t.Errorf("ns-b scope = %+v, want only E2", nsB)
|
|
}
|
|
}
|
|
|
|
func TestExpandBraces(t *testing.T) {
|
|
out := expandBraces("/etc/x.{service,timer}")
|
|
if len(out) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(out))
|
|
}
|
|
if out[0] != "/etc/x.service" || out[1] != "/etc/x.timer" {
|
|
t.Errorf("out = %v", out)
|
|
}
|
|
}
|
|
|
|
func TestPathGlobMatch(t *testing.T) {
|
|
if !pathGlobMatch("/etc/orca/actual/*", "/etc/orca/actual/ns-a") {
|
|
t.Errorf("expected glob match for single segment")
|
|
}
|
|
if !pathGlobMatch("/etc/orca/actual/*", "/etc/orca/actual/ns-a/x") {
|
|
t.Errorf("expected glob match for nested segment (drift /* crosses /)")
|
|
}
|
|
if !pathGlobMatch("/etc/systemd/system/orca-collector.{service,timer}", "/etc/systemd/system/orca-collector.service") {
|
|
t.Errorf("expected brace match")
|
|
}
|
|
}
|
|
|
|
func TestNsForPath(t *testing.T) {
|
|
if got := nsForPath("/etc/orca/actual/ns-a/x"); got != "ns-a" {
|
|
t.Errorf("nsForPath = %q, want ns-a", got)
|
|
}
|
|
if got := nsForPath("/etc/traefik/dynamic/orca.yml"); got != "" {
|
|
t.Errorf("nsForPath = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// --- REQ-140 / F18 drift event authentication test ---
|
|
|
|
// TestVerifyEventSignature verifies HMAC verification works.
|
|
func TestVerifyEventSignature(t *testing.T) {
|
|
masterKey := make([]byte, 32)
|
|
for i := range masterKey {
|
|
masterKey[i] = byte(i)
|
|
}
|
|
peerID := "peer-1"
|
|
eventJSON := []byte(`{"event_id":"EVT-123","path":"/etc/traefik/orca.yaml","status":"changed"}`)
|
|
// Compute a valid signature.
|
|
hk := hkdf.New(sha256.New, masterKey, []byte(peerID), []byte("orca-drift-event-hmac"))
|
|
key := make([]byte, 32)
|
|
hk.Read(key)
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write(eventJSON)
|
|
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
if !VerifyEventSignature(eventJSON, sig, masterKey, peerID) {
|
|
t.Error("valid signature should verify")
|
|
}
|
|
// Wrong key.
|
|
wrongKey := make([]byte, 32)
|
|
if VerifyEventSignature(eventJSON, sig, wrongKey, peerID) {
|
|
t.Error("wrong key should fail")
|
|
}
|
|
// Wrong peer.
|
|
if VerifyEventSignature(eventJSON, sig, masterKey, "wrong-peer") {
|
|
t.Error("wrong peer should fail")
|
|
}
|
|
// Tampered event.
|
|
tampered := append([]byte{}, eventJSON...)
|
|
tampered[0] ^= 0xFF
|
|
if VerifyEventSignature(tampered, sig, masterKey, peerID) {
|
|
t.Error("tampered event should fail")
|
|
}
|
|
// Empty signature.
|
|
if VerifyEventSignature(eventJSON, "", masterKey, peerID) {
|
|
t.Error("empty signature should fail")
|
|
}
|
|
}
|