Skip to content

Commit 0cb33f5

Browse files
feat[backend](threat_intelligense): added custommer manager connectio… (#2339)
* feat[backend](threat_intelligense): added custommer manager connection module and threat intelligence proxy module * fix[backend](threat_intelligense): added header injection protection and sanitized id on proxy requests
1 parent b41cdef commit 0cb33f5

7 files changed

Lines changed: 333 additions & 0 deletions

File tree

backend/modules.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
opensearchgw "github.com/utmstack/utmstack/backend/modules/opensearch"
3838
"github.com/utmstack/utmstack/backend/modules/soar"
3939
"github.com/utmstack/utmstack/backend/modules/socai"
40+
"github.com/utmstack/utmstack/backend/modules/threatintel"
4041
"github.com/utmstack/utmstack/backend/pkg/agentmanager"
4142
"github.com/utmstack/utmstack/backend/pkg/env"
4243
jwtpkg "github.com/utmstack/utmstack/backend/pkg/jwt"
@@ -74,6 +75,7 @@ type modules struct {
7475
notifications *notifications.Module
7576
socAI *socai.Module
7677
adaudit *adaudit.Module
78+
threatIntel *threatintel.Module
7779
mcp *mcpmod.Module
7880
signer *jwtpkg.Signer
7981
}
@@ -191,6 +193,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
191193
auditMod.Logger(),
192194
)
193195
adauditMod := adaudit.NewModule(db)
196+
threatintelMod := threatintel.NewModule(env.String("UPDATES_DIR", "/updates", false))
194197

195198
//loaded after opensearch has fully loaded
196199
integrationsMod := integrations.NewModule(db, cipher,
@@ -244,6 +247,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
244247
incidents: incidentsMod,
245248
notifications: notificationsMod,
246249
adaudit: adauditMod,
250+
threatIntel: threatintelMod,
247251
mcp: mcpModule,
248252
signer: signer,
249253
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package handler
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/http/httputil"
8+
"net/textproto"
9+
"net/url"
10+
"time"
11+
12+
"github.com/gin-gonic/gin"
13+
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
14+
)
15+
16+
// ReverseProxyHandler creates a reverse proxy that maps a local route to a CM proxy target.
17+
type ReverseProxyHandler struct {
18+
targetPath string
19+
}
20+
21+
// NewReverseProxyHandler creates a handler for a specific route mapping.
22+
func NewReverseProxyHandler(targetPath string) *ReverseProxyHandler {
23+
return &ReverseProxyHandler{targetPath: targetPath}
24+
}
25+
26+
// Handle is the Gin handler that proxies the request.
27+
func (h *ReverseProxyHandler) Handle(c *gin.Context) {
28+
cfg := internal.Get()
29+
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
30+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
31+
return
32+
}
33+
34+
targetURL, err := url.Parse(cfg.Server)
35+
if err != nil {
36+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "invalid CM server URL"})
37+
return
38+
}
39+
40+
// Create reverse proxy with custom director
41+
proxy := &httputil.ReverseProxy{
42+
Director: h.directorFunc(cfg, targetURL),
43+
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
44+
w.Header().Set("Content-Type", "application/json")
45+
w.WriteHeader(http.StatusBadGateway)
46+
w.Write([]byte(`{"error":"upstream service error"}`))
47+
},
48+
}
49+
50+
proxy.ServeHTTP(c.Writer, c.Request)
51+
}
52+
53+
// directorFunc returns a Director function that rewrites the request.
54+
func (h *ReverseProxyHandler) directorFunc(cfg *internal.InstanceConfig, targetURL *url.URL) func(*http.Request) {
55+
return func(req *http.Request) {
56+
// Set scheme and host from CM proxy
57+
req.URL.Scheme = targetURL.Scheme
58+
req.URL.Host = targetURL.Host
59+
60+
// Rewrite path to the target on CM proxy
61+
req.URL.Path = h.targetPath
62+
// Preserve query string (already in req.URL.RawQuery)
63+
64+
req.RequestURI = "" // must be cleared for the reverse proxy
65+
req.Host = targetURL.Host
66+
67+
// Clear auth headers
68+
req.Header.Del("Authorization")
69+
req.Header.Del("id")
70+
req.Header.Del("key")
71+
72+
// Set instance credentials
73+
req.Header.Set("id", cfg.InstanceID)
74+
req.Header.Set("key", cfg.InstanceKey)
75+
}
76+
}
77+
78+
// HandleUsageEndpoint is a special handler for the /usage endpoint which has a different prefix structure.
79+
func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) {
80+
cfg := internal.Get()
81+
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
82+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
83+
return
84+
}
85+
86+
// Build the full target URL for usage endpoint (no /proxy/api prefix, just /proxy/usage)
87+
fullURL := fmt.Sprintf("%s%s", cfg.Server, h.targetPath)
88+
targetURL, err := url.Parse(fullURL)
89+
if err != nil {
90+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "invalid target URL"})
91+
return
92+
}
93+
94+
// Create and execute the request
95+
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL.String(), c.Request.Body)
96+
if err != nil {
97+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to create request"})
98+
return
99+
}
100+
101+
allowed := map[string]struct{}{
102+
"Accept": {},
103+
"Content-Type": {},
104+
"User-Agent": {},
105+
"X-Request-Id": {},
106+
"X-Correlation-Id": {},
107+
}
108+
109+
// Copy headers
110+
for k, values := range c.Request.Header {
111+
canonical := textproto.CanonicalMIMEHeaderKey(k)
112+
if _, ok := allowed[canonical]; ok {
113+
for _, v := range values {
114+
req.Header.Add(canonical, v)
115+
}
116+
}
117+
}
118+
119+
// Set instance credentials
120+
req.Header.Set("id", cfg.InstanceID)
121+
req.Header.Set("key", cfg.InstanceKey)
122+
123+
// Execute request
124+
client := &http.Client{
125+
Timeout: 20* time.Second,
126+
}
127+
resp, err := client.Do(req)
128+
if err != nil {
129+
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream service error"})
130+
return
131+
}
132+
defer resp.Body.Close()
133+
134+
// Copy response
135+
for k, v := range resp.Header {
136+
for _, vv := range v {
137+
c.Header(k, vv)
138+
}
139+
}
140+
c.Status(resp.StatusCode)
141+
io.Copy(c.Writer, resp.Body)
142+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package handler
2+
3+
import (
4+
"net/http/httptest"
5+
"net/url"
6+
"strings"
7+
"testing"
8+
9+
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
10+
)
11+
12+
func TestDirectorRewritesPathAndHeaders(t *testing.T) {
13+
// Setup instance config
14+
cfg := &internal.InstanceConfig{
15+
Server: "https://example.com",
16+
InstanceID: "test-id-123",
17+
InstanceKey: "test-key-456",
18+
}
19+
20+
// Create a test request
21+
req := httptest.NewRequest("GET", "http://localhost:8080/api/v1/threat-intel/entity/foo?param=value", nil)
22+
req.Header.Set("Authorization", "Bearer token123")
23+
req.Header.Set("id", "old-id")
24+
req.Header.Set("key", "old-key")
25+
26+
// Create a reverse proxy handler
27+
h := NewReverseProxyHandler("/proxy/api/analytics/v1/entity/foo/details")
28+
29+
// Parse the target URL
30+
targetURL, err := url.Parse("https://cm.example.com")
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
35+
// Get the director function
36+
director := h.directorFunc(cfg, targetURL)
37+
38+
// Apply director
39+
director(req)
40+
41+
// Verify URL rewrite
42+
if req.URL.Scheme != "https" {
43+
t.Fatalf("expected scheme https, got %s", req.URL.Scheme)
44+
}
45+
if req.URL.Host != "cm.example.com" {
46+
t.Fatalf("expected host cm.example.com, got %s", req.URL.Host)
47+
}
48+
if req.URL.Path != "/proxy/api/analytics/v1/entity/foo/details" {
49+
t.Fatalf("expected path /proxy/api/analytics/v1/entity/foo/details, got %s", req.URL.Path)
50+
}
51+
if !strings.Contains(req.URL.RawQuery, "param=value") {
52+
t.Fatalf("expected query param preserved, got %s", req.URL.RawQuery)
53+
}
54+
55+
// Verify headers
56+
if req.Header.Get("Authorization") != "" {
57+
t.Fatal("Authorization header should be deleted")
58+
}
59+
if req.Header.Get("id") != "test-id-123" {
60+
t.Fatalf("expected id header test-id-123, got %s", req.Header.Get("id"))
61+
}
62+
if req.Header.Get("key") != "test-key-456" {
63+
t.Fatalf("expected key header test-key-456, got %s", req.Header.Get("key"))
64+
}
65+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package internal
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sync"
7+
8+
"github.com/threatwinds/go-sdk/catcher"
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
type InstanceConfig struct {
13+
Server string `yaml:"server"`
14+
InstanceID string `yaml:"instance_id"`
15+
InstanceKey string `yaml:"instance_key"`
16+
}
17+
18+
var (
19+
instanceConfig *InstanceConfig
20+
configOnce sync.Once
21+
)
22+
23+
// LoadInstanceConfig reads instance-config.yml from updatesDir and caches it.
24+
// Returns an error if the file is missing or cannot be parsed.
25+
// Returns nil and valid config only if all fields are present.
26+
func LoadInstanceConfig(updatesDir string) (*InstanceConfig, error) {
27+
var err error
28+
configOnce.Do(func() {
29+
data, err := os.ReadFile(filepath.Join(updatesDir, "instance-config.yml"))
30+
if err != nil {
31+
_ = catcher.Error("error loading instance configuration",err,map[string]any{})
32+
return
33+
}
34+
var cfg InstanceConfig
35+
if err := yaml.Unmarshal(data, &cfg); err != nil {
36+
_ = catcher.Error("error loading instance configuration",err,map[string]any{})
37+
return
38+
}
39+
instanceConfig = &cfg
40+
})
41+
return instanceConfig, err
42+
}
43+
44+
// Get returns the cached instance config, or nil if not loaded.
45+
func Get() *InstanceConfig {
46+
return instanceConfig
47+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package threatintel
2+
3+
import (
4+
"context"
5+
6+
"github.com/threatwinds/go-sdk/catcher"
7+
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
8+
)
9+
10+
type Module struct {
11+
updatesDir string
12+
}
13+
14+
func NewModule(updatesDir string) *Module {
15+
m := &Module{updatesDir: updatesDir}
16+
17+
if _, err := internal.LoadInstanceConfig(updatesDir); err != nil {
18+
catcher.Warn("threatintel: failed to load instance config", map[string]any{"error": err.Error()})
19+
}
20+
21+
return m
22+
}
23+
24+
func (m *Module) Start(ctx context.Context) {}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package threatintel
2+
3+
import (
4+
"strings"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/utmstack/utmstack/backend/modules/threatintel/handler"
8+
)
9+
10+
func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
11+
g := api.Group("/threat-intel", userAuth)
12+
13+
// POST /api/v1/threat-intel/search → /proxy/api/search/v1/entities/simple
14+
searchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/simple")
15+
g.POST("/search", searchHandler.Handle)
16+
17+
// GET /api/v1/threat-intel/entity/:id → /proxy/api/analytics/v1/entity/{id}/details
18+
g.GET("/entity/:id", func(c *gin.Context) {
19+
id := c.Param("id")
20+
targetPath := "/proxy/api/analytics/v1/entity/" + sanitizeId(id) + "/details"
21+
h := handler.NewReverseProxyHandler(targetPath)
22+
h.Handle(c)
23+
})
24+
25+
// GET /api/v1/threat-intel/entity/:id/relations → /proxy/api/analytics/v1/entity/{id}/relations
26+
g.GET("/entity/:id/relations", func(c *gin.Context) {
27+
id := c.Param("id")
28+
targetPath := "/proxy/api/analytics/v1/entity/" + sanitizeId(id) + "/relations"
29+
h := handler.NewReverseProxyHandler(targetPath)
30+
h.Handle(c)
31+
})
32+
33+
// GET /api/v1/threat-intel/feeds → /proxy/api/feeds/v1/list
34+
feedsHandler := handler.NewReverseProxyHandler("/proxy/api/feeds/v1/list")
35+
g.GET("/feeds", feedsHandler.Handle)
36+
37+
// POST /api/v1/threat-intel/ai/chat → /proxy/api/ai/v1/chat/completions
38+
chatHandler := handler.NewReverseProxyHandler("/proxy/api/ai/v1/chat/completions")
39+
g.POST("/ai/chat", chatHandler.Handle)
40+
41+
// GET /api/v1/threat-intel/usage → /proxy/usage (special endpoint, no /api prefix)
42+
usageHandler := handler.NewReverseProxyHandler("/proxy/usage")
43+
g.GET("/usage", usageHandler.HandleUsageEndpoint)
44+
}
45+
46+
func sanitizeId(id string) string{
47+
replacer := strings.NewReplacer(".", "", "\\", "","/","")
48+
return replacer.Replace(id)
49+
}

backend/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
opensearchgw "github.com/utmstack/utmstack/backend/modules/opensearch"
3333
"github.com/utmstack/utmstack/backend/modules/soar"
3434
"github.com/utmstack/utmstack/backend/modules/socai"
35+
"github.com/utmstack/utmstack/backend/modules/threatintel"
3536
"github.com/utmstack/utmstack/backend/pkg/http/middleware"
3637
)
3738

@@ -137,6 +138,7 @@ func registerRoutes(engine *gin.Engine, m *modules, cfg *config) {
137138
incidents.RegisterRoutes(api, m.incidents, userAuth)
138139
notifications.RegisterRoutes(api, m.notifications, userAuth)
139140
socai.RegisterRoutes(api, m.socAI, userAuth)
141+
threatintel.RegisterRoutes(api, m.threatIntel, userAuth)
140142
datasources.RegisterRoutes(api, m.datasources, userAuth)
141143
adaudit.RegisterRoutes(api, m.adaudit, userAuth)
142144
if m.mcp != nil {

0 commit comments

Comments
 (0)