Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SIEM] Add license checks for ML Rules on the backend #61023

Merged
merged 4 commits into from
Mar 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions x-pack/legacy/plugins/siem/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,9 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR
*/
export const UNAUTHENTICATED_USER = 'Unauthenticated';

/*
Licensing requirements
*/
export const MINIMUM_ML_LICENSE = 'platinum';

export const NOTIFICATION_THROTTLE_RULE = 'rule';
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
} from '../../../../../../../../../src/core/server/mocks';
import { alertsClientMock } from '../../../../../../../../plugins/alerting/server/mocks';
import { actionsClientMock } from '../../../../../../../../plugins/actions/server/mocks';
import { licensingMock } from '../../../../../../../../plugins/licensing/server/mocks';

const createMockClients = () => ({
actionsClient: actionsClientMock.create(),
alertsClient: alertsClientMock.create(),
clusterClient: elasticsearchServiceMock.createScopedClusterClient(),
licensing: { license: licensingMock.createLicenseMock() },
savedObjectsClient: savedObjectsClientMock.create(),
siemClient: { signalsIndex: 'mockSignalsIndex' },
});
Expand All @@ -33,6 +35,7 @@ const createRequestContextMock = (
elasticsearch: { ...coreContext.elasticsearch, dataClient: clients.clusterClient },
savedObjects: { client: clients.savedObjectsClient },
},
licensing: clients.licensing,
siem: { getSiemClient: jest.fn(() => clients.siemClient) },
} as unknown) as RequestHandlerContext;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,18 +295,30 @@ export const getCreateRequest = () =>
body: typicalPayload(),
});

export const createMlRuleRequest = () => {
export const typicalMlRulePayload = () => {
const { query, language, index, ...mlParams } = typicalPayload();

return {
...mlParams,
type: 'machine_learning',
anomaly_threshold: 58,
machine_learning_job_id: 'typical-ml-job-id',
};
};

export const createMlRuleRequest = () => {
return requestMock.create({
method: 'post',
path: DETECTION_ENGINE_RULES_URL,
body: {
...mlParams,
type: 'machine_learning',
anomaly_threshold: 50,
machine_learning_job_id: 'some-uuid',
},
body: typicalMlRulePayload(),
});
};

export const createBulkMlRuleRequest = () => {
return requestMock.create({
method: 'post',
path: DETECTION_ENGINE_RULES_URL,
body: [typicalMlRulePayload()],
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ export const getSimpleRule = (ruleId = 'rule-1'): Partial<OutputRuleAlertRest> =
query: 'user.name: root or user.name: admin',
});

/**
* This is a typical ML rule for testing
* @param ruleId
*/
export const getSimpleMlRule = (ruleId = 'rule-1'): Partial<OutputRuleAlertRest> => ({
name: 'Simple Rule Query',
description: 'Simple Rule Query',
risk_score: 1,
rule_id: ruleId,
severity: 'high',
type: 'machine_learning',
anomaly_threshold: 44,
machine_learning_job_id: 'some_job_id',
});

/**
* This is a typical simple rule for testing that is easy for most basic testing
* @param ruleId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getFindResultWithSingleHit,
getEmptyFindResult,
getResult,
createBulkMlRuleRequest,
} from '../__mocks__/request_responses';
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { createRulesBulkRoute } from './create_rules_bulk_route';
Expand Down Expand Up @@ -56,6 +57,22 @@ describe('create_rules_bulk', () => {
});

describe('unhappy paths', () => {
it('returns an error object if creating an ML rule with an insufficient license', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);

const response = await server.inject(createBulkMlRuleRequest(), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
error: {
message: 'Your license does not support machine learning. Please upgrade your license.',
status_code: 400,
},
rule_id: 'rule-1',
},
]);
});

it('returns an error object if the index does not exist', async () => {
clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex());
const response = await server.inject(getReadBulkRequest(), context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createBulkErrorObject,
buildRouteValidation,
buildSiemResponse,
validateLicenseForRuleType,
} from '../utils';
import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema';
import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema';
Expand Down Expand Up @@ -90,6 +91,8 @@ export const createRulesBulkRoute = (router: IRouter) => {
} = payloadRule;
const ruleIdOrUuid = ruleId ?? uuid.v4();
try {
validateLicenseForRuleType({ license: context.licensing.license, ruleType: type });
rylnd marked this conversation as resolved.
Show resolved Hide resolved

const finalIndex = outputIndex ?? siemClient.signalsIndex;
const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex);
if (!indexExists) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,31 @@ describe('create_rules', () => {
expect(response.status).toEqual(404);
expect(response.body).toEqual({ message: 'Not Found', status_code: 404 });
});

it('returns 200 if license is not platinum', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);

const response = await server.inject(getCreateRequest(), context);
expect(response.status).toEqual(200);
});
});

describe('creating an ML Rule', () => {
it('is successful', async () => {
const response = await server.inject(createMlRuleRequest(), context);
expect(response.status).toEqual(200);
});

it('rejects the request if licensing is not platinum', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);

const response = await server.inject(createMlRuleRequest(), context);
expect(response.status).toEqual(400);
expect(response.body).toEqual({
message: 'Your license does not support machine learning. Please upgrade your license.',
status_code: 400,
});
});
});

describe('creating a Notification if throttle and actions were provided ', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings';
import { transformValidate } from './validate';
import { getIndexExists } from '../../index/get_index_exists';
import { createRulesSchema } from '../schemas/create_rules_schema';
import { buildRouteValidation, transformError, buildSiemResponse } from '../utils';
import {
buildRouteValidation,
transformError,
buildSiemResponse,
validateLicenseForRuleType,
} from '../utils';
import { createNotifications } from '../../notifications/create_notifications';

export const createRulesRoute = (router: IRouter): void => {
Expand Down Expand Up @@ -66,6 +71,7 @@ export const createRulesRoute = (router: IRouter): void => {
const siemResponse = buildSiemResponse(response);

try {
validateLicenseForRuleType({ license: context.licensing.license, ruleType: type });
if (!context.alerting || !context.actions) {
return siemResponse.error({ statusCode: 404 });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
ruleIdsToNdJsonString,
rulesToNdJsonString,
getSimpleRuleWithId,
getSimpleRule,
getSimpleMlRule,
} from '../__mocks__/utils';
import {
getImportRulesRequest,
Expand Down Expand Up @@ -102,6 +104,30 @@ describe('import_rules_route', () => {
});

describe('unhappy paths', () => {
it('returns an error object if creating an ML rule with an insufficient license', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);
const rules = [getSimpleRule(), getSimpleMlRule('rule-2')];
const hapiStreamWithMlRule = buildHapiStream(rulesToNdJsonString(rules));
request = getImportRulesRequest(hapiStreamWithMlRule);

const response = await server.inject(request, context);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
errors: [
{
error: {
message:
'Your license does not support machine learning. Please upgrade your license.',
status_code: 400,
},
rule_id: 'rule-2',
},
],
success: false,
success_count: 1,
});
});

test('returns error if createPromiseFromStreams throws error', async () => {
jest
.spyOn(createRulesStreamFromNdJson, 'createRulesStreamFromNdJson')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isImportRegular,
transformError,
buildSiemResponse,
validateLicenseForRuleType,
} from '../utils';
import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson';
import { ImportRuleAlertRest } from '../../types';
Expand Down Expand Up @@ -146,6 +147,11 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config
} = parsedRule;

try {
validateLicenseForRuleType({
license: context.licensing.license,
ruleType: type,
});

const signalsIndex = siemClient.signalsIndex;
const indexExists = await getIndexExists(
clusterClient.callAsCurrentUser,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getFindResultWithSingleHit,
getPatchBulkRequest,
getResult,
typicalMlRulePayload,
} from '../__mocks__/request_responses';
import { serverMock, requestContextMock, requestMock } from '../__mocks__';
import { patchRulesBulkRoute } from './patch_rules_bulk_route';
Expand Down Expand Up @@ -88,6 +89,27 @@ describe('patch_rules_bulk', () => {
expect(response.status).toEqual(404);
expect(response.body).toEqual({ message: 'Not Found', status_code: 404 });
});

it('rejects patching of an ML rule with an insufficient license', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);
const request = requestMock.create({
method: 'patch',
path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`,
body: [typicalMlRulePayload()],
});

const response = await server.inject(request, context);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
error: {
message: 'Your license does not support machine learning. Please upgrade your license.',
status_code: 400,
},
rule_id: 'rule-1',
},
]);
});
});

describe('request validation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
IRuleSavedAttributesSavedObjectAttributes,
PatchRuleAlertParamsRest,
} from '../../rules/types';
import { transformBulkError, buildRouteValidation, buildSiemResponse } from '../utils';
import {
transformBulkError,
buildRouteValidation,
buildSiemResponse,
validateLicenseForRuleType,
} from '../utils';
import { getIdBulkError } from './utils';
import { transformValidateBulkError, validate } from './validate';
import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema';
Expand Down Expand Up @@ -80,6 +85,10 @@ export const patchRulesBulkRoute = (router: IRouter) => {
} = payloadRule;
const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)';
try {
if (type) {
validateLicenseForRuleType({ license: context.licensing.license, ruleType: type });
}

const rule = await patchRules({
alertsClient,
actionsClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
typicalPayload,
getFindResultWithSingleHit,
nonRuleFindResult,
typicalMlRulePayload,
} from '../__mocks__/request_responses';
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { patchRulesRoute } from './patch_rules_route';
Expand Down Expand Up @@ -109,6 +110,22 @@ describe('patch_rules', () => {
})
);
});

it('rejects patching a rule to ML if licensing is not platinum', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);
const request = requestMock.create({
method: 'patch',
path: DETECTION_ENGINE_RULES_URL,
body: typicalMlRulePayload(),
});
const response = await server.inject(request, context);

expect(response.status).toEqual(400);
expect(response.body).toEqual({
message: 'Your license does not support machine learning. Please upgrade your license.',
status_code: 400,
});
});
});

describe('request validation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
IRuleSavedAttributesSavedObjectAttributes,
} from '../../rules/types';
import { patchRulesSchema } from '../schemas/patch_rules_schema';
import { buildRouteValidation, transformError, buildSiemResponse } from '../utils';
import {
buildRouteValidation,
transformError,
buildSiemResponse,
validateLicenseForRuleType,
} from '../utils';
import { getIdError } from './utils';
import { transformValidate } from './validate';
import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings';
Expand Down Expand Up @@ -65,6 +70,10 @@ export const patchRulesRoute = (router: IRouter) => {
const siemResponse = buildSiemResponse(response);

try {
if (type) {
validateLicenseForRuleType({ license: context.licensing.license, ruleType: type });
}

if (!context.alerting || !context.actions) {
return siemResponse.error({ statusCode: 404 });
}
Expand Down
Loading