Skip to content

Commit

Permalink
Risk Score Persistence API (#161503)
Browse files Browse the repository at this point in the history
## Summary

* Introduces a new API, POST `/api/risk_scores/calculate`, that triggers
the code introduced here
* As with the [preview
route](#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](elastic/ecs#2236) powering this data.
* Mappings created by the [Data
Client](#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)
  • Loading branch information
rylnd authored Jul 28, 2023
1 parent 180f861 commit 8df8920
Show file tree
Hide file tree
Showing 45 changed files with 2,378 additions and 608 deletions.
16 changes: 12 additions & 4 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}),
])
);
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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

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

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

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

Expand All @@ -189,22 +189,22 @@ 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',
};
const decoded = riskWeightSchema.decode(payload);
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ export enum RiskWeightTypes {
}

export enum RiskCategories {
alerts = 'alerts',
category_1 = 'category_1',
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('Entity analytics management page', () => {
cy.intercept('POST', '/internal/risk_score/preview', {
statusCode: 200,
body: {
scores: [],
scores: { host: [], user: [] },
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand Down Expand Up @@ -95,18 +95,19 @@ 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,
end: dateRange.to,
},
});

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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,28 @@

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

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,
}: {
items: IRiskScore[];
type: RiskScoreEntity;
}) => {
const columns = [
const columns: RiskScoreColumn[] = [
{
field: 'identifierValue',
field: 'id_value',
name: 'Name',
render: (itemName: string) => {
return type === RiskScoreEntity.host ? (
Expand All @@ -34,7 +39,7 @@ export const RiskScorePreviewTable = ({
},
},
{
field: 'level',
field: 'calculated_level',
name: 'Level',
render: (risk: RiskSeverity | null) => {
if (risk != null) {
Expand All @@ -45,7 +50,7 @@ export const RiskScorePreviewTable = ({
},
},
{
field: 'totalScoreNormalized',
field: 'calculated_score_norm',
// align: 'right',
name: 'Score norm',
render: (scoreNorm: number | null) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
Loading

0 comments on commit 8df8920

Please sign in to comment.