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

@ -397,8 +397,30 @@ func run(rlmDir, cachePath, sessionID string, noContext, uiMode, unfiltered bool
}
socSvc := appsoc.NewService(socRepo, socDecisionLogger)
// Load custom correlation rules from YAML (§7.5).
customRulesPath := filepath.Join(rlmDir, "soc_rules.yaml")
customRules, rulesErr := domsoc.LoadRulesFromYAML(customRulesPath)
if rulesErr != nil {
log.Printf("WARNING: failed to load custom SOC rules: %v", rulesErr)
} else if len(customRules) > 0 {
socSvc.AddCustomRules(customRules)
log.Printf("Loaded %d custom SOC correlation rules from %s", len(customRules), customRulesPath)
}
serverOpts = append(serverOpts, mcpserver.WithSOCService(socSvc))
log.Printf("SOC Service initialized (rules=7, playbooks=3, decision_logger=%v)", socDecisionLogger != nil)
// Initialize Threat Intelligence with default IOC feeds (§6).
threatIntelStore := appsoc.NewThreatIntelStore()
threatIntelStore.AddDefaultFeeds()
socSvc.SetThreatIntel(threatIntelStore)
stopThreatIntel := make(chan struct{})
threatIntelStore.StartBackgroundRefresh(30*time.Minute, stopThreatIntel)
// Cleanup: stop refresh goroutine on shutdown.
// (stopThreatIntel channel closed when main returns)
log.Printf("SOC Service initialized (rules=%d, playbooks=3, clustering=enabled, threat_intel=enabled, decision_logger=%v)",
7+len(customRules), socDecisionLogger != nil)
// --- Create MCP server ---

189
cmd/immune/main.go Normal file
View file

@ -0,0 +1,189 @@
// Package main provides the SENTINEL immune agent (SEC-002 eBPF Runtime Guard).
//
// The immune agent monitors SOC processes at the kernel level using eBPF
// tracepoints and enforces per-process security policies.
//
// On Linux: loads eBPF programs for syscall/file/network monitoring.
// On Windows/macOS: uses process monitoring fallback (polling /proc or WMI).
//
// Usage:
//
// go run ./cmd/immune/ --policy deploy/policies/soc_runtime_policy.yaml
// SOC_GUARD_MODE=enforce go run ./cmd/immune/
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"runtime"
"runtime/debug"
"strconv"
"syscall"
"time"
"github.com/syntrex/gomcp/internal/infrastructure/guard"
"github.com/syntrex/gomcp/internal/infrastructure/logging"
)
func main() {
// SEC-003: Panic recovery.
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
fmt.Fprintf(os.Stderr, "IMMUNE FATAL PANIC: %v\n%s\n", r, buf[:n])
os.Exit(2)
}
}()
logger := logging.New(env("SOC_LOG_FORMAT", "text"), env("SOC_LOG_LEVEL", "info"))
slog.SetDefault(logger)
// SEC-003: Memory safety — immune agent uses minimal RAM.
if os.Getenv("GOMEMLIMIT") == "" {
debug.SetMemoryLimit(128 * 1024 * 1024) // 128 MiB
}
policyPath := env("SOC_GUARD_POLICY", "deploy/policies/soc_runtime_policy.yaml")
port, _ := strconv.Atoi(env("SOC_IMMUNE_PORT", "9760"))
logger.Info("starting SENTINEL immune agent (SEC-002 eBPF Runtime Guard)",
"policy", policyPath,
"port", port,
"os", runtime.GOOS,
)
// Load policy.
policy, err := guard.LoadPolicy(policyPath)
if err != nil {
logger.Error("failed to load policy", "path", policyPath, "error", err)
os.Exit(1)
}
// Override mode from env if set.
if modeOverride := os.Getenv("SOC_GUARD_MODE"); modeOverride != "" {
policy.Mode = guard.Mode(modeOverride)
logger.Info("mode overridden via env", "mode", policy.Mode)
}
g := guard.New(policy)
// Register violation handler → forward to SOC.
g.OnViolation(func(v guard.Violation) {
logger.Warn("GUARD VIOLATION",
"process", v.ProcessName,
"pid", v.PID,
"type", v.Type,
"detail", v.Detail,
"severity", v.Severity,
"action", v.Action,
)
// TODO: Forward to SOC via HTTP or IPC.
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Start platform-specific monitoring.
go startProcessMonitor(ctx, g, logger)
// HTTP status endpoint for health checks and stats.
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "healthy",
"mode": g.CurrentMode(),
"os": runtime.GOOS,
})
})
mux.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(g.Stats())
})
mux.HandleFunc("/mode", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
g.SetMode(guard.Mode(req.Mode))
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"mode": string(g.CurrentMode())})
})
srv := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
logger.Info("immune HTTP status endpoint ready", "port", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP server failed", "error", err)
}
}()
<-ctx.Done()
logger.Info("immune shutting down")
srv.Shutdown(context.Background())
}
// startProcessMonitor runs the platform-specific process monitoring loop.
// On Linux: would attach eBPF programs and read from ringbuf.
// On Windows/macOS: polls process list for anomalies.
func startProcessMonitor(ctx context.Context, g *guard.Guard, logger *slog.Logger) {
logger.Info("starting process monitor",
"platform", runtime.GOOS,
"note", "using polling fallback (eBPF requires Linux)",
)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Polling fallback: check process resource usage.
// On Linux with eBPF: this would be event-driven from ringbuf.
checkProcessResources(g, logger)
}
}
}
// checkProcessResources polls OS for SOC process resource usage.
func checkProcessResources(g *guard.Guard, logger *slog.Logger) {
// This is a simplified polling fallback.
// On Linux with eBPF loaded, violations come from kernel tracepoints instead.
//
// In production:
// - Linux: bpf_ringbuf_poll() for real-time syscall events
// - Windows: ETW (Event Tracing for Windows) or WMI queries
// - macOS: Endpoint Security framework
logger.Debug("process resource check (polling fallback)")
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

91
cmd/sidecar/main.go Normal file
View file

@ -0,0 +1,91 @@
// Package main provides the Universal Sidecar CLI entry point (§5.5).
//
// Usage:
//
// sentinel-sidecar --sensor-type=sentinel-core --log-path=/var/log/core.log --bus-url=http://localhost:9100
// sentinel-sidecar --sensor-type=shield --stdin --bus-url=http://localhost:9100
// echo "[DETECT] engine=jailbreak confidence=0.95 pattern=DAN" | sentinel-sidecar --sensor-type=sentinel-core --stdin
//
// Environment variables:
//
// SIDECAR_SENSOR_TYPE sentinel-core|shield|immune|generic
// SIDECAR_LOG_PATH Path to sensor log file (or "stdin")
// SIDECAR_BUS_URL SOC Event Bus URL (default: http://localhost:9100)
// SIDECAR_SENSOR_ID Sensor ID for registration
// SIDECAR_API_KEY Sensor API key
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/syntrex/gomcp/internal/application/sidecar"
)
func main() {
sensorType := flag.String("sensor-type", env("SIDECAR_SENSOR_TYPE", "sentinel-core"),
"Sensor type: sentinel-core, shield, immune, generic")
logPath := flag.String("log-path", env("SIDECAR_LOG_PATH", ""),
"Path to sensor log file")
useStdin := flag.Bool("stdin", false,
"Read from stdin instead of log file")
busURL := flag.String("bus-url", env("SIDECAR_BUS_URL", "http://localhost:9100"),
"SOC Event Bus URL")
sensorID := flag.String("sensor-id", env("SIDECAR_SENSOR_ID", ""),
"Sensor registration ID")
apiKey := flag.String("api-key", env("SIDECAR_API_KEY", ""),
"Sensor API key")
flag.Parse()
// Derive sensor ID from type if not set.
if *sensorID == "" {
*sensorID = fmt.Sprintf("sidecar-%s", *sensorType)
}
// Determine log source.
source := *logPath
if *useStdin || source == "" {
source = "stdin"
}
cfg := sidecar.Config{
SensorType: *sensorType,
LogPath: source,
BusURL: *busURL,
SensorID: *sensorID,
APIKey: *apiKey,
PollInterval: 200 * time.Millisecond,
}
slog.Info("sentinel-sidecar starting",
"sensor_type", cfg.SensorType,
"log_path", cfg.LogPath,
"bus_url", cfg.BusURL,
"sensor_id", cfg.SensorID,
)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
sc := sidecar.New(cfg)
if err := sc.Run(ctx); err != nil {
slog.Error("sidecar exited with error", "error", err)
os.Exit(1)
}
slog.Info("sentinel-sidecar stopped", "stats", sc.GetStats())
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

188
cmd/soc-correlate/main.go Normal file
View file

@ -0,0 +1,188 @@
// Package main provides the SOC Correlate process (SEC-001 Process Isolation).
//
// Responsibility: Receives persisted events from soc-ingest via IPC,
// runs 15 correlation rules + clustering, creates incidents.
// Forwards incidents to soc-respond via IPC.
//
// This process has NO network access (by design) — only IPC pipes.
//
// Usage:
//
// go run ./cmd/soc-correlate/
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/signal"
"runtime"
"runtime/debug"
"syscall"
appsoc "github.com/syntrex/gomcp/internal/application/soc"
domsoc "github.com/syntrex/gomcp/internal/domain/soc"
"github.com/syntrex/gomcp/internal/infrastructure/audit"
"github.com/syntrex/gomcp/internal/infrastructure/ipc"
"github.com/syntrex/gomcp/internal/infrastructure/logging"
"github.com/syntrex/gomcp/internal/infrastructure/sqlite"
)
func main() {
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
fmt.Fprintf(os.Stderr, "SOC-CORRELATE FATAL PANIC: %v\n%s\n", r, buf[:n])
os.Exit(2)
}
}()
logger := logging.New(env("SOC_LOG_FORMAT", "text"), env("SOC_LOG_LEVEL", "info"))
slog.SetDefault(logger)
// SEC-003: Memory safety — correlate needs more RAM for rule evaluation.
if limitStr := os.Getenv("GOMEMLIMIT"); limitStr == "" {
debug.SetMemoryLimit(512 * 1024 * 1024) // 512 MiB
}
dbPath := env("SOC_DB_PATH", "soc.db")
logger.Info("starting SOC-CORRELATE (SEC-001 isolated process)",
"db", dbPath,
"upstream_pipe", "soc-ingest-to-correlate",
"downstream_pipe", "soc-correlate-to-respond",
)
// Infrastructure — SQLite access for correlation context.
db, err := sqlite.Open(dbPath)
if err != nil {
logger.Error("database open failed", "error", err)
os.Exit(1)
}
defer db.Close()
socRepo, err := sqlite.NewSOCRepo(db)
if err != nil {
logger.Error("SOC repo init failed", "error", err)
os.Exit(1)
}
decisionLogger, err := audit.NewDecisionLogger(env("SOC_AUDIT_DIR", "."))
if err != nil {
logger.Error("decision logger init failed", "error", err)
os.Exit(1)
}
socSvc := appsoc.NewService(socRepo, decisionLogger)
_ = domsoc.DefaultSOCCorrelationRules() // Loaded inside socSvc
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// IPC: Listen for events from soc-ingest.
ingestListener, err := ipc.Listen("soc-ingest-to-correlate")
if err != nil {
logger.Error("failed to listen for ingest", "error", err)
os.Exit(1)
}
defer ingestListener.Close()
logger.Info("IPC listener ready", "pipe", "soc-ingest-to-correlate")
// IPC: Connect to downstream soc-respond.
respondConn, err := ipc.DialWithRetry(ctx, "soc-correlate-to-respond", 30)
var respondSender *ipc.BufferedSender
if err != nil {
logger.Warn("soc-respond not available — incidents will only be stored", "error", err)
} else {
respondSender = ipc.NewBufferedSender(respondConn, "soc-correlate-to-respond")
defer respondSender.Close()
logger.Info("IPC connected to soc-respond")
}
// Accept ingest connection and process events.
go func() {
for {
conn, err := ingestListener.Accept()
if err != nil {
if ctx.Err() != nil {
return // Shutting down.
}
logger.Error("accept failed", "error", err)
continue
}
go handleIngestConnection(ctx, conn, socSvc, respondSender, logger)
}
}()
<-ctx.Done()
logger.Info("SOC-CORRELATE shutting down")
}
// handleIngestConnection processes events from a single soc-ingest connection.
func handleIngestConnection(
ctx context.Context,
conn net.Conn,
socSvc *appsoc.Service,
respondSender *ipc.BufferedSender,
logger *slog.Logger,
) {
defer conn.Close()
receiver := ipc.NewReceiver(conn, "ingest")
for {
select {
case <-ctx.Done():
return
default:
}
msg, err := receiver.Next()
if err == io.EOF {
logger.Info("ingest connection closed")
return
}
if err != nil {
logger.Error("read event", "error", err)
continue
}
if msg.Type != ipc.SOCMsgEvent {
continue
}
// Deserialize event and run correlation.
var event domsoc.SOCEvent
if err := json.Unmarshal(msg.Payload, &event); err != nil {
logger.Error("unmarshal event", "error", err)
continue
}
// Run correlation rules via service.
_, incident, err := socSvc.IngestEvent(event)
if err != nil {
logger.Error("correlate", "error", err)
continue
}
// Forward incident to soc-respond.
if incident != nil && respondSender != nil {
incMsg, _ := ipc.NewSOCMessage(ipc.SOCMsgIncident, incident)
if err := respondSender.Send(incMsg); err != nil {
logger.Error("forward incident to respond", "error", err)
}
}
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

130
cmd/soc-ingest/main.go Normal file
View file

@ -0,0 +1,130 @@
// Package main provides the SOC Ingest process (SEC-001 Process Isolation).
//
// Responsibility: HTTP endpoint, authentication, secret scanner,
// rate limiting, dedup, SQLite persistence.
// Forwards persisted events to soc-correlate via IPC.
//
// Usage:
//
// go run ./cmd/soc-ingest/
// SOC_DB_PATH=/data/soc.db SOC_INGEST_PORT=9750 go run ./cmd/soc-ingest/
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"runtime/debug"
"strconv"
"syscall"
"github.com/syntrex/gomcp/internal/application/soc"
"github.com/syntrex/gomcp/internal/infrastructure/audit"
"github.com/syntrex/gomcp/internal/infrastructure/ipc"
"github.com/syntrex/gomcp/internal/infrastructure/logging"
"github.com/syntrex/gomcp/internal/infrastructure/sqlite"
sochttp "github.com/syntrex/gomcp/internal/transport/http"
)
func main() {
// SEC-003: Panic recovery.
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
fmt.Fprintf(os.Stderr, "SOC-INGEST FATAL PANIC: %v\n%s\n", r, buf[:n])
os.Exit(2)
}
}()
logger := logging.New(env("SOC_LOG_FORMAT", "text"), env("SOC_LOG_LEVEL", "info"))
slog.SetDefault(logger)
// SEC-003: Memory safety.
if limitStr := os.Getenv("GOMEMLIMIT"); limitStr == "" {
debug.SetMemoryLimit(256 * 1024 * 1024) // 256 MiB for ingest
}
port, _ := strconv.Atoi(env("SOC_INGEST_PORT", "9750"))
dbPath := env("SOC_DB_PATH", "soc.db")
logger.Info("starting SOC-INGEST (SEC-001 isolated process)",
"port", port, "db", dbPath,
"ipc_pipe", "soc-ingest-to-correlate",
)
// Infrastructure.
db, err := sqlite.Open(dbPath)
if err != nil {
logger.Error("database open failed", "error", err)
os.Exit(1)
}
defer db.Close()
socRepo, err := sqlite.NewSOCRepo(db)
if err != nil {
logger.Error("SOC repo init failed", "error", err)
os.Exit(1)
}
decisionLogger, err := audit.NewDecisionLogger(env("SOC_AUDIT_DIR", "."))
if err != nil {
logger.Error("decision logger init failed", "error", err)
os.Exit(1)
}
// Service (ingest-only mode).
socSvc := soc.NewService(socRepo, decisionLogger)
// IPC: Connect to downstream soc-correlate.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
correlateConn, err := ipc.DialWithRetry(ctx, "soc-ingest-to-correlate", 30)
if err != nil {
logger.Warn("soc-correlate not available — running in standalone ingest mode", "error", err)
} else {
ipcSender := ipc.NewBufferedSender(correlateConn, "soc-ingest-to-correlate")
defer ipcSender.Close()
// Subscribe to event bus and forward events via IPC.
eventCh := socSvc.EventBus().Subscribe("ipc-forwarder")
go func() {
for event := range eventCh {
msg, err := ipc.NewSOCMessage(ipc.SOCMsgEvent, event)
if err != nil {
logger.Error("ipc: marshal event", "error", err)
continue
}
if err := ipcSender.Send(msg); err != nil {
logger.Error("ipc: forward to correlate", "error", err)
}
}
}()
logger.Info("IPC connected to soc-correlate", "pending_buffer", ipc.BufferSize)
}
// HTTP server (ingest endpoints only).
srv := sochttp.New(socSvc, port)
// JWT auth.
if jwtSecret := env("SOC_JWT_SECRET", ""); jwtSecret != "" {
srv.SetJWTAuth([]byte(jwtSecret))
}
logger.Info("SOC-INGEST ready", "port", port)
if err := srv.Start(ctx); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

158
cmd/soc-respond/main.go Normal file
View file

@ -0,0 +1,158 @@
// Package main provides the SOC Respond process (SEC-001 Process Isolation).
//
// Responsibility: Receives incidents from soc-correlate via IPC,
// executes playbooks, dispatches webhooks, writes audit log.
//
// Network access: restricted to outbound HTTPS (webhook endpoints only).
//
// Usage:
//
// go run ./cmd/soc-respond/
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/signal"
"runtime"
"runtime/debug"
"syscall"
domsoc "github.com/syntrex/gomcp/internal/domain/soc"
"github.com/syntrex/gomcp/internal/infrastructure/ipc"
"github.com/syntrex/gomcp/internal/infrastructure/logging"
)
func main() {
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
fmt.Fprintf(os.Stderr, "SOC-RESPOND FATAL PANIC: %v\n%s\n", r, buf[:n])
os.Exit(2)
}
}()
logger := logging.New(env("SOC_LOG_FORMAT", "text"), env("SOC_LOG_LEVEL", "info"))
slog.SetDefault(logger)
// SEC-003: Memory safety — respond process uses minimal RAM.
if limitStr := os.Getenv("GOMEMLIMIT"); limitStr == "" {
debug.SetMemoryLimit(128 * 1024 * 1024) // 128 MiB
}
logger.Info("starting SOC-RESPOND (SEC-001 isolated process)",
"upstream_pipe", "soc-correlate-to-respond",
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Playbook engine for automated response.
playbookEngine := domsoc.NewPlaybookEngine()
// IPC: Listen for incidents from soc-correlate.
listener, err := ipc.Listen("soc-correlate-to-respond")
if err != nil {
logger.Error("failed to listen", "error", err)
os.Exit(1)
}
defer listener.Close()
logger.Info("IPC listener ready", "pipe", "soc-correlate-to-respond")
// Accept connections from correlate.
go func() {
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept failed", "error", err)
continue
}
go handleCorrelateConnection(ctx, conn, playbookEngine, logger)
}
}()
<-ctx.Done()
logger.Info("SOC-RESPOND shutting down")
}
// handleCorrelateConnection processes incidents from soc-correlate.
func handleCorrelateConnection(
ctx context.Context,
conn net.Conn,
playbookEngine *domsoc.PlaybookEngine,
logger *slog.Logger,
) {
defer conn.Close()
receiver := ipc.NewReceiver(conn, "correlate")
for {
select {
case <-ctx.Done():
return
default:
}
msg, err := receiver.Next()
if err == io.EOF {
logger.Info("correlate connection closed")
return
}
if err != nil {
logger.Error("read incident", "error", err)
continue
}
if msg.Type != ipc.SOCMsgIncident {
continue
}
var incident domsoc.Incident
if err := json.Unmarshal(msg.Payload, &incident); err != nil {
logger.Error("unmarshal incident", "error", err)
continue
}
logger.Info("incident received for response",
"id", incident.ID,
"severity", incident.Severity,
"correlation_rule", incident.CorrelationRule,
)
// Execute matching playbooks.
for _, pb := range playbookEngine.ListPlaybooks() {
if pb.Enabled {
logger.Info("executing playbook",
"playbook", pb.ID,
"incident", incident.ID,
)
for _, action := range pb.Actions {
logger.Info("playbook action",
"playbook", pb.ID,
"action_type", action.Type,
"params", action.Params,
)
}
}
}
// TODO: Webhook dispatch (restricted to HTTPS only).
// TODO: Audit log write.
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

229
cmd/soc/main.go Normal file
View file

@ -0,0 +1,229 @@
// Package main provides the standalone SOC API server entry point.
//
// Usage:
//
// go run ./cmd/soc/
// SOC_DB_PATH=/data/soc.db SOC_PORT=9100 go run ./cmd/soc/
// SOC_DB_DRIVER=postgres SOC_DB_DSN=postgres://sentinel:pass@localhost:5432/soc go run ./cmd/soc/
//
// SEC-003 Memory Safety: set GOMEMLIMIT, SOC_GOMAXPROCS for runtime hardening.
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"runtime/debug"
"strconv"
"syscall"
"github.com/syntrex/gomcp/internal/application/soc"
socdomain "github.com/syntrex/gomcp/internal/domain/soc"
"github.com/syntrex/gomcp/internal/infrastructure/audit"
"github.com/syntrex/gomcp/internal/infrastructure/email"
"github.com/syntrex/gomcp/internal/infrastructure/logging"
"github.com/syntrex/gomcp/internal/infrastructure/postgres"
"github.com/syntrex/gomcp/internal/infrastructure/sqlite"
"github.com/syntrex/gomcp/internal/infrastructure/tracing"
sochttp "github.com/syntrex/gomcp/internal/transport/http"
)
func main() {
// SEC-003: Top-level panic recovery — log stack trace before crash.
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
fmt.Fprintf(os.Stderr, "SENTINEL SOC FATAL PANIC: %v\n%s\n", r, buf[:n])
os.Exit(2)
}
}()
// Structured logger: JSON for production, text for dev.
logFormat := env("SOC_LOG_FORMAT", "text")
logLevel := env("SOC_LOG_LEVEL", "info")
logger := logging.New(logFormat, logLevel)
slog.SetDefault(logger)
// SEC-003: Go runtime memory safety hardening.
configureMemorySafety(logger)
portStr := env("SOC_PORT", "9100")
dbPath := env("SOC_DB_PATH", "soc.db")
auditDir := env("SOC_AUDIT_DIR", ".")
port, err := strconv.Atoi(portStr)
if err != nil {
logger.Error("invalid port", "port", portStr, "error", err)
os.Exit(1)
}
logger.Info("starting SENTINEL SOC API",
"port", port,
"db", dbPath,
"log_format", logFormat,
"log_level", logLevel,
)
// Infrastructure — database driver selection.
dbDriver := env("SOC_DB_DRIVER", "sqlite")
dbDSN := env("SOC_DB_DSN", "")
var socRepo socdomain.SOCRepository
var dbCloser func() error
var sqlDB interface{} // raw DB reference for auth user store
switch dbDriver {
case "postgres":
if dbDSN == "" {
logger.Error("SOC_DB_DSN required for postgres driver")
os.Exit(1)
}
pgDB, err := postgres.Open(dbDSN, logger)
if err != nil {
logger.Error("PostgreSQL open failed", "error", err)
os.Exit(1)
}
dbCloser = pgDB.Close
socRepo = postgres.NewSOCRepo(pgDB)
sqlDB = pgDB.Pool() // pass PG pool to auth user/tenant stores
logger.Info("using PostgreSQL backend")
default: // "sqlite"
db, err := sqlite.Open(dbPath)
if err != nil {
logger.Error("database open failed", "path", dbPath, "error", err)
os.Exit(1)
}
dbCloser = db.Close
sqlDB = db // save for auth
repo, err := sqlite.NewSOCRepo(db)
if err != nil {
logger.Error("SOC repo init failed", "error", err)
os.Exit(1)
}
socRepo = repo
logger.Info("using SQLite backend", "path", dbPath)
}
defer dbCloser()
decisionLogger, err := audit.NewDecisionLogger(auditDir)
if err != nil {
logger.Error("decision logger init failed", "error", err)
os.Exit(1)
}
// Service + HTTP
socSvc := soc.NewService(socRepo, decisionLogger)
srv := sochttp.New(socSvc, port)
// Threat Intelligence Store — always initialized for IOC enrichment (§6)
threatIntelStore := soc.NewThreatIntelStore()
threatIntelStore.AddDefaultFeeds()
socSvc.SetThreatIntel(threatIntelStore)
srv.SetThreatIntel(threatIntelStore)
// JWT Authentication (optional — set SOC_JWT_SECRET to enable)
if jwtSecret := env("SOC_JWT_SECRET", ""); jwtSecret != "" {
if db, ok := sqlDB.(*sql.DB); ok {
srv.SetJWTAuth([]byte(jwtSecret), db)
} else {
srv.SetJWTAuth([]byte(jwtSecret))
}
logger.Info("JWT authentication configured")
}
// Email service — Resend (set RESEND_API_KEY to enable real email delivery)
if resendKey := env("RESEND_API_KEY", ""); resendKey != "" {
fromAddr := env("EMAIL_FROM", "SYNTREX <noreply@xn--80akacl3adqr.xn--p1acf>")
resendSender := email.NewResendSender(resendKey, fromAddr)
emailSvc := email.NewService(resendSender, "SYNTREX", fromAddr)
srv.SetEmailService(emailSvc)
logger.Info("email service configured", "provider", "Resend", "from", fromAddr)
} else {
logger.Warn("email service: RESEND_API_KEY not set — verification codes shown in API response (dev mode)")
}
// OpenTelemetry tracing (§P4B) — enabled when OTEL_EXPORTER_OTLP_ENDPOINT is set
otelEndpoint := env("OTEL_EXPORTER_OTLP_ENDPOINT", "")
tp, otelErr := tracing.InitTracer(context.Background(), otelEndpoint)
if otelErr != nil {
logger.Error("tracing init failed", "error", otelErr)
}
// Graceful shutdown via context
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
defer tracing.Shutdown(ctx, tp)
// STIX/TAXII Feed Sync (§P4A) — auto-enabled when OTX key is set
if otxKey := env("SOC_OTX_API_KEY", ""); otxKey != "" {
otxFeed := soc.DefaultOTXFeed(otxKey)
feedSync := soc.NewFeedSync(threatIntelStore, []soc.STIXFeedConfig{otxFeed})
feedSync.Start(ctx.Done())
logger.Info("STIX feed sync started", "feeds", 1, "feed", otxFeed.Name)
}
// Start background retention scheduler (§19)
socSvc.StartRetentionScheduler(ctx, 0) // 0 = default 1 hour
// pprof profiling (§P4C) — enabled by SOC_PPROF=true
if env("SOC_PPROF", "") == "true" {
srv.EnablePprof()
}
logger.Info("server ready", "endpoints", 49, "dashboard_pages", 20)
if err := srv.Start(ctx); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// configureMemorySafety applies SEC-003 runtime hardening:
// - GOMEMLIMIT: soft memory limit (default 450MiB) to avoid OOM kills
// - SOC_GOMAXPROCS: restrict CPU parallelism
// - Logs runtime memory stats at startup for diagnostics.
func configureMemorySafety(logger *slog.Logger) {
// GOMEMLIMIT: set soft memory limit via env var.
// Format: integer bytes, or use Go's debug.SetMemoryLimit default parsing.
if limitStr := os.Getenv("GOMEMLIMIT"); limitStr == "" {
// Default: 450 MiB (90% of typical 512Mi container limit).
const defaultLimit = 450 * 1024 * 1024
debug.SetMemoryLimit(defaultLimit)
logger.Info("SEC-003: GOMEMLIMIT set", "limit_mib", 450, "source", "default")
} else {
// When GOMEMLIMIT env var is set, Go runtime handles it automatically.
logger.Info("SEC-003: GOMEMLIMIT from env", "value", limitStr)
}
// SOC_GOMAXPROCS: optional CPU limit (useful in containers).
if maxProcs := os.Getenv("SOC_GOMAXPROCS"); maxProcs != "" {
if n, err := strconv.Atoi(maxProcs); err == nil && n > 0 {
prev := runtime.GOMAXPROCS(n)
logger.Info("SEC-003: GOMAXPROCS set", "new", n, "previous", prev)
}
}
// Log runtime info for diagnostics.
var m runtime.MemStats
runtime.ReadMemStats(&m)
logger.Info("SEC-003: runtime memory stats",
"go_version", runtime.Version(),
"num_cpu", runtime.NumCPU(),
"gomaxprocs", runtime.GOMAXPROCS(0),
"heap_alloc_mib", m.HeapAlloc/1024/1024,
"sys_mib", m.Sys/1024/1024,
)
}

View file

@ -0,0 +1,13 @@
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /syntrex-proxy ./cmd/syntrex-proxy/
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /syntrex-proxy /usr/local/bin/syntrex-proxy
EXPOSE 8080
ENTRYPOINT ["syntrex-proxy"]
CMD ["--listen", ":8080", "--target", "https://api.openai.com", "--mode", "block"]

233
cmd/syntrex-proxy/main.go Normal file
View file

@ -0,0 +1,233 @@
// syntrex-proxy — transparent reverse proxy that scans LLM prompts.
//
// Usage:
//
// syntrex-proxy \
// --target https://api.openai.com \
// --listen :8080 \
// --soc-url http://localhost:9100 \
// --api-key sk-xxx \
// --mode block
//
// Supported LLM APIs:
// - OpenAI /v1/chat/completions
// - Anthropic /v1/messages
// - Ollama /api/generate, /api/chat
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
type Config struct {
Listen string
Target string
SocURL string
APIKey string
Mode string // "block" or "audit"
Verbose bool
}
// ScanResult from SOC API
type ScanResult struct {
Verdict string `json:"verdict"` // ALLOW, BLOCK, WARN
Score float64 `json:"score"`
Category string `json:"category"`
EnginesTriggered int `json:"engines_triggered"`
}
// extractPrompts extracts user-facing text from various LLM API formats.
func extractPrompts(body []byte, path string) []string {
var prompts []string
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
// OpenAI: /v1/chat/completions → messages[].content
if messages, ok := data["messages"]; ok {
if msgs, ok := messages.([]interface{}); ok {
for _, m := range msgs {
if msg, ok := m.(map[string]interface{}); ok {
if role, _ := msg["role"].(string); role == "user" {
if content, ok := msg["content"].(string); ok && content != "" {
prompts = append(prompts, content)
}
}
}
}
}
}
// Anthropic: /v1/messages → content[].text
if content, ok := data["content"]; ok {
if items, ok := content.([]interface{}); ok {
for _, item := range items {
if c, ok := item.(map[string]interface{}); ok {
if text, ok := c["text"].(string); ok && text != "" {
prompts = append(prompts, text)
}
}
}
}
}
// Ollama: /api/generate → prompt, /api/chat → messages[].content
if prompt, ok := data["prompt"].(string); ok && prompt != "" {
prompts = append(prompts, prompt)
}
// Generic: input, query, text fields
for _, field := range []string{"input", "query", "text", "raw_input"} {
if val, ok := data[field].(string); ok && val != "" {
prompts = append(prompts, val)
}
}
return prompts
}
// scanPrompt sends the prompt to SOC for scanning.
func scanPrompt(socURL, apiKey, prompt string) (*ScanResult, error) {
payload, _ := json.Marshal(map[string]interface{}{
"source": "syntrex-proxy",
"category": "proxy_scan",
"severity": "MEDIUM",
"raw_input": prompt,
})
req, err := http.NewRequest("POST", socURL+"/api/v1/soc/events", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SOC unreachable: %w", err)
}
defer resp.Body.Close()
var result ScanResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// SOC returned event, not scan result — default to ALLOW
return &ScanResult{Verdict: "ALLOW", Score: 0, Category: "safe"}, nil
}
return &result, nil
}
func main() {
cfg := Config{}
flag.StringVar(&cfg.Listen, "listen", ":8080", "Listen address")
flag.StringVar(&cfg.Target, "target", "https://api.openai.com", "Target LLM API URL")
flag.StringVar(&cfg.SocURL, "soc-url", "http://localhost:9100", "SYNTREX SOC API URL")
flag.StringVar(&cfg.APIKey, "api-key", "", "SYNTREX API key")
flag.StringVar(&cfg.Mode, "mode", "block", "Mode: block (reject threats) or audit (log only)")
flag.BoolVar(&cfg.Verbose, "verbose", false, "Verbose logging")
flag.Parse()
targetURL, err := url.Parse(cfg.Target)
if err != nil {
log.Fatalf("Invalid target URL: %v", err)
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = targetURL.Host
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only scan POST requests to known LLM endpoints
if r.Method != "POST" {
proxy.ServeHTTP(w, r)
return
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
proxy.ServeHTTP(w, r)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
// Extract prompts
prompts := extractPrompts(body, r.URL.Path)
if len(prompts) == 0 {
// No prompts found — pass through
proxy.ServeHTTP(w, r)
return
}
combined := strings.Join(prompts, " ")
start := time.Now()
// Scan
result, err := scanPrompt(cfg.SocURL, cfg.APIKey, combined)
scanDuration := time.Since(start)
if err != nil {
log.Printf("[WARN] Scan failed (allowing): %v", err)
proxy.ServeHTTP(w, r)
return
}
if cfg.Verbose {
log.Printf("[SCAN] %s %s → %s (score=%.2f, category=%s, %v)",
r.Method, r.URL.Path, result.Verdict, result.Score, result.Category, scanDuration)
}
// Block mode
if cfg.Mode == "block" && result.Verdict == "BLOCK" {
log.Printf("[BLOCKED] %s %s — %s (score=%.2f, engines=%d)",
r.Method, r.URL.Path, result.Category, result.Score, result.EnginesTriggered)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"message": fmt.Sprintf("Request blocked by SYNTREX Guard: %s (score: %.0f%%)", result.Category, result.Score*100),
"type": "syntrex_guard_block",
"code": "prompt_blocked",
},
})
return
}
// Audit mode or ALLOW — pass through
if result.Verdict != "ALLOW" {
log.Printf("[AUDIT] %s %s — %s (score=%.2f)", r.Method, r.URL.Path, result.Category, result.Score)
}
proxy.ServeHTTP(w, r)
})
log.Printf("🛡️ SYNTREX Proxy starting")
log.Printf(" Listen: %s", cfg.Listen)
log.Printf(" Target: %s", cfg.Target)
log.Printf(" SOC: %s", cfg.SocURL)
log.Printf(" Mode: %s", cfg.Mode)
log.Printf("")
log.Printf(" Usage: set your OpenAI base_url to http://localhost%s", cfg.Listen)
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
log.Fatalf("Server error: %v", err)
}
}