diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.gen.ts new file mode 100644 index 0000000000000..8124d05bff23e --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.gen.ts @@ -0,0 +1,169 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Risk Engine Common Schema + * version: 1.0.0 + */ + +export type AfterKeys = z.infer; +export const AfterKeys = z.object({ + host: z.object({}).catchall(z.string()).optional(), + user: z.object({}).catchall(z.string()).optional(), +}); + +/** + * The identifier of the Kibana data view to be used when generating risk scores. + */ +export type DataViewId = z.infer; +export const DataViewId = z.string(); + +/** + * An elasticsearch DSL filter object. Used to filter the risk inputs involved, which implicitly filters the risk scores themselves. + */ +export type Filter = z.infer; +export const Filter = z.unknown(); + +/** + * Specifies how many scores will be involved in a given calculation. Note that this value is per `identifier_type`, i.e. a value of 10 will calculate 10 host scores and 10 user scores, if available. To avoid missed data, keep this value consistent while paginating through scores. + */ +export type PageSize = z.infer; +export const PageSize = z.number().default(1000); + +export type KibanaDate = z.infer; +export const KibanaDate = z.union([z.string(), z.string().datetime(), z.string()]); + +/** + * Defines the time period on which risk inputs will be filtered. + */ +export type DateRange = z.infer; +export const DateRange = z.object({ + start: KibanaDate, + end: KibanaDate, +}); + +export type IdentifierType = z.infer; +export const IdentifierType = z.enum(['host', 'user']); +export type IdentifierTypeEnum = typeof IdentifierType.enum; +export const IdentifierTypeEnum = IdentifierType.enum; + +/** + * A generic representation of a document contributing to a Risk Score. + */ +export type RiskScoreInput = z.infer; +export const RiskScoreInput = z.object({ + /** + * The unique identifier (`_id`) of the original source document + */ + id: z.string().optional(), + /** + * The unique index (`_index`) of the original source document + */ + index: z.string().optional(), + /** + * The risk category of the risk input document. + */ + category: z.string().optional(), + /** + * A human-readable description of the risk input document. + */ + description: z.string().optional(), + /** + * The weighted risk score of the risk input document. + */ + risk_score: z.number().min(0).max(100).optional(), + /** + * The @timestamp of the risk input document. + */ + timestamp: z.string().optional(), +}); + +export type RiskScore = z.infer; +export const RiskScore = z.object({ + /** + * The time at which the risk score was calculated. + */ + '@timestamp': z.string().datetime(), + /** + * The identifier field defining this risk score. Coupled with `id_value`, uniquely identifies the entity being scored. + */ + id_field: z.string(), + /** + * The identifier value defining this risk score. Coupled with `id_field`, uniquely identifies the entity being scored. + */ + id_value: z.string(), + /** + * Lexical description of the entity's risk. + */ + calculated_level: z.string(), + /** + * The raw numeric value of the given entity's risk score. + */ + calculated_score: z.number(), + /** + * The normalized numeric value of the given entity's risk score. Useful for comparing with other entities. + */ + calculated_score_norm: z.number().min(0).max(100), + /** + * The contribution of Category 1 to the overall risk score (`calculated_score_norm`). Category 1 contains Detection Engine Alerts. + */ + category_1_score: z.number(), + /** + * The number of risk input documents that contributed to the Category 1 score (`category_1_score`). + */ + category_1_count: z.number(), + /** + * The contribution of Category 2 to the overall risk score (`calculated_score_norm`). Category 2 contains context from external sources. + */ + category_2_score: z.number().optional(), + /** + * The number of risk input documents that contributed to the Category 2 score (`category_2_score`). + */ + category_2_count: z.number().optional(), + /** + * The designated criticality level of the entity. Possible values are `low_impact`, `medium_impact`, `high_impact`, and `extreme_impact`. + */ + criticality_level: z.string().optional(), + /** + * The numeric modifier corresponding to the criticality level of the entity, which is used as an input to the risk score calculation. + */ + criticality_modifier: z.number().optional(), + /** + * A list of the highest-risk documents contributing to this risk score. Useful for investigative purposes. + */ + inputs: z.array(RiskScoreInput), +}); + +/** + * Configuration used to tune risk scoring. Weights can be used to change the score contribution of risk inputs for hosts and users at both a global level and also for Risk Input categories (e.g. 'category_1'). + */ +export type RiskScoreWeight = z.infer; +export const RiskScoreWeight = z.object({ + type: z.string(), + value: z.string().optional(), + host: z.number().min(0).max(1).optional(), + user: z.number().min(0).max(1).optional(), +}); + +/** + * A list of weights to be applied to the scoring calculation. + */ +export type RiskScoreWeights = z.infer; +export const RiskScoreWeights = z.array(RiskScoreWeight); + +export type RiskEngineInitStep = z.infer; +export const RiskEngineInitStep = z.object({ + type: z.string(), + success: z.boolean(), + error: z.string().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.yml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.schema.yaml similarity index 95% rename from x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.yml rename to x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.schema.yaml index 47103564090fd..6df8760d89674 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.yml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/common.schema.yaml @@ -33,13 +33,25 @@ components: Filter: description: An elasticsearch DSL filter object. Used to filter the risk inputs involved, which implicitly filters the risk scores themselves. - $ref: 'https://cloud.elastic.co/api/v1/api-docs/spec.json#/definitions/QueryContainer' + # TODO Fix the following line reference. Issue: https://github.com/elastic/kibana/issues/181948 + # $ref: 'https://cloud.elastic.co/api/v1/api-docs/spec.json#/definitions/QueryContainer' + PageSize: description: Specifies how many scores will be involved in a given calculation. Note that this value is per `identifier_type`, i.e. a value of 10 will calculate 10 host scores and 10 user scores, if available. To avoid missed data, keep this value consistent while paginating through scores. default: 1000 type: number + KibanaDate: + oneOf: + - type: string + format: date + - type: string + format: date-time + - type: string + format: datemath + example: '2017-07-21T17:32:28Z' + DateRange: description: Defines the time period on which risk inputs will be filtered. type: object @@ -52,20 +64,44 @@ components: end: $ref: '#/components/schemas/KibanaDate' - KibanaDate: - type: string - oneOf: - - format: date - - format: date-time - - format: datemath - example: '2017-07-21T17:32:28Z' - IdentifierType: type: string enum: - host - user + RiskScoreInput: + description: A generic representation of a document contributing to a Risk Score. + type: object + properties: + id: + type: string + example: 91a93376a507e86cfbf282166275b89f9dbdb1f0be6c8103c6ff2909ca8e1a1c + description: The unique identifier (`_id`) of the original source document + index: + type: string + example: .internal.alerts-security.alerts-default-000001 + description: The unique index (`_index`) of the original source document + category: + type: string + example: category_1 + description: The risk category of the risk input document. + description: + type: string + example: 'Generated from Detection Engine Rule: Malware Prevention Alert' + description: A human-readable description of the risk input document. + risk_score: + type: number + format: double + minimum: 0 + maximum: 100 + description: The weighted risk score of the risk input document. + timestamp: + type: string + example: '2017-07-21T17:32:28Z' + description: The @timestamp of the risk input document. + + RiskScore: type: object required: @@ -136,37 +172,6 @@ components: items: $ref: '#/components/schemas/RiskScoreInput' - RiskScoreInput: - description: A generic representation of a document contributing to a Risk Score. - type: object - properties: - id: - type: string - example: 91a93376a507e86cfbf282166275b89f9dbdb1f0be6c8103c6ff2909ca8e1a1c - description: The unique identifier (`_id`) of the original source document - index: - type: string - example: .internal.alerts-security.alerts-default-000001 - description: The unique index (`_index`) of the original source document - category: - type: string - example: category_1 - description: The risk category of the risk input document. - description: - type: string - example: 'Generated from Detection Engine Rule: Malware Prevention Alert' - description: A human-readable description of the risk input document. - risk_score: - type: number - format: double - minimum: 0 - maximum: 100 - description: The weighted risk score of the risk input document. - timestamp: - type: string - example: '2017-07-21T17:32:28Z' - description: The @timestamp of the risk input document. - RiskScoreWeight: description: "Configuration used to tune risk scoring. Weights can be used to change the score contribution of risk inputs for hosts and users at both a global level and also for Risk Input categories (e.g. 'category_1')." type: object diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts new file mode 100644 index 0000000000000..c583616198313 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts @@ -0,0 +1,39 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Risk Scoring API + * version: 1.0.0 + */ + +import { IdentifierType, RiskScore } from './common.gen'; + +export type RiskScoresEntityCalculationRequest = z.infer; +export const RiskScoresEntityCalculationRequest = z.object({ + /** + * Used to identify the entity. + */ + identifier: z.string(), + /** + * Used to define the type of entity. + */ + identifier_type: IdentifierType, +}); + +export type RiskScoresEntityCalculationResponse = z.infer< + typeof RiskScoresEntityCalculationResponse +>; +export const RiskScoresEntityCalculationResponse = z.object({ + success: z.boolean(), + score: RiskScore.optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml new file mode 100644 index 0000000000000..de5f01f850187 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml @@ -0,0 +1,62 @@ +openapi: 3.0.0 + +info: + version: 1.0.0 + title: Risk Scoring API + description: These APIs allow the consumer to manage Entity Risk Scores within Entity Analytics. + +servers: + - url: 'http://{kibana_host}:{port}' + variables: + kibana_host: + default: localhost + port: + default: '5601' + +paths: + /api/risk_scores/calculation/entity: + post: + summary: Trigger calculation of Risk Scores for an entity + description: Calculates and persists Risk Scores for an entity, returning the calculated risk score. + requestBody: + description: The entity type and identifier + content: + application/json: + schema: + $ref: '#/components/schemas/RiskScoresEntityCalculationRequest' + required: true + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RiskScoresEntityCalculationResponse' + '400': + description: Invalid request + +components: + schemas: + RiskScoresEntityCalculationRequest: + type: object + required: + - identifier + - identifier_type + properties: + identifier: + description: Used to identify the entity. + type: string + example: 'my.host' + identifier_type: + description: Used to define the type of entity. + $ref: './common.schema.yaml#/components/schemas/IdentifierType' + + RiskScoresEntityCalculationResponse: + type: object + required: + - success + properties: + success: + type: boolean + score: + $ref: './common.schema.yaml#/components/schemas/RiskScore' diff --git a/x-pack/plugins/security_solution/common/entity_analytics/risk_score/constants.ts b/x-pack/plugins/security_solution/common/entity_analytics/risk_score/constants.ts index 0c5648fc537bd..f4aa3f4f32976 100644 --- a/x-pack/plugins/security_solution/common/entity_analytics/risk_score/constants.ts +++ b/x-pack/plugins/security_solution/common/entity_analytics/risk_score/constants.ts @@ -9,6 +9,8 @@ */ export const RISK_ENGINE_PUBLIC_PREFIX = '/api/risk_scores' as const; export const RISK_SCORE_CALCULATION_URL = `${RISK_ENGINE_PUBLIC_PREFIX}/calculation` as const; +export const RISK_SCORE_ENTITY_CALCULATION_URL = + `${RISK_ENGINE_PUBLIC_PREFIX}/calculation/entity` as const; /** * Internal Risk Score routes diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_privileges.ts index df7914e5b242b..573f8147f8b90 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_privileges.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_privileges.ts @@ -82,17 +82,17 @@ export const _getMissingPrivilegesMessage = (riskEnginePrivileges: EntityAnalyti * @param getStartServices - Kibana's start services accessor * @param handler - The route handler to wrap **/ -export const withRiskEnginePrivilegeCheck = ( +export const withRiskEnginePrivilegeCheck = ( getStartServices: StartServicesAccessor, handler: ( context: SecuritySolutionRequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest, response: KibanaResponseFactory ) => Promise ) => { return async ( context: SecuritySolutionRequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest, response: KibanaResponseFactory ) => { const [_, { security }] = await getStartServices(); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/audit.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/audit.ts index 012b55cf4f325..92a77645347c3 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/audit.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/audit.ts @@ -9,5 +9,6 @@ export enum RiskScoreAuditActions { RISK_ENGINE_INSTALL = 'risk_engine_install', RISK_ENGINE_PRIVILEGES_GET = 'risk_engine_privileges_get', RISK_ENGINE_MANUAL_SCORING = 'risk_engine_manual_scoring', + RISK_ENGINE_ENTITY_MANUAL_SCORING = 'risk_engine_entity_manual_scoring', RISK_ENGINE_PREVIEW = 'risk_engine_preview', } diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts index 58eedb35699ed..09fe204fe69e1 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts @@ -21,7 +21,7 @@ export const calculateAndPersistRiskScores = async ( riskScoreDataClient: RiskScoreDataClient; } ): Promise => { - const { riskScoreDataClient, spaceId, ...rest } = params; + const { riskScoreDataClient, spaceId, returnScores, ...rest } = params; const writer = await riskScoreDataClient.getWriter({ namespace: spaceId, @@ -42,5 +42,7 @@ export const calculateAndPersistRiskScores = async ( const { errors, docs_written: scoresWritten } = await writer.bulk(scores); - return { after_keys: afterKeys, errors, scores_written: scoresWritten }; + const result = { after_keys: afterKeys, errors, scores_written: scoresWritten }; + + return returnScores ? { ...result, scores } : result; }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts index 86d0d3cfd2294..c4ac061d259fb 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts @@ -34,7 +34,7 @@ import { getCriticalityModifier, normalize, } from '../asset_criticality/helpers'; -import { getAfterKeyForIdentifierType, getFieldForIdentifierAgg } from './helpers'; +import { getAfterKeyForIdentifierType, getFieldForIdentifier } from './helpers'; import { buildCategoryCountDeclarations, buildCategoryAssignment, @@ -177,7 +177,7 @@ const buildIdentifierTypeAggregation = ({ alertSampleSizePerShard: number; }): AggregationsAggregationContainer => { const globalIdentifierTypeWeight = getGlobalWeightForIdentifierType({ identifierType, weights }); - const identifierField = getFieldForIdentifierAgg(identifierType); + const identifierField = getFieldForIdentifier(identifierType); return { composite: { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/helpers.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/helpers.ts index 59b8b0535f97d..f5df6ef1b80ee 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/helpers.ts @@ -12,7 +12,7 @@ import type { } from '../../../../common/entity_analytics/risk_engine'; import type { CalculateAndPersistScoresResponse } from '../types'; -export const getFieldForIdentifierAgg = (identifierType: IdentifierType): string => +export const getFieldForIdentifier = (identifierType: IdentifierType): string => identifierType === 'host' ? 'host.name' : 'user.name'; export const getAfterKeyForIdentifierType = ({ diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts index 62d08d89b169a..bfc86643a6fd0 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts @@ -22,7 +22,7 @@ import type { RiskScoreDataClient } from './risk_score_data_client'; import type { RiskInputsIndexResponse } from './get_risk_inputs_index'; import { scheduleLatestTransformNow } from '../utils/transforms'; -type RiskEngineConfigurationWithDefaults = RiskEngineConfiguration & { +export type RiskEngineConfigurationWithDefaults = RiskEngineConfiguration & { alertSampleSizePerShard: number; }; export interface RiskScoreService { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/calculation.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/calculation.ts index b303eaaf407d5..210d792ded720 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/calculation.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/calculation.ts @@ -15,12 +15,11 @@ import { } from '../../../../../common/constants'; import { riskScoreCalculationRequestSchema } from '../../../../../common/entity_analytics/risk_engine/risk_score_calculation/request_schema'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; -import { assetCriticalityServiceFactory } from '../../asset_criticality'; -import { riskScoreServiceFactory } from '../risk_score_service'; import { getRiskInputsIndex } from '../get_risk_inputs_index'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { RiskScoreAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { buildRiskScoreServiceForRequest } from './helpers'; export const riskScoreCalculationRoute = ( router: EntityAnalyticsRoutesDeps['router'], @@ -54,26 +53,14 @@ export const riskScoreCalculationRoute = ( const siemResponse = buildSiemResponse(response); const coreContext = await context.core; - const esClient = coreContext.elasticsearch.client.asCurrentUser; const soClient = coreContext.savedObjects.client; - const spaceId = securityContext.getSpaceId(); - const riskEngineDataClient = securityContext.getRiskEngineDataClient(); - const riskScoreDataClient = securityContext.getRiskScoreDataClient(); - const assetCriticalityDataClient = securityContext.getAssetCriticalityDataClient(); const securityConfig = await securityContext.getConfig(); - const assetCriticalityService = assetCriticalityServiceFactory({ - assetCriticalityDataClient, - uiSettingsClient: coreContext.uiSettings.client, - }); - const riskScoreService = riskScoreServiceFactory({ - assetCriticalityService, - esClient, - logger, - riskEngineDataClient, - riskScoreDataClient, - spaceId, - }); + const riskScoreService = buildRiskScoreServiceForRequest( + securityContext, + coreContext, + logger + ); const { after_keys: userAfterKeys, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts new file mode 100644 index 0000000000000..1ba609e885dda --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts @@ -0,0 +1,156 @@ +/* + * 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 { loggerMock } from '@kbn/logging-mocks'; +import { RISK_SCORE_ENTITY_CALCULATION_URL } from '../../../../../common/constants'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../../detection_engine/routes/__mocks__'; +import type { RiskEngineConfigurationWithDefaults } from '../risk_score_service'; +import { riskScoreServiceFactory } from '../risk_score_service'; +import { riskScoreServiceMock } from '../risk_score_service.mock'; +import { getRiskInputsIndex } from '../get_risk_inputs_index'; +import { calculateAndPersistRiskScoresMock } from '../calculate_and_persist_risk_scores.mock'; +import { riskScoreEntityCalculationRoute } from './entity_calculation'; +import { riskEnginePrivilegesMock } from '../../risk_engine/routes/risk_engine_privileges.mock'; + +jest.mock('../get_risk_inputs_index'); +jest.mock('../risk_score_service'); + +describe('entity risk score calculation route', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + let logger: ReturnType; + let mockRiskScoreService: ReturnType; + const entityAnalyticsConfig = { + alertSampleSizePerShard: 10_000, + enabled: true, + range: { start: 'now-30d', end: 'now' }, + } as unknown as RiskEngineConfigurationWithDefaults; + let getStartServicesMock: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + security: riskEnginePrivilegesMock.createMockSecurityStartWithFullRiskEngineAccess(), + }, + ]); + + server = serverMock.create(); + logger = loggerMock.create(); + ({ clients, context } = requestContextMock.createTools()); + mockRiskScoreService = riskScoreServiceMock.create(); + mockRiskScoreService.getConfigurationWithDefaults.mockResolvedValue(entityAnalyticsConfig); + mockRiskScoreService.calculateAndPersistScores.mockResolvedValue( + calculateAndPersistRiskScoresMock.buildResponse() + ); + + (getRiskInputsIndex as jest.Mock).mockResolvedValue({ + index: 'default-dataview-index', + runtimeMappings: {}, + }); + clients.appClient.getAlertsIndex.mockReturnValue('default-alerts-index'); + (riskScoreServiceFactory as jest.Mock).mockReturnValue(mockRiskScoreService); + + riskScoreEntityCalculationRoute(server.router, getStartServicesMock, logger); + }); + + const buildRequest = (overrides: object = {}) => { + const defaults = { + identifier: 'test-host-name', + identifier_type: 'host', + }; + + return requestMock.create({ + method: 'post', + path: RISK_SCORE_ENTITY_CALCULATION_URL, + body: { ...defaults, ...overrides }, + }); + }; + + it('should return 200 when risk score calculation is successful', async () => { + const request = buildRequest(); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(200); + }); + + it('should call "calculateAndPersistScores" with entity filter', async () => { + const request = buildRequest(); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(200); + expect(mockRiskScoreService.calculateAndPersistScores).toHaveBeenCalledWith( + expect.objectContaining({ filter: [{ term: { 'host.name': 'test-host-name' } }] }) + ); + }); + + describe('validation', () => { + it('requires a parameter for the identifier type', async () => { + const request = buildRequest({ identifier_type: undefined }); + const result = await server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith('identifier_type: Required'); + }); + + it('requires a parameter for the entity identifier', async () => { + const request = buildRequest({ identifier: undefined }); + const result = await server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith('identifier: Required'); + }); + + it('returns an error if no entity analytics configuration is found', async () => { + mockRiskScoreService.getConfigurationWithDefaults.mockResolvedValue(null); + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.body).toEqual({ + message: 'No Risk engine configuration found', + status_code: 405, + }); + expect(response.status).toEqual(405); + }); + + it('returns an error if the risk engine is disabled', async () => { + mockRiskScoreService.getConfigurationWithDefaults.mockResolvedValue({ + ...entityAnalyticsConfig, + enabled: false, + }); + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.body).toEqual({ + message: 'Risk engine is disabled', + status_code: 405, + }); + expect(response.status).toEqual(405); + }); + + it('filter by user provided filter when it is defined', async () => { + const userFilter = { term: { 'test.filter': 'test-value' } }; + mockRiskScoreService.getConfigurationWithDefaults.mockResolvedValue({ + ...entityAnalyticsConfig, + filter: userFilter, + }); + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(200); + expect(mockRiskScoreService.calculateAndPersistScores).toHaveBeenCalledWith( + expect.objectContaining({ filter: expect.arrayContaining([userFilter]) }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts new file mode 100644 index 0000000000000..497e9ac189100 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts @@ -0,0 +1,162 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { isEmpty } from 'lodash/fp'; +import { RiskScoresEntityCalculationRequest } from '../../../../../common/api/entity_analytics/risk_engine/entity_calculation_route.gen'; +import { APP_ID, RISK_SCORE_ENTITY_CALCULATION_URL } from '../../../../../common/constants'; +import type { AfterKeys } from '../../../../../common/entity_analytics/risk_engine'; +import { buildRouteValidationWithZod } from '../../../../utils/build_validation/route_validation'; +import { getRiskInputsIndex } from '../get_risk_inputs_index'; +import type { CalculateAndPersistScoresResponse, EntityAnalyticsRoutesDeps } from '../../types'; +import { RiskScoreAuditActions } from '../audit'; +import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { convertRangeToISO } from '../tasks/helpers'; +import { buildRiskScoreServiceForRequest } from './helpers'; +import { getFieldForIdentifier } from '../helpers'; +import { withRiskEnginePrivilegeCheck } from '../../risk_engine/risk_engine_privileges'; + +export const riskScoreEntityCalculationRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + getStartServices: EntityAnalyticsRoutesDeps['getStartServices'], + logger: Logger +) => { + router.versioned + .post({ + path: RISK_SCORE_ENTITY_CALCULATION_URL, + access: 'internal', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: buildRouteValidationWithZod(RiskScoresEntityCalculationRequest), + }, + }, + }, + withRiskEnginePrivilegeCheck(getStartServices, async (context, request, response) => { + const securityContext = await context.securitySolution; + + securityContext.getAuditLogger()?.log({ + message: 'User triggered custom manual scoring', + event: { + action: RiskScoreAuditActions.RISK_ENGINE_ENTITY_MANUAL_SCORING, + category: AUDIT_CATEGORY.DATABASE, + type: AUDIT_TYPE.CHANGE, + outcome: AUDIT_OUTCOME.UNKNOWN, + }, + }); + + const coreContext = await context.core; + const securityConfig = await securityContext.getConfig(); + const siemResponse = buildSiemResponse(response); + const soClient = coreContext.savedObjects.client; + + const riskScoreService = buildRiskScoreServiceForRequest( + securityContext, + coreContext, + logger + ); + + const { identifier_type: identifierType, identifier } = request.body; + + try { + const entityAnalyticsConfig = await riskScoreService.getConfigurationWithDefaults( + securityConfig.entityAnalytics + ); + + if (entityAnalyticsConfig == null) { + return siemResponse.error({ + statusCode: 405, + body: 'No Risk engine configuration found', + }); + } + + const { + dataViewId, + enabled, + range: configuredRange, + pageSize, + alertSampleSizePerShard, + filter: userFilter, + } = entityAnalyticsConfig; + + if (!enabled) { + return siemResponse.error({ + statusCode: 405, + body: 'Risk engine is disabled', + }); + } + + const { index, runtimeMappings } = await getRiskInputsIndex({ + dataViewId, + logger, + soClient, + }); + + const range = convertRangeToISO(configuredRange); + + const afterKeys: AfterKeys = {}; + + const identifierFilter = { + term: { [getFieldForIdentifier(identifierType)]: identifier }, + }; + const filter = isEmpty(userFilter) ? [identifierFilter] : [userFilter, identifierFilter]; + + const result: CalculateAndPersistScoresResponse = + await riskScoreService.calculateAndPersistScores({ + pageSize, + identifierType, + index, + filter, + range, + runtimeMappings, + weights: [], + alertSampleSizePerShard, + afterKeys, + returnScores: true, + }); + + if (result.errors.length) { + return siemResponse.error({ + statusCode: 500, + body: { + message: 'Error calculating the risk score for an entity.', + full_error: JSON.stringify(result.errors), + }, + bypassErrorFormat: true, + }); + } + + const score = + result.scores_written === 1 ? result.scores?.[identifierType]?.[0] : undefined; + + return response.ok({ + body: { + success: true, + score, + }, + }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + bypassErrorFormat: true, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/helpers.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/helpers.ts new file mode 100644 index 0000000000000..41145f799eb43 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/helpers.ts @@ -0,0 +1,37 @@ +/* + * 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 type { CoreRequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import type { Logger } from '@kbn/core/server'; +import type { SecuritySolutionApiRequestHandlerContext } from '../../../..'; +import { assetCriticalityServiceFactory } from '../../asset_criticality'; +import { riskScoreServiceFactory } from '../risk_score_service'; + +export function buildRiskScoreServiceForRequest( + securityContext: SecuritySolutionApiRequestHandlerContext, + coreContext: CoreRequestHandlerContext, + logger: Logger +) { + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const spaceId = securityContext.getSpaceId(); + const assetCriticalityDataClient = securityContext.getAssetCriticalityDataClient(); + const assetCriticalityService = assetCriticalityServiceFactory({ + assetCriticalityDataClient, + uiSettingsClient: coreContext.uiSettings.client, + }); + const riskEngineDataClient = securityContext.getRiskEngineDataClient(); + const riskScoreDataClient = securityContext.getRiskScoreDataClient(); + + return riskScoreServiceFactory({ + assetCriticalityService, + esClient, + logger, + riskEngineDataClient, + riskScoreDataClient, + spaceId, + }); +} diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts index d0a083cf2a5a2..b592f3a8a48c8 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts @@ -16,12 +16,11 @@ import { } from '../../../../../common/constants'; import { riskScorePreviewRequestSchema } from '../../../../../common/entity_analytics/risk_engine/risk_score_preview/request_schema'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; -import { assetCriticalityServiceFactory } from '../../asset_criticality'; -import { riskScoreServiceFactory } from '../risk_score_service'; import { getRiskInputsIndex } from '../get_risk_inputs_index'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { RiskScoreAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { buildRiskScoreServiceForRequest } from './helpers'; export const riskScorePreviewRoute = ( router: EntityAnalyticsRoutesDeps['router'], @@ -44,27 +43,14 @@ export const riskScorePreviewRoute = ( const siemResponse = buildSiemResponse(response); const securityContext = await context.securitySolution; const coreContext = await context.core; - const esClient = coreContext.elasticsearch.client.asCurrentUser; const soClient = coreContext.savedObjects.client; - const spaceId = securityContext.getSpaceId(); - const riskEngineDataClient = securityContext.getRiskEngineDataClient(); - const riskScoreDataClient = securityContext.getRiskScoreDataClient(); - const assetCriticalityDataClient = securityContext.getAssetCriticalityDataClient(); const securityConfig = await securityContext.getConfig(); - const assetCriticalityService = assetCriticalityServiceFactory({ - assetCriticalityDataClient, - uiSettingsClient: coreContext.uiSettings.client, - }); - - const riskScoreService = riskScoreServiceFactory({ - assetCriticalityService, - esClient, - logger, - riskEngineDataClient, - riskScoreDataClient, - spaceId, - }); + const riskScoreService = buildRiskScoreServiceForRequest( + securityContext, + coreContext, + logger + ); const { after_keys: userAfterKeys, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/register_risk_score_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/register_risk_score_routes.ts index 28cab1cdca397..015b12c5d8ee1 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/register_risk_score_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/register_risk_score_routes.ts @@ -7,8 +7,14 @@ import { riskScorePreviewRoute } from './preview'; import { riskScoreCalculationRoute } from './calculation'; import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { riskScoreEntityCalculationRoute } from './entity_calculation'; -export const registerRiskScoreRoutes = ({ router, logger }: EntityAnalyticsRoutesDeps) => { +export const registerRiskScoreRoutes = ({ + router, + getStartServices, + logger, +}: EntityAnalyticsRoutesDeps) => { riskScorePreviewRoute(router, logger); riskScoreCalculationRoute(router, logger); + riskScoreEntityCalculationRoute(router, getStartServices, logger); }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts index c5a65cb59c5e3..d41edbd215642 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts @@ -51,12 +51,17 @@ export interface CalculateAndPersistScoresParams { runtimeMappings: MappingRuntimeFields; weights?: RiskWeights; alertSampleSizePerShard?: number; + returnScores?: boolean; } export interface CalculateAndPersistScoresResponse { after_keys: AfterKeys; errors: string[]; scores_written: number; + scores?: { + host?: RiskScore[]; + user?: RiskScore[]; + }; } export interface CalculateScoresResponse { diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts new file mode 100644 index 0000000000000..2e1b3a4406571 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts @@ -0,0 +1,209 @@ +/* + * 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 expect from '@kbn/expect'; +import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common'; + +import { RISK_SCORE_ENTITY_CALCULATION_URL } from '@kbn/security-solution-plugin/common/constants'; +import type { RiskScore } from '@kbn/security-solution-plugin/common/entity_analytics/risk_engine'; +import { v4 as uuidv4 } from 'uuid'; +import { dataGeneratorFactory } from '../../../detections_response/utils'; +import { deleteAllAlerts, deleteAllRules } from '../../../../../common/utils/security_solution'; +import { + buildDocument, + createAndSyncRuleAndAlertsFactory, + deleteAllRiskScores, + readRiskScores, + normalizeScores, + waitForRiskScoresToBePresent, + assetCriticalityRouteHelpersFactory, + cleanAssetCriticality, + waitForAssetCriticalityToBePresent, + riskEngineRouteHelpersFactory, + cleanRiskEngine, + enableAssetCriticalityAdvancedSetting, + sanitizeScores, +} from '../../utils'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const log = getService('log'); + const kibanaServer = getService('kibanaServer'); + + const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest); + + const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log }); + + const calculateEntityRiskScores = async ({ + body, + }: { + body: object; + }): Promise<{ score: RiskScore; success: boolean }> => { + const { body: result } = await supertest + .post(RISK_SCORE_ENTITY_CALCULATION_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send(body) + .expect(200); + return result; + }; + + const calculateEntityRiskScoreAfterRuleCreationAndExecution = async ( + documentId: string, + identifier: string, + { + alerts = 1, + riskScore = 21, + maxSignals = 100, + }: { alerts?: number; riskScore?: number; maxSignals?: number } = {} + ) => { + await createAndSyncRuleAndAlerts({ query: `id: ${documentId}`, alerts, riskScore, maxSignals }); + + return await calculateEntityRiskScores({ + body: { + identifier_type: 'host', + identifier, + }, + }); + }; + + describe('@ess @serverless Risk Scoring Entity Calculation API', () => { + before(async () => { + enableAssetCriticalityAdvancedSetting(kibanaServer, log); + }); + + context('with auditbeat data', () => { + const { indexListOfDocuments } = dataGeneratorFactory({ + es, + index: 'ecs_compliant', + log, + }); + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/ecs_compliant' + ); + }); + + beforeEach(async () => { + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + + await cleanRiskEngine({ kibanaServer, es, log }); + await riskEngineRoutes.init(); + }); + + afterEach(async () => { + await deleteAllRiskScores(log, es); + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + + await cleanRiskEngine({ kibanaServer, es, log }); + }); + + it('calculates and persists risk score for entity', async () => { + const documentId = uuidv4(); + await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, documentId)]); + + const results = await calculateEntityRiskScoreAfterRuleCreationAndExecution( + documentId, + 'host-1' + ); + + const expectedScore = { + calculated_level: 'Unknown', + calculated_score: 21, + calculated_score_norm: 8.039816232771823, + category_1_score: 8.039816232771821, + category_1_count: 1, + id_field: 'host.name', + id_value: 'host-1', + }; + + const [score] = sanitizeScores([results.score]); + + expect(score).to.eql(expectedScore); + expect(results.success).to.be(true); + + await waitForRiskScoresToBePresent({ es, log }); + const persistedScores = await readRiskScores(es); + + expect(persistedScores.length).to.eql(1); + const [persistedScore] = normalizeScores(persistedScores); + + expect(persistedScore).to.eql(expectedScore); + }); + + describe('with asset criticality data', () => { + const assetCriticalityRoutes = assetCriticalityRouteHelpersFactory(supertest); + + beforeEach(async () => { + await assetCriticalityRoutes.upsert({ + id_field: 'host.name', + id_value: 'host-1', + criticality_level: 'high_impact', + }); + }); + + afterEach(async () => { + await cleanAssetCriticality({ log, es }); + }); + + it('calculates and persists risk scores with additional criticality metadata and modifiers', async () => { + const documentId = uuidv4(); + await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, documentId)]); + await waitForAssetCriticalityToBePresent({ es, log }); + + const results = await calculateEntityRiskScoreAfterRuleCreationAndExecution( + documentId, + 'host-1' + ); + const expectedScore = { + criticality_level: 'high_impact', + criticality_modifier: 1.5, + calculated_level: 'Unknown', + calculated_score: 21, + calculated_score_norm: 11.59366948840633, + category_1_score: 8.039816232771821, + category_1_count: 1, + id_field: 'host.name', + id_value: 'host-1', + }; + + const [score] = sanitizeScores([results.score]); + expect(results.success).to.be(true); + expect(score).to.eql(expectedScore); + + await waitForRiskScoresToBePresent({ es, log }); + const persistedScores = await readRiskScores(es); + expect(persistedScores.length).to.eql(1); + + const [persistedScore] = normalizeScores(persistedScores); + + expect(persistedScore).to.eql(expectedScore); + const [rawScore] = persistedScores; + + expect( + rawScore.host?.risk.category_1_score! + rawScore.host?.risk.category_2_score! + ).to.be.within( + persistedScore.calculated_score_norm! - 0.000000000000001, + persistedScore.calculated_score_norm! + 0.000000000000001 + ); + }); + }); + }); + }); +};