@cccsaurora/howler-ui 2.19.0-cases.1129 → 2.19.0-cases.1131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/routes/cases/detail/CaseObservables.d.ts +0 -5
- package/components/routes/cases/detail/CaseObservables.js +101 -63
- package/components/routes/cases/detail/CaseObservables.test.js +86 -81
- package/components/routes/cases/detail/observables/Observable.d.ts +2 -8
- package/components/routes/cases/detail/observables/Observable.js +2 -2
- package/components/routes/cases/detail/observables/Observable.test.js +8 -8
- package/components/routes/cases/detail/observables/ObservableTable.d.ts +7 -0
- package/components/routes/cases/detail/observables/ObservableTable.js +61 -0
- package/components/routes/cases/detail/types.d.ts +23 -0
- package/components/routes/cases/detail/utils.d.ts +29 -0
- package/components/routes/cases/detail/utils.js +114 -0
- package/components/routes/cases/detail/utils.test.d.ts +1 -0
- package/components/routes/cases/detail/utils.test.js +144 -0
- package/locales/en/translation.json +15 -0
- package/locales/fr/translation.json +15 -0
- package/package.json +1 -1
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
-
import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
|
|
3
|
-
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
4
|
-
import { type ObservableEntry } from './observables/Observable';
|
|
5
|
-
/** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
|
|
6
|
-
export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
|
|
7
2
|
declare const _default: import("react").NamedExoticComponent<{
|
|
8
3
|
case?: Case;
|
|
9
4
|
caseId?: string;
|
|
@@ -1,64 +1,31 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { FilterList } from '@mui/icons-material';
|
|
3
|
+
import { Chip, Grid, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
|
3
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
|
+
import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
|
|
4
6
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
7
|
+
import { toArray, uniq } from 'lodash-es';
|
|
5
8
|
import { memo, useEffect, useMemo, useState } from 'react';
|
|
6
9
|
import { useTranslation } from 'react-i18next';
|
|
7
10
|
import { useOutletContext } from 'react-router-dom';
|
|
8
11
|
import useCase from '../hooks/useCase';
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
|
|
12
|
-
/** Extract (type, value, seenInId) triples from a record's related field */
|
|
13
|
-
const extractObservables = (related, recordId) => {
|
|
14
|
-
if (!related) {
|
|
15
|
-
return [];
|
|
16
|
-
}
|
|
17
|
-
const results = [];
|
|
18
|
-
for (const field of OBSERVABLE_FIELDS) {
|
|
19
|
-
const raw = related[field];
|
|
20
|
-
if (!raw) {
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
const values = Array.isArray(raw) ? raw : [raw];
|
|
24
|
-
for (const value of values) {
|
|
25
|
-
if (value) {
|
|
26
|
-
results.push({ type: field, value: String(value), id: recordId });
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return results;
|
|
31
|
-
};
|
|
32
|
-
/** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
|
|
33
|
-
export const buildObservableEntries = (records) => {
|
|
34
|
-
const map = new Map();
|
|
35
|
-
for (const record of records) {
|
|
36
|
-
const related = record.related ?? record.related;
|
|
37
|
-
const recordId = record.howler?.id ?? record.howler?.id;
|
|
38
|
-
if (!recordId) {
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
for (const { type, value, id } of extractObservables(related, recordId)) {
|
|
42
|
-
const key = `${type}:${value}`;
|
|
43
|
-
if (!map.has(key)) {
|
|
44
|
-
map.set(key, { type, value, seenIn: [] });
|
|
45
|
-
}
|
|
46
|
-
const entry = map.get(key);
|
|
47
|
-
if (!entry.seenIn.includes(id)) {
|
|
48
|
-
entry.seenIn.push(id);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return Array.from(map.values());
|
|
53
|
-
};
|
|
12
|
+
import ObservableTable from './observables/ObservableTable';
|
|
13
|
+
import { OBSERVABLE_FIELDS, buildObservableEntries, classifyRole, resolveSources } from './utils';
|
|
54
14
|
const RELATED_FIELDS = OBSERVABLE_FIELDS.map(f => `related.${f}`).join(',');
|
|
15
|
+
const EXTRA_FIELDS = 'howler.escalation,howler.outline.threat,howler.outline.target,howler.outline.indicators,threat.indicator.ip,threat.indicator.description';
|
|
55
16
|
const CaseObservables = ({ case: providedCase, caseId }) => {
|
|
56
17
|
const { t } = useTranslation();
|
|
57
18
|
const { dispatchApi } = useMyApi();
|
|
58
19
|
const routeCase = useOutletContext();
|
|
59
20
|
const { case: _case } = useCase({ case: providedCase ?? routeCase, caseId });
|
|
60
21
|
const [records, setRecords] = useState(null);
|
|
61
|
-
const [activeFilters, setActiveFilters] = useState(
|
|
22
|
+
const [activeFilters, setActiveFilters] = useState([]);
|
|
23
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
24
|
+
const [originFilters, setOriginFilters] = useState([]);
|
|
25
|
+
const [roleFilters, setRoleFilters] = useState([]);
|
|
26
|
+
const [escalationOptions, setEscalationOptions] = useState([]);
|
|
27
|
+
const [activeEscalations, setActiveEscalations] = useState([]);
|
|
28
|
+
const [escalationMap, setEscalationMap] = useState(new Map());
|
|
62
29
|
const ids = useMemo(() => (_case?.items ?? [])
|
|
63
30
|
.filter(item => ['hit', 'event'].includes(item.type))
|
|
64
31
|
.map(item => item.value)
|
|
@@ -70,35 +37,106 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
|
|
|
70
37
|
}
|
|
71
38
|
dispatchApi(api.v2.search.post(['hit', 'event'], {
|
|
72
39
|
query: `howler.id:(${ids.join(' OR ')})`,
|
|
73
|
-
fl: `howler.id,${RELATED_FIELDS}`
|
|
74
|
-
})).then(response =>
|
|
40
|
+
fl: `howler.id,${RELATED_FIELDS},${EXTRA_FIELDS}`
|
|
41
|
+
})).then(response => {
|
|
42
|
+
setRecords(response.items);
|
|
43
|
+
// Build escalation map from fetched records
|
|
44
|
+
const escMap = new Map();
|
|
45
|
+
for (const record of response.items) {
|
|
46
|
+
const id = record.howler?.id ?? record.howler?.id;
|
|
47
|
+
const escalation = record.howler?.escalation;
|
|
48
|
+
if (id && escalation) {
|
|
49
|
+
escMap.set(id, escalation);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
setEscalationMap(escMap);
|
|
53
|
+
});
|
|
75
54
|
}, [dispatchApi, ids]);
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (ids.length < 1) {
|
|
57
|
+
setEscalationOptions([]);
|
|
58
|
+
setActiveEscalations([]);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
dispatchApi(api.v2.search.facet.post(['hit', 'event'], {
|
|
62
|
+
fields: ['howler.escalation'],
|
|
63
|
+
filters: [`howler.id:(${ids.join(' OR ')})`]
|
|
64
|
+
}), { throwError: false }).then(result => {
|
|
65
|
+
if (result) {
|
|
66
|
+
setEscalationOptions(Object.keys(result['howler.escalation'] ?? {}));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}, [dispatchApi, ids]);
|
|
70
|
+
const allObservables = useMemo(() => {
|
|
71
|
+
if (!records || !_case) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const entries = buildObservableEntries(records);
|
|
75
|
+
return entries.map(entry => ({
|
|
76
|
+
...entry,
|
|
77
|
+
role: classifyRole(entry.value, _case, records),
|
|
78
|
+
sources: resolveSources(entry.seenIn, _case.items, escalationMap)
|
|
79
|
+
}));
|
|
80
|
+
}, [records, _case, escalationMap]);
|
|
81
|
+
const observableTypes = useMemo(() => (allObservables ? uniq(allObservables.map(a => a.type)).sort() : []), [allObservables]);
|
|
78
82
|
const filteredObservables = useMemo(() => {
|
|
79
83
|
if (allObservables.length < 1) {
|
|
80
84
|
return [];
|
|
81
85
|
}
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
let result = allObservables;
|
|
87
|
+
if (activeFilters.length > 0) {
|
|
88
|
+
result = result.filter(a => activeFilters.includes(a.type));
|
|
89
|
+
}
|
|
90
|
+
if (searchQuery.trim()) {
|
|
91
|
+
const query = searchQuery.trim().toLowerCase();
|
|
92
|
+
result = result.filter(a => a.value.toLowerCase().includes(query) || a.type.toLowerCase().includes(query));
|
|
93
|
+
}
|
|
94
|
+
if (originFilters.length > 0) {
|
|
95
|
+
result = result.filter(a => a.sources?.some(s => originFilters.includes(s.type)));
|
|
96
|
+
}
|
|
97
|
+
if (activeEscalations.length > 0) {
|
|
98
|
+
result = result.filter(a => a.sources?.some(s => s.escalation && activeEscalations.includes(s.escalation)));
|
|
99
|
+
}
|
|
100
|
+
if (roleFilters.length > 0) {
|
|
101
|
+
result = result.filter(a => a.role && roleFilters.includes(a.role));
|
|
84
102
|
}
|
|
85
|
-
return
|
|
86
|
-
}, [allObservables, activeFilters]);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
next.delete(type);
|
|
103
|
+
return result;
|
|
104
|
+
}, [allObservables, activeFilters, searchQuery, originFilters, activeEscalations, roleFilters]);
|
|
105
|
+
const toggleSetItem = (setter, item) => {
|
|
106
|
+
setter(prev => {
|
|
107
|
+
if (prev.includes(item)) {
|
|
108
|
+
return prev.filter(_item => _item !== item);
|
|
92
109
|
}
|
|
93
110
|
else {
|
|
94
|
-
|
|
111
|
+
return [...prev, item];
|
|
95
112
|
}
|
|
96
|
-
return next;
|
|
97
113
|
});
|
|
98
114
|
};
|
|
99
115
|
if (!_case) {
|
|
100
116
|
return null;
|
|
101
117
|
}
|
|
102
|
-
return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, {
|
|
118
|
+
return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { spacing: 1.5, children: [_jsx(TextField, { size: "small", placeholder: t('page.cases.observables.search'), value: searchQuery, onChange: e => setSearchQuery(e.target.value), sx: { maxWidth: 360 }, id: "observable-search" }), _jsxs(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", children: [_jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: activeFilters.length > 0
|
|
119
|
+
? `${t('page.cases.observables.filter_by_type')} (${toArray(activeFilters)
|
|
120
|
+
.map(type => t(`page.cases.observables.type.${type}`))
|
|
121
|
+
.sort()
|
|
122
|
+
.join(', ')})`
|
|
123
|
+
: t('page.cases.observables.filter_by_type'), slotProps: {
|
|
124
|
+
chip: { size: 'small', color: activeFilters.length > 0 ? 'primary' : 'default' }
|
|
125
|
+
}, children: records === null ? (_jsx(Skeleton, { width: 200, height: 32 })) : (_jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: observableTypes.map(type => (_jsx(Chip, { label: t(`page.cases.observables.type.${type}`), size: "small", onClick: () => toggleSetItem(setActiveFilters, type), color: activeFilters.includes(type) ? 'primary' : 'default', variant: activeFilters.includes(type) ? 'filled' : 'outlined' }, type))) })) }), _jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: originFilters.length > 0
|
|
126
|
+
? `${t('page.cases.observables.filter_by_origin')} (${toArray(originFilters).join(', ')})`
|
|
127
|
+
: t('page.cases.observables.filter_by_origin'), slotProps: {
|
|
128
|
+
chip: { size: 'small', color: originFilters.length > 0 ? 'primary' : 'default' }
|
|
129
|
+
}, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: ['hit', 'event'].map(origin => (_jsx(Chip, { label: t(`page.cases.observables.origin.${origin}`), size: "small", onClick: () => toggleSetItem(setOriginFilters, origin), color: originFilters.includes(origin) ? 'primary' : 'default', variant: originFilters.includes(origin) ? 'filled' : 'outlined' }, origin))) }) }), _jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: roleFilters.length > 0
|
|
130
|
+
? `${t('page.cases.observables.filter_by_role')} (${toArray(roleFilters).join(', ')})`
|
|
131
|
+
: t('page.cases.observables.filter_by_role'), slotProps: {
|
|
132
|
+
chip: { size: 'small', color: roleFilters.length > 0 ? 'primary' : 'default' }
|
|
133
|
+
}, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: ['threat', 'target', 'indicator'].map(role => (_jsx(Chip, { label: t(`page.cases.observables.role.${role}`), size: "small", onClick: () => toggleSetItem(setRoleFilters, role), color: roleFilters.includes(role) ? 'primary' : 'default', variant: roleFilters.includes(role) ? 'filled' : 'outlined' }, role))) }) }), escalationOptions.length > 0 && (_jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: activeEscalations.length > 0
|
|
134
|
+
? `${t('page.cases.observables.filter_by_escalation')} (${toArray(activeEscalations).join(', ')})`
|
|
135
|
+
: t('page.cases.observables.filter_by_escalation'), slotProps: {
|
|
136
|
+
chip: {
|
|
137
|
+
size: 'small',
|
|
138
|
+
color: activeEscalations.length > 0 ? 'primary' : 'default'
|
|
139
|
+
}
|
|
140
|
+
}, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: escalationOptions.map(esc => (_jsx(Chip, { label: esc, size: "small", onClick: () => toggleSetItem(setActiveEscalations, esc), color: activeEscalations.includes(esc) ? 'primary' : 'default', variant: activeEscalations.includes(esc) ? 'filled' : 'outlined' }, esc))) }) }))] })] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, children: _jsx(Skeleton, { height: 40 }) }, `skeleton-${i}`)))) : filteredObservables.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.observables.empty') }) })) : (_jsx(Grid, { item: true, xs: 12, children: _jsx(ObservableTable, { observables: filteredObservables, case: _case }) }))] }));
|
|
103
141
|
};
|
|
104
142
|
export default memo(CaseObservables);
|
|
@@ -1,76 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/// <reference types="vitest" />
|
|
3
|
-
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
5
5
|
import { createElement } from 'react';
|
|
6
6
|
import { MemoryRouter } from 'react-router-dom';
|
|
7
|
-
import { createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
|
|
8
7
|
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
-
import { buildObservableEntries } from './CaseObservables';
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Pure logic tests — no React needed
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
describe('buildObservableEntries', () => {
|
|
14
|
-
it('returns an empty array for records with no related field', () => {
|
|
15
|
-
expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
|
|
16
|
-
});
|
|
17
|
-
it('extracts a single IP from a hit', () => {
|
|
18
|
-
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
|
|
19
|
-
expect(result).toHaveLength(1);
|
|
20
|
-
expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
|
|
21
|
-
});
|
|
22
|
-
it('extracts multiple fields from a single record', () => {
|
|
23
|
-
const result = buildObservableEntries([
|
|
24
|
-
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
|
|
25
|
-
]);
|
|
26
|
-
const types = result.map(a => a.type).sort();
|
|
27
|
-
expect(types).toEqual(['ip', 'user']);
|
|
28
|
-
});
|
|
29
|
-
it('deduplicates the same asset value across multiple records', () => {
|
|
30
|
-
const result = buildObservableEntries([
|
|
31
|
-
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
32
|
-
createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
|
|
33
|
-
]);
|
|
34
|
-
expect(result).toHaveLength(1);
|
|
35
|
-
expect(result[0].seenIn).toEqual(['h1', 'obs1']);
|
|
36
|
-
});
|
|
37
|
-
it('keeps distinct asset values as separate entries', () => {
|
|
38
|
-
const result = buildObservableEntries([
|
|
39
|
-
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
40
|
-
createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
|
|
41
|
-
]);
|
|
42
|
-
expect(result).toHaveLength(2);
|
|
43
|
-
});
|
|
44
|
-
it('does not duplicate seenIn ids when the same record appears twice for the same asset', () => {
|
|
45
|
-
const result = buildObservableEntries([
|
|
46
|
-
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
47
|
-
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
|
|
48
|
-
]);
|
|
49
|
-
expect(result[0].seenIn).toEqual(['h1']);
|
|
50
|
-
});
|
|
51
|
-
it('skips records with no howler.id', () => {
|
|
52
|
-
const noId = { related: { ip: ['1.2.3.4'] } };
|
|
53
|
-
expect(buildObservableEntries([noId])).toEqual([]);
|
|
54
|
-
});
|
|
55
|
-
it('handles the scalar `id` field on Related', () => {
|
|
56
|
-
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
|
|
57
|
-
expect(result).toHaveLength(1);
|
|
58
|
-
expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
|
|
59
|
-
});
|
|
60
|
-
it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
|
|
61
|
-
const related = {
|
|
62
|
-
hash: ['abc123'],
|
|
63
|
-
hosts: ['host.example.com'],
|
|
64
|
-
user: ['bob'],
|
|
65
|
-
ids: ['guid-1'],
|
|
66
|
-
uri: ['https://example.com'],
|
|
67
|
-
signature: ['rule-X']
|
|
68
|
-
};
|
|
69
|
-
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
|
|
70
|
-
const types = result.map(a => a.type).sort();
|
|
71
|
-
expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
8
|
// ---------------------------------------------------------------------------
|
|
75
9
|
// Component rendering tests
|
|
76
10
|
// ---------------------------------------------------------------------------
|
|
@@ -81,14 +15,29 @@ vi.mock('react-i18next', () => ({
|
|
|
81
15
|
vi.mock('components/hooks/useMyApi', () => ({
|
|
82
16
|
default: () => ({ dispatchApi: mockDispatchApi })
|
|
83
17
|
}));
|
|
18
|
+
vi.mock('api', () => ({
|
|
19
|
+
default: {
|
|
20
|
+
v2: {
|
|
21
|
+
search: {
|
|
22
|
+
post: vi.fn(() => Promise.resolve({ items: [] })),
|
|
23
|
+
facet: {
|
|
24
|
+
post: vi.fn(() => Promise.resolve({}))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
84
30
|
vi.mock('../hooks/useCase', () => ({
|
|
85
31
|
default: ({ case: c }) => ({ case: c, updateCase: vi.fn(), loading: false, missing: false })
|
|
86
32
|
}));
|
|
33
|
+
vi.mock('components/elements/PluginTypography', () => ({
|
|
34
|
+
default: ({ value }) => createElement('span', null, value)
|
|
35
|
+
}));
|
|
87
36
|
const mockCase = {
|
|
88
37
|
case_id: 'case-001',
|
|
89
38
|
items: [
|
|
90
|
-
{ type: 'hit', value: 'hit-1' },
|
|
91
|
-
{ type: '
|
|
39
|
+
{ type: 'hit', value: 'hit-1', path: 'alerts/analytic-1 (hit-1)' },
|
|
40
|
+
{ type: 'observable', value: 'obs-1', path: 'observables/obs-1' }
|
|
92
41
|
]
|
|
93
42
|
};
|
|
94
43
|
const Wrapper = ({ children }) => createElement(MemoryRouter, { initialEntries: ['/cases/case-001/observables'] }, children);
|
|
@@ -101,7 +50,6 @@ describe('CaseObservables component', () => {
|
|
|
101
50
|
it('renders skeletons while records are loading', () => {
|
|
102
51
|
mockDispatchApi.mockReturnValue(new Promise(() => { })); // never resolves
|
|
103
52
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
104
|
-
// 6 skeleton cards
|
|
105
53
|
const skeletons = document.querySelectorAll('.MuiSkeleton-root');
|
|
106
54
|
expect(skeletons.length).toBeGreaterThan(0);
|
|
107
55
|
});
|
|
@@ -112,34 +60,39 @@ describe('CaseObservables component', () => {
|
|
|
112
60
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
113
61
|
await screen.findByText('page.cases.observables.empty');
|
|
114
62
|
});
|
|
115
|
-
it('renders
|
|
63
|
+
it('renders observables in a table for extracted observables', async () => {
|
|
116
64
|
mockDispatchApi.mockResolvedValue({
|
|
117
65
|
items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
|
|
118
66
|
});
|
|
119
67
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
120
68
|
await screen.findByText('1.2.3.4');
|
|
121
69
|
expect(screen.getByText('alice')).toBeTruthy();
|
|
70
|
+
// Table headers should be present
|
|
71
|
+
expect(screen.getByText('page.cases.observables.columns.type')).toBeTruthy();
|
|
72
|
+
expect(screen.getByText('page.cases.observables.columns.value')).toBeTruthy();
|
|
122
73
|
});
|
|
123
|
-
it('renders a filter chip for each observable type present', async () => {
|
|
74
|
+
it('renders a filter chip for each observable type present when popper is opened', async () => {
|
|
124
75
|
mockDispatchApi.mockResolvedValue({
|
|
125
76
|
items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
|
|
126
77
|
});
|
|
127
78
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
128
|
-
await screen.
|
|
129
|
-
|
|
130
|
-
|
|
79
|
+
await screen.findByText('1.2.3.4');
|
|
80
|
+
// Open the type filter popper
|
|
81
|
+
await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
|
|
82
|
+
// Filter chips visible in popper + table = at least 2 of each type
|
|
83
|
+
expect(screen.getAllByText('page.cases.observables.type.ip').length).toBeGreaterThanOrEqual(2);
|
|
84
|
+
expect(screen.getAllByText('page.cases.observables.type.user').length).toBeGreaterThanOrEqual(2);
|
|
131
85
|
});
|
|
132
86
|
it('filters observables when a type chip is clicked', async () => {
|
|
133
87
|
mockDispatchApi.mockResolvedValue({
|
|
134
88
|
items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
|
|
135
89
|
});
|
|
136
90
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
137
|
-
// Wait for both observables to appear
|
|
138
91
|
await screen.findByText('1.2.3.4');
|
|
139
92
|
expect(screen.getByText('alice')).toBeTruthy();
|
|
140
|
-
//
|
|
141
|
-
await userEvent.click(screen.
|
|
142
|
-
|
|
93
|
+
// Open the type filter popper and click the first 'ip' chip (in the popper)
|
|
94
|
+
await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
|
|
95
|
+
await userEvent.click(screen.getAllByText('page.cases.observables.type.ip')[0]);
|
|
143
96
|
expect(screen.queryByText('alice')).toBeNull();
|
|
144
97
|
expect(screen.getByText('1.2.3.4')).toBeTruthy();
|
|
145
98
|
});
|
|
@@ -149,16 +102,68 @@ describe('CaseObservables component', () => {
|
|
|
149
102
|
});
|
|
150
103
|
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
151
104
|
await screen.findByText('1.2.3.4');
|
|
152
|
-
|
|
105
|
+
// Open the type filter popper
|
|
106
|
+
await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
|
|
107
|
+
const ipChip = screen.getAllByText('page.cases.observables.type.ip')[0];
|
|
153
108
|
await userEvent.click(ipChip);
|
|
154
109
|
await userEvent.click(ipChip);
|
|
155
110
|
expect(screen.getByText('1.2.3.4')).toBeTruthy();
|
|
156
111
|
expect(screen.getByText('alice')).toBeTruthy();
|
|
157
112
|
});
|
|
158
|
-
it('renders nothing when the case has no hit/
|
|
113
|
+
it('renders nothing when the case has no hit/observable items', async () => {
|
|
159
114
|
const emptyCase = { case_id: 'case-002', items: [] };
|
|
160
115
|
render(_jsx(CaseObservables, { case: emptyCase }), { wrapper: Wrapper });
|
|
161
116
|
await screen.findByText('page.cases.observables.empty');
|
|
162
117
|
expect(mockDispatchApi).not.toHaveBeenCalled();
|
|
163
118
|
});
|
|
119
|
+
it('filters observables by search query', async () => {
|
|
120
|
+
mockDispatchApi.mockResolvedValue({
|
|
121
|
+
items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
|
|
122
|
+
});
|
|
123
|
+
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
124
|
+
await screen.findByText('1.2.3.4');
|
|
125
|
+
const searchInput = screen.getByPlaceholderText('page.cases.observables.search');
|
|
126
|
+
await userEvent.type(searchInput, 'alice');
|
|
127
|
+
expect(screen.queryByText('1.2.3.4')).toBeNull();
|
|
128
|
+
expect(screen.getByText('alice')).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
it('renders origin filter chips inside popper', async () => {
|
|
131
|
+
mockDispatchApi.mockResolvedValue({
|
|
132
|
+
items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'] } }]
|
|
133
|
+
});
|
|
134
|
+
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
135
|
+
await screen.findByText('1.2.3.4');
|
|
136
|
+
// Open the origin filter popper
|
|
137
|
+
await userEvent.click(screen.getByText('page.cases.observables.filter_by_origin'));
|
|
138
|
+
expect(screen.getByText('page.cases.observables.origin.hit')).toBeTruthy();
|
|
139
|
+
expect(screen.getByText('page.cases.observables.origin.event')).toBeTruthy();
|
|
140
|
+
});
|
|
141
|
+
it('renders escalation filter chips from facet response', async () => {
|
|
142
|
+
mockDispatchApi
|
|
143
|
+
.mockResolvedValueOnce({
|
|
144
|
+
items: [{ howler: { id: 'hit-1', escalation: 'evidence' }, related: { ip: ['1.2.3.4'] } }]
|
|
145
|
+
})
|
|
146
|
+
.mockResolvedValueOnce({
|
|
147
|
+
'howler.escalation': { evidence: 1 }
|
|
148
|
+
});
|
|
149
|
+
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
150
|
+
await screen.findByText('1.2.3.4');
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
// 'evidence' appears in the table escalation column
|
|
153
|
+
expect(screen.getByText('evidence')).toBeTruthy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
it('classifies roles and renders role chips in the table', async () => {
|
|
157
|
+
mockDispatchApi.mockResolvedValue({
|
|
158
|
+
items: [
|
|
159
|
+
{
|
|
160
|
+
howler: { id: 'hit-1', outline: { threat: 'malware.exe' } },
|
|
161
|
+
related: { hosts: ['malware.exe'] }
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
});
|
|
165
|
+
render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
|
|
166
|
+
await screen.findByText('malware.exe');
|
|
167
|
+
expect(screen.getByText('page.cases.observables.role.threat')).toBeTruthy();
|
|
168
|
+
});
|
|
164
169
|
});
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
2
|
import type { FC } from 'react';
|
|
3
|
-
|
|
4
|
-
export interface ObservableEntry {
|
|
5
|
-
type: ObservableType;
|
|
6
|
-
value: string;
|
|
7
|
-
/** IDs of the hits/events this observable was seen in */
|
|
8
|
-
seenIn: string[];
|
|
9
|
-
}
|
|
3
|
+
import type { ObservableEntry } from '../types';
|
|
10
4
|
declare const Observable: FC<{
|
|
11
|
-
|
|
5
|
+
observable: ObservableEntry;
|
|
12
6
|
case: Case;
|
|
13
7
|
}>;
|
|
14
8
|
export default Observable;
|
|
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Card, CardContent, Chip, Stack, Typography } from '@mui/material';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { Link } from 'react-router-dom';
|
|
5
|
-
const Observable = ({
|
|
5
|
+
const Observable = ({ observable, case: _case }) => {
|
|
6
6
|
const { t } = useTranslation();
|
|
7
|
-
return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${
|
|
7
|
+
return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: observable.value })] }), observable.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: observable.seenIn.map(id => {
|
|
8
8
|
const entry = _case.items.find(item => item.value === id);
|
|
9
9
|
return (_jsx(Chip, { clickable: true, size: "small", label: entry.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${entry.path}` }, id));
|
|
10
10
|
}) })] }))] }) }) }));
|
|
@@ -4,7 +4,7 @@ import { render, screen } from '@testing-library/react';
|
|
|
4
4
|
import { MemoryRouter } from 'react-router-dom';
|
|
5
5
|
import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
|
-
import Observable
|
|
7
|
+
import Observable from './Observable';
|
|
8
8
|
const makeObservable = (overrides = {}) => ({
|
|
9
9
|
type: 'ip',
|
|
10
10
|
value: '192.168.1.1',
|
|
@@ -16,7 +16,7 @@ describe('Observable', () => {
|
|
|
16
16
|
it('renders the correct label for each type', () => {
|
|
17
17
|
const cases = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
|
|
18
18
|
for (const type of cases) {
|
|
19
|
-
const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
19
|
+
const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ type, value: 'x' }), case: createMockCase() }) }));
|
|
20
20
|
expect(screen.getByText(`page.cases.observables.type.${type}`)).toBeTruthy();
|
|
21
21
|
unmount();
|
|
22
22
|
}
|
|
@@ -24,25 +24,25 @@ describe('Observable', () => {
|
|
|
24
24
|
});
|
|
25
25
|
describe('value display', () => {
|
|
26
26
|
it('renders the asset value', () => {
|
|
27
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
27
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ value: '10.0.0.1' }), case: createMockCase() }) }));
|
|
28
28
|
expect(screen.getByText('10.0.0.1')).toBeTruthy();
|
|
29
29
|
});
|
|
30
30
|
it('renders long hash values without truncation', () => {
|
|
31
31
|
const hash = 'a'.repeat(64);
|
|
32
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
32
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ type: 'hash', value: hash }), case: createMockCase() }) }));
|
|
33
33
|
expect(screen.getByText(hash)).toBeTruthy();
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
describe('seen-in chips', () => {
|
|
37
37
|
it('renders nothing when seenIn is empty', () => {
|
|
38
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
38
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
|
|
39
39
|
expect(screen.queryByText('page.cases.observables.seen_in')).toBeNull();
|
|
40
40
|
});
|
|
41
41
|
it('renders "Seen in" label when seenIn has entries', () => {
|
|
42
42
|
const _case = createMockCase({
|
|
43
43
|
items: [{ path: 'alerts/test-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
|
|
44
44
|
});
|
|
45
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
45
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
|
|
46
46
|
expect(screen.getByText('page.cases.observables.seen_in')).toBeTruthy();
|
|
47
47
|
});
|
|
48
48
|
it('renders a chip labelled with entry.path for each seenIn id', () => {
|
|
@@ -53,7 +53,7 @@ describe('Observable', () => {
|
|
|
53
53
|
{ path: 'alerts/other-analytic (hit-003)', type: 'hit', value: 'hit-003' }
|
|
54
54
|
]
|
|
55
55
|
});
|
|
56
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
56
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
|
|
57
57
|
expect(screen.getByText('alerts/my-analytic (hit-001)')).toBeTruthy();
|
|
58
58
|
expect(screen.getByText('events/obs-002')).toBeTruthy();
|
|
59
59
|
expect(screen.getByText('alerts/other-analytic (hit-003)')).toBeTruthy();
|
|
@@ -63,7 +63,7 @@ describe('Observable', () => {
|
|
|
63
63
|
case_id: 'case-abc',
|
|
64
64
|
items: [{ path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
|
|
65
65
|
});
|
|
66
|
-
render(_jsx(MemoryRouter, { children: _jsx(Observable, {
|
|
66
|
+
render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
|
|
67
67
|
const link = screen.getByText('alerts/my-analytic (hit-001)').closest('a');
|
|
68
68
|
expect(link).not.toBeNull();
|
|
69
69
|
expect(link?.getAttribute('href')).toBe('/cases/case-abc/alerts/my-analytic (hit-001)');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
import type { ObservableEntry } from '../types';
|
|
3
|
+
declare const _default: import("react").NamedExoticComponent<{
|
|
4
|
+
observables: ObservableEntry[];
|
|
5
|
+
case: Case;
|
|
6
|
+
}>;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Chip, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Typography } from '@mui/material';
|
|
3
|
+
import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
|
|
4
|
+
import PluginTypography from '@cccsaurora/howler-ui/components/elements/PluginTypography';
|
|
5
|
+
import { memo, useMemo, useState } from 'react';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import { Link } from 'react-router-dom';
|
|
8
|
+
import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
9
|
+
const ROLE_COLORS = {
|
|
10
|
+
threat: 'error',
|
|
11
|
+
target: 'warning',
|
|
12
|
+
indicator: 'info'
|
|
13
|
+
};
|
|
14
|
+
const getSortValue = (observable, column) => {
|
|
15
|
+
switch (column) {
|
|
16
|
+
case 'type':
|
|
17
|
+
return observable.type;
|
|
18
|
+
case 'value':
|
|
19
|
+
return observable.value.toLowerCase();
|
|
20
|
+
case 'role':
|
|
21
|
+
return observable.role ?? '';
|
|
22
|
+
case 'seen_in':
|
|
23
|
+
return observable.seenIn.length;
|
|
24
|
+
case 'escalation':
|
|
25
|
+
return (observable.sources ?? [])
|
|
26
|
+
.map(s => s.escalation)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join(',');
|
|
29
|
+
default:
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const ObservableTable = ({ observables, case: _case }) => {
|
|
34
|
+
const { t } = useTranslation();
|
|
35
|
+
const [sortColumn, setSortColumn] = useState('type');
|
|
36
|
+
const [sortDirection, setSortDirection] = useState('asc');
|
|
37
|
+
const handleSort = (column) => {
|
|
38
|
+
if (sortColumn === column) {
|
|
39
|
+
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
setSortColumn(column);
|
|
43
|
+
setSortDirection('asc');
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const sortedObservables = useMemo(() => {
|
|
47
|
+
return [...observables].sort((a, b) => {
|
|
48
|
+
const aVal = getSortValue(a, sortColumn);
|
|
49
|
+
const bVal = getSortValue(b, sortColumn);
|
|
50
|
+
const cmp = typeof aVal === 'number' && typeof bVal === 'number' ? aVal - bVal : String(aVal).localeCompare(String(bVal));
|
|
51
|
+
return sortDirection === 'asc' ? cmp : -cmp;
|
|
52
|
+
});
|
|
53
|
+
}, [observables, sortColumn, sortDirection]);
|
|
54
|
+
return (_jsx(TableContainer, { children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { sortDirection: sortColumn === 'type' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'type', direction: sortColumn === 'type' ? sortDirection : 'asc', onClick: () => handleSort('type'), children: t('page.cases.observables.columns.type') }) }), _jsx(TableCell, { sortDirection: sortColumn === 'value' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'value', direction: sortColumn === 'value' ? sortDirection : 'asc', onClick: () => handleSort('value'), children: t('page.cases.observables.columns.value') }) }), _jsx(TableCell, { sortDirection: sortColumn === 'role' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'role', direction: sortColumn === 'role' ? sortDirection : 'asc', onClick: () => handleSort('role'), children: t('page.cases.observables.columns.role') }) }), _jsx(TableCell, { align: "right", sortDirection: sortColumn === 'seen_in' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'seen_in', direction: sortColumn === 'seen_in' ? sortDirection : 'asc', onClick: () => handleSort('seen_in'), children: t('page.cases.observables.columns.seen_in') }) }), _jsx(TableCell, { children: t('page.cases.observables.columns.sources') }), _jsx(TableCell, { sortDirection: sortColumn === 'escalation' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'escalation', direction: sortColumn === 'escalation' ? sortDirection : 'asc', onClick: () => handleSort('escalation'), children: t('page.cases.observables.columns.escalation') }) })] }) }), _jsx(TableBody, { children: sortedObservables.map(observable => {
|
|
55
|
+
const sources = observable.sources ?? [];
|
|
56
|
+
const escalations = [...new Set(sources.map(s => s.escalation).filter(Boolean))];
|
|
57
|
+
const linkedSources = sources.filter(source => source.path);
|
|
58
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }) }), _jsx(TableCell, { sx: { maxWidth: 300 }, children: _jsx(PluginTypography, { value: observable.value, context: "table", variant: "body2", sx: { fontFamily: 'monospace', wordBreak: 'break-all' } }) }), _jsx(TableCell, { children: observable.role && (_jsx(Chip, { size: "small", label: t(`page.cases.observables.role.${observable.role}`), color: ROLE_COLORS[observable.role], variant: "outlined" })) }), _jsx(TableCell, { align: "right", children: _jsx(Typography, { variant: "body2", children: observable.seenIn.length }) }), _jsx(TableCell, { children: linkedSources.length === 0 ? null : linkedSources.length === 1 ? (_jsx(Chip, { clickable: true, size: "small", label: linkedSources[0].path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${linkedSources[0].path}` })) : (_jsx(ChipPopper, { label: `${linkedSources[0].path} (+${linkedSources.length - 1})`, slotProps: { chip: { size: 'small', variant: 'outlined' } }, children: _jsx(Stack, { gap: 0.5, children: linkedSources.map(source => (_jsx(Chip, { clickable: true, size: "small", label: source.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id))) }) })) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: escalations.map(esc => (_jsx(Chip, { size: "small", label: esc, color: ESCALATION_COLORS[esc] ?? 'default' }, esc))) }) })] }, `${observable.type}:${observable.value}`));
|
|
59
|
+
}) })] }) }));
|
|
60
|
+
};
|
|
61
|
+
export default memo(ObservableTable);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ObservableType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
|
|
2
|
+
|
|
3
|
+
export type ObservableRole = 'threat' | 'target' | 'indicator';
|
|
4
|
+
|
|
5
|
+
export interface ObservableSource {
|
|
6
|
+
id: string;
|
|
7
|
+
type: 'hit' | 'observable' | 'case';
|
|
8
|
+
path?: string;
|
|
9
|
+
escalation?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ObservableEntry {
|
|
13
|
+
type: ObservableType;
|
|
14
|
+
value: string;
|
|
15
|
+
/** IDs of the hits/observables this observable was seen in */
|
|
16
|
+
seenIn: string[];
|
|
17
|
+
/** Resolved source metadata for each seenIn item */
|
|
18
|
+
sources?: ObservableSource[];
|
|
19
|
+
/** Classified role of this observable */
|
|
20
|
+
role?: ObservableRole;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type OriginType = 'hit' | 'observable';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
|
|
3
|
+
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
4
|
+
import type { Related } from '@cccsaurora/howler-ui/models/entities/generated/Related';
|
|
5
|
+
import { type ObservableEntry, type ObservableRole, type ObservableSource, type ObservableType } from './types';
|
|
6
|
+
/** All Related fields that carry asset values */
|
|
7
|
+
export declare const OBSERVABLE_FIELDS: ObservableType[];
|
|
8
|
+
/** Extract (type, value, seenInId) triples from a record's related field */
|
|
9
|
+
export declare const extractObservables: (related: Related | undefined, recordId: string) => {
|
|
10
|
+
type: ObservableType;
|
|
11
|
+
value: string;
|
|
12
|
+
id: string;
|
|
13
|
+
}[];
|
|
14
|
+
/** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
|
|
15
|
+
export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
|
|
16
|
+
/**
|
|
17
|
+
* Classify an asset's role based on case-level lists and per-record outline fields.
|
|
18
|
+
*
|
|
19
|
+
* Resolution order:
|
|
20
|
+
* 1. Case-level `threats[]`, `targets[]`, `indicators[]` (authoritative).
|
|
21
|
+
* 2. Per-record `outline.threat` / `outline.target` (exact match).
|
|
22
|
+
* 3. Per-record `outline.indicators[]`, `threat.indicator.ip/.description`.
|
|
23
|
+
* 4. Default: "indicator" — all assets come from `related.*` fields which are IOCs by nature.
|
|
24
|
+
*
|
|
25
|
+
* Comparison is case-insensitive and trimmed.
|
|
26
|
+
*/
|
|
27
|
+
export declare const classifyRole: (value: string, _case: Case, records: Partial<Hit | Event>[]) => ObservableRole;
|
|
28
|
+
/** Resolve source metadata for an asset's seenIn IDs */
|
|
29
|
+
export declare const resolveSources: (seenIn: string[], caseItems: Case["items"], escalationMap: Map<string, string>) => ObservableSource[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {} from './types';
|
|
2
|
+
/** All Related fields that carry asset values */
|
|
3
|
+
export const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
|
|
4
|
+
/** Extract (type, value, seenInId) triples from a record's related field */
|
|
5
|
+
export const extractObservables = (related, recordId) => {
|
|
6
|
+
if (!related) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
const results = [];
|
|
10
|
+
for (const field of OBSERVABLE_FIELDS) {
|
|
11
|
+
const raw = related[field];
|
|
12
|
+
if (!raw) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
if (value) {
|
|
18
|
+
results.push({ type: field, value: String(value), id: recordId });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
};
|
|
24
|
+
/** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
|
|
25
|
+
export const buildObservableEntries = (records) => {
|
|
26
|
+
const map = new Map();
|
|
27
|
+
for (const record of records) {
|
|
28
|
+
const related = record.related ?? record.related;
|
|
29
|
+
const recordId = record.howler?.id ?? record.howler?.id;
|
|
30
|
+
if (!recordId) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const { type, value, id } of extractObservables(related, recordId)) {
|
|
34
|
+
const key = `${type}:${value}`;
|
|
35
|
+
if (!map.has(key)) {
|
|
36
|
+
map.set(key, { type, value, seenIn: [] });
|
|
37
|
+
}
|
|
38
|
+
const entry = map.get(key);
|
|
39
|
+
if (!entry.seenIn.includes(id)) {
|
|
40
|
+
entry.seenIn.push(id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Array.from(map.values());
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Classify an asset's role based on case-level lists and per-record outline fields.
|
|
48
|
+
*
|
|
49
|
+
* Resolution order:
|
|
50
|
+
* 1. Case-level `threats[]`, `targets[]`, `indicators[]` (authoritative).
|
|
51
|
+
* 2. Per-record `outline.threat` / `outline.target` (exact match).
|
|
52
|
+
* 3. Per-record `outline.indicators[]`, `threat.indicator.ip/.description`.
|
|
53
|
+
* 4. Default: "indicator" — all assets come from `related.*` fields which are IOCs by nature.
|
|
54
|
+
*
|
|
55
|
+
* Comparison is case-insensitive and trimmed.
|
|
56
|
+
*/
|
|
57
|
+
export const classifyRole = (value, _case, records) => {
|
|
58
|
+
const normalized = value.trim().toLowerCase();
|
|
59
|
+
// Case-level classification (most authoritative)
|
|
60
|
+
if (_case.threats?.some(t => String(t).trim().toLowerCase() === normalized)) {
|
|
61
|
+
return 'threat';
|
|
62
|
+
}
|
|
63
|
+
if (_case.targets?.some(t => String(t).trim().toLowerCase() === normalized)) {
|
|
64
|
+
return 'target';
|
|
65
|
+
}
|
|
66
|
+
if (_case.indicators?.some(i => String(i).trim().toLowerCase() === normalized)) {
|
|
67
|
+
return 'indicator';
|
|
68
|
+
}
|
|
69
|
+
// Per-record outline checks
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
const howlerOutline = record.howler?.outline;
|
|
72
|
+
if (howlerOutline) {
|
|
73
|
+
if (howlerOutline.threat && howlerOutline.threat.trim().toLowerCase() === normalized) {
|
|
74
|
+
return 'threat';
|
|
75
|
+
}
|
|
76
|
+
if (howlerOutline.target && howlerOutline.target.trim().toLowerCase() === normalized) {
|
|
77
|
+
return 'target';
|
|
78
|
+
}
|
|
79
|
+
if (howlerOutline.indicators?.some(ind => ind.trim().toLowerCase() === normalized)) {
|
|
80
|
+
return 'indicator';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const indicator = record.threat?.indicator;
|
|
84
|
+
if (indicator) {
|
|
85
|
+
const indicatorIp = indicator.ip?.trim().toLowerCase();
|
|
86
|
+
const indicatorDesc = indicator.description?.trim().toLowerCase();
|
|
87
|
+
if ((indicatorIp && indicatorIp === normalized) || (indicatorDesc && indicatorDesc === normalized)) {
|
|
88
|
+
return 'indicator';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Default: assets from related.* are IOCs
|
|
93
|
+
return 'indicator';
|
|
94
|
+
};
|
|
95
|
+
/** Resolve source metadata for an asset's seenIn IDs */
|
|
96
|
+
export const resolveSources = (seenIn, caseItems, escalationMap) => {
|
|
97
|
+
if (!caseItems?.length) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
return seenIn
|
|
101
|
+
.map(id => {
|
|
102
|
+
const item = caseItems.find(i => i.value === id);
|
|
103
|
+
if (!item) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
type: item.type,
|
|
109
|
+
path: item.path,
|
|
110
|
+
escalation: escalationMap.get(id)
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createMockCase, createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { buildObservableEntries, classifyRole } from './utils';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Pure logic tests — no React needed
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
describe('buildObservableEntries', () => {
|
|
8
|
+
it('returns an empty array for records with no related field', () => {
|
|
9
|
+
expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
it('extracts a single IP from a hit', () => {
|
|
12
|
+
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
|
|
13
|
+
expect(result).toHaveLength(1);
|
|
14
|
+
expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
|
|
15
|
+
});
|
|
16
|
+
it('extracts multiple fields from a single record', () => {
|
|
17
|
+
const result = buildObservableEntries([
|
|
18
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
|
|
19
|
+
]);
|
|
20
|
+
const types = result.map(a => a.type).sort();
|
|
21
|
+
expect(types).toEqual(['ip', 'user']);
|
|
22
|
+
});
|
|
23
|
+
it('deduplicates the same observable value across multiple records', () => {
|
|
24
|
+
const result = buildObservableEntries([
|
|
25
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
26
|
+
createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
|
|
27
|
+
]);
|
|
28
|
+
expect(result).toHaveLength(1);
|
|
29
|
+
expect(result[0].seenIn).toEqual(['h1', 'obs1']);
|
|
30
|
+
});
|
|
31
|
+
it('keeps distinct observable values as separate entries', () => {
|
|
32
|
+
const result = buildObservableEntries([
|
|
33
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
34
|
+
createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
|
|
35
|
+
]);
|
|
36
|
+
expect(result).toHaveLength(2);
|
|
37
|
+
});
|
|
38
|
+
it('does not duplicate seenIn ids when the same record appears twice for the same observable', () => {
|
|
39
|
+
const result = buildObservableEntries([
|
|
40
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
41
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
|
|
42
|
+
]);
|
|
43
|
+
expect(result[0].seenIn).toEqual(['h1']);
|
|
44
|
+
});
|
|
45
|
+
it('skips records with no howler.id', () => {
|
|
46
|
+
const noId = { related: { ip: ['1.2.3.4'] } };
|
|
47
|
+
expect(buildObservableEntries([noId])).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
it('handles the scalar `id` field on Related', () => {
|
|
50
|
+
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
|
|
51
|
+
expect(result).toHaveLength(1);
|
|
52
|
+
expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
|
|
53
|
+
});
|
|
54
|
+
it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
|
|
55
|
+
const related = {
|
|
56
|
+
hash: ['abc123'],
|
|
57
|
+
hosts: ['host.example.com'],
|
|
58
|
+
user: ['bob'],
|
|
59
|
+
ids: ['guid-1'],
|
|
60
|
+
uri: ['https://example.com'],
|
|
61
|
+
signature: ['rule-X']
|
|
62
|
+
};
|
|
63
|
+
const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
|
|
64
|
+
const types = result.map(a => a.type).sort();
|
|
65
|
+
expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// classifyRole tests
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
describe('classifyRole', () => {
|
|
72
|
+
const emptyCase = createMockCase();
|
|
73
|
+
it('returns "threat" when value is in case.threats', () => {
|
|
74
|
+
const _case = createMockCase({ threats: ['malicious.exe'] });
|
|
75
|
+
const records = [createMockHit({ howler: { id: 'h1' } })];
|
|
76
|
+
expect(classifyRole('malicious.exe', _case, records)).toBe('threat');
|
|
77
|
+
});
|
|
78
|
+
it('returns "target" when value is in case.targets', () => {
|
|
79
|
+
const _case = createMockCase({ targets: ['server-01'] });
|
|
80
|
+
const records = [createMockHit({ howler: { id: 'h1' } })];
|
|
81
|
+
expect(classifyRole('server-01', _case, records)).toBe('target');
|
|
82
|
+
});
|
|
83
|
+
it('returns "indicator" when value is in case.indicators', () => {
|
|
84
|
+
const _case = createMockCase({ indicators: ['1.2.3.4'] });
|
|
85
|
+
const records = [createMockHit({ howler: { id: 'h1' } })];
|
|
86
|
+
expect(classifyRole('1.2.3.4', _case, records)).toBe('indicator');
|
|
87
|
+
});
|
|
88
|
+
it('returns "threat" when value matches howler.outline.threat', () => {
|
|
89
|
+
const records = [createMockHit({ howler: { id: 'h1', outline: { threat: 'malicious.exe' } } })];
|
|
90
|
+
expect(classifyRole('malicious.exe', emptyCase, records)).toBe('threat');
|
|
91
|
+
});
|
|
92
|
+
it('returns "threat" with case-insensitive matching', () => {
|
|
93
|
+
const records = [createMockHit({ howler: { id: 'h1', outline: { threat: 'Malicious.EXE' } } })];
|
|
94
|
+
expect(classifyRole('malicious.exe', emptyCase, records)).toBe('threat');
|
|
95
|
+
});
|
|
96
|
+
it('does not match threat by substring — only exact match', () => {
|
|
97
|
+
const records = [createMockHit({ howler: { id: 'h1', outline: { threat: 'evil.com dropped payload' } } })];
|
|
98
|
+
// No substring match, defaults to indicator
|
|
99
|
+
expect(classifyRole('evil.com', emptyCase, records)).toBe('indicator');
|
|
100
|
+
});
|
|
101
|
+
it('classifies as indicator when value is in outline.indicators but not in threat/target', () => {
|
|
102
|
+
const records = [
|
|
103
|
+
createMockHit({
|
|
104
|
+
howler: { id: 'h1', outline: { threat: 'actor-x', target: 'server-01', indicators: ['1.2.3.4'] } }
|
|
105
|
+
})
|
|
106
|
+
];
|
|
107
|
+
expect(classifyRole('1.2.3.4', emptyCase, records)).toBe('indicator');
|
|
108
|
+
});
|
|
109
|
+
it('returns "target" when value matches howler.outline.target', () => {
|
|
110
|
+
const records = [createMockHit({ howler: { id: 'h1', outline: { target: 'server-01' } } })];
|
|
111
|
+
expect(classifyRole('server-01', emptyCase, records)).toBe('target');
|
|
112
|
+
});
|
|
113
|
+
it('returns "indicator" when value matches threat.indicator.ip', () => {
|
|
114
|
+
const records = [
|
|
115
|
+
createMockHit({
|
|
116
|
+
howler: { id: 'h1' },
|
|
117
|
+
threat: { indicator: { ip: '10.0.0.1' } }
|
|
118
|
+
})
|
|
119
|
+
];
|
|
120
|
+
expect(classifyRole('10.0.0.1', emptyCase, records)).toBe('indicator');
|
|
121
|
+
});
|
|
122
|
+
it('returns "indicator" when value matches threat.indicator.description', () => {
|
|
123
|
+
const records = [
|
|
124
|
+
createMockHit({
|
|
125
|
+
howler: { id: 'h1' },
|
|
126
|
+
threat: { indicator: { description: 'suspicious-hash-abc123' } }
|
|
127
|
+
})
|
|
128
|
+
];
|
|
129
|
+
expect(classifyRole('suspicious-hash-abc123', emptyCase, records)).toBe('indicator');
|
|
130
|
+
});
|
|
131
|
+
it('defaults to "indicator" when value has no specific classification', () => {
|
|
132
|
+
const records = [createMockHit({ howler: { id: 'h1' }, related: { ip: ['5.6.7.8'] } })];
|
|
133
|
+
expect(classifyRole('5.6.7.8', emptyCase, records)).toBe('indicator');
|
|
134
|
+
});
|
|
135
|
+
it('case-level threats take priority over per-record outline.target', () => {
|
|
136
|
+
const _case = createMockCase({ threats: ['dual-use.exe'] });
|
|
137
|
+
const records = [createMockHit({ howler: { id: 'h1', outline: { target: 'dual-use.exe' } } })];
|
|
138
|
+
expect(classifyRole('dual-use.exe', _case, records)).toBe('threat');
|
|
139
|
+
});
|
|
140
|
+
it('trims whitespace before comparing', () => {
|
|
141
|
+
const records = [createMockHit({ howler: { id: 'h1', outline: { threat: ' evil.com ' } } })];
|
|
142
|
+
expect(classifyRole('evil.com', emptyCase, records)).toBe('threat');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -414,8 +414,23 @@
|
|
|
414
414
|
"page.cases.escalation": "Escalation",
|
|
415
415
|
"page.cases.folder.drop.root": "Place here to move to root",
|
|
416
416
|
"page.cases.observables": "Observables",
|
|
417
|
+
"page.cases.observables.columns.escalation": "Escalation",
|
|
418
|
+
"page.cases.observables.columns.role": "Role",
|
|
419
|
+
"page.cases.observables.columns.seen_in": "Seen In",
|
|
420
|
+
"page.cases.observables.columns.sources": "Sources",
|
|
421
|
+
"page.cases.observables.columns.type": "Type",
|
|
422
|
+
"page.cases.observables.columns.value": "Value",
|
|
417
423
|
"page.cases.observables.empty": "No observables found.",
|
|
424
|
+
"page.cases.observables.filter_by_escalation": "Escalation",
|
|
425
|
+
"page.cases.observables.filter_by_origin": "Origin",
|
|
426
|
+
"page.cases.observables.filter_by_role": "Role",
|
|
418
427
|
"page.cases.observables.filter_by_type": "Filter by type:",
|
|
428
|
+
"page.cases.observables.origin.event": "Event",
|
|
429
|
+
"page.cases.observables.origin.hit": "Hit",
|
|
430
|
+
"page.cases.observables.role.indicator": "Indicator",
|
|
431
|
+
"page.cases.observables.role.target": "Target",
|
|
432
|
+
"page.cases.observables.role.threat": "Threat",
|
|
433
|
+
"page.cases.observables.search": "Search observables...",
|
|
419
434
|
"page.cases.observables.seen_in": "Seen in",
|
|
420
435
|
"page.cases.observables.type.hash": "Hash",
|
|
421
436
|
"page.cases.observables.type.hosts": "Host",
|
|
@@ -414,8 +414,23 @@
|
|
|
414
414
|
"page.cases.escalation": "Escalade",
|
|
415
415
|
"page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
|
|
416
416
|
"page.cases.observables": "Observables",
|
|
417
|
+
"page.cases.observables.columns.escalation": "Escalade",
|
|
418
|
+
"page.cases.observables.columns.role": "Rôle",
|
|
419
|
+
"page.cases.observables.columns.seen_in": "Vu dans",
|
|
420
|
+
"page.cases.observables.columns.sources": "Sources",
|
|
421
|
+
"page.cases.observables.columns.type": "Type",
|
|
422
|
+
"page.cases.observables.columns.value": "Valeur",
|
|
417
423
|
"page.cases.observables.empty": "Aucun observable trouvé.",
|
|
424
|
+
"page.cases.observables.filter_by_escalation": "Escalade",
|
|
425
|
+
"page.cases.observables.filter_by_origin": "Origine",
|
|
426
|
+
"page.cases.observables.filter_by_role": "Rôle",
|
|
418
427
|
"page.cases.observables.filter_by_type": "Filtrer par type :",
|
|
428
|
+
"page.cases.observables.origin.event": "Événement",
|
|
429
|
+
"page.cases.observables.origin.hit": "Alerte",
|
|
430
|
+
"page.cases.observables.role.indicator": "Indicateur",
|
|
431
|
+
"page.cases.observables.role.target": "Cible",
|
|
432
|
+
"page.cases.observables.role.threat": "Menace",
|
|
433
|
+
"page.cases.observables.search": "Rechercher des observables...",
|
|
419
434
|
"page.cases.observables.seen_in": "Vu dans",
|
|
420
435
|
"page.cases.observables.type.hash": "Hachage",
|
|
421
436
|
"page.cases.observables.type.hosts": "Hôte",
|