@cccsaurora/howler-ui 3.1.0-dev.1424 → 3.1.0-dev.1426
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/app/hooks/useMatchers.d.ts +2 -1
- package/components/app/hooks/useMatchers.js +4 -1
- package/components/app/hooks/useMatchers.test.js +10 -0
- package/components/elements/display/Markdown.js +9 -3
- package/components/elements/display/Markdown.test.d.ts +1 -0
- package/components/elements/display/Markdown.test.js +25 -0
- package/components/elements/display/json/JSONViewer.js +10 -3
- package/components/elements/display/json/JSONViewer.test.d.ts +1 -0
- package/components/elements/display/json/JSONViewer.test.js +35 -0
- package/components/elements/hit/HitBanner.js +15 -66
- package/components/elements/hit/HitBanner.test.d.ts +1 -0
- package/components/elements/hit/HitBanner.test.js +209 -0
- package/components/elements/hit/HitCard.js +1 -1
- package/components/elements/hit/HitLabels.js +1 -1
- package/components/elements/hit/HitOutline.d.ts +2 -0
- package/components/elements/hit/HitOutline.js +73 -28
- package/components/elements/hit/HitOutline.test.d.ts +1 -0
- package/components/elements/hit/HitOutline.test.js +63 -0
- package/components/elements/hit/elements/AnalyticLink.js +4 -3
- package/components/elements/hit/elements/AnalyticLink.test.d.ts +1 -0
- package/components/elements/hit/elements/AnalyticLink.test.js +26 -0
- package/components/routes/help/TemplateDocumentation.js +14 -3
- package/components/routes/help/markdown/en/templates.md.js +1 -1
- package/locales/en/translation.json +2 -0
- package/locales/fr/translation.json +2 -0
- package/package.json +1 -3
- package/components/elements/hit/outlines/DefaultOutline.d.ts +0 -12
- package/components/elements/hit/outlines/DefaultOutline.js +0 -45
- package/components/elements/hit/outlines/al/AssemblyLineRules.d.ts +0 -5
- package/components/elements/hit/outlines/al/AssemblyLineRules.js +0 -46
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
+
import type { Template } from '@cccsaurora/howler-ui/models/entities/generated/Template';
|
|
2
3
|
import type { WithMetadata } from '@cccsaurora/howler-ui/models/WithMetadata';
|
|
3
4
|
declare const useMatchers: (lazy?: boolean) => {
|
|
4
5
|
getMatchingDossiers: (hit: WithMetadata<Hit>) => Promise<import("../../../models/entities/generated/Dossier").Dossier[]>;
|
|
5
6
|
getMatchingOverview: (hit: WithMetadata<Hit>) => Promise<import("../../../models/entities/generated/Overview").Overview>;
|
|
6
|
-
getMatchingTemplate: (hit: WithMetadata<Hit
|
|
7
|
+
getMatchingTemplate: (hit: WithMetadata<Hit>, providedTemplate?: Template) => Promise<Template>;
|
|
7
8
|
getMatchingAnalytic: (hit: WithMetadata<Hit>) => Promise<import("../../../models/entities/generated/Analytic").Analytic>;
|
|
8
9
|
};
|
|
9
10
|
export default useMatchers;
|
|
@@ -5,7 +5,10 @@ import { useContextSelector } from 'use-context-selector';
|
|
|
5
5
|
import { RecordContext } from '../providers/RecordProvider';
|
|
6
6
|
const useMatchers = (lazy = false) => {
|
|
7
7
|
const getRecord = useContextSelector(RecordContext, ctx => ctx.getRecord);
|
|
8
|
-
const getMatchingTemplate = useCallback(async (hit) => {
|
|
8
|
+
const getMatchingTemplate = useCallback(async (hit, providedTemplate) => {
|
|
9
|
+
if (providedTemplate) {
|
|
10
|
+
return providedTemplate;
|
|
11
|
+
}
|
|
9
12
|
if (!hit) {
|
|
10
13
|
return null;
|
|
11
14
|
}
|
|
@@ -63,6 +63,16 @@ describe('useMatchers', () => {
|
|
|
63
63
|
const template = await result.current.getMatchingTemplate(undefined);
|
|
64
64
|
expect(template).toBeNull();
|
|
65
65
|
});
|
|
66
|
+
it('should prefer a provided template over hit metadata', async () => {
|
|
67
|
+
const { has } = await import('lodash-es');
|
|
68
|
+
const providedTemplate = { ...mockTemplate, name: 'provided-template' };
|
|
69
|
+
has.mockReturnValue(true);
|
|
70
|
+
const { result } = renderHook(() => useMatchers());
|
|
71
|
+
const template = await result.current.getMatchingTemplate(mockHitWithMetadata, providedTemplate);
|
|
72
|
+
expect(template).toBe(providedTemplate);
|
|
73
|
+
expect(has).not.toHaveBeenCalled();
|
|
74
|
+
expect(mockGetRecord).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
66
76
|
it('should return template from metadata when it exists', async () => {
|
|
67
77
|
const { has } = await import('lodash-es');
|
|
68
78
|
has.mockReturnValue(true);
|
|
@@ -73,17 +73,23 @@ const Markdown = ({ md, components = {}, disableLinks = false }) => {
|
|
|
73
73
|
return _jsx("pre", { className: "mermaid", children: node.children[0].value });
|
|
74
74
|
}
|
|
75
75
|
if (match?.[1] === 'json') {
|
|
76
|
+
// Allows passing of settings like so:
|
|
77
|
+
// ```json[hideSearch=true]
|
|
78
|
+
const opts = /.*\[(.+)\].*/.exec(className ?? '')?.[1];
|
|
79
|
+
const options = Object.fromEntries((opts ? opts.split(',') : [])
|
|
80
|
+
.map(entry => entry.split('=', 2))
|
|
81
|
+
.filter(pair => pair.length === 2 && !!pair[0]));
|
|
76
82
|
try {
|
|
77
|
-
return _jsx(JSONViewer, { data: JSON.parse(node.children[0].value) });
|
|
83
|
+
return (_jsx(JSONViewer, { data: JSON.parse(node.children[0].value), hideSearch: options.hideSearch === 'true' }));
|
|
78
84
|
}
|
|
79
85
|
catch {
|
|
80
86
|
return _jsx("code", { style: { color: 'red' }, children: t('markdown.json.invalid') });
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
return match ? (_jsx(SyntaxHighlighter
|
|
84
|
-
//
|
|
90
|
+
// oxlint-disable-next-line react/no-children-prop typescript/no-base-to-string
|
|
85
91
|
, {
|
|
86
|
-
//
|
|
92
|
+
// oxlint-disable-next-line react/no-children-prop typescript/no-base-to-string
|
|
87
93
|
children: String(children).replace(/\n$/, ''), style: isDark ? oneDark : oneLight, language: match[1], PreTag: "div", ...props })) : (_jsx("code", { className: className, ...props, children: children }));
|
|
88
94
|
},
|
|
89
95
|
blockquote({ children }) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import Markdown from './Markdown';
|
|
5
|
+
vi.mock('commons/components/app/hooks', () => ({
|
|
6
|
+
useAppTheme: () => ({ isDark: false })
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('mermaid', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
initialize: vi.fn(),
|
|
11
|
+
run: vi.fn()
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('./Notebook', () => ({
|
|
15
|
+
Notebook: () => null
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('./json/JSONViewer', () => ({
|
|
18
|
+
default: ({ data, hideSearch }) => (_jsx("output", { id: "json-viewer", children: JSON.stringify({ data, hideSearch }) }))
|
|
19
|
+
}));
|
|
20
|
+
describe('Markdown', () => {
|
|
21
|
+
it('forwards hideSearch from JSON fence options to the viewer', () => {
|
|
22
|
+
render(_jsx(Markdown, { md: '```json[hideSearch=true]\n{"value":"visible"}\n```' }));
|
|
23
|
+
expect(screen.getByTestId('json-viewer')).toHaveTextContent(JSON.stringify({ data: { value: 'visible' }, hideSearch: true }));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -11,7 +11,6 @@ import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
|
11
11
|
import { validateRegex } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
12
12
|
import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
|
|
13
13
|
import { removeEmpty, searchObject } from '@cccsaurora/howler-ui/utils/utils';
|
|
14
|
-
const THROTTLER = new Throttler(150);
|
|
15
14
|
const JSONViewer = ({ data, collapse = true, hideSearch = false, filter }) => {
|
|
16
15
|
const { t } = useTranslation();
|
|
17
16
|
const { isDark } = useAppTheme();
|
|
@@ -19,13 +18,21 @@ const JSONViewer = ({ data, collapse = true, hideSearch = false, filter }) => {
|
|
|
19
18
|
const [flat] = useMyLocalStorageItem(StorageKey.FLATTEN_JSON);
|
|
20
19
|
const [query, setQuery] = useState('');
|
|
21
20
|
const [result, setResult] = useState(null);
|
|
21
|
+
const throttler = useMemo(() => new Throttler(150), []);
|
|
22
22
|
useEffect(() => {
|
|
23
|
-
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
throttler.debounce(() => {
|
|
25
|
+
if (cancelled) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
24
28
|
const filteredData = removeEmpty(data, compact);
|
|
25
29
|
const searchedData = searchObject(filteredData, filter ?? query, flat);
|
|
26
30
|
setResult(searchedData);
|
|
27
31
|
});
|
|
28
|
-
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
};
|
|
35
|
+
}, [compact, data, filter, flat, query, throttler]);
|
|
29
36
|
const hasError = useMemo(() => !validateRegex(filter ?? query), [query, filter]);
|
|
30
37
|
const shouldCollapse = useCallback((field) => {
|
|
31
38
|
return (field.name !== 'root' && field.type !== 'object') || field.namespace.length > 3;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import JSONViewer from './JSONViewer';
|
|
5
|
+
vi.mock('commons/components/app/hooks', () => ({
|
|
6
|
+
useAppTheme: () => ({ isDark: false })
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('components/elements/addons/search/phrase/Phrase', () => ({
|
|
9
|
+
default: () => _jsx("input", {})
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('components/hooks/useMyLocalStorage', () => ({
|
|
12
|
+
useMyLocalStorageItem: (_key, defaultValue) => [defaultValue]
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('react-i18next', () => ({
|
|
15
|
+
useTranslation: () => ({ t: (key) => key })
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('utils/utils', () => ({
|
|
18
|
+
removeEmpty: vi.fn(),
|
|
19
|
+
searchObject: vi.fn()
|
|
20
|
+
}));
|
|
21
|
+
describe('JSONViewer', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
it('does not process a debounced result after unmounting', async () => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
const { removeEmpty } = await import('utils/utils');
|
|
28
|
+
const { unmount } = render(_jsx(JSONViewer, { data: { value: 'visible' } }));
|
|
29
|
+
unmount();
|
|
30
|
+
act(() => {
|
|
31
|
+
vi.advanceTimersByTime(150);
|
|
32
|
+
});
|
|
33
|
+
expect(removeEmpty).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { OpenInNew } from '@mui/icons-material';
|
|
3
|
-
import { Box, Chip,
|
|
4
|
-
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
3
|
+
import { Box, Chip, Grid, Stack, Tooltip, Typography, chipClasses, useTheme } from '@mui/material';
|
|
5
4
|
import { uniq } from 'lodash-es';
|
|
6
5
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
7
|
-
import {
|
|
6
|
+
import { Fragment, useCallback, useMemo } from 'react';
|
|
8
7
|
import { Trans, useTranslation } from 'react-i18next';
|
|
9
8
|
import { usePluginStore } from 'react-pluggable';
|
|
10
9
|
import { ESCALATION_COLORS, PROVIDER_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
@@ -19,7 +18,6 @@ import { HitLayout } from './HitLayout';
|
|
|
19
18
|
import RelatedRecords from './related/RelatedRecords';
|
|
20
19
|
const HitBanner = ({ hit, lazy = false, layout = HitLayout.NORMAL, showAssigned = true }) => {
|
|
21
20
|
const { t } = useTranslation();
|
|
22
|
-
const { config } = useContext(ApiConfigContext);
|
|
23
21
|
const theme = useTheme();
|
|
24
22
|
const pluginStore = usePluginStore();
|
|
25
23
|
const compressed = useMemo(() => layout === HitLayout.DENSE, [layout]);
|
|
@@ -30,75 +28,26 @@ const HitBanner = ({ hit, lazy = false, layout = HitLayout.NORMAL, showAssigned
|
|
|
30
28
|
}
|
|
31
29
|
return PROVIDER_COLORS[hit?.event.provider] ?? stringToColor(hit?.event.provider);
|
|
32
30
|
}, [hit?.event.provider]);
|
|
33
|
-
const mitreId = useMemo(() => {
|
|
34
|
-
if (hit.threat?.framework?.toLowerCase().startsWith('mitre')) {
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
let _id = hit.threat?.tactic?.id;
|
|
38
|
-
if (_id && config.lookups.icons.includes(_id)) {
|
|
39
|
-
return _id;
|
|
40
|
-
}
|
|
41
|
-
_id = hit.threat?.technique?.id;
|
|
42
|
-
if (_id && config.lookups.icons.includes(_id)) {
|
|
43
|
-
return _id;
|
|
44
|
-
}
|
|
45
|
-
}, [config.lookups.icons, hit.threat?.framework, hit.threat?.tactic?.id, hit.threat?.technique?.id]);
|
|
46
|
-
const iconUrl = useMemo(() => {
|
|
47
|
-
if (!mitreId) {
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
return `/api/static/mitre/${mitreId}.svg`;
|
|
51
|
-
}, [mitreId]);
|
|
52
|
-
const leftBox = useMemo(() => (_jsx(HitBannerTooltip, { hit: hit, children: _jsxs(Box, { sx: {
|
|
53
|
-
gridColumn: { xs: 'span 3', sm: 'span 1' },
|
|
54
|
-
minWidth: '90px',
|
|
55
|
-
backgroundColor: providerColor,
|
|
56
|
-
color: theme.palette.getContrastText(providerColor),
|
|
57
|
-
alignSelf: 'start',
|
|
58
|
-
borderRadius: theme.shape.borderRadius,
|
|
59
|
-
p: compressed ? 0.5 : 1,
|
|
60
|
-
pt: 2,
|
|
61
|
-
pl: 1
|
|
62
|
-
}, display: "flex", flexDirection: "column", children: [_jsx(Typography, { variant: compressed ? 'caption' : 'body1', style: { wordBreak: 'break-all' }, children: hit.organization?.name ?? _jsx(Trans, { i18nKey: "unknown" }) }), iconUrl && (_jsx(Box, { sx: {
|
|
63
|
-
width: '40px',
|
|
64
|
-
height: '40px',
|
|
65
|
-
mask: `url("${iconUrl}")`,
|
|
66
|
-
maskSize: 'cover',
|
|
67
|
-
background: theme.palette.getContrastText(providerColor)
|
|
68
|
-
} }))] }) })), [compressed, hit, iconUrl, providerColor, theme.palette, theme.shape.borderRadius]);
|
|
69
31
|
/**
|
|
70
32
|
* The tooltips are necessary only when in the most compressed format
|
|
71
33
|
*/
|
|
72
34
|
const Wrapper = useCallback(({ i18nKey, value, field, ...typographyProps }) => {
|
|
73
|
-
const _children = (_jsxs(Stack, { direction: "row", spacing: 1, flex: 1, children: [_jsxs(Typography, { variant: textVariant, noWrap: compressed, textOverflow: compressed ? 'ellipsis' : 'wrap', ...typographyProps, sx: [
|
|
35
|
+
const _children = (_jsxs(Stack, { direction: "row", spacing: 1, flex: 1, children: [_jsxs(Typography, { variant: textVariant, noWrap: compressed, fontWeight: "bold", textOverflow: compressed ? 'ellipsis' : 'wrap', ...typographyProps, sx: [
|
|
74
36
|
{ display: 'flex', flexDirection: 'row' },
|
|
75
|
-
...(Array.isArray(typographyProps
|
|
37
|
+
...(typographyProps?.sx && Array.isArray(typographyProps.sx) ? typographyProps.sx : [typographyProps?.sx])
|
|
76
38
|
], children: [t(i18nKey), ":"] }), (Array.isArray(value) ? value : [value]).map(val => (_jsx(PluginTypography, { component: "span", context: "banner", variant: textVariant, noWrap: compressed, textOverflow: compressed ? 'ellipsis' : 'wrap', ...typographyProps, value: val, field: field, obj: hit }, val)))] }));
|
|
77
39
|
return compressed ? (_jsx(Tooltip, { title: Array.isArray(value) ? (_jsx("div", { children: value.map(_indicator => (_jsx("p", { style: { margin: 0, padding: 0 }, children: _indicator }, _indicator))) })) : (value), children: _children })) : (_children);
|
|
78
40
|
}, [compressed, hit, t, textVariant]);
|
|
79
|
-
return (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
compressed && {
|
|
91
|
-
[`& .${avatarClasses.root}`]: {
|
|
92
|
-
height: theme.spacing(3),
|
|
93
|
-
width: theme.spacing(3)
|
|
94
|
-
},
|
|
95
|
-
[`& .${iconButtonClasses.root}`]: {
|
|
96
|
-
height: theme.spacing(3),
|
|
97
|
-
width: theme.spacing(3)
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
], children: [_jsx(HitTimestamp, { hit: hit, layout: layout }), _jsx(Assigned, { hit: hit, layout: layout, showAssigned: showAssigned }), hit.howler.links?.[0]?.href && (_jsx(Chip, { icon: _jsx(OpenInNew, {}), label: hit.howler.links[0].title || t('hit.header.link'), size: layout !== HitLayout.COMFY ? 'small' : 'medium', component: "a", href: hit.howler.links[0].href, target: "_blank", rel: "noopener noreferrer", sx: { [`.${chipClasses.label}`]: { cursor: 'pointer !important' } }, onClick: e => {
|
|
101
|
-
e.stopPropagation();
|
|
102
|
-
} })), _jsxs(Stack, { direction: "row", spacing: layout !== HitLayout.COMFY ? 0.5 : 1, children: [_jsx(EscalationChip, { hit: hit, layout: layout }), ['in-progress', 'on-hold'].includes(hit.howler.status) && (_jsx(Chip, { sx: { width: 'fit-content', display: 'inline-flex' }, label: hit.howler.status, color: "primary" }))] }), hit.howler.related && _jsx(RelatedRecords, { hit: hit }), howlerPluginStore.plugins.flatMap(plugin => pluginStore.executeFunction(`${plugin}.status`, { hit, layout }))] })] }));
|
|
41
|
+
return (_jsx(Box, { sx: {
|
|
42
|
+
width: '100%',
|
|
43
|
+
ml: 0,
|
|
44
|
+
overflow: 'hidden',
|
|
45
|
+
textDecoration: 'none',
|
|
46
|
+
color: 'text.primary'
|
|
47
|
+
}, component: "a", href: `/hits/${hit?.howler.id}`, onClick: e => e.preventDefault(), children: _jsxs(Stack, { spacing: layout !== HitLayout.COMFY ? 0.25 : 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", alignItems: "center", children: [_jsx(HitBannerTooltip, { hit: hit, children: _jsx(Chip, { sx: { backgroundColor: providerColor, color: theme.palette.getContrastText(providerColor) }, label: hit.organization?.name ?? _jsx(Trans, { i18nKey: "unknown" }) }) }), _jsx(AnalyticLink, { lazy: lazy, hit: hit }), _jsx("div", { style: { flex: 1 } }), _jsx(EscalationChip, { hit: hit, layout: layout }), ['in-progress', 'on-hold'].includes(hit.howler.status) && (_jsx(Chip, { sx: { width: 'fit-content', display: 'inline-flex' }, label: hit.howler.status, color: "primary" })), _jsx(HitTimestamp, { hit: hit, layout: layout }), _jsx(Assigned, { hit: hit, layout: layout, showAssigned: showAssigned }), hit.howler.related?.length > 0 && _jsx(RelatedRecords, { hit: hit }), howlerPluginStore.plugins.flatMap(plugin => (_jsx(Fragment, { children: pluginStore.executeFunction(`${plugin}.status`, { hit, layout }) }, plugin)))] }), hit.howler?.rationale && (_jsxs(Typography, { flex: 1, variant: textVariant, color: ESCALATION_COLORS[hit.howler.escalation] + '.main', sx: { fontWeight: 'bold' }, children: [t('hit.header.rationale'), ": ", hit.howler.rationale] })), hit.howler?.outline && (_jsxs(_Fragment, { children: [hit.howler.outline.threat && (_jsx(Wrapper, { i18nKey: "hit.header.threat", value: hit.howler.outline.threat, field: "howler.outline.threat" })), hit.howler.outline.target && (_jsx(Wrapper, { i18nKey: "hit.header.target", value: hit.howler.outline.target, field: "howler.outline.target" })), hit.howler.outline.indicators?.length > 0 && (_jsxs(Stack, { direction: "row", spacing: layout !== HitLayout.COMFY ? 0.25 : 1, children: [_jsxs(Typography, { component: "span", variant: textVariant, fontWeight: "bold", children: [t('hit.header.indicators'), ":"] }), _jsx(Grid, { container: true, spacing: 0.5, sx: { mt: `${theme.spacing(-0.5)} !important`, ml: `${theme.spacing(0.25)} !important` }, children: uniq(hit.howler.outline.indicators).map((_indicator, index) => {
|
|
48
|
+
return (_jsx(Grid, { item: true, children: _jsxs(Stack, { direction: "row", children: [_jsx(PluginTypography, { context: "indicators", variant: textVariant, value: _indicator, children: _indicator }), index < hit.howler.outline.indicators.length - 1 && (_jsx(Typography, { variant: textVariant, children: ',' }))] }) }, _indicator));
|
|
49
|
+
}) })] })), hit.howler.outline.summary && (_jsx(Wrapper, { i18nKey: "hit.header.summary", value: hit.howler.outline.summary, paragraph: true, textOverflow: "wrap", sx: [compressed && { marginTop: `0 !important` }], field: "howler.outline.summary" })), hit.howler.links?.[0]?.href && (_jsx(Chip, { icon: _jsx(OpenInNew, {}), label: hit.howler.links[0].title || t('hit.header.link'), size: layout !== HitLayout.COMFY ? 'small' : 'medium', component: "a", href: hit.howler.links[0].href, target: "_blank", rel: "noopener noreferrer", sx: { [`.${chipClasses.label}`]: { cursor: 'pointer !important' }, alignSelf: 'start' }, onClick: e => {
|
|
50
|
+
e.stopPropagation();
|
|
51
|
+
} }))] }))] }) }));
|
|
103
52
|
};
|
|
104
53
|
export default HitBanner;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createEvent, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
5
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
6
|
+
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
7
|
+
import { createMockHit } from '@cccsaurora/howler-ui/tests/utils';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import HitBanner from './HitBanner';
|
|
10
|
+
import { HitLayout } from './HitLayout';
|
|
11
|
+
const executeFunctionMock = vi.hoisted(() => vi.fn());
|
|
12
|
+
const stringToColorMock = vi.hoisted(() => vi.fn(() => '#123456'));
|
|
13
|
+
vi.mock('react-pluggable', async () => {
|
|
14
|
+
const actual = await vi.importActual('react-pluggable');
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
usePluginStore: () => ({ executeFunction: executeFunctionMock })
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
vi.mock('react-i18next', () => ({
|
|
21
|
+
useTranslation: () => ({
|
|
22
|
+
t: (key, options) => {
|
|
23
|
+
if (options?.count !== undefined) {
|
|
24
|
+
return `${key}:${options.count}`;
|
|
25
|
+
}
|
|
26
|
+
if (options?.duration) {
|
|
27
|
+
return `${key}:${options.duration}`;
|
|
28
|
+
}
|
|
29
|
+
return key;
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
Trans: ({ i18nKey }) => _jsx("span", { children: i18nKey })
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('commons/components/app/hooks', () => ({
|
|
35
|
+
useAppUser: () => ({ user: { username: 'current-user' } })
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('components/elements/display/HowlerAvatar', () => ({
|
|
38
|
+
default: ({ userId }) => _jsx("div", { id: `avatar-${userId}`, children: userId })
|
|
39
|
+
}));
|
|
40
|
+
vi.mock('utils/utils', async () => {
|
|
41
|
+
const actual = await vi.importActual('utils/utils');
|
|
42
|
+
return {
|
|
43
|
+
...actual,
|
|
44
|
+
stringToColor: stringToColorMock
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
vi.mock('./elements/AnalyticLink', () => ({
|
|
48
|
+
default: () => _jsx("div", { id: "analytic-link", children: "analytic-link" })
|
|
49
|
+
}));
|
|
50
|
+
vi.mock('./related/RelatedRecords', () => ({
|
|
51
|
+
default: () => _jsx("div", { id: "related-records", children: "related-records" })
|
|
52
|
+
}));
|
|
53
|
+
const mockConfig = {
|
|
54
|
+
indexes: {},
|
|
55
|
+
lookups: {},
|
|
56
|
+
configuration: {
|
|
57
|
+
system: {
|
|
58
|
+
retention: {
|
|
59
|
+
limit_amount: 350,
|
|
60
|
+
limit_unit: 'days'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
c12nDef: {},
|
|
65
|
+
mapping: {}
|
|
66
|
+
};
|
|
67
|
+
const createWrapper = (viewers = {}) => {
|
|
68
|
+
const Wrapper = ({ children }) => (_jsx(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: _jsx(SocketContext.Provider, { value: {
|
|
69
|
+
viewers,
|
|
70
|
+
addListener: vi.fn(),
|
|
71
|
+
removeListener: vi.fn(),
|
|
72
|
+
emit: vi.fn(),
|
|
73
|
+
status: 1,
|
|
74
|
+
reconnect: vi.fn(),
|
|
75
|
+
open: true,
|
|
76
|
+
fetchViewers: vi.fn()
|
|
77
|
+
}, children: children }) }));
|
|
78
|
+
return Wrapper;
|
|
79
|
+
};
|
|
80
|
+
const createBannerHit = (overrides) => createMockHit({
|
|
81
|
+
organization: { id: 'org-1', name: 'Test Org' },
|
|
82
|
+
event: { provider: 'howler', created: '2024-01-01T00:00:00Z' },
|
|
83
|
+
howler: {
|
|
84
|
+
id: 'hit-123',
|
|
85
|
+
analytic: 'Analytic Name',
|
|
86
|
+
detection: 'Detection Name',
|
|
87
|
+
status: 'open',
|
|
88
|
+
assignment: 'analyst-1',
|
|
89
|
+
escalation: 'hit',
|
|
90
|
+
rationale: 'Escalation rationale',
|
|
91
|
+
related: [],
|
|
92
|
+
outline: {
|
|
93
|
+
threat: 'Threat value',
|
|
94
|
+
target: 'Target value',
|
|
95
|
+
indicators: ['ioc-a', 'ioc-b'],
|
|
96
|
+
summary: 'Summary value'
|
|
97
|
+
},
|
|
98
|
+
links: [{ href: 'https://example.com', title: 'Open source link' }]
|
|
99
|
+
},
|
|
100
|
+
...overrides
|
|
101
|
+
});
|
|
102
|
+
const renderHitBanner = ({ hit = createBannerHit(), layout = HitLayout.NORMAL, showAssigned = true, viewers = {} } = {}) => render(_jsx(HitBanner, { hit: hit, layout: layout, showAssigned: showAssigned }), {
|
|
103
|
+
wrapper: createWrapper(viewers)
|
|
104
|
+
});
|
|
105
|
+
describe('HitBanner', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
howlerPluginStore.plugins.splice(0, howlerPluginStore.plugins.length);
|
|
108
|
+
executeFunctionMock.mockReset();
|
|
109
|
+
stringToColorMock.mockClear();
|
|
110
|
+
});
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
howlerPluginStore.plugins.splice(0, howlerPluginStore.plugins.length);
|
|
113
|
+
});
|
|
114
|
+
it('renders the main banner sections for a fully populated hit', () => {
|
|
115
|
+
renderHitBanner();
|
|
116
|
+
expect(screen.getByText('Test Org')).toBeInTheDocument();
|
|
117
|
+
expect(screen.getByTestId('analytic-link')).toBeInTheDocument();
|
|
118
|
+
expect(screen.getByText('hit.header.rationale: Escalation rationale')).toBeInTheDocument();
|
|
119
|
+
expect(screen.getByText('hit.header.threat:')).toBeInTheDocument();
|
|
120
|
+
expect(screen.getByText('Threat value')).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText('hit.header.target:')).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByText('Target value')).toBeInTheDocument();
|
|
123
|
+
expect(screen.getByText('hit.header.indicators:')).toBeInTheDocument();
|
|
124
|
+
expect(screen.getByText('ioc-a')).toBeInTheDocument();
|
|
125
|
+
expect(screen.getByText('ioc-b')).toBeInTheDocument();
|
|
126
|
+
expect(screen.getByText('hit.header.summary:')).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByText('Summary value')).toBeInTheDocument();
|
|
128
|
+
expect(screen.getByRole('link', { name: 'Open source link' })).toHaveAttribute('href', 'https://example.com');
|
|
129
|
+
});
|
|
130
|
+
it.each(['in-progress', 'on-hold'])('renders the status chip for %s', status => {
|
|
131
|
+
const hit = createBannerHit({ howler: { ...createBannerHit().howler, status } });
|
|
132
|
+
renderHitBanner({ hit });
|
|
133
|
+
expect(screen.getByText(status)).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
it('hides and shows the unassigned chip based on showAssigned', () => {
|
|
136
|
+
const hit = createBannerHit({ howler: { ...createBannerHit().howler, assignment: 'unassigned' } });
|
|
137
|
+
renderHitBanner({ hit, showAssigned: false });
|
|
138
|
+
expect(screen.queryByText('app.drawer.hit.assignment.unassigned.name')).not.toBeInTheDocument();
|
|
139
|
+
renderHitBanner({ hit, showAssigned: true });
|
|
140
|
+
expect(screen.getByText('app.drawer.hit.assignment.unassigned.name')).toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
it('renders related records only when related hits are present', () => {
|
|
143
|
+
const withRelated = createBannerHit({ howler: { ...createBannerHit().howler, related: ['hit-2'] } });
|
|
144
|
+
renderHitBanner({ hit: withRelated });
|
|
145
|
+
expect(screen.getByTestId('related-records')).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
it('uses stringToColor for unknown providers and skips it for known providers', () => {
|
|
148
|
+
const unknownProviderHit = createBannerHit({
|
|
149
|
+
event: { provider: 'custom-provider', created: '2024-01-01T00:00:00Z' }
|
|
150
|
+
});
|
|
151
|
+
renderHitBanner({ hit: unknownProviderHit });
|
|
152
|
+
expect(stringToColorMock).toHaveBeenCalledWith('custom-provider');
|
|
153
|
+
stringToColorMock.mockClear();
|
|
154
|
+
const knownProviderHit = createBannerHit({ event: { provider: 'howler', created: '2024-01-01T00:00:00Z' } });
|
|
155
|
+
renderHitBanner({ hit: knownProviderHit });
|
|
156
|
+
expect(stringToColorMock).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
it('renders plugin status sections from plugin hooks', () => {
|
|
159
|
+
howlerPluginStore.plugins.push('demo-plugin');
|
|
160
|
+
executeFunctionMock.mockImplementation((name) => {
|
|
161
|
+
if (name === 'demo-plugin.status') {
|
|
162
|
+
return _jsx("span", { id: "plugin-status", children: "plugin-status" });
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
});
|
|
166
|
+
const hit = createBannerHit();
|
|
167
|
+
renderHitBanner({ hit, layout: HitLayout.COMFY });
|
|
168
|
+
expect(screen.getByTestId('plugin-status')).toBeInTheDocument();
|
|
169
|
+
expect(executeFunctionMock).toHaveBeenCalledWith('demo-plugin.status', { hit, layout: HitLayout.COMFY });
|
|
170
|
+
});
|
|
171
|
+
it('prevents default navigation when the banner root link is clicked', () => {
|
|
172
|
+
const { container } = renderHitBanner();
|
|
173
|
+
const rootLink = container.querySelector('a[href="/hits/hit-123"]');
|
|
174
|
+
expect(rootLink).toBeTruthy();
|
|
175
|
+
const clickEvent = createEvent.click(rootLink, {
|
|
176
|
+
bubbles: true,
|
|
177
|
+
cancelable: true
|
|
178
|
+
});
|
|
179
|
+
fireEvent(rootLink, clickEvent);
|
|
180
|
+
expect(clickEvent.defaultPrevented).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
it('stops propagation when the external link chip is clicked', async () => {
|
|
183
|
+
const user = userEvent.setup();
|
|
184
|
+
const onClick = vi.fn();
|
|
185
|
+
render(_jsx("div", { onClick: onClick, children: _jsx(HitBanner, { hit: createBannerHit(), layout: HitLayout.NORMAL }) }), { wrapper: createWrapper() });
|
|
186
|
+
await user.click(screen.getByRole('link', { name: 'Open source link' }));
|
|
187
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
it('renders fallback link label and omits optional outline/rationale sections when missing', () => {
|
|
190
|
+
const hit = createBannerHit({
|
|
191
|
+
organization: { id: null, name: null },
|
|
192
|
+
event: { provider: null, created: '2024-01-01T00:00:00Z' },
|
|
193
|
+
howler: {
|
|
194
|
+
...createBannerHit().howler,
|
|
195
|
+
rationale: null,
|
|
196
|
+
outline: {},
|
|
197
|
+
links: [{ href: 'https://example.com' }],
|
|
198
|
+
status: 'open'
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
renderHitBanner({ hit, layout: HitLayout.DENSE });
|
|
202
|
+
expect(screen.getByText('unknown')).toBeInTheDocument();
|
|
203
|
+
expect(screen.getByRole('link', { name: 'hit.header.link' })).toHaveAttribute('href', 'https://example.com');
|
|
204
|
+
expect(screen.queryByText('hit.header.rationale:')).not.toBeInTheDocument();
|
|
205
|
+
expect(screen.queryByText('hit.header.threat:')).not.toBeInTheDocument();
|
|
206
|
+
expect(screen.queryByText('hit.header.target:')).not.toBeInTheDocument();
|
|
207
|
+
expect(screen.queryByText('hit.header.summary:')).not.toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -19,6 +19,6 @@ const HitCard = ({ id, layout, readOnly = true, lazy = false, elevation, hit: _h
|
|
|
19
19
|
if (!hit) {
|
|
20
20
|
return _jsx(Skeleton, { variant: "rounded", height: "200px" });
|
|
21
21
|
}
|
|
22
|
-
return (_jsx(HowlerCard, { id: hit?.howler.id, tabIndex: 0, sx: { position: 'relative' }, elevation: elevation, children: _jsxs(CardContent, { children: [_jsx(HitBanner, { hit: hit, layout: layout, lazy: lazy }), _jsx(HitOutline, { hit: hit, layout: layout, lazy: lazy }), _jsx(HitLabels, { hit: hit, readOnly: readOnly })] }) }));
|
|
22
|
+
return (_jsx(HowlerCard, { id: hit?.howler.id, tabIndex: 0, sx: { position: 'relative' }, elevation: elevation, children: _jsxs(CardContent, { sx: { display: 'flex', flexDirection: 'column', alignItems: 'start' }, children: [_jsx(HitBanner, { hit: hit, layout: layout, lazy: lazy }), _jsx(HitOutline, { hit: hit, layout: layout, lazy: lazy }), _jsx(HitLabels, { hit: hit, readOnly: readOnly })] }) }));
|
|
23
23
|
};
|
|
24
24
|
export default memo(HitCard);
|
|
@@ -89,7 +89,7 @@ const HitLabels = ({ hit, readOnly = false }) => {
|
|
|
89
89
|
}));
|
|
90
90
|
}
|
|
91
91
|
}, [hit]);
|
|
92
|
-
return (_jsxs(Box, {
|
|
92
|
+
return (_jsxs(Box, { children: [_jsxs(Drawer, { open: openDrawer, onClose: () => setOpenDrawer(false), anchor: "right", PaperProps: { sx: { maxWidth: '90%', width: '500px' } }, children: [_jsx(Backdrop, { open: loading, sx: { position: 'absolute', zIndex: theme => theme.zIndex.drawer + 1 }, children: _jsx(CircularProgress, { color: "inherit" }) }), _jsxs(Stack, { direction: "column", spacing: 2, sx: { p: 2 }, children: [_jsx(Typography, { variant: "h4", children: t('hit.label.edit') }), _jsx(Box, { children: labels.map(label => {
|
|
93
93
|
const category = label.category.toLowerCase();
|
|
94
94
|
return (_jsx(Tooltip, { title: t(`hit.label.category.${category}`), children: _jsx(Chip, { icon: LABEL_TYPES[category]?.icon ?? undefined, variant: "filled", size: "small", label: label.label, onDelete: () => deleteLabel(label), sx: [
|
|
95
95
|
{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
+
import type { Template } from '@cccsaurora/howler-ui/models/entities/generated/Template';
|
|
2
3
|
import type { WithMetadata } from '@cccsaurora/howler-ui/models/WithMetadata';
|
|
3
4
|
import { HitLayout } from './HitLayout';
|
|
4
5
|
export declare const DEFAULT_FIELDS: string[];
|
|
@@ -7,5 +8,6 @@ declare const _default: import("react").NamedExoticComponent<{
|
|
|
7
8
|
lazy?: boolean;
|
|
8
9
|
layout: HitLayout;
|
|
9
10
|
forceAllFields?: boolean;
|
|
11
|
+
template?: Template;
|
|
10
12
|
}>;
|
|
11
13
|
export default _default;
|
|
@@ -1,42 +1,87 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { ContentPaste, FilterList, Info, Language, Lock, Person } from '@mui/icons-material';
|
|
3
|
+
import { IconButton, Stack, Tooltip, Typography, useTheme } from '@mui/material';
|
|
3
4
|
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
5
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
|
+
import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
|
|
4
7
|
import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
|
|
5
|
-
import
|
|
6
|
-
import
|
|
8
|
+
import get from 'lodash-es/get';
|
|
9
|
+
import isNil from 'lodash-es/isNil';
|
|
10
|
+
import isObject from 'lodash-es/isObject';
|
|
11
|
+
import { memo, useContext, useEffect, useMemo, useState } from 'react';
|
|
7
12
|
import { useTranslation } from 'react-i18next';
|
|
8
|
-
import {
|
|
13
|
+
import { Link } from 'react-router-dom';
|
|
14
|
+
import { useContextSelector } from 'use-context-selector';
|
|
15
|
+
import { PROVIDER_COLORS, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
16
|
+
import { stringToColor } from '@cccsaurora/howler-ui/utils/utils';
|
|
17
|
+
import PluginTypography from '../PluginTypography';
|
|
9
18
|
import { HitLayout } from './HitLayout';
|
|
10
|
-
import DefaultOutline from './outlines/DefaultOutline';
|
|
11
19
|
export const DEFAULT_FIELDS = ['event.created', 'howler.id', 'howler.hash'];
|
|
12
|
-
const
|
|
20
|
+
const EditIcon = ({ label, icon: Icon, link }) => (_jsx(Tooltip, { title: label, children: _jsx(IconButton, { size: "small", component: Link, to: link, "aria-label": label, children: _jsx(Icon, { sx: { height: '16px !important', width: '16px !important' } }) }) }));
|
|
21
|
+
const HitOutline = ({ hit, layout, lazy = false, forceAllFields = false, template: providedTemplate = null }) => {
|
|
22
|
+
const theme = useTheme();
|
|
13
23
|
const { t } = useTranslation();
|
|
24
|
+
const { config } = useContext(ApiConfigContext);
|
|
25
|
+
const addFilter = useContextSelector(ParameterContext, ctx => ctx?.addFilter);
|
|
14
26
|
const { getMatchingTemplate } = useMatchers(lazy);
|
|
15
27
|
const [templateFieldCount] = useMyLocalStorageItem(StorageKey.TEMPLATE_FIELD_COUNT, null);
|
|
16
28
|
const [template, setTemplate] = useState(null);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
});
|
|
29
|
+
const providerColor = useMemo(() => {
|
|
30
|
+
if (!hit?.event.provider) {
|
|
31
|
+
return PROVIDER_COLORS.unknown;
|
|
32
|
+
}
|
|
33
|
+
return PROVIDER_COLORS[hit?.event.provider] ?? stringToColor(hit?.event.provider);
|
|
34
|
+
}, [hit?.event.provider]);
|
|
35
|
+
const fields = useMemo(() => {
|
|
36
|
+
const keys = template?.keys;
|
|
37
|
+
if (!keys?.length) {
|
|
38
|
+
return DEFAULT_FIELDS;
|
|
39
|
+
}
|
|
40
|
+
if (!isNil(templateFieldCount) && !forceAllFields) {
|
|
41
|
+
return keys.slice(0, templateFieldCount);
|
|
31
42
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
return keys;
|
|
44
|
+
}, [template, templateFieldCount, forceAllFields]);
|
|
45
|
+
const editUrl = useMemo(() => {
|
|
46
|
+
const params = {
|
|
47
|
+
analytic: hit.howler.analytic,
|
|
48
|
+
type: template?.type ?? 'personal'
|
|
49
|
+
};
|
|
50
|
+
if (template?.detection) {
|
|
51
|
+
params.detection = template.detection;
|
|
38
52
|
}
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
else if (!template && hit.howler.detection) {
|
|
54
|
+
params.detection = hit.howler.detection;
|
|
55
|
+
}
|
|
56
|
+
return '/templates/view?' + new URLSearchParams(params).toString();
|
|
57
|
+
}, [template, hit]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
void getMatchingTemplate(hit, providedTemplate).then(setTemplate);
|
|
60
|
+
}, [getMatchingTemplate, hit, providedTemplate]);
|
|
61
|
+
if (fields.length < 1) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return (_jsxs(Stack, { sx: { my: 1, borderLeft: `5px solid ${providerColor}`, pl: 1, alignItems: 'stretch' }, children: [_jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(Typography, { variant: "body2", fontWeight: "bold", children: t('hit.details.title') }), template?.type === 'readonly' ? (_jsx(EditIcon, { label: t('route.templates.builtin'), icon: Lock, link: editUrl })) : !template ? (_jsx(EditIcon, { label: t('route.templates.default'), icon: Info, link: editUrl })) : template.type === 'global' ? (_jsx(EditIcon, { label: t('route.templates.global'), icon: Language, link: editUrl })) : (_jsx(EditIcon, { label: t('route.templates.personal'), icon: Person, link: editUrl }))] }), (fields ?? [])
|
|
65
|
+
.map(field => [field, get(hit, field)])
|
|
66
|
+
.map(([field, data]) => {
|
|
67
|
+
const displayedData = (Array.isArray(data) ? data.join(', ') : isObject(data) ? JSON.stringify(data) : data)?.toString();
|
|
68
|
+
if (!displayedData) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return (_jsxs(Stack, { direction: "row", spacing: 1, sx: {
|
|
72
|
+
'& .copy': { opacity: 0, cursor: 'pointer', transition: theme.transitions.create('opacity') },
|
|
73
|
+
'&:hover .copy': { opacity: 1 },
|
|
74
|
+
position: 'relative',
|
|
75
|
+
pr: '75px'
|
|
76
|
+
}, children: [_jsx(Tooltip, { title: (config.indexes.hit[field]?.description ?? t('none')).split('\n')[0], children: _jsxs(Typography, { variant: layout !== HitLayout.COMFY ? 'caption' : 'body1', fontWeight: "bold", children: [field, ":"] }) }), _jsx(PluginTypography, { context: "outline", variant: layout !== HitLayout.COMFY ? 'caption' : 'body1', whiteSpace: "normal", sx: { wordBreak: 'break-all' }, value: displayedData, field: field, obj: hit, children: displayedData }), _jsxs(Stack, { spacing: 0.25, direction: "row", sx: { position: 'absolute', right: 0, top: '50%', transform: 'translateY(-50%)' }, children: [_jsx(Tooltip, { title: t('hit.outline.copy'), children: _jsx(IconButton, { className: "copy", size: "small", onClick: e => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
void navigator.clipboard.writeText(displayedData);
|
|
80
|
+
}, children: _jsx(ContentPaste, { fontSize: "small" }) }) }), addFilter && (_jsx(Tooltip, { title: t('hit.outline.add_filter'), children: _jsx(IconButton, { className: "copy", size: "small", onClick: e => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
e.stopPropagation();
|
|
83
|
+
addFilter(`${field}:"${displayedData}"`);
|
|
84
|
+
}, children: _jsx(FilterList, { fontSize: "small" }) }) }))] })] }, field));
|
|
85
|
+
})] }));
|
|
41
86
|
};
|
|
42
87
|
export default memo(HitOutline);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { HitLayout } from './HitLayout';
|
|
7
|
+
import HitOutline from './HitOutline';
|
|
8
|
+
const addFilter = vi.fn();
|
|
9
|
+
const getMatchingTemplate = vi.fn((_hit, template) => Promise.resolve(template));
|
|
10
|
+
vi.mock('components/app/hooks/useMatchers', () => ({
|
|
11
|
+
default: () => ({ getMatchingTemplate })
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('components/app/providers/ApiConfigProvider', async () => {
|
|
14
|
+
const { createContext } = await import('react');
|
|
15
|
+
return {
|
|
16
|
+
ApiConfigContext: createContext({ config: { indexes: { hit: {} } } })
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
vi.mock('components/app/providers/ParameterProvider', () => ({
|
|
20
|
+
ParameterContext: {}
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('components/hooks/useMyLocalStorage', () => ({
|
|
23
|
+
useMyLocalStorageItem: () => [null]
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('components/elements/PluginTypography', () => ({
|
|
26
|
+
default: ({ children }) => _jsx("span", { children: children })
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('react-i18next', () => ({
|
|
29
|
+
useTranslation: () => ({ t: (key) => key })
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('use-context-selector', () => ({
|
|
32
|
+
useContextSelector: (_context, selector) => selector({ addFilter })
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('utils/constants', () => ({
|
|
35
|
+
PROVIDER_COLORS: { unknown: '#000000' },
|
|
36
|
+
StorageKey: { TEMPLATE_FIELD_COUNT: 'template-field-count' }
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('utils/utils', () => ({
|
|
39
|
+
stringToColor: () => '#ffffff'
|
|
40
|
+
}));
|
|
41
|
+
describe('HitOutline', () => {
|
|
42
|
+
it('renders supplied template fields and adds a filter for the selected value', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
const hit = {
|
|
45
|
+
event: { provider: 'endpoint' },
|
|
46
|
+
howler: { id: 'hit-1', analytic: 'analytic-1', detection: 'hit-detection' },
|
|
47
|
+
details: { values: ['first', 'second'] }
|
|
48
|
+
};
|
|
49
|
+
const template = {
|
|
50
|
+
keys: ['event.provider', 'details.values'],
|
|
51
|
+
type: 'global',
|
|
52
|
+
detection: 'template-detection'
|
|
53
|
+
};
|
|
54
|
+
render(_jsx(BrowserRouter, { children: _jsx(HitOutline, { hit: hit, layout: HitLayout.NORMAL, template: template }) }));
|
|
55
|
+
expect(await screen.findByText('event.provider:')).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText('endpoint')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('details.values:')).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByText('first, second')).toBeInTheDocument();
|
|
59
|
+
expect(screen.getByRole('link')).toHaveAttribute('href', '/templates/view?analytic=analytic-1&type=global&detection=template-detection');
|
|
60
|
+
await user.click(screen.getAllByLabelText('hit.outline.add_filter')[0]);
|
|
61
|
+
expect(addFilter).toHaveBeenCalledWith('event.provider:"endpoint"');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Link as LinkIcon } from '@mui/icons-material';
|
|
3
|
+
import { IconButton, Stack, Typography } from '@mui/material';
|
|
3
4
|
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
4
5
|
import { useEffect, useState } from 'react';
|
|
5
6
|
import { Link } from 'react-router-dom';
|
|
@@ -13,10 +14,10 @@ const AnalyticLink = ({ hit, lazy = false, compressed, alignSelf = 'start' }) =>
|
|
|
13
14
|
void getMatchingAnalytic(hit).then(analytic => setAnalyticId(analytic?.analytic_id));
|
|
14
15
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
15
16
|
}, [hit?.howler.analytic]);
|
|
16
|
-
return (_jsxs(
|
|
17
|
+
return (_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.5, children: [_jsx(IconButton, { size: "small", component: Link, onAuxClick: e => {
|
|
17
18
|
e.stopPropagation();
|
|
18
19
|
}, onClick: e => {
|
|
19
20
|
e.stopPropagation();
|
|
20
|
-
}, children:
|
|
21
|
+
}, disabled: !analyticId, to: `/analytics/${analyticId}`, target: "_blank", rel: "noopener noreferrer", children: _jsx(LinkIcon, { fontSize: "small" }) }), _jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf, '& a': { color: 'text.primary' } }, children: [hit.howler.analytic, hit.howler.detection && ' > ', hit.howler.detection] })] }));
|
|
21
22
|
};
|
|
22
23
|
export default AnalyticLink;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import AnalyticLink from './AnalyticLink';
|
|
7
|
+
const getMatchingAnalytic = vi.hoisted(() => vi.fn());
|
|
8
|
+
vi.mock('components/app/hooks/useMatchers', () => ({
|
|
9
|
+
default: () => ({ getMatchingAnalytic })
|
|
10
|
+
}));
|
|
11
|
+
describe('AnalyticLink', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
getMatchingAnalytic.mockResolvedValue({ analytic_id: 'analytic-id' });
|
|
14
|
+
});
|
|
15
|
+
it('renders an isolated link button when the analytic is resolved', async () => {
|
|
16
|
+
const user = userEvent.setup();
|
|
17
|
+
const onClick = vi.fn();
|
|
18
|
+
const hit = { howler: { analytic: 'Analytic Name', detection: 'Detection Name' } };
|
|
19
|
+
render(_jsx(BrowserRouter, { children: _jsx("div", { onClick: onClick, children: _jsx(AnalyticLink, { hit: hit }) }) }));
|
|
20
|
+
const link = await screen.findByRole('link');
|
|
21
|
+
expect(link).toHaveAttribute('href', '/analytics/analytic-id');
|
|
22
|
+
expect(screen.getByRole('heading')).toHaveTextContent('Analytic Name > Detection Name');
|
|
23
|
+
await user.click(link);
|
|
24
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -3,7 +3,7 @@ import { Card, CardContent, Stack } from '@mui/material';
|
|
|
3
3
|
import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
|
|
4
4
|
import Markdown from '@cccsaurora/howler-ui/components/elements/display/Markdown';
|
|
5
5
|
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
6
|
-
import
|
|
6
|
+
import HitOutline from '@cccsaurora/howler-ui/components/elements/hit/HitOutline';
|
|
7
7
|
import { useScrollRestoration } from '@cccsaurora/howler-ui/components/hooks/useScrollRestoration';
|
|
8
8
|
import dayjs from 'dayjs';
|
|
9
9
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
@@ -13,6 +13,11 @@ import { usePluginStore } from 'react-pluggable';
|
|
|
13
13
|
import { modifyDocumentation } from '@cccsaurora/howler-ui/utils/utils';
|
|
14
14
|
import TEMPLATES_EN from './markdown/en/templates.md';
|
|
15
15
|
import TEMPLATES_FR from './markdown/fr/templates.md';
|
|
16
|
+
const TEMPLATE = {
|
|
17
|
+
analytic: 'Cat Checker',
|
|
18
|
+
owner: 'cat',
|
|
19
|
+
type: 'personal'
|
|
20
|
+
};
|
|
16
21
|
const ALERTS = [
|
|
17
22
|
{
|
|
18
23
|
howler: { id: 'hit1', analytic: 'Cat Checker', detection: 'Listening for Meows' },
|
|
@@ -43,8 +48,14 @@ const TemplateDocumentation = () => {
|
|
|
43
48
|
ALERTS.forEach((alert, index) => {
|
|
44
49
|
markdown = markdown.replace(`$ALERT_${index + 1}`, JSON.stringify(alert, null, 2));
|
|
45
50
|
});
|
|
46
|
-
return
|
|
51
|
+
return markdown
|
|
52
|
+
.split('\n===SPLIT===\n')
|
|
53
|
+
.map(section => modifyDocumentation(section, howlerPluginStore, pluginStore));
|
|
47
54
|
}, [i18n.language, pluginStore]);
|
|
48
|
-
return (_jsxs(PageCenter, { margin: 4, width: "100%", textAlign: "left", children: [_jsx(Markdown, { md: md1 }), _jsx(Stack, { spacing: 1, children: ALERTS.map(alert => (_jsx(Card, { variant: "outlined", children: _jsx(CardContent, { children: _jsx(
|
|
55
|
+
return (_jsxs(PageCenter, { margin: 4, width: "100%", textAlign: "left", children: [_jsx(Markdown, { md: md1 }), _jsx(Stack, { spacing: 1, children: ALERTS.map(alert => (_jsx(Card, { variant: "outlined", children: _jsx(CardContent, { children: _jsx(HitOutline, { hit: alert, template: {
|
|
56
|
+
...TEMPLATE,
|
|
57
|
+
detection: alert.howler.detection,
|
|
58
|
+
keys: Object.keys(alert['event']).map(key => `event.${key}`)
|
|
59
|
+
}, layout: HitLayout.NORMAL, forceAllFields: true }) }) }, alert.howler.id))) }), _jsx(Markdown, { md: md2 })] }));
|
|
49
60
|
};
|
|
50
61
|
export default TemplateDocumentation;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export default "# Howler Templates\n\nHowler is, fundamentally, an application that allows analysts to triage hits and alerts. In order to make sure analysts can do this as efficiently as possible, we want to have the ability to present relevant data for a given alert to analysts in an easy, understandable way.\n\nTo this end, Howler allows analysts and detection engineers to create **templates**, which allow various analytics and their detections to present fields and data relevant to triaging alerts generated by that analytic/detection. For example, let's consider two different alerts, by two different detections:\n\n```json\n$ALERT_1\n```\n\n```json\n$ALERT_2\n```\n\nNote that while both share some similar fields, they also differ. We want each of these alert cards to present different data - for that, we can use templates. This allows us to show both hits in the same list, but with differing fields displayed:\n\n===SPLIT===\n\nAs we can see, by specifying a template for each of the detections, different data will be presented to the analyst. To do so, you can use the template creator [here]($CURRENT_URL/templates/view?type=personal).\n\n```alert\nNote that you must have ingested some hits for the given analytic/detection pair for it to show as an option in the template creation UI!\n```\n"
|
|
1
|
+
export default "# Howler Templates\n\nHowler is, fundamentally, an application that allows analysts to triage hits and alerts. In order to make sure analysts can do this as efficiently as possible, we want to have the ability to present relevant data for a given alert to analysts in an easy, understandable way.\n\nTo this end, Howler allows analysts and detection engineers to create **templates**, which allow various analytics and their detections to present fields and data relevant to triaging alerts generated by that analytic/detection. For example, let's consider two different alerts, by two different detections:\n\n```json[hideSearch=true]\n$ALERT_1\n```\n\n---\n\n```json[hideSearch=true]\n$ALERT_2\n```\n\nNote that while both share some similar fields, they also differ. We want each of these alert cards to present different data - for that, we can use templates. This allows us to show both hits in the same list, but with differing fields displayed:\n\n===SPLIT===\n\nAs we can see, by specifying a template for each of the detections, different data will be presented to the analyst. To do so, you can use the template creator [here]($CURRENT_URL/templates/view?type=personal).\n\n```alert\nNote that you must have ingested some hits for the given analytic/detection pair for it to show as an option in the template creation UI!\n```\n"
|
|
@@ -212,6 +212,8 @@
|
|
|
212
212
|
"hit.notebook.select": "Please Select a notebook",
|
|
213
213
|
"hit.notebook.tooltip": "Open in Jupyterhub",
|
|
214
214
|
"hit.open": "Open Hit",
|
|
215
|
+
"hit.outline.add_filter": "Add filter",
|
|
216
|
+
"hit.outline.copy": "Copy value",
|
|
215
217
|
"hit.overview.missing": "No overview has been created for this hit. In order to create an overview, press the add button to the right.",
|
|
216
218
|
"hit.panel.aggregation.run": "Create Summary",
|
|
217
219
|
"hit.panel.bundles.open": "Parent Bundles",
|
|
@@ -212,6 +212,8 @@
|
|
|
212
212
|
"hit.notebook.select": "Veuillez sélectionner un notebook",
|
|
213
213
|
"hit.notebook.tooltip": "Ouvrir dans Jupyterhub",
|
|
214
214
|
"hit.open": "Ouvrir hit",
|
|
215
|
+
"hit.outline.add_filter": "Ajouter un filtre",
|
|
216
|
+
"hit.outline.copy": "Copier la valeur",
|
|
215
217
|
"hit.overview.missing": "Aucune vue d'ensemble n'a été créée pour ce hit. Pour créer une vue d'ensemble, cliquez sur le bouton pour ajouter à droite.",
|
|
216
218
|
"hit.panel.aggregation.run": "Créer un sommaire",
|
|
217
219
|
"hit.panel.bundles.open": "Groupes parentaux",
|
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"url": "https://github.com/CybercentreCanada/howler"
|
|
94
94
|
},
|
|
95
95
|
"type": "module",
|
|
96
|
-
"version": "3.1.0-dev.
|
|
96
|
+
"version": "3.1.0-dev.1426",
|
|
97
97
|
"exports": {
|
|
98
98
|
"./i18n": "./i18n.js",
|
|
99
99
|
"./index.css": "./index.css",
|
|
@@ -229,11 +229,9 @@
|
|
|
229
229
|
"./components/elements/addons/layout/vsbox/*": "./components/elements/addons/layout/vsbox/*.js",
|
|
230
230
|
"./components/elements/hit/elements/*": "./components/elements/hit/elements/*.js",
|
|
231
231
|
"./components/elements/hit/actions/*": "./components/elements/hit/actions/*.js",
|
|
232
|
-
"./components/elements/hit/outlines/*": "./components/elements/hit/outlines/*.js",
|
|
233
232
|
"./components/elements/hit/related/*": "./components/elements/hit/related/*.js",
|
|
234
233
|
"./components/elements/hit/aggregate/*": "./components/elements/hit/aggregate/*.js",
|
|
235
234
|
"./components/elements/hit/grid/*": "./components/elements/hit/grid/*.js",
|
|
236
|
-
"./components/elements/hit/outlines/al/*": "./components/elements/hit/outlines/al/*.js",
|
|
237
235
|
"./components/app/hooks/*": "./components/app/hooks/*.js",
|
|
238
236
|
"./components/app/providers/*": "./components/app/providers/*.js",
|
|
239
237
|
"./components/app/drawers/*": "./components/app/drawers/*.js",
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
-
import type { Template } from '@cccsaurora/howler-ui/models/entities/generated/Template';
|
|
3
|
-
import React from 'react';
|
|
4
|
-
import { HitLayout } from '../HitLayout';
|
|
5
|
-
declare const _default: React.NamedExoticComponent<{
|
|
6
|
-
hit: Hit;
|
|
7
|
-
fields: string[];
|
|
8
|
-
template?: Template;
|
|
9
|
-
layout?: HitLayout;
|
|
10
|
-
readonly?: boolean;
|
|
11
|
-
}>;
|
|
12
|
-
export default _default;
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Info, Language, Lock, Person } from '@mui/icons-material';
|
|
3
|
-
import { Box, IconButton, Tooltip, Typography } from '@mui/material';
|
|
4
|
-
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
5
|
-
import PluginTypography from '@cccsaurora/howler-ui/components/elements/PluginTypography';
|
|
6
|
-
import { get, isObject } from 'lodash-es';
|
|
7
|
-
import React, { memo, useCallback, useContext } from 'react';
|
|
8
|
-
import { useTranslation } from 'react-i18next';
|
|
9
|
-
import { useNavigate } from 'react-router-dom';
|
|
10
|
-
import { HitLayout } from '../HitLayout';
|
|
11
|
-
const DefaultOutline = ({ hit, fields, template, layout = HitLayout.NORMAL, readonly = false }) => {
|
|
12
|
-
const { t } = useTranslation();
|
|
13
|
-
const { config } = useContext(ApiConfigContext);
|
|
14
|
-
const navigate = useNavigate();
|
|
15
|
-
const handleOpen = useCallback((event) => {
|
|
16
|
-
event.stopPropagation();
|
|
17
|
-
const params = {
|
|
18
|
-
analytic: hit.howler.analytic,
|
|
19
|
-
type: template?.type ?? 'personal'
|
|
20
|
-
};
|
|
21
|
-
if (template?.detection) {
|
|
22
|
-
params.detection = template.detection;
|
|
23
|
-
}
|
|
24
|
-
else if (!template && hit.howler.detection) {
|
|
25
|
-
params.detection = hit.howler.detection;
|
|
26
|
-
}
|
|
27
|
-
navigate('/templates/view?' +
|
|
28
|
-
Object.entries(params)
|
|
29
|
-
.map(([key, val]) => `${key}=${val}`)
|
|
30
|
-
.join('&'));
|
|
31
|
-
}, [hit.howler.analytic, hit.howler.detection, navigate, template]);
|
|
32
|
-
return (_jsxs(Box, { display: "grid", gridTemplateColumns: "auto 1fr", columnGap: 1, sx: { position: 'relative' }, children: [_jsx(IconButton, { size: "small", sx: { position: 'absolute', right: 0, top: 0 }, onClick: handleOpen, children: readonly ? ( // Built in template
|
|
33
|
-
_jsx(Tooltip, { title: t('route.templates.builtin'), children: _jsx(Lock, { fontSize: "small" }) })) : !template ? ( // No type specified => using the default
|
|
34
|
-
_jsx(Tooltip, { title: t('route.templates.default'), children: _jsx(Info, { fontSize: "small" }) })) : template.type === 'global' ? ( // Type is global => global template
|
|
35
|
-
_jsx(Tooltip, { title: t('route.templates.global'), children: _jsx(Language, { fontSize: "small" }) })) : (_jsx(Tooltip, { title: t('route.templates.personal'), children: _jsx(Person, { fontSize: "small" }) })) }), (fields ?? [])
|
|
36
|
-
.map(field => [field, get(hit, field)])
|
|
37
|
-
.map(([field, data]) => {
|
|
38
|
-
const displayedData = (Array.isArray(data) ? data.join(', ') : isObject(data) ? JSON.stringify(data) : data)?.toString();
|
|
39
|
-
if (!displayedData) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return (_jsxs(React.Fragment, { children: [_jsx(Tooltip, { title: (config.indexes.hit[field]?.description ?? t('none')).split('\n')[0], children: _jsxs(Typography, { variant: layout !== HitLayout.COMFY ? 'caption' : 'body1', fontWeight: "bold", children: [field, ":"] }) }), _jsx(PluginTypography, { context: "outline", variant: layout !== HitLayout.COMFY ? 'caption' : 'body1', whiteSpace: "normal", sx: { width: '100%', wordBreak: 'break-all' }, value: displayedData, field: field, obj: hit, children: displayedData })] }, field));
|
|
43
|
-
})] }));
|
|
44
|
-
};
|
|
45
|
-
export default memo(DefaultOutline);
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Lock } from '@mui/icons-material';
|
|
3
|
-
import { Chip, Grid, Stack, Tooltip, Typography } from '@mui/material';
|
|
4
|
-
import { get } from 'lodash-es';
|
|
5
|
-
import { memo } from 'react';
|
|
6
|
-
import { useTranslation } from 'react-i18next';
|
|
7
|
-
const TAGS = [
|
|
8
|
-
'assemblyline.antivirus',
|
|
9
|
-
'assemblyline.behaviour',
|
|
10
|
-
'assemblyline.heuristic',
|
|
11
|
-
'assemblyline.yara',
|
|
12
|
-
'assemblyline.attribution',
|
|
13
|
-
'assemblyline.mitre.tactic',
|
|
14
|
-
'assemblyline.mitre.technique'
|
|
15
|
-
];
|
|
16
|
-
const VERDICT_COLORS = {
|
|
17
|
-
malicious: 'error',
|
|
18
|
-
suspicious: 'warning',
|
|
19
|
-
info: 'info',
|
|
20
|
-
safe: 'primary'
|
|
21
|
-
};
|
|
22
|
-
const VERDICT_ORDER = ['malicious', 'suspicious', 'safe', 'info'];
|
|
23
|
-
const ARRAY_LIMIT = 10;
|
|
24
|
-
const sortByVerdict = (a, b) => {
|
|
25
|
-
return VERDICT_ORDER.indexOf(a.verdict) > VERDICT_ORDER.indexOf(b.verdict)
|
|
26
|
-
? 1
|
|
27
|
-
: VERDICT_ORDER.indexOf(b.verdict) > VERDICT_ORDER.indexOf(a.verdict)
|
|
28
|
-
? -1
|
|
29
|
-
: 0;
|
|
30
|
-
};
|
|
31
|
-
const AssemblyLineRules = ({ hit }) => {
|
|
32
|
-
const { t } = useTranslation();
|
|
33
|
-
const ipArr = (hit.related.ip ?? []).filter(e => !!e).slice(0, ARRAY_LIMIT);
|
|
34
|
-
const tagsArr = TAGS.map(each => get(hit, each))
|
|
35
|
-
.filter(tag => !!tag?.value)
|
|
36
|
-
.sort(sortByVerdict)
|
|
37
|
-
.slice(0, ARRAY_LIMIT);
|
|
38
|
-
return (_jsxs(Grid, { container: true, direction: "row", justifyContent: "center", sx: { position: 'relative' }, children: [_jsx(Tooltip, { title: t('route.templates.builtin'), children: _jsx(Lock, { fontSize: "small", sx: { position: 'absolute', right: 0, top: 0 } }) }), _jsx(Grid, { item: true, xs: 2, children: _jsxs(Typography, { variant: "caption", fontWeight: "bold", children: [t('outline.assemblyline.file_path'), ":"] }) }), _jsx(Grid, { item: true, xs: 10, children: _jsx(Typography, { variant: "caption", children: hit.file.path ?? t('unknown') }) }), _jsx(Grid, { item: true, xs: 2, children: _jsxs(Typography, { variant: "caption", fontWeight: "bold", children: [t('outline.assemblyline.last_modified'), ":"] }) }), _jsx(Grid, { item: true, xs: 10, children: _jsxs(Typography, { variant: "caption", children: [hit.cbs?.sharepoint?.modified?.user ?? t('unknown'), " ", t('using'), ' ', hit.cbs?.sharepoint?.modified?.application ?? t('unknown'), " ", t('on'), " ", hit.file?.mtime ?? t('unknown')] }) }), _jsx(Grid, { item: true, xs: 2, children: _jsxs(Typography, { variant: "caption", fontWeight: "bold", children: [t('outline.assemblyline.beacons'), ":"] }) }), _jsx(Grid, { item: true, xs: 10, children: _jsxs(Stack, { direction: "row", spacing: 0.5, sx: { mb: 0.5 }, children: [ipArr.map(analytic => {
|
|
39
|
-
return (_jsx(Grid, { item: true, children: _jsx(Chip, { label: analytic, variant: "outlined", size: "small" }) }, analytic));
|
|
40
|
-
}), ipArr.length < 1 && (_jsx(Grid, { item: true, children: _jsx(Chip, { label: t('none'), variant: "outlined", size: "small" }) }))] }) }), _jsx(Grid, { item: true, xs: 2, children: _jsxs(Typography, { variant: "caption", fontWeight: "bold", children: [t('outline.assemblyline.tags'), ":"] }) }), _jsx(Grid, { item: true, xs: 10, children: _jsxs(Stack, { direction: "row", spacing: 0.5, sx: { mb: 0.5 }, children: [tagsArr.map(analytic => {
|
|
41
|
-
return (_jsx(Grid, { item: true, children: _jsx(Chip, { label: analytic?.value, color: analytic?.verdict in VERDICT_COLORS
|
|
42
|
-
? VERDICT_COLORS[analytic?.verdict]
|
|
43
|
-
: 'error', variant: "outlined", size: "small" }) }, analytic?.value + analytic?.verdict + analytic?.type + analytic?.subtype));
|
|
44
|
-
}), tagsArr.length < 1 && (_jsx(Grid, { item: true, children: _jsx(Chip, { label: t('none'), variant: "outlined", size: "small" }) }))] }) })] }));
|
|
45
|
-
};
|
|
46
|
-
export default memo(AssemblyLineRules);
|