[Security solution] Add field validation to data views on Network map (#147899)

This commit is contained in:
Steph Milovic 2023-01-10 12:22:54 -07:00 committed by GitHub
parent 52d235ae49
commit 0076fa5641
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 303 additions and 183 deletions

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useKibana } from '../../../common/lib/kibana';
import { useIsFieldInIndexPattern } from '.';
jest.mock('../../../common/lib/kibana');
const mockUseKibana = useKibana as jest.Mock;
describe('useIsFieldInIndexPattern', () => {
beforeAll(() => {
mockUseKibana.mockReturnValue({
services: {
data: {
dataViews: {
getFieldsForWildcard: () => [],
},
},
},
});
});
beforeEach(() => {
jest.clearAllMocks();
});
it('returns false when no fields in field list exist in the index pattern', async () => {
const isFieldInIndexPattern = useIsFieldInIndexPattern();
const res = await isFieldInIndexPattern('index-pattern-*', ['fields.list']);
expect(res).toEqual(false);
});
it('returns false when some but not all fields in field list exist in the index pattern', async () => {
mockUseKibana.mockReturnValue({
services: {
http: {},
data: {
dataViews: {
getFieldsForWildcard: () => [{ name: 'fields.list' }],
},
},
},
});
const isFieldInIndexPattern = useIsFieldInIndexPattern();
const res = await isFieldInIndexPattern('index-pattern-*', ['fields.list', 'another']);
expect(res).toEqual(false);
});
it('returns true when all fields in field list exist in the index pattern', async () => {
mockUseKibana.mockReturnValue({
services: {
http: {},
data: {
dataViews: {
getFieldsForWildcard: () => [{ name: 'fields.list' }],
},
},
},
});
const isFieldInIndexPattern = useIsFieldInIndexPattern();
const res = await isFieldInIndexPattern('index-pattern-*', ['fields.list']);
expect(res).toEqual(true);
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useKibana } from '../../../common/lib/kibana';
type FieldValidationCheck = (pattern: string, fieldsList: string[]) => Promise<boolean>;
export const useIsFieldInIndexPattern = (): FieldValidationCheck => {
const { dataViews } = useKibana().services.data;
return async (pattern: string, fieldsList: string[]) => {
const fields = await dataViews.getFieldsForWildcard({
pattern,
fields: fieldsList,
});
const fieldNames = fields.map((f) => f.name);
return fieldsList.every((field) => fieldNames.includes(field));
};
};

View file

@ -1,16 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmbeddedMapComponent renders correctly against snapshot 1`] = `
<EmbeddedMapComponent
endDate="2019-08-28T05:50:57.877Z"
filters={Array []}
query={
Object {
"language": "kuery",
"query": "",
}
}
setQuery={[MockFunction]}
startDate="2019-08-28T05:50:47.877Z"
/>
`;

View file

@ -5,221 +5,237 @@
* 2.0.
*/
import type { ReactWrapper } from 'enzyme';
import { mount, shallow } from 'enzyme';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import * as redux from 'react-redux';
import { waitFor } from '@testing-library/react';
import '../../../../common/mock/match_media';
import { TestProviders } from '../../../../common/mock';
import { EmbeddedMapComponent } from './embedded_map';
import { createEmbeddable } from './create_embeddable';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { getLayerList } from './map_config';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
jest.mock('../../../../common/lib/kibana');
jest.mock('./create_embeddable', () => ({
createEmbeddable: jest.fn(),
jest.mock('./create_embeddable');
jest.mock('./map_config');
jest.mock('../../../../common/containers/sourcerer');
jest.mock('../../../containers/fields');
jest.mock('./index_patterns_missing_prompt', () => ({
IndexPatternsMissingPrompt: jest.fn(() => <div data-test-subj="IndexPatternsMissingPrompt" />),
}));
const mockGetStorage = jest.fn();
const mockSetStorage = jest.fn();
jest.mock('../../../../common/lib/kibana', () => {
return {
useKibana: () => ({
services: {
embeddable: {
EmbeddablePanel: jest.fn(() => <div data-test-subj="EmbeddablePanel" />),
},
docLinks: {
ELASTIC_WEBSITE_URL: 'ELASTIC_WEBSITE_URL',
links: {
siem: { networkMap: '' },
},
},
storage: {
get: mockGetStorage,
set: mockSetStorage,
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
embeddable: {
EmbeddablePanel: jest.fn(() => <div data-test-subj="EmbeddablePanel" />),
},
docLinks: {
ELASTIC_WEBSITE_URL: 'ELASTIC_WEBSITE_URL',
links: {
siem: { networkMap: '' },
},
},
}),
};
});
jest.mock('../../../../common/containers/sourcerer', () => {
return {
useSourcererDataView: () => ({
selectedPatterns: ['filebeat-*', 'packetbeat-*'],
}),
};
});
jest.mock('./index_patterns_missing_prompt', () => {
return {
IndexPatternsMissingPrompt: jest.fn(() => <div data-test-subj="IndexPatternsMissingPrompt" />),
};
});
describe('EmbeddedMapComponent', () => {
const setQuery: jest.Mock = jest.fn();
const mockSelector = {
kibanaDataViews: [
{ id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9', title: 'filebeat-*' },
{ id: '28995490-023d-11eb-bcb6-6ba0578012a9', title: 'auditbeat-*' },
],
};
const mockCreateEmbeddable = {
destroyed: false,
enhancements: { dynamicActions: {} },
getActionContext: jest.fn(),
getFilterActions: jest.fn(),
id: '70969ddc-4d01-4048-8073-4ea63d595638',
input: {
viewMode: 'view',
title: 'Source -> Destination Point-to-Point Map',
id: '70969ddc-4d01-4048-8073-4ea63d595638',
filters: Array(0),
hidePanelTitles: true,
storage: {
get: mockGetStorage,
set: mockSetStorage,
},
},
input$: {},
isContainer: false,
output: {},
output$: {},
parent: undefined,
parentSubscription: undefined,
renderComplete: {},
runtimeId: 1,
reload: jest.fn(),
setLayerList: jest.fn(),
setEventHandlers: jest.fn(),
setRenderTooltipContent: jest.fn(),
type: 'map',
updateInput: jest.fn(),
};
const testProps = {
endDate: '2019-08-28T05:50:57.877Z',
filters: [],
query: { query: '', language: 'kuery' },
setQuery,
startDate: '2019-08-28T05:50:47.877Z',
};
}),
useToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
remove: jest.fn(),
}),
}));
const mockUseSourcererDataView = useSourcererDataView as jest.Mock;
const mockCreateEmbeddable = createEmbeddable as jest.Mock;
const mockUseIsFieldInIndexPattern = useIsFieldInIndexPattern as jest.Mock;
const mockGetStorage = jest.fn();
const mockSetStorage = jest.fn();
const setQuery: jest.Mock = jest.fn();
const filebeatDataView = { id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9', title: 'filebeat-*' };
const auditbeatDataView = { id: '28995490-023d-11eb-bcb6-6ba0578012a9', title: 'auditbeat-*' };
const mockSelector = {
kibanaDataViews: [filebeatDataView, auditbeatDataView],
};
const embeddableValue = {
destroyed: false,
enhancements: { dynamicActions: {} },
getActionContext: jest.fn(),
getFilterActions: jest.fn(),
id: '70969ddc-4d01-4048-8073-4ea63d595638',
input: {
viewMode: 'view',
title: 'Source -> Destination Point-to-Point Map',
id: '70969ddc-4d01-4048-8073-4ea63d595638',
filters: Array(0),
hidePanelTitles: true,
},
input$: {},
isContainer: false,
output: {},
output$: {},
parent: undefined,
parentSubscription: undefined,
renderComplete: {},
runtimeId: 1,
reload: jest.fn(),
setLayerList: jest.fn(),
setEventHandlers: jest.fn(),
setRenderTooltipContent: jest.fn(),
type: 'map',
updateInput: jest.fn(),
};
const testProps = {
endDate: '2019-08-28T05:50:57.877Z',
filters: [],
query: { query: '', language: 'kuery' },
setQuery,
startDate: '2019-08-28T05:50:47.877Z',
};
describe('EmbeddedMapComponent', () => {
beforeEach(() => {
setQuery.mockClear();
mockGetStorage.mockReturnValue(true);
jest.spyOn(redux, 'useSelector').mockReturnValue(mockSelector);
mockUseSourcererDataView.mockReturnValue({ selectedPatterns: ['filebeat-*', 'packetbeat-*'] });
mockCreateEmbeddable.mockResolvedValue(embeddableValue);
mockUseIsFieldInIndexPattern.mockReturnValue(() => [true, true]);
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
test('renders', async () => {
const { getByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
expect(wrapper.find('EmbeddedMapComponent')).toMatchSnapshot();
await waitFor(() => {
expect(getByTestId('EmbeddedMapComponent')).toBeInTheDocument();
});
});
test('renders services.embeddable.EmbeddablePanel', async () => {
const spy = jest.spyOn(redux, 'useSelector');
spy.mockReturnValue(mockSelector);
(createEmbeddable as jest.Mock).mockResolvedValue(mockCreateEmbeddable);
const wrapper: ReactWrapper = mount(
const { getByTestId, queryByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('[data-test-subj="EmbeddablePanel"]').exists()).toEqual(true);
expect(wrapper.find('[data-test-subj="IndexPatternsMissingPrompt"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="loading-panel"]').exists()).toEqual(false);
expect(getByTestId('EmbeddablePanel')).toBeInTheDocument();
expect(queryByTestId('IndexPatternsMissingPrompt')).not.toBeInTheDocument();
expect(queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
});
test('renders IndexPatternsMissingPrompt', async () => {
const spy = jest.spyOn(redux, 'useSelector');
spy.mockReturnValue({
jest.spyOn(redux, 'useSelector').mockReturnValue({
...mockSelector,
kibanaDataViews: [],
});
(createEmbeddable as jest.Mock).mockResolvedValue(mockCreateEmbeddable);
const wrapper: ReactWrapper = mount(
const { getByTestId, queryByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('[data-test-subj="EmbeddablePanel"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="IndexPatternsMissingPrompt"]').exists()).toEqual(true);
expect(wrapper.find('[data-test-subj="loading-panel"]').exists()).toEqual(false);
expect(queryByTestId('EmbeddablePanel')).not.toBeInTheDocument();
expect(getByTestId('IndexPatternsMissingPrompt')).toBeInTheDocument();
expect(queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
});
test('renders Loader', async () => {
const spy = jest.spyOn(redux, 'useSelector');
spy.mockReturnValue(mockSelector);
mockCreateEmbeddable.mockResolvedValue(null);
(createEmbeddable as jest.Mock).mockResolvedValue(null);
const wrapper: ReactWrapper = mount(
const { getByTestId, queryByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('[data-test-subj="EmbeddablePanel"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="IndexPatternsMissingPrompt"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="loading-panel"]').exists()).toEqual(true);
expect(queryByTestId('EmbeddablePanel')).not.toBeInTheDocument();
expect(queryByTestId('IndexPatternsMissingPrompt')).not.toBeInTheDocument();
expect(getByTestId('loading-spinner')).toBeInTheDocument();
});
});
test('map hidden on close', async () => {
mockGetStorage.mockReturnValue(false);
const wrapper = mount(
const { getByTestId, queryByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false);
const container = wrapper.find('[data-test-subj="false-toggle-network-map"]').last();
container.simulate('click');
expect(queryByTestId('siemEmbeddable')).not.toBeInTheDocument();
getByTestId('false-toggle-network-map').click();
await waitFor(() => {
wrapper.update();
expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', true);
expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true);
expect(getByTestId('siemEmbeddable')).toBeInTheDocument();
});
});
test('map visible on open', async () => {
const wrapper = mount(
const { getByTestId, queryByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true);
const container = wrapper.find('[data-test-subj="true-toggle-network-map"]').last();
container.simulate('click');
expect(getByTestId('siemEmbeddable')).toBeInTheDocument();
getByTestId('true-toggle-network-map').click();
await waitFor(() => {
wrapper.update();
expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', false);
expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false);
expect(queryByTestId('siemEmbeddable')).not.toBeInTheDocument();
});
});
test('On mount, selects existing Kibana data views that match any selected index pattern', async () => {
render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
const dataViewArg = (getLayerList as jest.Mock).mock.calls[0][0];
expect(dataViewArg).toEqual([filebeatDataView]);
});
});
test('On rerender with new selected patterns, selects existing Kibana data views that match any selected index pattern', async () => {
mockUseSourcererDataView
.mockReturnValueOnce({ selectedPatterns: ['filebeat-*', 'packetbeat-*'] })
.mockReturnValue({ selectedPatterns: ['filebeat-*', 'auditbeat-*'] });
const { rerender } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
const dataViewArg = (getLayerList as jest.Mock).mock.calls[0][0];
expect(dataViewArg).toEqual([filebeatDataView]);
});
rerender(
<TestProviders>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
// data view is updated with the returned embeddable.setLayerList callback, which is passesd getLayerList(dataViews)
const dataViewArg = (getLayerList as jest.Mock).mock.calls[1][0];
expect(dataViewArg).toEqual([filebeatDataView, auditbeatDataView]);
});
});
});

View file

@ -6,7 +6,6 @@
*/
import { EuiAccordion, EuiLink, EuiText } from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { createHtmlPortalNode, InPortal } from 'react-reverse-portal';
import styled, { css } from 'styled-components';
@ -15,8 +14,9 @@ import type { Filter, Query } from '@kbn/es-query';
import type { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import type { MapEmbeddable } from '@kbn/maps-plugin/public/embeddable';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
import { Loader } from '../../../../common/components/loader';
import { displayErrorToast, useStateToaster } from '../../../../common/components/toasters';
import type { GlobalTimeArgs } from '../../../../common/containers/use_global_time';
import { Embeddable } from './embeddable';
import { createEmbeddable } from './create_embeddable';
@ -24,8 +24,9 @@ import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt';
import { MapToolTip } from './map_tool_tip/map_tool_tip';
import * as i18n from './translations';
import { useKibana } from '../../../../common/lib/kibana';
import { getLayerList } from './map_config';
import { getLayerList, getRequiredMapsFields } from './map_config';
import { sourcererSelectors } from '../../../../common/store/sourcerer';
import type { SourcererDataView } from '../../../../common/store/sourcerer/model';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
@ -110,7 +111,7 @@ export const EmbeddedMapComponent = ({
const [isIndexError, setIsIndexError] = useState(false);
const [storageValue, setStorageValue] = useState(storage.get(NETWORK_MAP_VISIBLE) ?? true);
const [, dispatchToaster] = useStateToaster();
const { addError } = useAppToasts();
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
@ -119,9 +120,50 @@ export const EmbeddedMapComponent = ({
const { kibanaDataViews } = useDeepEqualSelector((state) => getDataViewsSelector(state));
const { selectedPatterns } = useSourcererDataView(SourcererScopeName.default);
const [mapIndexPatterns, setMapIndexPatterns] = useState(
kibanaDataViews.filter((dataView) => selectedPatterns.includes(dataView.title))
);
const isFieldInIndexPattern = useIsFieldInIndexPattern();
const [mapDataViews, setMapDataViews] = useState<SourcererDataView[]>([]);
const availableDataViews = useMemo(() => {
const dataViews = kibanaDataViews.filter((dataView) =>
selectedPatterns.includes(dataView.title)
);
if (selectedPatterns.length > 0 && dataViews.length === 0) {
setIsIndexError(true);
}
return dataViews;
}, [kibanaDataViews, selectedPatterns]);
useEffect(() => {
let canceled = false;
const fetchData = async () => {
try {
const apiResponse = await Promise.all(
availableDataViews.map(async ({ title }) =>
isFieldInIndexPattern(title, getRequiredMapsFields(title))
)
);
// ensures only index patterns with maps fields are passed
const goodDataViews = availableDataViews.filter((_, i) => apiResponse[i] ?? false);
if (!canceled) {
setMapDataViews(goodDataViews);
}
} catch (e) {
if (!canceled) {
setMapDataViews([]);
addError(e, { title: i18n.ERROR_CREATING_EMBEDDABLE });
setIsError(true);
}
}
};
if (availableDataViews.length) {
fetchData();
}
return () => {
canceled = true;
};
}, [addError, availableDataViews, isFieldInIndexPattern]);
// This portalNode provided by react-reverse-portal allows us re-parent the MapToolTip within our
// own component tree instead of the embeddables (default). This is necessary to have access to
@ -129,21 +171,6 @@ export const EmbeddedMapComponent = ({
// Search InPortal/OutPortal for implementation touch points
const portalNode = React.useMemo(() => createHtmlPortalNode(), []);
useEffect(() => {
setMapIndexPatterns((prevMapIndexPatterns) => {
const newIndexPatterns = kibanaDataViews.filter((dataView) =>
selectedPatterns.includes(dataView.title)
);
if (!deepEqual(newIndexPatterns, prevMapIndexPatterns)) {
if (newIndexPatterns.length === 0) {
setIsError(true);
}
return newIndexPatterns;
}
return prevMapIndexPatterns;
});
}, [kibanaDataViews, selectedPatterns]);
// Initial Load useEffect
useEffect(() => {
let isSubscribed = true;
@ -152,7 +179,7 @@ export const EmbeddedMapComponent = ({
try {
const embeddableObject = await createEmbeddable(
filters,
mapIndexPatterns,
mapDataViews,
query,
startDate,
endDate,
@ -161,21 +188,17 @@ export const EmbeddedMapComponent = ({
services.embeddable
);
if (isSubscribed) {
if (mapIndexPatterns.length === 0) {
setIsIndexError(true);
} else {
setEmbeddable(embeddableObject);
setIsIndexError(false);
}
setEmbeddable(embeddableObject);
}
} catch (e) {
if (isSubscribed) {
displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, [e.message], dispatchToaster);
addError(e, { title: i18n.ERROR_CREATING_EMBEDDABLE });
setIsError(true);
}
}
}
if (embeddable == null && selectedPatterns.length > 0) {
if (embeddable == null && selectedPatterns.length > 0 && !isIndexError) {
setupEmbeddable();
}
@ -183,32 +206,33 @@ export const EmbeddedMapComponent = ({
isSubscribed = false;
};
}, [
dispatchToaster,
addError,
endDate,
embeddable,
filters,
mapIndexPatterns,
mapDataViews,
query,
portalNode,
services.embeddable,
selectedPatterns,
setQuery,
startDate,
isIndexError,
]);
// update layer with new index patterns
useEffect(() => {
const setLayerList = async () => {
if (embeddable != null) {
if (embeddable != null && mapDataViews.length) {
// @ts-expect-error
await embeddable.setLayerList(getLayerList(mapIndexPatterns));
await embeddable.setLayerList(getLayerList(mapDataViews));
embeddable.reload();
}
};
if (embeddable != null && !isErrorEmbeddable(embeddable)) {
setLayerList();
}
}, [embeddable, mapIndexPatterns]);
}, [embeddable, mapDataViews]);
// queryExpression updated useEffect
useEffect(() => {
@ -267,6 +291,7 @@ export const EmbeddedMapComponent = ({
return isError ? null : (
<StyledEuiAccordion
data-test-subj="EmbeddedMapComponent"
onToggle={setDefaultMapVisibility}
id={'network-map'}
arrowDisplay="right"

View file

@ -97,6 +97,16 @@ export const lmc: LayerMappingCollection = {
'traces-apm*,logs-apm*,metrics-apm*,apm-*': APM_LAYER_FIELD_MAPPING,
};
export const getRequiredMapsFields = (title: string): string[] => {
const fieldMappings = lmc[title] ?? lmc.default;
return [
fieldMappings.source.metricField,
fieldMappings.source.geoField,
fieldMappings.destination.metricField,
fieldMappings.destination.geoField,
];
};
/**
* Returns `Source/Destination Point-to-point` Map LayerList configuration, with a source,
* destination, and line layer for each of the provided indexPatterns

View file

@ -5,4 +5,5 @@
* 2.0.
*/
require('../../src/setup_node_env');
require('@kbn/test').runJest();