Skip to content

Commit

Permalink
[Security Solutions] Adds security detection rule actions as importab…
Browse files Browse the repository at this point in the history
…le and exportable (elastic#115243)

## Summary

Adds the security detection rule actions as being exportable and importable.
* Adds exportable actions for legacy notification system
* Adds exportable actions for the new throttle notification system
* Adds importable but only imports into the new throttle notification system.
* Updates unit tests

In your `ndjson` file when you have actions exported you will see them like so:

```json
"actions": [
    {
      "group": "default",
      "id": "b55117e0-2df9-11ec-b789-7f03e3cdd668",
      "params": {
        "message": "Rule {{context.rule.name}} generated {{state.signals_count}} alerts"
      },
      "action_type_id": ".slack"
    }
  ]
```

where before it was `actions: []` and was not provided.

**Caveats**

If you delete your connector and have an invalid connector then the rule(s) that were referring to that invalid connector will not import and you will get an error like this:

<img width="802" alt="Screen Shot 2021-10-15 at 2 47 10 PM" src="https://user-images.githubusercontent.com/1151048/137554991-b3984be9-d2ad-488e-a309-29da656ca4ea.png">

This does _not_ export your connectors at this point in time. You have to export your connector through the Saved Object Management separate like so:
<img width="1545" alt="Screen Shot 2021-10-15 at 2 58 03 PM" src="https://user-images.githubusercontent.com/1151048/137555135-3f0bfd63-5d67-496b-8d5b-bdef01d6122f.png">

However, if remove everything and import your connector without changing its saved object ID and then go to import the rules everything should import ok and you will get your actions working.

**Manual Testing**:

* You can create normal actions on an alert and then do exports and you should see the actions in your ndjson file 
* You can create legacy notifications from 7.14.0 and then upgrade and export and you should see the actions in your ndjson file
* You can manually create legacy notifications by:

By getting an alert id first and ensuring that your `legacy_notifications/one_action.json` contains a valid action then running this command:
```ts
./post_legacy_notification.sh 3403c0d0-2d44-11ec-b147-3b0c6d563a60
```

* You can export your connector and remove everything and then do an import and you will have everything imported and working with your actions and connector wired up correctly.

### Checklist

- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added
  • Loading branch information
FrankHassanabad authored and kibanamachine committed Oct 19, 2021
1 parent 56d1c9b commit bd9ae18
Show file tree
Hide file tree
Showing 11 changed files with 145 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { transformError } from '@kbn/securitysolution-es-utils';
import { Logger } from 'src/core/server';
import {
exportRulesQuerySchema,
ExportRulesQuerySchemaDecoded,
Expand All @@ -24,6 +25,7 @@ import { buildSiemResponse } from '../utils';
export const exportRulesRoute = (
router: SecuritySolutionPluginRouter,
config: ConfigType,
logger: Logger,
isRuleRegistryEnabled: boolean
) => {
router.post(
Expand All @@ -44,6 +46,7 @@ export const exportRulesRoute = (
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const rulesClient = context.alerting?.getRulesClient();
const savedObjectsClient = context.core.savedObjects.client;

if (!rulesClient) {
return siemResponse.error({ statusCode: 404 });
Expand Down Expand Up @@ -71,8 +74,14 @@ export const exportRulesRoute = (

const exported =
request.body?.objects != null
? await getExportByObjectIds(rulesClient, request.body.objects, isRuleRegistryEnabled)
: await getExportAll(rulesClient, isRuleRegistryEnabled);
? await getExportByObjectIds(
rulesClient,
savedObjectsClient,
request.body.objects,
logger,
isRuleRegistryEnabled
)
: await getExportAll(rulesClient, savedObjectsClient, logger, isRuleRegistryEnabled);

const responseBody = request.query.exclude_export_details
? exported.rulesNdjson
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export const importRulesRoute = (
throttle,
version,
exceptions_list: exceptionsList,
actions,
} = parsedRule;

try {
Expand Down Expand Up @@ -264,7 +265,7 @@ export const importRulesRoute = (
note,
version,
exceptionsList,
actions: [], // Actions are not imported nor exported at this time
actions,
});
resolve({
rule_id: ruleId,
Expand Down Expand Up @@ -321,7 +322,7 @@ export const importRulesRoute = (
exceptionsList,
anomalyThreshold,
machineLearningJobId,
actions: undefined,
actions,
});
resolve({
rule_id: ruleId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { performBulkActionRoute } from './perform_bulk_action_route';
import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock';
import { loggingSystemMock } from 'src/core/server/mocks';

jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create());

Expand All @@ -27,15 +28,17 @@ describe.each([
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
let ml: ReturnType<typeof mlServicesMock.createSetupContract>;
let logger: ReturnType<typeof loggingSystemMock.createLogger>;

beforeEach(() => {
server = serverMock.create();
logger = loggingSystemMock.createLogger();
({ clients, context } = requestContextMock.createTools());
ml = mlServicesMock.createSetupContract();

clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled));

performBulkActionRoute(server.router, ml, isRuleRegistryEnabled);
performBulkActionRoute(server.router, ml, logger, isRuleRegistryEnabled);
});

describe('status codes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
*/

import { transformError } from '@kbn/securitysolution-es-utils';
import { Logger } from 'src/core/server';

import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants';
import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas';
import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema';
Expand All @@ -26,6 +28,7 @@ const BULK_ACTION_RULES_LIMIT = 10000;
export const performBulkActionRoute = (
router: SecuritySolutionPluginRouter,
ml: SetupPlugins['ml'],
logger: Logger,
isRuleRegistryEnabled: boolean
) => {
router.post(
Expand Down Expand Up @@ -133,7 +136,9 @@ export const performBulkActionRoute = (
case BulkAction.export:
const exported = await getExportByObjectIds(
rulesClient,
savedObjectsClient,
rules.data.map(({ params }) => ({ rule_id: params.ruleId })),
logger,
isRuleRegistryEnabled
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,12 +469,12 @@ describe.each([

describe('transformAlertsToRules', () => {
test('given an empty array returns an empty array', () => {
expect(transformAlertsToRules([])).toEqual([]);
expect(transformAlertsToRules([], {})).toEqual([]);
});

test('given single alert will return the alert transformed', () => {
const result1 = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams());
const transformed = transformAlertsToRules([result1]);
const transformed = transformAlertsToRules([result1], {});
const expected = getOutputRuleAlertForRest();
expect(transformed).toEqual([expected]);
});
Expand All @@ -485,7 +485,7 @@ describe.each([
result2.id = 'some other id';
result2.params.ruleId = 'some other id';

const transformed = transformAlertsToRules([result1, result2]);
const transformed = transformAlertsToRules([result1, result2], {});
const expected1 = getOutputRuleAlertForRest();
const expected2 = getOutputRuleAlertForRest();
expected2.id = 'some other id';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,11 @@ export const transformAlertToRule = (
return internalRuleToAPIResponse(alert, ruleStatus?.attributes, legacyRuleActions);
};

export const transformAlertsToRules = (alerts: RuleAlertType[]): Array<Partial<RulesSchema>> => {
return alerts.map((alert) => transformAlertToRule(alert));
export const transformAlertsToRules = (
alerts: RuleAlertType[],
legacyRuleActions: Record<string, LegacyRulesActionsSavedObject>
): Array<Partial<RulesSchema>> => {
return alerts.map((alert) => transformAlertToRule(alert, undefined, legacyRuleActions[alert.id]));
};

export const transformFindAlerts = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,33 @@ import {
getAlertMock,
getFindResultWithSingleHit,
FindHit,
getEmptySavedObjectsResponse,
} from '../routes/__mocks__/request_responses';
import { rulesClientMock } from '../../../../../alerting/server/mocks';
import { getExportAll } from './get_export_all';
import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock';
import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock';

import { getQueryRuleParams } from '../schemas/rule_schemas.mock';
import { loggingSystemMock } from 'src/core/server/mocks';
import { requestContextMock } from '../routes/__mocks__/request_context';

describe.each([
['Legacy', false],
['RAC', true],
])('getExportAll - %s', (_, isRuleRegistryEnabled) => {
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
const { clients } = requestContextMock.createTools();

beforeEach(async () => {
clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse());
});

test('it exports everything from the alerts client', async () => {
const rulesClient = rulesClientMock.create();
const result = getFindResultWithSingleHit(isRuleRegistryEnabled);
const alert = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams());

alert.params = {
...alert.params,
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
Expand All @@ -35,7 +47,12 @@ describe.each([
result.data = [alert];
rulesClient.find.mockResolvedValue(result);

const exports = await getExportAll(rulesClient, isRuleRegistryEnabled);
const exports = await getExportAll(
rulesClient,
clients.savedObjectsClient,
logger,
isRuleRegistryEnabled
);
const rulesJson = JSON.parse(exports.rulesNdjson);
const detailsJson = JSON.parse(exports.exportDetails);
expect(rulesJson).toEqual({
Expand Down Expand Up @@ -97,7 +114,12 @@ describe.each([

rulesClient.find.mockResolvedValue(findResult);

const exports = await getExportAll(rulesClient, isRuleRegistryEnabled);
const exports = await getExportAll(
rulesClient,
clients.savedObjectsClient,
logger,
isRuleRegistryEnabled
);
expect(exports).toEqual({
rulesNdjson: '',
exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,33 @@

import { transformDataToNdjson } from '@kbn/securitysolution-utils';

import { RulesClient } from '../../../../../alerting/server';
import { Logger } from 'src/core/server';
import { RulesClient, AlertServices } from '../../../../../alerting/server';
import { getNonPackagedRules } from './get_existing_prepackaged_rules';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
import { transformAlertsToRules } from '../routes/rules/utils';

// eslint-disable-next-line no-restricted-imports
import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object';

export const getExportAll = async (
rulesClient: RulesClient,
savedObjectsClient: AlertServices['savedObjectsClient'],
logger: Logger,
isRuleRegistryEnabled: boolean
): Promise<{
rulesNdjson: string;
exportDetails: string;
}> => {
const ruleAlertTypes = await getNonPackagedRules({ rulesClient, isRuleRegistryEnabled });
const rules = transformAlertsToRules(ruleAlertTypes);
const alertIds = ruleAlertTypes.map((rule) => rule.id);
const legacyActions = await legacyGetBulkRuleActionsSavedObject({
alertIds,
savedObjectsClient,
logger,
});

const rules = transformAlertsToRules(ruleAlertTypes, legacyActions);
// We do not support importing/exporting actions. When we do, delete this line of code
const rulesWithoutActions = rules.map((rule) => ({ ...rule, actions: [] }));
const rulesNdjson = transformDataToNdjson(rulesWithoutActions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,43 @@ import {
getAlertMock,
getFindResultWithSingleHit,
FindHit,
getEmptySavedObjectsResponse,
} from '../routes/__mocks__/request_responses';
import { rulesClientMock } from '../../../../../alerting/server/mocks';
import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock';
import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock';
import { getQueryRuleParams } from '../schemas/rule_schemas.mock';
import { loggingSystemMock } from 'src/core/server/mocks';
import { requestContextMock } from '../routes/__mocks__/request_context';

describe.each([
['Legacy', false],
['RAC', true],
])('get_export_by_object_ids - %s', (_, isRuleRegistryEnabled) => {
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
const { clients } = requestContextMock.createTools();

beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();

clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse());
});

describe('getExportByObjectIds', () => {
test('it exports object ids into an expected string with new line characters', async () => {
const rulesClient = rulesClientMock.create();
rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled));

const objects = [{ rule_id: 'rule-1' }];
const exports = await getExportByObjectIds(rulesClient, objects, isRuleRegistryEnabled);
const exports = await getExportByObjectIds(
rulesClient,
clients.savedObjectsClient,
objects,
logger,
isRuleRegistryEnabled
);
const exportsObj = {
rulesNdjson: JSON.parse(exports.rulesNdjson),
exportDetails: JSON.parse(exports.exportDetails),
Expand Down Expand Up @@ -102,7 +117,13 @@ describe.each([
rulesClient.find.mockResolvedValue(findResult);

const objects = [{ rule_id: 'rule-1' }];
const exports = await getExportByObjectIds(rulesClient, objects, isRuleRegistryEnabled);
const exports = await getExportByObjectIds(
rulesClient,
clients.savedObjectsClient,
objects,
logger,
isRuleRegistryEnabled
);
expect(exports).toEqual({
rulesNdjson: '',
exportDetails:
Expand All @@ -117,7 +138,13 @@ describe.each([
rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled));

const objects = [{ rule_id: 'rule-1' }];
const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled);
const exports = await getRulesFromObjects(
rulesClient,
clients.savedObjectsClient,
objects,
logger,
isRuleRegistryEnabled
);
const expected: RulesErrors = {
exportedCount: 1,
missingRules: [],
Expand Down Expand Up @@ -192,7 +219,13 @@ describe.each([
rulesClient.find.mockResolvedValue(findResult);

const objects = [{ rule_id: 'rule-1' }];
const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled);
const exports = await getRulesFromObjects(
rulesClient,
clients.savedObjectsClient,
objects,
logger,
isRuleRegistryEnabled
);
const expected: RulesErrors = {
exportedCount: 0,
missingRules: [{ rule_id: 'rule-1' }],
Expand All @@ -215,7 +248,13 @@ describe.each([
rulesClient.find.mockResolvedValue(findResult);

const objects = [{ rule_id: 'rule-1' }];
const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled);
const exports = await getRulesFromObjects(
rulesClient,
clients.savedObjectsClient,
objects,
logger,
isRuleRegistryEnabled
);
const expected: RulesErrors = {
exportedCount: 0,
missingRules: [{ rule_id: 'rule-1' }],
Expand Down
Loading

0 comments on commit bd9ae18

Please sign in to comment.