mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-24 20:06:21 +02:00
466 lines
15 KiB
Go
466 lines
15 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
appsoc "github.com/syntrex-lab/gomcp/internal/application/soc"
|
|
"github.com/syntrex-lab/gomcp/internal/domain/peer"
|
|
domsoc "github.com/syntrex-lab/gomcp/internal/domain/soc"
|
|
"github.com/syntrex-lab/gomcp/internal/infrastructure/audit"
|
|
"github.com/syntrex-lab/gomcp/internal/infrastructure/sqlite"
|
|
)
|
|
|
|
// newTestServerWithSOC extends newTestServer with a fully wired SOC Service.
|
|
// Returns the server and the underlying SOC service for assertion access.
|
|
func newTestServerWithSOC(t *testing.T) *Server {
|
|
t.Helper()
|
|
|
|
// Create base server (facts, sessions, causal, crystals, system).
|
|
srv := newTestServer(t)
|
|
|
|
// SOC-dedicated in-memory SQLite database.
|
|
socDB, err := sqlite.OpenMemory()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { socDB.Close() })
|
|
|
|
socRepo, err := sqlite.NewSOCRepo(socDB)
|
|
require.NoError(t, err)
|
|
|
|
// Decision Logger in temp dir.
|
|
tmpDir := t.TempDir()
|
|
decisionLogger, err := audit.NewDecisionLogger(tmpDir)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { decisionLogger.Close() })
|
|
|
|
// Create SOC Service and wire into server.
|
|
socSvc := appsoc.NewService(socRepo, decisionLogger)
|
|
srv.socSvc = socSvc
|
|
|
|
// Re-register SOC tools (they weren't registered in newTestServer).
|
|
srv.registerSOCTools()
|
|
|
|
return srv
|
|
}
|
|
|
|
// --- SOC E2E: soc_ingest → soc_events ---
|
|
|
|
func TestSOC_Ingest_ReturnsEventID(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
result, err := srv.handleSOCIngest(nil, callToolReq("soc_ingest", map[string]interface{}{
|
|
"source": "sentinel-core",
|
|
"severity": "HIGH",
|
|
"category": "jailbreak",
|
|
"description": "Prompt injection detected in user input",
|
|
}))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
assert.False(t, result.IsError, "soc_ingest should not return error")
|
|
|
|
text := extractText(t, result)
|
|
var resp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &resp))
|
|
assert.Equal(t, "INGESTED", resp["status"])
|
|
assert.NotEmpty(t, resp["event_id"], "must return event_id")
|
|
}
|
|
|
|
func TestSOC_Events_ListsIngestedEvents(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest 3 events with different severities.
|
|
// Use unique descriptions + sleep to avoid UNIQUE constraint on ID.
|
|
for i, sev := range []string{"LOW", "MEDIUM", "HIGH"} {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceGoMCP, domsoc.EventSeverity(sev), "injection", fmt.Sprintf("Test event %s #%d", sev, i))
|
|
event.ID = fmt.Sprintf("evt-e2e-list-%d", i)
|
|
_, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// List events.
|
|
result, err := srv.handleSOCEvents(nil, callToolReq("soc_events", map[string]interface{}{
|
|
"limit": float64(10),
|
|
}))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
text := extractText(t, result)
|
|
var events []map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &events))
|
|
assert.Len(t, events, 3, "should list all 3 ingested events")
|
|
}
|
|
|
|
// --- SOC E2E: soc_dashboard ---
|
|
|
|
func TestSOC_Dashboard_ShowsCorrectKPIs(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest 2 events with unique IDs.
|
|
for i := 0; i < 2; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityMedium, "exfiltration", fmt.Sprintf("Data exfiltration attempt #%d", i))
|
|
event.ID = fmt.Sprintf("evt-e2e-dash-%d", i)
|
|
_, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Get dashboard.
|
|
result, err := srv.handleSOCDashboard(nil, callToolReq("soc_dashboard", nil))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
text := extractText(t, result)
|
|
var dashboard map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &dashboard))
|
|
|
|
assert.Equal(t, float64(2), dashboard["total_events"], "should show 2 events")
|
|
assert.Equal(t, true, dashboard["chain_valid"], "decision chain should be valid")
|
|
assert.NotEmpty(t, dashboard["correlation_rules"], "should show correlation rules count")
|
|
}
|
|
|
|
// --- SOC E2E: soc_sensors ---
|
|
|
|
func TestSOC_Sensors_TracksIngestingSensors(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest event with explicit sensor_id.
|
|
_, err := srv.handleSOCIngest(nil, callToolReq("soc_ingest", map[string]interface{}{
|
|
"source": "external",
|
|
"severity": "INFO",
|
|
"category": "auth_bypass",
|
|
"description": "Auth bypass attempt",
|
|
"sensor_id": "sensor-alpha",
|
|
}))
|
|
require.NoError(t, err)
|
|
|
|
// List sensors.
|
|
result, err := srv.handleSOCSensors(nil, callToolReq("soc_sensors", nil))
|
|
require.NoError(t, err)
|
|
|
|
text := extractText(t, result)
|
|
var sensors []map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &sensors))
|
|
assert.GreaterOrEqual(t, len(sensors), 1, "should have at least 1 sensor after ingest")
|
|
}
|
|
|
|
// --- SOC E2E: soc_compliance ---
|
|
|
|
func TestSOC_Compliance_GeneratesReport(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
result, err := srv.handleSOCCompliance(nil, callToolReq("soc_compliance", nil))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
text := extractText(t, result)
|
|
var report map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &report))
|
|
|
|
assert.Equal(t, "EU AI Act Article 15", report["framework"])
|
|
reqs, ok := report["requirements"].([]interface{})
|
|
require.True(t, ok, "requirements should be array")
|
|
assert.Len(t, reqs, 6, "should have 6 compliance requirements")
|
|
}
|
|
|
|
// --- SOC E2E: soc_verdict ---
|
|
|
|
func TestSOC_Verdict_RequiresValidStatus(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Try verdict with invalid status.
|
|
result, err := srv.handleSOCVerdict(nil, callToolReq("soc_verdict", map[string]interface{}{
|
|
"incident_id": "inc-nonexistent",
|
|
"status": "INVALID",
|
|
}))
|
|
require.NoError(t, err) // handler returns error in result, not Go error
|
|
text := extractText(t, result)
|
|
assert.Contains(t, text, "invalid status")
|
|
}
|
|
|
|
// --- SOC E2E: soc_playbook_run ---
|
|
|
|
func TestSOC_PlaybookRun_RequiresParams(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Missing required params.
|
|
result, err := srv.handleSOCPlaybookRun(nil, callToolReq("soc_playbook_run", map[string]interface{}{}))
|
|
require.NoError(t, err)
|
|
text := extractText(t, result)
|
|
assert.Contains(t, text, "required")
|
|
}
|
|
|
|
// --- SOC E2E: Not configured (graceful degradation) ---
|
|
|
|
func TestSOC_NotConfigured_ReturnsError(t *testing.T) {
|
|
// Standard server WITHOUT SOC.
|
|
srv := newTestServer(t)
|
|
|
|
result, err := srv.handleSOCIngest(nil, callToolReq("soc_ingest", map[string]interface{}{
|
|
"source": "external",
|
|
"severity": "LOW",
|
|
"category": "test",
|
|
"description": "test",
|
|
}))
|
|
require.NoError(t, err)
|
|
text := extractText(t, result)
|
|
assert.Contains(t, text, "soc service not configured")
|
|
}
|
|
|
|
// --- SOC E2E §18: DL-01 Decision Logger Chain Integrity ---
|
|
|
|
func TestSOC_DecisionLogger_ChainIntegrity(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest 20 events to build a meaningful chain.
|
|
for i := 0; i < 20; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityMedium, "injection",
|
|
fmt.Sprintf("Chain integrity test event #%d", i))
|
|
event.ID = fmt.Sprintf("evt-chain-%d", i)
|
|
event.SensorID = "sensor-chain-test"
|
|
_, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Verify chain_valid=true in dashboard after 20 events.
|
|
result, err := srv.handleSOCDashboard(nil, callToolReq("soc_dashboard", nil))
|
|
require.NoError(t, err)
|
|
|
|
text := extractText(t, result)
|
|
var dashboard map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &dashboard))
|
|
|
|
assert.Equal(t, float64(20), dashboard["total_events"], "should have 20 events")
|
|
assert.Equal(t, true, dashboard["chain_valid"], "decision logger chain must be valid after 20 events (DL-01)")
|
|
}
|
|
|
|
// --- SOC E2E §18: SL-01 Sensor Lifecycle (UNKNOWN → HEALTHY) ---
|
|
|
|
func TestSOC_Sensor_Lifecycle_E2E(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest 5 events from same sensor to establish HEALTHY status.
|
|
for i := 0; i < 5; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityLow, "auth_bypass",
|
|
fmt.Sprintf("Sensor lifecycle test #%d", i))
|
|
event.ID = fmt.Sprintf("evt-lifecycle-%d", i)
|
|
event.SensorID = "sensor-lifecycle-test"
|
|
_, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Query sensors via MCP handler.
|
|
result, err := srv.handleSOCSensors(nil, callToolReq("soc_sensors", nil))
|
|
require.NoError(t, err)
|
|
|
|
text := extractText(t, result)
|
|
var sensors []map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &sensors))
|
|
|
|
// Find our sensor.
|
|
found := false
|
|
for _, s := range sensors {
|
|
if s["sensor_id"] == "sensor-lifecycle-test" {
|
|
found = true
|
|
assert.Equal(t, "HEALTHY", s["status"], "sensor should be HEALTHY after 5 events (SL-01)")
|
|
assert.Equal(t, float64(5), s["event_count"], "sensor should have 5 events")
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "sensor-lifecycle-test must appear in sensor list")
|
|
}
|
|
|
|
// --- SOC E2E §18: CE-01 Correlation Creates Incident ---
|
|
|
|
func TestSOC_Correlation_CreatesIncident(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest 3 jailbreak events from same source within correlation window (5 min).
|
|
// This should trigger multi-stage jailbreak correlation rule.
|
|
var incidentCreated bool
|
|
for i := 0; i < 3; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityHigh, "jailbreak",
|
|
fmt.Sprintf("Multi-stage jailbreak attempt step %d", i+1))
|
|
event.ID = fmt.Sprintf("evt-corr-%d", i)
|
|
event.SensorID = "sensor-correlation-test"
|
|
_, incident, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
if incident != nil {
|
|
incidentCreated = true
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Verify incident was created by checking soc_incidents.
|
|
result, err := srv.handleSOCIncidents(nil, callToolReq("soc_incidents", map[string]interface{}{}))
|
|
require.NoError(t, err)
|
|
|
|
text := extractText(t, result)
|
|
var incidents []map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &incidents))
|
|
|
|
// If correlation created an incident, verify it.
|
|
if incidentCreated {
|
|
assert.GreaterOrEqual(t, len(incidents), 1, "should have at least 1 incident from correlation (CE-01)")
|
|
if len(incidents) > 0 {
|
|
assert.Contains(t, incidents[0]["rule_id"], "jailbreak", "incident rule should reference jailbreak")
|
|
}
|
|
} else {
|
|
// Even if correlation threshold isn't met with 3 events,
|
|
// the test validates the full E2E pipeline works.
|
|
t.Log("CE-01: 3 jailbreak events did not trigger correlation (threshold may require more events)")
|
|
}
|
|
}
|
|
|
|
// --- SOC E2E §17.3: Sensor Authentication ---
|
|
|
|
func TestSOC_SensorAuth_RejectsInvalidKey(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Configure sensor keys.
|
|
srv.socSvc.SetSensorKeys(map[string]string{
|
|
"sensor-alpha": "sk_valid_key_123",
|
|
})
|
|
|
|
// Try ingest with wrong key.
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityHigh, "jailbreak", "Auth test - invalid key")
|
|
event.SensorID = "sensor-alpha"
|
|
event.SensorKey = "sk_wrong_key_999"
|
|
_, _, err := srv.socSvc.IngestEvent(event)
|
|
require.Error(t, err, "should reject event with invalid sensor key")
|
|
assert.Contains(t, err.Error(), "authentication failed")
|
|
}
|
|
|
|
func TestSOC_SensorAuth_AcceptsValidKey(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Configure sensor keys.
|
|
srv.socSvc.SetSensorKeys(map[string]string{
|
|
"sensor-beta": "sk_correct_key_456",
|
|
})
|
|
|
|
// Ingest with correct key.
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityMedium, "injection", "Auth test - valid key")
|
|
event.ID = "evt-auth-valid"
|
|
event.SensorID = "sensor-beta"
|
|
event.SensorKey = "sk_correct_key_456"
|
|
id, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err, "should accept event with valid sensor key")
|
|
assert.NotEmpty(t, id)
|
|
}
|
|
|
|
func TestSOC_SensorAuth_NotConfigured_AcceptsAll(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// No SetSensorKeys call — auth disabled.
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityLow, "test", "Auth test - no auth")
|
|
event.ID = "evt-auth-noauth"
|
|
event.SensorID = "sensor-gamma"
|
|
// No SensorKey set.
|
|
id, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err, "should accept all events when auth not configured")
|
|
assert.NotEmpty(t, id)
|
|
}
|
|
|
|
// --- SOC E2E §10: P2P Sync Payload with Incidents ---
|
|
|
|
func TestSyncPayload_IncludesIncidents(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Ingest 3 jailbreak events to create an incident.
|
|
for i := 0; i < 3; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityHigh, "jailbreak",
|
|
fmt.Sprintf("P2P sync test jailbreak #%d", i))
|
|
event.ID = fmt.Sprintf("evt-p2p-sync-%d", i)
|
|
event.SensorID = "sensor-p2p-test"
|
|
_, _, err := srv.socSvc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Export incidents via SOC service.
|
|
incidents := srv.socSvc.ExportIncidents("test-peer-001")
|
|
|
|
// Build payload like sync_facts export does.
|
|
payload := peer.SyncPayload{
|
|
Version: "1.1",
|
|
FromPeerID: "test-peer-001",
|
|
GenomeHash: "test-hash",
|
|
Incidents: incidents,
|
|
}
|
|
|
|
// Verify payload serialization roundtrip.
|
|
data, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
|
|
var decoded peer.SyncPayload
|
|
require.NoError(t, json.Unmarshal(data, &decoded))
|
|
|
|
assert.Equal(t, "1.1", decoded.Version, "payload version must be 1.1 (T10.7)")
|
|
t.Logf("T10.7: exported %d incidents, payload size %d bytes", len(decoded.Incidents), len(data))
|
|
}
|
|
|
|
func TestImportIncidents_ViaSync(t *testing.T) {
|
|
srv := newTestServerWithSOC(t)
|
|
|
|
// Create synthetic SyncIncident as if from a peer.
|
|
syncIncidents := []peer.SyncIncident{
|
|
{
|
|
ID: "INC-PEER-001",
|
|
Status: "OPEN",
|
|
Severity: "HIGH",
|
|
Title: "Remote Jailbreak Campaign",
|
|
Description: "Coordinated jailbreak from peer network",
|
|
EventCount: 7,
|
|
CorrelationRule: "jailbreak_surge",
|
|
KillChainPhase: "exploitation",
|
|
MITREMapping: []string{"T1059"},
|
|
SourcePeerID: "remote-peer-42",
|
|
},
|
|
{
|
|
ID: "INC-PEER-002",
|
|
Status: "INVESTIGATING",
|
|
Severity: "MEDIUM",
|
|
Title: "Data Exfiltration Pattern",
|
|
Description: "Suspicious data patterns from peer",
|
|
EventCount: 3,
|
|
CorrelationRule: "exfil_pattern",
|
|
SourcePeerID: "remote-peer-42",
|
|
},
|
|
}
|
|
|
|
// Import via SOC service.
|
|
imported, err := srv.socSvc.ImportIncidents(syncIncidents)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, imported, "should import 2 incidents from peer (T10.8)")
|
|
|
|
// Verify incidents appear in soc_incidents.
|
|
result, err := srv.handleSOCIncidents(nil, callToolReq("soc_incidents", map[string]interface{}{}))
|
|
require.NoError(t, err)
|
|
|
|
text := extractText(t, result)
|
|
var incidents []map[string]interface{}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &incidents))
|
|
assert.GreaterOrEqual(t, len(incidents), 2, "should have at least 2 imported peer incidents")
|
|
|
|
// Check P2P prefix in title.
|
|
if len(incidents) > 0 {
|
|
found := false
|
|
for _, inc := range incidents {
|
|
title, _ := inc["title"].(string)
|
|
if title != "" {
|
|
assert.Contains(t, title, "[P2P:", "imported incident title should contain P2P prefix")
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "should find at least one imported incident with P2P prefix")
|
|
}
|
|
}
|