[Security Solution][Platform] - Update rule exported counts to include total object count (#116338)

### Summary

Addresses #116330.
This commit is contained in:
Yara Tercero 2021-11-03 20:00:13 -07:00 committed by GitHub
parent 2e9d0c0ee7
commit 2f88776eac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 378 additions and 37 deletions

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ExportExceptionDetails } from '.';
export interface ExportExceptionDetailsMock {
listCount?: number;
missingListsCount?: number;
missingLists?: Array<Record<'list_id', string>>;
itemCount?: number;
missingItemCount?: number;
missingItems?: Array<Record<'item_id', string>>;
}
export const getExceptionExportDetailsMock = (
details?: ExportExceptionDetailsMock
): ExportExceptionDetails => ({
exported_exception_list_count: details?.listCount ?? 0,
exported_exception_list_item_count: details?.itemCount ?? 0,
missing_exception_list_item_count: details?.missingItemCount ?? 0,
missing_exception_list_items: details?.missingItems ?? [],
missing_exception_lists: details?.missingLists ?? [],
missing_exception_lists_count: details?.missingListsCount ?? 0,
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { getExceptionExportDetailsMock } from './index.mock';
import { exportExceptionDetailsSchema, ExportExceptionDetails } from '.';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
describe('exportExceptionDetails', () => {
test('it should validate export meta', () => {
const payload = getExceptionExportDetailsMock();
const decoded = exportExceptionDetailsSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should strip out extra keys', () => {
const payload: ExportExceptionDetails & {
extraKey?: string;
} = getExceptionExportDetailsMock();
payload.extraKey = 'some extra key';
const decoded = exportExceptionDetailsSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getExceptionExportDetailsMock());
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { NonEmptyString } from '@kbn/securitysolution-io-ts-types';
export const exportExceptionDetails = {
exported_exception_list_count: t.number,
exported_exception_list_item_count: t.number,
missing_exception_list_item_count: t.number,
missing_exception_list_items: t.array(
t.exact(
t.type({
item_id: NonEmptyString,
})
)
),
missing_exception_lists: t.array(
t.exact(
t.type({
list_id: NonEmptyString,
})
)
),
missing_exception_lists_count: t.number,
};
export const exportExceptionDetailsSchema = t.exact(t.type(exportExceptionDetails));
export type ExportExceptionDetails = t.TypeOf<typeof exportExceptionDetailsSchema>;

View file

@ -23,6 +23,7 @@ export * from './entry_match';
export * from './entry_match_any';
export * from './entry_match_wildcard';
export * from './entry_nested';
export * from './exception_export_details';
export * from './exception_list';
export * from './exception_list_item_type';
export * from './filter';

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ExportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types';
export interface ExportExceptionDetailsMock {
listCount?: number;
missingListsCount?: number;
missingLists?: Array<Record<'list_id', string>>;
itemCount?: number;
missingItemCount?: number;
missingItems?: Array<Record<'item_id', string>>;
}
export const getExceptionExportDetailsMock = (
details?: ExportExceptionDetailsMock
): ExportExceptionDetails => ({
exported_exception_list_count: details?.listCount ?? 0,
exported_exception_list_item_count: details?.itemCount ?? 0,
missing_exception_list_item_count: details?.missingItemCount ?? 0,
missing_exception_list_items: details?.missingItems ?? [],
missing_exception_lists: details?.missingLists ?? [],
missing_exception_lists_count: details?.missingListsCount ?? 0,
});

View file

@ -15,6 +15,7 @@ import type {
ExceptionListItemTypeOrUndefined,
ExceptionListType,
ExceptionListTypeOrUndefined,
ExportExceptionDetails,
FilterOrUndefined,
Id,
IdOrUndefined,
@ -229,12 +230,5 @@ export interface ExportExceptionListAndItemsOptions {
export interface ExportExceptionListAndItemsReturn {
exportData: string;
exportDetails: {
exported_exception_list_count: number;
exported_exception_list_item_count: number;
missing_exception_list_item_count: number;
missing_exception_list_items: string[];
missing_exception_lists: string[];
missing_exception_lists_count: number;
};
exportDetails: ExportExceptionDetails;
}

View file

@ -6,6 +6,7 @@
*/
import type {
ExportExceptionDetails,
IdOrUndefined,
ListIdOrUndefined,
NamespaceType,
@ -25,14 +26,7 @@ interface ExportExceptionListAndItemsOptions {
export interface ExportExceptionListAndItemsReturn {
exportData: string;
exportDetails: {
exported_exception_list_count: number;
exported_exception_list_item_count: number;
missing_exception_list_item_count: number;
missing_exception_list_items: string[];
missing_exception_lists: string[];
missing_exception_lists_count: number;
};
exportDetails: ExportExceptionDetails;
}
export const exportExceptionListAndItems = async ({

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ExportRulesDetails } from './export_rules_details_schema';
import {
ExportExceptionDetailsMock,
getExceptionExportDetailsMock,
} from '../../../../../lists/common/schemas/response/exception_export_details_schema.mock';
interface RuleDetailsMock {
totalCount?: number;
rulesCount?: number;
missingCount?: number;
missingRules?: Array<Record<'rule_id', string>>;
}
export const getOutputDetailsSample = (ruleDetails?: RuleDetailsMock): ExportRulesDetails => ({
exported_count: ruleDetails?.totalCount ?? 0,
exported_rules_count: ruleDetails?.rulesCount ?? 0,
missing_rules: ruleDetails?.missingRules ?? [],
missing_rules_count: ruleDetails?.missingCount ?? 0,
});
export const getOutputDetailsSampleWithExceptions = (
ruleDetails?: RuleDetailsMock,
exceptionDetails?: ExportExceptionDetailsMock
): ExportRulesDetails => ({
...getOutputDetailsSample(ruleDetails),
...getExceptionExportDetailsMock(exceptionDetails),
});
export const getSampleDetailsAsNdjson = (sample: ExportRulesDetails): string => {
return `${JSON.stringify(sample)}\n`;
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import {
getOutputDetailsSample,
getOutputDetailsSampleWithExceptions,
} from './export_rules_details_schema.mock';
import {
ExportRulesDetails,
exportRulesDetailsWithExceptionsSchema,
} from './export_rules_details_schema';
describe('exportRulesDetailsWithExceptionsSchema', () => {
test('it should validate export details response', () => {
const payload = getOutputDetailsSample();
const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate export details with exceptions details response', () => {
const payload = getOutputDetailsSampleWithExceptions();
const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should strip out extra keys', () => {
const payload: ExportRulesDetails & {
extraKey?: string;
} = getOutputDetailsSample();
payload.extraKey = 'some extra key';
const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getOutputDetailsSample());
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { exportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types';
import { NonEmptyString } from '@kbn/securitysolution-io-ts-types';
const createSchema = <Required extends t.Props, Optional extends t.Props>(
requiredFields: Required,
optionalFields: Optional
) => {
return t.intersection([t.exact(t.type(requiredFields)), t.exact(t.partial(optionalFields))]);
};
export const exportRulesDetails = {
exported_count: t.number,
exported_rules_count: t.number,
missing_rules: t.array(
t.exact(
t.type({
rule_id: NonEmptyString,
})
)
),
missing_rules_count: t.number,
};
const exportRulesDetailsSchema = t.exact(t.type(exportRulesDetails));
export type ExportRulesDetailsSchema = t.TypeOf<typeof exportRulesDetailsSchema>;
// With exceptions
export const exportRulesDetailsWithExceptionsSchema = createSchema(
exportRulesDetails,
exportExceptionDetails
);
export type ExportRulesDetails = t.TypeOf<typeof exportRulesDetailsWithExceptionsSchema>;

View file

@ -419,7 +419,63 @@ export const getEditedRule = (): CustomRule => ({
});
export const expectedExportedRule = (ruleResponse: Cypress.Response<RulesSchema>): string => {
const jsonrule = ruleResponse.body;
const {
id,
updated_at: updatedAt,
updated_by: updatedBy,
created_at: createdAt,
description,
name,
risk_score: riskScore,
severity,
query,
} = ruleResponse.body;
const rule = {
id,
updated_at: updatedAt,
updated_by: updatedBy,
created_at: createdAt,
created_by: 'elastic',
name,
tags: [],
interval: '100m',
enabled: false,
description,
risk_score: riskScore,
severity,
output_index: '.siem-signals-default',
author: [],
false_positives: [],
from: 'now-50000h',
rule_id: 'rule_testing',
max_signals: 100,
risk_score_mapping: [],
severity_mapping: [],
threat: [],
to: 'now',
references: [],
version: 1,
exceptions_list: [],
immutable: false,
type: 'query',
language: 'kuery',
index: ['exceptions-*'],
query,
throttle: 'no_actions',
actions: [],
};
const details = {
exported_count: 1,
exported_rules_count: 1,
missing_rules: [],
missing_rules_count: 0,
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
missing_exception_lists: [],
missing_exception_lists_count: 0,
};
return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-50000h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0,"exported_exception_list_count":0,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`;
return `${JSON.stringify(rule)}\n${JSON.stringify(details)}\n`;
};

View file

@ -10,6 +10,10 @@ import { createPromiseFromStreams } from '@kbn/utils';
import { createRulesStreamFromNdJson } from './create_rules_stream_from_ndjson';
import { BadRequestError } from '@kbn/securitysolution-es-utils';
import { ImportRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/import_rules_schema';
import {
getOutputDetailsSample,
getSampleDetailsAsNdjson,
} from '../../../../common/detection_engine/schemas/response/export_rules_details_schema.mock';
type PromiseFromStreams = ImportRulesSchemaDecoded | Error;
@ -202,12 +206,13 @@ describe('create_rules_stream_from_ndjson', () => {
test('filters the export details entry from the stream', async () => {
const sample1 = getOutputSample();
const sample2 = getOutputSample();
const details = getOutputDetailsSample({ totalCount: 1, rulesCount: 1 });
sample2.rule_id = 'rule-2';
const ndJsonStream = new Readable({
read() {
this.push(getSampleAsNdjson(sample1));
this.push(getSampleAsNdjson(sample2));
this.push('{"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0}\n');
this.push(getSampleDetailsAsNdjson(details));
this.push(null);
},
});

View file

@ -21,7 +21,6 @@ import {
} from '../../../../common/detection_engine/schemas/request/import_rules_schema';
import {
parseNdjsonStrings,
filterExportedRulesCounts,
filterExceptions,
createLimitStream,
filterExportedCounts,
@ -62,7 +61,6 @@ export const createRulesStreamFromNdJson = (ruleLimit: number) => {
createSplitStream('\n'),
parseNdjsonStrings(),
filterExportedCounts(),
filterExportedRulesCounts(),
filterExceptions(),
validateRules(),
createLimitStream(ruleLimit),

View file

@ -15,6 +15,10 @@ 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 {
getOutputDetailsSampleWithExceptions,
getSampleDetailsAsNdjson,
} from '../../../../common/detection_engine/schemas/response/export_rules_details_schema.mock';
import { getQueryRuleParams } from '../schemas/rule_schemas.mock';
import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock';
@ -103,6 +107,7 @@ describe.each([
expect(detailsJson).toEqual({
exported_exception_list_count: 1,
exported_exception_list_item_count: 1,
exported_count: 3,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
@ -121,6 +126,7 @@ describe.each([
total: 0,
data: [],
};
const details = getOutputDetailsSampleWithExceptions();
rulesClient.find.mockResolvedValue(findResult);
@ -133,8 +139,7 @@ describe.each([
);
expect(exports).toEqual({
rulesNdjson: '',
exportDetails:
'{"exported_rules_count":0,"missing_rules":[],"missing_rules_count":0,"exported_exception_list_count":0,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n',
exportDetails: getSampleDetailsAsNdjson(details),
exceptionLists: '',
});
});

View file

@ -15,6 +15,10 @@ import {
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 {
getSampleDetailsAsNdjson,
getOutputDetailsSampleWithExceptions,
} from '../../../../common/detection_engine/schemas/response/export_rules_details_schema.mock';
import { getQueryRuleParams } from '../schemas/rule_schemas.mock';
import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock';
@ -100,6 +104,7 @@ describe.each([
exportDetails: {
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 1,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
@ -135,10 +140,13 @@ describe.each([
logger,
isRuleRegistryEnabled
);
const details = getOutputDetailsSampleWithExceptions({
missingRules: [{ rule_id: 'rule-1' }],
missingCount: 1,
});
expect(exports).toEqual({
rulesNdjson: '',
exportDetails:
'{"exported_rules_count":0,"missing_rules":[{"rule_id":"rule-1"}],"missing_rules_count":1,"exported_exception_list_count":0,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n',
exportDetails: getSampleDetailsAsNdjson(details),
exceptionLists: '',
});
});

View file

@ -72,7 +72,11 @@ export const getExportByObjectIds = async (
exceptionDetails
);
return { rulesNdjson, exportDetails, exceptionLists };
return {
rulesNdjson,
exportDetails,
exceptionLists,
};
};
export const getRulesFromObjects = async (

View file

@ -20,6 +20,7 @@ describe('getExportDetailsNdjson', () => {
const details = getExportDetailsNdjson([rule]);
const reParsed = JSON.parse(details);
expect(reParsed).toEqual({
exported_count: 1,
exported_rules_count: 1,
missing_rules: [],
missing_rules_count: 0,
@ -31,6 +32,7 @@ describe('getExportDetailsNdjson', () => {
const details = getExportDetailsNdjson([], [missingRule]);
const reParsed = JSON.parse(details);
expect(reParsed).toEqual({
exported_count: 0,
exported_rules_count: 0,
missing_rules: [{ rule_id: 'rule-1' }],
missing_rules_count: 1,
@ -49,6 +51,7 @@ describe('getExportDetailsNdjson', () => {
const details = getExportDetailsNdjson([rule1, rule2], [missingRule1, missingRule2]);
const reParsed = JSON.parse(details);
expect(reParsed).toEqual({
exported_count: 2,
exported_rules_count: 2,
missing_rules: [missingRule1, missingRule2],
missing_rules_count: 2,

View file

@ -5,18 +5,27 @@
* 2.0.
*/
import type { ExportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types';
import { ExportRulesDetails } from '../../../../common/detection_engine/schemas/response/export_rules_details_schema';
import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema';
export const getExportDetailsNdjson = (
rules: Array<Partial<RulesSchema>>,
missingRules: Array<{ rule_id: string }> = [],
extraMeta: Record<string, number | string | string[]> = {}
exceptionDetails?: ExportExceptionDetails
): string => {
const stringified = JSON.stringify({
const stringified: ExportRulesDetails = {
exported_count:
exceptionDetails == null
? rules.length
: rules.length +
exceptionDetails.exported_exception_list_count +
exceptionDetails.exported_exception_list_item_count,
exported_rules_count: rules.length,
missing_rules: missingRules,
missing_rules_count: missingRules.length,
...extraMeta,
});
return `${stringified}\n`;
...exceptionDetails,
};
return `${JSON.stringify(stringified)}\n`;
};

View file

@ -34,12 +34,6 @@ export const filterExportedCounts = (): Transform => {
);
};
export const filterExportedRulesCounts = (): Transform => {
return createFilterStream<ImportRulesSchemaDecoded | RulesObjectsExportResultDetails>(
(obj) => obj != null && !has('exported_rules_count', obj)
);
};
export const filterExceptions = (): Transform => {
return createFilterStream<ImportRulesSchemaDecoded | RulesObjectsExportResultDetails>(
(obj) => obj != null && !has('list_id', obj)

View file

@ -78,6 +78,7 @@ export default ({ getService }: FtrProviderContext): void => {
expect(bodySplitAndParsed).to.eql({
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 1,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],

View file

@ -79,6 +79,7 @@ export default ({ getService }: FtrProviderContext): void => {
expect(bodySplitAndParsed).to.eql({
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 1,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],

View file

@ -59,6 +59,7 @@ export default ({ getService }: FtrProviderContext): void => {
expect(exportDetails).to.eql({
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 1,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],