@cccsaurora/howler-ui 2.19.0-cases.1129 → 2.19.0-cases.1131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,4 @@
1
1
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
- import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
3
- import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
4
- import { type ObservableEntry } from './observables/Observable';
5
- /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
6
- export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
7
2
  declare const _default: import("react").NamedExoticComponent<{
8
3
  case?: Case;
9
4
  caseId?: string;
@@ -1,64 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Chip, Grid, Skeleton, Stack, Typography } from '@mui/material';
2
+ import { FilterList } from '@mui/icons-material';
3
+ import { Chip, Grid, Skeleton, Stack, TextField, Typography } from '@mui/material';
3
4
  import api from '@cccsaurora/howler-ui/api';
5
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
4
6
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
7
+ import { toArray, uniq } from 'lodash-es';
5
8
  import { memo, useEffect, useMemo, useState } from 'react';
6
9
  import { useTranslation } from 'react-i18next';
7
10
  import { useOutletContext } from 'react-router-dom';
8
11
  import useCase from '../hooks/useCase';
9
- import Observable, {} from './observables/Observable';
10
- /** All Related fields that carry observable values */
11
- const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
12
- /** Extract (type, value, seenInId) triples from a record's related field */
13
- const extractObservables = (related, recordId) => {
14
- if (!related) {
15
- return [];
16
- }
17
- const results = [];
18
- for (const field of OBSERVABLE_FIELDS) {
19
- const raw = related[field];
20
- if (!raw) {
21
- continue;
22
- }
23
- const values = Array.isArray(raw) ? raw : [raw];
24
- for (const value of values) {
25
- if (value) {
26
- results.push({ type: field, value: String(value), id: recordId });
27
- }
28
- }
29
- }
30
- return results;
31
- };
32
- /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
33
- export const buildObservableEntries = (records) => {
34
- const map = new Map();
35
- for (const record of records) {
36
- const related = record.related ?? record.related;
37
- const recordId = record.howler?.id ?? record.howler?.id;
38
- if (!recordId) {
39
- continue;
40
- }
41
- for (const { type, value, id } of extractObservables(related, recordId)) {
42
- const key = `${type}:${value}`;
43
- if (!map.has(key)) {
44
- map.set(key, { type, value, seenIn: [] });
45
- }
46
- const entry = map.get(key);
47
- if (!entry.seenIn.includes(id)) {
48
- entry.seenIn.push(id);
49
- }
50
- }
51
- }
52
- return Array.from(map.values());
53
- };
12
+ import ObservableTable from './observables/ObservableTable';
13
+ import { OBSERVABLE_FIELDS, buildObservableEntries, classifyRole, resolveSources } from './utils';
54
14
  const RELATED_FIELDS = OBSERVABLE_FIELDS.map(f => `related.${f}`).join(',');
15
+ const EXTRA_FIELDS = 'howler.escalation,howler.outline.threat,howler.outline.target,howler.outline.indicators,threat.indicator.ip,threat.indicator.description';
55
16
  const CaseObservables = ({ case: providedCase, caseId }) => {
56
17
  const { t } = useTranslation();
57
18
  const { dispatchApi } = useMyApi();
58
19
  const routeCase = useOutletContext();
59
20
  const { case: _case } = useCase({ case: providedCase ?? routeCase, caseId });
60
21
  const [records, setRecords] = useState(null);
61
- const [activeFilters, setActiveFilters] = useState(new Set());
22
+ const [activeFilters, setActiveFilters] = useState([]);
23
+ const [searchQuery, setSearchQuery] = useState('');
24
+ const [originFilters, setOriginFilters] = useState([]);
25
+ const [roleFilters, setRoleFilters] = useState([]);
26
+ const [escalationOptions, setEscalationOptions] = useState([]);
27
+ const [activeEscalations, setActiveEscalations] = useState([]);
28
+ const [escalationMap, setEscalationMap] = useState(new Map());
62
29
  const ids = useMemo(() => (_case?.items ?? [])
63
30
  .filter(item => ['hit', 'event'].includes(item.type))
64
31
  .map(item => item.value)
@@ -70,35 +37,106 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
70
37
  }
71
38
  dispatchApi(api.v2.search.post(['hit', 'event'], {
72
39
  query: `howler.id:(${ids.join(' OR ')})`,
73
- fl: `howler.id,${RELATED_FIELDS}`
74
- })).then(response => setRecords(response.items));
40
+ fl: `howler.id,${RELATED_FIELDS},${EXTRA_FIELDS}`
41
+ })).then(response => {
42
+ setRecords(response.items);
43
+ // Build escalation map from fetched records
44
+ const escMap = new Map();
45
+ for (const record of response.items) {
46
+ const id = record.howler?.id ?? record.howler?.id;
47
+ const escalation = record.howler?.escalation;
48
+ if (id && escalation) {
49
+ escMap.set(id, escalation);
50
+ }
51
+ }
52
+ setEscalationMap(escMap);
53
+ });
75
54
  }, [dispatchApi, ids]);
76
- const allObservables = useMemo(() => (records ? buildObservableEntries(records) : []), [records]);
77
- const observableTypes = useMemo(() => (allObservables ? [...new Set(allObservables.map(a => a.type))].sort() : []), [allObservables]);
55
+ useEffect(() => {
56
+ if (ids.length < 1) {
57
+ setEscalationOptions([]);
58
+ setActiveEscalations([]);
59
+ return;
60
+ }
61
+ dispatchApi(api.v2.search.facet.post(['hit', 'event'], {
62
+ fields: ['howler.escalation'],
63
+ filters: [`howler.id:(${ids.join(' OR ')})`]
64
+ }), { throwError: false }).then(result => {
65
+ if (result) {
66
+ setEscalationOptions(Object.keys(result['howler.escalation'] ?? {}));
67
+ }
68
+ });
69
+ }, [dispatchApi, ids]);
70
+ const allObservables = useMemo(() => {
71
+ if (!records || !_case) {
72
+ return [];
73
+ }
74
+ const entries = buildObservableEntries(records);
75
+ return entries.map(entry => ({
76
+ ...entry,
77
+ role: classifyRole(entry.value, _case, records),
78
+ sources: resolveSources(entry.seenIn, _case.items, escalationMap)
79
+ }));
80
+ }, [records, _case, escalationMap]);
81
+ const observableTypes = useMemo(() => (allObservables ? uniq(allObservables.map(a => a.type)).sort() : []), [allObservables]);
78
82
  const filteredObservables = useMemo(() => {
79
83
  if (allObservables.length < 1) {
80
84
  return [];
81
85
  }
82
- if (activeFilters.size === 0) {
83
- return allObservables;
86
+ let result = allObservables;
87
+ if (activeFilters.length > 0) {
88
+ result = result.filter(a => activeFilters.includes(a.type));
89
+ }
90
+ if (searchQuery.trim()) {
91
+ const query = searchQuery.trim().toLowerCase();
92
+ result = result.filter(a => a.value.toLowerCase().includes(query) || a.type.toLowerCase().includes(query));
93
+ }
94
+ if (originFilters.length > 0) {
95
+ result = result.filter(a => a.sources?.some(s => originFilters.includes(s.type)));
96
+ }
97
+ if (activeEscalations.length > 0) {
98
+ result = result.filter(a => a.sources?.some(s => s.escalation && activeEscalations.includes(s.escalation)));
99
+ }
100
+ if (roleFilters.length > 0) {
101
+ result = result.filter(a => a.role && roleFilters.includes(a.role));
84
102
  }
85
- return allObservables.filter(a => activeFilters.has(a.type));
86
- }, [allObservables, activeFilters]);
87
- const toggleFilter = (type) => {
88
- setActiveFilters(prev => {
89
- const next = new Set(prev);
90
- if (next.has(type)) {
91
- next.delete(type);
103
+ return result;
104
+ }, [allObservables, activeFilters, searchQuery, originFilters, activeEscalations, roleFilters]);
105
+ const toggleSetItem = (setter, item) => {
106
+ setter(prev => {
107
+ if (prev.includes(item)) {
108
+ return prev.filter(_item => _item !== item);
92
109
  }
93
110
  else {
94
- next.add(type);
111
+ return [...prev, item];
95
112
  }
96
- return next;
97
113
  });
98
114
  };
99
115
  if (!_case) {
100
116
  return null;
101
117
  }
102
- return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, flexWrap: "wrap", children: [_jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: t('page.cases.observables.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (observableTypes.map(type => (_jsx(Chip, { label: t(`page.cases.observables.type.${type}`), size: "small", onClick: () => toggleFilter(type), color: activeFilters.has(type) ? 'primary' : 'default', variant: activeFilters.has(type) ? 'filled' : 'outlined' }, type))))] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 4, xl: 3, children: _jsx(Skeleton, { height: 100 }) }, `skeleton-${i}`)))) : filteredObservables.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.observables.empty') }) })) : (filteredObservables.map(observable => (_jsx(Grid, { item: true, xs: 12, md: 6, xl: 4, children: _jsx(Observable, { asset: observable, case: _case }) }, `${observable.type}:${observable.value}`))))] }));
118
+ return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { spacing: 1.5, children: [_jsx(TextField, { size: "small", placeholder: t('page.cases.observables.search'), value: searchQuery, onChange: e => setSearchQuery(e.target.value), sx: { maxWidth: 360 }, id: "observable-search" }), _jsxs(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", children: [_jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: activeFilters.length > 0
119
+ ? `${t('page.cases.observables.filter_by_type')} (${toArray(activeFilters)
120
+ .map(type => t(`page.cases.observables.type.${type}`))
121
+ .sort()
122
+ .join(', ')})`
123
+ : t('page.cases.observables.filter_by_type'), slotProps: {
124
+ chip: { size: 'small', color: activeFilters.length > 0 ? 'primary' : 'default' }
125
+ }, children: records === null ? (_jsx(Skeleton, { width: 200, height: 32 })) : (_jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: observableTypes.map(type => (_jsx(Chip, { label: t(`page.cases.observables.type.${type}`), size: "small", onClick: () => toggleSetItem(setActiveFilters, type), color: activeFilters.includes(type) ? 'primary' : 'default', variant: activeFilters.includes(type) ? 'filled' : 'outlined' }, type))) })) }), _jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: originFilters.length > 0
126
+ ? `${t('page.cases.observables.filter_by_origin')} (${toArray(originFilters).join(', ')})`
127
+ : t('page.cases.observables.filter_by_origin'), slotProps: {
128
+ chip: { size: 'small', color: originFilters.length > 0 ? 'primary' : 'default' }
129
+ }, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: ['hit', 'event'].map(origin => (_jsx(Chip, { label: t(`page.cases.observables.origin.${origin}`), size: "small", onClick: () => toggleSetItem(setOriginFilters, origin), color: originFilters.includes(origin) ? 'primary' : 'default', variant: originFilters.includes(origin) ? 'filled' : 'outlined' }, origin))) }) }), _jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: roleFilters.length > 0
130
+ ? `${t('page.cases.observables.filter_by_role')} (${toArray(roleFilters).join(', ')})`
131
+ : t('page.cases.observables.filter_by_role'), slotProps: {
132
+ chip: { size: 'small', color: roleFilters.length > 0 ? 'primary' : 'default' }
133
+ }, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: ['threat', 'target', 'indicator'].map(role => (_jsx(Chip, { label: t(`page.cases.observables.role.${role}`), size: "small", onClick: () => toggleSetItem(setRoleFilters, role), color: roleFilters.includes(role) ? 'primary' : 'default', variant: roleFilters.includes(role) ? 'filled' : 'outlined' }, role))) }) }), escalationOptions.length > 0 && (_jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: activeEscalations.length > 0
134
+ ? `${t('page.cases.observables.filter_by_escalation')} (${toArray(activeEscalations).join(', ')})`
135
+ : t('page.cases.observables.filter_by_escalation'), slotProps: {
136
+ chip: {
137
+ size: 'small',
138
+ color: activeEscalations.length > 0 ? 'primary' : 'default'
139
+ }
140
+ }, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: escalationOptions.map(esc => (_jsx(Chip, { label: esc, size: "small", onClick: () => toggleSetItem(setActiveEscalations, esc), color: activeEscalations.includes(esc) ? 'primary' : 'default', variant: activeEscalations.includes(esc) ? 'filled' : 'outlined' }, esc))) }) }))] })] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, children: _jsx(Skeleton, { height: 40 }) }, `skeleton-${i}`)))) : filteredObservables.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.observables.empty') }) })) : (_jsx(Grid, { item: true, xs: 12, children: _jsx(ObservableTable, { observables: filteredObservables, case: _case }) }))] }));
103
141
  };
104
142
  export default memo(CaseObservables);
@@ -1,76 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /// <reference types="vitest" />
3
- import { render, screen } from '@testing-library/react';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { createElement } from 'react';
6
6
  import { MemoryRouter } from 'react-router-dom';
7
- import { createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
8
7
  import { describe, expect, it, vi } from 'vitest';
9
- import { buildObservableEntries } from './CaseObservables';
10
- // ---------------------------------------------------------------------------
11
- // Pure logic tests — no React needed
12
- // ---------------------------------------------------------------------------
13
- describe('buildObservableEntries', () => {
14
- it('returns an empty array for records with no related field', () => {
15
- expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
16
- });
17
- it('extracts a single IP from a hit', () => {
18
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
19
- expect(result).toHaveLength(1);
20
- expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
21
- });
22
- it('extracts multiple fields from a single record', () => {
23
- const result = buildObservableEntries([
24
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
25
- ]);
26
- const types = result.map(a => a.type).sort();
27
- expect(types).toEqual(['ip', 'user']);
28
- });
29
- it('deduplicates the same asset value across multiple records', () => {
30
- const result = buildObservableEntries([
31
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
32
- createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
33
- ]);
34
- expect(result).toHaveLength(1);
35
- expect(result[0].seenIn).toEqual(['h1', 'obs1']);
36
- });
37
- it('keeps distinct asset values as separate entries', () => {
38
- const result = buildObservableEntries([
39
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
40
- createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
41
- ]);
42
- expect(result).toHaveLength(2);
43
- });
44
- it('does not duplicate seenIn ids when the same record appears twice for the same asset', () => {
45
- const result = buildObservableEntries([
46
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
47
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
48
- ]);
49
- expect(result[0].seenIn).toEqual(['h1']);
50
- });
51
- it('skips records with no howler.id', () => {
52
- const noId = { related: { ip: ['1.2.3.4'] } };
53
- expect(buildObservableEntries([noId])).toEqual([]);
54
- });
55
- it('handles the scalar `id` field on Related', () => {
56
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
57
- expect(result).toHaveLength(1);
58
- expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
59
- });
60
- it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
61
- const related = {
62
- hash: ['abc123'],
63
- hosts: ['host.example.com'],
64
- user: ['bob'],
65
- ids: ['guid-1'],
66
- uri: ['https://example.com'],
67
- signature: ['rule-X']
68
- };
69
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
70
- const types = result.map(a => a.type).sort();
71
- expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
72
- });
73
- });
74
8
  // ---------------------------------------------------------------------------
75
9
  // Component rendering tests
76
10
  // ---------------------------------------------------------------------------
@@ -81,14 +15,29 @@ vi.mock('react-i18next', () => ({
81
15
  vi.mock('components/hooks/useMyApi', () => ({
82
16
  default: () => ({ dispatchApi: mockDispatchApi })
83
17
  }));
18
+ vi.mock('api', () => ({
19
+ default: {
20
+ v2: {
21
+ search: {
22
+ post: vi.fn(() => Promise.resolve({ items: [] })),
23
+ facet: {
24
+ post: vi.fn(() => Promise.resolve({}))
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }));
84
30
  vi.mock('../hooks/useCase', () => ({
85
31
  default: ({ case: c }) => ({ case: c, updateCase: vi.fn(), loading: false, missing: false })
86
32
  }));
33
+ vi.mock('components/elements/PluginTypography', () => ({
34
+ default: ({ value }) => createElement('span', null, value)
35
+ }));
87
36
  const mockCase = {
88
37
  case_id: 'case-001',
89
38
  items: [
90
- { type: 'hit', value: 'hit-1' },
91
- { type: 'event', value: 'obs-1' }
39
+ { type: 'hit', value: 'hit-1', path: 'alerts/analytic-1 (hit-1)' },
40
+ { type: 'observable', value: 'obs-1', path: 'observables/obs-1' }
92
41
  ]
93
42
  };
94
43
  const Wrapper = ({ children }) => createElement(MemoryRouter, { initialEntries: ['/cases/case-001/observables'] }, children);
@@ -101,7 +50,6 @@ describe('CaseObservables component', () => {
101
50
  it('renders skeletons while records are loading', () => {
102
51
  mockDispatchApi.mockReturnValue(new Promise(() => { })); // never resolves
103
52
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
104
- // 6 skeleton cards
105
53
  const skeletons = document.querySelectorAll('.MuiSkeleton-root');
106
54
  expect(skeletons.length).toBeGreaterThan(0);
107
55
  });
@@ -112,34 +60,39 @@ describe('CaseObservables component', () => {
112
60
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
113
61
  await screen.findByText('page.cases.observables.empty');
114
62
  });
115
- it('renders observable cards for extracted observables', async () => {
63
+ it('renders observables in a table for extracted observables', async () => {
116
64
  mockDispatchApi.mockResolvedValue({
117
65
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
118
66
  });
119
67
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
120
68
  await screen.findByText('1.2.3.4');
121
69
  expect(screen.getByText('alice')).toBeTruthy();
70
+ // Table headers should be present
71
+ expect(screen.getByText('page.cases.observables.columns.type')).toBeTruthy();
72
+ expect(screen.getByText('page.cases.observables.columns.value')).toBeTruthy();
122
73
  });
123
- it('renders a filter chip for each observable type present', async () => {
74
+ it('renders a filter chip for each observable type present when popper is opened', async () => {
124
75
  mockDispatchApi.mockResolvedValue({
125
76
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
126
77
  });
127
78
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
128
- await screen.findAllByText('page.cases.observables.type.ip');
129
- expect(screen.getAllByText('page.cases.observables.type.ip')).toHaveLength(2);
130
- expect(screen.getAllByText('page.cases.observables.type.user')).toHaveLength(2);
79
+ await screen.findByText('1.2.3.4');
80
+ // Open the type filter popper
81
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
82
+ // Filter chips visible in popper + table = at least 2 of each type
83
+ expect(screen.getAllByText('page.cases.observables.type.ip').length).toBeGreaterThanOrEqual(2);
84
+ expect(screen.getAllByText('page.cases.observables.type.user').length).toBeGreaterThanOrEqual(2);
131
85
  });
132
86
  it('filters observables when a type chip is clicked', async () => {
133
87
  mockDispatchApi.mockResolvedValue({
134
88
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
135
89
  });
136
90
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
137
- // Wait for both observables to appear
138
91
  await screen.findByText('1.2.3.4');
139
92
  expect(screen.getByText('alice')).toBeTruthy();
140
- // Click the 'ip' filter chip
141
- await userEvent.click(screen.getByRole('button', { name: 'page.cases.observables.type.ip' }));
142
- // Alice (user) should be filtered out
93
+ // Open the type filter popper and click the first 'ip' chip (in the popper)
94
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
95
+ await userEvent.click(screen.getAllByText('page.cases.observables.type.ip')[0]);
143
96
  expect(screen.queryByText('alice')).toBeNull();
144
97
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
145
98
  });
@@ -149,16 +102,68 @@ describe('CaseObservables component', () => {
149
102
  });
150
103
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
151
104
  await screen.findByText('1.2.3.4');
152
- const ipChip = screen.getByRole('button', { name: 'page.cases.observables.type.ip' });
105
+ // Open the type filter popper
106
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
107
+ const ipChip = screen.getAllByText('page.cases.observables.type.ip')[0];
153
108
  await userEvent.click(ipChip);
154
109
  await userEvent.click(ipChip);
155
110
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
156
111
  expect(screen.getByText('alice')).toBeTruthy();
157
112
  });
158
- it('renders nothing when the case has no hit/event items', async () => {
113
+ it('renders nothing when the case has no hit/observable items', async () => {
159
114
  const emptyCase = { case_id: 'case-002', items: [] };
160
115
  render(_jsx(CaseObservables, { case: emptyCase }), { wrapper: Wrapper });
161
116
  await screen.findByText('page.cases.observables.empty');
162
117
  expect(mockDispatchApi).not.toHaveBeenCalled();
163
118
  });
119
+ it('filters observables by search query', async () => {
120
+ mockDispatchApi.mockResolvedValue({
121
+ items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
122
+ });
123
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
124
+ await screen.findByText('1.2.3.4');
125
+ const searchInput = screen.getByPlaceholderText('page.cases.observables.search');
126
+ await userEvent.type(searchInput, 'alice');
127
+ expect(screen.queryByText('1.2.3.4')).toBeNull();
128
+ expect(screen.getByText('alice')).toBeTruthy();
129
+ });
130
+ it('renders origin filter chips inside popper', async () => {
131
+ mockDispatchApi.mockResolvedValue({
132
+ items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'] } }]
133
+ });
134
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
135
+ await screen.findByText('1.2.3.4');
136
+ // Open the origin filter popper
137
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_origin'));
138
+ expect(screen.getByText('page.cases.observables.origin.hit')).toBeTruthy();
139
+ expect(screen.getByText('page.cases.observables.origin.event')).toBeTruthy();
140
+ });
141
+ it('renders escalation filter chips from facet response', async () => {
142
+ mockDispatchApi
143
+ .mockResolvedValueOnce({
144
+ items: [{ howler: { id: 'hit-1', escalation: 'evidence' }, related: { ip: ['1.2.3.4'] } }]
145
+ })
146
+ .mockResolvedValueOnce({
147
+ 'howler.escalation': { evidence: 1 }
148
+ });
149
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
150
+ await screen.findByText('1.2.3.4');
151
+ await waitFor(() => {
152
+ // 'evidence' appears in the table escalation column
153
+ expect(screen.getByText('evidence')).toBeTruthy();
154
+ });
155
+ });
156
+ it('classifies roles and renders role chips in the table', async () => {
157
+ mockDispatchApi.mockResolvedValue({
158
+ items: [
159
+ {
160
+ howler: { id: 'hit-1', outline: { threat: 'malware.exe' } },
161
+ related: { hosts: ['malware.exe'] }
162
+ }
163
+ ]
164
+ });
165
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
166
+ await screen.findByText('malware.exe');
167
+ expect(screen.getByText('page.cases.observables.role.threat')).toBeTruthy();
168
+ });
164
169
  });
@@ -1,14 +1,8 @@
1
1
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
2
  import type { FC } from 'react';
3
- export type ObservableType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
4
- export interface ObservableEntry {
5
- type: ObservableType;
6
- value: string;
7
- /** IDs of the hits/events this observable was seen in */
8
- seenIn: string[];
9
- }
3
+ import type { ObservableEntry } from '../types';
10
4
  declare const Observable: FC<{
11
- asset: ObservableEntry;
5
+ observable: ObservableEntry;
12
6
  case: Case;
13
7
  }>;
14
8
  export default Observable;
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Card, CardContent, Chip, Stack, Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- const Observable = ({ asset, case: _case }) => {
5
+ const Observable = ({ observable, case: _case }) => {
6
6
  const { t } = useTranslation();
7
- return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${asset.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: asset.value })] }), asset.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: asset.seenIn.map(id => {
7
+ return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: observable.value })] }), observable.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: observable.seenIn.map(id => {
8
8
  const entry = _case.items.find(item => item.value === id);
9
9
  return (_jsx(Chip, { clickable: true, size: "small", label: entry.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${entry.path}` }, id));
10
10
  }) })] }))] }) }) }));
@@ -4,7 +4,7 @@ import { render, screen } from '@testing-library/react';
4
4
  import { MemoryRouter } from 'react-router-dom';
5
5
  import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
6
6
  import { describe, expect, it } from 'vitest';
7
- import Observable, {} from './Observable';
7
+ import Observable from './Observable';
8
8
  const makeObservable = (overrides = {}) => ({
9
9
  type: 'ip',
10
10
  value: '192.168.1.1',
@@ -16,7 +16,7 @@ describe('Observable', () => {
16
16
  it('renders the correct label for each type', () => {
17
17
  const cases = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
18
18
  for (const type of cases) {
19
- const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ type, value: 'x' }), case: createMockCase() }) }));
19
+ const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ type, value: 'x' }), case: createMockCase() }) }));
20
20
  expect(screen.getByText(`page.cases.observables.type.${type}`)).toBeTruthy();
21
21
  unmount();
22
22
  }
@@ -24,25 +24,25 @@ describe('Observable', () => {
24
24
  });
25
25
  describe('value display', () => {
26
26
  it('renders the asset value', () => {
27
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ value: '10.0.0.1' }), case: createMockCase() }) }));
27
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ value: '10.0.0.1' }), case: createMockCase() }) }));
28
28
  expect(screen.getByText('10.0.0.1')).toBeTruthy();
29
29
  });
30
30
  it('renders long hash values without truncation', () => {
31
31
  const hash = 'a'.repeat(64);
32
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ type: 'hash', value: hash }), case: createMockCase() }) }));
32
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ type: 'hash', value: hash }), case: createMockCase() }) }));
33
33
  expect(screen.getByText(hash)).toBeTruthy();
34
34
  });
35
35
  });
36
36
  describe('seen-in chips', () => {
37
37
  it('renders nothing when seenIn is empty', () => {
38
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
38
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
39
39
  expect(screen.queryByText('page.cases.observables.seen_in')).toBeNull();
40
40
  });
41
41
  it('renders "Seen in" label when seenIn has entries', () => {
42
42
  const _case = createMockCase({
43
43
  items: [{ path: 'alerts/test-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
44
44
  });
45
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
45
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
46
46
  expect(screen.getByText('page.cases.observables.seen_in')).toBeTruthy();
47
47
  });
48
48
  it('renders a chip labelled with entry.path for each seenIn id', () => {
@@ -53,7 +53,7 @@ describe('Observable', () => {
53
53
  { path: 'alerts/other-analytic (hit-003)', type: 'hit', value: 'hit-003' }
54
54
  ]
55
55
  });
56
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
56
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
57
57
  expect(screen.getByText('alerts/my-analytic (hit-001)')).toBeTruthy();
58
58
  expect(screen.getByText('events/obs-002')).toBeTruthy();
59
59
  expect(screen.getByText('alerts/other-analytic (hit-003)')).toBeTruthy();
@@ -63,7 +63,7 @@ describe('Observable', () => {
63
63
  case_id: 'case-abc',
64
64
  items: [{ path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
65
65
  });
66
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
66
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
67
67
  const link = screen.getByText('alerts/my-analytic (hit-001)').closest('a');
68
68
  expect(link).not.toBeNull();
69
69
  expect(link?.getAttribute('href')).toBe('/cases/case-abc/alerts/my-analytic (hit-001)');
@@ -0,0 +1,7 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { ObservableEntry } from '../types';
3
+ declare const _default: import("react").NamedExoticComponent<{
4
+ observables: ObservableEntry[];
5
+ case: Case;
6
+ }>;
7
+ export default _default;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Chip, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Typography } from '@mui/material';
3
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
4
+ import PluginTypography from '@cccsaurora/howler-ui/components/elements/PluginTypography';
5
+ import { memo, useMemo, useState } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { Link } from 'react-router-dom';
8
+ import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
9
+ const ROLE_COLORS = {
10
+ threat: 'error',
11
+ target: 'warning',
12
+ indicator: 'info'
13
+ };
14
+ const getSortValue = (observable, column) => {
15
+ switch (column) {
16
+ case 'type':
17
+ return observable.type;
18
+ case 'value':
19
+ return observable.value.toLowerCase();
20
+ case 'role':
21
+ return observable.role ?? '';
22
+ case 'seen_in':
23
+ return observable.seenIn.length;
24
+ case 'escalation':
25
+ return (observable.sources ?? [])
26
+ .map(s => s.escalation)
27
+ .filter(Boolean)
28
+ .join(',');
29
+ default:
30
+ return '';
31
+ }
32
+ };
33
+ const ObservableTable = ({ observables, case: _case }) => {
34
+ const { t } = useTranslation();
35
+ const [sortColumn, setSortColumn] = useState('type');
36
+ const [sortDirection, setSortDirection] = useState('asc');
37
+ const handleSort = (column) => {
38
+ if (sortColumn === column) {
39
+ setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'));
40
+ }
41
+ else {
42
+ setSortColumn(column);
43
+ setSortDirection('asc');
44
+ }
45
+ };
46
+ const sortedObservables = useMemo(() => {
47
+ return [...observables].sort((a, b) => {
48
+ const aVal = getSortValue(a, sortColumn);
49
+ const bVal = getSortValue(b, sortColumn);
50
+ const cmp = typeof aVal === 'number' && typeof bVal === 'number' ? aVal - bVal : String(aVal).localeCompare(String(bVal));
51
+ return sortDirection === 'asc' ? cmp : -cmp;
52
+ });
53
+ }, [observables, sortColumn, sortDirection]);
54
+ return (_jsx(TableContainer, { children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { sortDirection: sortColumn === 'type' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'type', direction: sortColumn === 'type' ? sortDirection : 'asc', onClick: () => handleSort('type'), children: t('page.cases.observables.columns.type') }) }), _jsx(TableCell, { sortDirection: sortColumn === 'value' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'value', direction: sortColumn === 'value' ? sortDirection : 'asc', onClick: () => handleSort('value'), children: t('page.cases.observables.columns.value') }) }), _jsx(TableCell, { sortDirection: sortColumn === 'role' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'role', direction: sortColumn === 'role' ? sortDirection : 'asc', onClick: () => handleSort('role'), children: t('page.cases.observables.columns.role') }) }), _jsx(TableCell, { align: "right", sortDirection: sortColumn === 'seen_in' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'seen_in', direction: sortColumn === 'seen_in' ? sortDirection : 'asc', onClick: () => handleSort('seen_in'), children: t('page.cases.observables.columns.seen_in') }) }), _jsx(TableCell, { children: t('page.cases.observables.columns.sources') }), _jsx(TableCell, { sortDirection: sortColumn === 'escalation' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'escalation', direction: sortColumn === 'escalation' ? sortDirection : 'asc', onClick: () => handleSort('escalation'), children: t('page.cases.observables.columns.escalation') }) })] }) }), _jsx(TableBody, { children: sortedObservables.map(observable => {
55
+ const sources = observable.sources ?? [];
56
+ const escalations = [...new Set(sources.map(s => s.escalation).filter(Boolean))];
57
+ const linkedSources = sources.filter(source => source.path);
58
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }) }), _jsx(TableCell, { sx: { maxWidth: 300 }, children: _jsx(PluginTypography, { value: observable.value, context: "table", variant: "body2", sx: { fontFamily: 'monospace', wordBreak: 'break-all' } }) }), _jsx(TableCell, { children: observable.role && (_jsx(Chip, { size: "small", label: t(`page.cases.observables.role.${observable.role}`), color: ROLE_COLORS[observable.role], variant: "outlined" })) }), _jsx(TableCell, { align: "right", children: _jsx(Typography, { variant: "body2", children: observable.seenIn.length }) }), _jsx(TableCell, { children: linkedSources.length === 0 ? null : linkedSources.length === 1 ? (_jsx(Chip, { clickable: true, size: "small", label: linkedSources[0].path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${linkedSources[0].path}` })) : (_jsx(ChipPopper, { label: `${linkedSources[0].path} (+${linkedSources.length - 1})`, slotProps: { chip: { size: 'small', variant: 'outlined' } }, children: _jsx(Stack, { gap: 0.5, children: linkedSources.map(source => (_jsx(Chip, { clickable: true, size: "small", label: source.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id))) }) })) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: escalations.map(esc => (_jsx(Chip, { size: "small", label: esc, color: ESCALATION_COLORS[esc] ?? 'default' }, esc))) }) })] }, `${observable.type}:${observable.value}`));
59
+ }) })] }) }));
60
+ };
61
+ export default memo(ObservableTable);
@@ -0,0 +1,23 @@
1
+ export type ObservableType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
2
+
3
+ export type ObservableRole = 'threat' | 'target' | 'indicator';
4
+
5
+ export interface ObservableSource {
6
+ id: string;
7
+ type: 'hit' | 'observable' | 'case';
8
+ path?: string;
9
+ escalation?: string;
10
+ }
11
+
12
+ export interface ObservableEntry {
13
+ type: ObservableType;
14
+ value: string;
15
+ /** IDs of the hits/observables this observable was seen in */
16
+ seenIn: string[];
17
+ /** Resolved source metadata for each seenIn item */
18
+ sources?: ObservableSource[];
19
+ /** Classified role of this observable */
20
+ role?: ObservableRole;
21
+ }
22
+
23
+ export type OriginType = 'hit' | 'observable';
@@ -0,0 +1,29 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
3
+ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
4
+ import type { Related } from '@cccsaurora/howler-ui/models/entities/generated/Related';
5
+ import { type ObservableEntry, type ObservableRole, type ObservableSource, type ObservableType } from './types';
6
+ /** All Related fields that carry asset values */
7
+ export declare const OBSERVABLE_FIELDS: ObservableType[];
8
+ /** Extract (type, value, seenInId) triples from a record's related field */
9
+ export declare const extractObservables: (related: Related | undefined, recordId: string) => {
10
+ type: ObservableType;
11
+ value: string;
12
+ id: string;
13
+ }[];
14
+ /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
15
+ export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
16
+ /**
17
+ * Classify an asset's role based on case-level lists and per-record outline fields.
18
+ *
19
+ * Resolution order:
20
+ * 1. Case-level `threats[]`, `targets[]`, `indicators[]` (authoritative).
21
+ * 2. Per-record `outline.threat` / `outline.target` (exact match).
22
+ * 3. Per-record `outline.indicators[]`, `threat.indicator.ip/.description`.
23
+ * 4. Default: "indicator" — all assets come from `related.*` fields which are IOCs by nature.
24
+ *
25
+ * Comparison is case-insensitive and trimmed.
26
+ */
27
+ export declare const classifyRole: (value: string, _case: Case, records: Partial<Hit | Event>[]) => ObservableRole;
28
+ /** Resolve source metadata for an asset's seenIn IDs */
29
+ export declare const resolveSources: (seenIn: string[], caseItems: Case["items"], escalationMap: Map<string, string>) => ObservableSource[];
@@ -0,0 +1,114 @@
1
+ import {} from './types';
2
+ /** All Related fields that carry asset values */
3
+ export const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
4
+ /** Extract (type, value, seenInId) triples from a record's related field */
5
+ export const extractObservables = (related, recordId) => {
6
+ if (!related) {
7
+ return [];
8
+ }
9
+ const results = [];
10
+ for (const field of OBSERVABLE_FIELDS) {
11
+ const raw = related[field];
12
+ if (!raw) {
13
+ continue;
14
+ }
15
+ const values = Array.isArray(raw) ? raw : [raw];
16
+ for (const value of values) {
17
+ if (value) {
18
+ results.push({ type: field, value: String(value), id: recordId });
19
+ }
20
+ }
21
+ }
22
+ return results;
23
+ };
24
+ /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
25
+ export const buildObservableEntries = (records) => {
26
+ const map = new Map();
27
+ for (const record of records) {
28
+ const related = record.related ?? record.related;
29
+ const recordId = record.howler?.id ?? record.howler?.id;
30
+ if (!recordId) {
31
+ continue;
32
+ }
33
+ for (const { type, value, id } of extractObservables(related, recordId)) {
34
+ const key = `${type}:${value}`;
35
+ if (!map.has(key)) {
36
+ map.set(key, { type, value, seenIn: [] });
37
+ }
38
+ const entry = map.get(key);
39
+ if (!entry.seenIn.includes(id)) {
40
+ entry.seenIn.push(id);
41
+ }
42
+ }
43
+ }
44
+ return Array.from(map.values());
45
+ };
46
+ /**
47
+ * Classify an asset's role based on case-level lists and per-record outline fields.
48
+ *
49
+ * Resolution order:
50
+ * 1. Case-level `threats[]`, `targets[]`, `indicators[]` (authoritative).
51
+ * 2. Per-record `outline.threat` / `outline.target` (exact match).
52
+ * 3. Per-record `outline.indicators[]`, `threat.indicator.ip/.description`.
53
+ * 4. Default: "indicator" — all assets come from `related.*` fields which are IOCs by nature.
54
+ *
55
+ * Comparison is case-insensitive and trimmed.
56
+ */
57
+ export const classifyRole = (value, _case, records) => {
58
+ const normalized = value.trim().toLowerCase();
59
+ // Case-level classification (most authoritative)
60
+ if (_case.threats?.some(t => String(t).trim().toLowerCase() === normalized)) {
61
+ return 'threat';
62
+ }
63
+ if (_case.targets?.some(t => String(t).trim().toLowerCase() === normalized)) {
64
+ return 'target';
65
+ }
66
+ if (_case.indicators?.some(i => String(i).trim().toLowerCase() === normalized)) {
67
+ return 'indicator';
68
+ }
69
+ // Per-record outline checks
70
+ for (const record of records) {
71
+ const howlerOutline = record.howler?.outline;
72
+ if (howlerOutline) {
73
+ if (howlerOutline.threat && howlerOutline.threat.trim().toLowerCase() === normalized) {
74
+ return 'threat';
75
+ }
76
+ if (howlerOutline.target && howlerOutline.target.trim().toLowerCase() === normalized) {
77
+ return 'target';
78
+ }
79
+ if (howlerOutline.indicators?.some(ind => ind.trim().toLowerCase() === normalized)) {
80
+ return 'indicator';
81
+ }
82
+ }
83
+ const indicator = record.threat?.indicator;
84
+ if (indicator) {
85
+ const indicatorIp = indicator.ip?.trim().toLowerCase();
86
+ const indicatorDesc = indicator.description?.trim().toLowerCase();
87
+ if ((indicatorIp && indicatorIp === normalized) || (indicatorDesc && indicatorDesc === normalized)) {
88
+ return 'indicator';
89
+ }
90
+ }
91
+ }
92
+ // Default: assets from related.* are IOCs
93
+ return 'indicator';
94
+ };
95
+ /** Resolve source metadata for an asset's seenIn IDs */
96
+ export const resolveSources = (seenIn, caseItems, escalationMap) => {
97
+ if (!caseItems?.length) {
98
+ return [];
99
+ }
100
+ return seenIn
101
+ .map(id => {
102
+ const item = caseItems.find(i => i.value === id);
103
+ if (!item) {
104
+ return null;
105
+ }
106
+ return {
107
+ id,
108
+ type: item.type,
109
+ path: item.path,
110
+ escalation: escalationMap.get(id)
111
+ };
112
+ })
113
+ .filter(Boolean);
114
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,144 @@
1
+ import { createMockCase, createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildObservableEntries, classifyRole } from './utils';
4
+ // ---------------------------------------------------------------------------
5
+ // Pure logic tests — no React needed
6
+ // ---------------------------------------------------------------------------
7
+ describe('buildObservableEntries', () => {
8
+ it('returns an empty array for records with no related field', () => {
9
+ expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
10
+ });
11
+ it('extracts a single IP from a hit', () => {
12
+ const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
13
+ expect(result).toHaveLength(1);
14
+ expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
15
+ });
16
+ it('extracts multiple fields from a single record', () => {
17
+ const result = buildObservableEntries([
18
+ createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
19
+ ]);
20
+ const types = result.map(a => a.type).sort();
21
+ expect(types).toEqual(['ip', 'user']);
22
+ });
23
+ it('deduplicates the same observable value across multiple records', () => {
24
+ const result = buildObservableEntries([
25
+ createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
26
+ createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
27
+ ]);
28
+ expect(result).toHaveLength(1);
29
+ expect(result[0].seenIn).toEqual(['h1', 'obs1']);
30
+ });
31
+ it('keeps distinct observable values as separate entries', () => {
32
+ const result = buildObservableEntries([
33
+ createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
34
+ createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
35
+ ]);
36
+ expect(result).toHaveLength(2);
37
+ });
38
+ it('does not duplicate seenIn ids when the same record appears twice for the same observable', () => {
39
+ const result = buildObservableEntries([
40
+ createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
41
+ createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
42
+ ]);
43
+ expect(result[0].seenIn).toEqual(['h1']);
44
+ });
45
+ it('skips records with no howler.id', () => {
46
+ const noId = { related: { ip: ['1.2.3.4'] } };
47
+ expect(buildObservableEntries([noId])).toEqual([]);
48
+ });
49
+ it('handles the scalar `id` field on Related', () => {
50
+ const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
51
+ expect(result).toHaveLength(1);
52
+ expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
53
+ });
54
+ it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
55
+ const related = {
56
+ hash: ['abc123'],
57
+ hosts: ['host.example.com'],
58
+ user: ['bob'],
59
+ ids: ['guid-1'],
60
+ uri: ['https://example.com'],
61
+ signature: ['rule-X']
62
+ };
63
+ const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
64
+ const types = result.map(a => a.type).sort();
65
+ expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
66
+ });
67
+ });
68
+ // ---------------------------------------------------------------------------
69
+ // classifyRole tests
70
+ // ---------------------------------------------------------------------------
71
+ describe('classifyRole', () => {
72
+ const emptyCase = createMockCase();
73
+ it('returns "threat" when value is in case.threats', () => {
74
+ const _case = createMockCase({ threats: ['malicious.exe'] });
75
+ const records = [createMockHit({ howler: { id: 'h1' } })];
76
+ expect(classifyRole('malicious.exe', _case, records)).toBe('threat');
77
+ });
78
+ it('returns "target" when value is in case.targets', () => {
79
+ const _case = createMockCase({ targets: ['server-01'] });
80
+ const records = [createMockHit({ howler: { id: 'h1' } })];
81
+ expect(classifyRole('server-01', _case, records)).toBe('target');
82
+ });
83
+ it('returns "indicator" when value is in case.indicators', () => {
84
+ const _case = createMockCase({ indicators: ['1.2.3.4'] });
85
+ const records = [createMockHit({ howler: { id: 'h1' } })];
86
+ expect(classifyRole('1.2.3.4', _case, records)).toBe('indicator');
87
+ });
88
+ it('returns "threat" when value matches howler.outline.threat', () => {
89
+ const records = [createMockHit({ howler: { id: 'h1', outline: { threat: 'malicious.exe' } } })];
90
+ expect(classifyRole('malicious.exe', emptyCase, records)).toBe('threat');
91
+ });
92
+ it('returns "threat" with case-insensitive matching', () => {
93
+ const records = [createMockHit({ howler: { id: 'h1', outline: { threat: 'Malicious.EXE' } } })];
94
+ expect(classifyRole('malicious.exe', emptyCase, records)).toBe('threat');
95
+ });
96
+ it('does not match threat by substring — only exact match', () => {
97
+ const records = [createMockHit({ howler: { id: 'h1', outline: { threat: 'evil.com dropped payload' } } })];
98
+ // No substring match, defaults to indicator
99
+ expect(classifyRole('evil.com', emptyCase, records)).toBe('indicator');
100
+ });
101
+ it('classifies as indicator when value is in outline.indicators but not in threat/target', () => {
102
+ const records = [
103
+ createMockHit({
104
+ howler: { id: 'h1', outline: { threat: 'actor-x', target: 'server-01', indicators: ['1.2.3.4'] } }
105
+ })
106
+ ];
107
+ expect(classifyRole('1.2.3.4', emptyCase, records)).toBe('indicator');
108
+ });
109
+ it('returns "target" when value matches howler.outline.target', () => {
110
+ const records = [createMockHit({ howler: { id: 'h1', outline: { target: 'server-01' } } })];
111
+ expect(classifyRole('server-01', emptyCase, records)).toBe('target');
112
+ });
113
+ it('returns "indicator" when value matches threat.indicator.ip', () => {
114
+ const records = [
115
+ createMockHit({
116
+ howler: { id: 'h1' },
117
+ threat: { indicator: { ip: '10.0.0.1' } }
118
+ })
119
+ ];
120
+ expect(classifyRole('10.0.0.1', emptyCase, records)).toBe('indicator');
121
+ });
122
+ it('returns "indicator" when value matches threat.indicator.description', () => {
123
+ const records = [
124
+ createMockHit({
125
+ howler: { id: 'h1' },
126
+ threat: { indicator: { description: 'suspicious-hash-abc123' } }
127
+ })
128
+ ];
129
+ expect(classifyRole('suspicious-hash-abc123', emptyCase, records)).toBe('indicator');
130
+ });
131
+ it('defaults to "indicator" when value has no specific classification', () => {
132
+ const records = [createMockHit({ howler: { id: 'h1' }, related: { ip: ['5.6.7.8'] } })];
133
+ expect(classifyRole('5.6.7.8', emptyCase, records)).toBe('indicator');
134
+ });
135
+ it('case-level threats take priority over per-record outline.target', () => {
136
+ const _case = createMockCase({ threats: ['dual-use.exe'] });
137
+ const records = [createMockHit({ howler: { id: 'h1', outline: { target: 'dual-use.exe' } } })];
138
+ expect(classifyRole('dual-use.exe', _case, records)).toBe('threat');
139
+ });
140
+ it('trims whitespace before comparing', () => {
141
+ const records = [createMockHit({ howler: { id: 'h1', outline: { threat: ' evil.com ' } } })];
142
+ expect(classifyRole('evil.com', emptyCase, records)).toBe('threat');
143
+ });
144
+ });
@@ -414,8 +414,23 @@
414
414
  "page.cases.escalation": "Escalation",
415
415
  "page.cases.folder.drop.root": "Place here to move to root",
416
416
  "page.cases.observables": "Observables",
417
+ "page.cases.observables.columns.escalation": "Escalation",
418
+ "page.cases.observables.columns.role": "Role",
419
+ "page.cases.observables.columns.seen_in": "Seen In",
420
+ "page.cases.observables.columns.sources": "Sources",
421
+ "page.cases.observables.columns.type": "Type",
422
+ "page.cases.observables.columns.value": "Value",
417
423
  "page.cases.observables.empty": "No observables found.",
424
+ "page.cases.observables.filter_by_escalation": "Escalation",
425
+ "page.cases.observables.filter_by_origin": "Origin",
426
+ "page.cases.observables.filter_by_role": "Role",
418
427
  "page.cases.observables.filter_by_type": "Filter by type:",
428
+ "page.cases.observables.origin.event": "Event",
429
+ "page.cases.observables.origin.hit": "Hit",
430
+ "page.cases.observables.role.indicator": "Indicator",
431
+ "page.cases.observables.role.target": "Target",
432
+ "page.cases.observables.role.threat": "Threat",
433
+ "page.cases.observables.search": "Search observables...",
419
434
  "page.cases.observables.seen_in": "Seen in",
420
435
  "page.cases.observables.type.hash": "Hash",
421
436
  "page.cases.observables.type.hosts": "Host",
@@ -414,8 +414,23 @@
414
414
  "page.cases.escalation": "Escalade",
415
415
  "page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
416
416
  "page.cases.observables": "Observables",
417
+ "page.cases.observables.columns.escalation": "Escalade",
418
+ "page.cases.observables.columns.role": "Rôle",
419
+ "page.cases.observables.columns.seen_in": "Vu dans",
420
+ "page.cases.observables.columns.sources": "Sources",
421
+ "page.cases.observables.columns.type": "Type",
422
+ "page.cases.observables.columns.value": "Valeur",
417
423
  "page.cases.observables.empty": "Aucun observable trouvé.",
424
+ "page.cases.observables.filter_by_escalation": "Escalade",
425
+ "page.cases.observables.filter_by_origin": "Origine",
426
+ "page.cases.observables.filter_by_role": "Rôle",
418
427
  "page.cases.observables.filter_by_type": "Filtrer par type :",
428
+ "page.cases.observables.origin.event": "Événement",
429
+ "page.cases.observables.origin.hit": "Alerte",
430
+ "page.cases.observables.role.indicator": "Indicateur",
431
+ "page.cases.observables.role.target": "Cible",
432
+ "page.cases.observables.role.threat": "Menace",
433
+ "page.cases.observables.search": "Rechercher des observables...",
419
434
  "page.cases.observables.seen_in": "Vu dans",
420
435
  "page.cases.observables.type.hash": "Hachage",
421
436
  "page.cases.observables.type.hosts": "Hôte",
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.19.0-cases.1129",
99
+ "version": "2.19.0-cases.1131",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",