diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/blocklist.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/blocklist.cy.ts new file mode 100644 index 00000000000000..32dad9b0bbc0d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/blocklist.cy.ts @@ -0,0 +1,261 @@ +/* + * 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 { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; +import { login } from '../../tasks/login'; +import { createAgentPolicyTask, getEndpointIntegrationVersion } from '../../tasks/fleet'; +import { + blocklistFormSelectors, + createArtifactList, + createPerPolicyArtifact, + removeExceptionsList, +} from '../../tasks/artifacts'; + +const { + deleteBlocklistItem, + validateSuccessPopup, + submitBlocklist, + selectOperator, + validateRenderedCondition, + fillOutBlocklistFlyout, + setSingleValue, + setMultiValue, + openBlocklist, + selectPathField, + selectSignatureField, + expectSingleOperator, + expectMultiOperator, + validateSingleValue, + validateMultiValue, + selectHashField, + selectOs, + expectSubmitButtonToBe, + clearMultiValueInput, +} = blocklistFormSelectors; + +describe( + 'Blocklist', + { + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], // @skipInServerlessMKI until kibana is rebuilt after merge + }, + () => { + let indexedPolicy: IndexedFleetEndpointPolicyResponse; + + before(() => { + getEndpointIntegrationVersion().then((version) => { + createAgentPolicyTask(version).then((data) => { + indexedPolicy = data; + }); + }); + }); + + beforeEach(() => { + login(); + }); + + after(() => { + if (indexedPolicy) { + cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy); + } + }); + + const createArtifactBodyRequest = (type: 'match' | 'match_any') => { + return { + list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id, + entries: [ + { + entries: [ + { + field: 'subject_name', + value: type === 'match' ? 'Elastic, Inc.' : ['Elastic', 'Inc.'], + type, + operator: 'included', + }, + ], + field: 'file.Ext.code_signature', + type: 'nested', + }, + ], + os_types: ['windows'], + }; + }; + + describe('Renders blocklist fields', () => { + it('Correctly renders all blocklist fields for different OSs', () => { + openBlocklist({ create: true }); + + selectOs('windows'); + + selectPathField(); + expectSingleOperator('Path'); + selectSignatureField(); + expectMultiOperator('Signature'); + selectHashField(); + expectSingleOperator('Hash'); + + selectOs('linux'); + + selectPathField(false); + expectSingleOperator('Path'); + selectHashField(); + expectSingleOperator('Hash'); + + selectOs('macos'); + + selectPathField(); + expectSingleOperator('Path'); + selectHashField(); + expectSingleOperator('Hash'); + }); + + it('Correctly modifies value format based on field selection', () => { + openBlocklist({ create: true }); + // Start with default is one of operator + selectSignatureField(); + expectMultiOperator('Signature', 'is one of'); + setMultiValue(); + validateMultiValue(); + // Switch to is operator + selectOperator('is'); + expectMultiOperator('Signature', 'is'); + validateSingleValue(); + // Switch to different Field to reset value to multi value again + selectPathField(); + expectSingleOperator('Path'); + validateMultiValue(); + }); + + it('Correctly validates value input', () => { + openBlocklist({ create: true }); + fillOutBlocklistFlyout(); + selectSignatureField(); + + expectSubmitButtonToBe('disabled'); + + selectOperator('is'); + selectOperator('is'); + validateSingleValue(''); + expectSubmitButtonToBe('disabled'); + + selectOperator('is one of'); + selectOperator('is one of'); + validateMultiValue({ empty: true }); + + selectOperator('is'); + selectOperator('is'); + validateSingleValue(''); + expectSubmitButtonToBe('disabled'); + + setSingleValue(); + validateSingleValue(); + expectSubmitButtonToBe('enabled'); + + selectOperator('is one of'); + validateMultiValue(); + expectSubmitButtonToBe('enabled'); + + selectOperator('is one of'); + validateMultiValue(); + expectSubmitButtonToBe('enabled'); + + clearMultiValueInput(); + expectSubmitButtonToBe('disabled'); + + selectOperator('is'); + validateSingleValue(''); + expectSubmitButtonToBe('disabled'); + }); + }); + + describe('Handles CRUD with operator field', () => { + const IS_EXPECTED_CONDITION = /AND\s*file.Ext.code_signature\s*IS\s*Elastic,\s*Inc./; + const IS_ONE_OF_EXPECTED_CONDITION = + /AND\s*file.Ext.code_signature\s*is\s*one\s*of\s*Elastic\s*Inc./; + + afterEach(() => { + removeExceptionsList(ENDPOINT_ARTIFACT_LISTS.blocklists.id); + }); + + it('Create a blocklist item with single operator', () => { + openBlocklist({ create: true }); + fillOutBlocklistFlyout(); + selectSignatureField(); + selectOperator('is'); + setSingleValue(); + submitBlocklist(); + validateSuccessPopup('create'); + validateRenderedCondition(IS_EXPECTED_CONDITION); + }); + + it('Create a blocklist item with multi operator', () => { + openBlocklist({ create: true }); + fillOutBlocklistFlyout(); + selectSignatureField(); + selectOperator('is one of'); + setMultiValue(); + submitBlocklist(); + validateSuccessPopup('create'); + validateRenderedCondition(IS_ONE_OF_EXPECTED_CONDITION); + }); + + describe('Updates and deletes blocklist match_any item', () => { + let itemId: string; + + beforeEach(() => { + createArtifactList(ENDPOINT_ARTIFACT_LISTS.blocklists.id); + createPerPolicyArtifact('Test Blocklist', createArtifactBodyRequest('match_any')).then( + (response) => { + itemId = response.body.item_id; + } + ); + }); + + it('Updates a match_any blocklist item', () => { + openBlocklist({ itemId }); + selectOperator('is'); + submitBlocklist(); + validateSuccessPopup('update'); + validateRenderedCondition(IS_EXPECTED_CONDITION); + }); + + it('Deletes a blocklist item', () => { + openBlocklist(); + deleteBlocklistItem(); + validateSuccessPopup('delete'); + }); + }); + + describe('Updates and deletes blocklist match item', () => { + let itemId: string; + + beforeEach(() => { + createArtifactList(ENDPOINT_ARTIFACT_LISTS.blocklists.id); + createPerPolicyArtifact('Test Blocklist', createArtifactBodyRequest('match')).then( + (response) => { + itemId = response.body.item_id; + } + ); + }); + + it('Updates a match blocklist item', () => { + openBlocklist({ itemId }); + selectOperator('is one of'); + submitBlocklist(); + validateSuccessPopup('update'); + validateRenderedCondition(IS_ONE_OF_EXPECTED_CONDITION); + }); + + it('Deletes a blocklist item', () => { + openBlocklist(); + deleteBlocklistItem(); + validateSuccessPopup('delete'); + }); + }); + }); + } +); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/artifacts.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/artifacts.ts index 62b083cf218890..fdffa0bd03381a 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/artifacts.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/artifacts.ts @@ -18,7 +18,8 @@ import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL, } from '@kbn/securitysolution-list-constants'; -import { request } from './common'; +import { APP_BLOCKLIST_PATH } from '../../../../common/constants'; +import { loadPage, request } from './common'; export const removeAllArtifacts = () => { for (const listId of ENDPOINT_ARTIFACT_LIST_IDS) { @@ -80,7 +81,7 @@ export const createArtifactList = (listId: string) => { }); }; -export const createPerPolicyArtifact = (name: string, body: object, policyId?: 'all' | string) => { +export const createPerPolicyArtifact = (name: string, body: object, policyId?: 'all' | string) => request({ method: 'POST', url: EXCEPTION_LIST_ITEM_URL, @@ -95,8 +96,8 @@ export const createPerPolicyArtifact = (name: string, body: object, policyId?: ' }).then((response) => { expect(response.status).to.eql(200); expect(response.body.name).to.eql(name); + return response; }); -}; export const yieldFirstPolicyID = (): Cypress.Chainable => request({ @@ -106,3 +107,125 @@ export const yieldFirstPolicyID = (): Cypress.Chainable => expect(body.items.length).to.be.least(1); return body.items[0].id; }); + +export const blocklistFormSelectors = { + expectSingleOperator: (field: 'Path' | 'Signature' | 'Hash') => { + cy.getByTestSubj('blocklist-form-field-select').contains(field); + cy.getByTestSubj('blocklist-form-operator-select-single').should('have.value', 'is one of'); + cy.getByTestSubj('blocklist-form-operator-select-single').should('have.attr', 'readonly'); + cy.getByTestSubj('blocklist-form-operator-select-multi').should('not.exist'); + }, + expectMultiOperator: (field: 'Path' | 'Signature' | 'Hash', type = 'is one of') => { + cy.getByTestSubj('blocklist-form-field-select').contains(field); + cy.getByTestSubj('blocklist-form-operator-select-multi').contains(type); + cy.getByTestSubj('blocklist-form-operator-select-multi').should('not.have.attr', 'readonly'); + cy.getByTestSubj('blocklist-form-operator-select-single').should('not.exist'); + }, + selectPathField: (caseless = true) => { + cy.getByTestSubj('blocklist-form-field-select').click(); + cy.getByTestSubj( + caseless ? 'blocklist-form-file.path.caseless' : 'blocklist-form-file.path' + ).click(); + }, + selectSignatureField: () => { + cy.getByTestSubj('blocklist-form-field-select').click(); + cy.getByTestSubj('blocklist-form-file.Ext.code_signature').click(); + }, + selectOs: (os: 'windows' | 'macos' | 'linux') => { + cy.getByTestSubj('blocklist-form-os-select').click(); + cy.get(`button[role="option"][id="${os}"]`).click(); + }, + selectOperator: (operator: 'is one of' | 'is') => { + const matchOperator = operator === 'is' ? 'match' : 'match_any'; + cy.getByTestSubj('blocklist-form-operator-select-multi').click(); + cy.get(`button[role="option"][id="${matchOperator}"]`).click(); + }, + selectHashField: () => { + cy.getByTestSubj('blocklist-form-field-select').click(); + cy.getByTestSubj('blocklist-form-file.hash.*').click(); + }, + openBlocklist: ({ create, itemId }: { create?: boolean; itemId?: string } = {}) => { + if (!create && !itemId) { + loadPage(APP_BLOCKLIST_PATH); + } else if (create) { + loadPage(`${APP_BLOCKLIST_PATH}?show=create`); + } else if (itemId) { + loadPage(`${APP_BLOCKLIST_PATH}?itemId=${itemId}&show=edit`); + } + }, + fillOutBlocklistFlyout: () => { + cy.getByTestSubj('blocklist-form-name-input').type('Test Blocklist'); + cy.getByTestSubj('blocklist-form-description-input').type('Test Description'); + }, + setMultiValue: () => { + cy.getByTestSubj('blocklist-form-values-input').within(() => { + cy.getByTestSubj('comboBoxSearchInput').type(`Elastic, Inc.{enter}`); + }); + }, + setSingleValue: () => { + cy.getByTestSubj('blocklist-form-value-input').type('Elastic, Inc.'); + }, + validateMultiValue: ({ empty } = { empty: false }) => { + if (!empty) { + cy.getByTestSubj('blocklist-form-values-input').within(() => { + cy.getByTestSubj('comboBoxInput').within(() => { + cy.getByTestSubj('blocklist-form-values-input-Elastic'); + cy.getByTestSubj('blocklist-form-values-input- Inc.'); + }); + }); + } else { + cy.getByTestSubj('blocklist-form-values-input').within(() => { + cy.getByTestSubj('comboBoxInput').children('span').should('not.exist'); + }); + } + }, + validateSingleValue: (value = 'Elastic, Inc.') => { + cy.getByTestSubj('blocklist-form-value-input').should('have.value', value); + }, + submitBlocklist: () => { + cy.getByTestSubj('blocklistPage-flyout-submitButton').click(); + }, + expectSubmitButtonToBe: (state: 'disabled' | 'enabled') => { + cy.getByTestSubj('blocklistPage-flyout-submitButton').should( + state === 'disabled' ? 'be.disabled' : 'not.be.disabled' + ); + }, + clearMultiValueInput: () => { + cy.getByTestSubj('comboBoxClearButton').click(); + }, + validateSuccessPopup: (type: 'create' | 'update' | 'delete') => { + let expectedTitle = ''; + switch (type) { + case 'create': + expectedTitle = '"Test Blocklist" has been added to your blocklist.'; + break; + case 'update': + expectedTitle = '"Test Blocklist" has been updated'; + break; + case 'delete': + expectedTitle = '"Test Blocklist" has been removed from blocklist.'; + break; + } + cy.getByTestSubj('euiToastHeader__title').contains(expectedTitle); + }, + validateRenderedCondition: (expectedCondition: RegExp) => { + cy.getByTestSubj('blocklistPage-card') + .first() + .within(() => { + cy.getByTestSubj('blocklistPage-card-criteriaConditions-condition') + .invoke('text') + // .should('match', /OS\s*IS\s*Windows/); + .should('match', expectedCondition); + }); + }, + deleteBlocklistItem: () => { + cy.getByTestSubj('blocklistPage-card') + .first() + .within(() => { + cy.getByTestSubj('blocklistPage-card-header-actions-button').click(); + }); + + cy.getByTestSubj('blocklistPage-card-cardDeleteAction').click(); + cy.getByTestSubj('blocklistPage-deleteModal-submitButton').click(); + }, +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts index 60e87de41bf215..03d07a19aa924f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts @@ -69,6 +69,13 @@ export const VALUE_LABEL_HELPER = i18n.translate( } ); +export const SINGLE_VALUE_LABEL_HELPER = i18n.translate( + 'xpack.securitySolution.blocklist.single_value.label.helper', + { + defaultMessage: 'Type or copy & paste a value', + } +); + export const CONDITION_FIELD_TITLE: { [K in BlocklistConditionEntryField]: string } = { 'file.hash.*': i18n.translate('xpack.securitySolution.blocklist.entry.field.hash', { defaultMessage: 'Hash', diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx index b119ac81f0a8bc..21798594641d8b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx @@ -11,7 +11,6 @@ import userEvent, { type UserEvent } from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import type { BlocklistConditionEntryField } from '@kbn/securitysolution-utils'; import { OperatingSystem } from '@kbn/securitysolution-utils'; -import { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants'; import type { BlocklistEntry } from './blocklist_form'; import { BlockListForm } from './blocklist_form'; @@ -25,6 +24,8 @@ import { ERRORS } from '../../translations'; import { licenseService } from '../../../../../common/hooks/use_license'; import type { PolicyData } from '../../../../../../common/endpoint/types'; import { GLOBAL_ARTIFACT_TAG } from '../../../../../../common/endpoint/service/artifacts'; +import { ListOperatorEnum, ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; jest.mock('../../../../../common/hooks/use_license', () => { const licenseServiceInstance = { @@ -38,6 +39,58 @@ jest.mock('../../../../../common/hooks/use_license', () => { }; }); +const blocklistOperatorFieldTestCases = [ + { + os: OperatingSystem.LINUX, + field: 'file.path', + fieldText: 'Path, ', + osText: 'Linux, ', + isMulti: false, + }, + { + os: OperatingSystem.LINUX, + field: 'file.hash.*', + fieldText: 'Hash, ', + osText: 'Linux, ', + isMulti: false, + }, + { + os: OperatingSystem.WINDOWS, + field: 'file.path.caseless', + fieldText: 'Path, ', + osText: 'Windows, ', + isMulti: false, + }, + { + os: OperatingSystem.WINDOWS, + field: 'file.hash.*', + fieldText: 'Hash, ', + osText: 'Windows, ', + isMulti: false, + }, + { + os: OperatingSystem.WINDOWS, + field: 'file.Ext.code_signature', + fieldText: 'Signature, ', + osText: 'Windows, ', + isMulti: true, + }, + { + os: OperatingSystem.MAC, + field: 'file.path.caseless', + fieldText: 'Path, ', + osText: 'Mac, ', + isMulti: false, + }, + { + os: OperatingSystem.MAC, + field: 'file.hash.*', + fieldText: 'Hash, ', + osText: 'Mac, ', + isMulti: false, + }, +]; + describe('blocklist form', () => { let user: UserEvent; let onChangeSpy: jest.Mock; @@ -47,8 +100,8 @@ describe('blocklist form', () => { function createEntry(field: BlocklistConditionEntryField, value: string[]): BlocklistEntry { return { field, - operator: 'included', - type: 'match_any', + operator: ListOperatorEnum.INCLUDED, + type: ListOperatorTypeEnum.MATCH_ANY, value, }; } @@ -57,7 +110,7 @@ describe('blocklist form', () => { overrides: Partial = {} ): ArtifactFormComponentProps['item'] { const defaults: ArtifactFormComponentProps['item'] = { - list_id: ENDPOINT_BLOCKLISTS_LIST_ID, + list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id, name: '', description: '', entries: [], @@ -197,6 +250,41 @@ describe('blocklist form', () => { expect(screen.getByTestId('blocklist-form-field-select').textContent).toEqual('Hash, '); }); + describe.each(blocklistOperatorFieldTestCases)( + 'should correctly render operator field for $os OS, $fieldText', + ({ os, field, fieldText, osText, isMulti }) => { + it(`should correctly render operator field for ${os} OS, ${fieldText}`, () => { + const validItem: ArtifactFormComponentProps['item'] = { + list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id, + name: 'test name', + description: 'test description', + entries: [createEntry(field as BlocklistConditionEntryField, isMulti ? ['hello'] : [])], + os_types: [os], + tags: [GLOBAL_ARTIFACT_TAG], + type: 'simple', + }; + + render(createProps({ item: validItem })); + expect(screen.getByTestId('blocklist-form-os-select').textContent).toEqual(osText); + expect(screen.getByTestId('blocklist-form-field-select').textContent).toEqual(fieldText); + + if (isMulti) { + expect(screen.queryByTestId('blocklist-form-operator-select-single')).toBeNull(); + const element = screen.getByTestId('blocklist-form-operator-select-multi'); + expect(element).toBeTruthy(); + expect(element.textContent).toEqual('is one of, '); + expect(element).not.toHaveAttribute('readonly'); + } else { + expect(screen.queryByTestId('blocklist-form-operator-select-multi')).toBeNull(); + const element = screen.getByTestId('blocklist-form-operator-select-single'); + expect(element).toBeTruthy(); + expect(element).toHaveValue('is one of'); + expect(element).toHaveAttribute('readonly'); + } + }); + } + ); + it('should allow all 3 fields when Windows OS is selected', async () => { render(); expect(screen.getByTestId('blocklist-form-os-select').textContent).toEqual('Windows, '); @@ -444,7 +532,7 @@ describe('blocklist form', () => { it('should be valid if all required inputs complete', async () => { const validItem: ArtifactFormComponentProps['item'] = { - list_id: ENDPOINT_BLOCKLISTS_LIST_ID, + list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id, name: 'test name', description: 'test description', entries: [ diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx index ee59fd26714e37..d4640aed42c114 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx @@ -25,9 +25,10 @@ import { } from '@elastic/eui'; import type { BlocklistConditionEntryField } from '@kbn/securitysolution-utils'; import { OperatingSystem, isPathValid } from '@kbn/securitysolution-utils'; -import { isOneOfOperator } from '@kbn/securitysolution-list-utils'; +import { isOneOfOperator, isOperator } from '@kbn/securitysolution-list-utils'; import { uniq } from 'lodash'; +import { ListOperatorEnum, ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { OS_TITLES } from '../../../../common/translations'; import type { ArtifactFormComponentProps } from '../../../../components/artifact_list_page'; import { @@ -46,6 +47,7 @@ import { VALUE_LABEL, ERRORS, VALUE_LABEL_HELPER, + SINGLE_VALUE_LABEL_HELPER, } from '../../translations'; import type { EffectedPolicySelection } from '../../../../components/effected_policy_select'; import { EffectedPolicySelect } from '../../../../components/effected_policy_select'; @@ -60,13 +62,22 @@ import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator'; const testIdPrefix = 'blocklist-form'; -export interface BlocklistEntry { +interface BlocklistEntryMatch { field: BlocklistConditionEntryField; - operator: 'included'; - type: 'match_any'; + operator: ListOperatorEnum.INCLUDED; + type: ListOperatorTypeEnum.MATCH; + value: string; +} + +interface BlocklistEntryMatchAny { + field: BlocklistConditionEntryField; + operator: ListOperatorEnum.INCLUDED; + type: ListOperatorTypeEnum.MATCH_ANY; value: string[]; } +export type BlocklistEntry = BlocklistEntryMatch | BlocklistEntryMatchAny; + type ERROR_KEYS = keyof typeof ERRORS; type ItemValidationNodes = { @@ -142,14 +153,19 @@ export const BlockListForm = memo( if (!item.entries.length) { return { field: 'file.hash.*', - operator: 'included', - type: 'match_any', + operator: ListOperatorEnum.INCLUDED, + type: ListOperatorTypeEnum.MATCH_ANY, value: [], }; } return item.entries[0] as BlocklistEntry; }, [item.entries]); + const windowsSignatureField = 'file.Ext.code_signature'; + const isWindowsSignatureEntry = blocklistEntry.field === windowsSignatureField; + const displaySingleValueInput = + isWindowsSignatureEntry && blocklistEntry.type === ListOperatorTypeEnum.MATCH; + const selectedOs = useMemo((): OperatingSystem => { if (!item?.os_types?.length) { return OperatingSystem.WINDOWS; @@ -159,10 +175,19 @@ export const BlockListForm = memo( }, [item?.os_types]); const selectedValues = useMemo(() => { - return blocklistEntry.value.map((label) => ({ - label, - 'data-test-subj': getTestId(`values-input-${label}`), - })); + if (Array.isArray(blocklistEntry.value)) { + return blocklistEntry.value.map((label) => ({ + label, + 'data-test-subj': getTestId(`values-input-${label}`), + })); + } else { + return [ + { + label: blocklistEntry.value, + 'data-test-subj': getTestId(`values-input-${blocklistEntry.value}`), + }, + ]; + } }, [blocklistEntry.value, getTestId]); const osOptions: Array> = useMemo( @@ -202,40 +227,59 @@ export const BlockListForm = memo( if (selectedOs === OperatingSystem.WINDOWS) { selectableFields.push({ - value: 'file.Ext.code_signature', - inputDisplay: CONDITION_FIELD_TITLE['file.Ext.code_signature'], - dropdownDisplay: getDropdownDisplay('file.Ext.code_signature'), - 'data-test-subj': getTestId('file.Ext.code_signature'), + value: windowsSignatureField, + inputDisplay: CONDITION_FIELD_TITLE[windowsSignatureField], + dropdownDisplay: getDropdownDisplay(windowsSignatureField), + 'data-test-subj': getTestId(windowsSignatureField), }); } return selectableFields; }, [selectedOs, getTestId]); + const operatorOptions: Array> = useMemo(() => { + return [ + { + value: isOneOfOperator.type, + inputDisplay: isOneOfOperator.message, + dropdownDisplay: isOneOfOperator.message, + }, + { + value: isOperator.type, + inputDisplay: isOperator.message, + dropdownDisplay: isOperator.message, + }, + ]; + }, []); + const valueLabel = useMemo(() => { return (
- + <> {VALUE_LABEL}
); - }, []); + }, [displaySingleValueInput]); const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; const { field = 'file.hash.*', - type = 'match_any', - value: values = [], + type = ListOperatorTypeEnum.MATCH_ANY, + value = [], } = (nextItem.entries[0] ?? {}) as BlocklistEntry; + // value can be a string when isOperator is selected + const values = Array.isArray(value) ? value : [value].filter(Boolean); + const newValueWarnings: ItemValidationNodes = {}; const newNameErrors: ItemValidationNodes = {}; const newValueErrors: ItemValidationNodes = {}; - // error if name empty if (!nextItem.name.trim()) { newNameErrors.NAME_REQUIRED = createValidationMessage(ERRORS.NAME_REQUIRED); @@ -247,17 +291,15 @@ export const BlockListForm = memo( } // error if invalid hash - if (field === 'file.hash.*' && values.some((value) => !isValidHash(value))) { + if (field === 'file.hash.*' && values.some((v) => !isValidHash(v))) { newValueErrors.INVALID_HASH = createValidationMessage(ERRORS.INVALID_HASH); } - const isInvalidPath = values.some((value) => !isPathValid({ os, field, type, value })); - + const isInvalidPath = values.some((v) => !isPathValid({ os, field, type, value: v })); // warn if invalid path if (field !== 'file.hash.*' && isInvalidPath) { newValueWarnings.INVALID_PATH = createValidationMessage(ERRORS.INVALID_PATH); } - // warn if duplicates if (values.length !== uniq(values).length) { newValueWarnings.DUPLICATE_VALUES = createValidationMessage(ERRORS.DUPLICATE_VALUES); @@ -320,12 +362,16 @@ export const BlockListForm = memo( { ...blocklistEntry, field: - os !== OperatingSystem.WINDOWS && blocklistEntry.field === 'file.Ext.code_signature' + os !== OperatingSystem.WINDOWS && isWindowsSignatureEntry ? 'file.hash.*' : blocklistEntry.field, + type: ListOperatorTypeEnum.MATCH_ANY, + ...(typeof blocklistEntry.value === 'string' + ? { value: blocklistEntry.value.length ? blocklistEntry.value.split(',') : [] } + : {}), }, ], - }; + } as ArtifactFormComponentProps['item']; validateValues(nextItem); onChange({ @@ -334,15 +380,24 @@ export const BlockListForm = memo( }); setHasFormChanged(true); }, - [validateValues, blocklistEntry, onChange, item] + [item, blocklistEntry, isWindowsSignatureEntry, validateValues, onChange] ); const handleOnFieldChange = useCallback( (field: BlocklistConditionEntryField) => { const nextItem = { ...item, - entries: [{ ...blocklistEntry, field }], - }; + entries: [ + { + ...blocklistEntry, + field, + type: ListOperatorTypeEnum.MATCH_ANY, + ...(typeof blocklistEntry.value === 'string' + ? { value: blocklistEntry.value.length ? blocklistEntry.value.split(',') : [] } + : {}), + }, + ], + } as ArtifactFormComponentProps['item']; validateValues(nextItem); onChange({ @@ -354,6 +409,40 @@ export const BlockListForm = memo( [validateValues, onChange, item, blocklistEntry] ); + const generateBlocklistEntryValue = useCallback( + (value: string | string[], newOperator: ListOperatorTypeEnum) => { + if (newOperator === ListOperatorTypeEnum.MATCH) { + return { value: Array.isArray(value) ? value.join(',') : value }; + } else { + return { + value: (typeof value === 'string' ? value.split(',') : value).filter(Boolean), + }; + } + }, + [] + ); + + const handleOperatorUpdate = useCallback( + (newOperator) => { + const nextItem = { + ...item, + entries: [ + { + ...blocklistEntry, + type: newOperator, + ...generateBlocklistEntryValue(blocklistEntry.value, newOperator), + }, + ], + }; + + onChange({ + isValid: isValid(errorsRef.current), + item: nextItem, + }); + }, + [item, blocklistEntry, generateBlocklistEntryValue, onChange] + ); + const handleOnValueTextChange = useCallback( (value: string) => { const nextWarnings = { ...warningsRef.current.value }; @@ -375,6 +464,24 @@ export const BlockListForm = memo( [blocklistEntry] ); + const handleSingleValueUpdate = useCallback( + (event: React.ChangeEvent) => { + const nextItem = { + ...item, + entries: [{ ...blocklistEntry, value: event.target.value }], + } as ArtifactFormComponentProps['item']; + + validateValues(nextItem); + + onChange({ + isValid: isValid(errorsRef.current), + item: nextItem, + }); + setHasFormChanged(true); + }, + [item, blocklistEntry, validateValues, onChange] + ); + // only triggered on remove / clear const handleOnValueChange = useCallback( (change: Array>) => { @@ -382,7 +489,7 @@ export const BlockListForm = memo( const nextItem = { ...item, entries: [{ ...blocklistEntry, value }], - }; + } as ArtifactFormComponentProps['item']; validateValues(nextItem); onChange({ @@ -404,13 +511,13 @@ export const BlockListForm = memo( entries: [{ ...blocklistEntry, value }], }; - validateValues(nextItem); + validateValues(nextItem as ArtifactFormComponentProps['item']); nextItem.entries[0].value = uniq(nextItem.entries[0].value); setVisited((prevVisited) => ({ ...prevVisited, value: true })); onChange({ isValid: isValid(errorsRef.current), - item: nextItem, + item: nextItem as ArtifactFormComponentProps['item'], }); setHasFormChanged(true); }, @@ -516,7 +623,23 @@ export const BlockListForm = memo( - + {isWindowsSignatureEntry ? ( + + ) : ( + + )} @@ -529,16 +652,27 @@ export const BlockListForm = memo( error={Object.values(errorsRef.current.value)} fullWidth > - + {displaySingleValueInput ? ( + + ) : ( + + )} {showAssignmentSection && ( diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts index 3841c6f0e9b67d..d5c66dc2c8e6e5 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -6,7 +6,6 @@ */ import { cloneDeep, uniq } from 'lodash'; -import { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants'; import type { Type, TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; @@ -15,6 +14,7 @@ import type { CreateExceptionListItemOptions, UpdateExceptionListItemOptions, } from '@kbn/lists-plugin/server'; +import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; import { BaseValidator } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; import { isValidHash } from '../../../../common/endpoint/service/artifacts/validations'; @@ -44,9 +44,9 @@ type ConditionEntryFieldAllowedType = type BlocklistConditionEntry = | { field: ConditionEntryFieldAllowedType; - type: 'match_any'; + type: 'match_any' | 'match'; operator: 'included'; - value: string[]; + value: string[] | string; } | TypeOf; @@ -97,8 +97,13 @@ const WindowsSignerEntrySchema = schema.object({ entries: schema.arrayOf( schema.object({ field: schema.literal('subject_name'), - value: schema.arrayOf(schema.string({ minLength: 1 })), - type: schema.literal('match_any'), + value: schema.conditional( + schema.siblingRef('type'), + schema.literal('match'), + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 })) + ), + type: schema.oneOf([schema.literal('match'), schema.literal('match_any')]), operator: schema.literal('included'), }), { minSize: 1 } @@ -214,7 +219,7 @@ function removeDuplicateEntryValues(entries: BlocklistConditionEntry[]): Blockli export class BlocklistValidator extends BaseValidator { static isBlocklist(item: { listId: string }): boolean { - return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID; + return item.listId === ENDPOINT_ARTIFACT_LISTS.blocklists.id; } protected async validateHasWritePrivilege(): Promise { @@ -230,7 +235,9 @@ export class BlocklistValidator extends BaseValidator { ): Promise { await this.validateHasWritePrivilege(); - item.entries = removeDuplicateEntryValues(item.entries as BlocklistConditionEntry[]); + (item.entries as BlocklistConditionEntry[]) = removeDuplicateEntryValues( + item.entries as BlocklistConditionEntry[] + ); await this.validateBlocklistData(item); await this.validateCanCreateByPolicyArtifacts(item); @@ -271,7 +278,7 @@ export class BlocklistValidator extends BaseValidator { await this.validateHasWritePrivilege(); - _updatedItem.entries = removeDuplicateEntryValues( + (_updatedItem.entries as BlocklistConditionEntry[]) = removeDuplicateEntryValues( _updatedItem.entries as BlocklistConditionEntry[] ); diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/blocklists.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/blocklists.ts index 1641f9821c7545..1f250f76a2bfc9 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/blocklists.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/blocklists.ts @@ -213,6 +213,58 @@ export default function ({ getService }: FtrProviderContext) { ); }); + it(`should error on [${blocklistApiCall.method}] if signer is set to match_any and a string is provided`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['windows']; + body.entries = [ + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: 'foo' as unknown as string[], + type: 'match_any', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await endpointPolicyManagerSupertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anErrorMessageWith(/^.*(?!file\.Ext\.code_signature)/)); + }); + + it(`should error on [${blocklistApiCall.method}] if signer is set to match and an array is provided`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['windows']; + body.entries = [ + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: ['foo'] as unknown as string, + type: 'match', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await endpointPolicyManagerSupertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anErrorMessageWith(/^.*(?!file\.Ext\.code_signature)/)); + }); + it(`should error on [${blocklistApiCall.method}] if signer is set for a non windows os entry item`, async () => { const body = blocklistApiCall.getBody();