[Security Solution] Add Host/User flyout in One Discover. (#199279)

## Summary

Handles https://github.com/elastic/kibana/issues/191998

Follow up work:
  - https://github.com/elastic/security-team/issues/11112
  - https://github.com/elastic/kibana/issues/196667


This PR add below entity flyouts for below entities in One Discover:
- host.name
- user.name
- source.ip
- destination.ip


In this PR we re-use the security solution code by making use of below
model based on `discover-shared` plugin.

```mermaid
flowchart TD
  discoverShared["Discover Shared"]
  securitySolution["Security Solution"]
  discover["Discover"]


  securitySolution -- "registers Features" --> discoverShared
  discover -- "consume Features" --> discoverShared

```

## How to Test

>[!Note]
>This PR adds `security-root-profile` in One discover which is currently
in `experimental mode`. All changes below can only be tested when
profile is activated. Profile can activated by adding below lines in
`config/kibana.dev.yml`
> ```yaml
>  discover.experimental.enabledProfiles:
>     - security-root-profile
> ```
>

1. As mentioned above, adding above experimental flag in
`kibana.dev.yml`.
2. Spin up Security Serverless project and add some alert Data.
3. Navigate to Discover and add columns `host.name` and `user.name` in
table. Now `host` and `user` flyouts should be available on clicking
`host.name`, `user.name`, `source.ip` & `destination.ip`.
4. Flyout should work without any error.
5. Below things are not working and will be tackled in followup PR :
    - Security Hover actions
    - Actions such as `Add to Timeline` or `Add to Case` 



### Checklist

Delete any items that are not applicable to this PR.


- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jatin Kathuria 2024-11-29 14:04:58 +01:00 committed by GitHub
parent 89063df988
commit c80f91efeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 728 additions and 81 deletions

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ReactElement } from 'react';
import type { FunctionComponent } from 'react';
import type { EuiDataGridCellValueElementProps, EuiDataGridColumn } from '@elastic/eui';
import type { DataTableRecord } from '@kbn/discover-utils/src/types';
import type { DataView } from '@kbn/data-views-plugin/common';
@ -46,10 +46,7 @@ export type DataGridCellValueElementProps = EuiDataGridCellValueElementProps & {
isCompressed?: boolean;
};
export type CustomCellRenderer = Record<
string,
(props: DataGridCellValueElementProps) => ReactElement
>;
export type CustomCellRenderer = Record<string, FunctionComponent<DataGridCellValueElementProps>>;
export interface CustomGridColumnProps {
column: EuiDataGridColumn;

View file

@ -32,6 +32,7 @@
"unifiedSearch",
"unifiedHistogram",
"contentManagement",
"discoverShared"
],
"optionalPlugins": [
"dataVisualizer",

View file

@ -47,6 +47,7 @@ import { urlTrackerMock } from './url_tracker.mock';
import { createElement } from 'react';
import { createContextAwarenessMocks } from '../context_awareness/__mocks__';
import { DiscoverEBTManager } from '../services/discover_ebt_manager';
import { discoverSharedPluginMock } from '@kbn/discover-shared-plugin/public/mocks';
export function createDiscoverServicesMock(): DiscoverServices {
const dataPlugin = dataPluginMock.createStartContract();
@ -250,6 +251,7 @@ export function createDiscoverServicesMock(): DiscoverServices {
profilesManager: profilesManagerMock,
ebtManager: new DiscoverEBTManager(),
setHeaderActionMenu: jest.fn(),
discoverShared: discoverSharedPluginMock.createStartContract().features,
} as unknown as DiscoverServices;
}

View file

@ -25,6 +25,7 @@ import { ProfileProviderServices } from '../profile_providers/profile_provider_s
import { ProfilesManager } from '../profiles_manager';
import { DiscoverEBTManager } from '../../services/discover_ebt_manager';
import { createLogsContextServiceMock } from '@kbn/discover-utils/src/__mocks__';
import { discoverSharedPluginMock } from '@kbn/discover-shared-plugin/public/mocks';
export const createContextAwarenessMocks = ({
shouldRegisterProviders = true,
@ -181,5 +182,6 @@ export const createContextAwarenessMocks = ({
const createProfileProviderServicesMock = () => {
return {
logsContextService: createLogsContextServiceMock(),
discoverShared: discoverSharedPluginMock.createStartContract(),
} as ProfileProviderServices;
};

View file

@ -0,0 +1,17 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { SecuritySolutionAppWrapperFeature } from '@kbn/discover-shared-plugin/public';
export const createAppWrapperAccessor = async (
appWrapperFeature?: SecuritySolutionAppWrapperFeature
) => {
if (!appWrapperFeature) return undefined;
return appWrapperFeature.getWrapper();
};

View file

@ -0,0 +1,57 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import type { SecuritySolutionCellRendererFeature } from '@kbn/discover-shared-plugin/public';
import { DataGridCellValueElementProps } from '@kbn/unified-data-table';
import { createCellRendererAccessor } from './get_cell_renderer_accessor';
import { render } from '@testing-library/react';
const cellRendererFeature: SecuritySolutionCellRendererFeature = {
id: 'security-solution-cell-renderer',
getRenderer: async () => (fieldName: string) => {
if (fieldName === 'host.name') {
return (props: DataGridCellValueElementProps) => {
return <div data-test-subj="cell-render-feature">{props.columnId}</div>;
};
}
},
};
const mockCellProps = {
columnId: 'host.name',
row: {
id: '1',
raw: {},
flattened: {},
},
} as DataGridCellValueElementProps;
describe('getCellRendererAccessort', () => {
it('should return a cell renderer', async () => {
const getCellRenderer = await createCellRendererAccessor(cellRendererFeature);
expect(getCellRenderer).toBeDefined();
const CellRenderer = getCellRenderer?.('host.name') as React.FC<DataGridCellValueElementProps>;
expect(CellRenderer).toBeDefined();
const { getByTestId } = render(<CellRenderer {...mockCellProps} />);
expect(getByTestId('cell-render-feature')).toBeVisible();
expect(getByTestId('cell-render-feature')).toHaveTextContent('host.name');
});
it('should return undefined if cellRendererFeature is not defined', async () => {
const getCellRenderer = await createCellRendererAccessor();
expect(getCellRenderer).toBeUndefined();
});
it('should return undefined if cellRendererGetter returns undefined', async () => {
const getCellRenderer = await createCellRendererAccessor(cellRendererFeature);
const cellRenderer = getCellRenderer?.('user.name');
expect(cellRenderer).toBeUndefined();
});
});

View file

@ -0,0 +1,28 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import type { SecuritySolutionCellRendererFeature } from '@kbn/discover-shared-plugin/public';
import { DataGridCellValueElementProps } from '@kbn/unified-data-table';
export const createCellRendererAccessor = async (
cellRendererFeature?: SecuritySolutionCellRendererFeature
) => {
if (!cellRendererFeature) return undefined;
const cellRendererGetter = await cellRendererFeature.getRenderer();
function getCellRenderer(fieldName: string) {
const CellRenderer = cellRendererGetter(fieldName);
if (!CellRenderer) return undefined;
return React.memo(function SecuritySolutionCellRenderer(props: DataGridCellValueElementProps) {
return <CellRenderer {...props} />;
});
}
return getCellRenderer;
};

View file

@ -7,25 +7,71 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FunctionComponent, PropsWithChildren } from 'react';
import { DataGridCellValueElementProps } from '@kbn/unified-data-table';
import { RootProfileProvider, SolutionType } from '../../../profiles';
import { ProfileProviderServices } from '../../profile_provider_services';
import { SecurityProfileProviderFactory } from '../types';
import { createCellRendererAccessor } from '../accessors/get_cell_renderer_accessor';
import { createAppWrapperAccessor } from '../accessors/create_app_wrapper_accessor';
interface SecurityRootProfileContext {
appWrapper?: FunctionComponent<PropsWithChildren<{}>>;
getCellRenderer?: (
fieldName: string
) => FunctionComponent<DataGridCellValueElementProps> | undefined;
}
const EmptyAppWrapper: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => <>{children}</>;
export const createSecurityRootProfileProvider: SecurityProfileProviderFactory<
RootProfileProvider
> = (services: ProfileProviderServices) => ({
profileId: 'security-root-profile',
isExperimental: true,
profile: {
getCellRenderers: (prev) => (params) => ({
...prev(params),
}),
},
resolve: (params) => {
if (params.solutionNavId === SolutionType.Security) {
return { isMatch: true, context: { solutionType: SolutionType.Security } };
}
RootProfileProvider<SecurityRootProfileContext>
> = (services: ProfileProviderServices) => {
const { discoverShared } = services;
const discoverFeaturesRegistry = discoverShared.features.registry;
const cellRendererFeature = discoverFeaturesRegistry.getById('security-solution-cell-renderer');
const appWrapperFeature = discoverFeaturesRegistry.getById('security-solution-app-wrapper');
return { isMatch: false };
},
});
return {
profileId: 'security-root-profile',
isExperimental: true,
profile: {
getRenderAppWrapper: (PrevWrapper, params) => {
const AppWrapper = params.context.appWrapper ?? EmptyAppWrapper;
return ({ children }) => (
<PrevWrapper>
<AppWrapper>{children}</AppWrapper>
</PrevWrapper>
);
},
getCellRenderers:
(prev, { context }) =>
(params) => {
const entries = prev(params);
['host.name', 'user.name', 'source.ip', 'destination.ip'].forEach((fieldName) => {
entries[fieldName] = context.getCellRenderer?.(fieldName) ?? entries[fieldName];
});
return entries;
},
},
resolve: async (params) => {
if (params.solutionNavId !== SolutionType.Security) {
return {
isMatch: false,
};
}
const getAppWrapper = await createAppWrapperAccessor(appWrapperFeature);
const getCellRenderer = await createCellRendererAccessor(cellRendererFeature);
return {
isMatch: true,
context: {
solutionType: SolutionType.Security,
appWrapper: getAppWrapper?.(),
getCellRenderer,
},
};
},
};
};

View file

@ -20,7 +20,7 @@ import type { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { OmitIndexSignature } from 'type-fest';
import type { Trigger } from '@kbn/ui-actions-plugin/public';
import type { PropsWithChildren, ReactElement } from 'react';
import type { FunctionComponent, PropsWithChildren } from 'react';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import type { DiscoverDataSource } from '../../common/data_sources';
import type { DiscoverAppState } from '../application/main/state_management/discover_app_state_container';
@ -268,7 +268,7 @@ export interface Profile {
* @param props The app wrapper props
* @returns The custom app wrapper component
*/
getRenderAppWrapper: (props: PropsWithChildren<{}>) => ReactElement;
getRenderAppWrapper: FunctionComponent<PropsWithChildren<{}>>;
/**
* Gets default Discover app state that should be used when the profile is resolved

View file

@ -38,3 +38,4 @@ export {
} from './embeddable';
export { loadSharingDataHelpers } from './utils';
export { LogsExplorerTabs, type LogsExplorerTabsProps } from './components/logs_explorer_tabs';
export type { DiscoverServices } from './build_services';

View file

@ -42,7 +42,7 @@ import type { AiopsPluginStart } from '@kbn/aiops-plugin/public';
import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public';
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public';
import { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public';
import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public';
import { DiscoverAppLocator } from '../common';
import { DiscoverCustomizationContext } from './customizations';
import { type DiscoverContainerProps } from './components/discover_container';

View file

@ -95,9 +95,9 @@
"@kbn/presentation-containers",
"@kbn/observability-ai-assistant-plugin",
"@kbn/fields-metadata-plugin",
"@kbn/discover-contextual-components",
"@kbn/logs-data-access-plugin",
"@kbn/core-lifecycle-browser",
"@kbn/discover-contextual-components",
"@kbn/esql-ast",
"@kbn/discover-shared-plugin"
],

View file

@ -17,5 +17,9 @@ export type { DiscoverSharedPublicSetup, DiscoverSharedPublicStart } from './typ
export type {
ObservabilityLogsAIAssistantFeatureRenderDeps,
ObservabilityLogsAIAssistantFeature,
SecuritySolutionCellRendererFeature,
SecuritySolutionAppWrapperFeature,
DiscoverFeature,
} from './services/discover_features';
DiscoverFeaturesServiceSetup,
DiscoverFeaturesServiceStart,
} from './services/discover_features/types';

View file

@ -7,7 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { DataTableRecord } from '@kbn/discover-utils';
import type { DataTableRecord } from '@kbn/discover-utils';
import type { FunctionComponent, PropsWithChildren } from 'react';
import type { DataGridCellValueElementProps } from '@kbn/unified-data-table';
import { FeaturesRegistry } from '../../../common';
/**
@ -38,8 +40,31 @@ export interface ObservabilityCreateSLOFeature {
}) => React.ReactNode;
}
/** **************** Security Solution ****************/
export interface SecuritySolutionCellRendererFeature {
id: 'security-solution-cell-renderer';
getRenderer: () => Promise<
(fieldName: string) => FunctionComponent<DataGridCellValueElementProps> | undefined
>;
}
export interface SecuritySolutionAppWrapperFeature {
id: 'security-solution-app-wrapper';
getWrapper: () => Promise<() => FunctionComponent<PropsWithChildren<{}>>>;
}
export type SecuritySolutionFeature =
| SecuritySolutionCellRendererFeature
| SecuritySolutionAppWrapperFeature;
/** ****************************************************************************************/
// This should be a union of all the available client features.
export type DiscoverFeature = ObservabilityLogsAIAssistantFeature | ObservabilityCreateSLOFeature;
export type DiscoverFeature =
| ObservabilityLogsAIAssistantFeature
| ObservabilityCreateSLOFeature
| SecuritySolutionFeature;
/**
* Service types

View file

@ -13,5 +13,6 @@
"kbn_references": [
"@kbn/discover-utils",
"@kbn/core",
"@kbn/unified-data-table",
]
}

View file

@ -23,9 +23,10 @@ import { ENROLLMENT_API_KEYS_INDEX } from '../../constants';
import { packagePolicyService } from '../package_policy';
import { FleetError, HostedAgentPolicyRestrictionRelatedError } from '../../errors';
import { isSpaceAwarenessEnabled } from './helpers';
import type { UninstallTokenSOAttributes } from '../security/uninstall_token_service';
import { isSpaceAwarenessEnabled } from './helpers';
export async function updateAgentPolicySpaces({
agentPolicyId,
currentSpaceId,

View file

@ -13,6 +13,10 @@ import type { BrowserFields, TimelineNonEcsData } from '../../../search_strategy
/** The following props are provided to the function called by `renderCellValue` */
export type CellValueElementProps = EuiDataGridCellValueElementProps & {
/**
* makes sure that field is not rendered as a plain text
* but according to the renderer.
*/
asPlainText?: boolean;
browserFields?: BrowserFields;
data: TimelineNonEcsData[];

View file

@ -25,7 +25,6 @@
"dashboard",
"data",
"dataViews",
"discover",
"ecsDataQualityDashboard",
"elasticAssistant",
"embeddable",
@ -59,7 +58,8 @@
"unifiedDocViewer",
"charts",
"entityManager",
"inference"
"inference",
"discoverShared"
],
"optionalPlugins": [
"encryptedSavedObjects",

View file

@ -6,8 +6,8 @@
*/
import type { CellAction, CellActionFactory } from '@kbn/cell-actions';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import type { SecurityAppStore } from '../../../../common/store';
import { isInSecurityApp } from '../../utils';
import type { StartServices } from '../../../../types';
import { createAddToTimelineCellActionFactory } from '../cell_action/add_to_timeline';

View file

@ -10,12 +10,13 @@ import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { apiPublishesUnifiedSearch } from '@kbn/presentation-publishing';
import { isLensApi } from '@kbn/lens-plugin/public';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import { KibanaServices } from '../../../../common/lib/kibana';
import type { SecurityAppStore } from '../../../../common/store/types';
import { addProvider } from '../../../../timelines/store/actions';
import type { DataProvider } from '../../../../../common/types';
import { EXISTS_OPERATOR, TimelineId } from '../../../../../common/types';
import { fieldHasCellActions, isInSecurityApp } from '../../utils';
import { fieldHasCellActions } from '../../utils';
import {
ADD_TO_TIMELINE,
ADD_TO_TIMELINE_FAILED_TEXT,

View file

@ -6,7 +6,7 @@
*/
import type { CellAction, CellActionFactory } from '@kbn/cell-actions';
import { isInSecurityApp } from '../../utils';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import type { StartServices } from '../../../../types';
import { createCopyToClipboardCellActionFactory } from '../cell_action/copy_to_clipboard';

View file

@ -9,8 +9,9 @@ import type { CellValueContext, IEmbeddable } from '@kbn/embeddable-plugin/publi
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { createAction } from '@kbn/ui-actions-plugin/public';
import copy from 'copy-to-clipboard';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import { KibanaServices } from '../../../../common/lib/kibana';
import { fieldHasCellActions, isCountField, isInSecurityApp, isLensEmbeddable } from '../../utils';
import { fieldHasCellActions, isCountField, isLensEmbeddable } from '../../utils';
import { COPY_TO_CLIPBOARD, COPY_TO_CLIPBOARD_ICON, COPY_TO_CLIPBOARD_SUCCESS } from '../constants';
export const ACTION_ID = 'embeddable_copyToClipboard';

View file

@ -6,8 +6,8 @@
*/
import type { CellAction, CellActionFactory } from '@kbn/cell-actions';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import type { SecurityAppStore } from '../../../../common/store';
import { isInSecurityApp } from '../../utils';
import type { StartServices } from '../../../../types';
import { createFilterInCellActionFactory } from '../cell_action/filter_in';

View file

@ -6,7 +6,7 @@
*/
import type { CellActionFactory, CellAction } from '@kbn/cell-actions';
import { isInSecurityApp } from '../../utils';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import type { SecurityAppStore } from '../../../../common/store';
import type { StartServices } from '../../../../types';
import { createFilterOutCellActionFactory } from '../cell_action/filter_out';

View file

@ -16,8 +16,9 @@ import type { CellValueContext, IEmbeddable } from '@kbn/embeddable-plugin/publi
import { createAction } from '@kbn/ui-actions-plugin/public';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations';
import { i18n } from '@kbn/i18n';
import { isInSecurityApp } from '../../../../common/hooks/is_in_security_app';
import { timelineSelectors } from '../../../../timelines/store';
import { fieldHasCellActions, isInSecurityApp, isLensEmbeddable } from '../../utils';
import { fieldHasCellActions, isLensEmbeddable } from '../../utils';
import { TimelineId } from '../../../../../common/types';
import { DefaultCellActionTypes } from '../../constants';
import type { SecurityAppStore } from '../../../../common/store';

View file

@ -7,7 +7,6 @@
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
import { isLensApi } from '@kbn/lens-plugin/public';
import type { Serializable } from '@kbn/utility-types';
import { APP_UI_ID } from '../../../common/constants';
// All cell actions are disabled for these fields in Security
const FIELDS_WITHOUT_CELL_ACTIONS = [
@ -17,10 +16,6 @@ const FIELDS_WITHOUT_CELL_ACTIONS = [
'kibana.alert.reason',
];
export const isInSecurityApp = (currentAppId?: string): boolean => {
return !!currentAppId && currentAppId === APP_UI_ID;
};
// @TODO: this is a temporary fix. It needs a better refactor on the consumer side here to
// adapt to the new Embeddable architecture
export const isLensEmbeddable = (embeddable: IEmbeddable): embeddable is IEmbeddable => {

View file

@ -0,0 +1,25 @@
/*
* 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 useObservable from 'react-use/lib/useObservable';
import { useMemo } from 'react';
import { APP_UI_ID } from '../../../common';
import { useKibana } from '../lib/kibana';
export const isInSecurityApp = (currentAppId?: string): boolean => {
return !!currentAppId && currentAppId === APP_UI_ID;
};
export const useIsInSecurityApp = () => {
const {
services: { application },
} = useKibana();
const currentAppId = useObservable(application.currentAppId$);
return useMemo(() => isInSecurityApp(currentAppId), [currentAppId]);
};

View file

@ -0,0 +1,131 @@
/*
* 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 React, { useMemo } from 'react';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { KibanaContextProvider, useKibana } from '@kbn/kibana-react-plugin/public';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import type { CoreStart } from '@kbn/core/public';
import type { SecuritySolutionAppWrapperFeature } from '@kbn/discover-shared-plugin/public';
import type { DiscoverServices } from '@kbn/discover-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import { APP_ID } from '../../../common';
import { SecuritySolutionFlyout } from '../../flyout';
import { StatefulEventContext } from '../../common/components/events_viewer/stateful_event_context';
import type { SecurityAppStore } from '../../common/store';
import { ReactQueryClientProvider } from '../../common/containers/query_client/query_client_provider';
import type { StartPluginsDependencies, StartServices } from '../../types';
import { MlCapabilitiesProvider } from '../../common/components/ml/permissions/ml_capabilities_provider';
import { UserPrivilegesProvider } from '../../common/components/user_privileges/user_privileges_context';
import { DiscoverInTimelineContextProvider } from '../../common/components/discover_in_timeline/provider';
import { UpsellingProvider } from '../../common/components/upselling_provider';
import { ConsoleManager } from '../../management/components/console';
import { AssistantProvider } from '../../assistant/provider';
import { ONE_DISCOVER_SCOPE_ID } from '../constants';
export const createSecuritySolutionDiscoverAppWrapperGetter = ({
core,
services,
plugins,
store,
}: {
core: CoreStart;
services: StartServices;
plugins: StartPluginsDependencies;
/**
* instance of Security App store that should be used in Discover
*/
store: SecurityAppStore;
}) => {
const getSecuritySolutionDiscoverAppWrapper: Awaited<
ReturnType<SecuritySolutionAppWrapperFeature['getWrapper']>
> = () => {
return function SecuritySolutionDiscoverAppWrapper({ children }) {
const { services: discoverServices } = useKibana<DiscoverServices>();
const CasesContext = useMemo(() => plugins.cases.ui.getCasesContext(), []);
const userCasesPermissions = useMemo(() => plugins.cases.helpers.canUseCases([APP_ID]), []);
/**
*
* Since this component is meant to be used only in the context of Discover,
* these services are appended/overwritten to the existing services object
* provided by the Discover plugin.
*
*/
const securitySolutionServices: StartServices = useMemo(
() => ({
...services,
/* Helps with getting correct instance of query, timeFilter and filterManager instances from discover */
data: discoverServices.data,
}),
[discoverServices]
);
const statefulEventContextValue = useMemo(
() => ({
// timelineId acts as scopeId
timelineID: ONE_DISCOVER_SCOPE_ID,
enableHostDetailsFlyout: true,
/* behaviour similar to query tab */
tabType: 'query',
enableIpDetailsFlyout: true,
}),
[]
);
return (
<KibanaContextProvider services={securitySolutionServices}>
<EuiThemeProvider>
<MlCapabilitiesProvider>
<CasesContext owner={[APP_ID]} permissions={userCasesPermissions}>
<UserPrivilegesProvider kibanaCapabilities={services.application.capabilities}>
{/* ^_^ Needed for notes addition */}
<NavigationProvider core={core}>
<CellActionsProvider
getTriggerCompatibleActions={services.uiActions.getTriggerCompatibleActions}
>
{/* ^_^ Needed for Cell Actions since it gives errors when CellActionsContext is used */}
<UpsellingProvider upsellingService={services.upselling}>
{/* ^_^ Needed for Alert Preview from Expanded Section of Entity Flyout */}
<ReduxStoreProvider store={store}>
<ReactQueryClientProvider>
<ConsoleManager>
{/* ^_^ Needed for AlertPreview -> Alert Details Flyout Action */}
<AssistantProvider>
{/* ^_^ Needed for AlertPreview -> Alert Details Flyout Action */}
<DiscoverInTimelineContextProvider>
{/* ^_^ Needed for Add to Timeline action by `useRiskInputActions`*/}
<ExpandableFlyoutProvider>
<SecuritySolutionFlyout />
{/* vv below context should not be here and should be removed */}
<StatefulEventContext.Provider
value={statefulEventContextValue}
>
{children}
</StatefulEventContext.Provider>
</ExpandableFlyoutProvider>
</DiscoverInTimelineContextProvider>
</AssistantProvider>
</ConsoleManager>
</ReactQueryClientProvider>
</ReduxStoreProvider>
</UpsellingProvider>
</CellActionsProvider>
</NavigationProvider>
</UserPrivilegesProvider>
</CasesContext>
</MlCapabilitiesProvider>
</EuiThemeProvider>
</KibanaContextProvider>
);
};
};
return getSecuritySolutionDiscoverAppWrapper;
};

View file

@ -0,0 +1,105 @@
/*
* 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 React from 'react';
import { DefaultCellRenderer } from '../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import { render } from '@testing-library/react';
import { getCellRendererForGivenRecord } from './cell_renderers';
import type { DataGridCellValueElementProps } from '@kbn/unified-data-table';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
jest.mock('../../timelines/components/timeline/cell_rendering/default_cell_renderer');
const DefaultCellRendererMock = DefaultCellRenderer as unknown as jest.Mock<React.ReactElement>;
/**
* Mocking DefaultCellRenderer here because it will be renderered
* in Discover's environment and context and we cannot test that here in jest.
*
* Actual working of Cell Renderer will be tested in Discover's functional tests
*
* */
const mockDefaultCellRenderer = jest.fn((props) => {
return <div data-test-subj="mocked-default-cell-render" />;
});
const mockDataView = dataViewMock;
mockDataView.getFieldByName = jest.fn().mockReturnValue({ type: 'string' } as DataViewField);
describe('getCellRendererForGivenRecord', () => {
beforeEach(() => {
DefaultCellRendererMock.mockImplementation(mockDefaultCellRenderer);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return cell renderer correctly for allowed fields with correct data format', () => {
const cellRenderer = getCellRendererForGivenRecord('host.name');
expect(cellRenderer).toBeDefined();
const props: DataGridCellValueElementProps = {
columnId: 'host.name',
isDetails: false,
isExpanded: false,
row: {
id: '1',
raw: {},
flattened: {
'host.name': 'host1',
'user.name': 'user1',
},
},
dataView: mockDataView,
setCellProps: jest.fn(),
isExpandable: false,
rowIndex: 0,
colIndex: 0,
fieldFormats: fieldFormatsMock,
closePopover: jest.fn(),
};
const CellRenderer = cellRenderer as React.FC<DataGridCellValueElementProps>;
const { getByTestId } = render(<CellRenderer {...props} />);
expect(getByTestId('mocked-default-cell-render')).toBeVisible();
expect(mockDefaultCellRenderer).toHaveBeenCalledWith(
{
isDraggable: false,
isTimeline: false,
isDetails: false,
data: [
{ field: 'host.name', value: ['host1'] },
{ field: 'user.name', value: ['user1'] },
],
eventId: '1',
scopeId: 'one-discover',
linkValues: undefined,
header: {
id: 'host.name',
columnHeaderType: 'not-filtered',
type: 'string',
},
asPlainText: false,
context: undefined,
rowRenderers: undefined,
ecsData: undefined,
colIndex: 0,
rowIndex: 0,
isExpandable: false,
isExpanded: false,
setCellProps: props.setCellProps,
columnId: 'host.name',
},
{}
);
});
it('should return undefined for non-allowedFields', () => {
const cellRenderer = getCellRendererForGivenRecord('non-allowed-field');
expect(cellRenderer).toBeUndefined();
});
});

View file

@ -0,0 +1,71 @@
/*
* 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 React, { useMemo } from 'react';
import type { DataGridCellValueElementProps } from '@kbn/unified-data-table';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import type { SecuritySolutionCellRendererFeature } from '@kbn/discover-shared-plugin/public';
import type { ColumnHeaderType } from '../../../common/types';
import type { Maybe } from '../../../common/search_strategy';
import { DefaultCellRenderer } from '../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import { ONE_DISCOVER_SCOPE_ID } from '../constants';
export type SecuritySolutionRowCellRendererGetter = Awaited<
ReturnType<SecuritySolutionCellRendererFeature['getRenderer']>
>;
const ALLOWED_DISCOVER_RENDERED_FIELDS = ['host.name', 'user.name', 'source.ip', 'destination.ip'];
export const getCellRendererForGivenRecord: SecuritySolutionRowCellRendererGetter = (
fieldName: string
) => {
if (!ALLOWED_DISCOVER_RENDERED_FIELDS.includes(fieldName)) return undefined;
return function UnifiedFieldRenderBySecuritySolution(props: DataGridCellValueElementProps) {
// convert discover data format to timeline data format
const data: TimelineNonEcsData[] = useMemo(
() =>
Object.keys(props.row.flattened).map((field) => ({
field,
value: Array.isArray(props.row.flattened[field])
? (props.row.flattened[field] as Maybe<string[]>)
: ([props.row.flattened[field]] as Maybe<string[]>),
})),
[props.row.flattened]
);
const header = useMemo(() => {
return {
id: props.columnId,
columnHeaderType: 'not-filtered' as ColumnHeaderType,
type: props.dataView.getFieldByName(props.columnId)?.type,
};
}, [props.columnId, props.dataView]);
return (
<DefaultCellRenderer
data={data}
ecsData={undefined}
eventId={props.row.id}
header={header}
isDetails={props.isDetails}
isDraggable={false}
isTimeline={false}
linkValues={undefined}
rowRenderers={undefined}
scopeId={ONE_DISCOVER_SCOPE_ID}
asPlainText={false}
context={undefined}
isExpandable={props.isExpandable}
rowIndex={props.rowIndex}
colIndex={props.colIndex}
setCellProps={props.setCellProps}
isExpanded={props.isExpanded}
columnId={props.columnId}
/>
);
};
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getCellRendererForGivenRecord } from './cell_renderers';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const ONE_DISCOVER_SCOPE_ID = 'one-discover';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { getCellRendererForGivenRecord } from './cell_renderers';
export { createSecuritySolutionDiscoverAppWrapperGetter } from './app_wrapper';

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/security_solution/public/one_discover'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/one_discover',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/security_solution/public/one_discover/**/*.{ts,tsx}',
],
moduleNameMapper: require('../../server/__mocks__/module_name_map'),
};

View file

@ -21,6 +21,10 @@ import { AppStatus, DEFAULT_APP_CATEGORIES } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public';
import { uiMetricService } from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import type {
SecuritySolutionAppWrapperFeature,
SecuritySolutionCellRendererFeature,
} from '@kbn/discover-shared-plugin/public/services/discover_features';
import { getLazyCloudSecurityPosturePliAuthBlockExtension } from './cloud_security_posture/lazy_cloud_security_posture_pli_auth_block_extension';
import { getLazyEndpointAgentTamperProtectionExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_agent_tamper_protection_extension';
import type {
@ -70,6 +74,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
// Lazily instantiated dependencies
private _subPlugins?: SubPlugins;
private _store?: SecurityAppStore;
private _securityStoreForDiscover?: SecurityAppStore;
private _actionsRegistered?: boolean = false;
private _alertsTableRegistered?: boolean = false;
@ -203,6 +208,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
getExternalReferenceAttachmentEndpointRegular()
);
this.registerDiscoverSharedFeatures(core, plugins);
return this.contract.getSetupContract();
}
@ -217,6 +224,60 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.services.stop();
}
public async registerDiscoverSharedFeatures(
core: CoreSetup<StartPluginsDependencies, PluginStart>,
plugins: SetupPlugins
) {
const { discoverShared } = plugins;
const discoverFeatureRegistry = discoverShared.features.registry;
const cellRendererFeature: SecuritySolutionCellRendererFeature = {
id: 'security-solution-cell-renderer',
getRenderer: async () => {
const { getCellRendererForGivenRecord } = await this.getLazyDiscoverSharedDeps();
return getCellRendererForGivenRecord;
},
};
const appWrapperFeature: SecuritySolutionAppWrapperFeature = {
id: 'security-solution-app-wrapper',
getWrapper: async () => {
const [coreStart, startPlugins] = await core.getStartServices();
const services = await this.services.generateServices(coreStart, startPlugins);
const subPlugins = await this.startSubPlugins(this.storage, coreStart, startPlugins);
const securityStoreForDiscover = await this.getStoreForDiscover(
coreStart,
startPlugins,
subPlugins
);
const { createSecuritySolutionDiscoverAppWrapperGetter } =
await this.getLazyDiscoverSharedDeps();
return createSecuritySolutionDiscoverAppWrapperGetter({
core: coreStart,
services,
plugins: startPlugins,
store: securityStoreForDiscover,
});
},
};
discoverFeatureRegistry.register(cellRendererFeature);
discoverFeatureRegistry.register(appWrapperFeature);
}
public async getLazyDiscoverSharedDeps() {
/**
* The specially formatted comment in the `import` expression causes the corresponding webpack chunk to be named. This aids us in debugging chunk size issues.
* See https://webpack.js.org/api/module-methods/#magic-comments
*/
return import(
/* webpackChunkName: "one_discover_shared_deps" */
'./one_discover'
);
}
/**
* SubPlugins are the individual building blocks of the Security Solution plugin.
* They are lazily instantiated to improve startup time.
@ -311,6 +372,31 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
return this._store;
}
/**
* Lazily instantiate a `SecurityAppStore` for discover.
*/
private async getStoreForDiscover(
coreStart: CoreStart,
startPlugins: StartPlugins,
subPlugins: StartedSubPlugins
): Promise<SecurityAppStore> {
if (!this._securityStoreForDiscover) {
const { createStoreFactory } = await this.lazyApplicationDependencies();
this._securityStoreForDiscover = await createStoreFactory(
coreStart,
startPlugins,
subPlugins,
this.storage,
this.experimentalFeatures
);
}
if (startPlugins.timelines) {
startPlugins.timelines.setTimelineEmbeddedStore(this._securityStoreForDiscover);
}
return this._securityStoreForDiscover;
}
private async registerActions(
store: SecurityAppStore,
history: H.History,

View file

@ -12,30 +12,14 @@ import { HostName } from './host_name';
import { TestProviders } from '../../../../../common/mock';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
import { TableId } from '@kbn/securitysolution-data-table';
import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
const mockedTelemetry = createTelemetryServiceMock();
const mockOpenRightPanel = jest.fn();
jest.mock('@kbn/expandable-flyout');
jest.mock('../../../../../common/lib/kibana/kibana_react', () => {
return {
useKibana: () => ({
services: {
application: {
getUrlForApp: jest.fn(),
navigateToApp: jest.fn(),
},
telemetry: mockedTelemetry,
},
}),
};
});
jest.mock('../../../../../common/components/draggables', () => ({
DefaultDraggable: () => <div data-test-subj="DefaultDraggable" />,
}));

View file

@ -15,6 +15,7 @@ import { HostDetailsLink } from '../../../../../common/components/links';
import { DefaultDraggable } from '../../../../../common/components/draggables';
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
import { useIsInSecurityApp } from '../../../../../common/hooks/is_in_security_app';
interface Props {
contextId: string;
@ -45,6 +46,8 @@ const HostNameComponent: React.FC<Props> = ({
}) => {
const { openRightPanel } = useExpandableFlyoutApi();
const isInSecurityApp = useIsInSecurityApp();
const eventContext = useContext(StatefulEventContext);
const hostName = `${value}`;
const isInTimelineContext =
@ -58,6 +61,10 @@ const HostNameComponent: React.FC<Props> = ({
onClick();
}
/*
* if and only if renderer is running inside security solution app
* we check for event and timeline context
* */
if (!eventContext || !isInTimelineContext) {
return;
}
@ -85,13 +92,21 @@ const HostNameComponent: React.FC<Props> = ({
Component={Component}
hostName={hostName}
isButton={isButton}
onClick={isInTimelineContext ? openHostDetailsSidePanel : undefined}
onClick={isInTimelineContext || !isInSecurityApp ? openHostDetailsSidePanel : undefined}
title={title}
>
<TruncatableText data-test-subj="draggable-truncatable-content">{hostName}</TruncatableText>
</HostDetailsLink>
),
[Component, hostName, isButton, isInTimelineContext, openHostDetailsSidePanel, title]
[
Component,
hostName,
isButton,
isInTimelineContext,
openHostDetailsSidePanel,
title,
isInSecurityApp,
]
);
return isString(value) && hostName.length > 0 ? (

View file

@ -12,30 +12,14 @@ import { TestProviders } from '../../../../../common/mock';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { UserName } from './user_name';
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
import { TableId } from '@kbn/securitysolution-data-table';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout';
const mockedTelemetry = createTelemetryServiceMock();
const mockOpenRightPanel = jest.fn();
jest.mock('@kbn/expandable-flyout');
jest.mock('../../../../../common/lib/kibana/kibana_react', () => {
return {
useKibana: () => ({
services: {
application: {
getUrlForApp: jest.fn(),
navigateToApp: jest.fn(),
},
telemetry: mockedTelemetry,
},
}),
};
});
jest.mock('../../../../../common/components/draggables', () => ({
DefaultDraggable: () => <div data-test-subj="DefaultDraggable" />,
}));

View file

@ -15,6 +15,7 @@ import { DefaultDraggable } from '../../../../../common/components/draggables';
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
import { UserDetailsLink } from '../../../../../common/components/links';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
import { useIsInSecurityApp } from '../../../../../common/hooks/is_in_security_app';
interface Props {
contextId: string;
@ -48,6 +49,8 @@ const UserNameComponent: React.FC<Props> = ({
const isInTimelineContext = userName && eventContext?.timelineID;
const { openRightPanel } = useExpandableFlyoutApi();
const isInSecurityApp = useIsInSecurityApp();
const openUserDetailsSidePanel = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault();
@ -83,13 +86,21 @@ const UserNameComponent: React.FC<Props> = ({
Component={Component}
userName={userName}
isButton={isButton}
onClick={isInTimelineContext ? openUserDetailsSidePanel : undefined}
onClick={isInTimelineContext || !isInSecurityApp ? openUserDetailsSidePanel : undefined}
title={title}
>
<TruncatableText data-test-subj="draggable-truncatable-content">{userName}</TruncatableText>
</UserDetailsLink>
),
[userName, isButton, isInTimelineContext, openUserDetailsSidePanel, Component, title]
[
userName,
isButton,
isInTimelineContext,
openUserDetailsSidePanel,
Component,
title,
isInSecurityApp,
]
);
return isString(value) && userName.length > 0 ? (

View file

@ -61,6 +61,7 @@ import type { PluginStartContract } from '@kbn/alerting-plugin/public/plugin';
import type { MapsStartApi } from '@kbn/maps-plugin/public';
import type { IntegrationAssistantPluginStart } from '@kbn/integration-assistant-plugin/public';
import type { ServerlessPluginStart } from '@kbn/serverless/public';
import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -107,6 +108,7 @@ export interface SetupPlugins {
ml?: MlPluginSetup;
cases?: CasesPublicSetup;
data: DataPublicPluginSetup;
discoverShared: DiscoverSharedPublicStart;
}
/**

View file

@ -15,7 +15,11 @@
"public/**/*.json",
"../../../typings/**/*"
],
"exclude": ["target/**/*", "**/cypress/**", "public/management/cypress.config.ts"],
"exclude": [
"target/**/*",
"**/cypress/**",
"public/management/cypress.config.ts"
],
"kbn_references": [
"@kbn/core",
{
@ -228,6 +232,7 @@
"@kbn/core-lifecycle-server",
"@kbn/core-user-profile-common",
"@kbn/langchain",
"@kbn/discover-shared-plugin",
"@kbn/react-hooks",
"@kbn/index-adapter",
"@kbn/core-http-server-utils"