[SecuritySolution][EntityAnalytics] Risk Scoring Preview API (#155966)

## Summary

This PR adds a new Risk Scoring API endpoint. Its functionality is meant
to replace the current transform-based solution.

### Contents of this PR:
- New feature flag: `riskScoringRoutesEnabled`
- A new POST endpoint at `/internal/risk_scores/preview`
- An OpenAPI doc for the endpoint
- Unit and integration tests

### Current behavior, and short-term plans
The endpoint as specified in this branch is _read-only_. When the
endpoint is hit, it triggers some aggregations in elasticsearch, and a
formatted response is returned; there is no persistence at this time.
This endpoint was originally written as a POC to demonstrate the new
Risk Engine's functionality, but it will now drive the [Preview Risk
Scoring](https://github.com/elastic/security-team/issues/6443) feature.

The main path for the Risk Engine is going to be a _scheduled task_ that
calculates Risk Scores and writes them to a persistent datastream that
we own. (https://github.com/elastic/security-team/issues/6450). To
accomplish this, we will decompose the full functionality of this
endpoint into constituent pieces (i.e. `calculate | persist, get`)

## How to review
I've created a Postman collection that can be used to exercise this
endpoint. It was generated by Postman from the OpenAPI spec, and
modified by me to contain a valid subset of request parameters; please
peruse the spec and/or feel free to generate your own scripts/tools from
the spec.
```
curl -L -H 'Authorization: 10c7f646373aa116' -o 'Risk Scoring API.postman_collection.json' https://upload.elastic.co/d/007a57857fc40c791835629ea6dd692d2a8a290860f2917329d688be78c03b1d
```

### Review against the PR instance
I've created a [demo
instance](https://rylnd-pr-155966-risk-score-api.kbndev.co/) containing
the code on this branch, along with some realistic(ish) alert data
(~200k alerts). While you can use this instance as a convenience, you
will need to [set up
kibana-remote-dev](https://github.com/elastic/kibana-remote-dev#access-kibana-es-locally-without-sso)
and forward ports in order to be able to access the instance's API from
a local machine:

1. Configure kibana-remote-dev with your SSH key and GitHub token.
2. Configure kibana-remote-dev to specify `GITHUB_USERNAME=rylnd`
* This allows you to bypass kibana-remote-dev code that assumes projects
are owned by you
3. Forward local ports to my instance: `./ports
rd-rylnd-pr-155966-risk-score-api`
4. Use postman to talk to `http://localhost:5601`, which will be
forwarded to the cloud instance via the previous command

### Review manually
1. Check out this branch
3. Enable the feature flag
4. Populate some event data and generate some alerts
5. Navigate to the new endpoint, and observe that the `host.name`s and
`user.name`s from those alerts have been aggregated into these "risk
scores" in the response
6. Play with the request options to see how these affect the scores (and
see docs/test for more details on how those work)

## _What_ to review
* Are the scores internally consistent? I.e. do they add up as expected?
Does the corresponding "level" make sense?
* Do parameters apply as expected? E.g. do weights predictably scale the
results?
* Are there discrepancies between the spec and the actual
implementation?
* Does pagination make sense? (i.e. the `after_keys` stuff)?

#### TODO (for @rylnd)
- [x] Add `description`s to the OpenAPI docs
- [x] Remove remaining TODOs from code


### Checklist


- [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
- [x] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)




### 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)

Related ticket: https://github.com/elastic/security-team/issues/4211

---------

Co-authored-by: Khristinin Nikita <nikita.khristinin@elastic.co>
This commit is contained in:
Ryland Herrick 2023-06-15 14:16:28 -05:00 committed by GitHub
parent 24bfa0514e
commit ae068a62f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2676 additions and 1 deletions

View file

@ -25,6 +25,7 @@ export * from './src/non_empty_array';
export * from './src/non_empty_or_nullable_string_array';
export * from './src/non_empty_string_array';
export * from './src/non_empty_string';
export * from './src/number_between_zero_and_one_inclusive';
export * from './src/only_false_allowed';
export * from './src/operator';
export * from './src/positive_integer_greater_than_zero';

View file

@ -0,0 +1,85 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { NumberBetweenZeroAndOneInclusive } from '.';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
describe('NumberBetweenZeroAndOneInclusive', () => {
test('it should validate 1', () => {
const payload = 1;
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate a zero', () => {
const payload = 0;
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate a float between 0 and 1', () => {
const payload = 0.58;
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate a negative number', () => {
const payload = -1;
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "-1" supplied to "NumberBetweenZeroAndOneInclusive"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate NaN', () => {
const payload = NaN;
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "NaN" supplied to "NumberBetweenZeroAndOneInclusive"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate Infinity', () => {
const payload = Infinity;
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "Infinity" supplied to "NumberBetweenZeroAndOneInclusive"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate a string', () => {
const payload = 'some string';
const decoded = NumberBetweenZeroAndOneInclusive.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "some string" supplied to "NumberBetweenZeroAndOneInclusive"',
]);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
/**
* Types a number between 0 and 1 inclusive. Useful for specifying a probability, weighting, etc.
*/
export const NumberBetweenZeroAndOneInclusive = new t.Type<number, number, unknown>(
'NumberBetweenZeroAndOneInclusive',
t.number.is,
(input, context): Either<t.Errors, number> => {
return typeof input === 'number' &&
!Number.isNaN(input) &&
Number.isFinite(input) &&
input >= 0 &&
input <= 1
? t.success(input)
: t.failure(input, context);
},
t.identity
);

View file

@ -41,6 +41,7 @@ export const DEFAULT_SIGNALS_INDEX = '.siem-signals' as const;
export const DEFAULT_PREVIEW_INDEX = '.preview.alerts-security.alerts' as const;
export const DEFAULT_LISTS_INDEX = '.lists' as const;
export const DEFAULT_ITEMS_INDEX = '.items' as const;
export const DEFAULT_RISK_SCORE_PAGE_SIZE = 1000 as const;
// The DEFAULT_MAX_SIGNALS value exists also in `x-pack/plugins/cases/common/constants.ts`
// If either changes, engineer should ensure both values are updated
export const DEFAULT_MAX_SIGNALS = 100 as const;
@ -314,6 +315,8 @@ export const RISK_SCORE_CREATE_INDEX = `${INTERNAL_RISK_SCORE_URL}/indices/creat
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`;
/**
* Internal detection engine routes
*/

View file

@ -116,6 +116,11 @@ export const allowedExperimentalValues = Object.freeze({
* The flag doesn't have to be documented and has to be removed after the feature is ready to release.
*/
detectionsCoverageOverview: false,
/**
* Enables experimental Entity Analytics HTTP endpoints
*/
riskScoringRoutesEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { afterKeysSchema } from './after_keys';
describe('after_keys schema', () => {
it('allows an empty object', () => {
const payload = {};
const decoded = afterKeysSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows a valid host key', () => {
const payload = { host: { 'host.name': 'hello' } };
const decoded = afterKeysSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows a valid user key', () => {
const payload = { user: { 'user.name': 'hello' } };
const decoded = afterKeysSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows both valid host and user keys', () => {
const payload = { user: { 'user.name': 'hello' }, host: { 'host.name': 'hello' } };
const decoded = afterKeysSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('removes an unknown identifier key if used', () => {
const payload = { bad: 'key' };
const decoded = afterKeysSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,21 @@
/*
* 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';
const afterKeySchema = t.record(t.string, t.string);
export type AfterKeySchema = t.TypeOf<typeof afterKeySchema>;
export type AfterKey = AfterKeySchema;
export const afterKeysSchema = t.exact(
t.partial({
host: afterKeySchema,
user: afterKeySchema,
})
);
export type AfterKeysSchema = t.TypeOf<typeof afterKeysSchema>;
export type AfterKeys = AfterKeysSchema;

View file

@ -0,0 +1,12 @@
/*
* 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';
export const identifierTypeSchema = t.keyof({ user: null, host: null });
export type IdentifierTypeSchema = t.TypeOf<typeof identifierTypeSchema>;
export type IdentifierType = IdentifierTypeSchema;

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export * from './after_keys';
export * from './risk_weights';
export * from './identifier_types';

View file

@ -0,0 +1,29 @@
/*
* 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 '../../detection_engine/rule_schema';
import { afterKeysSchema } from '../after_keys';
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,
}),
weights: riskWeightsSchema,
})
);
export type RiskScorePreviewRequestSchema = t.TypeOf<typeof riskScorePreviewRequestSchema>;

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './types';
export type { RiskWeight, RiskWeights, GlobalRiskWeight, RiskCategoryRiskWeight } from './schema';

View file

@ -0,0 +1,234 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { riskWeightSchema } from './schema';
import { RiskCategories, RiskWeightTypes } from './types';
describe('risk weight schema', () => {
let type: string;
describe('allowed types', () => {
it('allows the global weight type', () => {
const payload = {
type: RiskWeightTypes.global,
host: 0.1,
};
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows the risk category weight type', () => {
const payload = {
type: RiskWeightTypes.global,
host: 0.1,
};
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('rejects an unknown weight type', () => {
const payload = {
type: 'unknown',
host: 0.1,
};
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors)).length).toBeGreaterThan(0);
expect(message.schema).toEqual({});
});
});
describe('conditional fields', () => {
describe('global weights', () => {
beforeEach(() => {
type = RiskWeightTypes.global;
});
it('rejects if neither host nor user weight are specified', () => {
const payload = { type };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "host"',
'Invalid value "undefined" supplied to "user"',
]);
expect(message.schema).toEqual({});
});
it('allows a single host weight', () => {
const payload = { type, host: 0.1 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows a single user weight', () => {
const payload = { type, user: 0.1 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows both a host and user weight', () => {
const payload = { type, host: 0.1, user: 0.5 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({ type, host: 0.1, user: 0.5 });
});
it('rejects a weight outside of 0-1', () => {
const payload = { type, user: 55 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toContain('Invalid value "55" supplied to "user"');
expect(message.schema).toEqual({});
});
it('removes extra keys if specified', () => {
const payload = {
type,
host: 0.1,
value: 'superfluous',
extra: 'even more',
};
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({ type, host: 0.1 });
});
});
describe('risk category weights', () => {
beforeEach(() => {
type = RiskWeightTypes.riskCategory;
});
it('requires a value', () => {
const payload = { type, user: 0.1 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "value"',
]);
expect(message.schema).toEqual({});
});
it('rejects if neither host nor user weight are specified', () => {
const payload = { type, value: RiskCategories.alerts };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "host"',
'Invalid value "undefined" supplied to "user"',
]);
expect(message.schema).toEqual({});
});
it('allows a single host weight', () => {
const payload = { type, value: RiskCategories.alerts, host: 0.1 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows a single user weight', () => {
const payload = { type, value: RiskCategories.alerts, user: 0.1 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('allows both a host and user weight', () => {
const payload = { type, value: RiskCategories.alerts, user: 0.1, host: 0.5 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('rejects a weight outside of 0-1', () => {
const payload = { type, value: RiskCategories.alerts, host: -5 };
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toContain('Invalid value "-5" supplied to "host"');
expect(message.schema).toEqual({});
});
it('removes extra keys if specified', () => {
const payload = {
type,
value: RiskCategories.alerts,
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 });
});
describe('allowed category values', () => {
it('allows the alerts type for a category', () => {
const payload = {
type,
value: RiskCategories.alerts,
host: 0.1,
};
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
it('rejects an unknown category value', () => {
const payload = {
type,
value: 'unknown',
host: 0.1,
};
const decoded = riskWeightSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toContain(
'Invalid value "unknown" supplied to "value"'
);
expect(message.schema).toEqual({});
});
});
});
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { NumberBetweenZeroAndOneInclusive } from '@kbn/securitysolution-io-ts-types';
import { fromEnum } from '../utils';
import { RiskCategories, RiskWeightTypes } from './types';
const hostWeight = t.type({
host: NumberBetweenZeroAndOneInclusive,
});
const userWeight = t.type({
user: NumberBetweenZeroAndOneInclusive,
});
const identifierWeights = t.union([
t.exact(t.intersection([hostWeight, userWeight])),
t.exact(t.intersection([hostWeight, t.partial({ user: t.undefined })])),
t.exact(t.intersection([userWeight, t.partial({ host: t.undefined })])),
]);
const riskCategories = fromEnum('riskCategories', RiskCategories);
const globalRiskWeightSchema = t.intersection([
t.exact(
t.type({
type: t.literal(RiskWeightTypes.global),
})
),
identifierWeights,
]);
export type GlobalRiskWeight = t.TypeOf<typeof globalRiskWeightSchema>;
const riskCategoryRiskWeightSchema = t.intersection([
t.exact(
t.type({
type: t.literal(RiskWeightTypes.riskCategory),
value: riskCategories,
})
),
identifierWeights,
]);
export type RiskCategoryRiskWeight = t.TypeOf<typeof riskCategoryRiskWeightSchema>;
export const riskWeightSchema = t.union([globalRiskWeightSchema, riskCategoryRiskWeightSchema]);
export type RiskWeightSchema = t.TypeOf<typeof riskWeightSchema>;
export type RiskWeight = RiskWeightSchema;
export const riskWeightsSchema = t.array(riskWeightSchema);
export type RiskWeightsSchema = t.TypeOf<typeof riskWeightsSchema>;
export type RiskWeights = RiskWeightsSchema;

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export enum RiskWeightTypes {
global = 'global_identifier',
riskCategory = 'risk_category',
}
export enum RiskCategories {
alerts = 'alerts',
}

View file

@ -0,0 +1,25 @@
/*
* 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';
/*
* This utility function can be used to turn a TypeScript enum into a io-ts codec.
*/
export function fromEnum<EnumType extends string>(
enumName: string,
theEnum: Record<string, EnumType>
): t.Type<EnumType, EnumType, unknown> {
const isEnumValue = (input: unknown): input is EnumType =>
Object.values<unknown>(theEnum).includes(input);
return new t.Type<EnumType>(
enumName,
isEnumValue,
(input, context) => (isEnumValue(input) ? t.success(input) : t.failure(input, context)),
t.identity
);
}

View file

@ -6,9 +6,14 @@
*/
import type { ConfigType } from '../config';
import { DEFAULT_DATA_VIEW_ID, DEFAULT_PREVIEW_INDEX } from '../../common/constants';
import {
DEFAULT_ALERTS_INDEX,
DEFAULT_DATA_VIEW_ID,
DEFAULT_PREVIEW_INDEX,
} from '../../common/constants';
export class AppClient {
private readonly alertsIndex: string;
private readonly signalsIndex: string;
private readonly spaceId: string;
private readonly previewIndex: string;
@ -19,6 +24,7 @@ export class AppClient {
constructor(spaceId: string, config: ConfigType, kibanaVersion: string, kibanaBranch: string) {
const configuredSignalsIndex = config.signalsIndex;
this.alertsIndex = `${DEFAULT_ALERTS_INDEX}-${spaceId}`;
this.signalsIndex = `${configuredSignalsIndex}-${spaceId}`;
this.previewIndex = `${DEFAULT_PREVIEW_INDEX}-${spaceId}`;
this.sourcererDataViewId = `${DEFAULT_DATA_VIEW_ID}-${spaceId}`;
@ -27,6 +33,7 @@ export class AppClient {
this.kibanaBranch = kibanaBranch;
}
public getAlertsIndex = (): string => this.alertsIndex;
public getSignalsIndex = (): string => this.signalsIndex;
public getPreviewIndex = (): string => this.previewIndex;
public getSourcererDataViewId = (): string => this.sourcererDataViewId;

View file

@ -0,0 +1,61 @@
/*
* 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 { CalculateRiskScoreAggregations, RiskScoreBucket } from './types';
const createRiskScoreBucketMock = (overrides: Partial<RiskScoreBucket> = {}): RiskScoreBucket => ({
key: { 'user.name': 'username', category: 'alert' },
doc_count: 2,
risk_details: {
value: {
score: 20,
normalized_score: 30.0,
level: 'Unknown',
notes: [],
alerts_score: 30,
other_score: 0,
},
},
riskiest_inputs: {
took: 17,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
total: {
value: 1,
relation: 'eq',
},
hits: [{ _id: '_id', _index: '_index', sort: [30] }],
},
},
...overrides,
});
const createAggregationResponseMock = (
overrides: Partial<CalculateRiskScoreAggregations> = {}
): CalculateRiskScoreAggregations => ({
host: {
after_key: { 'host.name': 'hostname' },
buckets: [createRiskScoreBucketMock(), createRiskScoreBucketMock()],
},
user: {
after_key: { 'user.name': 'username' },
buckets: [createRiskScoreBucketMock(), createRiskScoreBucketMock()],
},
...overrides,
});
export const calculateRiskScoreMock = {
createAggregationResponse: createAggregationResponseMock,
createRiskScoreBucket: createRiskScoreBucketMock,
};

View file

@ -0,0 +1,198 @@
/*
* 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 { calculateRiskScores } from './calculate_risk_scores';
import { calculateRiskScoreMock } from './calculate_risk_scores.mock';
describe('calculateRiskScores()', () => {
let params: Parameters<typeof calculateRiskScores>[0];
let esClient: ElasticsearchClient;
let logger: Logger;
beforeEach(() => {
esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser;
logger = loggingSystemMock.createLogger();
params = {
afterKeys: {},
esClient,
logger,
index: 'index',
pageSize: 500,
range: { start: 'now - 15d', end: 'now' },
};
});
describe('inputs', () => {
it('builds a filter on @timestamp based on the provided range', async () => {
await calculateRiskScores(params);
expect(esClient.search).toHaveBeenCalledWith(
expect.objectContaining({
query: {
bool: {
filter: expect.arrayContaining([
{
range: { '@timestamp': { gte: 'now - 15d', lt: 'now' } },
},
]),
},
},
})
);
});
describe('identifierType', () => {
it('creates aggs for both host and user by default', async () => {
await calculateRiskScores(params);
expect(esClient.search).toHaveBeenCalledWith(
expect.objectContaining({
aggs: expect.objectContaining({ host: expect.anything(), user: expect.anything() }),
})
);
});
it('creates an aggregation per specified identifierType', async () => {
params = { ...params, identifierType: 'host' };
await calculateRiskScores(params);
const [[call]] = (esClient.search as jest.Mock).mock.calls;
expect(call).toEqual(
expect.objectContaining({ aggs: expect.objectContaining({ host: expect.anything() }) })
);
expect(call.aggs).toHaveProperty('host');
expect(call.aggs).not.toHaveProperty('user');
});
});
describe('after_keys', () => {
it('applies a single after_key to the correct aggregation', async () => {
params = { ...params, afterKeys: { host: { 'host.name': 'foo' } } };
await calculateRiskScores(params);
const [[call]] = (esClient.search as jest.Mock).mock.calls;
expect(call).toEqual(
expect.objectContaining({
aggs: expect.objectContaining({
host: expect.objectContaining({
composite: expect.objectContaining({ after: { 'host.name': 'foo' } }),
}),
}),
})
);
});
it('applies multiple after_keys to the correct aggregations', async () => {
params = {
...params,
afterKeys: {
host: { 'host.name': 'foo' },
user: { 'user.name': 'bar' },
},
};
await calculateRiskScores(params);
const [[call]] = (esClient.search as jest.Mock).mock.calls;
expect(call).toEqual(
expect.objectContaining({
aggs: expect.objectContaining({
host: expect.objectContaining({
composite: expect.objectContaining({ after: { 'host.name': 'foo' } }),
}),
user: expect.objectContaining({
composite: expect.objectContaining({ after: { 'user.name': 'bar' } }),
}),
}),
})
);
});
it('uses an undefined after_key by default', async () => {
await calculateRiskScores(params);
const [[call]] = (esClient.search as jest.Mock).mock.calls;
expect(call).toEqual(
expect.objectContaining({
aggs: expect.objectContaining({
host: expect.objectContaining({
composite: expect.objectContaining({ after: undefined }),
}),
user: expect.objectContaining({
composite: expect.objectContaining({ after: undefined }),
}),
}),
})
);
});
});
});
describe('outputs', () => {
beforeEach(() => {
// stub out a reasonable response
(esClient.search as jest.Mock).mockResolvedValueOnce({
aggregations: calculateRiskScoreMock.createAggregationResponse(),
});
});
it('returns a flattened list of risk scores', async () => {
const response = await calculateRiskScores(params);
expect(response).toHaveProperty('scores');
expect(response.scores).toHaveLength(4);
});
it('returns scores in the expected format', async () => {
const {
scores: [score],
} = await calculateRiskScores(params);
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),
})
);
});
it('returns risk inputs in the expected format', async () => {
const {
scores: [score],
} = await calculateRiskScores(params);
expect(score).toEqual(
expect.objectContaining({
riskiestInputs: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
index: expect.any(String),
riskScore: expect.any(Number),
}),
]),
})
);
});
});
describe('error conditions', () => {
beforeEach(() => {
// stub out a rejected response
(esClient.search as jest.Mock).mockRejectedValueOnce({
aggregations: calculateRiskScoreMock.createAggregationResponse(),
});
});
it('raises an error if elasticsearch client rejects', () => {
expect.assertions(1);
expect(() => calculateRiskScores(params)).rejects.toEqual({
aggregations: calculateRiskScoreMock.createAggregationResponse(),
});
});
});
});

View file

@ -0,0 +1,277 @@
/*
* 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 {
AggregationsAggregationContainer,
QueryDslQueryContainer,
} from '@elastic/elasticsearch/lib/api/types';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import {
ALERT_RISK_SCORE,
EVENT_KIND,
} from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import type { AfterKeys, IdentifierType, RiskWeights } from '../../../common/risk_engine';
import { withSecuritySpan } from '../../utils/with_security_span';
import { getAfterKeyForIdentifierType, getFieldForIdentifierAgg } from './helpers';
import {
buildCategoryScoreAssignment,
buildCategoryScoreDeclarations,
buildWeightingOfScoreByCategory,
getGlobalWeightForIdentifierType,
} from './risk_weights';
import type {
CalculateRiskScoreAggregations,
GetScoresParams,
GetScoresResponse,
RiskScore,
RiskScoreBucket,
} from './types';
const bucketToResponse = ({
bucket,
now,
identifierField,
}: {
bucket: RiskScoreBucket;
now: string;
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,
notes: bucket.risk_details.value.notes,
riskiestInputs: bucket.riskiest_inputs.hits.hits.map((riskInput) => ({
id: riskInput._id,
index: riskInput._index,
riskScore: riskInput.sort?.[0] ?? undefined,
})),
});
const filterFromRange = (range: GetScoresParams['range']): QueryDslQueryContainer => ({
range: { '@timestamp': { lt: range.end, gte: range.start } },
});
const buildReduceScript = ({
globalIdentifierTypeWeight,
}: {
globalIdentifierTypeWeight?: number;
}): string => {
return `
Map results = new HashMap();
List inputs = [];
for (state in states) {
inputs.addAll(state.inputs)
}
Collections.sort(inputs, (a, b) -> b.get('weighted_score').compareTo(a.get('weighted_score')));
double num_inputs_to_score = Math.min(inputs.length, params.max_risk_inputs_per_identity);
results['notes'] = [];
if (num_inputs_to_score == params.max_risk_inputs_per_identity) {
results['notes'].add('Number of risk inputs (' + inputs.length + ') exceeded the maximum allowed (' + params.max_risk_inputs_per_identity + ').');
}
${buildCategoryScoreDeclarations()}
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()}
total_score += current_score;
}
${globalIdentifierTypeWeight != null ? `total_score *= ${globalIdentifierTypeWeight};` : ''}
double score_norm = 100 * total_score / params.risk_cap;
results['score'] = total_score;
results['normalized_score'] = score_norm;
if (score_norm < 20) {
results['level'] = 'Unknown'
}
else if (score_norm >= 20 && score_norm < 40) {
results['level'] = 'Low'
}
else if (score_norm >= 40 && score_norm < 70) {
results['level'] = 'Moderate'
}
else if (score_norm >= 70 && score_norm < 90) {
results['level'] = 'High'
}
else if (score_norm >= 90) {
results['level'] = 'Critical'
}
return results;
`;
};
const buildIdentifierTypeAggregation = ({
afterKeys,
identifierType,
pageSize,
weights,
}: {
afterKeys: AfterKeys;
identifierType: IdentifierType;
pageSize: number;
weights?: RiskWeights;
}): AggregationsAggregationContainer => {
const globalIdentifierTypeWeight = getGlobalWeightForIdentifierType({ identifierType, weights });
const identifierField = getFieldForIdentifierAgg(identifierType);
return {
composite: {
size: pageSize,
sources: [
{
[identifierField]: {
terms: {
field: identifierField,
},
},
},
],
after: getAfterKeyForIdentifierType({ identifierType, afterKeys }),
},
aggs: {
riskiest_inputs: {
top_hits: {
size: 10,
sort: { [ALERT_RISK_SCORE]: 'desc' },
_source: false,
},
},
risk_details: {
scripted_metric: {
init_script: 'state.inputs = []',
map_script: `
Map fields = new HashMap();
String category = doc['${EVENT_KIND}'].value;
double score = doc['${ALERT_RISK_SCORE}'].value;
double weighted_score = 0.0;
fields.put('time', doc['@timestamp'].value);
fields.put('category', category);
fields.put('score', score);
${buildWeightingOfScoreByCategory({ userWeights: weights, identifierType })}
fields.put('weighted_score', weighted_score);
state.inputs.add(fields);
`,
combine_script: 'return state;',
params: {
max_risk_inputs_per_identity: 999999,
p: 1.5,
risk_cap: 261.2,
},
reduce_script: buildReduceScript({ globalIdentifierTypeWeight }),
},
},
},
};
};
export const calculateRiskScores = async ({
afterKeys: userAfterKeys,
debug,
esClient,
filter: userFilter,
identifierType,
index,
logger,
pageSize,
range,
weights,
}: {
esClient: ElasticsearchClient;
logger: Logger;
} & GetScoresParams): Promise<GetScoresResponse> =>
withSecuritySpan('calculateRiskScores', async () => {
const now = new Date().toISOString();
const filter = [{ exists: { field: ALERT_RISK_SCORE } }, filterFromRange(range)];
if (userFilter) {
filter.push(userFilter as QueryDslQueryContainer);
}
const identifierTypes: IdentifierType[] = identifierType ? [identifierType] : ['host', 'user'];
const request = {
size: 0,
_source: false,
index,
query: {
bool: {
filter,
},
},
aggs: identifierTypes.reduce((aggs, _identifierType) => {
aggs[_identifierType] = buildIdentifierTypeAggregation({
afterKeys: userAfterKeys,
identifierType: _identifierType,
pageSize,
weights,
});
return aggs;
}, {} as Record<string, AggregationsAggregationContainer>),
};
if (debug) {
logger.info(`Executing Risk Score query:\n${JSON.stringify(request)}`);
}
const response = await esClient.search<never, CalculateRiskScoreAggregations>(request);
if (debug) {
logger.info(`Received Risk Score response:\n${JSON.stringify(response)}`);
}
if (response.aggregations == null) {
return {
...(debug ? { request, response } : {}),
after_keys: {},
scores: [],
};
}
const userBuckets = response.aggregations.user?.buckets ?? [];
const hostBuckets = response.aggregations.host?.buckets ?? [];
const afterKeys = {
host: response.aggregations.host?.after_key,
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,
};
});

View file

@ -0,0 +1,38 @@
/*
* 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 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';
export const getAfterKeyForIdentifierType = ({
afterKeys,
identifierType,
}: {
afterKeys: AfterKeys;
identifierType: IdentifierType;
}): AfterKey | undefined => afterKeys[identifierType];

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 type { RiskScoreService } from './risk_score_service';
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,
notes: [],
riskiestInputs: [],
...overrides,
});
const createRiskScoreServiceMock = (): jest.Mocked<RiskScoreService> => ({
getScores: jest.fn(),
});
export const riskScoreServiceMock = {
create: createRiskScoreServiceMock,
createRiskScore: createRiskScoreMock,
};

View file

@ -0,0 +1,24 @@
/*
* 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 { GetScoresParams, GetScoresResponse } from './types';
import { calculateRiskScores } from './calculate_risk_scores';
export interface RiskScoreService {
getScores: (params: GetScoresParams) => Promise<GetScoresResponse>;
}
export const riskScoreService = ({
esClient,
logger,
}: {
esClient: ElasticsearchClient;
logger: Logger;
}): RiskScoreService => ({
getScores: (params) => calculateRiskScores({ ...params, esClient, logger }),
});

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RiskWeightTypes, RiskCategories } from '../../../common/risk_engine';
import {
buildCategoryScoreAssignment,
buildCategoryWeights,
buildWeightingOfScoreByCategory,
} from './risk_weights';
describe('buildCategoryWeights', () => {
it('returns the default weights if nothing else is provided', () => {
const result = buildCategoryWeights();
expect(result).toEqual([
{ host: 1, type: RiskWeightTypes.riskCategory, user: 1, value: RiskCategories.alerts },
]);
});
it('allows user weights to override defaults', () => {
const result = buildCategoryWeights([
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.alerts, host: 0.1, user: 0.2 },
]);
expect(result).toEqual([
{ host: 0.1, type: RiskWeightTypes.riskCategory, user: 0.2, value: RiskCategories.alerts },
]);
});
it('uses default category weights if unspecified in user-provided weight', () => {
const result = buildCategoryWeights([
{ type: RiskWeightTypes.riskCategory, value: RiskCategories.alerts, host: 0.1 },
]);
expect(result).toEqual([
{ host: 0.1, type: RiskWeightTypes.riskCategory, user: 1, value: RiskCategories.alerts },
]);
});
});
describe('buildCategoryScoreAssignment', () => {
it('builds the expected assignment statement', () => {
const result = buildCategoryScoreAssignment();
expect(result).toMatchInlineSnapshot(
`"if (inputs[i].category == 'signal') { results['alerts_score'] += current_score; } else { results['other_score'] += current_score; }"`
);
});
});
describe('buildWeightingOfScoreByCategory', () => {
it('returns default weights if no user values provided', () => {
const result = buildWeightingOfScoreByCategory({ identifierType: 'user' });
expect(result).toMatchInlineSnapshot(
`"if (category == 'signal') { weighted_score = score * 1; } else { weighted_score = score; }"`
);
});
it('returns default weights if no weights provided', () => {
const result = buildWeightingOfScoreByCategory({ userWeights: [], identifierType: 'host' });
expect(result).toMatchInlineSnapshot(
`"if (category == 'signal') { weighted_score = score * 1; } else { weighted_score = score; }"`
);
});
it('returns default weights if only global weights provided', () => {
const result = buildWeightingOfScoreByCategory({
userWeights: [{ type: RiskWeightTypes.global, host: 0.1 }],
identifierType: 'host',
});
expect(result).toMatchInlineSnapshot(
`"if (category == 'signal') { weighted_score = score * 1; } else { weighted_score = score; }"`
);
});
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 },
],
identifierType: 'host',
});
expect(result).toMatchInlineSnapshot(
`"if (category == 'signal') { weighted_score = score * 0.1; } else { weighted_score = score; }"`
);
});
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 },
],
identifierType: 'user',
});
expect(result).toMatchInlineSnapshot(
`"if (category == 'signal') { weighted_score = score * 1; } else { weighted_score = score; }"`
);
});
});

View file

@ -0,0 +1,104 @@
/*
* 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 { keyBy, merge } from 'lodash';
import type {
GlobalRiskWeight,
IdentifierType,
RiskCategoryRiskWeight,
RiskWeight,
RiskWeights,
} from '../../../common/risk_engine';
import { RiskCategories, RiskWeightTypes } from '../../../common/risk_engine';
const RISK_CATEGORIES = Object.values(RiskCategories);
const DEFAULT_CATEGORY_WEIGHTS: RiskWeights = RISK_CATEGORIES.map((category) => ({
type: RiskWeightTypes.riskCategory,
value: category,
host: 1,
user: 1,
}));
/*
* 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;
const isGlobalIdentifierTypeWeight = (weight: RiskWeight): weight is GlobalRiskWeight =>
weight.type === RiskWeightTypes.global;
const isRiskCategoryWeight = (weight: RiskWeight): weight is RiskCategoryRiskWeight =>
weight.type === RiskWeightTypes.riskCategory;
export const getGlobalWeightForIdentifierType = ({
identifierType,
weights,
}: {
identifierType: IdentifierType;
weights?: RiskWeights;
}): number | undefined => {
return weights?.find(isGlobalIdentifierTypeWeight)?.[identifierType];
};
const getRiskCategoryWeights = (weights?: RiskWeights): RiskCategoryRiskWeight[] =>
weights?.filter(isRiskCategoryWeight) ?? [];
const getWeightForIdentifierType = (weight: RiskWeight, identifierType: IdentifierType): number => {
const configuredWeight = weight[identifierType];
return typeof configuredWeight === 'number' ? configuredWeight : 1;
};
export const buildCategoryScoreDeclarations = (): string => {
const otherScoreDeclaration = `results['other_score'] = 0;`;
return RISK_CATEGORIES.map((riskCategory) => `results['${riskCategory}_score'] = 0;`)
.join('')
.concat(otherScoreDeclaration);
};
export const buildCategoryWeights = (userWeights?: RiskWeights): RiskCategoryRiskWeight[] => {
const categoryWeights = getRiskCategoryWeights(userWeights);
return Object.values(
merge({}, keyBy(DEFAULT_CATEGORY_WEIGHTS, 'value'), keyBy(categoryWeights, 'value'))
);
};
export const buildCategoryScoreAssignment = (): string => {
const otherClause = `results['other_score'] += current_score;`;
return RISK_CATEGORIES.map(
(category) =>
`if (inputs[i].category == '${convertCategoryToEventKindValue(
category
)}') { results['${category}_score'] += current_score; }`
)
.join(' else ')
.concat(` else { ${otherClause} }`);
};
export const buildWeightingOfScoreByCategory = ({
userWeights,
identifierType,
}: {
userWeights?: RiskWeights;
identifierType: IdentifierType;
}): string => {
const otherClause = `weighted_score = score;`;
const categoryWeights = buildCategoryWeights(userWeights);
return categoryWeights
.map(
(weight) =>
`if (category == '${convertCategoryToEventKindValue(
weight.value
)}') { weighted_score = score * ${getWeightForIdentifierType(weight, identifierType)}; }`
)
.join(' else ')
.concat(` else { ${otherClause} }`);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { riskScorePreviewRoute } from './risk_score_preview_route';

View file

@ -0,0 +1,252 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { loggerMock } from '@kbn/logging-mocks';
import { RISK_SCORE_PREVIEW_URL } from '../../../../common/constants';
import { RiskCategories, RiskWeightTypes } from '../../../../common/risk_engine';
import {
serverMock,
requestContextMock,
requestMock,
} from '../../detection_engine/routes/__mocks__';
import { riskScoreService } from '../risk_score_service';
import { riskScoreServiceMock } from '../risk_score_service.mock';
import { riskScorePreviewRoute } from './risk_score_preview_route';
jest.mock('../risk_score_service');
describe('POST risk_engine/preview 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();
clients.appClient.getAlertsIndex.mockReturnValue('default-alerts-index');
(riskScoreService as jest.Mock).mockReturnValue(mockRiskScoreService);
riskScorePreviewRoute(server.router, logger);
});
const buildRequest = (body: object = {}) =>
requestMock.create({
method: 'get',
path: RISK_SCORE_PREVIEW_URL,
body,
});
describe('parameters', () => {
describe('index / dataview', () => {
it('defaults to scoring the alerts index if no dataview is provided', async () => {
const request = buildRequest();
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.status).toEqual(200);
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
expect.objectContaining({ index: 'default-alerts-index' })
);
});
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' })
);
});
it('returns a 404 if dataview is not found', async () => {
const request = buildRequest({ data_view_id: 'custom-dataview-id' });
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(mockRiskScoreService.getScores).not.toHaveBeenCalled();
});
});
describe('date range', () => {
it('defaults to the last 15 days of data', async () => {
const request = buildRequest();
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.status).toEqual(200);
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
expect.objectContaining({ range: { start: 'now-15d', end: 'now' } })
);
});
it('respects the provided range if provided', 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.getScores).toHaveBeenCalledWith(
expect.objectContaining({ range: { start: 'now-30d', end: 'now-20d' } })
);
});
it('rejects an invalid date range', async () => {
const request = buildRequest({
range: { end: 'now' },
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
expect.stringContaining('Invalid value "undefined" supplied to "range,start"')
);
});
});
describe('data filter', () => {
it('respects the provided filter if provided', async () => {
const request = buildRequest({
filter: {
bool: {
filter: [
{
ids: {
values: '1',
},
},
],
},
},
});
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.status).toEqual(200);
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
expect.objectContaining({
filter: {
bool: {
filter: [
{
ids: {
values: '1',
},
},
],
},
},
})
);
});
});
describe('weights', () => {
it('uses the specified weights when provided', async () => {
const request = buildRequest({
weights: [
{
type: RiskWeightTypes.riskCategory,
value: RiskCategories.alerts,
host: 0.1,
user: 0.2,
},
],
});
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.status).toEqual(200);
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
expect.objectContaining({
weights: [
{
type: RiskWeightTypes.riskCategory,
value: RiskCategories.alerts,
host: 0.1,
user: 0.2,
},
],
})
);
});
it('rejects weight values outside the 0-1 range', async () => {
const request = buildRequest({
weights: [
{
type: RiskWeightTypes.riskCategory,
value: RiskCategories.alerts,
host: 1.1,
},
],
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
expect.stringContaining('Invalid value "1.1" supplied to "weights,host"')
);
});
it('rejects unknown weight types', async () => {
const request = buildRequest({
weights: [
{
type: 'something new',
host: 1.1,
},
],
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
'Invalid value "{"type":"something new","host":1.1}" supplied to "weights"'
);
});
});
describe('pagination', () => {
it('respects the provided after_key', async () => {
const afterKey = { 'host.name': 'hi mom' };
const request = buildRequest({ after_keys: { host: afterKey } });
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.status).toEqual(200);
expect(mockRiskScoreService.getScores).toHaveBeenCalledWith(
expect.objectContaining({ afterKeys: { host: afterKey } })
);
});
it('rejects an invalid after_key', async () => {
const request = buildRequest({
after_keys: {
bad: 'key',
},
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith('invalid keys "bad"');
});
});
});
});

View file

@ -0,0 +1,94 @@
/*
* 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_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';
export const riskScorePreviewRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => {
router.post(
{
path: RISK_SCORE_PREVIEW_URL,
validate: { body: buildRouteValidation(riskScorePreviewRequestSchema) },
options: {
tags: ['access:securitySolution'],
},
},
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({
esClient,
logger,
});
const {
after_keys: userAfterKeys,
data_view_id: dataViewId,
debug,
page_size: userPageSize,
identifier_type: identifierType,
filter,
range: userRange,
weights,
} = 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 afterKeys = userAfterKeys ?? {};
const range = userRange ?? { start: 'now-15d', end: 'now' };
const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE;
const result = await riskScore.getScores({
afterKeys,
debug,
pageSize,
identifierType,
index,
filter,
range,
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

@ -0,0 +1,15 @@
/*
* 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

@ -0,0 +1,223 @@
openapi: 3.0.0
info:
version: 1.0.0
title: Risk Scoring API
description: These APIs allow the consumer to manage Entity Risk Scores within Entity Analytics.
paths:
/preview:
post:
summary: Preview the calculation of Risk Scores
description: Calculates and returns a list of Risk Scores, sorted by identifier_type and risk score.
requestBody:
description: Details about the Risk Scores being requested
content:
application/json:
schema:
$ref: '#/components/schemas/RiskScoresRequest'
required: false
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RiskScoresResponse'
'400':
description: Invalid request
components:
schemas:
RiskScoresRequest:
type: object
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
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:
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
identifier_type:
description: Used to restrict the type of risk scores being returned. If unspecified, both `host` and `user` scores will be returned.
allOf:
- $ref: '#/components/schemas/IdentifierType'
range:
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:
type: object
required:
- 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.
allOf:
- $ref: '#/components/schemas/AfterKeys'
debug:
description: Object containing debug information, particularly the internal request and response from elasticsearch
type: object
properties:
request:
type: string
response:
type: string
scores:
type: array
description: A list of risk scores
items:
$ref: '#/components/schemas/RiskScore'
AfterKeys:
type: object
properties:
host:
type: object
additionalProperties:
type: string
user:
type: object
additionalProperties:
type: string
example:
host:
'host.name': 'example.host'
user:
'user.name': 'example_user_name'
KibanaDate:
type: string
oneOf:
- format: date
- format: date-time
- format: datemath
example: '2017-07-21T17:32:28Z'
IdentifierType:
type: string
enum:
- host
- user
RiskScore:
type: object
required:
- '@timestamp'
- identifierField
- identifierValue
- level
- totalScore
- totalScoreNormalized
- alertsScore
- otherScore
- riskiestInputs
properties:
'@timestamp':
type: string
format: 'date-time'
example: '2017-07-21T17:32:28Z'
description: The time at which the risk score was calculated.
identifierField:
type: string
example: 'host.name'
description: The identifier field defining this risk score. Coupled with `identifierValue`, uniquely identifies the entity being scored.
identifierValue:
type: string
example: 'example.host'
description: The identifier value defining this risk score. Coupled with `identifierField`, uniquely identifies the entity being scored.
level:
type: string
example: 'Critical'
description: Lexical description of the entity's risk.
totalScore:
type: number
format: double
description: The raw numeric value of the given entity's risk score.
totalScoreNormalized:
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:
type: number
format: double
description: The raw numeric risk score attributed to Security Alerts.
otherScore:
type: number
format: double
description: The raw numeric risk score attributed to other data sources
riskiestInputs:
type: array
description: A list of the 10 highest-risk documents contributing to this risk score. Useful for investigative purposes.
items:
$ref: '#/components/schemas/RiskScoreInput'
RiskScoreInput:
description: A generic representation of a document contributing to a Risk Score.
type: object
properties:
id:
type: string
example: 91a93376a507e86cfbf282166275b89f9dbdb1f0be6c8103c6ff2909ca8e1a1c
index:
type: string
example: .internal.alerts-security.alerts-default-000001
riskScore:
type: number
format: double
minimum: 0
maximum: 100
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')."
type: object
required:
- type
properties:
type:
type: string
value:
type: string
host:
type: number
format: double
minimum: 0
maximum: 1
user:
type: number
format: double
minimum: 0
maximum: 1
example:
type: 'risk_category'
value: 'alerts'
host: 0.8
user: 0.4

View file

@ -0,0 +1,79 @@
/*
* 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 { 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';
export interface GetScoresParams {
afterKeys: AfterKeys;
debug?: boolean;
index: string;
filter?: unknown;
identifierType?: IdentifierType;
pageSize: number;
range: { start: string; end: string };
weights?: RiskWeights;
}
export interface GetScoresResponse {
debug?: {
request: unknown;
response: unknown;
};
after_keys: AfterKeys;
scores: RiskScore[];
}
export interface SimpleRiskInput {
id: string;
index: string;
riskScore: string | number | undefined;
}
export type RiskInput = Ecs;
export interface RiskScore {
'@timestamp': string;
identifierField: string;
identifierValue: string;
level: string;
totalScore: number;
totalScoreNormalized: number;
alertsScore: number;
otherScore: number;
notes: string[];
riskiestInputs: SimpleRiskInput[] | RiskInput[];
}
export interface CalculateRiskScoreAggregations {
user?: {
after_key: AfterKey;
buckets: RiskScoreBucket[];
};
host?: {
after_key: AfterKey;
buckets: RiskScoreBucket[];
};
}
export interface RiskScoreBucket {
key: { [identifierField: string]: string; category: string };
doc_count: number;
risk_details: {
value: {
score: number;
normalized_score: number;
notes: string[];
level: string;
alerts_score: number;
other_score: number;
};
};
riskiest_inputs: SearchResponse;
}

View file

@ -10,6 +10,7 @@ import type { AppClient } from './types';
type AppClientMock = jest.Mocked<AppClient>;
const createAppClientMock = (): AppClientMock =>
({
getAlertsIndex: jest.fn(),
getSignalsIndex: jest.fn(),
getSourcererDataViewId: jest.fn().mockReturnValue('security-solution'),
} as unknown as AppClientMock);

View file

@ -73,6 +73,7 @@ import {
import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_routes';
import { registerDashboardsRoutes } from '../lib/dashboards/routes';
import { registerTagsRoutes } from '../lib/tags/routes';
import { riskScorePreviewRoute } from '../lib/risk_engine/routes';
export const initRoutes = (
router: SecuritySolutionPluginRouter,
@ -169,4 +170,8 @@ export const initRoutes = (
// telemetry preview endpoint for e2e integration tests only at the moment.
telemetryDetectionRulesPreviewRoute(router, logger, previewTelemetryReceiver, telemetrySender);
}
if (config.experimentalFeatures.riskScoringRoutesEnabled) {
riskScorePreviewRoute(router, logger);
}
};

View file

@ -76,6 +76,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s
'--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true',
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'previewTelemetryUrlEnabled',
'riskScoringRoutesEnabled',
])}`,
'--xpack.task_manager.poll_interval=1000',
`--xpack.actions.preconfigured=${JSON.stringify({

View file

@ -37,5 +37,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./throttle'));
loadTestFile(require.resolve('./ignore_fields'));
loadTestFile(require.resolve('./migrations'));
loadTestFile(require.resolve('./risk_engine'));
});
};

View file

@ -0,0 +1,554 @@
/*
* 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 { 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 {
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;
};
// 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 = 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]);
};
const getRiskScores = async ({ body }: { body: object }): Promise<{ scores: RiskScore[] }> => {
const { body: result } = await supertest
.post(RISK_SCORE_PREVIEW_URL)
.set('kbn-xsrf', 'true')
.send(body)
.expect(200);
return result;
};
const getRiskScoreAfterRuleCreationAndExecution = 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 getRiskScores({ body: { debug: true } });
};
describe('Risk engine', () => {
context('with auditbeat data', () => {
const { indexListOfDocuments } = dataGeneratorFactory({
es,
index: 'ecs_compliant',
log,
});
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant');
});
after(async () => {
await esArchiver.unload(
'x-pack/test/functional/es_archives/security_solution/ecs_compliant'
);
});
beforeEach(async () => {
await deleteAllAlerts(supertest, log, es);
await deleteAllRules(supertest, log);
await createSignalsIndex(supertest, log);
});
afterEach(async () => {
await deleteAllAlerts(supertest, log, es);
await deleteAllRules(supertest, log);
});
context('with a rule generating alerts with risk_score of 21', () => {
it('calculates risk from a single alert', async () => {
const documentId = uuidv4();
await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, documentId)]);
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId);
expect(removeFields(body.scores)).to.eql([
{
level: 'Unknown',
totalScore: 21,
totalScoreNormalized: 8.039816232771823,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
it('calculates risk from two alerts, each representing a unique host', async () => {
const documentId = uuidv4();
await indexListOfDocuments([
buildDocument({ host: { name: 'host-1' } }, documentId),
buildDocument({ host: { name: 'host-2' } }, documentId),
]);
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
alerts: 2,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Unknown',
totalScore: 21,
totalScoreNormalized: 8.039816232771823,
identifierField: 'host.name',
identifierValue: 'host-1',
},
{
level: 'Unknown',
totalScore: 21,
totalScoreNormalized: 8.039816232771823,
identifierField: 'host.name',
identifierValue: 'host-2',
},
]);
});
it('calculates risk from two alerts, both for the same host', async () => {
const documentId = uuidv4();
await indexListOfDocuments([
buildDocument({ host: { name: 'host-1' } }, documentId),
buildDocument({ host: { name: 'host-1' } }, documentId),
]);
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
alerts: 2,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Unknown',
totalScore: 28.42462120245875,
totalScoreNormalized: 10.88232052161514,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
it('calculates risk from 30 alerts, all for the same host', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(Array(30).fill(doc));
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
alerts: 30,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Unknown',
totalScore: 47.25513506055279,
totalScoreNormalized: 18.091552473412246,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
it('calculates risk from 31 alerts, 30 from the same host', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments([
...Array(30).fill(doc),
buildDocument({ host: { name: 'host-2' } }, documentId),
]);
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
alerts: 31,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Unknown',
totalScore: 47.25513506055279,
totalScoreNormalized: 18.091552473412246,
identifierField: 'host.name',
identifierValue: 'host-1',
},
{
level: 'Unknown',
totalScore: 21,
totalScoreNormalized: 8.039816232771823,
identifierField: 'host.name',
identifierValue: 'host-2',
},
]);
});
it('calculates risk from 100 alerts, all for the same host', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(Array(100).fill(doc));
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
alerts: 100,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Unknown',
totalScore: 50.67035607277805,
totalScoreNormalized: 19.399064346392823,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
});
context('with a rule generating alerts with risk_score of 100', () => {
it('calculates risk from 100 alerts, all for the same host', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(Array(100).fill(doc));
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
riskScore: 100,
alerts: 100,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Critical',
totalScore: 241.2874098703716,
totalScoreNormalized: 92.37649688758484,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
it('calculates risk from 1,000 alerts, all for the same host', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(
Array(1000)
.fill(doc)
.map((item, index) => ({
...item,
['@timestamp']: item['@timestamp'] - index,
}))
);
const body = await getRiskScoreAfterRuleCreationAndExecution(documentId, {
riskScore: 100,
alerts: 1000,
maxSignals: 1000,
});
expect(removeFields(body.scores)).to.eql([
{
level: 'Critical',
totalScore: 254.91456029175757,
totalScoreNormalized: 97.59362951445543,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
});
describe('risk score pagination', () => {
it('respects the specified after_keys', async () => {
const aaaId = uuidv4();
const zzzId = uuidv4();
const aaaDoc = buildDocument({ 'user.name': 'aaa' }, aaaId);
const zzzDoc = buildDocument({ 'user.name': 'zzz' }, zzzId);
await indexListOfDocuments(Array(50).fill(aaaDoc).concat(Array(50).fill(zzzDoc)));
await createAndSyncRuleAndAlerts({
query: `id: ${aaaId} OR ${zzzId}`,
alerts: 100,
riskScore: 100,
});
const { scores } = await getRiskScores({
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');
});
});
describe('risk score filtering', () => {
it('restricts the range of risk inputs used for scoring', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(
Array(100)
.fill(doc)
.map((_doc, i) => ({ ...doc, 'event.risk_score': i === 99 ? 1 : 100 }))
);
await createAndSyncRuleAndAlerts({
query: `id: ${documentId}`,
alerts: 100,
riskScore: 100,
riskScoreOverride: 'event.risk_score',
});
const { scores } = await getRiskScores({
body: {
filter: {
bool: {
filter: [
{
range: {
[ALERT_RISK_SCORE]: {
lte: 1,
},
},
},
],
},
},
},
});
expect(scores).to.have.length(1);
expect(scores[0].riskiestInputs).to.have.length(1);
});
});
describe('risk score ordering', () => {
it('aggregates multiple scores such that the highest-risk scores contribute the majority of the score', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(
Array(100)
.fill(doc)
.map((_doc, i) => ({ ...doc, 'event.risk_score': 100 - i }))
);
await createAndSyncRuleAndAlerts({
query: `id: ${documentId}`,
alerts: 100,
riskScore: 100,
riskScoreOverride: 'event.risk_score',
});
const { scores } = await getRiskScores({ body: {} });
expect(removeFields(scores)).to.eql([
{
level: 'High',
totalScore: 225.1106801442913,
totalScoreNormalized: 86.18326192354185,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
});
context('with global risk weights', () => {
it('weights host scores differently when host risk weight is configured', async () => {
const documentId = uuidv4();
const doc = buildDocument({ host: { name: 'host-1' } }, documentId);
await indexListOfDocuments(Array(100).fill(doc));
await createAndSyncRuleAndAlerts({
query: `id: ${documentId}`,
alerts: 100,
riskScore: 100,
});
const { scores } = await getRiskScores({
body: { weights: [{ type: 'global_identifier', host: 0.5 }] },
});
expect(removeFields(scores)).to.eql([
{
level: 'Moderate',
totalScore: 120.6437049351858,
totalScoreNormalized: 46.18824844379242,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
it('weights user scores differently if user risk weight is configured', async () => {
const documentId = uuidv4();
const doc = buildDocument({ user: { name: 'user-1' } }, documentId);
await indexListOfDocuments(Array(100).fill(doc));
await createAndSyncRuleAndAlerts({
query: `id: ${documentId}`,
alerts: 100,
riskScore: 100,
});
const { scores } = await getRiskScores({
body: { weights: [{ type: 'global_identifier', user: 0.7 }] },
});
expect(removeFields(scores)).to.eql([
{
level: 'Moderate',
totalScore: 168.9011869092601,
totalScoreNormalized: 64.66354782130938,
identifierField: 'user.name',
identifierValue: 'user-1',
},
]);
});
it('weights entity scores differently when host and user risk weights are configured', async () => {
const usersId = uuidv4();
const hostsId = uuidv4();
const userDocs = buildDocument({ 'user.name': 'user-1' }, usersId);
const hostDocs = buildDocument({ 'host.name': 'host-1' }, usersId);
await indexListOfDocuments(Array(50).fill(userDocs).concat(Array(50).fill(hostDocs)));
await createAndSyncRuleAndAlerts({
query: `id: ${hostsId} OR ${usersId}`,
alerts: 100,
riskScore: 100,
});
const { scores } = await getRiskScores({
body: { weights: [{ type: 'global_identifier', host: 0.4, user: 0.8 }] },
});
expect(removeFields(scores)).to.eql([
{
level: 'High',
totalScore: 186.47518232942502,
totalScoreNormalized: 71.39172370958079,
identifierField: 'user.name',
identifierValue: 'user-1',
},
{
level: 'Low',
totalScore: 93.23759116471251,
totalScoreNormalized: 35.695861854790394,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
});
context('with category weights', () => {
it('weights risk inputs from different categories according to the category weight', async () => {
const documentId = uuidv4();
const userSignal = buildDocument(
{ 'event.kind': 'signal', 'user.name': 'user-1' },
documentId
);
const hostSignal = buildDocument(
{ 'event.kind': 'signal', 'host.name': 'host-1' },
documentId
);
await indexListOfDocuments(Array(50).fill(userSignal).concat(Array(50).fill(hostSignal)));
await createAndSyncRuleAndAlerts({
query: `id: ${documentId}`,
alerts: 100,
riskScore: 100,
});
const { scores } = await getRiskScores({
body: {
weights: [{ type: 'risk_category', value: 'alerts', host: 0.4, user: 0.8 }],
},
});
expect(removeFields(scores)).to.eql([
{
level: 'High',
totalScore: 186.475182329425,
totalScoreNormalized: 71.39172370958079,
identifierField: 'user.name',
identifierValue: 'user-1',
},
{
level: 'Low',
totalScore: 93.2375911647125,
totalScoreNormalized: 35.695861854790394,
identifierField: 'host.name',
identifierValue: 'host-1',
},
]);
});
});
});
});
};