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.value}

+ {a.tags.length > 0 && ( +
+ {a.tags.join(', ')} +
+ )} +
+ + {a.reputation} + + {t('threatIntel.drawer.accuracy')} {a.accuracy} +
+
+
+ +
+ +
+ + + +
+
+ +
+
+ {a.description && ( +
+

{a.description}

+
+ )} + +
+
+ + + +
+
+ + {associations.length > 0 && ( +
+
    + {associations.slice(0, 10).map((r) => { + const rt = typeMeta(r.type) + const RIcon = rt.icon + return ( +
  • +
    + + + {t(rt.labelKey)} + + {r.value} +
    + + {r.reputation} + +
  • + ) + })} +
+
+ )} +
+
+
+
+ ) +} 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 ( + <> +
+
+
+
+ + {t(typeMeta_.labelKey)} + · + {a.reputation} + · + {t('threatIntel.drawer.accuracy')} {a.accuracy} +
+

+ {a.value} +

+ {a.description && ( +

{a.description}

+ )} + {a.tags.length > 0 && ( +
+ {a.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ +
+ +
+ + + + +
+
+ +
+
+
+
+
+ + {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 && ( +
+
    + {merged.slice(0, 12).map((r) => { + const rt = typeMeta(r.type) + const RIcon = rt.icon + const rTone = reputationTone(r.reputation_score) + return ( +
  • +
    + + + {t(rt.labelKey)} + + {r.value} +
    + + {r.reputation} + +
  • + ) + })} + {merged.length > 12 && ( +
  • + {t('threatIntel.drawer.moreCount', { count: merged.length - 12 })} +
  • + )} +
+
+ )} + + {detail.geolocations?.length > 0 && ( +
+
    + {detail.geolocations.slice(0, 6).map((g) => ( +
  • +
    + + {g.object} +
    +
    + + + {g.city} + {g.country ? `, ${g.country}` : ''} + +
    +
  • + ))} + {detail.geolocations.length > 6 && ( +
  • + {t('threatIntel.drawer.moreCount', { count: detail.geolocations.length - 6 })} +
  • + )} +
+
+ )} +
+ + +
+
+ + ) +} 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')} +
+
+
+ + + + + + + + + {data.length > 0 && } + + + +
+ {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 ( +
+
{label}
+
{value}
+
+ ) +} 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 ( -
-
- {total.toLocaleString()} matched in your env -
-
- - -
-
- ) -} - -/* ─── 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 ( -
-
{label}
-
{value}
-
- ) -} - -/* ─── 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()} - > -
-
-
-
- - {t.label} - · - {sev.label} -
-

{ioc.value}

-
- {ioc.attributedActor && ( - - - {ioc.attributedActor} - - )} - {ioc.tags.map((tag) => ( - {tag} - ))} -
-
- -
- -
- - - - -
-
- -
-
-
-
- {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()} - > -
-
-
-
0 - ? 'bg-fuchsia-500/15 text-fuchsia-500 ring-fuchsia-500/40' - : 'bg-muted text-foreground/80 ring-border' - )} - > - -
-
-

{actor.name}

-
- aka {actor.aliases.join(', ')} -
-
- {actor.matchedAdversaries > 0 && ( - - {actor.matchedAdversaries} adversaries in your env - - )} - - Origin · {actor.origin.join(', ')} - -
-
-
- -
- -
- - - -
-
- -
-
-
-

{actor.description}

-
- -
-
-
- {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" + } } }