[SECURITY_SOLUTION][ENDPOINT] Trusted Apps List API (#75476)

* Trusted Apps initial setup for route registration

* Added types for TrustedApp entries

* trusted apps list API returns results

* use methods and const from latest PR merge to lists

* a quick generator for trusted apps entries

* support cli options for trusted app data loader

* Add mocked `createTrustedAppsList()` method to `ExceptionListClientMock`

* tests fro trusted apps route handlers

* tests for trusted apps schema

* Correct name of mock method

* Fix service to ensure return value of `getExceptionList` service throws if service not available

* Fix types

* Refactor TrustedApp type + code review feedback
This commit is contained in:
Paul Tavares 2020-08-26 16:02:37 -04:00 committed by GitHub
parent 532f2d70e8
commit 9873df8ee0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 503 additions and 3 deletions

View file

@ -19,6 +19,11 @@ import {
_VERSION,
} from '../../constants.mock';
import { ENDPOINT_LIST_ID } from '../..';
import {
ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION,
ENDPOINT_TRUSTED_APPS_LIST_ID,
ENDPOINT_TRUSTED_APPS_LIST_NAME,
} from '../../constants';
import { ExceptionListSchema } from './exception_list_schema';
@ -42,6 +47,15 @@ export const getExceptionListSchemaMock = (): ExceptionListSchema => ({
version: VERSION,
});
export const getTrustedAppsListSchemaMock = (): ExceptionListSchema => {
return {
...getExceptionListSchemaMock(),
description: ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION,
list_id: ENDPOINT_TRUSTED_APPS_LIST_ID,
name: ENDPOINT_TRUSTED_APPS_LIST_NAME,
};
};
/**
* This is useful for end to end tests where we remove the auto generated parts for comparisons
* such as created_at, updated_at, and id.

View file

@ -9,7 +9,10 @@ import { savedObjectsClientMock } from 'src/core/server/mocks';
import { getFoundExceptionListSchemaMock } from '../../../common/schemas/response/found_exception_list_schema.mock';
import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock';
import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock';
import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock';
import {
getExceptionListSchemaMock,
getTrustedAppsListSchemaMock,
} from '../../../common/schemas/response/exception_list_schema.mock';
import { ExceptionListClient } from './exception_list_client';
@ -24,6 +27,7 @@ export class ExceptionListClientMock extends ExceptionListClient {
public deleteExceptionListItem = jest.fn().mockResolvedValue(getExceptionListItemSchemaMock());
public findExceptionListItem = jest.fn().mockResolvedValue(getFoundExceptionListItemSchemaMock());
public findExceptionList = jest.fn().mockResolvedValue(getFoundExceptionListSchemaMock());
public createTrustedAppsList = jest.fn().mockResolvedValue(getTrustedAppsListSchemaMock());
}
export const getExceptionListClientMock = (): ExceptionListClient => {

View file

@ -11,3 +11,5 @@ export const policyIndexPattern = 'metrics-endpoint.policy-*';
export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*';
export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency';
export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100;
export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps';

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { GetTrustedAppsRequestSchema } from './trusted_apps';
describe('When invoking Trusted Apps Schema', () => {
describe('for GET List', () => {
const getListQueryParams = (page: unknown = 1, perPage: unknown = 20) => ({
page,
per_page: perPage,
});
const query = GetTrustedAppsRequestSchema.query;
describe('query param validation', () => {
it('should return query params if valid', () => {
expect(query.validate(getListQueryParams())).toEqual({
page: 1,
per_page: 20,
});
});
it('should use default values', () => {
expect(query.validate(getListQueryParams(undefined, undefined))).toEqual({
page: 1,
per_page: 20,
});
expect(query.validate(getListQueryParams(undefined, 100))).toEqual({
page: 1,
per_page: 100,
});
expect(query.validate(getListQueryParams(10, undefined))).toEqual({
page: 10,
per_page: 20,
});
});
it('should throw if `page` param is not a number', () => {
expect(() => {
query.validate(getListQueryParams('one'));
}).toThrowError();
});
it('should throw if `page` param is less than 1', () => {
expect(() => {
query.validate(getListQueryParams(0));
}).toThrowError();
expect(() => {
query.validate(getListQueryParams(-1));
}).toThrowError();
});
it('should throw if `per_page` param is not a number', () => {
expect(() => {
query.validate(getListQueryParams(1, 'twenty'));
}).toThrowError();
});
it('should throw if `per_page` param is less than 1', () => {
expect(() => {
query.validate(getListQueryParams(1, 0));
}).toThrowError();
expect(() => {
query.validate(getListQueryParams(1, -1));
}).toThrowError();
});
});
});
});

View file

@ -0,0 +1,14 @@
/*
* 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 { schema } from '@kbn/config-schema';
export const GetTrustedAppsRequestSchema = {
query: schema.object({
page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })),
per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })),
}),
};

View file

@ -5,8 +5,10 @@
*/
import { ApplicationStart } from 'kibana/public';
import { NewPackagePolicy, PackagePolicy } from '../../../ingest_manager/common';
import { ManifestSchema } from './schema/manifest';
import { NewPackagePolicy, PackagePolicy } from '../../../../ingest_manager/common';
import { ManifestSchema } from '../schema/manifest';
export * from './trusted_apps';
/**
* Supported React-Router state for the Policy Details page

View file

@ -0,0 +1,52 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import { GetTrustedAppsRequestSchema } from '../schema/trusted_apps';
/** API request params for retrieving a list of Trusted Apps */
export type GetTrustedAppsListRequest = TypeOf<typeof GetTrustedAppsRequestSchema.query>;
export interface GetTrustedListAppsResponse {
per_page: number;
page: number;
total: number;
data: TrustedApp[];
}
interface MacosLinuxConditionEntry {
field: 'hash' | 'path';
type: 'match';
operator: 'included';
value: string;
}
type WindowsConditionEntry =
| MacosLinuxConditionEntry
| (Omit<MacosLinuxConditionEntry, 'field'> & {
field: 'signer';
});
/** Type for a new Trusted App Entry */
export type NewTrustedApp = {
name: string;
description?: string;
} & (
| {
os: 'linux' | 'macos';
entries: MacosLinuxConditionEntry[];
}
| {
os: 'windows';
entries: WindowsConditionEntry[];
}
);
/** A trusted app entry */
export type TrustedApp = NewTrustedApp & {
id: string;
created_at: string;
created_by: string;
};

View file

@ -0,0 +1,9 @@
#!/usr/bin/env node
/*
* 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.
*/
require('../../../../../src/setup_node_env');
require('./trusted_apps').cli();

View file

@ -0,0 +1,91 @@
/*
* 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 { v4 as generateUUID } from 'uuid';
// @ts-ignore
import minimist from 'minimist';
import { KbnClient, ToolingLog } from '@kbn/dev-utils';
import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../lists/common/constants';
import { TRUSTED_APPS_LIST_API } from '../../../common/endpoint/constants';
import { ExceptionListItemSchema } from '../../../../lists/common/schemas/response';
interface RunOptions {
count?: number;
}
const logger = new ToolingLog({ level: 'info', writeTo: process.stdout });
const separator = '----------------------------------------';
export const cli = async () => {
const options: RunOptions = minimist(process.argv.slice(2), {
default: {
count: 10,
},
});
logger.write(`${separator}
Loading ${options.count} Trusted App Entries`);
await run(options);
logger.write(`Done!
${separator}`);
};
export const run: (options?: RunOptions) => Promise<ExceptionListItemSchema[]> = async ({
count = 10,
}: RunOptions = {}) => {
const kbnClient = new KbnClient(logger, { url: 'http://elastic:changeme@localhost:5601' });
// touch the Trusted Apps List so it can be created
await kbnClient.request({
method: 'GET',
path: TRUSTED_APPS_LIST_API,
});
return Promise.all(
Array.from({ length: count }, () => {
return kbnClient
.request({
method: 'POST',
path: '/api/exception_lists/items',
body: generateTrustedAppEntry(),
})
.then<ExceptionListItemSchema>((item) => (item as unknown) as ExceptionListItemSchema);
})
);
};
interface GenerateTrustedAppEntryOptions {
os?: 'windows' | 'macos' | 'linux';
name?: string;
}
const generateTrustedAppEntry: (options?: GenerateTrustedAppEntryOptions) => object = ({
os = 'windows',
name = `Sample Endpoint Trusted App Entry ${Date.now()}`,
} = {}) => {
return {
list_id: ENDPOINT_TRUSTED_APPS_LIST_ID,
item_id: `generator_endpoint_trusted_apps_${generateUUID()}`,
_tags: ['endpoint', `os:${os}`],
tags: ['user added string for a tag', 'malware'],
type: 'simple',
description: 'This is a sample agnostic endpoint trusted app entry',
name,
namespace_type: 'agnostic',
entries: [
{
field: 'actingProcess.file.signer',
operator: 'included',
type: 'match',
value: 'Elastic, N.V.',
},
{
field: 'actingProcess.file.path',
operator: 'included',
type: 'match',
value: '/one/two/three',
},
],
};
};

View file

@ -12,10 +12,12 @@ import {
import { AgentService, IngestManagerStartContract } from '../../../ingest_manager/server';
import { getPackagePolicyCreateCallback } from './ingest_integration';
import { ManifestManager } from './services/artifacts';
import { ExceptionListClient } from '../../../lists/server';
export type EndpointAppContextServiceStartContract = Partial<
Pick<IngestManagerStartContract, 'agentService'>
> & {
exceptionsListService: ExceptionListClient;
logger: Logger;
manifestManager?: ManifestManager;
registerIngestCallback?: IngestManagerStartContract['registerExternalCallback'];
@ -30,9 +32,11 @@ export class EndpointAppContextService {
private agentService: AgentService | undefined;
private manifestManager: ManifestManager | undefined;
private savedObjectsStart: SavedObjectsServiceStart | undefined;
private exceptionsListService: ExceptionListClient | undefined;
public start(dependencies: EndpointAppContextServiceStartContract) {
this.agentService = dependencies.agentService;
this.exceptionsListService = dependencies.exceptionsListService;
this.manifestManager = dependencies.manifestManager;
this.savedObjectsStart = dependencies.savedObjectsStart;
@ -50,6 +54,13 @@ export class EndpointAppContextService {
return this.agentService;
}
public getExceptionsList() {
if (!this.exceptionsListService) {
throw new Error('exceptionsListService not set');
}
return this.exceptionsListService;
}
public getManifestManager(): ManifestManager | undefined {
return this.manifestManager;
}

View file

@ -21,6 +21,7 @@ import {
import { ManifestManager } from './services/artifacts/manifest_manager/manifest_manager';
import { getManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock';
import { EndpointAppContext } from './types';
import { listMock } from '../../../lists/server/mocks';
/**
* Creates a mocked EndpointAppContext.
@ -58,6 +59,7 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked<
> => {
return {
agentService: createMockAgentService(),
exceptionsListService: listMock.getExceptionListClient(),
logger: loggingSystemMock.create().get('mock_endpoint_app_context'),
savedObjectsStart: savedObjectsServiceMock.createStartContract(),
manifestManager: getManifestManagerMock(),

View file

@ -0,0 +1,49 @@
/*
* 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 { RequestHandler } from 'kibana/server';
import {
GetTrustedAppsListRequest,
GetTrustedListAppsResponse,
} from '../../../../common/endpoint/types';
import { EndpointAppContext } from '../../types';
import { exceptionItemToTrustedAppItem } from './utils';
import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants';
export const getTrustedAppsListRouteHandler = (
endpointAppContext: EndpointAppContext
): RequestHandler<undefined, GetTrustedAppsListRequest> => {
const logger = endpointAppContext.logFactory.get('trusted_apps');
return async (context, req, res) => {
const exceptionsListService = endpointAppContext.service.getExceptionsList();
const { page, per_page: perPage } = req.query;
try {
// Ensure list is created if it does not exist
await exceptionsListService?.createTrustedAppsList();
const results = await exceptionsListService.findExceptionListItem({
listId: ENDPOINT_TRUSTED_APPS_LIST_ID,
page,
perPage,
filter: undefined,
namespaceType: 'agnostic',
sortField: 'name',
sortOrder: 'asc',
});
const body: GetTrustedListAppsResponse = {
data: results?.data.map(exceptionItemToTrustedAppItem) ?? [],
total: results?.total ?? 0,
page: results?.page ?? 1,
per_page: results?.per_page ?? perPage!,
};
return res.ok({ body });
} catch (error) {
logger.error(error);
return res.internalError({ body: error });
}
};
};

View file

@ -0,0 +1,26 @@
/*
* 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 { IRouter } from 'kibana/server';
import { GetTrustedAppsRequestSchema } from '../../../../common/endpoint/schema/trusted_apps';
import { TRUSTED_APPS_LIST_API } from '../../../../common/endpoint/constants';
import { getTrustedAppsListRouteHandler } from './handlers';
import { EndpointAppContext } from '../../types';
export const registerTrustedAppsRoutes = (
router: IRouter,
endpointAppContext: EndpointAppContext
) => {
// GET list
router.get(
{
path: TRUSTED_APPS_LIST_API,
validate: GetTrustedAppsRequestSchema,
options: { authRequired: true },
},
getTrustedAppsListRouteHandler(endpointAppContext)
);
};

View file

@ -0,0 +1,108 @@
/*
* 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 { EndpointAppContextService } from '../../endpoint_app_context_services';
import {
createMockEndpointAppContext,
createMockEndpointAppContextServiceStartContract,
} from '../../mocks';
import { IRouter, RequestHandler } from 'kibana/server';
import { httpServerMock, httpServiceMock } from '../../../../../../../src/core/server/mocks';
import { registerTrustedAppsRoutes } from './index';
import { TRUSTED_APPS_LIST_API } from '../../../../common/endpoint/constants';
import { GetTrustedAppsListRequest } from '../../../../common/endpoint/types';
import { xpackMocks } from '../../../../../../mocks';
import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants';
import { EndpointAppContext } from '../../types';
import { ExceptionListClient } from '../../../../../lists/server';
describe('when invoking endpoint trusted apps route handlers', () => {
let routerMock: jest.Mocked<IRouter>;
let endpointAppContextService: EndpointAppContextService;
let context: ReturnType<typeof xpackMocks.createRequestHandlerContext>;
let response: ReturnType<typeof httpServerMock.createResponseFactory>;
let exceptionsListClient: jest.Mocked<ExceptionListClient>;
let endpointAppContext: EndpointAppContext;
beforeEach(() => {
routerMock = httpServiceMock.createRouter();
endpointAppContextService = new EndpointAppContextService();
const startContract = createMockEndpointAppContextServiceStartContract();
exceptionsListClient = startContract.exceptionsListService as jest.Mocked<ExceptionListClient>;
endpointAppContextService.start(startContract);
endpointAppContext = {
...createMockEndpointAppContext(),
service: endpointAppContextService,
};
registerTrustedAppsRoutes(routerMock, endpointAppContext);
// For use in individual API calls
context = xpackMocks.createRequestHandlerContext();
response = httpServerMock.createResponseFactory();
});
describe('when fetching list of trusted apps', () => {
let routeHandler: RequestHandler<undefined, GetTrustedAppsListRequest>;
const createListRequest = (page: number = 1, perPage: number = 20) => {
return httpServerMock.createKibanaRequest<undefined, GetTrustedAppsListRequest>({
path: TRUSTED_APPS_LIST_API,
method: 'get',
query: {
page,
per_page: perPage,
},
});
};
beforeEach(() => {
// Get the registered List handler from the IRouter instance
[, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) =>
path.startsWith(TRUSTED_APPS_LIST_API)
)!;
});
it('should create the Trusted Apps List first', async () => {
const request = createListRequest();
await routeHandler(context, request, response);
expect(exceptionsListClient.createTrustedAppsList).toHaveBeenCalled();
expect(response.ok).toHaveBeenCalled();
});
it('should pass pagination query params to exception list service', async () => {
const request = createListRequest(10, 100);
const emptyResponse = {
data: [],
page: 10,
per_page: 100,
total: 0,
};
exceptionsListClient.findExceptionListItem.mockResolvedValue(emptyResponse);
await routeHandler(context, request, response);
expect(response.ok).toHaveBeenCalledWith({ body: emptyResponse });
expect(exceptionsListClient.findExceptionListItem).toHaveBeenCalledWith({
listId: ENDPOINT_TRUSTED_APPS_LIST_ID,
page: 10,
perPage: 100,
filter: undefined,
namespaceType: 'agnostic',
sortField: 'name',
sortOrder: 'asc',
});
});
it('should log unexpected error if one occurs', async () => {
exceptionsListClient.findExceptionListItem.mockImplementation(() => {
throw new Error('expected error');
});
const request = createListRequest(10, 100);
await routeHandler(context, request, response);
expect(response.internalError).toHaveBeenCalled();
expect(endpointAppContext.logFactory.get('trusted_apps').error).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { ExceptionListItemSchema } from '../../../../../lists/common/shared_exports';
import { TrustedApp } from '../../../../common/endpoint/types';
/**
* Map an ExcptionListItem to a TrustedApp item
* @param exceptionListItem
*/
export const exceptionItemToTrustedAppItem = (
exceptionListItem: ExceptionListItemSchema
): TrustedApp => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { entries, description, created_by, created_at, name, _tags, id } = exceptionListItem;
const os = osFromTagsList(_tags);
return {
entries,
description,
created_at,
created_by,
name,
os,
id,
} as TrustedApp;
};
/**
* Retrieves the OS entry from a list of tags (property returned with ExcptionListItem).
* For Trusted Apps each entry must have at MOST 1 OS.
* */
const osFromTagsList = (tags: string[]): TrustedApp['os'] | 'unknown' => {
for (const tag of tags) {
if (tag.startsWith('os:')) {
return tag.substr(3) as TrustedApp['os'];
}
}
return 'unknown';
};

View file

@ -59,6 +59,7 @@ import { EndpointAppContext } from './endpoint/types';
import { registerDownloadExceptionListRoute } from './endpoint/routes/artifacts';
import { initUsageCollectors } from './usage';
import { AppRequestContext } from './types';
import { registerTrustedAppsRoutes } from './endpoint/routes/trusted_apps';
import { securitySolutionSearchStrategyProvider } from './search_strategy/security_solution';
export interface SetupPlugins {
@ -166,6 +167,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
registerLimitedConcurrencyRoutes(core);
registerResolverRoutes(router, endpointContext);
registerPolicyRoutes(router, endpointContext);
registerTrustedAppsRoutes(router, endpointContext);
registerDownloadExceptionListRoute(router, endpointContext, this.exceptionsCache);
plugins.features.registerFeature({
@ -305,6 +307,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.endpointAppContextService.start({
agentService: plugins.ingestManager?.agentService,
exceptionsListService: this.lists!.getExceptionListClient(savedObjectsClient, 'kibana'),
logger: this.logger,
manifestManager,
registerIngestCallback,