diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index 5a27aa9283ffb..2e4c34d86bdc5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -50,10 +50,22 @@ export class EndpointActionGenerator extends BaseDataGenerator { overrides: DeepPartial> = {} ): LogsEndpointAction { const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date(); + const agent = (overrides.agent?.id ?? [ + this.seededUUIDv4(), + ]) as LogsEndpointAction['agent']['id']; + const agentId = Array.isArray(agent) ? (agent[0] as string) : agent; const doc: LogsEndpointAction = { '@timestamp': timeStamp.toISOString(), agent: { - id: [this.seededUUIDv4()], + id: agent, + policy: [ + { + agentId, + elasticAgentId: agentId, + integrationPolicyId: 'integration-policy-1', + agentPolicyId: 'agent-policy-1', + }, + ], }, EndpointActions: { action_id: this.seededUUIDv4(), diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/microsoft_defender_data_generator.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/microsoft_defender_data_generator.ts index 5d69b51d68a03..a6ee65eca5c42 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/microsoft_defender_data_generator.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/microsoft_defender_data_generator.ts @@ -5,7 +5,13 @@ * 2.0. */ +import type { DeepPartial } from 'utility-types'; +import { merge } from 'lodash'; +import type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import { buildIndexNameWithNamespace } from '../utils/index_name_utilities'; +import { MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN } from '../service/response_actions/microsoft_defender'; import { BaseDataGenerator } from './base_data_generator'; +import type { MicrosoftDefenderEndpointLogEsDoc } from '../types'; export class MicrosoftDefenderDataGenerator extends BaseDataGenerator { /** @@ -13,133 +19,153 @@ export class MicrosoftDefenderDataGenerator extends BaseDataGenerator { * integration into the `logs-microsoft_defender_endpoint.log` index. * If adding this generated document to ES, make sure that the integration has been installed. */ - generateEndpointLog() { + generateEndpointLog( + overrides: DeepPartial = {} + ): MicrosoftDefenderEndpointLogEsDoc { const now = new Date().toISOString(); - return { - agent: { - name: 'ptavares-agentless-integrations-default-8511', - id: 'a572fc2e-0276-494a-b693-6e907bc2a78b', - ephemeral_id: 'e7d70430-d25e-4d72-863e-918ac36bbbf7', - type: 'filebeat', - version: '9.0.0', - }, - process: { - parent: { + return merge( + { + agent: { + name: 'ptavares-agentless-integrations-default-8511', + id: 'a572fc2e-0276-494a-b693-6e907bc2a78b', + ephemeral_id: 'e7d70430-d25e-4d72-863e-918ac36bbbf7', + type: 'filebeat', + version: '9.0.0', + }, + process: { + parent: { + start: now, + pid: 9901, + }, start: now, - pid: 9901, + pid: 10083, + command_line: '-bash', }, - start: now, - pid: 10083, - command_line: '-bash', - }, - elastic_agent: { - id: 'a572fc2e-0276-494a-b693-6e907bc2a78b', - version: '9.0.0', - snapshot: true, - }, - rule: { - description: - 'Remote file transfer activity was observed on this device. Attackers might be attempting to steal data from the device or move laterally on the network.', - }, - message: 'Remote exfiltration activity', - microsoft: { - defender_endpoint: { - evidence: { - accountName: 'ubuntu', - detectionStatus: 'Detected', - parentProcessFileName: 'bash', - entityType: 'Process', - evidenceCreationTime: now, - domainName: 'discerning-spaniel', + elastic_agent: { + id: 'a572fc2e-0276-494a-b693-6e907bc2a78b', + version: '9.0.0', + snapshot: true, + }, + rule: { + description: + 'Remote file transfer activity was observed on this device. Attackers might be attempting to steal data from the device or move laterally on the network.', + }, + message: 'Remote exfiltration activity', + microsoft: { + defender_endpoint: { + evidence: { + accountName: 'ubuntu', + detectionStatus: 'Detected', + parentProcessFileName: 'bash', + entityType: 'Process', + evidenceCreationTime: now, + domainName: 'discerning-spaniel', + }, + mitreTechniques: [ + 'T1005', + 'T1020', + 'T1041', + 'T1048', + 'T1071', + 'T1071.001', + 'T1204.001', + 'T1567', + 'T1570', + ], + detectorId: this.seededUUIDv4(), + investigationState: 'UnsupportedOs', + incidentId: '4', + lastUpdateTime: now, + status: 'New', }, - mitreTechniques: [ - 'T1005', - 'T1020', - 'T1041', - 'T1048', - 'T1071', - 'T1071.001', - 'T1204.001', - 'T1567', - 'T1570', + }, + tags: ['microsoft-defender-endpoint', 'forwarded'], + cloud: { + instance: { + id: '7bcf55e03728756dbf02ba7979a0c6218321ade7', + }, + provider: 'azure', + account: { + id: 'c38d90f4-369c-4815-ab69-4663a1f5c115', + }, + }, + input: { + type: 'httpjson', + }, + observer: { + product: 'Defender for Endpoint', + vendor: 'Microsoft', + name: 'WindowsDefenderAtp', + }, + '@timestamp': now, + file: { + path: '/usr/bin/', + name: 'bash', + hash: { + sha1: 'ce4fbd66c02e235bbc8dfa4a512c51414d8e0e67', + sha256: 'c5f8a98c674631609902846fae6df219b3b16d97db58cf1c1334f8eb14962bde', + }, + }, + ecs: { + version: '8.11.0', + }, + related: { + hosts: ['discerning-spaniel'], + hash: [ + 'ce4fbd66c02e235bbc8dfa4a512c51414d8e0e67', + 'c5f8a98c674631609902846fae6df219b3b16d97db58cf1c1334f8eb14962bde', ], - detectorId: this.seededUUIDv4(), - investigationState: 'UnsupportedOs', - incidentId: '4', - lastUpdateTime: now, - status: 'New', }, - }, - tags: ['microsoft-defender-endpoint', 'forwarded'], - cloud: { - instance: { - id: '7bcf55e03728756dbf02ba7979a0c6218321ade7', + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'microsoft_defender_endpoint.log', }, - provider: 'azure', - account: { - id: 'c38d90f4-369c-4815-ab69-4663a1f5c115', + host: { + hostname: 'discerning-spaniel', + name: 'discerning-spaniel', }, - }, - input: { - type: 'httpjson', - }, - observer: { - product: 'Defender for Endpoint', - vendor: 'Microsoft', - name: 'WindowsDefenderAtp', - }, - '@timestamp': now, - file: { - path: '/usr/bin/', - name: 'bash', - hash: { - sha1: 'ce4fbd66c02e235bbc8dfa4a512c51414d8e0e67', - sha256: 'c5f8a98c674631609902846fae6df219b3b16d97db58cf1c1334f8eb14962bde', + threat: { + framework: 'MITRE ATT&CK', + technique: { + name: ['Exfiltration'], + }, }, - }, - ecs: { - version: '8.11.0', - }, - related: { - hosts: ['discerning-spaniel'], - hash: [ - 'ce4fbd66c02e235bbc8dfa4a512c51414d8e0e67', - 'c5f8a98c674631609902846fae6df219b3b16d97db58cf1c1334f8eb14962bde', - ], - }, - data_stream: { - namespace: 'default', - type: 'logs', - dataset: 'microsoft_defender_endpoint.log', - }, - host: { - hostname: 'discerning-spaniel', - name: 'discerning-spaniel', - }, - threat: { - framework: 'MITRE ATT&CK', - technique: { - name: ['Exfiltration'], + event: { + severity: 3, + created: now, + kind: 'alert', + timezone: 'UTC', + start: now, + type: ['start'], + duration: 5253721000, + agent_id_status: 'verified', + ingested: now, + provider: 'defender_endpoint', + action: 'Exfiltration', + end: now, + id: this.seededUUIDv4(), + category: ['host', 'process'], + dataset: 'microsoft_defender_endpoint.log', }, }, - event: { - severity: 3, - created: now, - kind: 'alert', - timezone: 'UTC', - start: now, - type: ['start'], - duration: 5253721000, - agent_id_status: 'verified', - ingested: now, - provider: 'defender_endpoint', - action: 'Exfiltration', - end: now, - id: this.seededUUIDv4(), - category: ['host', 'process'], - dataset: 'microsoft_defender_endpoint.log', - }, - }; + overrides + ); + } + + generateEndpointLogEsHit( + overrides: DeepPartial = {} + ): SearchHit { + return this.toEsSearchHit( + this.generateEndpointLog(overrides), + buildIndexNameWithNamespace(MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN, 'default') + ); + } + + generateEndpointLogEsSearchResponse( + docs: Array> = [this.generateEndpointLogEsHit()] + ) { + return this.toEsSearchResponse(docs); } } diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts index 011df8c46b07d..54880ce124a41 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts @@ -18,6 +18,7 @@ import type { SentinelOneGetRemoteScriptStatusApiResponse, SentinelOneRemoteScriptExecutionStatus, } from '@kbn/stack-connectors-plugin/common/sentinelone/types'; +import { buildIndexNameWithNamespace } from '../utils/index_name_utilities'; import { EndpointActionGenerator } from './endpoint_action_generator'; import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../..'; import type { @@ -26,7 +27,9 @@ import type { EndpointActionDataParameterTypes, EndpointActionResponseDataOutput, SentinelOneActivityDataForType80, + SentinelOneAgentEsDoc, } from '../types'; +import { SENTINEL_ONE_AGENT_INDEX_PATTERN } from '../service/response_actions/sentinel_one'; export class SentinelOneDataGenerator extends EndpointActionGenerator { static readonly scriptExecutionStatusValues: Readonly< @@ -428,6 +431,45 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator { pagination: { totalItems: 1, nextCursor: undefined }, }; } + + /** + * Generate a SentinelOne Agent record that is ingested into Elasticsearch by the + * integration into `logs-sentinel_one.agent-` + */ + generateAgentEsDoc(overrides: DeepPartial = {}): SentinelOneAgentEsDoc { + return merge( + { + agent: { + id: '1-2-3', + type: 'filebeat', + version: '9.1.0', + }, + sentinel_one: { + agent: { + agent: { + id: 's1-agent-1', + }, + }, + }, + }, + overrides + ); + } + + generateAgentEsSearchHit( + overrides: DeepPartial = {} + ): SearchHit { + return this.toEsSearchHit( + this.generateAgentEsDoc(overrides), + buildIndexNameWithNamespace(SENTINEL_ONE_AGENT_INDEX_PATTERN, 'default') + ); + } + + generateAgentEsSearchResponse( + docs: Array> = [this.generateAgentEsSearchHit()] + ): SearchResponse { + return this.toEsSearchResponse(docs); + } } // Activity types from SentinelOne. Values can be retrieved from the SentineOne API at: diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts index 6a18865c1f30c..74116cc8d60dd 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts @@ -213,10 +213,10 @@ export const RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS: Readonly< ], crowdstrike: ['device.id'], microsoft_defender_endpoint: [ + 'cloud.instance.id', 'm365_defender.alerts.entities.deviceId', 'm365_defender.alerts.devices.mdatpDeviceId', 'm365_defender.incident.alert.evidence.mde_device_id', - 'cloud.instance.id', ], }); diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/crowdstrike.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/crowdstrike.ts new file mode 100644 index 0000000000000..bddf7a7345b6e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/crowdstrike.ts @@ -0,0 +1,21 @@ +/* + * 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 { deepFreeze } from '@kbn/std'; + +/** + * List of all crowdstrike index patterns by integration + */ +export const CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION = deepFreeze({ + crowdstrike: [ + 'logs-crowdstrike.alert-*', + 'logs-crowdstrike.falcon-*', + 'logs-crowdstrike.fdr-*', + 'logs-crowdstrike.host-*', + 'logs-crowdstrike.vulnerability-*', + ], +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/microsoft_defender.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/microsoft_defender.ts new file mode 100644 index 0000000000000..8fa51a85f32bf --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/microsoft_defender.ts @@ -0,0 +1,21 @@ +/* + * 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 { deepFreeze } from '@kbn/std'; + +/** Index pattern for MS Defender for Endpoint log data (ingested by Microsoft Defender for Endpoint integration */ +export const MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN = + 'logs-microsoft_defender_endpoint.log-*'; + +export const MICROSOFT_DEFENDER_INDEX_PATTERNS_BY_INTEGRATION = deepFreeze({ + microsoft_defender_endpoint: [MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN], + m365_defender: [ + 'logs-m365_defender.alert-*', + 'logs-m365_defender.log-*', + 'logs-m365_defender.incident-*', + ], +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts index 786e43dae61ac..2c5d2668b305b 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts @@ -9,3 +9,6 @@ * Index pattern where the SentinelOne activity log is written to by the SentinelOne integration */ export const SENTINEL_ONE_ACTIVITY_INDEX_PATTERN = 'logs-sentinel_one.activity-*'; + +/** Index pattern where the SentinelOne Agent records are ingested into by the integration */ +export const SENTINEL_ONE_AGENT_INDEX_PATTERN = 'logs-sentinel_one.agent-*'; diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts index e3c47102ce719..442d75477e7a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts @@ -140,6 +140,18 @@ export interface LogsEndpointAction< '@timestamp': string; agent: { id: string | string[]; + /** + * Policy information for each of the agents that the response action was sent to. + * Added in support of space awareness. + */ + policy: Array<{ + /** The agent id running on the host */ + agentId: string; + /** The elastic agent id that ingested the data. This will be different from `agentId` for 3rd party EDRs */ + elasticAgentId: string; + integrationPolicyId: string; + agentPolicyId: string; + }>; }; EndpointActions: EndpointActionFields & ActionRequestFields; error?: EcsError; diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts index e0e6c498b5621..fe2eb92573169 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts @@ -9,3 +9,25 @@ export interface MicrosoftDefenderEndpointActionRequestCommonMeta { /** The ID of the action in Microsoft Defender's system */ machineActionId: string; } + +/** + * The log record that is ingested into Elasticsearch by the Microsoft Defender for Endpoint integration + * + * NOTE: not all properties are currently mapped below. Check the index definition if wanting to + * see what else is available and add it below if needed + */ +export interface MicrosoftDefenderEndpointLogEsDoc { + agent: { + id: string; + type: string; + version: string; + }; + cloud: { + instance: { + id: string; + }; + }; + microsoft: { + defender_endpoint: Record; + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/sentinel_one.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/sentinel_one.ts index c7f65388bdece..f9405be0c9f7a 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/sentinel_one.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/sentinel_one.ts @@ -32,6 +32,27 @@ export interface SentinelOneActivityEsDoc { }; } +/** + * The `agent` records that are ingested by the SentinelOne integration into Elasticsearch + * + * NOTE: not all properties are currently mapped below. Check the index definition if wanting to + * see what else is available and add it below if needed + */ +export interface SentinelOneAgentEsDoc { + agent: { + id: string; + type: string; + version: string; + }; + sentinel_one: { + agent: { + agent: { + id: string; + }; + }; + }; +} + /** * Activity data for file uploaded to S1 by an Agent: * ``` diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/microsoft_defender_host/services/onboard_microsoft_vm.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/microsoft_defender_host/services/onboard_microsoft_vm.ts index cc973e4295b99..67dbaa2fe1ef7 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/microsoft_defender_host/services/onboard_microsoft_vm.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/microsoft_defender_host/services/onboard_microsoft_vm.ts @@ -9,6 +9,7 @@ import type { ToolingLog } from '@kbn/tooling-log'; import type { KbnClient } from '@kbn/test'; import pRetry, { AbortError } from 'p-retry'; import { userInfo } from 'os'; +import { fetchActiveSpace } from '../../common/spaces'; import { createMultipassHostVmClient, createVm, @@ -37,7 +38,8 @@ export const onboardVmHostWithMicrosoftDefender = async ({ forceNewHost, onboardingPackage, }: OnboardVmHostWithMicrosoftDefenderOptions): Promise => { - const vmName = _vmName || generateVmName('msdefender'); + const activeSpaceId = (await fetchActiveSpace(kbnClient)).id; + const vmName = _vmName || generateVmName(`msdefender-${activeSpaceId}`); const hostVmNameAlreadyRunning = ( await findVm( 'multipass', diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts index d8c8053d2f281..6cc97c07b7f8f 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts @@ -122,7 +122,6 @@ const runCli: RunFn = async ({ log, flags }) => { ok(s1Url, getRequiredArgMessage('s1Url')); ok(s1ApiToken, getRequiredArgMessage('s1ApiToken')); - const vmName = (flags.vmName as string) || generateVmName('sentinelone'); const s1Client = new S1Client({ url: s1Url, apiToken: s1ApiToken, log }); const kbnClient = createKbnClient({ log, @@ -139,6 +138,7 @@ const runCli: RunFn = async ({ log, flags }) => { } const activeSpaceId = (await fetchActiveSpace(kbnClient)).id; + const vmName = (flags.vmName as string) || generateVmName(`sentinelone-${activeSpaceId}`); const runningS1VMs = ( await findVm( diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 36897ed4459be..04fe28875ad94 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -369,6 +369,7 @@ export class EndpointAppContextService { endpointService: this, esClient: this.startDependencies.esClient, username, + spaceId: DEFAULT_SPACE_ID, isAutomated: true, connectorActions: new NormalizedExternalConnectorClient( this.startDependencies.connectorActions.getUnsecuredActionsClient(), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts index 74460e908a5a1..72ea2189b3672 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts @@ -38,7 +38,6 @@ import { createFleetStartContractMock, createFleetToHostFilesClientMock, createMessageSigningServiceMock, - createPackagePolicyServiceMock, } from '@kbn/fleet-plugin/server/mocks'; import type { RequestFixtureOptions, RouterMock } from '@kbn/core-http-router-server-mocks'; import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; @@ -187,9 +186,17 @@ export const createMockEndpointAppContextServiceStartContract = const logger = loggingSystemMock.create().get('mock_endpoint_app_context'); const security = securityServiceMock.createStart() as unknown as DeeplyMockedKeys; - const packagePolicyService = createPackagePolicyServiceMock(); + const fleetStartServices = createFleetStartContractMock(); - packagePolicyService.list.mockImplementation(async (_, options) => { + // Ensure the agent service always returns the same agent service instance + fleetStartServices.agentService.asInternalScopedUser.mockReturnValue( + fleetStartServices.agentService.asInternalUser + ); + fleetStartServices.agentService.asScoped.mockReturnValue( + fleetStartServices.agentService.asInternalUser + ); + + fleetStartServices.packagePolicyService.list.mockImplementation(async (_, options) => { return { items: [], total: 0, @@ -213,7 +220,7 @@ export const createMockEndpointAppContextServiceStartContract = logger ) as DeeplyMockedKeys, experimentalFeatures: config.experimentalFeatures, - fleetStartServices: createFleetStartContractMock(), + fleetStartServices, cases: casesPluginMock.createStartContract(), manifestManager: getManifestManagerMock() as DeeplyMockedKeys, alerting: alertsMock.createStart(), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/utils.mock.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/utils.mock.ts index 07784bd6e7c0e..c5d6f61f997a0 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/utils.mock.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/utils.mock.ts @@ -5,13 +5,23 @@ * 2.0. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import type { OpenPointInTimeResponse, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { v4 as uuidV4 } from 'uuid'; import { BaseDataGenerator } from '../../../common/endpoint/data_generators/base_data_generator'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common'; +import { isObject, merge, reduce } from 'lodash'; interface ApplyEsClientSearchMockOptions { esClientMock: ElasticsearchClientMock; + /** + * The index to intercept and return the response provided. If providing an index value that + * ends with `*` (an index pattern), the search request will be checked to see if any of the + * defined indexes "start with" the index pattern defined (note: only supports a `*` at the + * end of the index name) + */ index: string; response: SearchResponse; /** @@ -20,6 +30,16 @@ interface ApplyEsClientSearchMockOptions { pitUsage?: boolean; } +const indexListHasMatchForIndex = (indexList: string[], indexNameOrPattern: string): boolean => { + const startsWithValue: string | undefined = indexNameOrPattern.endsWith('*') + ? indexNameOrPattern.substring(0, indexNameOrPattern.length - 1) + : ''; + + return indexList.some((index) => { + return index === indexNameOrPattern || (startsWithValue && index.startsWith(startsWithValue)); + }); +}; + /** * Generic utility for applying mocks to ES Client mock search method. Any existing mock implementation * for the `.search()` method will be called if the mock being applied does not match the target @@ -76,10 +96,10 @@ export const applyEsClientSearchMock = ({ esClientMock.search.mockImplementation(async (...args) => { const params = args[0] ?? {}; - const searchReqIndexes = Array.isArray(params.index) ? params.index : [params.index]; + const searchReqIndexes = Array.isArray(params.index) ? params.index : [params.index!]; const pit = 'pit' in params ? params.pit : undefined; - if (params.index && !pitUsage && searchReqIndexes.includes(index)) { + if (params.index && !pitUsage && indexListHasMatchForIndex(searchReqIndexes, index)) { return response; } else if (pit && pitUsage && openedPitIds.has(pit.id)) { return response; @@ -92,3 +112,87 @@ export const applyEsClientSearchMock = ({ return BaseDataGenerator.toEsSearchResponse([]); }); }; + +interface FleetKueryInfo { + packageNames: string[]; + agentPolicyIds: string[]; +} + +/** + * Parses a Fleet `kuery` string pass to the Package Policy service methods and returns info. about that kuery. + * Helpful to create more reusable mocks for testing. + * @param kuery + */ +export const getPackagePolicyInfoFromFleetKuery = async ( + kuery: string +): Promise => { + const response: FleetKueryInfo = { + packageNames: [], + agentPolicyIds: [], + }; + + // Why is a dynamic import being used here? + // There is a module (`grammar`) used by this ES Query utility that does not load correctly + // when cypress is ran (unclear why). The error seen when this occurs is below. The work-around + // seems to be to use dynamic import. + // Error: + // ``` + // Your configFile is invalid: /opt/buildkite-agent/.../kibana/x-pack/solutions/security/plugins/security_solution/public/management/cypress/cypress_serverless.config.ts + // It threw an error when required, check the stack trace below: + // /opt/buildkite-agent/.../kibana/src/platform/packages/shared/kbn-es-query/src/kuery/grammar/grammar.peggy:20 + // = Space* query:OrQuery? trailing:OptionalSpace { + // ^ + // SyntaxError: Unexpected token ':' + // at wrapSafe (node:internal/modules/cjs/loader:1378:20) + // at Module._compile (node:internal/modules/cjs/loader:1428:41) + // at Module._extensions..js (node:internal/modules/cjs/loader:1548:10) + // at Object.require.extensions. [as .js] (/opt/buildkite-agent/.cache/Cypress/13.17.0/Cypress/resources/app/node_modules/ts-node/dist/index.js:851:20).. + // ``` + + const kueryAst = (await import('@kbn/es-query')).fromKueryExpression(kuery); + const kueryFlatten = flattenKeys(kueryAst); + const getNextKueryAstArgumentPath = (keyPath: string): string => { + const lastIndexOfPackageNameArguments = + keyPath.lastIndexOf('.arguments.') + '.arguments.'.length - 1; + const nextArgumentNumber = Number(keyPath.charAt(lastIndexOfPackageNameArguments + 1)) + 1; + + return `${keyPath.substring(0, lastIndexOfPackageNameArguments)}.${nextArgumentNumber}.value`; + }; + + for (const [key, value] of Object.entries(kueryFlatten)) { + if (value && typeof value === 'string') { + if (value.includes(`${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name`)) { + const packageName = kueryFlatten[getNextKueryAstArgumentPath(key)]; + + if (packageName) { + response.packageNames.push(packageName); + } + } else if (value.includes(`${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.policy_ids`)) { + const policyId = kueryFlatten[getNextKueryAstArgumentPath(key)]; + + if (policyId) { + response.agentPolicyIds.push(policyId); + } + } + } + } + + return response; +}; + +export const flattenKeys = (obj: any, keyPath: any[] = []): any => { + // Copy taken from: + // https://github.com/elastic/kibana/blob/6a7c904f921434fe21dfa00eceabfb5e64e915dc/src/platform/packages/private/kbn-telemetry-tools/src/tools/utils.ts#L252-L264 + // Because it is not exposed from that library + if (isObject(obj)) { + return reduce( + obj, + (cum, next, key) => { + const keys = [...keyPath, key]; + return merge(cum, flattenKeys(next, keys)); + }, + {} + ); + } + return { [keyPath.join('.')]: obj }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts index 29aa6f4bba3d8..3460b545a5c46 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts @@ -74,6 +74,7 @@ export const getActionFileDownloadRouteHandler = ( return async (context, req, res) => { const { action_id: actionId, file_id: fileId } = req.params; const coreContext = await context.core; + const spaceId = (await context.securitySolution).getSpaceId(); try { const esClient = coreContext.elasticsearch.client.asInternalUser; @@ -84,6 +85,7 @@ export const getActionFileDownloadRouteHandler = ( const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, { esClient, casesClient, + spaceId, endpointService: endpointContext.service, username: user?.username || 'unknown', connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts index 63118a64fc453..561767c293b1d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts @@ -48,9 +48,11 @@ export const getActionFileInfoRouteHandler = ( const user = coreContext.security.authc.getCurrentUser(); const casesClient = await endpointContext.service.getCasesClient(req); const connectorActions = (await context.actions).getActionsClient(); + const spaceId = (await context.securitySolution).getSpaceId(); const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, { esClient, casesClient, + spaceId, endpointService: endpointContext.service, username: user?.username || 'unknown', connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts index 1c77f93162c45..f6f77cbf6d972 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts @@ -369,11 +369,13 @@ function responseActionRequestHandler( return res.forbidden({ body: error }); } - // Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error - throw error; + // Kibana core server handling of `500` errors does not actually return the `error.message` encountered, + // which can be critical in understanding what the root cause of a problem might be, so we handle + // `500` here to ensure that the `error.message` is returned + return res.customError({ + statusCode: 500, + body: error, + }); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/workflow_insights/update_insight.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/workflow_insights/update_insight.test.ts index ddf56deb19971..7a6fcea3fdcd7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/workflow_insights/update_insight.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/workflow_insights/update_insight.test.ts @@ -182,14 +182,14 @@ describe('Update Insights Route Handler', () => { it('should throw if retrieved insight is missing', async () => { const mockEndpointContext = createMockEndpointAppContext(); fetchMock.mockResolvedValue([]); // Simulate not found + await callRoute({ insightId: 'nope' }, { name: 'test' }, undefined, mockEndpointContext); - try { - await callRoute({ insightId: 'nope' }, { name: 'test' }, undefined, mockEndpointContext); - throw new Error('Expected to throw'); // failsafe, if it doesn't throw no assertions will be made - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect(err.message).toMatch(/Failed to retrieve insight/i); - } + expect(mockResponse.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: expect.objectContaining({ + message: expect.stringContaining('Failed to retrieve insight'), + }), + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts index 2ab1e3fc1709a..b67e728ed9610 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts @@ -70,7 +70,7 @@ describe('When using `getActionDetailsById()', () => { agents: ['agent-a'], agentType: 'endpoint', hosts: { 'agent-a': { name: 'Host-agent-a' } }, - command: 'kill-process', + command: 'running-processes', completedAt: '2022-04-30T16:08:47.449Z', wasSuccessful: true, errors: undefined, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts index 9d35c8e7bcd91..840867b836657 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts @@ -94,7 +94,7 @@ describe('When using `getActionList()', () => { agents: ['agent-a'], agentType: 'endpoint', hosts: { 'agent-a': { name: 'Host-agent-a' } }, - command: 'kill-process', + command: 'running-processes', alertIds: undefined, completedAt: '2022-04-30T16:08:47.449Z', wasSuccessful: true, @@ -180,7 +180,7 @@ describe('When using `getActionList()', () => { agents: ['agent-a'], agentType: 'endpoint', hosts: { 'agent-a': { name: 'Host-agent-a' } }, - command: 'kill-process', + command: 'running-processes', completedAt: '2022-04-30T16:08:47.449Z', errors: undefined, wasSuccessful: true, @@ -282,7 +282,7 @@ describe('When using `getActionList()', () => { 'agent-x': { name: '' }, }, alertIds: undefined, - command: 'kill-process', + command: 'running-processes', completedAt: undefined, wasSuccessful: false, errors: undefined, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts index cba00e7645e9f..d865e22b3eeb7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts @@ -19,6 +19,9 @@ import { } from '../../../../../../common/endpoint/constants'; import { SUB_ACTION } from '@kbn/stack-connectors-plugin/common/crowdstrike/constants'; import type { NormalizedExternalConnectorClient } from '../../..'; +import { applyEsClientSearchMock } from '../../../../mocks/utils.mock'; +import { CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION } from '../../../../../../common/endpoint/service/response_actions/crowdstrike'; +import { BaseDataGenerator } from '../../../../../../common/endpoint/data_generators/base_data_generator'; jest.mock('../../action_details_by_id', () => { const originalMod = jest.requireActual('../../action_details_by_id'); @@ -314,4 +317,77 @@ describe('CrowdstrikeActionsClient class', () => { expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalled(); }); }); + + describe('and space awareness is enabled', () => { + beforeEach(() => { + // @ts-expect-error write to readonly property + classConstructorOptions.endpointService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled = + true; + }); + + it('should write action request doc with policy info', async () => { + await crowdstrikeActionsClient.release(createCrowdstrikeIsolationOptions()); + + expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + agent: { + id: ['1-2-3'], + policy: [ + { + agentId: '1-2-3', + agentPolicyId: '6f12b025-fcb0-4db4-99e5-4927e3502bb8', + elasticAgentId: 'fleet-agent-id-123', + integrationPolicyId: '90d62689-f72d-4a05-b5e3-500cad0dc366', + }, + ], + }, + }), + }), + expect.anything() + ); + }); + + it('should search for Crwodstrike agent ID using index names', async () => { + await expect( + crowdstrikeActionsClient.isolate(createCrowdstrikeIsolationOptions()) + ).resolves.toBeTruthy(); + + expect(classConstructorOptions.esClient.search).toHaveBeenCalledWith({ + _source: false, + collapse: { + field: 'device.id', + inner_hits: { + name: 'most_recent', + size: 1, + _source: ['agent', 'device.id', 'event.created'], + sort: [{ 'event.created': 'desc' }], + }, + }, + ignore_unavailable: true, + index: [ + 'logs-crowdstrike.alert-default', + 'logs-crowdstrike.falcon-default', + 'logs-crowdstrike.fdr-default', + 'logs-crowdstrike.host-default', + 'logs-crowdstrike.vulnerability-default', + ], // << Important: should NOT contain a index pattern + query: { + bool: { filter: [{ terms: { 'device.id': ['1-2-3'] } }] }, + }, + }); + }); + + it('should error if unable to find agent id in crowdstrike ingested data', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION.crowdstrike[0], + response: BaseDataGenerator.toEsSearchResponse([]), + }); + + await expect( + crowdstrikeActionsClient.isolate(createCrowdstrikeIsolationOptions()) + ).rejects.toThrow('Unable to find elastic agent IDs for Crowdstrike agent ids: [1-2-3]'); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts index 6c0612c65f220..363ee012b7699 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts @@ -10,13 +10,14 @@ import { SUB_ACTION, CROWDSTRIKE_CONNECTOR_ID, } from '@kbn/stack-connectors-plugin/common/crowdstrike/constants'; -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { SearchRequest, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { CrowdstrikeBaseApiResponse, CrowdStrikeExecuteRTRResponse, } from '@kbn/stack-connectors-plugin/common/crowdstrike/types'; import { v4 as uuidv4 } from 'uuid'; +import { CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION } from '../../../../../../common/endpoint/service/response_actions/crowdstrike'; import { mapParametersToCrowdStrikeArguments } from './utils'; import type { CrowdstrikeActionRequestCommonMeta } from '../../../../../../common/endpoint/types/crowdstrike'; import type { @@ -49,6 +50,8 @@ import type { NormalizedExternalConnectorClient, NormalizedExternalConnectorClientExecuteOptions, } from '../lib/normalized_external_connector_client'; +import { catchAndWrapError } from '../../../../utils'; +import { buildIndexNameWithNamespace } from '../../../../../../common/endpoint/utils/index_name_utilities'; export type CrowdstrikeActionsClientOptions = ResponseActionsClientOptions & { connectorActions: NormalizedExternalConnectorClient; @@ -64,6 +67,121 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl { connectorActions.setup(CROWDSTRIKE_CONNECTOR_ID); } + /** + * Returns a list of all indexes for Crowdstrike data supported for response actions + * @private + */ + private async fetchIndexNames(): Promise { + const cachedInfo = this.cache.get('fetchIndexNames'); + + if (cachedInfo) { + this.log.debug( + `Returning cached response with list of index names:\n${stringify(cachedInfo)}` + ); + return cachedInfo; + } + + const integrationNames = Object.keys(CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION); + const fleetServices = this.options.endpointService.getInternalFleetServices( + this.options.spaceId + ); + const indexNamespaces = await fleetServices.getIntegrationNamespaces(integrationNames); + const indexNames: string[] = []; + + for (const [integrationName, namespaces] of Object.entries(indexNamespaces)) { + if (namespaces.length > 0) { + const indexPatterns = + CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION[ + integrationName as keyof typeof CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION + ]; + + for (const indexPattern of indexPatterns) { + indexNames.push( + ...namespaces.map((namespace) => buildIndexNameWithNamespace(indexPattern, namespace)) + ); + } + } + } + + this.cache.set('fetchIndexNames', indexNames); + this.log.debug(() => `Crowdstrike indexes with namespace:\n${stringify(indexNames)}`); + + return indexNames; + } + + protected async fetchAgentPolicyInfo( + agentIds: string[] + ): Promise { + const esClient = this.options.esClient; + const esSearchRequest: SearchRequest = { + index: await this.fetchIndexNames(), + query: { bool: { filter: [{ terms: { 'device.id': agentIds } }] } }, + collapse: { + field: 'device.id', + inner_hits: { + name: 'most_recent', + size: 1, + _source: ['agent', 'device.id', 'event.created'], + sort: [{ 'event.created': 'desc' }], + }, + }, + _source: false, + ignore_unavailable: true, + }; + + if (!esSearchRequest.index || esSearchRequest.index.length === 0) { + throw new ResponseActionsClientError( + `Unable to build list of indexes while retrieving policy information for Crowdstrike agents [${agentIds.join( + ', ' + )}]. Check to ensure at least one integration policy exists.`, + 400 + ); + } + + this.log.debug(() => `Searching for agents with:\n${stringify(esSearchRequest)}`); + + // Get the latest ingested document for each agent ID + const crowdstrikeEsResults = await esClient.search(esSearchRequest).catch(catchAndWrapError); + + this.log.debug(() => `Records found:\n${stringify(crowdstrikeEsResults, 20)}`); + + const agentIdsFound: string[] = []; + const fleetAgentIdToCrowdstrikeAgentIdMap: Record = + crowdstrikeEsResults.hits.hits.reduce((acc, esDoc) => { + const doc = esDoc.inner_hits?.most_recent.hits.hits[0]._source; + + if (doc) { + agentIdsFound.push(doc.device.id); + acc[doc.agent.id] = doc.device.id; + } + + return acc; + }, {} as Record); + const elasticAgentIds = Object.keys(fleetAgentIdToCrowdstrikeAgentIdMap); + + if (elasticAgentIds.length === 0) { + throw new ResponseActionsClientError( + `Unable to find elastic agent IDs for Crowdstrike agent ids: [${agentIds.join(', ')}]`, + 400 + ); + } + + // ensure all agent ids were found + for (const agentId of agentIds) { + if (!agentIdsFound.includes(agentId)) { + throw new ResponseActionsClientError(`Crowdstrike agent id [${agentId}] not found`, 404); + } + } + + return this.fetchFleetInfoForAgents(elasticAgentIds, ['crowdstrike']).then((agentInfoList) => { + for (const agentInfo of agentInfoList) { + agentInfo.agentId = fleetAgentIdToCrowdstrikeAgentIdMap[agentInfo.elasticAgentId]; + } + + return agentInfoList; + }); + } + protected async writeActionRequestToEndpointIndex< TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/mocks.ts index d1c0734e90909..bdab2050743c9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/mocks.ts @@ -11,6 +11,7 @@ import { } from '@kbn/stack-connectors-plugin/common/crowdstrike/constants'; import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types'; +import { BaseDataGenerator } from '../../../../../../common/endpoint/data_generators/base_data_generator'; import { createCrowdstrikeAgentDetailsMock, createCrowdstrikeGetAgentOnlineStatusDetailsMock, @@ -19,6 +20,8 @@ import { import type { ResponseActionsClientOptionsMock } from '../mocks'; import { responseActionsClientMock } from '../mocks'; import type { NormalizedExternalConnectorClient } from '../../..'; +import { applyEsClientSearchMock } from '../../../../mocks/utils.mock'; +import { CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION } from '../../../../../../common/endpoint/service/response_actions/crowdstrike'; export interface CrowdstrikeActionsClientOptionsMock extends ResponseActionsClientOptionsMock { connectorActions: NormalizedExternalConnectorClient; @@ -61,12 +64,38 @@ const createConnectorActionsClientMock = (): ActionsClientMock => { }; const createConstructorOptionsMock = (): CrowdstrikeActionsClientOptionsMock => { - return { + const options = { ...responseActionsClientMock.createConstructorOptions(), connectorActions: responseActionsClientMock.createNormalizedExternalConnectorClient( createConnectorActionsClientMock() ), }; + + const crowdstrikeIndexDoc = BaseDataGenerator.toEsSearchHit({ + device: { id: '1-2-3' }, + }); + crowdstrikeIndexDoc.inner_hits = { + most_recent: { + hits: { + hits: [ + { + _index: '', + _source: { + agent: { id: 'fleet-agent-id-123' }, + device: { id: '1-2-3' }, + }, + }, + ], + }, + }, + }; + applyEsClientSearchMock({ + esClientMock: options.esClient, + index: CROWDSTRIKE_INDEX_PATTERNS_BY_INTEGRATION.crowdstrike[0], + response: BaseDataGenerator.toEsSearchResponse([crowdstrikeIndexDoc]), + }); + + return options; }; interface CrowdstrikeEventSearchResponseMock { diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts index 3dde5e798c666..ef1b03e7f4739 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts @@ -122,6 +122,35 @@ describe('EndpointActionsClient', () => { ); }); + it('should write action request with agent policy info when space awareness is enabled', async () => { + // @ts-expect-error assign to readonly property + classConstructorOptions.endpointService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled = + true; + + await endpointActionsClient.isolate( + responseActionsClientMock.createIsolateOptions(getCommonResponseActionOptions()) + ); + + expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + agent: { + id: ['1-2-3'], + policy: [ + { + agentId: '1-2-3', + agentPolicyId: '6f12b025-fcb0-4db4-99e5-4927e3502bb8', + elasticAgentId: '1-2-3', + integrationPolicyId: '90d62689-f72d-4a05-b5e3-500cad0dc366', + }, + ], + }, + }), + }), + expect.anything() + ); + }); + it('should write correct comment when invalid agent ids', async () => { await endpointActionsClient.isolate( responseActionsClientMock.createIsolateOptions({ @@ -320,7 +349,7 @@ describe('EndpointActionsClient', () => { // NOTE: checking only the keys in order to avoid confusion - because the use of Mocks would // have returned a action details that would not match the request sent in this test. - await expect(Object.keys(actionResponse)).toEqual([ + expect(Object.keys(actionResponse)).toEqual([ 'action', 'id', 'agentType', diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts index 0aeedb51bc512..8b26387143829 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts @@ -64,6 +64,12 @@ const getInvalidAgentsWarning = (invalidAgents: string[]) => export class EndpointActionsClient extends ResponseActionsClientImpl { protected readonly agentType: ResponseActionAgentType = 'endpoint'; + protected async fetchAgentPolicyInfo( + agentIds: string[] + ): Promise { + return this.fetchFleetInfoForAgents(agentIds, ['endpoint']); + } + private async checkAgentIds(ids: string[]): Promise<{ valid: string[]; invalid: string[]; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts index cfa031280743f..ea6e37d1665f0 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts @@ -46,6 +46,7 @@ import { ENDPOINT_RESPONSE_ACTION_SENT_ERROR_EVENT, ENDPOINT_RESPONSE_ACTION_SENT_EVENT, } from '../../../../../lib/telemetry/event_based/events'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; jest.mock('../../action_details_by_id', () => { const original = jest.requireActual('../../action_details_by_id'); @@ -195,6 +196,7 @@ describe('ResponseActionsClientImpl base class', () => { esClient, endpointService: endpointAppContextService, username: 'foo', + spaceId: DEFAULT_SPACE_ID, }); await mockInstance.updateCases(updateCasesOptions); @@ -341,6 +343,7 @@ describe('ResponseActionsClientImpl base class', () => { input_type: 'endpoint', type: 'INPUT_ACTION', }, + // @ts-expect-error missing `agent.policy`, which will only be present if space awareness is enabled agent: { id: ['one'], }, @@ -459,6 +462,29 @@ describe('ResponseActionsClientImpl base class', () => { await expect(responsePromise).rejects.toBeInstanceOf(ResponseActionsNotSupportedError); }); + it('should include `agent.policy` document field if space awareness is enabled', async () => { + // @ts-expect-error writing to a readonly property + endpointAppContextService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled = true; + + await expect( + baseClassMock.writeActionRequestToEndpointIndex(indexDocOptions) + ).resolves.toEqual( + expect.objectContaining({ + agent: { + id: ['one'], + policy: [ + { + agentId: 'one', + agentPolicyId: 'agent-policy-a', + elasticAgentId: 'one', + integrationPolicyId: 'integration-policy-a', + }, + ], + }, + }) + ); + }); + describe('And class is instantiated with `isAutomated` set to `true`', () => { beforeEach(() => { constructorOptions.isAutomated = true; @@ -780,7 +806,7 @@ describe('ResponseActionsClientImpl base class', () => { EndpointActions: expect.objectContaining({ action_id: 'action-id-2', }), - agent: { id: 'agent-b' }, + agent: { id: 'agent-b', policy: expect.any(Array) }, }), pendingAgentIds: ['agent-b'], }, @@ -792,6 +818,17 @@ describe('ResponseActionsClientImpl base class', () => { class MockClassWithExposedProtectedMembers extends ResponseActionsClientImpl { protected readonly agentType: ResponseActionAgentType = 'endpoint'; + protected async fetchAgentPolicyInfo( + agentIds: string[] + ): Promise { + return agentIds.map((id) => ({ + agentId: id, + elasticAgentId: id, + agentPolicyId: 'agent-policy-a', + integrationPolicyId: 'integration-policy-a', + })); + } + public async updateCases(options: ResponseActionsClientUpdateCasesOptions): Promise { return super.updateCases(options); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts index 6b09dff0b3eae..fe3f11aa9cad3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts @@ -13,6 +13,9 @@ import { AttachmentType, ExternalReferenceStorageType } from '@kbn/cases-plugin/ import type { CaseAttachments } from '@kbn/cases-plugin/public/types'; import { i18n } from '@kbn/i18n'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { PackagePolicy } from '@kbn/fleet-plugin/common'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common'; +import { catchAndWrapError } from '../../../../utils'; import { ENDPOINT_RESPONSE_ACTION_SENT_EVENT, ENDPOINT_RESPONSE_ACTION_SENT_ERROR_EVENT, @@ -119,6 +122,7 @@ export const HOST_NOT_ENROLLED = i18n.translate( export interface ResponseActionsClientOptions { endpointService: EndpointAppContextService; esClient: ElasticsearchClient; + spaceId: string; casesClient?: CasesClient; /** Username that will be stored along with the action's ES documents */ username: string; @@ -209,6 +213,98 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient ); } + /** + * Fetches information about the policies for each agent id that the response action is being sent to. + * Must be implemented by each subclass, since "agentId" for 3rd party EDRs agent IDs will need to + * to be mapped to elastic agent ids. + * + * @param agentIds + * @protected + */ + protected abstract fetchAgentPolicyInfo( + /** + * The agent IDs that the response action is being sent to. For 3rd party EDRs, these will be + * the IDs of the agent in the 3rd party system and NOT the Elastic Agent ID. + * The `fetchFleetInfoForAgents()` method can be used in conjunction with this method retrieve info. + * from Fleet once the Fleet Agent Ids have been calculated. + */ + agentIds: string[] + ): Promise; + + /** + * Fetches Fleet agent information for each of the Fleet agent ids provided on input. + * @param agentIds + * @param integrations + * @protected + */ + protected async fetchFleetInfoForAgents( + /** Fleet Agent IDs */ + agentIds: string[], + /** A list of integration names (value found in `package.name` in an integration policy) */ + integrations: string[] + ): Promise { + if (integrations.length === 0) { + throw new ResponseActionsClientError(`'integrations' argument can not be empty`); + } + + const spaceId = this.options.spaceId; + const fleetServices = this.options.endpointService.getInternalFleetServices(spaceId); + const soClient = fleetServices.savedObjects.createInternalScopedSoClient({ spaceId }); + const agentPolicyIds = new Set(); + const policyInfo: LogsEndpointAction['agent']['policy'] = []; + + // Get a list of Agent records so we can identify the Agent Policy ID + const agents = await fleetServices.agent.getByIds(agentIds).catch(catchAndWrapError); + + this.log.debug( + () => `Fleet agent records for agent IDs [${agentIds.join(' | ')}]:\n${stringify(agents)}` + ); + + for (const agent of agents) { + if (agent.policy_id) { + agentPolicyIds.add(agent.policy_id); + } + } + + // Get a list of integration policies that are associated with the agent policies identified + const kuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: (${integrations.join( + ' OR ' + )}) AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.policy_ids: (${Array.from(agentPolicyIds.values()) + .map((id) => `"${id}"`) + .join(' OR ')})`; + + this.log.debug( + () => `Looking for integration policies in fleet using filter (kuery):\n${kuery}` + ); + + const integrationPolicies = await fleetServices.packagePolicy + .list(soClient, { perPage: 10_000, kuery }) + .catch(catchAndWrapError); + + this.log.debug(() => `Integration policies found:\n${stringify(integrationPolicies)}`); + + const agentPolicyToIntegrationPolicyMap: Record = {}; + + for (const integrationPolicy of integrationPolicies.items) { + for (const agentPolicyId of integrationPolicy.policy_ids) { + agentPolicyToIntegrationPolicyMap[agentPolicyId] = integrationPolicy; + } + } + + for (const agent of agents) { + if (agent.policy_id) { + policyInfo.push({ + agentId: agent.id, + elasticAgentId: agent.id, + agentPolicyId: agent.policy_id, + integrationPolicyId: agentPolicyToIntegrationPolicyMap[agent.policy_id].id, + }); + } + } + + return policyInfo; + } + /** * Update cases with information about the hosts that received a response action. * @@ -477,8 +573,18 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient const doc: LogsEndpointAction = { '@timestamp': new Date().toISOString(), + // Need to suppress this TS error around `agent.policy` not supporting `undefined`. + // It will be removed once we enable the feature and delete the feature flag checks. + // @ts-expect-error agent: { id: actionRequest.endpoint_ids, + // add the `policy` info if space awareness is enabled + ...(this.options.endpointService.experimentalFeatures + .endpointManagementSpaceAwarenessEnabled + ? { + policy: await this.fetchAgentPolicyInfo(actionRequest.endpoint_ids), + } + : {}), }, EndpointActions: { action_id: actionRequest.actionId || uuidv4(), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts index 5baf18f9200d3..f6668d9234b02 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts @@ -17,8 +17,11 @@ import type { MicrosoftDefenderEndpointMachine, MicrosoftDefenderEndpointMachineAction, } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; -import type { NormalizedExternalConnectorClient } from '../../../../..'; +import { applyEsClientSearchMock } from '../../../../../../mocks/utils.mock'; +import { MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN } from '../../../../../../../../common/endpoint/service/response_actions/microsoft_defender'; +import { MicrosoftDefenderDataGenerator } from '../../../../../../../../common/endpoint/data_generators/microsoft_defender_data_generator'; import { responseActionsClientMock, type ResponseActionsClientOptionsMock } from '../../../mocks'; +import type { NormalizedExternalConnectorClient } from '../../../../..'; export interface MicrosoftDefenderActionsClientOptionsMock extends ResponseActionsClientOptionsMock { @@ -26,12 +29,45 @@ export interface MicrosoftDefenderActionsClientOptionsMock } const createMsDefenderClientConstructorOptionsMock = () => { - return { + const options = { ...responseActionsClientMock.createConstructorOptions(), connectorActions: responseActionsClientMock.createNormalizedExternalConnectorClient( createMsConnectorActionsClientMock() ), }; + const generator = new MicrosoftDefenderDataGenerator('seed'); + const msLogIndexEsHit = generator.generateEndpointLogEsHit({ + cloud: { instance: { id: '1-2-3' } }, + }); + + msLogIndexEsHit.inner_hits = { + most_recent: { + hits: { + hits: [ + { + _index: '', + _source: { + agent: { + id: '1-2-3', + }, + cloud: { + instance: { id: '1-2-3' }, + }, + }, + }, + ], + }, + }, + }; + + // Mocks for MS data indexes + applyEsClientSearchMock({ + esClientMock: options.esClient, + index: MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN, + response: generator.generateEndpointLogEsSearchResponse([msLogIndexEsHit]), + }); + + return options; }; const createMsConnectorActionsClientMock = (): ActionsClientMock => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts index a01132ad6989b..364c8dec6fee1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts @@ -25,6 +25,8 @@ import type { MicrosoftDefenderEndpointMachineAction, } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; import { MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants'; +import { MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN } from '../../../../../../../../common/endpoint/service/response_actions/microsoft_defender'; +import { MicrosoftDefenderDataGenerator } from '../../../../../../../../common/endpoint/data_generators/microsoft_defender_data_generator'; jest.mock('../../../../action_details_by_id', () => { const originalMod = jest.requireActual('../../../../action_details_by_id'); @@ -232,7 +234,7 @@ describe('MS Defender response actions client', () => { expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({ '@timestamp': expect.any(String), EndpointActions: { - action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366', completed_at: expect.any(String), data: { command: 'isolate' }, input_type: 'microsoft_defender_endpoint', @@ -267,7 +269,7 @@ describe('MS Defender response actions client', () => { const expectedResult: LogsEndpointActionResponse = { '@timestamp': expect.any(String), EndpointActions: { - action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366', completed_at: expect.any(String), data: { command: 'isolate' }, input_type: 'microsoft_defender_endpoint', @@ -289,4 +291,73 @@ describe('MS Defender response actions client', () => { ); }); }); + + describe('and space awareness is enabled', () => { + beforeEach(() => { + // @ts-expect-error assign to readonly property + clientConstructorOptionsMock.endpointService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled = + true; + }); + + it('should write action request doc with agent policy info when space awareness is enabled', async () => { + await msClientMock.isolate(responseActionsClientMock.createIsolateOptions()); + + expect(clientConstructorOptionsMock.esClient.index).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + agent: { + id: ['1-2-3'], + policy: [ + { + agentId: '1-2-3', + agentPolicyId: '6f12b025-fcb0-4db4-99e5-4927e3502bb8', + elasticAgentId: '1-2-3', + integrationPolicyId: '90d62689-f72d-4a05-b5e3-500cad0dc366', + }, + ], + }, + }), + }), + expect.anything() + ); + }); + + it('should search for MS defender agent using correct index names', async () => { + await expect( + msClientMock.isolate(responseActionsClientMock.createIsolateOptions()) + ).resolves.toBeTruthy(); + + expect(clientConstructorOptionsMock.esClient.search).toHaveBeenCalledWith({ + _source: false, + collapse: { + field: 'cloud.instance.id', + inner_hits: { + _source: ['agent', 'cloud.instance.id', 'event.created'], + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + ignore_unavailable: true, + index: ['logs-microsoft_defender_endpoint.log-default'], // << Important: should NOT contain a index pattern + query: { + bool: { filter: [{ terms: { 'cloud.instance.id': ['1-2-3'] } }] }, + }, + }); + }); + + it('should error is unable to find MS agent in ingested data', async () => { + applyEsClientSearchMock({ + esClientMock: clientConstructorOptionsMock.esClient, + index: MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN, + response: MicrosoftDefenderDataGenerator.toEsSearchResponse([]), + }); + + await expect( + msClientMock.isolate(responseActionsClientMock.createIsolateOptions()) + ).rejects.toThrow( + 'Unable to find Elastic agent IDs for Microsoft Defender agent ids: [1-2-3]' + ); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts index 59477ffa610bc..34dae57c8cfed 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts @@ -21,6 +21,9 @@ import { type MicrosoftDefenderEndpointMachineAction, } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; import { groupBy } from 'lodash'; +import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { buildIndexNameWithNamespace } from '../../../../../../../../common/endpoint/utils/index_name_utilities'; +import { MICROSOFT_DEFENDER_INDEX_PATTERNS_BY_INTEGRATION } from '../../../../../../../../common/endpoint/service/response_actions/microsoft_defender'; import type { IsolationRouteRequestBody, UnisolationRouteRequestBody, @@ -32,6 +35,7 @@ import type { LogsEndpointAction, LogsEndpointActionResponse, MicrosoftDefenderEndpointActionRequestCommonMeta, + MicrosoftDefenderEndpointLogEsDoc, } from '../../../../../../../../common/endpoint/types'; import type { ResponseActionAgentType, @@ -53,6 +57,7 @@ import type { CommonResponseActionMethodOptions, ProcessPendingActionsMethodOptions, } from '../../../lib/types'; +import { catchAndWrapError } from '../../../../../../utils'; export type MicrosoftDefenderActionsClientOptions = ResponseActionsClientOptions & { connectorActions: NormalizedExternalConnectorClient; @@ -68,6 +73,135 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien connectorActions.setup(MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID); } + /** + * Returns a list of all indexes for Microsoft Defender data supported for response actions + * @private + */ + private async fetchIndexNames(): Promise { + const cachedInfo = this.cache.get('fetchIndexNames'); + + if (cachedInfo) { + this.log.debug( + `Returning cached response with list of index names:\n${stringify(cachedInfo)}` + ); + return cachedInfo; + } + + const integrationNames = Object.keys(MICROSOFT_DEFENDER_INDEX_PATTERNS_BY_INTEGRATION); + const fleetServices = this.options.endpointService.getInternalFleetServices( + this.options.spaceId + ); + const indexNamespaces = await fleetServices.getIntegrationNamespaces(integrationNames); + const indexNames: string[] = []; + + for (const [integrationName, namespaces] of Object.entries(indexNamespaces)) { + if (namespaces.length > 0) { + const indexPatterns = + MICROSOFT_DEFENDER_INDEX_PATTERNS_BY_INTEGRATION[ + integrationName as keyof typeof MICROSOFT_DEFENDER_INDEX_PATTERNS_BY_INTEGRATION + ]; + + for (const indexPattern of indexPatterns) { + indexNames.push( + ...namespaces.map((namespace) => buildIndexNameWithNamespace(indexPattern, namespace)) + ); + } + } + } + + this.cache.set('fetchIndexNames', indexNames); + this.log.debug(() => `MS Defender indexes with namespace:\n${stringify(indexNames)}`); + + return indexNames; + } + + protected async fetchAgentPolicyInfo( + agentIds: string[] + ): Promise { + const esClient = this.options.esClient; + const esSearchRequest: SearchRequest = { + index: await this.fetchIndexNames(), + query: { bool: { filter: [{ terms: { 'cloud.instance.id': agentIds } }] } }, + collapse: { + field: 'cloud.instance.id', + inner_hits: { + name: 'most_recent', + size: 1, + _source: ['agent', 'cloud.instance.id', 'event.created'], + sort: [{ 'event.created': 'desc' }], + }, + }, + _source: false, + ignore_unavailable: true, + }; + + if (!esSearchRequest.index || esSearchRequest.index.length === 0) { + throw new ResponseActionsClientError( + `Unable to build list of indexes while retrieving policy information for Microsoft Defender agents [${agentIds.join( + ', ' + )}]. Check to ensure at least one integration policy exists.`, + 400 + ); + } + + this.log.debug(() => `Searching for agents with:\n${stringify(esSearchRequest)}`); + + const msDefenderLogEsResults = await esClient + .search< + MicrosoftDefenderEndpointLogEsDoc, + { most_recent: MicrosoftDefenderEndpointLogEsDoc } + >(esSearchRequest) + .catch(catchAndWrapError); + + this.log.debug( + () => `MS Defender Log records found:\n${stringify(msDefenderLogEsResults, 20)}` + ); + + const agentIdsFound: string[] = []; + const fleetAgentIdToMsDefenderAgentIdMap: Record = ( + msDefenderLogEsResults.hits.hits ?? [] + ).reduce((acc, esDoc) => { + const doc = esDoc.inner_hits?.most_recent.hits.hits[0]._source; + + if (doc) { + agentIdsFound.push(doc.cloud.instance.id); + acc[doc.agent.id] = doc.cloud.instance.id; + } + + return acc; + }, {} as Record); + const elasticAgentIds = Object.keys(fleetAgentIdToMsDefenderAgentIdMap); + + if (elasticAgentIds.length === 0) { + throw new ResponseActionsClientError( + `Unable to find Elastic agent IDs for Microsoft Defender agent ids: [${agentIds.join( + ', ' + )}]`, + 400 + ); + } + + // ensure all MS agent ids were found + for (const agentId of agentIds) { + if (!agentIdsFound.includes(agentId)) { + throw new ResponseActionsClientError( + `Microsoft Defender agent id [${agentId}] not found`, + 404 + ); + } + } + + return this.fetchFleetInfoForAgents(elasticAgentIds, ['microsoft_defender_endpoint']).then( + (agentInfoList) => { + for (const agentInfo of agentInfoList) { + agentInfo.agentId = fleetAgentIdToMsDefenderAgentIdMap[agentInfo.elasticAgentId]; + } + + return agentInfoList; + } + ); + } + protected async handleResponseActionCreation< TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts index 180e4e5d3f1fc..480d108ed002b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts @@ -22,6 +22,10 @@ import type { AttachmentsSubClient } from '@kbn/cases-plugin/server/client/attac import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import { getPackagePolicyInfoFromFleetKuery } from '../../../mocks/utils.mock'; +import { FleetPackagePolicyGenerator } from '../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import { FleetAgentGenerator } from '../../../../../common/endpoint/data_generators/fleet_agent_generator'; import type { ResponseActionsClient } from '../..'; import { NormalizedExternalConnectorClient } from '../..'; import type { KillOrSuspendProcessRequestBody } from '../../../../../common/endpoint/types'; @@ -83,6 +87,7 @@ const createConstructorOptionsMock = (): Required { switch (payload.index) { @@ -111,9 +116,46 @@ const createConstructorOptionsMock = (): Required {}) as unknown as jest.Mocked['bulkCreate'] ); + // Mock some Fleet apis in order to support the `fetchFleetInfoForAgents()` method + const fleetStartServices = endpointServiceStartContract.fleetStartServices; + const packagePolicy = new FleetPackagePolicyGenerator('seed').generate(); + + fleetStartServices.agentService.asInternalUser.getByIds.mockImplementation(async (agentIds) => { + return agentIds?.map((id) => + new FleetAgentGenerator('seed').generate({ id, policy_id: packagePolicy.policy_ids[0] }) + ); + }); + fleetStartServices.packagePolicyService.list.mockImplementation(async (_, options) => { + const kueryInfo = await getPackagePolicyInfoFromFleetKuery(options.kuery ?? ''); + + const packagePolicyOverrides: Parameters[0] = { + id: packagePolicy.id, + }; + + if (kueryInfo.packageNames.length > 0) { + packagePolicyOverrides.package = { + name: kueryInfo.packageNames[0], + version: '1.0.0', + title: kueryInfo.packageNames[0], + }; + } + + if (kueryInfo.agentPolicyIds) { + packagePolicyOverrides.policy_ids = [kueryInfo.agentPolicyIds[0]]; + } + + return { + items: [new FleetPackagePolicyGenerator('seed').generate(packagePolicyOverrides)], + size: 1, + page: 1, + perPage: 20, + total: 1, + }; + }); + endpointService.setup(createMockEndpointAppContextServiceSetupContract()); endpointService.start({ - ...createMockEndpointAppContextServiceStartContract(), + ...endpointServiceStartContract, esClient, }); @@ -121,6 +163,7 @@ const createConstructorOptionsMock = (): Required { const originalMod = jest.requireActual('../../action_details_by_id'); @@ -462,7 +464,7 @@ describe('SentinelOneActionsClient class', () => { expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({ '@timestamp': expect.any(String), EndpointActions: { - action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366', completed_at: expect.any(String), data: { command: 'isolate', @@ -476,9 +478,9 @@ describe('SentinelOneActionsClient class', () => { error: undefined, meta: { activityLogEntryDescription: 'Some description here', - activityLogEntryId: 'd78282bc-e413-468d-9df6-570b91756a6d', + activityLogEntryId: 'f4ab34cb-56f9-412d-9202-7691c3a6d70c', activityLogEntryType: 1001, - elasticDocId: '85f7f003-ebed-4157-b8e6-16ae44fc4be7', + elasticDocId: 'f28861a0-9771-4647-bb3f-b5a8e32a6126', }, }); }); @@ -728,7 +730,7 @@ describe('SentinelOneActionsClient class', () => { expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({ '@timestamp': expect.any(String), EndpointActions: { - action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366', completed_at: expect.any(String), data: { command: 'get-file', @@ -751,8 +753,8 @@ describe('SentinelOneActionsClient class', () => { error: undefined, meta: { activityLogEntryId: 'activity-222', - downloadUrl: '/agents/5173897/uploads/40558796', - elasticDocId: '16ae44fc-4be7-446c-8e8f-a5c082dda918', + downloadUrl: '/agents/20793397/uploads/821770', + elasticDocId: 'b5a8e32a-6126-45f7-b003-ebedf157b8e6', createdAt: expect.any(String), filename: 'file.zip', }, @@ -812,7 +814,7 @@ describe('SentinelOneActionsClient class', () => { expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({ '@timestamp': expect.any(String), EndpointActions: { - action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366', completed_at: expect.any(String), data: { command: requestData.command, @@ -1946,4 +1948,134 @@ describe('SentinelOneActionsClient class', () => { ); }); }); + + describe('and space awareness is enabled', () => { + beforeEach(() => { + // @ts-expect-error assignment to readonly prop + classConstructorOptions.endpointService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled = + true; + + ( + classConstructorOptions.endpointService.getInternalFleetServices().packagePolicy + .list as jest.Mock + ).mockResolvedValue({ + items: [ + new FleetPackagePolicyGenerator('seed').generate({ + package: { + name: 'sentinel_one', + title: 'sentinelone', + version: '1.0.0', + }, + namespace: 'foo', + }), + ], + page: 1, + perPage: 10, + total: 1, + }); + + const generator = new SentinelOneDataGenerator('seed'); + const agentSearchHit = generator.generateAgentEsSearchHit(); + + agentSearchHit.inner_hits = { + most_recent: { + hits: { + hits: [ + { + _index: '', + _source: { + agent: { + id: 'elastic-agent-id-1', + }, + sentinel_one: { + agent: { + agent: { + id: '1-2-3', + }, + }, + }, + }, + }, + ], + }, + }, + }; + + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: SENTINEL_ONE_AGENT_INDEX_PATTERN, + response: generator.generateAgentEsSearchResponse([agentSearchHit]), + }); + }); + + it('should error is unable to build sentinelone agent index names', async () => { + ( + classConstructorOptions.endpointService.getInternalFleetServices().packagePolicy + .list as jest.Mock + ).mockResolvedValue({ items: [] }); + + await expect(s1ActionsClient.isolate(createS1IsolationOptions())).rejects.toThrow( + 'Unable to build list of indexes while retrieving policy information for SentinelOne agents [1-2-3]. Check to ensure at least one integration policy exists.' + ); + }); + + it('should search sentinelone agent index with correct index name', async () => { + await expect(s1ActionsClient.isolate(createS1IsolationOptions())).resolves.toBeTruthy(); + + expect(classConstructorOptions.esClient.search).toHaveBeenCalledWith({ + _source: false, + collapse: { + field: 'sentinel_one.agent.agent.id', + inner_hits: { + _source: ['agent', 'sentinel_one.agent.agent.id', 'event.created'], + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + ignore_unavailable: true, + index: ['logs-sentinel_one.agent-foo'], // << Important: should NOT contain a index pattern + query: { + bool: { filter: [{ terms: { 'sentinel_one.agent.agent.id': ['1-2-3'] } }] }, + }, + }); + }); + + it('should error if S1 agent id is not found in SentinelOne agent index', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: SENTINEL_ONE_AGENT_INDEX_PATTERN, + response: SentinelOneDataGenerator.toEsSearchResponse([]), + }); + + await expect(s1ActionsClient.isolate(createS1IsolationOptions())).rejects.toThrow( + 'Unable to find elastic agent IDs for SentinelOne agent ids: [1-2-3]' + ); + }); + + it('should include agent policy info. when action request is written to index', async () => { + await expect(s1ActionsClient.isolate(createS1IsolationOptions())).resolves.toBeTruthy(); + + expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + agent: { + id: ['1-2-3'], + // FYI: mock that enables this to be generated is located in: + // `x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts` + policy: [ + { + agentId: '1-2-3', + agentPolicyId: '6f12b025-fcb0-4db4-99e5-4927e3502bb8', + elasticAgentId: 'elastic-agent-id-1', + integrationPolicyId: '90d62689-f72d-4a05-b5e3-500cad0dc366', + }, + ], + }, + }), + }), + expect.anything() + ); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts index b2bd5e99c35d9..e39a7a3d13943 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts @@ -36,6 +36,8 @@ import type { } from '@elastic/elasticsearch/lib/api/types'; import type { Readable } from 'stream'; import type { Mutable } from 'utility-types'; +import { buildIndexNameWithNamespace } from '../../../../../../common/endpoint/utils/index_name_utilities'; +import { SENTINEL_ONE_AGENT_INDEX_PATTERN } from '../../../../../../common/endpoint/service/response_actions/sentinel_one'; import type { SentinelOneKillProcessScriptArgs, SentinelOneProcessListScriptArgs, @@ -72,6 +74,7 @@ import type { SentinelOneActionRequestCommonMeta, SentinelOneActivityDataForType80, SentinelOneActivityEsDoc, + SentinelOneAgentEsDoc, SentinelOneGetFileRequestMeta, SentinelOneGetFileResponseMeta, SentinelOneIsolationRequestMeta, @@ -124,6 +127,120 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { connectorActions.setup(SENTINELONE_CONNECTOR_ID); } + private async fetchSentinelOneAgentIndexNames(): Promise { + const cachedInfo = this.cache.get('fetchSentinelOneAgentIndexNames'); + + if (cachedInfo) { + this.log.debug( + `Returning cached response with list of Agent index names:\n${stringify(cachedInfo)}` + ); + return cachedInfo; + } + + const fleetServices = this.options.endpointService.getInternalFleetServices( + this.options.spaceId + ); + const indexNamespaces = await fleetServices.getIntegrationNamespaces(['sentinel_one']); + const indexNames: string[] = []; + + for (const namespaces of Object.values(indexNamespaces)) { + if (namespaces.length > 0) { + indexNames.push( + ...namespaces.map((namespace) => + buildIndexNameWithNamespace(SENTINEL_ONE_AGENT_INDEX_PATTERN, namespace) + ) + ); + } + } + + this.log.debug(() => `Index list with namespace:\n${stringify(indexNames)}`); + + this.cache.set('fetchSentinelOneAgentIndexNames', indexNames); + + return indexNames; + } + + protected async fetchAgentPolicyInfo( + agentIds: string[] + ): Promise { + const esClient = this.options.esClient; + const esSearchRequest: SearchRequest = { + index: await this.fetchSentinelOneAgentIndexNames(), + query: { + bool: { + filter: [{ terms: { 'sentinel_one.agent.agent.id': agentIds } }], + }, + }, + collapse: { + field: 'sentinel_one.agent.agent.id', + inner_hits: { + name: 'most_recent', + size: 1, + _source: ['agent', 'sentinel_one.agent.agent.id', 'event.created'], + sort: [{ 'event.created': 'desc' }], + }, + }, + _source: false, + ignore_unavailable: true, + }; + + if (!esSearchRequest.index || esSearchRequest.index.length === 0) { + throw new ResponseActionsClientError( + `Unable to build list of indexes while retrieving policy information for SentinelOne agents [${agentIds.join( + ', ' + )}]. Check to ensure at least one integration policy exists.`, + 400 + ); + } + + this.log.debug(() => `Looking for agents with:\n${stringify(esSearchRequest)}`); + + // Get the latest ingested document for each agent ID + const s1AgentsEsResults = await esClient + .search(esSearchRequest) + .catch(catchAndWrapError); + + this.log.debug(() => `SentinelOne Agent records found:\n${stringify(s1AgentsEsResults, 20)}`); + + const agentIdsFound: string[] = []; + const fleetAgentIdToS1AgentIdMap: Record = s1AgentsEsResults.hits.hits.reduce( + (acc, s1AgentEsDoc) => { + const doc = s1AgentEsDoc.inner_hits?.most_recent.hits.hits[0]._source; + + if (doc) { + agentIdsFound.push(doc.sentinel_one.agent.agent.id); + acc[doc.agent.id] = doc.sentinel_one.agent.agent.id; + } + + return acc; + }, + {} as Record + ); + const elasticAgentIds = Object.keys(fleetAgentIdToS1AgentIdMap); + + if (elasticAgentIds.length === 0) { + throw new ResponseActionsClientError( + `Unable to find elastic agent IDs for SentinelOne agent ids: [${agentIds.join(', ')}]`, + 400 + ); + } + + // ensure all MS agent ids were found + for (const agentId of agentIds) { + if (!agentIdsFound.includes(agentId)) { + throw new ResponseActionsClientError(`SentinelOne agent id [${agentId}] not found`, 404); + } + } + + return this.fetchFleetInfoForAgents(elasticAgentIds, ['sentinel_one']).then((agentInfoList) => { + for (const agentInfo of agentInfoList) { + agentInfo.agentId = fleetAgentIdToS1AgentIdMap[agentInfo.elasticAgentId]; + } + + return agentInfoList; + }); + } + private async handleResponseActionCreation< TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.ts index 7a270ede879e8..ac44bed08dd54 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.ts @@ -47,5 +47,10 @@ export const fetchActionRequestById = async < throw new NotFoundError(`Action with id '${actionId}' not found.`); } + // Ensure `agent.policy` is an array + if (!Array.isArray(actionRequest.agent.policy)) { + actionRequest.agent.policy = actionRequest.agent.policy ? [actionRequest.agent.policy] : []; + } + return actionRequest; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts index f56afd92e7484..87b9e5f3c9450 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts @@ -32,27 +32,7 @@ describe('fetchActionRequests()', () => { it('should return an array of items', async () => { await expect(fetchActionRequests(fetchOptions)).resolves.toEqual({ - data: [ - { - '@timestamp': '2022-04-27T16:08:47.449Z', - EndpointActions: { - action_id: '123', - data: { - command: 'kill-process', - comment: '5wb6pu6kh2xix5i', - }, - expiration: '2022-05-10T16:08:47.449Z', - input_type: 'endpoint', - type: 'INPUT_ACTION', - }, - agent: { - id: 'agent-a', - }, - user: { - id: 'Shanel', - }, - }, - ], + data: expect.any(Array), total: 1, from: 0, size: 10, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.ts index 6bfbf752c8d45..43074273f0ab3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.ts @@ -139,7 +139,14 @@ export const fetchActionRequests = async ({ return { data: (actionRequests?.hits?.hits ?? []).map((esHit) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return esHit._source!; + const action = esHit._source!; + + // Ensure `agent.policy` is an array + if (!Array.isArray(action.agent.policy)) { + action.agent.policy = action.agent.policy ? [action.agent.policy] : []; + } + + return action; }), size, from, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts index 302528b024f76..1f5dafe3ded0c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts @@ -55,6 +55,7 @@ export const createEndpointFleetServicesFactoryMock = ( const fleetInternalServicesMocked = serviceFactoryMock.asInternalUser(); jest.spyOn(fleetInternalServicesMocked, 'ensureInCurrentSpace'); jest.spyOn(fleetInternalServicesMocked, 'getPolicyNamespace'); + jest.spyOn(fleetInternalServicesMocked, 'getIntegrationNamespaces'); const asInternalUserSpy = jest.spyOn(serviceFactoryMock, 'asInternalUser'); asInternalUserSpy.mockReturnValue(fleetInternalServicesMocked); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.test.ts index c1f7ca004e03e..8dd99a3fb4462 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.test.ts @@ -35,6 +35,7 @@ describe('EndpointServiceFactory', () => { 'endpointPolicyKuery', 'ensureInCurrentSpace', 'getPolicyNamespace', + 'getIntegrationNamespaces', ]); }); @@ -119,7 +120,7 @@ describe('EndpointServiceFactory', () => { }); }); - describe('#getPolicyNamespace()', () => { + describe('Policy namespace methods', () => { let integrationPolicy: PackagePolicy; let agentPolicy1: AgentPolicy; let agentPolicy2: AgentPolicy; @@ -143,39 +144,87 @@ describe('EndpointServiceFactory', () => { ); }); - it('should return namespace from agent policies if integration policy does not have one defined', async () => { - await expect( - fleetServicesMock.getPolicyNamespace({ - integrationPolicies: [integrationPolicy.id], - }) - ).resolves.toEqual({ - integrationPolicy: { - [integrationPolicy.id]: ['foo1', 'foo2'], - }, + describe('#getPolicyNamespace()', () => { + it('should return namespace from agent policies if integration policy does not have one defined', async () => { + await expect( + fleetServicesMock.getPolicyNamespace({ + integrationPolicies: [integrationPolicy.id], + }) + ).resolves.toEqual({ + integrationPolicy: { + [integrationPolicy.id]: ['foo1', 'foo2'], + }, + }); + expect( + fleetServicesFactoryMock.dependencies.fleetDependencies.agentPolicyService.getByIds + ).toHaveBeenCalledWith(expect.anything(), [agentPolicy1.id, agentPolicy2.id]); + }); + + it('should return namespace from integration policy if defined', async () => { + integrationPolicy.namespace = 'bar'; + + await expect( + fleetServicesMock.getPolicyNamespace({ + integrationPolicies: [integrationPolicy.id], + }) + ).resolves.toEqual({ + integrationPolicy: { + [integrationPolicy.id]: ['bar'], + }, + }); + + // The agentPolicy sevice should not have been called because the package policy has + // a namespace id, so no need. + expect( + fleetServicesFactoryMock.dependencies.fleetDependencies.agentPolicyService.getByIds + ).not.toHaveBeenCalled(); }); - expect( - fleetServicesFactoryMock.dependencies.fleetDependencies.agentPolicyService.getByIds - ).toHaveBeenCalledWith(expect.anything(), [agentPolicy1.id, agentPolicy2.id]); }); - it('should return namespace from integration policy if defined', async () => { - integrationPolicy.namespace = 'bar'; + describe('#getIntegrationNamespaces()', () => { + beforeEach(() => { + integrationPolicy.package!.name = 'packageOne'; + + fleetServicesFactoryMock.dependencies.fleetDependencies.packagePolicyService.list.mockResolvedValue( + { + items: [integrationPolicy], + page: 1, + total: 1, + perPage: 20, + } + ); + }); - await expect( - fleetServicesMock.getPolicyNamespace({ - integrationPolicies: [integrationPolicy.id], - }) - ).resolves.toEqual({ - integrationPolicy: { - [integrationPolicy.id]: ['bar'], - }, + it('should call fleet package policy service with expected arguments', async () => { + await expect(fleetServicesMock.getIntegrationNamespaces(['packageOne', 'packageTwo'])) + .resolves; + expect( + fleetServicesFactoryMock.dependencies.fleetDependencies.packagePolicyService.list + ).toHaveBeenCalledWith(expect.anything(), { + perPage: 10_000, + kuery: 'ingest-package-policies.package.name: (packageOne OR packageTwo)', + }); }); - // The agentPolicy sevice should not have been called because the package policy has - // a namespace id, so no need. - expect( - fleetServicesFactoryMock.dependencies.fleetDependencies.agentPolicyService.getByIds - ).not.toHaveBeenCalled(); + it('should return namespaces from integration policy when defined', async () => { + integrationPolicy.namespace = 'ns_one'; + + await expect( + fleetServicesMock.getIntegrationNamespaces(['packageOne', 'packageTwo']) + ).resolves.toEqual({ + packageOne: ['ns_one'], + packageTwo: [], + }); + }); + + it('should return namespaces from agent policies if integration policy does not have one defined', async () => { + await expect( + fleetServicesMock.getIntegrationNamespaces(['packageOne', 'packageTwo']) + ).resolves.toEqual({ + packageOne: ['foo1', 'foo2'], + packageTwo: [], + }); + }); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts index bbda061b3ceff..e43df563f444c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts @@ -20,6 +20,7 @@ import { AgentPolicyNotFoundError, PackagePolicyNotFoundError, } from '@kbn/fleet-plugin/server/errors'; +import { catchAndWrapError } from '../../utils'; import { stringify } from '../../utils/stringify'; import { NotFoundError } from '../../errors'; import type { SavedObjectsClientFactory } from '../saved_objects'; @@ -47,12 +48,18 @@ export interface EndpointFleetServicesInterface { ): Promise; /** - * Retrieves the `namespace` assigned to Endpoint Integration Policies + * Retrieves the `namespace` assigned to Integration Policies * @param options */ getPolicyNamespace( - options: Pick - ): Promise; + options: Pick + ): Promise; + + /** + * Retrieves a list of all `namespace`'s in use by a given integration + * @param integrationNames + */ + getIntegrationNamespaces(integrationNames: string[]): Promise>; } export interface EndpointInternalFleetServicesInterface extends EndpointFleetServicesInterface { @@ -113,7 +120,7 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor soClient = this.savedObjects.createInternalScopedSoClient({ spaceId }); } - return fetchEndpointPolicyNamespace({ + return fetchIntegrationPolicyNamespace({ ...options, soClient, logger: this.logger, @@ -122,6 +129,21 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor }); }; + const getIntegrationNamespaces: EndpointFleetServicesInterface['getIntegrationNamespaces'] = + async (integrationNames) => { + if (!soClient) { + soClient = this.savedObjects.createInternalScopedSoClient({ spaceId }); + } + + return fetchIntegrationNamespaces({ + soClient, + logger: this.logger, + packagePolicyService: packagePolicy, + agentPolicyService: agentPolicy, + integrationNames, + }); + }; + return { agent, agentPolicy, @@ -134,6 +156,7 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor endpointPolicyKuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: "endpoint"`, ensureInCurrentSpace, getPolicyNamespace, + getIntegrationNamespaces, }; } } @@ -197,27 +220,33 @@ const checkInCurrentSpace = async ({ ]); }; -interface FetchEndpointPolicyNamespaceOptions { +interface FetchIntegrationPolicyNamespaceOptions { logger: Logger; soClient: SavedObjectsClientContract; packagePolicyService: PackagePolicyClient; agentPolicyService: AgentPolicyServiceInterface; /** A list of integration policies IDs */ integrationPolicies: string[]; + /** A list of Integration names */ + integrationNames?: string[]; } -export interface FetchEndpointPolicyNamespaceResponse { +export interface FetchIntegrationPolicyNamespaceResponse { + /** + * A map with the policy ids provided to `integrationPolicies` param along with a list of + * namespaces for that policy + */ integrationPolicy: Record; } -const fetchEndpointPolicyNamespace = async ({ +const fetchIntegrationPolicyNamespace = async ({ logger, soClient, packagePolicyService, agentPolicyService, integrationPolicies, -}: FetchEndpointPolicyNamespaceOptions): Promise => { - const response: FetchEndpointPolicyNamespaceResponse = { +}: FetchIntegrationPolicyNamespaceOptions): Promise => { + const response: FetchIntegrationPolicyNamespaceResponse = { integrationPolicy: {}, }; const agentPolicyIdsToRetrieve = new Set(); @@ -229,7 +258,9 @@ const fetchEndpointPolicyNamespace = async ({ () => `Retrieving package policies from fleet for:\n${stringify(integrationPolicies)}` ); const packagePolicies = - (await packagePolicyService.getByIDs(soClient, integrationPolicies)) ?? []; + (await packagePolicyService + .getByIDs(soClient, integrationPolicies) + .catch(catchAndWrapError)) ?? []; logger.trace(() => `Fleet package policies retrieved:\n${stringify(packagePolicies)}`); @@ -251,7 +282,7 @@ const fetchEndpointPolicyNamespace = async ({ logger.debug(() => `Retrieving agent policies from fleet for:\n${stringify(ids)}`); - const agentPolicies = await agentPolicyService.getByIds(soClient, ids); + const agentPolicies = await agentPolicyService.getByIds(soClient, ids).catch(catchAndWrapError); logger.trace(() => `Fleet agent policies retrieved:\n${stringify(agentPolicies)}`); @@ -274,3 +305,95 @@ const fetchEndpointPolicyNamespace = async ({ return response; }; + +interface FetchIntegrationNamespacesOptions { + logger: Logger; + soClient: SavedObjectsClientContract; + packagePolicyService: PackagePolicyClient; + agentPolicyService: AgentPolicyServiceInterface; + /** A list of Integration names */ + integrationNames: string[]; +} + +const fetchIntegrationNamespaces = async ({ + logger, + soClient, + packagePolicyService, + agentPolicyService, + integrationNames = [], +}: FetchIntegrationNamespacesOptions): Promise> => { + const integrationToNamespaceMap = integrationNames.reduce((acc, name) => { + acc[name] = new Set(); + return acc; + }, {} as Record>); + const agentPolicyIdsToRetrieve: Record>> = {}; + + if (integrationNames.length > 0) { + const kuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: (${integrationNames.join( + ' OR ' + )})`; + + logger.debug(() => `Fetch of policies for integrations using Kuery [${kuery}]`); + + const policiesFound = await packagePolicyService + .list(soClient, { perPage: 10_000, kuery }) + .catch(catchAndWrapError); + + logger.trace( + () => + `Fetch of policies for integrations using Kuery [${kuery}] returned:\n${stringify( + policiesFound + )}` + ); + + for (const packagePolicy of policiesFound.items) { + if (packagePolicy.package?.name) { + const integrationName = packagePolicy.package.name; + + if (packagePolicy.namespace) { + integrationToNamespaceMap[integrationName].add(packagePolicy.namespace); + } else { + // Integration policy does not have an explicit namespace, which means it + // inherits it from the associated agent policies. We'll retrieve these next + packagePolicy.policy_ids.forEach((agentPolicyId) => { + if (!agentPolicyIdsToRetrieve[agentPolicyId]) { + agentPolicyIdsToRetrieve[agentPolicyId] = new Set(); + } + + agentPolicyIdsToRetrieve[agentPolicyId].add(integrationToNamespaceMap[integrationName]); + }); + } + } + } + } + + const agentPolicyIds = Object.keys(agentPolicyIdsToRetrieve); + + if (agentPolicyIds.length > 0) { + logger.debug(() => `Retrieving agent policies from fleet for:\n${stringify(agentPolicyIds)}`); + + const agentPolicies = await agentPolicyService + .getByIds(soClient, agentPolicyIds) + .catch(catchAndWrapError); + + logger.trace(() => `Fleet agent policies retrieved:\n${stringify(agentPolicies)}`); + + for (const agentPolicy of agentPolicies) { + for (const nameSpaceSet of agentPolicyIdsToRetrieve[agentPolicy.id]) { + nameSpaceSet.add(agentPolicy.namespace); + } + } + } + + const response = Object.entries(integrationToNamespaceMap).reduce( + (acc, [integrationName, namespaceSet]) => { + acc[integrationName] = Array.from(namespaceSet.values()); + return acc; + }, + {} as Record + ); + + logger.debug(() => `Integration namespaces in use:\n${stringify(response)}`); + + return response; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/utils/wrap_errors.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/utils/wrap_errors.ts index 071a735a3091a..f4f22916d4d2f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/utils/wrap_errors.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/utils/wrap_errors.ts @@ -5,6 +5,12 @@ * 2.0. */ +import { AgentNotFoundError } from '@kbn/fleet-plugin/server'; +import { + AgentPolicyNotFoundError, + PackagePolicyNotFoundError, +} from '@kbn/fleet-plugin/server/errors'; +import { NotFoundError } from '../errors'; import { EndpointError } from '../../../common/endpoint/errors'; /** @@ -12,7 +18,21 @@ import { EndpointError } from '../../../common/endpoint/errors'; * our code the error originated (better stack trace). */ export const wrapErrorIfNeeded = (error: Error): E => { - return (error instanceof EndpointError ? error : new EndpointError(error.message, error)) as E; + if (error instanceof EndpointError) { + return error as E; + } + + // Check for known "Not Found" errors and wrap them with our own `NotFoundError`, which will enable + // the correct HTTP status code to be used if it is thrown during processing of an API route + if ( + error instanceof AgentNotFoundError || + error instanceof AgentPolicyNotFoundError || + error instanceof PackagePolicyNotFoundError + ) { + return new NotFoundError(error.message, error) as E; + } + + return new EndpointError(error.message, error) as E; }; /** diff --git a/x-pack/solutions/security/plugins/security_solution/server/fleet_integration/handlers/create_policy_datastreams.test.ts b/x-pack/solutions/security/plugins/security_solution/server/fleet_integration/handlers/create_policy_datastreams.test.ts index 0efaa5516a6f9..201e678b38d12 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/fleet_integration/handlers/create_policy_datastreams.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/fleet_integration/handlers/create_policy_datastreams.test.ts @@ -7,13 +7,13 @@ import { createMockEndpointAppContextService } from '../../endpoint/mocks'; import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; -import type { FetchEndpointPolicyNamespaceResponse } from '../../endpoint/services/fleet'; +import type { FetchIntegrationPolicyNamespaceResponse } from '../../endpoint/services/fleet'; import { createPolicyDataStreamsIfNeeded } from './create_policy_datastreams'; describe('createPolicyDataStreamsIfNeeded()', () => { let endpointServicesMock: ReturnType; let esClientMock: ElasticsearchClientMock; - let policyNamespacesMock: FetchEndpointPolicyNamespaceResponse; + let policyNamespacesMock: FetchIntegrationPolicyNamespaceResponse; beforeEach(() => { endpointServicesMock = createMockEndpointAppContextService();