diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index 5304c3a710613..5dd3adc32a826 100644 --- a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -10,10 +10,12 @@ import { hasSimpleExecutableName, OperatingSystem, ConditionEntryField, + TrustedDeviceConditionEntryField, validateWildcardInput, validateHasWildcardWithWrongOperator, validatePotentialWildcardInput, validateFilePathInput, + isTrustedDeviceFieldAvailableForOs, WILDCARD_WARNING, FILEPATH_WARNING, } from '.'; @@ -756,3 +758,86 @@ describe('hasSimpleExecutableName', () => { ).toEqual(false); }); }); + +describe('isTrustedDeviceFieldAvailableForOs', () => { + describe('USERNAME field availability', () => { + it('should return true for USERNAME field when Windows OS is selected exclusively', () => { + expect( + isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, [ + OperatingSystem.WINDOWS, + ]) + ).toBe(true); + }); + + it('should return false for USERNAME field when Mac OS is selected exclusively', () => { + expect( + isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, [ + OperatingSystem.MAC, + ]) + ).toBe(false); + }); + + it('should return false for USERNAME field when both Windows and Mac OS are selected', () => { + expect( + isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, [ + OperatingSystem.WINDOWS, + OperatingSystem.MAC, + ]) + ).toBe(false); + }); + + it('should return false for USERNAME field when Mac and Windows OS are selected (different order)', () => { + expect( + isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, [ + OperatingSystem.MAC, + OperatingSystem.WINDOWS, + ]) + ).toBe(false); + }); + + it('should return false for USERNAME field when empty OS array is provided', () => { + expect( + isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, []) + ).toBe(false); + }); + }); + + describe('Other fields availability', () => { + const commonFields = [ + TrustedDeviceConditionEntryField.HOST, + TrustedDeviceConditionEntryField.DEVICE_ID, + TrustedDeviceConditionEntryField.MANUFACTURER, + TrustedDeviceConditionEntryField.PRODUCT_ID, + ]; + + it.each(commonFields)( + 'should return true for %s field when Windows OS is selected exclusively', + (field) => { + expect(isTrustedDeviceFieldAvailableForOs(field, [OperatingSystem.WINDOWS])).toBe(true); + } + ); + + it.each(commonFields)( + 'should return true for %s field when Mac OS is selected exclusively', + (field) => { + expect(isTrustedDeviceFieldAvailableForOs(field, [OperatingSystem.MAC])).toBe(true); + } + ); + + it.each(commonFields)( + 'should return true for %s field when both Windows and Mac OS are selected', + (field) => { + expect( + isTrustedDeviceFieldAvailableForOs(field, [OperatingSystem.WINDOWS, OperatingSystem.MAC]) + ).toBe(true); + } + ); + + it.each(commonFields)( + 'should return true for %s field when empty OS array is provided', + (field) => { + expect(isTrustedDeviceFieldAvailableForOs(field, [])).toBe(true); + } + ); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.ts index bf5b4f9abc5c9..b817a8000905b 100644 --- a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -63,6 +63,32 @@ export type EntryTypes = 'match' | 'wildcard' | 'match_any'; export type TrustedAppEntryTypes = Extract; export type EventFiltersTypes = EntryTypes | 'exists' | 'nested'; +export const TRUSTED_DEVICE_OS_FIELD_AVAILABILITY = { + /** Fields available for all supported operating systems */ + ALL_OS: [ + TrustedDeviceConditionEntryField.HOST, + TrustedDeviceConditionEntryField.DEVICE_ID, + TrustedDeviceConditionEntryField.MANUFACTURER, + TrustedDeviceConditionEntryField.PRODUCT_ID, + ] as const, + + /** Fields available only for Windows OS exclusively */ + WINDOWS_ONLY: [TrustedDeviceConditionEntryField.USERNAME] as const, +} as const; + +export function isTrustedDeviceFieldAvailableForOs( + field: TrustedDeviceConditionEntryField, + osTypes: readonly string[] +): boolean { + const { WINDOWS_ONLY, ALL_OS } = TRUSTED_DEVICE_OS_FIELD_AVAILABILITY; + + if (WINDOWS_ONLY.includes(field as (typeof WINDOWS_ONLY)[number])) { + return osTypes.length === 1 && osTypes.includes(OperatingSystem.WINDOWS); + } + + return ALL_OS.includes(field as (typeof ALL_OS)[number]); +} + export const validatePotentialWildcardInput = ({ field = '', os, diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index fd9fc98e8bfed..6922c8c8bb17f 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -410,18 +410,22 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = {}): ExceptionListItemSchema { + // Use HOST field by default for compatibility with all OS types + // USERNAME field can only be used with Windows-only OS + const defaultEntries: ExceptionListItemSchema['entries'] = [ + { + field: 'host.name', + operator: 'included' as const, + type: 'match' as const, + value: `host_${this.randomString(5)}`, + }, + ]; + return this.generate({ name: `Trusted device (${this.randomString(5)})`, list_id: ENDPOINT_ARTIFACT_LISTS.trustedDevices.id, os_types: this.randomChoice([['windows'], ['macos'], ['windows', 'macos']]), - entries: [ - { - field: 'user.name', - operator: 'included', - type: 'match', - value: `user_${this.randomString(5)}`, - }, - ], + entries: defaultEntries, ...overrides, }); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/trusted_devices.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/trusted_devices.cy.ts index 574cab36ed474..b72142ac43820 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/trusted_devices.cy.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/trusted_devices.cy.ts @@ -72,23 +72,23 @@ describe( value: 'test-user', }, ], - os_types: ['windows', 'macos'], + os_types: ['windows'], }); describe('Renders Trusted Devices form fields', () => { - it('Correctly renders trusted devices form for Windows and Mac', () => { + it('Correctly renders trusted devices form for Windows only with Username field', () => { openTrustedDevices({ create: true }); - selectOs('Windows and Mac'); + selectOs('Windows'); selectField('Username'); selectOperator('is'); fillValue('test-user'); }); - it('Renders all field options correctly', () => { + it('Renders all field options correctly for Windows only', () => { openTrustedDevices({ create: true }); - selectOs('Windows and Mac'); + selectOs('Windows'); const fields: Array<'Username' | 'Host' | 'Device ID' | 'Manufacturer' | 'Product ID'> = [ 'Username', @@ -103,6 +103,26 @@ describe( cy.getByTestSubj('trustedDevices-form-fieldSelect').should('contain', field); }); }); + + it('Shows limited field options for Windows and Mac (no Username)', () => { + openTrustedDevices({ create: true }); + selectOs('Windows and Mac'); + + cy.getByTestSubj('trustedDevices-form-fieldSelect').click(); + + const availableFields: Array<'Host' | 'Device ID' | 'Manufacturer' | 'Product ID'> = [ + 'Host', + 'Device ID', + 'Manufacturer', + 'Product ID', + ]; + + availableFields.forEach((field) => { + cy.get('[role="option"]').should('contain', field); + }); + + cy.get('[role="option"]').should('not.contain', 'Username'); + }); }); describe('Handles CRUD with device fields', () => { @@ -110,12 +130,12 @@ describe( removeExceptionsList(ENDPOINT_ARTIFACT_LISTS.trustedDevices.id); }); - it('Correctly creates a trusted device with a single username field on Windows and Mac', () => { + it('Correctly creates a trusted device with a single username field on Windows only', () => { const expectedCondition = /user\.name\s*IS\s*test-user/i; openTrustedDevices({ create: true }); fillOutTrustedDevicesFlyout(); - selectOs('Windows and Mac'); + selectOs('Windows'); selectField('Username'); selectOperator('is'); fillValue('test-user'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts index eb4bfd9b4c409..1dff1ddfc4c92 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts @@ -541,18 +541,18 @@ export const getArtifactsListTestsData = (): ArtifactsFixtureType[] => [ }, { type: 'click', - customSelector: '[role="option"]:contains("Username")', + customSelector: '[role="option"]:contains("Host")', }, { type: 'input', selector: 'trustedDevices-form-valueField', - value: 'test-user', + value: 'test-host', }, ], checkResults: [ { selector: 'trustedDevicesList-card-criteriaConditions', - value: ' OSIS Windows, MacAND user.nameIS test-user', + value: ' OSIS Windows, MacAND host.nameIS test-host', }, ], }, @@ -583,13 +583,13 @@ export const getArtifactsListTestsData = (): ArtifactsFixtureType[] => [ { type: 'input', selector: 'trustedDevices-form-valueField', - value: 'updated-user', + value: 'updated-host', }, ], checkResults: [ { selector: 'trustedDevicesList-card-criteriaConditions', - value: ' OSIS Windows, MacAND user.nameIS test-user', + value: ' OSIS Windows, MacAND host.nameIS updated-host', }, { selector: 'trustedDevicesList-card-header-title', @@ -611,10 +611,10 @@ export const getArtifactsListTestsData = (): ArtifactsFixtureType[] => [ list_id: ENDPOINT_ARTIFACT_LISTS.trustedDevices.id, entries: [ { - field: 'user.name', + field: 'host.name', operator: 'included', type: 'match', - value: 'test-user', + value: 'test-host', }, ], os_types: ['windows', 'macos'], diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts index 7dcde4e06d43f..aa4ac3903e4a1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts @@ -239,7 +239,9 @@ export const trustedAppsFormSelectors = { export const trustedDevicesFormSelectors = { selectOs: (osOption: 'Windows and Mac' | 'Windows' | 'Mac') => { cy.getByTestSubj('trustedDevices-form-osSelectField').click(); - cy.get('[role="option"]').contains(osOption).click(); + cy.get('[role="option"]') + .contains(new RegExp(`^${osOption}$`)) + .click(); }, selectField: (field: 'Username' | 'Host' | 'Device ID' | 'Manufacturer' | 'Product ID') => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.test.tsx index 21abdc4bdcf49..f901d433104bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.test.tsx @@ -79,9 +79,9 @@ describe('Trusted devices form', () => { list_id: 'trusted-devices-list-id', name: '', description: '', - // start with Windows to let the component normalize to [Windows, Mac] for create mode + // Use Windows-only OS for USERNAME field compatibility, or HOST field for other combinations os_types: [OperatingSystem.WINDOWS], - entries: [createEntry(TrustedDeviceConditionEntryField.USERNAME, 'match', '')], + entries: [createEntry(TrustedDeviceConditionEntryField.HOST, 'match', '')], type: 'simple', tags: ['policy:all'], meta: { temporaryUuid: 'td-1111' }, @@ -266,7 +266,37 @@ describe('Trusted devices form', () => { expect(labels).toEqual(expect.arrayContaining(['Field', 'Operator', 'Value'])); }); - it('should display 5 options for Field with proper labels', async () => { + it('should display field options based on OS selection', async () => { + // Form defaults to Windows+Mac OS, so USERNAME field should NOT be available + const fieldSelect = getConditionsFieldSelect(); + await userEvent.click(fieldSelect); + + const options = Array.from( + renderResult.baseElement.querySelectorAll( + '.euiSuperSelect__listbox button.euiSuperSelect__item' + ) + ).map((button) => button.textContent?.trim()); + + // USERNAME should not be available with Windows+Mac OS + expect(options).toEqual([ + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.HOST], + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.DEVICE_ID], + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.MANUFACTURER], + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.PRODUCT_ID], + ]); + expect(options).not.toContain( + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.USERNAME] + ); + }); + + it('should show USERNAME field when Windows-only OS is selected', async () => { + // Change to Windows-only OS first + await openOsCombo(); + await userEvent.click( + screen.getByRole('option', { name: OS_TITLES[OperatingSystem.WINDOWS] }) + ); + + // Check field options - USERNAME should now be available const fieldSelect = getConditionsFieldSelect(); await userEvent.click(fieldSelect); @@ -285,6 +315,40 @@ describe('Trusted devices form', () => { ]); }); + it('should hide USERNAME field when Mac-only OS is selected', async () => { + // Change to Mac-only OS first + await openOsCombo(); + await userEvent.click(screen.getByRole('option', { name: OS_TITLES[OperatingSystem.MAC] })); + + // Wait for component to update after OS change + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Re-render to ensure component reflects the OS change + rerenderWithLatestProps(); + + // Check field options - USERNAME should be hidden + const fieldSelect = getConditionsFieldSelect(); + await userEvent.click(fieldSelect); + + const options = Array.from( + renderResult.baseElement.querySelectorAll( + '.euiSuperSelect__listbox button.euiSuperSelect__item' + ) + ).map((button) => button.textContent?.trim()); + + expect(options).toEqual([ + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.HOST], + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.DEVICE_ID], + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.MANUFACTURER], + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.PRODUCT_ID], + ]); + expect(options).not.toContain( + CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.USERNAME] + ); + }); + it('should toggle operator from "is" to "matches" and update entry type to wildcard', async () => { const operatorSelect = getConditionsOperatorSelect(); await userEvent.click(operatorSelect); @@ -335,6 +399,66 @@ describe('Trusted devices form', () => { ) ).toBeTruthy(); }); + + it('should reset USERNAME field to HOST when OS changes from Windows to Mac', async () => { + // Start with USERNAME field and Windows OS + formProps.item = createItem({ + os_types: [OperatingSystem.WINDOWS], + entries: [createEntry(TrustedDeviceConditionEntryField.USERNAME, 'match', 'testuser')], + }); + rerenderWithLatestProps(); + + // Change OS to Mac-only + await openOsCombo(); + await userEvent.click(screen.getByRole('option', { name: OS_TITLES[OperatingSystem.MAC] })); + + // Expect field to be reset to HOST and value cleared + const lastCall = (formProps.onChange as jest.Mock).mock.calls.at(-1)?.[0]; + expect(lastCall?.item.entries?.[0]?.field).toBe(TrustedDeviceConditionEntryField.HOST); + expect(lastCall?.item.entries?.[0]?.value).toBe(''); + expect(lastCall?.item.os_types).toEqual([OperatingSystem.MAC]); + }); + + it('should reset USERNAME field to HOST when OS changes from Windows to Windows+Mac', async () => { + // Start with USERNAME field and Windows OS + formProps.item = createItem({ + os_types: [OperatingSystem.WINDOWS], + entries: [createEntry(TrustedDeviceConditionEntryField.USERNAME, 'match', 'testuser')], + }); + rerenderWithLatestProps(); + + // Change OS to Windows+Mac + await openOsCombo(); + await userEvent.click(screen.getByRole('option', { name: OPERATING_SYSTEM_WINDOWS_AND_MAC })); + + // Expect field to be reset to HOST and value cleared + const lastCall = (formProps.onChange as jest.Mock).mock.calls.at(-1)?.[0]; + expect(lastCall?.item.entries?.[0]?.field).toBe(TrustedDeviceConditionEntryField.HOST); + expect(lastCall?.item.entries?.[0]?.value).toBe(''); + expect(lastCall?.item.os_types).toEqual([OperatingSystem.WINDOWS, OperatingSystem.MAC]); + }); + + it('should preserve HOST field value when OS changes', async () => { + // Start with HOST field and Mac OS + formProps.item = createItem({ + os_types: [OperatingSystem.MAC], + entries: [createEntry(TrustedDeviceConditionEntryField.HOST, 'match', 'myhost')], + }); + latestUpdatedItem = formProps.item; + rerenderWithLatestProps(); + + // Clear onChange calls to get only the OS change call + (formProps.onChange as jest.Mock).mockClear(); + + // Change OS to Windows+Mac - HOST field should be preserved + await openOsCombo(); + await userEvent.click(screen.getByRole('option', { name: OPERATING_SYSTEM_WINDOWS_AND_MAC })); + + // Expect field and value to be preserved (no reset for HOST field) + const lastCall = (formProps.onChange as jest.Mock).mock.calls.at(-1)?.[0]; + expect(lastCall?.item.entries?.[0]?.field).toBe(TrustedDeviceConditionEntryField.HOST); + expect(lastCall?.item.entries?.[0]?.value).toBe('myhost'); + }); }); it('should display effective scope options', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.tsx index d8547bcd129be..94bf070585214 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/components/form.tsx @@ -21,7 +21,11 @@ import { EuiFlexItem, EuiSuperSelect, } from '@elastic/eui'; -import { OperatingSystem, TrustedDeviceConditionEntryField } from '@kbn/securitysolution-utils'; +import { + OperatingSystem, + TrustedDeviceConditionEntryField, + isTrustedDeviceFieldAvailableForOs, +} from '@kbn/securitysolution-utils'; import type { ExceptionListItemSchema, CreateExceptionListItemSchema, @@ -184,116 +188,133 @@ const ConditionsSection = memo<{ disabled, visitedFields, validationResult, - }) => ( - <> - -

{CONDITIONS_HEADER}

-
- - {CONDITIONS_HEADER_DESCRIPTION} - + }) => { + // Get field options based on selected OS + const availableFieldOptions = useMemo(() => { + return getFieldOptionsForOs(selectedOs); + }, [selectedOs]); - - + +

{CONDITIONS_HEADER}

+
+ + {CONDITIONS_HEADER_DESCRIPTION} + + + JSON.stringify(option.value) === JSON.stringify(selectedOs) - )} - onChange={handleOsChange} - isClearable={false} - data-test-subj={getTestId('osSelectField')} - isDisabled={disabled} - /> - - - - - - - - - - - - - - - - - - - - - - - ) + error={visitedFields.os ? validationResult.errors.os : undefined} + helpText={ + visitedFields.os && validationResult.warnings.os + ? validationResult.warnings.os[0] + : undefined + } + > + JSON.stringify(option.value) === JSON.stringify(selectedOs) + )} + onChange={handleOsChange} + isClearable={false} + data-test-subj={getTestId('osSelectField')} + isDisabled={disabled} + /> +
+ + + + + + + + + + + + + + + + + + + + + + ); + } ); ConditionsSection.displayName = 'ConditionsSection'; -const FIELD_OPTIONS = [ - { - value: TrustedDeviceConditionEntryField.USERNAME, - inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.USERNAME], - }, - { - value: TrustedDeviceConditionEntryField.HOST, - inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.HOST], - }, - { - value: TrustedDeviceConditionEntryField.DEVICE_ID, - inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.DEVICE_ID], - }, - { - value: TrustedDeviceConditionEntryField.MANUFACTURER, - inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.MANUFACTURER], - }, - { - value: TrustedDeviceConditionEntryField.PRODUCT_ID, - inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.PRODUCT_ID], - }, -]; +const getFieldOptionsForOs = (osTypes: OsTypeArray) => { + const commonFields = [ + { + value: TrustedDeviceConditionEntryField.HOST, + inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.HOST], + }, + { + value: TrustedDeviceConditionEntryField.DEVICE_ID, + inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.DEVICE_ID], + }, + { + value: TrustedDeviceConditionEntryField.MANUFACTURER, + inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.MANUFACTURER], + }, + { + value: TrustedDeviceConditionEntryField.PRODUCT_ID, + inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.PRODUCT_ID], + }, + ]; + + if (isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, osTypes)) { + return [ + { + value: TrustedDeviceConditionEntryField.USERNAME, + inputDisplay: CONDITION_FIELD_TITLE[TrustedDeviceConditionEntryField.USERNAME], + }, + ...commonFields, + ]; + } + + return commonFields; +}; const OPERATOR_OPTIONS = [ { value: 'is', inputDisplay: OPERATOR_TITLES.is }, @@ -436,21 +457,38 @@ export const TrustedDevicesForm = memo( const handleOsChange = useCallback( (selectedOptions: Array>) => { const osTypes = selectedOptions[0]?.value || []; + const currentEntry = currentItem.entries?.[0]; // Mark that user has explicitly selected an OS setHasUserSelectedOs(true); setHasFormChanged(true); + let fieldToUse = currentEntry?.field || TrustedDeviceConditionEntryField.USERNAME; + let shouldResetValue = false; + + // If current field is USERNAME but USERNAME is not available for new OS selection, reset to HOST + if ( + fieldToUse === TrustedDeviceConditionEntryField.USERNAME && + !isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, osTypes) + ) { + fieldToUse = TrustedDeviceConditionEntryField.HOST; + shouldResetValue = true; + } + const updatedItem = { ...currentItem, os_types: osTypes, entries: [ { - field: TrustedDeviceConditionEntryField.USERNAME, + field: fieldToUse, operator: 'included' as const, type: 'match' as const, - value: '', - }, + value: shouldResetValue + ? '' + : currentEntry && 'value' in currentEntry + ? String(currentEntry.value || '') + : '', + } as const, ], }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts index 4efc1b3b36d4f..7d2fe0e4c75ad 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts @@ -12,7 +12,11 @@ import type { CreateExceptionListItemOptions, UpdateExceptionListItemOptions, } from '@kbn/lists-plugin/server'; -import { TrustedDeviceConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { + TrustedDeviceConditionEntryField, + OperatingSystem, + isTrustedDeviceFieldAvailableForOs, +} from '@kbn/securitysolution-utils'; import { BaseValidator, BasicEndpointExceptionDataSchema } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; import { EndpointArtifactExceptionValidationError } from './errors'; @@ -21,6 +25,8 @@ import { EndpointArtifactExceptionValidationError } from './errors'; const TRUSTED_DEVICE_EMPTY_VALUE_ERROR = 'Field value cannot be empty'; const TRUSTED_DEVICE_DUPLICATE_FIELD_ERROR = 'Duplicate field entries are not allowed'; const TRUSTED_DEVICE_DUPLICATE_OS_ERROR = 'Duplicate OS entries are not allowed'; +const TRUSTED_DEVICE_USERNAME_OS_ERROR = + 'Username field is only supported for Windows OS exclusively. Please select Windows OS only or choose a different field.'; const TrustedDeviceFieldSchema = schema.oneOf([ schema.literal(TrustedDeviceConditionEntryField.USERNAME), @@ -196,5 +202,23 @@ export class TrustedDeviceValidator extends BaseValidator { } catch (error) { throw new EndpointArtifactExceptionValidationError(error.message); } + + this.validateOsSpecificFields(item); + } + + private validateOsSpecificFields(item: ExceptionItemLikeOptions): void { + const osTypes = item.osTypes || []; + const entries = item.entries || []; + + const hasUsernameField = entries.some( + (entry) => entry.field === TrustedDeviceConditionEntryField.USERNAME + ); + + if (hasUsernameField) { + // USERNAME field is only allowed for Windows OS exclusively + if (!isTrustedDeviceFieldAvailableForOs(TrustedDeviceConditionEntryField.USERNAME, osTypes)) { + throw new EndpointArtifactExceptionValidationError(TRUSTED_DEVICE_USERNAME_OS_ERROR); + } + } } } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/trusted_devices.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/trusted_devices.ts index 3226f576c0d5d..16489959719ce 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/trusted_devices.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/trusted_devices.ts @@ -341,9 +341,87 @@ export default function ({ getService }: FtrProviderContext) { .expect(anErrorMessageWith(/Duplicate OS entries are not allowed/)); }); - // Test all supported trusted device fields + it(`should allow USERNAME field with Windows-only OS on [${trustedDeviceApiCall.method}]`, async () => { + const body = trustedDeviceApiCall.getBody(); + + if ('_version' in body) { + body._version = trustedDeviceData.artifact._version; + } + + body.os_types = ['windows']; + body.entries = [ + { + field: TrustedDeviceConditionEntryField.USERNAME, + operator: 'included', + type: 'match', + value: 'test-user', + }, + ]; + + await endpointPolicyManagerSupertest[trustedDeviceApiCall.method]( + trustedDeviceApiCall.path + ) + .set('kbn-xsrf', 'true') + .send(body) + .expect(200); + }); + + it(`should error on [${trustedDeviceApiCall.method}] if USERNAME field is used with Mac-only OS`, async () => { + const body = trustedDeviceApiCall.getBody(); + + body.os_types = ['macos']; + body.entries = [ + { + field: TrustedDeviceConditionEntryField.USERNAME, + operator: 'included', + type: 'match', + value: 'test-user', + }, + ]; + + await endpointPolicyManagerSupertest[trustedDeviceApiCall.method]( + trustedDeviceApiCall.path + ) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect( + anErrorMessageWith(/Username field is only supported for Windows OS exclusively/) + ); + }); + + it(`should error on [${trustedDeviceApiCall.method}] if USERNAME field is used with Windows+Mac OS`, async () => { + const body = trustedDeviceApiCall.getBody(); + + body.os_types = ['windows', 'macos']; + body.entries = [ + { + field: TrustedDeviceConditionEntryField.USERNAME, + operator: 'included', + type: 'match', + value: 'test-user', + }, + ]; + + await endpointPolicyManagerSupertest[trustedDeviceApiCall.method]( + trustedDeviceApiCall.path + ) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect( + anErrorMessageWith(/Username field is only supported for Windows OS exclusively/) + ); + }); + for (const field of Object.values(TrustedDeviceConditionEntryField)) { - it(`should allow valid ${field} field on [${trustedDeviceApiCall.method}]`, async () => { + if (field === TrustedDeviceConditionEntryField.USERNAME) { + continue; // Skip USERNAME field - handled separately above + } + + it(`should allow valid ${field} field with any OS on [${trustedDeviceApiCall.method}]`, async () => { const body = trustedDeviceApiCall.getBody(); // Match request version with artifact version @@ -378,6 +456,7 @@ export default function ({ getService }: FtrProviderContext) { body._version = trustedDeviceData.artifact._version; } + body.os_types = ['windows']; body.entries = [ { field: TrustedDeviceConditionEntryField.USERNAME,