diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts new file mode 100644 index 0000000000000..ddc366f49ed95 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts @@ -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 { EffectScope } from '../../types'; + +export const POLICY_REFERENCE_PREFIX = 'policy:'; + +/** + * Looks at an array of `tags` (attributed defined on the `ExceptionListItemSchema`) and returns back + * the `EffectScope` of based on the data in the array + * @param tags + */ +export const tagsToEffectScope = (tags: string[]): EffectScope => { + const policyReferenceTags = tags.filter((tag) => tag.startsWith(POLICY_REFERENCE_PREFIX)); + + if (policyReferenceTags.some((tag) => tag === `${POLICY_REFERENCE_PREFIX}all`)) { + return { + type: 'global', + }; + } else { + return { + type: 'policy', + policies: policyReferenceTags.map((tag) => tag.substr(POLICY_REFERENCE_PREFIX.length)), + }; + } +}; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx new file mode 100644 index 0000000000000..e6e4bb0c2643c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx @@ -0,0 +1,241 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { ArtifactEntryCard, ArtifactEntryCardProps } from './artifact_entry_card'; +import { TrustedAppGenerator } from '../../../../common/endpoint/data_generators/trusted_app_generator'; +import { act, fireEvent, getByTestId } from '@testing-library/react'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { AnyArtifact } from './types'; +import { isTrustedApp } from './hooks/use_normalized_artifact'; + +const getCommonItemDataOverrides = () => { + return { + name: 'some internal app', + description: 'this app is trusted by the company', + created_at: new Date('2021-07-01').toISOString(), + }; +}; + +const getTrustedAppProvider = () => + new TrustedAppGenerator('seed').generate(getCommonItemDataOverrides()); + +const getExceptionProvider = () => { + // cloneDeep needed because exception mock generator uses state across instances + return cloneDeep( + getExceptionListItemSchemaMock({ + ...getCommonItemDataOverrides(), + os_types: ['windows'], + updated_at: new Date().toISOString(), + created_by: 'Justa', + updated_by: 'Mara', + entries: [ + { + field: 'process.hash.*', + operator: 'included', + type: 'match', + value: '1234234659af249ddf3e40864e9fb241', + }, + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/one/two/three', + }, + ], + tags: ['policy:all'], + }) + ); +}; + +describe.each([ + ['trusted apps', getTrustedAppProvider], + ['exceptions/event filters', getExceptionProvider], +])('when using the ArtifactEntryCard component with %s', (_, generateItem) => { + let item: AnyArtifact; + let appTestContext: AppContextTestRender; + let renderResult: ReturnType; + let render: ( + props?: Partial + ) => ReturnType; + + beforeEach(() => { + item = generateItem(); + appTestContext = createAppRootMockRenderer(); + render = (props = {}) => { + renderResult = appTestContext.render( + + ); + return renderResult; + }; + }); + + it('should display title and who has created and updated it last', async () => { + render(); + + expect(renderResult.getByTestId('testCard-header-title').textContent).toEqual( + 'some internal app' + ); + expect(renderResult.getByTestId('testCard-subHeader-touchedBy-createdBy').textContent).toEqual( + 'Created byJJusta' + ); + expect(renderResult.getByTestId('testCard-subHeader-touchedBy-updatedBy').textContent).toEqual( + 'Updated byMMara' + ); + }); + + it('should display Global effected scope', async () => { + render(); + + expect(renderResult.getByTestId('testCard-subHeader-effectScope-value').textContent).toEqual( + 'Applied globally' + ); + }); + + it('should display dates in expected format', () => { + render(); + + expect(renderResult.getByTestId('testCard-header-updated').textContent).toEqual( + expect.stringMatching(/Last updated(\s seconds? ago|now)/) + ); + }); + + it('should display description if one exists', async () => { + render(); + + expect(renderResult.getByTestId('testCard-description').textContent).toEqual(item.description); + }); + + it('should display default empty value if description does not exist', async () => { + item.description = undefined; + render(); + + expect(renderResult.getByTestId('testCard-description').textContent).toEqual('—'); + }); + + it('should display OS and criteria conditions', () => { + render(); + + expect(renderResult.getByTestId('testCard-criteriaConditions').textContent).toEqual( + ' OSIS WindowsAND process.hash.*IS 1234234659af249ddf3e40864e9fb241AND process.executable.caselessIS /one/two/three' + ); + }); + + it('should NOT show the action menu button if no actions were provided', async () => { + render(); + const menuButton = await renderResult.queryByTestId('testCard-header-actions-button'); + + expect(menuButton).toBeNull(); + }); + + describe('and actions were defined', () => { + let actions: ArtifactEntryCardProps['actions']; + + beforeEach(() => { + actions = [ + { + 'data-test-subj': 'test-action', + children: 'action one', + }, + ]; + }); + + it('should show the actions icon when actions were defined', () => { + render({ actions }); + + expect(renderResult.getByTestId('testCard-header-actions-button')).not.toBeNull(); + }); + + it('should show popup with defined actions', async () => { + render({ actions }); + await act(async () => { + await fireEvent.click(renderResult.getByTestId('testCard-header-actions-button')); + }); + + const bodyHtmlElement = renderResult.baseElement as HTMLElement; + + expect(getByTestId(bodyHtmlElement, 'testCard-header-actions-popoverPanel')).not.toBeNull(); + expect(getByTestId(bodyHtmlElement, 'test-action')).not.toBeNull(); + }); + }); + + describe('and artifact is defined per policy', () => { + let policies: ArtifactEntryCardProps['policies']; + + beforeEach(() => { + if (isTrustedApp(item)) { + item.effectScope = { + type: 'policy', + policies: ['policy-1'], + }; + } else { + item.tags = ['policy:policy-1']; + } + + policies = { + 'policy-1': { + children: 'Policy one', + 'data-test-subj': 'policyMenuItem', + }, + }; + }); + + it('should display correct label with count of policies', () => { + render({ policies }); + + expect(renderResult.getByTestId('testCard-subHeader-effectScope-value').textContent).toEqual( + 'Applied to 1 policy' + ); + }); + + it('should display effected scope as a button', () => { + render({ policies }); + + expect( + renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-button') + ).not.toBeNull(); + }); + + it('should show popup menu with list of associated policies when clicked', async () => { + render({ policies }); + await act(async () => { + await fireEvent.click( + renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-button') + ); + }); + + expect( + renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-popoverPanel') + ).not.toBeNull(); + + expect(renderResult.getByTestId('policyMenuItem').textContent).toEqual('Policy one'); + }); + + it('should display policy ID if no policy menu item found in `policies` prop', async () => { + render(); + await act(async () => { + await fireEvent.click( + renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-button') + ); + }); + + expect( + renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-popoverPanel') + ).not.toBeNull(); + + expect(renderResult.getByText('policy-1').textContent).not.toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx index 14c4e6b947988..4adb81411395a 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx @@ -102,7 +102,7 @@ export const ArtifactEntryCard = memo( - + { return i18n.translate('xpack.securitySolution.artifactCard.policyEffectScope', { - defaultMessage: 'Applied to {count} policies', + defaultMessage: 'Applied to {count} {count, plural, one {policy} other {policies}}', values: { count: policyCount, }, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_normalized_artifact.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_normalized_artifact.ts index 175731ee57acb..78d7bd2d2f804 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_normalized_artifact.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_normalized_artifact.ts @@ -10,7 +10,8 @@ import { useMemo } from 'react'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { AnyArtifact, ArtifactInfo } from '../types'; -import { TrustedApp } from '../../../../../common/endpoint/types'; +import { EffectScope, TrustedApp } from '../../../../../common/endpoint/types'; +import { tagsToEffectScope } from '../../../../../common/endpoint/service/trusted_apps/mapping'; /** * Takes in any artifact and return back a new data structure used internally with by the card's components @@ -37,12 +38,12 @@ export const useNormalizedArtifact = (item: AnyArtifact): ArtifactInfo => { description, entries: (entries as unknown) as ArtifactInfo['entries'], os: isTrustedApp(item) ? item.os : getOsFromExceptionItem(item), - effectScope: isTrustedApp(item) ? item.effectScope : { type: 'global' }, + effectScope: isTrustedApp(item) ? item.effectScope : getEffectScopeFromExceptionItem(item), }; }, [item]); }; -const isTrustedApp = (item: AnyArtifact): item is TrustedApp => { +export const isTrustedApp = (item: AnyArtifact): item is TrustedApp => { return 'effectScope' in item; }; @@ -50,3 +51,7 @@ const getOsFromExceptionItem = (item: ExceptionListItemSchema): string => { // FYI: Exceptions seem to allow for items to be assigned to more than one OS, unlike Event Filters and Trusted Apps return item.os_types.join(', '); }; + +const getEffectScopeFromExceptionItem = (item: ExceptionListItemSchema): EffectScope => { + return tagsToEffectScope(item.tags); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index 3087914a438ef..236a93d63bcee 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -744,7 +744,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `





























}; type Mapping = { [K in T]: U }; @@ -48,7 +52,6 @@ const OPERATING_SYSTEM_TO_OS_TYPE: Mapping = { [OperatingSystem.WINDOWS]: 'windows', }; -const POLICY_REFERENCE_PREFIX = 'policy:'; const OPERATOR_VALUE = 'included'; const filterUndefined = (list: Array): T[] => { @@ -63,21 +66,6 @@ export const createConditionEntry = ( return { field, value, type, operator: OPERATOR_VALUE }; }; -export const tagsToEffectScope = (tags: string[]): EffectScope => { - const policyReferenceTags = tags.filter((tag) => tag.startsWith(POLICY_REFERENCE_PREFIX)); - - if (policyReferenceTags.some((tag) => tag === `${POLICY_REFERENCE_PREFIX}all`)) { - return { - type: 'global', - }; - } else { - return { - type: 'policy', - policies: policyReferenceTags.map((tag) => tag.substr(POLICY_REFERENCE_PREFIX.length)), - }; - } -}; - export const entriesToConditionEntriesMap = (entries: EntriesArray): ConditionEntriesMap => { return entries.reduce((result, entry) => { if (entry.field.startsWith('process.hash') && entry.type === 'match') {