[Security Solutions] Adds security detection rule actions as importable and exportable (#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
This commit is contained in:
Frank Hassanabad 2021-10-19 08:24:42 -06:00 committed by GitHub
parent 1a917674a4
commit c2c08be709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 145 additions and 28 deletions

View file

@ -6,6 +6,7 @@
*/
import { transformError } from '@kbn/securitysolution-es-utils';
import { Logger } from 'src/core/server';
import {
exportRulesQuerySchema,
ExportRulesQuerySchemaDecoded,
@ -24,6 +25,7 @@ import { buildSiemResponse } from '../utils';
export const exportRulesRoute = (
router: SecuritySolutionPluginRouter,
config: ConfigType,
logger: Logger,
isRuleRegistryEnabled: boolean
) => {
router.post(
@ -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 });
@ -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

View file

@ -194,6 +194,7 @@ export const importRulesRoute = (
throttle,
version,
exceptions_list: exceptionsList,
actions,
} = parsedRule;
try {
@ -265,7 +266,7 @@ export const importRulesRoute = (
note,
version,
exceptionsList,
actions: [], // Actions are not imported nor exported at this time
actions,
});
resolve({
rule_id: ruleId,
@ -328,7 +329,7 @@ export const importRulesRoute = (
exceptionsList,
anomalyThreshold,
machineLearningJobId,
actions: undefined,
actions,
});
resolve({
rule_id: ruleId,

View file

@ -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());
@ -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', () => {

View file

@ -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';
@ -26,6 +28,7 @@ const BULK_ACTION_RULES_LIMIT = 10000;
export const performBulkActionRoute = (
router: SecuritySolutionPluginRouter,
ml: SetupPlugins['ml'],
logger: Logger,
isRuleRegistryEnabled: boolean
) => {
router.post(
@ -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
);

View file

@ -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]);
});
@ -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';

View file

@ -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 = (

View file

@ -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' } } }],
@ -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({
@ -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',

View file

@ -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);

View file

@ -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),
@ -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:
@ -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: [],
@ -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' }],
@ -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' }],

View file

@ -8,14 +8,20 @@
import { chunk } from 'lodash';
import { transformDataToNdjson } from '@kbn/securitysolution-utils';
import { Logger } from 'src/core/server';
import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema';
import { RulesClient } from '../../../../../alerting/server';
import { RulesClient, AlertServices } from '../../../../../alerting/server';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
import { isAlertType } from '../rules/types';
import { transformAlertToRule } from '../routes/rules/utils';
import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants';
import { findRules } from './find_rules';
// eslint-disable-next-line no-restricted-imports
import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object';
interface ExportSuccessRule {
statusCode: 200;
rule: Partial<RulesSchema>;
@ -34,23 +40,32 @@ export interface RulesErrors {
export const getExportByObjectIds = async (
rulesClient: RulesClient,
savedObjectsClient: AlertServices['savedObjectsClient'],
objects: Array<{ rule_id: string }>,
logger: Logger,
isRuleRegistryEnabled: boolean
): Promise<{
rulesNdjson: string;
exportDetails: string;
}> => {
const rulesAndErrors = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled);
// We do not support importing/exporting actions. When we do, delete this line of code
const rulesWithoutActions = rulesAndErrors.rules.map((rule) => ({ ...rule, actions: [] }));
const rulesNdjson = transformDataToNdjson(rulesWithoutActions);
const exportDetails = getExportDetailsNdjson(rulesWithoutActions, rulesAndErrors.missingRules);
const rulesAndErrors = await getRulesFromObjects(
rulesClient,
savedObjectsClient,
objects,
logger,
isRuleRegistryEnabled
);
const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules);
const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules);
return { rulesNdjson, exportDetails };
};
export const getRulesFromObjects = async (
rulesClient: RulesClient,
savedObjectsClient: AlertServices['savedObjectsClient'],
objects: Array<{ rule_id: string }>,
logger: Logger,
isRuleRegistryEnabled: boolean
): Promise<RulesErrors> => {
// If we put more than 1024 ids in one block like "alert.attributes.tags: (id1 OR id2 OR ... OR id1100)"
@ -78,6 +93,13 @@ export const getRulesFromObjects = async (
sortField: undefined,
sortOrder: undefined,
});
const alertIds = rules.data.map((rule) => rule.id);
const legacyActions = await legacyGetBulkRuleActionsSavedObject({
alertIds,
savedObjectsClient,
logger,
});
const alertsAndErrors = objects.map(({ rule_id: ruleId }) => {
const matchingRule = rules.data.find((rule) => rule.params.ruleId === ruleId);
if (
@ -87,7 +109,7 @@ export const getRulesFromObjects = async (
) {
return {
statusCode: 200,
rule: transformAlertToRule(matchingRule),
rule: transformAlertToRule(matchingRule, undefined, legacyActions[matchingRule.id]),
};
} else {
return {

View file

@ -91,12 +91,12 @@ export const initRoutes = (
updateRulesBulkRoute(router, ml, isRuleRegistryEnabled);
patchRulesBulkRoute(router, ml, isRuleRegistryEnabled);
deleteRulesBulkRoute(router, isRuleRegistryEnabled);
performBulkActionRoute(router, ml, isRuleRegistryEnabled);
performBulkActionRoute(router, ml, logger, isRuleRegistryEnabled);
createTimelinesRoute(router, config, security);
patchTimelinesRoute(router, config, security);
importRulesRoute(router, config, ml, isRuleRegistryEnabled);
exportRulesRoute(router, config, isRuleRegistryEnabled);
exportRulesRoute(router, config, logger, isRuleRegistryEnabled);
importTimelinesRoute(router, config, security);
exportTimelinesRoute(router, config, security);