@cccsaurora/howler-ui 2.19.0-dev.1318 → 2.19.0-dev.1344
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/commons/components/app/hooks/useAppConfigs.d.ts +1 -1
- package/components/elements/ContextMenu.js +9 -5
- package/components/elements/hit/HitOverview.js +1 -1
- package/components/hooks/useMySitemap.js +0 -1
- package/components/routes/ErrorBoundary.d.ts +2 -13
- package/components/routes/ErrorBoundary.js +12 -1
- package/components/routes/ErrorBoundary.test.js +27 -4
- package/components/routes/cases/detail/CaseObservables.js +8 -23
- package/components/routes/cases/detail/CaseObservables.test.js +4 -6
- package/components/routes/cases/detail/CaseOverview.js +1 -4
- package/components/routes/cases/detail/CaseTimeline.js +15 -1
- package/components/routes/cases/detail/CaseTimeline.test.js +0 -1
- package/components/routes/cases/detail/observables/Observable.js +5 -4
- package/components/routes/cases/detail/observables/Observable.test.js +17 -9
- package/components/routes/cases/detail/observables/ObservableTable.js +2 -2
- package/components/routes/cases/detail/observables/ObservableTable.test.js +22 -10
- package/components/routes/cases/detail/types.d.ts +2 -4
- package/components/routes/cases/detail/utils.d.ts +5 -6
- package/components/routes/cases/detail/utils.js +34 -31
- package/components/routes/cases/detail/utils.test.js +38 -16
- package/components/routes/overviews/OverviewViewer.js +0 -1
- package/index.css +4 -0
- package/package.json +3 -3
|
@@ -23,7 +23,7 @@ export declare function useAppConfigs(): {
|
|
|
23
23
|
hideNestedIcons?: boolean;
|
|
24
24
|
};
|
|
25
25
|
appName: string;
|
|
26
|
-
appLink?: import("react-router
|
|
26
|
+
appLink?: import("react-router").To;
|
|
27
27
|
appIconDark: import("react").ReactElement<any>;
|
|
28
28
|
appIconLight: import("react").ReactElement<any>;
|
|
29
29
|
bannerDark?: import("react").ReactElement<any>;
|
|
@@ -83,7 +83,7 @@ const ContextMenu = ({ items, onOpen, onClose, Component = Box, id, children })
|
|
|
83
83
|
sx: {
|
|
84
84
|
minWidth: '250px',
|
|
85
85
|
paddingY: '0 !important',
|
|
86
|
-
'& >
|
|
86
|
+
'& > .first': {
|
|
87
87
|
borderTopLeftRadius: theme.shape.borderRadius,
|
|
88
88
|
borderTopRightRadius: theme.shape.borderRadius
|
|
89
89
|
},
|
|
@@ -92,15 +92,19 @@ const ContextMenu = ({ items, onOpen, onClose, Component = Box, id, children })
|
|
|
92
92
|
borderBottomRightRadius: theme.shape.borderRadius
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
}, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: handleClose, children: items.map(entry => {
|
|
95
|
+
}, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: handleClose, children: items.map((entry, index) => {
|
|
96
|
+
let className = '';
|
|
97
|
+
if (index === 0) {
|
|
98
|
+
className = 'first';
|
|
99
|
+
}
|
|
96
100
|
if (entry.kind === 'divider') {
|
|
97
|
-
return _jsx(Divider, { sx: { my: '0 !important' } }, entry.id);
|
|
101
|
+
return _jsx(Divider, { className: className, sx: { my: '0 !important' } }, entry.id);
|
|
98
102
|
}
|
|
99
103
|
if (entry.kind === 'item') {
|
|
100
104
|
if (entry.to) {
|
|
101
|
-
return (_jsxs(MenuItem, { component: Link, to: entry.to, disabled: entry.disabled, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
|
|
105
|
+
return (_jsxs(MenuItem, { className: className, component: Link, to: entry.to, disabled: entry.disabled, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
|
|
102
106
|
}
|
|
103
|
-
return (_jsxs(MenuItem, { disabled: entry.disabled, onClick: entry.onClick, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
|
|
107
|
+
return (_jsxs(MenuItem, { className: className, disabled: entry.disabled, onClick: entry.onClick, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
|
|
104
108
|
}
|
|
105
109
|
const { id: entryId, icon, label, disabled, items: subItems } = entry;
|
|
106
110
|
return (_jsxs(MenuItem, { id: `${entryId}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [entryId]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [entryId]: null })), disabled: disabled, children: [icon && _jsx(ListItemIcon, { children: icon }), _jsx(ListItemText, { sx: { flex: 1 }, children: label }), !disabled && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show[entryId], unmountOnExit: true, children: _jsx(Paper, { id: `${entryId}-submenu`, sx: calculateSubMenuStyles(show[entryId]), elevation: 2, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: subItems.map(subItem => (_jsx(MenuItem, { onClick: subItem.onClick, disabled: subItem.disabled, children: _jsx(ListItemText, { children: subItem.label }) }, subItem.key))) }) }) })] }, entryId));
|
|
@@ -17,6 +17,6 @@ const HitOverview = ({ content, hit }) => {
|
|
|
17
17
|
: hit
|
|
18
18
|
? `/overviews/view?analytic=${encodeURIComponent(hit?.howler.analytic)}${hit?.howler.detection && '&detection=' + encodeURIComponent(hit?.howler.detection)}`
|
|
19
19
|
: null, [hit, matchingOverview]);
|
|
20
|
-
return (_jsxs(Box, { sx: { position: 'relative', height: '100%', overflow: 'auto' }, children: [
|
|
20
|
+
return (_jsxs(Box, { className: "no-margin-first-child", sx: { position: 'relative', height: '100%', overflow: 'auto' }, children: [matchingOverview || content ? (_jsx(ErrorBoundary, { children: _jsx(HandlebarsMarkdown, { md: content ?? matchingOverview.content, object: hit, disableLinks: true }) })) : (_jsx(Skeleton, { variant: "rounded", height: "40vh" })), link && (_jsx(IconButton, { component: Link, to: link, sx: theme => ({ position: 'absolute', right: theme.spacing(1), top: theme.spacing(1) }), children: _jsx(InsertLink, {}) }))] }));
|
|
21
21
|
};
|
|
22
22
|
export default memo(HitOverview);
|
|
@@ -25,7 +25,6 @@ const useMySitemap = () => {
|
|
|
25
25
|
routes: [
|
|
26
26
|
{ path: '/', title: t('route.home'), isRoot: true, icon: _jsx(Dashboard, {}) },
|
|
27
27
|
{ path: '/cases', title: t('route.cases'), isRoot: true, icon: _jsx(BookRounded, {}) },
|
|
28
|
-
{ path: '/cases/:id', title: t('route.cases.view'), breadcrumbs: ['/cases'] },
|
|
29
28
|
{ path: '/cases/:id/*', title: t('route.cases.view'), isLeaf: true, breadcrumbs: ['/cases'] },
|
|
30
29
|
{ path: '/admin/users', title: t('route.admin.user.search'), isRoot: true, icon: _jsx(PersonSearch, {}) },
|
|
31
30
|
{
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
declare
|
|
3
|
-
hasError: boolean;
|
|
4
|
-
error: Error;
|
|
5
|
-
}> {
|
|
6
|
-
constructor(props: any);
|
|
7
|
-
static getDerivedStateFromError(error: any): {
|
|
8
|
-
hasError: boolean;
|
|
9
|
-
error: any;
|
|
10
|
-
};
|
|
11
|
-
componentDidCatch(error: Error): void;
|
|
12
|
-
render(): string | number | boolean | import("react/jsx-runtime").JSX.Element | Iterable<React.ReactNode>;
|
|
13
|
-
}
|
|
1
|
+
import { type PropsWithChildren } from 'react';
|
|
2
|
+
declare const ErrorBoundary: ({ children }: PropsWithChildren) => import("react/jsx-runtime").JSX.Element;
|
|
14
3
|
export default ErrorBoundary;
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { ExpandMore } from '@mui/icons-material';
|
|
3
3
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material';
|
|
4
4
|
import React, {} from 'react';
|
|
5
|
+
import { useLocation } from 'react-router-dom';
|
|
5
6
|
import ErrorOccured from './ErrorOccured';
|
|
6
|
-
class
|
|
7
|
+
class ErrorBoundaryComponent extends React.Component {
|
|
7
8
|
constructor(props) {
|
|
8
9
|
super(props);
|
|
9
10
|
this.state = { hasError: false, error: null };
|
|
@@ -14,6 +15,12 @@ class ErrorBoundary extends React.Component {
|
|
|
14
15
|
componentDidCatch(error) {
|
|
15
16
|
this.setState({ hasError: true, error: error });
|
|
16
17
|
}
|
|
18
|
+
componentDidUpdate(previousProps) {
|
|
19
|
+
console.log(previousProps, this.props);
|
|
20
|
+
if (this.props.locationKey !== previousProps.locationKey && this.state.hasError) {
|
|
21
|
+
this.setState({ hasError: false, error: null });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
17
24
|
render() {
|
|
18
25
|
if (this.state.hasError) {
|
|
19
26
|
return (_jsxs(Box, { pt: 6, textAlign: "center", fontSize: 20, children: [_jsx(ErrorOccured, {}), _jsxs(Accordion, { elevation: 0, children: [_jsx(AccordionSummary, { expandIcon: _jsx(ExpandMore, {}), "aria-controls": "panel1-content", id: "panel1-header", children: _jsx(Typography, { align: "center", sx: { width: '100%', fontSize: '1.2rem' }, variant: "h5", children: this.state.error.message }) }), _jsx(AccordionDetails, { children: _jsx("code", { children: _jsx(Typography, { variant: "h6", children: this.state.error.stack }) }) })] })] }));
|
|
@@ -21,4 +28,8 @@ class ErrorBoundary extends React.Component {
|
|
|
21
28
|
return this.props.children;
|
|
22
29
|
}
|
|
23
30
|
}
|
|
31
|
+
const ErrorBoundary = ({ children }) => {
|
|
32
|
+
const location = useLocation();
|
|
33
|
+
return _jsx(ErrorBoundaryComponent, { locationKey: location.key, children: children });
|
|
34
|
+
};
|
|
24
35
|
export default ErrorBoundary;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/// <reference types="vitest" />
|
|
3
3
|
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
4
5
|
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
5
6
|
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
|
6
8
|
import { describe, expect, it, vi } from 'vitest';
|
|
7
9
|
import ErrorBoundary from './ErrorBoundary';
|
|
8
10
|
vi.mock('commons/components/pages/PageCenter', () => ({
|
|
@@ -20,24 +22,45 @@ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children:
|
|
|
20
22
|
const ThrowingComponent = ({ error }) => {
|
|
21
23
|
throw error;
|
|
22
24
|
};
|
|
25
|
+
const RouteContent = () => {
|
|
26
|
+
const location = useLocation();
|
|
27
|
+
if (location.pathname === '/broken') {
|
|
28
|
+
return _jsx(ThrowingComponent, { error: new Error('Broken route') });
|
|
29
|
+
}
|
|
30
|
+
return _jsx("div", { children: "Recovered route" });
|
|
31
|
+
};
|
|
32
|
+
const NavigationButton = () => {
|
|
33
|
+
const navigate = useNavigate();
|
|
34
|
+
return _jsx("button", { onClick: () => navigate('/recovered'), children: "Navigate away" });
|
|
35
|
+
};
|
|
23
36
|
describe('ErrorBoundary', () => {
|
|
24
37
|
it('should render children when no error occurs', () => {
|
|
25
|
-
render(_jsx(ErrorBoundary, { children: _jsx("div", { children: "Child content" }) }), { wrapper: Wrapper });
|
|
38
|
+
render(_jsx(MemoryRouter, { children: _jsx(ErrorBoundary, { children: _jsx("div", { children: "Child content" }) }) }), { wrapper: Wrapper });
|
|
26
39
|
expect(screen.getByText('Child content')).toBeInTheDocument();
|
|
27
40
|
});
|
|
28
41
|
it('should render error UI when a child throws', () => {
|
|
29
42
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
30
43
|
const testError = new Error('Test error message');
|
|
31
44
|
testError.stack = 'Error: Test error message\n at TestComponent';
|
|
32
|
-
render(_jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }), { wrapper: Wrapper });
|
|
45
|
+
render(_jsx(MemoryRouter, { children: _jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }) }), { wrapper: Wrapper });
|
|
33
46
|
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
|
34
47
|
consoleSpy.mockRestore();
|
|
35
48
|
});
|
|
36
49
|
it('should display the error page title when an error occurs', () => {
|
|
37
50
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
38
51
|
const testError = new Error('Something went wrong');
|
|
39
|
-
render(_jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }), { wrapper: Wrapper });
|
|
52
|
+
render(_jsx(MemoryRouter, { children: _jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }) }), { wrapper: Wrapper });
|
|
40
53
|
expect(screen.getByText('Application Stopped Working')).toBeInTheDocument();
|
|
41
54
|
consoleSpy.mockRestore();
|
|
42
55
|
});
|
|
56
|
+
it('should reset after navigating away from an error', async () => {
|
|
57
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
render(_jsxs(MemoryRouter, { initialEntries: ['/broken'], children: [_jsx(NavigationButton, {}), _jsx(ErrorBoundary, { children: _jsx(RouteContent, {}) })] }), { wrapper: Wrapper });
|
|
60
|
+
expect(screen.getByText('Broken route')).toBeInTheDocument();
|
|
61
|
+
await user.click(screen.getByRole('button', { name: 'Navigate away' }));
|
|
62
|
+
expect(screen.queryByText('Broken route')).not.toBeInTheDocument();
|
|
63
|
+
expect(screen.getByText('Recovered route')).toBeInTheDocument();
|
|
64
|
+
consoleSpy.mockRestore();
|
|
65
|
+
});
|
|
43
66
|
});
|
|
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
10
10
|
import { useOutletContext } from 'react-router-dom';
|
|
11
11
|
import useCase from '../hooks/useCase';
|
|
12
12
|
import ObservableTable from './observables/ObservableTable';
|
|
13
|
-
import { OBSERVABLE_FIELDS, buildObservableEntries
|
|
13
|
+
import { OBSERVABLE_FIELDS, buildObservableEntries } from './utils';
|
|
14
14
|
const RELATED_FIELDS = OBSERVABLE_FIELDS.map(f => `related.${f}`).join(',');
|
|
15
15
|
const EXTRA_FIELDS = 'howler.escalation,howler.outline.threat,howler.outline.target,howler.outline.indicators,threat.indicator.ip,threat.indicator.description';
|
|
16
16
|
const CaseObservables = ({ case: providedCase, caseId }) => {
|
|
@@ -25,7 +25,6 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
|
|
|
25
25
|
const [roleFilters, setRoleFilters] = useState([]);
|
|
26
26
|
const [escalationOptions, setEscalationOptions] = useState([]);
|
|
27
27
|
const [activeEscalations, setActiveEscalations] = useState([]);
|
|
28
|
-
const [escalationMap, setEscalationMap] = useState(new Map());
|
|
29
28
|
const ids = useMemo(() => (_case?.items ?? [])
|
|
30
29
|
.filter(item => ['hit', 'event'].includes(item.type))
|
|
31
30
|
.map(item => item.value)
|
|
@@ -38,19 +37,7 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
|
|
|
38
37
|
void dispatchApi(api.v2.search.post(['hit', 'event'], {
|
|
39
38
|
query: `howler.id:(${ids.join(' OR ')})`,
|
|
40
39
|
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
|
-
});
|
|
40
|
+
}), { throwError: false, showError: true }).then(response => response && setRecords(response.items));
|
|
54
41
|
}, [dispatchApi, ids]);
|
|
55
42
|
useEffect(() => {
|
|
56
43
|
if (ids.length < 1) {
|
|
@@ -68,16 +55,14 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
|
|
|
68
55
|
});
|
|
69
56
|
}, [dispatchApi, ids]);
|
|
70
57
|
const allObservables = useMemo(() => {
|
|
71
|
-
if (!
|
|
58
|
+
if (!_case) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
if (!records?.length) {
|
|
72
62
|
return [];
|
|
73
63
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
...entry,
|
|
77
|
-
role: classifyRole(entry.value, _case, records),
|
|
78
|
-
sources: resolveSources(entry.seenIn, _case.items, escalationMap)
|
|
79
|
-
}));
|
|
80
|
-
}, [records, _case, escalationMap]);
|
|
64
|
+
return buildObservableEntries(_case, records);
|
|
65
|
+
}, [records, _case]);
|
|
81
66
|
const observableTypes = useMemo(() => (allObservables ? uniq(allObservables.map(a => a.type)).sort() : []), [allObservables]);
|
|
82
67
|
const filteredObservables = useMemo(() => {
|
|
83
68
|
if (allObservables.length < 1) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
4
3
|
import userEvent from '@testing-library/user-event';
|
|
5
4
|
import { createElement } from 'react';
|
|
6
5
|
import { MemoryRouter } from 'react-router-dom';
|
|
@@ -148,10 +147,9 @@ describe('CaseObservables component', () => {
|
|
|
148
147
|
});
|
|
149
148
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
150
149
|
await screen.findByText('1.2.3.4');
|
|
151
|
-
await
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
});
|
|
150
|
+
await screen.findByText('page.cases.observables.filter_by_escalation');
|
|
151
|
+
await userEvent.click(screen.getByText('page.cases.observables.filter_by_escalation'));
|
|
152
|
+
expect(screen.getAllByText('evidence').length).toBeGreaterThanOrEqual(2);
|
|
155
153
|
});
|
|
156
154
|
it('classifies roles and renders role chips in the table', async () => {
|
|
157
155
|
mockDispatchApi.mockResolvedValue({
|
|
@@ -17,10 +17,7 @@ const CaseOverview = ({ case: _case, updateCase }) => {
|
|
|
17
17
|
if (!_case) {
|
|
18
18
|
return _jsx(Skeleton, { height: 370 });
|
|
19
19
|
}
|
|
20
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { title: _case.title, subheader: _case.summary }), _jsxs(Stack, { children: [_jsx(Divider, {}), _jsx(LinearProgress, { sx: { opacity: +loading } })] }), _jsx(CardContent, {
|
|
21
|
-
'& > :first-child': {
|
|
22
|
-
marginTop: '0 !important'
|
|
23
|
-
},
|
|
20
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { title: _case.title, subheader: _case.summary }), _jsxs(Stack, { children: [_jsx(Divider, {}), _jsx(LinearProgress, { sx: { opacity: +loading } })] }), _jsx(CardContent, { children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Box, { flex: 1, className: "no-margin-first-child", sx: {
|
|
24
21
|
'& > h1,h2,h3,h4,h5': {
|
|
25
22
|
fontSize: theme.typography.h5.fontSize
|
|
26
23
|
}
|
|
@@ -18,6 +18,7 @@ import { useContextSelector } from 'use-context-selector';
|
|
|
18
18
|
import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
19
19
|
import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
|
|
20
20
|
import useCase from '../hooks/useCase';
|
|
21
|
+
import { buildPathFromID } from '../utils';
|
|
21
22
|
// builds the additional filters for the lucene query
|
|
22
23
|
export const buildFilters = (mitre, escalations) => {
|
|
23
24
|
const filters = [];
|
|
@@ -101,6 +102,19 @@ const CaseTimeline = ({ case: providedCase, caseId }) => {
|
|
|
101
102
|
if (!_case) {
|
|
102
103
|
return null;
|
|
103
104
|
}
|
|
104
|
-
return (_jsxs(Stack, { spacing: 0, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", sx: { p: 1, gap: 1 }, children: [_jsx(Tooltip, { title: t('page.cases.timeline.filter.label'), children: _jsx(FilterList, { fontSize: "small", color: "action" }) }), _jsx(Autocomplete, { multiple: true, size: "small", options: mitreOptions, value: selectedMitres, onChange: (_e, values) => setSelectedMitres(values), getOptionLabel: opt => `${opt.id} - ${opt.name}`, isOptionEqualToValue: (opt, val) => opt.id === val.id, groupBy: opt => capitalize(opt.kind), renderTags: (value, getTagProps) => value.map((opt, index) => (_createElement(Chip, { ...getTagProps({ index }), key: opt.id, size: "small", label: opt.id, color: "primary" }))), renderInput: params => (_jsx(TextField, { ...params, label: t('page.cases.timeline.filter.mitre'), sx: { minWidth: 260 } })), noOptionsText: t('page.cases.timeline.filter.mitre.empty') }), _jsx(Autocomplete, { multiple: true, size: "small", options: escalationOptions, value: selectedEscalations, onChange: (_e, value) => setSelectedEscalations(value), getOptionLabel: opt => t(`howler.escalation.${opt}`, opt), renderTags: (value, getTagProps) => value.map((opt, index) => (_createElement(Chip, { ...getTagProps({ index }), key: opt, size: "small", label: opt, color: ESCALATION_COLORS[opt] }))), renderInput: params => (_jsx(TextField, { ...params, label: t('page.cases.timeline.filter.escalation'), sx: { minWidth: 220 } })), noOptionsText: t('page.cases.timeline.filter.escalation.empty') })] }), _jsx(Divider, {}), loading ? (_jsx(Stack, { spacing: 2, sx: { px: 2, py: 1 }, children: [0, 1, 2].map(i => (_jsxs(Stack, { direction: "row", width: "100%", spacing: 1, children: [_jsx(Skeleton, { variant: "text", width: 120, height: 24 }), _jsx(Skeleton, { variant: "rounded", height: 120, sx: { flex: 1 } })] }, i))) })) : displayedEntries.length === 0 ? (_jsx(Box, { sx: { pt: 4, textAlign: 'center' }, children: _jsx(Typography, { color: "textSecondary", children: t('page.cases.timeline.empty') }) })) : (_jsx(Stack, { component: "ol", spacing: 0, sx: { px: 2, py: 1, listStyle: 'none', m: 0, overflow: 'auto' }, children: displayedEntries.map(entry => (_jsxs(Stack, { component: "li", spacing: 1, sx: { pb: 1 }, children: [_jsxs(Stack, { direction: "row", spacing: 2, alignItems: "flex-start", children: [_jsxs(Stack, { spacing: 0.5, alignItems: "end", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: dayjs(entry.event?.created ?? entry.timestamp).format('YYYY-MM-DD HH:mm:ss') }), entry.threat?.technique?.id && (_jsx(Tooltip, { title: `${entry.threat.technique.id}: ${config.lookups?.techniques?.[entry.threat.technique.id].name}`, children: _jsx(Typography, { component: config.lookups?.techniques?.[entry.threat.technique.id]?.url ? 'a' : undefined, href: config.lookups?.techniques?.[entry.threat.technique.id]?.url, variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: entry.threat.technique.id }) })), entry.threat?.tactic?.id && (_jsx(Tooltip, { title: `${entry.threat.tactic.id}: ${config.lookups?.tactics?.[entry.threat.tactic.id]?.name ?? t('unknown')}`, children: _jsx(Typography, { component: config.lookups?.tactics?.[entry.threat.tactic.id]?.url ? 'a' : undefined, href: config.lookups?.tactics?.[entry.threat.tactic.id]?.url, variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: entry.threat.tactic.id }) }))] }),
|
|
105
|
+
return (_jsxs(Stack, { spacing: 0, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", sx: { p: 1, gap: 1 }, children: [_jsx(Tooltip, { title: t('page.cases.timeline.filter.label'), children: _jsx(FilterList, { fontSize: "small", color: "action" }) }), _jsx(Autocomplete, { multiple: true, size: "small", options: mitreOptions, value: selectedMitres, onChange: (_e, values) => setSelectedMitres(values), getOptionLabel: opt => `${opt.id} - ${opt.name}`, isOptionEqualToValue: (opt, val) => opt.id === val.id, groupBy: opt => capitalize(opt.kind), renderTags: (value, getTagProps) => value.map((opt, index) => (_createElement(Chip, { ...getTagProps({ index }), key: opt.id, size: "small", label: opt.id, color: "primary" }))), renderInput: params => (_jsx(TextField, { ...params, label: t('page.cases.timeline.filter.mitre'), sx: { minWidth: 260 } })), noOptionsText: t('page.cases.timeline.filter.mitre.empty') }), _jsx(Autocomplete, { multiple: true, size: "small", options: escalationOptions, value: selectedEscalations, onChange: (_e, value) => setSelectedEscalations(value), getOptionLabel: opt => t(`howler.escalation.${opt}`, opt), renderTags: (value, getTagProps) => value.map((opt, index) => (_createElement(Chip, { ...getTagProps({ index }), key: opt, size: "small", label: opt, color: ESCALATION_COLORS[opt] }))), renderInput: params => (_jsx(TextField, { ...params, label: t('page.cases.timeline.filter.escalation'), sx: { minWidth: 220 } })), noOptionsText: t('page.cases.timeline.filter.escalation.empty') })] }), _jsx(Divider, {}), loading ? (_jsx(Stack, { spacing: 2, sx: { px: 2, py: 1 }, children: [0, 1, 2].map(i => (_jsxs(Stack, { direction: "row", width: "100%", spacing: 1, children: [_jsx(Skeleton, { variant: "text", width: 120, height: 24 }), _jsx(Skeleton, { variant: "rounded", height: 120, sx: { flex: 1 } })] }, i))) })) : displayedEntries.length === 0 ? (_jsx(Box, { sx: { pt: 4, textAlign: 'center' }, children: _jsx(Typography, { color: "textSecondary", children: t('page.cases.timeline.empty') }) })) : (_jsx(Stack, { component: "ol", spacing: 0, sx: { px: 2, py: 1, listStyle: 'none', m: 0, overflow: 'auto' }, children: displayedEntries.map(entry => (_jsxs(Stack, { component: "li", spacing: 1, sx: { pb: 1 }, children: [_jsxs(Stack, { direction: "row", spacing: 2, alignItems: "flex-start", children: [_jsxs(Stack, { spacing: 0.5, alignItems: "end", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: dayjs(entry.event?.created ?? entry.timestamp).format('YYYY-MM-DD HH:mm:ss') }), entry.threat?.technique?.id && (_jsx(Tooltip, { title: `${entry.threat.technique.id}: ${config.lookups?.techniques?.[entry.threat.technique.id].name}`, children: _jsx(Typography, { component: config.lookups?.techniques?.[entry.threat.technique.id]?.url ? 'a' : undefined, href: config.lookups?.techniques?.[entry.threat.technique.id]?.url, variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: entry.threat.technique.id }) })), entry.threat?.tactic?.id && (_jsx(Tooltip, { title: `${entry.threat.tactic.id}: ${config.lookups?.tactics?.[entry.threat.tactic.id]?.name ?? t('unknown')}`, children: _jsx(Typography, { component: config.lookups?.tactics?.[entry.threat.tactic.id]?.url ? 'a' : undefined, href: config.lookups?.tactics?.[entry.threat.tactic.id]?.url, variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: entry.threat.tactic.id }) }))] }), _jsxs(Box, { sx: { flex: 1, minWidth: 0, textDecoration: 'none', position: 'relative' }, children: [isHit(entry) ? (_jsx(HitCard, { id: entry.howler.id, hit: entry, layout: HitLayout.DENSE, readOnly: true })) : (_jsx(EventCard, { id: entry.howler.id, event: entry })), _jsx(Box, { component: Link, to: `/cases/${_case.case_id}/${buildPathFromID(_case, getItemId(entry.howler.id))}`, sx: theme => ({
|
|
106
|
+
position: 'absolute',
|
|
107
|
+
top: 0,
|
|
108
|
+
left: 0,
|
|
109
|
+
width: '100%',
|
|
110
|
+
height: '100%',
|
|
111
|
+
cursor: 'pointer',
|
|
112
|
+
zIndex: 100,
|
|
113
|
+
borderRadius: '4px',
|
|
114
|
+
'&:hover': {
|
|
115
|
+
background: theme.palette.divider,
|
|
116
|
+
border: `thin solid ${theme.palette.primary.light}`
|
|
117
|
+
}
|
|
118
|
+
}) })] })] }), _jsx(Divider, { flexItem: true })] }, entry.howler.id))) }))] }));
|
|
105
119
|
};
|
|
106
120
|
export default memo(CaseTimeline);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/* eslint-disable react/no-children-prop */
|
|
3
|
-
/// <reference types="vitest" />
|
|
4
3
|
import { act, render, screen } from '@testing-library/react';
|
|
5
4
|
import userEvent from '@testing-library/user-event';
|
|
6
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
@@ -2,12 +2,13 @@ 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
|
-
import { buildPathFromID } from '../../utils';
|
|
6
5
|
const Observable = ({ observable, case: _case }) => {
|
|
7
6
|
const { t } = useTranslation();
|
|
8
|
-
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.
|
|
9
|
-
|
|
10
|
-
|
|
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.sources?.length ?? 0) > 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.sources?.map(source => {
|
|
8
|
+
if (!source.path) {
|
|
9
|
+
return _jsx(Chip, { size: "small", label: source.label ?? source.id, variant: "outlined" }, source.id);
|
|
10
|
+
}
|
|
11
|
+
return (_jsx(Chip, { clickable: true, size: "small", label: source.label ?? source.id, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id));
|
|
11
12
|
}) })] }))] }) }) }));
|
|
12
13
|
};
|
|
13
14
|
export default Observable;
|
|
@@ -8,7 +8,7 @@ import Observable from './Observable';
|
|
|
8
8
|
const makeObservable = (overrides = {}) => ({
|
|
9
9
|
type: 'ip',
|
|
10
10
|
value: '192.168.1.1',
|
|
11
|
-
|
|
11
|
+
sources: [],
|
|
12
12
|
...overrides
|
|
13
13
|
});
|
|
14
14
|
describe('Observable', () => {
|
|
@@ -33,19 +33,19 @@ describe('Observable', () => {
|
|
|
33
33
|
expect(screen.getByText(hash)).toBeTruthy();
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
|
-
describe('
|
|
37
|
-
it('renders nothing when
|
|
38
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
|
|
36
|
+
describe('source chips', () => {
|
|
37
|
+
it('renders nothing when sources are empty', () => {
|
|
38
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ sources: [] }), case: createMockCase() }) }));
|
|
39
39
|
expect(screen.queryByText('page.cases.observables.seen_in')).toBeNull();
|
|
40
40
|
});
|
|
41
|
-
it('renders "Seen in" label when
|
|
41
|
+
it('renders "Seen in" label when sources have entries', () => {
|
|
42
42
|
const _case = createMockCase({
|
|
43
43
|
items: [{ type: 'hit', value: 'hit-001' }]
|
|
44
44
|
});
|
|
45
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
|
|
45
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ sources: [{ id: 'hit-001', type: 'hit' }] }), case: _case }) }));
|
|
46
46
|
expect(screen.getByText('page.cases.observables.seen_in')).toBeTruthy();
|
|
47
47
|
});
|
|
48
|
-
it('renders chips labelled from
|
|
48
|
+
it('renders chips labelled from source metadata', () => {
|
|
49
49
|
const _case = createMockCase({
|
|
50
50
|
items: [
|
|
51
51
|
{ id: 'i1', type: 'hit', value: 'hit-001', name: 'first-hit' },
|
|
@@ -53,7 +53,13 @@ describe('Observable', () => {
|
|
|
53
53
|
{ id: 'i3', type: 'hit', value: 'hit-003', name: 'third-hit' }
|
|
54
54
|
]
|
|
55
55
|
});
|
|
56
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
|
|
56
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
|
|
57
|
+
sources: [
|
|
58
|
+
{ id: 'hit-001', type: 'hit', label: 'first-hit' },
|
|
59
|
+
{ id: 'obs-002', type: 'event', label: 'obs-two' },
|
|
60
|
+
{ id: 'hit-003', type: 'hit', label: 'third-hit' }
|
|
61
|
+
]
|
|
62
|
+
}), case: _case }) }));
|
|
57
63
|
expect(screen.getByText('first-hit')).toBeTruthy();
|
|
58
64
|
expect(screen.getByText('obs-two')).toBeTruthy();
|
|
59
65
|
expect(screen.getByText('third-hit')).toBeTruthy();
|
|
@@ -63,7 +69,9 @@ describe('Observable', () => {
|
|
|
63
69
|
case_id: 'case-abc',
|
|
64
70
|
items: [{ id: 'item-1', type: 'hit', value: 'hit-001', name: 'first-hit' }]
|
|
65
71
|
});
|
|
66
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
|
|
72
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
|
|
73
|
+
sources: [{ id: 'hit-001', type: 'hit', path: 'first-hit', label: 'first-hit' }]
|
|
74
|
+
}), case: _case }) }));
|
|
67
75
|
const link = screen.getByText('first-hit').closest('a');
|
|
68
76
|
expect(link).not.toBeNull();
|
|
69
77
|
expect(link?.getAttribute('href')).toBe('/cases/case-abc/first-hit');
|
|
@@ -20,7 +20,7 @@ const getSortValue = (observable, column) => {
|
|
|
20
20
|
case 'role':
|
|
21
21
|
return observable.role ?? '';
|
|
22
22
|
case 'seen_in':
|
|
23
|
-
return observable.
|
|
23
|
+
return observable.sources?.length ?? 0;
|
|
24
24
|
case 'escalation':
|
|
25
25
|
return (observable.sources ?? [])
|
|
26
26
|
.map(s => s.escalation)
|
|
@@ -55,7 +55,7 @@ const ObservableTable = ({ observables, case: _case }) => {
|
|
|
55
55
|
const sources = observable.sources ?? [];
|
|
56
56
|
const escalations = [...new Set(sources.map(s => s.escalation).filter(Boolean))];
|
|
57
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:
|
|
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: sources.length }) }), _jsx(TableCell, { children: linkedSources.length === 0 ? null : linkedSources.length === 1 ? (_jsx(Chip, { clickable: true, size: "small", label: linkedSources[0].label ?? linkedSources[0].path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${linkedSources[0].path}` })) : (_jsx(ChipPopper, { label: `${linkedSources[0].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.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
59
|
}) })] }) }));
|
|
60
60
|
};
|
|
61
61
|
export default memo(ObservableTable);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
/// <reference types="vitest" />
|
|
3
2
|
import { render, screen, within } from '@testing-library/react';
|
|
4
3
|
import userEvent from '@testing-library/user-event';
|
|
5
4
|
import { createElement } from 'react';
|
|
@@ -16,7 +15,7 @@ vi.mock('components/elements/PluginTypography', () => ({
|
|
|
16
15
|
const makeObservable = (overrides = {}) => ({
|
|
17
16
|
type: 'ip',
|
|
18
17
|
value: '192.168.1.1',
|
|
19
|
-
|
|
18
|
+
sources: [],
|
|
20
19
|
...overrides
|
|
21
20
|
});
|
|
22
21
|
const renderTable = (observables) => {
|
|
@@ -38,12 +37,25 @@ describe('ObservableTable', () => {
|
|
|
38
37
|
await user.click(screen.getByText('page.cases.observables.columns.type'));
|
|
39
38
|
expect(getObservableValues()).toEqual(['bravo', 'charlie', 'alpha']);
|
|
40
39
|
});
|
|
41
|
-
it('sorts observables numerically by their
|
|
40
|
+
it('sorts observables numerically by their source count', async () => {
|
|
42
41
|
const user = userEvent.setup();
|
|
43
42
|
renderTable([
|
|
44
|
-
makeObservable({
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
makeObservable({
|
|
44
|
+
value: 'three',
|
|
45
|
+
sources: [
|
|
46
|
+
{ id: 'one', type: 'hit' },
|
|
47
|
+
{ id: 'two', type: 'hit' },
|
|
48
|
+
{ id: 'three', type: 'hit' }
|
|
49
|
+
]
|
|
50
|
+
}),
|
|
51
|
+
makeObservable({ value: 'one', sources: [{ id: 'one', type: 'hit' }] }),
|
|
52
|
+
makeObservable({
|
|
53
|
+
value: 'two',
|
|
54
|
+
sources: [
|
|
55
|
+
{ id: 'one', type: 'hit' },
|
|
56
|
+
{ id: 'two', type: 'hit' }
|
|
57
|
+
]
|
|
58
|
+
})
|
|
47
59
|
]);
|
|
48
60
|
await user.click(screen.getByText('page.cases.observables.columns.seen_in'));
|
|
49
61
|
expect(getObservableValues()).toEqual(['one', 'two', 'three']);
|
|
@@ -67,9 +79,9 @@ describe('ObservableTable', () => {
|
|
|
67
79
|
{ id: 'source-1', type: 'hit', path: 'alerts/one', label: 'Alert one', escalation: 'evidence' },
|
|
68
80
|
{
|
|
69
81
|
id: 'source-2',
|
|
70
|
-
type: '
|
|
71
|
-
path: '
|
|
72
|
-
label: '
|
|
82
|
+
type: 'event',
|
|
83
|
+
path: 'events/two',
|
|
84
|
+
label: 'Event two',
|
|
73
85
|
escalation: 'evidence'
|
|
74
86
|
},
|
|
75
87
|
{ id: 'source-3', type: 'case', escalation: 'malicious' }
|
|
@@ -80,6 +92,6 @@ describe('ObservableTable', () => {
|
|
|
80
92
|
expect(screen.getAllByText('evidence')).toHaveLength(1);
|
|
81
93
|
expect(screen.getByText('malicious')).toBeInTheDocument();
|
|
82
94
|
await user.click(screen.getByText('Alert one (+1)'));
|
|
83
|
-
expect(screen.getByText('
|
|
95
|
+
expect(screen.getByText('Event two').closest('a')).toHaveAttribute('href', '/cases/case-123/events/two');
|
|
84
96
|
});
|
|
85
97
|
});
|
|
@@ -4,7 +4,7 @@ export type ObservableRole = 'threat' | 'target' | 'indicator';
|
|
|
4
4
|
|
|
5
5
|
export interface ObservableSource {
|
|
6
6
|
id: string;
|
|
7
|
-
type: 'hit' | '
|
|
7
|
+
type: 'hit' | 'event' | 'case';
|
|
8
8
|
path?: string;
|
|
9
9
|
label?: string;
|
|
10
10
|
escalation?: string;
|
|
@@ -13,12 +13,10 @@ export interface ObservableSource {
|
|
|
13
13
|
export interface ObservableEntry {
|
|
14
14
|
type: ObservableType;
|
|
15
15
|
value: string;
|
|
16
|
-
/** IDs of the hits/observables this observable was seen in */
|
|
17
|
-
seenIn: string[];
|
|
18
16
|
/** Resolved source metadata for each seenIn item */
|
|
19
17
|
sources?: ObservableSource[];
|
|
20
18
|
/** Classified role of this observable */
|
|
21
19
|
role?: ObservableRole;
|
|
22
20
|
}
|
|
23
21
|
|
|
24
|
-
export type OriginType = 'hit' | '
|
|
22
|
+
export type OriginType = 'hit' | 'event';
|
|
@@ -6,13 +6,12 @@ import type { ObservableEntry, ObservableRole, ObservableSource, ObservableType
|
|
|
6
6
|
/** All Related fields that carry asset values */
|
|
7
7
|
export declare const OBSERVABLE_FIELDS: ObservableType[];
|
|
8
8
|
/** Extract (type, value, seenInId) triples from a record's related field */
|
|
9
|
-
export declare const extractObservables: (related: Related | undefined
|
|
9
|
+
export declare const extractObservables: (related: Related | undefined) => {
|
|
10
10
|
type: ObservableType;
|
|
11
11
|
value: string;
|
|
12
|
-
id: string;
|
|
13
12
|
}[];
|
|
14
|
-
/** Deduplicate and
|
|
15
|
-
export declare const buildObservableEntries: (records:
|
|
13
|
+
/** Deduplicate observables and resolve their record IDs to case-item sources */
|
|
14
|
+
export declare const buildObservableEntries: (_case: Case, records: (Hit | Event)[]) => ObservableEntry[];
|
|
16
15
|
/**
|
|
17
16
|
* Classify an asset's role based on case-level lists and per-record outline fields.
|
|
18
17
|
*
|
|
@@ -25,5 +24,5 @@ export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) =
|
|
|
25
24
|
* Comparison is case-insensitive and trimmed.
|
|
26
25
|
*/
|
|
27
26
|
export declare const classifyRole: (value: string, _case: Case, records: Partial<Hit | Event>[]) => ObservableRole;
|
|
28
|
-
/** Resolve source metadata for an
|
|
29
|
-
export declare const
|
|
27
|
+
/** Resolve source metadata for an observable's record IDs */
|
|
28
|
+
export declare const resolveSource: (record: Hit | Event, _case: Case) => ObservableSource;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { has } from 'lodash-es';
|
|
2
|
+
import { buildPathFromID } from '../utils';
|
|
1
3
|
/** All Related fields that carry asset values */
|
|
2
4
|
export const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
|
|
3
5
|
/** Extract (type, value, seenInId) triples from a record's related field */
|
|
4
|
-
export const extractObservables = (related
|
|
6
|
+
export const extractObservables = (related) => {
|
|
5
7
|
if (!related) {
|
|
6
8
|
return [];
|
|
7
9
|
}
|
|
@@ -14,33 +16,38 @@ export const extractObservables = (related, recordId) => {
|
|
|
14
16
|
const values = Array.isArray(raw) ? raw : [raw];
|
|
15
17
|
for (const value of values) {
|
|
16
18
|
if (value) {
|
|
17
|
-
results.push({ type: field, value: String(value)
|
|
19
|
+
results.push({ type: field, value: String(value) });
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
return results;
|
|
22
24
|
};
|
|
23
|
-
/** Deduplicate and
|
|
24
|
-
export const buildObservableEntries = (records) => {
|
|
25
|
-
const map =
|
|
25
|
+
/** Deduplicate observables and resolve their record IDs to case-item sources */
|
|
26
|
+
export const buildObservableEntries = (_case, records) => {
|
|
27
|
+
const map = {};
|
|
26
28
|
for (const record of records) {
|
|
27
29
|
const related = record.related ?? record.related;
|
|
28
30
|
const recordId = record.howler?.id ?? record.howler?.id;
|
|
29
31
|
if (!recordId) {
|
|
30
32
|
continue;
|
|
31
33
|
}
|
|
32
|
-
for (const { type, value
|
|
34
|
+
for (const { type, value } of extractObservables(related)) {
|
|
33
35
|
const key = `${type}:${value}`;
|
|
34
|
-
if (!
|
|
35
|
-
map
|
|
36
|
+
if (!has(map, key)) {
|
|
37
|
+
map[key] = { type, value, sources: [], role: classifyRole(value, _case, records) };
|
|
36
38
|
}
|
|
37
|
-
const entry = map
|
|
38
|
-
if (
|
|
39
|
-
|
|
39
|
+
const entry = map[key];
|
|
40
|
+
if (entry.sources.some(existingSource => existingSource.id === recordId)) {
|
|
41
|
+
continue;
|
|
40
42
|
}
|
|
43
|
+
const source = resolveSource(record, _case);
|
|
44
|
+
if (!source) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
entry.sources.push(source);
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
|
-
return
|
|
50
|
+
return Object.values(map).filter(Boolean);
|
|
44
51
|
};
|
|
45
52
|
/**
|
|
46
53
|
* Classify an asset's role based on case-level lists and per-record outline fields.
|
|
@@ -91,24 +98,20 @@ export const classifyRole = (value, _case, records) => {
|
|
|
91
98
|
// Default: assets from related.* are IOCs
|
|
92
99
|
return 'indicator';
|
|
93
100
|
};
|
|
94
|
-
/** Resolve source metadata for an
|
|
95
|
-
export const
|
|
96
|
-
if (!
|
|
97
|
-
return
|
|
101
|
+
/** Resolve source metadata for an observable's record IDs */
|
|
102
|
+
export const resolveSource = (record, _case) => {
|
|
103
|
+
if (!_case?.items?.length) {
|
|
104
|
+
return null;
|
|
98
105
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
escalation: escalationMap.get(id)
|
|
111
|
-
};
|
|
112
|
-
})
|
|
113
|
-
.filter(Boolean);
|
|
106
|
+
const item = _case.items.find(i => i.value === record.howler.id);
|
|
107
|
+
if (!item) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
id: record.howler.id,
|
|
112
|
+
type: item.type,
|
|
113
|
+
path: item.id ? buildPathFromID(_case, item.id) : undefined,
|
|
114
|
+
label: item.name ?? item.value,
|
|
115
|
+
escalation: record.howler.escalation
|
|
116
|
+
};
|
|
114
117
|
};
|
|
@@ -6,50 +6,72 @@ import { buildObservableEntries, classifyRole } from './utils';
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
describe('buildObservableEntries', () => {
|
|
8
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
|
|
12
|
-
const
|
|
9
|
+
expect(buildObservableEntries(createMockCase(), [createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
it('extracts a single IP with a resolved source', () => {
|
|
12
|
+
const _case = createMockCase({
|
|
13
|
+
items: [{ id: 'hit-item', type: 'hit', value: 'h1', name: 'First hit' }]
|
|
14
|
+
});
|
|
15
|
+
const result = buildObservableEntries(_case, [
|
|
16
|
+
createMockHit({ howler: { id: 'h1', escalation: 'evidence' }, related: { ip: ['1.2.3.4'] } })
|
|
17
|
+
]);
|
|
13
18
|
expect(result).toHaveLength(1);
|
|
14
|
-
expect(result[0]).toEqual({
|
|
19
|
+
expect(result[0]).toEqual({
|
|
20
|
+
type: 'ip',
|
|
21
|
+
value: '1.2.3.4',
|
|
22
|
+
role: 'indicator',
|
|
23
|
+
sources: [{ id: 'h1', type: 'hit', path: 'First hit', label: 'First hit', escalation: 'evidence' }]
|
|
24
|
+
});
|
|
15
25
|
});
|
|
16
26
|
it('extracts multiple fields from a single record', () => {
|
|
17
|
-
const result = buildObservableEntries([
|
|
27
|
+
const result = buildObservableEntries(createMockCase(), [
|
|
18
28
|
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
|
|
19
29
|
]);
|
|
20
30
|
const types = result.map(a => a.type).sort();
|
|
21
31
|
expect(types).toEqual(['ip', 'user']);
|
|
22
32
|
});
|
|
23
33
|
it('deduplicates the same observable value across multiple records', () => {
|
|
24
|
-
const
|
|
34
|
+
const _case = createMockCase({
|
|
35
|
+
items: [
|
|
36
|
+
{ id: 'hit-item', type: 'hit', value: 'h1', name: 'First hit' },
|
|
37
|
+
{ id: 'event-item', type: 'event', value: 'obs1', name: 'First event' }
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
const result = buildObservableEntries(_case, [
|
|
25
41
|
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
26
42
|
createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
|
|
27
43
|
]);
|
|
28
44
|
expect(result).toHaveLength(1);
|
|
29
|
-
expect(result[0].
|
|
45
|
+
expect(result[0].sources).toEqual([
|
|
46
|
+
{ id: 'h1', type: 'hit', path: 'First hit', label: 'First hit', escalation: undefined },
|
|
47
|
+
{ id: 'obs1', type: 'event', path: 'First event', label: 'First event', escalation: undefined }
|
|
48
|
+
]);
|
|
30
49
|
});
|
|
31
50
|
it('keeps distinct observable values as separate entries', () => {
|
|
32
|
-
const result = buildObservableEntries([
|
|
51
|
+
const result = buildObservableEntries(createMockCase(), [
|
|
33
52
|
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
34
53
|
createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
|
|
35
54
|
]);
|
|
36
55
|
expect(result).toHaveLength(2);
|
|
37
56
|
});
|
|
38
|
-
it('does not duplicate
|
|
39
|
-
const
|
|
57
|
+
it('does not duplicate sources when the same record appears twice for the same observable', () => {
|
|
58
|
+
const _case = createMockCase({ items: [{ id: 'hit-item', type: 'hit', value: 'h1' }] });
|
|
59
|
+
const result = buildObservableEntries(_case, [
|
|
40
60
|
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
41
61
|
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
|
|
42
62
|
]);
|
|
43
|
-
expect(result[0].
|
|
63
|
+
expect(result[0].sources).toEqual([{ id: 'h1', type: 'hit', path: 'h1', label: 'h1', escalation: undefined }]);
|
|
44
64
|
});
|
|
45
65
|
it('skips records with no howler.id', () => {
|
|
46
66
|
const noId = { related: { ip: ['1.2.3.4'] } };
|
|
47
|
-
expect(buildObservableEntries([noId])).toEqual([]);
|
|
67
|
+
expect(buildObservableEntries(createMockCase(), [noId])).toEqual([]);
|
|
48
68
|
});
|
|
49
69
|
it('handles the scalar `id` field on Related', () => {
|
|
50
|
-
const result = buildObservableEntries(
|
|
70
|
+
const result = buildObservableEntries(createMockCase(), [
|
|
71
|
+
createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })
|
|
72
|
+
]);
|
|
51
73
|
expect(result).toHaveLength(1);
|
|
52
|
-
expect(result[0]).toEqual({ type: 'id', value: 'some-id',
|
|
74
|
+
expect(result[0]).toEqual({ type: 'id', value: 'some-id', role: 'indicator', sources: [] });
|
|
53
75
|
});
|
|
54
76
|
it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
|
|
55
77
|
const related = {
|
|
@@ -60,7 +82,7 @@ describe('buildObservableEntries', () => {
|
|
|
60
82
|
uri: ['https://example.com'],
|
|
61
83
|
signature: ['rule-X']
|
|
62
84
|
};
|
|
63
|
-
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
|
|
85
|
+
const result = buildObservableEntries(createMockCase(), [createMockHit({ howler: { id: 'h1' }, related })]);
|
|
64
86
|
const types = result.map(a => a.type).sort();
|
|
65
87
|
expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
|
|
66
88
|
});
|
|
@@ -220,7 +220,6 @@ const OverviewViewer = () => {
|
|
|
220
220
|
pt: 1,
|
|
221
221
|
mt: -1,
|
|
222
222
|
'& > *': { width: '100%' },
|
|
223
|
-
'& > div > :first-child': { mt: 0 },
|
|
224
223
|
backgroundColor: activeTheme.palette.background.default,
|
|
225
224
|
color: activeTheme.palette.text.primary
|
|
226
225
|
}, children: _jsx(HitOverview, { content: content || startingTemplate, hit: exampleHit }) }) })] }))] })] }));
|
package/index.css
CHANGED
package/package.json
CHANGED
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"react-markdown": "^10.1.0",
|
|
57
57
|
"react-pluggable": "^0.4.3",
|
|
58
58
|
"react-resize-detector": "^9.1.1",
|
|
59
|
-
"react-router": "
|
|
60
|
-
"react-router-dom": "7.
|
|
59
|
+
"react-router": "7.18.1",
|
|
60
|
+
"react-router-dom": "7.18.1",
|
|
61
61
|
"react-syntax-highlighter": "^15.6.6",
|
|
62
62
|
"rehype-raw": "^7.0.0",
|
|
63
63
|
"remark": "^15.0.1",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"url": "https://github.com/CybercentreCanada/howler"
|
|
94
94
|
},
|
|
95
95
|
"type": "module",
|
|
96
|
-
"version": "2.19.0-dev.
|
|
96
|
+
"version": "2.19.0-dev.1344",
|
|
97
97
|
"exports": {
|
|
98
98
|
"./i18n": "./i18n.js",
|
|
99
99
|
"./index.css": "./index.css",
|