mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
Risk Score Persistence API (#161503)
## Summary * Introduces a new API, POST `/api/risk_scores/calculate`, that triggers the code introduced here * As with the [preview route](https://github.com/elastic/kibana/pull/155966), this endpoint is behind the `riskScoringRoutesEnabled` feature flag * We intend to __REMOVE__ this endpoint before 8.10 release; it's mainly a convenience/checkpoint for testing the existing code. The next PR will introduce a scheduled Task Manager task that invokes this code periodically. * Updates to the /preview route: * `data_view_id` is now a required parameter on both endpoints. If a dataview is not found by that ID, the id is used as the general index pattern to the query. * Response has been updated to be more similar to the [ECS risk fields](https://github.com/elastic/ecs/pull/2236) powering this data. * Mappings created by the [Data Client](https://github.com/elastic/kibana/pull/158422) have been updated to be aligned to the ECS risk fields (linked above) * Adds/updates the [OpenAPI spec](https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/server/lib/risk_engine/schema/risk_score_apis.yml) for these endpoints; useful starting point if you're trying to get oriented here. ## Things to review * [PR Demo environment](https://rylnd-pr-161503-risk-score-task-api.kbndev.co/app/home) * Preview API and related UI still works as expected * Calculation/Persistence API correctly bootstraps/persists data * correct mappings/ILM are created * things work in non-default spaces ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
parent
180f86138b
commit
8df89203c2
45 changed files with 2408 additions and 638 deletions
|
@ -233,6 +233,9 @@ export const DETECTION_ENGINE_RULES_BULK_CREATE =
|
|||
export const DETECTION_ENGINE_RULES_BULK_UPDATE =
|
||||
`${DETECTION_ENGINE_RULES_URL}/_bulk_update` as const;
|
||||
|
||||
/**
|
||||
* Internal Risk Score routes
|
||||
*/
|
||||
export const INTERNAL_RISK_SCORE_URL = '/internal/risk_score' as const;
|
||||
export const DEV_TOOL_PREBUILT_CONTENT =
|
||||
`${INTERNAL_RISK_SCORE_URL}/prebuilt_content/dev_tool/{console_id}` as const;
|
||||
|
@ -244,16 +247,21 @@ export const prebuiltSavedObjectsBulkCreateUrl = (templateName: string) =>
|
|||
export const PREBUILT_SAVED_OBJECTS_BULK_DELETE = `${INTERNAL_RISK_SCORE_URL}/prebuilt_content/saved_objects/_bulk_delete/{template_name}`;
|
||||
export const prebuiltSavedObjectsBulkDeleteUrl = (templateName: string) =>
|
||||
`${INTERNAL_RISK_SCORE_URL}/prebuilt_content/saved_objects/_bulk_delete/${templateName}` as const;
|
||||
|
||||
export const INTERNAL_DASHBOARDS_URL = `/internal/dashboards` as const;
|
||||
export const INTERNAL_TAGS_URL = `/internal/tags`;
|
||||
|
||||
export const RISK_SCORE_CREATE_INDEX = `${INTERNAL_RISK_SCORE_URL}/indices/create`;
|
||||
export const RISK_SCORE_DELETE_INDICES = `${INTERNAL_RISK_SCORE_URL}/indices/delete`;
|
||||
export const RISK_SCORE_CREATE_STORED_SCRIPT = `${INTERNAL_RISK_SCORE_URL}/stored_scripts/create`;
|
||||
export const RISK_SCORE_DELETE_STORED_SCRIPT = `${INTERNAL_RISK_SCORE_URL}/stored_scripts/delete`;
|
||||
export const RISK_SCORE_PREVIEW_URL = `${INTERNAL_RISK_SCORE_URL}/preview`;
|
||||
|
||||
/**
|
||||
* Public Risk Score routes
|
||||
*/
|
||||
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 INTERNAL_DASHBOARDS_URL = `/internal/dashboards` as const;
|
||||
export const INTERNAL_TAGS_URL = `/internal/tags`;
|
||||
|
||||
/**
|
||||
* Internal detection engine routes
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 * as t from 'io-ts';
|
||||
import { DataViewId } from '../../api/detection_engine';
|
||||
import { afterKeysSchema } from '../after_keys';
|
||||
import { identifierTypeSchema } from '../identifier_types';
|
||||
import { riskWeightsSchema } from '../risk_weights/schema';
|
||||
|
||||
export const riskScoreCalculationRequestSchema = t.exact(
|
||||
t.intersection([
|
||||
t.type({
|
||||
data_view_id: DataViewId,
|
||||
identifier_type: identifierTypeSchema,
|
||||
range: t.type({
|
||||
start: t.string,
|
||||
end: t.string,
|
||||
}),
|
||||
}),
|
||||
t.partial({
|
||||
after_keys: afterKeysSchema,
|
||||
debug: t.boolean,
|
||||
filter: t.unknown,
|
||||
page_size: t.number,
|
||||
weights: riskWeightsSchema,
|
||||
}),
|
||||
])
|
||||
);
|
|
@ -12,18 +12,22 @@ import { identifierTypeSchema } from '../identifier_types';
|
|||
import { riskWeightsSchema } from '../risk_weights/schema';
|
||||
|
||||
export const riskScorePreviewRequestSchema = t.exact(
|
||||
t.partial({
|
||||
after_keys: afterKeysSchema,
|
||||
data_view_id: DataViewId,
|
||||
debug: t.boolean,
|
||||
filter: t.unknown,
|
||||
page_size: t.number,
|
||||
identifier_type: identifierTypeSchema,
|
||||
range: t.type({
|
||||
start: t.string,
|
||||
end: t.string,
|
||||
t.intersection([
|
||||
t.type({
|
||||
data_view_id: DataViewId,
|
||||
}),
|
||||
weights: riskWeightsSchema,
|
||||
})
|
||||
t.partial({
|
||||
after_keys: afterKeysSchema,
|
||||
debug: t.boolean,
|
||||
filter: t.unknown,
|
||||
page_size: t.number,
|
||||
identifier_type: identifierTypeSchema,
|
||||
range: t.type({
|
||||
start: t.string,
|
||||
end: t.string,
|
||||
}),
|
||||
weights: riskWeightsSchema,
|
||||
}),
|
||||
])
|
||||
);
|
||||
export type RiskScorePreviewRequestSchema = t.TypeOf<typeof riskScorePreviewRequestSchema>;
|
||||
|
|
|
@ -139,7 +139,7 @@ describe('risk weight schema', () => {
|
|||
});
|
||||
|
||||
it('rejects if neither host nor user weight are specified', () => {
|
||||
const payload = { type, value: RiskCategories.alerts };
|
||||
const payload = { type, value: RiskCategories.category_1 };
|
||||
const decoded = riskWeightSchema.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
|
@ -151,7 +151,7 @@ describe('risk weight schema', () => {
|
|||
});
|
||||
|
||||
it('allows a single host weight', () => {
|
||||
const payload = { type, value: RiskCategories.alerts, host: 0.1 };
|
||||
const payload = { type, value: RiskCategories.category_1, host: 0.1 };
|
||||
const decoded = riskWeightSchema.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
|
@ -160,7 +160,7 @@ describe('risk weight schema', () => {
|
|||
});
|
||||
|
||||
it('allows a single user weight', () => {
|
||||
const payload = { type, value: RiskCategories.alerts, user: 0.1 };
|
||||
const payload = { type, value: RiskCategories.category_1, user: 0.1 };
|
||||
const decoded = riskWeightSchema.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
|
@ -169,7 +169,7 @@ describe('risk weight schema', () => {
|
|||
});
|
||||
|
||||
it('allows both a host and user weight', () => {
|
||||
const payload = { type, value: RiskCategories.alerts, user: 0.1, host: 0.5 };
|
||||
const payload = { type, value: RiskCategories.category_1, user: 0.1, host: 0.5 };
|
||||
const decoded = riskWeightSchema.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
|
@ -178,7 +178,7 @@ describe('risk weight schema', () => {
|
|||
});
|
||||
|
||||
it('rejects a weight outside of 0-1', () => {
|
||||
const payload = { type, value: RiskCategories.alerts, host: -5 };
|
||||
const payload = { type, value: RiskCategories.category_1, host: -5 };
|
||||
const decoded = riskWeightSchema.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
|
@ -189,7 +189,7 @@ describe('risk weight schema', () => {
|
|||
it('removes extra keys if specified', () => {
|
||||
const payload = {
|
||||
type,
|
||||
value: RiskCategories.alerts,
|
||||
value: RiskCategories.category_1,
|
||||
host: 0.1,
|
||||
extra: 'even more',
|
||||
};
|
||||
|
@ -197,14 +197,14 @@ describe('risk weight schema', () => {
|
|||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual({ type, value: RiskCategories.alerts, host: 0.1 });
|
||||
expect(message.schema).toEqual({ type, value: RiskCategories.category_1, host: 0.1 });
|
||||
});
|
||||
|
||||
describe('allowed category values', () => {
|
||||
it('allows the alerts type for a category', () => {
|
||||
const payload = {
|
||||
type,
|
||||
value: RiskCategories.alerts,
|
||||
value: RiskCategories.category_1,
|
||||
host: 0.1,
|
||||
};
|
||||
const decoded = riskWeightSchema.decode(payload);
|
||||
|
|
|
@ -11,5 +11,5 @@ export enum RiskWeightTypes {
|
|||
}
|
||||
|
||||
export enum RiskCategories {
|
||||
alerts = 'alerts',
|
||||
category_1 = 'category_1',
|
||||
}
|
||||
|
|
|
@ -82,7 +82,7 @@ describe('Entity analytics management page', () => {
|
|||
cy.intercept('POST', '/internal/risk_score/preview', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
scores: [],
|
||||
scores: { host: [], user: [] },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { RISK_SCORE_PREVIEW_URL } from '../../../common/constants';
|
||||
|
||||
import { KibanaServices } from '../../common/lib/kibana';
|
||||
import type { GetScoresResponse } from '../../../server/lib/risk_engine/types';
|
||||
import type { CalculateScoresResponse } from '../../../server/lib/risk_engine/types';
|
||||
import type { RiskScorePreviewRequestSchema } from '../../../common/risk_engine/risk_score_preview/request_schema';
|
||||
|
||||
/**
|
||||
|
@ -20,8 +20,8 @@ export const fetchRiskScorePreview = async ({
|
|||
}: {
|
||||
signal?: AbortSignal;
|
||||
params: RiskScorePreviewRequestSchema;
|
||||
}): Promise<GetScoresResponse> => {
|
||||
return KibanaServices.get().http.fetch<GetScoresResponse>(RISK_SCORE_PREVIEW_URL, {
|
||||
}): Promise<CalculateScoresResponse> => {
|
||||
return KibanaServices.get().http.fetch<CalculateScoresResponse>(RISK_SCORE_PREVIEW_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
signal,
|
||||
|
|
|
@ -9,9 +9,13 @@ import dateMath from '@kbn/datemath';
|
|||
import { fetchRiskScorePreview } from '../api';
|
||||
import type { RiskScorePreviewRequestSchema } from '../../../../common/risk_engine/risk_score_preview/request_schema';
|
||||
|
||||
export const useRiskScorePreview = ({ range, filter }: RiskScorePreviewRequestSchema) => {
|
||||
export const useRiskScorePreview = ({
|
||||
data_view_id: dataViewId,
|
||||
range,
|
||||
filter,
|
||||
}: RiskScorePreviewRequestSchema) => {
|
||||
return useQuery(['POST', 'FETCH_PREVIEW_RISK_SCORE', range, filter], async ({ signal }) => {
|
||||
const params: RiskScorePreviewRequestSchema = {};
|
||||
const params: RiskScorePreviewRequestSchema = { data_view_id: dataViewId };
|
||||
|
||||
if (range) {
|
||||
const startTime = dateMath.parse(range.start)?.utc().toISOString();
|
||||
|
|
|
@ -40,8 +40,8 @@ interface IRiskScorePreviewPanel {
|
|||
|
||||
const getRiskiestScores = (scores: RiskScore[] = [], field: string) =>
|
||||
scores
|
||||
?.filter((item) => item?.identifierField === field)
|
||||
?.sort((a, b) => b?.totalScoreNormalized - a?.totalScoreNormalized)
|
||||
?.filter((item) => item?.id_field === field)
|
||||
?.sort((a, b) => b?.calculated_score_norm - a?.calculated_score_norm)
|
||||
?.slice(0, 5) || [];
|
||||
|
||||
const RiskScorePreviewPanel = ({
|
||||
|
@ -95,7 +95,10 @@ export const RiskScorePreviewSection = () => {
|
|||
|
||||
const { addError } = useAppToasts();
|
||||
|
||||
const { indexPattern } = useSourcererDataView(SourcererScopeName.detections);
|
||||
|
||||
const { data, isLoading, refetch, isError } = useRiskScorePreview({
|
||||
data_view_id: indexPattern.title, // TODO @nkhristinin verify this is correct
|
||||
filter: filters,
|
||||
range: {
|
||||
start: dateRange.from,
|
||||
|
@ -103,10 +106,8 @@ export const RiskScorePreviewSection = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const { indexPattern } = useSourcererDataView(SourcererScopeName.detections);
|
||||
|
||||
const hosts = getRiskiestScores(data?.scores, 'host.name');
|
||||
const users = getRiskiestScores(data?.scores, 'user.name');
|
||||
const hosts = getRiskiestScores(data?.scores.host, 'host.name');
|
||||
const users = getRiskiestScores(data?.scores.user, 'user.name');
|
||||
|
||||
const onQuerySubmit = useCallback(
|
||||
(payload: { dateRange: TimeRange; query?: Query }) => {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { EuiInMemoryTable } from '@elastic/eui';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import type { RiskSeverity } from '../../../common/search_strategy';
|
||||
import { RiskScore } from '../../explore/components/risk_score/severity/common';
|
||||
|
||||
|
@ -14,6 +15,10 @@ import { HostDetailsLink, UserDetailsLink } from '../../common/components/links'
|
|||
import type { RiskScore as IRiskScore } from '../../../server/lib/risk_engine/types';
|
||||
import { RiskScoreEntity } from '../../../common/risk_engine/types';
|
||||
|
||||
type RiskScoreColumn = EuiBasicTableColumn<IRiskScore> & {
|
||||
field: keyof IRiskScore;
|
||||
};
|
||||
|
||||
export const RiskScorePreviewTable = ({
|
||||
items,
|
||||
type,
|
||||
|
@ -21,9 +26,9 @@ export const RiskScorePreviewTable = ({
|
|||
items: IRiskScore[];
|
||||
type: RiskScoreEntity;
|
||||
}) => {
|
||||
const columns = [
|
||||
const columns: RiskScoreColumn[] = [
|
||||
{
|
||||
field: 'identifierValue',
|
||||
field: 'id_value',
|
||||
name: 'Name',
|
||||
render: (itemName: string) => {
|
||||
return type === RiskScoreEntity.host ? (
|
||||
|
@ -34,7 +39,7 @@ export const RiskScorePreviewTable = ({
|
|||
},
|
||||
},
|
||||
{
|
||||
field: 'level',
|
||||
field: 'calculated_level',
|
||||
name: 'Level',
|
||||
render: (risk: RiskSeverity | null) => {
|
||||
if (risk != null) {
|
||||
|
@ -45,7 +50,7 @@ export const RiskScorePreviewTable = ({
|
|||
},
|
||||
},
|
||||
{
|
||||
field: 'totalScoreNormalized',
|
||||
field: 'calculated_score_norm',
|
||||
// align: 'right',
|
||||
name: 'Score norm',
|
||||
render: (scoreNorm: number | null) => {
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { CalculateAndPersistScoresResponse } from './types';
|
||||
|
||||
const buildResponseMock = (
|
||||
overrides: Partial<CalculateAndPersistScoresResponse> = {}
|
||||
): CalculateAndPersistScoresResponse => ({
|
||||
after_keys: {
|
||||
host: { 'host.name': 'hostname' },
|
||||
},
|
||||
errors: [],
|
||||
scores_written: 2,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const calculateAndPersistRiskScoresMock = {
|
||||
buildResponse: buildResponseMock,
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
||||
import { calculateAndPersistRiskScores } from './calculate_and_persist_risk_scores';
|
||||
import { calculateRiskScores } from './calculate_risk_scores';
|
||||
import { calculateRiskScoresMock } from './calculate_risk_scores.mock';
|
||||
|
||||
jest.mock('./calculate_risk_scores');
|
||||
|
||||
describe('calculateAndPersistRiskScores', () => {
|
||||
let esClient: ElasticsearchClient;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser;
|
||||
logger = loggingSystemMock.createLogger();
|
||||
});
|
||||
|
||||
describe('with no risk scores to persist', () => {
|
||||
beforeEach(() => {
|
||||
(calculateRiskScores as jest.Mock).mockResolvedValueOnce(
|
||||
calculateRiskScoresMock.buildResponse({ scores: { host: [] } })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an appropriate response', async () => {
|
||||
const results = await calculateAndPersistRiskScores({
|
||||
afterKeys: {},
|
||||
identifierType: 'host',
|
||||
esClient,
|
||||
logger,
|
||||
index: 'index',
|
||||
pageSize: 500,
|
||||
range: { start: 'now - 15d', end: 'now' },
|
||||
spaceId: 'default',
|
||||
// @ts-expect-error not relevant for this test
|
||||
riskEngineDataClient: { getWriter: jest.fn() },
|
||||
runtimeMappings: {},
|
||||
});
|
||||
|
||||
expect(results).toEqual({ after_keys: {}, errors: [], scores_written: 0 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
|
||||
import type { RiskEngineDataClient } from './risk_engine_data_client';
|
||||
import type { CalculateAndPersistScoresParams, CalculateAndPersistScoresResponse } from './types';
|
||||
import { calculateRiskScores } from './calculate_risk_scores';
|
||||
|
||||
export const calculateAndPersistRiskScores = async (
|
||||
params: CalculateAndPersistScoresParams & {
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
spaceId: string;
|
||||
riskEngineDataClient: RiskEngineDataClient;
|
||||
}
|
||||
): Promise<CalculateAndPersistScoresResponse> => {
|
||||
const { riskEngineDataClient, spaceId, ...rest } = params;
|
||||
const writer = await riskEngineDataClient.getWriter({ namespace: spaceId });
|
||||
const { after_keys: afterKeys, scores } = await calculateRiskScores(rest);
|
||||
|
||||
if (!scores.host?.length && !scores.user?.length) {
|
||||
return { after_keys: {}, errors: [], scores_written: 0 };
|
||||
}
|
||||
|
||||
const { errors, docs_written: scoresWritten } = await writer.bulk(scores);
|
||||
|
||||
return { after_keys: afterKeys, errors, scores_written: scoresWritten };
|
||||
};
|
|
@ -5,10 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { CalculateRiskScoreAggregations, RiskScoreBucket } from './types';
|
||||
import {
|
||||
ALERT_RISK_SCORE,
|
||||
ALERT_RULE_NAME,
|
||||
} from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
|
||||
import { RiskCategories } from '../../../common/risk_engine';
|
||||
import type {
|
||||
CalculateRiskScoreAggregations,
|
||||
CalculateScoresResponse,
|
||||
RiskScoreBucket,
|
||||
} from './types';
|
||||
|
||||
const createRiskScoreBucketMock = (overrides: Partial<RiskScoreBucket> = {}): RiskScoreBucket => ({
|
||||
key: { 'user.name': 'username', category: 'alert' },
|
||||
const buildRiskScoreBucketMock = (overrides: Partial<RiskScoreBucket> = {}): RiskScoreBucket => ({
|
||||
key: { 'user.name': 'username' },
|
||||
doc_count: 2,
|
||||
risk_details: {
|
||||
value: {
|
||||
|
@ -16,11 +25,11 @@ const createRiskScoreBucketMock = (overrides: Partial<RiskScoreBucket> = {}): Ri
|
|||
normalized_score: 30.0,
|
||||
level: 'Unknown',
|
||||
notes: [],
|
||||
alerts_score: 30,
|
||||
other_score: 0,
|
||||
category_1_score: 30,
|
||||
category_1_count: 1,
|
||||
},
|
||||
},
|
||||
riskiest_inputs: {
|
||||
inputs: {
|
||||
took: 17,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
|
@ -34,28 +43,76 @@ const createRiskScoreBucketMock = (overrides: Partial<RiskScoreBucket> = {}): Ri
|
|||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
hits: [{ _id: '_id', _index: '_index', sort: [30] }],
|
||||
hits: [
|
||||
{
|
||||
_id: '_id',
|
||||
_index: '_index',
|
||||
fields: {
|
||||
'@timestamp': ['2023-07-20T20:31:24.896Z'],
|
||||
[ALERT_RISK_SCORE]: [21],
|
||||
[ALERT_RULE_NAME]: ['Rule Name'],
|
||||
},
|
||||
sort: [21],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAggregationResponseMock = (
|
||||
const buildAggregationResponseMock = (
|
||||
overrides: Partial<CalculateRiskScoreAggregations> = {}
|
||||
): CalculateRiskScoreAggregations => ({
|
||||
host: {
|
||||
after_key: { 'host.name': 'hostname' },
|
||||
buckets: [createRiskScoreBucketMock(), createRiskScoreBucketMock()],
|
||||
buckets: [
|
||||
buildRiskScoreBucketMock({ key: { 'host.name': 'hostname' } }),
|
||||
buildRiskScoreBucketMock({ key: { 'host.name': 'hostname' } }),
|
||||
],
|
||||
},
|
||||
user: {
|
||||
after_key: { 'user.name': 'username' },
|
||||
buckets: [createRiskScoreBucketMock(), createRiskScoreBucketMock()],
|
||||
buckets: [buildRiskScoreBucketMock(), buildRiskScoreBucketMock()],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const calculateRiskScoreMock = {
|
||||
createAggregationResponse: createAggregationResponseMock,
|
||||
createRiskScoreBucket: createRiskScoreBucketMock,
|
||||
const buildResponseMock = (
|
||||
overrides: Partial<CalculateScoresResponse> = {}
|
||||
): CalculateScoresResponse => ({
|
||||
after_keys: { host: { 'host.name': 'hostname' } },
|
||||
scores: {
|
||||
host: [
|
||||
{
|
||||
'@timestamp': '2021-08-19T20:55:59.000Z',
|
||||
id_field: 'host.name',
|
||||
id_value: 'hostname',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 20,
|
||||
calculated_score_norm: 30,
|
||||
category_1_score: 30,
|
||||
category_1_count: 12,
|
||||
notes: [],
|
||||
inputs: [
|
||||
{
|
||||
id: '_id',
|
||||
index: '_index',
|
||||
category: RiskCategories.category_1,
|
||||
description: 'Alert from Rule: My rule',
|
||||
risk_score: 30,
|
||||
timestamp: '2021-08-19T18:55:59.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
user: [],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const calculateRiskScoresMock = {
|
||||
buildResponse: buildResponseMock,
|
||||
buildAggregationResponse: buildAggregationResponseMock,
|
||||
buildRiskScoreBucket: buildRiskScoreBucketMock,
|
||||
};
|
||||
|
|
|
@ -9,7 +9,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server';
|
|||
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
||||
import { calculateRiskScores } from './calculate_risk_scores';
|
||||
import { calculateRiskScoreMock } from './calculate_risk_scores.mock';
|
||||
import { calculateRiskScoresMock } from './calculate_risk_scores.mock';
|
||||
|
||||
describe('calculateRiskScores()', () => {
|
||||
let params: Parameters<typeof calculateRiskScores>[0];
|
||||
|
@ -26,6 +26,7 @@ describe('calculateRiskScores()', () => {
|
|||
index: 'index',
|
||||
pageSize: 500,
|
||||
range: { start: 'now - 15d', end: 'now' },
|
||||
runtimeMappings: {},
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -134,45 +135,52 @@ describe('calculateRiskScores()', () => {
|
|||
beforeEach(() => {
|
||||
// stub out a reasonable response
|
||||
(esClient.search as jest.Mock).mockResolvedValueOnce({
|
||||
aggregations: calculateRiskScoreMock.createAggregationResponse(),
|
||||
aggregations: calculateRiskScoresMock.buildAggregationResponse(),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a flattened list of risk scores', async () => {
|
||||
const response = await calculateRiskScores(params);
|
||||
expect(response).toHaveProperty('scores');
|
||||
expect(response.scores).toHaveLength(4);
|
||||
expect(response.scores.host).toHaveLength(2);
|
||||
expect(response.scores.user).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns scores in the expected format', async () => {
|
||||
const {
|
||||
scores: [score],
|
||||
scores: { host: hostScores },
|
||||
} = await calculateRiskScores(params);
|
||||
const [score] = hostScores ?? [];
|
||||
expect(score).toEqual(
|
||||
expect.objectContaining({
|
||||
'@timestamp': expect.any(String),
|
||||
identifierField: expect.any(String),
|
||||
identifierValue: expect.any(String),
|
||||
level: 'Unknown',
|
||||
totalScore: expect.any(Number),
|
||||
totalScoreNormalized: expect.any(Number),
|
||||
alertsScore: expect.any(Number),
|
||||
otherScore: expect.any(Number),
|
||||
id_field: expect.any(String),
|
||||
id_value: expect.any(String),
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: expect.any(Number),
|
||||
calculated_score_norm: expect.any(Number),
|
||||
category_1_score: expect.any(Number),
|
||||
category_1_count: expect.any(Number),
|
||||
notes: expect.any(Array),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns risk inputs in the expected format', async () => {
|
||||
const {
|
||||
scores: [score],
|
||||
scores: { user: userScores },
|
||||
} = await calculateRiskScores(params);
|
||||
const [score] = userScores ?? [];
|
||||
expect(score).toEqual(
|
||||
expect.objectContaining({
|
||||
riskiestInputs: expect.arrayContaining([
|
||||
inputs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
index: expect.any(String),
|
||||
riskScore: expect.any(Number),
|
||||
category: expect.any(String),
|
||||
description: expect.any(String),
|
||||
risk_score: expect.any(Number),
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
@ -184,14 +192,14 @@ describe('calculateRiskScores()', () => {
|
|||
beforeEach(() => {
|
||||
// stub out a rejected response
|
||||
(esClient.search as jest.Mock).mockRejectedValueOnce({
|
||||
aggregations: calculateRiskScoreMock.createAggregationResponse(),
|
||||
aggregations: calculateRiskScoresMock.buildAggregationResponse(),
|
||||
});
|
||||
});
|
||||
|
||||
it('raises an error if elasticsearch client rejects', () => {
|
||||
expect.assertions(1);
|
||||
expect(() => calculateRiskScores(params)).rejects.toEqual({
|
||||
aggregations: calculateRiskScoreMock.createAggregationResponse(),
|
||||
aggregations: calculateRiskScoresMock.buildAggregationResponse(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,21 +12,24 @@ import type {
|
|||
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import {
|
||||
ALERT_RISK_SCORE,
|
||||
ALERT_RULE_NAME,
|
||||
EVENT_KIND,
|
||||
} from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
|
||||
import type { AfterKeys, IdentifierType, RiskWeights } from '../../../common/risk_engine';
|
||||
import { RiskCategories } from '../../../common/risk_engine';
|
||||
import { withSecuritySpan } from '../../utils/with_security_span';
|
||||
import { getAfterKeyForIdentifierType, getFieldForIdentifierAgg } from './helpers';
|
||||
import {
|
||||
buildCategoryScoreAssignment,
|
||||
buildCategoryCountDeclarations,
|
||||
buildCategoryAssignment,
|
||||
buildCategoryScoreDeclarations,
|
||||
buildWeightingOfScoreByCategory,
|
||||
getGlobalWeightForIdentifierType,
|
||||
} from './risk_weights';
|
||||
import type {
|
||||
CalculateRiskScoreAggregations,
|
||||
GetScoresParams,
|
||||
GetScoresResponse,
|
||||
CalculateScoresParams,
|
||||
CalculateScoresResponse,
|
||||
RiskScore,
|
||||
RiskScoreBucket,
|
||||
} from './types';
|
||||
|
@ -41,22 +44,25 @@ const bucketToResponse = ({
|
|||
identifierField: string;
|
||||
}): RiskScore => ({
|
||||
'@timestamp': now,
|
||||
identifierField,
|
||||
identifierValue: bucket.key[identifierField],
|
||||
level: bucket.risk_details.value.level,
|
||||
totalScore: bucket.risk_details.value.score,
|
||||
totalScoreNormalized: bucket.risk_details.value.normalized_score,
|
||||
alertsScore: bucket.risk_details.value.alerts_score,
|
||||
otherScore: bucket.risk_details.value.other_score,
|
||||
id_field: identifierField,
|
||||
id_value: bucket.key[identifierField],
|
||||
calculated_level: bucket.risk_details.value.level,
|
||||
calculated_score: bucket.risk_details.value.score,
|
||||
calculated_score_norm: bucket.risk_details.value.normalized_score,
|
||||
category_1_score: bucket.risk_details.value.category_1_score,
|
||||
category_1_count: bucket.risk_details.value.category_1_count,
|
||||
notes: bucket.risk_details.value.notes,
|
||||
riskiestInputs: bucket.riskiest_inputs.hits.hits.map((riskInput) => ({
|
||||
inputs: bucket.inputs.hits.hits.map((riskInput) => ({
|
||||
id: riskInput._id,
|
||||
index: riskInput._index,
|
||||
riskScore: riskInput.sort?.[0] ?? undefined,
|
||||
description: `Alert from Rule: ${riskInput.fields?.[ALERT_RULE_NAME]?.[0] ?? 'RULE_NOT_FOUND'}`,
|
||||
category: RiskCategories.category_1,
|
||||
risk_score: riskInput.fields?.[ALERT_RISK_SCORE]?.[0] ?? undefined,
|
||||
timestamp: riskInput.fields?.['@timestamp']?.[0] ?? undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
const filterFromRange = (range: GetScoresParams['range']): QueryDslQueryContainer => ({
|
||||
const filterFromRange = (range: CalculateScoresParams['range']): QueryDslQueryContainer => ({
|
||||
range: { '@timestamp': { lt: range.end, gte: range.start } },
|
||||
});
|
||||
|
||||
|
@ -80,13 +86,14 @@ const buildReduceScript = ({
|
|||
}
|
||||
|
||||
${buildCategoryScoreDeclarations()}
|
||||
${buildCategoryCountDeclarations()}
|
||||
|
||||
double total_score = 0;
|
||||
double current_score = 0;
|
||||
for (int i = 0; i < num_inputs_to_score; i++) {
|
||||
current_score = inputs[i].weighted_score / Math.pow(i + 1, params.p);
|
||||
|
||||
${buildCategoryScoreAssignment()}
|
||||
${buildCategoryAssignment()}
|
||||
total_score += current_score;
|
||||
}
|
||||
|
||||
|
@ -144,11 +151,12 @@ const buildIdentifierTypeAggregation = ({
|
|||
after: getAfterKeyForIdentifierType({ identifierType, afterKeys }),
|
||||
},
|
||||
aggs: {
|
||||
riskiest_inputs: {
|
||||
inputs: {
|
||||
top_hits: {
|
||||
size: 10,
|
||||
sort: { [ALERT_RISK_SCORE]: 'desc' },
|
||||
_source: false,
|
||||
docvalue_fields: ['@timestamp', ALERT_RISK_SCORE, ALERT_RULE_NAME],
|
||||
},
|
||||
},
|
||||
risk_details: {
|
||||
|
@ -191,11 +199,12 @@ export const calculateRiskScores = async ({
|
|||
logger,
|
||||
pageSize,
|
||||
range,
|
||||
runtimeMappings,
|
||||
weights,
|
||||
}: {
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
} & GetScoresParams): Promise<GetScoresResponse> =>
|
||||
} & CalculateScoresParams): Promise<CalculateScoresResponse> =>
|
||||
withSecuritySpan('calculateRiskScores', async () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
@ -209,6 +218,7 @@ export const calculateRiskScores = async ({
|
|||
size: 0,
|
||||
_source: false,
|
||||
index,
|
||||
runtime_mappings: runtimeMappings,
|
||||
query: {
|
||||
bool: {
|
||||
filter,
|
||||
|
@ -239,7 +249,10 @@ export const calculateRiskScores = async ({
|
|||
return {
|
||||
...(debug ? { request, response } : {}),
|
||||
after_keys: {},
|
||||
scores: [],
|
||||
scores: {
|
||||
host: [],
|
||||
user: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -251,27 +264,16 @@ export const calculateRiskScores = async ({
|
|||
user: response.aggregations.user?.after_key,
|
||||
};
|
||||
|
||||
const scores = userBuckets
|
||||
.map((bucket) =>
|
||||
bucketToResponse({
|
||||
bucket,
|
||||
identifierField: 'user.name',
|
||||
now,
|
||||
})
|
||||
)
|
||||
.concat(
|
||||
hostBuckets.map((bucket) =>
|
||||
bucketToResponse({
|
||||
bucket,
|
||||
identifierField: 'host.name',
|
||||
now,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
...(debug ? { request, response } : {}),
|
||||
after_keys: afterKeys,
|
||||
scores,
|
||||
scores: {
|
||||
host: hostBuckets.map((bucket) =>
|
||||
bucketToResponse({ bucket, identifierField: 'host.name', now })
|
||||
),
|
||||
user: userBuckets.map((bucket) =>
|
||||
bucketToResponse({ bucket, identifierField: 'user.name', now })
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import type { FieldMap } from '@kbn/alerts-as-data-utils';
|
||||
import type { IdentifierType } from '../../../common/risk_engine';
|
||||
import { RiskScoreEntity } from '../../../common/risk_engine/types';
|
||||
import type { IIndexPatternString } from './utils/create_datastream';
|
||||
|
||||
export const ilmPolicy = {
|
||||
|
@ -23,66 +25,114 @@ export const ilmPolicy = {
|
|||
},
|
||||
};
|
||||
|
||||
export const riskFieldMap: FieldMap = {
|
||||
const commonRiskFields: FieldMap = {
|
||||
id_field: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
id_value: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
calculated_level: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
calculated_score: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
calculated_score_norm: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
category_1_score: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
inputs: {
|
||||
type: 'object',
|
||||
array: true,
|
||||
required: false,
|
||||
},
|
||||
'inputs.id': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'inputs.index': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'inputs.category': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'inputs.description': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'inputs.risk_score': {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'inputs.timestamp': {
|
||||
type: 'date',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
notes: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const buildIdentityRiskFields = (identifierType: IdentifierType): FieldMap =>
|
||||
Object.keys(commonRiskFields).reduce((fieldMap, key) => {
|
||||
const identifierKey = `${identifierType}.risk.${key}`;
|
||||
fieldMap[identifierKey] = commonRiskFields[key];
|
||||
return fieldMap;
|
||||
}, {} as FieldMap);
|
||||
|
||||
export const riskScoreFieldMap: FieldMap = {
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
identifierField: {
|
||||
'host.name': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
identifierValue: {
|
||||
'host.risk': {
|
||||
type: 'object',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
...buildIdentityRiskFields(RiskScoreEntity.host),
|
||||
'user.name': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
level: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
totalScore: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
totalScoreNormalized: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
alertsScore: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
otherScore: {
|
||||
type: 'float',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
riskiestInputs: {
|
||||
type: 'nested',
|
||||
required: false,
|
||||
},
|
||||
'riskiestInputs.id': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'riskiestInputs.index': {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
'riskiestInputs.riskScore': {
|
||||
type: 'float',
|
||||
'user.risk': {
|
||||
type: 'object',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
...buildIdentityRiskFields(RiskScoreEntity.user),
|
||||
} as const;
|
||||
|
||||
export const ilmPolicyName = '.risk-score-ilm-policy';
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 { DataViewAttributes, SavedObject } from '@kbn/data-views-plugin/common';
|
||||
|
||||
export const buildDataViewResponseMock = (): SavedObject<DataViewAttributes> => ({
|
||||
id: 'security-solution-default',
|
||||
type: 'index-pattern',
|
||||
namespaces: ['default'],
|
||||
updated_at: '2023-06-07T18:57:09.766Z',
|
||||
created_at: '2023-06-07T18:57:09.766Z',
|
||||
version: 'WzUsMV0=',
|
||||
attributes: {
|
||||
fieldAttrs: '{}',
|
||||
title:
|
||||
'.alerts-security.alerts-default,apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,traces-apm*,winlogbeat-*,-*elastic-cloud-logs-*',
|
||||
timeFieldName: '@timestamp',
|
||||
sourceFilters: '[]',
|
||||
fields: '[]',
|
||||
fieldFormatMap: '{}',
|
||||
typeMeta: '{}',
|
||||
allowNoIndex: true,
|
||||
runtimeFieldMap: '{"custom":{"type":"keyword","script":{"source":"emit(\'hi mom\')"}}}',
|
||||
name: '.alerts-security.alerts-default,apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,traces-apm*,winlogbeat-*,-*elastic-cloud-logs-*',
|
||||
},
|
||||
references: [],
|
||||
managed: false,
|
||||
coreMigrationVersion: '8.8.0',
|
||||
typeMigrationVersion: '8.0.0',
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { getRiskInputsIndex } from './get_risk_inputs_index';
|
||||
import { buildDataViewResponseMock } from './get_risk_inputs_index.mock';
|
||||
|
||||
describe('getRiskInputsIndex', () => {
|
||||
let soClient: SavedObjectsClientContract;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
soClient = savedObjectsClientMock.create();
|
||||
logger = loggingSystemMock.create().get('security_solution');
|
||||
});
|
||||
|
||||
it('returns an index and runtimeMappings for an existing dataView', async () => {
|
||||
(soClient.get as jest.Mock).mockResolvedValueOnce(buildDataViewResponseMock());
|
||||
const {
|
||||
id,
|
||||
attributes: { runtimeFieldMap, title },
|
||||
} = buildDataViewResponseMock();
|
||||
|
||||
const { index, runtimeMappings } = await getRiskInputsIndex({
|
||||
dataViewId: id,
|
||||
logger,
|
||||
soClient,
|
||||
});
|
||||
|
||||
expect(index).toEqual(title);
|
||||
expect(runtimeMappings).toEqual(JSON.parse(runtimeFieldMap as string));
|
||||
});
|
||||
|
||||
it('returns the index and empty runtimeMappings for a nonexistent dataView', async () => {
|
||||
const { index, runtimeMappings } = await getRiskInputsIndex({
|
||||
dataViewId: 'my-data-view',
|
||||
logger,
|
||||
soClient,
|
||||
});
|
||||
expect(index).toEqual('my-data-view');
|
||||
expect(runtimeMappings).toEqual({});
|
||||
});
|
||||
|
||||
it('logs that the dataview was not found', async () => {
|
||||
await getRiskInputsIndex({
|
||||
dataViewId: 'my-data-view',
|
||||
logger,
|
||||
soClient,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"No dataview found for ID 'my-data-view'; using ID instead as simple index pattern"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { Logger, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import type { DataViewAttributes } from '@kbn/data-views-plugin/common';
|
||||
|
||||
interface RiskInputsIndexResponse {
|
||||
index: string;
|
||||
runtimeMappings: MappingRuntimeFields;
|
||||
}
|
||||
|
||||
export const getRiskInputsIndex = async ({
|
||||
dataViewId,
|
||||
logger,
|
||||
soClient,
|
||||
}: {
|
||||
dataViewId: string;
|
||||
logger: Logger;
|
||||
soClient: SavedObjectsClientContract;
|
||||
}): Promise<RiskInputsIndexResponse> => {
|
||||
try {
|
||||
const dataView = await soClient.get<DataViewAttributes>('index-pattern', dataViewId);
|
||||
const index = dataView.attributes.title;
|
||||
const runtimeMappings =
|
||||
dataView.attributes.runtimeFieldMap != null
|
||||
? JSON.parse(dataView.attributes.runtimeFieldMap)
|
||||
: {};
|
||||
|
||||
return {
|
||||
index,
|
||||
runtimeMappings,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.info(
|
||||
`No dataview found for ID '${dataViewId}'; using ID instead as simple index pattern`
|
||||
);
|
||||
return { index: dataViewId, runtimeMappings: {} };
|
||||
}
|
||||
};
|
|
@ -5,27 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Logger, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import type { DataViewAttributes } from '@kbn/data-views-plugin/common';
|
||||
import type { AfterKey, AfterKeys, IdentifierType } from '../../../common/risk_engine';
|
||||
|
||||
export const getRiskInputsIndex = async ({
|
||||
dataViewId,
|
||||
logger,
|
||||
soClient,
|
||||
}: {
|
||||
dataViewId: string;
|
||||
logger: Logger;
|
||||
soClient: SavedObjectsClientContract;
|
||||
}): Promise<string | undefined> => {
|
||||
try {
|
||||
const dataView = await soClient.get<DataViewAttributes>('index-pattern', dataViewId);
|
||||
return dataView.attributes.title;
|
||||
} catch (e) {
|
||||
logger.debug(`No dataview found for ID '${dataViewId}'`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getFieldForIdentifierAgg = (identifierType: IdentifierType): string =>
|
||||
identifierType === 'host' ? 'host.name' : 'user.name';
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ describe('RiskEngineDataClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('initializeResources succes', () => {
|
||||
describe('initializeResources success', () => {
|
||||
it('should initialize risk engine resources', async () => {
|
||||
await riskEngineDataClient.initializeResources({ namespace: 'default' });
|
||||
|
||||
|
@ -93,63 +93,145 @@ describe('RiskEngineDataClient', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(createOrUpdateComponentTemplate).toHaveBeenCalledWith({
|
||||
logger,
|
||||
esClient,
|
||||
template: {
|
||||
name: '.risk-score-mappings',
|
||||
_meta: {
|
||||
managed: true,
|
||||
},
|
||||
template: {
|
||||
settings: {},
|
||||
mappings: {
|
||||
dynamic: 'strict',
|
||||
properties: {
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
},
|
||||
alertsScore: {
|
||||
type: 'float',
|
||||
},
|
||||
identifierField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
identifierValue: {
|
||||
type: 'keyword',
|
||||
},
|
||||
level: {
|
||||
type: 'keyword',
|
||||
},
|
||||
otherScore: {
|
||||
type: 'float',
|
||||
},
|
||||
riskiestInputs: {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
index: {
|
||||
type: 'keyword',
|
||||
},
|
||||
riskScore: {
|
||||
type: 'float',
|
||||
},
|
||||
expect(createOrUpdateComponentTemplate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
logger,
|
||||
esClient,
|
||||
template: expect.objectContaining({
|
||||
name: '.risk-score-mappings',
|
||||
_meta: {
|
||||
managed: true,
|
||||
},
|
||||
}),
|
||||
totalFieldsLimit: 1000,
|
||||
})
|
||||
);
|
||||
expect((createOrUpdateComponentTemplate as jest.Mock).mock.lastCall[0].template.template)
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"mappings": Object {
|
||||
"dynamic": "strict",
|
||||
"properties": Object {
|
||||
"@timestamp": Object {
|
||||
"type": "date",
|
||||
},
|
||||
"host": Object {
|
||||
"properties": Object {
|
||||
"name": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"risk": Object {
|
||||
"properties": Object {
|
||||
"calculated_level": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"calculated_score": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"calculated_score_norm": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"category_1_score": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"id_field": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"id_value": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"inputs": Object {
|
||||
"properties": Object {
|
||||
"category": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"description": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"id": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"index": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"risk_score": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"timestamp": Object {
|
||||
"type": "date",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"notes": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
type: 'nested',
|
||||
},
|
||||
totalScore: {
|
||||
type: 'float',
|
||||
},
|
||||
totalScoreNormalized: {
|
||||
type: 'float',
|
||||
},
|
||||
"user": Object {
|
||||
"properties": Object {
|
||||
"name": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"risk": Object {
|
||||
"properties": Object {
|
||||
"calculated_level": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"calculated_score": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"calculated_score_norm": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"category_1_score": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"id_field": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"id_value": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"inputs": Object {
|
||||
"properties": Object {
|
||||
"category": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"description": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"id": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"index": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
"risk_score": Object {
|
||||
"type": "float",
|
||||
},
|
||||
"timestamp": Object {
|
||||
"type": "date",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"notes": Object {
|
||||
"type": "keyword",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
totalFieldsLimit,
|
||||
});
|
||||
"settings": Object {},
|
||||
}
|
||||
`);
|
||||
|
||||
expect(createOrUpdateIndexTemplate).toHaveBeenCalledWith({
|
||||
logger,
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Metadata } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
import {
|
||||
|
@ -15,7 +16,7 @@ import { mappingFromFieldMap } from '@kbn/alerting-plugin/common';
|
|||
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
|
||||
import type { Logger, ElasticsearchClient } from '@kbn/core/server';
|
||||
import {
|
||||
riskFieldMap,
|
||||
riskScoreFieldMap,
|
||||
getIndexPattern,
|
||||
totalFieldsLimit,
|
||||
mappingComponentName,
|
||||
|
@ -23,6 +24,8 @@ import {
|
|||
ilmPolicy,
|
||||
} from './configurations';
|
||||
import { createDataStream } from './utils/create_datastream';
|
||||
import type { RiskEngineDataWriter as Writer } from './risk_engine_data_writer';
|
||||
import { RiskEngineDataWriter } from './risk_engine_data_writer';
|
||||
|
||||
interface InitializeRiskEngineResourcesOpts {
|
||||
namespace?: string;
|
||||
|
@ -34,10 +37,6 @@ interface RiskEngineDataClientOpts {
|
|||
elasticsearchClientPromise: Promise<ElasticsearchClient>;
|
||||
}
|
||||
|
||||
interface Writer {
|
||||
bulk: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class RiskEngineDataClient {
|
||||
private writerCache: Map<string, Writer> = new Map();
|
||||
constructor(private readonly options: RiskEngineDataClientOpts) {}
|
||||
|
@ -51,10 +50,13 @@ export class RiskEngineDataClient {
|
|||
return this.writerCache.get(namespace) as Writer;
|
||||
}
|
||||
|
||||
private async initializeWriter(namespace: string): Promise<Writer> {
|
||||
const writer: Writer = {
|
||||
bulk: async () => {},
|
||||
};
|
||||
private async initializeWriter(namespace: string, index: string): Promise<Writer> {
|
||||
const writer = new RiskEngineDataWriter({
|
||||
esClient: await this.options.elasticsearchClientPromise,
|
||||
namespace,
|
||||
index,
|
||||
logger: this.options.logger,
|
||||
});
|
||||
this.writerCache.set(namespace, writer);
|
||||
return writer;
|
||||
}
|
||||
|
@ -92,7 +94,7 @@ export class RiskEngineDataClient {
|
|||
},
|
||||
template: {
|
||||
settings: {},
|
||||
mappings: mappingFromFieldMap(riskFieldMap, 'strict'),
|
||||
mappings: mappingFromFieldMap(riskScoreFieldMap, 'strict'),
|
||||
},
|
||||
} as ClusterPutComponentTemplateRequest,
|
||||
totalFieldsLimit,
|
||||
|
@ -134,7 +136,7 @@ export class RiskEngineDataClient {
|
|||
indexPatterns,
|
||||
});
|
||||
|
||||
this.initializeWriter(namespace);
|
||||
await this.initializeWriter(namespace, indexPatterns.alias);
|
||||
} catch (error) {
|
||||
this.options.logger.error(`Error initializing risk engine resources: ${error.message}`);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,317 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { RiskEngineDataWriter } from './risk_engine_data_writer';
|
||||
import { riskScoreServiceMock } from './risk_score_service.mock';
|
||||
|
||||
describe('RiskEngineDataWriter', () => {
|
||||
describe('#bulk', () => {
|
||||
let writer: RiskEngineDataWriter;
|
||||
let esClientMock: ElasticsearchClient;
|
||||
let loggerMock: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
esClientMock = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser;
|
||||
loggerMock = loggingSystemMock.createLogger();
|
||||
writer = new RiskEngineDataWriter({
|
||||
esClient: esClientMock,
|
||||
logger: loggerMock,
|
||||
index: 'risk-score.risk-score-default',
|
||||
namespace: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts a list of host risk scores to an appropriate list of operations', async () => {
|
||||
await writer.bulk({
|
||||
host: [riskScoreServiceMock.createRiskScore(), riskScoreServiceMock.createRiskScore()],
|
||||
});
|
||||
|
||||
const [{ operations }] = (esClientMock.bulk as jest.Mock).mock.lastCall;
|
||||
|
||||
expect(operations).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"host": Object {
|
||||
"name": "hostname",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "host.name",
|
||||
"id_value": "hostname",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"host": Object {
|
||||
"name": "hostname",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "host.name",
|
||||
"id_value": "hostname",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('converts a list of user risk scores to an appropriate list of operations', async () => {
|
||||
await writer.bulk({
|
||||
user: [
|
||||
riskScoreServiceMock.createRiskScore({
|
||||
id_field: 'user.name',
|
||||
id_value: 'username_1',
|
||||
}),
|
||||
riskScoreServiceMock.createRiskScore({
|
||||
id_field: 'user.name',
|
||||
id_value: 'username_2',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const [{ operations }] = (esClientMock.bulk as jest.Mock).mock.lastCall;
|
||||
|
||||
expect(operations).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"user": Object {
|
||||
"name": "username_1",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "user.name",
|
||||
"id_value": "username_1",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"user": Object {
|
||||
"name": "username_2",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "user.name",
|
||||
"id_value": "username_2",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('converts a list of mixed risk scores to an appropriate list of operations', async () => {
|
||||
await writer.bulk({
|
||||
host: [
|
||||
riskScoreServiceMock.createRiskScore({
|
||||
id_field: 'host.name',
|
||||
id_value: 'hostname_1',
|
||||
}),
|
||||
],
|
||||
user: [
|
||||
riskScoreServiceMock.createRiskScore({
|
||||
id_field: 'user.name',
|
||||
id_value: 'username_1',
|
||||
}),
|
||||
riskScoreServiceMock.createRiskScore({
|
||||
id_field: 'user.name',
|
||||
id_value: 'username_2',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const [{ operations }] = (esClientMock.bulk as jest.Mock).mock.lastCall;
|
||||
|
||||
expect(operations).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"host": Object {
|
||||
"name": "hostname_1",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "host.name",
|
||||
"id_value": "hostname_1",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"user": Object {
|
||||
"name": "username_1",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "user.name",
|
||||
"id_value": "username_1",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"create": Object {
|
||||
"_index": "risk-score.risk-score-default",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"@timestamp": "2023-02-15T00:15:19.231Z",
|
||||
"user": Object {
|
||||
"name": "username_2",
|
||||
"risk": Object {
|
||||
"calculated_level": "High",
|
||||
"calculated_score": 149,
|
||||
"calculated_score_norm": 85.332,
|
||||
"category_1_count": 12,
|
||||
"category_1_score": 85,
|
||||
"id_field": "user.name",
|
||||
"id_value": "username_2",
|
||||
"inputs": Array [],
|
||||
"notes": Array [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns an error if something went wrong', async () => {
|
||||
(esClientMock.bulk as jest.Mock).mockRejectedValue(new Error('something went wrong'));
|
||||
|
||||
const { errors } = await writer.bulk({
|
||||
host: [riskScoreServiceMock.createRiskScore()],
|
||||
});
|
||||
|
||||
expect(errors).toEqual(['something went wrong']);
|
||||
});
|
||||
|
||||
it('returns the time it took to write the risk scores', async () => {
|
||||
(esClientMock.bulk as jest.Mock).mockResolvedValue({
|
||||
took: 123,
|
||||
items: [],
|
||||
});
|
||||
|
||||
const { took } = await writer.bulk({
|
||||
host: [riskScoreServiceMock.createRiskScore()],
|
||||
});
|
||||
|
||||
expect(took).toEqual(123);
|
||||
});
|
||||
|
||||
it('returns the number of docs written', async () => {
|
||||
(esClientMock.bulk as jest.Mock).mockResolvedValue({
|
||||
items: [{ create: { status: 201 } }, { create: { status: 200 } }],
|
||||
});
|
||||
|
||||
const { docs_written: docsWritten } = await writer.bulk({
|
||||
host: [riskScoreServiceMock.createRiskScore()],
|
||||
});
|
||||
|
||||
expect(docsWritten).toEqual(2);
|
||||
});
|
||||
|
||||
describe('when some documents failed to be written', () => {
|
||||
beforeEach(() => {
|
||||
(esClientMock.bulk as jest.Mock).mockResolvedValue({
|
||||
errors: true,
|
||||
items: [
|
||||
{ create: { status: 201 } },
|
||||
{ create: { status: 500, error: { reason: 'something went wrong' } } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the number of docs written', async () => {
|
||||
const { docs_written: docsWritten } = await writer.bulk({
|
||||
host: [riskScoreServiceMock.createRiskScore()],
|
||||
});
|
||||
|
||||
expect(docsWritten).toEqual(1);
|
||||
});
|
||||
|
||||
it('returns the errors', async () => {
|
||||
const { errors } = await writer.bulk({
|
||||
host: [riskScoreServiceMock.createRiskScore()],
|
||||
});
|
||||
|
||||
expect(errors).toEqual(['something went wrong']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are no risk scores to write', () => {
|
||||
it('returns an appropriate response', async () => {
|
||||
const response = await writer.bulk({});
|
||||
expect(response).toEqual({ errors: [], docs_written: 0, took: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* 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 { BulkOperationContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { Logger, ElasticsearchClient } from '@kbn/core/server';
|
||||
import type { IdentifierType } from '../../../common/risk_engine';
|
||||
import type { RiskScore } from './types';
|
||||
|
||||
interface WriterBulkResponse {
|
||||
errors: string[];
|
||||
docs_written: number;
|
||||
took: number;
|
||||
}
|
||||
|
||||
interface BulkParams {
|
||||
host?: RiskScore[];
|
||||
user?: RiskScore[];
|
||||
}
|
||||
|
||||
export interface RiskEngineDataWriter {
|
||||
bulk: (params: BulkParams) => Promise<WriterBulkResponse>;
|
||||
}
|
||||
|
||||
interface RiskEngineDataWriterOptions {
|
||||
esClient: ElasticsearchClient;
|
||||
index: string;
|
||||
namespace: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class RiskEngineDataWriter implements RiskEngineDataWriter {
|
||||
constructor(private readonly options: RiskEngineDataWriterOptions) {}
|
||||
|
||||
public bulk = async (params: BulkParams) => {
|
||||
try {
|
||||
if (!params.host?.length && !params.user?.length) {
|
||||
return { errors: [], docs_written: 0, took: 0 };
|
||||
}
|
||||
|
||||
const { errors, items, took } = await this.options.esClient.bulk({
|
||||
operations: this.buildBulkOperations(params),
|
||||
});
|
||||
|
||||
return {
|
||||
errors: errors
|
||||
? items
|
||||
.map((item) => item.create?.error?.reason)
|
||||
.filter((error): error is string => !!error)
|
||||
: [],
|
||||
docs_written: items.filter(
|
||||
(item) => item.create?.status === 201 || item.create?.status === 200
|
||||
).length,
|
||||
took,
|
||||
};
|
||||
} catch (e) {
|
||||
this.options.logger.error(`Error writing risk scores: ${e.message}`);
|
||||
return {
|
||||
errors: [`${e.message}`],
|
||||
docs_written: 0,
|
||||
took: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private buildBulkOperations = (params: BulkParams): BulkOperationContainer[] => {
|
||||
const hostBody =
|
||||
params.host?.flatMap((score) => [
|
||||
{ create: { _index: this.options.index } },
|
||||
this.scoreToEcs(score, 'host'),
|
||||
]) ?? [];
|
||||
|
||||
const userBody =
|
||||
params.user?.flatMap((score) => [
|
||||
{ create: { _index: this.options.index } },
|
||||
this.scoreToEcs(score, 'user'),
|
||||
]) ?? [];
|
||||
|
||||
return hostBody.concat(userBody) as BulkOperationContainer[];
|
||||
};
|
||||
|
||||
private scoreToEcs = (score: RiskScore, identifierType: IdentifierType): unknown => {
|
||||
const { '@timestamp': _, ...rest } = score;
|
||||
return {
|
||||
'@timestamp': score['@timestamp'],
|
||||
[identifierType]: {
|
||||
name: score.id_value,
|
||||
risk: {
|
||||
...rest,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
|
@ -10,20 +10,21 @@ import type { RiskScore } from './types';
|
|||
|
||||
const createRiskScoreMock = (overrides: Partial<RiskScore> = {}): RiskScore => ({
|
||||
'@timestamp': '2023-02-15T00:15:19.231Z',
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'hostname',
|
||||
level: 'High',
|
||||
totalScore: 149,
|
||||
totalScoreNormalized: 85.332,
|
||||
alertsScore: 85,
|
||||
otherScore: 0,
|
||||
id_field: 'host.name',
|
||||
id_value: 'hostname',
|
||||
calculated_level: 'High',
|
||||
calculated_score: 149,
|
||||
calculated_score_norm: 85.332,
|
||||
category_1_score: 85,
|
||||
category_1_count: 12,
|
||||
notes: [],
|
||||
riskiestInputs: [],
|
||||
inputs: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createRiskScoreServiceMock = (): jest.Mocked<RiskScoreService> => ({
|
||||
getScores: jest.fn(),
|
||||
calculateScores: jest.fn(),
|
||||
calculateAndPersistScores: jest.fn(),
|
||||
});
|
||||
|
||||
export const riskScoreServiceMock = {
|
||||
|
|
|
@ -6,19 +6,37 @@
|
|||
*/
|
||||
|
||||
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import type { GetScoresParams, GetScoresResponse } from './types';
|
||||
import type {
|
||||
CalculateAndPersistScoresParams,
|
||||
CalculateAndPersistScoresResponse,
|
||||
CalculateScoresParams,
|
||||
CalculateScoresResponse,
|
||||
} from './types';
|
||||
import { calculateRiskScores } from './calculate_risk_scores';
|
||||
import { calculateAndPersistRiskScores } from './calculate_and_persist_risk_scores';
|
||||
import type { RiskEngineDataClient } from './risk_engine_data_client';
|
||||
|
||||
export interface RiskScoreService {
|
||||
getScores: (params: GetScoresParams) => Promise<GetScoresResponse>;
|
||||
calculateScores: (params: CalculateScoresParams) => Promise<CalculateScoresResponse>;
|
||||
calculateAndPersistScores: (
|
||||
params: CalculateAndPersistScoresParams
|
||||
) => Promise<CalculateAndPersistScoresResponse>;
|
||||
}
|
||||
|
||||
export const riskScoreService = ({
|
||||
esClient,
|
||||
logger,
|
||||
}: {
|
||||
export interface RiskScoreServiceFactoryParams {
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
}): RiskScoreService => ({
|
||||
getScores: (params) => calculateRiskScores({ ...params, esClient, logger }),
|
||||
riskEngineDataClient: RiskEngineDataClient;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export const riskScoreServiceFactory = ({
|
||||
esClient,
|
||||
logger,
|
||||
riskEngineDataClient,
|
||||
spaceId,
|
||||
}: RiskScoreServiceFactoryParams): RiskScoreService => ({
|
||||
calculateScores: (params) => calculateRiskScores({ ...params, esClient, logger }),
|
||||
calculateAndPersistScores: (params) =>
|
||||
calculateAndPersistRiskScores({ ...params, esClient, logger, riskEngineDataClient, spaceId }),
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { RiskWeightTypes, RiskCategories } from '../../../common/risk_engine';
|
||||
import {
|
||||
buildCategoryScoreAssignment,
|
||||
buildCategoryAssignment,
|
||||
buildCategoryWeights,
|
||||
buildWeightingOfScoreByCategory,
|
||||
} from './risk_weights';
|
||||
|
@ -17,37 +17,47 @@ describe('buildCategoryWeights', () => {
|
|||
const result = buildCategoryWeights();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ host: 1, type: RiskWeightTypes.riskCategory, user: 1, value: RiskCategories.alerts },
|
||||
{ host: 1, type: RiskWeightTypes.riskCategory, user: 1, value: RiskCategories.category_1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows user weights to override defaults', () => {
|
||||
const result = buildCategoryWeights([
|
||||
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.alerts, host: 0.1, user: 0.2 },
|
||||
{
|
||||
type: RiskWeightTypes.riskCategory,
|
||||
value: RiskCategories.category_1,
|
||||
host: 0.1,
|
||||
user: 0.2,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ host: 0.1, type: RiskWeightTypes.riskCategory, user: 0.2, value: RiskCategories.alerts },
|
||||
{
|
||||
host: 0.1,
|
||||
type: RiskWeightTypes.riskCategory,
|
||||
user: 0.2,
|
||||
value: RiskCategories.category_1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses default category weights if unspecified in user-provided weight', () => {
|
||||
const result = buildCategoryWeights([
|
||||
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.alerts, host: 0.1 },
|
||||
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.category_1, host: 0.1 },
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ host: 0.1, type: RiskWeightTypes.riskCategory, user: 1, value: RiskCategories.alerts },
|
||||
{ host: 0.1, type: RiskWeightTypes.riskCategory, user: 1, value: RiskCategories.category_1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCategoryScoreAssignment', () => {
|
||||
describe('buildCategoryAssignment', () => {
|
||||
it('builds the expected assignment statement', () => {
|
||||
const result = buildCategoryScoreAssignment();
|
||||
const result = buildCategoryAssignment();
|
||||
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"if (inputs[i].category == 'signal') { results['alerts_score'] += current_score; } else { results['other_score'] += current_score; }"`
|
||||
`"if (inputs[i].category == 'signal') { results['category_1_score'] += current_score; results['category_1_count'] += 1; }"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -83,7 +93,12 @@ describe('buildWeightingOfScoreByCategory', () => {
|
|||
it('returns specified weight when a category weight is provided', () => {
|
||||
const result = buildWeightingOfScoreByCategory({
|
||||
userWeights: [
|
||||
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.alerts, host: 0.1, user: 0.2 },
|
||||
{
|
||||
type: RiskWeightTypes.riskCategory,
|
||||
value: RiskCategories.category_1,
|
||||
host: 0.1,
|
||||
user: 0.2,
|
||||
},
|
||||
],
|
||||
identifierType: 'host',
|
||||
});
|
||||
|
@ -96,7 +111,7 @@ describe('buildWeightingOfScoreByCategory', () => {
|
|||
it('returns a default weight when a category weight is provided but not the one being used', () => {
|
||||
const result = buildWeightingOfScoreByCategory({
|
||||
userWeights: [
|
||||
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.alerts, host: 0.1 },
|
||||
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.category_1, host: 0.1 },
|
||||
],
|
||||
identifierType: 'user',
|
||||
});
|
||||
|
|
|
@ -28,7 +28,7 @@ const DEFAULT_CATEGORY_WEIGHTS: RiskWeights = RISK_CATEGORIES.map((category) =>
|
|||
* This function and its use can be deleted once we've replaced our use of event.kind with a proper risk category field.
|
||||
*/
|
||||
const convertCategoryToEventKindValue = (category?: string): string | undefined =>
|
||||
category === 'alerts' ? 'signal' : category;
|
||||
category === 'category_1' ? 'signal' : category;
|
||||
|
||||
const isGlobalIdentifierTypeWeight = (weight: RiskWeight): weight is GlobalRiskWeight =>
|
||||
weight.type === RiskWeightTypes.global;
|
||||
|
@ -54,11 +54,11 @@ const getWeightForIdentifierType = (weight: RiskWeight, identifierType: Identifi
|
|||
};
|
||||
|
||||
export const buildCategoryScoreDeclarations = (): string => {
|
||||
const otherScoreDeclaration = `results['other_score'] = 0;`;
|
||||
return RISK_CATEGORIES.map((riskCategory) => `results['${riskCategory}_score'] = 0;`).join('');
|
||||
};
|
||||
|
||||
return RISK_CATEGORIES.map((riskCategory) => `results['${riskCategory}_score'] = 0;`)
|
||||
.join('')
|
||||
.concat(otherScoreDeclaration);
|
||||
export const buildCategoryCountDeclarations = (): string => {
|
||||
return RISK_CATEGORIES.map((riskCategory) => `results['${riskCategory}_count'] = 0;`).join('');
|
||||
};
|
||||
|
||||
export const buildCategoryWeights = (userWeights?: RiskWeights): RiskCategoryRiskWeight[] => {
|
||||
|
@ -69,17 +69,13 @@ export const buildCategoryWeights = (userWeights?: RiskWeights): RiskCategoryRis
|
|||
);
|
||||
};
|
||||
|
||||
export const buildCategoryScoreAssignment = (): string => {
|
||||
const otherClause = `results['other_score'] += current_score;`;
|
||||
|
||||
export const buildCategoryAssignment = (): string => {
|
||||
return RISK_CATEGORIES.map(
|
||||
(category) =>
|
||||
`if (inputs[i].category == '${convertCategoryToEventKindValue(
|
||||
category
|
||||
)}') { results['${category}_score'] += current_score; }`
|
||||
)
|
||||
.join(' else ')
|
||||
.concat(` else { ${otherClause} }`);
|
||||
)}') { results['${category}_score'] += current_score; results['${category}_count'] += 1; }`
|
||||
).join(' else ');
|
||||
};
|
||||
|
||||
export const buildWeightingOfScoreByCategory = ({
|
||||
|
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* 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 { riskScoreCalculationRoute } from './risk_score_calculation_route';
|
||||
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
import { RISK_SCORE_CALCULATION_URL } from '../../../../common/constants';
|
||||
import {
|
||||
serverMock,
|
||||
requestContextMock,
|
||||
requestMock,
|
||||
} from '../../detection_engine/routes/__mocks__';
|
||||
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';
|
||||
|
||||
jest.mock('../get_risk_inputs_index');
|
||||
jest.mock('../risk_score_service');
|
||||
|
||||
describe('risk score calculation route', () => {
|
||||
let server: ReturnType<typeof serverMock.create>;
|
||||
let { clients, context } = requestContextMock.createTools();
|
||||
let logger: ReturnType<typeof loggerMock.create>;
|
||||
let mockRiskScoreService: ReturnType<typeof riskScoreServiceMock.create>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
server = serverMock.create();
|
||||
logger = loggerMock.create();
|
||||
({ clients, context } = requestContextMock.createTools());
|
||||
mockRiskScoreService = riskScoreServiceMock.create();
|
||||
|
||||
(getRiskInputsIndex as jest.Mock).mockResolvedValue({
|
||||
index: 'default-dataview-index',
|
||||
runtimeMappings: {},
|
||||
});
|
||||
clients.appClient.getAlertsIndex.mockReturnValue('default-alerts-index');
|
||||
(riskScoreServiceFactory as jest.Mock).mockReturnValue(mockRiskScoreService);
|
||||
|
||||
riskScoreCalculationRoute(server.router, logger);
|
||||
});
|
||||
|
||||
const buildRequest = (overrides: object = {}) => {
|
||||
const defaults = {
|
||||
data_view_id: 'default-dataview-id',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
identifier_type: 'host',
|
||||
};
|
||||
|
||||
return requestMock.create({
|
||||
method: 'post',
|
||||
path: RISK_SCORE_CALCULATION_URL,
|
||||
body: { ...defaults, ...overrides },
|
||||
});
|
||||
};
|
||||
|
||||
it('should return 200 when risk score calculation is successful', async () => {
|
||||
mockRiskScoreService.calculateAndPersistScores.mockResolvedValue(
|
||||
calculateAndPersistRiskScoresMock.buildResponse()
|
||||
);
|
||||
const request = buildRequest();
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
||||
describe('parameters', () => {
|
||||
it('accepts a parameter for the dataview', async () => {
|
||||
const request = buildRequest({ data_view_id: 'custom-dataview-id' });
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(getRiskInputsIndex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataViewId: 'custom-dataview-id' })
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a parameter for the range', async () => {
|
||||
const request = buildRequest({ range: { start: 'now-30d', end: 'now-20d' } });
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.calculateAndPersistScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ range: { start: 'now-30d', end: 'now-20d' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
describe('required parameters', () => {
|
||||
it('requires a parameter for the dataview', async () => {
|
||||
const request = buildRequest({ data_view_id: undefined });
|
||||
const result = await server.validate(request);
|
||||
|
||||
expect(result.badRequest).toHaveBeenCalledWith(
|
||||
'Invalid value "undefined" supplied to "data_view_id"'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a parameter for the date range', async () => {
|
||||
const request = buildRequest({ range: undefined });
|
||||
const result = await server.validate(request);
|
||||
|
||||
expect(result.badRequest).toHaveBeenCalledWith(
|
||||
'Invalid value "undefined" supplied to "range"'
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'Invalid value "undefined" supplied to "identifier_type"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an unknown dataview as index pattern', async () => {
|
||||
const request = buildRequest({ data_view_id: 'unknown-dataview' });
|
||||
(getRiskInputsIndex as jest.Mock).mockResolvedValue({
|
||||
index: 'unknown-dataview',
|
||||
runtimeMappings: {},
|
||||
});
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.calculateAndPersistScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 'unknown-dataview', runtimeMappings: {} })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid date range', async () => {
|
||||
const request = buildRequest({ range: 'bad range' });
|
||||
const result = await server.validate(request);
|
||||
|
||||
expect(result.badRequest).toHaveBeenCalledWith(
|
||||
'Invalid value "bad range" supplied to "range"'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 {
|
||||
DEFAULT_RISK_SCORE_PAGE_SIZE,
|
||||
RISK_SCORE_CALCULATION_URL,
|
||||
} from '../../../../common/constants';
|
||||
import { riskScoreCalculationRequestSchema } from '../../../../common/risk_engine/risk_score_calculation/request_schema';
|
||||
import type { SecuritySolutionPluginRouter } from '../../../types';
|
||||
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
|
||||
import { riskScoreServiceFactory } from '../risk_score_service';
|
||||
import { getRiskInputsIndex } from '../get_risk_inputs_index';
|
||||
|
||||
export const riskScoreCalculationRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => {
|
||||
router.post(
|
||||
{
|
||||
path: RISK_SCORE_CALCULATION_URL,
|
||||
validate: { body: buildRouteValidation(riskScoreCalculationRequestSchema) },
|
||||
options: {
|
||||
tags: ['access:securitySolution'],
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
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 riskScoreService = riskScoreServiceFactory({
|
||||
esClient,
|
||||
logger,
|
||||
riskEngineDataClient,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const {
|
||||
after_keys: userAfterKeys,
|
||||
data_view_id: dataViewId,
|
||||
debug,
|
||||
page_size: userPageSize,
|
||||
identifier_type: identifierType,
|
||||
filter,
|
||||
range,
|
||||
weights,
|
||||
} = request.body;
|
||||
|
||||
try {
|
||||
const { index, runtimeMappings } = await getRiskInputsIndex({
|
||||
dataViewId,
|
||||
logger,
|
||||
soClient,
|
||||
});
|
||||
|
||||
const afterKeys = userAfterKeys ?? {};
|
||||
const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE;
|
||||
|
||||
const result = await riskScoreService.calculateAndPersistScores({
|
||||
afterKeys,
|
||||
debug,
|
||||
pageSize,
|
||||
identifierType,
|
||||
index,
|
||||
filter,
|
||||
range,
|
||||
runtimeMappings,
|
||||
weights,
|
||||
});
|
||||
|
||||
return response.ok({ body: result });
|
||||
} catch (e) {
|
||||
const error = transformError(e);
|
||||
|
||||
return siemResponse.error({
|
||||
statusCode: error.statusCode,
|
||||
body: { message: error.message, full_error: JSON.stringify(e) },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -14,11 +14,13 @@ import {
|
|||
requestContextMock,
|
||||
requestMock,
|
||||
} from '../../detection_engine/routes/__mocks__';
|
||||
import { riskScoreService } from '../risk_score_service';
|
||||
import { getRiskInputsIndex } from '../get_risk_inputs_index';
|
||||
import { riskScoreServiceFactory } from '../risk_score_service';
|
||||
import { riskScoreServiceMock } from '../risk_score_service.mock';
|
||||
import { riskScorePreviewRoute } from './risk_score_preview_route';
|
||||
|
||||
jest.mock('../risk_score_service');
|
||||
jest.mock('../get_risk_inputs_index');
|
||||
|
||||
describe('POST risk_engine/preview route', () => {
|
||||
let server: ReturnType<typeof serverMock.create>;
|
||||
|
@ -33,9 +35,15 @@ describe('POST risk_engine/preview route', () => {
|
|||
logger = loggerMock.create();
|
||||
({ clients, context } = requestContextMock.createTools());
|
||||
mockRiskScoreService = riskScoreServiceMock.create();
|
||||
(getRiskInputsIndex as jest.Mock).mockImplementationOnce(
|
||||
async ({ dataViewId }: { dataViewId: string }) => ({
|
||||
index: dataViewId,
|
||||
runtimeMappings: {},
|
||||
})
|
||||
);
|
||||
|
||||
clients.appClient.getAlertsIndex.mockReturnValue('default-alerts-index');
|
||||
(riskScoreService as jest.Mock).mockReturnValue(mockRiskScoreService);
|
||||
(riskScoreServiceFactory as jest.Mock).mockReturnValue(mockRiskScoreService);
|
||||
|
||||
riskScorePreviewRoute(server.router, logger);
|
||||
});
|
||||
|
@ -44,50 +52,47 @@ describe('POST risk_engine/preview route', () => {
|
|||
requestMock.create({
|
||||
method: 'get',
|
||||
path: RISK_SCORE_PREVIEW_URL,
|
||||
body,
|
||||
body: {
|
||||
data_view_id: 'default-dataview-id',
|
||||
...body,
|
||||
},
|
||||
});
|
||||
|
||||
describe('parameters', () => {
|
||||
describe('index / dataview', () => {
|
||||
it('defaults to scoring the alerts index if no dataview is provided', async () => {
|
||||
const request = buildRequest();
|
||||
it('requires a parameter for the dataview', async () => {
|
||||
const request = buildRequest({ data_view_id: undefined });
|
||||
const result = await server.validate(request);
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 'default-alerts-index' })
|
||||
expect(result.badRequest).toHaveBeenCalledWith(
|
||||
'Invalid value "undefined" supplied to "data_view_id"'
|
||||
);
|
||||
});
|
||||
|
||||
it('respects the provided dataview', async () => {
|
||||
const request = buildRequest({ data_view_id: 'custom-dataview-id' });
|
||||
|
||||
// mock call to get dataview title
|
||||
clients.savedObjectsClient.get.mockResolvedValueOnce({
|
||||
id: '',
|
||||
type: '',
|
||||
references: [],
|
||||
attributes: { title: 'custom-dataview-index' },
|
||||
});
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 'custom-dataview-index' })
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 'custom-dataview-id' })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a 404 if dataview is not found', async () => {
|
||||
const request = buildRequest({ data_view_id: 'custom-dataview-id' });
|
||||
it('uses an unknown dataview as index pattern', async () => {
|
||||
const request = buildRequest({ data_view_id: 'unknown-dataview' });
|
||||
(getRiskInputsIndex as jest.Mock).mockResolvedValue({
|
||||
index: 'unknown-dataview',
|
||||
runtimeMappings: {},
|
||||
});
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.body.message).toEqual(
|
||||
'The specified dataview (custom-dataview-id) was not found. Please use an existing dataview, or omit the parameter to use the default risk inputs.'
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 'unknown-dataview', runtimeMappings: {} })
|
||||
);
|
||||
expect(mockRiskScoreService.getScores).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -97,7 +102,7 @@ describe('POST risk_engine/preview route', () => {
|
|||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ range: { start: 'now-15d', end: 'now' } })
|
||||
);
|
||||
});
|
||||
|
@ -107,7 +112,7 @@ describe('POST risk_engine/preview route', () => {
|
|||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ range: { start: 'now-30d', end: 'now-20d' } })
|
||||
);
|
||||
});
|
||||
|
@ -142,7 +147,7 @@ describe('POST risk_engine/preview route', () => {
|
|||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: {
|
||||
bool: {
|
||||
|
@ -166,7 +171,7 @@ describe('POST risk_engine/preview route', () => {
|
|||
weights: [
|
||||
{
|
||||
type: RiskWeightTypes.riskCategory,
|
||||
value: RiskCategories.alerts,
|
||||
value: RiskCategories.category_1,
|
||||
host: 0.1,
|
||||
user: 0.2,
|
||||
},
|
||||
|
@ -176,12 +181,12 @@ describe('POST risk_engine/preview route', () => {
|
|||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
weights: [
|
||||
{
|
||||
type: RiskWeightTypes.riskCategory,
|
||||
value: RiskCategories.alerts,
|
||||
value: RiskCategories.category_1,
|
||||
host: 0.1,
|
||||
user: 0.2,
|
||||
},
|
||||
|
@ -195,7 +200,7 @@ describe('POST risk_engine/preview route', () => {
|
|||
weights: [
|
||||
{
|
||||
type: RiskWeightTypes.riskCategory,
|
||||
value: RiskCategories.alerts,
|
||||
value: RiskCategories.category_1,
|
||||
host: 1.1,
|
||||
},
|
||||
],
|
||||
|
@ -232,7 +237,7 @@ describe('POST risk_engine/preview route', () => {
|
|||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
|
||||
expect(mockRiskScoreService.calculateScores).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ afterKeys: { host: afterKey } })
|
||||
);
|
||||
});
|
||||
|
|
|
@ -8,13 +8,13 @@
|
|||
import type { Logger } from '@kbn/core/server';
|
||||
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
|
||||
import { DEFAULT_RISK_SCORE_PAGE_SIZE, RISK_SCORE_PREVIEW_URL } from '../../../../common/constants';
|
||||
import { riskScorePreviewRequestSchema } from '../../../../common/risk_engine/risk_score_preview/request_schema';
|
||||
import type { SecuritySolutionPluginRouter } from '../../../types';
|
||||
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
|
||||
import { riskScoreService } from '../risk_score_service';
|
||||
import { getRiskInputsIndex } from '../helpers';
|
||||
import { DATAVIEW_NOT_FOUND } from './translations';
|
||||
import { riskScoreServiceFactory } from '../risk_score_service';
|
||||
import { getRiskInputsIndex } from '../get_risk_inputs_index';
|
||||
|
||||
export const riskScorePreviewRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => {
|
||||
router.post(
|
||||
|
@ -27,12 +27,18 @@ export const riskScorePreviewRoute = (router: SecuritySolutionPluginRouter, logg
|
|||
},
|
||||
async (context, request, response) => {
|
||||
const siemResponse = buildSiemResponse(response);
|
||||
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
const soClient = (await context.core).savedObjects.client;
|
||||
const siemClient = (await context.securitySolution).getAppClient();
|
||||
const riskScore = riskScoreService({
|
||||
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 riskScoreService = riskScoreServiceFactory({
|
||||
esClient,
|
||||
logger,
|
||||
riskEngineDataClient,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const {
|
||||
|
@ -47,36 +53,25 @@ export const riskScorePreviewRoute = (router: SecuritySolutionPluginRouter, logg
|
|||
} = request.body;
|
||||
|
||||
try {
|
||||
let index: string;
|
||||
if (dataViewId) {
|
||||
const dataViewIndex = await getRiskInputsIndex({
|
||||
dataViewId,
|
||||
logger,
|
||||
soClient,
|
||||
});
|
||||
|
||||
if (!dataViewIndex) {
|
||||
return siemResponse.error({
|
||||
statusCode: 404,
|
||||
body: DATAVIEW_NOT_FOUND(dataViewId),
|
||||
});
|
||||
}
|
||||
index = dataViewIndex;
|
||||
}
|
||||
index ??= siemClient.getAlertsIndex();
|
||||
const { index, runtimeMappings } = await getRiskInputsIndex({
|
||||
dataViewId,
|
||||
logger,
|
||||
soClient,
|
||||
});
|
||||
|
||||
const afterKeys = userAfterKeys ?? {};
|
||||
const range = userRange ?? { start: 'now-15d', end: 'now' };
|
||||
const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE;
|
||||
|
||||
const result = await riskScore.getScores({
|
||||
const result = await riskScoreService.calculateScores({
|
||||
afterKeys,
|
||||
debug,
|
||||
pageSize,
|
||||
filter,
|
||||
identifierType,
|
||||
index,
|
||||
filter,
|
||||
pageSize,
|
||||
range,
|
||||
runtimeMappings,
|
||||
weights,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const DATAVIEW_NOT_FOUND = (dataViewId: string): string =>
|
||||
i18n.translate('xpack.securitySolution.riskEngine.calculateScores.dataViewNotFoundError', {
|
||||
values: { dataViewId },
|
||||
defaultMessage:
|
||||
'The specified dataview ({dataViewId}) was not found. Please use an existing dataview, or omit the parameter to use the default risk inputs.',
|
||||
});
|
|
@ -4,6 +4,26 @@ info:
|
|||
title: Risk Scoring API
|
||||
description: These APIs allow the consumer to manage Entity Risk Scores within Entity Analytics.
|
||||
paths:
|
||||
/calculate:
|
||||
post:
|
||||
summary: Trigger calculation of Risk Scores
|
||||
description: Calculates and persists a segment of Risk Scores, returning details about the calculation.
|
||||
requestBody:
|
||||
description: Details about the Risk Scores being calculated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RiskScoresCalculationRequest'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RiskScoresCalculationResponse'
|
||||
'400':
|
||||
description: Invalid request
|
||||
/preview:
|
||||
post:
|
||||
summary: Preview the calculation of Risk Scores
|
||||
|
@ -13,76 +33,111 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RiskScoresRequest'
|
||||
required: false
|
||||
$ref: '#/components/schemas/RiskScoresPreviewRequest'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RiskScoresResponse'
|
||||
$ref: '#/components/schemas/RiskScoresPreviewResponse'
|
||||
'400':
|
||||
description: Invalid request
|
||||
|
||||
components:
|
||||
schemas:
|
||||
RiskScoresRequest:
|
||||
RiskScoresCalculationRequest:
|
||||
type: object
|
||||
required:
|
||||
- data_view_id
|
||||
- identifier_type
|
||||
- range
|
||||
properties:
|
||||
after_keys:
|
||||
description: Used to calculate a specific "page" of risk scores. If unspecified, the first "page" of scores is returned. See also the `after_keys` key in a risk scores response.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AfterKeys'
|
||||
data_view_id:
|
||||
$ref: '#/components/schemas/DataViewId'
|
||||
description: The identifier of the Kibana data view to be used when generating risk scores. If a data view is not found, the provided ID will be used as the query's index pattern instead.
|
||||
debug:
|
||||
description: If set to `true`, the internal ES requests/responses will be logged in Kibana.
|
||||
type: boolean
|
||||
filter:
|
||||
$ref: '#/components/schemas/Filter'
|
||||
description: An elasticsearch DSL filter object. Used to filter the data being scored, which implicitly filters the risk scores calculated.
|
||||
page_size:
|
||||
$ref: '#/components/schemas/PageSize'
|
||||
identifier_type:
|
||||
description: Used to restrict the type of risk scores calculated.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/IdentifierType'
|
||||
range:
|
||||
$ref: '#/components/schemas/DateRange'
|
||||
description: Defines the time period over which scores will be evaluated. If unspecified, a range of `[now, now-30d]` will be used.
|
||||
weights:
|
||||
$ref: '#/components/schemas/RiskScoreWeights'
|
||||
|
||||
RiskScoresPreviewRequest:
|
||||
type: object
|
||||
required:
|
||||
- data_view_id
|
||||
properties:
|
||||
after_keys:
|
||||
description: Used to retrieve a specific "page" of risk scores. If unspecified, the first "page" of scores is returned. See also the `after_keys` key in a risk scores response.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AfterKeys'
|
||||
data_view_id:
|
||||
description: The identifier of the Kibana data view to be used when generating risk scores. If unspecified, the Security Alerts data view for the current space will be used.
|
||||
example: security-solution-default
|
||||
type: string
|
||||
$ref: '#/components/schemas/DataViewId'
|
||||
description: The identifier of the Kibana data view to be used when generating risk scores. If a data view is not found, the provided ID will be used as the query's index pattern instead.
|
||||
debug:
|
||||
description: If set to `true`, a `debug` key is added to the response, containing both the internal request and response with elasticsearch.
|
||||
type: boolean
|
||||
filter:
|
||||
$ref: '#/components/schemas/Filter'
|
||||
description: An elasticsearch DSL filter object. Used to filter the data being scored, which implicitly filters the risk scores returned.
|
||||
$ref: 'https://cloud.elastic.co/api/v1/api-docs/spec.json#/definitions/QueryContainer'
|
||||
page_size:
|
||||
description: Specifies how many scores will be returned in a given response. Note that this value is per `identifier_type`, i.e. a value of 10 will return 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
|
||||
$ref: '#/components/schemas/PageSize'
|
||||
identifier_type:
|
||||
description: Used to restrict the type of risk scores being returned. If unspecified, both `host` and `user` scores will be returned.
|
||||
description: Used to restrict the type of risk scores involved. If unspecified, both `host` and `user` scores will be returned.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/IdentifierType'
|
||||
range:
|
||||
$ref: '#/components/schemas/DateRange'
|
||||
description: Defines the time period over which scores will be evaluated. If unspecified, a range of `[now, now-30d]` will be used.
|
||||
type: object
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
properties:
|
||||
start:
|
||||
$ref: '#/components/schemas/KibanaDate'
|
||||
end:
|
||||
$ref: '#/components/schemas/KibanaDate'
|
||||
weights:
|
||||
description: 'A list of weights to be applied to the scoring calculation.'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RiskScoreWeight'
|
||||
example:
|
||||
- type: 'risk_category'
|
||||
value: 'alerts'
|
||||
host: 0.8
|
||||
user: 0.4
|
||||
- type: 'global_identifier'
|
||||
host: 0.5
|
||||
user: 0.1
|
||||
RiskScoresResponse:
|
||||
$ref: '#/components/schemas/RiskScoreWeights'
|
||||
|
||||
RiskScoresCalculationResponse:
|
||||
type: object
|
||||
required:
|
||||
- after_keys
|
||||
- errors
|
||||
- scores_written
|
||||
properties:
|
||||
after_keys:
|
||||
description: Used to obtain the next "page" of risk scores. See also the `after_keys` key in a risk scores request. If this key is empty, the calculation is complete.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AfterKeys'
|
||||
errors:
|
||||
type: array
|
||||
description: A list of errors encountered during the calculation.
|
||||
items:
|
||||
type: string
|
||||
scores_written:
|
||||
type: number
|
||||
format: integer
|
||||
description: The number of risk scores persisted to elasticsearch.
|
||||
|
||||
RiskScoresPreviewResponse:
|
||||
type: object
|
||||
required:
|
||||
- after_keys
|
||||
- scores
|
||||
properties:
|
||||
after_keys:
|
||||
description: Used to obtain the next "page" of risk scores. See also the `after_keys` key in a risk scores request.
|
||||
description: Used to obtain the next "page" of risk scores. See also the `after_keys` key in a risk scores request. If this key is empty, the calculation is complete.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AfterKeys'
|
||||
debug:
|
||||
|
@ -115,6 +170,28 @@ components:
|
|||
'host.name': 'example.host'
|
||||
user:
|
||||
'user.name': 'example_user_name'
|
||||
DataViewId:
|
||||
description: The identifier of the Kibana data view to be used when generating risk scores.
|
||||
example: security-solution-default
|
||||
type: string
|
||||
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'
|
||||
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
|
||||
DateRange:
|
||||
description: Defines the time period on which risk inputs will be filtered.
|
||||
type: object
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
properties:
|
||||
start:
|
||||
$ref: '#/components/schemas/KibanaDate'
|
||||
end:
|
||||
$ref: '#/components/schemas/KibanaDate'
|
||||
KibanaDate:
|
||||
type: string
|
||||
oneOf:
|
||||
|
@ -131,53 +208,53 @@ components:
|
|||
type: object
|
||||
required:
|
||||
- '@timestamp'
|
||||
- identifierField
|
||||
- identifierValue
|
||||
- level
|
||||
- totalScore
|
||||
- totalScoreNormalized
|
||||
- alertsScore
|
||||
- otherScore
|
||||
- riskiestInputs
|
||||
- id_field
|
||||
- id_value
|
||||
- calculated_level
|
||||
- calculated_score
|
||||
- calculated_score_norm
|
||||
- category_1_score
|
||||
- category_1_count
|
||||
- inputs
|
||||
properties:
|
||||
'@timestamp':
|
||||
type: string
|
||||
format: 'date-time'
|
||||
example: '2017-07-21T17:32:28Z'
|
||||
description: The time at which the risk score was calculated.
|
||||
identifierField:
|
||||
id_field:
|
||||
type: string
|
||||
example: 'host.name'
|
||||
description: The identifier field defining this risk score. Coupled with `identifierValue`, uniquely identifies the entity being scored.
|
||||
identifierValue:
|
||||
description: The identifier field defining this risk score. Coupled with `id_value`, uniquely identifies the entity being scored.
|
||||
id_value:
|
||||
type: string
|
||||
example: 'example.host'
|
||||
description: The identifier value defining this risk score. Coupled with `identifierField`, uniquely identifies the entity being scored.
|
||||
level:
|
||||
description: The identifier value defining this risk score. Coupled with `id_field`, uniquely identifies the entity being scored.
|
||||
calculated_level:
|
||||
type: string
|
||||
example: 'Critical'
|
||||
description: Lexical description of the entity's risk.
|
||||
totalScore:
|
||||
calculated_score:
|
||||
type: number
|
||||
format: double
|
||||
description: The raw numeric value of the given entity's risk score.
|
||||
totalScoreNormalized:
|
||||
calculated_score_norm:
|
||||
type: number
|
||||
format: double
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: The normalized numeric value of the given entity's risk score. Useful for comparing with other entities.
|
||||
alertsScore:
|
||||
category_1_score:
|
||||
type: number
|
||||
format: double
|
||||
description: The raw numeric risk score attributed to Security Alerts.
|
||||
otherScore:
|
||||
description: The contribution of Category 1 to the overall risk score (`calculated_score`). Category 1 contains Detection Engine Alerts.
|
||||
category_1_count:
|
||||
type: number
|
||||
format: double
|
||||
description: The raw numeric risk score attributed to other data sources
|
||||
riskiestInputs:
|
||||
format: integer
|
||||
description: The number of risk input documents that contributed to the Category 1 score (`category_1_score`).
|
||||
inputs:
|
||||
type: array
|
||||
description: A list of the 10 highest-risk documents contributing to this risk score. Useful for investigative purposes.
|
||||
description: A list of the highest-risk documents contributing to this risk score. Useful for investigative purposes.
|
||||
items:
|
||||
$ref: '#/components/schemas/RiskScoreInput'
|
||||
|
||||
|
@ -188,16 +265,31 @@ components:
|
|||
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
|
||||
riskScore:
|
||||
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. 'alerts')."
|
||||
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
|
||||
required:
|
||||
- type
|
||||
|
@ -218,6 +310,19 @@ components:
|
|||
maximum: 1
|
||||
example:
|
||||
type: 'risk_category'
|
||||
value: 'alerts'
|
||||
value: 'category_1'
|
||||
host: 0.8
|
||||
user: 0.4
|
||||
RiskScoreWeights:
|
||||
description: 'A list of weights to be applied to the scoring calculation.'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RiskScoreWeight'
|
||||
example:
|
||||
- type: 'risk_category'
|
||||
value: 'category_1'
|
||||
host: 0.8
|
||||
user: 0.4
|
||||
- type: 'global_identifier'
|
||||
host: 0.5
|
||||
user: 0.1
|
||||
|
|
|
@ -6,10 +6,16 @@
|
|||
*/
|
||||
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { AfterKey, AfterKeys, IdentifierType, RiskWeights } from '../../../common/risk_engine';
|
||||
import type { MappingRuntimeFields, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {
|
||||
AfterKey,
|
||||
AfterKeys,
|
||||
IdentifierType,
|
||||
RiskCategories,
|
||||
RiskWeights,
|
||||
} from '../../../common/risk_engine';
|
||||
|
||||
export interface GetScoresParams {
|
||||
export interface CalculateScoresParams {
|
||||
afterKeys: AfterKeys;
|
||||
debug?: boolean;
|
||||
index: string;
|
||||
|
@ -17,37 +23,72 @@ export interface GetScoresParams {
|
|||
identifierType?: IdentifierType;
|
||||
pageSize: number;
|
||||
range: { start: string; end: string };
|
||||
runtimeMappings: MappingRuntimeFields;
|
||||
weights?: RiskWeights;
|
||||
}
|
||||
|
||||
export interface GetScoresResponse {
|
||||
export interface CalculateAndPersistScoresParams {
|
||||
afterKeys: AfterKeys;
|
||||
debug?: boolean;
|
||||
index: string;
|
||||
filter?: unknown;
|
||||
identifierType: IdentifierType;
|
||||
pageSize: number;
|
||||
range: { start: string; end: string };
|
||||
runtimeMappings: MappingRuntimeFields;
|
||||
weights?: RiskWeights;
|
||||
}
|
||||
|
||||
export interface CalculateAndPersistScoresResponse {
|
||||
after_keys: AfterKeys;
|
||||
errors: string[];
|
||||
scores_written: number;
|
||||
}
|
||||
|
||||
export interface CalculateScoresResponse {
|
||||
debug?: {
|
||||
request: unknown;
|
||||
response: unknown;
|
||||
};
|
||||
after_keys: AfterKeys;
|
||||
scores: RiskScore[];
|
||||
scores: {
|
||||
host?: RiskScore[];
|
||||
user?: RiskScore[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimpleRiskInput {
|
||||
id: string;
|
||||
index: string;
|
||||
riskScore: string | number | undefined;
|
||||
category: RiskCategories;
|
||||
description: string;
|
||||
risk_score: string | number | undefined;
|
||||
timestamp: string | undefined;
|
||||
}
|
||||
|
||||
export type RiskInput = Ecs;
|
||||
|
||||
export interface EcsRiskScore {
|
||||
'@timestamp': string;
|
||||
host?: {
|
||||
risk: Omit<RiskScore, '@timestamp'>;
|
||||
};
|
||||
user?: {
|
||||
risk: Omit<RiskScore, '@timestamp'>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RiskScore {
|
||||
'@timestamp': string;
|
||||
identifierField: string;
|
||||
identifierValue: string;
|
||||
level: string;
|
||||
totalScore: number;
|
||||
totalScoreNormalized: number;
|
||||
alertsScore: number;
|
||||
otherScore: number;
|
||||
id_field: string;
|
||||
id_value: string;
|
||||
calculated_level: string;
|
||||
calculated_score: number;
|
||||
calculated_score_norm: number;
|
||||
category_1_score: number;
|
||||
category_1_count: number;
|
||||
notes: string[];
|
||||
riskiestInputs: SimpleRiskInput[] | RiskInput[];
|
||||
inputs: SimpleRiskInput[] | RiskInput[];
|
||||
}
|
||||
|
||||
export interface CalculateRiskScoreAggregations {
|
||||
|
@ -62,7 +103,7 @@ export interface CalculateRiskScoreAggregations {
|
|||
}
|
||||
|
||||
export interface RiskScoreBucket {
|
||||
key: { [identifierField: string]: string; category: string };
|
||||
key: { [identifierField: string]: string };
|
||||
doc_count: number;
|
||||
risk_details: {
|
||||
value: {
|
||||
|
@ -70,10 +111,9 @@ export interface RiskScoreBucket {
|
|||
normalized_score: number;
|
||||
notes: string[];
|
||||
level: string;
|
||||
alerts_score: number;
|
||||
other_score: number;
|
||||
category_1_score: number;
|
||||
category_1_count: number;
|
||||
};
|
||||
};
|
||||
|
||||
riskiest_inputs: SearchResponse;
|
||||
inputs: SearchResponse;
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@ import { registerDashboardsRoutes } from '../lib/dashboards/routes';
|
|||
import { registerTagsRoutes } from '../lib/tags/routes';
|
||||
import { setAlertTagsRoute } from '../lib/detection_engine/routes/signals/set_alert_tags_route';
|
||||
import { riskScorePreviewRoute } from '../lib/risk_engine/routes';
|
||||
import { riskScoreCalculationRoute } from '../lib/risk_engine/routes/risk_score_calculation_route';
|
||||
|
||||
export const initRoutes = (
|
||||
router: SecuritySolutionPluginRouter,
|
||||
|
@ -175,5 +176,6 @@ export const initRoutes = (
|
|||
|
||||
if (config.experimentalFeatures.riskScoringRoutesEnabled) {
|
||||
riskScorePreviewRoute(router, logger);
|
||||
riskScoreCalculationRoute(router, logger);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -30281,7 +30281,6 @@
|
|||
"xpack.securitySolution.responseActionsList.list.item.wasSuccessful": "{command} terminée",
|
||||
"xpack.securitySolution.responseActionsList.list.recordRange": "Affichage de {range} sur {total} {recordsLabel}",
|
||||
"xpack.securitySolution.responseActionsList.list.recordRangeLabel": "{records, plural, one {action de réponse} many {actions de réponse} other {actions de réponse}}",
|
||||
"xpack.securitySolution.riskEngine.calculateScores.dataViewNotFoundError": "L’affichage de données spécifié ({dataViewId}) n’a pas été trouvé. Veuillez utiliser un affichage de données existant ou omettre le paramètre pour utiliser les entrées de risque par défaut.",
|
||||
"xpack.securitySolution.riskInformation.explanation": "Cette fonctionnalité utilise une transformation, avec une agrégation d'indicateurs scriptée pour calculer les scores de risque {riskEntityLower} en fonction des alertes de règle de détection ayant le statut \"ouvert\", sur une fenêtre temporelle de 5 jours. La transformation s'exécute toutes les heures afin que le score reste à jour au moment où de nouvelles alertes de règles de détection sont transmises.",
|
||||
"xpack.securitySolution.riskInformation.introduction": "La fonctionnalité de score de risque {riskEntity} détecte les {riskEntityLowerPlural} à risque depuis l'intérieur de votre environnement.",
|
||||
"xpack.securitySolution.riskInformation.learnMore": "Vous pouvez en savoir plus sur les risques de {riskEntity} {riskScoreDocumentationLink}",
|
||||
|
|
|
@ -30280,7 +30280,6 @@
|
|||
"xpack.securitySolution.responseActionsList.list.item.wasSuccessful": "{command}は正常に完了しました",
|
||||
"xpack.securitySolution.responseActionsList.list.recordRange": "{total} {recordsLabel}件中{range}を表示中",
|
||||
"xpack.securitySolution.responseActionsList.list.recordRangeLabel": "{records, plural, other {対応アクション}}",
|
||||
"xpack.securitySolution.riskEngine.calculateScores.dataViewNotFoundError": "指定したデータビュー({dataViewId})が見つかりませんでした。既存のデータビューを使用するか、パラメーターを省略してデフォルトのリスク入力を使用します。",
|
||||
"xpack.securitySolution.riskInformation.explanation": "この機能は変換を利用します。また、5日間の範囲で、スクリプトメトリックアグリゲーションを使用して、「オープン」ステータスの検知ルールアラートに基づいて{riskEntityLower}リスクスコアを計算します。変換は毎時実行され、新しい検知ルールアラートを受信するとスコアが常に更新されます。",
|
||||
"xpack.securitySolution.riskInformation.introduction": "{riskEntity}リスクスコア機能は、環境内のリスクが高い{riskEntityLowerPlural}を明らかにします。",
|
||||
"xpack.securitySolution.riskInformation.learnMore": "{riskEntity}リスク{riskScoreDocumentationLink}の詳細をご覧ください",
|
||||
|
|
|
@ -30275,7 +30275,6 @@
|
|||
"xpack.securitySolution.responseActionsList.list.item.wasSuccessful": "{command} 已成功完成",
|
||||
"xpack.securitySolution.responseActionsList.list.recordRange": "正在显示第 {range} 个(共 {total} 个){recordsLabel}",
|
||||
"xpack.securitySolution.responseActionsList.list.recordRangeLabel": "{records, plural, other {响应操作}}",
|
||||
"xpack.securitySolution.riskEngine.calculateScores.dataViewNotFoundError": "找不到指定数据视图 ({dataViewId})。请使用现有数据视图,或忽略该参数以使用默认风险输入。",
|
||||
"xpack.securitySolution.riskInformation.explanation": "此功能利用转换,通过脚本指标聚合基于“开放”状态的检测规则告警来计算 5 天时间窗口内的 {riskEntityLower} 风险分数。该转换每小时运行一次,以根据流入的新检测规则告警更新分数。",
|
||||
"xpack.securitySolution.riskInformation.introduction": "{riskEntity} 风险分数功能将显示您环境中存在风险的 {riskEntityLowerPlural}。",
|
||||
"xpack.securitySolution.riskInformation.learnMore": "您可以详细了解 {riskEntity} 风险{riskScoreDocumentationLink}",
|
||||
|
|
|
@ -37,8 +37,9 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
|
|||
loadTestFile(require.resolve('./throttle'));
|
||||
loadTestFile(require.resolve('./ignore_fields'));
|
||||
loadTestFile(require.resolve('./migrations'));
|
||||
loadTestFile(require.resolve('./risk_engine_install_resources'));
|
||||
loadTestFile(require.resolve('./risk_engine'));
|
||||
loadTestFile(require.resolve('./risk_engine/risk_engine_install_resources'));
|
||||
loadTestFile(require.resolve('./risk_engine/risk_score_preview'));
|
||||
loadTestFile(require.resolve('./risk_engine/risk_score_calculation'));
|
||||
loadTestFile(require.resolve('./set_alert_tags'));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -6,13 +6,13 @@
|
|||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../common/ftr_provider_context';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
const es = getService('es');
|
||||
|
||||
describe('install risk engine resources', () => {
|
||||
describe('Risk Engine - Install Resources', () => {
|
||||
it('should install resources on startup', async () => {
|
||||
const ilmPolicyName = '.risk-score-ilm-policy';
|
||||
const componentTemplateName = '.risk-score-mappings';
|
||||
|
@ -54,40 +54,117 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
'@timestamp': {
|
||||
type: 'date',
|
||||
},
|
||||
alertsScore: {
|
||||
type: 'float',
|
||||
},
|
||||
identifierField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
identifierValue: {
|
||||
type: 'keyword',
|
||||
},
|
||||
level: {
|
||||
type: 'keyword',
|
||||
},
|
||||
otherScore: {
|
||||
type: 'float',
|
||||
},
|
||||
riskiestInputs: {
|
||||
host: {
|
||||
properties: {
|
||||
id: {
|
||||
name: {
|
||||
type: 'keyword',
|
||||
},
|
||||
index: {
|
||||
type: 'keyword',
|
||||
},
|
||||
riskScore: {
|
||||
type: 'float',
|
||||
risk: {
|
||||
properties: {
|
||||
calculated_level: {
|
||||
type: 'keyword',
|
||||
},
|
||||
calculated_score: {
|
||||
type: 'float',
|
||||
},
|
||||
calculated_score_norm: {
|
||||
type: 'float',
|
||||
},
|
||||
category_1_score: {
|
||||
type: 'float',
|
||||
},
|
||||
id_field: {
|
||||
type: 'keyword',
|
||||
},
|
||||
id_value: {
|
||||
type: 'keyword',
|
||||
},
|
||||
notes: {
|
||||
type: 'keyword',
|
||||
},
|
||||
inputs: {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
index: {
|
||||
type: 'keyword',
|
||||
},
|
||||
category: {
|
||||
type: 'keyword',
|
||||
},
|
||||
description: {
|
||||
type: 'keyword',
|
||||
},
|
||||
risk_score: {
|
||||
type: 'float',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
type: 'nested',
|
||||
},
|
||||
totalScore: {
|
||||
type: 'float',
|
||||
},
|
||||
totalScoreNormalized: {
|
||||
type: 'float',
|
||||
user: {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'keyword',
|
||||
},
|
||||
risk: {
|
||||
properties: {
|
||||
calculated_level: {
|
||||
type: 'keyword',
|
||||
},
|
||||
calculated_score: {
|
||||
type: 'float',
|
||||
},
|
||||
calculated_score_norm: {
|
||||
type: 'float',
|
||||
},
|
||||
category_1_score: {
|
||||
type: 'float',
|
||||
},
|
||||
id_field: {
|
||||
type: 'keyword',
|
||||
},
|
||||
id_value: {
|
||||
type: 'keyword',
|
||||
},
|
||||
notes: {
|
||||
type: 'keyword',
|
||||
},
|
||||
inputs: {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
index: {
|
||||
type: 'keyword',
|
||||
},
|
||||
category: {
|
||||
type: 'keyword',
|
||||
},
|
||||
description: {
|
||||
type: 'keyword',
|
||||
},
|
||||
risk_score: {
|
||||
type: 'float',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -0,0 +1,268 @@
|
|||
/*
|
||||
* 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 { RISK_SCORE_CALCULATION_URL } from '@kbn/security-solution-plugin/common/constants';
|
||||
import type { RiskScore } from '@kbn/security-solution-plugin/server/lib/risk_engine/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import { deleteAllAlerts, deleteAllRules } from '../../../utils';
|
||||
import { dataGeneratorFactory } from '../../../utils/data_generator';
|
||||
import {
|
||||
buildDocument,
|
||||
createAndSyncRuleAndAlertsFactory,
|
||||
deleteAllRiskScores,
|
||||
readRiskScores,
|
||||
normalizeScores,
|
||||
waitForRiskScoresToBePresent,
|
||||
} from './utils';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService }: FtrProviderContext): void => {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const es = getService('es');
|
||||
const log = getService('log');
|
||||
|
||||
const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log });
|
||||
|
||||
const calculateRiskScores = async ({
|
||||
body,
|
||||
}: {
|
||||
body: object;
|
||||
}): Promise<{ scores: RiskScore[] }> => {
|
||||
const { body: result } = await supertest
|
||||
.post(RISK_SCORE_CALCULATION_URL)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(body)
|
||||
.expect(200);
|
||||
return result;
|
||||
};
|
||||
|
||||
const calculateRiskScoreAfterRuleCreationAndExecution = async (
|
||||
documentId: string,
|
||||
{
|
||||
alerts = 1,
|
||||
riskScore = 21,
|
||||
maxSignals = 100,
|
||||
}: { alerts?: number; riskScore?: number; maxSignals?: number } = {}
|
||||
) => {
|
||||
await createAndSyncRuleAndAlerts({ query: `id: ${documentId}`, alerts, riskScore, maxSignals });
|
||||
|
||||
return await calculateRiskScores({
|
||||
body: {
|
||||
data_view_id: '.alerts-security.alerts-default',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
identifier_type: 'host',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Risk Engine Scoring - Calculation', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllRiskScores(log, es);
|
||||
await deleteAllAlerts(supertest, log, es);
|
||||
await deleteAllRules(supertest, log);
|
||||
});
|
||||
|
||||
it('calculates and persists risk score', async () => {
|
||||
const documentId = uuidv4();
|
||||
await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, documentId)]);
|
||||
|
||||
const results = await calculateRiskScoreAfterRuleCreationAndExecution(documentId);
|
||||
expect(results).to.eql({
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-1',
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
scores_written: 1,
|
||||
});
|
||||
|
||||
await waitForRiskScoresToBePresent(es, log);
|
||||
const scores = await readRiskScores(es);
|
||||
|
||||
expect(scores.length).to.eql(1);
|
||||
expect(normalizeScores(scores)).to.eql([
|
||||
{
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 21,
|
||||
calculated_score_norm: 8.039816232771823,
|
||||
category_1_score: 21,
|
||||
category_1_count: 1,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('paging through calculationss', () => {
|
||||
let documentId: string;
|
||||
beforeEach(async () => {
|
||||
documentId = uuidv4();
|
||||
const baseEvent = buildDocument({ host: { name: 'host-1' } }, documentId);
|
||||
await indexListOfDocuments(
|
||||
Array(10)
|
||||
.fill(baseEvent)
|
||||
.map((_baseEvent, index) => ({
|
||||
..._baseEvent,
|
||||
'host.name': `host-${index}`,
|
||||
}))
|
||||
);
|
||||
|
||||
await createAndSyncRuleAndAlerts({
|
||||
query: `id: ${documentId}`,
|
||||
alerts: 10,
|
||||
riskScore: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates and persists a single page of risk scores', async () => {
|
||||
const results = await calculateRiskScores({
|
||||
body: {
|
||||
data_view_id: '.alerts-security.alerts-default',
|
||||
identifier_type: 'host',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
},
|
||||
});
|
||||
expect(results).to.eql({
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-9',
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
scores_written: 10,
|
||||
});
|
||||
|
||||
await waitForRiskScoresToBePresent(es, log);
|
||||
const scores = await readRiskScores(es);
|
||||
|
||||
expect(scores.length).to.eql(10);
|
||||
});
|
||||
|
||||
it('calculates and persists multiple pages of risk scores', async () => {
|
||||
const results = await calculateRiskScores({
|
||||
body: {
|
||||
data_view_id: '.alerts-security.alerts-default',
|
||||
identifier_type: 'host',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
page_size: 5,
|
||||
},
|
||||
});
|
||||
expect(results).to.eql({
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-4',
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
scores_written: 5,
|
||||
});
|
||||
|
||||
const secondResults = await calculateRiskScores({
|
||||
body: {
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-4',
|
||||
},
|
||||
},
|
||||
data_view_id: '.alerts-security.alerts-default',
|
||||
identifier_type: 'host',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
page_size: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(secondResults).to.eql({
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-9',
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
scores_written: 5,
|
||||
});
|
||||
|
||||
await waitForRiskScoresToBePresent(es, log);
|
||||
const scores = await readRiskScores(es);
|
||||
|
||||
expect(scores.length).to.eql(10);
|
||||
});
|
||||
|
||||
it('returns an appropriate response if there are no inputs left to score/persist', async () => {
|
||||
const results = await calculateRiskScores({
|
||||
body: {
|
||||
data_view_id: '.alerts-security.alerts-default',
|
||||
identifier_type: 'host',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
page_size: 10,
|
||||
},
|
||||
});
|
||||
expect(results).to.eql({
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-9',
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
scores_written: 10,
|
||||
});
|
||||
|
||||
const noopCalculationResults = await calculateRiskScores({
|
||||
body: {
|
||||
after_keys: {
|
||||
host: {
|
||||
'host.name': 'host-9',
|
||||
},
|
||||
},
|
||||
debug: true,
|
||||
data_view_id: '.alerts-security.alerts-default',
|
||||
identifier_type: 'host',
|
||||
range: { start: 'now-30d', end: 'now' },
|
||||
page_size: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(noopCalculationResults).to.eql({
|
||||
after_keys: {},
|
||||
errors: [],
|
||||
scores_written: 0,
|
||||
});
|
||||
|
||||
await waitForRiskScoresToBePresent(es, log);
|
||||
const scores = await readRiskScores(es);
|
||||
|
||||
expect(scores.length).to.eql(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -10,40 +10,15 @@ import { ALERT_RISK_SCORE } from '@kbn/rule-data-utils';
|
|||
import { RISK_SCORE_PREVIEW_URL } from '@kbn/security-solution-plugin/common/constants';
|
||||
import type { RiskScore } from '@kbn/security-solution-plugin/server/lib/risk_engine/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { FtrProviderContext } from '../../common/ftr_provider_context';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import { createSignalsIndex, deleteAllAlerts, deleteAllRules } from '../../../utils';
|
||||
import { dataGeneratorFactory } from '../../../utils/data_generator';
|
||||
import {
|
||||
createSignalsIndex,
|
||||
deleteAllAlerts,
|
||||
deleteAllRules,
|
||||
createRule,
|
||||
waitForSignalsToBePresent,
|
||||
waitForRuleSuccess,
|
||||
getRuleForSignalTesting,
|
||||
} from '../../utils';
|
||||
import { dataGeneratorFactory } from '../../utils/data_generator';
|
||||
|
||||
const removeFields = (scores: any[]) =>
|
||||
scores.map((item) => {
|
||||
delete item['@timestamp'];
|
||||
delete item.riskiestInputs;
|
||||
delete item.notes;
|
||||
delete item.alertsScore;
|
||||
delete item.otherScore;
|
||||
return item;
|
||||
});
|
||||
|
||||
const buildDocument = (body: any, id?: string) => {
|
||||
const firstTimestamp = Date.now();
|
||||
const doc = {
|
||||
id: id || uuidv4(),
|
||||
'@timestamp': firstTimestamp,
|
||||
agent: {
|
||||
name: 'agent-12345',
|
||||
},
|
||||
...body,
|
||||
};
|
||||
return doc;
|
||||
};
|
||||
buildDocument,
|
||||
createAndSyncRuleAndAlertsFactory,
|
||||
deleteAllRiskScores,
|
||||
sanitizeScores,
|
||||
} from './utils';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService }: FtrProviderContext): void => {
|
||||
|
@ -52,42 +27,17 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
const es = getService('es');
|
||||
const log = getService('log');
|
||||
|
||||
const createAndSyncRuleAndAlerts = async ({
|
||||
alerts = 1,
|
||||
riskScore = 21,
|
||||
maxSignals = 100,
|
||||
query,
|
||||
riskScoreOverride,
|
||||
const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log });
|
||||
const previewRiskScores = async ({
|
||||
body,
|
||||
}: {
|
||||
alerts?: number;
|
||||
riskScore?: number;
|
||||
maxSignals?: number;
|
||||
query: string;
|
||||
riskScoreOverride?: string;
|
||||
}): Promise<void> => {
|
||||
const rule = getRuleForSignalTesting(['ecs_compliant']);
|
||||
const { id } = await createRule(supertest, log, {
|
||||
...rule,
|
||||
risk_score: riskScore,
|
||||
query,
|
||||
max_signals: maxSignals,
|
||||
...(riskScoreOverride
|
||||
? {
|
||||
risk_score_mapping: [
|
||||
{ field: riskScoreOverride, operator: 'equals', value: '', risk_score: undefined },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await waitForRuleSuccess({ supertest, log, id });
|
||||
await waitForSignalsToBePresent(supertest, log, alerts, [id]);
|
||||
};
|
||||
|
||||
const getRiskScores = async ({ body }: { body: object }): Promise<{ scores: RiskScore[] }> => {
|
||||
body: object;
|
||||
}): Promise<{ scores: { host?: RiskScore[]; user?: RiskScore[] } }> => {
|
||||
const defaultBody = { data_view_id: '.alerts-security.alerts-default' };
|
||||
const { body: result } = await supertest
|
||||
.post(RISK_SCORE_PREVIEW_URL)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(body)
|
||||
.send({ ...defaultBody, ...body })
|
||||
.expect(200);
|
||||
return result;
|
||||
};
|
||||
|
@ -102,10 +52,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
) => {
|
||||
await createAndSyncRuleAndAlerts({ query: `id: ${documentId}`, alerts, riskScore, maxSignals });
|
||||
|
||||
return await getRiskScores({ body: { debug: true } });
|
||||
return await previewRiskScores({ body: {} });
|
||||
};
|
||||
|
||||
describe('Risk engine', () => {
|
||||
describe('Risk Engine Scoring - Preview', () => {
|
||||
context('with auditbeat data', () => {
|
||||
const { indexListOfDocuments } = dataGeneratorFactory({
|
||||
es,
|
||||
|
@ -131,6 +81,7 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllRiskScores(log, es);
|
||||
await deleteAllAlerts(supertest, log, es);
|
||||
await deleteAllRules(supertest, log);
|
||||
});
|
||||
|
@ -142,13 +93,15 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
|
||||
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId);
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 21,
|
||||
totalScoreNormalized: 8.039816232771823,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 21,
|
||||
calculated_score_norm: 8.039816232771823,
|
||||
category_1_count: 1,
|
||||
category_1_score: 21,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -164,20 +117,24 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 2,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 21,
|
||||
totalScoreNormalized: 8.039816232771823,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 21,
|
||||
calculated_score_norm: 8.039816232771823,
|
||||
category_1_count: 1,
|
||||
category_1_score: 21,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 21,
|
||||
totalScoreNormalized: 8.039816232771823,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-2',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 21,
|
||||
calculated_score_norm: 8.039816232771823,
|
||||
category_1_count: 1,
|
||||
category_1_score: 21,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -193,13 +150,15 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 2,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 28.42462120245875,
|
||||
totalScoreNormalized: 10.88232052161514,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 28.42462120245875,
|
||||
calculated_score_norm: 10.88232052161514,
|
||||
category_1_count: 2,
|
||||
category_1_score: 28,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -213,13 +172,15 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 30,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 47.25513506055279,
|
||||
totalScoreNormalized: 18.091552473412246,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 47.25513506055279,
|
||||
calculated_score_norm: 18.091552473412246,
|
||||
category_1_count: 30,
|
||||
category_1_score: 37,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -236,20 +197,24 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 31,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 47.25513506055279,
|
||||
totalScoreNormalized: 18.091552473412246,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 47.25513506055279,
|
||||
calculated_score_norm: 18.091552473412246,
|
||||
category_1_count: 30,
|
||||
category_1_score: 37,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 21,
|
||||
totalScoreNormalized: 8.039816232771823,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-2',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 21,
|
||||
calculated_score_norm: 8.039816232771823,
|
||||
category_1_count: 1,
|
||||
category_1_score: 21,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -263,13 +228,15 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 100,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Unknown',
|
||||
totalScore: 50.67035607277805,
|
||||
totalScoreNormalized: 19.399064346392823,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Unknown',
|
||||
calculated_score: 50.67035607277805,
|
||||
calculated_score_norm: 19.399064346392823,
|
||||
category_1_count: 100,
|
||||
category_1_score: 37,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -286,13 +253,15 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 100,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Critical',
|
||||
totalScore: 241.2874098703716,
|
||||
totalScoreNormalized: 92.37649688758484,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Critical',
|
||||
calculated_score: 241.2874098703716,
|
||||
calculated_score_norm: 92.37649688758484,
|
||||
category_1_count: 100,
|
||||
category_1_score: 209,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -315,13 +284,15 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
maxSignals: 1000,
|
||||
});
|
||||
|
||||
expect(removeFields(body.scores)).to.eql([
|
||||
expect(sanitizeScores(body.scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Critical',
|
||||
totalScore: 254.91456029175757,
|
||||
totalScoreNormalized: 97.59362951445543,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Critical',
|
||||
calculated_score: 254.91456029175757,
|
||||
calculated_score_norm: 97.59362951445543,
|
||||
category_1_count: 1000,
|
||||
category_1_score: 209,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -341,14 +312,14 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
riskScore: 100,
|
||||
});
|
||||
|
||||
const { scores } = await getRiskScores({
|
||||
const { scores } = await previewRiskScores({
|
||||
body: {
|
||||
after_keys: { user: { 'user.name': 'aaa' } },
|
||||
},
|
||||
});
|
||||
// if after_key was not respected, 'aaa' would be included here
|
||||
expect(scores).to.have.length(1);
|
||||
expect(scores[0].identifierValue).to.equal('zzz');
|
||||
expect(scores.user).to.have.length(1);
|
||||
expect(scores.user?.[0].id_value).to.equal('zzz');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -368,7 +339,7 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
riskScore: 100,
|
||||
riskScoreOverride: 'event.risk_score',
|
||||
});
|
||||
const { scores } = await getRiskScores({
|
||||
const { scores } = await previewRiskScores({
|
||||
body: {
|
||||
filter: {
|
||||
bool: {
|
||||
|
@ -386,8 +357,8 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(scores).to.have.length(1);
|
||||
expect(scores[0].riskiestInputs).to.have.length(1);
|
||||
expect(scores.host).to.have.length(1);
|
||||
expect(scores.host?.[0].inputs).to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -407,15 +378,17 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
riskScore: 100,
|
||||
riskScoreOverride: 'event.risk_score',
|
||||
});
|
||||
const { scores } = await getRiskScores({ body: {} });
|
||||
const { scores } = await previewRiskScores({ body: {} });
|
||||
|
||||
expect(removeFields(scores)).to.eql([
|
||||
expect(sanitizeScores(scores.host!)).to.eql([
|
||||
{
|
||||
level: 'High',
|
||||
totalScore: 225.1106801442913,
|
||||
totalScoreNormalized: 86.18326192354185,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'High',
|
||||
calculated_score: 225.1106801442913,
|
||||
calculated_score_norm: 86.18326192354185,
|
||||
category_1_count: 100,
|
||||
category_1_score: 203,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -432,17 +405,19 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 100,
|
||||
riskScore: 100,
|
||||
});
|
||||
const { scores } = await getRiskScores({
|
||||
const { scores } = await previewRiskScores({
|
||||
body: { weights: [{ type: 'global_identifier', host: 0.5 }] },
|
||||
});
|
||||
|
||||
expect(removeFields(scores)).to.eql([
|
||||
expect(sanitizeScores(scores.host!)).to.eql([
|
||||
{
|
||||
level: 'Moderate',
|
||||
totalScore: 120.6437049351858,
|
||||
totalScoreNormalized: 46.18824844379242,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'Moderate',
|
||||
calculated_score: 120.6437049351858,
|
||||
calculated_score_norm: 46.18824844379242,
|
||||
category_1_count: 100,
|
||||
category_1_score: 209,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -457,17 +432,19 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 100,
|
||||
riskScore: 100,
|
||||
});
|
||||
const { scores } = await getRiskScores({
|
||||
const { scores } = await previewRiskScores({
|
||||
body: { weights: [{ type: 'global_identifier', user: 0.7 }] },
|
||||
});
|
||||
|
||||
expect(removeFields(scores)).to.eql([
|
||||
expect(sanitizeScores(scores.user!)).to.eql([
|
||||
{
|
||||
level: 'Moderate',
|
||||
totalScore: 168.9011869092601,
|
||||
totalScoreNormalized: 64.66354782130938,
|
||||
identifierField: 'user.name',
|
||||
identifierValue: 'user-1',
|
||||
calculated_level: 'Moderate',
|
||||
calculated_score: 168.9011869092601,
|
||||
calculated_score_norm: 64.66354782130938,
|
||||
category_1_count: 100,
|
||||
category_1_score: 209,
|
||||
id_field: 'user.name',
|
||||
id_value: 'user-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -484,24 +461,31 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 100,
|
||||
riskScore: 100,
|
||||
});
|
||||
const { scores } = await getRiskScores({
|
||||
const { scores } = await previewRiskScores({
|
||||
body: { weights: [{ type: 'global_identifier', host: 0.4, user: 0.8 }] },
|
||||
});
|
||||
|
||||
expect(removeFields(scores)).to.eql([
|
||||
expect(sanitizeScores(scores.host!)).to.eql([
|
||||
{
|
||||
level: 'High',
|
||||
totalScore: 186.47518232942502,
|
||||
totalScoreNormalized: 71.39172370958079,
|
||||
identifierField: 'user.name',
|
||||
identifierValue: 'user-1',
|
||||
calculated_level: 'Low',
|
||||
calculated_score: 93.23759116471251,
|
||||
calculated_score_norm: 35.695861854790394,
|
||||
category_1_count: 50,
|
||||
category_1_score: 209,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sanitizeScores(scores.user!)).to.eql([
|
||||
{
|
||||
level: 'Low',
|
||||
totalScore: 93.23759116471251,
|
||||
totalScoreNormalized: 35.695861854790394,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'High',
|
||||
calculated_score: 186.47518232942502,
|
||||
calculated_score_norm: 71.39172370958079,
|
||||
category_1_count: 50,
|
||||
category_1_score: 209,
|
||||
id_field: 'user.name',
|
||||
id_value: 'user-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -525,26 +509,33 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alerts: 100,
|
||||
riskScore: 100,
|
||||
});
|
||||
const { scores } = await getRiskScores({
|
||||
const { scores } = await previewRiskScores({
|
||||
body: {
|
||||
weights: [{ type: 'risk_category', value: 'alerts', host: 0.4, user: 0.8 }],
|
||||
weights: [{ type: 'risk_category', value: 'category_1', host: 0.4, user: 0.8 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(removeFields(scores)).to.eql([
|
||||
expect(sanitizeScores(scores.host!)).to.eql([
|
||||
{
|
||||
level: 'High',
|
||||
totalScore: 186.475182329425,
|
||||
totalScoreNormalized: 71.39172370958079,
|
||||
identifierField: 'user.name',
|
||||
identifierValue: 'user-1',
|
||||
calculated_level: 'Low',
|
||||
calculated_score: 93.2375911647125,
|
||||
calculated_score_norm: 35.695861854790394,
|
||||
category_1_score: 77,
|
||||
category_1_count: 50,
|
||||
id_field: 'host.name',
|
||||
id_value: 'host-1',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sanitizeScores(scores.user!)).to.eql([
|
||||
{
|
||||
level: 'Low',
|
||||
totalScore: 93.2375911647125,
|
||||
totalScoreNormalized: 35.695861854790394,
|
||||
identifierField: 'host.name',
|
||||
identifierValue: 'host-1',
|
||||
calculated_level: 'High',
|
||||
calculated_score: 186.475182329425,
|
||||
calculated_score_norm: 71.39172370958079,
|
||||
category_1_score: 165,
|
||||
category_1_count: 50,
|
||||
id_field: 'user.name',
|
||||
id_value: 'user-1',
|
||||
},
|
||||
]);
|
||||
});
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import type SuperTest from 'supertest';
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
import type { ToolingLog } from '@kbn/tooling-log';
|
||||
import type {
|
||||
EcsRiskScore,
|
||||
RiskScore,
|
||||
} from '@kbn/security-solution-plugin/server/lib/risk_engine/types';
|
||||
|
||||
import {
|
||||
createRule,
|
||||
waitForSignalsToBePresent,
|
||||
waitForRuleSuccess,
|
||||
getRuleForSignalTesting,
|
||||
countDownTest,
|
||||
waitFor,
|
||||
} from '../../../utils';
|
||||
|
||||
const sanitizeScore = (score: Partial<RiskScore>): Partial<RiskScore> => {
|
||||
delete score['@timestamp'];
|
||||
delete score.inputs;
|
||||
delete score.notes;
|
||||
// delete score.category_1_score;
|
||||
return score;
|
||||
};
|
||||
|
||||
export const sanitizeScores = (scores: Array<Partial<RiskScore>>): Array<Partial<RiskScore>> =>
|
||||
scores.map(sanitizeScore);
|
||||
|
||||
export const normalizeScores = (scores: Array<Partial<EcsRiskScore>>): Array<Partial<RiskScore>> =>
|
||||
scores.map((score) => sanitizeScore(score.host?.risk ?? score.user?.risk ?? {}));
|
||||
|
||||
export const buildDocument = (body: object, id?: string) => {
|
||||
const firstTimestamp = Date.now();
|
||||
const doc = {
|
||||
id: id || uuidv4(),
|
||||
'@timestamp': firstTimestamp,
|
||||
agent: {
|
||||
name: 'agent-12345',
|
||||
},
|
||||
...body,
|
||||
};
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const createAndSyncRuleAndAlertsFactory =
|
||||
({ supertest, log }: { supertest: SuperTest.SuperTest<SuperTest.Test>; log: ToolingLog }) =>
|
||||
async ({
|
||||
alerts = 1,
|
||||
riskScore = 21,
|
||||
maxSignals = 100,
|
||||
query,
|
||||
riskScoreOverride,
|
||||
}: {
|
||||
alerts?: number;
|
||||
riskScore?: number;
|
||||
maxSignals?: number;
|
||||
query: string;
|
||||
riskScoreOverride?: string;
|
||||
}): Promise<void> => {
|
||||
const rule = getRuleForSignalTesting(['ecs_compliant']);
|
||||
const { id } = await createRule(supertest, log, {
|
||||
...rule,
|
||||
risk_score: riskScore,
|
||||
query,
|
||||
max_signals: maxSignals,
|
||||
...(riskScoreOverride
|
||||
? {
|
||||
risk_score_mapping: [
|
||||
{ field: riskScoreOverride, operator: 'equals', value: '', risk_score: undefined },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await waitForRuleSuccess({ supertest, log, id });
|
||||
await waitForSignalsToBePresent(supertest, log, alerts, [id]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes all risk scores from a given index or indices, defaults to `risk-score.risk-score-*`
|
||||
* For use inside of afterEach blocks of tests
|
||||
*/
|
||||
export const deleteAllRiskScores = async (
|
||||
log: ToolingLog,
|
||||
es: Client,
|
||||
index: string[] = ['risk-score.risk-score-default']
|
||||
): Promise<void> => {
|
||||
await countDownTest(
|
||||
async () => {
|
||||
await es.deleteByQuery({
|
||||
index,
|
||||
body: {
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
},
|
||||
refresh: true,
|
||||
});
|
||||
return {
|
||||
passed: true,
|
||||
};
|
||||
},
|
||||
'deleteAllRiskScores',
|
||||
log
|
||||
);
|
||||
};
|
||||
|
||||
export const readRiskScores = async (
|
||||
es: Client,
|
||||
index: string[] = ['risk-score.risk-score-default']
|
||||
): Promise<EcsRiskScore[]> => {
|
||||
const results = await es.search({
|
||||
index: 'risk-score.risk-score-default',
|
||||
});
|
||||
return results.hits.hits.map((hit) => hit._source as EcsRiskScore);
|
||||
};
|
||||
|
||||
export const waitForRiskScoresToBePresent = async (
|
||||
es: Client,
|
||||
log: ToolingLog,
|
||||
index: string[] = ['risk-score.risk-score-default']
|
||||
): Promise<void> => {
|
||||
await waitFor(
|
||||
async () => {
|
||||
const riskScores = await readRiskScores(es, index);
|
||||
return riskScores.length > 0;
|
||||
},
|
||||
'waitForRiskScoresToBePresent',
|
||||
log
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue