Skip to content
12 changes: 6 additions & 6 deletions backend/modules/threatintel/handler/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions backend/modules/threatintel/internal/instanceconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions backend/modules/threatintel/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
65 changes: 65 additions & 0 deletions frontend/src/features/threat-intel/components/ActorCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={() => onOpen(actor.id)}
className={cn(
'group flex flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-all hover:shadow-md',
dangerous ? 'border-red-500/30' : 'border-border'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ring-2',
dangerous
? 'bg-red-500/15 text-red-500 ring-red-500/40'
: 'bg-muted text-foreground/80 ring-border'
)}
>
<UserX size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{searchItemValue(actor)}</span>
<span className={cn('rounded px-1.5 py-0.5 text-[9px] font-medium uppercase ring-1', rep.tone)}>
{t(reputationLabelKey(actor.reputation))}
</span>
</div>
{actor.tags.length > 0 && (
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{actor.tags.join(', ')}
</div>
)}
</div>
</div>

<div className="grid grid-cols-3 gap-2 text-[11px]">
<Stat label="Reputation" value={actor.reputation.toString()} />
<Stat label="Accuracy" value={actor.accuracy.toString()} />
<Stat label="Tags" value={actor.tags.length.toString()} />
</div>

<div className="border-t border-border pt-3 text-[10px] text-muted-foreground">
Last seen <span className="font-mono">{actor.lastSeen ? relativeTime(actor.lastSeen) : '—'}</span>
</div>
</button>
)
}
160 changes: 160 additions & 0 deletions frontend/src/features/threat-intel/components/ActorDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed inset-0 z-50 flex items-stretch justify-end bg-black/40 backdrop-blur-sm"
onClick={onClose}
>
<div
className="flex w-full max-w-[860px] flex-col overflow-hidden border-l border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-6 py-4">
<div className="h-20 animate-pulse rounded bg-muted" />
</div>
<div className="flex-1 space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded bg-muted" />
))}
</div>
</div>
</div>
)
}

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 (
<div
className="fixed inset-0 z-50 flex items-stretch justify-end bg-black/40 backdrop-blur-sm"
onClick={onClose}
>
<div
className="flex w-full max-w-[860px] flex-col overflow-hidden border-l border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<header className="border-b border-border px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 items-start gap-3">
<div
className={cn(
'flex h-11 w-11 shrink-0 items-center justify-center rounded-lg ring-2',
dangerous
? 'bg-red-500/15 text-red-500 ring-red-500/40'
: 'bg-muted text-foreground/80 ring-border'
)}
>
<UserX size={20} />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-xl font-semibold">{a.value}</h2>
{a.tags.length > 0 && (
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{a.tags.join(', ')}
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
<span className={cn('rounded-md px-1.5 py-0.5 font-medium ring-1', rep.tone)}>
{a.reputation}
</span>
<span className="text-muted-foreground">{t('threatIntel.drawer.accuracy')} {a.accuracy}</span>
</div>
</div>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X size={16} />
</button>
</div>

<div className="mt-4 flex items-center gap-2">
<Button size="sm">
<Crosshair size={13} className="mr-1.5" />
{t('threatIntel.drawer.actions.trackCampaign')}
</Button>
<Button size="sm" variant="outline">
<Search size={13} className="mr-1.5" />
{t('threatIntel.drawer.actions.viewIocs')}
</Button>
<Button size="sm" variant="outline">
<ExternalLink size={13} className="mr-1.5" />
{t('threatIntel.drawer.actions.mitre')}
</Button>
</div>
</header>

<div className="flex-1 overflow-y-auto bg-muted/20 p-6">
<div className="space-y-4">
{a.description && (
<Section title={t('threatIntel.drawer.sections.about')}>
<p className="text-sm leading-relaxed">{a.description}</p>
</Section>
)}

<Section title={t('threatIntel.drawer.sections.activity')}>
<div className="grid grid-cols-3 gap-3 text-xs">
<Stat label={t('threatIntel.drawer.fields.reputation')} value={`${a.reputation_score}`} />
<Stat label={t('threatIntel.drawer.fields.accuracy')} value={a.accuracy} />
<Stat label={t('threatIntel.drawer.fields.lastSeen')} value={absTimestamp(a.last_seen)} />
</div>
</Section>

{associations.length > 0 && (
<Section title={t('threatIntel.drawer.sections.associationsWithCount', { count: associations.length })}>
<ul className="space-y-1.5">
{associations.slice(0, 10).map((r) => {
const rt = typeMeta(r.type)
const RIcon = rt.icon
return (
<li
key={r.id}
className="flex items-center justify-between gap-3 rounded border border-border bg-background/40 px-3 py-2 text-xs"
>
<div className="flex min-w-0 items-center gap-2">
<RIcon size={11} className={rt.tone} />
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{t(rt.labelKey)}
</span>
<span className="truncate font-mono">{r.value}</span>
</div>
<span className={cn('shrink-0 text-[11px]', REPUTATION_STYLE[reputationTone(r.reputation_score)].tone)}>
{r.reputation}
</span>
</li>
)
})}
</ul>
</Section>
)}
</div>
</div>
</div>
</div>
)
}
45 changes: 45 additions & 0 deletions frontend/src/features/threat-intel/components/ActorsList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-48 animate-pulse rounded-xl border border-border bg-muted"
/>
))}
</div>
)
}

if (actors.length === 0) {
return (
<div className="rounded-xl border border-border bg-card px-6 py-16 text-center text-sm text-muted-foreground">
{t('threatIntel.actors.empty')}
</div>
)
}

return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{actors.map((actor) => (
<ActorCard
key={actor.id}
actor={actor}
onOpen={() => onOpen(actor.id)}
/>
))}
</div>
)
}
27 changes: 27 additions & 0 deletions frontend/src/features/threat-intel/components/FeedRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="group grid items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs last:border-b-0 hover:bg-muted/40"
style={{ gridTemplateColumns: FEED_COLS }}
>
<span className={cn('h-2 w-2 rounded-full', acc.dot)} title={t(acc.labelKey)} />
<div className="min-w-0 truncate font-medium">{feed.name}</div>
<div className={cn('text-[11px] capitalize', feedTypeTone(feed.type))}>{feed.type}</div>
<div className="text-[11px] text-muted-foreground">{t(acc.labelKey)}</div>
</div>
)
}
18 changes: 18 additions & 0 deletions frontend/src/features/threat-intel/components/FeedsHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next'

const FEED_COLS = '12px 1fr 160px 140px'

export function FeedsHeader() {
const { t } = useTranslation()
return (
<div
className="grid items-center gap-3 border-b border-border bg-muted/40 px-4 py-2 text-[10px] uppercase tracking-wider text-muted-foreground"
style={{ gridTemplateColumns: FEED_COLS }}
>
<div />
<div>{t('threatIntel.feeds.table.name')}</div>
<div>{t('threatIntel.feeds.table.type')}</div>
<div>{t('threatIntel.feeds.table.accuracy')}</div>
</div>
)
}
Loading
Loading