@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
|
@@ -2,13 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Typography } from '@mui/material';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { Link } from 'react-router-dom';
|
|
5
|
-
const QueryResultText = ({ count, query }) => {
|
|
5
|
+
const QueryResultText = ({ count, query, mb = 0.5 }) => {
|
|
6
6
|
const { t } = useTranslation();
|
|
7
7
|
return (_jsxs(Typography, { sx: theme => ({
|
|
8
8
|
color: theme.palette.text.secondary,
|
|
9
9
|
fontSize: '0.9em',
|
|
10
10
|
fontStyle: 'italic',
|
|
11
|
-
mb
|
|
11
|
+
mb,
|
|
12
12
|
'& a': { color: theme.palette.text.secondary }
|
|
13
13
|
}), variant: "body2", children: [t('search.total', { count }), " ", _jsx(Link, { to: `/hits?query=${encodeURIComponent(query)}`, children: t('search.open') })] }));
|
|
14
14
|
};
|
|
@@ -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 {
|
|
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
|
|
10
|
-
|
|
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(
|
|
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 =>
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
86
|
-
}, [allObservables, activeFilters]);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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, {
|
|
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: '
|
|
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
|
|
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.
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
141
|
-
await userEvent.click(screen.
|
|
142
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
});
|
|
@@ -5,7 +5,6 @@ import api from '@cccsaurora/howler-ui/api';
|
|
|
5
5
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
6
|
import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
|
|
7
7
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
-
import dayjs from 'dayjs';
|
|
9
8
|
import { useCallback, useContext, useState } from 'react';
|
|
10
9
|
import { useTranslation } from 'react-i18next';
|
|
11
10
|
import { useOutletContext } from 'react-router-dom';
|
|
@@ -47,11 +46,10 @@ const CaseRules = ({ case: providedCase, caseId }) => {
|
|
|
47
46
|
return null;
|
|
48
47
|
}
|
|
49
48
|
const rules = _case.rules ?? [];
|
|
50
|
-
return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
|
|
49
|
+
return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
|
|
50
|
+
fontFamily: 'monospace'
|
|
51
|
+
}, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: rule.timeframe != null ? (_jsx(Chip, { size: "small", label: rule.expire_after_resolved
|
|
52
|
+
? t('page.cases.rules.timeframe.after_resolved', { days: rule.timeframe })
|
|
53
|
+
: t('page.cases.rules.timeframe.days', { days: rule.timeframe }), color: rule.expire_after_resolved ? 'info' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id))) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
|
|
56
54
|
};
|
|
57
55
|
export default CaseRules;
|
|
@@ -68,7 +68,8 @@ const makeRule = (overrides) => ({
|
|
|
68
68
|
destination: 'alerts/{{howler.analytic}}',
|
|
69
69
|
author: 'analyst1',
|
|
70
70
|
enabled: true,
|
|
71
|
-
timeframe:
|
|
71
|
+
timeframe: 14,
|
|
72
|
+
expire_after_resolved: false,
|
|
72
73
|
indexes: ['hit'],
|
|
73
74
|
...overrides
|
|
74
75
|
});
|
|
@@ -104,6 +105,22 @@ describe('CaseRules', () => {
|
|
|
104
105
|
render(_jsx(CaseRules, {}));
|
|
105
106
|
expect(screen.getByText('page.cases.rules.no_expiry')).toBeInTheDocument();
|
|
106
107
|
});
|
|
108
|
+
it('shows after-resolved chip when expire_after_resolved is true', () => {
|
|
109
|
+
mockCase.current = createMockCase({
|
|
110
|
+
case_id: 'case-001',
|
|
111
|
+
rules: [makeRule({ timeframe: 7, expire_after_resolved: true })]
|
|
112
|
+
});
|
|
113
|
+
render(_jsx(CaseRules, {}));
|
|
114
|
+
expect(screen.getByText('page.cases.rules.timeframe.after_resolved')).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
it('shows days chip when expire_after_resolved is false', () => {
|
|
117
|
+
mockCase.current = createMockCase({
|
|
118
|
+
case_id: 'case-001',
|
|
119
|
+
rules: [makeRule({ timeframe: 14, expire_after_resolved: false })]
|
|
120
|
+
});
|
|
121
|
+
render(_jsx(CaseRules, {}));
|
|
122
|
+
expect(screen.getByText('page.cases.rules.timeframe.days')).toBeInTheDocument();
|
|
123
|
+
});
|
|
107
124
|
it('renders enabled toggle for each rule', () => {
|
|
108
125
|
mockCase.current = createMockCase({
|
|
109
126
|
case_id: 'case-001',
|