From ea7e7622eb0a3a184b9fc9a2c1dd65c61fc0fa6d Mon Sep 17 00:00:00 2001 From: nnamdifrankie <56440728+nnamdifrankie@users.noreply.github.com> Date: Thu, 25 Jun 2020 11:50:16 -0400 Subject: [PATCH] [Endpoint]EMT-451: add ability to filter endpoint metadata based on presence of unenrolled events (#69708) [Endpoint]EMT-451: add ability to filter endpoint metadata based on presence of unenrolled events --- .../common/endpoint/constants.ts | 1 + .../common/endpoint/generate_data.ts | 5 +- .../common/endpoint/types.ts | 19 +- .../server/endpoint/routes/metadata/index.ts | 24 ++- .../endpoint/routes/metadata/metadata.test.ts | 134 ++++++++++---- .../routes/metadata/query_builders.test.ts | 175 +++++++++++++++++- .../routes/metadata/query_builders.ts | 66 +++++-- .../routes/metadata/support/unenroll.test.ts | 147 +++++++++++++++ .../routes/metadata/support/unenroll.ts | 114 ++++++++++++ .../apis/endpoint/data_stream_helper.ts | 5 + .../api_integration/apis/endpoint/metadata.ts | 36 +++- .../unenroll_feature/metadata/data.json.gz | Bin 0 -> 598 bytes .../metadata_mirror/data.json.gz | Bin 0 -> 535 bytes 13 files changed, 670 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts create mode 100644 x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata/data.json.gz create mode 100644 x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata_mirror/data.json.gz diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index e311e358e6146..984cd7d2506a9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -7,5 +7,6 @@ export const eventsIndexPattern = 'logs-endpoint.events.*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; export const metadataIndexPattern = 'metrics-endpoint.metadata-*'; +export const metadataMirrorIndexPattern = 'metrics-endpoint.metadata_mirror-*'; export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index ef9e8376827a0..5af34b6a694e8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -14,6 +14,7 @@ import { HostPolicyResponse, HostPolicyResponseActionStatus, PolicyData, + EndpointStatus, } from './types'; import { factory as policyFactory } from './models/policy_config'; @@ -209,6 +210,7 @@ interface HostInfo { }; host: Host; Endpoint: { + status: EndpointStatus; policy: { applied: { id: string; @@ -305,7 +307,7 @@ export class EndpointDocGenerator { * Creates new random policy id for the host to simulate new policy application */ public updatePolicyId() { - this.commonInfo.Endpoint.policy.applied = this.randomChoice(APPLIED_POLICIES); + this.commonInfo.Endpoint.policy.applied.id = this.randomChoice(APPLIED_POLICIES).id; this.commonInfo.Endpoint.policy.applied.status = this.randomChoice([ HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.failure, @@ -333,6 +335,7 @@ export class EndpointDocGenerator { os: this.randomChoice(OS), }, Endpoint: { + status: EndpointStatus.enrolled, policy: { applied: this.randomChoice(APPLIED_POLICIES), }, diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index f8cfb8f7c3bbc..4f13fd97ce442 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -350,7 +350,23 @@ export interface AlertEvent { } /** - * The status of the host + * The status of the Endpoint Agent as reported by the Agent or the + * Security Solution app using events from Fleet. + */ +export enum EndpointStatus { + /** + * Agent is enrolled with Fleet + */ + enrolled = 'enrolled', + + /** + * Agent is unenrrolled from Fleet + */ + unenrolled = 'unenrolled', +} + +/** + * The status of the host, which is mapped to the Elastic Agent status in Fleet */ export enum HostStatus { /** @@ -386,6 +402,7 @@ export type HostMetadata = Immutable<{ }; }; Endpoint: { + status: EndpointStatus; policy: { applied: { id: string; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 4037f1a7cbc46..7c50a10846f9a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -8,6 +8,7 @@ import { IRouter, Logger, RequestHandlerContext } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; import { metadataIndexPattern } from '../../../../common/endpoint/constants'; import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; import { @@ -18,6 +19,7 @@ import { } from '../../../../common/endpoint/types'; import { EndpointAppContext } from '../../types'; import { AgentStatus } from '../../../../../ingest_manager/common/types/models'; +import { findAllUnenrolledHostIds, findUnenrolledHostByHostId, HostId } from './support/unenroll'; interface HitSource { _source: HostMetadata; @@ -68,10 +70,17 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp }, async (context, req, res) => { try { + const unenrolledHostIds = await findAllUnenrolledHostIds( + context.core.elasticsearch.legacy.client + ); + const queryParams = await kibanaRequestToMetadataListESQuery( req, endpointAppContext, - metadataIndexPattern + metadataIndexPattern, + { + unenrolledHostIds: unenrolledHostIds.map((host: HostId) => host.host.id), + } ); const response = (await context.core.elasticsearch.legacy.client.callAsCurrentUser( 'search', @@ -113,6 +122,12 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp return res.notFound({ body: 'Endpoint Not Found' }); } catch (err) { logger.warn(JSON.stringify(err, null, 2)); + if (err.isBoom) { + return res.customError({ + statusCode: err.output.statusCode, + body: { message: err.message }, + }); + } return res.internalError({ body: err }); } } @@ -123,6 +138,13 @@ export async function getHostData( metadataRequestContext: MetadataRequestContext, id: string ): Promise { + const unenrolledHostId = await findUnenrolledHostByHostId( + metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client, + id + ); + if (unenrolledHostId) { + throw Boom.badRequest('the requested endpoint is unenrolled'); + } const query = getESQueryHostMetadataByID(id, metadataIndexPattern); const response = (await metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client.callAsCurrentUser( 'search', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index c04975fa8b28e..1ca205f669fa3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -35,6 +35,7 @@ import Boom from 'boom'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { HostId } from './support/unenroll'; describe('test endpoint route', () => { let routerMock: jest.Mocked; @@ -50,6 +51,12 @@ describe('test endpoint route', () => { typeof createMockEndpointAppContextServiceStartContract >['agentService']; let endpointAppContextService: EndpointAppContextService; + const noUnenrolledEndpoint = () => + Promise.resolve(({ + hits: { + hits: [], + }, + } as unknown) as SearchResponse); beforeEach(() => { mockClusterClient = elasticsearchServiceMock.createClusterClient() as jest.Mocked< @@ -77,7 +84,9 @@ describe('test endpoint route', () => { it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -88,7 +97,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; @@ -113,9 +122,11 @@ describe('test endpoint route', () => { }); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) - ); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -126,8 +137,8 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); - expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); + expect(mockScopedClient.callAsCurrentUser.mock.calls[1][1]?.body?.query).toEqual({ match_all: {}, }); expect(routeConfig.options).toEqual({ authRequired: true }); @@ -156,9 +167,11 @@ describe('test endpoint route', () => { }); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) - ); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -170,20 +183,26 @@ describe('test endpoint route', () => { ); expect(mockScopedClient.callAsCurrentUser).toBeCalled(); - expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + expect(mockScopedClient.callAsCurrentUser.mock.calls[1][1]?.body?.query).toEqual({ bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.ip': '10.140.73.246', + must: [ + { + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], }, }, - ], + }, }, - }, + ], }, }); expect(routeConfig.options).toEqual({ authRequired: true }); @@ -199,9 +218,10 @@ describe('test endpoint route', () => { it('should return 404 on no results', async () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse()) - ); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(createSearchResponse())); + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') @@ -212,7 +232,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.notFound).toBeCalled(); const message = mockResponse.notFound.mock.calls[0][0]?.body; @@ -224,8 +244,12 @@ describe('test endpoint route', () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: response.hits.hits[0]._id }, }); + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('online'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -236,7 +260,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; @@ -254,7 +278,11 @@ describe('test endpoint route', () => { mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { throw Boom.notFound('Agent not found'); }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -265,7 +293,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; @@ -280,7 +308,11 @@ describe('test endpoint route', () => { }); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('warning'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -291,12 +323,50 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; expect(result.host_status).toEqual(HostStatus.ERROR); }); + + it('should throw error when endpoint is unenrolled', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: 'hostId' }, + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(({ + hits: { + hits: [ + { + _index: 'metrics-endpoint.metadata_mirror-default', + _id: 'S5M1yHIBLSMVtiLw6Wpr', + _score: 0.0, + _source: { + host: { + id: 'hostId', + }, + }, + }, + ], + }, + } as unknown) as SearchResponse) + ); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith('/api/endpoint/metadata') + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + }); }); }); @@ -319,7 +389,7 @@ function createSearchResponse(hostMetadata?: HostMetadata): SearchResponse { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record); }); + + it( + 'test default query params for all endpoints metadata when no params or body is provided ' + + 'with unenrolled host ids excluded', + async () => { + const unenrolledHostId = '1fdca33f-799f-49f4-939c-ea4383c77672'; + const mockRequest = httpServerMock.createKibanaRequest({ + body: {}, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataIndexPattern, + { + unenrolledHostIds: [unenrolledHostId], + } + ); + + expect(query).toEqual({ + body: { + query: { + bool: { + must_not: { + terms: { + 'host.id': ['1fdca33f-799f-49f4-939c-ea4383c77672'], + }, + }, + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + } + ); }); describe('test query builder with kql filter', () => { @@ -76,22 +139,29 @@ describe('query builder', () => { }, metadataIndexPattern ); + expect(query).toEqual({ body: { query: { bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.ip': '10.140.73.246', + must: [ + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, }, }, - ], + }, }, - }, + ], }, }, collapse: { @@ -123,6 +193,93 @@ describe('query builder', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record); }); + + it( + 'test default query params for all endpoints endpoint metadata excluding unerolled endpoint ' + + 'and when body filter is provided', + async () => { + const unenrolledHostId = '1fdca33f-799f-49f4-939c-ea4383c77672'; + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + filter: 'not host.ip:10.140.73.246', + }, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataIndexPattern, + { + unenrolledHostIds: [unenrolledHostId], + } + ); + + expect(query).toEqual({ + body: { + query: { + bool: { + must: [ + { + bool: { + must_not: { + terms: { + 'host.id': [unenrolledHostId], + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + } + ); }); describe('MetadataGetQuery', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index 075e4377f0b2a..b6ec91675f248 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -7,17 +7,22 @@ import { KibanaRequest } from 'kibana/server'; import { esKuery } from '../../../../../../../src/plugins/data/server'; import { EndpointAppContext } from '../../types'; -export const kibanaRequestToMetadataListESQuery = async ( +export interface QueryBuilderOptions { + unenrolledHostIds?: string[]; +} + +export async function kibanaRequestToMetadataListESQuery( // eslint-disable-next-line @typescript-eslint/no-explicit-any request: KibanaRequest, endpointAppContext: EndpointAppContext, - index: string + index: string, + queryBuilderOptions?: QueryBuilderOptions // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise> => { +): Promise> { const pagingProperties = await getPagingProperties(request, endpointAppContext); return { body: { - query: buildQueryBody(request), + query: buildQueryBody(request, queryBuilderOptions?.unenrolledHostIds!), collapse: { field: 'host.id', inner_hits: { @@ -45,7 +50,7 @@ export const kibanaRequestToMetadataListESQuery = async ( size: pagingProperties.pageSize, index, }; -}; +} async function getPagingProperties( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -68,14 +73,53 @@ async function getPagingProperties( }; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function buildQueryBody(request: KibanaRequest): Record { +function buildQueryBody( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: KibanaRequest, + unerolledHostIds: string[] | undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Record { + const filterUnenrolledHosts = unerolledHostIds && unerolledHostIds.length > 0; if (typeof request?.body?.filter === 'string') { - return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(request.body.filter)); + const kqlQuery = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(request.body.filter)); + return { + bool: { + must: filterUnenrolledHosts + ? [ + { + bool: { + must_not: { + terms: { + 'host.id': unerolledHostIds, + }, + }, + }, + }, + { + ...kqlQuery, + }, + ] + : [ + { + ...kqlQuery, + }, + ], + }, + }; } - return { - match_all: {}, - }; + return filterUnenrolledHosts + ? { + bool: { + must_not: { + terms: { + 'host.id': unerolledHostIds, + }, + }, + }, + } + : { + match_all: {}, + }; } export function getESQueryHostMetadataByID(hostID: string, index: string) { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts new file mode 100644 index 0000000000000..2e6bb2c976fef --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IScopedClusterClient } from 'kibana/server'; +import { + findAllUnenrolledHostIds, + fetchAllUnenrolledHostIdsWithScroll, + HostId, + findUnenrolledHostByHostId, +} from './unenroll'; +import { elasticsearchServiceMock } from '../../../../../../../../src/core/server/mocks'; +import { SearchResponse } from 'elasticsearch'; +import { metadataMirrorIndexPattern } from '../../../../../common/endpoint/constants'; +import { EndpointStatus } from '../../../../../common/endpoint/types'; + +const noUnenrolledEndpoint = () => + Promise.resolve(({ + hits: { + hits: [], + }, + } as unknown) as SearchResponse); + +describe('test find all unenrolled HostId', () => { + let mockScopedClient: jest.Mocked; + + it('can find all hits with scroll', async () => { + const firstHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + const secondHostId = '2fdca33f-799f-49f4-939c-ea4383c77672'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(() => Promise.resolve(createSearchResponse(secondHostId, 'scrollId'))) + .mockImplementationOnce(noUnenrolledEndpoint); + + const initialResponse = createSearchResponse(firstHostId, 'initialScrollId'); + const hostIds = await fetchAllUnenrolledHostIdsWithScroll( + initialResponse, + mockScopedClient.callAsCurrentUser + ); + + expect(hostIds).toEqual([{ host: { id: firstHostId } }, { host: { id: secondHostId } }]); + }); + + it('can find all unerolled endpoint host ids', async () => { + const firstEndpointHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + const secondEndpointHostId = '2fdca33f-799f-49f4-939c-ea4383c77672'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(firstEndpointHostId, 'initialScrollId')) + ) + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(secondEndpointHostId, 'scrollId')) + ) + .mockImplementationOnce(noUnenrolledEndpoint); + const hostIds = await findAllUnenrolledHostIds(mockScopedClient); + + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]).toEqual({ + index: metadataMirrorIndexPattern, + scroll: '30s', + body: { + size: 1000, + _source: ['host.id'], + query: { + bool: { + filter: { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + }, + }, + }, + }); + expect(hostIds).toEqual([ + { host: { id: firstEndpointHostId } }, + { host: { id: secondEndpointHostId } }, + ]); + }); +}); + +describe('test find unenrolled endpoint host id by hostId', () => { + let mockScopedClient: jest.Mocked; + + it('can find unenrolled endpoint by the host id when unenrolled', async () => { + const firstEndpointHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createSearchResponse(firstEndpointHostId, 'initialScrollId')) + ); + const endpointHostId = await findUnenrolledHostByHostId(mockScopedClient, firstEndpointHostId); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.index).toEqual( + metadataMirrorIndexPattern + ); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body).toEqual({ + size: 1, + _source: ['host.id'], + query: { + bool: { + filter: [ + { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + { + term: { + 'host.id': firstEndpointHostId, + }, + }, + ], + }, + }, + }); + expect(endpointHostId).toEqual({ host: { id: firstEndpointHostId } }); + }); + + it('find unenrolled endpoint host by the host id return undefined when no unenrolled host', async () => { + const firstHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(noUnenrolledEndpoint); + const hostId = await findUnenrolledHostByHostId(mockScopedClient, firstHostId); + expect(hostId).toBeFalsy(); + }); +}); + +function createSearchResponse(hostId: string, scrollId: string): SearchResponse { + return ({ + hits: { + hits: [ + { + _index: metadataMirrorIndexPattern, + _id: 'S5M1yHIBLSMVtiLw6Wpr', + _score: 0.0, + _source: { + host: { + id: hostId, + }, + }, + }, + ], + }, + _scroll_id: scrollId, + } as unknown) as SearchResponse; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts new file mode 100644 index 0000000000000..ef6898fad2807 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { APICaller, IScopedClusterClient } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; +import { metadataMirrorIndexPattern } from '../../../../../common/endpoint/constants'; +import { EndpointStatus } from '../../../../../common/endpoint/types'; + +const KEEPALIVE = '30s'; +const SIZE = 1000; + +export interface HostId { + host: { + id: string; + }; +} + +interface HitSource { + _source: HostId; +} + +export async function findUnenrolledHostByHostId( + client: IScopedClusterClient, + hostId: string +): Promise { + const queryParams = { + index: metadataMirrorIndexPattern, + body: { + size: 1, + _source: ['host.id'], + query: { + bool: { + filter: [ + { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + { + term: { + 'host.id': hostId, + }, + }, + ], + }, + }, + }, + }; + + const response = (await client.callAsCurrentUser('search', queryParams)) as SearchResponse< + HostId + >; + const newHits = response.hits?.hits || []; + + if (newHits.length > 0) { + const hostIds = newHits.map((hitSource: HitSource) => hitSource._source); + return hostIds[0]; + } else { + return undefined; + } +} + +export async function findAllUnenrolledHostIds(client: IScopedClusterClient): Promise { + const queryParams = { + index: metadataMirrorIndexPattern, + scroll: KEEPALIVE, + body: { + size: SIZE, + _source: ['host.id'], + query: { + bool: { + filter: { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + }, + }, + }, + }; + const response = (await client.callAsCurrentUser('search', queryParams)) as SearchResponse< + HostId + >; + + return fetchAllUnenrolledHostIdsWithScroll(response, client.callAsCurrentUser); +} + +export async function fetchAllUnenrolledHostIdsWithScroll( + response: SearchResponse, + client: APICaller, + hits: HostId[] = [] +): Promise { + let newHits = response.hits?.hits || []; + let scrollId = response._scroll_id; + + while (newHits.length > 0) { + const hostIds: HostId[] = newHits.map((hitSource: HitSource) => hitSource._source); + hits.push(...hostIds); + + const innerResponse = await client('scroll', { + body: { + scroll: KEEPALIVE, + scroll_id: scrollId, + }, + }); + + newHits = innerResponse.hits?.hits || []; + scrollId = innerResponse._scroll_id; + } + return hits; +} diff --git a/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts b/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts index b239ab41e41f1..d2e99a80ef8a1 100644 --- a/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts +++ b/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts @@ -10,6 +10,7 @@ import { eventsIndexPattern, alertsIndexPattern, policyIndexPattern, + metadataMirrorIndexPattern, } from '../../../../plugins/security_solution/common/endpoint/constants'; export async function deleteDataStream(getService: (serviceName: 'es') => Client, index: string) { @@ -29,6 +30,10 @@ export async function deleteMetadataStream(getService: (serviceName: 'es') => Cl await deleteDataStream(getService, metadataIndexPattern); } +export async function deleteMetadataMirrorStream(getService: (serviceName: 'es') => Client) { + await deleteDataStream(getService, metadataMirrorIndexPattern); +} + export async function deleteEventsStream(getService: (serviceName: 'es') => Client) { await deleteDataStream(getService, eventsIndexPattern); } diff --git a/x-pack/test/api_integration/apis/endpoint/metadata.ts b/x-pack/test/api_integration/apis/endpoint/metadata.ts index 41531269ddeb9..0d77486e07536 100644 --- a/x-pack/test/api_integration/apis/endpoint/metadata.ts +++ b/x-pack/test/api_integration/apis/endpoint/metadata.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { deleteMetadataStream } from './data_stream_helper'; +import { deleteMetadataMirrorStream, deleteMetadataStream } from './data_stream_helper'; /** * The number of host documents in the es archive. @@ -33,6 +33,40 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('POST /api/endpoint/metadata when metadata mirror index contains unenrolled host', () => { + before(async () => { + await esArchiver.load('endpoint/metadata/unenroll_feature/metadata', { useCreate: true }); + await esArchiver.load('endpoint/metadata/unenroll_feature/metadata_mirror', { + useCreate: true, + }); + }); + + after(async () => { + await deleteMetadataStream(getService); + await deleteMetadataMirrorStream(getService); + }); + + it('metadata api should return only enrolled host', async () => { + const { body } = await supertest + .post('/api/endpoint/metadata') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(1); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return 400 when an unenrolled host is retrieved', async () => { + const { body } = await supertest + .get('/api/endpoint/metadata/1fdca33f-799f-49f4-939c-ea4383c77671') + .send() + .expect(400); + expect(body.message).to.eql('the requested endpoint is unenrolled'); + }); + }); + describe('POST /api/endpoint/metadata when index is not empty', () => { before( async () => await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }) diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata/data.json.gz b/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..d7b130e4051569b283b25ab9337bcc07cd4eeb16 GIT binary patch literal 598 zcmV-c0;&BUiwFpV`RrZ*17u-zVJ>QOZ*BnXR7sE1FciM$S5!IImbWbK35@_vkvK6D zFo=WWWw4r!EG;vm{&$>C(`KnOED{I62P^*Gd-nI1e2?B@;WziC_E!sE71CdJz*eMf zhdjE2J6hFQ4XHjpT(7Uf8}Mdl>D9wpzCO5j9=X!rI;TuGm6bKnxhe~rH_!n>iADgW zjcC&b;6A1<+De{Zamb6tX1Z=fRyq_1oG=o`h@v=L_AalE_YT4wS{A95_an@qqAXLZ z)dW7}gN_Sa*!tx!$C0_n4wZWOl+4uZxHoNmD3-8kTWNn_-=Dts=deMD&Z{C#9ba$a z<%>H#&G;z=91%m3C;~{(05BRvAPI2*bYfJX5sR3?1CFOg_uU!Vwz{fqk$2`05=iDW zbSmn`$}y2Sw=+8=IYUVT6pbZdA!0x%Vt^t9#Yje!hU8qJ{rtV{ENxk7(HvUp6GU9E zLV)=VrmFz0f*5knZs)we6!qkq4(VHY?Y@ECB|J;%MtmKX(WuC_o8C{ua$p1rLeO;!Vva{c)7dbElt4N+5XxX2LmcCCnLZC*%7mOg zl}KPBU^L(i&=9E0fki#-m}%3rOZL6{lZ#!wc&95j5DS7Z8Pn>^wmUkySs6QQMPeSn{{P16=M&>`QQ{;J_BL9L;u@#~#5<-ON k@8}fU-08Vak>_=a{De-CU)3C~{}R#p4wUF`<{SwC0D?arZ2$lO literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata_mirror/data.json.gz b/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata_mirror/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..3b4da7c47d9f22017c0b80365a9e613e90dbbf5e GIT binary patch literal 535 zcmV+y0_go8iwFpu`RrZ*17u-zVJ>QOZ*Bl>Q^{`IFc7`_D-4}80yi!6sR&vi>9qlh zwkQIMOG`vsDamdc_}@!9maWA+5aOFR;^e=F$NTgNJ|8T-|MqCQ6Fo3$rT+#}rF&;(2f9{mW9vTlfKZ|r&y{tqaiFvj zL)il!Q@dtx^7@!ZKJ>QIT`#KEqd4J&ku*mX<>}o>`E^lAla!&>wQI`KE8Z-4 zk@%&THNO{uGh#@QWq<@tfYBs_BE<>l!l*;Bzzi)#Whn)%?r!5#`;mGnjYnYQFyhEY;bY9Qm>0ON)Mr(A*- zjOJ8kS(?q7Y{UHin6?9>m>?8;w_?okY-~ad)0mQ&t^Ti-A-(}rDJ9&%TVlB|4TQAZu><$KM-4jFqz95+jck;{jAIhd*Q4&`L?)h ZL7R=+jWO7a`*CyJ{0IMZTXL`j004KW02=@R literal 0 HcmV?d00001