Skip to content

Commit

Permalink
[Security Solution][Detections] Support arrays in event fields for Se…
Browse files Browse the repository at this point in the history
…verity/Risk overrides (elastic#83723)

This PR changes the behavior of severity and risk score overrides in two ways:

- adds support for arrays in the mapped event fields (so a rule can be triggered by an event where e.g. `event.custom_severity` has a value like `[45, 70, 90]`)
- makes the logic of overrides more flexible, resilient to the incoming values (filters out junk, extracts meaningful values, does its best to find a value that would fit the mapping)
  • Loading branch information
banderror authored Dec 1, 2020
1 parent c0d7ce7 commit 80e88ce
Show file tree
Hide file tree
Showing 8 changed files with 743 additions and 99 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { set } from '@elastic/safer-lodash-set';
import {
SignalSourceHit,
SignalSearchResponse,
Expand Down Expand Up @@ -189,9 +190,25 @@ export const sampleDocNoSortId = (
sort: [],
});

export const sampleDocSeverity = (
severity?: Array<string | number | null> | string | number | null
): SignalSourceHit => ({
export const sampleDocSeverity = (severity?: unknown, fieldName?: string): SignalSourceHit => {
const doc = {
_index: 'myFakeSignalIndex',
_type: 'doc',
_score: 100,
_version: 1,
_id: sampleIdGuid,
_source: {
someKey: 'someValue',
'@timestamp': '2020-04-20T21:27:45+0000',
},
sort: [],
};

set(doc._source, fieldName ?? 'event.severity', severity);
return doc;
};

export const sampleDocRiskScore = (riskScore?: unknown): SignalSourceHit => ({
_index: 'myFakeSignalIndex',
_type: 'doc',
_score: 100,
Expand All @@ -201,7 +218,7 @@ export const sampleDocSeverity = (
someKey: 'someValue',
'@timestamp': '2020-04-20T21:27:45+0000',
event: {
severity: severity ?? 100,
risk: riskScore,
},
},
sort: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,218 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { sampleDocNoSortId } from '../__mocks__/es_results';
import { buildRiskScoreFromMapping } from './build_risk_score_from_mapping';
import {
RiskScore,
RiskScoreMappingOrUndefined,
} from '../../../../../common/detection_engine/schemas/common/schemas';
import { sampleDocRiskScore } from '../__mocks__/es_results';
import {
buildRiskScoreFromMapping,
BuildRiskScoreFromMappingReturn,
} from './build_risk_score_from_mapping';

describe('buildRiskScoreFromMapping', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('risk score defaults to provided if mapping is incomplete', () => {
const riskScore = buildRiskScoreFromMapping({
eventSource: sampleDocNoSortId()._source,
riskScore: 57,
riskScoreMapping: undefined,
describe('base cases: when mapping is undefined', () => {
test('returns the provided default score', () => {
testIt({
fieldValue: 42,
scoreDefault: 57,
scoreMapping: undefined,
expected: scoreOf(57),
});
});
});

describe('base cases: when mapping to a field of type number', () => {
test(`returns that number if it's integer and within the range [0;100]`, () => {
testIt({
fieldValue: 42,
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(42),
});
});

test(`returns that number if it's float and within the range [0;100]`, () => {
testIt({
fieldValue: 3.14,
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(3.14),
});
});

test(`returns default score if the number is < 0`, () => {
testIt({
fieldValue: -0.0000000000001,
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: scoreOf(57),
});
});

test(`returns default score if the number is > 100`, () => {
testIt({
fieldValue: 100.0000000000001,
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: scoreOf(57),
});
});
});

describe('base cases: when mapping to a field of type string', () => {
test(`returns the number casted from string if it's integer and within the range [0;100]`, () => {
testIt({
fieldValue: '42',
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(42),
});
});

test(`returns the number casted from string if it's float and within the range [0;100]`, () => {
testIt({
fieldValue: '3.14',
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(3.14),
});
});

test(`returns default score if the "number" is < 0`, () => {
testIt({
fieldValue: '-1',
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: scoreOf(57),
});
});

test(`returns default score if the "number" is > 100`, () => {
testIt({
fieldValue: '101',
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: scoreOf(57),
});
});
});

expect(riskScore).toEqual({ riskScore: 57, riskScoreMeta: {} });
describe('base cases: when mapping to an array of numbers or strings', () => {
test(`returns that number if it's a single element and it's within the range [0;100]`, () => {
testIt({
fieldValue: [3.14],
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(3.14),
});
});

test(`returns the max number of those that are within the range [0;100]`, () => {
testIt({
fieldValue: [42, -42, 17, 87, 87.5, '86.5', 110, 66],
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(87.5),
});
});

test(`supports casting strings to numbers`, () => {
testIt({
fieldValue: [-1, 1, '3', '1.5', '3.14', 2],
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(3.14),
});
});
});

// TODO: Enhance...
describe('edge cases: when mapping to a single junk value', () => {
describe('ignores it and returns the default score', () => {
const cases = [
undefined,
null,
NaN,
Infinity,
-Infinity,
Number.MAX_VALUE,
-Number.MAX_VALUE,
-Number.MIN_VALUE,
'string',
[],
{},
new Date(),
];

test.each(cases)('%p', (value) => {
testIt({
fieldValue: value,
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: scoreOf(57),
});
});
});
});

describe('edge cases: when mapping to an array of junk values', () => {
describe('ignores junk, extracts valid numbers and returns the max number within the range [0;100]', () => {
type Case = [unknown[], number];
const cases: Case[] = [
[[undefined, null, 1.5, 1, -Infinity], 1.5],
[['42', NaN, '44', '43', 42, {}], 44],
[[Infinity, '101', 100, 99, Number.MIN_VALUE], 100],
[[Number.MIN_VALUE, -0], Number.MIN_VALUE],
];

test.each(cases)('%p', (value, expectedScore) => {
testIt({
fieldValue: value,
scoreDefault: 57,
scoreMapping: mappingToSingleField(),
expected: overriddenScoreOf(expectedScore),
});
});
});
});
});

interface TestCase {
fieldValue: unknown;
scoreDefault: RiskScore;
scoreMapping: RiskScoreMappingOrUndefined;
expected: BuildRiskScoreFromMappingReturn;
}

function testIt({ fieldValue, scoreDefault, scoreMapping, expected }: TestCase) {
const result = buildRiskScoreFromMapping({
eventSource: sampleDocRiskScore(fieldValue)._source,
riskScore: scoreDefault,
riskScoreMapping: scoreMapping,
});

expect(result).toEqual(expected);
}

function mappingToSingleField() {
return [{ field: 'event.risk', operator: 'equals' as const, value: '', risk_score: undefined }];
}

function scoreOf(value: number) {
return {
riskScore: value,
riskScoreMeta: {},
};
}

function overriddenScoreOf(value: number) {
return {
riskScore: value,
riskScoreMeta: { riskScoreOverridden: true },
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,78 @@ import {
} from '../../../../../common/detection_engine/schemas/common/schemas';
import { SignalSource } from '../types';

interface BuildRiskScoreFromMappingProps {
export interface BuildRiskScoreFromMappingProps {
eventSource: SignalSource;
riskScore: RiskScore;
riskScoreMapping: RiskScoreMappingOrUndefined;
}

interface BuildRiskScoreFromMappingReturn {
export interface BuildRiskScoreFromMappingReturn {
riskScore: RiskScore;
riskScoreMeta: Meta; // TODO: Stricter types
}

/**
* Calculates the final risk score for a detection alert based on:
* - source event object that can potentially contain fields representing risk score
* - the default score specified by the user
* - (optional) score mapping specified by the user ("map this field to the score")
*
* NOTE: Current MVP support is for mapping from a single field.
*/
export const buildRiskScoreFromMapping = ({
eventSource,
riskScore,
riskScoreMapping,
}: BuildRiskScoreFromMappingProps): BuildRiskScoreFromMappingReturn => {
// MVP support is for mapping from a single field
if (riskScoreMapping != null && riskScoreMapping.length > 0) {
const mappedField = riskScoreMapping[0].field;
// TODO: Expand by verifying fieldType from index via doc._index
const mappedValue = get(mappedField, eventSource);
if (
typeof mappedValue === 'number' &&
Number.isSafeInteger(mappedValue) &&
mappedValue >= 0 &&
mappedValue <= 100
) {
return { riskScore: mappedValue, riskScoreMeta: { riskScoreOverridden: true } };
if (!riskScoreMapping || !riskScoreMapping.length) {
return defaultScore(riskScore);
}

// TODO: Expand by verifying fieldType from index via doc._index
const eventField = riskScoreMapping[0].field;
const eventValue = get(eventField, eventSource);
const eventValues = Array.isArray(eventValue) ? eventValue : [eventValue];

const validNumbers = eventValues.map(toValidNumberOrMinusOne).filter((n) => n > -1);

if (validNumbers.length > 0) {
const maxNumber = getMaxOf(validNumbers);
return overriddenScore(maxNumber);
}

return defaultScore(riskScore);
};

function toValidNumberOrMinusOne(value: unknown): number {
if (typeof value === 'number' && isValidNumber(value)) {
return value;
}

if (typeof value === 'string') {
const num = Number(value);
if (isValidNumber(num)) {
return num;
}
}

return -1;
}

function isValidNumber(value: number): boolean {
return Number.isFinite(value) && value >= 0 && value <= 100;
}

function getMaxOf(array: number[]) {
// NOTE: It's safer to use reduce rather than Math.max(...array). The latter won't handle large input.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max
return array.reduce((a, b) => Math.max(a, b));
}

function defaultScore(riskScore: RiskScore): BuildRiskScoreFromMappingReturn {
return { riskScore, riskScoreMeta: {} };
};
}

function overriddenScore(riskScore: RiskScore): BuildRiskScoreFromMappingReturn {
return { riskScore, riskScoreMeta: { riskScoreOverridden: true } };
}
Loading

0 comments on commit 80e88ce

Please sign in to comment.