From 2247bfeab529d63a9d2c45ae283b4ac0f89d7548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 20 Jul 2026 16:25:22 -0600 Subject: [PATCH 1/3] fix[frontend](threat_intelligense): fixed filters on threat intelligense section --- .../threat-intel/components/FiltersPanel.tsx | 312 ++++++++++++++++++ .../threat-intel/components/LookupBar.tsx | 30 +- .../threat-intel/domain/threat-intel.types.ts | 44 ++- .../hooks/use-ti-entity-lookup.ts | 12 + .../hooks/use-ti-search-advanced.ts | 13 + .../threat-intel/hooks/use-ti-search.ts | 12 - .../threat-intel/pages/ThreatIntelPage.tsx | 188 +++++++++-- .../services/threat-intel-http.service.ts | 8 +- .../threat-intel/services/ti-errors.ts | 4 + 9 files changed, 566 insertions(+), 57 deletions(-) create mode 100644 frontend/src/features/threat-intel/components/FiltersPanel.tsx create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-entity-lookup.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-search-advanced.ts delete mode 100644 frontend/src/features/threat-intel/hooks/use-ti-search.ts diff --git a/frontend/src/features/threat-intel/components/FiltersPanel.tsx b/frontend/src/features/threat-intel/components/FiltersPanel.tsx new file mode 100644 index 000000000..19ef6d581 --- /dev/null +++ b/frontend/src/features/threat-intel/components/FiltersPanel.tsx @@ -0,0 +1,312 @@ +import { useState } from 'react' +import { Filter, X } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { cn } from '@/shared/lib/utils' +import { + DateRangePickerDialog, + formatRange, + type DateRangeValue, +} from '@/shared/components/ui/date-range-picker' +import type { + EntityType, + AdvancedSearchRequest, + AdvancedCondition, +} from '@/features/threat-intel/domain/threat-intel.types' + +const ALL_TYPES: EntityType[] = [ + 'ip', 'domain', 'hostname', 'url', + 'md5', 'sha1', 'sha256', 'sha3-256', + 'cve', 'email-address', 'threat', 'malware', +] + +export interface FiltersState { + types: EntityType[] + reputation: { min: number; max: number } | null + accuracy: { min: number; max: number } | null + tagsInclude: string[] + tagsExclude: string[] + dateFrom: string | null + dateTo: string | null +} + +export const EMPTY_FILTERS: FiltersState = { + types: [], + reputation: null, + accuracy: null, + tagsInclude: [], + tagsExclude: [], + dateFrom: null, + dateTo: null, +} + +export function filtersToRequest( + state: FiltersState, + extra?: AdvancedSearchRequest, +): AdvancedSearchRequest { + const must: AdvancedCondition[] = [...(extra?.query?.must ?? [])] + const mustNot: AdvancedCondition[] = [...(extra?.query?.must_not ?? [])] + const filter: AdvancedCondition[] = [...(extra?.query?.filter ?? [])] + const should: AdvancedCondition[] = [...(extra?.query?.should ?? [])] + + if (state.types.length > 0) { + must.push({ terms: { type: state.types } }) + } + if (state.reputation !== null) { + filter.push({ + range: { + reputation: { + gte: String(state.reputation.min), + lte: String(state.reputation.max), + }, + }, + }) + } + if (state.accuracy !== null) { + filter.push({ + range: { + accuracy: { + gte: String(state.accuracy.min), + lte: String(state.accuracy.max), + }, + }, + }) + } + if (state.tagsInclude.length > 0) { + must.push({ terms: { tags: state.tagsInclude } }) + } + if (state.tagsExclude.length > 0) { + mustNot.push({ terms: { tags: state.tagsExclude } }) + } + if (state.dateFrom || state.dateTo) { + const rangeVal: { gte?: string; lte?: string } = {} + if (state.dateFrom) rangeVal.gte = state.dateFrom + if (state.dateTo) rangeVal.lte = state.dateTo + filter.push({ range: { '@timestamp': rangeVal } }) + } + + const query: AdvancedSearchRequest['query'] = {} + if (must.length) query.must = must + if (should.length) query.should = should + if (mustNot.length) query.must_not = mustNot + if (filter.length) query.filter = filter + + return { + query: Object.keys(query).length ? query : undefined, + aggs: extra?.aggs, + } +} + +function countActive(state: FiltersState): number { + let n = 0 + if (state.types.length) n++ + if (state.reputation) n++ + if (state.accuracy) n++ + if (state.tagsInclude.length) n++ + if (state.tagsExclude.length) n++ + if (state.dateFrom || state.dateTo) n++ + return n +} + +export interface FiltersPanelProps { + value: FiltersState + onChange: (next: FiltersState) => void + onApply: (request: AdvancedSearchRequest) => void + onClear: () => void + onClose?: () => void +} + +export function FiltersPanel({ value, onChange, onApply, onClear, onClose }: FiltersPanelProps) { + const [datePickerOpen, setDatePickerOpen] = useState(false) + + const dateRangeValue: DateRangeValue = { + from: value.dateFrom ? new Date(value.dateFrom) : null, + to: value.dateTo ? new Date(value.dateTo) : null, + } + + const toggleType = (t: EntityType) => { + const next = value.types.includes(t) + ? value.types.filter((x) => x !== t) + : [...value.types, t] + onChange({ ...value, types: next }) + } + + const setRepField = (field: 'min' | 'max', raw: string) => { + const n = raw === '' ? (field === 'min' ? -3 : 3) : Math.max(-3, Math.min(3, Number(raw))) + const base = value.reputation ?? { min: -3, max: 3 } + const next = { ...base, [field]: n } + onChange({ ...value, reputation: next.min === -3 && next.max === 3 ? null : next }) + } + + const setAccField = (field: 'min' | 'max', raw: string) => { + const n = raw === '' ? (field === 'min' ? 0 : 3) : Math.max(0, Math.min(3, Number(raw))) + const base = value.accuracy ?? { min: 0, max: 3 } + const next = { ...base, [field]: n } + onChange({ ...value, accuracy: next.min === 0 && next.max === 3 ? null : next }) + } + + const parseTags = (raw: string) => + raw.split(',').map((s) => s.trim()).filter(Boolean) + + const active = countActive(value) + + return ( +
+
+
+ + Filters + {active > 0 && ( + + {active} + + )} +
+
+ + + {onClose && ( + + )} +
+
+ +
+

Types

+
+ {ALL_TYPES.map((t) => ( + + ))} +
+
+ +
+
+

+ Reputation (−3 to 3) +

+
+ setRepField('min', e.target.value)} + className="h-8 text-sm" + /> + + setRepField('max', e.target.value)} + className="h-8 text-sm" + /> +
+
+ +
+

+ Accuracy (0 to 3) +

+
+ setAccField('min', e.target.value)} + className="h-8 text-sm" + /> + + setAccField('max', e.target.value)} + className="h-8 text-sm" + /> +
+
+
+ +
+
+

Tags include

+ onChange({ ...value, tagsInclude: parseTags(e.target.value) })} + className="h-8 text-sm" + /> +
+
+

Tags exclude

+ onChange({ ...value, tagsExclude: parseTags(e.target.value) })} + className="h-8 text-sm" + /> +
+
+ +
+

Date range

+ +
+ + setDatePickerOpen(false)} + onConfirm={(next) => { + onChange({ + ...value, + dateFrom: next.from ? next.from.toISOString() : null, + dateTo: next.to ? next.to.toISOString() : null, + }) + setDatePickerOpen(false) + }} + /> +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/LookupBar.tsx b/frontend/src/features/threat-intel/components/LookupBar.tsx index 368062edb..d9cc2615d 100644 --- a/frontend/src/features/threat-intel/components/LookupBar.tsx +++ b/frontend/src/features/threat-intel/components/LookupBar.tsx @@ -4,24 +4,36 @@ import { Crosshair, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' +const ENTITY_TYPES = ['ip', 'domain', 'hostname', 'url', 'md5', 'sha1', 'sha256', 'sha3-256', 'cve', 'email-address', 'threat', 'malware'] + interface LookupBarProps { onSearch: (query: string) => void + onLookup: (input: { type: string; value: string }) => void isPending?: boolean + isLookupPending?: boolean } -export function LookupBar({ onSearch, isPending }: LookupBarProps) { +export function LookupBar({ onSearch, onLookup, isPending, isLookupPending }: LookupBarProps) { const { t } = useTranslation() const [q, setQ] = useState('') + const [selectedType, setSelectedType] = useState('any') const handleSubmit = () => { const trimmed = q.trim() - if (trimmed) onSearch(trimmed) + if (!trimmed) return + if (selectedType === 'any') { + onSearch(trimmed) + } else { + onLookup({ type: selectedType, value: trimmed }) + } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') handleSubmit() } + const busy = isPending || isLookupPending + return (
@@ -29,6 +41,16 @@ export function LookupBar({ onSearch, isPending }: LookupBarProps) { {t('threatIntel.lookup.title')}
+
-
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 607fcb02c..018aa6a9a 100644 --- a/frontend/src/features/threat-intel/domain/threat-intel.types.ts +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -90,20 +90,6 @@ 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 & {}) @@ -148,7 +134,35 @@ export interface AdvancedRangeCondition { export interface AdvancedTermCondition { term: Record } -export type AdvancedCondition = AdvancedRangeCondition | AdvancedTermCondition +export interface AdvancedMatchCondition { + match: Record +} +export interface AdvancedTermsCondition { + terms: Record +} +export interface AdvancedExistsCondition { + exists: { field: string } +} +export interface AdvancedWildcardCondition { + wildcard: Record +} +export interface AdvancedMultiMatchCondition { + multi_match: { query: string; fields: string[] } +} +export type AdvancedCondition = + | AdvancedRangeCondition + | AdvancedTermCondition + | AdvancedMatchCondition + | AdvancedTermsCondition + | AdvancedExistsCondition + | AdvancedWildcardCondition + | AdvancedMultiMatchCondition + +export interface EntityLookupRequest { + type: EntityType + value: string | number +} +export type EntityLookupResponse = EntitySearchItem export interface AdvancedDateHistogram { date_histogram: { field: string; interval: string } diff --git a/frontend/src/features/threat-intel/hooks/use-ti-entity-lookup.ts b/frontend/src/features/threat-intel/hooks/use-ti-entity-lookup.ts new file mode 100644 index 000000000..642c01593 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-entity-lookup.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, isNotFound } from '../services/ti-errors' +import type { EntityLookupRequest } from '../domain/threat-intel.types' + +export function useTiEntityLookup() { + return useMutation({ + mutationFn: (req: EntityLookupRequest) => threatIntelHttpService.entityLookup(req), + onError: (e) => { if (!isNotConfigured(e) && !isNotFound(e)) toast.error(describeError(e)) }, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-search-advanced.ts b/frontend/src/features/threat-intel/hooks/use-ti-search-advanced.ts new file mode 100644 index 000000000..ee1ab4676 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-search-advanced.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { threatIntelHttpService } from '../services/threat-intel-http.service' +import { describeError, isNotConfigured, isNotFound } from '../services/ti-errors' +import type { AdvancedSearchRequest } from '../domain/threat-intel.types' + +export function useTiSearchAdvanced() { + return useMutation({ + mutationFn: (input: { body: AdvancedSearchRequest; limit?: number; page?: number }) => + threatIntelHttpService.searchAdvanced(input.body, { limit: input.limit, page: input.page }), + onError: (e) => { if (!isNotConfigured(e) && !isNotFound(e)) toast.error(describeError(e)) }, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-search.ts b/frontend/src/features/threat-intel/hooks/use-ti-search.ts deleted file mode 100644 index 0422409c7..000000000 --- a/frontend/src/features/threat-intel/hooks/use-ti-search.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index ee2fe52b5..2fcaedc33 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -1,14 +1,15 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronDown, Clock, ListFilter, RefreshCw, Search } from 'lucide-react' +import { Clock, ListFilter, RefreshCw, Search } 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' -import { useTiSearch } from '../hooks/use-ti-search' +import { useTiSearchAdvanced } from '../hooks/use-ti-search-advanced' +import { useTiEntityLookup } from '../hooks/use-ti-entity-lookup' import { threatIntelHttpService } from '../services/threat-intel-http.service' -import { describeError } from '../services/ti-errors' +import { describeError, isNotFound } from '../services/ti-errors' import { downloadCsv, toCsv } from '../services/csv' import { searchItemValue } from '../domain/threat-intel.types' import { NotConfiguredState } from '../components/NotConfiguredState' @@ -21,19 +22,84 @@ 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' +import { FiltersPanel, EMPTY_FILTERS, filtersToRequest } from '../components/FiltersPanel' +import type { FiltersState } from '../components/FiltersPanel' +import type { + EntitySummary, + AdvancedSearchRequest, + AdvancedCondition, +} from '../domain/threat-intel.types' + +const MULTI_MATCH_FIELDS = [ + 'attributes.ip', 'attributes.domain', 'attributes.hostname', 'attributes.url', + 'attributes.md5', 'attributes.sha1', 'attributes.sha256', 'attributes.sha3-256', + 'attributes.cve', 'attributes.email-address', 'attributes.text', 'tags', +] + +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 }, +] + +function textQueryFragment(q: string): AdvancedSearchRequest | undefined { + if (!q || q === '*') return undefined + return { query: { must: [{ multi_match: { query: q, fields: MULTI_MATCH_FIELDS } }] } } +} + +function timeRangeFragment(range: TimeRange): AdvancedSearchRequest | undefined { + const expr = TIME_RANGE_OPTIONS.find((o) => o.value === range)?.expr + if (!expr) return undefined + return { query: { filter: [{ range: { lastSeen: { gte: expr, lte: 'now' } } }] } } +} + +function composeBody( + filterFragment: AdvancedSearchRequest, + q: string, + range: TimeRange, +): AdvancedSearchRequest { + const must: AdvancedCondition[] = [...(filterFragment.query?.must ?? [])] + const should: AdvancedCondition[] = [...(filterFragment.query?.should ?? [])] + const mustNot: AdvancedCondition[] = [...(filterFragment.query?.must_not ?? [])] + const filter: AdvancedCondition[] = [...(filterFragment.query?.filter ?? [])] + const text = textQueryFragment(q) + if (text?.query?.must) must.push(...text.query.must) + const time = timeRangeFragment(range) + if (time?.query?.filter) filter.push(...time.query.filter) + const query: NonNullable = {} + if (must.length) query.must = must + if (should.length) query.should = should + if (mustNot.length) query.must_not = mustNot + if (filter.length) query.filter = filter + return { + query: Object.keys(query).length ? query : undefined, + aggs: filterFragment.aggs, + } +} export function ThreatIntelPage() { const { t } = useTranslation() const { isConfigured, isLoading: configLoading } = useTiConfigStatus() const feedsQuery = useTiFeeds() - const searchMutation = useTiSearch() + const searchAdvancedMutation = useTiSearchAdvanced() + const lookupMutation = useTiEntityLookup() const [query, setQuery] = useState('*') const [page, setPage] = useState(0) const [size, setSize] = useState(20) - const [results, setResults] = useState(null) const [iocs, setIocs] = useState([]) + const [totalItems, setTotalItems] = useState(0) + const [totalPages, setTotalPages] = useState(0) + + const [filters, setFilters] = useState(EMPTY_FILTERS) + const [lastBody, setLastBody] = useState({}) + const [filtersOpen, setFiltersOpen] = useState(false) + const [timeRange, setTimeRange] = useState('24h') // 'replace' when the user searches, jumps pages, or changes page size. // 'append' when infinite scroll asks for the next page. @@ -51,30 +117,38 @@ export function ThreatIntelPage() { if (!isConfigured) return const my = ++seqRef.current const mode = modeRef.current - searchMutation.mutate( - { query, page: page + 1, size }, + const body = composeBody(filtersToRequest(filters), query, timeRange) + setLastBody(body) + searchAdvancedMutation.mutate( + { body, limit: size, page: page + 1 }, { onSuccess: (data) => { if (my !== seqRef.current) return if (data?.kind === 'not-configured') return if (data?.kind !== 'ok') return - setResults(data.value) + setTotalItems(data.value.items) + setTotalPages(data.value.pages) setIocs((prev) => mode === 'append' ? [...prev, ...data.value.results] : data.value.results ) }, + onError: (e) => { + if (my !== seqRef.current) return + if (!isNotFound(e) || mode === 'append') return + setIocs([]) + setTotalItems(0) + setTotalPages(0) + }, } ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, page, size, isConfigured]) + }, [query, page, size, isConfigured, timeRange]) 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) => { @@ -95,16 +169,52 @@ export function ThreatIntelPage() { } const handleLoadMore = () => { - if (!hasMore || searchMutation.isPending) return + if (!hasMore || searchAdvancedMutation.isPending) return modeRef.current = 'append' setPage((p) => p + 1) } + const handleFiltersApply = (request: AdvancedSearchRequest) => { + const body = composeBody(request, query, timeRange) + modeRef.current = 'replace' + setPage(0) + setLastBody(body) + setIocs([]) + setTotalItems(0) + setTotalPages(0) + const my = ++seqRef.current + searchAdvancedMutation.mutate( + { body, limit: size, page: 1 }, + { + onSuccess: (data) => { + if (my !== seqRef.current) return + if (data?.kind === 'not-configured') return + if (data?.kind !== 'ok') return + setTotalItems(data.value.items) + setTotalPages(data.value.pages) + setIocs(data.value.results) + }, + } + ) + } + + const handleLookup = (input: { type: string; value: string }) => { + lookupMutation.mutate( + { type: input.type, value: input.value }, + { + onSuccess: (result) => { + if (result?.kind !== 'ok') return + setOpenIoc(result.value.id) + }, + } + ) + } + const handleExport = async () => { if (!totalItems || isExporting) return setIsExporting(true) try { - const res = await threatIntelHttpService.search({ query, page: 1, size: totalItems }) + const res = await threatIntelHttpService.searchAdvanced(lastBody, { limit: totalItems, page: 1 }) if (res.kind !== 'ok') return const rows = res.value.results.map((r) => [ r.id, @@ -142,7 +252,12 @@ export function ThreatIntelPage() {
- +
@@ -171,12 +286,26 @@ export function ThreatIntelPage() {
{tab === 'iocs' && ( <> - - @@ -191,12 +320,27 @@ export function ThreatIntelPage() { + {filtersOpen && tab === 'iocs' && ( +
+ { + setFilters(EMPTY_FILTERS) + handleFiltersApply({}) + }} + onClose={() => setFiltersOpen(false)} + /> +
+ )} +
{tab === 'iocs' && ( (fn: () => Promise): Promise> { } 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)) @@ -44,4 +42,6 @@ export const threatIntelHttpService = { feeds: () => wrap(() => api.get(`${BASE}/feeds`)), chat: (body: ChatRequest) => wrap(() => api.post(`${BASE}/ai/chat`, body)), usage: () => wrap(() => api.get(`${BASE}/usage`)), + entityLookup: (body: EntityLookupRequest) => + wrap(() => api.post(`${BASE}/entity/lookup`, body)), } diff --git a/frontend/src/features/threat-intel/services/ti-errors.ts b/frontend/src/features/threat-intel/services/ti-errors.ts index eb6639c59..253482152 100644 --- a/frontend/src/features/threat-intel/services/ti-errors.ts +++ b/frontend/src/features/threat-intel/services/ti-errors.ts @@ -4,6 +4,10 @@ export function isNotConfigured(e: unknown): boolean { return e instanceof ApiError && e.status === 503 && /not configured/i.test(e.message ?? '') } +export function isNotFound(e: unknown): boolean { + return e instanceof ApiError && e.status === 404 +} + export function describeError(e: unknown): string { if (e instanceof ApiError) { if (e.status === 502) return 'Threat intel upstream unavailable — try again.' From 00e9374199cba59fe37fae8609cecb8aaec8a964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 20 Jul 2026 16:44:30 -0600 Subject: [PATCH 2/3] fix[frontend](threat_intelligense): fetch alert related ioc data to link threatintelligense filters --- .../threat-intel/domain/threat-intel.types.ts | 1 + .../threat-intel/hooks/use-alert-iocs.ts | 48 +++++++++++++++++++ .../threat-intel/pages/ThreatIntelPage.tsx | 44 +++++++++++++---- 3 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 frontend/src/features/threat-intel/hooks/use-alert-iocs.ts 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 018aa6a9a..38074c369 100644 --- a/frontend/src/features/threat-intel/domain/threat-intel.types.ts +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -175,6 +175,7 @@ export interface AdvancedSearchRequest { should?: AdvancedCondition[] must_not?: AdvancedCondition[] filter?: AdvancedCondition[] + minimum_should_match?: number } aggs?: Record } diff --git a/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts b/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts new file mode 100644 index 000000000..b43d97c1c --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import { alertsHttpService } from '@/features/alerts/services/alerts-http.service' + +const IOC_FIELD_MAP: { field: string; twAttr: string }[] = [ + { field: 'adversary.ip', twAttr: 'ip' }, + { field: 'target.ip', twAttr: 'ip' }, + { field: 'adversary.host', twAttr: 'hostname' }, + { field: 'target.host', twAttr: 'hostname' }, + { field: 'adversary.domain', twAttr: 'domain' }, + { field: 'target.domain', twAttr: 'domain' }, +] + +const TOP_TOTAL = 20 + +export interface AlertIocs { + byAttr: Record + total: number +} + +export function useAlertIocs() { + return useQuery({ + queryKey: ['ti', 'alert-iocs', TOP_TOTAL], + queryFn: async () => { + const perField = await Promise.all( + IOC_FIELD_MAP.map(async ({ field, twAttr }) => ({ + twAttr, + values: await alertsHttpService.fieldValues(field, TOP_TOTAL).catch(() => []), + })), + ) + const flat = perField.flatMap(({ twAttr, values }) => + values.map((v) => ({ twAttr, value: v.value, count: v.count })), + ) + flat.sort((a, b) => b.count - a.count) + const byAttr: Record = {} + let total = 0 + for (const entry of flat) { + if (total >= TOP_TOTAL) break + const bucket = byAttr[entry.twAttr] ?? [] + if (bucket.includes(entry.value)) continue + bucket.push(entry.value) + byAttr[entry.twAttr] = bucket + total++ + } + return { byAttr, total } + }, + staleTime: 5 * 60_000, + }) +} diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index 2fcaedc33..7221757aa 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Clock, ListFilter, RefreshCw, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' @@ -8,6 +8,7 @@ import { useTiConfigStatus } from '../hooks/use-ti-config-status' import { useTiFeeds } from '../hooks/use-ti-feeds' import { useTiSearchAdvanced } from '../hooks/use-ti-search-advanced' import { useTiEntityLookup } from '../hooks/use-ti-entity-lookup' +import { useAlertIocs, type AlertIocs } from '../hooks/use-alert-iocs' import { threatIntelHttpService } from '../services/threat-intel-http.service' import { describeError, isNotFound } from '../services/ti-errors' import { downloadCsv, toCsv } from '../services/csv' @@ -58,10 +59,25 @@ function timeRangeFragment(range: TimeRange): AdvancedSearchRequest | undefined return { query: { filter: [{ range: { lastSeen: { gte: expr, lte: 'now' } } }] } } } +function alertIocsFragment(iocs: AlertIocs): AdvancedSearchRequest { + const entries = Object.entries(iocs.byAttr).filter(([, v]) => v.length > 0) + if (entries.length === 0) { + return { query: { must: [{ terms: { id: ['__no_observed_iocs__'] } }] } } + } + return { + query: { + should: entries.map(([attr, values]) => ({ + terms: { [`attributes.${attr}`]: values }, + })), + }, + } +} + function composeBody( filterFragment: AdvancedSearchRequest, q: string, range: TimeRange, + observed?: AdvancedSearchRequest, ): AdvancedSearchRequest { const must: AdvancedCondition[] = [...(filterFragment.query?.must ?? [])] const should: AdvancedCondition[] = [...(filterFragment.query?.should ?? [])] @@ -71,9 +87,14 @@ function composeBody( if (text?.query?.must) must.push(...text.query.must) const time = timeRangeFragment(range) if (time?.query?.filter) filter.push(...time.query.filter) + if (observed?.query?.must) must.push(...observed.query.must) + if (observed?.query?.should) should.push(...observed.query.should) const query: NonNullable = {} if (must.length) query.must = must - if (should.length) query.should = should + if (should.length) { + query.should = should + query.minimum_should_match = 1 + } if (mustNot.length) query.must_not = mustNot if (filter.length) query.filter = filter return { @@ -88,6 +109,12 @@ export function ThreatIntelPage() { const feedsQuery = useTiFeeds() const searchAdvancedMutation = useTiSearchAdvanced() const lookupMutation = useTiEntityLookup() + const alertIocsQuery = useAlertIocs() + console.log(alertIocsQuery.data) + const observedFragment = useMemo( + () => (alertIocsQuery.data ? alertIocsFragment(alertIocsQuery.data) : undefined), + [alertIocsQuery.data], + ) const [query, setQuery] = useState('*') const [page, setPage] = useState(0) @@ -99,12 +126,9 @@ export function ThreatIntelPage() { const [filters, setFilters] = useState(EMPTY_FILTERS) const [lastBody, setLastBody] = useState({}) const [filtersOpen, setFiltersOpen] = useState(false) - const [timeRange, setTimeRange] = useState('24h') + const [timeRange, setTimeRange] = useState('all') - // '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') @@ -115,9 +139,10 @@ export function ThreatIntelPage() { useEffect(() => { if (!isConfigured) return + if (!observedFragment) return const my = ++seqRef.current const mode = modeRef.current - const body = composeBody(filtersToRequest(filters), query, timeRange) + const body = composeBody(filtersToRequest(filters), query, timeRange, observedFragment) setLastBody(body) searchAdvancedMutation.mutate( { body, limit: size, page: page + 1 }, @@ -141,8 +166,7 @@ export function ThreatIntelPage() { }, } ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, page, size, isConfigured, timeRange]) + }, [query, page, size, isConfigured, timeRange, observedFragment]) if (configLoading) return null if (isConfigured === false) return @@ -175,7 +199,7 @@ export function ThreatIntelPage() { } const handleFiltersApply = (request: AdvancedSearchRequest) => { - const body = composeBody(request, query, timeRange) + const body = composeBody(request, query, timeRange, observedFragment) modeRef.current = 'replace' setPage(0) setLastBody(body) From 28b08aa8c2cfcabfe0853efae1b83d277f3f48e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 20 Jul 2026 16:54:44 -0600 Subject: [PATCH 3/3] fix[frontend](threat_intelligense): fixed timestamp set --- .../threat-intel/components/IocRow.tsx | 4 +- .../components/utils/severity-style.ts | 3 - .../threat-intel/domain/threat-intel.types.ts | 2 - .../threat-intel/hooks/use-alert-iocs.ts | 21 +++++++ .../threat-intel/hooks/use-ti-iocs-24h.ts | 13 ++++- .../threat-intel/pages/ThreatIntelPage.tsx | 55 ++++--------------- .../threat-intel/services/advanced-query.ts | 37 +++++++++++++ .../src/features/threat-intel/services/csv.ts | 2 - 8 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 frontend/src/features/threat-intel/services/advanced-query.ts diff --git a/frontend/src/features/threat-intel/components/IocRow.tsx b/frontend/src/features/threat-intel/components/IocRow.tsx index 57a5b9f22..e3cfc4108 100644 --- a/frontend/src/features/threat-intel/components/IocRow.tsx +++ b/frontend/src/features/threat-intel/components/IocRow.tsx @@ -3,7 +3,7 @@ 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' +import { absTimestamp } from './utils/time-format' interface IocRowProps { ioc: EntitySummary @@ -49,7 +49,7 @@ export function IocRow({ ioc, onOpen }: IocRowProps) { )}
- {ioc.lastSeen ? relativeTime(ioc.lastSeen) : '—'} + {ioc.lastSeen ? absTimestamp(ioc.lastSeen) : '—'}