Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 83 additions & 17 deletions frontend/src/features/threat-intel/components/MatchOverviewCard.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,76 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTiIocs24h } from '../hooks/use-ti-iocs-24h'
import { fillHourlyBuckets } from './utils/hourly-buckets'
import { useTiIocsOverview } from '../hooks/use-ti-iocs-overview'
import type { AdvancedSearchRequest } from '../domain/threat-intel.types'

export function MatchOverviewCard() {
const TYPE_FAMILIES: Record<'ip' | 'url' | 'domain' | 'signatures', string[]> = {
ip: ['ip', 'cidr'],
url: ['url', 'link', 'github-organization', 'github-repository'],
domain: ['domain', 'hostname'],
signatures: [
'md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha512-224', 'sha512-256',
'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512',
'authentihash', 'cdhash', 'malware', 'filename',
'profile-photo', 'facebook-profile', 'tiktok-profile', 'twitter-profile',
],
}

// Show time-of-day for sub-day intervals, date for day+ intervals.
function formatTs(ts: number, interval: string): string {
const perDay = /min|hour|^[0-9]+[mh]$/i.test(interval)
return new Date(ts).toLocaleString(undefined, perDay
? { hour: '2-digit', minute: '2-digit' }
: { month: 'short', day: '2-digit' })
}

interface MatchOverviewCardProps {
body: AdvancedSearchRequest | undefined
interval: string
}

export function MatchOverviewCard({ body, interval }: MatchOverviewCardProps) {
const { t } = useTranslation()
const query = useTiIocs24h()
const query = useTiIocsOverview(body, interval)

const total = useMemo(() => {
if (query.data?.kind !== 'ok') return 0
return query.data.value.items
}, [query.data])

const data = useMemo(() => {
const points = useMemo(() => {
if (query.data?.kind !== 'ok') return []
const buckets = query.data.value.aggregations?.hourly_iocs?.buckets ?? []
return fillHourlyBuckets(buckets).map((b) => b.count)
const buckets = query.data.value.aggregations?.histogram?.buckets ?? []
return buckets
.map((b) => ({ ts: Number(b.key), count: b.doc_count }))
.filter((b) => Number.isFinite(b.ts))
.sort((a, b) => a.ts - b.ts)
}, [query.data])

const familyCounts = useMemo(() => {
const zero = { ip: 0, url: 0, domain: 0, signatures: 0 }
if (query.data?.kind !== 'ok') return zero
const buckets = query.data.value.aggregations?.by_types?.buckets ?? []
const byType: Record<string, number> = {}
for (const b of buckets) byType[String(b.key)] = b.doc_count
const sum = (types: string[]) => types.reduce((n, t) => n + (byType[t] ?? 0), 0)
return {
ip: sum(TYPE_FAMILIES.ip),
url: sum(TYPE_FAMILIES.url),
domain: sum(TYPE_FAMILIES.domain),
signatures: sum(TYPE_FAMILIES.signatures),
}
}, [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)
const counts = points.map((p) => p.count)
const max = counts.length > 0 ? Math.max(...counts) * 1.15 : 1
const xs = counts.map((_, i) => (i * w) / Math.max(counts.length - 1, 1))
const ys = counts.map((v) => h - (v / max) * h)

let linePath = ''
if (data.length > 0) {
linePath = data.reduce((acc, _, i) => {
if (counts.length > 0) {
linePath = counts.reduce((acc, _, i) => {
if (i === 0) return `M ${xs[i]} ${ys[i]}`
const prevX = xs[i - 1]
const prevY = ys[i - 1]
Expand All @@ -38,7 +82,13 @@ export function MatchOverviewCard() {
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
const areaPath = counts.length > 0 ? `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z` : linePath

const startLabel = points.length > 0 ? formatTs(points[0].ts, interval) : ''
const endLabel = points.length > 0 ? formatTs(points[points.length - 1].ts, interval) : ''
const midLabel = points.length > 1
? formatTs(points[Math.floor(points.length / 2)].ts, interval)
: ''

if (query.data?.kind === 'not-configured') return null

Expand All @@ -56,6 +106,22 @@ export function MatchOverviewCard() {
<span className="text-sm text-muted-foreground">{t('threatIntel.overview.totalIndicators')}</span>
</div>
</div>

<div className="flex flex-wrap items-center gap-2">
{(['ip', 'url', 'domain', 'signatures'] as const).map((family) => (
<div
key={family}
className="rounded-md border border-border bg-background px-3 py-1.5"
>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{t(`threatIntel.overview.families.${family}`, { defaultValue: family })}
</div>
<div className="text-lg font-semibold tabular-nums">
{query.isPending ? '—' : familyCounts[family].toLocaleString()}
</div>
</div>
))}
</div>
</div>

<svg viewBox={`0 0 ${w} ${h}`} className="mt-4 h-24 w-full" preserveAspectRatio="none">
Expand All @@ -65,7 +131,7 @@ export function MatchOverviewCard() {
<stop offset="100%" stopColor="rgb(168 85 247)" stopOpacity="0" />
</linearGradient>
</defs>
{data.length > 0 && <path d={areaPath} fill="url(#iocGrad)" />}
{counts.length > 0 && <path d={areaPath} fill="url(#iocGrad)" />}
<path
d={linePath}
fill="none"
Expand All @@ -78,9 +144,9 @@ export function MatchOverviewCard() {
</svg>

<div className="mt-2 flex justify-between text-[10px] text-muted-foreground">
<span>{t('threatIntel.overview.axis.start')}</span>
<span>{t('threatIntel.overview.axis.middle')}</span>
<span>{t('threatIntel.overview.axis.end')}</span>
<span>{startLabel}</span>
<span>{midLabel}</span>
<span>{endLabel}</span>
</div>
</div>
)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ export type EntityLookupResponse = EntitySearchItem
export interface AdvancedDateHistogram {
date_histogram: { field: string; interval: string }
}
export type AdvancedAggregation = AdvancedDateHistogram
export interface AdvancedTermsAggregation {
terms: { field: string; size?: number }
}
export type AdvancedAggregation = AdvancedDateHistogram | AdvancedTermsAggregation

export interface AdvancedSearchRequest {
query?: {
Expand All @@ -179,8 +182,8 @@ export interface AdvancedSearchRequest {
}

export interface AggregationBucket {
key: number
key_as_string: string
key: string | number
key_as_string?: string
doc_count: number
}
export interface AggregationResult {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/features/threat-intel/hooks/use-alert-iocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ const IOC_FIELD_MAP: { field: string; twAttr: string }[] = [
{ field: 'target.host', twAttr: 'hostname' },
{ field: 'adversary.domain', twAttr: 'domain' },
{ field: 'target.domain', twAttr: 'domain' },
{ field: 'adversary.hash', twAttr: 'hash' },
{ field: 'target.hash', twAttr: 'hash' },
{ field: 'adversary.url', twAttr: 'url' },
{ field: 'target.url', twAttr: 'url' },
{ field: 'adversary.email', twAttr: 'email' },
{ field: 'target.email', twAttr: 'email' },
]

const TOP_TOTAL = 20
const TOP_TOTAL = 1000

export interface AlertIocs {
byAttr: Record<string, string[]>
Expand Down
22 changes: 0 additions & 22 deletions frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts

This file was deleted.

35 changes: 35 additions & 0 deletions frontend/src/features/threat-intel/hooks/use-ti-iocs-overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query'
import { threatIntelHttpService } from '../services/threat-intel-http.service'
import { mergeAdvancedRequests } from '../services/advanced-query'
import type { AdvancedSearchRequest } from '../domain/threat-intel.types'

const IOC_TYPES = [
'domain', 'hostname', 'url', 'link', 'github-organization', 'github-repository',
'ip', 'cidr', 'malware',
'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha512-224', 'sha512-256',
'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512',
'authentihash', 'cdhash', 'md5',
'profile-photo', 'facebook-profile', 'tiktok-profile', 'twitter-profile',
'filename',
]

export function useTiIocsOverview(
extra: AdvancedSearchRequest | undefined,
interval: string,
) {
const base: AdvancedSearchRequest = {
query: { must: [{ terms: { 'type.keyword': IOC_TYPES } }] },
aggs: {
histogram: { date_histogram: { field: 'lastSeen', interval } },
by_types: { terms: { field: 'type.keyword', size: 50 } },
},
}
return useQuery({
queryKey: ['ti', 'iocs-overview', extra, interval],
queryFn: () => threatIntelHttpService.searchAdvanced(
mergeAdvancedRequests(base, extra),
{ limit: 0, page: 1 },
),
enabled: !!extra,
})
}
40 changes: 13 additions & 27 deletions frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Clock, ListFilter, RefreshCw, Search } from 'lucide-react'
import { Clock, ListFilter, RefreshCw } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { toast } from 'sonner'
import { useTiConfigStatus } from '../hooks/use-ti-config-status'
import { useTiFeeds } from '../hooks/use-ti-feeds'
Expand Down Expand Up @@ -39,13 +38,13 @@ const MULTI_MATCH_FIELDS = [

type TimeRange = 'all' | '15m' | '1h' | '24h' | '7d' | '30d'

const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; expr: string | null }[] = [
{ value: '15m', label: 'Last 15 min', expr: 'now-15m' },
{ value: '1h', label: 'Last 1 hour', expr: 'now-1h' },
{ value: '24h', label: 'Last 24 hours', expr: 'now-24h' },
{ value: '7d', label: 'Last 7 days', expr: 'now-7d' },
{ value: '30d', label: 'Last 30 days', expr: 'now-30d' },
{ value: 'all', label: 'All time', expr: null },
const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; expr: string | null; interval: string }[] = [
{ value: '15m', label: 'Last 15 min', expr: 'now-15m', interval: 'minute' },
{ value: '1h', label: 'Last 1 hour', expr: 'now-1h', interval: 'minute' },
{ value: '24h', label: 'Last 24 hours', expr: 'now-24h', interval: 'hour' },
{ value: '7d', label: 'Last 7 days', expr: 'now-7d', interval: 'hour' },
{ value: '30d', label: 'Last 30 days', expr: 'now-30d', interval: 'day' },
{ value: 'all', label: 'All time', expr: null, interval: 'day' },
]

function textQueryFragment(q: string): AdvancedSearchRequest | undefined {
Expand Down Expand Up @@ -99,7 +98,6 @@ export function ThreatIntelPage() {
const [tab, setTab] = useState<TabKey>('iocs')
const [openIoc, setOpenIoc] = useState<string | null>(null)
const [openActor, setOpenActor] = useState<string | null>(null)
const [uiSearch, setUiSearch] = useState('')
const [isExporting, setIsExporting] = useState(false)

useEffect(() => {
Expand Down Expand Up @@ -237,7 +235,10 @@ export function ThreatIntelPage() {
/>

<div className="mt-5">
<MatchOverviewCard />
<MatchOverviewCard
body={observedFragment ? lastBody : undefined}
interval={TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.interval ?? 'hour'}
/>
</div>

<div className="mt-5">
Expand All @@ -257,22 +258,7 @@ export function ThreatIntelPage() {
/>
</div>

<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[280px] flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={
tab === 'feeds'
? t('threatIntel.toolbar.searchPlaceholders.feeds')
: tab === 'actors'
? t('threatIntel.toolbar.searchPlaceholders.actors')
: t('threatIntel.toolbar.searchPlaceholders.iocs')
}
value={uiSearch}
onChange={(e) => setUiSearch(e.target.value)}
className="h-9 pl-9"
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 justify-end">
{tab === 'iocs' && (
<>
<div className="relative">
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/shared/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -5029,12 +5029,13 @@
"exporting": "Wird exportiert…"
},
"overview": {
"title": "IOC-Treffer · letzte 24 Stunden",
"title": "IOC-Treffer",
"totalIndicators": "Gesamtzahl Indikatoren",
"axis": {
"start": "vor 24h",
"middle": "vor 12h",
"end": "jetzt"
"families": {
"ip": "IP",
"url": "URL",
"domain": "Domain",
"signatures": "Signaturen"
}
},
"notConfigured": {
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/shared/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -681,12 +681,13 @@
"exporting": "Exporting…"
},
"overview": {
"title": "IOC matches · last 24 hours",
"title": "IOC matches",
"totalIndicators": "total indicators",
"axis": {
"start": "24h ago",
"middle": "12h ago",
"end": "now"
"families": {
"ip": "IP",
"url": "URL",
"domain": "Domain",
"signatures": "Signatures"
}
},
"notConfigured": {
Expand Down
Loading
Loading