mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-02 15:52:36 +02:00
Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates
This commit is contained in:
parent
694e32be26
commit
41cbfd6e0a
178 changed files with 36008 additions and 399 deletions
|
|
@ -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
189
cmd/immune/main.go
Normal 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
91
cmd/sidecar/main.go
Normal 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
188
cmd/soc-correlate/main.go
Normal 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
130
cmd/soc-ingest/main.go
Normal 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
158
cmd/soc-respond/main.go
Normal 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
229
cmd/soc/main.go
Normal 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,
|
||||
)
|
||||
}
|
||||
|
||||
13
cmd/syntrex-proxy/Dockerfile
Normal file
13
cmd/syntrex-proxy/Dockerfile
Normal 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
233
cmd/syntrex-proxy/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue