[Security Solution][Exceptions] - Common flyout components (#142054)

## Summary

Adds components shared between new add/edit exception flyouts. Does not yet modify the flyouts themselves. Trying to break down what would be an even larger PR into chunks.
This commit is contained in:
Yara Tercero 2022-10-05 11:38:33 -07:00 committed by GitHub
parent 63aee48127
commit 0149bd063c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 4206 additions and 529 deletions

View file

@ -138,13 +138,13 @@ export const getNewExceptionItem = ({
namespaceType,
ruleName,
}: {
listId: string;
namespaceType: NamespaceType;
listId: string | undefined;
namespaceType: NamespaceType | undefined;
ruleName: string;
}): CreateExceptionListItemBuilderSchema => {
return {
comments: [],
description: `${ruleName} - exception list item`,
description: 'Exception list item',
entries: addIdToEntries([
{
field: '',

View file

@ -9,6 +9,7 @@
import { DataViewFieldBase } from '@kbn/es-query';
import type {
CreateExceptionListItemSchema,
CreateRuleExceptionListItemSchema,
Entry,
EntryExists,
EntryMatch,
@ -18,6 +19,7 @@ import type {
ExceptionListItemSchema,
ListOperatorEnum as OperatorEnum,
ListOperatorTypeEnum as OperatorTypeEnum,
NamespaceType,
} from '@kbn/securitysolution-io-ts-list-types';
import {
EXCEPTION_LIST_NAMESPACE,
@ -93,16 +95,23 @@ export type ExceptionListItemBuilderSchema = Omit<ExceptionListItemSchema, 'entr
export type CreateExceptionListItemBuilderSchema = Omit<
CreateExceptionListItemSchema,
'meta' | 'entries'
'meta' | 'entries' | 'list_id' | 'namespace_type'
> & {
meta: { temporaryUuid: string };
entries: BuilderEntry[];
list_id: string | undefined;
namespace_type: NamespaceType | undefined;
};
export type ExceptionsBuilderExceptionItem =
| ExceptionListItemBuilderSchema
| CreateExceptionListItemBuilderSchema;
export type ExceptionsBuilderReturnExceptionItem =
| ExceptionListItemSchema
| CreateExceptionListItemSchema
| CreateRuleExceptionListItemSchema;
export const exceptionListSavedObjectType = EXCEPTION_LIST_NAMESPACE;
export const exceptionListAgnosticSavedObjectType = EXCEPTION_LIST_NAMESPACE_AGNOSTIC;
export type SavedObjectType =

View file

@ -11,7 +11,6 @@ import styled from 'styled-components';
import { HttpStart } from '@kbn/core/public';
import { addIdToItem } from '@kbn/securitysolution-utils';
import {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
ExceptionListType,
NamespaceType,
@ -24,6 +23,7 @@ import {
import {
CreateExceptionListItemBuilderSchema,
ExceptionsBuilderExceptionItem,
ExceptionsBuilderReturnExceptionItem,
OperatorOption,
containsValueListEntry,
filterExceptionItems,
@ -68,7 +68,7 @@ const initialState: State = {
export interface OnChangeProps {
errorExists: boolean;
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
exceptionItems: ExceptionsBuilderReturnExceptionItem[];
exceptionsToDelete: ExceptionListItemSchema[];
warningExists: boolean;
}
@ -84,15 +84,16 @@ export interface ExceptionBuilderProps {
isNestedDisabled: boolean;
isOrDisabled: boolean;
isOrHidden?: boolean;
listId: string;
listNamespaceType: NamespaceType;
listId: string | undefined;
listNamespaceType: NamespaceType | undefined;
listType: ExceptionListType;
listTypeSpecificIndexPatternFilter?: (
pattern: DataViewBase,
type: ExceptionListType
) => DataViewBase;
onChange: (arg: OnChangeProps) => void;
ruleName: string;
exceptionItemName?: string;
ruleName?: string;
isDisabled?: boolean;
operatorsList?: OperatorOption[];
}
@ -113,6 +114,7 @@ export const ExceptionBuilderComponent = ({
listTypeSpecificIndexPatternFilter,
onChange,
ruleName,
exceptionItemName,
isDisabled = false,
osTypes,
operatorsList,
@ -289,10 +291,10 @@ export const ExceptionBuilderComponent = ({
const newException = getNewExceptionItem({
listId,
namespaceType: listNamespaceType,
ruleName,
ruleName: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`,
});
setUpdateExceptions([...exceptions, { ...newException }]);
}, [setUpdateExceptions, exceptions, listId, listNamespaceType, ruleName]);
}, [listId, listNamespaceType, exceptionItemName, ruleName, setUpdateExceptions, exceptions]);
// The builder can have existing exception items, or new exception items that have yet
// to be created (and thus lack an id), this was creating some React bugs with relying

View file

@ -28,7 +28,7 @@ describe('find_exception_list_references_schema', () => {
});
});
test('"ids" cannot be undefined', () => {
test('"ids" can be optional', () => {
const payload: Omit<FindExceptionReferencesOnRuleSchema, 'ids'> = {
list_ids: '123,456',
namespace_types: 'single,agnostic',
@ -37,11 +37,14 @@ describe('find_exception_list_references_schema', () => {
const decoded = findExceptionReferencesOnRuleSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const output = foldLeftRight(checked);
expect(formatErrors(output.errors)).toEqual(['Invalid value "undefined" supplied to "ids"']);
expect(output.schema).toEqual({});
expect(formatErrors(output.errors)).toEqual([]);
expect(output.schema).toEqual({
list_ids: ['123', '456'],
namespace_types: ['single', 'agnostic'],
});
});
test('"list_ids" cannot be undefined', () => {
test('"list_ids" can be undefined', () => {
const payload: Omit<FindExceptionReferencesOnRuleSchema, 'list_ids'> = {
ids: 'abc',
namespace_types: 'single',
@ -50,10 +53,11 @@ describe('find_exception_list_references_schema', () => {
const decoded = findExceptionReferencesOnRuleSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const output = foldLeftRight(checked);
expect(formatErrors(output.errors)).toEqual([
'Invalid value "undefined" supplied to "list_ids"',
]);
expect(output.schema).toEqual({});
expect(formatErrors(output.errors)).toEqual([]);
expect(output.schema).toEqual({
ids: ['abc'],
namespace_types: ['single'],
});
});
test('defaults "namespacetypes" to ["single"] if none set', () => {

View file

@ -9,13 +9,21 @@ import * as t from 'io-ts';
import { NonEmptyStringArray } from '@kbn/securitysolution-io-ts-types';
import { DefaultNamespaceArray } from '@kbn/securitysolution-io-ts-list-types';
export const findExceptionReferencesOnRuleSchema = t.exact(
t.type({
ids: NonEmptyStringArray,
list_ids: NonEmptyStringArray,
namespace_types: DefaultNamespaceArray,
})
);
// If ids and list_ids are undefined, route will fetch all lists matching the
// specified namespace type
export const findExceptionReferencesOnRuleSchema = t.intersection([
t.exact(
t.type({
namespace_types: DefaultNamespaceArray,
})
),
t.exact(
t.partial({
ids: NonEmptyStringArray,
list_ids: NonEmptyStringArray,
})
),
]);
export type FindExceptionReferencesOnRuleSchema = t.OutputOf<
typeof findExceptionReferencesOnRuleSchema

View file

@ -7,62 +7,66 @@
import { exactCheck, formatErrors, foldLeftRight } from '@kbn/securitysolution-io-ts-utils';
import {
ruleReferenceSchema,
exceptionListRuleReferencesSchema,
rulesReferencedByExceptionListsSchema,
} from './find_exception_list_references_schema';
import type {
RuleReferenceSchema,
ExceptionListRuleReferencesSchema,
RulesReferencedByExceptionListsSchema,
} from './find_exception_list_references_schema';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
describe('find_exception_list_references_schema', () => {
describe('ruleReferenceSchema', () => {
describe('exceptionListRuleReferencesSchema', () => {
test('validates all fields', () => {
const payload: RuleReferenceSchema = {
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
const payload: ExceptionListRuleReferencesSchema = {
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
},
],
};
const decoded = ruleReferenceSchema.decode(payload);
const decoded = exceptionListRuleReferencesSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const output = foldLeftRight(checked);
expect(formatErrors(output.errors)).toEqual([]);
expect(output.schema).toEqual({
exception_lists: [
{ id: 'myListId', list_id: 'my-list-id', namespace_type: 'single', type: 'detection' },
],
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
name: 'My rule',
rule_id: 'my-rule-id',
});
expect(output.schema).toEqual(payload);
});
test('cannot add extra values', () => {
const payload: RuleReferenceSchema & { extra_value?: string } = {
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
const payload: ExceptionListRuleReferencesSchema & { extra_value?: string } = {
extra_value: 'foo',
exception_lists: [
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
},
],
};
const decoded = ruleReferenceSchema.decode(payload);
const decoded = exceptionListRuleReferencesSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const output = foldLeftRight(checked);
expect(formatErrors(output.errors)).toEqual(['invalid keys "extra_value"']);
@ -75,21 +79,24 @@ describe('find_exception_list_references_schema', () => {
const payload: RulesReferencedByExceptionListsSchema = {
references: [
{
'my-list-id': [
{
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
},
],
'my-list-id': {
...getExceptionListSchemaMock(),
referenced_rules: [
{
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
},
],
},
},
],
};
@ -98,27 +105,7 @@ describe('find_exception_list_references_schema', () => {
const checked = exactCheck(payload, decoded);
const output = foldLeftRight(checked);
expect(formatErrors(output.errors)).toEqual([]);
expect(output.schema).toEqual({
references: [
{
'my-list-id': [
{
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
name: 'My rule',
rule_id: 'my-rule-id',
},
],
},
],
});
expect(output.schema).toEqual(payload);
});
test('validates "references" with empty array', () => {
@ -140,21 +127,24 @@ describe('find_exception_list_references_schema', () => {
extra_value: 'foo',
references: [
{
'my-list-id': [
{
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
},
],
'my-list-id': {
...getExceptionListSchemaMock(),
referenced_rules: [
{
name: 'My rule',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
rule_id: 'my-rule-id',
exception_lists: [
{
id: 'myListId',
list_id: 'my-list-id',
namespace_type: 'single',
type: 'detection',
},
],
},
],
},
},
],
};

View file

@ -7,11 +7,11 @@
import * as t from 'io-ts';
import { listArray, list_id } from '@kbn/securitysolution-io-ts-list-types';
import { exceptionListSchema, listArray, list_id } from '@kbn/securitysolution-io-ts-list-types';
import { rule_id, id, name } from '../common/schemas';
export const ruleReferenceSchema = t.exact(
export const ruleReferenceRuleInfoSchema = t.exact(
t.type({
name,
id,
@ -20,9 +20,25 @@ export const ruleReferenceSchema = t.exact(
})
);
export type RuleReferenceSchema = t.OutputOf<typeof ruleReferenceSchema>;
export type ExceptionListRuleReferencesInfoSchema = t.OutputOf<typeof ruleReferenceRuleInfoSchema>;
export const rulesReferencedByExceptionListSchema = t.record(list_id, t.array(ruleReferenceSchema));
export const exceptionListRuleReferencesSchema = t.intersection([
exceptionListSchema,
t.exact(
t.type({
referenced_rules: t.array(ruleReferenceRuleInfoSchema),
})
),
]);
export type ExceptionListRuleReferencesSchema = t.OutputOf<
typeof exceptionListRuleReferencesSchema
>;
export const rulesReferencedByExceptionListSchema = t.record(
list_id,
exceptionListRuleReferencesSchema
);
export type RuleReferencesSchema = t.OutputOf<typeof rulesReferencedByExceptionListSchema>;

View file

@ -30,12 +30,15 @@ import {
EuiFlexGroup,
} from '@elastic/eui';
import type {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
ExceptionListType,
OsTypeArray,
ExceptionListItemSchema,
CreateExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionsBuilderExceptionItem } from '@kbn/securitysolution-list-utils';
import type {
ExceptionsBuilderExceptionItem,
ExceptionsBuilderReturnExceptionItem,
} from '@kbn/securitysolution-list-utils';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
import type { DataViewBase } from '@kbn/es-query';
import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices';
@ -146,7 +149,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false);
const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false);
const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState<
Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
ExceptionsBuilderReturnExceptionItem[]
>([]);
const [fetchOrCreateListError, setFetchOrCreateListError] = useState<ErrorInfo | null>(null);
const { addError, addSuccess, addWarning } = useAppToasts();
@ -190,7 +193,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
exceptionItems,
errorExists,
}: {
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
exceptionItems: ExceptionsBuilderReturnExceptionItem[];
errorExists: boolean;
}) => {
setExceptionItemsToAdd(exceptionItems);
@ -337,10 +340,8 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
return hasAlertData ? retrieveAlertOsTypes(alertData) : selectedOs ? [...selectedOs] : [];
}, [hasAlertData, alertData, selectedOs]);
const enrichExceptionItems = useCallback((): Array<
ExceptionListItemSchema | CreateExceptionListItemSchema
> => {
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [];
const enrichExceptionItems = useCallback((): ExceptionsBuilderReturnExceptionItem[] => {
let enriched: ExceptionsBuilderReturnExceptionItem[] = [];
enriched =
comment !== ''
? enrichNewExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }])
@ -359,7 +360,9 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
shouldBulkCloseAlert && signalIndexName != null ? [signalIndexName] : undefined;
addOrUpdateExceptionItems(
maybeRule?.rule_id ?? '',
enrichExceptionItems(),
// This is being rewritten in https://github.com/elastic/kibana/pull/140643
// As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema
enrichExceptionItems() as Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
alertIdToClose,
bulkCloseIndex
);

View file

@ -69,7 +69,9 @@ const ExceptionItemsViewerComponent: React.FC<ExceptionItemsViewerProps> = ({
disableActions={disableActions}
exceptionItem={exception}
listType={listType}
ruleReferences={ruleReferences != null ? ruleReferences[exception.list_id] : []}
ruleReferences={
ruleReferences != null ? ruleReferences[exception.list_id] : null
}
onDeleteException={onDeleteException}
onEditException={onEditExceptionItem}
dataTestSubj="exceptionItemsViewerItem"

View file

@ -162,8 +162,17 @@ const ExceptionsViewerComponent = ({
[dispatch]
);
const [isLoadingReferences, isFetchReferencesError, allReferences] =
useFindExceptionListReferences(exceptionListsToQuery);
const [isLoadingReferences, isFetchReferencesError, allReferences, fetchReferences] =
useFindExceptionListReferences();
useEffect(() => {
if (fetchReferences != null && exceptionListsToQuery.length) {
const listsToQuery = exceptionListsToQuery.map(
({ id, list_id: listId, namespace_type: namespaceType }) => ({ id, listId, namespaceType })
);
fetchReferences(listsToQuery);
}
}, [exceptionListsToQuery, fetchReferences]);
useEffect(() => {
if (isFetchReferencesError) {

View file

@ -38,6 +38,7 @@ import type {
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
import type { DataViewBase } from '@kbn/es-query';
import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils';
import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices';
import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils';
import { useFetchIndex } from '../../../../common/containers/source';
@ -127,7 +128,7 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false);
const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false);
const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState<
Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
ExceptionsBuilderReturnExceptionItem[]
>([]);
const { addError, addSuccess } = useAppToasts();
const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex();
@ -254,7 +255,7 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
exceptionItems,
errorExists,
}: {
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
exceptionItems: ExceptionsBuilderReturnExceptionItem[];
errorExists: boolean;
}) => {
setExceptionItemsToAdd(exceptionItems);
@ -279,7 +280,7 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
const enrichExceptionItems = useCallback(() => {
const [exceptionItemToEdit] = exceptionItemsToAdd;
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [
let enriched: ExceptionsBuilderReturnExceptionItem[] = [
{
...enrichExistingExceptionItemWithComments(exceptionItemToEdit, [
...exceptionItem.comments,
@ -299,7 +300,9 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined;
addOrUpdateExceptionItems(
maybeRule?.rule_id ?? '',
enrichExceptionItems(),
// This is being rewritten in https://github.com/elastic/kibana/pull/140643
// As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema
enrichExceptionItems() as Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
undefined,
bulkCloseIndex
);

View file

@ -13,6 +13,7 @@ import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas
import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { TestProviders } from '../../../../common/mock';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
jest.mock('../../../../common/lib/kibana');
@ -28,27 +29,30 @@ describe('ExceptionItemCard', () => {
onEditException={jest.fn()}
exceptionItem={exceptionItem}
listType={ExceptionListTypeEnum.DETECTION}
ruleReferences={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
ruleReferences={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
dataTestSubj="item"
/>
</TestProviders>
@ -74,27 +78,30 @@ describe('ExceptionItemCard', () => {
exceptionItem={exceptionItem}
dataTestSubj="item"
listType={ExceptionListTypeEnum.DETECTION}
ruleReferences={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
ruleReferences={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
/>
</TestProviders>
);
@ -119,27 +126,30 @@ describe('ExceptionItemCard', () => {
exceptionItem={exceptionItem}
dataTestSubj="item"
listType={ExceptionListTypeEnum.DETECTION}
ruleReferences={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
ruleReferences={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
/>
</TestProviders>
);
@ -160,27 +170,30 @@ describe('ExceptionItemCard', () => {
exceptionItem={exceptionItem}
dataTestSubj="item"
listType={ExceptionListTypeEnum.DETECTION}
ruleReferences={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
ruleReferences={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
/>
</TestProviders>
);
@ -210,27 +223,30 @@ describe('ExceptionItemCard', () => {
exceptionItem={exceptionItem}
dataTestSubj="item"
listType={ExceptionListTypeEnum.DETECTION}
ruleReferences={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
ruleReferences={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
/>
</TestProviders>
);
@ -263,27 +279,30 @@ describe('ExceptionItemCard', () => {
exceptionItem={exceptionItem}
dataTestSubj="item"
listType={ExceptionListTypeEnum.DETECTION}
ruleReferences={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
ruleReferences={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
/>
</TestProviders>
);

View file

@ -17,14 +17,14 @@ import * as i18n from './translations';
import { ExceptionItemCardHeader } from './header';
import { ExceptionItemCardConditions } from './conditions';
import { ExceptionItemCardMetaInfo } from './meta';
import type { RuleReferenceSchema } from '../../../../../common/detection_engine/schemas/response';
import type { ExceptionListRuleReferencesSchema } from '../../../../../common/detection_engine/schemas/response';
import { ExceptionItemCardComments } from './comments';
export interface ExceptionItemProps {
exceptionItem: ExceptionListItemSchema;
listType: ExceptionListTypeEnum;
disableActions: boolean;
ruleReferences: RuleReferenceSchema[];
ruleReferences: ExceptionListRuleReferencesSchema | null;
onDeleteException: (arg: ExceptionListItemIdentifiers) => void;
onEditException: (item: ExceptionListItemSchema) => void;
dataTestSubj: string;

View file

@ -7,7 +7,9 @@
import React from 'react';
import { mount } from 'enzyme';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import { ExceptionItemCardMetaInfo } from './meta';
import { TestProviders } from '../../../../common/mock';
@ -18,27 +20,30 @@ describe('ExceptionItemCardMetaInfo', () => {
<TestProviders>
<ExceptionItemCardMetaInfo
item={getExceptionListItemSchemaMock()}
references={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
references={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
dataTestSubj="exceptionItemMeta"
/>
</TestProviders>
@ -57,27 +62,30 @@ describe('ExceptionItemCardMetaInfo', () => {
<TestProviders>
<ExceptionItemCardMetaInfo
item={getExceptionListItemSchemaMock()}
references={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
references={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
dataTestSubj="exceptionItemMeta"
/>
</TestProviders>
@ -96,27 +104,30 @@ describe('ExceptionItemCardMetaInfo', () => {
<TestProviders>
<ExceptionItemCardMetaInfo
item={getExceptionListItemSchemaMock()}
references={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
]}
references={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
],
}}
dataTestSubj="exceptionItemMeta"
/>
</TestProviders>
@ -132,46 +143,49 @@ describe('ExceptionItemCardMetaInfo', () => {
<TestProviders>
<ExceptionItemCardMetaInfo
item={getExceptionListItemSchemaMock()}
references={[
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: 'aaa',
name: 'Simple Rule Query 2',
rule_id: 'rule-3',
},
]}
references={{
...getExceptionListSchemaMock(),
referenced_rules: [
{
id: '1a2b3c',
name: 'Simple Rule Query',
rule_id: 'rule-2',
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
},
{
exception_lists: [
{
id: '123',
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: '456',
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: 'aaa',
name: 'Simple Rule Query 2',
rule_id: 'rule-3',
},
],
}}
dataTestSubj="exceptionItemMeta"
/>
</TestProviders>

View file

@ -24,7 +24,7 @@ import styled from 'styled-components';
import * as i18n from './translations';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { SecurityPageName } from '../../../../../common/constants';
import type { RuleReferenceSchema } from '../../../../../common/detection_engine/schemas/response';
import type { ExceptionListRuleReferencesSchema } from '../../../../../common/detection_engine/schemas/response';
import { SecuritySolutionLinkAnchor } from '../../../../common/components/links';
import { RuleDetailTabs } from '../../../../detections/pages/detection_engine/rules/details';
import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
@ -36,7 +36,7 @@ const StyledFlexItem = styled(EuiFlexItem)`
export interface ExceptionItemCardMetaInfoProps {
item: ExceptionListItemSchema;
references: RuleReferenceSchema[];
references: ExceptionListRuleReferencesSchema | null;
dataTestSubj: string;
}
@ -51,7 +51,7 @@ export const ExceptionItemCardMetaInfo = memo<ExceptionItemCardMetaInfoProps>(
if (references == null) {
return [];
}
return references.map((reference) => (
return references.referenced_rules.map((reference) => (
<EuiContextMenuItem
data-test-subj={`${dataTestSubj}-actionItem-${reference.id}`}
key={reference.id}
@ -94,25 +94,27 @@ export const ExceptionItemCardMetaInfo = memo<ExceptionItemCardMetaInfoProps>(
dataTestSubj={`${dataTestSubj}-updatedBy`}
/>
</StyledFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonEmpty
onClick={onAffectedRulesClick}
iconType="list"
data-test-subj={`${dataTestSubj}-affectedRulesButton`}
>
{i18n.AFFECTED_RULES(references?.length ?? 0)}
</EuiButtonEmpty>
}
panelPaddingSize="none"
isOpen={isPopoverOpen}
closePopover={onClosePopover}
data-test-subj={`${dataTestSubj}-items`}
>
<EuiContextMenuPanel size="s" items={itemActions} />
</EuiPopover>
</EuiFlexItem>
{references != null && (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonEmpty
onClick={onAffectedRulesClick}
iconType="list"
data-test-subj={`${dataTestSubj}-affectedRulesButton`}
>
{i18n.AFFECTED_RULES(references?.referenced_rules.length ?? 0)}
</EuiButtonEmpty>
}
panelPaddingSize="none"
isOpen={isPopoverOpen}
closePopover={onClosePopover}
data-test-subj={`${dataTestSubj}-items`}
>
<EuiContextMenuPanel size="s" items={itemActions} />
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,139 @@
/*
* 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 React from 'react';
import { shallow } from 'enzyme';
import { getRulesSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { ExceptionsAddToRulesOrLists } from '.';
describe('ExceptionsAddToRulesOrLists', () => {
it('it passes empty array for shared lists attached if no rules passed in', () => {
const wrapper = shallow(
<ExceptionsAddToRulesOrLists
rules={null}
isBulkAction={false}
selectedRadioOption="add_to_rule"
onListSelectionChange={jest.fn()}
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(wrapper.find('ExceptionsAddToListsOptions').prop('sharedLists')).toEqual([]);
expect(wrapper.find('ExceptionsAddToListsOptions').prop('rulesCount')).toEqual(0);
});
it('it passes all shared lists attached to a single rule', () => {
const wrapper = shallow(
<ExceptionsAddToRulesOrLists
rules={[
{
...getRulesSchemaMock(),
exceptions_list: [
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
],
} as Rule,
]}
isBulkAction={false}
selectedRadioOption="add_to_rule"
onListSelectionChange={jest.fn()}
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(wrapper.find('ExceptionsAddToListsOptions').prop('sharedLists')).toEqual([
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
]);
});
it('it passes shared lists that are in common if multiple rules exist', () => {
const wrapper = shallow(
<ExceptionsAddToRulesOrLists
rules={[
{
...getRulesSchemaMock(),
exceptions_list: [
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
],
} as Rule,
{
...getRulesSchemaMock(),
id: '2',
rule_id: '2',
exceptions_list: [
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
{ id: '456', list_id: 'my_list_2', namespace_type: 'single', type: 'detection' },
{ id: '789', list_id: 'my_list_3', namespace_type: 'single', type: 'detection' },
],
} as Rule,
{
...getRulesSchemaMock(),
id: '3',
rule_id: '3',
exceptions_list: [
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
{ id: '789', list_id: 'my_list_3', namespace_type: 'single', type: 'detection' },
],
} as Rule,
]}
isBulkAction={false}
selectedRadioOption="add_to_rule"
onListSelectionChange={jest.fn()}
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(wrapper.find('ExceptionsAddToListsOptions').prop('sharedLists')).toEqual([
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
]);
});
it('it passes an empty array for shared lists if multiple rules exist and they have no shared lists in common', () => {
const wrapper = shallow(
<ExceptionsAddToRulesOrLists
rules={[
{
...getRulesSchemaMock(),
exceptions_list: [
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
],
} as Rule,
{
...getRulesSchemaMock(),
id: '2',
rule_id: '2',
exceptions_list: [
{ id: '456', list_id: 'my_list_2', namespace_type: 'single', type: 'detection' },
{ id: '789', list_id: 'my_list_3', namespace_type: 'single', type: 'detection' },
],
} as Rule,
{
...getRulesSchemaMock(),
id: '3',
rule_id: '3',
exceptions_list: [
{ id: '123', list_id: 'my_list', namespace_type: 'single', type: 'detection' },
{ id: '789', list_id: 'my_list_3', namespace_type: 'single', type: 'detection' },
],
} as Rule,
]}
isBulkAction={false}
selectedRadioOption="add_to_rule"
onListSelectionChange={jest.fn()}
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(wrapper.find('ExceptionsAddToListsOptions').prop('sharedLists')).toEqual([]);
});
});

View file

@ -0,0 +1,111 @@
/*
* 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 React, { useMemo } from 'react';
import { EuiTitle, EuiSpacer, EuiPanel } from '@elastic/eui';
import styled, { css } from 'styled-components';
import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import * as i18n from './translations';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { ExceptionsAddToRulesOptions } from '../add_to_rules_options';
import { ExceptionsAddToListsOptions } from '../add_to_lists_options';
interface ExceptionsAddToRulesOrListsComponentProps {
/* Rules that exception item will be added to, or whose shared lists will be used to populate add to lists option. If none passed in, user is prompted to select what rules to add exception to. */
rules: Rule[] | null;
selectedRadioOption: string;
/* Is user adding an exception item from the rules bulk actions */
isBulkAction: boolean;
onListSelectionChange: (lists: ExceptionListSchema[]) => void;
onRuleSelectionChange: (rulesSelectedToAdd: Rule[]) => void;
onRadioChange: (radioId: string) => void;
}
const SectionHeader = styled(EuiTitle)`
${() => css`
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
`}
`;
const ExceptionsAddToRulesOrListsComponent: React.FC<ExceptionsAddToRulesOrListsComponentProps> = ({
rules,
isBulkAction,
selectedRadioOption,
onListSelectionChange,
onRuleSelectionChange,
onRadioChange,
}): JSX.Element => {
const isSingleRule = useMemo(() => rules != null && rules.length === 1, [rules]);
/*
* Determine what shared lists to display as selectable options for adding the exception item to:
* - if dealing with a single rule - show any shared exception lists it has attached
* - if dealing with multiple rules - show only shared exception lists that are common
* across all of the rules
*/
const sharedLists = useMemo(() => {
if (rules == null) return [];
if (rules.length === 1)
return (
rules[0].exceptions_list?.filter((list) => list.type === ExceptionListTypeEnum.DETECTION) ??
[]
);
const lists =
rules?.map((rule) => (rule.exceptions_list != null ? rule.exceptions_list : [])) ?? [];
lists.sort((a, b) => {
return a.length - b.length;
});
const shortestArrOfLists = lists.shift();
if (shortestArrOfLists == null || !shortestArrOfLists.length) return [];
return shortestArrOfLists
.filter((exceptionListInfo) =>
lists.every((l) => l.some(({ id }) => exceptionListInfo.id === id))
)
.filter((list) => list.type === ExceptionListTypeEnum.DETECTION);
}, [rules]);
const rulesCount = useMemo(() => (rules != null ? rules.length : 0), [rules]);
return (
<EuiPanel
paddingSize="none"
hasShadow={false}
data-test-subj="exceptionItemAddToRuleOrListSection"
>
<SectionHeader size="xs">
<h3>{i18n.ADD_TO_LISTS_SECTION_TITLE}</h3>
</SectionHeader>
<EuiSpacer size="s" />
<ExceptionsAddToRulesOptions
possibleRules={rules}
isSingleRule={isSingleRule}
isBulkAction={isBulkAction}
selectedRadioOption={selectedRadioOption}
onRuleSelectionChange={onRuleSelectionChange}
onRadioChange={onRadioChange}
/>
<ExceptionsAddToListsOptions
rulesCount={rulesCount}
selectedRadioOption={selectedRadioOption}
sharedLists={sharedLists}
onListsSelectionChange={onListSelectionChange}
onRadioChange={onRadioChange}
/>
</EuiPanel>
);
};
export const ExceptionsAddToRulesOrLists = React.memo(ExceptionsAddToRulesOrListsComponent);
ExceptionsAddToRulesOrLists.displayName = 'ExceptionsAddToRulesOrLists';

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ADD_TO_LISTS_SECTION_TITLE = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addExceptionToRuleOrList.addToListsLabel',
{
defaultMessage: 'Add to rule or lists',
}
);

View file

@ -0,0 +1,78 @@
/*
* 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 React from 'react';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { shallow } from 'enzyme';
import { ExceptionsAddToListsOptions } from '.';
jest.mock('../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules');
describe('ExceptionsAddToListsOptions', () => {
it('it displays radio option as disabled if there are no "sharedLists"', () => {
const wrapper = shallow(
<ExceptionsAddToListsOptions
rulesCount={1}
selectedRadioOption="add_to_rule"
sharedLists={[]}
onListsSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="exceptionsAddToListTable"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="addToListsRadioOption"]').at(0).props().disabled
).toBeTruthy();
});
it('it displays lists table if radio is selected', () => {
const wrapper = shallow(
<ExceptionsAddToListsOptions
rulesCount={1}
selectedRadioOption="add_to_lists"
sharedLists={[
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
]}
onListsSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(
wrapper.find('[data-test-subj="addToListsRadioOption"]').at(0).props().disabled
).toBeFalsy();
expect(wrapper.find('[data-test-subj="exceptionsAddToListTable"]').exists()).toBeTruthy();
});
it('it does not display lists table if radio is not selected', () => {
const wrapper = shallow(
<ExceptionsAddToListsOptions
rulesCount={1}
selectedRadioOption="add_to_rule"
sharedLists={[
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
]}
onListsSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="exceptionsAddToListTable"]').exists()).toBeFalsy();
});
});

View file

@ -0,0 +1,80 @@
/*
* 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 React from 'react';
import { EuiText, EuiRadio, EuiFlexItem, EuiFlexGroup, EuiIconTip } from '@elastic/eui';
import type { ExceptionListSchema, ListArray } from '@kbn/securitysolution-io-ts-list-types';
import * as i18n from './translations';
import { ExceptionsAddToListsTable } from '../add_to_lists_table';
interface ExceptionsAddToListsOptionsComponentProps {
rulesCount: number;
selectedRadioOption: string;
sharedLists: ListArray;
onListsSelectionChange: (listsSelectedToAdd: ExceptionListSchema[]) => void;
onRadioChange: (option: string) => void;
}
const ExceptionsAddToListsOptionsComponent: React.FC<ExceptionsAddToListsOptionsComponentProps> = ({
rulesCount,
selectedRadioOption,
sharedLists,
onListsSelectionChange,
onRadioChange,
}): JSX.Element => {
return (
<>
<EuiRadio
id="add_to_lists"
label={
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
ata-test-subj="addToListsRadioOptionLabel"
>
<EuiFlexItem grow={false}>
<EuiText>{i18n.ADD_TO_LISTS_OPTION}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="addToListsOption">
<EuiIconTip
content={
sharedLists.length === 0
? i18n.ADD_TO_LISTS_OPTION_DISABLED_TOOLTIP(rulesCount)
: i18n.ADD_TO_LISTS_OPTION_TOOLTIP
}
title={i18n.ADD_TO_LISTS_OPTION}
position="top"
type="iInCircle"
data-test-subj="addToListsOptionTooltip"
/>
</EuiFlexItem>
</EuiFlexGroup>
}
checked={selectedRadioOption === 'add_to_lists'}
disabled={sharedLists.length === 0 && rulesCount > 0}
onChange={() => onRadioChange('add_to_lists')}
data-test-subj="addToListsRadioOption"
/>
{selectedRadioOption === 'add_to_lists' && (sharedLists.length > 0 || rulesCount === 0) && (
<ExceptionsAddToListsTable
showAllSharedLists={rulesCount === 0}
sharedExceptionLists={sharedLists}
onListSelectionChange={onListsSelectionChange}
data-test-subj="exceptionsAddToListTable"
/>
)}
</>
);
};
export const ExceptionsAddToListsOptions = React.memo(ExceptionsAddToListsOptionsComponent);
ExceptionsAddToListsOptions.displayName = 'ExceptionsAddToListsOptions';

View file

@ -0,0 +1,33 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ADD_TO_LISTS_OPTION = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToListsOptions.addToListsOptionLabel',
{
defaultMessage: 'Add to shared exception lists',
}
);
export const ADD_TO_LISTS_OPTION_TOOLTIP = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToListsOptions.addToListsTooltip',
{
defaultMessage:
'Shared exception list is a group of exceptions. Select this option if youd like to add this exception to shared exception lists.',
}
);
export const ADD_TO_LISTS_OPTION_DISABLED_TOOLTIP = (rulesCount: number) =>
i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToListsOptions.addToListsTooltipTitle',
{
values: { rulesCount },
defaultMessage:
'Shared exception list is a group of exceptions. {rulesCount, plural, =1 {This rule currently has no shared} other {These rules currently have no commonly shared}} exception lists attached. To create one, visit the Exception lists management page.',
}
);

View file

@ -0,0 +1,162 @@
/*
* 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 React from 'react';
import { ExceptionsAddToListsTable } from '.';
import { TestProviders } from '../../../../../common/mock';
import { useFindExceptionListReferences } from '../../../logic/use_find_references';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { mount } from 'enzyme';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
jest.mock('../../../logic/use_find_references');
describe('ExceptionsAddToListsTable', () => {
const mockFn = jest.fn();
beforeEach(() => {
(useFindExceptionListReferences as jest.Mock).mockReturnValue([
false,
false,
{
my_list_id: {
...getExceptionListSchemaMock(),
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
name: 'My exception list',
referenced_rules: [
{
id: '345',
name: 'My rule',
rule_id: 'my_rule_id',
exception_lists: [
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
],
},
],
},
},
mockFn,
]);
});
afterEach(() => {
jest.resetAllMocks();
});
it('it displays loading state while fetching data', () => {
(useFindExceptionListReferences as jest.Mock).mockReturnValue([true, false, null, mockFn]);
const wrapper = mount(
<TestProviders>
<ExceptionsAddToListsTable
showAllSharedLists={false}
sharedExceptionLists={[
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
]}
onListSelectionChange={jest.fn()}
/>
</TestProviders>
);
expect(mockFn).toHaveBeenCalledWith([
{
id: '123',
listId: 'my_list_id',
namespaceType: 'single',
},
]);
expect(wrapper.find('[data-test-subj="exceptionItemListsTableLoading"]').exists()).toBeTruthy();
});
it('it displays error state if fetching list and references data fails', () => {
(useFindExceptionListReferences as jest.Mock).mockReturnValue([false, true, null, jest.fn()]);
const wrapper = mount(
<TestProviders>
<ExceptionsAddToListsTable
showAllSharedLists={false}
sharedExceptionLists={[
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
]}
onListSelectionChange={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('EuiInMemoryTable').prop('error')).toEqual(
'Unable to load shared exception lists'
);
});
it('it invokes "useFindExceptionListReferences" with array of namespace types to fetch all lists if "showAllSharedLists" is "true"', () => {
mount(
<TestProviders>
<ExceptionsAddToListsTable
showAllSharedLists
sharedExceptionLists={[
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
]}
onListSelectionChange={jest.fn()}
/>
</TestProviders>
);
expect(mockFn).toHaveBeenCalledWith([
{ namespaceType: 'single' },
{ namespaceType: 'agnostic' },
]);
});
it('it displays lists with rule references', async () => {
const wrapper = mount(
<TestProviders>
<ExceptionsAddToListsTable
showAllSharedLists={false}
sharedExceptionLists={[
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
]}
onListSelectionChange={jest.fn()}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="ruleReferencesDisplayPopoverButton"]').at(1).text()
).toEqual('1');
// Formatting is off since doesn't take css into account
expect(wrapper.find('[data-test-subj="exceptionListNameCell"]').at(1).text()).toEqual(
'NameMy exception list'
);
});
});

View file

@ -0,0 +1,138 @@
/*
* 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 React, { useEffect, useState, useMemo } from 'react';
import type { CriteriaWithPagination } from '@elastic/eui';
import { EuiText, EuiSpacer, EuiInMemoryTable, EuiPanel, EuiLoadingContent } from '@elastic/eui';
import type { ExceptionListSchema, ListArray } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { FindRulesReferencedByExceptionsListProp } from '../../../../../detections/containers/detection_engine/rules';
import * as i18n from './translations';
import { getSharedListsTableColumns } from '../utils';
import { useFindExceptionListReferences } from '../../../logic/use_find_references';
import type { ExceptionListRuleReferencesSchema } from '../../../../../../common/detection_engine/schemas/response';
interface ExceptionsAddToListsComponentProps {
/**
* Normally if there's no sharedExceptionLists, this opition is disabled, however,
* when adding an exception item from the exception lists management page, there is no
* list or rule to go off of, so user can select to add the exception to any rule or to any
* shared list.
*/
showAllSharedLists: boolean;
/* Shared exception lists to display as options to add item to */
sharedExceptionLists: ListArray;
onListSelectionChange?: (listsSelectedToAdd: ExceptionListSchema[]) => void;
}
const ExceptionsAddToListsComponent: React.FC<ExceptionsAddToListsComponentProps> = ({
showAllSharedLists,
sharedExceptionLists,
onListSelectionChange,
}): JSX.Element => {
const listsToFetch = useMemo(() => {
return showAllSharedLists ? [] : sharedExceptionLists;
}, [showAllSharedLists, sharedExceptionLists]);
const [listsToDisplay, setListsToDisplay] = useState<ExceptionListRuleReferencesSchema[]>([]);
const [pagination, setPagination] = useState({ pageIndex: 0 });
const [message, setMessage] = useState<JSX.Element | string | undefined>(
<EuiLoadingContent lines={4} data-test-subj="exceptionItemListsTableLoading" />
);
const [error, setError] = useState<string | undefined>(undefined);
const [isLoadingReferences, referenceFetchError, ruleReferences, fetchReferences] =
useFindExceptionListReferences();
useEffect(() => {
if (fetchReferences != null) {
const listsToQuery: FindRulesReferencedByExceptionsListProp[] = !listsToFetch.length
? [{ namespaceType: 'single' }, { namespaceType: 'agnostic' }]
: listsToFetch.map(({ id, list_id: listId, namespace_type: namespaceType }) => ({
id,
listId,
namespaceType,
}));
fetchReferences(listsToQuery);
}
}, [listsToFetch, fetchReferences]);
useEffect(() => {
if (referenceFetchError) return setError(i18n.REFERENCES_FETCH_ERROR);
if (isLoadingReferences) {
return setMessage(
<EuiLoadingContent lines={4} data-test-subj="exceptionItemListsTableLoading" />
);
}
if (!ruleReferences) return;
const lists: ExceptionListRuleReferencesSchema[] = [];
for (const [_, value] of Object.entries(ruleReferences))
if (value.type === ExceptionListTypeEnum.DETECTION) lists.push(value);
setMessage(undefined);
setListsToDisplay(lists);
}, [isLoadingReferences, referenceFetchError, ruleReferences, showAllSharedLists]);
const selectionValue = {
onSelectionChange: (selection: ExceptionListRuleReferencesSchema[]) => {
if (onListSelectionChange != null) {
onListSelectionChange(
selection.map(
({
referenced_rules: _,
namespace_type: namespaceType,
os_types: osTypes,
tags,
...rest
}) => ({
...rest,
namespace_type: namespaceType ?? 'single',
os_types: osTypes ?? [],
tags: tags ?? [],
})
)
);
}
},
initialSelected: [],
};
return (
<EuiPanel color="subdued" borderRadius="none" hasShadow={false}>
<>
<EuiText size="s">{i18n.ADD_TO_LISTS_DESCRIPTION}</EuiText>
<EuiSpacer size="s" />
<EuiSpacer size="s" />
<EuiInMemoryTable<ExceptionListRuleReferencesSchema>
tableCaption="Table of exception lists"
itemId="id"
items={listsToDisplay}
loading={message != null}
message={message}
columns={getSharedListsTableColumns()}
error={error}
pagination={{
...pagination,
pageSizeOptions: [5],
showPerPageOptions: false,
}}
onTableChange={({ page: { index } }: CriteriaWithPagination<never>) =>
setPagination({ pageIndex: index })
}
selection={selectionValue}
isSelectable
sorting
data-test-subj="addExceptionToSharedListsTable"
/>
</>
</EuiPanel>
);
};
export const ExceptionsAddToListsTable = React.memo(ExceptionsAddToListsComponent);
ExceptionsAddToListsTable.displayName = 'ExceptionsAddToListsTable';

View file

@ -0,0 +1,30 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ADD_TO_LISTS_DESCRIPTION = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToListsTableSelection.addToListsDescription',
{
defaultMessage:
'Select shared exception list to add to. We will make a copy of this exception if multiple lists are selected.',
}
);
export const VIEW_LIST_DETAIL_ACTION = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToListsTableSelection.viewListDetailActionLabel',
{
defaultMessage: 'View list detail',
}
);
export const REFERENCES_FETCH_ERROR = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToListsTableSelection.referencesFetchError',
{
defaultMessage: 'Unable to load shared exception lists',
}
);

View file

@ -0,0 +1,80 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ExceptionsAddToRulesOptions } from '.';
import { TestProviders } from '../../../../../common/mock';
import { useFindRules } from '../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules';
import { getRulesSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
jest.mock('../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules');
describe('ExceptionsAddToRulesOptions', () => {
beforeEach(() => {
(useFindRules as jest.Mock).mockReturnValue({
data: {
rules: [getRulesSchemaMock(), { ...getRulesSchemaMock(), id: '345', name: 'My rule' }],
total: 0,
},
isFetched: true,
});
});
it('it displays option to add exception to single rule if a single rule is passed in', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsAddToRulesOptions
possibleRules={[getRulesSchemaMock() as Rule]}
isSingleRule
isBulkAction={false}
selectedRadioOption="add_to_rule"
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="addToRuleRadioOption"]').exists()).toBeTruthy();
});
it('it displays option to add exception to multiple rules if "isBulkAction" is "true" and rules are passed in', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsAddToRulesOptions
possibleRules={[getRulesSchemaMock() as Rule]}
isSingleRule={false}
isBulkAction
selectedRadioOption="add_to_rules"
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="addToRulesRadioOption"]').exists()).toBeTruthy();
});
it('it displays rules selection table if "isBulkAction" is "true" and rules are passed in', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsAddToRulesOptions
possibleRules={null}
isSingleRule={false}
isBulkAction={false}
selectedRadioOption="select_rules_to_add_to"
onRuleSelectionChange={jest.fn()}
onRadioChange={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="selectRulesToAddToRadioOption"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,124 @@
/*
* 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 React, { useMemo } from 'react';
import { EuiRadio, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { ExceptionsAddToRulesTable } from '../add_to_rules_table';
export type AddToRuleListsRadioOptions = 'select_rules_to_add_to' | 'add_to_rules' | 'add_to_rule';
interface ExceptionsAddToRulesOptionsComponentProps {
possibleRules: Rule[] | null;
selectedRadioOption: string;
isSingleRule: boolean;
isBulkAction: boolean;
onRuleSelectionChange: (rulesSelectedToAdd: Rule[]) => void;
onRadioChange: (option: AddToRuleListsRadioOptions) => void;
}
const ExceptionsAddToRulesOptionsComponent: React.FC<ExceptionsAddToRulesOptionsComponentProps> = ({
possibleRules,
isSingleRule,
isBulkAction,
selectedRadioOption,
onRuleSelectionChange,
onRadioChange,
}): JSX.Element => {
const ruleRadioOptionProps = useMemo(() => {
if (isBulkAction && possibleRules != null) {
return {
id: 'add_to_rules',
label: (
<EuiText data-test-subj="addToRulesRadioOption">
<FormattedMessage
defaultMessage="Add to [{numRules}] selected rules: {ruleNames}"
id="xpack.securitySolution.exceptions.common.addToRulesOptionLabel"
values={{
numRules: possibleRules.length,
ruleNames: (
<span style={{ fontWeight: 'bold' }}>
{possibleRules.map(({ name }) => name).join(',')}
</span>
),
}}
/>
</EuiText>
),
checked: selectedRadioOption === 'add_to_rules',
'data-test-subj': 'addToRulesOptionsRadio',
onChange: () => {
onRadioChange('add_to_rules');
onRuleSelectionChange(possibleRules);
},
};
}
if (isSingleRule && possibleRules != null) {
return {
id: 'add_to_rule',
label: (
<EuiText data-test-subj="addToRuleRadioOption">
<FormattedMessage
defaultMessage="Add to this rule: {ruleName}"
id="xpack.securitySolution.exceptions.common.addToRuleOptionLabel"
values={{
ruleName: <span style={{ fontWeight: 'bold' }}>{possibleRules[0].name}</span>,
}}
/>
</EuiText>
),
checked: selectedRadioOption === 'add_to_rule',
'data-test-subj': 'addToRuleOptionsRadio',
onChange: () => {
onRadioChange('add_to_rule');
onRuleSelectionChange(possibleRules);
},
};
}
return {
id: 'select_rules_to_add_to',
label: (
<EuiText data-test-subj="selectRulesToAddToRadioOption">
<FormattedMessage
defaultMessage="Add to rules"
id="xpack.securitySolution.exceptions.common.selectRulesOptionLabel"
/>
</EuiText>
),
checked: selectedRadioOption === 'select_rules_to_add_to',
'data-test-subj': 'selectRulesToAddToOptionRadio',
onChange: () => onRadioChange('select_rules_to_add_to'),
};
}, [
isBulkAction,
possibleRules,
isSingleRule,
selectedRadioOption,
onRadioChange,
onRuleSelectionChange,
]);
return (
<>
<EuiRadio {...ruleRadioOptionProps} />
{selectedRadioOption === 'select_rules_to_add_to' && (
<ExceptionsAddToRulesTable
onRuleSelectionChange={onRuleSelectionChange}
initiallySelectedRules={[]}
/>
)}
</>
);
};
export const ExceptionsAddToRulesOptions = React.memo(ExceptionsAddToRulesOptionsComponent);
ExceptionsAddToRulesOptions.displayName = 'ExceptionsAddToRulesOptions';

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.
*/
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ExceptionsAddToRulesTable } from '.';
import { TestProviders } from '../../../../../common/mock';
import { useFindRules } from '../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules';
import { getRulesSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
jest.mock('../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules');
describe('ExceptionsAddToRulesTable', () => {
it('it displays loading state while fetching rules', () => {
(useFindRules as jest.Mock).mockReturnValue({
data: { rules: [], total: 0 },
isFetched: false,
});
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsAddToRulesTable initiallySelectedRules={[]} onRuleSelectionChange={jest.fn()} />
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-loading"]').exists()
).toBeTruthy();
});
it('it displays fetched rules', () => {
(useFindRules as jest.Mock).mockReturnValue({
data: {
rules: [getRulesSchemaMock(), { ...getRulesSchemaMock(), id: '345', name: 'My rule' }],
total: 0,
},
isFetched: true,
});
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsAddToRulesTable
initiallySelectedRules={[{ ...getRulesSchemaMock(), id: '345', name: 'My rule' } as Rule]}
onRuleSelectionChange={jest.fn()}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-loading"]').exists()
).toBeFalsy();
expect(
wrapper.find('.euiTableRow-isSelected td[data-test-subj="ruleNameCell"]').text()
).toEqual('NameMy rule');
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 React, { useEffect, useMemo, useState } from 'react';
import type { CriteriaWithPagination } from '@elastic/eui';
import { EuiSpacer, EuiPanel, EuiText, EuiInMemoryTable, EuiLoadingContent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import * as myI18n from './translations';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { useFindRules } from '../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules';
import { getRulesTableColumn } from '../utils';
interface ExceptionsAddToRulesComponentProps {
initiallySelectedRules?: Rule[];
onRuleSelectionChange?: (rulesSelectedToAdd: Rule[]) => void;
}
const ExceptionsAddToRulesTableComponent: React.FC<ExceptionsAddToRulesComponentProps> = ({
initiallySelectedRules,
onRuleSelectionChange,
}) => {
const { data: { rules } = { rules: [], total: 0 }, isFetched } = useFindRules({
isInMemorySorting: true,
filterOptions: {
filter: '',
showCustomRules: false,
showElasticRules: false,
tags: [],
},
sortingOptions: undefined,
pagination: undefined,
refetchInterval: false,
});
const [pagination, setPagination] = useState({ pageIndex: 0 });
const [message, setMessage] = useState<JSX.Element | string | undefined>(
<EuiLoadingContent lines={4} data-test-subj="exceptionItemViewerEmptyPrompts-loading" />
);
useEffect(() => {
if (!isFetched) {
setMessage(
<EuiLoadingContent lines={4} data-test-subj="exceptionItemViewerEmptyPrompts-loading" />
);
}
if (isFetched) {
setMessage(undefined);
}
}, [setMessage, isFetched]);
const ruleSelectionValue = {
onSelectionChange: (selection: Rule[]) => {
if (onRuleSelectionChange != null) {
onRuleSelectionChange(selection);
}
},
initialSelected: initiallySelectedRules ?? [],
};
const searchOptions = useMemo(
() => ({
box: {
incremental: true,
},
filters: [
{
type: 'field_value_selection' as const,
field: 'tags',
name: i18n.translate(
'xpack.securitySolution.exceptions.addToRulesTable.tagsFilterLabel',
{
defaultMessage: 'Tags',
}
),
multiSelect: 'or' as const,
options: rules.flatMap(({ tags }) => {
return tags.map((tag) => ({
value: tag,
name: tag,
}));
}),
},
],
}),
[rules]
);
return (
<EuiPanel color="subdued" borderRadius="none" hasShadow={false}>
<>
<EuiText size="s">{myI18n.ADD_TO_SELECTED_RULES_DESCRIPTION}</EuiText>
<EuiSpacer size="s" />
<EuiInMemoryTable<Rule>
tableCaption="Rules table"
itemId="id"
items={rules}
loading={!isFetched}
columns={getRulesTableColumn()}
pagination={{
...pagination,
itemsPerPage: 5,
showPerPageOptions: false,
}}
message={message}
onTableChange={({ page: { index } }: CriteriaWithPagination<never>) =>
setPagination({ pageIndex: index })
}
selection={ruleSelectionValue}
search={searchOptions}
sorting
isSelectable
data-test-subj="addExceptionToRulesTable"
/>
</>
</EuiPanel>
);
};
export const ExceptionsAddToRulesTable = React.memo(ExceptionsAddToRulesTableComponent);
ExceptionsAddToRulesTable.displayName = 'ExceptionsAddToRulesTable';

View file

@ -0,0 +1,16 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ADD_TO_SELECTED_RULES_DESCRIPTION = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.addToRulesTableSelection.addToSelectedRulesDescription',
{
defaultMessage:
'Select rules add to. We will make a copy of this exception if it links to multiple rules. ',
}
);

View file

@ -0,0 +1,202 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionItemsFlyoutAlertsActions } from '.';
import { TestProviders } from '../../../../../common/mock';
import type { AlertData } from '../../../utils/types';
jest.mock('../../../../../common/lib/kibana');
const alertDataMock: AlertData = {
'@timestamp': '1234567890',
_id: 'test-id',
file: { path: 'test/path' },
};
describe('ExceptionItemsFlyoutAlertsActions', () => {
it('it displays single alert close checkbox if alert status is not "closed" and "alertData" exists', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.DETECTION}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={false}
alertData={alertDataMock}
alertStatus="open"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading={false}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists()
).toBeTruthy();
});
it('it does not display single alert close checkbox if alert status is "closed"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.DETECTION}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={false}
alertData={alertDataMock}
alertStatus="closed"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading={false}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists()
).toBeFalsy();
});
it('it does not display single alert close checkbox if "alertData" does not exist', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.DETECTION}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={false}
alertData={undefined}
alertStatus="open"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading={false}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists()
).toBeFalsy();
});
it('it displays bulk close checkbox', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.DETECTION}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={false}
alertData={alertDataMock}
alertStatus="open"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading={false}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').exists()
).toBeTruthy();
});
it('it displays checkboxes disabled if "isAlertDataLoading" is "true"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.DETECTION}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={false}
alertData={alertDataMock}
alertStatus="open"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').at(0).props().disabled
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').at(0).props().disabled
).toBeTruthy();
});
it('it displays bulk close checkbox disabled if "disableBulkCloseAlert" is "true"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.DETECTION}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={true}
alertData={alertDataMock}
alertStatus="open"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading={false}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').at(0).props().disabled
).toBeTruthy();
expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy();
});
it('it displays endpoint quarantine text if exception list type is "endpoint"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemsFlyoutAlertsActions
exceptionListItems={[getExceptionListItemSchemaMock()]}
exceptionListType={ExceptionListTypeEnum.ENDPOINT}
shouldCloseSingleAlert={false}
shouldBulkCloseAlert={false}
disableBulkClose={false}
alertData={alertDataMock}
alertStatus="open"
onDisableBulkClose={jest.fn()}
onUpdateBulkCloseIndex={jest.fn()}
onBulkCloseCheckboxChange={jest.fn()}
onSingleAlertCloseCheckboxChange={jest.fn()}
isAlertDataLoading={false}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,168 @@
/*
* 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 React, { useCallback, useEffect, useMemo } from 'react';
import styled, { css } from 'styled-components';
import { EuiTitle, EuiFormRow, EuiCheckbox, EuiSpacer, EuiText } from '@elastic/eui';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils';
import { useSignalIndex } from '../../../../../detections/containers/detection_engine/alerts/use_signal_index';
import type { Status } from '../../../../../../common/detection_engine/schemas/common/schemas';
import { useFetchIndex } from '../../../../../common/containers/source';
import { entryHasListType, entryHasNonEcsType } from './utils';
import * as i18n from './translations';
import type { AlertData } from '../../../utils/types';
const FlyoutCheckboxesSection = styled.section`
overflow-y: inherit;
height: auto;
.euiFlyoutBody__overflowContent {
padding-top: 0;
}
`;
const SectionHeader = styled(EuiTitle)`
${() => css`
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
`}
`;
interface ExceptionsFlyoutAlertsActionsComponentProps {
exceptionListItems: ExceptionsBuilderReturnExceptionItem[];
exceptionListType: ExceptionListType;
shouldBulkCloseAlert: boolean;
disableBulkClose: boolean;
alertData?: AlertData;
alertStatus?: Status;
isAlertDataLoading?: boolean;
shouldCloseSingleAlert?: boolean;
onUpdateBulkCloseIndex: (arg: string[] | undefined) => void;
onBulkCloseCheckboxChange: (arg: boolean) => void;
onSingleAlertCloseCheckboxChange?: (arg: boolean) => void;
onDisableBulkClose: (arg: boolean) => void;
}
const ExceptionItemsFlyoutAlertsActionsComponent: React.FC<
ExceptionsFlyoutAlertsActionsComponentProps
> = ({
isAlertDataLoading,
exceptionListItems,
exceptionListType,
shouldCloseSingleAlert,
shouldBulkCloseAlert,
disableBulkClose,
alertData,
alertStatus,
onDisableBulkClose,
onUpdateBulkCloseIndex,
onBulkCloseCheckboxChange,
onSingleAlertCloseCheckboxChange,
}): JSX.Element => {
const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex();
const memoSignalIndexName = useMemo(
() => (signalIndexName !== null ? [signalIndexName] : []),
[signalIndexName]
);
const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] =
useFetchIndex(memoSignalIndexName);
const handleBulkCloseCheckbox = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
onBulkCloseCheckboxChange(event.currentTarget.checked);
},
[onBulkCloseCheckboxChange]
);
const handleCloseSingleAlertCheckbox = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
if (onSingleAlertCloseCheckboxChange != null) {
onSingleAlertCloseCheckboxChange(event.currentTarget.checked);
}
},
[onSingleAlertCloseCheckboxChange]
);
useEffect(() => {
onUpdateBulkCloseIndex(
shouldBulkCloseAlert && memoSignalIndexName != null ? memoSignalIndexName : undefined
);
}, [memoSignalIndexName, onUpdateBulkCloseIndex, shouldBulkCloseAlert]);
useEffect((): void => {
if (disableBulkClose === true) {
onBulkCloseCheckboxChange(false);
}
}, [disableBulkClose, onBulkCloseCheckboxChange]);
useEffect((): void => {
if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) {
onDisableBulkClose(
entryHasListType(exceptionListItems) ||
entryHasNonEcsType(exceptionListItems, signalIndexPatterns) ||
exceptionListItems.every((item) => item.entries.length === 0)
);
}
}, [
onDisableBulkClose,
exceptionListItems,
isSignalIndexPatternLoading,
isSignalIndexLoading,
signalIndexPatterns,
]);
return (
<FlyoutCheckboxesSection>
<SectionHeader size="xs">
<h3>{i18n.CLOSE_ALERTS_SECTION_TITLE}</h3>
</SectionHeader>
<EuiSpacer size="s" />
{alertData != null && alertStatus !== 'closed' && (
<EuiFormRow fullWidth>
<EuiCheckbox
data-test-subj="closeAlertOnAddExceptionCheckbox"
id="close-alert-on-add-add-exception-checkbox"
label={i18n.SINGLE_ALERT_CLOSE_LABEL}
checked={shouldCloseSingleAlert}
onChange={handleCloseSingleAlertCheckbox}
disabled={isSignalIndexLoading || isSignalIndexPatternLoading || isAlertDataLoading}
/>
</EuiFormRow>
)}
<EuiFormRow fullWidth>
<EuiCheckbox
data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"
id="bulk-close-alert-on-add-add-exception-checkbox"
label={disableBulkClose ? i18n.BULK_CLOSE_LABEL_DISABLED : i18n.BULK_CLOSE_LABEL}
checked={shouldBulkCloseAlert}
onChange={handleBulkCloseCheckbox}
disabled={
disableBulkClose ||
isSignalIndexLoading ||
isSignalIndexPatternLoading ||
isAlertDataLoading
}
/>
</EuiFormRow>
{exceptionListType === 'endpoint' && (
<>
<EuiSpacer size="s" />
<EuiText data-test-subj="addExceptionEndpointText" color="subdued" size="s">
{i18n.ENDPOINT_QUARANTINE_TEXT}
</EuiText>
</>
)}
</FlyoutCheckboxesSection>
);
};
export const ExceptionItemsFlyoutAlertsActions = React.memo(
ExceptionItemsFlyoutAlertsActionsComponent
);
ExceptionItemsFlyoutAlertsActions.displayName = 'ExceptionItemsFlyoutAlertsActions';

View file

@ -0,0 +1,46 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CLOSE_ALERTS_SECTION_TITLE = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.alertsActions.sectionTitle',
{
defaultMessage: 'Alerts actions',
}
);
export const SINGLE_ALERT_CLOSE_LABEL = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.alertsActions.singleAlertCloseLabel',
{
defaultMessage: 'Close this alert',
}
);
export const BULK_CLOSE_LABEL_DISABLED = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.alertsActions.bulkCloseLabel.disabled',
{
defaultMessage:
'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)',
}
);
export const BULK_CLOSE_LABEL = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.alertsActions.bulkCloseLabel',
{
defaultMessage:
'Close all alerts that match this exception and were generated by selected rule/s',
}
);
export const ENDPOINT_QUARANTINE_TEXT = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.alertsActions.endpointQuarantineText',
{
defaultMessage:
'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.',
}
);

View file

@ -0,0 +1,81 @@
/*
* 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 { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import type { EntriesArray, ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { entryHasNonEcsType, entryHasListType } from './utils';
import type { DataViewBase } from '@kbn/es-query';
describe('alerts_actions#utils', () => {
describe('#entryHasListType', () => {
test('it should return false with an empty array', () => {
const payload: ExceptionListItemSchema[] = [];
const result = entryHasListType(payload);
expect(result).toEqual(false);
});
test("it should return false with exception items that don't contain a list type", () => {
const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()];
const result = entryHasListType(payload);
expect(result).toEqual(false);
});
test('it should return true with exception items that do contain a list type', () => {
const payload = [
{
...getExceptionListItemSchemaMock(),
entries: [{ type: ListOperatorTypeEnum.LIST }] as EntriesArray,
},
getExceptionListItemSchemaMock(),
];
const result = entryHasListType(payload);
expect(result).toEqual(true);
});
});
describe('#entryHasNonEcsType', () => {
const mockEcsIndexPattern = {
title: 'testIndex',
fields: [
{
name: 'some.parentField',
},
{
name: 'some.not.nested.field',
},
{
name: 'nested.field',
},
],
} as DataViewBase;
test('it should return false with an empty array', () => {
const payload: ExceptionListItemSchema[] = [];
const result = entryHasNonEcsType(payload, mockEcsIndexPattern);
expect(result).toEqual(false);
});
test("it should return false with exception items that don't contain a non ecs type", () => {
const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()];
const result = entryHasNonEcsType(payload, mockEcsIndexPattern);
expect(result).toEqual(false);
});
test('it should return true with exception items that do contain a non ecs type', () => {
const payload = [
{
...getExceptionListItemSchemaMock(),
entries: [{ field: 'some.nonEcsField' }] as EntriesArray,
},
getExceptionListItemSchemaMock(),
];
const result = entryHasNonEcsType(payload, mockEcsIndexPattern);
expect(result).toEqual(true);
});
});
});

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.
*/
import type { DataViewBase } from '@kbn/es-query';
import type { Entry } from '@kbn/securitysolution-io-ts-list-types';
import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type {
EmptyEntry,
EmptyListEntry,
ExceptionsBuilderReturnExceptionItem,
} from '@kbn/securitysolution-list-utils';
import { getOperatorType } from '@kbn/securitysolution-list-utils';
/**
* Determines if item entries has 'is in list'/'is not in list' entry
*/
export const entryHasListType = (exceptionItems: ExceptionsBuilderReturnExceptionItem[]) => {
for (const { entries } of exceptionItems) {
for (const exceptionEntry of entries ?? []) {
if (getOperatorType(exceptionEntry) === ListOperatorTypeEnum.LIST) {
return true;
}
}
}
return false;
};
/**
* Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping
*/
export const entryHasNonEcsType = (
exceptionItems: ExceptionsBuilderReturnExceptionItem[],
indexPatterns: DataViewBase
): boolean => {
const doesFieldNameExist = (exceptionEntry: Entry | EmptyListEntry | EmptyEntry): boolean => {
return indexPatterns.fields.some(({ name }) => name === exceptionEntry.field);
};
if (exceptionItems.length === 0) {
return false;
}
for (const { entries } of exceptionItems) {
for (const exceptionEntry of entries ?? []) {
if (exceptionEntry.type === 'nested') {
for (const nestedExceptionEntry of exceptionEntry.entries) {
if (doesFieldNameExist(nestedExceptionEntry) === false) {
return true;
}
}
} else if (doesFieldNameExist(exceptionEntry) === false) {
return true;
}
}
}
return false;
};

View file

@ -0,0 +1,207 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ExceptionsConditions } from '.';
import { TestProviders, mockIndexPattern } from '../../../../../common/mock';
import { getRulesEqlSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import * as i18n from './translations';
jest.mock('@kbn/lists-plugin/public');
describe('ExceptionsConditions', () => {
describe('EQL rule type', () => {
it('it displays EQL warning callout if rule is EQL sequence', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsConditions
exceptionItemName={'Item name'}
allowLargeValueLists={false}
exceptionListItems={[]}
indexPatterns={mockIndexPattern}
rules={[
{
...getRulesEqlSchemaMock(),
query: 'sequence [process where process.name = "test.exe"]',
} as Rule,
]}
showOsTypeOptions={false}
isEdit={false}
selectedOs={undefined}
exceptionListType={ExceptionListTypeEnum.DETECTION}
onOsChange={jest.fn()}
onExceptionItemAdd={jest.fn()}
onSetErrorExists={jest.fn()}
onFilterIndexPatterns={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').at(0).text()).toEqual(
i18n.ADD_EXCEPTION_SEQUENCE_WARNING
);
});
it('it displays EQL editing warning callout if rule is EQL sequence and "isEdit" is "true"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsConditions
exceptionItemName={'Item name'}
allowLargeValueLists={false}
exceptionListItems={[getExceptionListItemSchemaMock()]}
indexPatterns={mockIndexPattern}
rules={[
{
...getRulesEqlSchemaMock(),
query: 'sequence [process where process.name = "test.exe"]',
} as Rule,
]}
showOsTypeOptions={false}
isEdit
selectedOs={undefined}
exceptionListType={ExceptionListTypeEnum.RULE_DEFAULT}
onOsChange={jest.fn()}
onExceptionItemAdd={jest.fn()}
onSetErrorExists={jest.fn()}
onFilterIndexPatterns={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').at(0).text()).toEqual(
i18n.EDIT_EXCEPTION_SEQUENCE_WARNING
);
});
it('it does not display EQL warning callout if rule is EQL sequence', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsConditions
exceptionItemName={'Item name'}
allowLargeValueLists={false}
exceptionListItems={[]}
indexPatterns={mockIndexPattern}
rules={[
{
...getRulesEqlSchemaMock(),
} as Rule,
]}
showOsTypeOptions={false}
isEdit={false}
selectedOs={undefined}
exceptionListType={ExceptionListTypeEnum.DETECTION}
onOsChange={jest.fn()}
onExceptionItemAdd={jest.fn()}
onSetErrorExists={jest.fn()}
onFilterIndexPatterns={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy();
});
});
describe('OS options', () => {
it('it displays os options if "showOsTypeOptions" is "true"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsConditions
exceptionItemName={'Item name'}
allowLargeValueLists={false}
exceptionListItems={[]}
indexPatterns={mockIndexPattern}
rules={[
{
...getRulesEqlSchemaMock(),
} as Rule,
]}
showOsTypeOptions
isEdit={false}
selectedOs={undefined}
exceptionListType={ExceptionListTypeEnum.ENDPOINT}
onOsChange={jest.fn()}
onExceptionItemAdd={jest.fn()}
onSetErrorExists={jest.fn()}
onFilterIndexPatterns={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeTruthy();
});
it('it displays the exception item os text if "isEdit" is "true"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsConditions
exceptionItemName={'Item name'}
allowLargeValueLists={false}
exceptionListItems={[
{
...getExceptionListItemSchemaMock(),
os_types: ['windows'],
},
]}
indexPatterns={mockIndexPattern}
rules={[
{
...getRulesEqlSchemaMock(),
} as Rule,
]}
showOsTypeOptions
isEdit
exceptionListType={ExceptionListTypeEnum.ENDPOINT}
selectedOs={undefined}
onOsChange={jest.fn()}
onExceptionItemAdd={jest.fn()}
onSetErrorExists={jest.fn()}
onFilterIndexPatterns={jest.fn()}
/>
</TestProviders>
);
// Text appears funky since not applying styling
expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').at(0).text()).toEqual(
'Operating SystemWindows'
);
});
it('it does not display os options if "showOsTypeOptions" is "false"', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsConditions
exceptionItemName={'Item name'}
allowLargeValueLists={false}
exceptionListItems={[]}
indexPatterns={mockIndexPattern}
rules={[
{
...getRulesEqlSchemaMock(),
} as Rule,
]}
showOsTypeOptions={false}
isEdit={false}
selectedOs={undefined}
exceptionListType={ExceptionListTypeEnum.ENDPOINT}
onOsChange={jest.fn()}
onExceptionItemAdd={jest.fn()}
onSetErrorExists={jest.fn()}
onFilterIndexPatterns={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy();
});
});
});

View file

@ -0,0 +1,276 @@
/*
* 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 React, { useCallback, useMemo } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiCallOut, EuiComboBox, EuiFormRow, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
import type {
CreateRuleExceptionListItemSchema,
CreateExceptionListItemSchema,
ExceptionListItemSchema,
ExceptionListType,
OsType,
OsTypeArray,
NamespaceType,
} from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type {
ExceptionsBuilderExceptionItem,
ExceptionsBuilderReturnExceptionItem,
} from '@kbn/securitysolution-list-utils';
import type { DataViewBase } from '@kbn/es-query';
import styled, { css } from 'styled-components';
import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
import { hasEqlSequenceQuery, isEqlRule } from '../../../../../../common/detection_engine/utils';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { useKibana } from '../../../../../common/lib/kibana';
import * as i18n from './translations';
import * as sharedI18n from '../../../utils/translations';
const OS_OPTIONS: Array<EuiComboBoxOptionOption<OsTypeArray>> = [
{
label: sharedI18n.OPERATING_SYSTEM_WINDOWS,
value: ['windows'],
},
{
label: sharedI18n.OPERATING_SYSTEM_MAC,
value: ['macos'],
},
{
label: sharedI18n.OPERATING_SYSTEM_LINUX,
value: ['linux'],
},
{
label: sharedI18n.OPERATING_SYSTEM_WINDOWS_AND_MAC,
value: ['windows', 'macos'],
},
];
const SectionHeader = styled(EuiTitle)`
${() => css`
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
`}
`;
interface ExceptionsFlyoutConditionsComponentProps {
/* Exception list item field value for "name" */
exceptionItemName: string;
/* Not all rule types support large value lists */
allowLargeValueLists: boolean;
/* Exception items - could be one being edited, or multiple being added */
exceptionListItems: ExceptionsBuilderExceptionItem[];
/* Fields used to populate the field option dropdown */
indexPatterns: DataViewBase;
/* Exception items can be added to zero (just being added to a shared list), one or more rules */
rules: Rule[] | null;
/* OS options required for endpoint exceptions */
showOsTypeOptions: boolean;
/* Selected OS option required for endpoint exceptions */
selectedOs: OsTypeArray | undefined;
/* Determines whether component is being used in an add or edit functionality */
isEdit: boolean;
/*
* Supported exception list types are 'endpoint', 'detection' and 'rule_default' */
exceptionListType: ExceptionListType;
/* OS selection handler */
onOsChange?: (os: OsTypeArray | undefined) => void;
/* Exception item builder takes a callback used when there are updates to the item */
onExceptionItemAdd: (items: ExceptionsBuilderReturnExceptionItem[]) => void;
/* Exception item builder takes a callback used when there are updates to the item that includes information on if any form errors exist */
onSetErrorExists: (errorExists: boolean) => void;
onFilterIndexPatterns: (
patterns: DataViewBase,
type: ExceptionListType,
osTypes?: Array<'linux' | 'macos' | 'windows'> | undefined
) => DataViewBase;
}
const ExceptionsConditionsComponent: React.FC<ExceptionsFlyoutConditionsComponentProps> = ({
exceptionItemName,
allowLargeValueLists,
exceptionListItems,
indexPatterns,
rules,
exceptionListType,
showOsTypeOptions,
selectedOs,
isEdit,
onOsChange,
onExceptionItemAdd,
onSetErrorExists,
onFilterIndexPatterns,
}): JSX.Element => {
const { http, unifiedSearch } = useKibana().services;
const isEndpointException = useMemo(
(): boolean => exceptionListType === ExceptionListTypeEnum.ENDPOINT,
[exceptionListType]
);
const includesRuleWithEQLSequenceStatement = useMemo((): boolean => {
return (
rules != null && rules.some((rule) => isEqlRule(rule.type) && hasEqlSequenceQuery(rule.query))
);
}, [rules]);
// If editing an item (can only edit a single item at a time), get it's
// list_id. Otherwise, if it is an item for an endpoint list, the list_id is
// hard coded. For all others, we'll fill in the appropriate list_id when
// enriching items before creating when we know if they'll be added to the rule_default
// list or a shared list.
const listIdToUse = useMemo((): string | undefined => {
if (isEndpointException) {
return 'endpoint_list';
}
const defaultValue = isEndpointException ? ENDPOINT_LIST_ID : undefined;
return isEdit ? exceptionListItems[0].list_id : defaultValue;
}, [isEndpointException, isEdit, exceptionListItems]);
// If editing an item (can only edit a single item at a time), get it's
// namespace_type. Otherwise, if it is an item for an endpoint list, the namespace_type is
// 'agnostic'. For all others, we'll fill in the appropriate list_id when
// enriching items before creating when we know if they'll be added to the rule_default
// list or a shared list.
const listNamespaceType = useMemo((): NamespaceType | undefined => {
const defaultValue = isEndpointException ? 'agnostic' : undefined;
return isEdit ? exceptionListItems[0].namespace_type : defaultValue;
}, [exceptionListItems, isEdit, isEndpointException]);
const handleBuilderOnChange = useCallback(
({
exceptionItems,
errorExists,
}: {
exceptionItems: Array<
ExceptionListItemSchema | CreateExceptionListItemSchema | CreateRuleExceptionListItemSchema
>;
errorExists: boolean;
}) => {
onExceptionItemAdd(exceptionItems);
onSetErrorExists(errorExists);
},
[onSetErrorExists, onExceptionItemAdd]
);
const handleOSSelectionChange = useCallback(
(selectedOptions: Array<EuiComboBoxOptionOption<OsTypeArray>>): void => {
const os = selectedOptions[0].value;
if (onOsChange != null) {
onOsChange(os ? os : undefined);
}
},
[onOsChange]
);
const osSingleSelectionOptions = useMemo(() => {
return { asPlainText: true };
}, []);
const selectedOStoOptions = useMemo((): Array<EuiComboBoxOptionOption<OsTypeArray>> => {
return OS_OPTIONS.filter((option) => {
return selectedOs === option.value;
});
}, [selectedOs]);
const isExceptionBuilderFormDisabled = useMemo(() => {
return showOsTypeOptions && selectedOs === undefined;
}, [showOsTypeOptions, selectedOs]);
const osDisplay = (osTypes: OsTypeArray): string => {
const translateOS = (currentOs: OsType): string => {
return currentOs === 'linux'
? sharedI18n.OPERATING_SYSTEM_LINUX
: currentOs === 'macos'
? sharedI18n.OPERATING_SYSTEM_MAC
: sharedI18n.OPERATING_SYSTEM_WINDOWS;
};
return osTypes
.reduce((osString, currentOs) => {
return `${translateOS(currentOs)}, ${osString}`;
}, '')
.slice(0, -2);
};
const eqlCalloutWarning = useMemo((): string => {
return isEdit ? i18n.EDIT_EXCEPTION_SEQUENCE_WARNING : i18n.ADD_EXCEPTION_SEQUENCE_WARNING;
}, [isEdit]);
const osTypes = useMemo((): OsTypeArray => {
return (isEdit ? exceptionListItems[0].os_types : selectedOs) ?? [];
}, [exceptionListItems, isEdit, selectedOs]);
return (
<>
<SectionHeader size="xs">
<h3>{i18n.RULE_EXCEPTION_CONDITIONS}</h3>
</SectionHeader>
{includesRuleWithEQLSequenceStatement && (
<>
<EuiCallOut data-test-subj="eqlSequenceCallout" title={eqlCalloutWarning} />
<EuiSpacer />
</>
)}
<EuiSpacer size="s" />
<EuiText size="s">{i18n.EXCEPTION_BUILDER_INFO}</EuiText>
<EuiSpacer size="s" />
{showOsTypeOptions && !isEdit && (
<>
<EuiFormRow label={sharedI18n.OPERATING_SYSTEM_LABEL}>
<EuiComboBox
placeholder={i18n.OPERATING_SYSTEM_PLACEHOLDER}
singleSelection={osSingleSelectionOptions}
options={OS_OPTIONS}
selectedOptions={selectedOStoOptions}
onChange={handleOSSelectionChange}
isClearable={false}
data-test-subj="osSelectionDropdown"
/>
</EuiFormRow>
<EuiSpacer size="l" />
</>
)}
{showOsTypeOptions && isEdit && (
<>
<EuiText size="xs" data-test-subj="exceptionItemSelectedOs">
<dl>
<dt>{sharedI18n.OPERATING_SYSTEM_LABEL}</dt>
<dd>{osDisplay(osTypes)}</dd>
</dl>
</EuiText>
<EuiSpacer />
</>
)}
{getExceptionBuilderComponentLazy({
allowLargeValueLists,
httpService: http,
autocompleteService: unifiedSearch.autocomplete,
exceptionListItems,
listType: exceptionListType,
osTypes,
listId: listIdToUse,
listNamespaceType,
listTypeSpecificIndexPatternFilter: onFilterIndexPatterns,
exceptionItemName,
indexPatterns,
isOrDisabled: isExceptionBuilderFormDisabled,
isAndDisabled: isExceptionBuilderFormDisabled,
isNestedDisabled: isExceptionBuilderFormDisabled,
dataTestSubj: 'alertExceptionBuilder',
idAria: 'alertExceptionBuilder',
onChange: handleBuilderOnChange,
isDisabled: isExceptionBuilderFormDisabled,
})}
</>
);
};
export const ExceptionsConditions = React.memo(ExceptionsConditionsComponent);
ExceptionsConditions.displayName = 'ExceptionsConditions';

View file

@ -0,0 +1,46 @@
/*
* 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 { i18n } from '@kbn/i18n';
// Conditions component
export const RULE_EXCEPTION_CONDITIONS = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.itemConditions.conditionsTitle',
{
defaultMessage: 'Conditions',
}
);
export const EXCEPTION_BUILDER_INFO = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.itemConditions.infoLabel',
{
defaultMessage: "Alerts are generated when the rule's conditions are met, except when:",
}
);
export const ADD_EXCEPTION_SEQUENCE_WARNING = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.itemConditions.sequenceWarningAdd',
{
defaultMessage:
"This rule's query contains an EQL sequence statement. The exception created will apply to all events in the sequence.",
}
);
export const EDIT_EXCEPTION_SEQUENCE_WARNING = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.itemConditions.sequenceWarningEdit',
{
defaultMessage:
"This rule's query contains an EQL sequence statement. The exception modified will apply to all events in the sequence.",
}
);
export const OPERATING_SYSTEM_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.itemConditions.operatingSystemPlaceHolder',
{
defaultMessage: 'Select an operating system',
}
);

View file

@ -0,0 +1,45 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { EuiFieldText } from '@elastic/eui';
import { ExceptionsFlyoutMeta } from '.';
import { TestProviders } from '../../../../../common/mock';
describe('ExceptionsFlyoutMeta', () => {
it('it renders component', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsFlyoutMeta exceptionItemName={'Test name'} onChange={jest.fn()} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionFlyoutName"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').at(1).props().value).toEqual(
'Test name'
);
});
it('it calls onChange on name change', () => {
const mockOnChange = jest.fn();
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsFlyoutMeta exceptionItemName={''} onChange={mockOnChange} />
</TestProviders>
);
(
wrapper.find(EuiFieldText).at(0).props() as unknown as {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
).onChange({ target: { value: 'Name change' } } as React.ChangeEvent<HTMLInputElement>);
expect(mockOnChange).toHaveBeenCalledWith(['name', 'Name change']);
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 React, { useCallback } from 'react';
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
import * as i18n from './translations';
interface ExceptionsFlyoutMetaComponentProps {
exceptionItemName: string;
onChange: (value: [string, string]) => void;
}
const ExceptionsFlyoutMetaComponent: React.FC<ExceptionsFlyoutMetaComponentProps> = ({
exceptionItemName,
onChange,
}): JSX.Element => {
const onNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(['name', e.target.value]);
},
[onChange]
);
return (
<EuiFormRow label={i18n.RULE_EXCEPTION_NAME_LABEL} data-test-subj="exceptionFlyoutName">
<EuiFieldText
placeholder={i18n.RULE_EXCEPTION_NAME_PLACEHOLDER}
value={exceptionItemName}
onChange={onNameChange}
data-test-subj="exceptionFlyoutNameInput"
/>
</EuiFormRow>
);
};
export const ExceptionsFlyoutMeta = React.memo(ExceptionsFlyoutMetaComponent);
ExceptionsFlyoutMeta.displayName = 'ExceptionsFlyoutMeta';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const RULE_EXCEPTION_NAME_LABEL = i18n.translate(
'xpack.securitySolution.rule_exceptions.itemMeta.nameLabel',
{
defaultMessage: 'Rule exception name',
}
);
export const RULE_EXCEPTION_NAME_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.rule_exceptions.itemMeta.namePlaceholder',
{
defaultMessage: 'Name your rule exception',
}
);

View file

@ -0,0 +1,92 @@
/*
* 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 React from 'react';
import { ExceptionsLinkedToLists } from '.';
import { TestProviders } from '../../../../../common/mock';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { mount } from 'enzyme';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
jest.mock('../../../logic/use_find_references');
describe('ExceptionsLinkedToLists', () => {
it('it displays loading state while "isLoadingReferences" is "true"', () => {
const wrapper = mount(
<TestProviders>
<ExceptionsLinkedToLists
isLoadingReferences={true}
errorFetchingReferences={false}
listAndReferences={[]}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionItemListsTableLoading"]').exists()).toBeTruthy();
});
it('it displays error state if "errorFetchingReferences" is "true"', () => {
const wrapper = mount(
<TestProviders>
<ExceptionsLinkedToLists
isLoadingReferences={false}
errorFetchingReferences
listAndReferences={[]}
/>
</TestProviders>
);
expect(wrapper.find('EuiInMemoryTable').prop('error')).toEqual(
'Unable to fetch exception list.'
);
});
it('it displays lists with rule references', async () => {
const wrapper = mount(
<TestProviders>
<ExceptionsLinkedToLists
isLoadingReferences={false}
errorFetchingReferences={false}
listAndReferences={[
{
...getExceptionListSchemaMock(),
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
name: 'My exception list',
referenced_rules: [
{
id: '345',
name: 'My rule',
rule_id: 'my_rule_id',
exception_lists: [
{
id: '123',
list_id: 'my_list_id',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
},
],
},
],
},
]}
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="ruleReferencesDisplayPopoverButton"]').at(1).text()
).toEqual('1');
// Formatting is off since doesn't take css into account
expect(wrapper.find('[data-test-subj="exceptionListNameCell"]').at(1).text()).toEqual(
'NameMy exception list'
);
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { EuiTitle, EuiSpacer, EuiPanel, EuiInMemoryTable, EuiLoadingContent } from '@elastic/eui';
import styled, { css } from 'styled-components';
import * as i18n from './translations';
import type { ExceptionListRuleReferencesSchema } from '../../../../../../common/detection_engine/schemas/response';
import { getSharedListsTableColumns } from '../utils';
interface ExceptionsLinkedToListComponentProps {
isLoadingReferences: boolean;
errorFetchingReferences: boolean;
listAndReferences: ExceptionListRuleReferencesSchema[];
}
const SectionHeader = styled(EuiTitle)`
${() => css`
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
`}
`;
const ExceptionsLinkedToListsComponent: React.FC<ExceptionsLinkedToListComponentProps> = ({
isLoadingReferences,
errorFetchingReferences,
listAndReferences,
}): JSX.Element => {
const [message, setMessage] = useState<JSX.Element | string | undefined>(
<EuiLoadingContent lines={4} data-test-subj="exceptionItemListsTableLoading" />
);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
if (errorFetchingReferences) {
setError(i18n.LINKED_TO_LIST_ERROR);
} else if (!isLoadingReferences) {
setMessage(undefined);
}
}, [errorFetchingReferences, isLoadingReferences]);
return (
<EuiPanel
paddingSize="none"
hasShadow={false}
data-test-subj="exceptionItemLinkedToListSection"
>
<SectionHeader size="xs">
<h3>{i18n.LINKED_TO_LIST_TITLE}</h3>
</SectionHeader>
<EuiSpacer size="s" />
<EuiInMemoryTable<ExceptionListRuleReferencesSchema>
tableCaption="Table of exception lists"
itemId="id"
message={message}
loading={isLoadingReferences}
items={listAndReferences}
error={error}
columns={getSharedListsTableColumns()}
isSelectable={false}
sorting
data-test-subj="exceptionItemSharedList"
/>
</EuiPanel>
);
};
export const ExceptionsLinkedToLists = React.memo(ExceptionsLinkedToListsComponent);
ExceptionsLinkedToLists.displayName = 'ExceptionsLinkedToLists';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const LINKED_TO_LIST_TITLE = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.linkedToListSection.title',
{
defaultMessage: 'Linked to shared list',
}
);
export const LINKED_TO_LIST_ERROR = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.linkedToListSection.error',
{
defaultMessage: 'Unable to fetch exception list.',
}
);

View file

@ -0,0 +1,31 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ExceptionsLinkedToRule } from '.';
import { TestProviders } from '../../../../../common/mock';
import { getRulesSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
jest.mock('../../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules');
describe('ExceptionsLinkedToRule', () => {
it('it displays rule name and link', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionsLinkedToRule
rule={{ ...getRulesSchemaMock(), id: '345', name: 'My rule' } as Rule}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="ruleNameCell"]').at(0).text()).toEqual('NameMy rule');
expect(wrapper.find('[data-test-subj="ruleAction-viewDetails"]').exists()).toBeTruthy();
});
});

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiTitle, EuiSpacer, EuiInMemoryTable } from '@elastic/eui';
import styled, { css } from 'styled-components';
import * as i18n from './translations';
import type { Rule } from '../../../../../detections/containers/detection_engine/rules/types';
import { getRulesTableColumn } from '../utils';
interface ExceptionsLinkedToRuleComponentProps {
rule: Rule;
}
const SectionHeader = styled(EuiTitle)`
${() => css`
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
`}
`;
const ExceptionsLinkedToRuleComponent: React.FC<ExceptionsLinkedToRuleComponentProps> = ({
rule,
}): JSX.Element => {
return (
<>
<SectionHeader size="xs" data-test-subj="exceptionItemLinkedToRuleSection">
<h3>{i18n.LINKED_TO_RULE_TITLE}</h3>
</SectionHeader>
<EuiSpacer size="s" />
<EuiInMemoryTable<Rule>
tableCaption="Rules table"
itemId="id"
items={[rule]}
columns={getRulesTableColumn()}
sorting
data-test-subj="addExceptionToRulesTable"
/>
</>
);
};
export const ExceptionsLinkedToRule = React.memo(ExceptionsLinkedToRuleComponent);
ExceptionsLinkedToRule.displayName = 'ExceptionsLinkedToRule';

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const LINKED_TO_RULE_TITLE = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.linkedToRule.title',
{
defaultMessage: 'Linked to rule',
}
);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const VIEW_LIST_DETAIL_ACTION = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.viewListDetailActionLabel',
{
defaultMessage: 'View list detail',
}
);
export const VIEW_RULE_DETAIL_ACTION = i18n.translate(
'xpack.securitySolution.rule_exceptions.flyoutComponents.viewRuleDetailActionLabel',
{
defaultMessage: 'View rule detail',
}
);

View file

@ -0,0 +1,284 @@
/*
* 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 { getCreateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils';
import {
enrichItemWithComment,
enrichItemWithName,
enrichEndpointItems,
enrichItemsForDefaultRuleList,
enrichItemsForSharedLists,
entrichNewExceptionItems,
} from './utils';
const getExceptionItems = (): ExceptionsBuilderReturnExceptionItem[] => [
{ ...getCreateExceptionListItemSchemaMock(), os_types: [] },
];
describe('add_exception_flyout#utils', () => {
describe('entrichNewExceptionItems', () => {
it('enriches exception items for rule default list', () => {
const items = getExceptionItems();
expect(
entrichNewExceptionItems({
itemName: 'My item',
commentToAdd: 'New comment',
addToRules: true,
addToSharedLists: false,
sharedLists: [],
selectedOs: [],
listType: ExceptionListTypeEnum.RULE_DEFAULT,
items,
})
).toEqual([
{
...items[0],
comments: [{ comment: 'New comment' }],
list_id: undefined,
name: 'My item',
namespace_type: 'single',
},
]);
});
it('enriches exception items for shared lists', () => {
const items = getExceptionItems();
const sharedLists: ExceptionListSchema[] = [
{
...getExceptionListSchemaMock(),
list_id: 'foo',
namespace_type: 'single',
},
{
...getExceptionListSchemaMock(),
list_id: 'bar',
},
];
expect(
entrichNewExceptionItems({
itemName: 'My item',
commentToAdd: 'New comment',
addToRules: false,
addToSharedLists: true,
sharedLists,
selectedOs: [],
listType: ExceptionListTypeEnum.DETECTION,
items,
})
).toEqual([
{
...items[0],
comments: [{ comment: 'New comment' }],
list_id: 'foo',
name: 'My item',
namespace_type: 'single',
},
{
...items[0],
comments: [{ comment: 'New comment' }],
list_id: 'bar',
name: 'My item',
namespace_type: 'agnostic',
},
]);
});
it('enriches exception items for endpoint list', () => {
const items: ExceptionsBuilderReturnExceptionItem[] = [
{
...getCreateExceptionListItemSchemaMock(),
list_id: 'endpoint_list',
namespace_type: 'agnostic',
os_types: [],
},
];
expect(
entrichNewExceptionItems({
itemName: 'My item',
commentToAdd: 'New comment',
addToRules: false,
addToSharedLists: false,
sharedLists: [],
selectedOs: ['windows'],
listType: ExceptionListTypeEnum.ENDPOINT,
items,
})
).toEqual([
{
...items[0],
comments: [{ comment: 'New comment' }],
list_id: 'endpoint_list',
name: 'My item',
namespace_type: 'agnostic',
os_types: ['windows'],
},
]);
});
});
describe('enrichItemWithComment', () => {
it('returns items unchanged if no comment', () => {
const items = getExceptionItems();
expect(enrichItemWithComment(' ')(items)).toEqual(items);
});
it('returns items with new comment', () => {
const items = getExceptionItems();
expect(enrichItemWithComment('My new comment')(items)).toEqual([
{
...items[0],
comments: [
{
comment: 'My new comment',
},
],
},
]);
});
});
describe('enrichItemWithName', () => {
it('returns items unchanged if no name', () => {
const items = getExceptionItems();
expect(enrichItemWithName(' ')(items)).toEqual(items);
});
it('returns items with name', () => {
const items = getExceptionItems();
expect(enrichItemWithName('My item')(items)).toEqual([
{
...items[0],
name: 'My item',
},
]);
});
});
describe('enrichEndpointItems', () => {
it('returns items unchanged if "listType" is not "endpoint"', () => {
const items = getExceptionItems();
expect(enrichEndpointItems(ExceptionListTypeEnum.DETECTION, [])(items)).toEqual(items);
});
it('returns items with os types', () => {
const items: ExceptionsBuilderReturnExceptionItem[] = [
{
...getCreateExceptionListItemSchemaMock(),
namespace_type: 'agnostic',
list_id: 'endpoint_list',
},
];
expect(enrichEndpointItems(ExceptionListTypeEnum.ENDPOINT, ['windows'])(items)).toEqual([
{
...items[0],
os_types: ['windows'],
},
]);
});
});
describe('enrichItemsForDefaultRuleList', () => {
it('returns items unchanged if "addToRules" is "false"', () => {
const items = getExceptionItems();
expect(enrichItemsForDefaultRuleList(ExceptionListTypeEnum.DETECTION, false)(items)).toEqual(
items
);
});
/*
* Wouldn't make sense for the second argument to be "true", when
* listType is endpoint, but figured it'd be good
* to test anyways.
*/
it('returns items unchanged if "listType" is "endpoint"', () => {
const items = getExceptionItems();
expect(enrichItemsForDefaultRuleList(ExceptionListTypeEnum.ENDPOINT, true)(items)).toEqual(
items
);
});
it('returns items with to add for each shared list', () => {
const items = getExceptionItems();
expect(enrichItemsForDefaultRuleList(ExceptionListTypeEnum.DETECTION, true)(items)).toEqual([
{
...items[0],
list_id: undefined,
namespace_type: 'single',
},
]);
});
});
describe('enrichItemsForSharedLists', () => {
it('returns items unchanged if "addToSharedLists" is "false"', () => {
const items = getExceptionItems();
expect(enrichItemsForSharedLists(ExceptionListTypeEnum.DETECTION, false, [])(items)).toEqual(
items
);
});
/*
* Wouldn't make sense for the second argument to be "true", when
* listType is endpoint, but figured it'd be good
* to test anyways.
*/
it('returns items unchanged if "listType" is "endpoint"', () => {
const items = getExceptionItems();
expect(enrichItemsForSharedLists(ExceptionListTypeEnum.ENDPOINT, true, [])(items)).toEqual(
items
);
});
it('returns items with to add for each shared list', () => {
const items = getExceptionItems();
const sharedLists: ExceptionListSchema[] = [
{
...getExceptionListSchemaMock(),
list_id: 'foo',
namespace_type: 'single',
},
{
...getExceptionListSchemaMock(),
list_id: 'bar',
},
];
expect(
enrichItemsForSharedLists(ExceptionListTypeEnum.DETECTION, true, sharedLists)(items)
).toEqual([
{
...items[0],
list_id: 'foo',
namespace_type: 'single',
},
{
...items[0],
list_id: 'bar',
namespace_type: 'agnostic',
},
]);
});
});
});

View file

@ -0,0 +1,269 @@
/*
* 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 React from 'react';
import { pipe } from 'lodash/fp';
import type { ExceptionListSchema, OsType } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils';
import {
enrichExceptionItemsWithOS,
enrichNewExceptionItemsWithComments,
enrichNewExceptionItemsWithName,
enrichRuleExceptions,
enrichSharedExceptions,
lowercaseHashValues,
} from '../../utils/helpers';
import { SecuritySolutionLinkAnchor } from '../../../../common/components/links';
import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import { RuleDetailTabs } from '../../../../detections/pages/detection_engine/rules/details';
import { SecurityPageName } from '../../../../../common/constants';
import { PopoverItems } from '../../../../common/components/popover_items';
import type {
ExceptionListRuleReferencesInfoSchema,
ExceptionListRuleReferencesSchema,
} from '../../../../../common/detection_engine/schemas/response';
import type { Rule } from '../../../../detections/containers/detection_engine/rules/types';
import * as i18n from './translations';
/**
* Adds user defined name to all new exceptionItems
* @param commentToAdd new comment to add to item
*/
export const enrichItemWithComment =
(commentToAdd: string) =>
(items: ExceptionsBuilderReturnExceptionItem[]): ExceptionsBuilderReturnExceptionItem[] => {
return commentToAdd.trim() !== ''
? enrichNewExceptionItemsWithComments(items, [{ comment: commentToAdd }])
: items;
};
/**
* Adds user defined name to all new exceptionItems
* @param itemName exception item name
*/
export const enrichItemWithName =
(itemName: string) => (items: ExceptionsBuilderReturnExceptionItem[]) => {
return itemName.trim() !== '' ? enrichNewExceptionItemsWithName(items, itemName) : items;
};
/**
* Modifies item entries to be in correct format and adds os selection to items
* @param listType exception list type
* @param selectedOs os selection
*/
export const enrichEndpointItems =
(listType: ExceptionListTypeEnum, selectedOs: OsType[]) =>
(items: ExceptionsBuilderReturnExceptionItem[]) => {
if (listType === ExceptionListTypeEnum.ENDPOINT) {
return lowercaseHashValues(enrichExceptionItemsWithOS(items, selectedOs));
} else {
return items;
}
};
/**
* Modifies exception items to prepare for creating as rule_default
* list items
* @param listType exception list type
* @param addToRules boolean determining if user selected to add items to default rule list
*/
export const enrichItemsForDefaultRuleList =
(listType: ExceptionListTypeEnum, addToRules: boolean) =>
(items: ExceptionsBuilderReturnExceptionItem[]) => {
if (addToRules && listType !== ExceptionListTypeEnum.ENDPOINT) {
return enrichRuleExceptions(items);
} else {
return items;
}
};
/**
* Prepares items to be added to shared exception lists
* @param listType exception list type
* @param addToSharedLists boolean determining if user selected to add items to shared list
* @param lists shared exception lists that were selected to add items to
*/
export const enrichItemsForSharedLists =
(listType: ExceptionListTypeEnum, addToSharedLists: boolean, lists: ExceptionListSchema[]) =>
(items: ExceptionsBuilderReturnExceptionItem[]) => {
if (addToSharedLists && listType !== ExceptionListTypeEnum.ENDPOINT) {
return enrichSharedExceptions(items, lists);
} else {
return items;
}
};
/**
* Series of utils to modify and prepare exception items for update or creation
* @param itemName user defined exception item name
* @param commentToAdd comment to be added to item
* @param addToRules boolean determining if user selected to add items to default rule list
* @param addToSharedLists boolean determining if user selected to add items to shared list
* @param sharedLists shared exception lists that were selected to add items to
* @param selectedOs os selection
* @param listType exception list type
* @param items exception items to be modified
*/
export const entrichNewExceptionItems = ({
itemName,
commentToAdd,
addToRules,
addToSharedLists,
sharedLists,
selectedOs,
listType,
items,
}: {
itemName: string;
commentToAdd: string;
selectedOs: OsType[];
addToRules: boolean;
addToSharedLists: boolean;
sharedLists: ExceptionListSchema[];
listType: ExceptionListTypeEnum;
items: ExceptionsBuilderReturnExceptionItem[];
}): ExceptionsBuilderReturnExceptionItem[] => {
const enriched: ExceptionsBuilderReturnExceptionItem[] = pipe(
enrichItemWithComment(commentToAdd),
enrichItemWithName(itemName),
enrichEndpointItems(listType, selectedOs),
enrichItemsForDefaultRuleList(listType, addToRules),
enrichItemsForSharedLists(listType, addToSharedLists, sharedLists)
)(items);
return enriched;
};
/**
* Series of utils to modify and prepare exception items for update or creation
* @param itemName user defined exception item name
* @param commentToAdd comment to be added to item
* @param addToRules boolean determining if user selected to add items to default rule list
* @param addToSharedLists boolean determining if user selected to add items to shared list
* @param sharedLists shared exception lists that were selected to add items to
* @param selectedOs os selection
* @param listType exception list type
* @param items exception items to be modified
*/
export const entrichExceptionItemsForUpdate = ({
itemName,
commentToAdd,
selectedOs,
listType,
items,
}: {
itemName: string;
commentToAdd: string;
selectedOs: OsType[];
listType: ExceptionListTypeEnum;
items: ExceptionsBuilderReturnExceptionItem[];
}): ExceptionsBuilderReturnExceptionItem[] => {
const enriched: ExceptionsBuilderReturnExceptionItem[] = pipe(
enrichItemWithComment(commentToAdd),
enrichItemWithName(itemName),
enrichEndpointItems(listType, selectedOs)
)(items);
return enriched;
};
/**
* Shared lists columns for EuiInMemoryTable
*/
export const getSharedListsTableColumns = () => [
{
field: 'name',
name: 'Name',
sortable: true,
'data-test-subj': 'exceptionListNameCell',
},
{
field: 'referenced_rules',
name: '# of rules linked to',
sortable: false,
'data-test-subj': 'exceptionListRulesLinkedToIdCell',
render: (references: ExceptionListRuleReferencesInfoSchema[]) => {
if (references.length === 0) return '0';
const renderItem = (reference: ExceptionListRuleReferencesInfoSchema, i: number) => (
<SecuritySolutionLinkAnchor
data-test-subj="referencedRuleLink"
deepLinkId={SecurityPageName.rules}
path={getRuleDetailsTabUrl(reference.id, RuleDetailTabs.alerts)}
external
>
{reference.name}
</SecuritySolutionLinkAnchor>
);
return (
<PopoverItems
items={references}
popoverButtonTitle={references.length.toString()}
dataTestPrefix="ruleReferences"
renderItem={renderItem}
/>
);
},
},
// TODO: This will need to be updated once PR goes in with list details page
{
name: 'Actions',
actions: [
{
'data-test-subj': 'exceptionListRulesActionCell',
render: (list: ExceptionListRuleReferencesSchema) => {
return (
<SecuritySolutionLinkAnchor
data-test-subj="exceptionListActionCell-link"
deepLinkId={SecurityPageName.exceptions}
path={''}
external
>
{i18n.VIEW_LIST_DETAIL_ACTION}
</SecuritySolutionLinkAnchor>
);
},
},
],
},
];
/**
* Rules columns for EuiInMemoryTable
*/
export const getRulesTableColumn = () => [
{
field: 'name',
name: 'Name',
sortable: true,
'data-test-subj': 'ruleNameCell',
},
{
name: 'Actions',
actions: [
{
'data-test-subj': 'ruleAction-view',
render: (rule: Rule) => {
return (
<SecuritySolutionLinkAnchor
data-test-subj="ruleAction-viewDetails"
deepLinkId={SecurityPageName.rules}
path={getRuleDetailsTabUrl(rule.id, RuleDetailTabs.alerts)}
external
>
{i18n.VIEW_RULE_DETAIL_ACTION}
</SecuritySolutionLinkAnchor>
);
},
},
],
},
];

View file

@ -0,0 +1,162 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { EuiTextArea } from '@elastic/eui';
import { ExceptionItemComments } from '.';
import { TestProviders } from '../../../../common/mock';
import { useCurrentUser } from '../../../../common/lib/kibana';
import { shallow } from 'enzyme';
jest.mock('../../../../common/lib/kibana');
describe('ExceptionItemComments', () => {
beforeEach(() => {
(useCurrentUser as jest.Mock).mockReturnValue({
username: 'user',
email: 'email',
fullName: 'full name',
roles: ['user-role'],
enabled: true,
authentication_realm: { name: 'native1', type: 'native' },
lookup_realm: { name: 'native1', type: 'native' },
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_type: 'realm',
elastic_cloud_user: false,
metadata: { _reserved: false },
});
});
it('it uses user full_name if one exists', () => {
const wrapper = shallow(
<ExceptionItemComments
newCommentValue={'This is a new comment'}
newCommentOnChange={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="exceptionItemCommentAvatar"]').prop('name')).toEqual(
'full name'
);
});
it('it uses user email if fullName is not available', () => {
(useCurrentUser as jest.Mock).mockReturnValue({
username: 'user',
email: 'email',
fullName: '',
roles: ['user-role'],
enabled: true,
authentication_realm: { name: 'native1', type: 'native' },
lookup_realm: { name: 'native1', type: 'native' },
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_type: 'realm',
elastic_cloud_user: false,
metadata: { _reserved: false },
});
const wrapper = shallow(
<ExceptionItemComments
newCommentValue={'This is a new comment'}
newCommentOnChange={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="exceptionItemCommentAvatar"]').prop('name')).toEqual(
'email'
);
});
it('it uses username if fullName and email are not available', () => {
(useCurrentUser as jest.Mock).mockReturnValue({
username: 'user',
email: '',
fullName: '',
roles: ['user-role'],
enabled: true,
authentication_realm: { name: 'native1', type: 'native' },
lookup_realm: { name: 'native1', type: 'native' },
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_type: 'realm',
elastic_cloud_user: false,
metadata: { _reserved: false },
});
const wrapper = shallow(
<ExceptionItemComments
newCommentValue={'This is a new comment'}
newCommentOnChange={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="exceptionItemCommentAvatar"]').prop('name')).toEqual(
'user'
);
});
it('it renders new comment', () => {
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemComments
newCommentValue={'This is a new comment'}
newCommentOnChange={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionItemCommentsAccordion"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="newExceptionItemCommentTextArea"]').at(1).props().value
).toEqual('This is a new comment');
});
it('it calls newCommentOnChange on comment update change', () => {
const mockOnCommentChange = jest.fn();
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemComments
newCommentValue="This is a new comment"
newCommentOnChange={mockOnCommentChange}
/>
</TestProviders>
);
(
wrapper.find(EuiTextArea).at(0).props() as unknown as {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
).onChange({
target: { value: 'Updating my new comment' },
} as React.ChangeEvent<HTMLInputElement>);
expect(mockOnCommentChange).toHaveBeenCalledWith('Updating my new comment');
});
it('it renders existing comments if any exist', () => {
const mockOnCommentChange = jest.fn();
const wrapper = mountWithIntl(
<TestProviders>
<ExceptionItemComments
exceptionItemComments={[
{
comment: 'This is an existing comment',
created_at: '2020-04-20T15:25:31.830Z',
created_by: 'elastic',
id: 'uuid_here',
},
]}
newCommentValue={''}
newCommentOnChange={mockOnCommentChange}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionItemCommentsAccordion"]').exists()).toBeTruthy();
});
});

View file

@ -18,13 +18,14 @@ import {
EuiText,
} from '@elastic/eui';
import type { Comment } from '@kbn/securitysolution-io-ts-list-types';
import * as i18n from '../../utils/translations';
import * as i18n from './translations';
import { useCurrentUser } from '../../../../common/lib/kibana';
import { getFormattedComments } from '../../utils/helpers';
interface ExceptionItemCommentsProps {
exceptionItemComments?: Comment[];
newCommentValue: string;
accordionTitle?: JSX.Element;
newCommentOnChange: (value: string) => void;
}
@ -48,10 +49,27 @@ const CommentAccordion = styled(EuiAccordion)`
export const ExceptionItemComments = memo(function ExceptionItemComments({
exceptionItemComments,
newCommentValue,
accordionTitle,
newCommentOnChange,
}: ExceptionItemCommentsProps) {
const [shouldShowComments, setShouldShowComments] = useState(false);
const currentUser = useCurrentUser();
const fullName = currentUser?.fullName;
const userName = currentUser?.username;
const userEmail = currentUser?.email;
const avatarName = useMemo(() => {
if (fullName && fullName.length > 0) {
return fullName;
}
// Did email second because for cloud users, username is a uuid,
// so favor using name or email prior to using the cloud generated id
if (userEmail && userEmail.length > 0) {
return userEmail;
}
return userName && userName.length > 0 ? userName : i18n.UNKNOWN_AVATAR_NAME;
}, [fullName, userEmail, userName]);
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -64,7 +82,7 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({
setShouldShowComments(isOpen);
}, []);
const shouldShowAccordion: boolean = useMemo(() => {
const exceptionItemsExist: boolean = useMemo(() => {
return exceptionItemComments != null && exceptionItemComments.length > 0;
}, [exceptionItemComments]);
@ -92,12 +110,12 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({
return (
<div>
{shouldShowAccordion && (
{exceptionItemsExist && (
<CommentAccordion
id={'add-exception-comments-accordion'}
buttonClassName={COMMENT_ACCORDION_BUTTON_CLASS_NAME}
buttonContent={commentsAccordionTitle}
data-test-subj="ExceptionItemCommentsAccordion"
buttonContent={accordionTitle ?? commentsAccordionTitle}
data-test-subj="exceptionItemCommentsAccordion"
onToggle={(isOpen) => handleTriggerOnClick(isOpen)}
>
<EuiCommentList comments={formattedComments} />
@ -105,10 +123,7 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({
)}
<EuiFlexGroup gutterSize={'none'}>
<EuiFlexItem grow={false}>
<MyAvatar
name={currentUser != null ? currentUser.username.toUpperCase() ?? '' : ''}
size="l"
/>
<MyAvatar name={avatarName} size="l" data-test-subj="exceptionItemCommentAvatar" />
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiTextArea
@ -117,6 +132,7 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({
value={newCommentValue}
onChange={handleOnChange}
fullWidth={true}
data-test-subj="newExceptionItemCommentTextArea"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,34 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const UNKNOWN_AVATAR_NAME = i18n.translate(
'xpack.securitySolution.rule_exceptions.itemComments.unknownAvatarName',
{
defaultMessage: 'Uknown',
}
);
export const ADD_COMMENT_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.rule_exceptions.itemComments.addCommentPlaceholder',
{
defaultMessage: 'Add a new comment...',
}
);
export const COMMENTS_SHOW = (comments: number) =>
i18n.translate('xpack.securitySolution.rule_exceptions.itemComments.showCommentsLabel', {
values: { comments },
defaultMessage: 'Show ({comments}) {comments, plural, =1 {Comment} other {Comments}}',
});
export const COMMENTS_HIDE = (comments: number) =>
i18n.translate('xpack.securitySolution.rule_exceptions.itemComments.hideCommentsLabel', {
values: { comments },
defaultMessage: 'Hide ({comments}) {comments, plural, =1 {Comment} other {Comments}}',
});

View file

@ -5,51 +5,49 @@
* 2.0.
*/
import { useEffect, useMemo, useState } from 'react';
import type { ListArray } from '@kbn/securitysolution-io-ts-list-types';
import { useEffect, useRef, useState } from 'react';
import type { RuleReferenceSchema } from '../../../../common/detection_engine/schemas/response';
import type { ExceptionListRuleReferencesSchema } from '../../../../common/detection_engine/schemas/response';
import { findRuleExceptionReferences } from '../../../detections/containers/detection_engine/rules/api';
import { useToasts } from '../../../common/lib/kibana';
import type { FindRulesReferencedByExceptionsListProp } from '../../../detections/containers/detection_engine/rules/types';
import * as i18n from '../utils/translations';
export type ReturnUseFindExceptionListReferences = [boolean, boolean, RuleReferences | null];
export interface RuleReferences {
[key: string]: RuleReferenceSchema[];
[key: string]: ExceptionListRuleReferencesSchema;
}
export type FetchReferencesFunc = (
listsToFetch: FindRulesReferencedByExceptionsListProp[]
) => Promise<void>;
export type ReturnUseFindExceptionListReferences = [
boolean,
boolean,
RuleReferences | null,
FetchReferencesFunc | null
];
/**
* Hook for finding what rules are referenced by a set of exception lists
* @param ruleExceptionLists array of exception list info stored on a rule
*/
export const useFindExceptionListReferences = (
ruleExceptionLists: ListArray
): ReturnUseFindExceptionListReferences => {
export const useFindExceptionListReferences = (): ReturnUseFindExceptionListReferences => {
const toasts = useToasts();
const [isLoading, setIsLoading] = useState(false);
const [errorExists, setErrorExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [didError, setError] = useState(false);
const [references, setReferences] = useState<RuleReferences | null>(null);
const listRefs = useMemo((): FindRulesReferencedByExceptionsListProp[] => {
return ruleExceptionLists.map((list) => {
return {
id: list.id,
listId: list.list_id,
namespaceType: list.namespace_type,
};
});
}, [ruleExceptionLists]);
const findExceptionListAndReferencesRef = useRef<FetchReferencesFunc | null>(null);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const findReferences = async () => {
const findReferences = async (listsToFetch: FindRulesReferencedByExceptionsListProp[]) => {
try {
setIsLoading(true);
const { references: referencesResults } = await findRuleExceptionReferences({
lists: listRefs,
lists: listsToFetch,
signal: abortCtrl.signal,
});
@ -62,32 +60,26 @@ export const useFindExceptionListReferences = (
}, {});
if (isSubscribed) {
setErrorExists(false);
setIsLoading(false);
setError(false);
setReferences(results);
}
} catch (error) {
if (isSubscribed) {
setErrorExists(true);
setError(true);
setIsLoading(false);
toasts.addError(error, { title: i18n.ERROR_FETCHING_REFERENCES_TITLE });
}
}
};
if (listRefs.length === 0 && isSubscribed) {
setErrorExists(false);
setIsLoading(false);
setReferences(null);
} else {
findReferences();
}
findExceptionListAndReferencesRef.current = findReferences;
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [ruleExceptionLists, listRefs, toasts]);
}, [toasts]);
return [isLoading, errorExists, references];
return [isLoading, didError, references, findExceptionListAndReferencesRef.current];
};

View file

@ -23,6 +23,7 @@ import type {
ExceptionListItemSchema,
CreateExceptionListItemSchema,
UpdateExceptionListItemSchema,
ExceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import {
comment,
@ -30,7 +31,10 @@ import {
ListOperatorTypeEnum as OperatorTypeEnum,
} from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionsBuilderExceptionItem } from '@kbn/securitysolution-list-utils';
import type {
ExceptionsBuilderExceptionItem,
ExceptionsBuilderReturnExceptionItem,
} from '@kbn/securitysolution-list-utils';
import {
getOperatorType,
getNewExceptionItem,
@ -169,10 +173,10 @@ export const prepareExceptionItemsForBulkClose = (
* @param comments new Comment
*/
export const enrichNewExceptionItemsWithComments = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
exceptionItems: ExceptionsBuilderReturnExceptionItem[],
comments: Array<Comment | CreateComment>
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => {
): ExceptionsBuilderReturnExceptionItem[] => {
return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => {
return {
...item,
comments,
@ -197,9 +201,9 @@ export const buildGetAlertByIdQuery = (id: string | undefined) => ({
* and new comments
*/
export const enrichExistingExceptionItemWithComments = (
exceptionItem: ExceptionListItemSchema | CreateExceptionListItemSchema,
exceptionItem: ExceptionsBuilderReturnExceptionItem,
comments: Array<Comment | CreateComment>
): ExceptionListItemSchema | CreateExceptionListItemSchema => {
): ExceptionsBuilderReturnExceptionItem => {
const formattedComments = comments.map((item) => {
if (comment.is(item)) {
const { id, comment: existingComment } = item;
@ -226,10 +230,10 @@ export const enrichExistingExceptionItemWithComments = (
* @param osTypes array of os values
*/
export const enrichExceptionItemsWithOS = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
exceptionItems: ExceptionsBuilderReturnExceptionItem[],
osTypes: OsTypeArray
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => {
): ExceptionsBuilderReturnExceptionItem[] => {
return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => {
return {
...item,
os_types: osTypes,
@ -255,8 +259,8 @@ export const retrieveAlertOsTypes = (alertData?: AlertData): OsTypeArray => {
* Returns given exceptionItems with all hash-related entries lowercased
*/
export const lowercaseHashValues = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
exceptionItems: ExceptionsBuilderReturnExceptionItem[]
): ExceptionsBuilderReturnExceptionItem[] => {
return exceptionItems.map((item) => {
const newEntries = item.entries.map((itemEntry) => {
if (itemEntry.field.includes('.hash')) {
@ -281,9 +285,7 @@ export const lowercaseHashValues = (
});
};
export const entryHasListType = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
) => {
export const entryHasListType = (exceptionItems: ExceptionsBuilderReturnExceptionItem[]) => {
for (const { entries } of exceptionItems) {
for (const exceptionEntry of entries ?? []) {
if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) {
@ -755,7 +757,7 @@ export const getPrepopulatedBehaviorException = ({
* Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping
*/
export const entryHasNonEcsType = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
exceptionItems: ExceptionsBuilderReturnExceptionItem[],
indexPatterns: DataViewBase
): boolean => {
const doesFieldNameExist = (exceptionEntry: Entry): boolean => {
@ -842,3 +844,57 @@ export const defaultEndpointExceptionItems = (
);
}
};
/**
* Adds user defined name to all new exceptionItems
* @param exceptionItems new or existing ExceptionItem[]
* @param name new exception item name
*/
export const enrichNewExceptionItemsWithName = (
exceptionItems: ExceptionsBuilderReturnExceptionItem[],
name: string
): ExceptionsBuilderReturnExceptionItem[] => {
return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => {
return {
...item,
name,
};
});
};
/**
* Modifies exception items to prepare for creating as rule_default
* list items
* @param exceptionItems new or existing ExceptionItem[]
*/
export const enrichRuleExceptions = (
exceptionItems: ExceptionsBuilderReturnExceptionItem[]
): ExceptionsBuilderReturnExceptionItem[] => {
return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => {
return {
...item,
list_id: undefined,
namespace_type: 'single',
};
});
};
/**
* Prepares items to be added to shared exception lists
* @param exceptionItems new or existing ExceptionItem[]
* @param lists shared exception lists that were selected to add items to
*/
export const enrichSharedExceptions = (
exceptionItems: ExceptionsBuilderReturnExceptionItem[],
lists: ExceptionListSchema[]
): ExceptionsBuilderReturnExceptionItem[] => {
return lists.flatMap((list) => {
return exceptionItems.map((item) => {
return {
...item,
list_id: list.list_id,
namespace_type: list.namespace_type,
};
});
});
};

View file

@ -386,16 +386,23 @@ export const fetchInstalledIntegrations = async ({
export const findRuleExceptionReferences = async ({
lists,
signal,
}: FindRulesReferencedByExceptionsProps): Promise<RulesReferencedByExceptionListsSchema> =>
KibanaServices.get().http.fetch<RulesReferencedByExceptionListsSchema>(
DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL,
{
method: 'GET',
query: {
}: FindRulesReferencedByExceptionsProps): Promise<RulesReferencedByExceptionListsSchema> => {
const idsUndefined = lists.some(({ id }) => id === undefined);
const query = idsUndefined
? {
namespace_types: lists.map(({ namespaceType }) => namespaceType).join(','),
}
: {
ids: lists.map(({ id }) => id).join(','),
list_ids: lists.map(({ listId }) => listId).join(','),
namespace_types: lists.map(({ namespaceType }) => namespaceType).join(','),
},
};
return KibanaServices.get().http.fetch<RulesReferencedByExceptionListsSchema>(
DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL,
{
method: 'GET',
query,
signal,
}
);
};

View file

@ -344,8 +344,8 @@ export interface PrePackagedRulesStatusResponse {
}
export interface FindRulesReferencedByExceptionsListProp {
id: string;
listId: string;
id?: string;
listId?: string;
namespaceType: NamespaceType;
}

View file

@ -14,10 +14,18 @@ import {
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { findRuleExceptionReferencesRoute } from './find_rule_exceptions_route';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
describe('findRuleExceptionReferencesRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
const mockList = {
...getExceptionListSchemaMock(),
type: 'detection',
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
list_id: 'my_default_list',
namespace_type: 'single',
};
beforeEach(() => {
server = serverMock.create();
@ -42,6 +50,10 @@ describe('findRuleExceptionReferencesRoute', () => {
],
});
(clients.lists.exceptionListClient.findExceptionList as jest.Mock).mockResolvedValue({
data: [mockList],
});
findRuleExceptionReferencesRoute(server.router);
});
@ -62,21 +74,24 @@ describe('findRuleExceptionReferencesRoute', () => {
expect(response.body).toEqual({
references: [
{
my_default_list: [
{
exception_lists: [
{
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
list_id: 'my_default_list',
namespace_type: 'single',
type: 'detection',
},
],
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
name: 'Detect Root/Admin Users',
rule_id: 'rule-1',
},
],
my_default_list: {
...mockList,
referenced_rules: [
{
exception_lists: [
{
id: '4656dc92-5832-11ea-8e2d-0242ac130003',
list_id: 'my_default_list',
namespace_type: 'single',
type: 'detection',
},
],
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
name: 'Detect Root/Admin Users',
rule_id: 'rule-1',
},
],
},
},
],
});
@ -101,7 +116,10 @@ describe('findRuleExceptionReferencesRoute', () => {
expect(response.body).toEqual({
references: [
{
my_default_list: [],
my_default_list: {
...mockList,
referenced_rules: [],
},
},
],
});

View file

@ -8,7 +8,6 @@
import { transformError } from '@kbn/securitysolution-es-utils';
import { getSavedObjectType } from '@kbn/securitysolution-list-utils';
import { validate } from '@kbn/securitysolution-io-ts-utils';
import type { FindResult } from '@kbn/alerting-plugin/server';
import type { SecuritySolutionPluginRouter } from '../../../../types';
import { DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL } from '../../../../../common/constants';
@ -43,39 +42,67 @@ export const findRuleExceptionReferencesRoute = (router: SecuritySolutionPluginR
const ctx = await context.resolve(['core', 'securitySolution', 'alerting']);
const rulesClient = ctx.alerting.getRulesClient();
const listsClient = ctx.securitySolution.getExceptionListClient();
if (ids.length !== namespaceTypes.length || ids.length !== listIds.length) {
if (
ids != null &&
listIds != null &&
(ids.length !== namespaceTypes.length || ids.length !== listIds.length)
) {
return siemResponse.error({
body: `"ids", "list_ids" and "namespace_types" need to have the same comma separated number of values. Expected "ids" length: ${ids.length} to equal "namespace_types" length: ${namespaceTypes.length} and "list_ids" length: ${listIds.length}.`,
statusCode: 400,
});
}
const foundRules: Array<FindResult<RuleParams>> = await Promise.all(
ids.map(async (id, index) => {
return rulesClient.find({
const fetchExact = ids != null && listIds != null;
const foundExceptionLists = await listsClient?.findExceptionList({
filter: fetchExact
? `(${listIds
.map((listId) => `exception-list.attributes.list_id:${listId}`)
.join(' OR ')})`
: undefined,
namespaceType: ['agnostic', 'single'],
page: 1,
perPage: 10000,
sortField: undefined,
sortOrder: undefined,
});
if (foundExceptionLists == null) {
return response.ok({ body: { references: [] } });
}
const references: RuleReferencesSchema[] = await Promise.all(
foundExceptionLists.data.map(async (list, index) => {
const foundRules = await rulesClient.find<RuleParams>({
options: {
perPage: 10000,
filter: enrichFilterWithRuleTypeMapping(null),
hasReference: {
id,
id: list.id,
type: getSavedObjectType({ namespaceType: namespaceTypes[index] }),
},
},
});
const ruleData = foundRules.data.map(({ name, id, params }) => ({
name,
id,
rule_id: params.ruleId,
exception_lists: params.exceptionsList,
}));
return {
[list.list_id]: {
...list,
referenced_rules: ruleData,
},
};
})
);
const references = foundRules.map<RuleReferencesSchema>(({ data }, index) => {
const wantedData = data.map(({ name, id, params }) => ({
name,
id,
rule_id: params.ruleId,
exception_lists: params.exceptionsList,
}));
return { [listIds[index]]: wantedData };
});
const [validated, errors] = validate({ references }, rulesReferencedByExceptionListsSchema);
if (errors != null) {

View file

@ -5,6 +5,8 @@
* 2.0.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import expect from '@kbn/expect';
import { DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL } from '@kbn/security-solution-plugin/common/constants';
@ -13,6 +15,7 @@ import {
ExceptionListTypeEnum,
} from '@kbn/securitysolution-io-ts-list-types';
import { getCreateExceptionListMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_schema.mock';
import { RuleReferencesSchema } from '@kbn/security-solution-plugin/common/detection_engine/schemas/response';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
createRule,
@ -66,10 +69,46 @@ export default ({ getService }: FtrProviderContext) => {
})
.expect(200);
expect(references).to.eql({ references: [{ i_exist: [] }] });
const {
_version,
id,
created_at,
created_by,
tie_breaker_id,
updated_at,
updated_by,
...referencesWithoutServerValues
} = references.references[0].i_exist;
expect({
references: [
{
i_exist: {
...referencesWithoutServerValues,
},
},
],
}).to.eql({
references: [
{
i_exist: {
description: 'some description',
immutable: false,
list_id: 'i_exist',
name: 'some name',
namespace_type: 'single',
os_types: [],
tags: [],
type: 'detection',
version: 1,
referenced_rules: [],
},
},
],
});
});
it('returns empty array per list_id if list does not exist', async () => {
it('returns empty array per list_id if list does not exist', async () => {
// create rule
await createRule(supertest, log, getSimpleRule('rule-1'));
@ -83,7 +122,7 @@ export default ({ getService }: FtrProviderContext) => {
})
.expect(200);
expect(references).to.eql({ references: [{ i_dont_exist: [] }] });
expect(references).to.eql({ references: [] });
});
it('returns found references', async () => {
@ -101,7 +140,7 @@ export default ({ getService }: FtrProviderContext) => {
});
// create rule
const rule = await createRule(supertest, log, {
await createRule(supertest, log, {
...getSimpleRule('rule-2'),
exceptions_list: [
{
@ -129,56 +168,54 @@ export default ({ getService }: FtrProviderContext) => {
})
.expect(200);
expect(references).to.eql({
references: [
const refs = references.references.flatMap((ref: RuleReferencesSchema) => Object.keys(ref));
expect(refs.sort()).to.eql(['i_exist', 'i_exist_2'].sort());
});
it('returns found references for all existing exception lists if no list id/list_id passed in', async () => {
// create exception list
const newExceptionList: CreateExceptionListSchema = {
...getCreateExceptionListMinimalSchemaMock(),
list_id: 'i_exist',
namespace_type: 'single',
type: ExceptionListTypeEnum.DETECTION,
};
const exceptionList = await createExceptionList(supertest, log, newExceptionList);
const exceptionList2 = await createExceptionList(supertest, log, {
...newExceptionList,
list_id: 'i_exist_2',
});
// create rule
await createRule(supertest, log, {
...getSimpleRule('rule-2'),
exceptions_list: [
{
i_exist: [
{
exception_lists: [
{
id: references.references[0].i_exist[0].exception_lists[0].id,
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: references.references[0].i_exist[0].exception_lists[1].id,
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: rule.id,
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
],
id: `${exceptionList.id}`,
list_id: `${exceptionList.list_id}`,
namespace_type: `${exceptionList.namespace_type}`,
type: `${exceptionList.type}`,
},
{
i_exist_2: [
{
exception_lists: [
{
id: references.references[1].i_exist_2[0].exception_lists[0].id,
list_id: 'i_exist',
namespace_type: 'single',
type: 'detection',
},
{
id: references.references[1].i_exist_2[0].exception_lists[1].id,
list_id: 'i_exist_2',
namespace_type: 'single',
type: 'detection',
},
],
id: rule.id,
name: 'Simple Rule Query',
rule_id: 'rule-2',
},
],
id: `${exceptionList2.id}`,
list_id: `${exceptionList2.list_id}`,
namespace_type: `${exceptionList2.namespace_type}`,
type: `${exceptionList2.type}`,
},
],
});
const { body: references } = await supertest
.get(DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL)
.set('kbn-xsrf', 'true')
.query({
namespace_types: `${exceptionList.namespace_type},${exceptionList2.namespace_type}`,
})
.expect(200);
const refs = references.references.flatMap((ref: RuleReferencesSchema) => Object.keys(ref));
expect(refs.sort()).to.eql(['i_exist', 'i_exist_2', 'endpoint_list'].sort());
});
});
};