[8.x] [Case Observables][Similar Cases] Add value label to similarities in response & the view (#206934) (#207377)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Case Observables][Similar Cases] Add value label to similarities in
response & the view
(#206934)](https://github.com/elastic/kibana/pull/206934)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Luke
Gmys","email":"11671118+lgestc@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-01-21T14:53:27Z","message":"[Case
Observables][Similar Cases] Add value label to similarities in response
& the view (#206934)\n\n## Summary\r\n\r\nThis PR improves similar value
rendering by wrapping them in badges and\r\nadding in the observable
type label to the api response & the view.\r\n\r\n![Screenshot
2025-01-16 at 14
07\r\n20](https://github.com/user-attachments/assets/ae4424a6-5ccb-465c-b601-89f3f756b37c)\r\n\r\n###
Testing:\r\n\r\nAdd two observables in distinct cases, with same value
and type. They\r\nshould show up in the Similar Cases tab just like on
the screenshot\r\nbelow.\r\n\r\n---------\r\n\r\nCo-authored-by: Antonio
<antoniodcoelho@gmail.com>","sha":"b44ccfcede6300412b0ec6ddcc95939a40625260","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","v9.0.0","Team:Threat
Hunting:Investigations","backport:prev-minor"],"title":"[Case
Observables][Similar Cases] Add value label to similarities in response
& the
view","number":206934,"url":"https://github.com/elastic/kibana/pull/206934","mergeCommit":{"message":"[Case
Observables][Similar Cases] Add value label to similarities in response
& the view (#206934)\n\n## Summary\r\n\r\nThis PR improves similar value
rendering by wrapping them in badges and\r\nadding in the observable
type label to the api response & the view.\r\n\r\n![Screenshot
2025-01-16 at 14
07\r\n20](https://github.com/user-attachments/assets/ae4424a6-5ccb-465c-b601-89f3f756b37c)\r\n\r\n###
Testing:\r\n\r\nAdd two observables in distinct cases, with same value
and type. They\r\nshould show up in the Similar Cases tab just like on
the screenshot\r\nbelow.\r\n\r\n---------\r\n\r\nCo-authored-by: Antonio
<antoniodcoelho@gmail.com>","sha":"b44ccfcede6300412b0ec6ddcc95939a40625260"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/206934","number":206934,"mergeCommit":{"message":"[Case
Observables][Similar Cases] Add value label to similarities in response
& the view (#206934)\n\n## Summary\r\n\r\nThis PR improves similar value
rendering by wrapping them in badges and\r\nadding in the observable
type label to the api response & the view.\r\n\r\n![Screenshot
2025-01-16 at 14
07\r\n20](https://github.com/user-attachments/assets/ae4424a6-5ccb-465c-b601-89f3f756b37c)\r\n\r\n###
Testing:\r\n\r\nAdd two observables in distinct cases, with same value
and type. They\r\nshould show up in the Similar Cases tab just like on
the screenshot\r\nbelow.\r\n\r\n---------\r\n\r\nCo-authored-by: Antonio
<antoniodcoelho@gmail.com>","sha":"b44ccfcede6300412b0ec6ddcc95939a40625260"}}]}]
BACKPORT-->

Co-authored-by: Luke Gmys <11671118+lgestc@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-01-22 03:34:35 +11:00 committed by GitHub
parent 3abc1783a6
commit 864adea19f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 133 additions and 34 deletions

View file

@ -162,6 +162,7 @@ export const RelatedCaseRt = rt.strict({
export const SimilarityRt = rt.strict({
typeKey: rt.string,
typeLabel: rt.string,
value: rt.string,
});

View file

@ -8,12 +8,12 @@
import React from 'react';
import { type AppMockRenderer, createAppMockRenderer } from '../../common/mock';
import { SimilarCasesTable, type SimilarCasesTableProps } from './table';
import { mockCase, mockObservables } from '../../containers/mock';
import { mockCase, mockSimilarObservables } from '../../containers/mock';
describe('SimilarCasesTable', () => {
let appMock: AppMockRenderer;
const props: SimilarCasesTableProps = {
cases: [{ ...mockCase, similarities: { observables: mockObservables } }],
cases: [{ ...mockCase, similarities: { observables: mockSimilarObservables } }],
isLoading: false,
onChange: jest.fn(),
pagination: { pageIndex: 0, totalItemCount: 1 },
@ -30,6 +30,12 @@ describe('SimilarCasesTable', () => {
expect(result.getByTestId('similar-cases-table')).toBeInTheDocument();
});
it('renders similarities correctly', async () => {
const result = appMock.render(<SimilarCasesTable {...props} />);
expect(await result.findByTestId('similar-cases-table-column-similarities')).toBeTruthy();
});
it('renders loading indicator when loading', async () => {
const result = appMock.render(<SimilarCasesTable {...props} isLoading={true} />);
expect(result.queryByTestId('similar-cases-table')).not.toBeInTheDocument();

View file

@ -48,13 +48,14 @@ export interface UseSimilarCasesColumnsReturnValue {
export const useSimilarCasesColumns = (): UseSimilarCasesColumnsReturnValue => {
const casesColumnsConfig = useCasesColumnsConfiguration(false);
const columns: SimilarCasesColumns[] = useMemo(
() => [
{
field: casesColumnsConfig.title.field,
name: casesColumnsConfig.title.name,
sortable: false,
render: (title: string, theCase: SimilarCaseUI) => {
render: (_title: string, theCase: SimilarCaseUI) => {
if (theCase.id != null && theCase.title != null) {
const caseDetailsLinkComponent = (
<CaseDetailsLink detailName={theCase.id} title={theCase.title}>
@ -175,16 +176,74 @@ export const useSimilarCasesColumns = (): UseSimilarCasesColumnsReturnValue => {
field: SIMILARITIES_FIELD,
name: i18n.SIMILARITY_REASON,
sortable: false,
render: (similarities: SimilarCaseUI['similarities'], theCase: SimilarCaseUI) => {
if (theCase.id != null && theCase.title != null) {
return similarities.observables.map((similarity) => similarity.value).join(', ');
render: (similarities: SimilarCaseUI['similarities']) => {
const similarObservableValues = similarities.observables.map(
(similarity) => `${similarity.typeLabel}:${similarity.value}`
);
if (similarObservableValues.length > 0) {
const clampedBadges = (
<EuiBadgeGroup
data-test-subj="similar-cases-table-column-similarities"
css={getLineClampedCss}
gutterSize="xs"
>
{similarObservableValues.map((similarValue: string) => (
<EuiBadge
css={css`
max-width: 100px;
`}
color="hollow"
key={`${similarValue}`}
data-test-subj={`similar-cases-table-column-similarities-${similarValue}`}
>
{similarValue}
</EuiBadge>
))}
</EuiBadgeGroup>
);
const unclampedBadges = (
<EuiBadgeGroup data-test-subj="similar-cases-table-column-similarities">
{similarObservableValues.map((similarValue: string) => (
<EuiBadge
color="hollow"
key={`${similarValue}`}
data-test-subj={`similar-cases-table-column-similarities-${similarValue}`}
>
{similarValue}
</EuiBadge>
))}
</EuiBadgeGroup>
);
return (
<EuiToolTip
data-test-subj="similar-cases-table-column-similarities-tooltip"
position="left"
content={unclampedBadges}
>
{clampedBadges}
</EuiToolTip>
);
}
return getEmptyCellValue();
},
width: '20%',
},
],
[casesColumnsConfig]
[
casesColumnsConfig.category.field,
casesColumnsConfig.category.name,
casesColumnsConfig.severity.field,
casesColumnsConfig.severity.name,
casesColumnsConfig.status.field,
casesColumnsConfig.status.name,
casesColumnsConfig.tags.field,
casesColumnsConfig.tags.name,
casesColumnsConfig.title.field,
casesColumnsConfig.title.name,
]
);
return { columns, rowHeader: casesColumnsConfig.title.field };

View file

@ -1324,3 +1324,16 @@ export const mockObservables: ObservableUI[] = [
updatedAt: '2024-12-11',
},
];
export const mockSimilarObservables = [
{
value: '127.0.0.1',
typeKey: OBSERVABLE_TYPE_IPV4.key,
typeLabel: OBSERVABLE_TYPE_IPV4.label,
},
{
value: '10.0.0.1',
typeKey: OBSERVABLE_TYPE_IPV4.key,
typeLabel: OBSERVABLE_TYPE_IPV4.label,
},
];

View file

@ -7,6 +7,7 @@
import { intersection } from 'lodash';
import Boom from '@hapi/boom';
import type { ObservableType } from '../../../common/types/domain/observable/v1';
import { OWNER_FIELD } from '../../../common/constants';
import type { CasesSimilarResponse, SimilarCasesSearchRequest } from '../../../common/types/api';
import { SimilarCasesSearchRequestRt, CasesSimilarResponseRt } from '../../../common/types/api';
@ -19,7 +20,7 @@ import { Operations } from '../../authorization';
import { buildFilter, buildObservablesFieldsFilter, combineFilters } from '../utils';
import { combineFilterWithAuthorizationFilter } from '../../authorization/utils';
import type { CaseSavedObjectTransformed } from '../../common/types/case';
import { getAvailableObservableTypesSet } from '../observable_types';
import { getAvailableObservableTypesMap } from '../observable_types';
interface Similarity {
typeKey: string;
@ -29,7 +30,7 @@ interface Similarity {
const getSimilarities = (
a: CaseSavedObjectTransformed,
b: CaseSavedObjectTransformed,
availableObservableTypes: Set<string>
availableObservableTypes: Map<string, ObservableType>
): Similarity[] => {
const stringify = (observable: { typeKey: string; value: string }) =>
[observable.typeKey, observable.value].join(',');
@ -46,6 +47,7 @@ const getSimilarities = (
return {
typeKey,
value,
typeLabel: availableObservableTypes.get(typeKey)?.label,
};
})
.filter((observable) => availableObservableTypes.has(observable.typeKey));
@ -78,7 +80,7 @@ export const similar = async (
const paramArgs = decodeWithExcessOrThrow(SimilarCasesSearchRequestRt)(params);
const retrievedCase = await caseService.getCase({ id: caseId });
const availableObservableTypesSet = await getAvailableObservableTypesSet(
const availableObservableTypesMap = await getAvailableObservableTypesMap(
casesClient,
retrievedCase.attributes.owner
);
@ -95,7 +97,7 @@ export const similar = async (
const similarCasesFilter = buildObservablesFieldsFilter(
retrievedCase.attributes.observables.reduce((observableMap, observable) => {
// NOTE: skip non-existent observable types
if (!availableObservableTypesSet.has(observable.typeKey)) {
if (!availableObservableTypesMap.has(observable.typeKey)) {
return observableMap;
}
@ -144,7 +146,7 @@ export const similar = async (
cases: cases.saved_objects.map((so) => ({
...flattenCaseSavedObject({ savedObject: so }),
similarities: {
observables: getSimilarities(retrievedCase, so, availableObservableTypesSet),
observables: getSimilarities(retrievedCase, so, availableObservableTypesMap),
},
})),
page: cases.page,

View file

@ -6,18 +6,23 @@
*/
import type { Configurations } from '../../common/types/domain/configure/v1';
import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../common/constants';
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
import { createCasesClientMock } from './mocks';
import { getAvailableObservableTypesSet } from './observable_types';
import { getAvailableObservableTypesMap } from './observable_types';
import type { ObservableType } from '../../common/types/domain';
const mockCasesClient = createCasesClientMock();
describe('getAvailableObservableTypesSet', () => {
const arrayToMap = (arr: ObservableType[]): Map<string, ObservableType> => {
return new Map(arr.map((item) => [item.key, item]));
};
describe('getAvailableObservableTypesMap', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return a set of available observable types', async () => {
it('should return a map of available observable types', async () => {
const mockObservableTypes = [
{ key: 'type1', label: 'test 1' },
{ key: 'type2', label: 'test 2' },
@ -29,9 +34,9 @@ describe('getAvailableObservableTypesSet', () => {
},
] as unknown as Configurations);
const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner');
const result = await getAvailableObservableTypesMap(mockCasesClient, 'mock-owner');
expect(result).toEqual(new Set(['type1', 'type2', ...OBSERVABLE_TYPES_BUILTIN_KEYS]));
expect(result).toEqual(arrayToMap([...OBSERVABLE_TYPES_BUILTIN, ...mockObservableTypes]));
});
it('should return only built-in observable types if no types are configured', async () => {
@ -41,18 +46,18 @@ describe('getAvailableObservableTypesSet', () => {
},
] as unknown as Configurations);
const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner');
const result = await getAvailableObservableTypesMap(mockCasesClient, 'mock-owner');
expect(result).toEqual(new Set(OBSERVABLE_TYPES_BUILTIN_KEYS));
expect(result).toEqual(arrayToMap(OBSERVABLE_TYPES_BUILTIN));
});
it('should handle errors and return an empty set', async () => {
it('should handle errors and return an empty map', async () => {
jest
.mocked(mockCasesClient.configure.get)
.mockRejectedValue(new Error('Failed to fetch configuration'));
const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner');
const result = await getAvailableObservableTypesMap(mockCasesClient, 'mock-owner');
expect(result).toEqual(new Set());
expect(result).toEqual(new Map());
});
});

View file

@ -5,22 +5,35 @@
* 2.0.
*/
import type { ObservableType } from '../../common/types/domain';
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
import type { CasesClient } from './client';
export const getAvailableObservableTypesSet = async (casesClient: CasesClient, owner: string) => {
try {
const configurations = await casesClient.configure.get({
owner,
});
const observableTypes = configurations?.[0]?.observableTypes ?? [];
export const getAvailableObservableTypes = async (casesClient: CasesClient, owner: string) => {
const configurations = await casesClient.configure.get({
owner,
});
const observableTypes = configurations?.[0]?.observableTypes ?? [];
const availableObservableTypesSet = new Set(
[...observableTypes, ...OBSERVABLE_TYPES_BUILTIN].map(({ key }) => key)
return [...observableTypes, ...OBSERVABLE_TYPES_BUILTIN];
};
export const getAvailableObservableTypesMap = async (
casesClient: CasesClient,
owner: string
): Promise<Map<string, ObservableType>> => {
try {
const observableTypes = await getAvailableObservableTypes(casesClient, owner);
const availableObservableTypesSet = new Map(
[...observableTypes, ...OBSERVABLE_TYPES_BUILTIN].map((observableType) => [
observableType.key,
observableType,
])
);
return availableObservableTypesSet;
} catch (error) {
return new Set<string>();
return new Map();
}
};

View file

@ -8,7 +8,7 @@
import Boom from '@hapi/boom';
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
import { type CasesClient } from './client';
import { getAvailableObservableTypesSet } from './observable_types';
import { getAvailableObservableTypesMap } from './observable_types';
/**
* Throws an error if the request has custom fields with duplicated keys.
@ -122,7 +122,7 @@ export const validateObservableTypeKeyExists = async (
observableTypeKey: string;
}
) => {
const observableTypesSet = await getAvailableObservableTypesSet(casesClient, caseOwner);
const observableTypesSet = await getAvailableObservableTypesMap(casesClient, caseOwner);
if (!observableTypesSet.has(observableTypeKey)) {
throw Boom.badRequest(`Invalid observable type, key does not exist: ${observableTypeKey}`);
}