diff --git a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx index f15976a57..6425f52a9 100644 --- a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx +++ b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx @@ -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 = {} + 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] @@ -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 @@ -56,6 +106,22 @@ export function MatchOverviewCard() { {t('threatIntel.overview.totalIndicators')} + +
+ {(['ip', 'url', 'domain', 'signatures'] as const).map((family) => ( +
+
+ {t(`threatIntel.overview.families.${family}`, { defaultValue: family })} +
+
+ {query.isPending ? '—' : familyCounts[family].toLocaleString()} +
+
+ ))} +
@@ -65,7 +131,7 @@ export function MatchOverviewCard() { - {data.length > 0 && } + {counts.length > 0 && }
- {t('threatIntel.overview.axis.start')} - {t('threatIntel.overview.axis.middle')} - {t('threatIntel.overview.axis.end')} + {startLabel} + {midLabel} + {endLabel}
) diff --git a/frontend/src/features/threat-intel/components/utils/hourly-buckets.ts b/frontend/src/features/threat-intel/components/utils/hourly-buckets.ts deleted file mode 100644 index 4fc9f807b..000000000 --- a/frontend/src/features/threat-intel/components/utils/hourly-buckets.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/domain/threat-intel.types.ts b/frontend/src/features/threat-intel/domain/threat-intel.types.ts index c39c9d1fc..595626509 100644 --- a/frontend/src/features/threat-intel/domain/threat-intel.types.ts +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -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?: { @@ -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 { diff --git a/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts b/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts index 4c1f6caf4..c94a5052c 100644 --- a/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts +++ b/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts @@ -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 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 deleted file mode 100644 index 9803b4d6a..000000000 --- a/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { threatIntelHttpService } from '../services/threat-intel-http.service' -import { mergeAdvancedRequests } from '../services/advanced-query' -import { useAlertIocsFragment } from './use-alert-iocs' -import type { AdvancedSearchRequest } from '../domain/threat-intel.types' - -const BASE: AdvancedSearchRequest = { - query: { must: [{ range: { lastSeen: { gte: 'now-24h', lte: 'now' } } }] }, - aggs: { hourly_iocs: { date_histogram: { field: 'lastSeen', interval: 'hour' } } }, -} - -export function useTiIocs24h() { - const observed = useAlertIocsFragment() - return useQuery({ - queryKey: ['ti', 'iocs-24h', observed], - queryFn: () => threatIntelHttpService.searchAdvanced( - mergeAdvancedRequests(BASE, observed), - { limit: 0, page: 1 }, - ), - enabled: !!observed, - }) -} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-iocs-overview.ts b/frontend/src/features/threat-intel/hooks/use-ti-iocs-overview.ts new file mode 100644 index 000000000..75df9db34 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-iocs-overview.ts @@ -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, + }) +} diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index dac347daa..4f180fbf3 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -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' @@ -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 { @@ -99,7 +98,6 @@ export function ThreatIntelPage() { 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(() => { @@ -237,7 +235,10 @@ export function ThreatIntelPage() { />
- + o.value === timeRange)?.interval ?? 'hour'} + />
@@ -257,22 +258,7 @@ export function ThreatIntelPage() { />
-
-
- - setUiSearch(e.target.value)} - className="h-9 pl-9" - /> -
+
{tab === 'iocs' && ( <>
diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 376a1e1a4..5f4da509a 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -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": { diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 1c69f73ea..ad96e1f3a 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index f3cc48856..993191d7a 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -4991,12 +4991,13 @@ "exporting": "Exportando…" }, "overview": { - "title": "Coincidencias de IOCs · últimas 24 horas", + "title": "Coincidencias de IOCs", "totalIndicators": "indicadores totales", - "axis": { - "start": "hace 24h", - "middle": "hace 12h", - "end": "ahora" + "families": { + "ip": "IP", + "url": "URL", + "domain": "Dominio", + "signatures": "Firmas" } }, "notConfigured": { diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index d81c7e78b..67daa1e82 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -5029,12 +5029,13 @@ "exporting": "Exportation…" }, "overview": { - "title": "Correspondances IOC · dernières 24 heures", + "title": "Correspondances IOC", "totalIndicators": "indicateurs au total", - "axis": { - "start": "il y a 24h", - "middle": "il y a 12h", - "end": "maintenant" + "families": { + "ip": "IP", + "url": "URL", + "domain": "Domaine", + "signatures": "Signatures" } }, "notConfigured": { diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index d9e90edd8..cad035bd8 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -5029,12 +5029,13 @@ "exporting": "Esportazione…" }, "overview": { - "title": "Corrispondenze IOC · ultimi 24 ore", + "title": "Corrispondenze IOC", "totalIndicators": "indicatori totali", - "axis": { - "start": "24h fa", - "middle": "12h fa", - "end": "ora" + "families": { + "ip": "IP", + "url": "URL", + "domain": "Dominio", + "signatures": "Firme" } }, "notConfigured": { diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index f33edb1df..cbdba19cd 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -4991,12 +4991,13 @@ "exporting": "Exportando…" }, "overview": { - "title": "Correspondências de IOC · últimas 24 horas", + "title": "Correspondências de IOC", "totalIndicators": "indicadores totais", - "axis": { - "start": "há 24h", - "middle": "há 12h", - "end": "agora" + "families": { + "ip": "IP", + "url": "URL", + "domain": "Domínio", + "signatures": "Assinaturas" } }, "notConfigured": { diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index e8abd75c2..fc0aa2f4c 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -5055,12 +5055,13 @@ "exporting": "Экспортирование…" }, "overview": { - "title": "Совпадения IOC · последние 24 часа", + "title": "Совпадения IOC", "totalIndicators": "всего индикаторов", - "axis": { - "start": "24ч назад", - "middle": "12ч назад", - "end": "сейчас" + "families": { + "ip": "IP", + "url": "URL", + "domain": "Домен", + "signatures": "Сигнатуры" } }, "notConfigured": {