[EDR Workflows] POC - Show Sentinel One data in analyzer (#170829)

This commit is contained in:
Tomasz Ciecierski 2023-12-06 15:15:57 +01:00 committed by GitHub
parent 9b822b96c2
commit 3ca265abe2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 204 additions and 93 deletions

View file

@ -147,6 +147,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
entityAnalyticsAssetCriticalityEnabled: false,
/*
* Enables experimental Experimental S1 integration data to be available in Analyzer
*/
sentinelOneDataInAnalyzerEnabled: false,
/**
* Enables SentinelOne manual host manipulation actions
*/

View file

@ -17,7 +17,7 @@ import {
getPinOnClick,
} from '../../../timelines/components/timeline/body/helpers';
import { getScopedActions, isTimelineScope } from '../../../helpers';
import { isInvestigateInResolverActionEnabled } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { useIsInvestigateInResolverActionEnabled } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import type { ActionProps, OnPinEvent } from '../../../../common/types';
import { TimelineId } from '../../../../common/types';
@ -117,7 +117,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
);
}, [ecsData, eventType]);
const isDisabled = useMemo(() => !isInvestigateInResolverActionEnabled(ecsData), [ecsData]);
const isDisabled = !useIsInvestigateInResolverActionEnabled(ecsData);
const { setGlobalFullScreen } = useGlobalFullScreen();
const { setTimelineFullScreen } = useTimelineFullScreen();
const scopedActions = getScopedActions(timelineId);

View file

@ -6,26 +6,40 @@
*/
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { isInvestigateInResolverActionEnabled } from './investigate_in_resolver';
import { useIsInvestigateInResolverActionEnabled } from './investigate_in_resolver';
import { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../../common/mock';
describe('InvestigateInResolverAction', () => {
describe('isInvestigateInResolverActionEnabled', () => {
describe('useIsInvestigateInResolverActionEnabled', () => {
it('returns false if agent.type does not equal endpoint', () => {
const data: Ecs = { _id: '1', agent: { type: ['blah'] } };
expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeFalsy();
});
it('returns false if agent.type does not have endpoint in first array index', () => {
const data: Ecs = { _id: '1', agent: { type: ['blah', 'endpoint'] } };
expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeFalsy();
});
it('returns false if process.entity_id is not defined', () => {
const data: Ecs = { _id: '1', agent: { type: ['endpoint'] } };
expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeFalsy();
});
it('returns true if agent.type has endpoint in first array index', () => {
@ -35,7 +49,11 @@ describe('InvestigateInResolverAction', () => {
process: { entity_id: ['5'] },
};
expect(isInvestigateInResolverActionEnabled(data)).toBeTruthy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeTruthy();
});
it('returns false if multiple entity_ids', () => {
@ -45,7 +63,11 @@ describe('InvestigateInResolverAction', () => {
process: { entity_id: ['5', '10'] },
};
expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeFalsy();
});
it('returns false if entity_id is an empty string', () => {
@ -55,7 +77,11 @@ describe('InvestigateInResolverAction', () => {
process: { entity_id: [''] },
};
expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeFalsy();
});
it('returns true for process event from sysmon via filebeat', () => {
@ -66,7 +92,11 @@ describe('InvestigateInResolverAction', () => {
process: { entity_id: ['always_unique'] },
};
expect(isInvestigateInResolverActionEnabled(data)).toBeTruthy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeTruthy();
});
it('returns false for process event from filebeat but not from sysmon', () => {
@ -77,7 +107,11 @@ describe('InvestigateInResolverAction', () => {
process: { entity_id: ['always_unique'] },
};
expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy();
const { result } = renderHook(() => useIsInvestigateInResolverActionEnabled(data), {
wrapper: TestProviders,
});
expect(result.current).toBeFalsy();
});
});
});

View file

@ -5,23 +5,36 @@
* 2.0.
*/
import { useMemo } from 'react';
import { get } from 'lodash/fp';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => {
const agentType = get(['agent', 'type', 0], ecsData);
const processEntityIds = get(['process', 'entity_id'], ecsData);
const firstProcessEntityId = get(['process', 'entity_id', 0], ecsData);
const eventModule = get(['event', 'module', 0], ecsData);
const eventDataStream = get(['event', 'dataset'], ecsData);
const datasetIncludesSysmon =
Array.isArray(eventDataStream) &&
eventDataStream.some((datastream) => datastream.includes('windows.sysmon'));
const agentTypeIsEndpoint = agentType === 'endpoint';
const agentTypeIsWinlogBeat = agentType === 'winlogbeat' && eventModule === 'sysmon';
const isEndpointOrSysmonFromWinlogBeat =
agentTypeIsEndpoint || agentTypeIsWinlogBeat || datasetIncludesSysmon;
const hasProcessEntityId =
processEntityIds != null && processEntityIds.length === 1 && firstProcessEntityId !== '';
return isEndpointOrSysmonFromWinlogBeat && hasProcessEntityId;
export const useIsInvestigateInResolverActionEnabled = (ecsData?: Ecs) => {
const sentinelOneDataInAnalyzerEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneDataInAnalyzerEnabled'
);
return useMemo(() => {
const fileBeatModules = [
...(sentinelOneDataInAnalyzerEnabled ? ['sentinel_one_cloud_funnel', 'sentinel_one'] : []),
] as const;
const agentType = get(['agent', 'type', 0], ecsData);
const processEntityIds = get(['process', 'entity_id'], ecsData);
const firstProcessEntityId = get(['process', 'entity_id', 0], ecsData);
const eventModule = get(['event', 'module', 0], ecsData);
const eventDataStream = get(['event', 'dataset'], ecsData);
const datasetIncludesSysmon =
Array.isArray(eventDataStream) &&
eventDataStream.some((datastream) => datastream.includes('windows.sysmon'));
const agentTypeIsEndpoint = agentType === 'endpoint';
const agentTypeIsWinlogBeat = agentType === 'winlogbeat' && eventModule === 'sysmon';
const agentTypeIsFilebeat = agentType === 'filebeat' && fileBeatModules.includes(eventModule);
const isAcceptedAgentType =
agentTypeIsEndpoint || agentTypeIsWinlogBeat || datasetIncludesSysmon || agentTypeIsFilebeat;
const hasProcessEntityId =
processEntityIds != null && processEntityIds.length === 1 && firstProcessEntityId !== '';
return isAcceptedAgentType && hasProcessEntityId;
}, [ecsData, sentinelOneDataInAnalyzerEnabled]);
};

View file

@ -11,7 +11,7 @@ import React from 'react';
import { RightPanelContext } from '../context';
import { mockContextValue } from '../mocks/mock_context';
import { AnalyzerPreviewContainer } from './analyzer_preview_container';
import { isInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { ANALYZER_PREVIEW_TEST_ID } from './test_ids';
import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree';
import * as mock from '../mocks/mock_analyzer_data';
@ -68,7 +68,7 @@ describe('AnalyzerPreviewContainer', () => {
});
it('should render component and link in header', () => {
(isInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({
loading: false,
error: false,
@ -103,7 +103,7 @@ describe('AnalyzerPreviewContainer', () => {
});
it('should render error message and text in header', () => {
(isInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(false);
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(false);
(useInvestigateInTimeline as jest.Mock).mockReturnValue({
investigateInTimelineAlertClick: jest.fn(),
});
@ -118,7 +118,7 @@ describe('AnalyzerPreviewContainer', () => {
});
it('should navigate to analyzer in timeline when clicking on title', () => {
(isInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({
loading: false,
error: false,
@ -138,7 +138,7 @@ describe('AnalyzerPreviewContainer', () => {
});
it('should not navigate to analyzer when in preview and clicking on title', () => {
(isInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({
loading: false,
error: false,

View file

@ -16,7 +16,7 @@ import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions';
import { getScopedActions } from '../../../../helpers';
import { setActiveTabTimeline } from '../../../../timelines/store/timeline/actions';
import { useRightPanelContext } from '../context';
import { isInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { AnalyzerPreview } from './analyzer_preview';
import { ANALYZER_PREVIEW_TEST_ID } from './test_ids';
import { ExpandablePanel } from '../../../shared/components/expandable_panel';
@ -30,7 +30,7 @@ export const AnalyzerPreviewContainer: React.FC = () => {
const { dataAsNestedObject, isPreview } = useRightPanelContext();
// decide whether to show the analyzer preview or not
const isEnabled = isInvestigateInResolverActionEnabled(dataAsNestedObject);
const isEnabled = useIsInvestigateInResolverActionEnabled(dataAsNestedObject);
const dispatch = useDispatch();
const { startTransaction } = useStartTransaction();

View file

@ -10,7 +10,7 @@ import { useMemo } from 'react';
import { find } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { GetFieldsData } from '../../../../common/hooks/use_get_fields_data';
import { isInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useLicense } from '../../../../common/hooks/use_license';
import { getField } from '../utils';
@ -57,7 +57,7 @@ export const useShowRelatedAlertsByAncestry = ({
const isRelatedAlertsByProcessAncestryEnabled = useIsExperimentalFeatureEnabled(
'insightsRelatedAlertsByProcessAncestry'
);
const hasProcessEntityInfo = isInvestigateInResolverActionEnabled(dataAsNestedObject);
const hasProcessEntityInfo = useIsInvestigateInResolverActionEnabled(dataAsNestedObject);
const originalDocumentId = getField(getFieldsData(ANCESTOR_ID));

View file

@ -50,6 +50,6 @@ export const registerResolverRoutes = async (
validate: validateEntities,
options: { authRequired: true },
},
handleEntities()
handleEntities(config.experimentalFeatures)
);
};

View file

@ -7,6 +7,7 @@
import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import type { ExperimentalFeatures } from '../../../../../common';
import { EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER } from '../../../../../common/constants';
import type { validateEntities } from '../../../../../common/endpoint/schema/resolver';
import type { ResolverEntityIndex } from '../../../../../common/endpoint/types';
@ -17,7 +18,9 @@ import { createSharedFilters } from '../utils/shared_filters';
* This is used to get an 'entity_id' which is an internal-to-Resolver concept, from an `_id`, which
* is the artificial ID generated by ES for each document.
*/
export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validateEntities.query>> {
export function handleEntities(
experimentalFeatures: ExperimentalFeatures
): RequestHandler<unknown, TypeOf<typeof validateEntities.query>> {
return async (context, request, response) => {
const {
query: { _id, indices },
@ -50,7 +53,10 @@ export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validate
},
});
const responseBody: ResolverEntityIndex = resolverEntity(queryResponse.hits.hits);
const responseBody: ResolverEntityIndex = resolverEntity(
queryResponse.hits.hits,
experimentalFeatures
);
return response.ok({ body: responseBody });
};
}

View file

@ -6,11 +6,18 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { getFieldAsString, supportedSchemas } from './supported_schemas';
import type { ExperimentalFeatures } from '../../../../../../common';
import { getFieldAsString, getSupportedSchemas } from './supported_schemas';
import type { ResolverEntityIndex } from '../../../../../../common/endpoint/types';
export function resolverEntity(hits: Array<estypes.SearchHit<unknown>>) {
const toArray = <T>(input: T | T[]) => ([] as T[]).concat(input);
export function resolverEntity(
hits: Array<estypes.SearchHit<unknown>>,
experimentalFeatures: ExperimentalFeatures | undefined
) {
const responseBody: ResolverEntityIndex = [];
const supportedSchemas = getSupportedSchemas(experimentalFeatures);
for (const hit of hits) {
for (const supportedSchema of supportedSchemas) {
let foundSchema = true;
@ -20,7 +27,12 @@ export function resolverEntity(hits: Array<estypes.SearchHit<unknown>>) {
const fieldValue = getFieldAsString(hit._source, constraint.field);
// track that all the constraints are true, if one of them is false then this schema is not valid so mark it
// that we did not find the schema
foundSchema = foundSchema && fieldValue?.toLowerCase() === constraint.value.toLowerCase();
foundSchema =
foundSchema &&
toArray(constraint.value).some(
(constraintValue) => constraintValue.toLowerCase() === fieldValue?.toLowerCase()
);
}
if (foundSchema && id !== undefined && id !== '') {

View file

@ -6,6 +6,7 @@
*/
import _ from 'lodash';
import type { ExperimentalFeatures } from '../../../../../../common';
import type { ResolverSchema } from '../../../../../../common/endpoint/types';
interface SupportedSchema {
@ -17,7 +18,7 @@ interface SupportedSchema {
/**
* A constraint to search for in the documented returned by Elasticsearch
*/
constraints: Array<{ field: string; value: string }>;
constraints: Array<{ field: string; value: string | string[] }>;
/**
* Schema to return to the frontend so that it can be passed in to call to the /tree API
@ -29,59 +30,90 @@ interface SupportedSchema {
* This structure defines the preset supported schemas for a resolver graph. We'll probably want convert this
* implementation to something similar to how row renderers is implemented.
*/
export const supportedSchemas: SupportedSchema[] = [
{
name: 'endpoint',
constraints: [
{
field: 'agent.type',
value: 'endpoint',
export const getSupportedSchemas = (
experimentalFeatures: ExperimentalFeatures | undefined
): SupportedSchema[] => {
const sentinelOneDataInAnalyzerEnabled = experimentalFeatures?.sentinelOneDataInAnalyzerEnabled;
const supportedFileBeatDataSets = [
...(sentinelOneDataInAnalyzerEnabled
? ['sentinel_one_cloud_funnel.event', 'sentinel_one.alert']
: []),
];
return [
{
name: 'endpoint',
constraints: [
{
field: 'agent.type',
value: 'endpoint',
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
ancestry: 'process.Ext.ancestry',
name: 'process.name',
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
ancestry: 'process.Ext.ancestry',
name: 'process.name',
},
},
{
name: 'winlogbeat',
constraints: [
{
field: 'agent.type',
value: 'winlogbeat',
{
name: 'winlogbeat',
constraints: [
{
field: 'agent.type',
value: 'winlogbeat',
},
{
field: 'event.module',
value: 'sysmon',
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
name: 'process.name',
},
{
field: 'event.module',
value: 'sysmon',
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
name: 'process.name',
},
},
{
name: 'sysmonViaFilebeat',
constraints: [
{
field: 'agent.type',
value: 'filebeat',
{
name: 'sysmonViaFilebeat',
constraints: [
{
field: 'agent.type',
value: 'filebeat',
},
{
field: 'event.dataset',
value: 'windows.sysmon_operational',
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
name: 'process.name',
},
{
field: 'event.dataset',
value: 'windows.sysmon_operational',
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
name: 'process.name',
},
},
];
{
name: 'filebeat',
constraints: [
{
field: 'agent.type',
value: 'filebeat',
},
{
field: 'event.dataset',
value: supportedFileBeatDataSets,
},
],
schema: {
id: 'process.entity_id',
parent: 'process.parent.entity_id',
name: 'process.name',
},
},
];
};
export function getFieldAsString(doc: unknown, field: string): string | undefined {
const value = _.get(doc, field);

View file

@ -85,6 +85,7 @@ export const createMockTelemetryReceiver = (
openPointInTime: jest.fn().mockReturnValue(Promise.resolve('test-pit-id')),
getAlertsIndex: jest.fn().mockReturnValue('alerts-*'),
fetchDiagnosticAlertsBatch: jest.fn().mockReturnValue(diagnosticsAlert ?? jest.fn()),
getExperimentalFeatures: jest.fn().mockReturnValue(undefined),
fetchEndpointMetrics: jest.fn().mockReturnValue(stubEndpointMetricsResponse),
fetchEndpointPolicyResponses: jest.fn(),
fetchPrebuiltRuleAlertsBatch: jest.fn().mockReturnValue(prebuiltRuleAlertsResponse),

View file

@ -382,7 +382,7 @@ export class TelemetryTimelineFetcher {
const eventId = event._source ? event._source['event.id'] : 'unknown';
const alertUUID = event._source ? event._source['kibana.alert.uuid'] : 'unknown';
const entities = resolverEntity([event]);
const entities = resolverEntity([event], this.receiver.getExperimentalFeatures());
// Build Tree
const tree = await this.receiver.buildProcessTree(

View file

@ -44,6 +44,7 @@ import type {
} from '@kbn/fleet-plugin/server';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
import moment from 'moment';
import type { ExperimentalFeatures } from '../../../common';
import type { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services';
import {
exceptionListItemToTelemetryEntry,
@ -197,6 +198,8 @@ export interface ITelemetryReceiver {
fetchValueListMetaData(interval: number): Promise<ValueListResponse>;
getAlertsIndex(): string | undefined;
getExperimentalFeatures(): ExperimentalFeatures | undefined;
}
export class TelemetryReceiver implements ITelemetryReceiver {
@ -211,6 +214,7 @@ export class TelemetryReceiver implements ITelemetryReceiver {
private clusterInfo?: ESClusterInfo;
private processTreeFetcher?: Fetcher;
private packageService?: PackageService;
private experimentalFeatures: ExperimentalFeatures | undefined;
private readonly maxRecords = 10_000 as const;
constructor(logger: Logger) {
@ -235,7 +239,7 @@ export class TelemetryReceiver implements ITelemetryReceiver {
this.soClient =
core?.savedObjects.createInternalRepository() as unknown as SavedObjectsClientContract;
this.clusterInfo = await this.fetchClusterInfo();
this.experimentalFeatures = endpointContextService?.experimentalFeatures;
const elasticsearch = core?.elasticsearch.client as unknown as IScopedClusterClient;
this.processTreeFetcher = new Fetcher(elasticsearch);
}
@ -248,6 +252,10 @@ export class TelemetryReceiver implements ITelemetryReceiver {
return this.alertsIndex;
}
public getExperimentalFeatures(): ExperimentalFeatures | undefined {
return this.experimentalFeatures;
}
public async fetchDetectionRulesPackageVersion(): Promise<Installation | undefined> {
return this.packageService?.asInternalUser.getInstallation(PREBUILT_RULES_PACKAGE_NAME);
}