@cccsaurora/howler-ui 3.1.0-dev.1700 → 3.1.0-dev.1707
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/api/search/fields/event.d.ts +3 -0
- package/api/search/fields/event.js +8 -0
- package/api/search/fields/index.d.ts +6 -1
- package/api/search/fields/index.js +2 -1
- package/api/v2/index.d.ts +2 -1
- package/api/v2/index.js +2 -1
- package/api/v2/ingest.d.ts +3 -0
- package/api/v2/ingest.js +8 -0
- package/components/app/providers/RecordSearchProvider.test.js +2 -1
- package/components/elements/event/EventPreview.js +1 -1
- package/components/routes/cases/detail/CaseSidebar.js +17 -2
- package/components/routes/cases/modals/AddEventModal.d.ts +9 -0
- package/components/routes/cases/modals/AddEventModal.js +213 -0
- package/components/routes/cases/modals/AddEventModal.test.d.ts +1 -0
- package/components/routes/cases/modals/AddEventModal.test.js +152 -0
- package/locales/en/translation.json +19 -0
- package/locales/fr/translation.json +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { hget, joinUri } from '@cccsaurora/howler-ui/api';
|
|
2
|
+
import { indexed, uri as parentUri } from '@cccsaurora/howler-ui/api/search/fields';
|
|
3
|
+
export const uri = () => {
|
|
4
|
+
return joinUri(parentUri(), 'event');
|
|
5
|
+
};
|
|
6
|
+
export const get = async () => {
|
|
7
|
+
return indexed((await hget(uri())) ?? {});
|
|
8
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as event from '@cccsaurora/howler-ui/api/search/fields/event';
|
|
1
2
|
import * as hit from '@cccsaurora/howler-ui/api/search/fields/hit';
|
|
2
3
|
import * as user from '@cccsaurora/howler-ui/api/search/fields/user';
|
|
3
4
|
export type SearchField = {
|
|
@@ -8,6 +9,10 @@ export type SearchField = {
|
|
|
8
9
|
stored: boolean;
|
|
9
10
|
type: string;
|
|
10
11
|
description?: string;
|
|
12
|
+
deprecated?: boolean;
|
|
13
|
+
deprecated_description?: string;
|
|
14
|
+
regex?: string;
|
|
15
|
+
values?: string[];
|
|
11
16
|
};
|
|
12
17
|
export declare const uri: () => string;
|
|
13
18
|
export declare const map: (fields: {
|
|
@@ -16,4 +21,4 @@ export declare const map: (fields: {
|
|
|
16
21
|
export declare const indexed: (fields: {
|
|
17
22
|
[key: string]: SearchField;
|
|
18
23
|
}) => SearchField[];
|
|
19
|
-
export { hit, user };
|
|
24
|
+
export { event, hit, user };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { uri as parentUri } from '@cccsaurora/howler-ui/api/search';
|
|
2
|
+
import * as event from '@cccsaurora/howler-ui/api/search/fields/event';
|
|
2
3
|
import * as hit from '@cccsaurora/howler-ui/api/search/fields/hit';
|
|
3
4
|
import * as user from '@cccsaurora/howler-ui/api/search/fields/user';
|
|
4
5
|
// import urlJoin from 'url-join';
|
|
@@ -12,4 +13,4 @@ export const map = (fields) => {
|
|
|
12
13
|
export const indexed = (fields) => {
|
|
13
14
|
return map(fields).filter(field => field.indexed);
|
|
14
15
|
};
|
|
15
|
-
export { hit, user };
|
|
16
|
+
export { event, hit, user };
|
package/api/v2/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
|
|
2
2
|
import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
|
|
3
|
+
import * as ingest from '@cccsaurora/howler-ui/api/v2/ingest';
|
|
3
4
|
import * as search from '@cccsaurora/howler-ui/api/v2/search';
|
|
4
5
|
export declare const uri: () => string;
|
|
5
|
-
export { case_ as case, fuzzy, search };
|
|
6
|
+
export { case_ as case, fuzzy, ingest, search };
|
package/api/v2/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
|
|
2
2
|
import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
|
|
3
|
+
import * as ingest from '@cccsaurora/howler-ui/api/v2/ingest';
|
|
3
4
|
import * as search from '@cccsaurora/howler-ui/api/v2/search';
|
|
4
5
|
export const uri = () => {
|
|
5
6
|
return '/api/v2';
|
|
6
7
|
};
|
|
7
|
-
export { case_ as case, fuzzy, search };
|
|
8
|
+
export { case_ as case, fuzzy, ingest, search };
|
package/api/v2/ingest.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
|
|
2
|
+
import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2';
|
|
3
|
+
export const uri = (index) => {
|
|
4
|
+
return joinAllUri(parentUri(), 'ingest', index);
|
|
5
|
+
};
|
|
6
|
+
export const post = (index, records, refresh) => {
|
|
7
|
+
return hpost(uri(index), records, undefined, refresh ? new URLSearchParams({ refresh }) : undefined);
|
|
8
|
+
};
|
|
@@ -297,7 +297,8 @@ describe('RecordSearchContext', () => {
|
|
|
297
297
|
hook.result.current('test query');
|
|
298
298
|
});
|
|
299
299
|
await waitFor(() => {
|
|
300
|
-
|
|
300
|
+
const searchRequest = vi.mocked(hpost).mock.calls.find(([url]) => url === '/api/v2/search/hit')?.[1];
|
|
301
|
+
expect(searchRequest).toEqual(expect.objectContaining({
|
|
301
302
|
filters: expect.arrayContaining([
|
|
302
303
|
'event.provider:"azure"',
|
|
303
304
|
'-howler.outline.indicators:("a" OR "b")',
|
|
@@ -7,6 +7,6 @@ import { Link } from 'react-router';
|
|
|
7
7
|
const EventPreview = ({ event }) => {
|
|
8
8
|
const { t } = useTranslation();
|
|
9
9
|
const theme = useTheme();
|
|
10
|
-
return (_jsx(Stack, { flex: 1, spacing: 1, sx: { overflow: 'hidden', borderBottom: `thin solid ${theme.palette.divider}`, pb: 1, mb: 0 }, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "body1", fontWeight: "bold", children: event.event.provider }), _jsx("div", { style: { flex: 1 } }), _jsx(Chip, { label: event.event.kind }), event.event.reference && (_jsx(IconButton, { size: "small", component: Link, to: event.event.reference, sx: { opacity: 1 }, target: "_blank", rel: "noopener noreferrer", children: _jsx(OpenInNew, { fontSize: "small" }) }))] }), event.event.type && (_jsxs(Typography, { variant: "caption", children: [t('event.type'), " - ", event.event.type.join(', ')] })), event.event.module && (_jsxs(Typography, { variant: "caption", children: [t('event.module'), " - ", event.event.module] }))] }) }));
|
|
10
|
+
return (_jsx(Stack, { flex: 1, spacing: 1, sx: { overflow: 'hidden', borderBottom: `thin solid ${theme.palette.divider}`, pb: 1, mb: 0 }, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "body1", fontWeight: "bold", children: event.message || event.event.provider }), _jsx("div", { style: { flex: 1 } }), _jsx(Chip, { label: event.event.kind }), event.event.reference && (_jsx(IconButton, { size: "small", component: Link, to: event.event.reference, sx: { opacity: 1 }, target: "_blank", rel: "noopener noreferrer", children: _jsx(OpenInNew, { fontSize: "small" }) }))] }), event.message && event.event.provider && (_jsxs(Typography, { variant: "caption", children: [t('event.provider'), " - ", event.event.provider] })), event.event.type && (_jsxs(Typography, { variant: "caption", children: [t('event.type'), " - ", event.event.type.join(', ')] })), event.event.module && (_jsxs(Typography, { variant: "caption", children: [t('event.module'), " - ", event.event.module] }))] }) }));
|
|
11
11
|
};
|
|
12
12
|
export default memo(EventPreview);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { DndContext, DragOverlay, MouseSensor, pointerWithin, TouchSensor, useSensor, useSensors } from '@dnd-kit/core';
|
|
3
|
-
import { AddCircle, CalendarMonth, Circle, Dashboard, Dataset, Description, Folder, Refresh, Rule, Search, UnfoldLess } from '@mui/icons-material';
|
|
3
|
+
import { AddCircle, CalendarMonth, Circle, Dashboard, Dataset, Description, Event as EventIcon, Folder, Refresh, Rule, Search, UnfoldLess } from '@mui/icons-material';
|
|
4
4
|
import { alpha, Box, Card, Chip, IconButton, LinearProgress, Skeleton, Stack, Tooltip, Typography, useTheme } from '@mui/material';
|
|
5
5
|
import api from '@cccsaurora/howler-ui/api';
|
|
6
6
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
7
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
+
import AddEventModal from '@cccsaurora/howler-ui/components/routes/cases/modals/AddEventModal';
|
|
8
9
|
import AddToCaseModal from '@cccsaurora/howler-ui/components/routes/cases/modals/AddToCaseModal';
|
|
9
10
|
import dayjs from 'dayjs';
|
|
10
11
|
import { useCallback, useContext, useReducer, useState } from 'react';
|
|
@@ -90,13 +91,27 @@ const CaseSidebar = ({ case: _case, update }) => {
|
|
|
90
91
|
setLoading(false);
|
|
91
92
|
}
|
|
92
93
|
}, [_case, dispatchApi, update]);
|
|
94
|
+
if (!_case) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
93
97
|
return (_jsxs(Box, { sx: {
|
|
94
98
|
flex: 1,
|
|
95
99
|
maxWidth: '350px',
|
|
96
100
|
maxHeight: 'calc(100vh - 64px)',
|
|
97
101
|
display: 'flex',
|
|
98
102
|
flexDirection: 'column'
|
|
99
|
-
}, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _case ? (_jsxs(_Fragment, { children: [_jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case.case_id)), component: Link, to: `/cases/${_case.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('search')), component: Link, to: `/cases/${_case.case_id}/search`, children: [_jsx(Search, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.search') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('observables')), component: Link, to: `/cases/${_case.case_id}/observables`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.observables') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 })] })), _jsx(Card, { sx: { borderRadius: 0, p: 0.25 }, children: _jsxs(Stack, { direction: "row", spacing: 0.25, children: [_jsx("div", { style: { flex: 1 } }), _jsx(Tooltip, { title: t('page.cases.sidebar.
|
|
103
|
+
}, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _case ? (_jsxs(_Fragment, { children: [_jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case.case_id)), component: Link, to: `/cases/${_case.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('search')), component: Link, to: `/cases/${_case.case_id}/search`, children: [_jsx(Search, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.search') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('observables')), component: Link, to: `/cases/${_case.case_id}/observables`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.observables') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 })] })), _jsx(Card, { sx: { borderRadius: 0, p: 0.25 }, children: _jsxs(Stack, { direction: "row", spacing: 0.25, children: [_jsx("div", { style: { flex: 1 } }), _jsx(Tooltip, { title: t('page.cases.sidebar.add_event'), children: _jsxs(IconButton, { size: "small", sx: { position: 'relative' }, onClick: () => {
|
|
104
|
+
if (_case) {
|
|
105
|
+
showModal(_jsx(AddEventModal, { case: _case, onUpdated: update }), { maxHeight: '90vh', height: 'unset' });
|
|
106
|
+
}
|
|
107
|
+
}, children: [_jsx(EventIcon, { sx: { fontSize: '18px' } }), _jsx(AddCircle, { sx: {
|
|
108
|
+
fontSize: '12px',
|
|
109
|
+
position: 'absolute',
|
|
110
|
+
bottom: 2,
|
|
111
|
+
right: 2,
|
|
112
|
+
backgroundColor: theme.palette.background.paper,
|
|
113
|
+
borderRadius: '100%'
|
|
114
|
+
}, htmlColor: "grey" })] }) }), _jsx(Tooltip, { title: t('page.cases.sidebar.add_item'), children: _jsxs(IconButton, { size: "small", sx: { position: 'relative' }, onClick: () => {
|
|
100
115
|
if (_case) {
|
|
101
116
|
showModal(_jsx(AddToCaseModal, { case: _case, onUpdated: update }), { maxHeight: '90vh', height: 'unset' });
|
|
102
117
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
import { type FC } from 'react';
|
|
3
|
+
type AddEventModalProps = {
|
|
4
|
+
case: Case;
|
|
5
|
+
parentId?: string;
|
|
6
|
+
onUpdated?: (updatedCase: Case) => void;
|
|
7
|
+
};
|
|
8
|
+
declare const AddEventModal: FC<AddEventModalProps>;
|
|
9
|
+
export default AddEventModal;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createElement as _createElement } from "react";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Add, Delete } from '@mui/icons-material';
|
|
4
|
+
import { Autocomplete, Box, Button, CircularProgress, Divider, IconButton, Stack, TextField, Typography } from '@mui/material';
|
|
5
|
+
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
|
6
|
+
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
|
7
|
+
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
|
8
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
9
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
10
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
11
|
+
import { unflatten } from 'flat';
|
|
12
|
+
import Fuse from 'fuse.js';
|
|
13
|
+
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
14
|
+
import { useTranslation } from 'react-i18next';
|
|
15
|
+
import { useFolderOptions } from './hooks';
|
|
16
|
+
const ESCALATIONS = ['hit', 'alert', 'evidence'];
|
|
17
|
+
const MANAGED_FIELDS = new Set([
|
|
18
|
+
'event.created',
|
|
19
|
+
'event.id',
|
|
20
|
+
'event.ingested',
|
|
21
|
+
'event.kind',
|
|
22
|
+
'event.provider',
|
|
23
|
+
'howler.escalation',
|
|
24
|
+
'howler.hash',
|
|
25
|
+
'howler.id',
|
|
26
|
+
'howler.outline.indicators',
|
|
27
|
+
'howler.outline.summary',
|
|
28
|
+
'howler.outline.target',
|
|
29
|
+
'howler.outline.threat',
|
|
30
|
+
'howler.related',
|
|
31
|
+
'message',
|
|
32
|
+
'classification',
|
|
33
|
+
'timestamp'
|
|
34
|
+
]);
|
|
35
|
+
const isBlank = (value) => {
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return value.length === 0;
|
|
38
|
+
}
|
|
39
|
+
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
|
40
|
+
};
|
|
41
|
+
const getFieldOptions = (fields) => fields.filter(field => {
|
|
42
|
+
const key = field.key ?? '';
|
|
43
|
+
return (key &&
|
|
44
|
+
!MANAGED_FIELDS.has(key) &&
|
|
45
|
+
field.type !== 'object' &&
|
|
46
|
+
!key.startsWith('howler.comment.') &&
|
|
47
|
+
!key.startsWith('howler.log.'));
|
|
48
|
+
});
|
|
49
|
+
const parseList = (value) => value
|
|
50
|
+
.split(/[\n,]/)
|
|
51
|
+
.map(entry => entry.trim())
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
const searchFields = (fields, query) => {
|
|
54
|
+
if (!query.trim()) {
|
|
55
|
+
return fields;
|
|
56
|
+
}
|
|
57
|
+
const tokens = query.toLowerCase().trim().split(/\s+/);
|
|
58
|
+
const tokenMatches = fields.filter(field => {
|
|
59
|
+
const searchable = `${field.key ?? ''} ${field.description ?? ''}`.toLowerCase();
|
|
60
|
+
return tokens.every(token => searchable.includes(token));
|
|
61
|
+
});
|
|
62
|
+
const fuzzyMatches = new Fuse(fields, {
|
|
63
|
+
keys: ['key', 'description'],
|
|
64
|
+
ignoreLocation: true,
|
|
65
|
+
threshold: 0.4
|
|
66
|
+
})
|
|
67
|
+
.search(query)
|
|
68
|
+
.map(result => result.item);
|
|
69
|
+
return [...tokenMatches, ...fuzzyMatches.filter(field => !tokenMatches.includes(field))];
|
|
70
|
+
};
|
|
71
|
+
const FieldInput = ({ field, value, onChange }) => {
|
|
72
|
+
const label = field.key ?? '';
|
|
73
|
+
const values = field.values ?? [];
|
|
74
|
+
if (values.length > 0) {
|
|
75
|
+
if (field.list) {
|
|
76
|
+
return (_jsx(Autocomplete, { multiple: true, options: values, value: Array.isArray(value) ? value.map(String) : [], onChange: (_, nextValue) => onChange(nextValue), renderInput: params => _jsx(TextField, { ...params, size: "small", label: label, helperText: field.description }) }));
|
|
77
|
+
}
|
|
78
|
+
return (_jsx(Autocomplete, { options: values, value: typeof value === 'string' ? value : null, onChange: (_, nextValue) => onChange(nextValue ?? undefined), renderInput: params => _jsx(TextField, { ...params, size: "small", label: label, helperText: field.description }) }));
|
|
79
|
+
}
|
|
80
|
+
if (field.type === 'boolean') {
|
|
81
|
+
return (_jsx(Autocomplete, { options: ['true', 'false'], value: typeof value === 'boolean' ? String(value) : null, onChange: (_, nextValue) => onChange(nextValue === null ? undefined : nextValue === 'true'), renderInput: params => _jsx(TextField, { ...params, size: "small", label: label, helperText: field.description }) }));
|
|
82
|
+
}
|
|
83
|
+
if (field.list) {
|
|
84
|
+
return (_jsx(TextField, { size: "small", label: label, helperText: field.description ?? 'Enter one value per line.', value: Array.isArray(value) ? value.join('\n') : '', onChange: event => onChange(parseList(event.target.value)), multiline: true }));
|
|
85
|
+
}
|
|
86
|
+
const numeric = ['integer', 'long', 'float', 'double', 'number'].includes(field.type);
|
|
87
|
+
return (_jsx(TextField, { size: "small", label: label, type: numeric ? 'number' : 'text', helperText: field.description, value: value === undefined ? '' : String(value), onChange: event => {
|
|
88
|
+
const nextValue = event.target.value;
|
|
89
|
+
if (!nextValue) {
|
|
90
|
+
onChange(undefined);
|
|
91
|
+
}
|
|
92
|
+
else if (numeric) {
|
|
93
|
+
onChange(field.type === 'integer' || field.type === 'long' ? Number.parseInt(nextValue, 10) : Number(nextValue));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
onChange(nextValue);
|
|
97
|
+
}
|
|
98
|
+
} }));
|
|
99
|
+
};
|
|
100
|
+
const AddEventModal = ({ case: _case, parentId, onUpdated }) => {
|
|
101
|
+
const { t } = useTranslation();
|
|
102
|
+
const { dispatchApi } = useMyApi();
|
|
103
|
+
const { close } = useContext(ModalContext);
|
|
104
|
+
const [fields, setFields] = useState([]);
|
|
105
|
+
const [selectedFields, setSelectedFields] = useState([]);
|
|
106
|
+
const [fieldValues, setFieldValues] = useState({});
|
|
107
|
+
const [fieldSearch, setFieldSearch] = useState('');
|
|
108
|
+
const [title, setTitle] = useState('');
|
|
109
|
+
const [created, setCreated] = useState(null);
|
|
110
|
+
const [provider, setProvider] = useState('');
|
|
111
|
+
const [escalation, setEscalation] = useState(null);
|
|
112
|
+
const [target, setTarget] = useState('');
|
|
113
|
+
const [threat, setThreat] = useState('');
|
|
114
|
+
const [indicators, setIndicators] = useState('');
|
|
115
|
+
const [summary, setSummary] = useState('');
|
|
116
|
+
const [selectedParent, setSelectedParent] = useState(parentId);
|
|
117
|
+
const [createdEventId, setCreatedEventId] = useState();
|
|
118
|
+
const [submitting, setSubmitting] = useState(false);
|
|
119
|
+
const folderOptions = useFolderOptions(_case);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
void dispatchApi(api.search.fields.event.get(), { throwError: false }).then(result => {
|
|
122
|
+
if (result) {
|
|
123
|
+
setFields(getFieldOptions(result));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}, [dispatchApi]);
|
|
127
|
+
const filteredFields = useMemo(() => searchFields(fields, fieldSearch), [fieldSearch, fields]);
|
|
128
|
+
const hasOutlineValue = Boolean(target.trim() || threat.trim() || parseList(indicators).length);
|
|
129
|
+
const isValid = Boolean(title.trim() && created?.isValid() && provider.trim() && escalation && hasOutlineValue);
|
|
130
|
+
const setOptionalField = (field) => {
|
|
131
|
+
if (!field.key || selectedFields.some(selected => selected.key === field.key)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setSelectedFields(current => [...current, field]);
|
|
135
|
+
setFieldSearch('');
|
|
136
|
+
};
|
|
137
|
+
const removeOptionalField = (key) => {
|
|
138
|
+
setSelectedFields(current => current.filter(field => field.key !== key));
|
|
139
|
+
setFieldValues(current => {
|
|
140
|
+
return Object.fromEntries(Object.entries(current).filter(([fieldKey]) => fieldKey !== key));
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
const buildPayload = () => {
|
|
144
|
+
const payload = {
|
|
145
|
+
classification: _case.classification,
|
|
146
|
+
message: title.trim(),
|
|
147
|
+
'event.created': created.toISOString(),
|
|
148
|
+
'event.kind': 'event',
|
|
149
|
+
'event.provider': provider.trim(),
|
|
150
|
+
'howler.escalation': escalation,
|
|
151
|
+
'howler.outline.indicators': parseList(indicators)
|
|
152
|
+
};
|
|
153
|
+
if (target.trim()) {
|
|
154
|
+
payload['howler.outline.target'] = target.trim();
|
|
155
|
+
}
|
|
156
|
+
if (threat.trim()) {
|
|
157
|
+
payload['howler.outline.threat'] = threat.trim();
|
|
158
|
+
}
|
|
159
|
+
if (summary.trim()) {
|
|
160
|
+
payload['howler.outline.summary'] = summary.trim();
|
|
161
|
+
}
|
|
162
|
+
for (const field of selectedFields) {
|
|
163
|
+
const value = field.key ? fieldValues[field.key] : undefined;
|
|
164
|
+
if (field.key && !isBlank(value)) {
|
|
165
|
+
payload[field.key] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return unflatten(payload);
|
|
169
|
+
};
|
|
170
|
+
const onSubmit = async () => {
|
|
171
|
+
if (!isValid || !_case.case_id) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setSubmitting(true);
|
|
175
|
+
try {
|
|
176
|
+
let eventId = createdEventId;
|
|
177
|
+
if (!eventId) {
|
|
178
|
+
const ids = await dispatchApi(api.v2.ingest.post('event', [buildPayload()], 'wait_for'));
|
|
179
|
+
eventId = ids?.[0];
|
|
180
|
+
if (!eventId) {
|
|
181
|
+
throw new Error(t('modal.cases.add_event.error.no_id'));
|
|
182
|
+
}
|
|
183
|
+
setCreatedEventId(eventId);
|
|
184
|
+
}
|
|
185
|
+
const updatedCase = await dispatchApi(api.v2.case.items.post(_case.case_id, {
|
|
186
|
+
type: 'event',
|
|
187
|
+
value: eventId,
|
|
188
|
+
name: title.trim(),
|
|
189
|
+
parent: selectedParent
|
|
190
|
+
}));
|
|
191
|
+
if (updatedCase) {
|
|
192
|
+
onUpdated?.(updatedCase);
|
|
193
|
+
close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
setSubmitting(false);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const renderField = (field) => {
|
|
201
|
+
if (!field.key) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return (_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-start", children: [_jsx(Box, { flex: 1, children: _jsx(FieldInput, { field: field, value: fieldValues[field.key], onChange: value => setFieldValues(current => ({ ...current, [field.key]: value })) }) }), _jsx(IconButton, { "aria-label": t('modal.cases.add_event.remove_field'), onClick: () => removeOptionalField(field.key), children: _jsx(Delete, {}) })] }, field.key));
|
|
205
|
+
};
|
|
206
|
+
return (_jsxs(Stack, { spacing: 2, p: 2, sx: { minWidth: 'min(900px, 70vw)', maxHeight: '90vh', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.add_event') }), _jsxs(Stack, { spacing: 1, alignItems: "stretch", children: [_jsx(TextField, { required: true, size: "small", label: t('modal.cases.add_event.title'), value: title, onChange: event => setTitle(event.target.value), fullWidth: true }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsx(DateTimePicker, { value: created, onChange: setCreated, ampm: false, slotProps: {
|
|
207
|
+
textField: { required: true, size: 'small', fullWidth: true },
|
|
208
|
+
dialog: { disablePortal: true },
|
|
209
|
+
popper: { disablePortal: true }
|
|
210
|
+
}, label: t('modal.cases.add_event.created') }) }), _jsx(Autocomplete, { fullWidth: true, disablePortal: true, options: ESCALATIONS, value: escalation, onChange: (_, value) => setEscalation(value), renderInput: params => (_jsx(TextField, { ...params, required: true, size: "small", label: t('modal.cases.add_event.escalation') })) })] }), _jsx(TextField, { required: true, size: "small", label: t('modal.cases.add_event.provider'), value: provider, onChange: event => setProvider(event.target.value), fullWidth: true }), _jsx(TextField, { size: "small", label: t('modal.cases.add_event.summary'), value: summary, onChange: event => setSummary(event.target.value) }), folderOptions.length > 0 && (_jsx(Autocomplete, { disablePortal: true, options: folderOptions, getOptionLabel: option => option.label, value: folderOptions.find(folder => folder.id === selectedParent) ?? null, onChange: (_, value) => setSelectedParent(value?.id), renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('modal.cases.add_item.folder') }) })), _jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.add_event.outline_required') }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(TextField, { fullWidth: true, size: "small", label: t('modal.cases.add_event.target'), value: target, onChange: event => setTarget(event.target.value) }), _jsx(TextField, { fullWidth: true, size: "small", label: t('modal.cases.add_event.threat'), value: threat, onChange: event => setThreat(event.target.value) })] }), _jsx(TextField, { size: "small", multiline: true, label: t('modal.cases.add_event.indicators'), value: indicators, onChange: event => setIndicators(event.target.value), helperText: t('modal.cases.add_event.indicators_help') })] }), _jsx(Divider, { children: _jsx(Typography, { variant: "caption", children: t('modal.cases.add_event.additional_fields') }) }), _jsx(Autocomplete, { disablePortal: true, options: filteredFields, filterOptions: options => options, inputValue: fieldSearch, onInputChange: (_, value) => setFieldSearch(value), value: null, onChange: (_, field) => field && setOptionalField(field), getOptionLabel: field => field.key ?? '', isOptionEqualToValue: (option, value) => option.key === value.key, renderOption: (props, field) => (_createElement(Box, { component: "li", ...props, key: field.key },
|
|
211
|
+
_jsxs(Stack, { children: [_jsx(Typography, { variant: "body2", children: field.key }), _jsxs(Typography, { variant: "caption", color: "text.secondary", children: [field.type, " ", field.description ? `- ${field.description}` : ''] })] }))), renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('modal.cases.add_event.search_fields') }) }), selectedFields.length > 0 && (_jsx(Stack, { spacing: 1, overflow: "auto", flex: 1, pt: 1, children: selectedFields.map(renderField) })), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, disabled: submitting, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: !isValid || submitting, startIcon: submitting ? _jsx(CircularProgress, { size: 16, color: "inherit" }) : _jsx(Add, {}), onClick: onSubmit, children: t('modal.cases.add_event.submit') })] })] }));
|
|
212
|
+
};
|
|
213
|
+
export default AddEventModal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import dayjs from 'dayjs';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import AddEventModal from './AddEventModal';
|
|
7
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockClose = vi.hoisted(() => vi.fn());
|
|
9
|
+
const mockIngestPost = vi.hoisted(() => vi.fn());
|
|
10
|
+
const mockItemsPost = vi.hoisted(() => vi.fn());
|
|
11
|
+
const mockFieldsGet = vi.hoisted(() => vi.fn());
|
|
12
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
13
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({ t: (key) => key })
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@mui/x-date-pickers/LocalizationProvider', () => ({
|
|
19
|
+
LocalizationProvider: ({ children }) => children
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('@mui/x-date-pickers/AdapterDayjs', () => ({ AdapterDayjs: class {
|
|
22
|
+
} }));
|
|
23
|
+
vi.mock('@mui/x-date-pickers/DateTimePicker', () => ({
|
|
24
|
+
DateTimePicker: ({ label, onChange }) => (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", onClick: () => onChange(dayjs('2026-09-21T12:00:00Z')), children: [label, " valid"] }), _jsxs("button", { type: "button", onClick: () => onChange(dayjs('not-a-date')), children: [label, " invalid"] })] }))
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('api', () => ({
|
|
27
|
+
default: {
|
|
28
|
+
search: {
|
|
29
|
+
fields: {
|
|
30
|
+
event: {
|
|
31
|
+
get: () => mockFieldsGet()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
v2: {
|
|
36
|
+
ingest: {
|
|
37
|
+
post: (...args) => mockIngestPost(...args)
|
|
38
|
+
},
|
|
39
|
+
case: {
|
|
40
|
+
items: {
|
|
41
|
+
post: (...args) => mockItemsPost(...args)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
vi.mock('components/app/providers/ModalProvider', async () => {
|
|
48
|
+
const { createContext } = await import('react');
|
|
49
|
+
return {
|
|
50
|
+
ModalContext: createContext({ close: mockClose })
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
const testCase = {
|
|
54
|
+
__index: 'case',
|
|
55
|
+
case_id: 'case-1',
|
|
56
|
+
classification: 'UNCLASSIFIED',
|
|
57
|
+
items: []
|
|
58
|
+
};
|
|
59
|
+
const fields = [
|
|
60
|
+
{
|
|
61
|
+
key: 'destination.ip',
|
|
62
|
+
default: false,
|
|
63
|
+
indexed: true,
|
|
64
|
+
list: false,
|
|
65
|
+
stored: true,
|
|
66
|
+
type: 'ip',
|
|
67
|
+
description: 'IP address of the destination'
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
const renderModal = () => render(_jsx(AddEventModal, { case: testCase }), {
|
|
71
|
+
wrapper: ({ children }) => children
|
|
72
|
+
});
|
|
73
|
+
describe('AddEventModal', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.clearAllMocks();
|
|
76
|
+
});
|
|
77
|
+
it('requires escalation in addition to the other required fields', async () => {
|
|
78
|
+
mockFieldsGet.mockResolvedValue(fields);
|
|
79
|
+
mockDispatchApi.mockImplementation(async (request) => request);
|
|
80
|
+
renderModal();
|
|
81
|
+
await waitFor(() => expect(mockFieldsGet).toHaveBeenCalled());
|
|
82
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.title/), { target: { value: 'Manual event' } });
|
|
83
|
+
fireEvent.click(screen.getByRole('button', { name: 'modal.cases.add_event.created valid' }));
|
|
84
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.provider/), { target: { value: 'Analyst' } });
|
|
85
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.target/), { target: { value: 'host-1' } });
|
|
86
|
+
expect(screen.getByRole('button', { name: 'modal.cases.add_event.submit' })).toBeDisabled();
|
|
87
|
+
});
|
|
88
|
+
it('rejects an invalid created date', async () => {
|
|
89
|
+
const user = userEvent.setup();
|
|
90
|
+
mockFieldsGet.mockResolvedValue(fields);
|
|
91
|
+
mockDispatchApi.mockImplementation(async (request) => request);
|
|
92
|
+
renderModal();
|
|
93
|
+
await waitFor(() => expect(mockFieldsGet).toHaveBeenCalled());
|
|
94
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.title/), { target: { value: 'Manual event' } });
|
|
95
|
+
fireEvent.click(screen.getByRole('button', { name: 'modal.cases.add_event.created invalid' }));
|
|
96
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.provider/), { target: { value: 'Analyst' } });
|
|
97
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.target/), { target: { value: 'host-1' } });
|
|
98
|
+
await user.click(screen.getByRole('combobox', { name: /modal\.cases\.add_event\.escalation/ }));
|
|
99
|
+
await user.click(await screen.findByRole('option', { name: 'evidence' }));
|
|
100
|
+
expect(screen.getByRole('button', { name: 'modal.cases.add_event.submit' })).toBeDisabled();
|
|
101
|
+
});
|
|
102
|
+
it('fuzzy-searches optional fields and submits summary and escalation', async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
const updatedCase = { ...testCase, items: [{ type: 'event', value: 'event-1' }] };
|
|
105
|
+
mockFieldsGet.mockResolvedValue(fields);
|
|
106
|
+
mockIngestPost.mockReturnValue(Promise.resolve(['event-1']));
|
|
107
|
+
mockItemsPost.mockReturnValue(Promise.resolve(updatedCase));
|
|
108
|
+
mockDispatchApi.mockImplementation(async (request) => request);
|
|
109
|
+
renderModal();
|
|
110
|
+
await waitFor(() => expect(mockFieldsGet).toHaveBeenCalled());
|
|
111
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.title/), { target: { value: 'Manual event' } });
|
|
112
|
+
fireEvent.click(screen.getByRole('button', { name: 'modal.cases.add_event.created valid' }));
|
|
113
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.provider/), { target: { value: 'Analyst' } });
|
|
114
|
+
await user.click(screen.getByRole('combobox', { name: /modal\.cases\.add_event\.escalation/ }));
|
|
115
|
+
await user.click(await screen.findByRole('option', { name: 'evidence' }));
|
|
116
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.target/), { target: { value: 'host-1' } });
|
|
117
|
+
fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.summary/), {
|
|
118
|
+
target: { value: 'Observed manually' }
|
|
119
|
+
});
|
|
120
|
+
const fieldSearch = screen.getByLabelText(/modal\.cases\.add_event\.search_fields/);
|
|
121
|
+
fireEvent.change(fieldSearch, { target: { value: 'destination IP' } });
|
|
122
|
+
await user.click(await screen.findByText('destination.ip'));
|
|
123
|
+
fireEvent.change(screen.getByLabelText(/destination\.ip/), { target: { value: '192.0.2.10' } });
|
|
124
|
+
await user.click(screen.getByRole('button', { name: 'modal.cases.add_event.submit' }));
|
|
125
|
+
expect(mockIngestPost).toHaveBeenCalledWith('event', [
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
classification: 'UNCLASSIFIED',
|
|
128
|
+
message: 'Manual event',
|
|
129
|
+
event: expect.objectContaining({
|
|
130
|
+
created: '2026-09-21T12:00:00.000Z',
|
|
131
|
+
kind: 'event',
|
|
132
|
+
provider: 'Analyst'
|
|
133
|
+
}),
|
|
134
|
+
howler: expect.objectContaining({
|
|
135
|
+
escalation: 'evidence',
|
|
136
|
+
outline: expect.objectContaining({
|
|
137
|
+
target: 'host-1',
|
|
138
|
+
summary: 'Observed manually'
|
|
139
|
+
})
|
|
140
|
+
}),
|
|
141
|
+
destination: expect.objectContaining({ ip: '192.0.2.10' })
|
|
142
|
+
})
|
|
143
|
+
], 'wait_for');
|
|
144
|
+
expect(mockItemsPost).toHaveBeenCalledWith('case-1', {
|
|
145
|
+
type: 'event',
|
|
146
|
+
value: 'event-1',
|
|
147
|
+
name: 'Manual event',
|
|
148
|
+
parent: undefined
|
|
149
|
+
});
|
|
150
|
+
expect(mockClose).toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
"error.invalid_detection.message": "Invalid Detection",
|
|
118
118
|
"event.module": "Event Module",
|
|
119
119
|
"event.open": "Open Event",
|
|
120
|
+
"event.provider": "Event Provider",
|
|
120
121
|
"event.type": "Event Type",
|
|
121
122
|
"features.warning.description": "This feature is undergoing active development, and is not yet in a finished state. You may encounter bugs or instability.",
|
|
122
123
|
"features.warning.title": "Feature In Active Development",
|
|
@@ -336,6 +337,23 @@
|
|
|
336
337
|
"modal.action.empty": "Action Name cannot be empty.",
|
|
337
338
|
"modal.action.label": "Action Name",
|
|
338
339
|
"modal.action.title": "Save Action",
|
|
340
|
+
"modal.cases.add_event": "Add Event",
|
|
341
|
+
"modal.cases.add_event.additional_fields": "Additional Fields",
|
|
342
|
+
"modal.cases.add_event.created": "Event Created",
|
|
343
|
+
"modal.cases.add_event.error.no_id": "The event was created without an ID.",
|
|
344
|
+
"modal.cases.add_event.escalation": "Escalation",
|
|
345
|
+
"modal.cases.add_event.indicators": "Indicators",
|
|
346
|
+
"modal.cases.add_event.indicators_help": "Enter one indicator per line or separated by commas.",
|
|
347
|
+
"modal.cases.add_event.outline": "Event Outline",
|
|
348
|
+
"modal.cases.add_event.outline_required": "At least one target, threat, or indicator is required.",
|
|
349
|
+
"modal.cases.add_event.provider": "Source / Provenance",
|
|
350
|
+
"modal.cases.add_event.remove_field": "Remove field",
|
|
351
|
+
"modal.cases.add_event.search_fields": "Search field names and descriptions",
|
|
352
|
+
"modal.cases.add_event.submit": "Add Event",
|
|
353
|
+
"modal.cases.add_event.summary": "Summary",
|
|
354
|
+
"modal.cases.add_event.target": "Target",
|
|
355
|
+
"modal.cases.add_event.threat": "Threat",
|
|
356
|
+
"modal.cases.add_event.title": "Title",
|
|
339
357
|
"modal.cases.add_item": "Add item",
|
|
340
358
|
"modal.cases.add_item.content": "Content",
|
|
341
359
|
"modal.cases.add_item.folder": "Folder",
|
|
@@ -476,6 +494,7 @@
|
|
|
476
494
|
"page.cases.rules.timeframe.days": "{{days}} day(s)",
|
|
477
495
|
"page.cases.rules.timeframe.help": "Number of days before the rule expires",
|
|
478
496
|
"page.cases.search": "Search",
|
|
497
|
+
"page.cases.sidebar.add_event": "Add event",
|
|
479
498
|
"page.cases.sidebar.add_folder": "Add folder",
|
|
480
499
|
"page.cases.sidebar.add_item": "Add item",
|
|
481
500
|
"page.cases.sidebar.collapse_all": "Collapse all folders",
|
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
"error.invalid_detection.message": "Détection non valide",
|
|
118
118
|
"event.module": "Module d'événement",
|
|
119
119
|
"event.open": "Ouvrir événement",
|
|
120
|
+
"event.provider": "Fournisseur de l'événement",
|
|
120
121
|
"event.type": "Type d'événement",
|
|
121
122
|
"features.warning.description": "Cette fonctionnalité fait l'objet d'un développement actif et n'est pas encore achevée. Il est possible que vous rencontriez des problèmes ou de l'instabilité.",
|
|
122
123
|
"features.warning.title": "Fonctionnalité en développement actif",
|
|
@@ -336,6 +337,23 @@
|
|
|
336
337
|
"modal.action.empty": "Le nom de l'action ne peut pas être vide.",
|
|
337
338
|
"modal.action.label": "Nom de l'action",
|
|
338
339
|
"modal.action.title": "Enregistrer l'action",
|
|
340
|
+
"modal.cases.add_event": "Ajouter un événement",
|
|
341
|
+
"modal.cases.add_event.additional_fields": "Champs supplémentaires",
|
|
342
|
+
"modal.cases.add_event.created": "Création de l'événement",
|
|
343
|
+
"modal.cases.add_event.error.no_id": "L'événement a été créé sans identifiant.",
|
|
344
|
+
"modal.cases.add_event.escalation": "Escalade",
|
|
345
|
+
"modal.cases.add_event.indicators": "Indicateurs",
|
|
346
|
+
"modal.cases.add_event.indicators_help": "Saisissez un indicateur par ligne ou séparé par des virgules.",
|
|
347
|
+
"modal.cases.add_event.outline": "Résumé de l'événement",
|
|
348
|
+
"modal.cases.add_event.outline_required": "Au moins une cible, une menace ou un indicateur est requis.",
|
|
349
|
+
"modal.cases.add_event.provider": "Source / provenance",
|
|
350
|
+
"modal.cases.add_event.remove_field": "Supprimer le champ",
|
|
351
|
+
"modal.cases.add_event.search_fields": "Rechercher dans les noms et descriptions de champs",
|
|
352
|
+
"modal.cases.add_event.submit": "Ajouter l'événement",
|
|
353
|
+
"modal.cases.add_event.summary": "Résumé",
|
|
354
|
+
"modal.cases.add_event.target": "Cible",
|
|
355
|
+
"modal.cases.add_event.threat": "Menace",
|
|
356
|
+
"modal.cases.add_event.title": "Titre",
|
|
339
357
|
"modal.cases.add_item": "Ajouter un élément",
|
|
340
358
|
"modal.cases.add_item.content": "Contenu",
|
|
341
359
|
"modal.cases.add_item.folder": "Dossier",
|
|
@@ -476,6 +494,7 @@
|
|
|
476
494
|
"page.cases.rules.timeframe.days": "{{days}} jour(s)",
|
|
477
495
|
"page.cases.rules.timeframe.help": "Nombre de jours avant l'expiration de la règle",
|
|
478
496
|
"page.cases.search": "Rechercher",
|
|
497
|
+
"page.cases.sidebar.add_event": "Ajouter un événement",
|
|
479
498
|
"page.cases.sidebar.add_folder": "Ajouter un dossier",
|
|
480
499
|
"page.cases.sidebar.add_item": "Ajouter un élément",
|
|
481
500
|
"page.cases.sidebar.collapse_all": "Réduire tous les dossiers",
|