[SIEM][Detection Engine] Import/Export REST endpoints (#54332)

## Summary

* Adds Import and Export REST endpoints
* Fixes minor misc issues with types
* Changes camel case from bulk api to become snake_case

For the API and testing it is very similar to the saved objects API

For import:

```ts
POST /api/detection_engine/rules/_import
```

With a ndjson body of:

```ts
{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}
{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}
{"exported_count":2,"missing_rules":[],"missing_rules_count":0}
```

If you want to overwrite existing objects you can use the overwrite query parameter like so:

```ts
POST /api/detection_engine/rules/_import?overwrite=true
```

See and run the scripts of:
```ts
import_rules.sh
import_rules_no_overwrite.sh
```

For exporting everything:

```ts
POST /api/detection_engine/rules/_export
```

For exporting just a handful of things you would send a body like so:

```ts
POST /api/detection_engine/rules/_export
{
  "objects": [
    {
      "rule_id": "query-rule-id-1"
    },
    {
      "rule_id": "query-rule-id-2"
    }
  ]
}
```

To change either the filename of the file that gets downloaded or to remove the extra appended export details you can do the following:

```ts
POST /api/detection_engine/rules/_export?exclude_export_details=true&file_name=my_file.ndjson"
```

See the scripts of:
```ts
export_rules.sh
export_rules_by_rule_id.sh
export_rules_by_rule_id_to_file.sh
export_rules_to_file.sh
```

### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~

~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~

~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~

- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios

~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~

### For maintainers

~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~

- [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
This commit is contained in:
Frank Hassanabad 2020-01-09 17:16:45 -07:00 committed by GitHub
parent 299df2dae7
commit 7eb88c4d13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 3808 additions and 71 deletions

View file

@ -25,6 +25,8 @@ import { addPrepackedRulesRoute } from './lib/detection_engine/routes/rules/add_
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';
import { importRulesRoute } from './lib/detection_engine/routes/rules/import_rules_route';
import { exportRulesRoute } from './lib/detection_engine/routes/rules/export_rules_route';
const APP_ID = 'siem';
@ -50,6 +52,8 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy
createRulesBulkRoute(__legacy);
updateRulesBulkRoute(__legacy);
deleteRulesBulkRoute(__legacy);
importRulesRoute(__legacy);
exportRulesRoute(__legacy);
// Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals
// POST /api/detection_engine/signals/status

View file

@ -159,7 +159,7 @@ export const getPrivilegeRequest = (): ServerInjectOptions => ({
url: `${DETECTION_ENGINE_PRIVILEGES_URL}`,
});
interface FindHit {
export interface FindHit {
page: number;
perPage: number;
total: number;
@ -176,7 +176,7 @@ export const getFindResult = (): FindHit => ({
export const getFindResultWithSingleHit = (): FindHit => ({
page: 1,
perPage: 1,
total: 0,
total: 1,
data: [getResult()],
});

View file

@ -24,6 +24,7 @@ import {
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { deleteRulesBulkRoute } from './delete_rules_bulk_route';
import { BulkError } from '../utils';
describe('delete_rules', () => {
let { server, alertsClient } = createMockServer();
@ -83,10 +84,14 @@ describe('delete_rules', () => {
alertsClient.get.mockResolvedValue(getResult());
alertsClient.delete.mockResolvedValue({});
const { payload } = await server.inject(getDeleteBulkRequest());
const parsed = JSON.parse(payload);
expect(parsed).toEqual([
{ error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' },
]);
const parsed: BulkError[] = JSON.parse(payload);
const expected: BulkError[] = [
{
error: { message: 'rule_id: "rule-1" not found', status_code: 404 },
rule_id: 'rule-1',
},
];
expect(parsed).toEqual(expected);
});
test('returns 404 if actionClient is not available on the route', async () => {

View file

@ -0,0 +1,70 @@
/*
* 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 Boom from 'boom';
import Hapi from 'hapi';
import { isFunction } from 'lodash/fp';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { ExportRulesRequest } from '../../rules/types';
import { ServerFacade } from '../../../../types';
import { getNonPackagedRulesCount } from '../../rules/get_existing_prepackaged_rules';
import { exportRulesSchema, exportRulesQuerySchema } from '../schemas/export_rules_schema';
import { getExportByObjectIds } from '../../rules/get_export_by_object_ids';
import { getExportAll } from '../../rules/get_export_all';
export const createExportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => {
return {
method: 'POST',
path: `${DETECTION_ENGINE_RULES_URL}/_export`,
options: {
tags: ['access:siem'],
validate: {
options: {
abortEarly: false,
},
payload: exportRulesSchema,
query: exportRulesQuerySchema,
},
},
async handler(request: ExportRulesRequest, 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 exportSizeLimit = server.config().get<number>('savedObjects.maxImportExportSize');
if (request.payload?.objects != null && request.payload.objects.length > exportSizeLimit) {
return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`);
} else {
const nonPackagedRulesCount = await getNonPackagedRulesCount({ alertsClient });
if (nonPackagedRulesCount > exportSizeLimit) {
return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`);
}
}
const exported =
request.payload?.objects != null
? await getExportByObjectIds(alertsClient, request.payload.objects)
: await getExportAll(alertsClient);
const response = request.query.exclude_export_details
? headers.response(exported.rulesNdjson)
: headers.response(`${exported.rulesNdjson}${exported.exportDetails}`);
return response
.header('Content-Disposition', `attachment; filename="${request.query.file_name}"`)
.header('Content-Type', 'application/ndjson');
},
};
};
export const exportRulesRoute = (server: ServerFacade): void => {
server.route(createExportRulesRoute(server));
};

View file

@ -0,0 +1,213 @@
/*
* 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 Boom from 'boom';
import Hapi from 'hapi';
import { extname } from 'path';
import { isFunction } from 'lodash/fp';
import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { createRules } from '../../rules/create_rules';
import { ImportRulesRequest } from '../../rules/types';
import { ServerFacade } from '../../../../types';
import { readRules } from '../../rules/read_rules';
import { getIndexExists } from '../../index/get_index_exists';
import {
callWithRequestFactory,
getIndex,
createImportErrorObject,
transformImportError,
ImportSuccessError,
} from '../utils';
import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson';
import { ImportRuleAlertRest } from '../../types';
import { transformOrImportError } from './utils';
import { updateRules } from '../../rules/update_rules';
import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema';
export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => {
return {
method: 'POST',
path: `${DETECTION_ENGINE_RULES_URL}/_import`,
options: {
tags: ['access:siem'],
payload: {
maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'),
output: 'stream',
allow: 'multipart/form-data',
},
validate: {
options: {
abortEarly: false,
},
query: importRulesQuerySchema,
payload: importRulesPayloadSchema,
},
},
async handler(request: ImportRulesRequest, 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 { filename } = request.payload.file.hapi;
const fileExtension = extname(filename).toLowerCase();
if (fileExtension !== '.ndjson') {
return Boom.badRequest(`Invalid file extension ${fileExtension}`);
}
const objectLimit = server.config().get<number>('savedObjects.maxImportExportSize');
const readStream = createRulesStreamFromNdJson(request.payload.file, objectLimit);
const parsedObjects = await createPromiseFromStreams<[ImportRuleAlertRest | Error]>([
readStream,
]);
const reduced = await parsedObjects.reduce<Promise<ImportSuccessError>>(
async (accum, parsedRule) => {
const existingImportSuccessError = await accum;
if (parsedRule instanceof Error) {
// If the JSON object had a validation or parse error then we return
// early with the error and an (unknown) for the ruleId
return createImportErrorObject({
ruleId: '(unknown)', // TODO: Better handling where we know which ruleId is having issues with imports
statusCode: 400,
message: parsedRule.message,
existingImportSuccessError,
});
}
const {
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,
references,
timeline_id: timelineId,
timeline_title: timelineTitle,
version,
} = parsedRule;
try {
const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server);
const callWithRequest = callWithRequestFactory(request, server);
const indexExists = await getIndexExists(callWithRequest, finalIndex);
if (!indexExists) {
return createImportErrorObject({
ruleId,
statusCode: 409,
message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`,
existingImportSuccessError,
});
}
const rule = await readRules({ alertsClient, ruleId });
if (rule == null) {
const createdRule = await createRules({
alertsClient,
actionsClient,
createdAt: new Date().toISOString(),
description,
enabled,
falsePositives,
from,
immutable,
query,
language,
outputIndex: finalIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
ruleId,
index,
interval,
maxSignals,
riskScore,
name,
severity,
tags,
to,
type,
threats,
updatedAt: new Date().toISOString(),
references,
version,
});
return transformOrImportError(ruleId, createdRule, existingImportSuccessError);
} else if (rule != null && request.query.overwrite) {
const updatedRule = await updateRules({
alertsClient,
actionsClient,
description,
enabled,
falsePositives,
from,
immutable,
query,
language,
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
id: undefined,
ruleId,
index,
interval,
maxSignals,
riskScore,
name,
severity,
tags,
to,
type,
threats,
references,
version,
});
return transformOrImportError(ruleId, updatedRule, existingImportSuccessError);
} else {
return existingImportSuccessError;
}
} catch (err) {
return transformImportError(ruleId, err, existingImportSuccessError);
}
},
Promise.resolve({
success: true,
success_count: 0,
errors: [],
})
);
return reduced;
},
};
};
export const importRulesRoute = (server: ServerFacade): void => {
server.route(createImportRulesRoute(server));
};

View file

@ -23,6 +23,7 @@ import {
} from '../__mocks__/request_responses';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { updateRulesBulkRoute } from './update_rules_bulk_route';
import { BulkError } from '../utils';
describe('update_rules_bulk', () => {
let { server, alertsClient, actionsClient } = createMockServer();
@ -58,10 +59,14 @@ describe('update_rules_bulk', () => {
actionsClient.update.mockResolvedValue(updateActionResult());
alertsClient.update.mockResolvedValue(getResult());
const { payload } = await server.inject(getUpdateBulkRequest());
const parsed = JSON.parse(payload);
expect(parsed).toEqual([
{ error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' },
]);
const parsed: BulkError[] = JSON.parse(payload);
const expected: BulkError[] = [
{
error: { message: 'rule_id: "rule-1" not found', status_code: 404 },
rule_id: 'rule-1',
},
];
expect(parsed).toEqual(expected);
});
test('returns 404 if actionClient is not available on the route', async () => {
@ -125,10 +130,14 @@ describe('update_rules_bulk', () => {
payload: [typicalPayload()],
};
const { payload } = await server.inject(request);
const parsed = JSON.parse(payload);
expect(parsed).toEqual([
{ error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' },
]);
const parsed: BulkError[] = JSON.parse(payload);
const expected: BulkError[] = [
{
error: { message: 'rule_id: "rule-1" not found', status_code: 404 },
rule_id: 'rule-1',
},
];
expect(parsed).toEqual(expected);
});
test('returns 200 if type is query', async () => {

View file

@ -14,11 +14,15 @@ import {
transformTags,
getIdBulkError,
transformOrBulkError,
transformRulesToNdjson,
transformAlertsToRules,
transformOrImportError,
} from './utils';
import { getResult } from '../__mocks__/request_responses';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
import { OutputRuleAlertRest } from '../../types';
import { BulkError } from '../utils';
import { BulkError, ImportSuccessError } from '../utils';
import { sampleRule } from '../../signals/__mocks__/es_results';
describe('utils', () => {
describe('transformAlertToRule', () => {
@ -756,8 +760,8 @@ describe('utils', () => {
test('outputs message about id not being found if only id is defined and ruleId is undefined', () => {
const error = getIdBulkError({ id: '123', ruleId: undefined });
const expected: BulkError = {
id: '123',
error: { message: 'id: "123" not found', statusCode: 404 },
rule_id: '123',
error: { message: 'id: "123" not found', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -765,8 +769,8 @@ describe('utils', () => {
test('outputs message about id not being found if only id is defined and ruleId is null', () => {
const error = getIdBulkError({ id: '123', ruleId: null });
const expected: BulkError = {
id: '123',
error: { message: 'id: "123" not found', statusCode: 404 },
rule_id: '123',
error: { message: 'id: "123" not found', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -774,8 +778,8 @@ describe('utils', () => {
test('outputs message about ruleId not being found if only ruleId is defined and id is undefined', () => {
const error = getIdBulkError({ id: undefined, ruleId: 'rule-id-123' });
const expected: BulkError = {
id: 'rule-id-123',
error: { message: 'rule_id: "rule-id-123" not found', statusCode: 404 },
rule_id: 'rule-id-123',
error: { message: 'rule_id: "rule-id-123" not found', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -783,8 +787,8 @@ describe('utils', () => {
test('outputs message about ruleId not being found if only ruleId is defined and id is null', () => {
const error = getIdBulkError({ id: null, ruleId: 'rule-id-123' });
const expected: BulkError = {
id: 'rule-id-123',
error: { message: 'rule_id: "rule-id-123" not found', statusCode: 404 },
rule_id: 'rule-id-123',
error: { message: 'rule_id: "rule-id-123" not found', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -792,8 +796,8 @@ describe('utils', () => {
test('outputs message about both being not defined when both are undefined', () => {
const error = getIdBulkError({ id: undefined, ruleId: undefined });
const expected: BulkError = {
id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', statusCode: 404 },
rule_id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -801,8 +805,8 @@ describe('utils', () => {
test('outputs message about both being not defined when both are null', () => {
const error = getIdBulkError({ id: null, ruleId: null });
const expected: BulkError = {
id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', statusCode: 404 },
rule_id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -810,8 +814,8 @@ describe('utils', () => {
test('outputs message about both being not defined when id is null and ruleId is undefined', () => {
const error = getIdBulkError({ id: null, ruleId: undefined });
const expected: BulkError = {
id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', statusCode: 404 },
rule_id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -819,8 +823,8 @@ describe('utils', () => {
test('outputs message about both being not defined when id is undefined and ruleId is null', () => {
const error = getIdBulkError({ id: undefined, ruleId: null });
const expected: BulkError = {
id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', statusCode: 404 },
rule_id: '(unknown id)',
error: { message: 'id or rule_id should have been defined', status_code: 404 },
};
expect(error).toEqual(expected);
});
@ -893,10 +897,279 @@ describe('utils', () => {
test('returns 500 if the data is not of type siem alert', () => {
const output = transformOrBulkError('rule-1', { data: [{ random: 1 }] });
expect(output).toEqual({
id: 'rule-1',
error: { message: 'Internal error transforming', statusCode: 500 },
const expected: BulkError = {
rule_id: 'rule-1',
error: { message: 'Internal error transforming', status_code: 500 },
};
expect(output).toEqual(expected);
});
});
describe('transformRulesToNdjson', () => {
test('if rules are empty it returns an empty string', () => {
const ruleNdjson = transformRulesToNdjson([]);
expect(ruleNdjson).toEqual('');
});
test('single rule will transform with new line ending character for ndjson', () => {
const rule = sampleRule();
const ruleNdjson = transformRulesToNdjson([rule]);
expect(ruleNdjson.endsWith('\n')).toBe(true);
});
test('multiple rules will transform with two new line ending characters for ndjson', () => {
const result1 = sampleRule();
const result2 = sampleRule();
result2.id = 'some other id';
result2.rule_id = 'some other id';
result2.name = 'Some other rule';
const ruleNdjson = transformRulesToNdjson([result1, result2]);
// this is how we count characters in JavaScript :-)
const count = ruleNdjson.split('\n').length - 1;
expect(count).toBe(2);
});
test('you can parse two rules back out without errors', () => {
const result1 = sampleRule();
const result2 = sampleRule();
result2.id = 'some other id';
result2.rule_id = 'some other id';
result2.name = 'Some other rule';
const ruleNdjson = transformRulesToNdjson([result1, result2]);
const ruleStrings = ruleNdjson.split('\n');
const reParsed1 = JSON.parse(ruleStrings[0]);
const reParsed2 = JSON.parse(ruleStrings[1]);
expect(reParsed1).toEqual(result1);
expect(reParsed2).toEqual(result2);
});
});
describe('transformAlertsToRules', () => {
test('given an empty array returns an empty array', () => {
expect(transformAlertsToRules([])).toEqual([]);
});
test('given single alert will return the alert transformed', () => {
const result1 = getResult();
const transformed = transformAlertsToRules([result1]);
expect(transformed).toEqual([
{
created_at: '2019-12-13T16:40:33.400Z',
created_by: 'elastic',
description: 'Detecting root and admin users',
enabled: true,
false_positives: [],
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
from: 'now-6m',
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { someMeta: 'someField' },
name: 'Detect Root/Admin Users',
output_index: '.siem-signals',
query: 'user.name: root or user.name: admin',
references: ['http://www.example.com', 'https://ww.example.com'],
risk_score: 50,
rule_id: 'rule-1',
saved_id: 'some-id',
severity: 'high',
tags: [],
threats: [
{
framework: 'MITRE ATT&CK',
tactic: {
id: 'TA0040',
name: 'impact',
reference: 'https://attack.mitre.org/tactics/TA0040/',
},
techniques: [
{
id: 'T1499',
name: 'endpoint denial of service',
reference: 'https://attack.mitre.org/techniques/T1499/',
},
],
},
],
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
updated_at: '2019-12-13T16:40:33.400Z',
updated_by: 'elastic',
version: 1,
},
]);
});
test('given two alerts will return the two alerts transformed', () => {
const result1 = getResult();
const result2 = getResult();
result2.id = 'some other id';
result2.params.ruleId = 'some other id';
const transformed = transformAlertsToRules([result1, result2]);
expect(transformed).toEqual([
{
created_at: '2019-12-13T16:40:33.400Z',
created_by: 'elastic',
description: 'Detecting root and admin users',
enabled: true,
false_positives: [],
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
from: 'now-6m',
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { someMeta: 'someField' },
name: 'Detect Root/Admin Users',
output_index: '.siem-signals',
query: 'user.name: root or user.name: admin',
references: ['http://www.example.com', 'https://ww.example.com'],
risk_score: 50,
rule_id: 'rule-1',
saved_id: 'some-id',
severity: 'high',
tags: [],
threats: [
{
framework: 'MITRE ATT&CK',
tactic: {
id: 'TA0040',
name: 'impact',
reference: 'https://attack.mitre.org/tactics/TA0040/',
},
techniques: [
{
id: 'T1499',
name: 'endpoint denial of service',
reference: 'https://attack.mitre.org/techniques/T1499/',
},
],
},
],
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
updated_at: '2019-12-13T16:40:33.400Z',
updated_by: 'elastic',
version: 1,
},
{
created_at: '2019-12-13T16:40:33.400Z',
created_by: 'elastic',
description: 'Detecting root and admin users',
enabled: true,
false_positives: [],
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
from: 'now-6m',
id: 'some other id',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { someMeta: 'someField' },
name: 'Detect Root/Admin Users',
output_index: '.siem-signals',
query: 'user.name: root or user.name: admin',
references: ['http://www.example.com', 'https://ww.example.com'],
risk_score: 50,
rule_id: 'some other id',
saved_id: 'some-id',
severity: 'high',
tags: [],
threats: [
{
framework: 'MITRE ATT&CK',
tactic: {
id: 'TA0040',
name: 'impact',
reference: 'https://attack.mitre.org/tactics/TA0040/',
},
techniques: [
{
id: 'T1499',
name: 'endpoint denial of service',
reference: 'https://attack.mitre.org/techniques/T1499/',
},
],
},
],
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
updated_at: '2019-12-13T16:40:33.400Z',
updated_by: 'elastic',
version: 1,
},
]);
});
});
describe('transformOrImportError', () => {
test('returns 1 given success if the alert is an alert type and the existing success count is 0', () => {
const output = transformOrImportError('rule-1', getResult(), {
success: true,
success_count: 0,
errors: [],
});
const expected: ImportSuccessError = {
success: true,
errors: [],
success_count: 1,
};
expect(output).toEqual(expected);
});
test('returns 2 given successes if the alert is an alert type and the existing success count is 1', () => {
const output = transformOrImportError('rule-1', getResult(), {
success: true,
success_count: 1,
errors: [],
});
const expected: ImportSuccessError = {
success: true,
errors: [],
success_count: 2,
};
expect(output).toEqual(expected);
});
test('returns 1 error and success of false if the data is not of type siem alert', () => {
const output = transformOrImportError(
'rule-1',
{ data: [{ random: 1 }] },
{
success: true,
success_count: 1,
errors: [],
}
);
const expected: ImportSuccessError = {
success: false,
errors: [
{
rule_id: 'rule-1',
error: {
message: 'Internal error transforming',
status_code: 500,
},
},
],
success_count: 1,
};
expect(output).toEqual(expected);
});
});
});

View file

@ -9,7 +9,13 @@ import { pickBy } from 'lodash/fp';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
import { RuleAlertType, isAlertType, isAlertTypes } from '../../rules/types';
import { OutputRuleAlertRest } from '../../types';
import { createBulkErrorObject, BulkError } from '../utils';
import {
createBulkErrorObject,
BulkError,
createSuccessObject,
ImportSuccessError,
createImportErrorObject,
} from '../utils';
export const getIdError = ({
id,
@ -97,6 +103,21 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial<OutputRuleAl
});
};
export const transformRulesToNdjson = (rules: Array<Partial<OutputRuleAlertRest>>): string => {
if (rules.length !== 0) {
const rulesString = rules.map(rule => JSON.stringify(rule)).join('\n');
return `${rulesString}\n`;
} else {
return '';
}
};
export const transformAlertsToRules = (
alerts: RuleAlertType[]
): Array<Partial<OutputRuleAlertRest>> => {
return alerts.map(alert => transformAlertToRule(alert));
};
export const transformFindAlertsOrError = (findResults: { data: unknown[] }): unknown | Boom => {
if (isAlertTypes(findResults.data)) {
findResults.data = findResults.data.map(alert => transformAlertToRule(alert));
@ -128,3 +149,20 @@ export const transformOrBulkError = (
});
}
};
export const transformOrImportError = (
ruleId: string,
alert: unknown,
existingImportSuccessError: ImportSuccessError
): ImportSuccessError => {
if (isAlertType(alert)) {
return createSuccessObject(existingImportSuccessError);
} else {
return createImportErrorObject({
ruleId,
statusCode: 500,
message: 'Internal error transforming',
existingImportSuccessError,
});
}
};

View file

@ -0,0 +1,99 @@
/*
* 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 { exportRulesSchema, exportRulesQuerySchema } from './export_rules_schema';
import { ExportRulesRequest } from '../../rules/types';
describe('create rules schema', () => {
describe('exportRulesSchema', () => {
test('null value or absent values validate', () => {
expect(exportRulesSchema.validate(null).error).toBeFalsy();
});
test('empty object does not validate', () => {
expect(
exportRulesSchema.validate<Partial<ExportRulesRequest['payload']>>({}).error
).toBeTruthy();
});
test('empty object array does validate', () => {
expect(
exportRulesSchema.validate<Partial<ExportRulesRequest['payload']>>({ objects: [] }).error
).toBeTruthy();
});
test('array with rule_id validates', () => {
expect(
exportRulesSchema.validate<Partial<ExportRulesRequest['payload']>>({
objects: [{ rule_id: 'test-1' }],
}).error
).toBeFalsy();
});
test('array with id does not validate as we do not allow that on purpose since we export rule_id', () => {
expect(
exportRulesSchema.validate<Omit<ExportRulesRequest['payload'], 'objects'>>({
objects: [{ id: 'test-1' }],
}).error
).toBeTruthy();
});
});
describe('exportRulesQuerySchema', () => {
test('default value for file_name is export.ndjson', () => {
expect(
exportRulesQuerySchema.validate<Partial<ExportRulesRequest['query']>>({}).value.file_name
).toEqual('export.ndjson');
});
test('default value for exclude_export_details is false', () => {
expect(
exportRulesQuerySchema.validate<Partial<ExportRulesRequest['query']>>({}).value
.exclude_export_details
).toEqual(false);
});
test('file_name validates', () => {
expect(
exportRulesQuerySchema.validate<Partial<ExportRulesRequest['query']>>({
file_name: 'test.ndjson',
}).error
).toBeFalsy();
});
test('file_name does not validate with a number', () => {
expect(
exportRulesQuerySchema.validate<
Partial<Omit<ExportRulesRequest['query'], 'file_name'> & { file_name: number }>
>({
file_name: 5,
}).error
).toBeTruthy();
});
test('exclude_export_details validates with a boolean true', () => {
expect(
exportRulesQuerySchema.validate<Partial<ExportRulesRequest['query']>>({
exclude_export_details: true,
}).error
).toBeFalsy();
});
test('exclude_export_details does not validate with a weird string', () => {
expect(
exportRulesQuerySchema.validate<
Partial<
Omit<ExportRulesRequest['query'], 'exclude_export_details'> & {
exclude_export_details: string;
}
>
>({
exclude_export_details: 'blah',
}).error
).toBeTruthy();
});
});
});

View file

@ -0,0 +1,22 @@
/*
* 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 Joi from 'joi';
/* eslint-disable @typescript-eslint/camelcase */
import { objects, exclude_export_details, file_name } from './schemas';
/* eslint-disable @typescript-eslint/camelcase */
export const exportRulesSchema = Joi.object({
objects,
})
.min(1)
.allow(null);
export const exportRulesQuerySchema = Joi.object({
file_name: file_name.default('export.ndjson'),
exclude_export_details: exclude_export_details.default(false),
});

View file

@ -0,0 +1,100 @@
/*
* 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 Joi from 'joi';
/* eslint-disable @typescript-eslint/camelcase */
import {
id,
created_at,
updated_at,
created_by,
updated_by,
enabled,
description,
false_positives,
filters,
from,
immutable,
index,
rule_id,
interval,
query,
language,
output_index,
saved_id,
timeline_id,
timeline_title,
meta,
risk_score,
max_signals,
name,
severity,
tags,
to,
type,
threats,
references,
version,
} from './schemas';
/* eslint-enable @typescript-eslint/camelcase */
import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants';
/**
* Differences from this and the createRulesSchema are
* - rule_id is required
* - id is optional (but ignored in the import code - rule_id is exclusively used for imports)
* - created_at is optional (but ignored in the import code)
* - updated_at is optional (but ignored in the import code)
* - created_by is optional (but ignored in the import code)
* - updated_by is optional (but ignored in the import code)
*/
export const importRulesSchema = Joi.object({
id,
description: description.required(),
enabled: enabled.default(true),
false_positives: false_positives.default([]),
filters,
from: from.required(),
rule_id: rule_id.required(),
immutable: immutable.default(false),
index,
interval: interval.default('5m'),
query: query.allow('').default(''),
language: language.default('kuery'),
output_index,
saved_id: saved_id.when('type', {
is: 'saved_query',
then: Joi.required(),
otherwise: Joi.forbidden(),
}),
timeline_id,
timeline_title,
meta,
risk_score: risk_score.required(),
max_signals: max_signals.default(DEFAULT_MAX_SIGNALS),
name: name.required(),
severity: severity.required(),
tags: tags.default([]),
to: to.required(),
type: type.required(),
threats: threats.default([]),
references: references.default([]),
version: version.default(1),
created_at,
updated_at,
created_by,
updated_by,
});
export const importRulesQuerySchema = Joi.object({
overwrite: Joi.boolean().default(false),
});
export const importRulesPayloadSchema = Joi.object({
file: Joi.object().required(),
});

View file

@ -9,7 +9,9 @@ import Joi from 'joi';
/* eslint-disable @typescript-eslint/camelcase */
export const description = Joi.string();
export const enabled = Joi.boolean();
export const exclude_export_details = Joi.boolean();
export const false_positives = Joi.array().items(Joi.string());
export const file_name = Joi.string();
export const filters = Joi.array();
export const from = Joi.string();
export const immutable = Joi.boolean();
@ -21,6 +23,11 @@ export const index = Joi.array()
export const interval = Joi.string();
export const query = Joi.string();
export const language = Joi.string().valid('kuery', 'lucene');
export const objects = Joi.array().items(
Joi.object({
rule_id,
}).required()
);
export const output_index = Joi.string();
export const saved_id = Joi.string();
export const timeline_id = Joi.string();
@ -75,7 +82,6 @@ export const threat_technique = Joi.object({
reference: threat_technique_reference.required(),
});
export const threat_techniques = Joi.array().items(threat_technique.required());
export const threats = Joi.array().items(
Joi.object({
framework: threat_framework.required(),
@ -83,5 +89,12 @@ export const threats = Joi.array().items(
techniques: threat_techniques.required(),
})
);
export const created_at = Joi.string()
.isoDate()
.strict();
export const updated_at = Joi.string()
.isoDate()
.strict();
export const created_by = Joi.string();
export const updated_by = Joi.string();
export const version = Joi.number().min(1);

View file

@ -6,7 +6,15 @@
import Boom from 'boom';
import { transformError, transformBulkError, BulkError } from './utils';
import {
transformError,
transformBulkError,
BulkError,
createSuccessObject,
ImportSuccessError,
createImportErrorObject,
transformImportError,
} from './utils';
describe('utils', () => {
describe('transformError', () => {
@ -63,8 +71,8 @@ describe('utils', () => {
const boom = new Boom('some boom message', { statusCode: 400 });
const transformed = transformBulkError('rule-1', boom);
const expected: BulkError = {
id: 'rule-1',
error: { message: 'some boom message', statusCode: 400 },
rule_id: 'rule-1',
error: { message: 'some boom message', status_code: 400 },
};
expect(transformed).toEqual(expected);
});
@ -77,8 +85,8 @@ describe('utils', () => {
};
const transformed = transformBulkError('rule-1', error);
const expected: BulkError = {
id: 'rule-1',
error: { message: 'some message', statusCode: 403 },
rule_id: 'rule-1',
error: { message: 'some message', status_code: 403 },
};
expect(transformed).toEqual(expected);
});
@ -90,8 +98,8 @@ describe('utils', () => {
};
const transformed = transformBulkError('rule-1', error);
const expected: BulkError = {
id: 'rule-1',
error: { message: 'some message', statusCode: 500 },
rule_id: 'rule-1',
error: { message: 'some message', status_code: 500 },
};
expect(transformed).toEqual(expected);
});
@ -100,8 +108,168 @@ describe('utils', () => {
const error: TypeError = new TypeError('I have a type error');
const transformed = transformBulkError('rule-1', error);
const expected: BulkError = {
id: 'rule-1',
error: { message: 'I have a type error', statusCode: 400 },
rule_id: 'rule-1',
error: { message: 'I have a type error', status_code: 400 },
};
expect(transformed).toEqual(expected);
});
});
describe('createSuccessObject', () => {
test('it should increment the existing success object by 1', () => {
const success = createSuccessObject({
success_count: 0,
success: true,
errors: [],
});
const expected: ImportSuccessError = {
success_count: 1,
success: true,
errors: [],
};
expect(success).toEqual(expected);
});
test('it should increment the existing success object by 1 and not touch the boolean or errors', () => {
const success = createSuccessObject({
success_count: 0,
success: false,
errors: [
{ rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } },
],
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [
{ rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } },
],
};
expect(success).toEqual(expected);
});
});
describe('createImportErrorObject', () => {
test('it creates an error message and does not increment the success count', () => {
const error = createImportErrorObject({
ruleId: 'some-rule-id',
statusCode: 400,
message: 'some-message',
existingImportSuccessError: {
success_count: 1,
success: true,
errors: [],
},
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }],
};
expect(error).toEqual(expected);
});
test('appends a second error message and does not increment the success count', () => {
const error = createImportErrorObject({
ruleId: 'some-rule-id',
statusCode: 400,
message: 'some-message',
existingImportSuccessError: {
success_count: 1,
success: false,
errors: [
{ rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } },
],
},
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [
{ rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } },
{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } },
],
};
expect(error).toEqual(expected);
});
});
describe('transformImportError', () => {
test('returns transformed object if it is a boom object', () => {
const boom = new Boom('some boom message', { statusCode: 400 });
const transformed = transformImportError('rule-1', boom, {
success_count: 1,
success: false,
errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }],
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [
{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } },
{ rule_id: 'rule-1', error: { status_code: 400, message: 'some boom message' } },
],
};
expect(transformed).toEqual(expected);
});
test('returns a normal error if it is some non boom object that has a statusCode', () => {
const error: Error & { statusCode?: number } = {
statusCode: 403,
name: 'some name',
message: 'some message',
};
const transformed = transformImportError('rule-1', error, {
success_count: 1,
success: false,
errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }],
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [
{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } },
{ rule_id: 'rule-1', error: { status_code: 403, message: 'some message' } },
],
};
expect(transformed).toEqual(expected);
});
test('returns a 500 if the status code is not set', () => {
const error: Error & { statusCode?: number } = {
name: 'some name',
message: 'some message',
};
const transformed = transformImportError('rule-1', error, {
success_count: 1,
success: false,
errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }],
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [
{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } },
{ rule_id: 'rule-1', error: { status_code: 500, message: 'some message' } },
],
};
expect(transformed).toEqual(expected);
});
test('it detects a TypeError and returns a Boom status of 400', () => {
const error: TypeError = new TypeError('I have a type error');
const transformed = transformImportError('rule-1', error, {
success_count: 1,
success: false,
errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }],
});
const expected: ImportSuccessError = {
success_count: 1,
success: false,
errors: [
{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } },
{ rule_id: 'rule-1', error: { status_code: 400, message: 'I have a type error' } },
],
};
expect(transformed).toEqual(expected);
});

View file

@ -27,12 +27,13 @@ export const transformError = (err: Error & { statusCode?: number }) => {
};
export interface BulkError {
id: string;
rule_id: string;
error: {
statusCode: number;
status_code: number;
message: string;
};
}
export const createBulkErrorObject = ({
ruleId,
statusCode,
@ -43,14 +44,84 @@ export const createBulkErrorObject = ({
message: string;
}): BulkError => {
return {
id: ruleId,
rule_id: ruleId,
error: {
statusCode,
status_code: statusCode,
message,
},
};
};
export interface ImportSuccessError {
success: boolean;
success_count: number;
errors: BulkError[];
}
export const createSuccessObject = (
existingImportSuccessError: ImportSuccessError
): ImportSuccessError => {
return {
success_count: existingImportSuccessError.success_count + 1,
success: existingImportSuccessError.success,
errors: existingImportSuccessError.errors,
};
};
export const createImportErrorObject = ({
ruleId,
statusCode,
message,
existingImportSuccessError,
}: {
ruleId: string;
statusCode: number;
message: string;
existingImportSuccessError: ImportSuccessError;
}): ImportSuccessError => {
return {
success: false,
errors: [
...existingImportSuccessError.errors,
createBulkErrorObject({
ruleId,
statusCode,
message,
}),
],
success_count: existingImportSuccessError.success_count,
};
};
export const transformImportError = (
ruleId: string,
err: Error & { statusCode?: number },
existingImportSuccessError: ImportSuccessError
): ImportSuccessError => {
if (Boom.isBoom(err)) {
return createImportErrorObject({
ruleId,
statusCode: err.output.statusCode,
message: err.message,
existingImportSuccessError,
});
} else if (err instanceof TypeError) {
return createImportErrorObject({
ruleId,
statusCode: 400,
message: err.message,
existingImportSuccessError,
});
} else {
return createImportErrorObject({
ruleId,
statusCode: err.statusCode ?? 500,
message: err.message,
existingImportSuccessError,
});
}
};
export const transformBulkError = (
ruleId: string,
err: Error & { statusCode?: number }
@ -76,13 +147,19 @@ export const transformBulkError = (
}
};
export const getIndex = (request: RequestFacade, server: ServerFacade): string => {
export const getIndex = (
request: RequestFacade | Omit<RequestFacade, 'query'>,
server: ServerFacade
): string => {
const spaceId = server.plugins.spaces.getSpaceId(request);
const signalsIndex = server.config().get(`xpack.${APP_ID}.${SIGNALS_INDEX_KEY}`);
return `${signalsIndex}-${spaceId}`;
};
export const callWithRequestFactory = (request: RequestFacade, server: ServerFacade) => {
export const callWithRequestFactory = (
request: RequestFacade | Omit<RequestFacade, 'query'>,
server: ServerFacade
) => {
const { callWithRequest } = server.plugins.elasticsearch.getCluster('data');
return <T, U>(endpoint: string, params: T, options?: U) => {
return callWithRequest(request, endpoint, params, options);

View file

@ -0,0 +1,375 @@
/*
* 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 { Readable } from 'stream';
import { createRulesStreamFromNdJson } from './create_rules_stream_from_ndjson';
import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams';
import { ImportRuleAlertRest } from '../types';
const readStreamToCompletion = (stream: Readable) => {
return createPromiseFromStreams([stream, createConcatStream([])]);
};
export const getOutputSample = (): Partial<ImportRuleAlertRest> => ({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
});
export const getSampleAsNdjson = (sample: Partial<ImportRuleAlertRest>): string => {
return `${JSON.stringify(sample)}\n`;
};
describe('create_rules_stream_from_ndjson', () => {
describe('createRulesStreamFromNdJson', () => {
test('transforms an ndjson stream into a stream of rule objects', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push(getSampleAsNdjson(sample2));
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
const result = await readStreamToCompletion(rulesObjectsStream);
expect(result).toEqual([
{
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
},
{
rule_id: 'rule-2',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
},
]);
});
test('skips empty lines', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push('\n');
this.push(getSampleAsNdjson(sample2));
this.push('');
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
const result = await readStreamToCompletion(rulesObjectsStream);
expect(result).toEqual([
{
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
},
{
rule_id: 'rule-2',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
},
]);
});
test('filters the export details entry from the stream', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push(getSampleAsNdjson(sample2));
this.push('{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n');
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
const result = await readStreamToCompletion(rulesObjectsStream);
expect(result).toEqual([
{
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
},
{
rule_id: 'rule-2',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
},
]);
});
test('handles non parsable JSON strings and inserts the error as part of the return array', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push('{,,,,\n');
this.push(getSampleAsNdjson(sample2));
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
const result = await readStreamToCompletion(rulesObjectsStream);
const resultOrError = result as Error[];
expect(resultOrError[0]).toEqual({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
});
expect(resultOrError[1].message).toEqual('Unexpected token , in JSON at position 1');
expect(resultOrError[2]).toEqual({
rule_id: 'rule-2',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
});
});
test('handles non-validated data', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push(`{}\n`);
this.push(getSampleAsNdjson(sample2));
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
const result = await readStreamToCompletion(rulesObjectsStream);
const resultOrError = result as TypeError[];
expect(resultOrError[0]).toEqual({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
});
expect(resultOrError[1].message).toEqual(
'child "description" fails because ["description" is required]'
);
expect(resultOrError[2]).toEqual({
rule_id: 'rule-2',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
enabled: true,
false_positives: [],
immutable: false,
query: '',
language: 'kuery',
max_signals: 100,
tags: [],
threats: [],
references: [],
version: 1,
});
});
test('non validated data is an instanceof TypeError', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push(`{}\n`);
this.push(getSampleAsNdjson(sample2));
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
const result = await readStreamToCompletion(rulesObjectsStream);
const resultOrError = result as TypeError[];
expect(resultOrError[1] instanceof TypeError).toEqual(true);
});
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 { Readable, Transform } from 'stream';
import { has, isString } from 'lodash/fp';
import { ImportRuleAlertRest } from '../types';
import {
createSplitStream,
createMapStream,
createFilterStream,
createConcatStream,
} from '../../../../../../../../src/legacy/utils/streams';
import { importRulesSchema } from '../routes/schemas/import_rules_schema';
export interface RulesObjectsExportResultDetails {
/** number of successfully exported objects */
exportedCount: number;
}
export const parseNdjsonStrings = (): Transform => {
return createMapStream((ndJsonStr: string) => {
if (isString(ndJsonStr) && ndJsonStr.trim() !== '') {
try {
return JSON.parse(ndJsonStr);
} catch (err) {
return err;
}
}
});
};
export const filterExportedCounts = (): Transform => {
return createFilterStream<ImportRuleAlertRest | RulesObjectsExportResultDetails>(
obj => obj != null && !has('exported_count', obj)
);
};
export const validateRules = (): Transform => {
return createMapStream((obj: ImportRuleAlertRest) => {
if (!(obj instanceof Error)) {
const validated = importRulesSchema.validate(obj);
if (validated.error != null) {
return new TypeError(validated.error.message);
} else {
return validated.value;
}
} else {
return obj;
}
});
};
// Adaptation from: saved_objects/import/create_limit_stream.ts
export const createLimitStream = (limit: number): Transform => {
let counter = 0;
return new Transform({
objectMode: true,
async transform(obj, _, done) {
if (counter >= limit) {
return done(new Error(`Can't import more than ${limit} rules`));
}
counter++;
done(undefined, obj);
},
});
};
// TODO: Capture both the line number and the rule_id if you have that information for the error message
// eventually and then pass it down so we can give error messages on the line number
/**
* Inspiration and the pattern of code followed is from:
* saved_objects/lib/create_saved_objects_stream_from_ndjson.ts
*/
export const createRulesStreamFromNdJson = (
ndJsonStream: Readable,
ruleLimit: number
): Transform => {
return ndJsonStream
.pipe(createSplitStream('\n'))
.pipe(parseNdjsonStrings())
.pipe(filterExportedCounts())
.pipe(validateRules())
.pipe(createLimitStream(ruleLimit))
.pipe(createConcatStream([]));
};

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FindResult } from '../../../../../alerting/server/alerts_client';
import { SIGNALS_ID } from '../../../../common/constants';
import { FindRuleParams, RuleAlertType } from './types';
import { FindRuleParams } from './types';
export const getFilter = (filter: string | null | undefined) => {
if (filter == null) {
@ -23,7 +24,7 @@ export const findRules = async ({
filter,
sortField,
sortOrder,
}: FindRuleParams) => {
}: FindRuleParams): Promise<FindResult> => {
return alertsClient.find({
options: {
fields,
@ -33,10 +34,5 @@ export const findRules = async ({
sortOrder,
sortField,
},
}) as Promise<{
page: number;
perPage: number;
total: number;
data: RuleAlertType[];
}>;
});
};

View file

@ -11,7 +11,13 @@ import {
getFindResultWithSingleHit,
getFindResultWithMultiHits,
} from '../routes/__mocks__/request_responses';
import { getExistingPrepackagedRules } from './get_existing_prepackaged_rules';
import {
getExistingPrepackagedRules,
getNonPackagedRules,
getRules,
getRulesCount,
getNonPackagedRulesCount,
} from './get_existing_prepackaged_rules';
describe('get_existing_prepackaged_rules', () => {
afterEach(() => {
@ -29,6 +35,108 @@ describe('get_existing_prepackaged_rules', () => {
expect(rules).toEqual([getResult()]);
});
test('should return 2 items over two pages, one per page', async () => {
const alertsClient = alertsClientMock.create();
const result1 = getResult();
result1.params.immutable = true;
result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result2 = getResult();
result2.params.immutable = true;
result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d';
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 2 })
);
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 2 })
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getExistingPrepackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([result1, result2]);
});
test('should return 3 items with over 3 pages one per page', async () => {
const alertsClient = alertsClientMock.create();
const result1 = getResult();
result1.params.immutable = true;
result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result2 = getResult();
result2.params.immutable = true;
result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result3 = getResult();
result3.params.immutable = true;
result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a';
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 3 })
);
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 3 })
);
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result3], perPage: 1, page: 2, total: 3 })
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getExistingPrepackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([result1, result2, result3]);
});
test('should return 3 items over 1 pages with all on one page', async () => {
const alertsClient = alertsClientMock.create();
const result1 = getResult();
result1.params.immutable = true;
result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result2 = getResult();
result2.params.immutable = true;
result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result3 = getResult();
result3.params.immutable = true;
result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a';
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({
data: [result1, result2, result3],
perPage: 3,
page: 1,
total: 3,
})
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getExistingPrepackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([result1, result2, result3]);
});
});
describe('getNonPackagedRules', () => {
test('should return a single item in a single page', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getNonPackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([getResult()]);
});
test('should return 2 items over two pages, one per page', async () => {
const alertsClient = alertsClientMock.create();
@ -46,7 +154,7 @@ describe('get_existing_prepackaged_rules', () => {
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getExistingPrepackagedRules({
const rules = await getNonPackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([result1, result2]);
@ -77,7 +185,7 @@ describe('get_existing_prepackaged_rules', () => {
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getExistingPrepackagedRules({
const rules = await getNonPackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([result1, result2, result3]);
@ -105,10 +213,133 @@ describe('get_existing_prepackaged_rules', () => {
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getExistingPrepackagedRules({
const rules = await getNonPackagedRules({
alertsClient: unsafeCast,
});
expect(rules).toEqual([result1, result2, result3]);
});
});
describe('getRules', () => {
test('should return a single item in a single page', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getRules({
alertsClient: unsafeCast,
filter: '',
});
expect(rules).toEqual([getResult()]);
});
test('should return 2 items over two pages, one per page', async () => {
const alertsClient = alertsClientMock.create();
const result1 = getResult();
result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result2 = getResult();
result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d';
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 2 })
);
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 2 })
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getRules({
alertsClient: unsafeCast,
filter: '',
});
expect(rules).toEqual([result1, result2]);
});
test('should return 3 items with over 3 pages one per page', async () => {
const alertsClient = alertsClientMock.create();
const result1 = getResult();
result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result2 = getResult();
result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result3 = getResult();
result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a';
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 3 })
);
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 3 })
);
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({ data: [result3], perPage: 1, page: 2, total: 3 })
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getRules({
alertsClient: unsafeCast,
filter: '',
});
expect(rules).toEqual([result1, result2, result3]);
});
test('should return 3 items over 1 pages with all on one page', async () => {
const alertsClient = alertsClientMock.create();
const result1 = getResult();
result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result2 = getResult();
result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d';
const result3 = getResult();
result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a';
alertsClient.find.mockResolvedValueOnce(
getFindResultWithMultiHits({
data: [result1, result2, result3],
perPage: 3,
page: 1,
total: 3,
})
);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getRules({
alertsClient: unsafeCast,
filter: '',
});
expect(rules).toEqual([result1, result2, result3]);
});
});
describe('getRulesCount', () => {
test('it returns a count', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getRulesCount({
alertsClient: unsafeCast,
filter: '',
});
expect(rules).toEqual(1);
});
});
describe('getNonPackagedRulesCount', () => {
test('it returns a count', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const rules = await getNonPackagedRulesCount({
alertsClient: unsafeCast,
});
expect(rules).toEqual(1);
});
});
});

View file

@ -9,18 +9,46 @@ import { AlertsClient } from '../../../../../alerting';
import { RuleAlertType, isAlertTypes } from './types';
import { findRules } from './find_rules';
export const DEFAULT_PER_PAGE: number = 100;
export const DEFAULT_PER_PAGE = 100;
export const FILTER_NON_PREPACKED_RULES = `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`;
export const FILTER_PREPACKED_RULES = `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`;
export const getExistingPrepackagedRules = async ({
export const getNonPackagedRulesCount = async ({
alertsClient,
}: {
alertsClient: AlertsClient;
}): Promise<number> => {
return getRulesCount({ alertsClient, filter: FILTER_NON_PREPACKED_RULES });
};
export const getRulesCount = async ({
alertsClient,
filter,
}: {
alertsClient: AlertsClient;
filter: string;
}): Promise<number> => {
const firstRule = await findRules({
alertsClient,
filter,
perPage: 1,
page: 1,
});
return firstRule.total;
};
export const getRules = async ({
alertsClient,
perPage = DEFAULT_PER_PAGE,
filter,
}: {
alertsClient: AlertsClient;
perPage?: number;
filter: string;
}): Promise<RuleAlertType[]> => {
const firstPrepackedRules = await findRules({
alertsClient,
filter: `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`,
filter,
perPage,
page: 1,
});
@ -40,7 +68,7 @@ export const getExistingPrepackagedRules = async ({
// page index starts at 2 as we already got the first page and we have more pages to go
return findRules({
alertsClient,
filter: `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`,
filter,
perPage,
page: page + 2,
});
@ -58,3 +86,31 @@ export const getExistingPrepackagedRules = async ({
}
}
};
export const getNonPackagedRules = async ({
alertsClient,
perPage = DEFAULT_PER_PAGE,
}: {
alertsClient: AlertsClient;
perPage?: number;
}): Promise<RuleAlertType[]> => {
return getRules({
alertsClient,
perPage,
filter: FILTER_NON_PREPACKED_RULES,
});
};
export const getExistingPrepackagedRules = async ({
alertsClient,
perPage = DEFAULT_PER_PAGE,
}: {
alertsClient: AlertsClient;
perPage?: number;
}): Promise<RuleAlertType[]> => {
return getRules({
alertsClient,
perPage,
filter: FILTER_PREPACKED_RULES,
});
};

View file

@ -0,0 +1,49 @@
/*
* 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 { alertsClientMock } from '../../../../../alerting/server/alerts_client.mock';
import {
getResult,
getFindResultWithSingleHit,
FindHit,
} from '../routes/__mocks__/request_responses';
import { AlertsClient } from '../../../../../alerting';
import { getExportAll } from './get_export_all';
describe('getExportAll', () => {
test('it exports everything from the alerts client', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.get.mockResolvedValue(getResult());
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const exports = await getExportAll(unsafeCast);
expect(exports).toEqual({
rulesNdjson:
'{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"saved_id":"some-id","timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threats":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"techniques":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"version":1}\n',
exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n',
});
});
test('it will export empty rules', async () => {
const alertsClient = alertsClientMock.create();
const findResult: FindHit = {
page: 1,
perPage: 1,
total: 0,
data: [],
};
alertsClient.find.mockResolvedValue(findResult);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const exports = await getExportAll(unsafeCast);
expect(exports).toEqual({
rulesNdjson: '',
exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n',
});
});
});

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertsClient } from '../../../../../alerting';
import { getNonPackagedRules } from './get_existing_prepackaged_rules';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
import { transformAlertsToRules, transformRulesToNdjson } from '../routes/rules/utils';
export const getExportAll = async (
alertsClient: AlertsClient
): Promise<{
rulesNdjson: string;
exportDetails: string;
}> => {
const ruleAlertTypes = await getNonPackagedRules({ alertsClient });
const rules = transformAlertsToRules(ruleAlertTypes);
const rulesNdjson = transformRulesToNdjson(rules);
const exportDetails = getExportDetailsNdjson(rules);
return { rulesNdjson, exportDetails };
};

View file

@ -0,0 +1,173 @@
/*
* 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 { alertsClientMock } from '../../../../../alerting/server/alerts_client.mock';
import { getExportByObjectIds, getRulesFromObjects, RulesErrors } from './get_export_by_object_ids';
import {
getResult,
getFindResultWithSingleHit,
FindHit,
} from '../routes/__mocks__/request_responses';
import { AlertsClient } from '../../../../../alerting';
describe('get_export_by_object_ids', () => {
describe('getExportByObjectIds', () => {
test('it exports object ids into an expected string with new line characters', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.get.mockResolvedValue(getResult());
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const objects = [{ rule_id: 'rule-1' }];
const exports = await getExportByObjectIds(unsafeCast, objects);
expect(exports).toEqual({
rulesNdjson:
'{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"saved_id":"some-id","timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threats":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"techniques":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"version":1}\n',
exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n',
});
});
test('it does not export immutable rules', async () => {
const alertsClient = alertsClientMock.create();
const result = getResult();
result.params.immutable = true;
const findResult: FindHit = {
page: 1,
perPage: 1,
total: 0,
data: [result],
};
alertsClient.get.mockResolvedValue(getResult());
alertsClient.find.mockResolvedValue(findResult);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const objects = [{ rule_id: 'rule-1' }];
const exports = await getExportByObjectIds(unsafeCast, objects);
expect(exports).toEqual({
rulesNdjson: '',
exportDetails:
'{"exported_count":0,"missing_rules":[{"rule_id":"rule-1"}],"missing_rules_count":1}\n',
});
});
});
describe('getRulesFromObjects', () => {
test('it returns transformed rules from objects sent in', async () => {
const alertsClient = alertsClientMock.create();
alertsClient.get.mockResolvedValue(getResult());
alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const objects = [{ rule_id: 'rule-1' }];
const exports = await getRulesFromObjects(unsafeCast, objects);
const expected: RulesErrors = {
missingRules: [],
rules: [
{
created_at: '2019-12-13T16:40:33.400Z',
updated_at: '2019-12-13T16:40:33.400Z',
created_by: 'elastic',
description: 'Detecting root and admin users',
enabled: true,
false_positives: [],
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
from: 'now-6m',
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
rule_id: 'rule-1',
language: 'kuery',
output_index: '.siem-signals',
max_signals: 100,
risk_score: 50,
name: 'Detect Root/Admin Users',
query: 'user.name: root or user.name: admin',
references: ['http://www.example.com', 'https://ww.example.com'],
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
meta: { someMeta: 'someField' },
severity: 'high',
updated_by: 'elastic',
tags: [],
to: 'now',
type: 'query',
threats: [
{
framework: 'MITRE ATT&CK',
tactic: {
id: 'TA0040',
name: 'impact',
reference: 'https://attack.mitre.org/tactics/TA0040/',
},
techniques: [
{
id: 'T1499',
name: 'endpoint denial of service',
reference: 'https://attack.mitre.org/techniques/T1499/',
},
],
},
],
version: 1,
},
],
};
expect(exports).toEqual(expected);
});
test('it does not transform the rule if the rule is an immutable rule and designates it as a missing rule', async () => {
const alertsClient = alertsClientMock.create();
const result = getResult();
result.params.immutable = true;
const findResult: FindHit = {
page: 1,
perPage: 1,
total: 0,
data: [result],
};
alertsClient.get.mockResolvedValue(result);
alertsClient.find.mockResolvedValue(findResult);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const objects = [{ rule_id: 'rule-1' }];
const exports = await getRulesFromObjects(unsafeCast, objects);
const expected: RulesErrors = {
missingRules: [{ rule_id: 'rule-1' }],
rules: [],
};
expect(exports).toEqual(expected);
});
test('it exports missing rules', async () => {
const alertsClient = alertsClientMock.create();
const findResult: FindHit = {
page: 1,
perPage: 1,
total: 0,
data: [],
};
alertsClient.get.mockRejectedValue({ output: { statusCode: 404 } });
alertsClient.find.mockResolvedValue(findResult);
const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient;
const objects = [{ rule_id: 'rule-1' }];
const exports = await getRulesFromObjects(unsafeCast, objects);
const expected: RulesErrors = {
missingRules: [{ rule_id: 'rule-1' }],
rules: [],
};
expect(exports).toEqual(expected);
});
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 { AlertsClient } from '../../../../../alerting';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
import { isAlertType } from '../rules/types';
import { readRules } from './read_rules';
import { transformRulesToNdjson, transformAlertToRule } from '../routes/rules/utils';
import { OutputRuleAlertRest } from '../types';
export interface RulesErrors {
missingRules: Array<{ rule_id: string }>;
rules: Array<Partial<OutputRuleAlertRest>>;
}
export const getExportByObjectIds = async (
alertsClient: AlertsClient,
objects: Array<{ rule_id: string }>
): Promise<{
rulesNdjson: string;
exportDetails: string;
}> => {
const rulesAndErrors = await getRulesFromObjects(alertsClient, objects);
const rulesNdjson = transformRulesToNdjson(rulesAndErrors.rules);
const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules);
return { rulesNdjson, exportDetails };
};
export const getRulesFromObjects = async (
alertsClient: AlertsClient,
objects: Array<{ rule_id: string }>
): Promise<RulesErrors> => {
const alertsAndErrors = await objects.reduce<Promise<RulesErrors>>(
async (accumPromise, object) => {
const accum = await accumPromise;
const rule = await readRules({ alertsClient, ruleId: object.rule_id });
if (rule != null && isAlertType(rule) && rule.params.immutable !== true) {
const transformedRule = transformAlertToRule(rule);
return {
missingRules: accum.missingRules,
rules: [...accum.rules, transformedRule],
};
} else {
return {
missingRules: [...accum.missingRules, { rule_id: object.rule_id }],
rules: accum.rules,
};
}
},
Promise.resolve({
exportedCount: 0,
missingRules: [],
rules: [],
})
);
return alertsAndErrors;
};

View file

@ -0,0 +1,56 @@
/*
* 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 { sampleRule } from '../signals/__mocks__/es_results';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
describe('getExportDetailsNdjson', () => {
test('it ends with a new line character', () => {
const rule = sampleRule();
const details = getExportDetailsNdjson([rule]);
expect(details.endsWith('\n')).toEqual(true);
});
test('it exports a correct count given a single rule and no missing rules', () => {
const rule = sampleRule();
const details = getExportDetailsNdjson([rule]);
const reParsed = JSON.parse(details);
expect(reParsed).toEqual({
exported_count: 1,
missing_rules: [],
missing_rules_count: 0,
});
});
test('it exports a correct count given a no rules and a single missing rule', () => {
const missingRule = { rule_id: 'rule-1' };
const details = getExportDetailsNdjson([], [missingRule]);
const reParsed = JSON.parse(details);
expect(reParsed).toEqual({
exported_count: 0,
missing_rules: [{ rule_id: 'rule-1' }],
missing_rules_count: 1,
});
});
test('it exports a correct count given multiple rules and multiple missing rules', () => {
const rule1 = sampleRule();
const rule2 = sampleRule();
rule2.rule_id = 'some other id';
rule2.id = 'some other id';
const missingRule1 = { rule_id: 'rule-1' };
const missingRule2 = { rule_id: 'rule-2' };
const details = getExportDetailsNdjson([rule1, rule2], [missingRule1, missingRule2]);
const reParsed = JSON.parse(details);
expect(reParsed).toEqual({
exported_count: 2,
missing_rules: [missingRule1, missingRule2],
missing_rules_count: 2,
});
});
});

View file

@ -0,0 +1,19 @@
/*
* 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 { OutputRuleAlertRest } from '../types';
export const getExportDetailsNdjson = (
rules: Array<Partial<OutputRuleAlertRest>>,
missingRules: Array<{ rule_id: string }> = []
): string => {
const stringified = JSON.stringify({
exported_count: rules.length,
missing_rules: missingRules,
missing_rules_count: missingRules.length,
});
return `${stringified}\n`;
};

View file

@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Alert } from '../../../../../alerting/server/types';
import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants';
import { findRules } from './find_rules';
import { RuleAlertType, ReadRuleParams, isAlertType } from './types';
import { ReadRuleParams, isAlertType } from './types';
/**
* This reads the rules through a cascade try of what is fastest to what is slowest.
@ -20,7 +21,7 @@ export const readRules = async ({
alertsClient,
id,
ruleId,
}: ReadRuleParams): Promise<RuleAlertType | null> => {
}: ReadRuleParams): Promise<Alert | null> => {
if (id != null) {
try {
const rule = await alertsClient.get({ id });

View file

@ -5,6 +5,7 @@
*/
import { get } from 'lodash/fp';
import { Readable } from 'stream';
import { SIGNALS_ID } from '../../../../common/constants';
import { AlertsClient } from '../../../../../alerting/server/alerts_client';
@ -47,6 +48,24 @@ export interface BulkRulesRequest extends RequestFacade {
payload: RuleAlertParamsRest[];
}
export interface HapiReadableStream extends Readable {
hapi: {
filename: string;
};
}
export interface ImportRulesRequest extends Omit<RequestFacade, 'query'> {
query: { overwrite: boolean };
payload: { file: HapiReadableStream };
}
export interface ExportRulesRequest extends Omit<RequestFacade, 'query'> {
payload: { objects: Array<{ rule_id: string }> | null | undefined };
query: {
file_name: string;
exclude_export_details: boolean;
};
}
export type QueryRequest = Omit<RequestFacade, 'query'> & {
query: { id: string | undefined; rule_id: string | undefined };
};

View file

@ -0,0 +1,25 @@
#!/bin/sh
#
# 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.
#
set -e
./check_env_variables.sh
EXCLUDE_DETAILS=${1:-false}
# Note: This file does not use jq on purpose for testing and pipe redirections
# Example get all the rules except pre-packaged rules
# ./export_rules.sh
# Example get the export details at the end
# ./export_rules.sh false
curl -s -k \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}

View file

@ -0,0 +1,29 @@
#!/bin/sh
#
# 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.
#
set -e
./check_env_variables.sh
# Uses a default if no argument is specified
RULES=${1:-./rules/export/ruleid_queries.json}
EXCLUDE_DETAILS=${2:-false}
# Note: This file does not use jq on purpose for testing and pipe redirections
# Example get all the rules except pre-packaged rules
# ./export_rules_by_rule_id.sh
# Example get the export details at the end
# ./export_rules_by_rule_id.sh ./rules/export/ruleid_queries.json false
curl -s -k \
-H 'Content-Type: application/json' \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS} \
-d @${RULES}

View file

@ -0,0 +1,27 @@
#!/bin/sh
#
# 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.
#
set -e
./check_env_variables.sh
# Uses a defaults if no arguments are specified
RULES=${1:-./rules/export/ruleid_queries.json}
FILENAME=${2:-test.ndjson}
EXCLUDE_DETAILS=${3:-false}
# Example export to the file named test.ndjson
# ./export_rules_by_rule_id_to_file.sh
# Example export to the file named test.ndjson with export details appended
# ./export_rules_by_rule_id_to_file.sh ./rules/export/ruleid_queries.json test.ndjson false
curl -s -k -OJ \
-H 'Content-Type: application/json' \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}&file_name=${FILENAME}" \
-d @${RULES}

View file

@ -0,0 +1,23 @@
#!/bin/sh
#
# 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.
#
set -e
./check_env_variables.sh
FILENAME=${1:-test.ndjson}
EXCLUDE_DETAILS=${2:-false}
# Example export to the file named test.ndjson
# ./export_rules_to_file.sh
# Example export to the file named test.ndjson with export details appended
# ./export_rules_to_file.sh test.ndjson false
curl -s -k -OJ \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}&file_name=${FILENAME}"

View file

@ -0,0 +1,26 @@
#!/bin/sh
#
# 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.
#
set -e
./check_env_variables.sh
# Uses a defaults if no argument is specified
RULES=${1:-./rules/import/multiple_ruleid_queries.ndjson}
OVERWRITE=${2:-true}
# Example to import and overwrite everything from ./rules/import/multiple_ruleid_queries.ndjson
# ./import_rules.sh
# Example to not overwrite everything if it exists from ./rules/import/multiple_ruleid_queries.ndjson
# ./import_rules.sh ./rules/import/multiple_ruleid_queries.ndjson false
curl -s -k \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_import?overwrite=${OVERWRITE}" \
--form file=@${RULES} \
| jq .;

View file

@ -0,0 +1,21 @@
#!/bin/sh
#
# 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.
#
set -e
./check_env_variables.sh
# Uses a default if no argument is specified
RULES=${1:-./rules/import/multiple_ruleid_queries.ndjson}
# Example: ./import_rules_no_overwrite.sh
curl -s -k \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_import \
--form file=@${RULES} \
| jq .;

View file

@ -0,0 +1,10 @@
{
"objects": [
{
"rule_id": "query-rule-id-1"
},
{
"rule_id": "query-rule-id-2"
}
]
}

View file

@ -0,0 +1,3 @@
{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}
{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}
{"exported_count":2,"missing_rules":[],"missing_rules_count":0}

View file

@ -21,7 +21,7 @@
}
],
"enabled": false,
"immutable": true,
"immutable": false,
"index": ["auditbeat-*", "filebeat-*"],
"interval": "5m",
"query": "user.name: root or user.name: admin",

View file

@ -0,0 +1,4 @@
{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1},
{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}
{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-3","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}
{"exported_count":2,"missing_rules":[],"missing_rules_count":0}

View file

@ -84,4 +84,9 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & {
updated_by: string | undefined | null;
};
export type ImportRuleAlertRest = Omit<OutputRuleAlertRest, 'rule_id' | 'id'> & {
id: string | undefined | null;
rule_id: string;
};
export type CallWithRequest<T, U, V> = (endpoint: string, params: T, options?: U) => Promise<V>;