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][Detection Engine] Bulk REST API for create, update, and delete #53543

Merged
merged 20 commits into from
Dec 19, 2019
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
6 changes: 6 additions & 0 deletions x-pack/legacy/plugins/siem/server/kibana.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { isAlertExecutor } from './lib/detection_engine/signals/types';
import { readTagsRoute } from './lib/detection_engine/routes/tags/read_tags_route';
import { readPrivilegesRoute } from './lib/detection_engine/routes/privileges/read_privileges_route';
import { addPrepackedRulesRoute } from './lib/detection_engine/routes/rules/add_prepackaged_rules_route';
import { createRulesBulkRoute } from './lib/detection_engine/routes/rules/create_rules_bulk_route';
import { updateRulesBulkRoute } from './lib/detection_engine/routes/rules/update_rules_bulk_route';
import { deleteRulesBulkRoute } from './lib/detection_engine/routes/rules/delete_rules_bulk_route';

const APP_ID = 'siem';

Expand All @@ -44,6 +47,9 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy
deleteRulesRoute(__legacy);
findRulesRoute(__legacy);
addPrepackedRulesRoute(__legacy);
createRulesBulkRoute(__legacy);
updateRulesBulkRoute(__legacy);
deleteRulesBulkRoute(__legacy);

// Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals
// POST /api/detection_engine/signals/status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,42 @@ export const getFindRequest = (): ServerInjectOptions => ({
url: `${DETECTION_ENGINE_RULES_URL}/_find`,
});

export const getReadBulkRequest = (): ServerInjectOptions => ({
method: 'POST',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
payload: [typicalPayload()],
});

export const getUpdateBulkRequest = (): ServerInjectOptions => ({
method: 'PUT',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`,
payload: [typicalPayload()],
});

export const getDeleteBulkRequest = (): ServerInjectOptions => ({
method: 'DELETE',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_delete`,
payload: [{ rule_id: 'rule-1' }],
});

export const getDeleteBulkRequestById = (): ServerInjectOptions => ({
method: 'DELETE',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_delete`,
payload: [{ id: 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd' }],
});

export const getDeleteAsPostBulkRequestById = (): ServerInjectOptions => ({
method: 'POST',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_delete`,
payload: [{ id: 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd' }],
});

export const getDeleteAsPostBulkRequest = (): ServerInjectOptions => ({
method: 'POST',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_delete`,
payload: [{ rule_id: 'rule-1' }],
});

export const getPrivilegeRequest = (): ServerInjectOptions => ({
method: 'GET',
url: `${DETECTION_ENGINE_PRIVILEGES_URL}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import {
createMockServer,
createMockServerWithoutActionClientDecoration,
createMockServerWithoutAlertClientDecoration,
createMockServerWithoutActionOrAlertClientDecoration,
} from '../__mocks__/_mock_server';
import { createRulesRoute } from './create_rules_route';
import { ServerInjectOptions } from 'hapi';
import {
getFindResult,
getResult,
createActionResult,
typicalPayload,
getReadBulkRequest,
} from '../__mocks__/request_responses';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { createRulesBulkRoute } from './create_rules_bulk_route';

describe('create_rules_bulk', () => {
let { server, alertsClient, actionsClient, elasticsearch } = createMockServer();

beforeEach(() => {
jest.resetAllMocks();
({ server, alertsClient, actionsClient, elasticsearch } = createMockServer());
elasticsearch.getCluster = jest.fn().mockImplementation(() => ({
callWithRequest: jest.fn().mockImplementation(() => true),
}));

createRulesBulkRoute(server);
});

describe('status codes with actionClient and alertClient', () => {
test('returns 200 when creating a single rule with a valid actionClient and alertClient', async () => {
alertsClient.find.mockResolvedValue(getFindResult());
alertsClient.get.mockResolvedValue(getResult());
actionsClient.create.mockResolvedValue(createActionResult());
alertsClient.create.mockResolvedValue(getResult());
const { statusCode } = await server.inject(getReadBulkRequest());
expect(statusCode).toBe(200);
});

test('returns 404 if actionClient is not available on the route', async () => {
const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration();
createRulesRoute(serverWithoutActionClient);
const { statusCode } = await serverWithoutActionClient.inject(getReadBulkRequest());
expect(statusCode).toBe(404);
});

test('returns 404 if alertClient is not available on the route', async () => {
const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration();
createRulesRoute(serverWithoutAlertClient);
const { statusCode } = await serverWithoutAlertClient.inject(getReadBulkRequest());
expect(statusCode).toBe(404);
});

test('returns 404 if alertClient and actionClient are both not available on the route', async () => {
const {
serverWithoutActionOrAlertClient,
} = createMockServerWithoutActionOrAlertClientDecoration();
createRulesRoute(serverWithoutActionOrAlertClient);
const { statusCode } = await serverWithoutActionOrAlertClient.inject(getReadBulkRequest());
expect(statusCode).toBe(404);
});
});

describe('validation', () => {
test('returns 200 if rule_id is not given as the id is auto generated from the alert framework', async () => {
alertsClient.find.mockResolvedValue(getFindResult());
alertsClient.get.mockResolvedValue(getResult());
actionsClient.create.mockResolvedValue(createActionResult());
alertsClient.create.mockResolvedValue(getResult());
// missing rule_id should return 200 as it will be auto generated if not given
const { rule_id, ...noRuleId } = typicalPayload();
const request: ServerInjectOptions = {
method: 'POST',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
payload: [noRuleId],
};
const { statusCode } = await server.inject(request);
expect(statusCode).toBe(200);
});

test('returns 200 if type is query', async () => {
alertsClient.find.mockResolvedValue(getFindResult());
alertsClient.get.mockResolvedValue(getResult());
actionsClient.create.mockResolvedValue(createActionResult());
alertsClient.create.mockResolvedValue(getResult());
const { type, ...noType } = typicalPayload();
const request: ServerInjectOptions = {
method: 'POST',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
payload: [
{
...noType,
type: 'query',
},
],
};
const { statusCode } = await server.inject(request);
expect(statusCode).toBe(200);
});

test('returns 400 if type is not filter or kql', async () => {
alertsClient.find.mockResolvedValue(getFindResult());
alertsClient.get.mockResolvedValue(getResult());
actionsClient.create.mockResolvedValue(createActionResult());
alertsClient.create.mockResolvedValue(getResult());
const { type, ...noType } = typicalPayload();
const request: ServerInjectOptions = {
method: 'POST',
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
payload: [
{
...noType,
type: 'something-made-up',
},
],
};
const { statusCode } = await server.inject(request);
expect(statusCode).toBe(400);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import Hapi from 'hapi';
import { isFunction } from 'lodash/fp';
import uuid from 'uuid';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { createRules } from '../../rules/create_rules';
import { BulkRulesRequest } from '../../rules/types';
import { ServerFacade } from '../../../../types';
import { readRules } from '../../rules/read_rules';
import { transformOrBulkError } from './utils';
import { getIndexExists } from '../../index/get_index_exists';
import {
callWithRequestFactory,
getIndex,
transformBulkError,
createBulkErrorObject,
} from '../utils';
import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema';

export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRoute => {
return {
method: 'POST',
path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
options: {
tags: ['access:siem'],
validate: {
options: {
abortEarly: false,
},
payload: createRulesBulkSchema,
},
},
async handler(request: BulkRulesRequest, headers) {
const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null;
const actionsClient = isFunction(request.getActionsClient)
? request.getActionsClient()
: null;

if (!alertsClient || !actionsClient) {
return headers.response().code(404);
}

const rules = Promise.all(
request.payload.map(async payloadRule => {
const {
created_at: createdAt,
description,
enabled,
false_positives: falsePositives,
from,
immutable,
query,
language,
output_index: outputIndex,
saved_id: savedId,
meta,
filters,
rule_id: ruleId,
index,
interval,
max_signals: maxSignals,
risk_score: riskScore,
name,
severity,
tags,
threats,
to,
type,
updated_at: updatedAt,
references,
timeline_id: timelineId,
version,
} = payloadRule;
const ruleIdOrUuid = ruleId ?? uuid.v4();
try {
const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server);
const callWithRequest = callWithRequestFactory(request, server);
const indexExists = await getIndexExists(callWithRequest, finalIndex);
if (!indexExists) {
return createBulkErrorObject({
ruleId: ruleIdOrUuid,
statusCode: 409,
message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`,
});
}
if (ruleId != null) {
const rule = await readRules({ alertsClient, ruleId });
if (rule != null) {
return createBulkErrorObject({
ruleId,
statusCode: 409,
message: `rule_id: "${ruleId}" already exists`,
});
}
}
const createdRule = await createRules({
alertsClient,
actionsClient,
createdAt,
description,
enabled,
falsePositives,
from,
immutable,
query,
language,
outputIndex: finalIndex,
savedId,
timelineId,
meta,
filters,
ruleId: ruleIdOrUuid,
index,
interval,
maxSignals,
riskScore,
name,
severity,
tags,
to,
type,
threats,
updatedAt,
references,
version,
});
return transformOrBulkError(ruleIdOrUuid, createdRule);
} catch (err) {
return transformBulkError(ruleIdOrUuid, err);
}
})
);
return rules;
},
};
};

export const createRulesBulkRoute = (server: ServerFacade): void => {
server.route(createCreateRulesBulkRoute(server));
};
Loading