@cccsaurora/howler-ui 2.19.0-cases.1122 → 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.
- package/components/elements/display/QueryResultText.d.ts +1 -0
- package/components/elements/display/QueryResultText.js +2 -2
- package/components/routes/cases/detail/CaseObservables.d.ts +0 -5
- package/components/routes/cases/detail/CaseObservables.js +101 -63
- package/components/routes/cases/detail/CaseObservables.test.js +86 -81
- package/components/routes/cases/detail/CaseRules.js +5 -7
- package/components/routes/cases/detail/CaseRules.test.js +18 -1
- package/components/routes/cases/detail/CreateRuleDialog.js +37 -55
- package/components/routes/cases/detail/CreateRuleDialog.test.js +47 -22
- package/components/routes/cases/detail/observables/Observable.d.ts +2 -8
- package/components/routes/cases/detail/observables/Observable.js +2 -2
- package/components/routes/cases/detail/observables/Observable.test.js +8 -8
- package/components/routes/cases/detail/observables/ObservableTable.d.ts +7 -0
- package/components/routes/cases/detail/observables/ObservableTable.js +61 -0
- package/components/routes/cases/detail/types.d.ts +23 -0
- package/components/routes/cases/detail/utils.d.ts +29 -0
- package/components/routes/cases/detail/utils.js +114 -0
- package/components/routes/cases/detail/utils.test.d.ts +1 -0
- package/components/routes/cases/detail/utils.test.js +144 -0
- package/locales/en/translation.json +33 -13
- package/locales/fr/translation.json +33 -13
- package/models/entities/generated/Rule.d.ts +3 -1
- package/package.json +1 -1
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMonaco } from '@monaco-editor/react';
|
|
3
|
-
import { Search } from '@mui/icons-material';
|
|
4
|
-
import { Box, Button, Card,
|
|
3
|
+
import { CheckCircleOutline, FilterList, RadioButtonUnchecked, Search } from '@mui/icons-material';
|
|
4
|
+
import { Autocomplete, Box, Button, Card, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, TextField, Tooltip, Typography } from '@mui/material';
|
|
5
5
|
import api from '@cccsaurora/howler-ui/api';
|
|
6
|
+
import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
|
|
6
7
|
import QueryResultText from '@cccsaurora/howler-ui/components/elements/display/QueryResultText';
|
|
7
8
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
9
|
import QueryEditor from '@cccsaurora/howler-ui/components/routes/advanced/QueryEditor';
|
|
9
|
-
import dayjs, {} from 'dayjs';
|
|
10
10
|
import { useCallback, useEffect, useState } from 'react';
|
|
11
11
|
import { useTranslation } from 'react-i18next';
|
|
12
|
-
import { LocalizationProvider } from '@mui/x-date-pickers';
|
|
13
|
-
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
|
14
|
-
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
|
15
12
|
const DEFAULT_TIMEFRAME_DAYS = 14;
|
|
16
13
|
const PROMPT_CONTEXT = 'isRecordQuery && !suggestWidgetVisible && !renameInputVisible && !inSnippetMode && !quickFixWidgetVisible';
|
|
17
14
|
const OPTIONS = {
|
|
@@ -26,6 +23,15 @@ const OPTIONS = {
|
|
|
26
23
|
renderLineHighlight: 'none',
|
|
27
24
|
overviewRulerLanes: 0
|
|
28
25
|
};
|
|
26
|
+
const INDEX_OPTIONS = ['hit', 'event'];
|
|
27
|
+
const Subtitle = ({ i18nKey }) => {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
return (_jsx(Typography, { sx: theme => ({
|
|
30
|
+
color: theme.palette.text.secondary,
|
|
31
|
+
fontSize: '0.9em',
|
|
32
|
+
fontStyle: 'italic'
|
|
33
|
+
}), variant: "body2", children: t(i18nKey) }));
|
|
34
|
+
};
|
|
29
35
|
const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
|
|
30
36
|
const { t } = useTranslation();
|
|
31
37
|
const { dispatchApi } = useMyApi();
|
|
@@ -33,8 +39,9 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
|
|
|
33
39
|
const [query, setQuery] = useState('');
|
|
34
40
|
const [destination, setDestination] = useState('');
|
|
35
41
|
const [indexes, setIndexes] = useState(['hit']);
|
|
36
|
-
const [
|
|
37
|
-
const [
|
|
42
|
+
const [timeframeDays, setTimeframeDays] = useState(DEFAULT_TIMEFRAME_DAYS);
|
|
43
|
+
const [hasExpiry, setHasExpiry] = useState(true);
|
|
44
|
+
const [expireAfterResolved, setExpireAfterResolved] = useState(false);
|
|
38
45
|
const [loading, setLoading] = useState(false);
|
|
39
46
|
const [searching, setSearching] = useState(false);
|
|
40
47
|
const [response, setResponse] = useState(null);
|
|
@@ -42,8 +49,9 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
|
|
|
42
49
|
setQuery('');
|
|
43
50
|
setDestination('');
|
|
44
51
|
setIndexes(['hit']);
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
setTimeframeDays(DEFAULT_TIMEFRAME_DAYS);
|
|
53
|
+
setHasExpiry(true);
|
|
54
|
+
setExpireAfterResolved(false);
|
|
47
55
|
setResponse(null);
|
|
48
56
|
}, []);
|
|
49
57
|
const handleSearch = useCallback(async () => {
|
|
@@ -73,7 +81,8 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
|
|
|
73
81
|
await onSubmit({
|
|
74
82
|
query: query.trim(),
|
|
75
83
|
destination: destination.trim(),
|
|
76
|
-
timeframe:
|
|
84
|
+
timeframe: hasExpiry ? timeframeDays : null,
|
|
85
|
+
expire_after_resolved: hasExpiry ? expireAfterResolved : false,
|
|
77
86
|
indexes
|
|
78
87
|
});
|
|
79
88
|
onClose();
|
|
@@ -81,7 +90,7 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
|
|
|
81
90
|
finally {
|
|
82
91
|
setLoading(false);
|
|
83
92
|
}
|
|
84
|
-
}, [query, destination,
|
|
93
|
+
}, [query, destination, hasExpiry, timeframeDays, expireAfterResolved, indexes, onSubmit, onClose]);
|
|
85
94
|
const onMount = useCallback((ed) => {
|
|
86
95
|
ed.createContextKey('isRecordQuery', true);
|
|
87
96
|
}, []);
|
|
@@ -116,48 +125,21 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
|
|
|
116
125
|
}, [monaco, handleSearch]);
|
|
117
126
|
return (_jsxs(Dialog, { open: open, onClose: onClose, maxWidth: "md", fullWidth: true, id: "create-rule-dialog", TransitionProps: { onEnter: handleOpen }, PaperProps: {
|
|
118
127
|
elevation: 0
|
|
119
|
-
}, children: [_jsx(DialogTitle, { children: t('page.cases.rules.create') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
fontStyle: 'italic',
|
|
136
|
-
mt: 0.5
|
|
137
|
-
}), variant: "body2", children: t('hit.search.prompt') }))] }), _jsx(TextField, { id: "rule-destination-input", label: t('page.cases.rules.destination'), value: destination, onChange: e => setDestination(e.target.value), fullWidth: true, placeholder: "alerts/{{howler.analytic}}", helperText: t('page.cases.rules.destination.help'), size: "small" }), _jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('page.cases.rules.indexes') }), _jsxs(FormGroup, { row: true, children: [_jsx(FormControlLabel, { control: _jsx(Checkbox, { id: "rule-index-hit", checked: indexes.includes('hit'), onChange: (_e, checked) => {
|
|
138
|
-
setIndexes(prev => {
|
|
139
|
-
if (checked) {
|
|
140
|
-
return prev.includes('hit') ? prev : [...prev, 'hit'];
|
|
141
|
-
}
|
|
142
|
-
return prev.filter(i => i !== 'hit');
|
|
143
|
-
});
|
|
144
|
-
} }), label: t('hit.search.index.hit') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { id: "rule-index-event", checked: indexes.includes('event'), onChange: (_e, checked) => {
|
|
145
|
-
setIndexes(prev => {
|
|
146
|
-
if (checked) {
|
|
147
|
-
return prev.includes('event') ? prev : [...prev, 'event'];
|
|
148
|
-
}
|
|
149
|
-
return prev.filter(i => i !== 'event');
|
|
150
|
-
});
|
|
151
|
-
} }), label: t('hit.search.index.event') })] })] }), _jsxs(Stack, { direction: "row", spacing: 2, alignItems: "center", children: [_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsx(DateTimePicker, { slotProps: {
|
|
152
|
-
textField: {
|
|
153
|
-
id: 'rule-timeframe-input',
|
|
154
|
-
fullWidth: true,
|
|
155
|
-
size: 'small'
|
|
156
|
-
}
|
|
157
|
-
}, label: t('page.cases.rules.timeframe'), value: timeframe, onChange: nv => {
|
|
158
|
-
if (nv) {
|
|
159
|
-
setTimeframe(nv);
|
|
160
|
-
}
|
|
161
|
-
}, disabled: noExpiry, ampm: false, disablePast: true, sx: { flex: 1 } }) }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { id: "rule-no-expiry-checkbox", checked: noExpiry, onChange: (_e, checked) => setNoExpiry(checked) }), label: t('page.cases.rules.no_expiry') })] })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: onClose, children: t('cancel') }), _jsx(Button, { id: "rule-submit-button", variant: "contained", onClick: handleSubmit, disabled: !response || loading || !query.trim() || !destination.trim() || indexes.length === 0, children: t('page.cases.rules.create') })] })] }));
|
|
128
|
+
}, children: [_jsx(DialogTitle, { children: t('page.cases.rules.create') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 3, sx: { mt: 1 }, children: [_jsxs(Box, { children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('page.cases.rules.query') }), _jsx(ChipPopper, { icon: _jsx(FilterList, { fontSize: "small" }), label: indexes.map(opt => t(`hit.search.index.${opt}`)).join(', '), minWidth: "225px", slotProps: { chip: { size: 'small' } }, children: _jsx(Autocomplete, { size: "small", multiple: true, options: INDEX_OPTIONS, value: indexes, onChange: (_ev, values) => values.length > 0 && setIndexes(values), getOptionLabel: opt => t(`hit.search.index.${opt}`), renderInput: params => _jsx(TextField, { ...params }) }) })] }), _jsx(Stack, { direction: "row", alignItems: "center", spacing: 1, children: _jsx(Box, { sx: { border: 1, borderColor: 'divider', borderRadius: 1, flex: 1 }, children: _jsxs(Card, { variant: "outlined", sx: theme => ({
|
|
129
|
+
width: '100%',
|
|
130
|
+
height: theme.spacing(5),
|
|
131
|
+
py: 1,
|
|
132
|
+
pl: 1,
|
|
133
|
+
pr: 0.5,
|
|
134
|
+
position: 'relative',
|
|
135
|
+
overflow: 'visible',
|
|
136
|
+
display: 'flex',
|
|
137
|
+
alignItems: 'center',
|
|
138
|
+
justifyContent: 'space-between',
|
|
139
|
+
'& .monaco-editor': {
|
|
140
|
+
position: 'absolute !important'
|
|
141
|
+
},
|
|
142
|
+
transition: theme.transitions.create('border-color')
|
|
143
|
+
}), onKeyDown: e => e.stopPropagation(), children: [_jsx(QueryEditor, { query: query, setQuery: handleQueryChange, language: "lucene", id: "rule-query-editor", height: "20px", editorOptions: OPTIONS, onMount: onMount }), _jsx(Tooltip, { title: t('route.search'), children: _jsx("span", { children: _jsx(IconButton, { size: "small", id: "rule-search-button", onClick: handleSearch, disabled: searching || !query.trim(), children: _jsx(Search, {}) }) }) })] }) }) }), response ? (_jsx(QueryResultText, { count: response.total, query: query, mb: 0 })) : (_jsx(Subtitle, { i18nKey: "hit.search.prompt" }))] }), _jsxs(Box, { children: [_jsx(TextField, { id: "rule-destination-input", label: t('page.cases.rules.destination'), value: destination, onChange: e => setDestination(e.target.value), fullWidth: true, placeholder: "alerts/{{howler.analytic}}", size: "small" }), _jsx(Subtitle, { i18nKey: "page.cases.rules.destination.help" })] }), _jsxs(Stack, { children: [_jsx(TextField, { id: "rule-timeframe-input", type: "number", label: t('page.cases.rules.timeframe'), value: timeframeDays, onChange: e => setTimeframeDays(Math.max(1, parseInt(e.target.value, 10) || 1)), size: "small", disabled: !hasExpiry, sx: { flex: 1 }, InputProps: { inputProps: { min: 1 } } }), _jsx(Subtitle, { i18nKey: "page.cases.rules.timeframe.help" })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", children: [_jsx(Chip, { id: "rule-no-expiry-checkbox", label: t('page.cases.rules.no_expiry'), icon: !hasExpiry ? _jsx(CheckCircleOutline, {}) : _jsx(RadioButtonUnchecked, {}), variant: "filled", color: !hasExpiry ? 'primary' : 'default', clickable: true, disabled: expireAfterResolved, onClick: () => setHasExpiry(prev => !prev) }), _jsx(Chip, { id: "rule-expire-after-resolved", label: t('page.cases.rules.expire_after_resolved'), icon: expireAfterResolved ? _jsx(CheckCircleOutline, {}) : _jsx(RadioButtonUnchecked, {}), variant: "filled", color: expireAfterResolved ? 'primary' : 'default', clickable: hasExpiry, onClick: () => setExpireAfterResolved(prev => !prev), disabled: !hasExpiry })] })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: onClose, children: t('cancel') }), _jsx(Button, { id: "rule-submit-button", variant: "contained", onClick: handleSubmit, disabled: !response || loading || !query.trim() || !destination.trim() || indexes.length === 0, children: t('page.cases.rules.create') })] })] }));
|
|
162
144
|
};
|
|
163
145
|
export default CreateRuleDialog;
|
|
@@ -35,8 +35,8 @@ vi.mock('@mui/x-date-pickers/AdapterDayjs', () => ({
|
|
|
35
35
|
AdapterDayjs: class {
|
|
36
36
|
}
|
|
37
37
|
}));
|
|
38
|
-
vi.mock('
|
|
39
|
-
|
|
38
|
+
vi.mock('components/elements/display/ChipPopper', () => ({
|
|
39
|
+
default: ({ children }) => _jsx(_Fragment, { children: children })
|
|
40
40
|
}));
|
|
41
41
|
import api from '@cccsaurora/howler-ui/api';
|
|
42
42
|
import CreateRuleDialog from './CreateRuleDialog';
|
|
@@ -60,8 +60,8 @@ describe('CreateRuleDialog', () => {
|
|
|
60
60
|
expect(screen.getByTestId('rule-destination-input')).toBeInTheDocument();
|
|
61
61
|
expect(screen.getByTestId('rule-timeframe-input')).toBeInTheDocument();
|
|
62
62
|
expect(screen.getByTestId('rule-no-expiry-checkbox')).toBeInTheDocument();
|
|
63
|
-
expect(screen.getByTestId('rule-
|
|
64
|
-
expect(screen.
|
|
63
|
+
expect(screen.getByTestId('rule-expire-after-resolved')).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
65
65
|
});
|
|
66
66
|
it('disables submit when query is empty', () => {
|
|
67
67
|
render(_jsx(CreateRuleDialog, { ...defaultProps }));
|
|
@@ -149,7 +149,7 @@ describe('CreateRuleDialog', () => {
|
|
|
149
149
|
expect(screen.getByText('hit.search.prompt')).toBeInTheDocument();
|
|
150
150
|
expect(screen.queryByTestId('query-result-text')).not.toBeInTheDocument();
|
|
151
151
|
});
|
|
152
|
-
it('includes timeframe as
|
|
152
|
+
it('includes timeframe as number when expiry is enabled', async () => {
|
|
153
153
|
const user = userEvent.setup();
|
|
154
154
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
155
155
|
mockDispatchApi.mockResolvedValueOnce({ items: [], total: 1, offset: 0, rows: 0 });
|
|
@@ -169,7 +169,7 @@ describe('CreateRuleDialog', () => {
|
|
|
169
169
|
});
|
|
170
170
|
await waitFor(() => {
|
|
171
171
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
172
|
-
timeframe:
|
|
172
|
+
timeframe: 14
|
|
173
173
|
}));
|
|
174
174
|
});
|
|
175
175
|
});
|
|
@@ -198,25 +198,18 @@ describe('CreateRuleDialog', () => {
|
|
|
198
198
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
199
199
|
query: 'event.kind:alert',
|
|
200
200
|
destination: 'alerts/incoming',
|
|
201
|
-
timeframe:
|
|
201
|
+
timeframe: null
|
|
202
202
|
}));
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
-
it('
|
|
206
|
-
render(_jsx(CreateRuleDialog, { ...defaultProps }));
|
|
207
|
-
const hitCheckbox = screen.getByTestId('rule-index-hit');
|
|
208
|
-
const eventCheckbox = screen.getByTestId('rule-index-event');
|
|
209
|
-
expect(hitCheckbox).toBeChecked();
|
|
210
|
-
expect(eventCheckbox).not.toBeChecked();
|
|
211
|
-
});
|
|
212
|
-
it('includes both indexes when both are selected', async () => {
|
|
205
|
+
it('includes expire_after_resolved when checkbox is checked', async () => {
|
|
213
206
|
const user = userEvent.setup();
|
|
214
207
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
215
208
|
mockDispatchApi.mockResolvedValueOnce({ items: [], total: 1, offset: 0, rows: 0 });
|
|
216
209
|
api.search.hit.post.mockReturnValue('search-request');
|
|
217
210
|
render(_jsx(CreateRuleDialog, { ...defaultProps, onSubmit: onSubmit }));
|
|
218
211
|
await act(async () => {
|
|
219
|
-
await user.click(screen.getByTestId('rule-
|
|
212
|
+
await user.click(screen.getByTestId('rule-expire-after-resolved'));
|
|
220
213
|
});
|
|
221
214
|
await act(async () => {
|
|
222
215
|
await user.type(screen.getByTestId('rule-query-editor'), 'event.kind:alert');
|
|
@@ -232,15 +225,31 @@ describe('CreateRuleDialog', () => {
|
|
|
232
225
|
});
|
|
233
226
|
await waitFor(() => {
|
|
234
227
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
235
|
-
|
|
228
|
+
timeframe: 14,
|
|
229
|
+
expire_after_resolved: true
|
|
236
230
|
}));
|
|
237
231
|
});
|
|
238
232
|
});
|
|
239
|
-
it('
|
|
233
|
+
it('defaults to hit index checked', () => {
|
|
234
|
+
render(_jsx(CreateRuleDialog, { ...defaultProps }));
|
|
235
|
+
// With 'hit' selected by default, its chip appears in the Autocomplete input area
|
|
236
|
+
expect(screen.getAllByText('hit.search.index.hit').length).toBeGreaterThan(0);
|
|
237
|
+
// 'event' is not selected, so its chip should not appear
|
|
238
|
+
expect(screen.queryByText('hit.search.index.event')).not.toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
it('includes both indexes when both are selected', async () => {
|
|
240
241
|
const user = userEvent.setup();
|
|
242
|
+
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
241
243
|
mockDispatchApi.mockResolvedValueOnce({ items: [], total: 1, offset: 0, rows: 0 });
|
|
242
244
|
api.search.hit.post.mockReturnValue('search-request');
|
|
243
|
-
render(_jsx(CreateRuleDialog, { ...defaultProps }));
|
|
245
|
+
render(_jsx(CreateRuleDialog, { ...defaultProps, onSubmit: onSubmit }));
|
|
246
|
+
// Open the index Autocomplete and select 'event'
|
|
247
|
+
await act(async () => {
|
|
248
|
+
await user.click(screen.getByRole('combobox'));
|
|
249
|
+
});
|
|
250
|
+
await act(async () => {
|
|
251
|
+
await user.click(screen.getByRole('option', { name: 'hit.search.index.event' }));
|
|
252
|
+
});
|
|
244
253
|
await act(async () => {
|
|
245
254
|
await user.type(screen.getByTestId('rule-query-editor'), 'event.kind:alert');
|
|
246
255
|
});
|
|
@@ -250,10 +259,26 @@ describe('CreateRuleDialog', () => {
|
|
|
250
259
|
await act(async () => {
|
|
251
260
|
await user.type(screen.getByTestId('rule-destination-input'), 'alerts/incoming');
|
|
252
261
|
});
|
|
253
|
-
// Uncheck the default hit index
|
|
254
262
|
await act(async () => {
|
|
255
|
-
await user.click(screen.getByTestId('rule-
|
|
263
|
+
await user.click(screen.getByTestId('rule-submit-button'));
|
|
256
264
|
});
|
|
257
|
-
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
267
|
+
indexes: expect.arrayContaining(['hit', 'event'])
|
|
268
|
+
}));
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
it('prevents deselecting the last index', async () => {
|
|
272
|
+
const user = userEvent.setup();
|
|
273
|
+
render(_jsx(CreateRuleDialog, { ...defaultProps }));
|
|
274
|
+
// 'hit' is selected by default; open the dropdown and try to deselect it
|
|
275
|
+
await act(async () => {
|
|
276
|
+
await user.click(screen.getByRole('combobox'));
|
|
277
|
+
});
|
|
278
|
+
await act(async () => {
|
|
279
|
+
await user.click(screen.getByRole('option', { name: 'hit.search.index.hit' }));
|
|
280
|
+
});
|
|
281
|
+
// 'hit' should still be selected because deselecting the last index is prevented
|
|
282
|
+
expect(screen.getAllByText('hit.search.index.hit').length).toBeGreaterThan(0);
|
|
258
283
|
});
|
|
259
284
|
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = ({
|
|
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.${
|
|
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
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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 {};
|