Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates

This commit is contained in:
DmitrL-dev 2026-03-23 16:45:40 +10:00
parent 694e32be26
commit 41cbfd6e0a
178 changed files with 36008 additions and 399 deletions

View file

@ -0,0 +1,92 @@
package httpserver
import (
"fmt"
"log/slog"
"net"
"net/http"
"time"
)
// RequestLogger provides structured HTTP access logging.
type RequestLogger struct {
enabled bool
}
// NewRequestLogger creates a request logger.
func NewRequestLogger(enabled bool) *RequestLogger {
return &RequestLogger{enabled: enabled}
}
// responseWriter wraps http.ResponseWriter to capture status code.
// Implements http.Flusher to support SSE/streaming endpoints.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Flush delegates to the underlying ResponseWriter if it supports http.Flusher.
// Required for SSE streaming (handleSSEStream, WSHub).
func (rw *responseWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap returns the underlying ResponseWriter for Go 1.20+ ResponseController.
func (rw *responseWriter) Unwrap() http.ResponseWriter {
return rw.ResponseWriter
}
// Middleware logs each request with method, path, status, duration, and IP.
func (rl *RequestLogger) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !rl.enabled {
next.ServeHTTP(w, r)
return
}
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
duration := time.Since(start)
ip := r.RemoteAddr
// T5-1 FIX: Use RemoteAddr directly (consistent with rate limiter T4-3).
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
logFn := slog.Info
if rw.statusCode >= 500 {
logFn = slog.Error
} else if rw.statusCode >= 400 {
logFn = slog.Warn
}
logFn("http request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration", formatDuration(duration),
"ip", ip,
"ua", r.UserAgent(),
)
})
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%dµs", d.Microseconds())
}
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.2fs", d.Seconds())
}

View file

@ -0,0 +1,91 @@
package httpserver
import (
"fmt"
"net/http"
"runtime"
"sync/atomic"
"time"
)
// Metrics collects runtime metrics for Prometheus-style /metrics endpoint.
type Metrics struct {
requestsTotal atomic.Int64
requestErrors atomic.Int64
eventsIngested atomic.Int64
incidentsTotal atomic.Int64
rateLimited atomic.Int64
startTime time.Time
}
// NewMetrics creates a metrics collector.
func NewMetrics() *Metrics {
return &Metrics{
startTime: time.Now(),
}
}
// IncRequests increments total request count.
func (m *Metrics) IncRequests() { m.requestsTotal.Add(1) }
// IncErrors increments error count.
func (m *Metrics) IncErrors() { m.requestErrors.Add(1) }
// IncEvents increments ingested events count.
func (m *Metrics) IncEvents() { m.eventsIngested.Add(1) }
// IncIncidents increments incident count.
func (m *Metrics) IncIncidents() { m.incidentsTotal.Add(1) }
// IncRateLimited increments rate-limited request count.
func (m *Metrics) IncRateLimited() { m.rateLimited.Add(1) }
// Middleware counts all requests.
func (m *Metrics) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.IncRequests()
next.ServeHTTP(w, r)
})
}
// Handler returns /metrics in Prometheus text exposition format.
func (m *Metrics) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
uptime := time.Since(m.startTime).Seconds()
fmt.Fprintf(w, "# HELP syntrex_uptime_seconds Time since server start\n")
fmt.Fprintf(w, "syntrex_uptime_seconds %.2f\n\n", uptime)
fmt.Fprintf(w, "# HELP syntrex_requests_total Total HTTP requests\n")
fmt.Fprintf(w, "syntrex_requests_total %d\n\n", m.requestsTotal.Load())
fmt.Fprintf(w, "# HELP syntrex_request_errors_total Total request errors\n")
fmt.Fprintf(w, "syntrex_request_errors_total %d\n\n", m.requestErrors.Load())
fmt.Fprintf(w, "# HELP syntrex_events_ingested_total Total events ingested\n")
fmt.Fprintf(w, "syntrex_events_ingested_total %d\n\n", m.eventsIngested.Load())
fmt.Fprintf(w, "# HELP syntrex_incidents_total Total incidents created\n")
fmt.Fprintf(w, "syntrex_incidents_total %d\n\n", m.incidentsTotal.Load())
fmt.Fprintf(w, "# HELP syntrex_rate_limited_total Total rate-limited requests\n")
fmt.Fprintf(w, "syntrex_rate_limited_total %d\n\n", m.rateLimited.Load())
fmt.Fprintf(w, "# HELP syntrex_goroutines Current goroutine count\n")
fmt.Fprintf(w, "syntrex_goroutines %d\n\n", runtime.NumGoroutine())
fmt.Fprintf(w, "# HELP syntrex_memory_alloc_bytes Current memory allocation\n")
fmt.Fprintf(w, "syntrex_memory_alloc_bytes %d\n\n", memStats.Alloc)
fmt.Fprintf(w, "# HELP syntrex_memory_sys_bytes Total memory from OS\n")
fmt.Fprintf(w, "syntrex_memory_sys_bytes %d\n\n", memStats.Sys)
fmt.Fprintf(w, "# HELP syntrex_gc_runs_total Total GC runs\n")
fmt.Fprintf(w, "syntrex_gc_runs_total %d\n", memStats.NumGC)
}
}

View file

@ -1,14 +1,37 @@
package httpserver
import "net/http"
import (
"net/http"
"os"
)
// corsMiddleware adds CORS headers for web dashboard integration.
// Allows all origins (suitable for local development and agent dashboards).
// corsAllowedOrigin returns the configured CORS origin.
// Set SOC_CORS_ORIGIN in production (e.g. "https://soc.отражение.рус").
// Defaults to "*" for local development.
func corsAllowedOrigin() string {
if v := os.Getenv("SOC_CORS_ORIGIN"); v != "" {
return v
}
return "*"
}
// corsMiddleware adds CORS headers with configurable origin.
// Production: set SOC_CORS_ORIGIN=https://your-domain.com
func corsMiddleware(next http.Handler) http.Handler {
origin := corsAllowedOrigin()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if origin == "*" {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
reqOrigin := r.Header.Get("Origin")
if reqOrigin == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
// Handle preflight
@ -20,3 +43,36 @@ func corsMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
// securityHeadersMiddleware adds defense-in-depth headers to all responses.
// Mitigates XSS, clickjacking, MIME sniffing, and information leak vectors.
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent MIME type sniffing (IE/Chrome auto-exec attacks)
w.Header().Set("X-Content-Type-Options", "nosniff")
// Block iframe embedding (clickjacking defense)
w.Header().Set("X-Frame-Options", "DENY")
// XSS filter (legacy browsers)
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer leak prevention (no full URL in Referer header)
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy — API only, no inline scripts
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
// Permissions Policy — deny all sensitive browser APIs
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
// Force HTTPS in production (1 year, include subdomains)
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Hide server identity
w.Header().Set("X-Powered-By", "")
w.Header().Del("Server")
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,32 @@
package httpserver
import (
"net/http"
"net/http/pprof"
)
// EnablePprof activates debug profiling endpoints.
// Should only be enabled in development/staging environments.
func (s *Server) EnablePprof() {
s.pprofEnabled = true
}
// handlePprof serves the pprof index page.
func (s *Server) handlePprof(w http.ResponseWriter, r *http.Request) {
pprof.Index(w, r)
}
// handlePprofProfile serves CPU profile data.
func (s *Server) handlePprofProfile(w http.ResponseWriter, r *http.Request) {
pprof.Profile(w, r)
}
// handlePprofHeap serves heap memory profile data.
func (s *Server) handlePprofHeap(w http.ResponseWriter, r *http.Request) {
pprof.Handler("heap").ServeHTTP(w, r)
}
// handlePprofGoroutine serves goroutine stack traces.
func (s *Server) handlePprofGoroutine(w http.ResponseWriter, r *http.Request) {
pprof.Handler("goroutine").ServeHTTP(w, r)
}

View file

@ -0,0 +1,130 @@
package httpserver
import (
"context"
"net"
"net/http"
"sync"
"time"
)
// RateLimiter provides per-IP sliding window rate limiting (§17.3).
type RateLimiter struct {
mu sync.RWMutex
windows map[string][]time.Time
limit int // max requests per window
window time.Duration // window size
enabled bool
}
// NewRateLimiter creates a rate limiter. Set limit=0 to disable.
// The cleanup goroutine stops when ctx is cancelled (T4-6).
func NewRateLimiter(ctx context.Context, limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
windows: make(map[string][]time.Time),
limit: limit,
window: window,
enabled: limit > 0,
}
// Background cleanup every 60s — stops on ctx cancellation
go rl.cleanup(ctx)
return rl
}
// Allow checks if the IP is within limits. Returns true if allowed.
func (rl *RateLimiter) Allow(ip string) bool {
if !rl.enabled {
return true
}
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Slide window: keep only timestamps within the window
timestamps := rl.windows[ip]
valid := timestamps[:0]
for _, ts := range timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
if len(valid) >= rl.limit {
rl.windows[ip] = valid
return false
}
rl.windows[ip] = append(valid, now)
return true
}
// Middleware wraps an HTTP handler with rate limiting.
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !rl.enabled {
next.ServeHTTP(w, r)
return
}
// T4-3 FIX: Use RemoteAddr directly to prevent X-Forwarded-For spoofing.
// When behind a trusted reverse proxy, configure the proxy to set
// X-Real-IP and strip external X-Forwarded-For headers.
ip := r.RemoteAddr
// Strip port from RemoteAddr (e.g. "192.168.1.1:12345" → "192.168.1.1")
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if !rl.Allow(ip) {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
// Stats returns rate limiter statistics.
func (rl *RateLimiter) Stats() map[string]any {
rl.mu.RLock()
defer rl.mu.RUnlock()
return map[string]any{
"enabled": rl.enabled,
"limit": rl.limit,
"window_sec": rl.window.Seconds(),
"tracked_ips": len(rl.windows),
}
}
// cleanup removes expired entries periodically. Stops on ctx cancellation.
func (rl *RateLimiter) cleanup(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
rl.mu.Lock()
cutoff := time.Now().Add(-rl.window)
for ip, timestamps := range rl.windows {
valid := timestamps[:0]
for _, ts := range timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
if len(valid) == 0 {
delete(rl.windows, ip)
} else {
rl.windows[ip] = valid
}
}
rl.mu.Unlock()
}
}
}

View file

@ -0,0 +1,90 @@
package httpserver
import (
"context"
"testing"
"time"
)
func TestRateLimiter_Allow(t *testing.T) {
rl := NewRateLimiter(context.Background(), 3, time.Second)
// First 3 should pass
for i := 0; i < 3; i++ {
if !rl.Allow("1.2.3.4") {
t.Fatalf("request %d should be allowed", i+1)
}
}
// 4th should be denied
if rl.Allow("1.2.3.4") {
t.Fatal("4th request should be rate-limited")
}
// Different IP should be fine
if !rl.Allow("5.6.7.8") {
t.Fatal("different IP should be allowed")
}
}
func TestRateLimiter_Disabled(t *testing.T) {
rl := NewRateLimiter(context.Background(), 0, time.Second)
for i := 0; i < 100; i++ {
if !rl.Allow("1.2.3.4") {
t.Fatal("disabled rate limiter should allow all")
}
}
}
func TestRateLimiter_WindowExpiry(t *testing.T) {
rl := NewRateLimiter(context.Background(), 2, 50*time.Millisecond)
rl.Allow("1.2.3.4")
rl.Allow("1.2.3.4")
if rl.Allow("1.2.3.4") {
t.Fatal("should be rate-limited")
}
// Wait for window to expire
time.Sleep(60 * time.Millisecond)
if !rl.Allow("1.2.3.4") {
t.Fatal("should be allowed after window expires")
}
}
func TestRateLimiter_Stats(t *testing.T) {
rl := NewRateLimiter(context.Background(), 10, time.Minute)
rl.Allow("1.1.1.1")
rl.Allow("2.2.2.2")
stats := rl.Stats()
if stats["enabled"] != true {
t.Fatal("should be enabled")
}
if stats["tracked_ips"].(int) != 2 {
t.Fatal("should track 2 IPs")
}
}
func TestMetrics_Counters(t *testing.T) {
m := NewMetrics()
m.IncRequests()
m.IncRequests()
m.IncErrors()
m.IncEvents()
m.IncIncidents()
m.IncRateLimited()
if m.requestsTotal.Load() != 2 {
t.Fatalf("expected 2 requests, got %d", m.requestsTotal.Load())
}
if m.requestErrors.Load() != 1 {
t.Fatalf("expected 1 error, got %d", m.requestErrors.Load())
}
if m.eventsIngested.Load() != 1 {
t.Fatalf("expected 1 event, got %d", m.eventsIngested.Load())
}
}

View file

@ -0,0 +1,161 @@
package httpserver
import (
"net/http"
"strings"
"sync"
"time"
)
// Role defines access level for RBAC.
type Role string
const (
RoleAdmin Role = "admin" // Full access: read + write + config
RoleAnalyst Role = "analyst" // Read + write (ingest, verdict)
RoleViewer Role = "viewer" // Read-only
RoleSensor Role = "sensor" // Ingest only (POST events + heartbeat)
RoleExternal Role = "external" // Kill Chain + dashboard only
)
// APIKey represents a registered API key with role.
type APIKey struct {
Key string `json:"key"`
Name string `json:"name"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used,omitempty"`
Active bool `json:"active"`
}
// RBACConfig holds authentication configuration.
type RBACConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Keys map[string]APIKey // key hash → APIKey
}
// RBACMiddleware provides role-based access control for HTTP endpoints (§17).
type RBACMiddleware struct {
mu sync.RWMutex
config RBACConfig
keys map[string]*APIKey // raw key → APIKey
}
// NewRBACMiddleware creates RBAC middleware. If not enabled, all requests pass through.
func NewRBACMiddleware(config RBACConfig) *RBACMiddleware {
m := &RBACMiddleware{
config: config,
keys: make(map[string]*APIKey),
}
return m
}
// RegisterKey adds an API key with a role.
func (m *RBACMiddleware) RegisterKey(name, key string, role Role) {
m.mu.Lock()
defer m.mu.Unlock()
m.keys[key] = &APIKey{
Key: key,
Name: name,
Role: role,
CreatedAt: time.Now(),
Active: true,
}
}
// RevokeKey deactivates an API key.
func (m *RBACMiddleware) RevokeKey(key string) {
m.mu.Lock()
defer m.mu.Unlock()
if k, ok := m.keys[key]; ok {
k.Active = false
}
}
// ListKeys returns all registered keys (with keys masked).
func (m *RBACMiddleware) ListKeys() []APIKey {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]APIKey, 0, len(m.keys))
for _, k := range m.keys {
masked := *k
if len(masked.Key) > 8 {
masked.Key = masked.Key[:4] + "..." + masked.Key[len(masked.Key)-4:]
}
result = append(result, masked)
}
return result
}
// Require returns middleware that enforces minimum role for the endpoint.
func (m *RBACMiddleware) Require(minRole Role, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !m.config.Enabled {
next(w, r)
return
}
// Extract API key from Authorization header or query param
key := extractAPIKey(r)
if key == "" {
writeError(w, http.StatusUnauthorized, "missing API key: use Authorization: Bearer <key>")
return
}
// Lookup and validate key
m.mu.RLock()
apiKey, exists := m.keys[key]
m.mu.RUnlock()
if !exists || !apiKey.Active {
writeError(w, http.StatusUnauthorized, "invalid or revoked API key")
return
}
// Note: timing-safe compare is not needed here because the Go map
// lookup above already reveals key existence via timing. The map
// is the canonical key store; this is a lookup, not a comparison
// of a user-supplied value against a stored secret.
// Check role hierarchy
if !hasPermission(apiKey.Role, minRole) {
writeError(w, http.StatusForbidden, "insufficient permissions: requires "+string(minRole))
return
}
// Update last used
m.mu.Lock()
apiKey.LastUsed = time.Now()
m.mu.Unlock()
next(w, r)
}
}
// extractAPIKey gets the API key from Authorization header or ?api_key query param.
func extractAPIKey(r *http.Request) string {
// Try Authorization: Bearer <key>
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
// Try X-API-Key header
if key := r.Header.Get("X-API-Key"); key != "" {
return key
}
// Try query parameter (least secure, for dashboard convenience)
return r.URL.Query().Get("api_key")
}
// hasPermission checks if userRole >= requiredRole in the hierarchy.
func hasPermission(userRole, requiredRole Role) bool {
hierarchy := map[Role]int{
RoleAdmin: 100,
RoleAnalyst: 50,
RoleViewer: 30,
RoleSensor: 20,
RoleExternal: 10,
}
return hierarchy[userRole] >= hierarchy[requiredRole]
}

View file

@ -0,0 +1,153 @@
package httpserver
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRBAC_Disabled_PassesThrough(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: false})
handler := rbac.Require(RoleAdmin, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestRBAC_Enabled_NoKey_Returns401(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: true})
handler := rbac.Require(RoleViewer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
}
func TestRBAC_Enabled_ValidKey_AdminAccess(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: true})
rbac.RegisterKey("admin-key", "sk-admin-123", RoleAdmin)
handler := rbac.Require(RoleAdmin, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("admin"))
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer sk-admin-123")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestRBAC_Enabled_InsufficientRole_Returns403(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: true})
rbac.RegisterKey("viewer-key", "sk-viewer-456", RoleViewer)
handler := rbac.Require(RoleAdmin, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer sk-viewer-456")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rec.Code)
}
}
func TestRBAC_XAPIKeyHeader(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: true})
rbac.RegisterKey("sensor", "sk-sensor-789", RoleSensor)
handler := rbac.Require(RoleSensor, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("POST", "/ingest", nil)
req.Header.Set("X-API-Key", "sk-sensor-789")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestRBAC_RevokedKey_Returns401(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: true})
rbac.RegisterKey("temp-key", "sk-temp-000", RoleAdmin)
rbac.RevokeKey("sk-temp-000")
handler := rbac.Require(RoleViewer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer sk-temp-000")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
}
func TestRBAC_ListKeys_MasksKeys(t *testing.T) {
rbac := NewRBACMiddleware(RBACConfig{Enabled: true})
rbac.RegisterKey("admin", "sk-admin-very-long-key-12345", RoleAdmin)
keys := rbac.ListKeys()
if len(keys) != 1 {
t.Fatalf("expected 1 key, got %d", len(keys))
}
if keys[0].Key == "sk-admin-very-long-key-12345" {
t.Fatal("key should be masked")
}
if keys[0].Name != "admin" {
t.Fatalf("expected name='admin', got %q", keys[0].Name)
}
t.Logf("masked key: %s", keys[0].Key)
}
func TestRBAC_RoleHierarchy(t *testing.T) {
tests := []struct {
userRole Role
minRole Role
allowed bool
}{
{RoleAdmin, RoleAdmin, true},
{RoleAdmin, RoleSensor, true},
{RoleAnalyst, RoleViewer, true},
{RoleViewer, RoleAnalyst, false},
{RoleSensor, RoleViewer, false},
{RoleExternal, RoleAdmin, false},
{RoleSensor, RoleSensor, true},
}
for _, tt := range tests {
got := hasPermission(tt.userRole, tt.minRole)
if got != tt.allowed {
t.Errorf("hasPermission(%s, %s) = %v, want %v", tt.userRole, tt.minRole, got, tt.allowed)
}
}
}

View file

@ -0,0 +1,281 @@
package httpserver
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/syntrex/gomcp/internal/application/resilience"
)
// ResilienceAPI holds references to the SARL engines for HTTP handlers.
type ResilienceAPI struct {
healthMonitor *resilience.HealthMonitor
healingEngine *resilience.HealingEngine
preservation *resilience.PreservationEngine
behavioral *resilience.BehavioralAnalyzer
playbooks *resilience.RecoveryPlaybookEngine
}
// NewResilienceAPI creates a new resilience API handler.
// Any engine can be nil — the handler will return 503 for that subsystem.
func NewResilienceAPI(
hm *resilience.HealthMonitor,
he *resilience.HealingEngine,
pe *resilience.PreservationEngine,
ba *resilience.BehavioralAnalyzer,
pb *resilience.RecoveryPlaybookEngine,
) *ResilienceAPI {
return &ResilienceAPI{
healthMonitor: hm,
healingEngine: he,
preservation: pe,
behavioral: ba,
playbooks: pb,
}
}
// RegisterRoutes registers all resilience API endpoints on the given mux.
func (api *ResilienceAPI) RegisterRoutes(mux *http.ServeMux, rbac *RBACMiddleware) {
// Read endpoints — viewer access.
mux.HandleFunc("GET /api/v1/resilience/health",
rbac.Require(RoleViewer, api.handleHealth))
mux.HandleFunc("GET /api/v1/resilience/metrics/{component}",
rbac.Require(RoleViewer, api.handleComponentMetrics))
mux.HandleFunc("GET /api/v1/resilience/audit",
rbac.Require(RoleAnalyst, api.handleAudit))
mux.HandleFunc("GET /api/v1/resilience/healing/{id}",
rbac.Require(RoleAnalyst, api.handleHealingStatus))
// Write endpoints — admin access.
mux.HandleFunc("POST /api/v1/resilience/healing/initiate",
rbac.Require(RoleAdmin, api.handleInitiateHealing))
mux.HandleFunc("POST /api/v1/resilience/mode/activate",
rbac.Require(RoleAdmin, api.handleActivateMode))
}
// GET /api/v1/resilience/health
func (api *ResilienceAPI) handleHealth(w http.ResponseWriter, r *http.Request) {
if api.healthMonitor == nil {
writeError(w, http.StatusServiceUnavailable, "health monitor not initialized")
return
}
health := api.healthMonitor.GetHealth()
// Add emergency mode info from preservation engine.
response := map[string]any{
"overall_status": health.OverallStatus,
"components": health.Components,
"quorum_valid": health.QuorumValid,
"last_check": health.LastCheck,
"anomalies_detected": health.AnomaliesDetected,
"active_emergency_mode": string(resilience.ModeNone),
}
if api.preservation != nil {
response["active_emergency_mode"] = string(api.preservation.CurrentMode())
}
writeJSON(w, http.StatusOK, response)
}
// GET /api/v1/resilience/metrics/{component}
func (api *ResilienceAPI) handleComponentMetrics(w http.ResponseWriter, r *http.Request) {
component := r.PathValue("component")
if component == "" {
writeError(w, http.StatusBadRequest, "missing component path parameter")
return
}
if api.healthMonitor == nil {
writeError(w, http.StatusServiceUnavailable, "health monitor not initialized")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"component": component,
"time_range": "1h",
"status": "ok",
})
}
// GET /api/v1/resilience/audit
func (api *ResilienceAPI) handleAudit(w http.ResponseWriter, r *http.Request) {
var entries []any
// Combine healing operations + preservation events.
if api.healingEngine != nil {
ops := api.healingEngine.RecentOperations(50)
for _, op := range ops {
entries = append(entries, map[string]any{
"type": "healing",
"timestamp": op.StartedAt,
"component": op.Component,
"strategy": op.StrategyID,
"result": op.Result,
"error": op.Error,
})
}
}
if api.preservation != nil {
for _, evt := range api.preservation.History() {
entries = append(entries, map[string]any{
"type": "preservation",
"timestamp": evt.Timestamp,
"mode": evt.Mode,
"action": evt.Action,
"success": evt.Success,
"error": evt.Error,
})
}
}
if api.playbooks != nil {
execs := api.playbooks.RecentExecutions(50)
for _, exec := range execs {
entries = append(entries, map[string]any{
"type": "playbook",
"timestamp": exec.StartedAt,
"playbook": exec.PlaybookID,
"component": exec.Component,
"status": exec.Status,
"error": exec.Error,
})
}
}
writeJSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": len(entries),
})
}
// GET /api/v1/resilience/healing/{id}
func (api *ResilienceAPI) handleHealingStatus(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing healing operation ID")
return
}
if api.healingEngine != nil {
op, ok := api.healingEngine.GetOperation(id)
if ok {
writeJSON(w, http.StatusOK, op)
return
}
}
if api.playbooks != nil {
exec, ok := api.playbooks.GetExecution(id)
if ok {
writeJSON(w, http.StatusOK, exec)
return
}
}
writeError(w, http.StatusNotFound, "operation not found")
}
// POST /api/v1/resilience/healing/initiate
func (api *ResilienceAPI) handleInitiateHealing(w http.ResponseWriter, r *http.Request) {
var req struct {
Component string `json:"component"`
Strategy string `json:"strategy,omitempty"`
Playbook string `json:"playbook,omitempty"`
Force bool `json:"force"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Component == "" {
writeError(w, http.StatusBadRequest, "component is required")
return
}
// Run playbook if specified.
if req.Playbook != "" && api.playbooks != nil {
execID, err := api.playbooks.Execute(r.Context(), req.Playbook, req.Component)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"healing_id": execID,
"status": "FAILED",
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"healing_id": execID,
"status": "COMPLETED",
})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"component": req.Component,
"status": "INITIATED",
"message": "healing request queued",
})
}
// POST /api/v1/resilience/mode/activate
func (api *ResilienceAPI) handleActivateMode(w http.ResponseWriter, r *http.Request) {
if api.preservation == nil {
writeError(w, http.StatusServiceUnavailable, "preservation engine not initialized")
return
}
var req struct {
Mode string `json:"mode"`
Reason string `json:"reason"`
Duration string `json:"duration,omitempty"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var mode resilience.EmergencyMode
switch strings.ToUpper(req.Mode) {
case "SAFE":
mode = resilience.ModeSafe
case "LOCKDOWN":
mode = resilience.ModeLockdown
case "APOPTOSIS":
mode = resilience.ModeApoptosis
case "NONE", "":
if err := api.preservation.DeactivateMode("api"); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"mode_activated": "NONE",
"activated_at": time.Now(),
})
return
default:
writeError(w, http.StatusBadRequest, "invalid mode: "+req.Mode)
return
}
if err := api.preservation.ActivateMode(mode, req.Reason, "api"); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
activation := api.preservation.Activation()
writeJSON(w, http.StatusOK, map[string]any{
"mode_activated": string(mode),
"activated_at": activation.ActivatedAt,
"auto_exit_at": activation.AutoExitAt,
})
}
// writeJSON and writeJSONError are defined in server.go (shared across package).

View file

@ -6,28 +6,57 @@ package httpserver
import (
"context"
"crypto/tls"
"database/sql"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"time"
shadowai "github.com/syntrex/gomcp/internal/application/shadow_ai"
appsoc "github.com/syntrex/gomcp/internal/application/soc"
"github.com/syntrex/gomcp/internal/domain/engines"
"github.com/syntrex/gomcp/internal/infrastructure/auth"
"github.com/syntrex/gomcp/internal/infrastructure/email"
"github.com/syntrex/gomcp/internal/infrastructure/tracing"
)
// Server provides HTTP API endpoints for SOC monitoring.
type Server struct {
socSvc *appsoc.Service
threatIntel *appsoc.ThreatIntelStore
port int
srv *http.Server
socSvc *appsoc.Service
threatIntel *appsoc.ThreatIntelStore
shadowAI *shadowai.ShadowAIController
rbac *RBACMiddleware
rateLimiter *RateLimiter
metrics *Metrics
logger *RequestLogger
sentinelCore engines.SentinelCore
jwtAuth *auth.JWTMiddleware
userStore *auth.UserStore
tenantStore *auth.TenantStore
emailService *email.Service
jwtSecret []byte
wsHub *WSHub
sovereignEnabled bool
sovereignMode string
pprofEnabled bool
port int
srv *http.Server
tlsCert string
tlsKey string
}
// New creates an HTTP server bound to the given port.
func New(socSvc *appsoc.Service, port int) *Server {
return &Server{
socSvc: socSvc,
port: port,
socSvc: socSvc,
port: port,
rbac: NewRBACMiddleware(RBACConfig{Enabled: false}),
rateLimiter: NewRateLimiter(context.Background(), 100, time.Minute),
metrics: NewMetrics(),
logger: NewRequestLogger(true),
wsHub: NewWSHub(),
}
}
@ -36,44 +65,279 @@ func (s *Server) SetThreatIntel(store *appsoc.ThreatIntelStore) {
s.threatIntel = store
}
// SetShadowAI sets the Shadow AI Controller for API access.
func (s *Server) SetShadowAI(controller *shadowai.ShadowAIController) {
s.shadowAI = controller
}
// SetEmailService sets the email service for sending verification codes and alerts.
func (s *Server) SetEmailService(svc *email.Service) {
s.emailService = svc
}
// SetJWTAuth enables JWT authentication with the given secret.
// If secret is empty or <32 bytes, JWT is disabled (backward compatible).
// Optional db parameter enables SQLite-backed user persistence.
func (s *Server) SetJWTAuth(secret []byte, db ...*sql.DB) {
if len(secret) < 32 {
slog.Warn("JWT auth disabled: secret too short or not set")
return
}
s.jwtSecret = secret
s.jwtAuth = auth.NewJWTMiddleware(secret)
if len(db) > 0 && db[0] != nil {
s.userStore = auth.NewUserStore(db[0])
s.tenantStore = auth.NewTenantStore(db[0])
} else {
s.userStore = auth.NewUserStore()
}
slog.Info("JWT authentication enabled")
}
// SetRBAC configures RBAC middleware with API key authentication (§17).
func (s *Server) SetRBAC(rbac *RBACMiddleware) {
s.rbac = rbac
}
// SetTLS enables TLS with the given certificate and key files.
// Cipher suites are hardened to AEAD-only (§P2 TLS hardening).
func (s *Server) SetTLS(certFile, keyFile string) {
s.tlsCert = certFile
s.tlsKey = keyFile
}
// StartEventBridge subscribes to the SOC EventBus and forwards events
// to the WSHub for real-time SSE/WebSocket dashboard streaming (§P1).
// Should be called once after server creation. Runs as a background goroutine.
func (s *Server) StartEventBridge(ctx context.Context) {
bus := s.socSvc.EventBus()
if bus == nil {
slog.Warn("event bridge: no EventBus available")
return
}
ch := bus.Subscribe("ws-hub-bridge")
go func() {
for {
select {
case <-ctx.Done():
bus.Unsubscribe("ws-hub-bridge")
return
case evt, ok := <-ch:
if !ok {
return
}
s.wsHub.Broadcast("soc_event", map[string]any{
"id": evt.ID,
"source": string(evt.Source),
"severity": string(evt.Severity),
"category": evt.Category,
"description": evt.Description,
"session_id": evt.SessionID,
})
}
}
}()
slog.Info("event bridge started: EventBus → WSHub")
}
// Start begins listening on the configured port. Blocks until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
// SOC API routes
mux.HandleFunc("GET /api/soc/dashboard", s.handleDashboard)
mux.HandleFunc("GET /api/soc/events", s.handleEvents)
mux.HandleFunc("GET /api/soc/incidents", s.handleIncidents)
mux.HandleFunc("GET /api/soc/sensors", s.handleSensors)
mux.HandleFunc("GET /api/soc/threat-intel", s.handleThreatIntel)
mux.HandleFunc("GET /api/soc/webhook-stats", s.handleWebhookStats)
mux.HandleFunc("GET /api/soc/analytics", s.handleAnalytics)
// SOC API routes — read (requires Viewer role when RBAC enabled)
mux.HandleFunc("GET /api/soc/dashboard", s.rbac.Require(RoleViewer, s.handleDashboard))
mux.HandleFunc("GET /api/soc/events", s.rbac.Require(RoleViewer, s.handleEvents))
mux.HandleFunc("GET /api/soc/incidents", s.rbac.Require(RoleViewer, s.handleIncidents))
// Sprint 2: Advanced incident management (must be before generic {id})
mux.HandleFunc("GET /api/soc/incidents/advanced", s.rbac.Require(RoleViewer, s.handleIncidentsAdvanced))
mux.HandleFunc("POST /api/soc/incidents/bulk", s.rbac.Require(RoleAnalyst, s.handleIncidentsBulk))
mux.HandleFunc("GET /api/soc/incidents/export", s.rbac.Require(RoleViewer, s.handleIncidentsExport))
mux.HandleFunc("GET /api/soc/sla-config", s.rbac.Require(RoleViewer, s.handleSLAConfig))
mux.HandleFunc("GET /api/soc/incidents/{id}", s.rbac.Require(RoleViewer, s.handleIncidentDetail))
mux.HandleFunc("GET /api/soc/incidents/{id}/sla", s.rbac.Require(RoleViewer, s.handleIncidentSLA))
mux.HandleFunc("GET /api/soc/sensors", s.rbac.Require(RoleViewer, s.handleSensors))
mux.HandleFunc("GET /api/soc/clusters", s.rbac.Require(RoleViewer, s.handleClusters))
mux.HandleFunc("GET /api/soc/rules", s.rbac.Require(RoleViewer, s.handleRules))
mux.HandleFunc("GET /api/soc/killchain/{id}", s.rbac.Require(RoleViewer, s.handleKillChain))
mux.HandleFunc("GET /api/soc/stream", s.rbac.Require(RoleViewer, s.handleSSEStream))
mux.HandleFunc("GET /api/soc/threat-intel", s.rbac.Require(RoleAnalyst, s.handleThreatIntel))
mux.HandleFunc("GET /api/soc/webhook-stats", s.rbac.Require(RoleAnalyst, s.handleWebhookStats))
mux.HandleFunc("GET /api/soc/analytics", s.rbac.Require(RoleViewer, s.handleAnalytics))
// Health check
// SOC API routes — write (requires Analyst/Sensor role when RBAC enabled)
mux.HandleFunc("POST /api/v1/soc/events", s.rbac.Require(RoleSensor, s.handleIngestEvent))
mux.HandleFunc("POST /api/v1/soc/events/batch", s.rbac.Require(RoleSensor, s.handleBatchIngest))
mux.HandleFunc("POST /api/soc/sensors/heartbeat", s.rbac.Require(RoleSensor, s.handleSensorHeartbeat))
mux.HandleFunc("POST /api/soc/incidents/{id}/verdict", s.rbac.Require(RoleAnalyst, s.handleVerdict))
// Case Management (SOAR §P3)
mux.HandleFunc("POST /api/soc/incidents/{id}/assign", s.rbac.Require(RoleAnalyst, s.handleIncidentAssign))
mux.HandleFunc("POST /api/soc/incidents/{id}/status", s.rbac.Require(RoleAnalyst, s.handleIncidentStatus))
mux.HandleFunc("GET /api/soc/incidents/{id}/notes", s.rbac.Require(RoleViewer, s.handleIncidentNotes))
mux.HandleFunc("POST /api/soc/incidents/{id}/notes", s.rbac.Require(RoleAnalyst, s.handleIncidentNotes))
mux.HandleFunc("GET /api/soc/incidents/{id}/timeline", s.rbac.Require(RoleViewer, s.handleIncidentTimeline))
mux.HandleFunc("GET /api/soc/incidents/{id}/detail", s.rbac.Require(RoleViewer, s.handleIncidentFullDetail))
// Webhook Management (SOAR §15)
mux.HandleFunc("GET /api/soc/webhooks", s.rbac.Require(RoleAnalyst, s.handleWebhooksGet))
mux.HandleFunc("POST /api/soc/webhooks", s.rbac.Require(RoleAdmin, s.handleWebhooksSet))
mux.HandleFunc("POST /api/soc/webhooks/test", s.rbac.Require(RoleAdmin, s.handleWebhooksTest))
mux.HandleFunc("POST /api/soc/sensors/register", s.rbac.Require(RoleAdmin, s.handleSensorRegister))
mux.HandleFunc("DELETE /api/soc/sensors/{id}", s.rbac.Require(RoleAdmin, s.handleSensorDelete))
// Admin routes (§9, §17)
mux.HandleFunc("GET /api/soc/audit", s.rbac.Require(RoleAdmin, s.handleAuditTrail))
mux.HandleFunc("GET /api/soc/keys", s.rbac.Require(RoleAdmin, s.handleListKeys))
// Zero-G Mode routes (§13.4)
mux.HandleFunc("GET /api/soc/zerog", s.rbac.Require(RoleAnalyst, s.handleZeroGStatus))
mux.HandleFunc("POST /api/soc/zerog/toggle", s.rbac.Require(RoleAdmin, s.handleZeroGToggle))
mux.HandleFunc("POST /api/soc/zerog/resolve", s.rbac.Require(RoleAnalyst, s.handleZeroGResolve))
// P2P SOC Sync routes (§14)
mux.HandleFunc("GET /api/soc/p2p/peers", s.rbac.Require(RoleAnalyst, s.handleP2PPeers))
mux.HandleFunc("POST /api/soc/p2p/peers", s.rbac.Require(RoleAdmin, s.handleP2PAddPeer))
mux.HandleFunc("DELETE /api/soc/p2p/peers/{id}", s.rbac.Require(RoleAdmin, s.handleP2PRemovePeer))
// Engine & Sovereign routes (§3, §4, §21)
mux.HandleFunc("GET /api/soc/engines", s.rbac.Require(RoleViewer, s.handleEngineStatus))
mux.HandleFunc("GET /api/soc/sovereign", s.rbac.Require(RoleAdmin, s.handleSovereignConfig))
// Anomaly detection (§5) + Playbook engine (§10)
mux.HandleFunc("GET /api/soc/anomaly/alerts", s.rbac.Require(RoleAnalyst, s.handleAnomalyAlerts))
mux.HandleFunc("GET /api/soc/anomaly/baselines", s.rbac.Require(RoleAnalyst, s.handleAnomalyBaselines))
mux.HandleFunc("GET /api/soc/playbooks", s.rbac.Require(RoleViewer, s.handlePlaybooks))
// Live updates — WebSocket-style SSE push (§20)
mux.HandleFunc("GET /api/soc/ws", s.rbac.Require(RoleViewer, s.wsHub.HandleSSEStream))
// Deep health, compliance, audit, explainability (§12, §15)
mux.HandleFunc("GET /api/soc/health/deep", s.rbac.Require(RoleViewer, s.handleDeepHealth))
mux.HandleFunc("GET /api/soc/compliance", s.rbac.Require(RoleAdmin, s.handleComplianceReport))
mux.HandleFunc("GET /api/soc/audit/trail", s.rbac.Require(RoleAnalyst, s.handleAuditTrailPage))
mux.HandleFunc("GET /api/soc/incidents/{id}/explain", s.rbac.Require(RoleAnalyst, s.handleIncidentExplain))
// Threat intel matching (§6) + Data retention (§19)
mux.HandleFunc("POST /api/soc/threat-intel/match", s.rbac.Require(RoleAnalyst, s.handleThreatIntelMatch))
mux.HandleFunc("GET /api/soc/retention", s.rbac.Require(RoleAdmin, s.handleRetentionPolicies))
// Shadow AI Control Module routes (§Shadow AI ТЗ)
mux.HandleFunc("GET /api/v1/shadow-ai/stats", s.rbac.Require(RoleViewer, s.handleShadowAIStats))
mux.HandleFunc("GET /api/v1/shadow-ai/events", s.rbac.Require(RoleViewer, s.handleShadowAIEvents))
mux.HandleFunc("GET /api/v1/shadow-ai/events/{id}", s.rbac.Require(RoleViewer, s.handleShadowAIEventDetail))
mux.HandleFunc("POST /api/v1/shadow-ai/block", s.rbac.Require(RoleAnalyst, s.handleShadowAIBlock))
mux.HandleFunc("POST /api/v1/shadow-ai/unblock", s.rbac.Require(RoleAnalyst, s.handleShadowAIUnblock))
mux.HandleFunc("POST /api/v1/shadow-ai/scan", s.rbac.Require(RoleAnalyst, s.handleShadowAIScan))
mux.HandleFunc("GET /api/v1/shadow-ai/integrations", s.rbac.Require(RoleViewer, s.handleShadowAIIntegrations))
mux.HandleFunc("GET /api/v1/shadow-ai/integrations/{vendor}/health", s.rbac.Require(RoleViewer, s.handleShadowAIVendorHealth))
mux.HandleFunc("GET /api/v1/shadow-ai/compliance", s.rbac.Require(RoleAdmin, s.handleShadowAICompliance))
mux.HandleFunc("POST /api/v1/shadow-ai/doc-review", s.rbac.Require(RoleAnalyst, s.handleShadowAIDocReview))
mux.HandleFunc("GET /api/v1/shadow-ai/doc-review/{id}", s.rbac.Require(RoleViewer, s.handleShadowAIDocReviewStatus))
mux.HandleFunc("GET /api/v1/shadow-ai/approvals", s.rbac.Require(RoleAnalyst, s.handleShadowAIPendingApprovals))
mux.HandleFunc("GET /api/v1/shadow-ai/approvals/tiers", s.rbac.Require(RoleViewer, s.handleShadowAIApprovalTiers))
mux.HandleFunc("POST /api/v1/shadow-ai/approvals/{id}/verdict", s.rbac.Require(RoleAnalyst, s.handleShadowAIApprovalVerdict))
// Observability — always public (unauthenticated, K8s probes)
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metrics", s.metrics.Handler())
mux.HandleFunc("GET /api/soc/ratelimit", s.handleRateLimitStats)
// Wrap with CORS middleware
handler := corsMiddleware(mux)
// pprof debug endpoints (§P4C) — gated behind EnablePprof()
if s.pprofEnabled {
mux.HandleFunc("GET /debug/pprof/", s.handlePprof)
mux.HandleFunc("GET /debug/pprof/profile", s.handlePprofProfile)
mux.HandleFunc("GET /debug/pprof/heap", s.handlePprofHeap)
mux.HandleFunc("GET /debug/pprof/goroutine", s.handlePprofGoroutine)
slog.Info("pprof endpoints enabled", "path", "/debug/pprof/")
}
// Auth routes — login/refresh are public (JWT middleware exempts these)
if s.jwtAuth != nil {
loginLimiter := auth.NewRateLimiter(5, time.Minute)
mux.HandleFunc("POST /api/auth/login", auth.RateLimitMiddleware(loginLimiter, auth.HandleLogin(s.userStore, s.jwtSecret)))
mux.HandleFunc("POST /api/auth/refresh", auth.HandleRefresh(s.jwtSecret))
// Auth routes — require authentication
mux.HandleFunc("GET /api/auth/me", auth.HandleMe(s.userStore))
// User management (admin only)
mux.HandleFunc("GET /api/auth/users", auth.HandleListUsers(s.userStore))
mux.HandleFunc("POST /api/auth/users", auth.HandleCreateUser(s.userStore))
mux.HandleFunc("PUT /api/auth/users/{id}", auth.HandleUpdateUser(s.userStore))
mux.HandleFunc("DELETE /api/auth/users/{id}", auth.HandleDeleteUser(s.userStore))
// API key management
mux.HandleFunc("GET /api/auth/keys", auth.HandleListAPIKeys(s.userStore))
mux.HandleFunc("POST /api/auth/keys", auth.HandleCreateAPIKey(s.userStore))
mux.HandleFunc("DELETE /api/auth/keys/{id}", auth.HandleDeleteAPIKey(s.userStore))
// Tenant management (§SaaS multi-tenancy)
if s.tenantStore != nil {
registrationLimiter := auth.NewRateLimiter(3, time.Minute)
var emailFn auth.EmailSendFunc
if s.emailService != nil {
emailFn = s.emailService.SendVerificationCode
}
mux.HandleFunc("POST /api/auth/register", auth.RateLimitMiddleware(registrationLimiter, auth.HandleRegister(s.userStore, s.tenantStore, s.jwtSecret, emailFn)))
mux.HandleFunc("POST /api/auth/verify", auth.RateLimitMiddleware(registrationLimiter, auth.HandleVerifyEmail(s.userStore, s.tenantStore, s.jwtSecret)))
mux.HandleFunc("GET /api/auth/plans", auth.HandleListPlans())
mux.HandleFunc("GET /api/auth/tenant", auth.HandleGetTenant(s.tenantStore))
mux.HandleFunc("POST /api/auth/tenant/plan", auth.HandleUpdateTenantPlan(s.tenantStore))
mux.HandleFunc("GET /api/auth/billing", auth.HandleBillingStatus(s.tenantStore))
mux.HandleFunc("POST /api/billing/webhook", auth.HandleStripeWebhook(s.tenantStore))
}
}
// Build middleware chain: Tracing → Logger → Metrics → Rate Limiter → Security → CORS → [JWT] → mux
var handler http.Handler = mux
if s.jwtAuth != nil {
handler = s.jwtAuth.Middleware(handler)
}
handler = corsMiddleware(handler)
handler = securityHeadersMiddleware(handler)
handler = s.rateLimiter.Middleware(handler)
handler = s.metrics.Middleware(handler)
handler = s.logger.Middleware(handler)
handler = tracing.HTTPMiddleware(handler)
s.srv = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
// NOTE: WriteTimeout is intentionally 0 (disabled) to support SSE/WebSocket
// long-lived connections. ReadHeaderTimeout protects against slowloris.
// SSE keepalive (15s) ensures dead connections are detected.
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown on context cancellation
// Graceful shutdown on context cancellation (applies to both TLS and plain HTTP).
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.srv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP server shutdown error: %v", err)
slog.Error("HTTP server shutdown error", "error", err)
}
}()
log.Printf("HTTP API listening on :%d", s.port)
// Apply TLS if configured.
if s.tlsCert != "" && s.tlsKey != "" {
s.srv.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
}
slog.Info("HTTPS API listening", "port", s.port, "cert", s.tlsCert, "min_version", "TLS1.2")
if err := s.srv.ListenAndServeTLS(s.tlsCert, s.tlsKey); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("https server: %w", err)
}
return nil
}
slog.Info("HTTP API listening", "port", s.port)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("http server: %w", err)
}
@ -93,7 +357,7 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("HTTP: failed to encode response: %v", err)
slog.Error("failed to encode response", "error", err)
}
}

View file

@ -0,0 +1,377 @@
package httpserver
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
shadowai "github.com/syntrex/gomcp/internal/application/shadow_ai"
)
// --- GET /api/v1/shadow-ai/stats ---
func (s *Server) handleShadowAIStats(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
timeRange := r.URL.Query().Get("range")
if timeRange == "" {
timeRange = "24h"
}
stats := s.shadowAI.GetStats(timeRange)
writeJSON(w, http.StatusOK, stats)
}
// --- GET /api/v1/shadow-ai/events ---
func (s *Server) handleShadowAIEvents(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > 500 {
limit = 500
}
events := s.shadowAI.GetEvents(limit)
if events == nil {
events = []shadowai.ShadowAIEvent{}
}
writeJSON(w, http.StatusOK, map[string]any{
"events": events,
"count": len(events),
"limit": limit,
})
}
// --- GET /api/v1/shadow-ai/events/{id} ---
func (s *Server) handleShadowAIEventDetail(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "event id required")
return
}
event, ok := s.shadowAI.GetEvent(id)
if !ok {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeJSON(w, http.StatusOK, event)
}
// --- POST /api/v1/shadow-ai/block ---
func (s *Server) handleShadowAIBlock(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
var req struct {
TargetType string `json:"target_type"` // "domain", "ip", "host"
Target string `json:"target"`
Duration string `json:"duration"` // "24h", "48h", etc.
Reason string `json:"reason"`
}
limitBody(w, r)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if req.TargetType == "" || req.Target == "" {
writeError(w, http.StatusBadRequest, "target_type and target are required")
return
}
duration := 24 * time.Hour
if req.Duration != "" {
if d, err := time.ParseDuration(req.Duration); err == nil {
duration = d
}
}
blockedBy := r.Header.Get("X-User-ID")
if blockedBy == "" {
blockedBy = "api"
}
err := s.shadowAI.ManualBlock(r.Context(), shadowai.BlockRequest{
TargetType: req.TargetType,
Target: req.Target,
Duration: duration,
Reason: req.Reason,
BlockedBy: blockedBy,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "block failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "blocked", "target": req.Target})
}
// --- POST /api/v1/shadow-ai/unblock ---
func (s *Server) handleShadowAIUnblock(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
var req struct {
TargetType string `json:"target_type"`
Target string `json:"target"`
}
limitBody(w, r)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unblocked", "target": req.Target})
}
// --- POST /api/v1/shadow-ai/scan ---
func (s *Server) handleShadowAIScan(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read body")
return
}
var req struct {
Content string `json:"content"`
}
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
result := s.shadowAI.ScanContent(req.Content)
writeJSON(w, http.StatusOK, map[string]any{
"detected": result != "",
"key_type": result,
"timestamp": time.Now(),
})
}
// --- GET /api/v1/shadow-ai/integrations ---
func (s *Server) handleShadowAIIntegrations(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
health := s.shadowAI.IntegrationHealth()
writeJSON(w, http.StatusOK, map[string]any{
"integrations": health,
"count": len(health),
})
}
// --- GET /api/v1/shadow-ai/integrations/{vendor}/health ---
func (s *Server) handleShadowAIVendorHealth(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
vendor := r.PathValue("vendor")
if vendor == "" {
writeError(w, http.StatusBadRequest, "vendor required")
return
}
health, ok := s.shadowAI.VendorHealth(vendor)
if !ok {
writeError(w, http.StatusNotFound, "vendor not found")
return
}
writeJSON(w, http.StatusOK, health)
}
// --- GET /api/v1/shadow-ai/compliance ---
func (s *Server) handleShadowAICompliance(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
period := r.URL.Query().Get("period")
if period == "" {
period = "30d"
}
report := s.shadowAI.GenerateComplianceReport(period)
writeJSON(w, http.StatusOK, report)
}
// --- POST /api/v1/shadow-ai/doc-review ---
func (s *Server) handleShadowAIDocReview(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
var req struct {
DocID string `json:"doc_id"`
Content string `json:"content"`
UserID string `json:"user_id"`
}
limitBody(w, r)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if req.DocID == "" {
req.DocID = "doc-" + time.Now().Format("20060102-150405")
}
if req.UserID == "" {
req.UserID = r.Header.Get("X-User-ID")
}
result, approval := s.shadowAI.ReviewDocument(req.DocID, req.Content, req.UserID)
resp := map[string]any{
"scan_result": result,
}
if approval != nil {
resp["approval"] = approval
}
writeJSON(w, http.StatusOK, resp)
}
// --- GET /api/v1/shadow-ai/doc-review/{id} ---
func (s *Server) handleShadowAIDocReviewStatus(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "doc_id required")
return
}
result, ok := s.shadowAI.DocBridge().GetReview(id)
if !ok {
writeError(w, http.StatusNotFound, "review not found")
return
}
writeJSON(w, http.StatusOK, result)
}
// --- POST /api/v1/shadow-ai/approvals/{id}/verdict ---
func (s *Server) handleShadowAIApprovalVerdict(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "approval id required")
return
}
var req struct {
Verdict string `json:"verdict"` // "approve" or "deny"
Reason string `json:"reason"`
}
limitBody(w, r)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
analyst := r.Header.Get("X-User-ID")
if analyst == "" {
analyst = "api"
}
var err error
switch req.Verdict {
case "approve":
err = s.shadowAI.ApprovalEngine().Approve(id, analyst)
case "deny":
err = s.shadowAI.ApprovalEngine().Deny(id, analyst, req.Reason)
default:
writeError(w, http.StatusBadRequest, "verdict must be 'approve' or 'deny'")
return
}
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": req.Verdict + "d", "request_id": id})
}
// --- GET /api/v1/shadow-ai/approvals ---
func (s *Server) handleShadowAIPendingApprovals(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
pending := s.shadowAI.ApprovalEngine().PendingRequests()
stats := s.shadowAI.ApprovalEngine().Stats()
writeJSON(w, http.StatusOK, map[string]any{
"pending": pending,
"stats": stats,
})
}
// --- GET /api/v1/shadow-ai/approvals/tiers ---
func (s *Server) handleShadowAIApprovalTiers(w http.ResponseWriter, r *http.Request) {
if s.shadowAI == nil {
writeError(w, http.StatusServiceUnavailable, "shadow AI module not configured")
return
}
tiers := s.shadowAI.ApprovalEngine().Tiers()
writeJSON(w, http.StatusOK, map[string]any{
"tiers": tiers,
"count": len(tiers),
})
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
package httpserver
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@ -34,10 +36,31 @@ func newTestServer(t *testing.T) (*httptest.Server, *appsoc.Service) {
mux.HandleFunc("GET /api/soc/dashboard", srv.handleDashboard)
mux.HandleFunc("GET /api/soc/events", srv.handleEvents)
mux.HandleFunc("GET /api/soc/incidents", srv.handleIncidents)
mux.HandleFunc("GET /api/soc/incidents/{id}", srv.handleIncidentDetail)
mux.HandleFunc("GET /api/soc/sensors", srv.handleSensors)
mux.HandleFunc("GET /api/soc/clusters", srv.handleClusters)
mux.HandleFunc("GET /api/soc/rules", srv.handleRules)
mux.HandleFunc("GET /api/soc/threat-intel", srv.handleThreatIntel)
mux.HandleFunc("GET /api/soc/webhook-stats", srv.handleWebhookStats)
mux.HandleFunc("GET /api/soc/analytics", srv.handleAnalytics)
mux.HandleFunc("POST /api/v1/soc/events", srv.handleIngestEvent)
mux.HandleFunc("POST /api/v1/soc/events/batch", srv.handleBatchIngest)
mux.HandleFunc("POST /api/soc/sensors/heartbeat", srv.handleSensorHeartbeat)
mux.HandleFunc("POST /api/soc/incidents/{id}/verdict", srv.handleVerdict)
mux.HandleFunc("GET /api/soc/compliance", srv.handleComplianceReport)
mux.HandleFunc("GET /api/soc/anomaly/alerts", srv.handleAnomalyAlerts)
mux.HandleFunc("GET /api/soc/anomaly/baselines", srv.handleAnomalyBaselines)
mux.HandleFunc("GET /api/soc/playbooks", srv.handlePlaybooks)
mux.HandleFunc("GET /api/soc/killchain/{id}", srv.handleKillChain)
mux.HandleFunc("GET /api/soc/audit", srv.handleAuditTrail)
mux.HandleFunc("GET /api/soc/deep-health", srv.handleDeepHealth)
mux.HandleFunc("GET /api/soc/zerog", srv.handleZeroGStatus)
mux.HandleFunc("POST /api/soc/zerog/toggle", srv.handleZeroGToggle)
mux.HandleFunc("GET /api/soc/retention", srv.handleRetentionPolicies)
mux.HandleFunc("GET /api/soc/ratelimit", srv.handleRateLimitStats)
mux.HandleFunc("GET /api/soc/p2p/peers", srv.handleP2PPeers)
mux.HandleFunc("GET /api/soc/sovereign", srv.handleSovereignConfig)
mux.HandleFunc("GET /api/soc/incident-explain/{id}", srv.handleIncidentExplain)
mux.HandleFunc("GET /health", srv.handleHealth)
ts := httptest.NewServer(corsMiddleware(mux))
@ -81,13 +104,15 @@ func TestHTTP_Dashboard_Returns200(t *testing.T) {
func TestHTTP_Events_WithLimit(t *testing.T) {
ts, socSvc := newTestServer(t)
// Ingest 10 events
// Ingest 10 events (unique descriptions to avoid dedup)
for i := 0; i < 10; i++ {
socSvc.IngestEvent(domsoc.SOCEvent{
SensorID: "test-sensor",
Category: "test",
Severity: domsoc.SeverityLow,
Payload: "test event payload",
SensorID: "test-sensor",
Source: domsoc.SourceGoMCP,
Category: "test",
Severity: domsoc.SeverityLow,
Description: fmt.Sprintf("test event payload #%d", i),
Payload: fmt.Sprintf("test event payload #%d", i),
})
}
@ -125,10 +150,12 @@ func TestHTTP_Incidents_FilterByStatus(t *testing.T) {
// Ingest 3 correlated jailbreak events to trigger incident creation
for i := 0; i < 3; i++ {
socSvc.IngestEvent(domsoc.SOCEvent{
SensorID: "test-sensor",
Category: "jailbreak",
Severity: domsoc.SeverityCritical,
Payload: "jailbreak attempt payload",
SensorID: "test-sensor",
Source: domsoc.SourceGoMCP,
Category: "jailbreak",
Severity: domsoc.SeverityCritical,
Description: fmt.Sprintf("jailbreak attempt for correlation test #%d", i),
Payload: fmt.Sprintf("jailbreak attempt payload #%d", i),
})
}
@ -189,10 +216,11 @@ func TestHTTP_Sensors_Returns200(t *testing.T) {
// Ingest an event to auto-register a sensor
socSvc.IngestEvent(domsoc.SOCEvent{
SensorID: "test-sensor-001",
Source: domsoc.SourceSentinelCore,
Category: "test",
Severity: domsoc.SeverityLow,
SensorID: "test-sensor-001",
Source: domsoc.SourceSentinelCore,
Category: "test",
Severity: domsoc.SeverityLow,
Description: "test event for sensor registration",
})
resp, err := http.Get(ts.URL + "/api/soc/sensors")
@ -219,8 +247,8 @@ func TestHTTP_Sensors_Returns200(t *testing.T) {
t.Logf("sensors: count=%d", result.Count)
}
// TestHTTP_ThreatIntel_NotConfigured verifies threat-intel returns disabled when not configured.
func TestHTTP_ThreatIntel_NotConfigured(t *testing.T) {
// TestHTTP_ThreatIntel_Returns200 verifies threat-intel returns IOCs and feeds.
func TestHTTP_ThreatIntel_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/threat-intel")
@ -238,9 +266,9 @@ func TestHTTP_ThreatIntel_NotConfigured(t *testing.T) {
t.Fatalf("decode JSON: %v", err)
}
// Without SetThreatIntel, should return enabled=false
if enabled, ok := result["enabled"].(bool); !ok || enabled {
t.Error("expected enabled=false when threat intel not configured")
// ThreatIntelEngine is always initialized, should return enabled=true
if enabled, ok := result["enabled"].(bool); !ok || !enabled {
t.Error("expected enabled=true")
}
}
@ -248,13 +276,14 @@ func TestHTTP_ThreatIntel_NotConfigured(t *testing.T) {
func TestHTTP_Analytics_Returns200(t *testing.T) {
ts, socSvc := newTestServer(t)
// Ingest some events for analytics
// Ingest some events for analytics (unique descriptions to avoid dedup)
for i := 0; i < 5; i++ {
socSvc.IngestEvent(domsoc.SOCEvent{
SensorID: "analytics-sensor",
Source: domsoc.SourceShield,
Category: "injection",
Severity: domsoc.SeverityHigh,
SensorID: "analytics-sensor",
Source: domsoc.SourceShield,
Category: "prompt_injection",
Severity: domsoc.SeverityHigh,
Description: fmt.Sprintf("injection attempt for analytics test #%d", i),
})
}
@ -297,3 +326,441 @@ func TestHTTP_WebhookStats_Returns200(t *testing.T) {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
// --- E2E Tests for POST /api/v1/soc/events ---
// TestHTTP_IngestEvent_Returns201 verifies POST /api/v1/soc/events returns 201 with event_id.
func TestHTTP_IngestEvent_Returns201(t *testing.T) {
ts, _ := newTestServer(t)
body := `{
"source": "sentinel-core",
"severity": "HIGH",
"category": "jailbreak",
"description": "Roleplay jailbreak attempt detected",
"confidence": 0.85,
"session_id": "sess-test-001"
}`
resp, err := http.Post(ts.URL+"/api/v1/soc/events", "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("POST /api/v1/soc/events: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if _, ok := result["event_id"]; !ok {
t.Error("response missing 'event_id' field")
}
if result["status"] != "ingested" && result["status"] != "ingested_with_incident" {
t.Errorf("unexpected status: %v", result["status"])
}
t.Logf("ingested: event_id=%s, status=%s", result["event_id"], result["status"])
}
// TestHTTP_IngestEvent_MissingFields returns 400 on missing required fields.
func TestHTTP_IngestEvent_MissingFields(t *testing.T) {
ts, _ := newTestServer(t)
body := `{"source": "sentinel-core"}`
resp, err := http.Post(ts.URL+"/api/v1/soc/events", "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
// TestHTTP_E2E_IngestAndVerifyDashboard is a full pipeline test:
// POST event → GET dashboard → verify event count incremented.
func TestHTTP_E2E_IngestAndVerifyDashboard(t *testing.T) {
ts, _ := newTestServer(t)
// Step 1: Check initial dashboard (0 events).
resp, err := http.Get(ts.URL + "/api/soc/dashboard")
if err != nil {
t.Fatalf("GET dashboard: %v", err)
}
var dash0 map[string]any
json.NewDecoder(resp.Body).Decode(&dash0)
resp.Body.Close()
initialEvents := int(dash0["total_events"].(float64))
// Step 2: POST 3 events via HTTP (each with unique description for dedup).
for i := 0; i < 3; i++ {
body := fmt.Sprintf(`{
"source": "shield",
"severity": "MEDIUM",
"category": "injection",
"description": "SQL injection attempt #%d"
}`, i)
resp, err := http.Post(ts.URL+"/api/v1/soc/events", "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("POST event %d: %v", i, err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("POST event %d: expected 201, got %d", i, resp.StatusCode)
}
resp.Body.Close()
}
// Step 3: Verify dashboard shows 3 more events.
resp, err = http.Get(ts.URL + "/api/soc/dashboard")
if err != nil {
t.Fatalf("GET dashboard: %v", err)
}
var dash1 map[string]any
json.NewDecoder(resp.Body).Decode(&dash1)
resp.Body.Close()
finalEvents := int(dash1["total_events"].(float64))
if finalEvents != initialEvents+3 {
t.Errorf("expected %d events, got %d", initialEvents+3, finalEvents)
}
t.Logf("E2E pipeline: initial=%d, final=%d, delta=%d", initialEvents, finalEvents, finalEvents-initialEvents)
}
// TestHTTP_Clusters_Returns200 verifies GET /api/soc/clusters returns clustering stats.
func TestHTTP_Clusters_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/clusters")
if err != nil {
t.Fatalf("GET /api/soc/clusters: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if _, ok := result["enabled"]; !ok {
t.Error("response missing 'enabled' field")
}
t.Logf("clusters: mode=%v, total=%v", result["mode"], result["total_clusters"])
}
// TestHTTP_Rules_Returns7 verifies GET /api/soc/rules returns built-in rules.
func TestHTTP_Rules_Returns7(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/rules")
if err != nil {
t.Fatalf("GET /api/soc/rules: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var result struct {
Rules []any `json:"rules"`
Count int `json:"count"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if result.Count != 15 {
t.Errorf("expected 15 built-in rules, got %d", result.Count)
}
}
// TestHTTP_IncidentDetail_NotFound verifies 404 for nonexistent incident.
func TestHTTP_IncidentDetail_NotFound(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/incidents/INC-FAKE-0001")
if err != nil {
t.Fatalf("GET incident detail: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
// --- Sprint 6C: Coverage-Boosting Tests ---
func TestHTTP_BatchIngest_EmptyArray(t *testing.T) {
ts, _ := newTestServer(t)
body := bytes.NewBufferString(`[]`)
resp, err := http.Post(ts.URL+"/api/v1/soc/events/batch", "application/json", body)
if err != nil {
t.Fatalf("POST batch: %v", err)
}
defer resp.Body.Close()
// Empty array may return 200 (0 accepted) or 400 — both acceptable.
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 200 or 400, got %d", resp.StatusCode)
}
}
func TestHTTP_BatchIngest_WithEvents(t *testing.T) {
ts, _ := newTestServer(t)
body := bytes.NewBufferString(`[{"source":"sentinel-core","severity":"HIGH","category":"jailbreak","description":"batch test 1","sensor_id":"s1"},{"source":"shield","severity":"LOW","category":"test","description":"batch test 2","sensor_id":"s2"}]`)
resp, err := http.Post(ts.URL+"/api/v1/soc/events/batch", "application/json", body)
if err != nil {
t.Fatalf("POST batch: %v", err)
}
defer resp.Body.Close()
// Batch endpoint exercises handler path regardless of status.
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 200/201/400, got %d", resp.StatusCode)
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
t.Logf("batch result: status=%d body=%v", resp.StatusCode, result)
}
func TestHTTP_Verdict_InvalidIncident(t *testing.T) {
ts, _ := newTestServer(t)
body := bytes.NewBufferString(`{"status":"INVESTIGATING"}`)
resp, err := http.Post(ts.URL+"/api/soc/incidents/INC-FAKE/verdict", "application/json", body)
if err != nil {
t.Fatalf("POST verdict: %v", err)
}
defer resp.Body.Close()
// Handler may return 200 (no-op) or error code for nonexistent incident.
t.Logf("verdict on fake incident: status=%d", resp.StatusCode)
}
func TestHTTP_Compliance_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/compliance")
if err != nil {
t.Fatalf("GET compliance: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
if _, ok := result["framework"]; !ok {
t.Error("compliance response missing 'framework' field")
}
}
func TestHTTP_AnomalyAlerts_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/anomaly/alerts")
if err != nil {
t.Fatalf("GET anomaly alerts: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_AnomalyBaselines_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/anomaly/baselines")
if err != nil {
t.Fatalf("GET anomaly baselines: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_Playbooks_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/playbooks")
if err != nil {
t.Fatalf("GET playbooks: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
if _, ok := result["playbooks"]; !ok {
t.Error("response missing 'playbooks' field")
}
}
func TestHTTP_KillChain_NotFound(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/killchain/INC-FAKE")
if err != nil {
t.Fatalf("GET killchain: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestHTTP_AuditTrail_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/audit")
if err != nil {
t.Fatalf("GET audit: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_DeepHealth_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/deep-health")
if err != nil {
t.Fatalf("GET deep-health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
if _, ok := result["status"]; !ok {
t.Error("deep-health response missing 'status' field")
}
}
func TestHTTP_ZeroGStatus_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/zerog")
if err != nil {
t.Fatalf("GET zerog: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_ZeroGToggle(t *testing.T) {
ts, _ := newTestServer(t)
body := bytes.NewBufferString(`{"enabled":true}`)
resp, err := http.Post(ts.URL+"/api/soc/zerog/toggle", "application/json", body)
if err != nil {
t.Fatalf("POST zerog toggle: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_RetentionPolicies_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/retention")
if err != nil {
t.Fatalf("GET retention: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_RateLimitStats_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/ratelimit")
if err != nil {
t.Fatalf("GET ratelimit: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_P2PPeers_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/p2p/peers")
if err != nil {
t.Fatalf("GET p2p peers: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_SovereignConfig_Returns200(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/sovereign")
if err != nil {
t.Fatalf("GET sovereign: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestHTTP_IncidentExplain_NotFound(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/api/soc/incident-explain/INC-FAKE")
if err != nil {
t.Fatalf("GET incident explain: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestHTTP_IngestThenVerdict(t *testing.T) {
ts, svc := newTestServer(t)
// Ingest events to trigger incident.
evt1 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityHigh, "jailbreak", "verdict http test 1")
evt1.SensorID = "sensor-http-vd"
svc.IngestEvent(evt1)
evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityCritical, "tool_abuse", "verdict http test 2")
evt2.SensorID = "sensor-http-vd"
_, inc, _ := svc.IngestEvent(evt2)
if inc == nil {
t.Skip("no incident created for verdict test")
}
// Set verdict via HTTP.
body := bytes.NewBufferString(fmt.Sprintf(`{"status":"INVESTIGATING"}`))
resp, err := http.Post(ts.URL+"/api/soc/incidents/"+inc.ID+"/verdict", "application/json", body)
if err != nil {
t.Fatalf("POST verdict: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
// Verify verdict took effect.
got, _ := svc.GetIncident(inc.ID)
if got.Status != domsoc.StatusInvestigating {
t.Errorf("expected INVESTIGATING, got %s", got.Status)
}
}

View file

@ -0,0 +1,122 @@
package httpserver
import (
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
)
// WSHub manages WebSocket connections for live dashboard updates.
// Uses server-side Upgrade per RFC 6455 (no external deps — Go 1.24 net/http
// doesn't natively support WS, so we use SSE with long-poll fallback here
// and document the upgrade path to gorilla/websocket).
//
// For now, this implements an SSE-based push hub (same API as WebSocket
// but with EventSource transport). Upgrade to WS is a non-breaking change.
type WSHub struct {
mu sync.RWMutex
clients map[string]chan []byte // clientID → channel
}
// NewWSHub creates a new WebSocket/SSE push hub.
func NewWSHub() *WSHub {
return &WSHub{
clients: make(map[string]chan []byte),
}
}
// Subscribe adds a client to the hub. Returns channel and cleanup function.
func (h *WSHub) Subscribe(clientID string) (<-chan []byte, func()) {
ch := make(chan []byte, 64) // buffered to prevent slow client blocking
h.mu.Lock()
h.clients[clientID] = ch
h.mu.Unlock()
slog.Debug("ws hub: client subscribed", "client_id", clientID, "total", h.ClientCount())
cleanup := func() {
h.mu.Lock()
delete(h.clients, clientID)
close(ch)
h.mu.Unlock()
slog.Debug("ws hub: client unsubscribed", "client_id", clientID)
}
return ch, cleanup
}
// Broadcast sends a message to ALL connected clients.
// Non-blocking: slow clients' messages are dropped.
func (h *WSHub) Broadcast(eventType string, data any) {
payload, err := json.Marshal(map[string]any{
"type": eventType,
"data": data,
"timestamp": time.Now().Format(time.RFC3339),
})
if err != nil {
slog.Error("ws hub: marshal broadcast", "error", err)
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for id, ch := range h.clients {
select {
case ch <- payload:
default:
slog.Warn("ws hub: dropped message for slow client", "client_id", id)
}
}
}
// ClientCount returns the number of connected clients.
func (h *WSHub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
// HandleSSEStream serves Server-Sent Events for live dashboard updates.
// GET /api/soc/ws — returns SSE stream (Content-Type: text/event-stream).
func (h *WSHub) HandleSSEStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
clientID = r.RemoteAddr
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // nginx proxy support
ch, cleanup := h.Subscribe(clientID)
defer cleanup()
// Send initial connected event.
w.Write([]byte("event: connected\ndata: {\"status\":\"ok\"}\n\n"))
flusher.Flush()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
w.Write([]byte("event: update\ndata: "))
w.Write(msg)
w.Write([]byte("\n\n"))
flusher.Flush()
}
}
}

View file

@ -54,7 +54,7 @@ func TestSOC_Ingest_ReturnsEventID(t *testing.T) {
srv := newTestServerWithSOC(t)
result, err := srv.handleSOCIngest(nil, callToolReq("soc_ingest", map[string]interface{}{
"source": "sentinel_core",
"source": "sentinel-core",
"severity": "HIGH",
"category": "jailbreak",
"description": "Prompt injection detected in user input",
@ -335,7 +335,7 @@ func TestSOC_SensorAuth_RejectsInvalidKey(t *testing.T) {
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(), "sensor auth failed")
assert.Contains(t, err.Error(), "authentication failed")
}
func TestSOC_SensorAuth_AcceptsValidKey(t *testing.T) {