mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-08 11:02:37 +02:00
1447 lines
41 KiB
Go
1447 lines
41 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
appsoc "github.com/syntrex/gomcp/internal/application/soc"
|
|
"github.com/syntrex/gomcp/internal/domain/engines"
|
|
domsoc "github.com/syntrex/gomcp/internal/domain/soc"
|
|
"github.com/syntrex/gomcp/internal/infrastructure/auth"
|
|
)
|
|
|
|
// MaxRequestBodySize limits POST body size to prevent OOM (T3-3).
|
|
const MaxRequestBodySize = 1 << 20 // 1 MB
|
|
|
|
// limitBody wraps r.Body with http.MaxBytesReader to enforce size limits.
|
|
func limitBody(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodySize)
|
|
}
|
|
|
|
// handleDashboard returns SOC KPI metrics.
|
|
// GET /api/soc/dashboard
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
dash, err := s.socSvc.Dashboard()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dash)
|
|
}
|
|
|
|
// handleEvents returns recent SOC events with optional limit.
|
|
// GET /api/soc/events?limit=50
|
|
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|
limit := 50 // default
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
// Cap to prevent excessive DB queries via external requests.
|
|
if limit > 10000 {
|
|
limit = 10000
|
|
}
|
|
|
|
events, err := s.socSvc.ListEvents(limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"events": events,
|
|
"count": len(events),
|
|
"limit": limit,
|
|
})
|
|
}
|
|
|
|
// handleIncidents returns SOC incidents with optional status filter and limit.
|
|
// GET /api/soc/incidents?status=open&limit=20
|
|
func (s *Server) handleIncidents(w http.ResponseWriter, r *http.Request) {
|
|
status := r.URL.Query().Get("status")
|
|
limit := 20 // default
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
incidents, err := s.socSvc.ListIncidents(status, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"incidents": incidents,
|
|
"count": len(incidents),
|
|
"status": status,
|
|
"limit": limit,
|
|
})
|
|
}
|
|
|
|
// handleHealth returns a simple health check response.
|
|
// GET /health
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "ok",
|
|
})
|
|
}
|
|
|
|
// handleHealthz is a K8s liveness probe — returns 200 if the server process is alive.
|
|
// GET /healthz
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
}
|
|
|
|
// handleReadyz is a K8s readiness probe — returns 200 when ready to accept traffic,
|
|
// 503 when draining (zero-downtime rolling update, §15.7).
|
|
// GET /readyz
|
|
func (s *Server) handleReadyz(w http.ResponseWriter, _ *http.Request) {
|
|
if s.socSvc.IsDraining() {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte("draining"))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
}
|
|
// handleSensors returns registered sensors with health status.
|
|
// GET /api/soc/sensors
|
|
func (s *Server) handleSensors(w http.ResponseWriter, _ *http.Request) {
|
|
sensors, err := s.socSvc.ListSensors()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"sensors": sensors,
|
|
"count": len(sensors),
|
|
})
|
|
}
|
|
|
|
// handleIncidentDetail returns a single incident by ID.
|
|
// GET /api/soc/incidents/{id}
|
|
func (s *Server) handleIncidentDetail(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing incident ID")
|
|
return
|
|
}
|
|
|
|
incident, err := s.socSvc.GetIncident(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, incident)
|
|
}
|
|
|
|
// handleClusters returns Alert Clustering statistics (§7.6).
|
|
// GET /api/soc/clusters
|
|
func (s *Server) handleClusters(w http.ResponseWriter, _ *http.Request) {
|
|
stats := s.socSvc.ClusterStats()
|
|
writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
// handleRules returns all active correlation rules.
|
|
// GET /api/soc/rules
|
|
func (s *Server) handleRules(w http.ResponseWriter, _ *http.Request) {
|
|
rules := s.socSvc.ListRules()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"rules": rules,
|
|
"count": len(rules),
|
|
})
|
|
}
|
|
|
|
// handleThreatIntel returns IOC database, feeds, and stats (§6).
|
|
// GET /api/soc/threat-intel
|
|
func (s *Server) handleThreatIntel(w http.ResponseWriter, _ *http.Request) {
|
|
ti := s.socSvc.ThreatIntelEngine()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"enabled": true,
|
|
"iocs": ti.ListIOCs(),
|
|
"feeds": ti.ListFeeds(),
|
|
"stats": ti.ThreatIntelStats(),
|
|
"recent_hits": ti.RecentHits(20),
|
|
})
|
|
}
|
|
|
|
// handleWebhookStats returns SOAR webhook delivery statistics.
|
|
// GET /api/soc/webhook-stats
|
|
func (s *Server) handleWebhookStats(w http.ResponseWriter, _ *http.Request) {
|
|
stats := s.socSvc.WebhookStats()
|
|
writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
// handleAnalytics returns SOC analytics report.
|
|
// GET /api/soc/analytics?window=24
|
|
func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
|
|
windowHours := 24 // default
|
|
if v := r.URL.Query().Get("window"); v != "" {
|
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
windowHours = parsed
|
|
}
|
|
}
|
|
|
|
report, err := s.socSvc.Analytics(windowHours)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, report)
|
|
}
|
|
|
|
// handleIngestEvent processes a security event through the full SOC pipeline.
|
|
// POST /api/v1/soc/events
|
|
//
|
|
// Pipeline: Sensor Auth → Secret Scanner → Rate Limit → Decision Logger → Persist → Correlate → Playbook → Webhook
|
|
func (s *Server) handleIngestEvent(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Source string `json:"source"`
|
|
SensorID string `json:"sensor_id"`
|
|
SensorKey string `json:"sensor_key"`
|
|
Severity string `json:"severity"`
|
|
Category string `json:"category"`
|
|
Subcategory string `json:"subcategory"`
|
|
Confidence float64 `json:"confidence"`
|
|
Description string `json:"description"`
|
|
Payload string `json:"payload"`
|
|
SessionID string `json:"session_id"`
|
|
ZeroGMode bool `json:"zero_g_mode"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Validate required fields.
|
|
if req.Source == "" || req.Severity == "" || req.Category == "" || req.Description == "" {
|
|
writeError(w, http.StatusBadRequest, "required fields: source, severity, category, description")
|
|
return
|
|
}
|
|
|
|
// Build domain event.
|
|
event := domsoc.NewSOCEvent(
|
|
domsoc.EventSource(req.Source),
|
|
domsoc.EventSeverity(req.Severity),
|
|
req.Category,
|
|
req.Description,
|
|
)
|
|
event.SensorID = req.SensorID
|
|
if event.SensorID == "" {
|
|
// Auto-assign sensor ID from source name.
|
|
switch req.Source {
|
|
case "sentinel-core":
|
|
event.SensorID = "sensor-core-01"
|
|
case "shield":
|
|
event.SensorID = "sensor-shield-01"
|
|
case "immune":
|
|
event.SensorID = "sensor-immune-01"
|
|
case "micro-swarm":
|
|
event.SensorID = "sensor-swarm-01"
|
|
case "gomcp":
|
|
event.SensorID = "sensor-gomcp-01"
|
|
default:
|
|
event.SensorID = "sensor-ext-01"
|
|
}
|
|
}
|
|
event.SensorKey = req.SensorKey
|
|
event.Subcategory = req.Subcategory
|
|
event.Confidence = req.Confidence
|
|
event.Payload = req.Payload
|
|
event.SessionID = req.SessionID
|
|
event.ZeroGMode = req.ZeroGMode
|
|
event.Metadata = req.Metadata
|
|
|
|
// Run full pipeline.
|
|
eventID, incident, err := s.socSvc.IngestEvent(event)
|
|
if err != nil {
|
|
// Map domain errors to HTTP status codes.
|
|
switch {
|
|
case errors.Is(err, domsoc.ErrInvalidInput):
|
|
// Return 422 with field-level validation details.
|
|
var ve *domsoc.ValidationErrors
|
|
if errors.As(err, &ve) {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
|
"error": err.Error(),
|
|
"fields": ve.Errors,
|
|
})
|
|
} else {
|
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
|
}
|
|
case errors.Is(err, domsoc.ErrDraining):
|
|
writeError(w, http.StatusServiceUnavailable, err.Error())
|
|
case errors.Is(err, domsoc.ErrAuthFailed), errors.Is(err, domsoc.ErrSecretDetected):
|
|
writeError(w, http.StatusForbidden, err.Error())
|
|
case errors.Is(err, domsoc.ErrRateLimited):
|
|
writeError(w, http.StatusTooManyRequests, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"event_id": eventID,
|
|
"status": "ingested",
|
|
}
|
|
if incident != nil {
|
|
resp["incident"] = incident
|
|
resp["status"] = "ingested_with_incident"
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// MaxBatchSize limits the number of events in a single batch request (§5.3).
|
|
const MaxBatchSize = 1000
|
|
|
|
// handleBatchIngest processes multiple security events through the SOC pipeline (§5.3).
|
|
// POST /api/v1/soc/events/batch
|
|
func (s *Server) handleBatchIngest(w http.ResponseWriter, r *http.Request) {
|
|
var events []struct {
|
|
Source string `json:"source"`
|
|
SensorID string `json:"sensor_id"`
|
|
SensorKey string `json:"sensor_key"`
|
|
Severity string `json:"severity"`
|
|
Category string `json:"category"`
|
|
Subcategory string `json:"subcategory"`
|
|
Confidence float64 `json:"confidence"`
|
|
Description string `json:"description"`
|
|
Payload string `json:"payload"`
|
|
SessionID string `json:"session_id"`
|
|
ZeroGMode bool `json:"zero_g_mode"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
limitBody(w, r)
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&events); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON array: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
writeError(w, http.StatusBadRequest, "empty batch")
|
|
return
|
|
}
|
|
if len(events) > MaxBatchSize {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("batch size %d exceeds max %d", len(events), MaxBatchSize))
|
|
return
|
|
}
|
|
|
|
type batchResult struct {
|
|
Index int `json:"index"`
|
|
EventID string `json:"event_id,omitempty"`
|
|
Status string `json:"status"`
|
|
Incident any `json:"incident,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
results := make([]batchResult, len(events))
|
|
ingested := 0
|
|
|
|
for i, req := range events {
|
|
event := domsoc.NewSOCEvent(
|
|
domsoc.EventSource(req.Source),
|
|
domsoc.EventSeverity(req.Severity),
|
|
req.Category,
|
|
req.Description,
|
|
)
|
|
event.SensorID = req.SensorID
|
|
event.SensorKey = req.SensorKey
|
|
event.Subcategory = req.Subcategory
|
|
event.Confidence = req.Confidence
|
|
event.Payload = req.Payload
|
|
event.SessionID = req.SessionID
|
|
event.ZeroGMode = req.ZeroGMode
|
|
event.Metadata = req.Metadata
|
|
|
|
eventID, incident, err := s.socSvc.IngestEvent(event)
|
|
if err != nil {
|
|
results[i] = batchResult{Index: i, Status: "rejected", Error: err.Error()}
|
|
continue
|
|
}
|
|
|
|
result := batchResult{Index: i, EventID: eventID, Status: "ingested"}
|
|
if incident != nil {
|
|
result.Status = "ingested_with_incident"
|
|
result.Incident = incident
|
|
}
|
|
results[i] = result
|
|
ingested++
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"total": len(events),
|
|
"ingested": ingested,
|
|
"rejected": len(events) - ingested,
|
|
"results": results,
|
|
})
|
|
}
|
|
// handleSensorHeartbeat records a sensor heartbeat (§11.3).
|
|
// POST /api/soc/sensors/heartbeat
|
|
func (s *Server) handleSensorHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
SensorID string `json:"sensor_id"`
|
|
}
|
|
limitBody(w, r)
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if req.SensorID == "" {
|
|
writeError(w, http.StatusBadRequest, "required field: sensor_id")
|
|
return
|
|
}
|
|
|
|
ok, err := s.socSvc.RecordHeartbeat(req.SensorID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"sensor_id": req.SensorID,
|
|
"recorded": ok,
|
|
})
|
|
}
|
|
|
|
// handleSSEStream provides Server-Sent Events for real-time event streaming.
|
|
// GET /api/soc/stream
|
|
func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "SSE not supported")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
// Note: CORS is already handled by corsMiddleware — no need to set it here.
|
|
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx/proxy buffering
|
|
|
|
// Explicitly write status and flush headers so EventSource.onopen fires immediately.
|
|
w.WriteHeader(http.StatusOK)
|
|
flusher.Flush()
|
|
|
|
// Subscribe to event bus
|
|
subID := fmt.Sprintf("sse-%d", time.Now().UnixNano())
|
|
ch := s.socSvc.EventBus().Subscribe(subID)
|
|
defer s.socSvc.EventBus().Unsubscribe(subID)
|
|
|
|
// Send initial comment to establish connection
|
|
fmt.Fprintf(w, ": connected to syntrex event stream\n\n")
|
|
flusher.Flush()
|
|
|
|
// Keepalive ticker
|
|
ticker := time.NewTicker(15 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
data, _ := json.Marshal(event)
|
|
fmt.Fprintf(w, "event: soc_event\ndata: %s\n\n", data)
|
|
flusher.Flush()
|
|
|
|
case <-ticker.C:
|
|
fmt.Fprintf(w, ": keepalive\n\n")
|
|
flusher.Flush()
|
|
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleKillChain reconstructs the Kill Chain for an incident (§8).
|
|
// GET /api/soc/killchain/{id}
|
|
func (s *Server) handleKillChain(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing incident ID")
|
|
return
|
|
}
|
|
|
|
kc, err := s.socSvc.GetKillChain(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, kc)
|
|
}
|
|
|
|
// handleAuditTrail returns decision log entries for forensic review (§9).
|
|
// GET /api/soc/audit
|
|
func (s *Server) handleAuditTrail(w http.ResponseWriter, r *http.Request) {
|
|
// The decision logger stores entries in the audit database.
|
|
// For now, return basic audit metadata from service.
|
|
result := map[string]any{
|
|
"status": "operational",
|
|
"message": "Audit trail available via decision logger",
|
|
}
|
|
|
|
// Add recent decisions if available via service
|
|
decisions := s.socSvc.GetRecentDecisions(50)
|
|
result["decisions"] = decisions
|
|
result["total"] = len(decisions)
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// handleListKeys returns registered RBAC API keys (masked) for admin review (§17).
|
|
// GET /api/soc/keys
|
|
func (s *Server) handleListKeys(w http.ResponseWriter, r *http.Request) {
|
|
keys := s.rbac.ListKeys()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"keys": keys,
|
|
"total": len(keys),
|
|
})
|
|
}
|
|
|
|
// handleZeroGStatus returns Zero-G mode status and pending requests (§13.4).
|
|
// GET /api/soc/zerog
|
|
func (s *Server) handleZeroGStatus(w http.ResponseWriter, r *http.Request) {
|
|
zg := s.socSvc.ZeroG()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"stats": zg.Stats(),
|
|
"pending": zg.PendingRequests(),
|
|
})
|
|
}
|
|
|
|
// handleZeroGToggle enables or disables Zero-G mode (§13.4).
|
|
// POST /api/soc/zerog/toggle body: {"enabled": true}
|
|
func (s *Server) handleZeroGToggle(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
|
|
zg := s.socSvc.ZeroG()
|
|
if req.Enabled {
|
|
zg.Enable()
|
|
} else {
|
|
zg.Disable()
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"zero_g_enabled": zg.IsEnabled(),
|
|
})
|
|
}
|
|
|
|
// handleZeroGResolve processes an analyst verdict on a pending Zero-G request (§13.4).
|
|
// POST /api/soc/zerog/resolve body: {"request_id": "zg-...", "verdict": "APPROVE", "analyst": "admin"}
|
|
func (s *Server) handleZeroGResolve(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
RequestID string `json:"request_id"`
|
|
Verdict string `json:"verdict"`
|
|
Analyst string `json:"analyst"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.RequestID == "" || req.Verdict == "" {
|
|
writeError(w, http.StatusBadRequest, "request_id and verdict required")
|
|
return
|
|
}
|
|
|
|
zg := s.socSvc.ZeroG()
|
|
err := zg.Resolve(req.RequestID, domsoc.ZeroGVerdict(req.Verdict), req.Analyst)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
|
}
|
|
|
|
// handleVerdict updates an incident's status (manual analyst verdict).
|
|
// POST /api/soc/incidents/{id}/verdict body: {"status": "RESOLVED"}
|
|
func (s *Server) handleVerdict(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.Status == "" {
|
|
writeError(w, http.StatusBadRequest, "status required (INVESTIGATING, RESOLVED)")
|
|
return
|
|
}
|
|
|
|
err := s.socSvc.UpdateVerdict(id, domsoc.IncidentStatus(req.Status))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"incident_id": id,
|
|
"status": req.Status,
|
|
})
|
|
}
|
|
|
|
// === Case Management Endpoints ===
|
|
|
|
// POST /api/soc/incidents/{id}/assign body: {"analyst": "john.doe"}
|
|
func (s *Server) handleIncidentAssign(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Analyst string `json:"analyst"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.Analyst == "" {
|
|
writeError(w, http.StatusBadRequest, "analyst name required")
|
|
return
|
|
}
|
|
|
|
if err := s.socSvc.AssignIncident(id, req.Analyst); err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"incident_id": id,
|
|
"assigned_to": req.Analyst,
|
|
"status": "assigned",
|
|
})
|
|
}
|
|
|
|
// POST /api/soc/incidents/{id}/status body: {"status": "INVESTIGATING", "actor": "john.doe"}
|
|
func (s *Server) handleIncidentStatus(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
Actor string `json:"actor"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.Status == "" {
|
|
writeError(w, http.StatusBadRequest, "status required")
|
|
return
|
|
}
|
|
if req.Actor == "" {
|
|
req.Actor = "system"
|
|
}
|
|
|
|
// Validate status
|
|
validStatuses := map[string]bool{
|
|
"OPEN": true, "INVESTIGATING": true, "RESOLVED": true, "FALSE_POSITIVE": true,
|
|
}
|
|
if !validStatuses[req.Status] {
|
|
writeError(w, http.StatusBadRequest, "invalid status (OPEN, INVESTIGATING, RESOLVED, FALSE_POSITIVE)")
|
|
return
|
|
}
|
|
|
|
if err := s.socSvc.ChangeIncidentStatus(id, domsoc.IncidentStatus(req.Status), req.Actor); err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"incident_id": id,
|
|
"status": req.Status,
|
|
"actor": req.Actor,
|
|
})
|
|
}
|
|
|
|
// POST /api/soc/incidents/{id}/notes body: {"author": "john.doe", "content": "Found C2 callback"}
|
|
// GET /api/soc/incidents/{id}/notes → returns notes array
|
|
func (s *Server) handleIncidentNotes(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
inc, err := s.socSvc.GetIncidentDetail(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"incident_id": id,
|
|
"notes": inc.Notes,
|
|
"count": len(inc.Notes),
|
|
})
|
|
return
|
|
}
|
|
|
|
// POST — add note
|
|
var req struct {
|
|
Author string `json:"author"`
|
|
Content string `json:"content"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content required")
|
|
return
|
|
}
|
|
if req.Author == "" {
|
|
req.Author = "analyst"
|
|
}
|
|
|
|
note, err := s.socSvc.AddIncidentNote(id, req.Author, req.Content)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, note)
|
|
}
|
|
|
|
// GET /api/soc/incidents/{id}/timeline → full incident timeline
|
|
func (s *Server) handleIncidentTimeline(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
inc, err := s.socSvc.GetIncidentDetail(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"incident_id": id,
|
|
"timeline": inc.Timeline,
|
|
"count": len(inc.Timeline),
|
|
"status": inc.Status,
|
|
"assigned_to": inc.AssignedTo,
|
|
"severity": inc.Severity,
|
|
})
|
|
}
|
|
|
|
// GET /api/soc/incidents/{id}/detail → full incident with notes + timeline
|
|
func (s *Server) handleIncidentFullDetail(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
inc, err := s.socSvc.GetIncidentDetail(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, inc)
|
|
}
|
|
|
|
|
|
// === Webhook Management Endpoints (SOAR §15) ===
|
|
|
|
// GET /api/soc/webhooks → returns webhook config + delivery stats
|
|
func (s *Server) handleWebhooksGet(w http.ResponseWriter, r *http.Request) {
|
|
stats := s.socSvc.WebhookStats()
|
|
config := s.socSvc.GetWebhookConfig()
|
|
|
|
result := map[string]any{
|
|
"stats": stats,
|
|
}
|
|
if config != nil {
|
|
result["config"] = config
|
|
} else {
|
|
result["config"] = map[string]any{
|
|
"endpoints": []string{},
|
|
"headers": map[string]string{},
|
|
"max_retries": 3,
|
|
"timeout_sec": 10,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// POST /api/soc/webhooks → configure webhook endpoints
|
|
// body: {"endpoints": ["https://hooks.slack.com/..."], "headers": {"Authorization": "Bearer xyz"}, "max_retries": 3}
|
|
func (s *Server) handleWebhooksSet(w http.ResponseWriter, r *http.Request) {
|
|
var config appsoc.WebhookConfig
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if config.MaxRetries <= 0 {
|
|
config.MaxRetries = 3
|
|
}
|
|
if config.TimeoutSec <= 0 {
|
|
config.TimeoutSec = 10
|
|
}
|
|
|
|
s.socSvc.SetWebhookConfig(config)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "configured",
|
|
"endpoints": len(config.Endpoints),
|
|
"retries": config.MaxRetries,
|
|
})
|
|
}
|
|
|
|
// POST /api/soc/webhooks/test → send test ping to all endpoints
|
|
func (s *Server) handleWebhooksTest(w http.ResponseWriter, r *http.Request) {
|
|
results := s.socSvc.TestWebhook()
|
|
if results == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "no_webhooks",
|
|
"message": "No webhook endpoints configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "tested",
|
|
"results": results,
|
|
})
|
|
}
|
|
|
|
// handleSensorRegister registers a new sensor with the SOC.
|
|
// POST /api/soc/sensors/register body: {"id":"s-1","name":"Shield-1","type":"shield"}
|
|
func (s *Server) handleSensorRegister(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.ID == "" || req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "id and name required")
|
|
return
|
|
}
|
|
|
|
s.socSvc.RegisterSensor(req.ID, req.Name, req.Type)
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{
|
|
"sensor_id": req.ID,
|
|
"status": "registered",
|
|
})
|
|
}
|
|
|
|
// handleSensorDelete removes a sensor from the SOC.
|
|
// DELETE /api/soc/sensors/{id}
|
|
func (s *Server) handleSensorDelete(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "sensor ID required")
|
|
return
|
|
}
|
|
|
|
s.socSvc.DeregisterSensor(id)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"sensor_id": id,
|
|
"status": "deregistered",
|
|
})
|
|
}
|
|
|
|
// handleRateLimitStats returns rate limiter statistics.
|
|
// GET /api/soc/ratelimit
|
|
func (s *Server) handleRateLimitStats(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.rateLimiter.Stats())
|
|
}
|
|
|
|
// handleP2PPeers returns all P2P SOC peers and sync stats (§14).
|
|
// GET /api/soc/p2p/peers
|
|
func (s *Server) handleP2PPeers(w http.ResponseWriter, r *http.Request) {
|
|
p2p := s.socSvc.P2PSync()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"peers": p2p.ListPeers(),
|
|
"stats": p2p.Stats(),
|
|
})
|
|
}
|
|
|
|
// handleP2PAddPeer registers a new SOC peer for synchronization (§14).
|
|
// POST /api/soc/p2p/peers body: {"id":"soc-2","name":"Site-B","endpoint":"http://b:9100","trust":"full"}
|
|
func (s *Server) handleP2PAddPeer(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Endpoint string `json:"endpoint"`
|
|
Trust string `json:"trust"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.ID == "" || req.Endpoint == "" {
|
|
writeError(w, http.StatusBadRequest, "id and endpoint required")
|
|
return
|
|
}
|
|
if req.Trust == "" {
|
|
req.Trust = "readonly"
|
|
}
|
|
|
|
s.socSvc.P2PSync().AddPeer(req.ID, req.Name, req.Endpoint, req.Trust)
|
|
writeJSON(w, http.StatusCreated, map[string]string{
|
|
"peer_id": req.ID,
|
|
"status": "registered",
|
|
})
|
|
}
|
|
|
|
// handleP2PRemovePeer deregisters a SOC peer (§14).
|
|
// DELETE /api/soc/p2p/peers/{id}
|
|
func (s *Server) handleP2PRemovePeer(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "peer ID required")
|
|
return
|
|
}
|
|
s.socSvc.P2PSync().RemovePeer(id)
|
|
writeJSON(w, http.StatusOK, map[string]string{"peer_id": id, "status": "removed"})
|
|
}
|
|
|
|
// handleEngineStatus returns status of security engines (§3, §4).
|
|
// GET /api/soc/engines
|
|
func (s *Server) handleEngineStatus(w http.ResponseWriter, r *http.Request) {
|
|
coreEngine := s.getEngine("sentinel-core")
|
|
shieldEngine := s.getEngine("shield")
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"engines": []map[string]any{
|
|
{
|
|
"name": coreEngine.Name(),
|
|
"status": coreEngine.Status(),
|
|
"version": coreEngine.Version(),
|
|
"type": "prompt_scanner",
|
|
},
|
|
{
|
|
"name": shieldEngine.Name(),
|
|
"status": shieldEngine.Status(),
|
|
"version": shieldEngine.Version(),
|
|
"type": "network_protection",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// getEngine returns the named engine or a stub.
|
|
func (s *Server) getEngine(name string) engines.SentinelCore {
|
|
if s.sentinelCore != nil && name == "sentinel-core" {
|
|
return s.sentinelCore
|
|
}
|
|
return engines.NewStubSentinelCore()
|
|
}
|
|
|
|
// handleSovereignConfig returns the Sovereign Mode configuration (§21).
|
|
// GET /api/soc/sovereign
|
|
func (s *Server) handleSovereignConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"sovereign": map[string]any{
|
|
"enabled": s.sovereignEnabled,
|
|
"mode": s.sovereignMode,
|
|
"air_gapped": s.sovereignMode == "airgap",
|
|
"external_api": !s.sovereignEnabled,
|
|
"local_only": s.sovereignMode == "airgap",
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAnomalyAlerts returns recent anomaly alerts (§5).
|
|
// GET /api/soc/anomaly/alerts
|
|
func (s *Server) handleAnomalyAlerts(w http.ResponseWriter, r *http.Request) {
|
|
limit := 50
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
detector := s.socSvc.AnomalyDetector()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"alerts": detector.Alerts(limit),
|
|
"stats": detector.Stats(),
|
|
})
|
|
}
|
|
|
|
// handleAnomalyBaselines returns tracked metric baselines (§5).
|
|
// GET /api/soc/anomaly/baselines
|
|
func (s *Server) handleAnomalyBaselines(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"baselines": s.socSvc.AnomalyDetector().Baselines(),
|
|
})
|
|
}
|
|
|
|
// handlePlaybooks returns all playbooks and execution stats (§10).
|
|
// GET /api/soc/playbooks
|
|
func (s *Server) handlePlaybooks(w http.ResponseWriter, r *http.Request) {
|
|
pe := s.socSvc.PlaybookEngine()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"playbooks": pe.ListPlaybooks(),
|
|
"stats": pe.PlaybookStats(),
|
|
"log": pe.ExecutionLog(20),
|
|
})
|
|
}
|
|
|
|
// handleDeepHealth returns deep system health across all components.
|
|
// GET /api/soc/health/deep
|
|
func (s *Server) handleDeepHealth(w http.ResponseWriter, r *http.Request) {
|
|
overallStatus := "HEALTHY"
|
|
|
|
// Anomaly detector
|
|
anomalyStats := s.socSvc.AnomalyDetector().Stats()
|
|
|
|
// Playbook engine
|
|
pbStats := s.socSvc.PlaybookEngine().PlaybookStats()
|
|
|
|
// P2P Sync
|
|
p2pStats := s.socSvc.P2PSync().Stats()
|
|
|
|
// Engine status
|
|
engineStatus := "stub"
|
|
if s.sentinelCore != nil {
|
|
st := s.sentinelCore.Status()
|
|
engineStatus = string(st)
|
|
if st == engines.EngineDegraded {
|
|
overallStatus = "DEGRADED"
|
|
}
|
|
}
|
|
|
|
// Check for critical anomalies
|
|
if alerts := s.socSvc.AnomalyDetector().Alerts(5); len(alerts) > 0 {
|
|
for _, a := range alerts {
|
|
if a.Severity == "CRITICAL" {
|
|
overallStatus = "DEGRADED"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": overallStatus,
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
"components": map[string]any{
|
|
"database": "HEALTHY",
|
|
"correlation": "HEALTHY",
|
|
"anomaly_detector": anomalyStats,
|
|
"playbook_engine": pbStats,
|
|
"p2p_sync": p2pStats,
|
|
"sentinel_core": engineStatus,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleComplianceReport returns EU AI Act Article 15 compliance summary (§12.3).
|
|
// GET /api/soc/compliance
|
|
func (s *Server) handleComplianceReport(w http.ResponseWriter, r *http.Request) {
|
|
dash, err := s.socSvc.Dashboard()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "compliance: dashboard unavailable: "+err.Error())
|
|
return
|
|
}
|
|
anomalyStats := s.socSvc.AnomalyDetector().Stats()
|
|
pbStats := s.socSvc.PlaybookEngine().PlaybookStats()
|
|
|
|
// Dynamic status checks based on live state
|
|
riskStatus := "COMPLIANT"
|
|
riskEvidence := []string{"Correlation rules loaded", "Kill chain reconstruction available"}
|
|
metricsTracked, _ := anomalyStats["metrics_tracked"].(int)
|
|
if metricsTracked > 0 {
|
|
riskEvidence = append(riskEvidence, fmt.Sprintf("Anomaly detection active: %d metrics", metricsTracked))
|
|
} else {
|
|
riskStatus = "PARTIAL"
|
|
riskEvidence = append(riskEvidence, "Anomaly detection: no metrics tracked yet")
|
|
}
|
|
|
|
accuracyStatus := "COMPLIANT"
|
|
if !dash.ChainValid {
|
|
accuracyStatus = "NON-COMPLIANT"
|
|
}
|
|
|
|
humanStatus := "COMPLIANT"
|
|
humanEvidence := []string{"RBAC with 5 roles", "Zero-G mode requires human approval"}
|
|
humanEvidence = append(humanEvidence, fmt.Sprintf("%d open incidents under analyst review", dash.OpenIncidents))
|
|
|
|
pbEnabled, _ := pbStats["enabled"].(int)
|
|
dataGovEvidence := []string{"Decision chain integrity verified", "Audit trail enabled"}
|
|
if pbEnabled > 0 {
|
|
dataGovEvidence = append(dataGovEvidence, fmt.Sprintf("Playbook engine: %d active playbooks", pbEnabled))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"framework": "EU AI Act Article 15",
|
|
"generated_at": time.Now().Format(time.RFC3339),
|
|
"requirements": []map[string]any{
|
|
{
|
|
"id": "Art15.1",
|
|
"title": "Risk Management System",
|
|
"status": riskStatus,
|
|
"evidence": riskEvidence,
|
|
},
|
|
{
|
|
"id": "Art15.2",
|
|
"title": "Data Governance",
|
|
"status": "COMPLIANT",
|
|
"evidence": dataGovEvidence,
|
|
},
|
|
{
|
|
"id": "Art15.3",
|
|
"title": "Technical Documentation",
|
|
"status": "COMPLIANT",
|
|
"evidence": []string{"API documentation available", "Dashboard operational"},
|
|
},
|
|
{
|
|
"id": "Art15.4",
|
|
"title": "Human Oversight",
|
|
"status": humanStatus,
|
|
"evidence": humanEvidence,
|
|
},
|
|
{
|
|
"id": "Art15.5",
|
|
"title": "Accuracy & Robustness",
|
|
"status": accuracyStatus,
|
|
"evidence": []string{fmt.Sprintf("Decision chain valid: %v", dash.ChainValid), fmt.Sprintf("Correlation rules: %d", dash.CorrelationRules)},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAuditTrailPage returns decision chain entries for the audit page.
|
|
// GET /api/soc/audit?limit=100
|
|
func (s *Server) handleAuditTrailPage(w http.ResponseWriter, r *http.Request) {
|
|
limit := 100
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
events, _ := s.socSvc.ListEvents(limit)
|
|
incidents, _ := s.socSvc.ListIncidents("", 50)
|
|
|
|
// Build audit entries from events
|
|
entries := make([]map[string]any, 0, len(events))
|
|
for _, e := range events {
|
|
entries = append(entries, map[string]any{
|
|
"timestamp": e.Timestamp.Format(time.RFC3339),
|
|
"type": "event",
|
|
"source": e.Source,
|
|
"severity": e.Severity,
|
|
"category": e.Category,
|
|
"verdict": e.Verdict,
|
|
"id": e.ID,
|
|
})
|
|
}
|
|
for _, inc := range incidents {
|
|
entries = append(entries, map[string]any{
|
|
"timestamp": inc.CreatedAt.Format(time.RFC3339),
|
|
"type": "incident",
|
|
"severity": inc.Severity,
|
|
"status": inc.Status,
|
|
"title": inc.Title,
|
|
"id": inc.ID,
|
|
"chain_hash": inc.DecisionChainAnchor,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"entries": entries,
|
|
"total": len(entries),
|
|
})
|
|
}
|
|
|
|
// handleThreatIntelMatch checks a value against the IOC database (§6).
|
|
// POST /api/soc/threat-intel/match
|
|
func (s *Server) handleThreatIntelMatch(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Value string `json:"value"`
|
|
EventID string `json:"event_id"`
|
|
}
|
|
limitBody(w, r)
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
ti := s.socSvc.ThreatIntelEngine()
|
|
if req.EventID != "" {
|
|
hits := ti.MatchEvent(req.EventID, req.Value)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"hits": hits,
|
|
})
|
|
return
|
|
}
|
|
ioc := ti.Match(req.Value)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"match": ioc,
|
|
})
|
|
}
|
|
|
|
// handleRetentionPolicies returns data retention policies and stats (§19).
|
|
// GET /api/soc/retention
|
|
func (s *Server) handleRetentionPolicies(w http.ResponseWriter, r *http.Request) {
|
|
rp := s.socSvc.RetentionPolicy()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"policies": rp.ListPolicies(),
|
|
"stats": rp.RetentionStats(),
|
|
})
|
|
}
|
|
|
|
// handleIncidentExplain returns human-readable explanation of an incident (§12.3 EU AI Act Art.15).
|
|
// GET /api/soc/incidents/{id}/explain
|
|
func (s *Server) handleIncidentExplain(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing incident ID")
|
|
return
|
|
}
|
|
|
|
incident, err := s.socSvc.GetIncident(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
// Build human-readable explanation
|
|
explanation := map[string]any{
|
|
"incident_id": incident.ID,
|
|
"summary": fmt.Sprintf("Incident '%s' (%s severity) was created by correlation rule '%s'.", incident.Title, incident.Severity, incident.CorrelationRule),
|
|
"trigger": map[string]any{
|
|
"rule_id": incident.CorrelationRule,
|
|
"severity": incident.Severity,
|
|
"created_at": incident.CreatedAt.Format(time.RFC3339),
|
|
},
|
|
"kill_chain": map[string]any{
|
|
"phase": incident.KillChainPhase,
|
|
"mitre_ids": incident.MITREMapping,
|
|
"description": fmt.Sprintf("This incident is classified in the '%s' phase of the Cyber Kill Chain.", incident.KillChainPhase),
|
|
},
|
|
"evidence": map[string]any{
|
|
"event_count": len(incident.Events),
|
|
"event_ids": incident.Events,
|
|
"decision_chain": incident.DecisionChainAnchor,
|
|
},
|
|
"response": map[string]any{
|
|
"playbook_applied": incident.PlaybookApplied,
|
|
"status": incident.Status,
|
|
},
|
|
"explainability_note": "This explanation is auto-generated from correlation rules and event metadata. For detailed rule logic, see /api/soc/rules.",
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, explanation)
|
|
}
|
|
|
|
// ── Sprint 2: Incident Management Enhancements ─────────────────────────
|
|
|
|
// handleIncidentsAdvanced returns filtered, paginated incidents.
|
|
// GET /api/soc/incidents/advanced?status=OPEN&severity=HIGH&assigned_to=&search=&page=1&limit=20&sort_by=created_at&sort_order=desc
|
|
func (s *Server) handleIncidentsAdvanced(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
page, _ := strconv.Atoi(q.Get("page"))
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
filter := appsoc.IncidentFilter{
|
|
Status: q.Get("status"),
|
|
Severity: q.Get("severity"),
|
|
AssignedTo: q.Get("assigned_to"),
|
|
Search: q.Get("search"),
|
|
Source: q.Get("source"),
|
|
DateFrom: q.Get("date_from"),
|
|
DateTo: q.Get("date_to"),
|
|
Page: page,
|
|
Limit: limit,
|
|
SortBy: q.Get("sort_by"),
|
|
SortOrder: q.Get("sort_order"),
|
|
}
|
|
|
|
result, err := s.socSvc.ListIncidentsAdvanced(filter)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Enrich with SLA status
|
|
type incidentWithSLA struct {
|
|
domsoc.Incident
|
|
SLA *appsoc.SLAStatus `json:"sla,omitempty"`
|
|
}
|
|
enriched := make([]incidentWithSLA, len(result.Incidents))
|
|
for i, inc := range result.Incidents {
|
|
enriched[i] = incidentWithSLA{Incident: inc, SLA: appsoc.CalculateSLA(&inc)}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"incidents": enriched,
|
|
"total": result.Total,
|
|
"page": result.Page,
|
|
"limit": result.Limit,
|
|
"total_pages": result.TotalPages,
|
|
})
|
|
}
|
|
|
|
// handleIncidentsBulk performs batch operations on incidents.
|
|
// POST /api/soc/incidents/bulk
|
|
func (s *Server) handleIncidentsBulk(w http.ResponseWriter, r *http.Request) {
|
|
limitBody(w, r)
|
|
var action appsoc.BulkAction
|
|
if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(action.IncidentIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "incident_ids required")
|
|
return
|
|
}
|
|
if action.Action == "" {
|
|
writeError(w, http.StatusBadRequest, "action required (assign, status, close)")
|
|
return
|
|
}
|
|
|
|
// Get actor from JWT claims
|
|
if claims := auth.GetClaims(r.Context()); claims != nil {
|
|
action.Actor = claims.Sub
|
|
}
|
|
|
|
result, err := s.socSvc.BulkUpdateIncidents(action)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// handleIncidentsExport exports incidents as CSV or JSON.
|
|
// GET /api/soc/incidents/export?format=csv&status=OPEN&severity=HIGH
|
|
func (s *Server) handleIncidentsExport(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
format := q.Get("format")
|
|
if format == "" {
|
|
format = "csv"
|
|
}
|
|
|
|
filter := appsoc.IncidentFilter{
|
|
Status: q.Get("status"),
|
|
Severity: q.Get("severity"),
|
|
Limit: 10000, // export all matching
|
|
}
|
|
|
|
switch format {
|
|
case "csv":
|
|
data, err := s.socSvc.ExportIncidentsCSV(filter)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=incidents.csv")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(data)
|
|
case "json":
|
|
result, err := s.socSvc.ListIncidentsAdvanced(filter)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Disposition", "attachment; filename=incidents.json")
|
|
writeJSON(w, http.StatusOK, result)
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "format must be csv or json")
|
|
}
|
|
}
|
|
|
|
// handleIncidentSLA returns SLA status for a specific incident.
|
|
// GET /api/soc/incidents/{id}/sla
|
|
func (s *Server) handleIncidentSLA(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "incident ID required")
|
|
return
|
|
}
|
|
|
|
inc, err := s.socSvc.GetIncident(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "incident not found")
|
|
return
|
|
}
|
|
|
|
sla := appsoc.CalculateSLA(inc)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"incident_id": id,
|
|
"severity": inc.Severity,
|
|
"sla": sla,
|
|
})
|
|
}
|
|
|
|
// handleSLAConfig returns SLA threshold configuration.
|
|
// GET /api/soc/sla-config
|
|
func (s *Server) handleSLAConfig(w http.ResponseWriter, _ *http.Request) {
|
|
thresholds := appsoc.DefaultSLAThresholds()
|
|
type slaEntry struct {
|
|
Severity string `json:"severity"`
|
|
ResponseMin float64 `json:"response_time_min"`
|
|
ResolutionMin float64 `json:"resolution_time_min"`
|
|
}
|
|
entries := make([]slaEntry, 0, len(thresholds))
|
|
for _, t := range thresholds {
|
|
entries = append(entries, slaEntry{
|
|
Severity: t.Severity,
|
|
ResponseMin: t.ResponseTime.Minutes(),
|
|
ResolutionMin: t.ResolutionTime.Minutes(),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"sla_thresholds": entries,
|
|
})
|
|
}
|