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:
Ryland Herrick 2023-07-28 06:44:25 -05:00 committed by GitHub
parent 180f86138b
commit 8df89203c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2408 additions and 638 deletions

View file

@ -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
*/

View file

@ -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,
}),
])
);

View file

@ -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>;

View file

@ -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);

View file

@ -11,5 +11,5 @@ export enum RiskWeightTypes {
}
export enum RiskCategories {
alerts = 'alerts',
category_1 = 'category_1',
}

View file

@ -82,7 +82,7 @@ describe('Entity analytics management page', () => {
cy.intercept('POST', '/internal/risk_score/preview', {
statusCode: 200,
body: {
scores: [],
scores: { host: [], user: [] },
},
});

View file

@ -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,

View file

@ -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();

View file

@ -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 }) => {

View file

@ -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) => {

View file

@ -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,
};

View file

@ -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 });
});
});
});

View file

@ -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 };
};

View file

@ -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,
};

View file

@ -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(),
});
});
});

View file

@ -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 })
),
},
};
});

View file

@ -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';

View file

@ -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',
});

View file

@ -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"
);
});
});

View file

@ -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: {} };
}
};

View file

@ -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';

View file

@ -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,

View file

@ -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}`);
}

View file

@ -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 });
});
});
});
});

View file

@ -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,
},
},
};
};
}

View file

@ -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 = {

View file

@ -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 }),
});

View file

@ -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',
});

View file

@ -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 = ({

View file

@ -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"'
);
});
});
});

View file

@ -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) },
});
}
}
);
};

View file

@ -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 } })
);
});

View file

@ -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,
});

View file

@ -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.',
});

View file

@ -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

View file

@ -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;
}

View file

@ -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);
}
};

View file

@ -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": "Laffichage de données spécifié ({dataViewId}) na 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}",

View file

@ -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}の詳細をご覧ください",

View file

@ -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}",

View file

@ -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'));
});
};

View file

@ -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',
},
},
},
},
});

View file

@ -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);
});
});
});
});
};

View file

@ -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',
},
]);
});

View file

@ -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
);
};