diff --git a/backend/modules/threatintel/handler/proxy.go b/backend/modules/threatintel/handler/proxy.go
index 9d77944ac..f5e3cca90 100644
--- a/backend/modules/threatintel/handler/proxy.go
+++ b/backend/modules/threatintel/handler/proxy.go
@@ -99,11 +99,11 @@ func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) {
}
allowed := map[string]struct{}{
- "Accept": {},
- "Content-Type": {},
- "User-Agent": {},
- "X-Request-Id": {},
- "X-Correlation-Id": {},
+ "Accept": {},
+ "Content-Type": {},
+ "User-Agent": {},
+ "X-Request-Id": {},
+ "X-Correlation-Id": {},
}
// Copy headers
@@ -122,7 +122,7 @@ func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) {
// Execute request
client := &http.Client{
- Timeout: 20* time.Second,
+ Timeout: 20 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
diff --git a/backend/modules/threatintel/internal/instanceconfig.go b/backend/modules/threatintel/internal/instanceconfig.go
index 9dc1f03c1..844559f5c 100644
--- a/backend/modules/threatintel/internal/instanceconfig.go
+++ b/backend/modules/threatintel/internal/instanceconfig.go
@@ -28,12 +28,12 @@ func LoadInstanceConfig(updatesDir string) (*InstanceConfig, error) {
configOnce.Do(func() {
data, err := os.ReadFile(filepath.Join(updatesDir, "instance-config.yml"))
if err != nil {
- _ = catcher.Error("error loading instance configuration",err,map[string]any{})
+ _ = catcher.Error("error loading instance configuration", err, map[string]any{})
return
}
var cfg InstanceConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
- _ = catcher.Error("error loading instance configuration",err,map[string]any{})
+ _ = catcher.Error("error loading instance configuration", err, map[string]any{})
return
}
instanceConfig = &cfg
diff --git a/backend/modules/threatintel/routes.go b/backend/modules/threatintel/routes.go
index bbbf2e001..26781c72f 100644
--- a/backend/modules/threatintel/routes.go
+++ b/backend/modules/threatintel/routes.go
@@ -14,6 +14,10 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
searchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/simple")
g.POST("/search", searchHandler.Handle)
+ // POST /api/v1/threat-intel/search/advanced → /proxy/api/search/v1/entities/advanced
+ advancedSearchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/advanced")
+ g.POST("/search/advanced", advancedSearchHandler.Handle)
+
// GET /api/v1/threat-intel/entity/:id → /proxy/api/analytics/v1/entity/{id}/details
g.GET("/entity/:id", func(c *gin.Context) {
id := c.Param("id")
@@ -43,7 +47,7 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
g.GET("/usage", usageHandler.HandleUsageEndpoint)
}
-func sanitizeId(id string) string{
- replacer := strings.NewReplacer(".", "", "\\", "","/","")
+func sanitizeId(id string) string {
+ replacer := strings.NewReplacer(".", "", "\\", "", "/", "")
return replacer.Replace(id)
}
diff --git a/frontend/src/features/threat-intel/components/ActorCard.tsx b/frontend/src/features/threat-intel/components/ActorCard.tsx
new file mode 100644
index 000000000..cc329dd58
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/ActorCard.tsx
@@ -0,0 +1,65 @@
+import { useTranslation } from 'react-i18next'
+import { UserX } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { searchItemValue, type EntitySummary } from '../domain/threat-intel.types'
+import { REPUTATION_STYLE, reputationLabelKey, reputationTone } from './utils/severity-style'
+import { Stat } from './Stat'
+import { relativeTime } from './utils/time-format'
+
+interface ActorCardProps {
+ actor: EntitySummary
+ onOpen: (id: string) => void
+}
+
+export function ActorCard({ actor, onOpen }: ActorCardProps) {
+ const { t } = useTranslation()
+ const tone = reputationTone(actor.reputation)
+ const rep = REPUTATION_STYLE[tone]
+ const dangerous = tone === 'danger'
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/ActorDrawer.tsx b/frontend/src/features/threat-intel/components/ActorDrawer.tsx
new file mode 100644
index 000000000..8bd8790ef
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/ActorDrawer.tsx
@@ -0,0 +1,160 @@
+import { useTranslation } from 'react-i18next'
+import { Crosshair, ExternalLink, Search, UserX, X } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { Button } from '@/shared/components/ui/button'
+import { useTiEntity } from '../hooks/use-ti-entity'
+import { REPUTATION_STYLE, reputationTone, typeMeta } from './utils/severity-style'
+import { absTimestamp } from './utils/time-format'
+import { Section } from './Section'
+import { Stat } from './Stat'
+
+interface ActorDrawerProps {
+ id: string | null
+ onClose: () => void
+}
+
+export function ActorDrawer({ id, onClose }: ActorDrawerProps) {
+ const { t } = useTranslation()
+ const { data, isLoading } = useTiEntity(id)
+
+ if (!id) return null
+ if (data?.kind === 'not-configured') return null
+ if (isLoading || !data || data.kind !== 'ok') {
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+ )
+ }
+
+ const a = data.value.attributes
+ const tone = reputationTone(a.reputation_score)
+ const rep = REPUTATION_STYLE[tone]
+ const dangerous = tone === 'danger'
+ const associations = data.value.latest_associations ?? []
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+ {a.description && (
+
+ )}
+
+
+
+ {associations.length > 0 && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/ActorsList.tsx b/frontend/src/features/threat-intel/components/ActorsList.tsx
new file mode 100644
index 000000000..74dcd7f8c
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/ActorsList.tsx
@@ -0,0 +1,45 @@
+import { useTranslation } from 'react-i18next'
+import type { EntitySummary } from '../domain/threat-intel.types'
+import { ActorCard } from './ActorCard'
+
+interface ActorsListProps {
+ actors: EntitySummary[]
+ onOpen: (id: string) => void
+ isLoading?: boolean
+}
+
+export function ActorsList({ actors, onOpen, isLoading }: ActorsListProps) {
+ const { t } = useTranslation()
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ if (actors.length === 0) {
+ return (
+
+ {t('threatIntel.actors.empty')}
+
+ )
+ }
+
+ return (
+
+ {actors.map((actor) => (
+
onOpen(actor.id)}
+ />
+ ))}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/FeedRow.tsx b/frontend/src/features/threat-intel/components/FeedRow.tsx
new file mode 100644
index 000000000..5fa8f2a1f
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/FeedRow.tsx
@@ -0,0 +1,27 @@
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/shared/lib/utils'
+import type { ThreatFeed } from '../domain/threat-intel.types'
+import { feedAccuracyMeta, feedTypeTone } from './utils/severity-style'
+
+interface FeedRowProps {
+ feed: ThreatFeed
+}
+
+const FEED_COLS = '12px 1fr 160px 140px'
+
+export function FeedRow({ feed }: FeedRowProps) {
+ const { t } = useTranslation()
+ const acc = feedAccuracyMeta(feed.accuracy)
+
+ return (
+
+
+
{feed.name}
+
{feed.type}
+
{t(acc.labelKey)}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/FeedsHeader.tsx b/frontend/src/features/threat-intel/components/FeedsHeader.tsx
new file mode 100644
index 000000000..00da0697a
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/FeedsHeader.tsx
@@ -0,0 +1,18 @@
+import { useTranslation } from 'react-i18next'
+
+const FEED_COLS = '12px 1fr 160px 140px'
+
+export function FeedsHeader() {
+ const { t } = useTranslation()
+ return (
+
+
+
{t('threatIntel.feeds.table.name')}
+
{t('threatIntel.feeds.table.type')}
+
{t('threatIntel.feeds.table.accuracy')}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx
new file mode 100644
index 000000000..ec8d8159a
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/FeedsList.tsx
@@ -0,0 +1,44 @@
+import { useTranslation } from 'react-i18next'
+import { useTiFeeds } from '../hooks/use-ti-feeds'
+import { FeedRow } from './FeedRow'
+import { FeedsHeader } from './FeedsHeader'
+
+export function FeedsList() {
+ const { t } = useTranslation()
+ const { data, isLoading } = useTiFeeds()
+
+ if (data?.kind === 'not-configured') return null
+
+ if (isLoading) {
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ const feeds = data?.kind === 'ok' ? data.value : []
+
+ if (feeds.length === 0) {
+ return (
+
+ {t('threatIntel.feeds.empty')}
+
+ )
+ }
+
+ return (
+
+
+ {feeds.map((feed) => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/IocDrawer.tsx b/frontend/src/features/threat-intel/components/IocDrawer.tsx
new file mode 100644
index 000000000..7ae8b63b2
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/IocDrawer.tsx
@@ -0,0 +1,38 @@
+import { useTiEntity } from '../hooks/use-ti-entity'
+import { useTiEntityRelations } from '../hooks/use-ti-entity-relations'
+import { IocDrawerBody } from './IocDrawerBody'
+import { IocDrawerLoading } from './IocDrawerLoading'
+
+interface IocDrawerProps {
+ id: string | null
+ onClose: () => void
+}
+
+export function IocDrawer({ id, onClose }: IocDrawerProps) {
+ const { data: entityData, isLoading: entityLoading } = useTiEntity(id ?? '')
+ const { data: relationsData } = useTiEntityRelations(id ?? '')
+
+ if (!id) return null
+ if (entityData?.kind === 'not-configured') return null
+
+ const detail = entityData?.kind === 'ok' ? entityData.value : null
+ const relations = relationsData?.kind === 'ok' ? relationsData.value : []
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {entityLoading || !detail ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/IocDrawerBody.tsx b/frontend/src/features/threat-intel/components/IocDrawerBody.tsx
new file mode 100644
index 000000000..8516f29c3
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/IocDrawerBody.tsx
@@ -0,0 +1,206 @@
+import { useTranslation } from 'react-i18next'
+import { ExternalLink, Globe2, MapPin, MoreHorizontal, Search, Shield, Sparkles, X } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { Button } from '@/shared/components/ui/button'
+import type { EntityDetail, EntityRelation } from '../domain/threat-intel.types'
+import { REPUTATION_STYLE, reputationTone, typeMeta } from './utils/severity-style'
+import { absTimestamp, relativeTime } from './utils/time-format'
+import { Section } from './Section'
+import { KV } from './KV'
+import { Metric } from './Metric'
+
+interface IocDrawerBodyProps {
+ detail: EntityDetail
+ relations: EntityRelation[]
+ onClose: () => void
+}
+
+export function IocDrawerBody({ detail, relations, onClose }: IocDrawerBodyProps) {
+ const { t } = useTranslation()
+ const a = detail.attributes
+ const tone = reputationTone(a.reputation_score)
+ const rep = REPUTATION_STYLE[tone]
+ const typeMeta_ = typeMeta(a.type)
+ const TIcon = typeMeta_.icon
+ const associations = detail.latest_associations ?? []
+ const merged = relations.length > 0 ? relations : associations
+ const meta = detail.metadata
+ const hasMeta = !!(meta && (meta.asn || meta.aso || meta.city || meta.country))
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {absTimestamp(a.first_seen)}
+
+
+ {absTimestamp(a.last_seen)}
+
+
+
+
+ {hasMeta && (
+
+
+ {meta.country && {meta.country}}
+ {meta.city && {meta.city}}
+ {meta.aso && {meta.aso}}
+ {meta.asn > 0 && {meta.asn}}
+
+
+ )}
+
+ {merged.length > 0 && (
+
+ )}
+
+ {detail.geolocations?.length > 0 && (
+
+ )}
+
+
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/IocDrawerLoading.tsx b/frontend/src/features/threat-intel/components/IocDrawerLoading.tsx
new file mode 100644
index 000000000..90612d346
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/IocDrawerLoading.tsx
@@ -0,0 +1,24 @@
+import { useTranslation } from 'react-i18next'
+import { X } from 'lucide-react'
+
+interface IocDrawerLoadingProps {
+ onClose: () => void
+}
+
+export function IocDrawerLoading({ onClose }: IocDrawerLoadingProps) {
+ const { t } = useTranslation()
+ return (
+ <>
+
+ {t('threatIntel.drawer.loading')}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/IocRow.tsx b/frontend/src/features/threat-intel/components/IocRow.tsx
new file mode 100644
index 000000000..57a5b9f22
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/IocRow.tsx
@@ -0,0 +1,64 @@
+import { useTranslation } from 'react-i18next'
+import { MoreHorizontal } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { searchItemValue, type EntitySummary } from '../domain/threat-intel.types'
+import { REPUTATION_STYLE, reputationLabelKey, reputationTone, typeMeta } from './utils/severity-style'
+import { relativeTime } from './utils/time-format'
+
+interface IocRowProps {
+ ioc: EntitySummary
+ onOpen: (id: string) => void
+}
+
+const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px'
+
+export function IocRow({ ioc, onOpen }: IocRowProps) {
+ const { t } = useTranslation()
+ const tone = reputationTone(ioc.reputation)
+ const rep = REPUTATION_STYLE[tone]
+ const typeMeta_ = typeMeta(ioc.type)
+ const TIcon = typeMeta_.icon
+
+ return (
+ onOpen(ioc.id)}
+ className="group grid cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs hover:bg-muted/40 last:border-b-0"
+ style={{ gridTemplateColumns: IOC_COLS }}
+ >
+
+
+
+
+ {t(typeMeta_.labelKey)}
+
+
+
{searchItemValue(ioc)}
+
+
+ {t(reputationLabelKey(ioc.reputation))}
+
+
+
+ {ioc.tags?.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+ {ioc.tags && ioc.tags.length > 3 && (
+ +{ioc.tags.length - 3}
+ )}
+
+
+ {ioc.lastSeen ? relativeTime(ioc.lastSeen) : '—'}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/IocTable.tsx b/frontend/src/features/threat-intel/components/IocTable.tsx
new file mode 100644
index 000000000..2b565c21b
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/IocTable.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { EntitySummary } from '../domain/threat-intel.types'
+import { Pagination } from '@/shared/components/ui/pagination'
+import { IocRow } from './IocRow'
+
+interface IocTableProps {
+ iocs: EntitySummary[]
+ onOpen: (id: string) => void
+ isLoading?: boolean
+ page: number
+ pageSize: number
+ totalItems: number
+ onPageChange: (page: number) => void
+ onPageSizeChange: (size: number) => void
+ hasMore?: boolean
+ onLoadMore?: () => void
+}
+
+const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px'
+
+export function IocTable({
+ iocs,
+ onOpen,
+ isLoading,
+ page,
+ pageSize,
+ totalItems,
+ onPageChange,
+ onPageSizeChange,
+ hasMore,
+ onLoadMore,
+}: IocTableProps) {
+ const { t } = useTranslation()
+ const scrollRef = useRef(null)
+ const sentinelRef = useRef(null)
+
+ useEffect(() => {
+ const node = sentinelRef.current
+ const root = scrollRef.current
+ if (!node || !root || !hasMore || !onLoadMore) return
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !isLoading) onLoadMore()
+ },
+ { root, rootMargin: '200px' }
+ )
+ io.observe(node)
+ return () => io.disconnect()
+ }, [hasMore, onLoadMore, isLoading])
+
+ return (
+ <>
+
+
+
+
{t('threatIntel.iocs.table.type')}
+
{t('threatIntel.iocs.table.indicator')}
+
{t('threatIntel.iocs.table.reputation')}
+
{t('threatIntel.iocs.table.tags')}
+
{t('threatIntel.iocs.table.lastSeen')}
+
+
+
+ {iocs.map((ioc) => (
+
onOpen(ioc.id)} />
+ ))}
+ {!isLoading && iocs.length === 0 && (
+ {t('threatIntel.iocs.empty')}
+ )}
+
+ {hasMore && (
+
+ {isLoading ? t('threatIntel.iocs.loadingMore') : t('threatIntel.iocs.loadedProgress', { loaded: iocs.length, total: totalItems })}
+
+ )}
+ {!hasMore && iocs.length > 0 && (
+
+ {t('threatIntel.iocs.endOfResults', { loaded: iocs.length, total: totalItems })}
+
+ )}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/KV.tsx b/frontend/src/features/threat-intel/components/KV.tsx
new file mode 100644
index 000000000..54f81cad5
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/KV.tsx
@@ -0,0 +1,15 @@
+import React from 'react'
+
+interface KVProps {
+ k: string
+ children: React.ReactNode
+}
+
+export function KV({ k, children }: KVProps) {
+ return (
+ <>
+ {k}
+ {children}
+ >
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/LookupBar.tsx b/frontend/src/features/threat-intel/components/LookupBar.tsx
new file mode 100644
index 000000000..368062edb
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/LookupBar.tsx
@@ -0,0 +1,49 @@
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import { Crosshair, Search } from 'lucide-react'
+import { Button } from '@/shared/components/ui/button'
+import { Input } from '@/shared/components/ui/input'
+
+interface LookupBarProps {
+ onSearch: (query: string) => void
+ isPending?: boolean
+}
+
+export function LookupBar({ onSearch, isPending }: LookupBarProps) {
+ const { t } = useTranslation()
+ const [q, setQ] = useState('')
+
+ const handleSubmit = () => {
+ const trimmed = q.trim()
+ if (trimmed) onSearch(trimmed)
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') handleSubmit()
+ }
+
+ return (
+
+
+
+ {t('threatIntel.lookup.title')}
+
+
+
+
+ setQ(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={t('threatIntel.lookup.placeholder')}
+ className="h-10 pl-9 font-mono text-sm"
+ />
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx
new file mode 100644
index 000000000..f15976a57
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx
@@ -0,0 +1,87 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useTiIocs24h } from '../hooks/use-ti-iocs-24h'
+import { fillHourlyBuckets } from './utils/hourly-buckets'
+
+export function MatchOverviewCard() {
+ const { t } = useTranslation()
+ const query = useTiIocs24h()
+
+ const total = useMemo(() => {
+ if (query.data?.kind !== 'ok') return 0
+ return query.data.value.items
+ }, [query.data])
+
+ const data = useMemo(() => {
+ if (query.data?.kind !== 'ok') return []
+ const buckets = query.data.value.aggregations?.hourly_iocs?.buckets ?? []
+ return fillHourlyBuckets(buckets).map((b) => b.count)
+ }, [query.data])
+
+ const w = 1000
+ const h = 100
+ const max = data.length > 0 ? Math.max(...data) * 1.15 : 1
+ const xs = data.map((_, i) => (i * w) / Math.max(data.length - 1, 1))
+ const ys = data.map((v) => h - (v / max) * h)
+
+ let linePath = ''
+ if (data.length > 0) {
+ linePath = data.reduce((acc, _, i) => {
+ if (i === 0) return `M ${xs[i]} ${ys[i]}`
+ const prevX = xs[i - 1]
+ const prevY = ys[i - 1]
+ const cx1 = prevX + (xs[i] - prevX) / 2
+ const cx2 = xs[i] - (xs[i] - prevX) / 2
+ return `${acc} C ${cx1} ${prevY}, ${cx2} ${ys[i]}, ${xs[i]} ${ys[i]}`
+ }, '')
+ } else {
+ linePath = `M 0 ${h} L ${w} ${h}`
+ }
+
+ const areaPath = data.length > 0 ? `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z` : linePath
+
+ if (query.data?.kind === 'not-configured') return null
+
+ return (
+
+
+
+
+ {t('threatIntel.overview.title')}
+
+
+
+ {query.isPending ? '—' : total.toLocaleString()}
+
+ {t('threatIntel.overview.totalIndicators')}
+
+
+
+
+
+
+
+ {t('threatIntel.overview.axis.start')}
+ {t('threatIntel.overview.axis.middle')}
+ {t('threatIntel.overview.axis.end')}
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/Metric.tsx b/frontend/src/features/threat-intel/components/Metric.tsx
new file mode 100644
index 000000000..47cc36503
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/Metric.tsx
@@ -0,0 +1,13 @@
+interface MetricProps {
+ label: string
+ value: string
+}
+
+export function Metric({ label, value }: MetricProps) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/NotConfiguredState.tsx b/frontend/src/features/threat-intel/components/NotConfiguredState.tsx
new file mode 100644
index 000000000..66483106a
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/NotConfiguredState.tsx
@@ -0,0 +1,19 @@
+import { useTranslation } from 'react-i18next'
+import { ShieldOff } from 'lucide-react'
+
+export function NotConfiguredState() {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
{t('threatIntel.notConfigured.title')}
+
+ {t('threatIntel.notConfigured.body')}
+
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/Section.tsx b/frontend/src/features/threat-intel/components/Section.tsx
new file mode 100644
index 000000000..f4b54721b
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/Section.tsx
@@ -0,0 +1,15 @@
+import React from 'react'
+
+interface SectionProps {
+ title: string
+ children: React.ReactNode
+}
+
+export function Section({ title, children }: SectionProps) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/Stat.tsx b/frontend/src/features/threat-intel/components/Stat.tsx
new file mode 100644
index 000000000..49a960b83
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/Stat.tsx
@@ -0,0 +1,16 @@
+import { cn } from '@/shared/lib/utils'
+
+interface StatProps {
+ label: string
+ value: string
+ tone?: string
+}
+
+export function Stat({ label, value, tone }: StatProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/TabsRow.tsx b/frontend/src/features/threat-intel/components/TabsRow.tsx
new file mode 100644
index 000000000..22d3b1e60
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/TabsRow.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/shared/lib/utils'
+
+export type TabKey = 'iocs' | 'actors' | 'feeds'
+
+export interface TabsRowProps {
+ active: TabKey
+ onChange: (t: TabKey) => void
+ counts?: { iocs?: number; actors?: number; feeds?: number }
+}
+
+export function TabsRow({ active, onChange, counts }: TabsRowProps) {
+ const { t } = useTranslation()
+ const TABS: { id: TabKey; labelKey: string }[] = [
+ { id: 'iocs', labelKey: 'threatIntel.tabs.iocs' },
+ { id: 'actors', labelKey: 'threatIntel.tabs.actors' },
+ { id: 'feeds', labelKey: 'threatIntel.tabs.feeds' },
+ ]
+ return (
+
+ {TABS.map((tab) => {
+ const tabActive = active === tab.id
+ const count = counts?.[tab.id] ?? 0
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx b/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx
new file mode 100644
index 000000000..3bfa5520b
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx
@@ -0,0 +1,28 @@
+import { useTranslation } from 'react-i18next'
+import { Download } from 'lucide-react'
+import { Button } from '@/shared/components/ui/button'
+
+export interface ThreatIntelHeaderProps {
+ matchedCount?: number
+ onRefresh?: () => void
+ onExport?: () => void
+ isExporting?: boolean
+}
+
+export function ThreatIntelHeader({ matchedCount, onExport, isExporting }: ThreatIntelHeaderProps) {
+ const { t } = useTranslation()
+ const canExport = !!onExport && !!matchedCount && !isExporting
+ return (
+
+
+ {matchedCount?.toLocaleString() || 0} {t('threatIntel.header.matchedInEnv')}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/utils/hourly-buckets.ts b/frontend/src/features/threat-intel/components/utils/hourly-buckets.ts
new file mode 100644
index 000000000..4fc9f807b
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/utils/hourly-buckets.ts
@@ -0,0 +1,22 @@
+import type { AggregationBucket } from '../../domain/threat-intel.types'
+
+export interface HourlyBucket {
+ ts: number
+ count: number
+}
+
+export function fillHourlyBuckets(buckets: AggregationBucket[]): HourlyBucket[] {
+ const now = Date.now()
+ const hours: HourlyBucket[] = []
+
+ // Generate 24 hourly slots ending at now, rounded to the hour.
+ const roundedNow = Math.floor(now / 3600000) * 3600000
+ const bucketMap = new Map(buckets.map((b) => [b.key, b.doc_count]))
+
+ for (let i = 23; i >= 0; i--) {
+ const ts = roundedNow - i * 3600000
+ hours.push({ ts, count: bucketMap.get(ts) ?? 0 })
+ }
+
+ return hours
+}
diff --git a/frontend/src/features/threat-intel/components/utils/severity-style.ts b/frontend/src/features/threat-intel/components/utils/severity-style.ts
new file mode 100644
index 000000000..f0bb50e82
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/utils/severity-style.ts
@@ -0,0 +1,71 @@
+import { type LucideIcon, Bug, Fingerprint, Globe2, Hash, LinkIcon, Network, Skull } from 'lucide-react'
+import type { EntityType, FeedType, FeedAccuracy } from '../../domain/threat-intel.types'
+
+export type ReputationTone = 'danger' | 'warning' | 'neutral' | 'unknown'
+
+export const REPUTATION_STYLE: Record = {
+ danger: { bar: 'bg-red-500', tone: 'text-red-500' },
+ warning: { bar: 'bg-amber-500', tone: 'text-amber-500' },
+ neutral: { bar: 'bg-sky-500', tone: 'text-sky-500' },
+ unknown: { bar: 'bg-muted', tone: 'text-muted-foreground' },
+}
+
+// ponytail: CM reputation_score seen values are 0 (Indefinable) and -3 (Alarming).
+// Buckets: <0 danger, 0 unknown-neutral, positive safe. Tighten when CM docs land.
+export function reputationTone(score: number): ReputationTone {
+ if (score <= -2) return 'danger'
+ if (score < 0) return 'warning'
+ if (score === 0) return 'unknown'
+ return 'neutral'
+}
+
+// Search items only give a numeric reputation. Detail responses give both the score
+// and CM's string. This helper is used when only the score is available.
+export function reputationLabel(score: number): string {
+ if (score <= -3) return 'Alarming'
+ if (score <= -1) return 'Suspicious'
+ if (score === 0) return 'Indefinable'
+ if (score === 1) return 'Fair'
+ return 'Trustworthy'
+}
+
+export function reputationLabelKey(score: number): string {
+ if (score <= -3) return 'threatIntel.reputation.alarming'
+ if (score <= -1) return 'threatIntel.reputation.suspicious'
+ if (score === 0) return 'threatIntel.reputation.indefinable'
+ if (score === 1) return 'threatIntel.reputation.fair'
+ return 'threatIntel.reputation.trustworthy'
+}
+
+const TYPE_FALLBACK = { icon: Fingerprint, labelKey: 'threatIntel.entityTypes.entity', tone: 'text-muted-foreground' } as const
+
+const TYPE_TABLE: Partial> = {
+ ip: { icon: Network, labelKey: 'threatIntel.entityTypes.ip', tone: 'text-sky-500' },
+ hostname: { icon: LinkIcon, labelKey: 'threatIntel.entityTypes.hostname', tone: 'text-amber-500' },
+ domain: { icon: Globe2, labelKey: 'threatIntel.entityTypes.domain', tone: 'text-violet-500' },
+ url: { icon: LinkIcon, labelKey: 'threatIntel.entityTypes.url', tone: 'text-amber-500' },
+ hash: { icon: Hash, labelKey: 'threatIntel.entityTypes.hash', tone: 'text-fuchsia-500' },
+ cve: { icon: Bug, labelKey: 'threatIntel.entityTypes.cve', tone: 'text-red-500' },
+ threat: { icon: Skull, labelKey: 'threatIntel.entityTypes.threat', tone: 'text-red-500' },
+}
+
+export function typeMeta(type: EntityType): { icon: LucideIcon; labelKey: string; tone: string } {
+ return TYPE_TABLE[type] ?? TYPE_FALLBACK
+}
+
+const FEED_TYPE_TABLE: Partial> = {
+ accumulative: 'text-violet-500',
+}
+export function feedTypeTone(type: FeedType): string {
+ return FEED_TYPE_TABLE[type] ?? 'text-muted-foreground'
+}
+
+// ponytail: CM feed accuracy labels seen so far: "level1". Assume level1 > level2 > level3.
+const FEED_ACCURACY_TABLE: Partial> = {
+ level1: { dot: 'bg-emerald-500', labelKey: 'threatIntel.feedAccuracy.level1' },
+ level2: { dot: 'bg-amber-500', labelKey: 'threatIntel.feedAccuracy.level2' },
+ level3: { dot: 'bg-red-500', labelKey: 'threatIntel.feedAccuracy.level3' },
+}
+export function feedAccuracyMeta(a: FeedAccuracy): { dot: string; labelKey: string } {
+ return FEED_ACCURACY_TABLE[a] ?? { dot: 'bg-muted-foreground', labelKey: a }
+}
diff --git a/frontend/src/features/threat-intel/components/utils/time-format.ts b/frontend/src/features/threat-intel/components/utils/time-format.ts
new file mode 100644
index 000000000..f55a9c1d8
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/utils/time-format.ts
@@ -0,0 +1,21 @@
+const NOW = new Date('2026-05-01T16:42:00Z').getTime()
+
+export function relativeTime(iso: string) {
+ const diff = NOW - new Date(iso).getTime()
+ const m = Math.round(diff / 60_000)
+ if (m < 1) return 'just now'
+ if (m < 60) return `${m}m ago`
+ const h = Math.round(m / 60)
+ if (h < 24) return `${h}h ago`
+ return `${Math.round(h / 24)}d ago`
+}
+
+export function absTimestamp(iso: string) {
+ return new Date(iso).toLocaleString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
diff --git a/frontend/src/features/threat-intel/domain/threat-intel.types.ts b/frontend/src/features/threat-intel/domain/threat-intel.types.ts
new file mode 100644
index 000000000..ff2e88f14
--- /dev/null
+++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts
@@ -0,0 +1,178 @@
+// CM/ThreatWinds analytics response shapes. Wire the proxy responses through as-is.
+
+export type EntityType =
+ | 'ip'
+ | 'hostname'
+ | 'domain'
+ | 'url'
+ | 'hash'
+ | 'cve'
+ | 'threat'
+ | (string & {})
+
+export interface EntityAttributes {
+ id: string
+ type: EntityType
+ value: string
+ label: string
+ description?: string
+ tags: string[]
+ first_seen: string
+ last_seen: string
+ reputation_score: number
+ reputation: string
+ best_reputation_score: number
+ best_reputation: string
+ worst_reputation_score: number
+ worst_reputation: string
+ accuracy_score: number
+ accuracy: string
+}
+
+export interface EntityMetadata {
+ asn: number
+ aso: string
+ city: string
+ country: string
+ latitude: number
+ longitude: number
+}
+
+export interface EntityGeolocation {
+ object: string
+ country: string
+ city: string
+ latitude: number
+ longitude: number
+ accuracy_radius: number
+ asn: number
+ aso: string
+}
+
+export interface EntityDetail {
+ attributes: EntityAttributes
+ metadata: EntityMetadata
+ extended_metadata: unknown[]
+ latest_associations: EntityAttributes[]
+ geolocations: EntityGeolocation[]
+}
+
+// Search results are a different CM shape than entity detail: camelCase, no label /
+// description, value nested under `attributes[]`, reputation is a number.
+export interface EntitySearchItem {
+ '@timestamp': string
+ id: string
+ type: EntityType
+ attributes: Record
+ tags: string[]
+ lastSeen: string
+ reputation: number
+ bestReputation: number
+ worstReputation: number
+ accuracy: number
+ score: number
+ version: number
+ visibleBy: string[]
+ wellKnown: boolean
+ fields: unknown | null
+ sort: unknown[]
+}
+
+export type EntitySummary = EntitySearchItem
+// ponytail: relations endpoint shape unconfirmed — assumed EntityAttributes[] like
+// latest_associations from detail. Narrow when a real response is observed.
+export type EntityRelation = EntityAttributes
+
+// Search items nest the display value inside `attributes[]` (e.g.
+// { type: 'hostname', attributes: { hostname: 'asas-asso.com' } }). Pick the
+// first string value if the expected key isn't found.
+export function searchItemValue(item: EntitySearchItem): string {
+ return item.attributes[item.type] ?? Object.values(item.attributes)[0] ?? ''
+}
+
+export interface EntitySearchRequest {
+ query: string
+ types?: EntityType[]
+ page?: number
+ size?: number
+}
+
+export interface EntitySearchResponse {
+ results: EntitySummary[]
+ items: number
+ pages: number
+ aggregations?: unknown | null
+}
+
+export type FeedType = 'accumulative' | (string & {})
+export type FeedAccuracy = 'level1' | 'level2' | 'level3' | (string & {})
+
+export interface ThreatFeed {
+ name: string
+ type: FeedType
+ accuracy: FeedAccuracy
+}
+
+export interface ChatMessage {
+ role: 'user' | 'assistant' | 'system'
+ content: string
+}
+
+export interface ChatRequest {
+ messages: ChatMessage[]
+ model?: string
+}
+
+export interface ChatResponse {
+ message: ChatMessage
+ usage?: { totalTokens: number }
+}
+
+export interface UsageInfo {
+ used: number
+ quota: number
+ resetsAt?: string
+}
+
+export type TiResult =
+ | { kind: 'ok'; value: T }
+ | { kind: 'not-configured' }
+
+export interface AdvancedRangeCondition {
+ range: Record
+}
+export interface AdvancedTermCondition {
+ term: Record
+}
+export type AdvancedCondition = AdvancedRangeCondition | AdvancedTermCondition
+
+export interface AdvancedDateHistogram {
+ date_histogram: { field: string; interval: string }
+}
+export type AdvancedAggregation = AdvancedDateHistogram
+
+export interface AdvancedSearchRequest {
+ query?: {
+ must?: AdvancedCondition[]
+ should?: AdvancedCondition[]
+ must_not?: AdvancedCondition[]
+ filter?: AdvancedCondition[]
+ }
+ aggs?: Record
+}
+
+export interface AggregationBucket {
+ key: number
+ key_as_string: string
+ doc_count: number
+}
+export interface AggregationResult {
+ buckets: AggregationBucket[]
+}
+
+export interface AdvancedSearchResponse {
+ items: number
+ pages: number
+ results: EntitySearchItem[]
+ aggregations?: Record | null
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-chat.ts b/frontend/src/features/threat-intel/hooks/use-ti-chat.ts
new file mode 100644
index 000000000..c1574173e
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-chat.ts
@@ -0,0 +1,12 @@
+import { useMutation } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+import { describeError, isNotConfigured } from '../services/ti-errors'
+import type { ChatRequest } from '../domain/threat-intel.types'
+
+export function useTiChat() {
+ return useMutation({
+ mutationFn: (req: ChatRequest) => threatIntelHttpService.chat(req),
+ onError: (e) => { if (!isNotConfigured(e)) toast.error(describeError(e)) },
+ })
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-config-status.ts b/frontend/src/features/threat-intel/hooks/use-ti-config-status.ts
new file mode 100644
index 000000000..a4625c80a
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-config-status.ts
@@ -0,0 +1,10 @@
+import { useTiUsage } from './use-ti-usage'
+
+export function useTiConfigStatus(): { isConfigured: boolean | undefined; isLoading: boolean } {
+ const q = useTiUsage()
+ if (q.isLoading) return { isConfigured: undefined, isLoading: true }
+ if (q.data?.kind === 'not-configured') return { isConfigured: false, isLoading: false }
+ if (q.data?.kind === 'ok') return { isConfigured: true, isLoading: false }
+ // request errored (non-503) — treat as configured but broken; the individual hooks handle their own errors
+ return { isConfigured: true, isLoading: false }
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts b/frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts
new file mode 100644
index 000000000..0038db86c
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+
+export function useTiEntityRelations(id: string | null | undefined) {
+ return useQuery({
+ queryKey: ['ti', 'entity', id, 'relations'],
+ queryFn: () => threatIntelHttpService.relations(id as string),
+ enabled: !!id,
+ staleTime: 5 * 60_000,
+ })
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-entity.ts b/frontend/src/features/threat-intel/hooks/use-ti-entity.ts
new file mode 100644
index 000000000..59391adf4
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-entity.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+
+export function useTiEntity(id: string | null | undefined) {
+ return useQuery({
+ queryKey: ['ti', 'entity', id],
+ queryFn: () => threatIntelHttpService.entity(id as string),
+ enabled: !!id,
+ staleTime: 5 * 60_000,
+ })
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-feeds.ts b/frontend/src/features/threat-intel/hooks/use-ti-feeds.ts
new file mode 100644
index 000000000..205275c1a
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-feeds.ts
@@ -0,0 +1,10 @@
+import { useQuery } from '@tanstack/react-query'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+
+export function useTiFeeds() {
+ return useQuery({
+ queryKey: ['ti', 'feeds'],
+ queryFn: () => threatIntelHttpService.feeds(),
+ staleTime: 60_000,
+ })
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts b/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts
new file mode 100644
index 000000000..278d43217
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+import type { AdvancedSearchRequest } from '../domain/threat-intel.types'
+
+const REQ: AdvancedSearchRequest = {
+ query: { must: [{ range: { lastSeen: { gte: 'now-24h', lte: 'now' } } }] },
+ aggs: { hourly_iocs: { date_histogram: { field: 'lastSeen', interval: 'hour' } } },
+}
+
+export function useTiIocs24h() {
+ return useQuery({
+ queryKey: ['ti', 'iocs-24h'],
+ queryFn: () => threatIntelHttpService.searchAdvanced(REQ, { limit: 0, page: 1 }),
+ })
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-search.ts b/frontend/src/features/threat-intel/hooks/use-ti-search.ts
new file mode 100644
index 000000000..0422409c7
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-search.ts
@@ -0,0 +1,12 @@
+import { useMutation } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+import { describeError, isNotConfigured } from '../services/ti-errors'
+import type { EntitySearchRequest } from '../domain/threat-intel.types'
+
+export function useTiSearch() {
+ return useMutation({
+ mutationFn: (req: EntitySearchRequest) => threatIntelHttpService.search(req),
+ onError: (e) => { if (!isNotConfigured(e)) toast.error(describeError(e)) },
+ })
+}
diff --git a/frontend/src/features/threat-intel/hooks/use-ti-usage.ts b/frontend/src/features/threat-intel/hooks/use-ti-usage.ts
new file mode 100644
index 000000000..5740c7a32
--- /dev/null
+++ b/frontend/src/features/threat-intel/hooks/use-ti-usage.ts
@@ -0,0 +1,10 @@
+import { useQuery } from '@tanstack/react-query'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+
+export function useTiUsage() {
+ return useQuery({
+ queryKey: ['ti', 'usage'],
+ queryFn: () => threatIntelHttpService.usage(),
+ staleTime: 60_000,
+ })
+}
diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx
index 6093169dd..ee2fe52b5 100644
--- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx
+++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx
@@ -1,320 +1,156 @@
-import { useMemo, useState } from 'react'
-import {
- AlertTriangle,
- Bug,
- ChevronDown,
- Clock,
- Crosshair,
- Download,
- ExternalLink,
- FileText,
- Fingerprint,
- Globe2,
- Hash,
- Link as LinkIcon,
- ListFilter,
- MoreHorizontal,
- Network,
- RefreshCw,
- Search,
- Shield,
- Sparkles,
- UserX,
- X,
- type LucideIcon,
-} from 'lucide-react'
-import { cn } from '@/shared/lib/utils'
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { ChevronDown, Clock, ListFilter, RefreshCw, Search } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
-
-/* ─── Types ────────────────────────────────────────────────────────────── */
-
-type IocType = 'ipv4' | 'domain' | 'sha256' | 'md5' | 'url' | 'cve'
-type Severity = 'critical' | 'high' | 'medium' | 'low'
-type FeedKind = 'commercial' | 'open' | 'internal'
-type FeedStatus = 'healthy' | 'stale' | 'error' | 'paused'
-
-interface IOC {
- id: string
- type: IocType
- value: string
- severity: Severity
- feeds: string[]
- matchesInEnv: number
- firstSeenInEnv?: string
- lastSeenInEnv?: string
- tags: string[]
- attributedActor?: string
-}
-
-interface ThreatActor {
- id: string
- name: string
- aliases: string[]
- techniques: string[]
- sectors: string[]
- origin: string[]
- matchedAdversaries: number
- lastActivity: string
- iocCount: number
- description: string
-}
-
-interface ThreatFeed {
- id: string
- name: string
- kind: FeedKind
- status: FeedStatus
- itemsTotal: number
- itemsAdded24h: number
- lastSync: string
- syncIntervalMin: number
-}
-
-/* ─── Mock data ────────────────────────────────────────────────────────── */
-
-const NOW = new Date('2026-05-01T16:42:00Z').getTime()
-const min = (m: number) => new Date(NOW - m * 60_000).toISOString()
-const hr = (h: number) => new Date(NOW - h * 3_600_000).toISOString()
-const days = (d: number) => new Date(NOW - d * 86_400_000).toISOString()
-
-const IOCS: IOC[] = [
- {
- id: 'ioc-1',
- type: 'ipv4',
- value: '198.51.100.14',
- severity: 'critical',
- feeds: ['AlienVault OTX', 'Mandiant', 'AbuseIPDB'],
- matchesInEnv: 23,
- firstSeenInEnv: hr(46),
- lastSeenInEnv: min(4),
- tags: ['C2', 'Scattered Spider', 'campaign-44'],
- attributedActor: 'Scattered Spider',
- },
- {
- id: 'ioc-2',
- type: 'sha256',
- value: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
- severity: 'critical',
- feeds: ['VirusTotal', 'Mandiant'],
- matchesInEnv: 1,
- firstSeenInEnv: min(62),
- lastSeenInEnv: min(62),
- tags: ['Mimikatz', 'credential-dumping'],
- attributedActor: 'Scattered Spider',
- },
- {
- id: 'ioc-3',
- type: 'domain',
- value: 'malicious-update[.]com',
- severity: 'high',
- feeds: ['AbuseCH', 'AlienVault OTX'],
- matchesInEnv: 8,
- firstSeenInEnv: hr(2),
- lastSeenInEnv: min(14),
- tags: ['phishing', 'credential-harvest'],
- },
- {
- id: 'ioc-4',
- type: 'ipv4',
- value: '45.83.66.12',
- severity: 'high',
- feeds: ['Spamhaus', 'AlienVault OTX'],
- matchesInEnv: 4,
- firstSeenInEnv: hr(8),
- lastSeenInEnv: min(74),
- tags: ['exfiltration', 'bulletproof-hoster'],
- },
- {
- id: 'ioc-5',
- type: 'cve',
- value: 'CVE-2024-38077',
- severity: 'critical',
- feeds: ['NVD', 'CISA KEV'],
- matchesInEnv: 0,
- tags: ['RCE', 'Windows', 'KEV'],
- },
- {
- id: 'ioc-6',
- type: 'ipv4',
- value: '203.0.113.7',
- severity: 'medium',
- feeds: ['Internal blocklist'],
- matchesInEnv: 312,
- firstSeenInEnv: hr(2),
- lastSeenInEnv: min(23),
- tags: ['brute-force', 'recent-abuse'],
- },
- {
- id: 'ioc-7',
- type: 'url',
- value: 'hxxp://exfil[.]bad/upload',
- severity: 'high',
- feeds: ['Internal IOC db'],
- matchesInEnv: 2,
- firstSeenInEnv: hr(4),
- lastSeenInEnv: hr(4),
- tags: ['exfiltration'],
- },
- {
- id: 'ioc-8',
- type: 'md5',
- value: 'd41d8cd98f00b204e9800998ecf8427e',
- severity: 'medium',
- feeds: ['VirusTotal'],
- matchesInEnv: 0,
- tags: ['adware'],
- },
- {
- id: 'ioc-9',
- type: 'domain',
- value: 'cdn-update-service[.]net',
- severity: 'low',
- feeds: ['Spamhaus'],
- matchesInEnv: 0,
- tags: ['suspicious-tld'],
- },
- {
- id: 'ioc-10',
- type: 'cve',
- value: 'CVE-2024-21412',
- severity: 'high',
- feeds: ['NVD', 'CISA KEV'],
- matchesInEnv: 0,
- tags: ['SmartScreen-bypass', 'KEV'],
- },
-]
-
-const ACTORS: ThreatActor[] = [
- {
- id: 'actor-scattered-spider',
- name: 'Scattered Spider',
- aliases: ['UNC3944', 'Scatter Swine', 'Octo Tempest'],
- techniques: ['T1558.003', 'T1059.001', 'T1071.001', 'T1003'],
- sectors: ['Telecom', 'Hospitality', 'Tech', 'Finance'],
- origin: ['Unknown'],
- matchedAdversaries: 3,
- lastActivity: min(4),
- iocCount: 47,
- description:
- 'Financially motivated threat group known for SIM swapping, social engineering, and Kerberoasting attacks against cloud and on-prem infrastructure.',
- },
- {
- id: 'actor-fin7',
- name: 'FIN7',
- aliases: ['Carbon Spider', 'Sangria Tempest'],
- techniques: ['T1566', 'T1059.001', 'T1027'],
- sectors: ['Retail', 'Hospitality', 'Finance'],
- origin: ['Russia (suspected)'],
- matchedAdversaries: 1,
- lastActivity: days(8),
- iocCount: 312,
- description:
- 'Long-running cybercrime group targeting POS systems and financial data through targeted phishing.',
- },
- {
- id: 'actor-apt29',
- name: 'APT29',
- aliases: ['Cozy Bear', 'Midnight Blizzard', 'NOBELIUM'],
- techniques: ['T1078', 'T1098', 'T1606'],
- sectors: ['Government', 'Tech', 'Diplomatic'],
- origin: ['Russia (SVR)'],
- matchedAdversaries: 0,
- lastActivity: days(32),
- iocCount: 184,
- description:
- 'Russian state-sponsored APT focused on intelligence collection. Known for SolarWinds and Microsoft 365 token theft campaigns.',
- },
- {
- id: 'actor-lazarus',
- name: 'Lazarus Group',
- aliases: ['Hidden Cobra', 'APT38'],
- techniques: ['T1190', 'T1059', 'T1486'],
- sectors: ['Finance', 'Crypto', 'Defense'],
- origin: ['North Korea (DPRK)'],
- matchedAdversaries: 0,
- lastActivity: days(60),
- iocCount: 521,
- description: 'DPRK state actor focused on financial theft and cryptocurrency targeting.',
- },
-]
-
-const FEEDS: ThreatFeed[] = [
- { id: 'f-otx', name: 'AlienVault OTX', kind: 'open', status: 'healthy', itemsTotal: 4_120_044, itemsAdded24h: 8_421, lastSync: min(8), syncIntervalMin: 15 },
- { id: 'f-mandiant', name: 'Mandiant Advantage', kind: 'commercial', status: 'healthy', itemsTotal: 218_410, itemsAdded24h: 412, lastSync: min(22), syncIntervalMin: 60 },
- { id: 'f-abusech', name: 'AbuseCH (URLhaus + ThreatFox)', kind: 'open', status: 'healthy', itemsTotal: 891_201, itemsAdded24h: 2_104, lastSync: min(11), syncIntervalMin: 30 },
- { id: 'f-virustotal', name: 'VirusTotal', kind: 'commercial', status: 'healthy', itemsTotal: 3_420_115, itemsAdded24h: 14_872, lastSync: min(4), syncIntervalMin: 10 },
- { id: 'f-spamhaus', name: 'Spamhaus DROP/EDROP', kind: 'open', status: 'healthy', itemsTotal: 18_421, itemsAdded24h: 22, lastSync: hr(2), syncIntervalMin: 240 },
- { id: 'f-cisa', name: 'CISA Known Exploited Vulns (KEV)', kind: 'open', status: 'healthy', itemsTotal: 1_142, itemsAdded24h: 0, lastSync: hr(8), syncIntervalMin: 1440 },
- { id: 'f-nvd', name: 'NVD CVE feed', kind: 'open', status: 'stale', itemsTotal: 218_001, itemsAdded24h: 47, lastSync: hr(36), syncIntervalMin: 360 },
- { id: 'f-misp', name: 'Internal MISP', kind: 'internal', status: 'healthy', itemsTotal: 4_812, itemsAdded24h: 18, lastSync: min(2), syncIntervalMin: 5 },
- { id: 'f-twinds', name: 'ThreatWinds Galaxy', kind: 'commercial', status: 'healthy', itemsTotal: 12_491_201, itemsAdded24h: 34_120, lastSync: min(1), syncIntervalMin: 5 },
- { id: 'f-recorded', name: 'Recorded Future', kind: 'commercial', status: 'error', itemsTotal: 0, itemsAdded24h: 0, lastSync: hr(12), syncIntervalMin: 60 },
-]
-
-/* ─── Style maps ───────────────────────────────────────────────────────── */
-
-const SEVERITY_STYLE: Record = {
- critical: { bar: 'bg-red-500', label: 'Critical', tone: 'text-red-500' },
- high: { bar: 'bg-orange-500', label: 'High', tone: 'text-orange-500' },
- medium: { bar: 'bg-amber-500', label: 'Medium', tone: 'text-amber-500' },
- low: { bar: 'bg-sky-500', label: 'Low', tone: 'text-sky-500' },
-}
-
-const TYPE_META: Record = {
- ipv4: { icon: Network, label: 'IPv4', tone: 'text-sky-500' },
- domain: { icon: Globe2, label: 'Domain', tone: 'text-violet-500' },
- sha256: { icon: Hash, label: 'SHA-256', tone: 'text-fuchsia-500' },
- md5: { icon: Fingerprint, label: 'MD5', tone: 'text-pink-500' },
- url: { icon: LinkIcon, label: 'URL', tone: 'text-amber-500' },
- cve: { icon: Bug, label: 'CVE', tone: 'text-red-500' },
-}
-
-const FEED_KIND_TONE: Record = {
- commercial: 'text-violet-500',
- open: 'text-sky-500',
- internal: 'text-emerald-500',
-}
-
-const FEED_STATUS_META: Record = {
- healthy: { dot: 'bg-emerald-500', label: 'Healthy' },
- stale: { dot: 'bg-amber-500', label: 'Stale' },
- error: { dot: 'bg-red-500 animate-pulse', label: 'Error' },
- paused: { dot: 'bg-muted-foreground', label: 'Paused' },
-}
-
-/* ─── Page ─────────────────────────────────────────────────────────────── */
-
-type Tab = 'matched' | 'all' | 'actors' | 'feeds'
+import { toast } from 'sonner'
+import { useTiConfigStatus } from '../hooks/use-ti-config-status'
+import { useTiFeeds } from '../hooks/use-ti-feeds'
+import { useTiSearch } from '../hooks/use-ti-search'
+import { threatIntelHttpService } from '../services/threat-intel-http.service'
+import { describeError } from '../services/ti-errors'
+import { downloadCsv, toCsv } from '../services/csv'
+import { searchItemValue } from '../domain/threat-intel.types'
+import { NotConfiguredState } from '../components/NotConfiguredState'
+import { ThreatIntelHeader } from '../components/ThreatIntelHeader'
+import { MatchOverviewCard } from '../components/MatchOverviewCard'
+import { LookupBar } from '../components/LookupBar'
+import { TabsRow, type TabKey } from '../components/TabsRow'
+import { IocTable } from '../components/IocTable'
+import { IocDrawer } from '../components/IocDrawer'
+import { ActorsList } from '../components/ActorsList'
+import { ActorDrawer } from '../components/ActorDrawer'
+import { FeedsList } from '../components/FeedsList'
+import type { EntitySearchResponse, EntitySummary } from '../domain/threat-intel.types'
export function ThreatIntelPage() {
- const [tab, setTab] = useState('matched')
- const [search, setSearch] = useState('')
- const [openIoc, setOpenIoc] = useState(null)
- const [openActor, setOpenActor] = useState(null)
-
- const matchedIocs = IOCS.filter((i) => i.matchesInEnv > 0)
- const totalMatches = matchedIocs.reduce((s, i) => s + i.matchesInEnv, 0)
+ const { t } = useTranslation()
+ const { isConfigured, isLoading: configLoading } = useTiConfigStatus()
+ const feedsQuery = useTiFeeds()
+ const searchMutation = useTiSearch()
+
+ const [query, setQuery] = useState('*')
+ const [page, setPage] = useState(0)
+ const [size, setSize] = useState(20)
+ const [results, setResults] = useState(null)
+ const [iocs, setIocs] = useState([])
+
+ // 'replace' when the user searches, jumps pages, or changes page size.
+ // 'append' when infinite scroll asks for the next page.
+ const modeRef = useRef<'replace' | 'append'>('replace')
+ // Guards against out-of-order responses (older mutation lands after newer).
+ const seqRef = useRef(0)
+
+ const [tab, setTab] = useState('iocs')
+ const [openIoc, setOpenIoc] = useState(null)
+ const [openActor, setOpenActor] = useState(null)
+ const [uiSearch, setUiSearch] = useState('')
+ const [isExporting, setIsExporting] = useState(false)
+
+ useEffect(() => {
+ if (!isConfigured) return
+ const my = ++seqRef.current
+ const mode = modeRef.current
+ searchMutation.mutate(
+ { query, page: page + 1, size },
+ {
+ onSuccess: (data) => {
+ if (my !== seqRef.current) return
+ if (data?.kind === 'not-configured') return
+ if (data?.kind !== 'ok') return
+ setResults(data.value)
+ setIocs((prev) =>
+ mode === 'append' ? [...prev, ...data.value.results] : data.value.results
+ )
+ },
+ }
+ )
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [query, page, size, isConfigured])
+
+ if (configLoading) return null
+ if (isConfigured === false) return
+
+ const actors: EntitySummary[] = iocs.filter((e) => e.type === 'threat')
+ const feedsCount = feedsQuery.data?.kind === 'ok' ? feedsQuery.data.value.length : 0
+ const totalItems = results?.items ?? 0
+ const totalPages = results?.pages ?? 0
+ const hasMore = page + 1 < totalPages
+
+ const handleSearch = (q: string) => {
+ modeRef.current = 'replace'
+ setPage(0)
+ setQuery(q)
+ }
+
+ const handlePageChange = (p: number) => {
+ modeRef.current = 'replace'
+ setPage(p)
+ }
+
+ const handlePageSizeChange = (s: number) => {
+ modeRef.current = 'replace'
+ setSize(s)
+ setPage(0)
+ }
+
+ const handleLoadMore = () => {
+ if (!hasMore || searchMutation.isPending) return
+ modeRef.current = 'append'
+ setPage((p) => p + 1)
+ }
+
+ const handleExport = async () => {
+ if (!totalItems || isExporting) return
+ setIsExporting(true)
+ try {
+ const res = await threatIntelHttpService.search({ query, page: 1, size: totalItems })
+ if (res.kind !== 'ok') return
+ const rows = res.value.results.map((r) => [
+ r.id,
+ r.type,
+ searchItemValue(r),
+ r.tags.join('|'),
+ r.lastSeen,
+ r.reputation,
+ r.bestReputation,
+ r.worstReputation,
+ r.accuracy,
+ ])
+ const csv = toCsv(
+ ['id', 'type', 'value', 'tags', 'lastSeen', 'reputation', 'bestReputation', 'worstReputation', 'accuracy'],
+ rows,
+ )
+ downloadCsv(`iocs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, csv)
+ } catch (e) {
+ toast.error(describeError(e))
+ } finally {
+ setIsExporting(false)
+ }
+ }
return (
-
+
-
-
-
-
-
-
-
+
+
-
+
-
+
@@ -323,31 +159,32 @@ export function ThreatIntelPage() {
setSearch(e.target.value)}
+ value={uiSearch}
+ onChange={(e) => setUiSearch(e.target.value)}
className="h-9 pl-9"
/>
- {(tab === 'matched' || tab === 'all') && (
+ {tab === 'iocs' && (
<>
>
)}
- {tab === 'matched' &&
}
- {tab === 'all' &&
}
- {tab === 'actors' &&
}
- {tab === 'feeds' &&
}
-
-
- {openIoc &&
setOpenIoc(null)} />}
- {openActor && setOpenActor(null)} />}
-
- )
-}
-
-/* ─── Header ───────────────────────────────────────────────────────────── */
-
-function Header({ total }: { total: number }) {
- return (
-
- )
-}
-
-/* ─── Match overview ───────────────────────────────────────────────────── */
-
-function MatchOverviewCard({ total, matchedCount }: { total: number; matchedCount: number }) {
- const buckets = 48
- const data = useMemo(
- () =>
- Array.from({ length: buckets }, (_, i) =>
- Math.max(
- 0,
- Math.round(Math.abs(Math.sin(i / 5)) * 18 + Math.abs(Math.sin(i / 11)) * 8 + (i === 38 ? 12 : 0) + 3)
- )
- ),
- []
- )
- const w = 1000
- const h = 100
- const max = Math.max(...data) * 1.15
- const xs = data.map((_, i) => (i * w) / (data.length - 1))
- const ys = data.map((v) => h - (v / max) * h)
- const linePath = data.reduce((acc, _, i) => {
- if (i === 0) return `M ${xs[i]} ${ys[i]}`
- const prevX = xs[i - 1]
- const prevY = ys[i - 1]
- const cx1 = prevX + (xs[i] - prevX) / 2
- const cx2 = xs[i] - (xs[i] - prevX) / 2
- return `${acc} C ${cx1} ${prevY}, ${cx2} ${ys[i]}, ${xs[i]} ${ys[i]}`
- }, '')
- const areaPath = `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z`
-
- return (
-
-
-
-
- IOC matches · last 24 hours
-
-
- {total.toLocaleString()}
-
- {matchedCount} distinct indicators
-
-
-
-
-
-
-
-
- 24h ago
- 12h ago
- now
-
-
- )
-}
-
-/* ─── AI insights ──────────────────────────────────────────────────────── */
-
-function IntelInsightsCard() {
- return (
-
-
-
-
SOC AI insights
-
-
-
- 3 adversaries in your env attributed to{' '}
- Scattered Spider — campaign appears active.{' '}
-
-
-
-
- 2 KEV CVEs applicable to your stack — no
- patches deployed yet.
-
-
-
- 1 feed errored · Recorded Future auth
- token expired 12h ago.
-
-
- )
-}
-
-/* ─── Lookup bar ───────────────────────────────────────────────────────── */
-
-function LookupBar() {
- const [q, setQ] = useState('')
- return (
-
-
-
- Lookup any indicator
-
-
-
-
- setQ(e.target.value)}
- placeholder="Paste an IP, domain, hash, URL, or CVE — we'll check feeds and your environment"
- className="h-10 pl-9 font-mono text-sm"
+ {tab === 'iocs' && (
+
-
-
-
-
- )
-}
-
-/* ─── Tabs row ─────────────────────────────────────────────────────────── */
-
-const TABS: { id: Tab; label: string; count: number }[] = [
- { id: 'matched', label: 'Matched in your env', count: IOCS.filter((i) => i.matchesInEnv > 0).length },
- { id: 'all', label: 'All IOCs', count: IOCS.length },
- { id: 'actors', label: 'Threat actors', count: ACTORS.length },
- { id: 'feeds', label: 'Feeds', count: FEEDS.length },
-]
-
-function TabsRow({ current, onChange }: { current: Tab; onChange: (t: Tab) => void }) {
- return (
-
- {TABS.map((t) => {
- const active = current === t.id
- return (
-
- )
- })}
-
- )
-}
-
-/* ─── IOC table ────────────────────────────────────────────────────────── */
-
-const IOC_COLS = '4px 90px 1fr 130px 70px 110px 100px 36px'
-
-function IocTable({
- iocs,
- search,
- onOpen,
- matched,
-}: {
- iocs: IOC[]
- search: string
- onOpen: (i: IOC) => void
- matched?: boolean
-}) {
- const filtered = iocs.filter((i) =>
- search
- ? (i.value + i.tags.join(' ') + i.feeds.join(' ') + (i.attributedActor ?? ''))
- .toLowerCase()
- .includes(search.toLowerCase())
- : true
- )
-
- return (
-
-
-
-
Type
-
Indicator
-
Source
-
{matched ? 'Matches' : 'Severity'}
-
Tags
-
Last seen
-
-
- {filtered.map((i) => (
-
onOpen(i)} matched={matched} />
- ))}
- {filtered.length === 0 && (
- No IOCs match.
- )}
-
- )
-}
-
-function IocRow({ ioc, onOpen, matched }: { ioc: IOC; onOpen: () => void; matched?: boolean }) {
- const sev = SEVERITY_STYLE[ioc.severity]
- const t = TYPE_META[ioc.type]
- const TIcon = t.icon
- return (
-
-
-
-
-
- {t.label}
-
-
-
-
{ioc.value}
- {ioc.attributedActor && (
-
-
- {ioc.attributedActor}
-
- )}
-
-
- {ioc.feeds[0]}
- {ioc.feeds.length > 1 && (
- +{ioc.feeds.length - 1}
- )}
-
-
- {matched ? (
- {ioc.matchesInEnv.toLocaleString()}
- ) : (
- {sev.label}
- )}
-
-
- {ioc.tags.slice(0, 2).map((tag) => (
-
- {tag}
-
- ))}
- {ioc.tags.length > 2 && (
- +{ioc.tags.length - 2}
- )}
-
-
- {ioc.lastSeenInEnv ? relativeTime(ioc.lastSeenInEnv) : '—'}
-
-
-
-
-
- )
-}
-
-/* ─── Actors list ──────────────────────────────────────────────────────── */
-
-function ActorsList({
- search,
- onOpen,
-}: {
- search: string
- onOpen: (a: ThreatActor) => void
-}) {
- const filtered = ACTORS.filter((a) =>
- search
- ? (a.name + a.aliases.join(' ') + a.techniques.join(' ') + a.sectors.join(' '))
- .toLowerCase()
- .includes(search.toLowerCase())
- : true
- )
- return (
-
- {filtered.map((a) => (
-
onOpen(a)} />
- ))}
-
- )
-}
-
-function ActorCard({ actor, onOpen }: { actor: ThreatActor; onOpen: () => void }) {
- const inEnv = actor.matchedAdversaries > 0
- return (
-
- )
-}
-
-function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
- return (
-
- )
-}
-
-/* ─── Feeds list ───────────────────────────────────────────────────────── */
-
-const FEED_COLS = '12px 1fr 110px 110px 100px 110px 36px'
-
-function FeedsList({ search }: { search: string }) {
- const filtered = FEEDS.filter((f) =>
- search ? f.name.toLowerCase().includes(search.toLowerCase()) : true
- )
- return (
-
-
-
-
Feed
-
Kind
-
Total
-
+24h
-
Last sync
-
-
- {filtered.map((f) => (
-
- ))}
- {filtered.length === 0 && (
-
No feeds match.
- )}
-
- )
-}
-
-function FeedRow({ feed }: { feed: ThreatFeed }) {
- const st = FEED_STATUS_META[feed.status]
- return (
-
-
-
-
{feed.name}
-
- syncs every{' '}
- {feed.syncIntervalMin < 60
- ? `${feed.syncIntervalMin}m`
- : feed.syncIntervalMin < 1440
- ? `${Math.round(feed.syncIntervalMin / 60)}h`
- : `${Math.round(feed.syncIntervalMin / 1440)}d`}
-
-
-
- {feed.kind}
-
-
- {feed.itemsTotal.toLocaleString()}
-
-
0 ? 'text-emerald-500' : 'text-muted-foreground'
)}
- >
- {feed.itemsAdded24h > 0 ? `+${feed.itemsAdded24h.toLocaleString()}` : '—'}
-
-
- {relativeTime(feed.lastSync)}
+ {tab === 'actors' &&
}
+ {tab === 'feeds' &&
}
-
-
-
-
- )
-}
-
-/* ─── IOC drawer ───────────────────────────────────────────────────────── */
-
-function IocDrawer({ ioc, onClose }: { ioc: IOC; onClose: () => void }) {
- const sev = SEVERITY_STYLE[ioc.severity]
- const t = TYPE_META[ioc.type]
- const TIcon = t.icon
- return (
-
-
e.stopPropagation()}
- >
-
-
-
-
-
-
- {ioc.matchesInEnv > 0 ? (
-
-
- {ioc.matchesInEnv.toLocaleString()} events
-
- {ioc.firstSeenInEnv && (
-
- {absTimestamp(ioc.firstSeenInEnv)}
-
- )}
- {ioc.lastSeenInEnv && (
-
- {absTimestamp(ioc.lastSeenInEnv)}
-
- )}
-
- ) : (
-
- Not seen in your environment yet. Pre-block this IOC to prevent matches.
-
- )}
-
-
-
-
- {ioc.feeds.map((f) => (
- -
- {f}
-
-
- ))}
-
-
-
-
-
- {['VirusTotal', 'AbuseIPDB', 'Shodan', 'AlienVault OTX', 'GreyNoise', 'Censys'].map(
- (name) => (
-
- )
- )}
-
-
-
-
-
-
-
-
+
setOpenIoc(null)} />
+ setOpenActor(null)} />
)
}
-
-/* ─── Actor drawer ─────────────────────────────────────────────────────── */
-
-function ActorDrawer({ actor, onClose }: { actor: ThreatActor; onClose: () => void }) {
- return (
-
-
e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
-
- {actor.techniques.map((t) => (
-
-
- {t}
-
- ))}
-
-
-
-
-
- {actor.sectors.map((s) => (
- {s}
- ))}
-
-
-
-
-
-
- 0 ? 'text-fuchsia-500' : ''} />
-
-
-
-
-
-
-
-
- )
-}
-
-/* ─── Small parts ──────────────────────────────────────────────────────── */
-
-function Section({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
-
{title}
- {children}
-
- )
-}
-
-function KV({ k, children }: { k: string; children: React.ReactNode }) {
- return (
- <>
- {k}
- {children}
- >
- )
-}
-
-function Metric({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- )
-}
-
-/* ─── Helpers ──────────────────────────────────────────────────────────── */
-
-function relativeTime(iso: string) {
- const diff = NOW - new Date(iso).getTime()
- const m = Math.round(diff / 60_000)
- if (m < 1) return 'just now'
- if (m < 60) return `${m}m ago`
- const h = Math.round(m / 60)
- if (h < 24) return `${h}h ago`
- return `${Math.round(h / 24)}d ago`
-}
-
-function absTimestamp(iso: string) {
- return new Date(iso).toLocaleString(undefined, {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })
-}
-
-// Suppress potential unused-import warnings
-void AlertTriangle
diff --git a/frontend/src/features/threat-intel/services/csv.ts b/frontend/src/features/threat-intel/services/csv.ts
new file mode 100644
index 000000000..bfaeecc7a
--- /dev/null
+++ b/frontend/src/features/threat-intel/services/csv.ts
@@ -0,0 +1,24 @@
+// ponytail: minimal CSV writer — no new dep. Quote-when-needed, escape internal quotes.
+
+function csvCell(value: unknown): string {
+ const s = value == null ? '' : String(value)
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
+}
+
+export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]): string {
+ const lines = [headers.map(csvCell).join(',')]
+ for (const r of rows) lines.push(r.map(csvCell).join(','))
+ return lines.join('\n')
+}
+
+export function downloadCsv(filename: string, csv: string): void {
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(url)
+}
diff --git a/frontend/src/features/threat-intel/services/threat-intel-http.service.ts b/frontend/src/features/threat-intel/services/threat-intel-http.service.ts
new file mode 100644
index 000000000..0991ffbc4
--- /dev/null
+++ b/frontend/src/features/threat-intel/services/threat-intel-http.service.ts
@@ -0,0 +1,47 @@
+import { createApiClient } from '@/shared/lib/api-client'
+import type {
+ EntitySearchRequest,
+ EntitySearchResponse,
+ EntityDetail,
+ EntityRelation,
+ ThreatFeed,
+ ChatRequest,
+ ChatResponse,
+ UsageInfo,
+ TiResult,
+ AdvancedSearchRequest,
+ AdvancedSearchResponse,
+} from '../domain/threat-intel.types'
+import { isNotConfigured } from './ti-errors'
+
+const api = createApiClient()
+const BASE = '/threat-intel'
+
+async function wrap(fn: () => Promise): Promise> {
+ try {
+ return { kind: 'ok', value: await fn() }
+ } catch (e) {
+ if (isNotConfigured(e)) return { kind: 'not-configured' }
+ throw e
+ }
+}
+
+export const threatIntelHttpService = {
+ search: (body: EntitySearchRequest) =>
+ wrap(() => api.post(`${BASE}/search`, body)),
+ searchAdvanced: (body: AdvancedSearchRequest, params?: { limit?: number; page?: number }) => {
+ const qs = new URLSearchParams()
+ if (params?.limit !== undefined) qs.set('limit', String(params.limit))
+ if (params?.page !== undefined) qs.set('page', String(params.page))
+ const query = qs.toString()
+ const path = `${BASE}/search/advanced${query ? `?${query}` : ''}`
+ return wrap(() => api.post(path, body))
+ },
+ entity: (id: string) =>
+ wrap(() => api.get(`${BASE}/entity/${encodeURIComponent(id)}`)),
+ relations: (id: string) =>
+ wrap(() => api.get(`${BASE}/entity/${encodeURIComponent(id)}/relations`)),
+ feeds: () => wrap(() => api.get(`${BASE}/feeds`)),
+ chat: (body: ChatRequest) => wrap(() => api.post(`${BASE}/ai/chat`, body)),
+ usage: () => wrap(() => api.get(`${BASE}/usage`)),
+}
diff --git a/frontend/src/features/threat-intel/services/ti-errors.ts b/frontend/src/features/threat-intel/services/ti-errors.ts
new file mode 100644
index 000000000..eb6639c59
--- /dev/null
+++ b/frontend/src/features/threat-intel/services/ti-errors.ts
@@ -0,0 +1,14 @@
+import { ApiError } from '@/shared/lib/api-client'
+
+export function isNotConfigured(e: unknown): boolean {
+ return e instanceof ApiError && e.status === 503 && /not configured/i.test(e.message ?? '')
+}
+
+export function describeError(e: unknown): string {
+ if (e instanceof ApiError) {
+ if (e.status === 502) return 'Threat intel upstream unavailable — try again.'
+ return e.message || `Request failed (${e.status}).`
+ }
+ if (e instanceof Error) return e.message
+ return 'Unknown error'
+}
diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json
index c0853389b..87d34bb31 100644
--- a/frontend/src/shared/i18n/locales/de.json
+++ b/frontend/src/shared/i18n/locales/de.json
@@ -5036,5 +5036,103 @@
"deleteError": "Regel konnte nicht gelöscht werden."
},
"deleteTitle": "Tagging-Regel löschen"
+ },
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "Treffer in Ihrer Umgebung",
+ "export": "IOCs exportieren",
+ "exporting": "Wird exportiert…"
+ },
+ "overview": {
+ "title": "IOC-Treffer · letzte 24 Stunden",
+ "totalIndicators": "Gesamtzahl Indikatoren",
+ "axis": { "start": "vor 24h", "middle": "vor 12h", "end": "jetzt" }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence ist nicht konfiguriert",
+ "body": "Wenden Sie sich an Ihren Administrator, um eine Cyber Mantra / ThreatWinds-Instanz mit dieser Umgebung zu verbinden."
+ },
+ "lookup": {
+ "title": "Beliebigen Indikator suchen",
+ "placeholder": "Geben Sie eine IP, einen Domain-Namen, einen Hash, eine URL oder CVE ein — wir suchen in den Feeds und Ihrer Umgebung",
+ "button": "Suchen",
+ "busy": "Wird gesucht…"
+ },
+ "tabs": { "iocs": "IOCs", "actors": "Bedrohungsakteure", "feeds": "Feeds" },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "IOC-Wert, Tag, Quelle suchen…",
+ "actors": "Akteure, Aliase, Techniken suchen…",
+ "feeds": "Feeds suchen…"
+ },
+ "last24h": "Letzte 24 Stunden",
+ "filters": "Filter",
+ "refresh": "Aktualisieren"
+ },
+ "iocs": {
+ "table": {
+ "type": "Typ",
+ "indicator": "Indikator",
+ "reputation": "Ruf",
+ "tags": "Tags",
+ "lastSeen": "Zuletzt gesehen"
+ },
+ "empty": "Keine IOC-Treffer.",
+ "loadingMore": "Wird geladen…",
+ "loadedProgress": "{{loaded}} von {{total}} geladen. Zum Scrollen nach unten.",
+ "endOfResults": "Ende der Ergebnisse ({{loaded}} von {{total}})."
+ },
+ "drawer": {
+ "loading": "Wird geladen…",
+ "accuracy": "Genauigkeit:",
+ "actions": {
+ "block": "Am Perimeter blockieren",
+ "viewEvents": "Übereinstimmende Ereignisse anzeigen",
+ "externalLookup": "Externe Suche",
+ "trackCampaign": "Kampagne verfolgen",
+ "viewIocs": "Übereinstimmende IOCs anzeigen",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Zeitstrahl",
+ "networkOrigin": "Netzwerk / Ursprung",
+ "associationsWithCount": "Assoziationen ({{count}})",
+ "geolocationsWithCount": "Geostandorte ({{count}})",
+ "about": "Über",
+ "activity": "Aktivität"
+ },
+ "fields": {
+ "firstSeen": "Erstmals gesehen",
+ "lastSeen": "Zuletzt gesehen",
+ "country": "Land",
+ "city": "Stadt",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Ruf",
+ "accuracy": "Genauigkeit",
+ "current": "Aktuell",
+ "best": "Beste",
+ "worst": "Schlechteste"
+ },
+ "aiBrief": "KI-Zusammenfassung",
+ "noDescription": "Keine Beschreibung für diesen Indikator verfügbar.",
+ "lastObserved": "Zuletzt beobachtet {{when}}."
+ },
+ "actors": { "empty": "Keine Akteure gefunden." },
+ "feeds": {
+ "empty": "Keine Feeds konfiguriert.",
+ "table": { "name": "Feed", "type": "Typ", "accuracy": "Genauigkeit" }
+ },
+ "entityTypes": {
+ "ip": "IP", "hostname": "Host", "domain": "Domain", "url": "URL",
+ "hash": "Hash", "cve": "CVE", "threat": "Bedrohung", "entity": "Entität"
+ },
+ "reputation": {
+ "alarming": "Alarmierend", "suspicious": "Verdächtig",
+ "indefinable": "Undefinierbar", "fair": "Annehmbar", "trustworthy": "Vertrauenswürdig"
+ },
+ "feedAccuracy": {
+ "level1": "Stufe 1", "level2": "Stufe 2", "level3": "Stufe 3"
+ }
}
}
diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json
index bc7b71341..03c914607 100644
--- a/frontend/src/shared/i18n/locales/en.json
+++ b/frontend/src/shared/i18n/locales/en.json
@@ -731,6 +731,129 @@
"logout": "Log out"
}
},
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "matched in your env",
+ "export": "Export IOCs",
+ "exporting": "Exporting…"
+ },
+ "overview": {
+ "title": "IOC matches · last 24 hours",
+ "totalIndicators": "total indicators",
+ "axis": {
+ "start": "24h ago",
+ "middle": "12h ago",
+ "end": "now"
+ }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence isn't configured",
+ "body": "Contact your administrator to connect an upstream Cyber Mantra / ThreatWinds instance for this environment."
+ },
+ "lookup": {
+ "title": "Lookup any indicator",
+ "placeholder": "Paste an IP, domain, hash, URL, or CVE — we'll check feeds and your environment",
+ "button": "Lookup",
+ "busy": "Searching…"
+ },
+ "tabs": {
+ "iocs": "IOCs",
+ "actors": "Threat actors",
+ "feeds": "Feeds"
+ },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "Search IOC value, tag, source…",
+ "actors": "Search actors, aliases, techniques…",
+ "feeds": "Search feeds…"
+ },
+ "last24h": "Last 24 hours",
+ "filters": "Filters",
+ "refresh": "Refresh"
+ },
+ "iocs": {
+ "table": {
+ "type": "Type",
+ "indicator": "Indicator",
+ "reputation": "Reputation",
+ "tags": "Tags",
+ "lastSeen": "Last seen"
+ },
+ "empty": "No IOCs match.",
+ "loadingMore": "Loading more…",
+ "loadedProgress": "Loaded {{loaded}} of {{total}}. Scroll for more.",
+ "endOfResults": "End of results ({{loaded}} of {{total}})."
+ },
+ "drawer": {
+ "loading": "Loading…",
+ "accuracy": "Accuracy:",
+ "actions": {
+ "block": "Block at perimeter",
+ "viewEvents": "View matched events",
+ "externalLookup": "External lookup",
+ "trackCampaign": "Track campaign",
+ "viewIocs": "View matched IOCs",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Timeline",
+ "networkOrigin": "Network / origin",
+ "associationsWithCount": "Associations ({{count}})",
+ "geolocationsWithCount": "Geolocations ({{count}})",
+ "about": "About",
+ "activity": "Activity"
+ },
+ "fields": {
+ "firstSeen": "First seen",
+ "lastSeen": "Last seen",
+ "country": "Country",
+ "city": "City",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Reputation",
+ "accuracy": "Accuracy",
+ "current": "Current",
+ "best": "Best",
+ "worst": "Worst"
+ },
+ "aiBrief": "AI brief",
+ "noDescription": "No description available for this indicator.",
+ "lastObserved": "Last observed {{when}}."
+ },
+ "actors": {
+ "empty": "No actors found."
+ },
+ "feeds": {
+ "empty": "No feeds configured.",
+ "table": {
+ "name": "Feed",
+ "type": "Type",
+ "accuracy": "Accuracy"
+ }
+ },
+ "entityTypes": {
+ "ip": "IP",
+ "hostname": "Host",
+ "domain": "Domain",
+ "url": "URL",
+ "hash": "Hash",
+ "cve": "CVE",
+ "threat": "Threat",
+ "entity": "Entity"
+ },
+ "reputation": {
+ "alarming": "Alarming",
+ "suspicious": "Suspicious",
+ "indefinable": "Indefinable",
+ "fair": "Fair",
+ "trustworthy": "Trustworthy"
+ },
+ "feedAccuracy": {
+ "level1": "Level 1",
+ "level2": "Level 2",
+ "level3": "Level 3"
+ }
+ },
"notifications": {
"title": "Notifications",
"markAllRead": "Mark all read",
diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json
index ec8d67b03..5c819ebb7 100644
--- a/frontend/src/shared/i18n/locales/es.json
+++ b/frontend/src/shared/i18n/locales/es.json
@@ -4998,5 +4998,103 @@
"deleteError": "No se pudo eliminar la regla."
},
"deleteTitle": "Eliminar regla de etiquetado"
+ },
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "coincidencias en tu entorno",
+ "export": "Exportar IOCs",
+ "exporting": "Exportando…"
+ },
+ "overview": {
+ "title": "Coincidencias de IOCs · últimas 24 horas",
+ "totalIndicators": "indicadores totales",
+ "axis": { "start": "hace 24h", "middle": "hace 12h", "end": "ahora" }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence no está configurado",
+ "body": "Contacta a tu administrador para conectar una instancia de Cyber Mantra / ThreatWinds para este entorno."
+ },
+ "lookup": {
+ "title": "Busca cualquier indicador",
+ "placeholder": "Pega una IP, dominio, hash, URL o CVE — revisaremos feeds y tu entorno",
+ "button": "Buscar",
+ "busy": "Buscando…"
+ },
+ "tabs": { "iocs": "IOCs", "actors": "Actores de amenaza", "feeds": "Feeds" },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "Busca IOC, etiqueta, fuente…",
+ "actors": "Busca actores, alias, técnicas…",
+ "feeds": "Busca feeds…"
+ },
+ "last24h": "Últimas 24 horas",
+ "filters": "Filtros",
+ "refresh": "Actualizar"
+ },
+ "iocs": {
+ "table": {
+ "type": "Tipo",
+ "indicator": "Indicador",
+ "reputation": "Reputación",
+ "tags": "Etiquetas",
+ "lastSeen": "Última vez visto"
+ },
+ "empty": "Sin coincidencias de IOCs.",
+ "loadingMore": "Cargando más…",
+ "loadedProgress": "Cargados {{loaded}} de {{total}}. Desplázate para más.",
+ "endOfResults": "Fin de resultados ({{loaded}} de {{total}})."
+ },
+ "drawer": {
+ "loading": "Cargando…",
+ "accuracy": "Precisión:",
+ "actions": {
+ "block": "Bloquear en perímetro",
+ "viewEvents": "Ver eventos coincidentes",
+ "externalLookup": "Búsqueda externa",
+ "trackCampaign": "Rastrear campaña",
+ "viewIocs": "Ver IOCs coincidentes",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Cronología",
+ "networkOrigin": "Red / origen",
+ "associationsWithCount": "Asociaciones ({{count}})",
+ "geolocationsWithCount": "Geolocalizaciones ({{count}})",
+ "about": "Acerca de",
+ "activity": "Actividad"
+ },
+ "fields": {
+ "firstSeen": "Primera vez visto",
+ "lastSeen": "Última vez visto",
+ "country": "País",
+ "city": "Ciudad",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Reputación",
+ "accuracy": "Precisión",
+ "current": "Actual",
+ "best": "Mejor",
+ "worst": "Peor"
+ },
+ "aiBrief": "Resumen IA",
+ "noDescription": "No hay descripción disponible para este indicador.",
+ "lastObserved": "Última observación {{when}}."
+ },
+ "actors": { "empty": "Sin actores encontrados." },
+ "feeds": {
+ "empty": "Sin feeds configurados.",
+ "table": { "name": "Feed", "type": "Tipo", "accuracy": "Precisión" }
+ },
+ "entityTypes": {
+ "ip": "IP", "hostname": "Host", "domain": "Dominio", "url": "URL",
+ "hash": "Hash", "cve": "CVE", "threat": "Amenaza", "entity": "Entidad"
+ },
+ "reputation": {
+ "alarming": "Alarmante", "suspicious": "Sospechoso",
+ "indefinable": "Indefinido", "fair": "Aceptable", "trustworthy": "Confiable"
+ },
+ "feedAccuracy": {
+ "level1": "Nivel 1", "level2": "Nivel 2", "level3": "Nivel 3"
+ }
}
}
diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json
index 9e12b6a28..3f54bd7e5 100644
--- a/frontend/src/shared/i18n/locales/fr.json
+++ b/frontend/src/shared/i18n/locales/fr.json
@@ -5036,5 +5036,103 @@
"deleteError": "Échec de la suppression de la règle."
},
"deleteTitle": "Supprimer la règle d'étiquetage"
+ },
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "correspondances détectées dans votre environnement",
+ "export": "Exporter les IOCs",
+ "exporting": "Exportation…"
+ },
+ "overview": {
+ "title": "Correspondances IOC · dernières 24 heures",
+ "totalIndicators": "indicateurs au total",
+ "axis": { "start": "il y a 24h", "middle": "il y a 12h", "end": "maintenant" }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence n'est pas configuré",
+ "body": "Contactez votre administrateur pour connecter une instance Cyber Mantra / ThreatWinds à cet environnement."
+ },
+ "lookup": {
+ "title": "Rechercher n'importe quel indicateur",
+ "placeholder": "Collez une IP, domaine, hash, URL ou CVE — nous vérifierons les flux et votre environnement",
+ "button": "Rechercher",
+ "busy": "Recherche en cours…"
+ },
+ "tabs": { "iocs": "IOCs", "actors": "Acteurs de menace", "feeds": "Flux" },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "Recherchez la valeur IOC, tag, source…",
+ "actors": "Recherchez acteurs, alias, techniques…",
+ "feeds": "Recherchez des flux…"
+ },
+ "last24h": "Dernières 24 heures",
+ "filters": "Filtres",
+ "refresh": "Actualiser"
+ },
+ "iocs": {
+ "table": {
+ "type": "Type",
+ "indicator": "Indicateur",
+ "reputation": "Réputation",
+ "tags": "Tags",
+ "lastSeen": "Dernière détection"
+ },
+ "empty": "Aucune correspondance IOC.",
+ "loadingMore": "Chargement…",
+ "loadedProgress": "{{loaded}} sur {{total}} chargés. Défilez pour plus.",
+ "endOfResults": "Fin des résultats ({{loaded}} sur {{total}})."
+ },
+ "drawer": {
+ "loading": "Chargement…",
+ "accuracy": "Précision :",
+ "actions": {
+ "block": "Bloquer en périphérie",
+ "viewEvents": "Afficher les événements correspondants",
+ "externalLookup": "Recherche externe",
+ "trackCampaign": "Suivre la campagne",
+ "viewIocs": "Afficher les IOCs correspondants",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Chronologie",
+ "networkOrigin": "Réseau / origine",
+ "associationsWithCount": "Associations ({{count}})",
+ "geolocationsWithCount": "Géolocalisations ({{count}})",
+ "about": "À propos",
+ "activity": "Activité"
+ },
+ "fields": {
+ "firstSeen": "Première détection",
+ "lastSeen": "Dernière détection",
+ "country": "Pays",
+ "city": "Ville",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Réputation",
+ "accuracy": "Précision",
+ "current": "Actuel",
+ "best": "Meilleur",
+ "worst": "Pire"
+ },
+ "aiBrief": "Résumé IA",
+ "noDescription": "Aucune description disponible pour cet indicateur.",
+ "lastObserved": "Dernière observation {{when}}."
+ },
+ "actors": { "empty": "Aucun acteur trouvé." },
+ "feeds": {
+ "empty": "Aucun flux configuré.",
+ "table": { "name": "Flux", "type": "Type", "accuracy": "Précision" }
+ },
+ "entityTypes": {
+ "ip": "IP", "hostname": "Hôte", "domain": "Domaine", "url": "URL",
+ "hash": "Hash", "cve": "CVE", "threat": "Menace", "entity": "Entité"
+ },
+ "reputation": {
+ "alarming": "Alarmant", "suspicious": "Suspect",
+ "indefinable": "Indéfini", "fair": "Acceptable", "trustworthy": "De confiance"
+ },
+ "feedAccuracy": {
+ "level1": "Niveau 1", "level2": "Niveau 2", "level3": "Niveau 3"
+ }
}
}
diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json
index dfb777b63..f2454ffb2 100644
--- a/frontend/src/shared/i18n/locales/it.json
+++ b/frontend/src/shared/i18n/locales/it.json
@@ -5036,5 +5036,103 @@
"deleteError": "Impossibile eliminare la regola."
},
"deleteTitle": "Elimina regola di tagging"
+ },
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "corrispondenze nel tuo ambiente",
+ "export": "Esporta IOCs",
+ "exporting": "Esportazione…"
+ },
+ "overview": {
+ "title": "Corrispondenze IOC · ultimi 24 ore",
+ "totalIndicators": "indicatori totali",
+ "axis": { "start": "24h fa", "middle": "12h fa", "end": "ora" }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence non è configurato",
+ "body": "Contatta il tuo amministratore per connettere un'istanza Cyber Mantra / ThreatWinds a questo ambiente."
+ },
+ "lookup": {
+ "title": "Ricerca qualsiasi indicatore",
+ "placeholder": "Incolla un IP, dominio, hash, URL o CVE — controlleremo i feed e il tuo ambiente",
+ "button": "Ricerca",
+ "busy": "Ricerca in corso…"
+ },
+ "tabs": { "iocs": "IOCs", "actors": "Attori minaccia", "feeds": "Feed" },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "Ricerca valore IOC, tag, fonte…",
+ "actors": "Ricerca attori, alias, tecniche…",
+ "feeds": "Ricerca feed…"
+ },
+ "last24h": "Ultimi 24 ore",
+ "filters": "Filtri",
+ "refresh": "Aggiorna"
+ },
+ "iocs": {
+ "table": {
+ "type": "Tipo",
+ "indicator": "Indicatore",
+ "reputation": "Reputazione",
+ "tags": "Tag",
+ "lastSeen": "Ultimo avvistamento"
+ },
+ "empty": "Nessuna corrispondenza IOC.",
+ "loadingMore": "Caricamento in corso…",
+ "loadedProgress": "Caricati {{loaded}} di {{total}}. Scorri per altri.",
+ "endOfResults": "Fine dei risultati ({{loaded}} di {{total}})."
+ },
+ "drawer": {
+ "loading": "Caricamento…",
+ "accuracy": "Accuratezza:",
+ "actions": {
+ "block": "Blocca al perimetro",
+ "viewEvents": "Visualizza eventi corrispondenti",
+ "externalLookup": "Ricerca esterna",
+ "trackCampaign": "Traccia campagna",
+ "viewIocs": "Visualizza IOCs corrispondenti",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Cronologia",
+ "networkOrigin": "Rete / origine",
+ "associationsWithCount": "Associazioni ({{count}})",
+ "geolocationsWithCount": "Geolocazioni ({{count}})",
+ "about": "Informazioni",
+ "activity": "Attività"
+ },
+ "fields": {
+ "firstSeen": "Primo avvistamento",
+ "lastSeen": "Ultimo avvistamento",
+ "country": "Paese",
+ "city": "Città",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Reputazione",
+ "accuracy": "Accuratezza",
+ "current": "Attuale",
+ "best": "Migliore",
+ "worst": "Peggiore"
+ },
+ "aiBrief": "Riassunto IA",
+ "noDescription": "Nessuna descrizione disponibile per questo indicatore.",
+ "lastObserved": "Ultimo avvistamento {{when}}."
+ },
+ "actors": { "empty": "Nessun attore trovato." },
+ "feeds": {
+ "empty": "Nessun feed configurato.",
+ "table": { "name": "Feed", "type": "Tipo", "accuracy": "Accuratezza" }
+ },
+ "entityTypes": {
+ "ip": "IP", "hostname": "Host", "domain": "Dominio", "url": "URL",
+ "hash": "Hash", "cve": "CVE", "threat": "Minaccia", "entity": "Entità"
+ },
+ "reputation": {
+ "alarming": "Allarmante", "suspicious": "Sospetto",
+ "indefinable": "Indefinibile", "fair": "Equo", "trustworthy": "Affidabile"
+ },
+ "feedAccuracy": {
+ "level1": "Livello 1", "level2": "Livello 2", "level3": "Livello 3"
+ }
}
}
diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json
index 3680383c0..3cb51076e 100644
--- a/frontend/src/shared/i18n/locales/pt.json
+++ b/frontend/src/shared/i18n/locales/pt.json
@@ -4998,5 +4998,103 @@
"deleteError": "Falha ao excluir a regra."
},
"deleteTitle": "Excluir regra de marcação"
+ },
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "coincidências em seu ambiente",
+ "export": "Exportar IOCs",
+ "exporting": "Exportando…"
+ },
+ "overview": {
+ "title": "Correspondências de IOC · últimas 24 horas",
+ "totalIndicators": "indicadores totais",
+ "axis": { "start": "há 24h", "middle": "há 12h", "end": "agora" }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence não está configurado",
+ "body": "Entre em contato com seu administrador para conectar uma instância do Cyber Mantra / ThreatWinds a este ambiente."
+ },
+ "lookup": {
+ "title": "Pesquisar qualquer indicador",
+ "placeholder": "Cole um IP, domínio, hash, URL ou CVE — verificaremos os feeds e seu ambiente",
+ "button": "Pesquisar",
+ "busy": "Pesquisando…"
+ },
+ "tabs": { "iocs": "IOCs", "actors": "Atores de ameaça", "feeds": "Feeds" },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "Pesquise valor IOC, tag, fonte…",
+ "actors": "Pesquise atores, aliases, técnicas…",
+ "feeds": "Pesquise feeds…"
+ },
+ "last24h": "Últimas 24 horas",
+ "filters": "Filtros",
+ "refresh": "Atualizar"
+ },
+ "iocs": {
+ "table": {
+ "type": "Tipo",
+ "indicator": "Indicador",
+ "reputation": "Reputação",
+ "tags": "Tags",
+ "lastSeen": "Visto pela última vez"
+ },
+ "empty": "Nenhuma correspondência de IOC.",
+ "loadingMore": "Carregando mais…",
+ "loadedProgress": "Carregados {{loaded}} de {{total}}. Role para mais.",
+ "endOfResults": "Fim dos resultados ({{loaded}} de {{total}})."
+ },
+ "drawer": {
+ "loading": "Carregando…",
+ "accuracy": "Precisão:",
+ "actions": {
+ "block": "Bloquear no perímetro",
+ "viewEvents": "Ver eventos correspondentes",
+ "externalLookup": "Pesquisa externa",
+ "trackCampaign": "Rastrear campanha",
+ "viewIocs": "Ver IOCs correspondentes",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Linha do tempo",
+ "networkOrigin": "Rede / origem",
+ "associationsWithCount": "Associações ({{count}})",
+ "geolocationsWithCount": "Geolocalizações ({{count}})",
+ "about": "Sobre",
+ "activity": "Atividade"
+ },
+ "fields": {
+ "firstSeen": "Visto pela primeira vez",
+ "lastSeen": "Visto pela última vez",
+ "country": "País",
+ "city": "Cidade",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Reputação",
+ "accuracy": "Precisão",
+ "current": "Atual",
+ "best": "Melhor",
+ "worst": "Pior"
+ },
+ "aiBrief": "Resumo de IA",
+ "noDescription": "Nenhuma descrição disponível para este indicador.",
+ "lastObserved": "Última observação {{when}}."
+ },
+ "actors": { "empty": "Nenhum ator encontrado." },
+ "feeds": {
+ "empty": "Nenhum feed configurado.",
+ "table": { "name": "Feed", "type": "Tipo", "accuracy": "Precisão" }
+ },
+ "entityTypes": {
+ "ip": "IP", "hostname": "Host", "domain": "Domínio", "url": "URL",
+ "hash": "Hash", "cve": "CVE", "threat": "Ameaça", "entity": "Entidade"
+ },
+ "reputation": {
+ "alarming": "Alarmante", "suspicious": "Suspeito",
+ "indefinable": "Indefinível", "fair": "Justo", "trustworthy": "Confiável"
+ },
+ "feedAccuracy": {
+ "level1": "Nível 1", "level2": "Nível 2", "level3": "Nível 3"
+ }
}
}
diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json
index 8546a533e..f9ddb6bc3 100644
--- a/frontend/src/shared/i18n/locales/ru.json
+++ b/frontend/src/shared/i18n/locales/ru.json
@@ -5062,5 +5062,103 @@
"deleteError": "Не удалось удалить правило."
},
"deleteTitle": "Удалить правило тегирования"
+ },
+ "threatIntel": {
+ "header": {
+ "matchedInEnv": "совпадения в вашей среде",
+ "export": "Экспортировать IOCs",
+ "exporting": "Экспортирование…"
+ },
+ "overview": {
+ "title": "Совпадения IOC · последние 24 часа",
+ "totalIndicators": "всего индикаторов",
+ "axis": { "start": "24ч назад", "middle": "12ч назад", "end": "сейчас" }
+ },
+ "notConfigured": {
+ "title": "Threat Intelligence не настроен",
+ "body": "Свяжитесь с администратором для подключения экземпляра Cyber Mantra / ThreatWinds к этой среде."
+ },
+ "lookup": {
+ "title": "Поиск любого индикатора",
+ "placeholder": "Вставьте IP, доменное имя, хеш, URL или CVE — мы проверим feed и вашу среду",
+ "button": "Поиск",
+ "busy": "Поиск…"
+ },
+ "tabs": { "iocs": "IOCs", "actors": "Угрозы", "feeds": "Feeds" },
+ "toolbar": {
+ "searchPlaceholders": {
+ "iocs": "Поиск по значению IOC, тегу, источнику…",
+ "actors": "Поиск по актёрам, алиасам, техникам…",
+ "feeds": "Поиск по feeds…"
+ },
+ "last24h": "Последние 24 часа",
+ "filters": "Фильтры",
+ "refresh": "Обновить"
+ },
+ "iocs": {
+ "table": {
+ "type": "Тип",
+ "indicator": "Индикатор",
+ "reputation": "Репутация",
+ "tags": "Теги",
+ "lastSeen": "Последний просмотр"
+ },
+ "empty": "Нет совпадений IOC.",
+ "loadingMore": "Загрузка…",
+ "loadedProgress": "Загружено {{loaded}} из {{total}}. Прокрутите для других.",
+ "endOfResults": "Конец результатов ({{loaded}} из {{total}})."
+ },
+ "drawer": {
+ "loading": "Загрузка…",
+ "accuracy": "Точность:",
+ "actions": {
+ "block": "Заблокировать на периметре",
+ "viewEvents": "Просмотреть совпадающие события",
+ "externalLookup": "Внешний поиск",
+ "trackCampaign": "Отслеживать кампанию",
+ "viewIocs": "Просмотреть совпадающие IOCs",
+ "mitre": "MITRE ATT&CK"
+ },
+ "sections": {
+ "timeline": "Временная шкала",
+ "networkOrigin": "Сеть / происхождение",
+ "associationsWithCount": "Ассоциации ({{count}})",
+ "geolocationsWithCount": "Геолокации ({{count}})",
+ "about": "О программе",
+ "activity": "Активность"
+ },
+ "fields": {
+ "firstSeen": "Впервые обнаружено",
+ "lastSeen": "Последний просмотр",
+ "country": "Страна",
+ "city": "Город",
+ "aso": "ASO",
+ "asn": "ASN",
+ "reputation": "Репутация",
+ "accuracy": "Точность",
+ "current": "Текущее",
+ "best": "Лучшее",
+ "worst": "Худшее"
+ },
+ "aiBrief": "Резюме ИИ",
+ "noDescription": "Описание не доступно для этого индикатора.",
+ "lastObserved": "Последний просмотр {{when}}."
+ },
+ "actors": { "empty": "Угрозы не найдены." },
+ "feeds": {
+ "empty": "Нет настроенных feeds.",
+ "table": { "name": "Feed", "type": "Тип", "accuracy": "Точность" }
+ },
+ "entityTypes": {
+ "ip": "IP", "hostname": "Хост", "domain": "Доменное имя", "url": "URL",
+ "hash": "Hash", "cve": "CVE", "threat": "Угроза", "entity": "Сущность"
+ },
+ "reputation": {
+ "alarming": "Тревожная", "suspicious": "Подозрительная",
+ "indefinable": "Неопределённая", "fair": "Приемлемая", "trustworthy": "Надёжная"
+ },
+ "feedAccuracy": {
+ "level1": "Уровень 1", "level2": "Уровень 2", "level3": "Уровень 3"
+ }
}
}