[Security Solution] add new GET endpoint metadata list api (#118968) (#119099)

Co-authored-by: Joey F. Poon <joey.poon@elastic.co>
This commit is contained in:
Kibana Machine 2021-11-18 16:50:30 -05:00 committed by GitHub
parent 0787862f74
commit 76f073f846
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 2114 additions and 992 deletions

View file

@ -177,7 +177,7 @@ export interface ResolverPaginatedEvents {
}
/**
* Returned by the server via /api/endpoint/metadata
* Returned by the server via POST /api/endpoint/metadata
*/
export interface HostResultList {
/* the hosts restricted by the page size */
@ -1231,3 +1231,22 @@ export interface ListPageRouteState {
/** The label for the button */
backButtonLabel?: string;
}
/**
* REST API standard base response for list types
*/
export interface BaseListResponse {
data: unknown[];
page: number;
pageSize: number;
total: number;
sort?: string;
sortOrder?: 'asc' | 'desc';
}
/**
* Returned by the server via GET /api/endpoint/metadata
*/
export interface MetadataListResponse extends BaseListResponse {
data: HostInfo[];
}

View file

@ -11,7 +11,6 @@ import { TypeOf } from '@kbn/config-schema';
import {
IKibanaResponse,
IScopedClusterClient,
KibanaRequest,
KibanaResponseFactory,
Logger,
RequestHandler,
@ -22,6 +21,7 @@ import {
HostMetadata,
HostResultList,
HostStatus,
MetadataListResponse,
} from '../../../../common/endpoint/types';
import type { SecuritySolutionRequestHandlerContext } from '../../../types';
@ -33,7 +33,11 @@ import {
import { Agent, PackagePolicy } from '../../../../../fleet/common/types/models';
import { AgentNotFoundError } from '../../../../../fleet/server';
import { EndpointAppContext, HostListQueryResult } from '../../types';
import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index';
import {
GetMetadataListRequestSchema,
GetMetadataListRequestSchemaV2,
GetMetadataRequestSchema,
} from './index';
import { findAllUnenrolledAgentIds } from './support/unenroll';
import { getAllEndpointPackagePolicies } from './support/endpoint_package_policies';
import { findAgentIdsByStatus } from './support/agent_status';
@ -125,33 +129,35 @@ export const getMetadataListRequestHandler = function (
context.core.savedObjects.client
);
body = await legacyListMetadataQuery(
context,
request,
endpointAppContext,
logger,
endpointPolicies
);
const pagingProperties = await getPagingProperties(request, endpointAppContext);
body = await legacyListMetadataQuery(context, endpointAppContext, logger, endpointPolicies, {
page: pagingProperties.pageIndex,
pageSize: pagingProperties.pageSize,
kuery: request?.body?.filters?.kql || '',
hostStatuses: request?.body?.filters?.host_status || [],
});
return response.ok({ body });
}
// Unified index is installed and being used - perform search using new approach
try {
const pagingProperties = await getPagingProperties(request, endpointAppContext);
const { data, page, total, pageSize } = await endpointMetadataService.getHostMetadataList(
const { data, total } = await endpointMetadataService.getHostMetadataList(
context.core.elasticsearch.client.asCurrentUser,
{
page: pagingProperties.pageIndex + 1,
page: pagingProperties.pageIndex,
pageSize: pagingProperties.pageSize,
filters: request.body?.filters || {},
hostStatuses: request.body?.filters.host_status || [],
kuery: request.body?.filters.kql || '',
}
);
body = {
hosts: data,
request_page_index: page - 1,
total,
request_page_size: pageSize,
request_page_index: pagingProperties.pageIndex * pagingProperties.pageSize,
request_page_size: pagingProperties.pageSize,
};
} catch (error) {
return errorHandler(logger, response, error);
@ -161,6 +167,83 @@ export const getMetadataListRequestHandler = function (
};
};
export function getMetadataListRequestHandlerV2(
endpointAppContext: EndpointAppContext,
logger: Logger
): RequestHandler<
unknown,
TypeOf<typeof GetMetadataListRequestSchemaV2.query>,
unknown,
SecuritySolutionRequestHandlerContext
> {
return async (context, request, response) => {
const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService();
if (!endpointMetadataService) {
throw new EndpointError('endpoint metadata service not available');
}
let doesUnitedIndexExist = false;
let didUnitedIndexError = false;
let body: MetadataListResponse = {
data: [],
total: 0,
page: 0,
pageSize: 0,
};
try {
doesUnitedIndexExist = await endpointMetadataService.doesUnitedIndexExist(
context.core.elasticsearch.client.asCurrentUser
);
} catch (error) {
// for better UX, try legacy query instead of immediately failing on united index error
didUnitedIndexError = true;
}
// If no unified Index present, then perform a search using the legacy approach
if (!doesUnitedIndexExist || didUnitedIndexError) {
const endpointPolicies = await getAllEndpointPackagePolicies(
endpointAppContext.service.getPackagePolicyService(),
context.core.savedObjects.client
);
const legacyResponse = await legacyListMetadataQuery(
context,
endpointAppContext,
logger,
endpointPolicies,
request.query
);
body = {
data: legacyResponse.hosts,
total: legacyResponse.total,
page: request.query.page,
pageSize: request.query.pageSize,
};
return response.ok({ body });
}
// Unified index is installed and being used - perform search using new approach
try {
const { data, total } = await endpointMetadataService.getHostMetadataList(
context.core.elasticsearch.client.asCurrentUser,
request.query
);
body = {
data,
total,
page: request.query.page,
pageSize: request.query.pageSize,
};
} catch (error) {
return errorHandler(logger, response, error);
}
return response.ok({ body });
};
}
export const getMetadataRequestHandler = function (
endpointAppContext: EndpointAppContext,
logger: Logger
@ -420,11 +503,10 @@ export async function enrichHostMetadata(
async function legacyListMetadataQuery(
context: SecuritySolutionRequestHandlerContext,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
request: KibanaRequest<any, any, any>,
endpointAppContext: EndpointAppContext,
logger: Logger,
endpointPolicies: PackagePolicy[]
endpointPolicies: PackagePolicy[],
queryOptions: TypeOf<typeof GetMetadataListRequestSchemaV2.query>
): Promise<HostResultList> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const agentService = endpointAppContext.service.getAgentService()!;
@ -447,14 +529,16 @@ async function legacyListMetadataQuery(
endpointPolicyIds
);
const statusesToFilter = request?.body?.filters?.host_status ?? [];
const statusAgentIds = await findAgentIdsByStatus(
agentService,
context.core.elasticsearch.client.asCurrentUser,
statusesToFilter
queryOptions.hostStatuses
);
const queryParams = await kibanaRequestToMetadataListESQuery(request, endpointAppContext, {
const queryParams = await kibanaRequestToMetadataListESQuery({
page: queryOptions.page,
pageSize: queryOptions.pageSize,
kuery: queryOptions.kuery,
unenrolledAgentIds,
statusAgentIds,
});

View file

@ -9,7 +9,12 @@ import { schema } from '@kbn/config-schema';
import { HostStatus } from '../../../../common/endpoint/types';
import { EndpointAppContext } from '../../types';
import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers';
import {
getLogger,
getMetadataListRequestHandler,
getMetadataRequestHandler,
getMetadataListRequestHandlerV2,
} from './handlers';
import type { SecuritySolutionPluginRouter } from '../../../types';
import {
HOST_METADATA_GET_ROUTE,
@ -60,27 +65,54 @@ export const GetMetadataListRequestSchema = {
),
};
export const GetMetadataListRequestSchemaV2 = {
query: schema.object({
page: schema.number({ defaultValue: 0 }),
pageSize: schema.number({ defaultValue: 10, min: 1, max: 10000 }),
kuery: schema.maybe(schema.string()),
hostStatuses: schema.arrayOf(
schema.oneOf([
schema.literal(HostStatus.HEALTHY.toString()),
schema.literal(HostStatus.OFFLINE.toString()),
schema.literal(HostStatus.UPDATING.toString()),
schema.literal(HostStatus.UNHEALTHY.toString()),
schema.literal(HostStatus.INACTIVE.toString()),
]),
{ defaultValue: [] }
),
}),
};
export function registerEndpointRoutes(
router: SecuritySolutionPluginRouter,
endpointAppContext: EndpointAppContext
) {
const logger = getLogger(endpointAppContext);
router.post(
router.get(
{
path: `${HOST_METADATA_LIST_ROUTE}`,
validate: GetMetadataListRequestSchema,
path: HOST_METADATA_LIST_ROUTE,
validate: GetMetadataListRequestSchemaV2,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
getMetadataListRequestHandler(endpointAppContext, logger)
getMetadataListRequestHandlerV2(endpointAppContext, logger)
);
router.get(
{
path: `${HOST_METADATA_GET_ROUTE}`,
path: HOST_METADATA_GET_ROUTE,
validate: GetMetadataRequestSchema,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
getMetadataRequestHandler(endpointAppContext, logger)
);
router.post(
{
path: HOST_METADATA_LIST_ROUTE,
validate: GetMetadataListRequestSchema,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
getMetadataListRequestHandler(endpointAppContext, logger)
);
}

View file

@ -5,39 +5,35 @@
* 2.0.
*/
import { httpServerMock, loggingSystemMock } from '../../../../../../../src/core/server/mocks';
import {
kibanaRequestToMetadataListESQuery,
getESQueryHostMetadataByID,
buildUnitedIndexQuery,
} from './query_builders';
import { EndpointAppContextService } from '../../endpoint_app_context_services';
import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__';
import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constants';
import { parseExperimentalConfigValue } from '../../../../common/experimental_features';
import { get } from 'lodash';
import { expectedCompleteUnitedIndexQuery } from './query_builders.fixtures';
describe('query builder', () => {
describe('MetadataListESQuery', () => {
it('queries the correct index', async () => {
const mockRequest = httpServerMock.createKibanaRequest({ body: {} });
const query = await kibanaRequestToMetadataListESQuery(mockRequest, {
logFactory: loggingSystemMock.create(),
service: new EndpointAppContextService(),
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
const query = await kibanaRequestToMetadataListESQuery({
page: 0,
pageSize: 10,
kuery: '',
unenrolledAgentIds: [],
statusAgentIds: [],
});
expect(query.index).toEqual(metadataCurrentIndexPattern);
});
it('sorts using *event.created', async () => {
const mockRequest = httpServerMock.createKibanaRequest({ body: {} });
const query = await kibanaRequestToMetadataListESQuery(mockRequest, {
logFactory: loggingSystemMock.create(),
service: new EndpointAppContextService(),
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
const query = await kibanaRequestToMetadataListESQuery({
page: 0,
pageSize: 10,
kuery: '',
unenrolledAgentIds: [],
statusAgentIds: [],
});
expect(query.body.sort).toContainEqual({
'event.created': {
@ -55,21 +51,13 @@ describe('query builder', () => {
it('excludes unenrolled elastic agents when they exist, by default', async () => {
const unenrolledElasticAgentId = '1fdca33f-799f-49f4-939c-ea4383c77672';
const mockRequest = httpServerMock.createKibanaRequest({
body: {},
const query = await kibanaRequestToMetadataListESQuery({
page: 0,
pageSize: 10,
kuery: '',
unenrolledAgentIds: [unenrolledElasticAgentId],
statusAgentIds: [],
});
const query = await kibanaRequestToMetadataListESQuery(
mockRequest,
{
logFactory: loggingSystemMock.create(),
service: new EndpointAppContextService(),
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
},
{
unenrolledAgentIds: [unenrolledElasticAgentId],
}
);
expect(query.body.query).toEqual({
bool: {
@ -100,16 +88,12 @@ describe('query builder', () => {
describe('test query builder with kql filter', () => {
it('test default query params for all endpoints metadata when body filter is provided', async () => {
const mockRequest = httpServerMock.createKibanaRequest({
body: {
filters: { kql: 'not host.ip:10.140.73.246' },
},
});
const query = await kibanaRequestToMetadataListESQuery(mockRequest, {
logFactory: loggingSystemMock.create(),
service: new EndpointAppContextService(),
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
const query = await kibanaRequestToMetadataListESQuery({
page: 0,
pageSize: 10,
kuery: 'not host.ip:10.140.73.246',
unenrolledAgentIds: [],
statusAgentIds: [],
});
expect(query.body.query.bool.must).toContainEqual({
@ -135,25 +119,13 @@ describe('query builder', () => {
'and when body filter is provided',
async () => {
const unenrolledElasticAgentId = '1fdca33f-799f-49f4-939c-ea4383c77672';
const mockRequest = httpServerMock.createKibanaRequest({
body: {
filters: { kql: 'not host.ip:10.140.73.246' },
},
const query = await kibanaRequestToMetadataListESQuery({
page: 0,
pageSize: 10,
kuery: 'not host.ip:10.140.73.246',
unenrolledAgentIds: [unenrolledElasticAgentId],
statusAgentIds: [],
});
const query = await kibanaRequestToMetadataListESQuery(
mockRequest,
{
logFactory: loggingSystemMock.create(),
service: new EndpointAppContextService(),
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(
createMockConfig().enableExperimental
),
},
{
unenrolledAgentIds: [unenrolledElasticAgentId],
}
);
expect(query.body.query.bool.must).toEqual([
{
@ -222,7 +194,10 @@ describe('query builder', () => {
describe('buildUnitedIndexQuery', () => {
it('correctly builds empty query', async () => {
const query = await buildUnitedIndexQuery({ page: 1, pageSize: 10, filters: {} }, []);
const query = await buildUnitedIndexQuery(
{ page: 1, pageSize: 10, hostStatuses: [], kuery: '' },
[]
);
const expected = {
bool: {
must_not: {
@ -267,10 +242,8 @@ describe('query builder', () => {
{
page: 1,
pageSize: 10,
filters: {
kql: 'united.endpoint.host.os.name : *',
host_status: ['healthy'],
},
kuery: 'united.endpoint.host.os.name : *',
hostStatuses: ['healthy'],
},
['test-endpoint-policy-id']
);

View file

@ -6,14 +6,16 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { TypeOf } from '@kbn/config-schema';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import {
metadataCurrentIndexPattern,
METADATA_UNITED_INDEX,
} from '../../../../common/endpoint/constants';
import { KibanaRequest } from '../../../../../../../src/core/server';
import { EndpointAppContext, GetHostMetadataListQuery } from '../../types';
import { EndpointAppContext } from '../../types';
import { buildStatusesKuery } from './support/agent_status';
import { GetMetadataListRequestSchemaV2 } from '.';
/**
* 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured
@ -25,6 +27,9 @@ const IGNORED_ELASTIC_AGENT_IDS = [
];
export interface QueryBuilderOptions {
page: number;
pageSize: number;
kuery?: string;
unenrolledAgentIds?: string[];
statusAgentIds?: string[];
}
@ -50,26 +55,21 @@ export const MetadataSortMethod: estypes.SearchSortContainer[] = [
];
export async function kibanaRequestToMetadataListESQuery(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
request: KibanaRequest<any, any, any>,
endpointAppContext: EndpointAppContext,
queryBuilderOptions?: QueryBuilderOptions
queryBuilderOptions: QueryBuilderOptions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<Record<string, any>> {
const pagingProperties = await getPagingProperties(request, endpointAppContext);
return {
body: {
query: buildQueryBody(
request,
queryBuilderOptions?.kuery,
IGNORED_ELASTIC_AGENT_IDS.concat(queryBuilderOptions?.unenrolledAgentIds ?? []),
queryBuilderOptions?.statusAgentIds
),
track_total_hits: true,
sort: MetadataSortMethod,
},
from: pagingProperties.pageIndex * pagingProperties.pageSize,
size: pagingProperties.pageSize,
from: queryBuilderOptions.page * queryBuilderOptions.pageSize,
size: queryBuilderOptions.pageSize,
index: metadataCurrentIndexPattern,
};
}
@ -96,8 +96,7 @@ export async function getPagingProperties(
}
function buildQueryBody(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
request: KibanaRequest<any, any, any>,
kuery: string = '',
unerolledAgentIds: string[] | undefined,
statusAgentIds: string[] | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -136,8 +135,8 @@ function buildQueryBody(
},
};
if (request?.body?.filters?.kql) {
const kqlQuery = toElasticsearchQuery(fromKueryExpression(request.body.filters.kql));
if (kuery) {
const kqlQuery = toElasticsearchQuery(fromKueryExpression(kuery));
const q = [];
if (filterUnenrolledAgents || filterStatusAgents) {
q.push(idFilter);
@ -233,12 +232,17 @@ interface BuildUnitedIndexQueryResponse {
size: number;
index: string;
}
export async function buildUnitedIndexQuery(
{ page = 1, pageSize = 10, filters = {} }: GetHostMetadataListQuery,
{
page = 0,
pageSize = 10,
hostStatuses = [],
kuery = '',
}: TypeOf<typeof GetMetadataListRequestSchemaV2.query>,
endpointPolicyIds: string[] = []
): Promise<BuildUnitedIndexQueryResponse> {
const statusesToFilter = filters?.host_status ?? [];
const statusesKuery = buildStatusesKuery(statusesToFilter);
const statusesKuery = buildStatusesKuery(hostStatuses);
const filterIgnoredAgents = {
must_not: { terms: { 'agent.id': IGNORED_ELASTIC_AGENT_IDS } },
@ -272,8 +276,8 @@ export async function buildUnitedIndexQuery(
let query: BuildUnitedIndexQueryResponse['body']['query'] = idFilter;
if (statusesKuery || filters?.kql) {
const kqlQuery = toElasticsearchQuery(fromKueryExpression(filters.kql ?? ''));
if (statusesKuery || kuery) {
const kqlQuery = toElasticsearchQuery(fromKueryExpression(kuery ?? ''));
const q = [];
if (filterIgnoredAgents || filterEndpointPolicyAgents) {
@ -295,7 +299,7 @@ export async function buildUnitedIndexQuery(
track_total_hits: true,
sort: MetadataSortMethod,
},
from: (page - 1) * pageSize,
from: page * pageSize,
size: pageSize,
index: METADATA_UNITED_INDEX,
};

View file

@ -36,7 +36,7 @@ export function buildStatusesKuery(statusesToFilter: string[]): string | undefin
export async function findAgentIdsByStatus(
agentService: AgentService,
esClient: ElasticsearchClient,
statuses: string[],
statuses: string[] = [],
pageSize: number = 1000
): Promise<string[]> {
if (!statuses.length) {

View file

@ -117,7 +117,12 @@ describe('EndpointMetadataService', () => {
it('should throw wrapped error if es error', async () => {
const esMockResponse = elasticsearchServiceMock.createErrorTransportRequestPromise({});
esClient.search.mockResolvedValue(esMockResponse);
const metadataListResponse = metadataService.getHostMetadataList(esClient);
const metadataListResponse = metadataService.getHostMetadataList(esClient, {
page: 0,
pageSize: 10,
kuery: '',
hostStatuses: [],
});
await expect(metadataListResponse).rejects.toThrow(EndpointError);
});
@ -168,18 +173,16 @@ describe('EndpointMetadataService', () => {
}
);
const metadataListResponse = await metadataService.getHostMetadataList(esClient);
const unitedIndexQuery = await buildUnitedIndexQuery(
{ page: 1, pageSize: 10, filters: {} },
packagePolicyIds
const queryOptions = { page: 1, pageSize: 10, kuery: '', hostStatuses: [] };
const metadataListResponse = await metadataService.getHostMetadataList(
esClient,
queryOptions
);
const unitedIndexQuery = await buildUnitedIndexQuery(queryOptions, packagePolicyIds);
expect(esClient.search).toBeCalledWith(unitedIndexQuery);
expect(agentPolicyServiceMock.getByIds).toBeCalledWith(expect.anything(), agentPolicyIds);
expect(metadataListResponse).toEqual({
pageSize: 10,
page: 1,
total: 1,
data: [
{
metadata: endpointMetadataDoc,
@ -202,6 +205,7 @@ describe('EndpointMetadataService', () => {
},
},
],
total: 1,
});
});
});

View file

@ -12,12 +12,14 @@ import {
SavedObjectsServiceStart,
} from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { TransportResult } from '@elastic/elasticsearch';
import { SearchTotalHits, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import {
HostInfo,
HostMetadata,
MaybeImmutable,
MetadataListResponse,
PolicyData,
UnitedAgentMetadata,
} from '../../../../common/endpoint/types';
@ -52,10 +54,10 @@ import {
} from '../../utils';
import { EndpointError } from '../../errors';
import { createInternalReadonlySoClient } from '../../utils/create_internal_readonly_so_client';
import { GetHostMetadataListQuery } from '../../types';
import { METADATA_UNITED_INDEX } from '../../../../common/endpoint/constants';
import { getAllEndpointPackagePolicies } from '../../routes/metadata/support/endpoint_package_policies';
import { getAgentStatus } from '../../../../../fleet/common/services/agent_status';
import { GetMetadataListRequestSchemaV2 } from '../../routes/metadata';
type AgentPolicyWithPackagePolicies = Omit<AgentPolicy, 'package_policies'> & {
package_policies: PackagePolicy[];
@ -401,8 +403,8 @@ export class EndpointMetadataService {
*/
async getHostMetadataList(
esClient: ElasticsearchClient,
queryOptions: GetHostMetadataListQuery = {}
): Promise<{ data: HostInfo[]; total: number; page: number; pageSize: number }> {
queryOptions: TypeOf<typeof GetMetadataListRequestSchemaV2.query>
): Promise<Pick<MetadataListResponse, 'data' | 'total'>> {
const endpointPolicies = await getAllEndpointPackagePolicies(
this.packagePolicyService,
this.DANGEROUS_INTERNAL_SO_CLIENT
@ -474,8 +476,6 @@ export class EndpointMetadataService {
return {
data: hosts,
pageSize: unitedIndexQuery.size,
page: unitedIndexQuery.from + 1,
total: (docsCount as unknown as SearchTotalHits).value,
};
}

View file

@ -7,12 +7,10 @@
import { LoggerFactory } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { ConfigType } from '../config';
import { EndpointAppContextService } from './endpoint_app_context_services';
import { HostMetadata } from '../../common/endpoint/types';
import { ExperimentalFeatures } from '../../common/experimental_features';
import { endpointFilters } from './routes/metadata';
/**
* The context for Endpoint apps.
@ -37,11 +35,3 @@ export interface HostQueryResult {
resultLength: number;
result: HostMetadata | undefined;
}
// FIXME: when new Host Metadata list API is created (and existing one deprecated - 8.0?), move this type out of here and created it from Schema
export interface GetHostMetadataListQuery {
/* page number 1 based - not an index */
page?: number;
pageSize?: number;
filters?: Partial<TypeOf<typeof endpointFilters>>;
}

File diff suppressed because it is too large Load diff