diff --git a/backend/database/migrations.go b/backend/database/migrations.go index 38c5bac21..2ab548f58 100644 --- a/backend/database/migrations.go +++ b/backend/database/migrations.go @@ -5,6 +5,7 @@ import ( "fmt" adaudit_domain "github.com/utmstack/utmstack/backend/modules/adaudit/domain" + threatintel_domain "github.com/utmstack/utmstack/backend/modules/threatintel/domain" alerts_domain "github.com/utmstack/utmstack/backend/modules/alerts/domain" appconfig_domain "github.com/utmstack/utmstack/backend/modules/appconfig/domain" audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain" @@ -68,6 +69,7 @@ func Models() []any { dashboards_domain.DashboardFilter{}, loganalyzer_domain.SavedQuery{}, adaudit_domain.ADUser{}, + threatintel_domain.AnalystConversation{}, } } diff --git a/backend/modules.go b/backend/modules.go index 540f5492f..63d7a1094 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -282,6 +282,7 @@ func initModules(db *gorm.DB, cfg *config) *modules { env.String("UPDATES_DIR", "/updates", false), env.String("INTEGRATIONS_CONFIG_DIR", "/workdir/pipeline", false), cipher, + db, ) integrationsMod := integrations.NewModule(db, cipher, diff --git a/backend/modules/audit/domain/event_type.go b/backend/modules/audit/domain/event_type.go index a5d1da267..4ba5efd4f 100644 --- a/backend/modules/audit/domain/event_type.go +++ b/backend/modules/audit/domain/event_type.go @@ -166,12 +166,12 @@ const ( VISUALIZATION_UPDATE_SUCCESS ApplicationEventType = "VISUALIZATION_UPDATE_SUCCESS" VISUALIZATION_DELETE_ATTEMPT ApplicationEventType = "VISUALIZATION_DELETE_ATTEMPT" VISUALIZATION_DELETE_SUCCESS ApplicationEventType = "VISUALIZATION_DELETE_SUCCESS" - DASHBOARD_FILTER_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_CREATE_ATTEMPT" - DASHBOARD_FILTER_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_CREATE_SUCCESS" - DASHBOARD_FILTER_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_UPDATE_ATTEMPT" - DASHBOARD_FILTER_UPDATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_UPDATE_SUCCESS" - DASHBOARD_FILTER_DELETE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_DELETE_ATTEMPT" - DASHBOARD_FILTER_DELETE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_DELETE_SUCCESS" + DASHBOARD_FILTER_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_CREATE_ATTEMPT" + DASHBOARD_FILTER_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_CREATE_SUCCESS" + DASHBOARD_FILTER_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_UPDATE_ATTEMPT" + DASHBOARD_FILTER_UPDATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_UPDATE_SUCCESS" + DASHBOARD_FILTER_DELETE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_DELETE_ATTEMPT" + DASHBOARD_FILTER_DELETE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_DELETE_SUCCESS" DASHBOARD_LAYOUT_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_LAYOUT_CREATE_ATTEMPT" DASHBOARD_LAYOUT_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_LAYOUT_CREATE_SUCCESS" DASHBOARD_LAYOUT_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_LAYOUT_UPDATE_ATTEMPT" @@ -209,7 +209,13 @@ const ( // MCP tool calls — every invocation of an MCP-exposed tool is audited // through the same Logger sink used by REST handlers, so /audit covers // both transports uniformly. ResourceType holds the tool name. - APP_EVENT_MCP_TOOL_CALL ApplicationEventType = "MCP_TOOL_CALL" + APP_EVENT_MCP_TOOL_CALL ApplicationEventType = "MCP_TOOL_CALL" + THREATINTEL_ANALYST_CREATE_ATTEMPT ApplicationEventType = "THREATINTEL_ANALYST_CREATE_ATTEMPT" + THREATINTEL_ANALYST_CREATE_SUCCESS ApplicationEventType = "THREATINTEL_ANALYST_CREATE_SUCCESS" + THREATINTEL_ANALYST_UPDATE_ATTEMPT ApplicationEventType = "THREATINTEL_ANALYST_UPDATE_ATTEMPT" + THREATINTEL_ANALYST_UPDATE_SUCCESS ApplicationEventType = "THREATINTEL_ANALYST_UPDATE_SUCCESS" + THREATINTEL_ANALYST_DELETE_ATTEMPT ApplicationEventType = "THREATINTEL_ANALYST_DELETE_ATTEMPT" + THREATINTEL_ANALYST_DELETE_SUCCESS ApplicationEventType = "THREATINTEL_ANALYST_DELETE_SUCCESS" // SERVER_MODULE_CREATE_ATTEMPT ApplicationEventType = "SERVER_MODULE_CREATE_ATTEMPT" // SERVER_MODULE_CREATE_SUCCESS ApplicationEventType = "SERVER_MODULE_CREATE_SUCCESS" // SERVER_MODULE_UPDATE_ATTEMPT ApplicationEventType = "SERVER_MODULE_UPDATE_ATTEMPT" diff --git a/backend/modules/threatintel/domain/analyst_conversation.go b/backend/modules/threatintel/domain/analyst_conversation.go new file mode 100644 index 000000000..249a4e81d --- /dev/null +++ b/backend/modules/threatintel/domain/analyst_conversation.go @@ -0,0 +1,27 @@ +package domain + +import ( + "time" +) + +// AnalystConversation is one AI-analyst chat, scoped to its tenant and creator. +// The frontend generates its own base36 id before the first save, so Id is a +// string rather than a UUID — the backend never rewrites it. +// +// Turns and History are full JSON snapshots of the conversation state. A turn +// and the history it produced are written together at the end of the agent +// loop, so a mid-stream crash loses nothing — the conversation is simply +// shorter. +type AnalystConversation struct { + ID string `gorm:"column:id;size:64;primaryKey" json:"id"` + TenantID string `gorm:"column:tenant_id;size:100;not null;index;uniqueIndex:idx_ti_analyst_conv_tenant_id,priority:1" json:"-"` + UserID string `gorm:"column:user_id;size:100;not null;index" json:"userId"` + Title string `gorm:"column:title;size:255;not null;uniqueIndex:idx_ti_analyst_conv_tenant_id" json:"title"` + Turns string `gorm:"column:turns;type:text;not null;default:'[]'" json:"turns"` + History string `gorm:"column:history;type:text;not null;default:'[]'" json:"history"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;index" json:"updatedAt"` + CreatedDate time.Time `gorm:"column:created_date" json:"createdDate"` + ModifiedDate time.Time `gorm:"column:modified_date" json:"modifiedDate"` +} + +func (AnalystConversation) TableName() string { return "ti_analyst_conversation" } diff --git a/backend/modules/threatintel/handler/analyst.go b/backend/modules/threatintel/handler/analyst.go new file mode 100644 index 000000000..47f646152 --- /dev/null +++ b/backend/modules/threatintel/handler/analyst.go @@ -0,0 +1,158 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/utmstack/utmstack/backend/modules/audit" + audit_connectors "github.com/utmstack/utmstack/backend/modules/audit/connectors" + audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain" + "github.com/utmstack/utmstack/backend/modules/threatintel/domain" + "github.com/utmstack/utmstack/backend/modules/threatintel/repository" + "github.com/utmstack/utmstack/backend/pkg/authz" +) + +type AnalystHandler struct { + store *repository.AnalystStore +} + +var errConversationNotFound = errors.New("conversation not found") + +func NewAnalystHandler(store *repository.AnalystStore) *AnalystHandler { + return &AnalystHandler{store: store} +} + +// scope returns (tenantID, userID) for the request, or "" "" when the actor +// is missing. Both values come from the authenticated actor — the tenant from +// the request context, the user from the middleware's "user_id" entry — so a +// tenant's conversations are never listed against another tenant. +func (h *AnalystHandler) scope(c *gin.Context) (string, string) { + tenantID := authz.TenantIDFromContext(c.Request.Context()) + uidRaw, _ := c.Get("user_id") + switch uid := uidRaw.(type) { + case string: + return tenantID, uid + case uuid.UUID: + return tenantID, uid.String() + } + return tenantID, "" +} + +type conversationDTO struct { + ID string `json:"id"` + Title string `json:"title"` + Turns interface{} `json:"turns"` + History interface{} `json:"history"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (h *AnalystHandler) List(c *gin.Context) { + tenantID, userID := h.scope(c) + if tenantID == "" || userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + rows, err := h.store.List(c.Request.Context(), tenantID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list conversations"}) + return + } + out := make([]conversationDTO, 0, len(rows)) + for _, row := range rows { + var turns interface{} + _ = json.Unmarshal([]byte(row.Turns), &turns) + var history interface{} + _ = json.Unmarshal([]byte(row.History), &history) + out = append(out, conversationDTO{ID: row.ID, Title: row.Title, Turns: turns, History: history, UpdatedAt: row.UpdatedAt}) + } + c.JSON(http.StatusOK, gin.H{"conversations": out}) +} + +func (h *AnalystHandler) Create(c *gin.Context) { + tenantID, userID := h.scope(c) + if tenantID == "" || userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + var req conversationDTO + if err := c.ShouldBindJSON(&req); err != nil || req.ID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id and valid JSON body required"}) + return + } + turns, _ := json.Marshal(req.Turns) + history, _ := json.Marshal(req.History) + conv := &domain.AnalystConversation{ + ID: req.ID, + TenantID: tenantID, + UserID: userID, + Title: req.Title, + Turns: string(turns), + History: string(history), + } + if err := h.store.Create(c.Request.Context(), conv); err != nil { + audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.create", ResourceType: "threatintel_analyst_conversation", ResourceID: conv.ID, Metadata: map[string]any{"title": req.Title}}, + audit_domain.THREATINTEL_ANALYST_CREATE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_CREATE_SUCCESS, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create conversation"}) + return + } + c.JSON(http.StatusCreated, gin.H{"id": conv.ID}) +} + +func (h *AnalystHandler) Update(c *gin.Context) { + tenantID, userID := h.scope(c) + if tenantID == "" || userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + id := c.Param("id") + var req conversationDTO + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "valid JSON body required"}) + return + } + conv, err := h.store.Get(c.Request.Context(), tenantID, userID, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) + return + } + if conv == nil { + audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.update", ResourceType: "threatintel_analyst_conversation", ResourceID: id}, + audit_domain.THREATINTEL_ANALYST_UPDATE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_UPDATE_SUCCESS, + errConversationNotFound) + c.JSON(http.StatusNotFound, gin.H{"error": "conversation not found"}) + return + } + conv.Title = req.Title + turns, _ := json.Marshal(req.Turns) + history, _ := json.Marshal(req.History) + conv.Turns = string(turns) + conv.History = string(history) + if err := h.store.Save(c.Request.Context(), conv); err != nil { + audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.update", ResourceType: "threatintel_analyst_conversation", ResourceID: id, Metadata: map[string]any{"title": req.Title}}, + audit_domain.THREATINTEL_ANALYST_UPDATE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_UPDATE_SUCCESS, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save conversation"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AnalystHandler) Delete(c *gin.Context) { + tenantID, userID := h.scope(c) + if tenantID == "" || userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + id := c.Param("id") + if err := h.store.Delete(c.Request.Context(), tenantID, userID, id); err != nil { + audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.delete", ResourceType: "threatintel_analyst_conversation", ResourceID: id}, + audit_domain.THREATINTEL_ANALYST_DELETE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_DELETE_SUCCESS, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete conversation"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/modules/threatintel/module.go b/backend/modules/threatintel/module.go index dc39d5095..95c9076ce 100644 --- a/backend/modules/threatintel/module.go +++ b/backend/modules/threatintel/module.go @@ -3,6 +3,8 @@ package threatintel import ( "context" + "gorm.io/gorm" + "github.com/utmstack/utmstack/backend/modules/threatintel/handler" "github.com/utmstack/utmstack/backend/modules/threatintel/repository" "github.com/utmstack/utmstack/backend/modules/threatintel/usecase" @@ -13,12 +15,13 @@ import ( type Module struct { updatesDir string feedsHandler *handler.FeedsHandler + analystHandler *handler.AnalystHandler } // NewModule wires the ThreatWinds surface: the proxy reads the instance // credentials from updatesDir, and the feed contribution is configured through // a file in configDir that the feeds plugin watches. -func NewModule(updatesDir, configDir string, cipher *secret.Cipher) *Module { +func NewModule(updatesDir, configDir string, cipher *secret.Cipher, db *gorm.DB) *Module { instanceconfig.Init(updatesDir) m := &Module{updatesDir: updatesDir} @@ -27,6 +30,9 @@ func NewModule(updatesDir, configDir string, cipher *secret.Cipher) *Module { usecase.NewFeedsService(repository.NewConfigStore(configDir), cipher), ) } + if db != nil { + m.analystHandler = handler.NewAnalystHandler(repository.NewAnalystStore(db)) + } return m } @@ -35,3 +41,5 @@ func (m *Module) Start(ctx context.Context) {} // FeedsHandler is nil when the instance has no encryption key, in which case // credentials could not be stored safely and the routes are not registered. func (m *Module) FeedsHandler() *handler.FeedsHandler { return m.feedsHandler } + +func (m *Module) AnalystHandler() *handler.AnalystHandler { return m.analystHandler } diff --git a/backend/modules/threatintel/repository/analyst_store.go b/backend/modules/threatintel/repository/analyst_store.go new file mode 100644 index 000000000..7d039cda5 --- /dev/null +++ b/backend/modules/threatintel/repository/analyst_store.go @@ -0,0 +1,72 @@ +package repository + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + "github.com/utmstack/utmstack/backend/modules/threatintel/domain" +) + +type AnalystStore struct { + db *gorm.DB +} + +// NewAnalystStore builds the GORM-backed conversation store. +func NewAnalystStore(db *gorm.DB) *AnalystStore { + return &AnalystStore{db: db} +} + +func (s *AnalystStore) Create(ctx context.Context, conv *domain.AnalystConversation) error { + now := time.Now().UTC() + conv.CreatedDate = now + conv.ModifiedDate = now + return s.db.WithContext(ctx).Create(conv).Error +} + +func (s *AnalystStore) List(ctx context.Context, tenantID, userID string) ([]domain.AnalystConversation, error) { + var rows []domain.AnalystConversation + err := s.db.WithContext(ctx). + Where("tenant_id = ? AND user_id = ?", tenantID, userID). + Order("updated_at DESC"). + Find(&rows).Error + return rows, err +} + +func (s *AnalystStore) Get(ctx context.Context, tenantID, userID, id string) (*domain.AnalystConversation, error) { + var row domain.AnalystConversation + err := s.db.WithContext(ctx). + Where("id = ? AND tenant_id = ? AND user_id = ?", id, tenantID, userID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *AnalystStore) Save(ctx context.Context, conv *domain.AnalystConversation) error { + now := time.Now().UTC() + conv.UpdatedAt = now + conv.ModifiedDate = now + return s.db.WithContext(ctx). + Model(&domain.AnalystConversation{}). + Where("id = ? AND tenant_id = ? AND user_id = ?", conv.ID, conv.TenantID, conv.UserID). + Updates(map[string]any{ + "title": conv.Title, + "turns": conv.Turns, + "history": conv.History, + "updated_at": conv.UpdatedAt, + "modified_date": conv.ModifiedDate, + }).Error +} + +func (s *AnalystStore) Delete(ctx context.Context, tenantID, userID, id string) error { + return s.db.WithContext(ctx). + Where("id = ? AND tenant_id = ? AND user_id = ?", id, tenantID, userID). + Delete(&domain.AnalystConversation{}).Error +} diff --git a/backend/modules/threatintel/repository/analyst_store_test.go b/backend/modules/threatintel/repository/analyst_store_test.go new file mode 100644 index 000000000..089cb5064 --- /dev/null +++ b/backend/modules/threatintel/repository/analyst_store_test.go @@ -0,0 +1,45 @@ +package repository + +import ( + "context" + "testing" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/utils/tests" + + "github.com/utmstack/utmstack/backend/modules/threatintel/domain" +) + +// DryRun: exercises the query-building and tenancy scoping without a database. +// The Where clauses are what enforce isolation; a dry run still renders and +// validates them. +func TestAnalystStoreIsolationAndSaveDryRun(t *testing.T) { + db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("gorm.Open: %v", err) + } + s := NewAnalystStore(db) + ctx := context.Background() + conv := &domain.AnalystConversation{ + ID: "conv_a1", TenantID: "t1", UserID: "u1", + Title: "First", Turns: `[{"id":"t1"}]`, History: "[]", + UpdatedAt: time.Now(), + } + + if err := s.Create(ctx, conv); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := s.List(ctx, "t1", "u1"); err != nil { + t.Fatalf("list: %v", err) + } + if _, err := s.Get(ctx, "t1", "u1", "conv_a1"); err != nil { + t.Fatalf("get: %v", err) + } + if err := s.Save(ctx, conv); err != nil { + t.Fatalf("save: %v", err) + } + if err := s.Delete(ctx, "t1", "u1", "conv_a1"); err != nil { + t.Fatalf("delete: %v", err) + } +} diff --git a/backend/modules/threatintel/routes.go b/backend/modules/threatintel/routes.go index c53731bc3..0f485a351 100644 --- a/backend/modules/threatintel/routes.go +++ b/backend/modules/threatintel/routes.go @@ -50,6 +50,31 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { h.Handle(c) }) + // GET /api/v1/threat-intel/entity/:id/history → /proxy/api/search/v1/entities/history/advanced/{id} + g.GET("/entity/:id/history", func(c *gin.Context) { + id := c.Param("id") + targetPath := "/proxy/api/search/v1/entities/history/advanced/" + sanitizeId(id) + handler.NewReverseProxyHandler(targetPath).Handle(c) + }) + + // GET /api/v1/threat-intel/entity/:id/comments → /proxy/api/search/v1/entities/comments/{id} + g.GET("/entity/:id/comments", func(c *gin.Context) { + id := c.Param("id") + handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/comments/" + sanitizeId(id)).Handle(c) + }) + + // AI-analyst conversation persistence. These are local to the tenant and + // user — they do not leave the instance — so they are not proxied and the + // upstream credential requirement does not apply. The handler is nil when + // the module was constructed without a database, in which case the routes + // are not registered and the console falls back to localStorage. + if ah := m.AnalystHandler(); ah != nil { + g.GET("/analyst/conversations", ah.List) + g.POST("/analyst/conversations", ah.Create) + g.PUT("/analyst/conversations/:id", ah.Update) + g.DELETE("/analyst/conversations/:id", ah.Delete) + } + // GET /api/v1/threat-intel/feeds → /proxy/api/feeds/v1/list feedsHandler := handler.NewReverseProxyHandler("/proxy/api/feeds/v1/list") g.GET("/feeds", feedsHandler.Handle) diff --git a/frontend/src/features/threat-intel/components/IocDrawer.tsx b/frontend/src/features/threat-intel/components/IocDrawer.tsx index 7ae8b63b2..9f8a71bc2 100644 --- a/frontend/src/features/threat-intel/components/IocDrawer.tsx +++ b/frontend/src/features/threat-intel/components/IocDrawer.tsx @@ -1,5 +1,7 @@ import { useTiEntity } from '../hooks/use-ti-entity' +import type { EntityRelation } from '../domain/threat-intel.types' import { useTiEntityRelations } from '../hooks/use-ti-entity-relations' +import type { RawRelationNode, RawRelations } from '../console/agent' import { IocDrawerBody } from './IocDrawerBody' import { IocDrawerLoading } from './IocDrawerLoading' @@ -16,7 +18,12 @@ export function IocDrawer({ id, onClose }: IocDrawerProps) { if (entityData?.kind === 'not-configured') return null const detail = entityData?.kind === 'ok' ? entityData.value : null - const relations = relationsData?.kind === 'ok' ? relationsData.value : [] + // The relations endpoint returns a graph object ({nodes, edges}) with full + // entity attributes, not a bare list. `EntityRelation = EntityAttributes` so + // the drawer body can render value/reputation straight off each node. + const rawRels = (relationsData?.kind === 'ok' ? relationsData.value : null) as unknown as RawRelations + const relNodes: RawRelationNode[] = Array.isArray(rawRels) ? rawRels : rawRels?.nodes ?? [] + const relations: EntityRelation[] = relNodes.map((r) => ({ ...r, value: r.value ?? r.label ?? r.id }) as EntityRelation) return (
{ + void initConsoleStore() + }, []) + + const storeReady = useSyncExternalStore( + (l) => subscribe(l), + isConsoleStoreReady, + isConsoleStoreReady, + ) + + useEffect(() => { + if (!storeReady) return if (conversationId || !store.conversations.length) return setConversationId([...store.conversations].sort(byRecency)[0].id) - }, [conversationId, store.conversations]) + }, [conversationId, storeReady, store.conversations]) useEffect(() => { + if (!storeReady) return if (conversationId || store.conversations.length) return const conversation = createConversation('New conversation') setConversationId(conversation.id) - }, [conversationId, store.conversations.length]) + }, [conversationId, storeReady, store.conversations.length]) useEffect(() => { if (!conversationId) return diff --git a/frontend/src/features/threat-intel/console/agent.ts b/frontend/src/features/threat-intel/console/agent.ts index f3d015008..688496cc7 100644 --- a/frontend/src/features/threat-intel/console/agent.ts +++ b/frontend/src/features/threat-intel/console/agent.ts @@ -2,18 +2,46 @@ import { getStoredTokens } from '@/shared/lib/api-client' import { IS_FEDERATION } from '@/shared/config/mode' import { getCurrentInstanceId } from '@/shared/lib/current-instance' import type { - AgentStep, ChatMessage, + AgentStep, + ChatMessage, IntelEntity, RelationsResult, EntityDetails, ModelToolCall, ToolPayload, } from './types' -import { countEntitiesByType, detectIndicatorTypes } from '../lib/report' +import { + countEntitiesByType, + detectIndicatorTypes, + reportIoc, +} from '../lib/report' +import { + addCaseNote, + addWatchlistItem, + createCase, + createWatchlist, + linkEntities, + listAlerts, + listCases, + listWatchlists, + pinEntityToCase, + type Case, + type CaseStatus, + type Watchlist, +} from '../lib/casework' +import { + compactMessages, + estimateTokens, + renderForSummary, + SUMMARY_INPUT_RATIO, + DEFAULT_COMPACTION, + type CompactionConfig, + type Summarizer, +} from './compaction' const API_URL = import.meta.env.VITE_API_URL || '/api/v1' const BASE = `${API_URL}/threat-intel` -const MAX_TOOL_ROUNDS = 6 +const MAX_TOOL_ROUNDS = 25 const SYSTEM_PROMPT = `You are the ThreatWinds analyst agent, embedded in the UTMStack AI analyst console. @@ -30,89 +58,36 @@ Rules: - Accuracy runs 0 to 3: 0 Inaccurate, 1 Imprecise, 2 Fairly precise, 3 Precise. It never goes negative, and it measures confidence in the scoring, not severity. - Be precise and concise. Lead with the verdict, then the evidence that supports it. - If the data is thin or ambiguous, say so plainly rather than inflating a conclusion. -- Never invent entity ids. Only use ids returned by tools.` +- Never invent entity ids. Only use ids returned by tools. -interface ToolSchema { - type: 'function' - function: { name: string; description: string; parameters: Record } -} +Reporting is different from investigating. report_ioc and link_entities WRITE to the shared ThreatWinds corpus where other instances see it, and it is not easy to retract: +- Only call them when the analyst explicitly asks to report, submit, flag or link. +- Never call them to "record" something you found while investigating, and never call them speculatively. +- State the type, value and reputation you are about to submit before calling it. Reputation uses the same -3..3 scale you read. -const TOOL_SCHEMAS: ToolSchema[] = [ - { - type: 'function', - function: { - name: 'lookup_indicator', - description: - 'Exact lookup of one indicator in the ThreatWinds corpus. Returns its record (id, tags, reputation, lastSeen) or reports it is absent.', - parameters: { - type: 'object', - properties: { - value: { type: 'string', description: 'The exact indicator, e.g. "8.8.8.8"' }, - type: { type: 'string', description: 'Optional indicator type (ip, domain, hostname, url, hash, cve, email-address). Inferred from the value when omitted.' }, - }, - required: ['value'], - }, - }, - }, - { - type: 'function', - function: { - name: 'search_entities', - description: - 'Free-text search over the indicator corpus. Returns up to 10 matches with id, type, value, tags, reputation.', - parameters: { - type: 'object', - properties: { query: { type: 'string', description: 'Search terms' } }, - required: ['query'], - }, - }, - }, - { - type: 'function', - function: { - name: 'get_entity_details', - description: - 'Full dossier for an entity id returned by search/lookup: attributes, geolocation metadata, associations.', - parameters: { - type: 'object', - properties: { id: { type: 'string', description: 'Entity id' } }, - required: ['id'], - }, - }, - }, - { - type: 'function', - function: { - name: 'get_relations', - description: 'Relation graph for an entity id: the connected entities and links.', - parameters: { - type: 'object', - properties: { id: { type: 'string', description: 'Entity id' } }, - required: ['id'], - }, - }, - }, - { - type: 'function', - function: { - name: 'count_entities_by_type', - description: - 'Corpus-wide counts of how many entities exist per type. Use for scale and coverage questions, not for investigating a specific indicator.', - parameters: { - type: 'object', - properties: { - types: { type: 'array', items: { type: 'string' }, description: 'Entity types to count. Omit for all known types.' }, - }, - }, - }, - }, -] +The workspace is the analyst's own: cases, watchlists and the alerts they raise. Everything in it can be undone, but it belongs to the tenant. +- list_cases, list_watchlists and list_alerts are ordinary reads. Use them freely — checking whether an indicator is already in a case is often the most useful thing you can say about it. +- create_case, pin_to_case, add_case_note, create_watchlist and add_watchlist_item change that workspace. Only on explicit request, never as a side effect of investigating. Finding something alarming is not permission to open a case about it; offer, and wait. +- Prefer adding to what already exists. Call list_cases before create_case and list_watchlists before create_watchlist, and say which you are adding to. +- Pin entities by the id a tool returned, never by a value you retyped. +- Case severity runs 0 to 4: 0 informational, 1 low, 2 medium, 3 high, 4 critical. Case status is open, in_progress or closed. A watchlist rule kind is entity, value, tag or type.` export interface AgentEvents { onText?(chunk: string): void onStep?(step: AgentStep): void + /** Older turns were folded into a summary to stay within the input budget. */ + onCompaction?(info: { droppedGroups: number; tokensBefore: number; tokensAfter: number }): void } +export interface AgentRunResult { + answer: string + history: ChatMessage[] + stoppedReason: 'complete' | 'max-turns' | 'aborted' +} + +let stepCounter = 0 +const nextStepId = () => `step_${++stepCounter}` + async function authHeaders(): Promise> { const h: Record = { 'Content-Type': 'application/json' } const tokens = getStoredTokens() @@ -124,12 +99,49 @@ async function authHeaders(): Promise> { return h } -async function proxyFetch(path: string, init: RequestInit = {}): Promise { - const res = await fetch(`${BASE}${path}`, { ...init, headers: await authHeaders() }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) +async function proxyFetch(path: string, init: RequestInit = {}, signal?: AbortSignal): Promise { + const res = await fetch(`${BASE}${path}`, { ...init, headers: await authHeaders(), signal }) + if (!res.ok) { + let msg = `HTTP ${res.status}` + try { + const body = JSON.parse(await res.text()) + const err = body?.error + msg = (typeof err === 'string' ? err : err?.message) ?? msg + } catch { + // non-JSON error body — keep the HTTP status message + } + throw new ProxyError(msg, res.status) + } return (await res.json()) as T } +/** Distinguished from generic failures so lookup can separate "the corpus said no" from "we never got an answer". */ +export class ProxyError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.name = 'ProxyError' + this.status = status + } +} + +/** Turns transport failures into something an analyst can act on. */ +export function explainAgentError(err: unknown): string { + if (err instanceof ProxyError) { + if (err.status === 503) { + return 'The ThreatWinds proxy is not configured on this instance (or is down). Ask the platform admin to set it up; search still works locally.' + } + if (err.status === 401 || err.status === 403) { + return 'Your session was rejected by the UTMStack backend. Sign in again.' + } + if (err.status === 504) { + return 'ThreatWinds did not answer in time. Try again; very deep relation graphs can exceed the proxy timeout.' + } + return err.message + } + return err instanceof Error ? err.message : String(err) +} + interface SearchItem { id: string type: string @@ -160,6 +172,28 @@ function detailsToGeo(d: EntityDetails): { lat: number; lng: number; radius: num return (d.geolocations ?? []).map((g) => ({ lat: g.lat, lng: g.lng, radius: g.radius, ip: g.ip })) } +/** + * Extended metadata arrives flat or already grouped by source. The inspector + * wants groups, so a flat `{key: value}` object is wrapped under a single + * default group; anything unrecognised is dropped rather than rendered as + * `undefined`. + */ +export function groupExtendedMetadata( + raw: Record> | unknown[] | null | undefined, +): Record> | null { + if (!raw) return null + if (Array.isArray(raw)) return null + const out: Record> = {} + for (const [group, fields] of Object.entries(raw)) { + if (fields && typeof fields === 'object' && !Array.isArray(fields)) { + out[group] = Object.fromEntries( + Object.entries(fields as Record).map(([k, v]) => [k, String(v)]), + ) + } + } + return Object.keys(out).length ? out : null +} + export interface RawDetails { attributes: { id: string @@ -179,8 +213,8 @@ export interface RawDetails { accuracy: string } metadata: Record | null - extended_metadata?: unknown[] - latest_associations: { id: string; type: string; value?: string; label?: string; tags?: string[]; reputation?: number; accuracy?: number }[] + extended_metadata?: Record> | unknown[] | null + latest_associations: { id: string; type: string; value?: string; label?: string; tags?: string[]; reputation?: number; accuracy?: number; last_seen?: string }[] geolocations: { latitude: number; longitude: number; accuracy_radius: number }[] } @@ -205,6 +239,7 @@ export function normalizeDetails(raw: RawDetails): EntityDetails { accuracy: a.accuracy, }, metadata: raw.metadata ?? null, + extendedMetadata: groupExtendedMetadata(raw.extended_metadata), latest_associations: (raw.latest_associations ?? []).map((x) => ({ id: x.id, type: x.type, @@ -212,6 +247,7 @@ export function normalizeDetails(raw: RawDetails): EntityDetails { reputation: x.reputation ?? 0, accuracy: x.accuracy ?? 0, tags: x.tags ?? [], + lastSeen: x.last_seen, })), geolocations: (raw.geolocations ?? []).map((g) => ({ lat: g.latitude, @@ -221,81 +257,726 @@ export function normalizeDetails(raw: RawDetails): EntityDetails { } } -export interface RawRelation { +export interface RawRelationNode { id: string type: string - label: string - reputation_score: number - reputation: string + label?: string + value?: string + reputation_score?: number + reputation?: string +} +export interface RawRelationEdge { + source: string + target: string } -export function relationsToGraph(id: string, self: string, rels: RawRelation[]): RelationsResult { +/** + * The upstream `/entity/:id/relations` response is not a flat list of related + * entities — it is a graph object `{nodes, edges}`, where each node carries its + * own `label` and `reputation_score`. An older shape returned a bare array; we + * accept that too so neither breaks. Treating the graph object as a list is what + * previously reduced every relation view to a single root node, because + * `Array.isArray(object)` is false and the fallback was an empty array. + */ +export type RawRelations = { nodes: RawRelationNode[]; edges: RawRelationEdge[] } | RawRelationNode[] + +export function normalizeRelations(raw: RawRelations | null | undefined): { nodes: RawRelationNode[]; edges: RawRelationEdge[] } { + if (!raw) return { nodes: [], edges: [] } + if (Array.isArray(raw)) return { nodes: raw, edges: [] } + return { nodes: Array.isArray(raw.nodes) ? raw.nodes : [], edges: Array.isArray(raw.edges) ? raw.edges : [] } +} + +export function relationsToGraph(id: string, self: string, rels: RawRelations | null | undefined): RelationsResult { + const { nodes: rawNodes, edges: rawEdges } = normalizeRelations(rels) const nodes: { id: string; type: string; value: string; reputation: number | null }[] = [ { id, type: 'root', value: self || id, reputation: null }, ] + const seen = new Set([id]) + for (const r of rawNodes) { + if (!r.id || seen.has(r.id)) continue + seen.add(r.id) + nodes.push({ id: r.id, type: r.type, value: r.label ?? r.value ?? r.id, reputation: typeof r.reputation_score === 'number' ? r.reputation_score : null }) + } const edges: { source: string; target: string }[] = [] - for (const r of rels) { - nodes.push({ id: r.id, type: r.type, value: r.label, reputation: r.reputation_score }) - edges.push({ source: id, target: r.id }) + if (rawEdges.length) { + // Real edges from the graph; drop any endpoint that was not returned as a node. + for (const e of rawEdges) { + if (seen.has(e.source) && seen.has(e.target)) edges.push({ source: e.source, target: e.target }) + } + } else { + // Flat list: no edge structure, so link every relation back to the root. + for (const r of rawNodes) { + if (r.id && seen.has(r.id)) edges.push({ source: id, target: r.id }) + } } return { nodes, edges } } -async function runTool(name: string, args: Record, step: AgentStep): Promise { - switch (name) { - case 'lookup_indicator': { - const value = String(args.value ?? '') - const type = String(args.type || '') || detectIndicatorTypes(value)[0] - if (!type) { - step.status = 'error' - step.error = 'could not infer an indicator type' - return `Tool failed: could not infer a type for "${value}".` +/** Timestamps, trimmed to the second — nanosecision precision is noise and costs tokens. */ +function at(iso: string | null | undefined): string | null { + if (!iso) return null + const ms = Date.parse(iso) + return Number.isNaN(ms) ? null : new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z') +} + +function seenText(firstSeen: string | null | undefined, lastSeen: string | null | undefined): string | null { + const first = at(firstSeen) + const last = at(lastSeen) + if (first && last) return first === last ? `seen=${first}` : `first_seen=${first} last_seen=${last}` + if (last) return `last_seen=${last}` + if (first) return `first_seen=${first}` + return null +} + +function describeEntity(e: IntelEntity): string { + const parts = [`${e.type}:${e.value}`, `id=${e.id}`, `reputation=${e.reputation}`] + if (e.tags.length) parts.push(`tags=${e.tags.join('|')}`) + const seen = seenText(null, e.lastSeen) + if (seen) parts.push(seen) + return parts.join(' ') +} + +const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback) +const int = (v: unknown, fallback: number): number => + typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : fallback + +/** Models emit nested associations as loosely-typed objects, so each level is validated. */ +function parseAssociations(raw: unknown): import('../lib/report').ReportAssociationInput[] | undefined { + if (!Array.isArray(raw) || raw.length === 0) return undefined + const parsed = raw + .map((entry): import('../lib/report').ReportAssociationInput | null => { + if (!entry || typeof entry !== 'object') return null + const e = entry as Record + const value = str(e.value) + if (!value) return null + return { + value, + type: str(e.type) || undefined, + mode: e.mode === 'aggregation' ? 'aggregation' : 'association', + associations: parseAssociations(e.associations), } - try { - const item = await proxyFetch('/entity/lookup', { - method: 'POST', - body: JSON.stringify({ type, value }), - }) - step.payload = { kind: 'lookup', value, entity: toItem(item) } - return JSON.stringify(toItem(item)) - } catch { - step.payload = { kind: 'lookup', value, entity: null } - return `No threat intelligence recorded for "${value}".` + }) + .filter((a): a is import('../lib/report').ReportAssociationInput => a !== null) + return parsed.length ? parsed : undefined +} + +interface ToolDef { + schema: { + name: string + description: string + parameters: Record + } + run(args: Record, signal?: AbortSignal): Promise<{ payload: ToolPayload; summarize: () => string }> +} + +const TOOLS: Record = { + lookup_indicator: { + schema: { + name: 'lookup_indicator', + description: + 'Resolve ONE exact indicator (IP, CIDR, domain, hostname, URL, or file hash) to its entity. This is the correct first step whenever the analyst names a specific indicator — full-text search tokenizes the value and returns thousands of unrelated entities instead.', + parameters: { + type: 'object', + properties: { + value: { type: 'string', description: 'The exact indicator, e.g. "8.8.8.8".' }, + type: { type: 'string', description: 'Optional entity type (ip, domain, hostname, url, md5, sha256...). Inferred from the value when omitted.' }, + }, + required: ['value'], + }, + }, + async run(args, signal) { + const value = str(args.value) + const types = str(args.type) ? [str(args.type)] : detectIndicatorTypes(value) + if (!types.length) { + return { + payload: { kind: 'lookup', value, entity: null, rejected: [] }, + summarize: () => + `"${value}" has no recognisable indicator shape. If it is a name (malware, campaign, filename) use search_entities.`, + } } - } - case 'search_entities': { + // Try each candidate type; remember which were refused so a miss can + // be distinguished from "the corpus was asked and had no answer". + const rejected: string[] = [] + let found: SearchItem | null = null + for (const type of types) { + try { + found = await proxyFetch('/entity/lookup', { + method: 'POST', + body: JSON.stringify({ type, value }), + }, signal) + break + } catch (err) { + if (err instanceof ProxyError && (err.status === 404 || err.status === 400)) { + rejected.push(type) + continue + } + if (err instanceof ProxyError && err.status === 404) continue + throw err + } + } + if (found) { + const entity = toItem(found) + return { + payload: { kind: 'lookup', value, entity, rejected: [] }, + summarize: () => `Resolved ${value} -> ${describeEntity(entity)}. Use this id with get_entity_details or get_relations.`, + } + } + const allRejected = rejected.length === types.length + return { + payload: { kind: 'lookup', value, entity: null, rejected: allRejected ? rejected : [] }, + summarize: () => + allRejected + ? `"${value}" was not looked up: the API rejected it as ${rejected.join(' and ')}, ` + + `which are the only types its shape suggests. This is NOT evidence of absence — nothing was searched. ` + + `If you know the type (malware, filename, campaign names have no recognisable shape), ` + + `call lookup_indicator again with it set. Otherwise use search_entities to find it by name.` + : `"${value}" is not present in the corpus. It has no recorded threat intelligence.`, + } + }, + }, + + search_entities: { + schema: { + name: 'search_entities', + description: + 'Full-text search for entities by NAME or KEYWORD — malware families, campaign names, filenames, free text. Do NOT use it to look up a specific IP, domain or hash: the query is tokenized, so "8.8.8.8" returns thousands of unrelated addresses. Use lookup_indicator for exact indicators.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search terms, e.g. "emotet".' }, + limit: { type: 'integer', description: 'Max results, 1-50. Default 15.' }, + }, + required: ['query'], + }, + }, + async run(args, signal) { + const query = str(args.query) + const limit = Math.min(Math.max(int(args.limit, 15), 1), 50) const res = await proxyFetch<{ items: number; results: SearchItem[] }>('/ai/search', { method: 'POST', - body: JSON.stringify({ query: String(args.query), page: 0, limit: 10 }), - }) + body: JSON.stringify({ query, page: 0, limit }), + }, signal) const results = (res.results ?? []).map(toItem) - step.payload = { kind: 'search', query: String(args.query), items: res.items ?? results.length, results } - return JSON.stringify({ items: res.items, results }) - } - case 'get_entity_details': { - const id = String(args.id).replace(/[.\//\\]/g, '') - const raw = await proxyFetch(`/entity/${encodeURIComponent(id)}`) + return { + payload: { kind: 'search', query, items: res.items ?? results.length, results }, + summarize: () => { + if (!results.length) return `No entities matched "${query}".` + const lines = results.slice(0, 15).map((e) => `- ${describeEntity(e)}`) + return `${res.items ?? results.length} total matches for "${query}". Top ${lines.length}:\n${lines.join('\n')}` + }, + } + }, + }, + + get_entity_details: { + schema: { + name: 'get_entity_details', + description: + 'Full dossier for one entity id: reputation and accuracy scores with their labels, when it was first and last reported, description, metadata (ASN, city, country), associated entities, and geolocations. This is where recency questions are answered — "when was this last seen" is first_seen/last_seen here. Requires an exact entity id from search_entities, lookup_indicator or get_relations.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Exact entity id, e.g. "ip-4f24afca…".' }, + }, + required: ['id'], + }, + }, + async run(args, signal) { + const id = str(args.id).replace(/[.\//\\]/g, '') + const raw = await proxyFetch(`/entity/${encodeURIComponent(id)}`, {}, signal) const details = normalizeDetails(raw) - step.payload = { kind: 'details', id, details } - return JSON.stringify(details) - } - case 'get_relations': { - const id = String(args.id).replace(/[.\//\\]/g, '') - const rels = await proxyFetch(`/entity/${encodeURIComponent(id)}/relations`) - const graph = relationsToGraph(id, '', Array.isArray(rels) ? rels : []) - step.payload = { kind: 'relations', id, graph } - return JSON.stringify(graph) - } - case 'count_entities_by_type': { + return { + payload: { kind: 'details', id, details }, + summarize: () => { + const a = details.attributes + const lines = [ + `${a.type}:${a.value}`, + `reputation=${a.reputation}(${a.reputation_score}) accuracy=${a.accuracy}(${a.accuracy_score})`, + `worst=${a.worst_reputation} best=${a.best_reputation}`, + ] + const seen = seenText(a.first_seen, a.last_seen) + if (seen) lines.push(seen) + if (details.metadata) { + const meta = Object.entries(details.metadata) + .map(([k, v]) => `${k}=${v}`) + .join(' ') + if (meta) lines.push(`metadata: ${meta}`) + } + const assoc = details.latest_associations ?? [] + if (assoc.length) { + lines.push( + `associations (${assoc.length}): ` + + assoc + .slice(0, 10) + .map((x) => `${x.type}:${x.value} id=${x.id}`) + .join('; '), + ) + } + const geo = details.geolocations ?? [] + if (geo.length) { + lines.push( + `geolocations (${geo.length}): ` + + geo.slice(0, 5).map((g) => `${g.lat},${g.lng} r=${g.radius}m`).join('; '), + ) + } + return lines.join('\n') + }, + } + }, + }, + + get_relations: { + schema: { + name: 'get_relations', + description: 'Relationship graph around an entity: connected entities and the edges between them. Use to pivot from one indicator to infrastructure it touches.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Exact entity id.' }, + }, + required: ['id'], + }, + }, + async run(args, signal) { + const id = str(args.id).replace(/[.\//\\]/g, '') + const depth = Math.max(1, Math.min(4, int(args.depth, 2))) + let rels: RawRelations = [] + try { + rels = await proxyFetch(`/entity/${encodeURIComponent(id)}/relations?depth=${depth}`, {}, signal) + } catch (err) { + // A shallower graph is far more useful than an error: retry depth 1. + if (depth > 1 && !signal?.aborted) { + rels = await proxyFetch(`/entity/${encodeURIComponent(id)}/relations?depth=1`, {}, signal) + } else { + throw err + } + } + const graph = relationsToGraph(id, '', rels) + return { + payload: { kind: 'relations', id, graph }, + summarize: () => { + const { nodes, edges } = graph + if (nodes.length <= 1) return 'No related entities found.' + const listed = nodes + .slice(1, 21) + .map((n) => `- ${n.type}:${n.value} id=${n.id} reputation=${n.reputation ?? 'n/a'}`) + return `${nodes.length} nodes, ${edges.length} edges.\n${listed.join('\n')}` + }, + } + }, + }, + + get_geolocations: { + schema: { + name: 'get_geolocations', + description: 'Geographic locations attributed to an entity, with accuracy radius. Only IP-like entities usually have these.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Exact entity id.' }, + }, + required: ['id'], + }, + }, + async run(args, signal) { + const id = str(args.id).replace(/[.\//\\]/g, '') + const raw = await proxyFetch(`/entity/${encodeURIComponent(id)}`, {}, signal) + const details = normalizeDetails(raw) + const locations = detailsToGeo(details) + return { + payload: { kind: 'geo', id, locations }, + summarize: () => { + if (!locations.length) return 'No geolocation data for this entity.' + return locations.map((g) => `- ${g.lat}, ${g.lng} radius=${g.radius}m`).join('\n') + }, + } + }, + }, + + count_entities_by_type: { + schema: { + name: 'count_entities_by_type', + description: 'Corpus-wide counts of how many entities exist per type. Use for scale and coverage questions, not for investigating a specific indicator.', + parameters: { + type: 'object', + properties: { + types: { type: 'array', items: { type: 'string' }, description: 'Entity types to count. Omit for all known types.' }, + }, + }, + }, + async run(args, _signal) { const requested = Array.isArray(args.types) ? args.types.filter((x): x is string => typeof x === 'string') : [] const buckets = await countEntitiesByType(requested) - step.payload = { kind: 'counts', buckets } - return buckets.map((b) => `${b.key}: ${b.count}`).join('\n') - } - default: - return `Tool failed: unknown tool ${name}` - } + return { + payload: { kind: 'counts', buckets }, + summarize: () => { + if (!buckets.length) return 'No counts returned.' + return buckets.map((b) => `${b.key}: ${b.count.toLocaleString()}`).join('\n') + }, + } + }, + }, + + report_ioc: { + schema: { + name: 'report_ioc', + description: + 'Report a NEW indicator of compromise back into the ThreatWinds corpus. This WRITES data visible to all instances sharing this proxy, so only call it when the user explicitly asks to report, submit or flag an indicator — never as part of an investigation.', + parameters: { + type: 'object', + properties: { + value: { type: 'string', description: 'The indicator, e.g. "203.0.113.1".' }, + type: { + type: 'string', + description: 'Entity type (ip, domain, hostname, url, md5, sha256...). Inferred when omitted. MUST be set explicitly for names — malware, filenames, campaigns — because those have no recognisable shape and are otherwise rejected.', + }, + reputation: { + type: 'integer', + description: + 'Reputation from -3 to 3, the same scale used when reading: -3 Alarming, -2 Worrying, -1 Concerning, 0 Indefinable, 1 Adequate, 2 Good, 3 Exceptional. 0 means no verdict was reached, not that the indicator is neutral or safe — omit reputation entirely rather than submitting 0 to mean "probably fine".', + }, + tags: { type: 'array', items: { type: 'string' }, description: 'Short labels, e.g. ["scanner", "bruteforce"]' }, + description: { type: 'string', description: 'Why this indicator is being reported.' }, + associations: { + type: 'array', + description: 'Related entities to create and link in the same submission, e.g. the domain an IP resolves to or the malware a file contains.', + items: { + type: 'object', + properties: { + value: { type: 'string', description: 'The related indicator.' }, + type: { type: 'string', description: 'Entity type. Inferred only for network indicators and hashes; set explicitly for names.' }, + mode: { type: 'string', enum: ['aggregation', 'association'], description: '"aggregation" when the related entity is a component OF the parent (a file contains malware); "association" for a looser link.' }, + }, + required: ['value'], + }, + }, + }, + required: ['value'], + }, + }, + async run(args, _signal) { + const tags = Array.isArray(args.tags) + ? args.tags.filter((t): t is string => typeof t === 'string') + : undefined + const result = await reportIoc({ + value: str(args.value), + type: str(args.type) || undefined, + reputation: typeof args.reputation === 'number' ? args.reputation : undefined, + tags, + description: str(args.description) || undefined, + associations: parseAssociations(args.associations), + }) + const skipped = result.skippedAssociations + return { + payload: { kind: 'report', value: result.value, type: result.type, reputation: result.reputation, skippedAssociations: skipped }, + summarize: () => { + const lines = [ + `Reported ${result.type}:${result.value} with reputation ${result.reputation ?? 'not stated'}${result.tags.length ? ` and tags ${result.tags.join(', ')}` : ''}.`, + ] + if (skipped.length) { + lines.push( + `WARNING: these associations were NOT recorded because no entity type was given and none could be inferred: ${skipped.join(', ')}. Retry them with an explicit "type" (e.g. "malware" or "filename"), and tell the analyst they were not saved.`, + ) + } + lines.push('Ingestion is asynchronous, so it will not be immediately searchable.') + return lines.join(' ') + }, + } + }, + }, + + link_entities: { + schema: { + name: 'link_entities', + description: + 'Associate two entities that ALREADY exist in the corpus, by their entity ids. Use this to record a relationship between things already in the corpus; use report_ioc with associations to create and link new entities in one go. WRITES data — only on explicit request.', + parameters: { + type: 'object', + properties: { + entityID: { type: 'string', description: 'Source entity id, e.g. "ip-4f24afca…".' }, + relatedEntityID: { type: 'string', description: 'Target entity id.' }, + }, + required: ['entityID', 'relatedEntityID'], + }, + }, + async run(args, signal) { + const result = await linkEntities(str(args.entityID), str(args.relatedEntityID), signal) + return { + payload: { kind: 'link', entityID: result.entityID, relatedEntityID: result.relatedEntityID }, + summarize: () => `Linked ${result.entityID} to ${result.relatedEntityID}.`, + } + }, + }, + + /* ------------------------------------------------------------------ */ + /* Casework — the tenant's own workspace: private, reversible. */ + /* ------------------------------------------------------------------ */ + + list_cases: { + schema: { + name: 'list_cases', + description: + "The tenant's investigation cases, with the entities pinned to each. Use it to find the case an indicator belongs in before pinning, to answer whether something is already being tracked, or to pick up where an investigation left off.", + parameters: { + type: 'object', + properties: { + status: { type: 'string', enum: ['open', 'in_progress', 'closed'], description: 'Filter by status. Omit for all cases.' }, + }, + }, + }, + async run(args, signal) { + const status = parseCaseStatus(args.status) + const cases = await listCases(status, signal) + return { + payload: { kind: 'cases', cases }, + summarize: () => { + if (!cases.length) return 'There are no cases.' + return `${cases.length} case(s):\n${cases.map(describeCase).join('\n')}` + }, + } + }, + }, + + create_case: { + schema: { + name: 'create_case', + description: + 'Open a NEW investigation case. Only on explicit request. Cases count against a standing tenant quota, so prefer pinning to an existing case — check list_cases first.', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Short name, e.g. "Emotet resurgence, Sept".' }, + summary: { type: 'string', description: 'What this case is about.' }, + severity: { type: 'integer', description: 'Severity 0-4: 0 informational, 1 low, 2 medium, 3 high, 4 critical.' }, + status: { type: 'string', enum: ['open', 'in_progress', 'closed'], description: 'Defaults to open.' }, + }, + required: ['title'], + }, + }, + async run(args, signal) { + const severity = parseSeverity(args.severity) + const created = await createCase( + { + title: str(args.title), + summary: str(args.summary) || undefined, + severity, + status: parseCaseStatus(args.status) ?? 'open', + }, + signal, + ) + return { + payload: { kind: 'casework', action: 'case-created', label: created.title, ref: created.id }, + summarize: () => `Created case "${created.title}" (id ${created.id}). Use this id with pin_to_case and add_case_note.`, + } + }, + }, + + pin_to_case: { + schema: { + name: 'pin_to_case', + description: + "Attach an entity already in the corpus to one of the tenant's cases, so it is kept with that investigation. Needs a case id from list_cases or create_case, and an entity id from lookup_indicator, search_entities or get_relations.", + parameters: { + type: 'object', + properties: { + caseId: { type: 'string', description: 'Target case id.' }, + entityId: { type: 'string', description: 'Entity id, e.g. "ip-4f24afca…".' }, + entityType: { type: 'string', description: 'Entity type, so the case reads without a lookup.' }, + entityValue: { type: 'string', description: 'The indicator itself, for the same reason.' }, + note: { type: 'string', description: 'Why this entity matters to the case.' }, + }, + required: ['caseId', 'entityId'], + }, + }, + async run(args, signal) { + const pinned = await pinEntityToCase( + str(args.caseId), + { + entityId: str(args.entityId), + entityType: str(args.entityType) || undefined, + entityValue: str(args.entityValue) || undefined, + note: str(args.note) || undefined, + }, + signal, + ) + return { + payload: { kind: 'casework', action: 'entity-pinned', label: pinned.entity_value || pinned.entity_id, ref: pinned.case_id }, + summarize: () => `Pinned ${pinned.entity_value || pinned.entity_id} to case ${pinned.case_id}.`, + } + }, + }, + + add_case_note: { + schema: { + name: 'add_case_note', + description: "Append a note to one of the tenant's cases — findings, reasoning, what to do next. This is how an investigation gets written up. Only on explicit request.", + parameters: { + type: 'object', + properties: { + caseId: { type: 'string', description: 'Target case id.' }, + body: { type: 'string', description: 'The note. Plain text.' }, + }, + required: ['caseId', 'body'], + }, + }, + async run(args, signal) { + const note = await addCaseNote(str(args.caseId), str(args.body), signal) + return { + payload: { kind: 'casework', action: 'note-added', label: note.id, ref: note.case_id }, + summarize: () => `Added a note to case ${note.case_id}.`, + } + }, + }, + + list_watchlists: { + schema: { + name: 'list_watchlists', + description: + "The tenant's watchlists and the rules in each. A watchlist fires an alert when a reported entity matches a rule. Use it to find which list a new rule belongs in, or to answer what is currently being watched.", + parameters: { type: 'object', properties: {} }, + }, + async run(_args, signal) { + const watchlists = await listWatchlists(signal) + return { + payload: { kind: 'watchlists', watchlists }, + summarize: () => { + if (!watchlists.length) return 'There are no watchlists.' + return `${watchlists.length} watchlist(s):\n${watchlists.map(describeWatchlist).join('\n')}` + }, + } + }, + }, + + create_watchlist: { + schema: { + name: 'create_watchlist', + description: + 'Create a NEW watchlist. Only on explicit request. Watchlists count against a standing tenant quota, so check list_watchlists and add to an existing one where it fits.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Short name, e.g. "Ransomware infrastructure".' }, + description: { type: 'string', description: 'What this list is for.' }, + }, + required: ['name'], + }, + }, + async run(args, signal) { + const created = await createWatchlist({ name: str(args.name), description: str(args.description) || undefined }, signal) + return { + payload: { kind: 'casework', action: 'watchlist-created', label: created.name, ref: created.id }, + summarize: () => `Created watchlist "${created.name}" (id ${created.id}). Use this id with add_watchlist_item.`, + } + }, + }, + + add_watchlist_item: { + schema: { + name: 'add_watchlist_item', + description: + 'Add one matching rule to a watchlist, so future reported entities matching it raise an alert. Needs a watchlist id from list_watchlists or create_watchlist.', + parameters: { + type: 'object', + properties: { + watchlistId: { type: 'string', description: 'Target watchlist id.' }, + kind: { + type: 'string', + enum: ['entity', 'value', 'tag', 'type'], + description: 'What to match on. "entity" is one exact entity id; "value" an exact indicator string; "tag" any entity carrying that tag; "type" every entity of a type, which is broad and will be noisy.', + }, + value: { type: 'string', description: 'The id, indicator, tag or type name to match, according to kind.' }, + maxReputation: { + type: 'integer', + description: 'Only alert on entities at or below this reputation, on the -3..+3 scale. Omit to alert on any match.', + }, + }, + required: ['watchlistId', 'kind', 'value'], + }, + }, + async run(args, signal) { + const kind = parseMatchKind(args.kind) + if (!kind) { + throw new Error(`kind must be one of ${MATCH_KINDS.join(', ')}`) + } + const item = await addWatchlistItem( + str(args.watchlistId), + { + kind, + value: str(args.value), + maxReputation: typeof args.maxReputation === 'number' ? Math.trunc(args.maxReputation) : undefined, + }, + signal, + ) + return { + payload: { kind: 'casework', action: 'watchlist-item-added', label: `${item.kind}:${item.value}`, ref: item.watchlist_id }, + summarize: () => `Added rule ${item.kind}:${item.value} to watchlist ${item.watchlist_id}.`, + } + }, + }, + + list_alerts: { + schema: { + name: 'list_alerts', + description: + "Alerts raised for the tenant when a reported entity matched a watchlist rule — what matched, which rule caught it, and the entity's reputation at the time. Use it to answer what has fired recently.", + parameters: { + type: 'object', + properties: { + limit: { type: 'integer', description: 'Max alerts, 1-100. Default 20.' }, + }, + }, + }, + async run(args, signal) { + const page = await listAlerts({ limit: Math.max(1, Math.min(100, int(args.limit, 20))) }, signal) + return { + payload: { kind: 'alerts', alerts: page.items, unread: page.unread }, + summarize: () => { + if (!page.items.length) return 'No alerts.' + const rows = page.items.map( + (a) => + `${a.created_at} ${a.entity_type}:${a.entity_value} reputation=${a.reputation ?? 'n/a'} matched ${a.matched_kind}:${a.matched_on} (watchlist ${a.watchlist_id})`, + ) + return `${page.items.length} alert(s):\n${rows.join('\n')}` + }, + } + }, + }, +} + +const CASE_STATUSES: readonly CaseStatus[] = ['open', 'in_progress', 'closed'] +const MATCH_KINDS = ['entity', 'value', 'tag', 'type'] as const + +function parseCaseStatus(v: unknown): CaseStatus | undefined { + return CASE_STATUSES.find((s) => s === v) +} + +/** 0 informational to 4 critical. Anything else is dropped, not clamped: a + * model that emitted 7 did not mean 4, and guessing its intent is worse than + * letting the case carry no severity. */ +function parseSeverity(v: unknown): number | undefined { + if (typeof v !== 'number' || !Number.isInteger(v)) return undefined + return v >= 0 && v <= 4 ? v : undefined +} + +function parseMatchKind(v: unknown): 'entity' | 'value' | 'tag' | 'type' | undefined { + return MATCH_KINDS.find((k) => k === v) +} + +function describeCase(c: Case): string { + return `${c.id} "${c.title}" status=${c.status} severity=${c.severity} pinned=${c.entities?.length ?? 0}` +} + +function describeWatchlist(w: Watchlist): string { + const rules = (w.items ?? []).map((i) => `${i.kind}:${i.value}`).join(', ') + return `${w.id} "${w.name}"${w.enabled ? '' : ' (disabled)'} rules=[${rules}]` +} + +function getToolSchemas() { + return Object.values(TOOLS).map((tool) => ({ + type: 'function' as const, + function: tool.schema, + })) } interface ChatChunk { @@ -322,6 +1003,116 @@ function sanitizeHistory(messages: ChatMessage[]): ChatMessage[] { }) } +/** + * A non-streaming completion. Used by the summarizer — compaction must not + * interleave with the main stream. + */ +async function plainCompletion( + model: string, + messages: ChatMessage[], + signal: AbortSignal | undefined, +): Promise { + const res = await fetch(`${BASE}/ai/chat`, { + method: 'POST', + headers: await authHeaders(), + body: JSON.stringify({ model, messages, stream: false }), + signal, + }) + if (!res.ok) { + let msg = `Request failed (${res.status})` + try { + const j = JSON.parse(await res.text()) + const err = j?.error + msg = (typeof err === 'string' ? err : err?.message) ?? msg + } catch { + // keep status message + } + throw new Error(msg) + } + const j = (await res.json()) as { choices?: { message?: { content?: string } }[] } + const content = j.choices?.[0]?.message?.content?.trim() + if (!content) throw new Error('summarizer returned nothing') + return `[Earlier conversation, condensed]\n${content}` +} + +function createSummarizer(model: string, config: CompactionConfig, signal?: AbortSignal): Summarizer { + const budget = Math.floor(config.maxInputTokens * SUMMARY_INPUT_RATIO) + return async (groups) => { + const transcript = renderForSummary(groups, budget) + return plainCompletion( + model, + [ + { + role: 'system', + content: + 'Condense this threat-intelligence conversation for use as context in an ongoing investigation. Keep every concrete finding: indicators, entity IDs, reputations, verdicts and which tools were already run with which arguments. Drop pleasantries and repetition. Be terse and factual.', + }, + { role: 'user', content: transcript }, + ], + signal, + ) + } +} + +async function handleToolCall( + call: ModelToolCall, + signal: AbortSignal | undefined, + emit: AgentEvents, + convo: ChatMessage[], +): Promise { + let args: Record = {} + try { + const parsed: unknown = JSON.parse(call.function.arguments || '{}') + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + args = parsed as Record + } + } catch { + // malformed tool args — call with {} rather than crashing the turn + } + const step: AgentStep = { + id: nextStepId(), + toolName: call.function.name, + args, + status: 'running', + } + emit.onStep?.({ ...step }) + const t0 = performance.now() + const tool = TOOLS[call.function.name] + if (!tool) { + step.status = 'error' + step.error = `Unknown tool "${call.function.name}"` + step.durationMs = performance.now() - t0 + step.result = step.error + emit.onStep?.({ ...step }) + convo.push({ role: 'tool', tool_call_id: call.id, content: step.error }) + return + } + try { + const { payload, summarize: toSummary } = await tool.run(args, signal) + step.status = 'done' + step.payload = payload + step.durationMs = performance.now() - t0 + step.result = toSummary() + emit.onStep?.({ ...step }) + convo.push({ role: 'tool', tool_call_id: call.id, content: step.result }) + } catch (e) { + step.status = 'error' + step.error = e instanceof Error ? e.message : String(e) + step.durationMs = performance.now() - t0 + step.result = `Tool failed: ${step.error}` + emit.onStep?.({ ...step }) + // Feed the failure back so the model can adapt rather than stall. + convo.push({ role: 'tool', tool_call_id: call.id, content: step.result }) + } +} + +/** + * Runs one investigation to completion. + * + * The loop is: stream a completion → if the model emitted tool calls, execute + * them all concurrently, append the results as `tool` messages, and go again → + * otherwise the text is the final answer. + */ export async function runAgent( question: string, opts: { @@ -329,12 +1120,41 @@ export async function runAgent( history: ChatMessage[] signal?: AbortSignal events?: AgentEvents + compaction?: Partial }, -): Promise<{ answer: string; history: ChatMessage[] }> { +): Promise { + const { model, signal, events } = opts + const emit = events ?? {} + const config: CompactionConfig = { ...DEFAULT_COMPACTION, ...opts.compaction } + const summarize = createSummarizer(model, config, signal) + const toolSchemas = getToolSchemas() + const convo: ChatMessage[] = [...opts.history, { role: 'user', content: question }] - const emit = opts.events ?? {} + let lastText = '' for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + if (signal?.aborted) { + return { answer: lastText, history: convo, stoppedReason: 'aborted' } + } + + // Compact before the call, not after: the point is to keep THIS request + // under the model's input limit. + const full = [{ role: 'system' as const, content: SYSTEM_PROMPT }, ...sanitizeHistory(convo)] + const compacted = await compactMessages(full, config, summarize) + if (compacted.compacted) { + emit.onCompaction?.({ + droppedGroups: compacted.droppedGroups, + tokensBefore: compacted.tokensBefore, + tokensAfter: compacted.tokensAfter, + }) + // The first entry is always the system prompt (never summarized); the + // second is the summary when compaction happened. The rest is the new + // convo: old turns replaced by their summary, recent turns verbatim. + const next = compacted.messages.slice(2) + convo.length = 0 + convo.push(...next) + } + const requestMessages: ChatMessage[] = [ { role: 'system', content: SYSTEM_PROMPT }, ...sanitizeHistory(convo), @@ -342,8 +1162,8 @@ export async function runAgent( const res = await fetch(`${BASE}/ai/chat`, { method: 'POST', headers: await authHeaders(), - body: JSON.stringify({ model: opts.model, messages: requestMessages, tools: TOOL_SCHEMAS, stream: true }), - signal: opts.signal, + body: JSON.stringify({ model, messages: requestMessages, tools: toolSchemas, stream: true }), + signal, }) if (!res.ok || !res.body) { let msg = `Request failed (${res.status})` @@ -354,7 +1174,7 @@ export async function runAgent( } catch { // non-JSON error body — keep the HTTP status message } - throw new Error(msg) + throw new ProxyError(msg, res.status) } let text = '' @@ -401,47 +1221,27 @@ export async function runAgent( } const calls = toolCalls.filter((c): c is ModelToolCall => Boolean(c)) + if (calls.length === 0) { + lastText = text + convo.push({ role: 'assistant', content: text }) + return { answer: text, history: convo, stoppedReason: 'complete' } + } + convo.push({ role: 'assistant', - content: calls.length ? text || null : text, - ...(calls.length ? { tool_calls: calls } : {}), + content: text || null, + tool_calls: calls, }) - if (calls.length === 0) { - return { answer: text, history: convo } - } - - for (const call of calls) { - let args: Record = {} - try { - args = JSON.parse(call.function.arguments || '{}') - } catch { - // malformed tool args — call with {} rather than crashing the turn - } - const step: AgentStep = { - id: call.id, - toolName: call.function.name, - args, - status: 'running', - } - emit.onStep?.({ ...step }) - const t0 = performance.now() - let result: string - try { - result = await runTool(call.function.name, args, step) - } catch (e) { - step.status = 'error' - step.error = e instanceof Error ? e.message : String(e) - result = `Tool failed: ${step.error}` - } - step.durationMs = performance.now() - t0 - if (step.status !== 'error') step.status = 'done' - step.result = result - emit.onStep?.({ ...step }) - convo.push({ role: 'tool', tool_call_id: call.id, content: result }) - } + // Tool calls within a turn are independent, so run them concurrently. + await Promise.all( + calls.map((call) => handleToolCall(call, signal, emit, convo)), + ) } - throw new Error('Reached the reasoning-step limit before finishing.') + + // Soft limit: return what we have rather than throwing — the analyst keeps + // the partial answer and the thread stays continuable. + return { answer: lastText, history: convo, stoppedReason: 'max-turns' } } -export { detailsToGeo, type ToolPayload } +export { detailsToGeo, estimateTokens } diff --git a/frontend/src/features/threat-intel/console/canvas-content.tsx b/frontend/src/features/threat-intel/console/canvas-content.tsx index 4842704a2..9200639f7 100644 --- a/frontend/src/features/threat-intel/console/canvas-content.tsx +++ b/frontend/src/features/threat-intel/console/canvas-content.tsx @@ -6,7 +6,7 @@ import type { CanvasPanel } from './types' import { EntityInspector } from './entity-inspector' import { RelationGraph } from './relation-graph' import { relationsToGraph } from './agent' -import type { RawRelation } from './agent' +import type { RawRelations } from './agent' export function CanvasContent({ panel, @@ -33,7 +33,7 @@ function GraphPanel({ entityId, onPivot }: { entityId: string; onPivot(target: { const { t } = useTranslation() const relations = useQuery({ queryKey: ['ti-analyst-relations', entityId], - queryFn: async () => relationsToGraph(entityId, '', (await proxyFetch(`/entity/${encodeURIComponent(entityId)}/relations`)) ?? []), + queryFn: async () => relationsToGraph(entityId, '', (await proxyFetch(`/entity/${encodeURIComponent(entityId)}/relations`)) ?? []), staleTime: 5 * 60 * 1000, }) diff --git a/frontend/src/features/threat-intel/console/compaction.spec.ts b/frontend/src/features/threat-intel/console/compaction.spec.ts new file mode 100644 index 000000000..511f08051 --- /dev/null +++ b/frontend/src/features/threat-intel/console/compaction.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + compactMessages, + digestGroups, + estimateTokens, + groupMessages, + renderForSummary, + DEFAULT_COMPACTION, +} from './compaction' +import type { ChatMessage } from './types' + +const user = (c: string): ChatMessage => ({ role: 'user', content: c }) +const asst = (c: string): ChatMessage => ({ role: 'assistant', content: c }) +const asstCall = (id: string, c = ''): ChatMessage => ({ + role: 'assistant', + content: c || null, + tool_calls: [{ id, type: 'function', function: { name: 'lookup_indicator', arguments: '{"value":"8.8.8.8"}' } }], +}) +const toolMsg = (id: string, c: string): ChatMessage => ({ role: 'tool', tool_call_id: id, content: c }) + +describe('compaction', () => { + it('counts tool_calls arguments in the estimate', () => { + const plain = [user('x')] + const withCalls = [asstCall('a1')] + expect(estimateTokens(withCalls)).toBeGreaterThan(estimateTokens(plain)) + }) + + it('groups an assistant tool call with every matching tool message', () => { + const groups = groupMessages([user('q'), asstCall('a1'), toolMsg('a1', 'r1'), toolMsg('a1', 'r2'), asst('final')]) + expect(groups.length).toBe(3) + expect(groups[1].messages.length).toBe(3) // asst + 2 tool messages + expect(groups[2].messages[0].role).toBe('assistant') + }) + + it('does not compact under budget', async () => { + const res = await compactMessages([user('hi'), asst('hello')], DEFAULT_COMPACTION) + expect(res.compacted).toBe(false) + expect(res.droppedGroups).toBe(0) + }) + + it('folds oldest groups into a summary, keeps recent verbatim', async () => { + const big = 'filler '.repeat(2000) + const messages: ChatMessage[] = [ + user('q1 ' + big), + asst('a1'), + user('q2 ' + big), + asst('a2'), + user('q3'), + asst('a3'), + user('q4'), + asst('a4'), + ] + const config = { ...DEFAULT_COMPACTION, maxInputTokens: 1000, keepRecentGroups: 2 } + const res = await compactMessages(messages, config) + expect(res.compacted).toBe(true) + expect(res.tokensAfter).toBeLessThanOrEqual(res.tokensBefore) + // Summary message is present, recent groups verbatim. + const summary = res.messages.find((m) => m.content?.includes('[Earlier conversation, condensed]')) + expect(summary).toBeDefined() + expect(res.messages.some((m) => m.content === 'a4')).toBe(true) + expect(res.messages.some((m) => m.content === 'q1 ' + big)).toBe(false) + }) + + it('digest records questions and tools run', () => { + const groups = groupMessages([user('what is 8.8.8.8?'), asstCall('a1'), toolMsg('a1', 'r'), asst('it is good')]) + const digest = digestGroups(groups) + expect(digest).toContain('what is 8.8.8.8?') + expect(digest).toContain('lookup_indicator') + expect(digest).toContain('1 earlier answer(s)') + }) + + it('bounds the summary render to budget, oldest end first', () => { + const groups = groupMessages([user('old ' + 'x'.repeat(5000)), user('new ' + 'y'.repeat(5000))]) + const out = renderForSummary(groups, 100) // ~400 chars budget + expect(out.length).toBeLessThanOrEqual(100 * 4) + expect(out).toContain('y') // recent survives + }) +}) diff --git a/frontend/src/features/threat-intel/console/compaction.ts b/frontend/src/features/threat-intel/console/compaction.ts new file mode 100644 index 000000000..eee66ad93 --- /dev/null +++ b/frontend/src/features/threat-intel/console/compaction.ts @@ -0,0 +1,234 @@ +import type { ChatMessage } from './types' + +/** + * Conversation compaction for the agent loop. + * + * A long investigation accumulates tool traffic fast and the model re-sends it + * every turn. Left alone the request exceeds the model's input limit and the + * run fails mid-investigation. Compaction replaces the oldest turns with a + * summary and keeps the recent ones verbatim. + * + * Pure: it does no networking. The caller supplies a summarizer (or omits one, + * in which case a deterministic digest is used). This keeps the logic testable + * and free of a dependency on the transport. + */ + +export interface CompactionConfig { + /** Compact once the estimated input exceeds this many tokens. */ + maxInputTokens: number + /** How much of the budget to aim for after compacting, as a fraction. */ + targetRatio: number + /** Turn groups always kept verbatim, however large. */ + keepRecentGroups: number +} + +export const DEFAULT_COMPACTION: CompactionConfig = { + // The server-side model's advertised limit is not plumbed to the client, so + // this is the standing budget. Overridable per run. + maxInputTokens: 96_000, + targetRatio: 0.5, + keepRecentGroups: 4, +} + +/** ~4 characters per token; the per-message constant covers role/delimiter overhead. */ +export function estimateTokens(messages: ChatMessage[]): number { + const PER_MESSAGE_OVERHEAD = 4 + let chars = 0 + for (const message of messages) { + chars += (message.content ?? '').length + if (message.role === 'assistant' && message.tool_calls) { + for (const call of message.tool_calls) { + chars += (call.function?.name ?? '').length + chars += (call.function?.arguments ?? '').length + } + } + chars += PER_MESSAGE_OVERHEAD * 4 + } + return Math.ceil(chars / 4) +} + +/** + * A contiguous run of messages that must survive or be dropped together. + * The API rejects a request where an assistant message with `tool_calls` is + * not followed by a `tool` message for every call id. Naively slicing by count + * will cut between the two and produce a 400 that looks like a model problem. + */ +export interface MessageGroup { + messages: ChatMessage[] +} + +export function groupMessages(messages: ChatMessage[]): MessageGroup[] { + const groups: MessageGroup[] = [] + let current: ChatMessage[] = [] + const flush = () => { + if (current.length > 0) { + groups.push({ messages: current }) + current = [] + } + } + for (const message of messages) { + if (message.role === 'tool') { + // Belongs with the assistant turn that requested it. + current.push(message) + continue + } + flush() + current = [message] + } + flush() + return groups +} + +/** Deterministic digest used when no summarizer is supplied or one fails. */ +export function digestGroups(groups: MessageGroup[]): string { + const questions: string[] = [] + const toolCalls: string[] = [] + let assistantAnswers = 0 + for (const group of groups) { + for (const message of group.messages) { + if (message.role === 'user' && message.content) { + questions.push(message.content.slice(0, 200)) + } + if (message.role === 'assistant') { + if (message.tool_calls) { + for (const call of message.tool_calls) { + const args = (call.function?.arguments ?? '').slice(0, 120) + toolCalls.push(`${call.function?.name ?? 'unknown'}(${args})`) + } + } else if (message.content) { + assistantAnswers += 1 + } + } + } + } + const lines = ['[Earlier conversation, condensed]'] + if (questions.length > 0) lines.push(`Questions asked: ${questions.join(' | ')}`) + if (toolCalls.length > 0) lines.push(`Tools already run: ${toolCalls.join(', ')}`) + if (assistantAnswers > 0) lines.push(`${assistantAnswers} earlier answer(s) were given.`) + lines.push('Do not repeat tool calls already made above unless the data may have changed.') + return lines.join('\n') +} + +/** Fraction of the input budget a summarizer's own request may use. */ +export const SUMMARY_INPUT_RATIO = 0.5 +/** ~4 characters per token, matching estimateTokens. */ +const CHARS_PER_TOKEN = 4 +const DIGEST_RESERVE = 0.3 + +function renderGroup(group: MessageGroup): string { + return group.messages + .map((message) => { + const calls = + message.role === 'assistant' && message.tool_calls + ? message.tool_calls + .map((call) => `${call.function?.name ?? 'unknown'}(${call.function?.arguments ?? ''})`) + .join(', ') + : '' + return `${message.role}: ${message.content ?? ''}${calls ? ` [tools: ${calls}]` : ''}` + }) + .join('\n') +} + +const clamp = (text: string, max: number) => (text.length > max ? text.slice(0, max) : text) + +/** + * Renders dropped turns as text for a summarizer, bounded to `maxTokens`. + * Drops from the oldest end, representing the rest with `digestGroups` so + * nothing vanishes without trace. + */ +export function renderForSummary(groups: MessageGroup[], maxTokens: number): string { + const budgetChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN + if (groups.length === 0) return '' + + const digestBudget = Math.floor(budgetChars * DIGEST_RESERVE) + const verbatimBudget = Math.max(0, budgetChars - digestBudget - 1) + + const rendered = groups.map(renderGroup) + const kept: string[] = [] + let chars = 0 + let firstKept = groups.length + + for (let i = groups.length - 1; i >= 0; i--) { + const cost = rendered[i].length + 1 + if (chars + cost > verbatimBudget && kept.length > 0) break + kept.unshift(rendered[i]) + chars += cost + firstKept = i + } + + const dropped = groups.slice(0, firstKept) + const body = kept.join('\n') + + if (dropped.length === 0) { + return body.length > budgetChars ? body.slice(0, budgetChars) : body + } + return `${clamp(digestGroups(dropped), digestBudget)}\n${clamp(body, verbatimBudget)}` +} + +export interface CompactionResult { + messages: ChatMessage[] + compacted: boolean + tokensBefore: number + tokensAfter: number + droppedGroups: number +} + +export type Summarizer = (groups: MessageGroup[]) => Promise + +/** + * Compacts a conversation if it exceeds the configured budget. The system + * prompt is always preserved verbatim. + */ +export async function compactMessages( + messages: ChatMessage[], + config: CompactionConfig, + summarize?: Summarizer, +): Promise { + const tokensBefore = estimateTokens(messages) + if (tokensBefore <= config.maxInputTokens) { + return { messages, compacted: false, tokensBefore, tokensAfter: tokensBefore, droppedGroups: 0 } + } + + const system = messages[0]?.role === 'system' ? [messages[0]] : [] + const rest = messages.slice(system.length) + const groups = groupMessages(rest) + + const maxDroppable = Math.max(0, groups.length - config.keepRecentGroups) + const target = config.maxInputTokens * config.targetRatio + const systemTokens = estimateTokens(system) + let dropCount = 0 + for (let candidate = 1; candidate <= maxDroppable; candidate++) { + dropCount = candidate + const remaining = groups.slice(candidate).flatMap((g) => g.messages) + if (systemTokens + estimateTokens(remaining) <= target) break + } + + if (dropCount <= 0) { + return { messages, compacted: false, tokensBefore, tokensAfter: tokensBefore, droppedGroups: 0 } + } + + const dropped = groups.slice(0, dropCount) + const retained = groups.slice(dropCount) + + let summary: string + try { + summary = summarize ? await summarize(dropped) : digestGroups(dropped) + } catch { + // A failed summarization must not fail the investigation. + summary = digestGroups(dropped) + } + + const compactedMessages: ChatMessage[] = [ + ...system, + { role: 'system', content: summary }, + ...retained.flatMap((g) => g.messages), + ] + + return { + messages: compactedMessages, + compacted: true, + tokensBefore, + tokensAfter: estimateTokens(compactedMessages), + droppedGroups: dropCount, + } +} diff --git a/frontend/src/features/threat-intel/console/entity-cards.tsx b/frontend/src/features/threat-intel/console/entity-cards.tsx new file mode 100644 index 000000000..03a5a835f --- /dev/null +++ b/frontend/src/features/threat-intel/console/entity-cards.tsx @@ -0,0 +1,178 @@ +import { Check, Copy, MapPin } from 'lucide-react' +import { useState } from 'react' +import type { EntityDetailAttributes, EntityHistoryRecord, EntityDetails } from './types' + +/** + * Entity cards for the inspector, ported from the ThreatWinds analyst + * console. The inspector renders one flat dossier; these break it into the + * cards TW uses so a verdict, metadata, history and geo each get their own + * scan-able block. + */ + +const SEVERITY: Record = { + '-3': { label: 'Alarming', cls: 'text-destructive border-destructive/40 bg-destructive/10', bar: 'bg-destructive' }, + '-2': { label: 'Worrying', cls: 'text-warning border-warning/40 bg-warning/10', bar: 'bg-warning' }, + '-1': { label: 'Concerning', cls: 'text-warning border-warning/40 bg-warning/10', bar: 'bg-warning' }, + 0: { label: 'Indefinable', cls: 'text-muted-foreground border-border bg-muted/40', bar: 'bg-muted-foreground' }, + 1: { label: 'Adequate', cls: 'text-primary border-primary/40 bg-primary/10', bar: 'bg-primary' }, + 2: { label: 'Good', cls: 'text-primary border-primary/40 bg-primary/10', bar: 'bg-primary' }, + 3: { label: 'Exceptional', cls: 'text-primary border-primary/40 bg-primary/10', bar: 'bg-primary' }, +} + +export function severityOf(score: number) { + return SEVERITY[Math.max(-3, Math.min(3, Math.round(score)))] ?? SEVERITY[0] +} + +/** + * The verdict, stated in words and in figures, coloured by severity. One + * card instead of four near-identical gauges: the score leads, the accuracy + * rides along, and the spread shows only when sources disagree. + */ +export function EntityVerdict({ attributes }: { attributes: EntityDetailAttributes }) { + const [copied, setCopied] = useState(false) + const s = severityOf(attributes.reputation_score) + + async function copyValue() { + try { + await navigator.clipboard.writeText(attributes.value) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // Clipboard access can be refused outright. Silence is fine — the value + // is still selectable text. + } + } + + const spread = + attributes.worst_reputation_score !== attributes.best_reputation_score + ? `${attributes.worst_reputation_score}…${attributes.best_reputation_score}` + : null + + return ( +
+
+ + {s.label} + + + {attributes.reputation_score > 0 ? `+${attributes.reputation_score}` : attributes.reputation_score} + + {spread && ( + + spread {spread} + + )} + + accuracy {attributes.accuracy_score}/3 + + +
+
+
+
+ {attributes.description && ( +

{attributes.description}

+ )} +
+ ) +} + +/** Flat metadata chips, as before — only rendered when metadata exists. */ +export function MetadataCard({ metadata }: { metadata: Record }) { + return ( +
+ {Object.entries(metadata).map(([k, v]) => ( + + {k}: {v} + + ))} +
+ ) +} + +/** Grouped extended metadata, when the corpus carries it. */ +export function ExtendedMetadataCard({ entries }: { entries: Record> }) { + const groups = Object.entries(entries) + if (!groups.length) return null + return ( +
+

Extended metadata

+ {groups.map(([group, fields], i) => ( +
0 ? 'mt-2 border-t border-border/30 pt-2' : 'mt-2'}> +
{group}
+
+ {Object.entries(fields).map(([key, val]) => ( +
+
{key}
+
{val}
+
+ ))} +
+
+ ))} +
+ ) +} + +/** One reported version of the entity. Reputation movement is the story. */ +export function HistoryRecordCard({ record, previous }: { record: EntityHistoryRecord; previous?: EntityHistoryRecord }) { + const changed = previous !== undefined && previous.reputation !== record.reputation + const ts = Date.parse(record.timestamp) + return ( +
+ + {record.reputation > 0 ? `+${record.reputation}` : record.reputation} + +
+
+ reported by {record.author || 'unknown'} +
+
+ {Number.isNaN(ts) ? '—' : new Date(ts).toLocaleString()} +
+
+ {changed && ( + reputation changed + )} +
+ ) +} + +/** One geolocation, as a card rather than a bare row. */ +export function GeoCard({ latitude, longitude, radius }: { latitude: number; longitude: number; radius: number }) { + return ( +
+ +
+
+ {latitude.toFixed(6)}°, {longitude.toFixed(6)}° +
+
r={radius}m
+
+
+ ) +} + +export function firstSeenLabel(details: EntityDetails): string | null { + const iso = details.attributes.first_seen + const ts = iso ? Date.parse(iso) : NaN + return Number.isNaN(ts) ? null : new Date(ts).toLocaleDateString() +} diff --git a/frontend/src/features/threat-intel/console/entity-inspector.tsx b/frontend/src/features/threat-intel/console/entity-inspector.tsx index df6a72e4b..eed8d28b6 100644 --- a/frontend/src/features/threat-intel/console/entity-inspector.tsx +++ b/frontend/src/features/threat-intel/console/entity-inspector.tsx @@ -1,11 +1,12 @@ import { useQuery } from '@tanstack/react-query' -import { MapPin, Sparkles } from 'lucide-react' +import { Sparkles } from 'lucide-react' import { useTranslation } from 'react-i18next' import { proxyFetch } from './proxy' import { normalizeDetails, relationsToGraph } from './agent' -import type { EntityDetails } from './types' -import type { RawDetails, RawRelation } from './agent' +import type { EntityDetails, EntityHistoryRecord } from './types' +import type { RawDetails, RawRelations } from './agent' import { RelationGraph } from './relation-graph' +import { EntityVerdict, ExtendedMetadataCard, GeoCard, HistoryRecordCard, MetadataCard } from './entity-cards' interface EntityInspectorProps { entityId: string @@ -24,7 +25,25 @@ export function EntityInspector({ entityId, onPivot, onAskAgent }: EntityInspect const relations = useQuery({ queryKey: ['ti-analyst-relations', entityId], - queryFn: async () => relationsToGraph(entityId, '', (await proxyFetch(`/entity/${encodeURIComponent(entityId)}/relations`)) ?? []), + queryFn: async () => relationsToGraph(entityId, '', (await proxyFetch(`/entity/${encodeURIComponent(entityId)}/relations`)) ?? []), + staleTime: 10 * 60 * 1000, + }) + + // History endpoint shape is upstream-dependent: accept an array or a wrapper. + const history = useQuery({ + queryKey: ['ti-analyst-history', entityId], + queryFn: async (): Promise => { + const raw = await proxyFetch(`/entity/${encodeURIComponent(entityId)}/history`) + const arr = Array.isArray(raw) ? raw : ((raw as Record | null)?.items as unknown[]) ?? ((raw as Record | null)?.records as unknown[]) ?? [] + return arr.map((r) => { + const x = r as Record + return { + timestamp: String(x.timestamp ?? x.time ?? ''), + reputation: Number(x.reputation ?? x.reputation_score ?? 0), + author: x.author ? String(x.author) : undefined, + } + }) + }, staleTime: 10 * 60 * 1000, }) @@ -47,15 +66,13 @@ export function EntityInspector({ entityId, onPivot, onAskAgent }: EntityInspect } const a = d.attributes - const malicious = a.reputation_score < 0 return (
{a.type} @@ -71,29 +88,10 @@ export function EntityInspector({ entityId, onPivot, onAskAgent }: EntityInspect
-
- - - - -
+ - {a.description && ( -

{a.description}

- )} - - {d.metadata && ( -
- {Object.entries(d.metadata).map(([k, v]) => ( - - {k}: {String(v)} - - ))} -
- )} + {d.metadata && } + {d.extendedMetadata && }

@@ -115,24 +113,24 @@ export function EntityInspector({ entityId, onPivot, onAskAgent }: EntityInspect )}

+ {history.data && history.data.length > 0 && ( +
+

+ {t('tiAnalyst.entity.history')} +

+ {history.data.map((record, i) => ( + + ))} +
+ )} + {d.geolocations.length > 0 && (

{t('tiAnalyst.entity.geolocations')}

{d.geolocations.map((geo, i) => ( -
- - - {geo.lat.toFixed(4)}°, {geo.lng.toFixed(4)}° - - - {t('tiAnalyst.entity.radius', { radius: geo.radius })} - -
+ ))}
)} @@ -153,6 +151,9 @@ export function EntityInspector({ entityId, onPivot, onAskAgent }: EntityInspect {assoc.type} {assoc.value} + + {assoc.reputation > 0 ? `+${assoc.reputation}` : assoc.reputation} + ))} @@ -160,13 +161,3 @@ export function EntityInspector({ entityId, onPivot, onAskAgent }: EntityInspect
) } - -function Stat({ label, value, score }: { label: string; value: string; score: number }) { - return ( -
-
{label}
-
{value || '—'}
-
{score}
-
- ) -} diff --git a/frontend/src/features/threat-intel/console/markdown.tsx b/frontend/src/features/threat-intel/console/markdown.tsx new file mode 100644 index 000000000..d66471b77 --- /dev/null +++ b/frontend/src/features/threat-intel/console/markdown.tsx @@ -0,0 +1,303 @@ +import { useMemo } from 'react' +import type { JSX } from 'react' + + +type Inline = string | JSX.Element + +const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*|_[^_]+_)/g + +function renderInline(text: string, keyPrefix: string): Inline[] { + const parts = text.split(INLINE_PATTERN).filter(Boolean) + return parts.map((part, i) => { + const key = `${keyPrefix}-${i}` + if (part.startsWith('**') && part.endsWith('**') && part.length > 4) { + return ( + + {part.slice(2, -2)} + + ) + } + if (part.startsWith('`') && part.endsWith('`') && part.length > 2) { + return ( + + {part.slice(1, -1)} + + ) + } + if ( + ((part.startsWith('*') && part.endsWith('*')) || + (part.startsWith('_') && part.endsWith('_'))) && + part.length > 2 + ) { + return ( + + {part.slice(1, -1)} + + ) + } + return part + }) +} + +export type Align = 'start' | 'center' | 'end' + +interface Block { + type: 'p' | 'h' | 'ul' | 'ol' | 'table' | 'code' + level?: number + lines: string[] + head?: string[] + rows?: string[][] + align?: Align[] + lang?: string +} + +/** ```` ```sh ```` or a bare ```` ``` ````. The language is optional. */ +const FENCE_OPEN = /^\s*```\s*([\w+#.-]*)\s*$/ +const FENCE_CLOSE = /^\s*```\s*$/ + +/** + * Splits one table row into cells. + * + * Aware of backticks, because a threat-intel table is full of inline code and + * a regex or a pattern can legitimately contain a pipe — `a|b` must stay one + * cell rather than silently becoming two and shifting every column after it. + * Escaped `\|` is honoured for the same reason. + */ +export function splitRow(line: string): string[] { + const body = line.trim().replace(/^\|/, '').replace(/\|$/, '') + const cells: string[] = [] + let cell = '' + let inCode = false + + for (let i = 0; i < body.length; i++) { + const ch = body[i] + if (ch === '\\' && body[i + 1] === '|') { + cell += '|' + i++ + continue + } + if (ch === '`') inCode = !inCode + if (ch === '|' && !inCode) { + cells.push(cell.trim()) + cell = '' + continue + } + cell += ch + } + cells.push(cell.trim()) + return cells +} + +/** `---`, `:---`, `---:` or `:---:` in every cell — the row under the header. */ +export function isDelimiterRow(line: string): boolean { + if (!line.includes('-')) return false + const cells = splitRow(line) + return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c)) +} + +function alignOf(cell: string): Align { + const left = cell.startsWith(':') + const right = cell.endsWith(':') + if (left && right) return 'center' + if (right) return 'end' + return 'start' +} + +export function parseBlocks(source: string): Block[] { + const blocks: Block[] = [] + let current: Block | null = null + + const flush = () => { + if (current) blocks.push(current) + current = null + } + + const all = source.split('\n') + for (let index = 0; index < all.length; index++) { + const rawLine = all[index] + const line = rawLine.trimEnd() + + // Fenced code is checked before anything else: a shell command beginning + // with `-` or a pattern containing `|` would otherwise be reformatted. + // An unterminated fence runs to the end of the input — the answer is + // streamed a token at a time, so a half-arrived fence must already + // render as code. + const opening = FENCE_OPEN.exec(line) + if (opening) { + flush() + const body: string[] = [] + let cursor = index + 1 + while (cursor < all.length && !FENCE_CLOSE.test(all[cursor])) { + body.push(all[cursor]) + cursor++ + } + blocks.push({ type: 'code', lines: body, lang: opening[1] || undefined }) + index = cursor + continue + } + + if (!line.trim()) { + flush() + continue + } + + // A table is a header row, a delimiter row, then body rows. + const next = all[index + 1]?.trimEnd() ?? '' + if (line.includes('|') && isDelimiterRow(next)) { + const head = splitRow(line) + const align = splitRow(next).map(alignOf) + if (align.length === head.length) { + flush() + const rows: string[][] = [] + let cursor = index + 2 + while (cursor < all.length && all[cursor].includes('|') && all[cursor].trim()) { + const cells = splitRow(all[cursor]) + while (cells.length < head.length) cells.push('') + rows.push(cells.slice(0, head.length)) + cursor++ + } + blocks.push({ type: 'table', lines: [], head, rows, align }) + index = cursor - 1 + continue + } + } + + const heading = /^(#{1,4})\s+(.*)$/.exec(line) + if (heading) { + flush() + blocks.push({ type: 'h', level: heading[1].length, lines: [heading[2]] }) + continue + } + + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line) + if (bullet) { + if (current?.type !== 'ul') { + flush() + current = { type: 'ul', lines: [] } + } + current.lines.push(bullet[1]) + continue + } + + const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line) + if (numbered) { + if (current?.type !== 'ol') { + flush() + current = { type: 'ol', lines: [] } + } + current.lines.push(numbered[1]) + continue + } + + if (current?.type !== 'p') { + flush() + current = { type: 'p', lines: [] } + } + current.lines.push(line.trim()) + } + + flush() + return blocks +} + +export function Markdown({ children }: { children: string }) { + const blocks = useMemo(() => parseBlocks(children), [children]) + + return ( +
+ {blocks.map((block, i) => { + const key = `b${i}` + switch (block.type) { + case 'h': + return ( +
+ {renderInline(block.lines[0], key)} +
+ ) + case 'ul': + return ( +
    + {block.lines.map((line, j) => ( +
  • + + {renderInline(line, `${key}-${j}`)} +
  • + ))} +
+ ) + case 'ol': + return ( +
    + {block.lines.map((line, j) => ( +
  1. + {j + 1}. + {renderInline(line, `${key}-${j}`)} +
  2. + ))} +
+ ) + case 'code': + return ( + // Scrolls inside itself; `renderInline` deliberately not applied — + // asterisks and backticks in code are characters, not formatting. +
+                
+                  {block.lines.join('\n')}
+                
+              
+ ) + + case 'table': { + const align = block.align ?? [] + const cellAlign = (c: number) => + align[c] === 'center' ? 'text-center' : align[c] === 'end' ? 'text-end' : 'text-start' + return ( +
+ + + + {(block.head ?? []).map((cell, c) => ( + + ))} + + + + {(block.rows ?? []).map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + + ))} + +
+ {renderInline(cell, `${key}-h${c}`)} +
+ {renderInline(cell, `${key}-r${r}c${c}`)} +
+
+ ) + } + default: + return

{renderInline(block.lines.join(' '), key)}

+ } + })} +
+ ) +} diff --git a/frontend/src/features/threat-intel/console/step-panel.ts b/frontend/src/features/threat-intel/console/step-panel.ts index 525fe3e80..d120b0123 100644 --- a/frontend/src/features/threat-intel/console/step-panel.ts +++ b/frontend/src/features/threat-intel/console/step-panel.ts @@ -21,6 +21,18 @@ export function panelForStep(step: AgentStep): PanelRequest | null { return payload.results.length ? { kind: 'search', title: payload.query, ref: payload.query } : null case 'counts': return null + case 'report': + return null + case 'link': + return null + case 'cases': + return null + case 'watchlists': + return null + case 'alerts': + return null + case 'casework': + return null } } diff --git a/frontend/src/features/threat-intel/console/store.ts b/frontend/src/features/threat-intel/console/store.ts index d93093d3f..c718c05d7 100644 --- a/frontend/src/features/threat-intel/console/store.ts +++ b/frontend/src/features/threat-intel/console/store.ts @@ -1,48 +1,65 @@ import { useSyncExternalStore } from 'react' import type { CanvasPanel, Conversation, LoopTurn, PanelGeometry, PanelRequest } from './types' +import { proxyFetch } from './proxy' -const CONV_KEY = 'utmstack.ti-analyst.conversations' const CANVAS_KEY = 'utmstack.ti-analyst.canvases' +// Legacy localStorage for conversations, used only as a fallback when the +// backend is unavailable or as a one-time migration source. +const CONV_KEY = 'utmstack.ti-analyst.conversations' interface Store { conversations: Conversation[] canvases: Record } -function load(): Store { - const read = (key: string, fallback: T): T => { - try { - const raw = localStorage.getItem(key) - return raw ? (JSON.parse(raw) as T) : fallback - } catch { - return fallback - } - } - return { - conversations: read(CONV_KEY, []), - canvases: read>(CANVAS_KEY, {}), +function loadCanvases(): Record { + try { + const raw = localStorage.getItem(CANVAS_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} } } -let state: Store = load() -const listeners = new Set<() => void>() +function readLegacyConversations(): Conversation[] { + try { + const raw = localStorage.getItem(CONV_KEY) + return raw ? (JSON.parse(raw) as Conversation[]) : [] + } catch { + return [] + } +} -function commit(next: Store) { - state = next +function writeLegacyConversations(conversations: Conversation[]) { try { - localStorage.setItem(CONV_KEY, JSON.stringify(next.conversations)) - localStorage.setItem(CANVAS_KEY, JSON.stringify(next.canvases)) + localStorage.setItem(CONV_KEY, JSON.stringify(conversations)) } catch { + // quota — ignore, the backend is the real store } - listeners.forEach((l) => l()) } -const noop = () => {} +let state: Store = { conversations: [], canvases: loadCanvases() } +const listeners = new Set<() => void>() +let backendReady = false + +/** + * Whether the store has finished hydrating from the backend (or falling back + * to localStorage). Consumers that auto-create must wait for this — creating + * into an unhydrated store orphans the conversation behind the backend's list. + */ +export function isConsoleStoreReady(): boolean { + return backendReady +} + +function emit() { + listeners.forEach((l) => l()) +} function subscribe(listener: () => void) { listeners.add(listener) return () => listeners.delete(listener) } +export { subscribe } function getSnapshot() { return state } @@ -55,6 +72,96 @@ function makeId(prefix: string): string { return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` } +// ---- backend persistence (best-effort; falls back to localStorage) ---- + +function toDTO(conv: Conversation) { + return { id: conv.id, title: conv.title, turns: conv.turns, history: conv.history } +} + +interface ConversationDTO { + id: string + title: string + turns: LoopTurn[] + history: Conversation['history'] + updatedAt: string +} + +function mapDTO(dto: ConversationDTO): Conversation { + const updatedAt = Date.parse(dto.updatedAt) + return { id: dto.id, title: dto.title, turns: dto.turns ?? [], history: dto.history ?? [], updatedAt: Number.isNaN(updatedAt) ? Date.now() : updatedAt } +} + +async function createOnBackend(conv: Conversation) { + await proxyFetch('/analyst/conversations', { method: 'POST', body: JSON.stringify(toDTO(conv)) }) +} + +async function putOnBackend(conv: Conversation) { + await proxyFetch(`/analyst/conversations/${encodeURIComponent(conv.id)}`, { method: 'PUT', body: JSON.stringify(toDTO(conv)) }) +} + +async function deleteOnBackend(id: string) { + await proxyFetch(`/analyst/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +/** + * Hydrates the store from the backend once. Seeds from legacy localStorage if + * the backend returns nothing (one-time migration); if the backend is + * unreachable, loads conversations from localStorage and stays local. + */ +let initPromise: Promise | null = null + +export async function initConsoleStore(): Promise { + if (backendReady) return + if (initPromise) return initPromise + initPromise = (async () => { + try { + const res = await proxyFetch<{ conversations: ConversationDTO[] }>('/analyst/conversations') + const dtos = res.conversations ?? [] + let conversations = dtos.map(mapDTO) + if (!conversations.length) { + // Empty on the backend — migrate anything the analyst had locally. + const legacy = readLegacyConversations() + conversations = legacy + for (const conv of legacy) await createOnBackend(conv).catch(() => undefined) + } + backendReady = true + state = { ...state, conversations } + emit() + } catch { + // Backend unavailable — degrade to localStorage so the console still works. + backendReady = true + state = { ...state, conversations: readLegacyConversations() } + writeLegacyConversations(state.conversations) + emit() + } + })() + return initPromise +} + +function persist(conv: Conversation, op: 'create' | 'put' | 'delete') { + if (!backendReady) return + const p = op === 'create' ? createOnBackend(conv) : op === 'put' ? putOnBackend(conv) : deleteOnBackend(conv.id) + p.catch(() => { + // Backend write failed — keep the local store authoritative and mirror to + // localStorage so a refresh does not lose it. + if (op !== 'delete') writeLegacyConversations(state.conversations) + }) +} + +// ---- conversations ---- + +function commit(next: Store, changedConv?: Conversation, op?: 'create' | 'put' | 'delete') { + state = next + try { + localStorage.setItem(CANVAS_KEY, JSON.stringify(next.canvases)) + } catch { + // Canvas layout is best-effort local state; a quota or storage error must + // not break the conversation store. + } + if (changedConv) persist(changedConv, op ?? 'put') + listeners.forEach((l) => l()) +} + export function createConversation(title: string): Conversation { const conversation: Conversation = { id: makeId('conv'), @@ -63,50 +170,66 @@ export function createConversation(title: string): Conversation { turns: [], history: [], } - commit({ ...state, conversations: [conversation, ...state.conversations] }) + commit({ ...state, conversations: [conversation, ...state.conversations] }, conversation, 'create') return conversation } export function deleteConversation(id: string) { const canvases = { ...state.canvases } delete canvases[id] - commit({ - conversations: state.conversations.filter((c) => c.id !== id), - canvases, - }) + const removed = state.conversations.find((c) => c.id === id) + commit( + { + conversations: state.conversations.filter((c) => c.id !== id), + canvases, + }, + removed, + 'delete', + ) } export function patchTurn(conversationId: string, turnId: string, fn: (t: LoopTurn) => LoopTurn) { + let conv: Conversation | undefined commit({ ...state, - conversations: state.conversations.map((c) => - c.id === conversationId - ? { ...c, turns: c.turns.map((t) => (t.id === turnId ? fn(t) : t)) } - : c, - ), - }) + conversations: state.conversations.map((c) => { + if (c.id !== conversationId) return c + conv = { ...c, turns: c.turns.map((t) => (t.id === turnId ? fn(t) : t)) } + return conv + }), + }, conv) } export function appendTurn(conversationId: string, turn: LoopTurn, history: Conversation['history']): string { const id = makeId('turn') + let conv: Conversation | undefined commit({ ...state, - conversations: state.conversations.map((c) => - c.id === conversationId - ? { ...c, updatedAt: Date.now(), turns: [...c.turns, { ...turn, id }], history } - : c, - ), - }) + conversations: state.conversations.map((c) => { + if (c.id !== conversationId) return c + conv = { ...c, updatedAt: Date.now(), turns: [...c.turns, { ...turn, id }], history } + return conv + }), + }, conv) return id } export function replaceHistory(conversationId: string, history: Conversation['history']) { + let conv: Conversation | undefined commit({ ...state, - conversations: state.conversations.map((c) => (c.id === conversationId ? { ...c, history } : c)), - }) + conversations: state.conversations.map((c) => { + if (c.id === conversationId) { + conv = { ...c, history } + return conv + } + return c + }), + }, conv) } +// ---- canvas panels (localStorage — layout, not worth a backend table) ---- + const CASCADE_STEP = 28 const DEFAULT_SIZE = { width: 520, height: 400 } const MAX_PANELS = 12 @@ -159,11 +282,7 @@ export function focusPanel(conversationId: string, panelId: string) { ) } -export function movePanel( - conversationId: string, - panelId: string, - geo: Partial, -) { +export function movePanel(conversationId: string, panelId: string, geo: Partial) { const prev = state.canvases[conversationId] ?? [] setPanels( conversationId, @@ -229,4 +348,4 @@ export function byRecency(a: Conversation, b: Conversation): number { return b.updatedAt - a.updatedAt } -export const noopListener = noop +export const noopListener = () => {} diff --git a/frontend/src/features/threat-intel/console/tool-result.tsx b/frontend/src/features/threat-intel/console/tool-result.tsx index e6b4394b3..814f211a9 100644 --- a/frontend/src/features/threat-intel/console/tool-result.tsx +++ b/frontend/src/features/threat-intel/console/tool-result.tsx @@ -1,12 +1,14 @@ import { useTranslation } from 'react-i18next' import { AlertTriangle, + BarChart3, CircleSlash, Crosshair, FileText, MapPin, Network, Search, + Send, } from 'lucide-react' import { cn } from '@/shared/lib/utils' import type { AgentStep, IntelEntity } from './types' @@ -23,6 +25,8 @@ const TOOL_ICONS: Record = { get_entity_details: FileText, get_relations: Network, get_geolocations: MapPin, + count_entities_by_type: BarChart3, + report_ioc: Send, } function reputationVariant(reputation: number | null) { @@ -251,6 +255,28 @@ function PayloadView({
) } + case 'report': { + return ( +
+
+ + {payload.type} + + {payload.value} + {payload.reputation != null && ( + rep {payload.reputation} + )} +
+ {payload.skippedAssociations.length > 0 && ( +
+ + {t('tiAnalyst.tool.skippedAssociations', { values: payload.skippedAssociations.join(', ') })} +
+ )} +
{t('tiAnalyst.tool.reported')}
+
+ ) + } } } diff --git a/frontend/src/features/threat-intel/console/types.ts b/frontend/src/features/threat-intel/console/types.ts index 04e6af8da..ce287805d 100644 --- a/frontend/src/features/threat-intel/console/types.ts +++ b/frontend/src/features/threat-intel/console/types.ts @@ -54,17 +54,31 @@ export interface EntityGeo { export interface EntityDetails { attributes: EntityDetailAttributes metadata: Record | null + /** Grouped extended metadata, when the corpus carries it. */ + extendedMetadata?: Record> | null latest_associations: IntelEntity[] geolocations: EntityGeo[] } +export interface EntityHistoryRecord { + timestamp: string + reputation: number + author?: string +} + export type ToolPayload = - | { kind: 'lookup'; value: string; entity: IntelEntity | null } + | { kind: 'lookup'; value: string; entity: IntelEntity | null; rejected: string[] } | { kind: 'search'; query: string; items: number; results: IntelEntity[] } | { kind: 'details'; id: string; details: EntityDetails } | { kind: 'relations'; id: string; graph: RelationsResult } | { kind: 'geo'; id: string; locations: EntityGeo[] } | { kind: 'counts'; buckets: { key: string; count: number }[] } + | { kind: 'report'; value: string; type: string; reputation: number | undefined; skippedAssociations: string[] } + | { kind: 'link'; entityID: string; relatedEntityID: string } + | { kind: 'cases'; cases: import('../lib/casework').Case[] } + | { kind: 'watchlists'; watchlists: import('../lib/casework').Watchlist[] } + | { kind: 'alerts'; alerts: import('../lib/casework').Alert[]; unread: number } + | { kind: 'casework'; action: string; label: string; ref: string } export interface AgentStep { id: string @@ -84,6 +98,7 @@ export interface LoopTurn { steps: AgentStep[] status: 'running' | 'done' | 'error' error?: string + stoppedReason?: 'complete' | 'max-turns' | 'aborted' } export interface Conversation { diff --git a/frontend/src/features/threat-intel/console/use-conversation-loop.ts b/frontend/src/features/threat-intel/console/use-conversation-loop.ts index 2af0a95dc..790e52da4 100644 --- a/frontend/src/features/threat-intel/console/use-conversation-loop.ts +++ b/frontend/src/features/threat-intel/console/use-conversation-loop.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' -import { runAgent } from './agent' +import { runAgent, explainAgentError } from './agent' import { appendTurn, patchTurn, @@ -121,13 +121,22 @@ export function useConversationLoop(conversationId: string | null, conversation: }, }, }) - patch((t) => ({ ...t, status: 'done' })) + if (result.stoppedReason === 'max-turns') { + patch((t) => ({ + ...t, + status: 'error', + stoppedReason: result.stoppedReason, + error: 'Reached the reasoning-step limit before finishing.', + })) + } else { + patch((t) => ({ ...t, status: 'done', stoppedReason: result.stoppedReason })) + } replaceHistory(conversationId, result.history) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { - patch((t) => ({ ...t, status: 'done' })) + patch((t) => ({ ...t, status: 'done', stoppedReason: 'aborted' })) } else { - patch((t) => ({ ...t, status: 'error', error: err instanceof Error ? err.message : String(err) })) + patch((t) => ({ ...t, status: 'error', error: explainAgentError(err) })) } } finally { e.busy = false diff --git a/frontend/src/features/threat-intel/lib/casework.ts b/frontend/src/features/threat-intel/lib/casework.ts new file mode 100644 index 000000000..beda08529 --- /dev/null +++ b/frontend/src/features/threat-intel/lib/casework.ts @@ -0,0 +1,138 @@ +import { proxyFetch } from '../console/proxy' + +/** + * Casework — the analyst's own records: cases, watchlists, alerts, and + * conversation history. Stored in the UTMStack tenant's own database + * (not proxied to ThreatWinds — the reverse proxy strips user credentials, + * so upstream per-user ownership is unexpressible). Every route here is + * scoped to the authenticated tenant. + */ + +export type CaseStatus = 'open' | 'in_progress' | 'closed' + +export interface CaseEntity { + id: string + case_id: string + entity_id: string + entity_type?: string + entity_value?: string + note?: string +} + +export interface CaseNote { + id: string + case_id: string + body: string + created_at: string +} + +export interface Case { + id: string + title: string + summary?: string + status: CaseStatus + severity: number + notes?: CaseNote[] + entities?: CaseEntity[] + created_at: string + updated_at: string +} + +export interface WatchlistItem { + id: string + watchlist_id: string + kind: 'entity' | 'value' | 'tag' | 'type' + value: string + max_reputation?: number +} + +export interface Watchlist { + id: string + name: string + enabled: boolean + items?: WatchlistItem[] + created_at: string +} + +export interface Alert { + id: string + watchlist_id: string + item_id: string + entity_id: string + entity_type: string + entity_value: string + reputation: number | null + matched_kind: string + matched_on: string + created_at: string +} + +function post(path: string, body: unknown, signal?: AbortSignal): Promise { + return proxyFetch(path, { method: 'POST', body: JSON.stringify(body), signal }) +} + +export function listCases(status?: CaseStatus, signal?: AbortSignal): Promise { + const q = status ? `?status=${status}` : '' + return proxyFetch<{ cases: Case[] }>(`/cases${q}`, { signal }).then(r => r.cases ?? []) +} + +export function createCase( + input: { title: string; summary?: string; severity?: number; status?: CaseStatus }, + signal?: AbortSignal, +): Promise { + return post('/cases', input, signal) +} + +export function pinEntityToCase( + caseId: string, + input: { entityId: string; entityType?: string; entityValue?: string; note?: string }, + signal?: AbortSignal, +): Promise { + return post(`/cases/${caseId}/entities`, { + entity_id: input.entityId, + entity_type: input.entityType, + entity_value: input.entityValue, + note: input.note, + }, signal) +} + +export function addCaseNote(caseId: string, body: string, signal?: AbortSignal): Promise { + return post(`/cases/${caseId}/notes`, { body }, signal) +} + +export function listWatchlists(signal?: AbortSignal): Promise { + return proxyFetch<{ watchlists: Watchlist[] }>('/watchlists', { signal }).then(r => r.watchlists ?? []) +} + +export function createWatchlist(input: { name: string; description?: string }, signal?: AbortSignal): Promise { + return post('/watchlists', input, signal) +} + +export function addWatchlistItem( + watchlistId: string, + input: { kind: 'entity' | 'value' | 'tag' | 'type'; value: string; maxReputation?: number }, + signal?: AbortSignal, +): Promise { + return post(`/watchlists/${watchlistId}/items`, { + kind: input.kind, + value: input.value, + max_reputation: input.maxReputation, + }, signal) +} + +export function listAlerts(opts: { limit?: number }, signal?: AbortSignal): Promise<{ items: Alert[]; unread: number }> { + const q = opts.limit ? `?limit=${opts.limit}` : '' + return proxyFetch<{ items: Alert[]; unread: number }>(`/alerts${q}`, { signal }) + .then(r => ({ items: r.items ?? [], unread: r.unread ?? 0 })) +} + +export interface LinkEntitiesResult { + entityID: string + relatedEntityID: string + message: string +} + +export function linkEntities(entityID: string, relatedEntityID: string, signal?: AbortSignal): Promise { + return post<{ message?: string } | null>('/report/association', { entityID, relatedEntityID }, signal) + .then(ack => ({ entityID, relatedEntityID, message: ack?.message ?? 'accepted' })) +} diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index a68ad77ce..299f6cb19 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -6054,6 +6054,7 @@ "worst": "Schlechtester", "best": "Bester", "relations": "Beziehungsgraph", + "history": "Verlauf", "geolocations": "Geolokalisierungen", "associations": "Assoziationen", "radius": "r={{radius}}m", @@ -6068,7 +6069,9 @@ "noIntel": "Für „{{value}}“ sind keine Bedrohungsdaten erfasst.", "noMatches": "Keine Treffer.", "matches": "{{items}} Treffer für „{{query}}“", - "more": "+{{count}} weitere" + "more": "+{{count}} weitere", + "reported": "An den gemeinsamen ThreatWinds-Korpus gemeldet. Ingestion ist asynchron, noch nicht durchsuchbar.", + "skippedAssociations": "Nicht aufgezeichnet (Typ nicht ermittelbar): {{values}}" } }, "threatwinds": { @@ -6087,4 +6090,4 @@ "failed": "Die Beitragseinstellung konnte nicht aktualisiert werden" } } -} +} \ No newline at end of file diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 256f849a2..18eafaf1b 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -6042,6 +6042,7 @@ "worst": "Worst", "best": "Best", "relations": "Relationship graph", + "history": "History", "geolocations": "Geolocations", "associations": "Associations", "radius": "r={{radius}}m", @@ -6056,7 +6057,9 @@ "noIntel": "\"{{value}}\" has no threat intelligence recorded.", "noMatches": "No matches.", "matches": "{{items}} matches for \"{{query}}\"", - "more": "+{{count}} more" + "more": "+{{count}} more", + "reported": "Reported to the shared ThreatWinds corpus. Ingestion is asynchronous, not immediately searchable.", + "skippedAssociations": "Not recorded (type could not be inferred): {{values}}" } }, "threatwinds": { @@ -6075,4 +6078,4 @@ "failed": "Could not update the contribution setting" } } -} +} \ No newline at end of file diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index eaaca72ae..c0b709a00 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -6054,6 +6054,7 @@ "worst": "Peor", "best": "Mejor", "relations": "Grafo de relaciones", + "history": "Historial", "geolocations": "Geolocalizaciones", "associations": "Asociaciones", "radius": "r={{radius}}m", @@ -6068,7 +6069,9 @@ "noIntel": "«{{value}}» no tiene información de amenazas registrada.", "noMatches": "Sin coincidencias.", "matches": "{{items}} coincidencias para «{{query}}»", - "more": "+{{count}} más" + "more": "+{{count}} más", + "reported": "Reportado al corpus compartido de ThreatWinds. La ingesta es asincrona, no es inmediatamente consultable.", + "skippedAssociations": "No registrado (tipo no inferible): {{values}}" } }, "threatwinds": { @@ -6087,4 +6090,4 @@ "failed": "No se pudo actualizar la configuración de contribución" } } -} +} \ No newline at end of file diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index 61269f00b..3d96acb4c 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -6054,6 +6054,7 @@ "worst": "Pire", "best": "Meilleure", "relations": "Graphe de relations", + "history": "Historique", "geolocations": "Géolocalisations", "associations": "Associations", "radius": "r={{radius}}m", @@ -6068,7 +6069,9 @@ "noIntel": "« {{value}} » n'a aucune information de menace enregistrée.", "noMatches": "Aucune correspondance.", "matches": "{{items}} correspondances pour « {{query}} »", - "more": "+{{count}} de plus" + "more": "+{{count}} de plus", + "reported": "Signale au corpus ThreatWinds partage. L ingestion est asynchrone, non immediatement rechercheable.", + "skippedAssociations": "Non enregistre (type non inferable) : {{values}}" } }, "threatwinds": { @@ -6087,4 +6090,4 @@ "failed": "Impossible de mettre à jour le réglage de contribution" } } -} +} \ No newline at end of file diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 56710c59f..290fede1d 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -6054,6 +6054,7 @@ "worst": "Peggio", "best": "Meglio", "relations": "Grafo delle relazioni", + "history": "Cronologia", "geolocations": "Geolocalizzazioni", "associations": "Associazioni", "radius": "r={{radius}}m", @@ -6068,7 +6069,9 @@ "noIntel": "«{{value}}» non ha informazioni di minaccia registrate.", "noMatches": "Nessuna corrispondenza.", "matches": "{{items}} corrispondenze per «{{query}}»", - "more": "+{{count}} altri" + "more": "+{{count}} altri", + "reported": "Segnalato al corpus ThreatWinds condiviso. L ingestione e asincrona, non immediatamente ricercabile.", + "skippedAssociations": "Non registrato (tipo non deducibile): {{values}}" } }, "threatwinds": { @@ -6087,4 +6090,4 @@ "failed": "Impossibile aggiornare l'impostazione di contribuzione" } } -} +} \ No newline at end of file diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index 0e2c0c7d6..514291932 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -6054,6 +6054,7 @@ "worst": "Pior", "best": "Melhor", "relations": "Grafo de relações", + "history": "Histórico", "geolocations": "Geolocalizações", "associations": "Associações", "radius": "r={{radius}}m", @@ -6068,7 +6069,9 @@ "noIntel": "«{{value}}» não tem inteligência de ameaça registrada.", "noMatches": "Sem correspondências.", "matches": "{{items}} correspondências para «{{query}}»", - "more": "+{{count}} mais" + "more": "+{{count}} mais", + "reported": "Reportado no corpus ThreatWinds compartilhado. A ingestao e assincrona, nao imediatamente pesquisavel.", + "skippedAssociations": "Nao registrado (tipo nao inferivel): {{values}}" } }, "threatwinds": { @@ -6087,4 +6090,4 @@ "failed": "Não foi possível atualizar a configuração de contribuição" } } -} +} \ No newline at end of file diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index fb037a118..689d67530 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -6080,6 +6080,7 @@ "worst": "Худшая", "best": "Лучшая", "relations": "Граф связей", + "history": "История", "geolocations": "Геолокации", "associations": "Ассоциации", "radius": "r={{radius}}м", @@ -6094,7 +6095,9 @@ "noIntel": "По «{{value}}» нет записанных данных об угрозах.", "noMatches": "Совпадений нет.", "matches": "совпадений по «{{query}}»: {{items}}", - "more": "ещё +{{count}}" + "more": "ещё +{{count}}", + "reported": "Сообщено в общий корпус ThreatWinds. Ингест асинхронный, пока не ищется.", + "skippedAssociations": "Не записано (тип не определён): {{values}}" } }, "threatwinds": { @@ -6113,4 +6116,4 @@ "failed": "Не удалось обновить настройку передачи" } } -} +} \ No newline at end of file