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/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) : '—'}
diff --git a/frontend/src/features/threat-intel/components/utils/severity-style.ts b/frontend/src/features/threat-intel/components/utils/severity-style.ts index f0bb50e82..5ae44fba0 100644 --- a/frontend/src/features/threat-intel/components/utils/severity-style.ts +++ b/frontend/src/features/threat-intel/components/utils/severity-style.ts @@ -10,8 +10,6 @@ export const REPUTATION_STYLE: Record 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' }, 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..c39c9d1fc 100644 --- a/frontend/src/features/threat-intel/domain/threat-intel.types.ts +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -79,8 +79,6 @@ export interface EntitySearchItem { } 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. @@ -90,20 +88,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 +132,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 } @@ -161,6 +173,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..4c1f6caf4 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-alert-iocs.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { alertsHttpService } from '@/features/alerts/services/alerts-http.service' +import type { AdvancedSearchRequest } from '../domain/threat-intel.types' + +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, + }) +} + +export 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 }, + })), + }, + } +} + +export function useAlertIocsFragment(): AdvancedSearchRequest | undefined { + const q = useAlertIocs() + return useMemo(() => (q.data ? alertIocsFragment(q.data) : undefined), [q.data]) +} 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-iocs-24h.ts b/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts index 278d43217..9803b4d6a 100644 --- a/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts +++ b/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts @@ -1,15 +1,22 @@ 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 REQ: AdvancedSearchRequest = { +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'], - queryFn: () => threatIntelHttpService.searchAdvanced(REQ, { limit: 0, page: 1 }), + 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-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..dac347daa 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -1,14 +1,17 @@ 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 { useAlertIocsFragment } from '../hooks/use-alert-iocs' +import { mergeAdvancedRequests } from '../services/advanced-query' 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,24 +24,76 @@ 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, +} 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, + observed?: AdvancedSearchRequest, +): AdvancedSearchRequest { + return mergeAdvancedRequests( + filterFragment, + textQueryFragment(q), + timeRangeFragment(range), + observed, + ) +} export function ThreatIntelPage() { const { t } = useTranslation() const { isConfigured, isLoading: configLoading } = useTiConfigStatus() const feedsQuery = useTiFeeds() - const searchMutation = useTiSearch() + const searchAdvancedMutation = useTiSearchAdvanced() + const lookupMutation = useTiEntityLookup() + const observedFragment = useAlertIocsFragment() 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('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') @@ -49,32 +104,40 @@ export function ThreatIntelPage() { useEffect(() => { if (!isConfigured) return + if (!observedFragment) return const my = ++seqRef.current const mode = modeRef.current - searchMutation.mutate( - { query, page: page + 1, size }, + const body = composeBody(filtersToRequest(filters), query, timeRange, observedFragment) + 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, observedFragment]) 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 +158,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, observedFragment) + 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 +241,12 @@ export function ThreatIntelPage() {
- +
@@ -171,12 +275,26 @@ export function ThreatIntelPage() {
{tab === 'iocs' && ( <> - - @@ -191,12 +309,27 @@ export function ThreatIntelPage() { + {filtersOpen && tab === 'iocs' && ( +
+ { + setFilters(EMPTY_FILTERS) + handleFiltersApply({}) + }} + onClose={() => setFiltersOpen(false)} + /> +
+ )} +
{tab === 'iocs' && ( = {} + for (const f of frags) { + if (!f) continue + if (f.query?.must) must.push(...f.query.must) + if (f.query?.should) should.push(...f.query.should) + if (f.query?.must_not) mustNot.push(...f.query.must_not) + if (f.query?.filter) filter.push(...f.query.filter) + if (f.query?.minimum_should_match !== undefined) minShouldMatch = f.query.minimum_should_match + if (f.aggs) Object.assign(aggs, f.aggs) + } + const query: NonNullable = {} + if (must.length) query.must = must + if (should.length) { + query.should = should + query.minimum_should_match = minShouldMatch ?? 1 + } + if (mustNot.length) query.must_not = mustNot + if (filter.length) query.filter = filter + return { + query: Object.keys(query).length ? query : undefined, + aggs: Object.keys(aggs).length ? aggs : undefined, + } +} diff --git a/frontend/src/features/threat-intel/services/csv.ts b/frontend/src/features/threat-intel/services/csv.ts index bfaeecc7a..038b20dd3 100644 --- a/frontend/src/features/threat-intel/services/csv.ts +++ b/frontend/src/features/threat-intel/services/csv.ts @@ -1,5 +1,3 @@ -// 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 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 index 0991ffbc4..550f7580c 100644 --- a/frontend/src/features/threat-intel/services/threat-intel-http.service.ts +++ b/frontend/src/features/threat-intel/services/threat-intel-http.service.ts @@ -1,7 +1,5 @@ import { createApiClient } from '@/shared/lib/api-client' import type { - EntitySearchRequest, - EntitySearchResponse, EntityDetail, EntityRelation, ThreatFeed, @@ -11,6 +9,8 @@ import type { TiResult, AdvancedSearchRequest, AdvancedSearchResponse, + EntityLookupRequest, + EntityLookupResponse, } from '../domain/threat-intel.types' import { isNotConfigured } from './ti-errors' @@ -27,8 +27,6 @@ async function wrap(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.'