From a90bd94ffcd50a518e242f6109e94e0f94006ad6 Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Mon, 12 Feb 2024 10:39:31 +0100 Subject: [PATCH 01/16] [Ops] Fix kibana coverage copy (#176575) ## Summary My assumption was probably wrong about the access to the legacy coverage bucket. Any other account than the default Kibana CI gcloud account doesn't have write access to that bucket, so with the impersonation, the copy fails. This PR separates the copying to two parts, and localizes impersonations to the required operations not to be interfering with the legacy bucket accesses. --- .buildkite/pipelines/code_coverage/daily.yml | 2 +- .../common/activate_service_account.sh | 18 +++++++-------- .../reporting/downloadPrevSha.sh | 1 + .../code_coverage/reporting/uploadPrevSha.sh | 1 + .../reporting/uploadStaticSite.sh | 23 ++++++++++++++----- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.buildkite/pipelines/code_coverage/daily.yml b/.buildkite/pipelines/code_coverage/daily.yml index 20b6b505306af..0ffcd8f377071 100644 --- a/.buildkite/pipelines/code_coverage/daily.yml +++ b/.buildkite/pipelines/code_coverage/daily.yml @@ -25,5 +25,5 @@ steps: depends_on: - jest - jest-integration - timeout_in_minutes: 30 + timeout_in_minutes: 50 key: ingest diff --git a/.buildkite/scripts/common/activate_service_account.sh b/.buildkite/scripts/common/activate_service_account.sh index e5cd116a7bce1..83e30e37b8f07 100755 --- a/.buildkite/scripts/common/activate_service_account.sh +++ b/.buildkite/scripts/common/activate_service_account.sh @@ -4,18 +4,18 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/vault_fns.sh" -BUCKET_OR_EMAIL="${1:-}" +CALL_ARGUMENT="${1:-}" GCLOUD_EMAIL_POSTFIX="elastic-kibana-ci.iam.gserviceaccount.com" GCLOUD_SA_PROXY_EMAIL="kibana-ci-sa-proxy@$GCLOUD_EMAIL_POSTFIX" -if [[ -z "$BUCKET_OR_EMAIL" ]]; then +if [[ -z "$CALL_ARGUMENT" ]]; then echo "Usage: $0 " exit 1 -elif [[ "$BUCKET_OR_EMAIL" == "--unset-impersonation" ]]; then +elif [[ "$CALL_ARGUMENT" == "--unset-impersonation" ]]; then echo "Unsetting impersonation" gcloud config unset auth/impersonate_service_account exit 0 -elif [[ "$BUCKET_OR_EMAIL" == "--logout-gcloud" ]]; then +elif [[ "$CALL_ARGUMENT" == "--logout-gcloud" ]]; then echo "Logging out of gcloud" if [[ -x "$(command -v gcloud)" ]] && [[ "$(gcloud auth list 2>/dev/null | grep $GCLOUD_SA_PROXY_EMAIL)" != "" ]]; then gcloud auth revoke $GCLOUD_SA_PROXY_EMAIL --no-user-output-enabled @@ -48,12 +48,12 @@ fi # Check if the arg is a service account e-mail or a bucket name EMAIL="" -if [[ "$BUCKET_OR_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then - EMAIL="$BUCKET_OR_EMAIL" -elif [[ "$BUCKET_OR_EMAIL" =~ ^gs://* ]]; then - BUCKET_NAME="${BUCKET_OR_EMAIL:5}" +if [[ "$CALL_ARGUMENT" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + EMAIL="$CALL_ARGUMENT" +elif [[ "$CALL_ARGUMENT" =~ ^gs://* ]]; then + BUCKET_NAME="${CALL_ARGUMENT:5}" else - BUCKET_NAME="$BUCKET_OR_EMAIL" + BUCKET_NAME="$CALL_ARGUMENT" fi if [[ -z "$EMAIL" ]]; then diff --git a/.buildkite/scripts/steps/code_coverage/reporting/downloadPrevSha.sh b/.buildkite/scripts/steps/code_coverage/reporting/downloadPrevSha.sh index a77cfbef54d55..a0399977457a4 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/downloadPrevSha.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/downloadPrevSha.sh @@ -8,6 +8,7 @@ gsutil -m cp -r gs://elastic-bekitzur-kibana-coverage-live/previous_pointer/prev # TODO: Activate after the above is removed #.buildkite/scripts/common/activate_service_account.sh gs://elastic-kibana-coverage-live #gsutil -m cp -r gs://elastic-kibana-coverage-live/previous_pointer/previous.txt . || echo "### Previous Pointer NOT FOUND?" +#.buildkite/scripts/common/activate_service_account.sh --unset-impersonation if [ -e ./previous.txt ]; then mv previous.txt downloaded_previous.txt diff --git a/.buildkite/scripts/steps/code_coverage/reporting/uploadPrevSha.sh b/.buildkite/scripts/steps/code_coverage/reporting/uploadPrevSha.sh index 42ef5faa5cd3d..2164a4cd64251 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/uploadPrevSha.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/uploadPrevSha.sh @@ -14,3 +14,4 @@ gsutil cp previous.txt gs://elastic-bekitzur-kibana-coverage-live/previous_point .buildkite/scripts/common/activate_service_account.sh gs://elastic-kibana-coverage-live gsutil cp previous.txt gs://elastic-kibana-coverage-live/previous_pointer/ +.buildkite/scripts/common/activate_service_account.sh --unset-impersonation diff --git a/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh b/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh index 5bd0c07cc9b9b..701704a3a8b23 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh @@ -4,29 +4,40 @@ set -euo pipefail xs=("$@") -# TODO: Safe to remove this after 2024-03-01 (https://github.com/elastic/kibana/issues/175904) - also clean up usages +# TODO: Safe to remove this block after 2024-03-01 (https://github.com/elastic/kibana/issues/175904) - also clean up usages +echo "--- Uploading static site (legacy)" uploadPrefix_old="gs://elastic-bekitzur-kibana-coverage-live/" uploadPrefixWithTimeStamp_old="${uploadPrefix_old}${TIME_STAMP}/" +uploadBase_old() { + for x in 'src/dev/code_coverage/www/index.html' 'src/dev/code_coverage/www/404.html'; do + gsutil -m -q cp -r -a public-read -z js,css,html "${x}" "${uploadPrefix_old}" + done +} +uploadRest_old() { + for x in "${xs[@]}"; do + gsutil -m -q cp -r -a public-read -z js,css,html "target/kibana-coverage/${x}-combined" "${uploadPrefixWithTimeStamp_old}" + done +} +.buildkite/scripts/common/activate_service_account.sh --logout-gcloud +uploadBase_old +uploadRest_old +echo "--- Uploading static site" uploadPrefix="gs://elastic-kibana-coverage-live/" uploadPrefixWithTimeStamp="${uploadPrefix}${TIME_STAMP}/" uploadBase() { for x in 'src/dev/code_coverage/www/index.html' 'src/dev/code_coverage/www/404.html'; do gsutil -m -q cp -r -z js,css,html "${x}" "${uploadPrefix}" - gsutil -m -q cp -r -a public-read -z js,css,html "${x}" "${uploadPrefix_old}" done } - uploadRest() { for x in "${xs[@]}"; do gsutil -m -q cp -r -z js,css,html "target/kibana-coverage/${x}-combined" "${uploadPrefixWithTimeStamp}" - gsutil -m -q cp -r -a public-read -z js,css,html "target/kibana-coverage/${x}-combined" "${uploadPrefixWithTimeStamp_old}" done } -echo "--- Uploading static site" - .buildkite/scripts/common/activate_service_account.sh gs://elastic-kibana-coverage-live uploadBase uploadRest +.buildkite/scripts/common/activate_service_account.sh --unset-impersonation From 3c45e8ce3840a71bcad392ee479376103791b7cd Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 12 Feb 2024 10:53:42 +0100 Subject: [PATCH 02/16] [Cases] Update UI to use custom field internal API (#176574) ## Summary fixes https://github.com/elastic/kibana/issues/175931 User should be able to update individual custom field without any error. This PR updates edit custom field with internal API. **Testing:** - able to update required field when multiple required fields are there - able to update option field with null - shows default value as before **Flaky test runner** https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5117 ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- x-pack/plugins/cases/common/api/helpers.ts | 8 + .../components/case_view_activity.test.tsx | 42 +++++ .../components/case_view_activity.tsx | 21 ++- .../components/custom_fields.test.tsx | 157 +++++++----------- .../case_view/components/custom_fields.tsx | 54 +----- .../cases/public/containers/__mocks__/api.ts | 14 ++ .../cases/public/containers/api.test.tsx | 38 +++++ x-pack/plugins/cases/public/containers/api.ts | 28 +++- .../cases/public/containers/constants.ts | 1 + .../use_replace_custom_field.test.tsx | 151 +++++++++++++++++ .../containers/use_replace_custom_field.tsx | 46 +++++ .../apps/cases/group1/view_case.ts | 10 +- .../observability/cases/view_case.ts | 8 - .../security/ftr/cases/view_case.ts | 8 - 14 files changed, 404 insertions(+), 182 deletions(-) create mode 100644 x-pack/plugins/cases/public/containers/use_replace_custom_field.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index da16dc028b854..230fe8128855e 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -21,6 +21,7 @@ import { INTERNAL_CASE_USERS_URL, INTERNAL_DELETE_FILE_ATTACHMENTS_URL, CASE_FIND_ATTACHMENTS_URL, + INTERNAL_PUT_CUSTOM_FIELDS_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -82,3 +83,10 @@ export const getCaseUsersUrl = (id: string): string => { export const getCasesDeleteFileAttachmentsUrl = (id: string): string => { return INTERNAL_DELETE_FILE_ATTACHMENTS_URL.replace('{case_id}', id); }; + +export const getCustomFieldReplaceUrl = (caseId: string, customFieldId: string): string => { + return INTERNAL_PUT_CUSTOM_FIELDS_URL.replace('{case_id}', caseId).replace( + '{custom_field_id}', + customFieldId + ); +}; diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index 74e04c4afac89..df6ea5de4d7c2 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -13,6 +13,8 @@ import { alertComment, basicCase, connectorsMock, + customFieldsConfigurationMock, + customFieldsMock, getCaseUsersMockResponse, getUserAction, } from '../../../containers/mock'; @@ -40,6 +42,7 @@ import { ConnectorTypes, UserActionTypes } from '../../../../common/types/domain import { CaseMetricsFeature } from '../../../../common/types/api'; import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; +import { useReplaceCustomField } from '../../../containers/use_replace_custom_field'; jest.mock('../../../containers/use_infinite_find_case_user_actions'); jest.mock('../../../containers/use_find_case_user_actions'); @@ -56,6 +59,7 @@ jest.mock('../../../containers/use_get_categories'); jest.mock('../../../containers/user_profiles/use_bulk_get_user_profiles'); jest.mock('../../../containers/use_get_case_connectors'); jest.mock('../../../containers/use_get_case_users'); +jest.mock('../../../containers/use_replace_custom_field'); jest.mock('../use_on_update_field'); jest.mock('../../../common/use_cases_features'); jest.mock('../../../containers/configure/use_get_case_configuration'); @@ -130,6 +134,8 @@ const useGetCasesFeaturesRes = { isSyncAlertsEnabled: true, }; +const replaceCustomField = jest.fn(); + const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock; const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock; const useGetCaseUserActionsStatsMock = useGetCaseUserActionsStats as jest.Mock; @@ -139,6 +145,7 @@ const useGetCaseConnectorsMock = useGetCaseConnectors as jest.Mock; const useGetCaseUsersMock = useGetCaseUsers as jest.Mock; const useOnUpdateFieldMock = useOnUpdateField as jest.Mock; const useCasesFeaturesMock = useCasesFeatures as jest.Mock; +const useReplaceCustomFieldMock = useReplaceCustomField as jest.Mock; describe('Case View Page activity tab', () => { let appMockRender: AppMockRenderer; @@ -169,6 +176,11 @@ describe('Case View Page activity tab', () => { isLoading: false, useOnUpdateField: jest.fn, }); + useReplaceCustomFieldMock.mockImplementation(() => ({ + isUpdatingCustomField: false, + isError: false, + mutate: replaceCustomField, + })); Object.defineProperty(window, 'getComputedStyle', { value: (el: HTMLElement) => { @@ -348,6 +360,36 @@ describe('Case View Page activity tab', () => { expect(await screen.findByTestId('case-view-edit-connector')).toBeInTheDocument(); }); + it('should call useReplaceCustomField correctly', async () => { + (useGetCaseConfiguration as jest.Mock).mockReturnValue({ + data: { + customFields: [customFieldsConfigurationMock[1]], + }, + }); + appMockRender.render( + + ); + + userEvent.click(await screen.findByRole('switch')); + + await waitFor(() => { + expect(replaceCustomField).toHaveBeenCalledWith({ + caseId: caseData.id, + caseVersion: caseData.version, + customFieldId: customFieldsMock[1].key, + customFieldValue: false, + }); + }); + + expect(await screen.findByTestId('case-view-edit-connector')).toBeInTheDocument(); + }); + describe('filter activity', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 1c579eaf08848..64d4c7f0cd327 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -40,6 +40,7 @@ import { Description } from '../../description'; import { EditCategory } from './edit_category'; import { parseCaseUsers } from '../../utils'; import { CustomFields } from './custom_fields'; +import { useReplaceCustomField } from '../../../containers/use_replace_custom_field'; export const CaseViewActivity = ({ ruleDetailsNavigation, @@ -93,6 +94,8 @@ export const CaseViewActivity = ({ caseData, }); + const { isLoading: isUpdatingCustomField, mutate: replaceCustomField } = useReplaceCustomField(); + const isLoadingAssigneeData = (isLoading && loadingKey === 'assignees') || isLoadingCaseUsers || isLoadingCurrentUserProfile; @@ -143,14 +146,16 @@ export const CaseViewActivity = ({ [onUpdateField] ); - const onSubmitCustomFields = useCallback( - (customFields: CaseUICustomField[]) => { - onUpdateField({ - key: 'customFields', - value: customFields, + const onSubmitCustomField = useCallback( + (customField: CaseUICustomField) => { + replaceCustomField({ + caseId: caseData.id, + customFieldId: customField.key, + customFieldValue: customField.value, + caseVersion: caseData.version, }); }, - [onUpdateField] + [replaceCustomField, caseData] ); const handleUserActionsActivityChanged = useCallback( @@ -290,10 +295,10 @@ export const CaseViewActivity = ({ /> ) : null} diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx index 9b1037959120b..a995e198774c9 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx @@ -121,37 +121,32 @@ describe('Case View Page files tab', () => { ).not.toBeInTheDocument(); }); - it('adds missing custom fields with no custom fields in the case', async () => { + it('removes extra custom fields', async () => { appMockRender.render( ); - userEvent.click((await screen.findAllByRole('switch'))[0]); + userEvent.click(await screen.findByRole('switch')); await waitFor(() => { - expect(onSubmit).toBeCalledWith([ - { - type: CustomFieldTypes.TEXT, - key: 'test_key_1', - value: customFieldsConfigurationMock[0].defaultValue, - }, - { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, - customFieldsMock[2], - customFieldsMock[3], - ]); + expect(onSubmit).toBeCalledWith({ + type: CustomFieldTypes.TOGGLE, + key: 'test_key_2', + value: false, + }); }); }); - it('adds missing custom fields with some custom fields in the case', async () => { + it('updates an existing toggle field correctly', async () => { appMockRender.render( @@ -160,130 +155,94 @@ describe('Case View Page files tab', () => { userEvent.click((await screen.findAllByRole('switch'))[0]); await waitFor(() => { - expect(onSubmit).toBeCalledWith([ - { - type: CustomFieldTypes.TEXT, - key: 'test_key_1', - value: customFieldsConfigurationMock[0].defaultValue, - }, - { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, - customFieldsMock[2], - customFieldsMock[3], - ]); + expect(onSubmit).toBeCalledWith({ + type: CustomFieldTypes.TOGGLE, + key: 'test_key_2', + value: false, + }); }); }); - it('adds missing defaultValues to required text custom fields without value', async () => { + it('updates new toggle field correctly', async () => { appMockRender.render( ); - // Clicking the toggle triggers the form submit userEvent.click((await screen.findAllByRole('switch'))[0]); await waitFor(() => { - expect(onSubmit).toBeCalledWith([ - { - type: CustomFieldTypes.TEXT, - key: 'test_key_1', - value: customFieldsConfigurationMock[0].defaultValue, - }, - { - type: CustomFieldTypes.TOGGLE, - key: 'test_key_2', - value: false, - }, - ]); + expect(onSubmit).toBeCalledWith({ + type: CustomFieldTypes.TOGGLE, + key: 'test_key_2', + value: true, + }); }); }); - it('does not overwrite existing text values with a configured defaultValue', async () => { + it('updates existing text field correctly', async () => { appMockRender.render( ); - userEvent.click((await screen.findAllByRole('switch'))[0]); - - await waitFor(() => { - expect(onSubmit).toBeCalledWith([ - { - type: CustomFieldTypes.TEXT, - key: 'test_key_1', - value: 'existing value', - }, - { - type: CustomFieldTypes.TOGGLE, - key: 'test_key_2', - value: false, - }, - ]); - }); - }); + userEvent.click( + await screen.findByTestId(`case-text-custom-field-edit-button-${customFieldsMock[0].key}`) + ); - it('removes extra custom fields', async () => { - appMockRender.render( - + userEvent.paste( + await screen.findByTestId('case-text-custom-field-form-field-test_key_1'), + '!!!' ); - userEvent.click(await screen.findByRole('switch')); + userEvent.click(await screen.findByTestId('case-text-custom-field-submit-button-test_key_1')); await waitFor(() => { - expect(onSubmit).toBeCalledWith([ - { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, - ]); + expect(onSubmit).toBeCalledWith({ + ...customFieldsMock[0], + value: 'My text test value 1!!!', + }); }); }); - it('updates an existing field correctly', async () => { + it('updates new text field correctly', async () => { appMockRender.render( ); - userEvent.click((await screen.findAllByRole('switch'))[0]); + userEvent.click( + await screen.findByTestId(`case-text-custom-field-edit-button-${customFieldsMock[0].key}`) + ); + + expect( + await screen.findByText('This field is populated with the default value.') + ).toBeInTheDocument(); + + userEvent.paste( + await screen.findByTestId('case-text-custom-field-form-field-test_key_1'), + ' updated!!' + ); + + userEvent.click(await screen.findByTestId('case-text-custom-field-submit-button-test_key_1')); await waitFor(() => { - expect(onSubmit).toBeCalledWith([ - customFieldsMock[0], - { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, - customFieldsMock[2], - customFieldsMock[3], - ]); + expect(onSubmit).toBeCalledWith({ + ...customFieldsMock[0], + value: `${customFieldsConfigurationMock[0].defaultValue} updated!!`, + }); }); }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx index b1bb01672c0dc..32d03bac8dc8e 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx @@ -8,20 +8,16 @@ import React, { useCallback, useMemo } from 'react'; import { sortBy } from 'lodash'; import { EuiFlexItem } from '@elastic/eui'; -import type { - CasesConfigurationUI, - CasesConfigurationUICustomField, - CaseUICustomField, -} from '../../../../common/ui'; +import type { CasesConfigurationUI, CaseUICustomField } from '../../../../common/ui'; import type { CaseUI } from '../../../../common'; import { useCasesContext } from '../../cases_context/use_cases_context'; import { builderMap as customFieldsBuilderMap } from '../../custom_fields/builder'; -import { addOrReplaceCustomField } from '../../custom_fields/utils'; + interface Props { isLoading: boolean; customFields: CaseUI['customFields']; customFieldsConfiguration: CasesConfigurationUI['customFields']; - onSubmit: (customFields: CaseUICustomField[]) => void; + onSubmit: (customField: CaseUICustomField) => void; } const CustomFieldsComponent: React.FC = ({ @@ -38,16 +34,9 @@ const CustomFieldsComponent: React.FC = ({ const onSubmitCustomField = useCallback( (customFieldToAdd) => { - const allCustomFields = createMissingAndRemoveExtraCustomFields( - customFields, - customFieldsConfiguration - ); - - const updatedCustomFields = addOrReplaceCustomField(allCustomFields, customFieldToAdd); - - onSubmit(updatedCustomFields); + onSubmit(customFieldToAdd); }, - [customFields, customFieldsConfiguration, onSubmit] + [onSubmit] ); const customFieldsComponents = sortedCustomFieldsConfiguration.map((customFieldConf) => { @@ -87,36 +76,3 @@ const sortCustomFieldsByLabel = (customFieldsConfiguration: Props['customFieldsC return customFieldConf.label; }); }; - -const createMissingAndRemoveExtraCustomFields = ( - customFields: CaseUICustomField[], - confCustomFields: CasesConfigurationUICustomField[] -): CaseUICustomField[] => { - const createdCustomFields: CaseUICustomField[] = confCustomFields.map((confCustomField) => { - const foundCustomField = customFields.find( - (customField) => customField.key === confCustomField.key - ); - - const shouldUseDefaultValue = Boolean( - confCustomField.required && confCustomField?.defaultValue - ); - - if (foundCustomField) { - return { - ...foundCustomField, - value: - foundCustomField.value == null && shouldUseDefaultValue - ? confCustomField.defaultValue - : foundCustomField.value, - } as CaseUICustomField; - } - - return { - key: confCustomField.key, - type: confCustomField.type, - value: shouldUseDefaultValue ? confCustomField.defaultValue : null, - } as CaseUICustomField; - }); - - return createdCustomFields; -}; diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 9d99f658e9da0..a24c8b5b677c5 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -13,6 +13,7 @@ import type { CasesStatus, FetchCasesProps, FindCaseUserActions, + CaseUICustomField, } from '../types'; import { SortFieldCase } from '../types'; import { @@ -30,6 +31,7 @@ import { findCaseUserActionsResponse, getCaseUserActionsStatsResponse, getCaseUsersMockResponse, + customFieldsMock, } from '../mock'; import type { CaseConnectors, @@ -178,3 +180,15 @@ export const deleteFileAttachments = async ({ export const getCategories = async (signal: AbortSignal): Promise => Promise.resolve(categories); + +export const replaceCustomField = async ({ + caseId, + customFieldId, + customFieldValue, + caseVersion, +}: { + caseId: string; + customFieldId: string; + customFieldValue: string | boolean | null; + caseVersion: string; +}): Promise => Promise.resolve(customFieldsMock[0]); diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index e2e0897d75ada..02d639b790885 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -40,6 +40,7 @@ import { getCaseUserActionsStats, deleteFileAttachments, getCategories, + replaceCustomField, } from './api'; import { @@ -64,6 +65,7 @@ import { basicPushSnake, getCaseUserActionsStatsResponse, basicFileMock, + customFieldsMock, } from './mock'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './constants'; @@ -1152,4 +1154,40 @@ describe('Cases API', () => { expect(resp).toEqual({ 'servicenow-1': connectorCamelCase }); }); }); + + describe('replaceCustomField', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(customFieldsMock[0]); + }); + + const data = { + caseId: basicCase.id, + customFieldId: customFieldsMock[0].key, + request: { + value: 'this is an updated custom field', + caseVersion: basicCase.version, + }, + }; + + it('should be called with correct check url, method, signal', async () => { + await replaceCustomField({ ...data, signal: abortCtrl.signal }); + + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_INTERNAL_URL}/${basicCase.id}/custom_fields/${customFieldsMock[0].key}`, + { + method: 'PUT', + body: JSON.stringify({ + ...data.request, + }), + signal: abortCtrl.signal, + } + ); + }); + + it('should return correct response', async () => { + const resp = await replaceCustomField({ ...data, signal: abortCtrl.signal }); + expect(resp).toEqual(customFieldsMock[0]); + }); + }); }); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index eeeffc6f4e424..020a4629552f4 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -7,7 +7,7 @@ import { ALERT_RULE_CONSUMER, ALERT_RULE_PRODUCER, ALERT_RULE_TYPE_ID } from '@kbn/rule-data-utils'; import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants'; -import type { User } from '../../common/types/domain'; +import type { CaseCustomField, User } from '../../common/types/domain'; import { AttachmentType } from '../../common/types/domain'; import type { Case, Cases } from '../../common'; import type { @@ -21,6 +21,7 @@ import type { GetCaseConnectorsResponse, UserActionFindResponse, SingleCaseMetricsResponse, + CustomFieldPutRequest, } from '../../common/types/api'; import type { CaseConnectors, @@ -34,6 +35,7 @@ import type { CasesFindResponseUI, CasesUI, FilterOptions, + CaseUICustomField, } from '../../common/ui/types'; import { SortFieldCase } from '../../common/ui/types'; import { @@ -47,6 +49,7 @@ import { getCaseConnectorsUrl, getCaseUsersUrl, getCaseUserActionStatsUrl, + getCustomFieldReplaceUrl, } from '../../common/api'; import { CASE_REPORTERS_URL, @@ -367,6 +370,29 @@ export const updateCases = async ({ return convertCasesToCamelCase(decodeCasesResponse(response)); }; +export const replaceCustomField = async ({ + caseId, + customFieldId, + request, + signal, +}: { + caseId: string; + customFieldId: string; + request: CustomFieldPutRequest; + signal?: AbortSignal; +}): Promise => { + const response = await KibanaServices.get().http.fetch( + getCustomFieldReplaceUrl(caseId, customFieldId), + { + method: 'PUT', + body: JSON.stringify(request), + signal, + } + ); + + return convertToCamelCase(response); +}; + export const postComment = async ( newComment: AttachmentRequest, caseId: string, diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 54e7cebba9025..0f57a729bc58b 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -63,6 +63,7 @@ export const casesMutationsKeys = { deleteFileAttachment: ['delete-file-attachment'] as const, bulkCreateAttachments: ['bulk-create-attachments'] as const, persistCaseConfiguration: ['persist-case-configuration'] as const, + replaceCustomField: ['replace-custom-field'] as const, }; const DEFAULT_SEARCH_FIELDS = ['title', 'description']; diff --git a/x-pack/plugins/cases/public/containers/use_replace_custom_field.test.tsx b/x-pack/plugins/cases/public/containers/use_replace_custom_field.test.tsx new file mode 100644 index 0000000000000..366d946af1d90 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_replace_custom_field.test.tsx @@ -0,0 +1,151 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { basicCase } from './mock'; +import * as api from './api'; +import type { AppMockRenderer } from '../common/mock'; +import { createAppMockRenderer } from '../common/mock'; +import { useToasts } from '../common/lib/kibana'; +import { casesQueriesKeys } from './constants'; +import { useReplaceCustomField } from './use_replace_custom_field'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); + +describe('useReplaceCustomField', () => { + const sampleData = { + caseId: basicCase.id, + customFieldId: 'test_key_1', + customFieldValue: 'this is an updated custom field', + caseVersion: basicCase.version, + }; + + const addSuccess = jest.fn(); + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError }); + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('replace a customField and refresh the case page', async () => { + const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); + + const { waitForNextUpdate, result } = renderHook(() => useReplaceCustomField(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(sampleData); + }); + + await waitForNextUpdate(); + + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.caseView()); + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.tags()); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const patchCustomFieldSpy = jest.spyOn(api, 'replaceCustomField'); + const { waitForNextUpdate, result } = renderHook(() => useReplaceCustomField(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(sampleData); + }); + + await waitForNextUpdate(); + + expect(patchCustomFieldSpy).toHaveBeenCalledWith({ + caseId: sampleData.caseId, + customFieldId: sampleData.customFieldId, + request: { + value: sampleData.customFieldValue, + caseVersion: sampleData.caseVersion, + }, + }); + }); + + it('calls the api when invoked with the correct parameters of toggle field', async () => { + const newData = { + caseId: basicCase.id, + customFieldId: 'test_key_2', + customFieldValue: false, + caseVersion: basicCase.version, + }; + const patchCustomFieldSpy = jest.spyOn(api, 'replaceCustomField'); + const { waitForNextUpdate, result } = renderHook(() => useReplaceCustomField(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(newData); + }); + + await waitForNextUpdate(); + + expect(patchCustomFieldSpy).toHaveBeenCalledWith({ + caseId: newData.caseId, + customFieldId: newData.customFieldId, + request: { + value: newData.customFieldValue, + caseVersion: newData.caseVersion, + }, + }); + }); + + it('calls the api when invoked with the correct parameters with null value', async () => { + const newData = { + caseId: basicCase.id, + customFieldId: 'test_key_3', + customFieldValue: null, + caseVersion: basicCase.version, + }; + const patchCustomFieldSpy = jest.spyOn(api, 'replaceCustomField'); + const { waitForNextUpdate, result } = renderHook(() => useReplaceCustomField(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(newData); + }); + + await waitForNextUpdate(); + + expect(patchCustomFieldSpy).toHaveBeenCalledWith({ + caseId: newData.caseId, + customFieldId: newData.customFieldId, + request: { + value: newData.customFieldValue, + caseVersion: newData.caseVersion, + }, + }); + }); + + it('shows a toast error when the api return an error', async () => { + jest + .spyOn(api, 'replaceCustomField') + .mockRejectedValue(new Error('useUpdateComment: Test error')); + + const { waitForNextUpdate, result } = renderHook(() => useReplaceCustomField(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(sampleData); + }); + + await waitForNextUpdate(); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx b/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx new file mode 100644 index 0000000000000..5d2969f6e6d44 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx @@ -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 { useMutation } from '@tanstack/react-query'; +import { useCasesToast } from '../common/use_cases_toast'; +import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page'; +import type { ServerError } from '../types'; +import { replaceCustomField } from './api'; +import { casesMutationsKeys } from './constants'; +import * as i18n from './translations'; + +interface ReplaceCustomField { + caseId: string; + customFieldId: string; + customFieldValue: string | boolean | null; + caseVersion: string; +} + +export const useReplaceCustomField = () => { + const { showErrorToast } = useCasesToast(); + const refreshCaseViewPage = useRefreshCaseViewPage(); + + return useMutation( + ({ caseId, customFieldId, customFieldValue, caseVersion }: ReplaceCustomField) => + replaceCustomField({ + caseId, + customFieldId, + request: { value: customFieldValue, caseVersion }, + }), + { + mutationKey: casesMutationsKeys.replaceCustomField, + onSuccess: () => { + refreshCaseViewPage(); + }, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + } + ); +}; + +export type UseReplaceCustomField = ReturnType; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 01cd6174b01cc..3f5e13d0b6615 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -1296,20 +1296,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); - await retry.waitFor('update toast exist', async () => { - return await testSubjects.exists('toastCloseButton'); - }); - - await testSubjects.click('toastCloseButton'); - - await header.waitUntilLoadingHasFinished(); + expect(await textField.getVisibleText()).equal('this is a text field value edited!!'); await toggle.click(); await header.waitUntilLoadingHasFinished(); - expect(await textField.getVisibleText()).equal('this is a text field value edited!!'); - expect(await toggle.getAttribute('aria-checked')).equal('false'); // validate user action diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts index 2386d2b0e9585..c1c94583b154d 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts @@ -529,14 +529,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); - await retry.waitFor('update toast exist', async () => { - return await testSubjects.exists('toastCloseButton'); - }); - - await testSubjects.click('toastCloseButton'); - - await header.waitUntilLoadingHasFinished(); - await toggle.click(); await header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts index 94329bc5b48dc..244bc58581052 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts @@ -530,14 +530,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); - await retry.waitFor('update toast exist', async () => { - return await testSubjects.exists('toastCloseButton'); - }); - - await testSubjects.click('toastCloseButton'); - - await header.waitUntilLoadingHasFinished(); - await toggle.click(); await header.waitUntilLoadingHasFinished(); From cc0dc98bf04cf5d35265c73ac0786dc7599247e6 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 12 Feb 2024 12:36:05 +0200 Subject: [PATCH 03/16] [ES|QL] Clickable badges on compact view (#176568) ## Summary Makes the warning / error badge clickable in the compact mode. ![image (17)](https://github.com/elastic/kibana/assets/17003240/b14eec53-f804-46e3-80b8-794f0eb5326b) ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../src/editor_footer.tsx | 136 +----------- .../src/errors_warnings_popover.tsx | 203 ++++++++++++++++++ .../src/text_based_languages_editor.test.tsx | 16 +- .../src/text_based_languages_editor.tsx | 47 ++-- 4 files changed, 231 insertions(+), 171 deletions(-) create mode 100644 packages/kbn-text-based-editor/src/errors_warnings_popover.tsx diff --git a/packages/kbn-text-based-editor/src/editor_footer.tsx b/packages/kbn-text-based-editor/src/editor_footer.tsx index 210988ac94e42..2d7196a3a774e 100644 --- a/packages/kbn-text-based-editor/src/editor_footer.tsx +++ b/packages/kbn-text-based-editor/src/editor_footer.tsx @@ -15,49 +15,18 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, - EuiPopover, - EuiPopoverTitle, - EuiDescriptionList, - EuiDescriptionListDescription, EuiButton, useEuiTheme, EuiLink, } from '@elastic/eui'; import { Interpolation, Theme, css } from '@emotion/react'; -import { css as classNameCss } from '@emotion/css'; - import type { MonacoMessage } from './helpers'; +import { ErrorsWarningsFooterPopover } from './errors_warnings_popover'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const COMMAND_KEY = isMac ? '⌘' : '^'; const FEEDBACK_LINK = 'https://ela.st/esql-feedback'; -const getConstsByType = (type: 'error' | 'warning', count: number) => { - if (type === 'error') { - return { - color: 'danger', - message: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.errorCount', { - defaultMessage: '{count} {count, plural, one {error} other {errors}}', - values: { count }, - }), - label: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.errorsTitle', { - defaultMessage: 'Errors', - }), - }; - } else { - return { - color: 'warning', - message: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.warningCount', { - defaultMessage: '{count} {count, plural, one {warning} other {warnings}}', - values: { count }, - }), - label: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.warningsTitle', { - defaultMessage: 'Warnings', - }), - }; - } -}; - export function SubmitFeedbackComponent({ isSpaceReduced }: { isSpaceReduced?: boolean }) { const { euiTheme } = useEuiTheme(); return ( @@ -89,105 +58,6 @@ export function SubmitFeedbackComponent({ isSpaceReduced }: { isSpaceReduced?: b ); } -export function ErrorsWarningsPopover({ - isPopoverOpen, - items, - type, - setIsPopoverOpen, - onErrorClick, - isSpaceReduced, -}: { - isPopoverOpen: boolean; - items: MonacoMessage[]; - type: 'error' | 'warning'; - setIsPopoverOpen: (flag: boolean) => void; - onErrorClick: (error: MonacoMessage) => void; - isSpaceReduced?: boolean; -}) { - const strings = getConstsByType(type, items.length); - return ( - - - - { - setIsPopoverOpen(!isPopoverOpen); - }} - /> - - - { - setIsPopoverOpen(!isPopoverOpen); - }} - > -

{isSpaceReduced ? items.length : strings.message}

- - } - ownFocus={false} - isOpen={isPopoverOpen} - closePopover={() => setIsPopoverOpen(false)} - > -
- {strings.label} - - {items.map((item, index) => { - return ( - onErrorClick(item)} - > - - - - - - - - {i18n.translate( - 'textBasedEditor.query.textBasedLanguagesEditor.lineNumber', - { - defaultMessage: 'Line {lineNumber}', - values: { lineNumber: item.startLineNumber }, - } - )} - - - - - {item.message} - - - - ); - })} - -
-
-
-
-
- ); -} - interface EditorFooterProps { lines: number; containerCSS: Interpolation; @@ -271,7 +141,7 @@ export const EditorFooter = memo(function EditorFooter({ )} {errors && errors.length > 0 && ( - )} {warnings && warnings.length > 0 && ( - { + if (type === 'error') { + return { + color: 'danger', + message: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.errorCount', { + defaultMessage: '{count} {count, plural, one {error} other {errors}}', + values: { count }, + }), + label: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.errorsTitle', { + defaultMessage: 'Errors', + }), + }; + } else { + return { + color: 'warning', + message: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.warningCount', { + defaultMessage: '{count} {count, plural, one {warning} other {warnings}}', + values: { count }, + }), + label: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.warningsTitle', { + defaultMessage: 'Warnings', + }), + }; + } +}; + +export function ErrorsWarningsContent({ + items, + type, + onErrorClick, +}: { + items: MonacoMessage[]; + type: 'error' | 'warning'; + onErrorClick: (error: MonacoMessage) => void; +}) { + const { label, color } = getConstsByType(type, items.length); + return ( +
+ {label} + + {items.map((item, index) => { + return ( + onErrorClick(item)} + > + + + + + + + + {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.lineNumber', { + defaultMessage: 'Line {lineNumber}', + values: { lineNumber: item.startLineNumber }, + })} + + + + + {item.message} + + + + ); + })} + +
+ ); +} + +export function ErrorsWarningsCompactViewPopover({ + items, + type, + onErrorClick, + popoverCSS, +}: { + items: MonacoMessage[]; + type: 'error' | 'warning'; + onErrorClick: (error: MonacoMessage) => void; + popoverCSS: Interpolation; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { color, message } = getConstsByType(type, items.length); + return ( + setIsPopoverOpen(true)} + onClickAriaLabel={message} + iconType={type} + iconSide="left" + data-test-subj={`TextBasedLangEditor-inline-${type}-badge`} + title={message} + css={css` + cursor: pointer; + `} + > + {items.length} + + } + css={popoverCSS} + ownFocus={false} + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + data-test-subj={`TextBasedLangEditor-inline-${type}-popover`} + > + + + ); +} + +export function ErrorsWarningsFooterPopover({ + isPopoverOpen, + items, + type, + setIsPopoverOpen, + onErrorClick, + isSpaceReduced, +}: { + isPopoverOpen: boolean; + items: MonacoMessage[]; + type: 'error' | 'warning'; + setIsPopoverOpen: (flag: boolean) => void; + onErrorClick: (error: MonacoMessage) => void; + isSpaceReduced?: boolean; +}) { + const { color, message } = getConstsByType(type, items.length); + return ( + + + + { + setIsPopoverOpen(!isPopoverOpen); + }} + /> + + + { + setIsPopoverOpen(!isPopoverOpen); + }} + > +

{isSpaceReduced ? items.length : message}

+ + } + ownFocus={false} + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + > + +
+
+
+
+ ); +} diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx index 0f9ed5c6cb9c5..114c8f6df510f 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx @@ -114,25 +114,33 @@ describe('TextBasedLanguagesEditor', () => { ).toStrictEqual('@timestamp found'); }); - it('should render the errors badge for the inline mode by default if errors are provides', async () => { + it('should render the errors badge for the inline mode by default if errors are provided', async () => { const newProps = { ...props, errors: [new Error('error1')], }; const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + const errorBadge = component.find('[data-test-subj="TextBasedLangEditor-inline-error-badge"]'); + expect(errorBadge.length).not.toBe(0); + errorBadge.at(0).simulate('click'); expect( - component.find('[data-test-subj="TextBasedLangEditor-inline-errors-badge"]').length + component.find('[data-test-subj="TextBasedLangEditor-inline-error-popover"]').length ).not.toBe(0); }); - it('should render the warnings badge for the inline mode by default if warning are provides', async () => { + it('should render the warnings badge for the inline mode by default if warning are provided', async () => { const newProps = { ...props, warning: 'Line 1: 20: Warning', }; const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + const warningBadge = component.find( + '[data-test-subj="TextBasedLangEditor-inline-warning-badge"]' + ); + expect(warningBadge.length).not.toBe(0); + warningBadge.at(0).simulate('click'); expect( - component.find('[data-test-subj="TextBasedLangEditor-inline-warning-badge"]').length + component.find('[data-test-subj="TextBasedLangEditor-inline-warning-popover"]').length ).not.toBe(0); }); diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx index 241679734e248..87ec4142e9b36 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx @@ -62,6 +62,7 @@ import { import { EditorFooter } from './editor_footer'; import { ResizableButton } from './resizable_button'; import { fetchFieldsFromESQL } from './fetch_fields_from_esql'; +import { ErrorsWarningsCompactViewPopover } from './errors_warnings_popover'; import './overwrite.scss'; @@ -772,44 +773,22 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ )} {!isCompactFocused && editorMessages.errors.length > 0 && ( - - {editorMessages.errors.length} - + )} {!isCompactFocused && editorMessages.warnings.length > 0 && editorMessages.errors.length === 0 && ( - - {editorMessages.warnings.length} - + )} Date: Mon, 12 Feb 2024 11:56:35 +0100 Subject: [PATCH 04/16] [EDR Workflows] [Osquery] Change query ID regex pattern (#176507) --- .../public/packs/queries/validations.test.ts | 40 +++++++++++++++++++ .../public/packs/queries/validations.ts | 4 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/osquery/public/packs/queries/validations.test.ts diff --git a/x-pack/plugins/osquery/public/packs/queries/validations.test.ts b/x-pack/plugins/osquery/public/packs/queries/validations.test.ts new file mode 100644 index 0000000000000..4460471b2a3ae --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/queries/validations.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { idHookSchemaValidation } from './validations'; + +describe('idSchemaValidation', () => { + it('returns undefined for valid id', () => { + expect(idHookSchemaValidation('valid-id')).toBeUndefined(); + }); + it('returns undefined for valid id with numbers', () => { + expect(idHookSchemaValidation('123valid_id_123')).toBeUndefined(); + }); + it('returns undefined for valid id with underscore _', () => { + expect(idHookSchemaValidation('valid_id')).toBeUndefined(); + }); + it('returns error message for invalid id with spaces', () => { + expect(idHookSchemaValidation('invalid id')).toEqual( + 'Characters must be alphanumeric, _, or -' + ); + }); + + it('returns error message for invalid id with dots', () => { + expect(idHookSchemaValidation('invalid.id')).toEqual( + 'Characters must be alphanumeric, _, or -' + ); + }); + + it('returns error message for invalid id with special characters', () => { + expect(idHookSchemaValidation('invalid@id')).toEqual( + 'Characters must be alphanumeric, _, or -' + ); + }); + it('returns error message for invalid id just numbers', () => { + expect(idHookSchemaValidation('1232')).toEqual('Characters must be alphanumeric, _, or -'); + }); +}); diff --git a/x-pack/plugins/osquery/public/packs/queries/validations.ts b/x-pack/plugins/osquery/public/packs/queries/validations.ts index 37d74806fd1ae..f4f2f7831804b 100644 --- a/x-pack/plugins/osquery/public/packs/queries/validations.ts +++ b/x-pack/plugins/osquery/public/packs/queries/validations.ts @@ -9,7 +9,9 @@ import { i18n } from '@kbn/i18n'; import type { FormData, ValidationFunc } from '../../shared_imports'; export const MAX_QUERY_LENGTH = 2000; -const idPattern = /^[a-zA-Z0-9-_]+$/; + +// Has to be a string, can't be just numbers, and cannot contain dot '.' +const idPattern = /^(?![0-9]+$)[a-zA-Z0-9-_]+$/; // still used in Packs export const idSchemaValidation: ValidationFunc = ({ value }) => { const valueIsValid = idPattern.test(value); From 87de70fd581fa5a0916d43854a2366eb85e3460a Mon Sep 17 00:00:00 2001 From: Cristina Amico Date: Mon, 12 Feb 2024 12:56:06 +0100 Subject: [PATCH 05/16] [Fleet] Add documentation about transforms/authorize endpoint in openapi (#176610) ## Summary PR to address missing openapi docs about thie following endpoint - it was added a while ago but we forgot to update the docs. ``` api/fleet/epm/packages/{pkgName}/{pkgVersion}/transforms/authorize { "transforms": [ { "transformId": } ] } ``` ### Checklist - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/fleet/common/openapi/bundled.json | 103 ++++++++++++++++++ .../fleet/common/openapi/entrypoint.yaml | 2 + ...e}@{pkg_version}@transforms@authorize.yaml | 64 +++++++++++ 3 files changed, 169 insertions(+) create mode 100644 x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}@transforms@authorize.yaml diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 008e1d1f0ba37..127ed8a877354 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -1312,6 +1312,109 @@ } } }, + "/epm/packages/{pkgName}/{pkgVersion}/transforms/authorize": { + "post": { + "summary": "Authorize transforms", + "tags": [ + "Elastic Package Manager (EPM)" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "transformId": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "transformId", + "error" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/error" + } + }, + "operationId": "reauthorize-transforms", + "description": "", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "schema": { + "type": "string" + }, + "name": "pkgName", + "in": "path", + "required": true + }, + { + "schema": { + "type": "string" + }, + "name": "pkgVersion", + "in": "path", + "required": true + }, + { + "in": "query", + "name": "prerelease", + "schema": { + "type": "boolean", + "default": false + }, + "description": "Whether to include prerelease packages in categories count (e.g. beta, rc, preview)" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "transforms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "transformId": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, "/epm/packages/{pkgName}/{pkgVersion}/{filePath}": { "get": { "summary": "Get package file", diff --git a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml index 8c390ea56c261..beb18d507079e 100644 --- a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml +++ b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml @@ -42,6 +42,8 @@ paths: $ref: 'paths/epm@packages@{pkgkey}_deprecated.yaml' '/epm/packages/{pkgName}/{pkgVersion}': $ref: 'paths/epm@packages@{pkg_name}@{pkg_version}.yaml' + '/epm/packages/{pkgName}/{pkgVersion}/transforms/authorize': + $ref: 'paths/epm@packages@{pkg_name}@{pkg_version}@transforms@authorize.yaml' '/epm/packages/{pkgName}/{pkgVersion}/{filePath}': $ref: paths/epm@get_file.yaml '/epm/packages/{pkgName}/stats': diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}@transforms@authorize.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}@transforms@authorize.yaml new file mode 100644 index 0000000000000..718e6e594c008 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}@transforms@authorize.yaml @@ -0,0 +1,64 @@ +post: + summary: Authorize transforms + tags: + - Elastic Package Manager (EPM) + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + transformId: + type: string + success: + type: boolean + error: + type: string + required: + - transformId + - error + required: + - items + '400': + $ref: ../components/responses/error.yaml + operationId: reauthorize-transforms + description: '' + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - schema: + type: string + name: pkgName + in: path + required: true + - schema: + type: string + name: pkgVersion + in: path + required: true + - in: query + name: prerelease + schema: + type: boolean + default: false + description: >- + Whether to include prerelease packages in categories count (e.g. beta, rc, preview) + requestBody: + content: + application/json: + schema: + type: object + properties: + transforms: + type: array + items: + type: object + properties: + transformId: + type: string From 915500b2bcc83fe093508410b2f1d4de0b16e389 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Mon, 12 Feb 2024 13:14:54 +0100 Subject: [PATCH 06/16] [Observability AI Assistant] Max out prompt height to 350 (#176494) ## Summary Maxes out the height of the prompt editor to 350 pixels when large amount of content is added in textarea. Text editor allows scrolling if content exceeds 350 pixels. Text area also resets to 1 line again after submitting. Screenshot 2024-02-08 at 13 30 42 --- .../prompt_editor_natural_language.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/observability_ai_assistant/public/components/prompt_editor/prompt_editor_natural_language.tsx b/x-pack/plugins/observability_ai_assistant/public/components/prompt_editor/prompt_editor_natural_language.tsx index 6bc53cd5fbf03..6d752ce95e1f6 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/prompt_editor/prompt_editor_natural_language.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/prompt_editor/prompt_editor_natural_language.tsx @@ -40,11 +40,12 @@ export function PromptEditorNaturalLanguage({ const handleResizeTextArea = useCallback(() => { if (textAreaRef.current) { textAreaRef.current.style.minHeight = 'auto'; - textAreaRef.current.style.minHeight = textAreaRef.current?.scrollHeight + 'px'; - } - if (textAreaRef.current?.scrollHeight) { - onChangeHeight(textAreaRef.current.scrollHeight); + const cappedHeight = Math.min(textAreaRef.current?.scrollHeight, 350); + + textAreaRef.current.style.minHeight = cappedHeight + 'px'; + + onChangeHeight(cappedHeight); } }, [onChangeHeight]); @@ -54,12 +55,18 @@ export function PromptEditorNaturalLanguage({ if (textarea) { textarea.focus(); } - }, [handleResizeTextArea]); + }, []); useEffect(() => { handleResizeTextArea(); }, [handleResizeTextArea]); + useEffect(() => { + if (prompt === undefined) { + handleResizeTextArea(); + } + }, [handleResizeTextArea, prompt]); + return ( Date: Mon, 12 Feb 2024 13:25:26 +0100 Subject: [PATCH 07/16] [Elastic connectors] Enable minute frequency for incremental syncs (#176603) ## Summary Enable "minute" frequency for incremental syncs. Default intervals are every 5, 10, 30 minutes. --- .../connector/connector_scheduling/full_content.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling/full_content.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling/full_content.tsx index 90505a8356b35..ef4dfcb85fa02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling/full_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_scheduling/full_content.tsx @@ -216,7 +216,11 @@ export const ConnectorContentScheduling: React.FC Date: Mon, 12 Feb 2024 13:26:57 +0100 Subject: [PATCH 08/16] [browser logging] allow to configure root level (#176397) ## Summary Part of https://github.com/elastic/kibana/issues/144276 - Introduce the concept of browser-side logging configuration, via a `logging.browser` config prefix - Allow to configure the log level for the root browser logger via `logging.browser.root.level` - Set the default level to `info` for both dev and production mode (consistent with server-side logging) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- config/kibana.yml | 4 ++ .../src/analytics_client/analytics_client.ts | 9 ++-- .../src/types.ts | 2 + .../tsconfig.json | 3 +- .../src/logging_system.test.ts | 46 ++++++++++++++++++- .../src/logging_system.ts | 10 ++-- .../core-logging-common-internal/index.ts | 1 + .../src/browser_config.ts | 23 ++++++++++ .../core-logging-server-internal/index.ts | 1 + .../src/logging_config.test.ts | 5 ++ .../src/logging_config.ts | 11 +++++ .../rendering_service.test.ts.snap | 16 +++++++ .../src/render_utils.ts | 16 +++++++ .../src/rendering_service.test.mocks.ts | 2 + .../src/rendering_service.test.ts | 23 +++++++++- .../src/rendering_service.tsx | 5 +- .../tsconfig.json | 2 + .../src/core_system.test.ts | 34 ++++++-------- .../src/core_system.ts | 4 +- .../core-root-browser-internal/tsconfig.json | 1 - 20 files changed, 174 insertions(+), 44 deletions(-) create mode 100644 packages/core/logging/core-logging-common-internal/src/browser_config.ts diff --git a/config/kibana.yml b/config/kibana.yml index eb6950af11e30..c816337f881d4 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -133,6 +133,10 @@ # - name: metrics.ops # level: debug +# Enables debug logging on the browser (dev console) +#logging.browser.root: +# level: debug + # =================== System: Other =================== # The path where Kibana stores persistent data not saved in Elasticsearch. Defaults to data #path.data: data diff --git a/packages/analytics/client/src/analytics_client/analytics_client.ts b/packages/analytics/client/src/analytics_client/analytics_client.ts index 9e0c559cbdc55..1029a87f2b935 100644 --- a/packages/analytics/client/src/analytics_client/analytics_client.ts +++ b/packages/analytics/client/src/analytics_client/analytics_client.ts @@ -133,12 +133,9 @@ export class AnalyticsClient implements IAnalyticsClient { properties: eventData as unknown as Record, }; - // debug-logging before checking the opt-in status to help during development - if (this.initContext.isDev) { - this.initContext.logger.debug(`Report event "${eventType}"`, { - ebt_event: event, - }); - } + this.initContext.logger.debug(`Report event "${eventType}"`, { + ebt_event: event, + }); const optInConfig = this.optInConfig$.value; diff --git a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts index 01b46679c7452..ad1b889a07b22 100644 --- a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts @@ -10,6 +10,7 @@ import type { PluginName, DiscoveredPlugin } from '@kbn/core-base-common'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import type { EnvironmentMode, PackageInfo } from '@kbn/config'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; +import type { BrowserLoggingConfig } from '@kbn/core-logging-common-internal'; /** @internal */ export interface InjectedMetadataClusterInfo { @@ -45,6 +46,7 @@ export interface InjectedMetadata { publicBaseUrl?: string; assetsHrefBase: string; clusterInfo: InjectedMetadataClusterInfo; + logging: BrowserLoggingConfig; env: { mode: EnvironmentMode; packageInfo: PackageInfo; diff --git a/packages/core/injected-metadata/core-injected-metadata-common-internal/tsconfig.json b/packages/core/injected-metadata/core-injected-metadata-common-internal/tsconfig.json index 32b30c506c223..991449b03d2f7 100644 --- a/packages/core/injected-metadata/core-injected-metadata-common-internal/tsconfig.json +++ b/packages/core/injected-metadata/core-injected-metadata-common-internal/tsconfig.json @@ -15,7 +15,8 @@ "@kbn/config", "@kbn/ui-shared-deps-npm", "@kbn/core-base-common", - "@kbn/core-custom-branding-common" + "@kbn/core-custom-branding-common", + "@kbn/core-logging-common-internal" ], "exclude": [ "target/**/*", diff --git a/packages/core/logging/core-logging-browser-internal/src/logging_system.test.ts b/packages/core/logging/core-logging-browser-internal/src/logging_system.test.ts index 75071169eef5f..0058104083f2b 100644 --- a/packages/core/logging/core-logging-browser-internal/src/logging_system.test.ts +++ b/packages/core/logging/core-logging-browser-internal/src/logging_system.test.ts @@ -6,19 +6,29 @@ * Side Public License, v 1. */ +import type { BrowserLoggingConfig } from '@kbn/core-logging-common-internal'; import { unsafeConsole } from '@kbn/security-hardening'; import { BrowserLoggingSystem } from './logging_system'; -describe('', () => { +describe('BrowserLoggingSystem', () => { const timestamp = new Date(Date.UTC(2012, 1, 1, 14, 33, 22, 11)); let mockConsoleLog: jest.SpyInstance; let loggingSystem: BrowserLoggingSystem; + const createLoggingConfig = (parts: Partial = {}): BrowserLoggingConfig => { + return { + root: { + level: 'warn', + }, + ...parts, + }; + }; + beforeEach(() => { mockConsoleLog = jest.spyOn(unsafeConsole, 'log').mockReturnValue(undefined); jest.spyOn(global, 'Date').mockImplementation(() => timestamp); - loggingSystem = new BrowserLoggingSystem({ logLevel: 'warn' }); + loggingSystem = new BrowserLoggingSystem(createLoggingConfig()); }); afterEach(() => { @@ -74,5 +84,37 @@ describe('', () => { ] `); }); + + it('allows to override the root logger level', () => { + loggingSystem = new BrowserLoggingSystem(createLoggingConfig({ root: { level: 'debug' } })); + + const logger = loggingSystem.get('foo.bar'); + logger.trace('some trace message'); + logger.debug('some debug message'); + logger.info('some info message'); + logger.warn('some warn message'); + logger.error('some error message'); + logger.fatal('some fatal message'); + + expect(mockConsoleLog.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "[2012-01-31T13:33:22.011-05:00][DEBUG][foo.bar] some debug message", + ], + Array [ + "[2012-01-31T08:33:22.011-05:00][INFO ][foo.bar] some info message", + ], + Array [ + "[2012-01-31T03:33:22.011-05:00][WARN ][foo.bar] some warn message", + ], + Array [ + "[2012-01-30T22:33:22.011-05:00][ERROR][foo.bar] some error message", + ], + Array [ + "[2012-01-30T17:33:22.011-05:00][FATAL][foo.bar] some fatal message", + ], + ] + `); + }); }); }); diff --git a/packages/core/logging/core-logging-browser-internal/src/logging_system.ts b/packages/core/logging/core-logging-browser-internal/src/logging_system.ts index 50f7c449996ba..155146dce772c 100644 --- a/packages/core/logging/core-logging-browser-internal/src/logging_system.ts +++ b/packages/core/logging/core-logging-browser-internal/src/logging_system.ts @@ -6,17 +6,13 @@ * Side Public License, v 1. */ -import { LogLevel, Logger, LoggerFactory, LogLevelId, DisposableAppender } from '@kbn/logging'; -import { getLoggerContext } from '@kbn/core-logging-common-internal'; +import { LogLevel, Logger, LoggerFactory, DisposableAppender } from '@kbn/logging'; +import { getLoggerContext, BrowserLoggingConfig } from '@kbn/core-logging-common-internal'; import type { LoggerConfigType } from './types'; import { BaseLogger } from './logger'; import { PatternLayout } from './layouts'; import { ConsoleAppender } from './appenders'; -export interface BrowserLoggingConfig { - logLevel: LogLevelId; -} - const CONSOLE_APPENDER_ID = 'console'; /** @@ -54,7 +50,7 @@ export class BrowserLoggingSystem implements IBrowserLoggingSystem { private getLoggerConfigByContext(context: string): LoggerConfigType { return { - level: this.loggingConfig.logLevel, + level: this.loggingConfig.root.level, appenders: [CONSOLE_APPENDER_ID], name: context, }; diff --git a/packages/core/logging/core-logging-common-internal/index.ts b/packages/core/logging/core-logging-common-internal/index.ts index 24d5e93316789..6c9f1c510a512 100644 --- a/packages/core/logging/core-logging-common-internal/index.ts +++ b/packages/core/logging/core-logging-common-internal/index.ts @@ -22,3 +22,4 @@ export { ROOT_CONTEXT_NAME, DEFAULT_APPENDER_NAME, } from './src'; +export type { BrowserLoggingConfig, BrowserRootLoggerConfig } from './src/browser_config'; diff --git a/packages/core/logging/core-logging-common-internal/src/browser_config.ts b/packages/core/logging/core-logging-common-internal/src/browser_config.ts new file mode 100644 index 0000000000000..ccdc57b9369b3 --- /dev/null +++ b/packages/core/logging/core-logging-common-internal/src/browser_config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { LogLevelId } from '@kbn/logging'; + +/** + * @internal + */ +export interface BrowserLoggingConfig { + root: BrowserRootLoggerConfig; +} + +/** + * @internal + */ +export interface BrowserRootLoggerConfig { + level: LogLevelId; +} diff --git a/packages/core/logging/core-logging-server-internal/index.ts b/packages/core/logging/core-logging-server-internal/index.ts index 306770d7a988b..0bd464ae21cdd 100644 --- a/packages/core/logging/core-logging-server-internal/index.ts +++ b/packages/core/logging/core-logging-server-internal/index.ts @@ -9,6 +9,7 @@ export { config } from './src/logging_config'; export type { LoggingConfigType, + LoggingConfigWithBrowserType, loggerContextConfigSchema, loggerSchema, } from './src/logging_config'; diff --git a/packages/core/logging/core-logging-server-internal/src/logging_config.test.ts b/packages/core/logging/core-logging-server-internal/src/logging_config.test.ts index 41acd072b295d..764cce1b34dd5 100644 --- a/packages/core/logging/core-logging-server-internal/src/logging_config.test.ts +++ b/packages/core/logging/core-logging-server-internal/src/logging_config.test.ts @@ -12,6 +12,11 @@ test('`schema` creates correct schema with defaults.', () => { expect(config.schema.validate({})).toMatchInlineSnapshot(` Object { "appenders": Map {}, + "browser": Object { + "root": Object { + "level": "info", + }, + }, "loggers": Array [], "root": Object { "appenders": Array [ diff --git a/packages/core/logging/core-logging-server-internal/src/logging_config.ts b/packages/core/logging/core-logging-server-internal/src/logging_config.ts index 00eb1450f0abe..191e859a0fe6f 100644 --- a/packages/core/logging/core-logging-server-internal/src/logging_config.ts +++ b/packages/core/logging/core-logging-server-internal/src/logging_config.ts @@ -47,6 +47,12 @@ export const loggerSchema = schema.object({ level: levelSchema, }); +const browserConfig = schema.object({ + root: schema.object({ + level: levelSchema, + }), +}); + export const config = { path: 'logging', schema: schema.object({ @@ -63,6 +69,7 @@ export const config = { }), level: levelSchema, }), + browser: browserConfig, }), }; @@ -71,6 +78,10 @@ export type LoggingConfigType = Pick, 'loggers' | ' appenders: Map; }; +/** @internal */ +export type LoggingConfigWithBrowserType = LoggingConfigType & + Pick, 'browser'>; + /** * Config schema for validating the inputs to the {@link LoggingServiceStart.configure} API. * See {@link LoggerContextConfigType}. diff --git a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap index b6fedfd8644e4..7dd7b1739075c 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap +++ b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap @@ -57,6 +57,7 @@ Object { }, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -122,6 +123,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -191,6 +193,7 @@ Object { }, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -256,6 +259,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -321,6 +325,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -390,6 +395,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -455,6 +461,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -520,6 +527,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -594,6 +602,7 @@ Object { }, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -659,6 +668,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -733,6 +743,7 @@ Object { }, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -803,6 +814,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -868,6 +880,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -942,6 +955,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -1012,6 +1026,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { @@ -1082,6 +1097,7 @@ Object { "user": Object {}, }, }, + "logging": Any, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", "theme": Object { diff --git a/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts b/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts index 51f15a2ba034d..6408b986a7c87 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts @@ -6,9 +6,16 @@ * Side Public License, v 1. */ +import { firstValueFrom } from 'rxjs'; import UiSharedDepsNpm from '@kbn/ui-shared-deps-npm'; import * as UiSharedDepsSrc from '@kbn/ui-shared-deps-src'; +import type { IConfigService } from '@kbn/config'; +import type { BrowserLoggingConfig } from '@kbn/core-logging-common-internal'; import type { UiSettingsParams, UserProvidedValues } from '@kbn/core-ui-settings-common'; +import { + config as loggingConfigDef, + type LoggingConfigWithBrowserType, +} from '@kbn/core-logging-server-internal'; export const getSettingValue = ( settingName: string, @@ -54,3 +61,12 @@ export const getStylesheetPaths = ({ ]), ]; }; + +export const getBrowserLoggingConfig = async ( + configService: IConfigService +): Promise => { + const loggingConfig = await firstValueFrom( + configService.atPath(loggingConfigDef.path) + ); + return loggingConfig.browser; +}; diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.mocks.ts b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.mocks.ts index 580b9ca90dfa1..e4abd8b3ff95b 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.mocks.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.mocks.ts @@ -17,8 +17,10 @@ jest.doMock('./bootstrap', () => ({ export const getSettingValueMock = jest.fn(); export const getStylesheetPathsMock = jest.fn(); +export const getBrowserLoggingConfigMock = jest.fn(); jest.doMock('./render_utils', () => ({ getSettingValue: getSettingValueMock, getStylesheetPaths: getStylesheetPathsMock, + getBrowserLoggingConfig: getBrowserLoggingConfigMock, })); diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts index 521e697f29a40..4cedc33b1b79d 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts @@ -11,6 +11,7 @@ import { bootstrapRendererMock, getSettingValueMock, getStylesheetPathsMock, + getBrowserLoggingConfigMock, } from './rendering_service.test.mocks'; import { load } from 'cheerio'; @@ -32,6 +33,7 @@ const INJECTED_METADATA = { version: expect.any(String), branch: expect.any(String), buildNumber: expect.any(Number), + logging: expect.any(Object), env: { mode: { name: expect.any(String), @@ -199,6 +201,22 @@ function renderTestCases( const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); expect(data).toMatchSnapshot(INJECTED_METADATA); }); + + it('renders "core" with logging config injected', async () => { + const loggingConfig = { + root: { + level: 'info', + }, + }; + getBrowserLoggingConfigMock.mockReturnValue(loggingConfig); + const [render] = await getRender(); + const content = await render(createKibanaRequest(), uiSettings, { + isAnonymousPage: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + expect(data.logging).toEqual(loggingConfig); + }); }); } @@ -418,8 +436,9 @@ describe('RenderingService', () => { jest.clearAllMocks(); service = new RenderingService(mockRenderingServiceParams); - getSettingValueMock.mockImplementation((settingName: string) => settingName); - getStylesheetPathsMock.mockReturnValue(['/style-1.css', '/style-2.css']); + getSettingValueMock.mockReset().mockImplementation((settingName: string) => settingName); + getStylesheetPathsMock.mockReset().mockReturnValue(['/style-1.css', '/style-2.css']); + getBrowserLoggingConfigMock.mockReset().mockReturnValue({}); }); describe('preboot()', () => { diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index f5bb1f5fa115f..3dde32bc72972 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -29,7 +29,7 @@ import { RenderingMetadata, } from './types'; import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap'; -import { getSettingValue, getStylesheetPaths } from './render_utils'; +import { getSettingValue, getStylesheetPaths, getBrowserLoggingConfig } from './render_utils'; import { filterUiPlugins } from './filter_ui_plugins'; import type { InternalRenderingRequestHandlerContext } from './internal_types'; @@ -185,6 +185,8 @@ export class RenderingService { buildNum, }); + const loggingConfig = await getBrowserLoggingConfig(this.coreContext.configService); + const filteredPlugins = filterUiPlugins({ uiPlugins, isAnonymousPage }); const bootstrapScript = isAnonymousPage ? 'bootstrap-anonymous.js' : 'bootstrap.js'; const metadata: RenderingMetadata = { @@ -210,6 +212,7 @@ export class RenderingService { serverBasePath, publicBaseUrl, assetsHrefBase: staticAssetsHrefBase, + logging: loggingConfig, env, clusterInfo, anonymousStatusPage: status?.isStatusPageAnonymous() ?? false, diff --git a/packages/core/rendering/core-rendering-server-internal/tsconfig.json b/packages/core/rendering/core-rendering-server-internal/tsconfig.json index c689fe370e784..ba9dfdd87f307 100644 --- a/packages/core/rendering/core-rendering-server-internal/tsconfig.json +++ b/packages/core/rendering/core-rendering-server-internal/tsconfig.json @@ -39,6 +39,8 @@ "@kbn/core-custom-branding-server-mocks", "@kbn/core-user-settings-server-mocks", "@kbn/core-user-settings-server-internal", + "@kbn/core-logging-common-internal", + "@kbn/core-logging-server-internal", ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.test.ts b/packages/core/root/core-root-browser-internal/src/core_system.test.ts index 5d9c1533a58a3..54dfeec3934dd 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.test.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.test.ts @@ -80,6 +80,11 @@ const defaultCoreSystemParams = { version: '1.2.3', }, }, + logging: { + root: { + level: 'debug', + }, + }, version: 'version', } as any, }; @@ -192,40 +197,27 @@ describe('constructor', () => { }); describe('logging system', () => { - it('instantiate the logging system with the correct level when in dev mode', () => { + it('instantiate the logging system with the correct level', () => { const envMode: EnvironmentMode = { name: 'development', dev: true, prod: false, }; - const injectedMetadata = { env: { mode: envMode } } as any; + const injectedMetadata = { + ...defaultCoreSystemParams.injectedMetadata, + env: { mode: envMode }, + } as any; createCoreSystem({ injectedMetadata, }); expect(LoggingSystemConstructor).toHaveBeenCalledTimes(1); - expect(LoggingSystemConstructor).toHaveBeenCalledWith({ - logLevel: 'all', - }); + expect(LoggingSystemConstructor).toHaveBeenCalledWith( + defaultCoreSystemParams.injectedMetadata.logging + ); }); - it('instantiate the logging system with the correct level when in production mode', () => { - const envMode: EnvironmentMode = { - name: 'production', - dev: false, - prod: true, - }; - const injectedMetadata = { env: { mode: envMode } } as any; - - createCoreSystem({ - injectedMetadata, - }); - expect(LoggingSystemConstructor).toHaveBeenCalledTimes(1); - expect(LoggingSystemConstructor).toHaveBeenCalledWith({ - logLevel: 'warn', - }); - }); it('retrieves the logger factory from the logging system', () => { createCoreSystem({}); expect(MockLoggingSystem.asLoggerFactory).toHaveBeenCalledTimes(1); diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index 406083969cba3..817bb9991cc05 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -7,7 +7,6 @@ */ import { filter, firstValueFrom } from 'rxjs'; -import type { LogLevelId } from '@kbn/logging'; import type { CoreContext } from '@kbn/core-base-browser-internal'; import { InjectedMetadataService, @@ -112,8 +111,7 @@ export class CoreSystem { this.rootDomElement = rootDomElement; - const logLevel: LogLevelId = injectedMetadata.env.mode.dev ? 'all' : 'warn'; - this.loggingSystem = new BrowserLoggingSystem({ logLevel }); + this.loggingSystem = new BrowserLoggingSystem(injectedMetadata.logging); this.injectedMetadata = new InjectedMetadataService({ injectedMetadata, diff --git a/packages/core/root/core-root-browser-internal/tsconfig.json b/packages/core/root/core-root-browser-internal/tsconfig.json index fc971f93f8fed..700ba5e74c5da 100644 --- a/packages/core/root/core-root-browser-internal/tsconfig.json +++ b/packages/core/root/core-root-browser-internal/tsconfig.json @@ -59,7 +59,6 @@ "@kbn/core-integrations-browser-mocks", "@kbn/core-apps-browser-mocks", "@kbn/core-logging-browser-mocks", - "@kbn/logging", "@kbn/config", "@kbn/core-custom-branding-browser-internal", "@kbn/core-custom-branding-browser-mocks", From dc8a50fba9ae0c3c33e4c8fdf8a3f1b8ea074a19 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 12 Feb 2024 13:51:56 +0100 Subject: [PATCH 09/16] [Cases] Severity flaky test (#176589) Fixes #176337 Fixes #176336 ## Summary The usual fix. Running 200 times now to see if it works. --- .../all_cases/severity_filter.test.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx index 28be7c63f22b3..ca09d53501e5f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx @@ -14,9 +14,7 @@ import { screen, waitFor } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { SeverityFilter } from './severity_filter'; -// FLAKY: https://github.com/elastic/kibana/issues/176336 -// FLAKY: https://github.com/elastic/kibana/issues/176337 -describe.skip('Severity form field', () => { +describe('Severity form field', () => { const onChange = jest.fn(); let appMockRender: AppMockRenderer; const props = { @@ -30,28 +28,31 @@ describe.skip('Severity form field', () => { it('renders', async () => { appMockRender.render(); - expect(screen.getByTestId('options-filter-popover-button-severity')).toBeInTheDocument(); - expect(screen.getByTestId('options-filter-popover-button-severity')).not.toBeDisabled(); + expect(await screen.findByTestId('options-filter-popover-button-severity')).toBeInTheDocument(); + expect(await screen.findByTestId('options-filter-popover-button-severity')).not.toBeDisabled(); - userEvent.click(screen.getByRole('button', { name: 'Severity' })); + userEvent.click(await screen.findByRole('button', { name: 'Severity' })); await waitForEuiPopoverOpen(); - expect(screen.getByRole('option', { name: CaseSeverity.LOW })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: CaseSeverity.MEDIUM })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: CaseSeverity.HIGH })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: CaseSeverity.CRITICAL })).toBeInTheDocument(); - expect(screen.getAllByRole('option').length).toBe(4); + expect(await screen.findByRole('option', { name: CaseSeverity.LOW })).toBeInTheDocument(); + expect(await screen.findByRole('option', { name: CaseSeverity.MEDIUM })).toBeInTheDocument(); + expect(await screen.findByRole('option', { name: CaseSeverity.HIGH })).toBeInTheDocument(); + expect(await screen.findByRole('option', { name: CaseSeverity.CRITICAL })).toBeInTheDocument(); + expect((await screen.findAllByRole('option')).length).toBe(4); }); it('selects the correct value when changed', async () => { appMockRender.render(); - userEvent.click(screen.getByRole('button', { name: 'Severity' })); + userEvent.click(await screen.findByRole('button', { name: 'Severity' })); await waitForEuiPopoverOpen(); - userEvent.click(screen.getByRole('option', { name: 'high' })); + userEvent.click(await screen.findByRole('option', { name: 'high' })); await waitFor(() => { - expect(onChange).toHaveBeenCalledWith({ filterId: 'severity', selectedOptionKeys: ['high'] }); + expect(onChange).toHaveBeenCalledWith({ + filterId: 'severity', + selectedOptionKeys: ['high'], + }); }); }); }); From 1c244176b517f269d221192ff2bf7b78602c1e73 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Mon, 12 Feb 2024 13:59:46 +0100 Subject: [PATCH 10/16] [EDR Workflows] Copy changes (#176479) --- .../rule_response_actions/constants.ts | 2 +- .../endpoint/callout.test.tsx | 77 +++++++++ .../endpoint/callout.tsx | 10 +- .../endpoint/check_permissions.test.ts | 91 +++++++++++ .../endpoint/check_permissions.ts | 1 + .../endpoint/comment_field.tsx | 2 +- .../endpoint/field_name.tsx | 12 +- .../endpoint/overwrite_process_field.tsx | 15 +- .../rule_response_actions/endpoint/utils.tsx | 65 ++------ .../get_supported_response_actions.ts | 2 +- .../response_actions_form.tsx | 9 +- .../public/management/common/translations.ts | 150 ++++++++++++++++++ .../lib/console_commands_definition.ts | 102 +++--------- .../e2e/automated_response_actions/form.cy.ts | 6 +- .../no_license.cy.ts | 2 +- .../cypress/tasks/response_actions.ts | 8 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 19 files changed, 390 insertions(+), 176 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.test.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts index 19d6111b5a936..7947b2647a27a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts @@ -12,7 +12,7 @@ export const getActionDetails = (actionTypeId: string) => { case ResponseActionTypesEnum['.osquery']: return { logo: 'logoOsquery', name: 'Osquery' }; case ResponseActionTypesEnum['.endpoint']: - return { logo: 'logoSecurity', name: 'Endpoint Security' }; + return { logo: 'logoSecurity', name: 'Elastic Defend' }; // update when new responseActions are provided default: return { logo: 'logoOsquery', name: 'Osquery' }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.test.tsx new file mode 100644 index 0000000000000..8387bf6f741e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { EndpointActionCallout } from './callout'; +import { render } from '@testing-library/react'; +import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +jest.mock('@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'); + +const useFormDataMock = useFormData as jest.MockedFunction; + +const mockFormData = (data: Record) => { + (useFormDataMock as jest.MockedFunction).mockReturnValue([ + data, + jest.fn(), + false, + ]); +}; + +describe('EndpointActionCallout', () => { + describe('isolate', () => { + beforeAll(() => { + mockFormData({ + 'test.command': 'isolate', + }); + }); + it('renders insufficient privileges warning when editDisabled', () => { + const { getByText } = render(); + expect(getByText('Insufficient privileges')).toBeInTheDocument(); + }); + + it('renders isolation caution for isolate command', () => { + const { queryByText, getByText } = render( + + ); + expect(getByText('Proceed with caution')).toBeInTheDocument(); + expect( + getByText( + 'Only select this option if you’re certain that you want to automatically block communication with other hosts on your network until you release this host.' + ) + ).toBeInTheDocument(); + expect( + queryByText( + 'Only select this option if you’re certain that you want to automatically terminate the process running on a host.' + ) + ).not.toBeInTheDocument(); + }); + }); + + describe('kill-process/suspend-process', () => { + beforeAll(() => { + mockFormData({ + 'test.command': 'kill-process', + }); + }); + it('renders process caution for kill-process/suspend-process commands', () => { + const { queryByText, getByText } = render( + + ); + expect(getByText('Proceed with caution')).toBeInTheDocument(); + expect( + getByText( + 'Only select this option if you’re certain that you want to automatically terminate the process running on a host.' + ) + ).toBeInTheDocument(); + expect( + queryByText( + 'Only select this option if you’re certain that you want to automatically block communication with other hosts on your network until you release this host.' + ) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.tsx index 3468743f5d9d7..7516cf0fa5aaf 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/callout.tsx @@ -10,6 +10,7 @@ import { EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { get } from 'lodash'; +import { CONSOLE_COMMANDS } from '../../../management/common/translations'; interface EndpointCallOutProps { basePath: string; @@ -34,12 +35,7 @@ const EndpointActionCalloutComponent = ({ basePath, editDisabled }: EndpointCall /> } > - - - + {CONSOLE_COMMANDS.isolate.privileges} @@ -88,7 +84,7 @@ const EndpointActionCalloutComponent = ({ basePath, editDisabled }: EndpointCall diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.test.ts new file mode 100644 index 0000000000000..9f4bf6724acf5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { EndpointPrivileges } from '../../../../common/endpoint/types'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useCheckEndpointPermissions } from './check_permissions'; +jest.mock('../../../common/components/user_privileges'); + +const usePrivilegesMock = useUserPrivileges as jest.MockedFunction; + +const mockPrivileges = (config: Partial) => { + usePrivilegesMock.mockReturnValue({ + // @ts-expect-error missing some values that are not required for testing purposes + endpointPrivileges: config, + }); +}; + +describe('useCheckEndpointPermissions', () => { + const action = { + actionTypeId: '.endpoint' as const, + params: { + command: 'isolate' as const, + comment: 'test', + }, + }; + describe('with privileges', () => { + beforeAll(() => { + mockPrivileges({ + loading: false, + canIsolateHost: true, + canUnIsolateHost: true, + canKillProcess: true, + canSuspendProcess: true, + }); + }); + it('returns false when user has privileges for isolate', () => { + const result = useCheckEndpointPermissions(action); + + expect(result).toBe(false); + }); + it('returns false when user has privileges for kill-process', () => { + const result = useCheckEndpointPermissions({ + ...action, + params: { command: 'kill-process', config: { overwrite: true, field: '' } }, + }); + + expect(result).toBe(false); + }); + + it('returns undefined when actionTypeId is not a registered action', () => { + const result = useCheckEndpointPermissions({ + ...action, + // @ts-expect-error wrong value just for testing purposes + actionTypeId: 'notEndpoint', + }); + + expect(result).toBe(undefined); + }); + }); + + describe('without privileges', () => { + beforeEach(() => { + mockPrivileges({ + loading: false, + canIsolateHost: false, + canUnIsolateHost: false, + canKillProcess: false, + canSuspendProcess: false, + }); + }); + + it('return true if user has no privilege to execute a command', () => { + const result = useCheckEndpointPermissions(action); + + expect(result).toBe(true); + }); + + it('returns false when action is not endpoint command', () => { + const result = useCheckEndpointPermissions({ + ...action, + // @ts-expect-error wrong value just for testing purposes + actionTypeId: 'notEndpoint', + }); + expect(result).toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts index 2fe8fb81fa169..26fdc8935ca3c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts @@ -10,6 +10,7 @@ import type { RuleResponseAction } from '../../../../common/api/detection_engine import { getRbacControl } from '../../../../common/endpoint/service/response_actions/utils'; import { useUserPrivileges } from '../../../common/components/user_privileges'; +// returns false if the user does have the required privileges to execute the action, returns true if the user does not have the required privileges export const useCheckEndpointPermissions = (action: RuleResponseAction) => { const endpointPrivileges = useUserPrivileges().endpointPrivileges; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx index 1ae100103c76f..75e2039a1a480 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx @@ -23,7 +23,7 @@ const CONFIG = { helpText: ( ), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/field_name.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/field_name.tsx index 227d6a5d3329a..c713c23cfe65d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/field_name.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/field_name.tsx @@ -54,15 +54,21 @@ const FieldNameFieldComponent = ({ const renderEntityIdNote = useMemo(() => { const contains = fieldValue?.includes('entity_id'); + if (contains) { return ( ); } - return null; + return ( + + ); }, [fieldValue]); const CONFIG = useMemo(() => { @@ -80,7 +86,7 @@ const FieldNameFieldComponent = ({ 'xpack.securitySolution.responseActions.endpoint.validations.fieldNameIsRequiredErrorMessage', { defaultMessage: - '{field} is a required field when process.pid toggle is turned off', + '{field} selection is required when the process.pid toggle is disabled.', values: { field: FIELD_LABEL }, } ), diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/overwrite_process_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/overwrite_process_field.tsx index 143bd78e339d7..60b39842cdafc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/overwrite_process_field.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/overwrite_process_field.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; interface OverwriteFieldProps { path: string; @@ -23,9 +23,16 @@ const OverwriteFieldComponent = ({ const CONFIG = useMemo(() => { return { defaultValue: true, - label: i18n.translate('xpack.securitySolution.responseActions.endpoint.overwriteFieldLabel', { - defaultMessage: 'Use process.pid as process identifier', - }), + label: ( + process.pid, + }} + /> + ) as unknown as string, // in order to add a tag to the label, we need to cast element to string }; }, []); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/utils.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/utils.tsx index a07fd84bf4430..74e1c8d80cef3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/utils.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/utils.tsx @@ -7,7 +7,7 @@ import type { ReactNode } from 'react'; import React from 'react'; import { EuiText, EuiSpacer, EuiToolTip } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { CONSOLE_COMMANDS } from '../../../management/common/translations'; import type { EnabledAutomatedResponseActionsCommands } from '../../../../common/endpoint/service/response_actions/constants'; interface EndpointActionTextProps { @@ -43,66 +43,21 @@ const useGetCommandText = ( switch (name) { case 'isolate': return { - title: ( - - ), - description: ( - - ), - tooltip: ( - - ), + title: CONSOLE_COMMANDS.isolate.title, + description: CONSOLE_COMMANDS.isolate.about, + tooltip: CONSOLE_COMMANDS.isolate.privileges, }; case 'kill-process': return { - title: ( - - ), - description: ( - - ), - tooltip: ( - - ), + title: CONSOLE_COMMANDS.killProcess.title, + description: CONSOLE_COMMANDS.killProcess.about, + tooltip: CONSOLE_COMMANDS.killProcess.privileges, }; case 'suspend-process': return { - title: ( - - ), - description: ( - - ), - tooltip: ( - - ), + title: CONSOLE_COMMANDS.suspendProcess.title, + description: CONSOLE_COMMANDS.suspendProcess.about, + tooltip: CONSOLE_COMMANDS.suspendProcess.privileges, }; default: return { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts index e8afdd91d1ff3..51b599028156d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts @@ -46,7 +46,7 @@ export const responseActionTypes: ResponseActionType[] = [ }, { id: ResponseActionTypesEnum['.endpoint'], - name: 'Endpoint Security', + name: 'Elastic Defend', iconClass: 'logoSecurity', }, ]; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx index 31b5f16c1a037..b3f367ef675f6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx @@ -7,13 +7,14 @@ import React, { useEffect, useMemo, useState } from 'react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { map, reduce, upperFirst } from 'lodash'; +import { map, reduce } from 'lodash'; import ReactMarkdown from 'react-markdown'; import { ResponseActionsWrapper } from './response_actions_wrapper'; import { FORM_ERRORS_TITLE } from '../rule_creation/components/rule_actions_field/translations'; import { ResponseActionsHeader } from './response_actions_header'; import type { ArrayItem, FormHook } from '../../shared_imports'; import { useSupportedResponseActionTypes } from './use_supported_response_action_types'; +import { getActionDetails } from './constants'; interface ResponseActionsFormProps { items: ArrayItem[]; @@ -58,9 +59,9 @@ export const ResponseActionsForm = ({ if (name.includes(paramsPath)) { if (fields[name]?.errors?.length) { - const responseActionType = upperFirst( - (fields[`${path}.actionTypeId`].value as string).substring(1) - ); + const responseActionType = getActionDetails( + fields[`${path}.actionTypeId`].value as string + ).name; acc.push({ type: responseActionType, errors: map(fields[name].errors, 'message'), diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts index bd054654aa344..190d8ff7915c6 100644 --- a/x-pack/plugins/security_solution/public/management/common/translations.ts +++ b/x-pack/plugins/security_solution/public/management/common/translations.ts @@ -43,3 +43,153 @@ export const getLoadPoliciesError = (error: ServerApiError) => { values: { error: error.message }, }); }; + +export const CONSOLE_COMMANDS = { + isolate: { + title: i18n.translate('xpack.securitySolution.endpointConsoleCommands.isolate.title', { + defaultMessage: 'Isolate', + }), + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.isolate.about', { + defaultMessage: 'Isolate the host', + }), + privileges: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.isolate.privileges', + { + defaultMessage: + 'Insufficient privileges to isolate hosts. Contact your Kibana administrator if you think you should have this permission.', + } + ), + }, + release: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.release.about', { + defaultMessage: 'Release the host', + }), + }, + killProcess: { + title: i18n.translate('xpack.securitySolution.endpointConsoleCommands.killProcess.title', { + defaultMessage: 'Kill process', + }), + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.killProcess.about', { + defaultMessage: 'Kill/terminate a process', + }), + privileges: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.killProcess.privileges', + { + defaultMessage: + 'Insufficient privileges to kill process. Contact your Kibana administrator if you think you should have this permission.', + } + ), + args: { + pid: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.pid.arg.comment', { + defaultMessage: 'A PID representing the process to kill', + }), + }, + entityId: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.entityId.arg.comment', + { + defaultMessage: 'An entity id representing the process to kill', + } + ), + }, + }, + }, + suspendProcess: { + title: i18n.translate('xpack.securitySolution.endpointConsoleCommands.suspendProcess.title', { + defaultMessage: 'Suspend process', + }), + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.suspendProcess.about', { + defaultMessage: 'Temporarily suspend a process', + }), + privileges: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.privileges', + { + defaultMessage: + 'Insufficient privileges to supend process. Contact your Kibana administrator if you think you should have this permission.', + } + ), + args: { + pid: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.pid.arg.comment', + { + defaultMessage: 'A PID representing the process to suspend', + } + ), + }, + entityId: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.entityId.arg.comment', + { + defaultMessage: 'An entity id representing the process to suspend', + } + ), + }, + }, + }, + status: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.status.about', { + defaultMessage: 'Show host status information', + }), + }, + processes: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.processes.about', { + defaultMessage: 'Show all running processes', + }), + }, + getFile: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.getFile.about', { + defaultMessage: 'Retrieve a file from the host', + }), + args: { + path: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.getFile.pathArgAbout', + { + defaultMessage: 'The full file path to be retrieved', + } + ), + }, + }, + }, + execute: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.execute.about', { + defaultMessage: 'Execute a command on the host', + }), + args: { + timeout: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.execute.args.timeout.about', + { + defaultMessage: + 'The timeout in units of time (h for hours, m for minutes, s for seconds) for the endpoint to wait for the script to complete. Example: 37m. If not given, it defaults to 4 hours.', + } + ), + }, + }, + }, + upload: { + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.upload.about', { + defaultMessage: 'Upload a file to the host', + }), + args: { + file: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.upload.args.file.about', + { + defaultMessage: 'The file that will be sent to the host', + } + ), + }, + overwrite: { + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.upload.args.overwrite.about', + { + defaultMessage: 'Overwrite the file on the host if it already exists', + } + ), + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts index 9e429ab6a383c..238efec7542dc 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts @@ -37,6 +37,7 @@ import { import { getCommandAboutInfo } from './get_command_about_info'; import { validateUnitOfTime } from './utils'; +import { CONSOLE_COMMANDS } from '../../../common/translations'; const emptyArgumentValidator = (argData: ParsedArgData): true | string => { if (argData?.length > 0 && typeof argData[0] === 'string' && argData[0]?.trim().length > 0) { @@ -154,9 +155,7 @@ export const getEndpointConsoleCommands = ({ { name: 'isolate', about: getCommandAboutInfo({ - aboutInfo: i18n.translate('xpack.securitySolution.endpointConsoleCommands.isolate.about', { - defaultMessage: 'Isolate the host', - }), + aboutInfo: CONSOLE_COMMANDS.isolate.about, isSupported: doesEndpointSupportCommand('isolate'), }), RenderComponent: IsolateActionResult, @@ -185,9 +184,8 @@ export const getEndpointConsoleCommands = ({ { name: 'release', about: getCommandAboutInfo({ - aboutInfo: i18n.translate('xpack.securitySolution.endpointConsoleCommands.release.about', { - defaultMessage: 'Release the host', - }), + aboutInfo: CONSOLE_COMMANDS.release.about, + isSupported: doesEndpointSupportCommand('release'), }), RenderComponent: ReleaseActionResult, @@ -214,14 +212,10 @@ export const getEndpointConsoleCommands = ({ helpHidden: !getRbacControl({ commandName: 'release', privileges: endpointPrivileges }), }, { + // name: 'kill-process', about: getCommandAboutInfo({ - aboutInfo: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.killProcess.about', - { - defaultMessage: 'Kill/terminate a process', - } - ), + aboutInfo: CONSOLE_COMMANDS.killProcess.about, isSupported: doesEndpointSupportCommand('kill-process'), }), RenderComponent: KillProcessActionResult, @@ -245,21 +239,14 @@ export const getEndpointConsoleCommands = ({ required: false, allowMultiples: false, exclusiveOr: true, - about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.pid.arg.comment', { - defaultMessage: 'A PID representing the process to kill', - }), + about: CONSOLE_COMMANDS.killProcess.args.pid.about, validate: pidValidator, }, entityId: { required: false, allowMultiples: false, exclusiveOr: true, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.entityId.arg.comment', - { - defaultMessage: 'An entity id representing the process to kill', - } - ), + about: CONSOLE_COMMANDS.killProcess.args.entityId.about, validate: emptyArgumentValidator, }, }, @@ -272,12 +259,7 @@ export const getEndpointConsoleCommands = ({ { name: 'suspend-process', about: getCommandAboutInfo({ - aboutInfo: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.about', - { - defaultMessage: 'Temporarily suspend a process', - } - ), + aboutInfo: CONSOLE_COMMANDS.suspendProcess.about, isSupported: doesEndpointSupportCommand('suspend-process'), }), RenderComponent: SuspendProcessActionResult, @@ -301,24 +283,14 @@ export const getEndpointConsoleCommands = ({ required: false, allowMultiples: false, exclusiveOr: true, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.pid.arg.comment', - { - defaultMessage: 'A PID representing the process to suspend', - } - ), + about: CONSOLE_COMMANDS.suspendProcess.args.pid.about, validate: pidValidator, }, entityId: { required: false, allowMultiples: false, exclusiveOr: true, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.entityId.arg.comment', - { - defaultMessage: 'An entity id representing the process to suspend', - } - ), + about: CONSOLE_COMMANDS.suspendProcess.args.entityId.about, validate: emptyArgumentValidator, }, }, @@ -333,9 +305,7 @@ export const getEndpointConsoleCommands = ({ }, { name: 'status', - about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.status.about', { - defaultMessage: 'Show host status information', - }), + about: CONSOLE_COMMANDS.status.about, RenderComponent: EndpointStatusActionResult, meta: { endpointId: endpointAgentId, @@ -347,12 +317,7 @@ export const getEndpointConsoleCommands = ({ { name: 'processes', about: getCommandAboutInfo({ - aboutInfo: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.processes.about', - { - defaultMessage: 'Show all running processes', - } - ), + aboutInfo: CONSOLE_COMMANDS.processes.about, isSupported: doesEndpointSupportCommand('processes'), }), RenderComponent: GetProcessesActionResult, @@ -381,9 +346,7 @@ export const getEndpointConsoleCommands = ({ { name: 'get-file', about: getCommandAboutInfo({ - aboutInfo: i18n.translate('xpack.securitySolution.endpointConsoleCommands.getFile.about', { - defaultMessage: 'Retrieve a file from the host', - }), + aboutInfo: CONSOLE_COMMANDS.getFile.about, isSupported: doesEndpointSupportCommand('processes'), }), RenderComponent: GetFileActionResult, @@ -401,12 +364,7 @@ export const getEndpointConsoleCommands = ({ path: { required: true, allowMultiples: false, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.getFile.pathArgAbout', - { - defaultMessage: 'The full file path to be retrieved', - } - ), + about: CONSOLE_COMMANDS.getFile.args.path.about, validate: (argData) => { return emptyArgumentValidator(argData); }, @@ -429,9 +387,7 @@ export const getEndpointConsoleCommands = ({ { name: 'execute', about: getCommandAboutInfo({ - aboutInfo: i18n.translate('xpack.securitySolution.endpointConsoleCommands.execute.about', { - defaultMessage: 'Execute a command on the host', - }), + aboutInfo: CONSOLE_COMMANDS.execute.about, isSupported: doesEndpointSupportCommand('execute'), }), RenderComponent: ExecuteActionResult, @@ -455,13 +411,7 @@ export const getEndpointConsoleCommands = ({ timeout: { required: false, allowMultiples: false, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.execute.args.timeout.about', - { - defaultMessage: - 'The timeout in units of time (h for hours, m for minutes, s for seconds) for the endpoint to wait for the script to complete. Example: 37m. If not given, it defaults to 4 hours.', - } - ), + about: CONSOLE_COMMANDS.execute.args.timeout.about, mustHaveValue: 'non-empty-string', validate: executeTimeoutValidator, }, @@ -488,9 +438,7 @@ export const getEndpointConsoleCommands = ({ consoleCommands.push({ name: 'upload', about: getCommandAboutInfo({ - aboutInfo: i18n.translate('xpack.securitySolution.endpointConsoleCommands.upload.about', { - defaultMessage: 'Upload a file to the host', - }), + aboutInfo: CONSOLE_COMMANDS.upload.about, isSupported: doesEndpointSupportCommand('upload'), }), RenderComponent: UploadActionResult, @@ -508,24 +456,14 @@ export const getEndpointConsoleCommands = ({ file: { required: true, allowMultiples: false, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.upload.args.file.about', - { - defaultMessage: 'The file that will be sent to the host', - } - ), + about: CONSOLE_COMMANDS.upload.args.file.about, mustHaveValue: 'truthy', SelectorComponent: ArgumentFileSelector, }, overwrite: { required: false, allowMultiples: false, - about: i18n.translate( - 'xpack.securitySolution.endpointConsoleCommands.upload.args.overwrite.about', - { - defaultMessage: 'Overwrite the file on the host if it already exists', - } - ), + about: CONSOLE_COMMANDS.upload.args.overwrite.about, mustHaveValue: false, }, comment: { diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts index 4fa0bb3b0c2e0..770be07a61257 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts @@ -119,7 +119,7 @@ describe( cy.getByTestSubj(RESPONSE_ACTIONS_ERRORS).within(() => { cy.contains( - 'Custom field name is a required field when process.pid toggle is turned off' + 'Custom field name selection is required when the process.pid toggle is disabled.' ); }); @@ -208,7 +208,7 @@ describe( it('response actions are disabled', () => { fillUpNewRule(ruleName, ruleDescription); cy.getByTestSubj('response-actions-wrapper').within(() => { - cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').should( + cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').should( 'be.disabled' ); }); @@ -234,7 +234,7 @@ describe( cy.getByTestSubj('edit-rule-actions-tab').click(); cy.getByTestSubj('response-actions-wrapper').within(() => { - cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').should( + cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').should( 'be.disabled' ); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts index d120459b64ea3..a427413429b19 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts @@ -25,7 +25,7 @@ describe('No License', { tags: '@ess', env: { ftrConfig: { license: 'basic' } } it('response actions are disabled', () => { fillUpNewRule(ruleName, ruleDescription); cy.getByTestSubj('response-actions-wrapper').within(() => { - cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').should( + cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').should( 'be.disabled' ); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts index a03d5a2ed483d..488742ac945c8 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -43,7 +43,7 @@ export const validateAvailableCommands = () => { }; export const addEndpointResponseAction = () => { cy.getByTestSubj('response-actions-wrapper').within(() => { - cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').click(); + cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').click(); }); }; export const focusAndOpenCommandDropdown = (number = 0) => { @@ -99,12 +99,10 @@ export const getRunningProcesses = (command: string): Cypress.Chainable export const tryAddingDisabledResponseAction = (itemNumber = 0) => { cy.getByTestSubj('response-actions-wrapper').within(() => { - cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').should( - 'be.disabled' - ); + cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').should('be.disabled'); }); // Try adding new action, should not add list item. - cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').click({ + cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').click({ force: true, }); cy.getByTestSubj(`response-actions-list-item-${itemNumber}`).should('not.exist'); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f985a5e55e58c..9f2a66925c226 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34322,6 +34322,7 @@ "xpack.securitySolution.endpointConsoleCommands.invalidExecuteTimeout": "L'argument doit être une chaîne avec une valeur entière positive suivie d'une unité de temps (h pour les heures, m pour les minutes, s pour les secondes). Exemple : 37m.", "xpack.securitySolution.endpointConsoleCommands.invalidPidMessage": "L'argument doit être un nombre positif représentant le PID d'un processus.", "xpack.securitySolution.endpointConsoleCommands.isolate.about": "Isoler l'hôte", + "xpack.securitySolution.endpointConsoleCommands.isolate.privileges": "Privilèges insuffisants pour isoler les hôtes. Contactez votre administrateur Kibana si vous pensez que vous devriez bénéficier de cette permission.", "xpack.securitySolution.endpointConsoleCommands.killProcess.about": "Arrêter un processus", "xpack.securitySolution.endpointConsoleCommands.pid.arg.comment": "Un PID représentant le processus à arrêter", "xpack.securitySolution.endpointConsoleCommands.processes.about": "Afficher tous les processus en cours d'exécution", @@ -35482,9 +35483,6 @@ "xpack.securitySolution.responseActions.endpoint.commentDescription": "Laissez une note qui explique ou décrit une action. Vous pouvez voir votre commentaire dans le log de l'historique d'actions de réponse.", "xpack.securitySolution.responseActions.endpoint.commentLabel": "Commentaire (facultatif)", "xpack.securitySolution.responseActions.endpoint.commentLearnMore": "En savoir plus", - "xpack.securitySolution.responseActions.endpoint.isolate": "Isoler", - "xpack.securitySolution.responseActions.endpoint.isolateDescription": "Mettez un hôte du réseau en quarantaine pour empêcher la propagation des menaces et limiter les dégâts potentiels", - "xpack.securitySolution.responseActions.endpoint.isolateTooltip": "Privilèges insuffisants pour isoler les hôtes. Contactez votre administrateur Kibana si vous pensez que vous devriez bénéficier de cette permission.", "xpack.securitySolution.responseActions.endpoint.validations.commandIsRequiredErrorMessage": "Action est un champ requis.", "xpack.securitySolution.responseActionsHistory.empty.content": "Aucune action de réponse effectuée", "xpack.securitySolution.responseActionsHistory.empty.link": "En savoir plus sur les actions de réponse", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 727b85d353289..248e4fddb7f3c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34322,6 +34322,7 @@ "xpack.securitySolution.endpointConsoleCommands.invalidExecuteTimeout": "引数は、正の整数の文字列の後に時間単位(h:時間、m:分、s:秒)を付けた値でなければなりません。例:37m", "xpack.securitySolution.endpointConsoleCommands.invalidPidMessage": "引数は、プロセスのPIDを表す正の数値でなければなりません", "xpack.securitySolution.endpointConsoleCommands.isolate.about": "ホストの分離", + "xpack.securitySolution.endpointConsoleCommands.isolate.privileges": "ホストを分離するための権限が不足しています。この権限が必要だと思われる場合は、Kibana管理者にお問い合わせください。", "xpack.securitySolution.endpointConsoleCommands.killProcess.about": "プロセスを終了", "xpack.securitySolution.endpointConsoleCommands.pid.arg.comment": "終了するプロセスを表すPID", "xpack.securitySolution.endpointConsoleCommands.processes.about": "すべての実行中のプロセスを表示", @@ -35482,9 +35483,6 @@ "xpack.securitySolution.responseActions.endpoint.commentDescription": "アクションについての説明やメモを残してください。自分のコメントは、対応アクションの履歴ログで確認できます。", "xpack.securitySolution.responseActions.endpoint.commentLabel": "コメント(任意)", "xpack.securitySolution.responseActions.endpoint.commentLearnMore": "詳細", - "xpack.securitySolution.responseActions.endpoint.isolate": "分離", - "xpack.securitySolution.responseActions.endpoint.isolateDescription": "ホストをネットワークから隔離して、脅威のさらなる拡散を防ぎ、潜在的な被害を抑える", - "xpack.securitySolution.responseActions.endpoint.isolateTooltip": "ホストを分離するための権限が不足しています。この権限が必要だと思われる場合は、Kibana管理者にお問い合わせください。", "xpack.securitySolution.responseActions.endpoint.validations.commandIsRequiredErrorMessage": "アクションは必須フィールドです", "xpack.securitySolution.responseActionsHistory.empty.content": "対応アクションログは実行されません", "xpack.securitySolution.responseActionsHistory.empty.link": "対応アクションの詳細を読む", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 827adc04e0444..6c23e8d2f26a3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34304,6 +34304,7 @@ "xpack.securitySolution.endpointConsoleCommands.invalidExecuteTimeout": "参数必须为含有正整数值的字符串,后接时间单位(h 表示小时,m 表示分钟,s 表示秒)。例如:37m。", "xpack.securitySolution.endpointConsoleCommands.invalidPidMessage": "参数必须为表示进程 PID 的正整数", "xpack.securitySolution.endpointConsoleCommands.isolate.about": "隔离主机", + "xpack.securitySolution.endpointConsoleCommands.isolate.privileges": "权限不足,无法隔离主机。如果认为您应具有此权限,请与 Kibana 管理员联系。", "xpack.securitySolution.endpointConsoleCommands.killProcess.about": "结束/终止进程", "xpack.securitySolution.endpointConsoleCommands.pid.arg.comment": "表示要结束的进程的 PID", "xpack.securitySolution.endpointConsoleCommands.processes.about": "显示所有正在运行的进程", @@ -35464,9 +35465,6 @@ "xpack.securitySolution.responseActions.endpoint.commentDescription": "留下解释或描述该操作的备注。您可以在响应操作历史记录日志中查看注释。", "xpack.securitySolution.responseActions.endpoint.commentLabel": "注释(可选)", "xpack.securitySolution.responseActions.endpoint.commentLearnMore": "了解详情", - "xpack.securitySolution.responseActions.endpoint.isolate": "隔离", - "xpack.securitySolution.responseActions.endpoint.isolateDescription": "将主机与网络隔离,防止威胁进一步扩散并限制潜在损害", - "xpack.securitySolution.responseActions.endpoint.isolateTooltip": "权限不足,无法隔离主机。如果认为您应具有此权限,请与 Kibana 管理员联系。", "xpack.securitySolution.responseActions.endpoint.validations.commandIsRequiredErrorMessage": "“操作”为必填字段。", "xpack.securitySolution.responseActionsHistory.empty.content": "未执行响应操作", "xpack.securitySolution.responseActionsHistory.empty.link": "阅读有关响应操作的更多内容", From 78c040d4a9f4a3a08fb628a0c910b657a67a4f7a Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Mon, 12 Feb 2024 14:00:15 +0100 Subject: [PATCH 11/16] [Security Solution] Add asset criticality to the entity details pages (#176294) ## Summary * Add asset criticality component to entity details pages * Creates a compacted version of the component that is only used on the details page * Extract `createMockStore ` to a file that doesn't reference jest to fix broken storybooks. [here](https://github.com/elastic/kibana/pull/176294/files#diff-476d75f8444044ba6e667feaa99464c5b04a91151bffcc2e02d134e8aa5bb115R22) * Create storybook for `AssetCriticalitySelector` * Create jest tests for `AssetCriticalitySelector` * Refactor a bit `useAssetCriticalityPrivileges`, `useAssetCriticalityData` and `AssetCriticalitySelector` to make it reusable for entity pages. They have a different design that doesn't have an accordion. https://github.com/elastic/kibana/assets/1490444/2320bee5-10e1-42c7-9ac1-6e0850f65f2d ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) --- .../guided_onboarding_tour/tour_step.test.tsx | 3 +- .../public/common/mock/create_store.ts | 30 +++ .../public/common/mock/index.ts | 1 + .../common/mock/storybook_providers.tsx | 5 +- .../public/common/mock/test_providers.tsx | 23 +-- .../asset_criticality_badge.tsx | 9 +- .../asset_criticality_selector.stories.tsx | 83 ++++++++ .../asset_criticality_selector.test.tsx | 57 ++++++ .../asset_criticality_selector.tsx | 180 ++++++++++++------ .../use_asset_criticality.ts | 56 +++--- .../explore/hosts/pages/details/index.tsx | 31 ++- .../explore/users/pages/details/index.tsx | 32 ++++ .../entity_details/host_right/content.tsx | 4 +- .../entity_details/user_right/content.tsx | 4 +- .../side_panel/host_details/index.tsx | 6 +- .../user_details/user_details_flyout.tsx | 4 +- .../user_details/user_details_side_panel.tsx | 4 +- 17 files changed, 398 insertions(+), 134 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/mock/create_store.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx index 7ab5ca58ca0da..cf693392f83c4 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx @@ -10,9 +10,8 @@ import type { EuiTourStepProps } from '@elastic/eui'; import { GuidedOnboardingTourStep, SecurityTourStep } from './tour_step'; import { AlertsCasesTourSteps, SecurityStepId } from './tour_config'; import { useTourContext } from './tour'; -import { mockGlobalState, TestProviders } from '../../mock'; +import { mockGlobalState, TestProviders, createMockStore } from '../../mock'; import { TimelineId } from '../../../../common/types'; -import { createMockStore } from '../../mock/test_providers'; jest.mock('./tour'); const mockTourStep = jest diff --git a/x-pack/plugins/security_solution/public/common/mock/create_store.ts b/x-pack/plugins/security_solution/public/common/mock/create_store.ts new file mode 100644 index 0000000000000..91eac259e61f2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/mock/create_store.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 type { Dispatch, Middleware } from 'redux'; +import { SUB_PLUGINS_REDUCER } from './utils'; +import type { State } from '../store'; +import { createStore } from '../store'; +import { mockGlobalState } from './global_state'; +import type { AppAction } from '../store/actions'; +import type { Immutable } from '../../../common/endpoint/types'; +import type { StartServices } from '../../types'; +import { createSecuritySolutionStorageMock } from './mock_local_storage'; + +const { storage: storageMock } = createSecuritySolutionStorageMock(); + +const kibanaMock = {} as unknown as StartServices; + +export const createMockStore = ( + state: State = mockGlobalState, + pluginsReducer: typeof SUB_PLUGINS_REDUCER = SUB_PLUGINS_REDUCER, + kibana: typeof kibanaMock = kibanaMock, + storage: typeof storageMock = storageMock, + additionalMiddleware?: Array>>> +) => { + return createStore(state, pluginsReducer, kibana, storage, additionalMiddleware); +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/index.ts b/x-pack/plugins/security_solution/public/common/mock/index.ts index 89330cad8d478..d4cc1185846bb 100644 --- a/x-pack/plugins/security_solution/public/common/mock/index.ts +++ b/x-pack/plugins/security_solution/public/common/mock/index.ts @@ -18,3 +18,4 @@ export * from './netflow'; export * from './test_providers'; export * from './timeline_results'; export * from './utils'; +export * from './create_store'; diff --git a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx index 0fe2b8a2ecec9..6417180b8fd70 100644 --- a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx @@ -16,11 +16,8 @@ import { I18nProvider } from '@kbn/i18n-react'; import { CellActionsProvider } from '@kbn/cell-actions'; import { NavigationProvider } from '@kbn/security-solution-navigation'; import { CASES_FEATURE_ID } from '../../../common'; -import type { StartServices } from '../../types'; import { ReactQueryClientProvider } from '../containers/query_client/query_client_provider'; -import { createMockStore } from './test_providers'; - -export const kibanaMock = {} as unknown as StartServices; +import { createMockStore } from './create_store'; const uiSettings = { get: (setting: string) => { diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 17e0efa86773c..3d97f62767837 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -12,7 +12,7 @@ import React from 'react'; import type { DropResult, ResponderProvided } from '@hello-pangea/dnd'; import { DragDropContext } from '@hello-pangea/dnd'; import { Provider as ReduxStoreProvider } from 'react-redux'; -import type { Dispatch, Middleware, Store } from 'redux'; +import type { Store } from 'redux'; import { ThemeProvider } from 'styled-components'; import type { Capabilities } from '@kbn/core/public'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -24,22 +24,16 @@ import { useKibana } from '../lib/kibana'; import { UpsellingProvider } from '../components/upselling_provider'; import { MockAssistantProvider } from './mock_assistant_provider'; import { ConsoleManager } from '../../management/components/console'; -import type { State } from '../store'; -import { createStore } from '../store'; -import { mockGlobalState } from './global_state'; import { createKibanaContextProviderMock, createStartServicesMock, } from '../lib/kibana/kibana_react.mock'; import type { FieldHook } from '../../shared_imports'; -import { SUB_PLUGINS_REDUCER } from './utils'; -import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; +import { localStorageMock } from './mock_local_storage'; import { ASSISTANT_FEATURE_ID, CASES_FEATURE_ID } from '../../../common/constants'; import { UserPrivilegesProvider } from '../components/user_privileges/user_privileges_context'; import { MockDiscoverInTimelineContext } from '../components/discover_in_timeline/mocks/discover_in_timeline_provider'; -import type { AppAction } from '../store/actions'; -import type { Immutable } from '../../../common/endpoint/types'; - +import { createMockStore } from './create_store'; interface Props { children?: React.ReactNode; store?: Store; @@ -54,17 +48,6 @@ Object.defineProperty(window, 'localStorage', { }); window.scrollTo = jest.fn(); const MockKibanaContextProvider = createKibanaContextProviderMock(); -const { storage: storageMock } = createSecuritySolutionStorageMock(); - -export const createMockStore = ( - state: State = mockGlobalState, - pluginsReducer: typeof SUB_PLUGINS_REDUCER = SUB_PLUGINS_REDUCER, - kibana: typeof kibanaMock = kibanaMock, - storage: typeof storageMock = storageMock, - additionalMiddleware?: Array>>> -) => { - return createStore(state, pluginsReducer, kibana, storage, additionalMiddleware); -}; /** A utility for wrapping children in the providers required to run most tests */ export const TestProvidersComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_badge.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_badge.tsx index a28e0e9e4df8a..036dcd4a6013b 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_badge.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_badge.tsx @@ -22,12 +22,14 @@ export const AssetCriticalityBadge: React.FC<{ criticalityLevel: CriticalityLevel; withDescription?: boolean; style?: React.CSSProperties; + className?: string; dataTestSubj?: string; }> = ({ criticalityLevel, style, dataTestSubj = 'asset-criticality-badge', withDescription = false, + className, }) => { const showDescription = withDescription ?? false; const badgeContent = showDescription ? ( @@ -46,6 +48,7 @@ export const AssetCriticalityBadge: React.FC<{ data-test-subj={dataTestSubj} color={CRITICALITY_LEVEL_COLOR[criticalityLevel]} style={style} + className={className} > {badgeContent} @@ -57,7 +60,8 @@ export const AssetCriticalityBadgeAllowMissing: React.FC<{ withDescription?: boolean; style?: React.CSSProperties; dataTestSubj?: string; -}> = ({ criticalityLevel, style, dataTestSubj, withDescription }) => { + className?: string; +}> = ({ criticalityLevel, style, dataTestSubj, withDescription, className }) => { if (criticalityLevel) { return ( ); } return ( - + ( + ({ eui: euiLightVars, darkMode: false })}>{storyFn()} +)); +const criticality = { + status: 'create', + query: {}, + privileges: {}, + mutation: {}, +} as State; + +const criticalityLoading = { + ...criticality, + query: { isLoading: true }, +} as State; + +export default { + component: AssetCriticalitySelector, + title: 'Components/AssetCriticalitySelector', +}; + +export const Default: Story = () => { + return ( + + +
+ +
+
+
+ ); +}; + +export const Compressed: Story = () => { + return ( + + +
+ +
+
+
+ ); +}; + +export const Loading: Story = () => { + return ( + + +
+ +
+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.test.tsx new file mode 100644 index 0000000000000..691c240e651a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.test.tsx @@ -0,0 +1,57 @@ +/* + * 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 { TestProviders } from '../../../common/mock'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { AssetCriticalitySelector } from './asset_criticality_selector'; +import type { State } from './use_asset_criticality'; + +const criticality = { + status: 'create', + query: {}, + privileges: { + data: { + has_write_permissions: true, + }, + }, + mutation: {}, +} as State; + +describe('AssetCriticalitySelector', () => { + it('renders', () => { + const { getByTestId } = render( + , + { + wrapper: TestProviders, + } + ); + + expect(getByTestId('asset-criticality-selector')).toBeInTheDocument(); + }); + + it('renders when compressed', () => { + const { getByTestId } = render( + , + { + wrapper: TestProviders, + } + ); + + expect(getByTestId('asset-criticality-change-btn')).toHaveAttribute( + 'aria-label', + 'Change asset criticality' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx index f6a0da49197c8..7bff57b119242 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx @@ -6,7 +6,12 @@ */ import type { EuiSuperSelectOption } from '@elastic/eui'; + import { + EuiSpacer, + useEuiFontSize, + EuiButtonIcon, + useGeneratedHtmlId, EuiAccordion, EuiButton, EuiButtonEmpty, @@ -19,32 +24,122 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiSuperSelect, - EuiText, EuiTitle, EuiHorizontalRule, useEuiTheme, } from '@elastic/eui'; import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; +import { css } from '@emotion/css'; +import { i18n } from '@kbn/i18n'; +import { useToggle } from 'react-use'; import { PICK_ASSET_CRITICALITY } from './translations'; import { AssetCriticalityBadge, AssetCriticalityBadgeAllowMissing, } from './asset_criticality_badge'; -import type { Entity, ModalState, State } from './use_asset_criticality'; -import { useAssetCriticalityData, useCriticalityModal } from './use_asset_criticality'; +import type { Entity, State } from './use_asset_criticality'; +import { useAssetCriticalityData, useAssetCriticalityPrivileges } from './use_asset_criticality'; import type { CriticalityLevel } from '../../../../common/entity_analytics/asset_criticality/types'; interface Props { entity: Entity; } -const AssetCriticalityComponent: React.FC = ({ entity }) => { - const modal = useCriticalityModal(); - const criticality = useAssetCriticalityData(entity, modal); +const AssetCriticalitySelectorComponent: React.FC<{ + criticality: State; + entity: Entity; + compressed?: boolean; +}> = ({ criticality, entity, compressed = false }) => { + const [visible, toggleModal] = useToggle(false); + const sFontSize = useEuiFontSize('s').fontSize; + + return ( + <> + {criticality.query.isLoading || criticality.mutation.isLoading ? ( + <> + + + + ) : ( + + + + + {compressed && criticality.privileges.data?.has_write_permissions && ( + + toggleModal(true)} + /> + + )} + + {!compressed && criticality.privileges.data?.has_write_permissions && ( + + toggleModal(true)} + > + {criticality.status === 'update' ? ( + + ) : ( + + )} + + + )} + + )} + {visible ? ( + + ) : null} + + ); +}; + +export const AssetCriticalitySelector = React.memo(AssetCriticalitySelectorComponent); +AssetCriticalitySelector.displayName = 'AssetCriticalitySelector'; + +const AssetCriticalityAccordionComponent: React.FC = ({ entity }) => { const { euiTheme } = useEuiTheme(); + const privileges = useAssetCriticalityPrivileges(entity.name); + const criticality = useAssetCriticalityData({ + entity, + enabled: !!privileges.data?.has_read_permissions, + }); - if (criticality.privileges.isLoading || !criticality.privileges.data?.has_read_permissions) { + if (privileges.isLoading || !privileges.data?.has_read_permissions) { return null; } @@ -70,69 +165,27 @@ const AssetCriticalityComponent: React.FC = ({ entity }) => { }} data-test-subj="asset-criticality-selector" > - {criticality.query.isLoading || criticality.mutation.isLoading ? ( - - ) : ( - - - - - - - {criticality.privileges.data?.has_write_permissions && ( - - modal.toggle(true)} - > - {criticality.status === 'update' ? ( - - ) : ( - - )} - - - )} - - )} + - {modal.visible ? ( - - ) : null} ); }; interface ModalProps { criticality: State; - modal: ModalState; + toggle: (nextValue: boolean) => void; entity: Entity; } -const AssetCriticalityModal: React.FC = ({ criticality, modal, entity }) => { + +const AssetCriticalityModal: React.FC = ({ criticality, entity, toggle }) => { + const basicSelectId = useGeneratedHtmlId({ prefix: 'basicSelect' }); const [value, setNewValue] = useState( criticality.query.data?.criticality_level ?? 'normal' ); return ( - modal.toggle(false)}> + toggle(false)}> {PICK_ASSET_CRITICALITY} @@ -140,7 +193,7 @@ const AssetCriticalityModal: React.FC = ({ criticality, modal, entit = ({ criticality, modal, entit /> - modal.toggle(false)}> + toggle(false)}> = ({ criticality, modal, entit + onClick={() => { criticality.mutation.mutate({ criticalityLevel: value, idField: `${entity.type}.name`, idValue: entity.name, - }) - } + }); + toggle(false); + }} fill data-test-subj="asset-criticality-modal-save-btn" > @@ -198,5 +252,5 @@ const options: Array> = [ option('very_important'), ]; -export const AssetCriticalitySelector = React.memo(AssetCriticalityComponent); -AssetCriticalitySelector.displayName = 'AssetCriticalitySelector'; +export const AssetCriticalityAccordion = React.memo(AssetCriticalityAccordionComponent); +AssetCriticalityAccordion.displayName = 'AssetCriticalityAccordion'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts index 893f157d045cf..1d43ec6dacde4 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts @@ -5,49 +5,50 @@ * 2.0. */ -import { useGeneratedHtmlId } from '@elastic/eui'; import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { useToggle } from 'react-use'; import type { AssetCriticalityRecord } from '../../../../common/api/entity_analytics/asset_criticality'; import type { EntityAnalyticsPrivileges } from '../../../../common/api/entity_analytics/common'; import type { AssetCriticality } from '../../api/api'; import { useEntityAnalyticsRoutes } from '../../api/api'; -// SUGGESTION: @tiansivive Move this to some more general place within Entity Analytics -export const buildCriticalityQueryKeys = (id: string) => { - const ASSET_CRITICALITY = 'ASSET_CRITICALITY'; - const PRIVILEGES = 'PRIVILEGES'; - return { - doc: [ASSET_CRITICALITY, id], - privileges: [ASSET_CRITICALITY, PRIVILEGES, id], - }; -}; - -export const useAssetCriticalityData = (entity: Entity, modal: ModalState): State => { - const QC = useQueryClient(); - const QUERY_KEYS = buildCriticalityQueryKeys(entity.name); +const ASSET_CRITICALITY_KEY = 'ASSET_CRITICALITY'; +const PRIVILEGES_KEY = 'PRIVILEGES'; - const { fetchAssetCriticality, createAssetCriticality, fetchAssetCriticalityPrivileges } = - useEntityAnalyticsRoutes(); +export const useAssetCriticalityPrivileges = ( + entityName: string +): UseQueryResult => { + const { fetchAssetCriticalityPrivileges } = useEntityAnalyticsRoutes(); - const privileges = useQuery({ - queryKey: QUERY_KEYS.privileges, + return useQuery({ + queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, entityName], queryFn: fetchAssetCriticalityPrivileges, }); +}; + +export const useAssetCriticalityData = ({ + entity, + enabled = true, +}: { + entity: Entity; + enabled?: boolean; +}): State => { + const QC = useQueryClient(); + const QUERY_KEY = [ASSET_CRITICALITY_KEY, entity.name]; + const { fetchAssetCriticality, createAssetCriticality } = useEntityAnalyticsRoutes(); + + const privileges = useAssetCriticalityPrivileges(entity.name); const query = useQuery({ - queryKey: QUERY_KEYS.doc, + queryKey: QUERY_KEY, queryFn: () => fetchAssetCriticality({ idField: `${entity.type}.name`, idValue: entity.name }), retry: (failureCount, error) => error.body.statusCode === 404 && failureCount > 0, - enabled: !!privileges.data?.has_read_permissions, + enabled, }); const mutation = useMutation({ mutationFn: createAssetCriticality, onSuccess: (data) => { - QC.setQueryData(QUERY_KEYS.doc, data); - modal.toggle(false); + QC.setQueryData(QUERY_KEY, data); }, }); @@ -59,12 +60,6 @@ export const useAssetCriticalityData = (entity: Entity, modal: ModalState): Stat }; }; -export const useCriticalityModal = () => { - const [visible, toggle] = useToggle(false); - const basicSelectId = useGeneratedHtmlId({ prefix: 'basicSelect' }); - return { visible, toggle, basicSelectId }; -}; - export interface State { status: 'create' | 'update'; query: UseQueryResult; @@ -74,7 +69,6 @@ export interface State { type Params = Pick; export interface ModalState { - basicSelectId: string; visible: boolean; toggle: (next: boolean) => void; } diff --git a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx index 002a1b24eb0a8..3bf714efaf866 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx @@ -10,6 +10,7 @@ import { EuiFlexItem, EuiHorizontalRule, EuiSpacer, + EuiTitle, EuiWindowEvent, } from '@elastic/eui'; import { noop } from 'lodash/fp'; @@ -20,6 +21,12 @@ import type { Filter } from '@kbn/es-query'; import { buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { tableDefaults, dataTableSelectors, TableId } from '@kbn/securitysolution-data-table'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + useAssetCriticalityData, + useAssetCriticalityPrivileges, +} from '../../../../entity_analytics/components/asset_criticality/use_asset_criticality'; +import { AssetCriticalitySelector } from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { AlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; @@ -167,6 +174,14 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta [detailName] ); + const entity = useMemo(() => ({ type: 'host' as const, name: detailName }), [detailName]); + const privileges = useAssetCriticalityPrivileges(entity.name); + const canReadAssetCriticality = !!privileges.data?.has_read_permissions; + const criticality = useAssetCriticalityData({ + entity, + enabled: canReadAssetCriticality, + }); + return ( <> {indicesExist ? ( @@ -199,7 +214,21 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta ), ]} /> - + {canReadAssetCriticality && ( + <> + +

+ +

+
+ + + + + )} = ({ [detailName] ); + const entity = useMemo(() => ({ type: 'user' as const, name: detailName }), [detailName]); + const privileges = useAssetCriticalityPrivileges(entity.name); + const canReadAssetCriticality = !!privileges.data?.has_read_permissions; + const criticality = useAssetCriticalityData({ + entity, + enabled: canReadAssetCriticality, + }); + return ( <> {indicesExist ? ( @@ -191,6 +206,23 @@ const UsersDetailsComponent: React.FC = ({ title={detailName} /> + {canReadAssetCriticality && ( + <> + + +

+ +

+
+ + + + + )} + )} - + )} - + = React.memo( - + @@ -111,7 +111,7 @@ export const HostDetailsPanel: React.FC = React.memo( - + - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_side_panel.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_side_panel.tsx index 1f52f65a899f9..2223b5cc04f6a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_side_panel.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_side_panel.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiSpacer } from '@elastic/eu import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { AssetCriticalitySelector } from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; +import { AssetCriticalityAccordion } from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { ExpandableUserDetailsTitle, ExpandableUserDetailsPageLink, @@ -68,7 +68,7 @@ export const UserDetailsSidePanel = ({ - + Date: Mon, 12 Feb 2024 14:09:23 +0100 Subject: [PATCH 12/16] [Security Solution] Fix losing data upon prebuilt rule upgrade to a new version in which the rule's type is different (#176421) **Fixes:** https://github.com/elastic/kibana/issues/169480 ## Summary This PR fixes losing the following rule data upon prebuilt rule upgrade to a new version in which the rule's type is different - Saved Object id - exceptions list (default and shared) - Timeline id - Timeline title ## Details The problem occurs when user upgrades a prebuilt rule to a newer version which has a different rule type. Checking the code it's not so hard to find [`upgradeRule()`](https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts#L49) function which performs prebuilt rule upgrade. It has the following comment > If we're trying to change the type of a prepackaged rule, we need to delete the old one and replace it with the new rule, keeping the enabled setting, actions, throttle, id, and exception lists from the old rule. Looking below in the code it's clear that only enabled state and actions get restored upon rule upgrade. Missing to restore `exceptions lists` leads to disappearing exceptions upon rule upgrade. On top of this `execution results` and `execution events` also get lost due to missing to restore saved object `id`. Execution log isn't gone anywhere but can't be bound to a new id. Direct links to rule details page won't work neither after upgrade. This PR fixes the problem by restoring rule bound data after upgrade. FTR tests were restructured to accommodate extra tests to cover this bug fix. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../installation_and_upgrade.md | 34 ++ .../upgrade_prebuilt_rules.test.ts | 184 ++++++- .../rule_objects/upgrade_prebuilt_rules.ts | 43 +- .../rule_assets/prebuilt_rule_asset.mock.ts | 24 +- .../config/ess/config.base.ts | 16 +- .../config/serverless/config.base.ts | 2 + .../config/shared.ts | 32 ++ .../trial_license_complete_tier/index.ts | 5 +- .../install_and_upgrade_prebuilt_rules.ts | 450 ------------------ .../install_prebuilt_rules.ts | 133 ++++++ ...prebuilt_rules_with_historical_versions.ts | 160 +++++++ .../upgrade_prebuilt_rules.ts | 268 +++++++++++ ...prebuilt_rules_with_historical_versions.ts | 152 ++++++ .../export_rules.ts | 3 +- .../create_prebuilt_rule_saved_objects.ts | 5 +- .../tsconfig.json | 1 + 16 files changed, 991 insertions(+), 521 deletions(-) create mode 100644 x-pack/test/security_solution_api_integration/config/shared.ts delete mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_and_upgrade_prebuilt_rules.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules_with_historical_versions.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md index 19a15b64b5046..b60609b45be9d 100644 --- a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md @@ -13,6 +13,7 @@ Status: `in progress`. The current test plan matches `Milestone 2` of the [Rule - [Non-functional requirements](#non-functional-requirements) - [Functional requirements](#functional-requirements) - [Scenarios](#scenarios) + - [Package installation](#package-installation) - [**Scenario: Package is installed via Fleet**](#scenario-package-is-installed-via-fleet) - [**Scenario: Package is installed via bundled Fleet package in Kibana**](#scenario-package-is-installed-via-bundled-fleet-package-in-kibana) @@ -63,6 +64,9 @@ Status: `in progress`. The current test plan matches `Milestone 2` of the [Rule - [**Scenario: Properties with semantically equal values should not be shown as modified**](#scenario-properties-with-semantically-equal-values-should-not-be-shown-as-modified) - [**Scenario: Unchanged sections of a rule should be hidden by default**](#scenario-unchanged-sections-of-a-rule-should-be-hidden-by-default) - [**Scenario: Properties should be sorted alphabetically**](#scenario-properties-should-be-sorted-alphabetically) + - [Rule upgrade workflow: preserving rule bound data](#rule-upgrade-workflow-preserving-rule-bound-data) + - [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with the same rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-the-same-rule-type) + - [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with a different rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-a-different-rule-type) - [Rule upgrade workflow: misc cases](#rule-upgrade-workflow-misc-cases) - [**Scenario: User doesn't see the Rule Updates tab until the package installation is completed**](#scenario-user-doesnt-see-the-rule-updates-tab-until-the-package-installation-is-completed) - [Error handling](#error-handling) @@ -949,6 +953,36 @@ When a user expands all hidden sections Then all properties of the rule should be sorted alphabetically ``` +### Rule upgrade workflow: preserving rule bound data + +#### **Scenario: Rule bound data is preserved after upgrading a rule to a newer version with the same rule type** + +**Automation**: 1 unit test per case, 1 integration test + +```Gherkin +Given a prebuilt rule is installed in Kibana +And this rule has an update available +And the update has the same rule type +When a user upgrades the rule +Then the rule bound data should be preserved +``` + +Examples: generated alerts, exception lists (rule exception list, shared exception list, endpoint exception list), timeline reference, actions, enabled state, execution results and execution events. + +#### **Scenario: Rule bound data is preserved after upgrading a rule to a newer version with a different rule type** + +**Automation**: 1 unit test per case, 1 integration test + +```Gherkin +Given a prebuilt rule is installed in Kibana +And this rule has an update available +And the update has a different rule type +When a user upgrades the rule +Then the rule bound data should be preserved +``` + +Examples: generated alerts, exception lists (rule exception list, shared exception list, endpoint exception list), timeline reference, actions, enabled state, execution results and execution events. + ### Rule upgrade workflow: misc cases #### **Scenario: User doesn't see the Rule Updates tab until the package installation is completed** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.test.ts index 2990533dc0f89..f4845be99c68e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.test.ts @@ -5,17 +5,23 @@ * 2.0. */ +import { omit } from 'lodash'; import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; import { getRuleMock, getFindResultWithSingleHit, + getFindResultWithMultiHits, } from '../../../routes/__mocks__/request_responses'; import { upgradePrebuiltRules } from './upgrade_prebuilt_rules'; import { patchRules } from '../../../rule_management/logic/crud/patch_rules'; +import { createRules } from '../../../rule_management/logic/crud/create_rules'; +import { deleteRules } from '../../../rule_management/logic/crud/delete_rules'; import { getPrebuiltRuleMock, getPrebuiltThreatMatchRuleMock } from '../../mocks'; -import { getThreatRuleParams } from '../../../rule_schema/mocks'; +import { getQueryRuleParams, getThreatRuleParams } from '../../../rule_schema/mocks'; jest.mock('../../../rule_management/logic/crud/patch_rules'); +jest.mock('../../../rule_management/logic/crud/create_rules'); +jest.mock('../../../rule_management/logic/crud/delete_rules'); describe('updatePrebuiltRules', () => { let rulesClient: ReturnType; @@ -24,35 +30,173 @@ describe('updatePrebuiltRules', () => { rulesClient = rulesClientMock.create(); }); - it('should omit actions and enabled when calling patchRules', async () => { + describe('when upgrading a prebuilt rule to a newer version with the same rule type', () => { + const prepackagedRule = getPrebuiltRuleMock({ + rule_id: 'rule-to-upgrade', + }); + + beforeEach(() => { + const installedRule = getRuleMock( + getQueryRuleParams({ + ruleId: 'rule-to-upgrade', + }) + ); + + rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [installedRule], + }) + ); + }); + + it('patches existing rule with incoming version data', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(patchRules).toHaveBeenCalledWith( + expect.objectContaining({ + nextParams: expect.objectContaining(prepackagedRule), + }) + ); + }); + + it('makes sure enabled state is preserved', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(patchRules).toHaveBeenCalledWith( + expect.objectContaining({ + nextParams: expect.objectContaining({ + enabled: undefined, + }), + }) + ); + }); + }); + + describe('when upgrading a prebuilt rule to a newer version with a different rule type', () => { + const prepackagedRule = getPrebuiltRuleMock({ + rule_id: 'rule-to-upgrade', + type: 'eql', + language: 'eql', + query: 'host where host.name == "something"', + }); const actions = [ { group: 'group', id: 'id', - action_type_id: 'action_type_id', + actionTypeId: 'action_type_id', params: {}, }, ]; - const prepackagedRule = getPrebuiltRuleMock(); - rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const installedRule = getRuleMock( + getQueryRuleParams({ + ruleId: 'rule-to-upgrade', + type: 'query', + exceptionsList: [ + { + id: 'exception_list_1', + list_id: 'exception_list_1', + namespace_type: 'agnostic', + type: 'rule_default', + }, + ], + timelineId: 'some-timeline-id', + timelineTitle: 'Some timeline title', + }), + { + id: 'installed-rule-so-id', + actions, + } + ); - await upgradePrebuiltRules(rulesClient, [{ ...prepackagedRule, actions }]); + beforeEach(() => { + rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [installedRule], + }) + ); + }); - expect(patchRules).toHaveBeenCalledWith( - expect.objectContaining({ - nextParams: expect.objectContaining({ - actions: undefined, - }), - }) - ); + it('deletes rule before creation', async () => { + let lastCalled!: string; - expect(patchRules).toHaveBeenCalledWith( - expect.objectContaining({ - nextParams: expect.objectContaining({ - enabled: undefined, - }), - }) - ); + (deleteRules as jest.Mock).mockImplementation(() => (lastCalled = 'deleteRules')); + (createRules as jest.Mock).mockImplementation(() => (lastCalled = 'createRules')); + + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(deleteRules).toHaveBeenCalledTimes(1); + expect(createRules).toHaveBeenCalledTimes(1); + expect(lastCalled).toBe('createRules'); + }); + + it('recreates a rule with incoming version data', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(createRules).toHaveBeenCalledWith( + expect.objectContaining({ + immutable: true, + params: expect.objectContaining(prepackagedRule), + }) + ); + }); + + it('restores saved object id', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(createRules).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'installed-rule-so-id', + }) + ); + }); + + it('restores enabled state', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(createRules).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ enabled: installedRule.enabled }), + }) + ); + }); + + it('restores actions', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(createRules).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + actions: actions.map((a) => ({ + ...omit(a, 'actionTypeId'), + action_type_id: a.actionTypeId, + })), + }), + }) + ); + }); + + it('restores exceptions list', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(createRules).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ exceptions_list: installedRule.params.exceptionsList }), + }) + ); + }); + + it('restores timeline reference', async () => { + await upgradePrebuiltRules(rulesClient, [prepackagedRule]); + + expect(createRules).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + timeline_id: installedRule.params.timelineId, + timeline_title: installedRule.params.timelineTitle, + }), + }) + ); + }); }); it('should update threat match rules', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts index a06b5dc17825f..98afa86114e4f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/upgrade_prebuilt_rules.ts @@ -72,36 +72,39 @@ const upgradeRule = async ( return createRules({ rulesClient, immutable: true, + id: existingRule.id, params: { ...rule, // Force the prepackaged rule to use the enabled state from the existing rule, // regardless of what the prepackaged rule says enabled: existingRule.enabled, + exceptions_list: existingRule.params.exceptionsList, actions: existingRule.actions.map(transformAlertToRuleAction), + timeline_id: existingRule.params.timelineId, + timeline_title: existingRule.params.timelineTitle, }, }); - } else { - await patchRules({ - rulesClient, - existingRule, - nextParams: { - ...rule, - // Force enabled to use the enabled state from the existing rule by passing in undefined to patchRules - enabled: undefined, - actions: undefined, - }, - }); + } - const updatedRule = await readRules({ - rulesClient, - ruleId: rule.rule_id, - id: undefined, - }); + await patchRules({ + rulesClient, + existingRule, + nextParams: { + ...rule, + // Force enabled to use the enabled state from the existing rule by passing in undefined to patchRules + enabled: undefined, + }, + }); - if (!updatedRule) { - throw new PrepackagedRulesError(`Rule ${rule.rule_id} not found after upgrade`, 500); - } + const updatedRule = await readRules({ + rulesClient, + ruleId: rule.rule_id, + id: undefined, + }); - return updatedRule; + if (!updatedRule) { + throw new PrepackagedRulesError(`Rule ${rule.rule_id} not found after upgrade`, 500); } + + return updatedRule; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts index 8329204637c3b..c73203c2871ab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts @@ -7,17 +7,19 @@ import type { PrebuiltRuleAsset } from './prebuilt_rule_asset'; -export const getPrebuiltRuleMock = (): PrebuiltRuleAsset => ({ - description: 'some description', - name: 'Query with a rule id', - query: 'user.name: root or user.name: admin', - severity: 'high', - type: 'query', - risk_score: 55, - language: 'kuery', - rule_id: 'rule-1', - version: 1, -}); +export const getPrebuiltRuleMock = (rewrites?: Partial): PrebuiltRuleAsset => + ({ + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'query', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + version: 1, + ...rewrites, + } as PrebuiltRuleAsset); export const getPrebuiltRuleWithExceptionsMock = (): PrebuiltRuleAsset => ({ description: 'A rule with an exception list', diff --git a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts index 6a9232050c3f0..c105b81263362 100644 --- a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts +++ b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts @@ -8,6 +8,7 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext, kbnTestConfig, kibanaTestUser } from '@kbn/test'; import { services } from '../../../api_integration/services'; +import { PRECONFIGURED_ACTION_CONNECTORS } from '../shared'; interface CreateTestConfigOptions { license: string; @@ -85,20 +86,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s 'alertSuppressionForIndicatorMatchRuleEnabled', ])}`, '--xpack.task_manager.poll_interval=1000', - `--xpack.actions.preconfigured=${JSON.stringify({ - 'my-test-email': { - actionTypeId: '.email', - name: 'TestEmail#xyz', - config: { - from: 'me@test.com', - service: '__json', - }, - secrets: { - user: 'user', - password: 'password', - }, - }, - })}`, + `--xpack.actions.preconfigured=${JSON.stringify(PRECONFIGURED_ACTION_CONNECTORS)}`, ...(ssl ? [ `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, diff --git a/x-pack/test/security_solution_api_integration/config/serverless/config.base.ts b/x-pack/test/security_solution_api_integration/config/serverless/config.base.ts index 374538e593efa..ae3b17ce086c3 100644 --- a/x-pack/test/security_solution_api_integration/config/serverless/config.base.ts +++ b/x-pack/test/security_solution_api_integration/config/serverless/config.base.ts @@ -12,6 +12,7 @@ export interface CreateTestConfigOptions { kbnTestServerEnv?: Record; } import { services } from '../../../../test_serverless/api_integration/services'; +import { PRECONFIGURED_ACTION_CONNECTORS } from '../shared'; export function createTestConfig(options: CreateTestConfigOptions) { return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -28,6 +29,7 @@ export function createTestConfig(options: CreateTestConfigOptions) { serverArgs: [ ...svlSharedConfig.get('kbnTestServer.serverArgs'), '--serverless=security', + `--xpack.actions.preconfigured=${JSON.stringify(PRECONFIGURED_ACTION_CONNECTORS)}`, ...(options.kbnTestServerArgs || []), ], env: { diff --git a/x-pack/test/security_solution_api_integration/config/shared.ts b/x-pack/test/security_solution_api_integration/config/shared.ts new file mode 100644 index 0000000000000..f8c55deef484a --- /dev/null +++ b/x-pack/test/security_solution_api_integration/config/shared.ts @@ -0,0 +1,32 @@ +/* + * 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 { Connector } from '@kbn/actions-plugin/server/application/connector/types'; + +interface PreconfiguredConnector extends Pick { + secrets: { + user: string; + password: string; + }; +} + +export const PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID = 'my-test-email'; + +export const PRECONFIGURED_ACTION_CONNECTORS: Record = { + [PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID]: { + actionTypeId: '.email', + name: 'TestEmail#xyz', + config: { + from: 'me@test.com', + service: '__json', + }, + secrets: { + user: 'user', + password: 'password', + }, + }, +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts index 16c37cd09eb5b..5625d00b5293d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts @@ -11,7 +11,10 @@ export default ({ loadTestFile }: FtrProviderContext): void => { describe('Rules Management - Prebuilt Rules - Prebuilt Rules Management', function () { loadTestFile(require.resolve('./get_prebuilt_rules_status')); loadTestFile(require.resolve('./get_prebuilt_timelines_status')); - loadTestFile(require.resolve('./install_and_upgrade_prebuilt_rules')); + loadTestFile(require.resolve('./install_prebuilt_rules')); + loadTestFile(require.resolve('./install_prebuilt_rules_with_historical_versions')); + loadTestFile(require.resolve('./upgrade_prebuilt_rules')); + loadTestFile(require.resolve('./upgrade_prebuilt_rules_with_historical_versions')); loadTestFile(require.resolve('./fleet_integration')); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_and_upgrade_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_and_upgrade_prebuilt_rules.ts deleted file mode 100644 index c3d39c532f9ca..0000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_and_upgrade_prebuilt_rules.ts +++ /dev/null @@ -1,450 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import expect from 'expect'; -import { FtrProviderContext } from '../../../../../../ftr_provider_context'; -import { - deleteAllRules, - deleteAllTimelines, - deleteAllPrebuiltRuleAssets, - createRuleAssetSavedObject, - createPrebuiltRuleAssetSavedObjects, - installPrebuiltRulesAndTimelines, - deleteRule, - getPrebuiltRulesAndTimelinesStatus, - createHistoricalPrebuiltRuleAssetSavedObjects, - getPrebuiltRulesStatus, - installPrebuiltRules, - getInstalledRules, - upgradePrebuiltRules, -} from '../../../../utils'; - -export default ({ getService }: FtrProviderContext): void => { - const es = getService('es'); - const supertest = getService('supertest'); - const log = getService('log'); - - describe('@ess @serverless @skipInQA install and upgrade prebuilt rules with mock rule assets', () => { - beforeEach(async () => { - await deleteAllRules(supertest, log); - await deleteAllTimelines(es, log); - await deleteAllPrebuiltRuleAssets(es, log); - }); - - describe(`rule package without historical versions`, () => { - const getRuleAssetSavedObjects = () => [ - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 1 }), - createRuleAssetSavedObject({ rule_id: 'rule-2', version: 2 }), - createRuleAssetSavedObject({ rule_id: 'rule-3', version: 3 }), - createRuleAssetSavedObject({ rule_id: 'rule-4', version: 4 }), - ]; - const RULES_COUNT = 4; - - describe('using legacy endpoint', () => { - it('should install prebuilt rules', async () => { - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await installPrebuiltRulesAndTimelines(es, supertest); - - expect(body.rules_installed).toBe(RULES_COUNT); - expect(body.rules_updated).toBe(0); - }); - - it('should install correct prebuilt rule versions', async () => { - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Get installed rules - const rulesResponse = await getInstalledRules(supertest); - - // Check that all prebuilt rules were actually installed and their versions match the latest - expect(rulesResponse.total).toBe(RULES_COUNT); - expect(rulesResponse.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ rule_id: 'rule-1', version: 1 }), - expect.objectContaining({ rule_id: 'rule-2', version: 2 }), - expect.objectContaining({ rule_id: 'rule-3', version: 3 }), - expect.objectContaining({ rule_id: 'rule-4', version: 4 }), - ]) - ); - }); - - it('should install missing prebuilt rules', async () => { - // Install all prebuilt detection rules - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Delete one of the installed rules - await deleteRule(supertest, 'rule-1'); - - // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_installed).toBe(1); - - // Call the install prebuilt rules again and check that the missing rule was installed - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(1); - expect(response.rules_updated).toBe(0); - }); - - it('should update outdated prebuilt rules', async () => { - // Install all prebuilt detection rules - const ruleAssetSavedObjects = getRuleAssetSavedObjects(); - await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es, log); - // Increment the version of one of the installed rules and create the new rule assets - ruleAssetSavedObjects[0]['security-rule'].version += 1; - await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - - // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_updated).toBe(1); - - // Call the install prebuilt rules again and check that the outdated rule was updated - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(0); - expect(response.rules_updated).toBe(1); - }); - - it('should not install prebuilt rules if they are up to date', async () => { - // Install all prebuilt detection rules - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_installed).toBe(0); - expect(statusResponse.rules_not_updated).toBe(0); - - // Call the install prebuilt rules again and check that no rules were installed - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(0); - expect(response.rules_updated).toBe(0); - }); - }); - - describe('using current endpoint', () => { - it('should install prebuilt rules', async () => { - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await installPrebuiltRules(es, supertest); - - expect(body.summary.succeeded).toBe(RULES_COUNT); - expect(body.summary.failed).toBe(0); - expect(body.summary.skipped).toBe(0); - }); - - it('should install correct prebuilt rule versions', async () => { - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await installPrebuiltRules(es, supertest); - - // Check that all prebuilt rules were actually installed and their versions match the latest - expect(body.results.created).toEqual( - expect.arrayContaining([ - expect.objectContaining({ rule_id: 'rule-1', version: 1 }), - expect.objectContaining({ rule_id: 'rule-2', version: 2 }), - expect.objectContaining({ rule_id: 'rule-3', version: 3 }), - expect.objectContaining({ rule_id: 'rule-4', version: 4 }), - ]) - ); - }); - - it('should install missing prebuilt rules', async () => { - // Install all prebuilt detection rules - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRules(es, supertest); - - // Delete one of the installed rules - await deleteRule(supertest, 'rule-1'); - - // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(1); - - // Call the install prebuilt rules again and check that the missing rule was installed - const response = await installPrebuiltRules(es, supertest); - expect(response.summary.succeeded).toBe(1); - }); - - it('should update outdated prebuilt rules', async () => { - // Install all prebuilt detection rules - const ruleAssetSavedObjects = getRuleAssetSavedObjects(); - await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - await installPrebuiltRules(es, supertest); - - // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es, log); - // Increment the version of one of the installed rules and create the new rule assets - ruleAssetSavedObjects[0]['security-rule'].version += 1; - await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - - // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); - expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); - - // Call the install prebuilt rules again and check that the outdated rule was updated - const response = await upgradePrebuiltRules(es, supertest); - expect(response.summary.succeeded).toBe(1); - expect(response.summary.skipped).toBe(0); - }); - - it('should not install prebuilt rules if they are up to date', async () => { - // Install all prebuilt detection rules - await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRules(es, supertest); - - // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); - expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(0); - - // Call the install prebuilt rules again and check that no rules were installed - const installResponse = await installPrebuiltRules(es, supertest); - expect(installResponse.summary.succeeded).toBe(0); - expect(installResponse.summary.skipped).toBe(0); - - // Call the upgrade prebuilt rules endpoint and check that no rules were updated - const upgradeResponse = await upgradePrebuiltRules(es, supertest); - expect(upgradeResponse.summary.succeeded).toBe(0); - expect(upgradeResponse.summary.skipped).toBe(0); - }); - }); - }); - - describe(`rule package with historical versions`, () => { - const getRuleAssetSavedObjects = () => [ - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 1 }), - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 2 }), - createRuleAssetSavedObject({ rule_id: 'rule-2', version: 1 }), - createRuleAssetSavedObject({ rule_id: 'rule-2', version: 2 }), - createRuleAssetSavedObject({ rule_id: 'rule-2', version: 3 }), - ]; - const RULES_COUNT = 2; - - describe('using legacy endpoint', () => { - it('should install prebuilt rules', async () => { - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await installPrebuiltRulesAndTimelines(es, supertest); - - expect(body.rules_installed).toBe(RULES_COUNT); - expect(body.rules_updated).toBe(0); - }); - - it('should install correct prebuilt rule versions', async () => { - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Get installed rules - const rulesResponse = await getInstalledRules(supertest); - - // Check that all prebuilt rules were actually installed and their versions match the latest - expect(rulesResponse.total).toBe(RULES_COUNT); - expect(rulesResponse.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ rule_id: 'rule-1', version: 2 }), - expect.objectContaining({ rule_id: 'rule-2', version: 3 }), - ]) - ); - }); - - it('should not install prebuilt rules if they are up to date', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_installed).toBe(0); - - // Call the install prebuilt rules again and check that no rules were installed - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(0); - expect(response.rules_updated).toBe(0); - }); - - it('should install missing prebuilt rules', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Delete one of the installed rules - await deleteRule(supertest, 'rule-1'); - - // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_installed).toBe(1); - - // Call the install prebuilt rules endpoint again and check that the missing rule was installed - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(1); - expect(response.rules_updated).toBe(0); - }); - - it('should update outdated prebuilt rules when previous historical versions available', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Add a new version of one of the installed rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), - ]); - - // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_updated).toBe(1); - - // Call the install prebuilt rules again and check that the outdated rule was updated - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(0); - expect(response.rules_updated).toBe(1); - - const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(_statusResponse.rules_not_installed).toBe(0); - expect(_statusResponse.rules_not_updated).toBe(0); - }); - - it('should update outdated prebuilt rules when previous historical versions unavailable', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRulesAndTimelines(es, supertest); - - // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es, log); - - // Add a new rule version - await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), - ]); - - // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(statusResponse.rules_not_updated).toBe(1); - expect(statusResponse.rules_not_installed).toBe(0); - - // Call the install prebuilt rules again and check that the outdated rule was updated - const response = await installPrebuiltRulesAndTimelines(es, supertest); - expect(response.rules_installed).toBe(0); - expect(response.rules_updated).toBe(1); - - const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); - expect(_statusResponse.rules_not_updated).toBe(0); - expect(_statusResponse.rules_not_installed).toBe(0); - }); - }); - - describe('using current endpoint', () => { - it('should install prebuilt rules', async () => { - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await installPrebuiltRules(es, supertest); - - expect(body.summary.succeeded).toBe(RULES_COUNT); - }); - - it('should install correct prebuilt rule versions', async () => { - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const response = await installPrebuiltRules(es, supertest); - - // Check that all prebuilt rules were actually installed and their versions match the latest - expect(response.summary.succeeded).toBe(RULES_COUNT); - expect(response.results.created).toEqual( - expect.arrayContaining([ - expect.objectContaining({ rule_id: 'rule-1', version: 2 }), - expect.objectContaining({ rule_id: 'rule-2', version: 3 }), - ]) - ); - }); - - it('should not install prebuilt rules if they are up to date', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRules(es, supertest); - - // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); - - // Call the install prebuilt rules again and check that no rules were installed - const response = await installPrebuiltRules(es, supertest); - expect(response.summary.succeeded).toBe(0); - expect(response.summary.total).toBe(0); - }); - - it('should install missing prebuilt rules', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRules(es, supertest); - - // Delete one of the installed rules - await deleteRule(supertest, 'rule-1'); - - // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(1); - - // Call the install prebuilt rules endpoint again and check that the missing rule was installed - const response = await installPrebuiltRules(es, supertest); - expect(response.summary.succeeded).toBe(1); - expect(response.summary.total).toBe(1); - }); - - it('should update outdated prebuilt rules when previous historical versions available', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRules(es, supertest); - - // Add a new version of one of the installed rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), - ]); - - // Check that the prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); - - // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated - const response = await upgradePrebuiltRules(es, supertest); - expect(response.summary.succeeded).toBe(1); - expect(response.summary.total).toBe(1); - - const status = await getPrebuiltRulesStatus(es, supertest); - expect(status.stats.num_prebuilt_rules_to_install).toBe(0); - expect(status.stats.num_prebuilt_rules_to_upgrade).toBe(0); - }); - - it('should update outdated prebuilt rules when previous historical versions unavailable', async () => { - // Install all prebuilt detection rules - await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - await installPrebuiltRules(es, supertest); - - // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es, log); - - // Add a new rule version - await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ - createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), - ]); - - // Check that the prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesStatus(es, supertest); - expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); - expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); - - // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated - const response = await upgradePrebuiltRules(es, supertest); - expect(response.summary.succeeded).toBe(1); - expect(response.summary.total).toBe(1); - - const status = await getPrebuiltRulesStatus(es, supertest); - expect(status.stats.num_prebuilt_rules_to_install).toBe(0); - expect(status.stats.num_prebuilt_rules_to_upgrade).toBe(0); - }); - }); - }); - }); -}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules.ts new file mode 100644 index 0000000000000..e9b8bbed84d1e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules.ts @@ -0,0 +1,133 @@ +/* + * 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 expect from 'expect'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + deleteAllRules, + deleteAllTimelines, + deleteAllPrebuiltRuleAssets, + createRuleAssetSavedObject, + createPrebuiltRuleAssetSavedObjects, + installPrebuiltRulesAndTimelines, + deleteRule, + getPrebuiltRulesAndTimelinesStatus, + getPrebuiltRulesStatus, + installPrebuiltRules, + getInstalledRules, +} from '../../../../utils'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @skipInQA install prebuilt rules from package without historical versions with mock rule assets', () => { + const getRuleAssetSavedObjects = () => [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 1 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 2 }), + createRuleAssetSavedObject({ rule_id: 'rule-3', version: 3 }), + createRuleAssetSavedObject({ rule_id: 'rule-4', version: 4 }), + ]; + const RULES_COUNT = getRuleAssetSavedObjects().length; + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + describe('using current endpoint', () => { + it('should install prebuilt rules', async () => { + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + const body = await installPrebuiltRules(es, supertest); + + expect(body.summary.succeeded).toBe(RULES_COUNT); + expect(body.summary.failed).toBe(0); + expect(body.summary.skipped).toBe(0); + }); + + it('should install correct prebuilt rule versions', async () => { + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + const body = await installPrebuiltRules(es, supertest); + + // Check that all prebuilt rules were actually installed and their versions match the latest + expect(body.results.created).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', version: 1 }), + expect.objectContaining({ rule_id: 'rule-2', version: 2 }), + expect.objectContaining({ rule_id: 'rule-3', version: 3 }), + expect.objectContaining({ rule_id: 'rule-4', version: 4 }), + ]) + ); + }); + + it('should install missing prebuilt rules', async () => { + // Install all prebuilt detection rules + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRules(es, supertest); + + // Delete one of the installed rules + await deleteRule(supertest, 'rule-1'); + + // Check that one prebuilt rule is missing + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(1); + + // Call the install prebuilt rules again and check that the missing rule was installed + const response = await installPrebuiltRules(es, supertest); + expect(response.summary.succeeded).toBe(1); + }); + }); + + describe('using legacy endpoint', () => { + it('should install prebuilt rules', async () => { + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + const body = await installPrebuiltRulesAndTimelines(es, supertest); + + expect(body.rules_installed).toBe(RULES_COUNT); + expect(body.rules_updated).toBe(0); + }); + + it('should install correct prebuilt rule versions', async () => { + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Get installed rules + const rulesResponse = await getInstalledRules(supertest); + + // Check that all prebuilt rules were actually installed and their versions match the latest + expect(rulesResponse.total).toBe(RULES_COUNT); + expect(rulesResponse.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', version: 1 }), + expect.objectContaining({ rule_id: 'rule-2', version: 2 }), + expect.objectContaining({ rule_id: 'rule-3', version: 3 }), + expect.objectContaining({ rule_id: 'rule-4', version: 4 }), + ]) + ); + }); + + it('should install missing prebuilt rules', async () => { + // Install all prebuilt detection rules + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Delete one of the installed rules + await deleteRule(supertest, 'rule-1'); + + // Check that one prebuilt rule is missing + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_installed).toBe(1); + + // Call the install prebuilt rules again and check that the missing rule was installed + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(1); + expect(response.rules_updated).toBe(0); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules_with_historical_versions.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules_with_historical_versions.ts new file mode 100644 index 0000000000000..6120caa8eda22 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/install_prebuilt_rules_with_historical_versions.ts @@ -0,0 +1,160 @@ +/* + * 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 expect from 'expect'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + deleteAllRules, + deleteAllTimelines, + deleteAllPrebuiltRuleAssets, + createRuleAssetSavedObject, + installPrebuiltRulesAndTimelines, + deleteRule, + getPrebuiltRulesAndTimelinesStatus, + createHistoricalPrebuiltRuleAssetSavedObjects, + getPrebuiltRulesStatus, + installPrebuiltRules, + getInstalledRules, +} from '../../../../utils'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @skipInQA install prebuilt rules from package with historical versions with mock rule assets', () => { + const getRuleAssetSavedObjects = () => [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 1 }), + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 2 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 1 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 2 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 3 }), + ]; + const RULES_COUNT = 2; + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + describe('using legacy endpoint', () => { + it('should install prebuilt rules', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + const body = await installPrebuiltRulesAndTimelines(es, supertest); + + expect(body.rules_installed).toBe(RULES_COUNT); + expect(body.rules_updated).toBe(0); + }); + + it('should install correct prebuilt rule versions', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Get installed rules + const rulesResponse = await getInstalledRules(supertest); + + // Check that all prebuilt rules were actually installed and their versions match the latest + expect(rulesResponse.total).toBe(RULES_COUNT); + expect(rulesResponse.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', version: 2 }), + expect.objectContaining({ rule_id: 'rule-2', version: 3 }), + ]) + ); + }); + + it('should not install prebuilt rules if they are up to date', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Check that all prebuilt rules were installed + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_installed).toBe(0); + + // Call the install prebuilt rules again and check that no rules were installed + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(0); + expect(response.rules_updated).toBe(0); + }); + + it('should install missing prebuilt rules', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Delete one of the installed rules + await deleteRule(supertest, 'rule-1'); + + // Check that one prebuilt rule is missing + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_installed).toBe(1); + + // Call the install prebuilt rules endpoint again and check that the missing rule was installed + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(1); + expect(response.rules_updated).toBe(0); + }); + }); + + describe('using current endpoint', () => { + it('should install prebuilt rules', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + const body = await installPrebuiltRules(es, supertest); + + expect(body.summary.succeeded).toBe(RULES_COUNT); + }); + + it('should install correct prebuilt rule versions', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + const response = await installPrebuiltRules(es, supertest); + + // Check that all prebuilt rules were actually installed and their versions match the latest + expect(response.summary.succeeded).toBe(RULES_COUNT); + expect(response.results.created).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', version: 2 }), + expect.objectContaining({ rule_id: 'rule-2', version: 3 }), + ]) + ); + }); + + it('should not install prebuilt rules if they are up to date', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRules(es, supertest); + + // Check that all prebuilt rules were installed + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); + + // Call the install prebuilt rules again and check that no rules were installed + const response = await installPrebuiltRules(es, supertest); + expect(response.summary.succeeded).toBe(0); + expect(response.summary.total).toBe(0); + }); + + it('should install missing prebuilt rules', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRules(es, supertest); + + // Delete one of the installed rules + await deleteRule(supertest, 'rule-1'); + + // Check that one prebuilt rule is missing + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(1); + + // Call the install prebuilt rules endpoint again and check that the missing rule was installed + const response = await installPrebuiltRules(es, supertest); + expect(response.summary.succeeded).toBe(1); + expect(response.summary.total).toBe(1); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts new file mode 100644 index 0000000000000..5d1f9662e118a --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts @@ -0,0 +1,268 @@ +/* + * 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 expect from 'expect'; +import { PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID } from '../../../../../../config/shared'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + deleteAllRules, + deleteAllTimelines, + deleteAllPrebuiltRuleAssets, + createRuleAssetSavedObject, + createPrebuiltRuleAssetSavedObjects, + installPrebuiltRulesAndTimelines, + getPrebuiltRulesAndTimelinesStatus, + getPrebuiltRulesStatus, + installPrebuiltRules, + upgradePrebuiltRules, + fetchRule, + patchRule, +} from '../../../../utils'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @skipInQA upgrade prebuilt rules from package without historical versions with mock rule assets', () => { + const getRuleAssetSavedObjects = () => [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 1 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 2 }), + createRuleAssetSavedObject({ rule_id: 'rule-3', version: 3 }), + createRuleAssetSavedObject({ rule_id: 'rule-4', version: 4 }), + ]; + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + describe('using legacy endpoint', () => { + it('should upgrade outdated prebuilt rules', async () => { + // Install all prebuilt detection rules + const ruleAssetSavedObjects = getRuleAssetSavedObjects(); + await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Clear previous rule assets + await deleteAllPrebuiltRuleAssets(es, log); + // Increment the version of one of the installed rules and create the new rule assets + ruleAssetSavedObjects[0]['security-rule'].version += 1; + await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); + + // Check that one prebuilt rule status shows that one rule is outdated + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_updated).toBe(1); + + // Call the install prebuilt rules again and check that the outdated rule was updated + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(0); + expect(response.rules_updated).toBe(1); + }); + + it('should not upgrade prebuilt rules if they are up to date', async () => { + // Install all prebuilt detection rules + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Check that all prebuilt rules were installed + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_installed).toBe(0); + expect(statusResponse.rules_not_updated).toBe(0); + + // Call the install prebuilt rules again and check that no rules were installed + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(0); + expect(response.rules_updated).toBe(0); + }); + }); + + describe('using current endpoint', () => { + it('should upgrade outdated prebuilt rules', async () => { + // Install all prebuilt detection rules + const ruleAssetSavedObjects = getRuleAssetSavedObjects(); + await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); + await installPrebuiltRules(es, supertest); + + // Clear previous rule assets + await deleteAllPrebuiltRuleAssets(es, log); + // Increment the version of one of the installed rules and create the new rule assets + ruleAssetSavedObjects[0]['security-rule'].version += 1; + await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); + + // Check that one prebuilt rule status shows that one rule is outdated + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); + expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); + + // Call the install prebuilt rules again and check that the outdated rule was updated + const response = await upgradePrebuiltRules(es, supertest); + expect(response.summary.succeeded).toBe(1); + expect(response.summary.skipped).toBe(0); + }); + + it('should not upgrade prebuilt rules if they are up to date', async () => { + // Install all prebuilt detection rules + await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRules(es, supertest); + + // Check that all prebuilt rules were installed + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); + expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(0); + + // Call the install prebuilt rules again and check that no rules were installed + const installResponse = await installPrebuiltRules(es, supertest); + expect(installResponse.summary.succeeded).toBe(0); + expect(installResponse.summary.skipped).toBe(0); + + // Call the upgrade prebuilt rules endpoint and check that no rules were updated + const upgradeResponse = await upgradePrebuiltRules(es, supertest); + expect(upgradeResponse.summary.succeeded).toBe(0); + expect(upgradeResponse.summary.skipped).toBe(0); + }); + + describe('when upgrading a prebuilt rule to a newer version with the same rule type', () => { + it('preserves rule bound data', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'rule-to-test-1', + enabled: true, + version: 1, + }), + ]); + const firstInstallResponse = await installPrebuiltRules(es, supertest); + const initialRuleSoId = firstInstallResponse.results.created[0].id; + + const actions = [ + // Use a preconfigured action connector to simplify the test and avoid action connector creation + { + id: PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID, + action_type_id: '.email', + group: 'default', + params: {}, + }, + ]; + const exceptionsList = [ + { + id: 'exception_list_1', + list_id: 'exception_list_1', + namespace_type: 'agnostic', + type: 'rule_default', + } as const, + ]; + + // Add some actions, exceptions list, and timeline reference + await patchRule(supertest, log, { + rule_id: 'rule-to-test-1', + enabled: false, + actions, + exceptions_list: exceptionsList, + timeline_id: 'some-timeline-id', + timeline_title: 'Some timeline title', + }); + + // Clear previous rule assets + await deleteAllPrebuiltRuleAssets(es, log); + // Create a new version with the same rule type asset + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'rule-to-test-1', + enabled: true, + version: 2, + }), + ]); + + // Upgrade to a newer version with the same type + await upgradePrebuiltRules(es, supertest); + + expect(await fetchRule(supertest, { ruleId: 'rule-to-test-1' })).toMatchObject({ + id: initialRuleSoId, + // If a user disabled the rule it's expected to stay disabled after upgrade + enabled: false, + actions, + exceptions_list: exceptionsList, + timeline_id: 'some-timeline-id', + timeline_title: 'Some timeline title', + }); + }); + }); + + describe('when upgrading a prebuilt rule to a newer version with a different rule type', () => { + it('preserves rule bound data', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'rule-to-test-2', + type: 'query', + language: 'kuery', + query: '*:*', + enabled: true, + version: 1, + }), + ]); + const firstInstallResponse = await installPrebuiltRules(es, supertest); + const initialRuleSoId = firstInstallResponse.results.created[0].id; + + const actions = [ + // Use a preconfigured action connector to simplify the test and avoid action connector creation + { + id: PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID, + action_type_id: '.email', + group: 'default', + params: {}, + }, + ]; + const exceptionsList = [ + { + id: 'exception_list_1', + list_id: 'exception_list_1', + namespace_type: 'agnostic', + type: 'rule_default', + } as const, + ]; + + // Add some actions, exceptions list, and timeline reference + await patchRule(supertest, log, { + rule_id: 'rule-to-test-2', + enabled: false, + actions, + exceptions_list: exceptionsList, + timeline_id: 'some-timeline-id', + timeline_title: 'Some timeline title', + }); + + // Clear previous rule assets + await deleteAllPrebuiltRuleAssets(es, log); + // Create a new version with a different rule type asset + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'rule-to-test-2', + type: 'eql', + language: 'eql', + query: 'host where host == "something"', + enabled: true, + version: 2, + }), + ]); + + // Upgrade to a newer version with a different rule type + await upgradePrebuiltRules(es, supertest); + + expect(await fetchRule(supertest, { ruleId: 'rule-to-test-2' })).toMatchObject({ + id: initialRuleSoId, + // If a user disabled the rule it's expected to stay disabled after upgrade + enabled: false, + actions, + exceptions_list: exceptionsList, + timeline_id: 'some-timeline-id', + timeline_title: 'Some timeline title', + }); + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts new file mode 100644 index 0000000000000..cd6ff46ecabb1 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts @@ -0,0 +1,152 @@ +/* + * 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 expect from 'expect'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + deleteAllRules, + deleteAllTimelines, + deleteAllPrebuiltRuleAssets, + createRuleAssetSavedObject, + installPrebuiltRulesAndTimelines, + getPrebuiltRulesAndTimelinesStatus, + createHistoricalPrebuiltRuleAssetSavedObjects, + getPrebuiltRulesStatus, + installPrebuiltRules, + upgradePrebuiltRules, +} from '../../../../utils'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @skipInQA upgrade prebuilt rules from package with historical versions with mock rule assets', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + describe(`rule package with historical versions`, () => { + const getRuleAssetSavedObjects = () => [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 1 }), + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 2 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 1 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 2 }), + createRuleAssetSavedObject({ rule_id: 'rule-2', version: 3 }), + ]; + + describe('using legacy endpoint', () => { + it('should upgrade outdated prebuilt rules when previous historical versions available', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Add a new version of one of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), + ]); + + // Check that one prebuilt rule status shows that one rule is outdated + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_updated).toBe(1); + + // Call the install prebuilt rules again and check that the outdated rule was updated + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(0); + expect(response.rules_updated).toBe(1); + + const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(_statusResponse.rules_not_installed).toBe(0); + expect(_statusResponse.rules_not_updated).toBe(0); + }); + + it('should upgrade outdated prebuilt rules when previous historical versions unavailable', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRulesAndTimelines(es, supertest); + + // Clear previous rule assets + await deleteAllPrebuiltRuleAssets(es, log); + + // Add a new rule version + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), + ]); + + // Check that one prebuilt rule status shows that one rule is outdated + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(statusResponse.rules_not_updated).toBe(1); + expect(statusResponse.rules_not_installed).toBe(0); + + // Call the install prebuilt rules again and check that the outdated rule was updated + const response = await installPrebuiltRulesAndTimelines(es, supertest); + expect(response.rules_installed).toBe(0); + expect(response.rules_updated).toBe(1); + + const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + expect(_statusResponse.rules_not_updated).toBe(0); + expect(_statusResponse.rules_not_installed).toBe(0); + }); + }); + + describe('using current endpoint', () => { + it('should upgrade outdated prebuilt rules when previous historical versions available', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRules(es, supertest); + + // Add a new version of one of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), + ]); + + // Check that the prebuilt rule status shows that one rule is outdated + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); + + // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated + const response = await upgradePrebuiltRules(es, supertest); + expect(response.summary.succeeded).toBe(1); + expect(response.summary.total).toBe(1); + + const status = await getPrebuiltRulesStatus(es, supertest); + expect(status.stats.num_prebuilt_rules_to_install).toBe(0); + expect(status.stats.num_prebuilt_rules_to_upgrade).toBe(0); + }); + + it('should upgrade outdated prebuilt rules when previous historical versions unavailable', async () => { + // Install all prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); + await installPrebuiltRules(es, supertest); + + // Clear previous rule assets + await deleteAllPrebuiltRuleAssets(es, log); + + // Add a new rule version + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), + ]); + + // Check that the prebuilt rule status shows that one rule is outdated + const statusResponse = await getPrebuiltRulesStatus(es, supertest); + expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); + expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); + + // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated + const response = await upgradePrebuiltRules(es, supertest); + expect(response.summary.succeeded).toBe(1); + expect(response.summary.total).toBe(1); + + const status = await getPrebuiltRulesStatus(es, supertest); + expect(status.stats.num_prebuilt_rules_to_install).toBe(0); + expect(status.stats.num_prebuilt_rules_to_upgrade).toBe(0); + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts index b87e1e0fcc4b1..555e845ec7b4f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts @@ -9,6 +9,7 @@ import expect from 'expect'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID } from '../../../../../config/shared'; import { binaryToString, createRule, @@ -381,7 +382,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should export rule without the action connector if it is Preconfigured Connector', async () => { const action = { group: 'default', - id: 'my-test-email', + id: PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID, action_type_id: '.email', params: {}, }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts index 0b4bfd9254b15..20a8e6cf17280 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts @@ -21,10 +21,7 @@ import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-se * @returns Created rule asset saved object */ export const createRuleAssetSavedObject = (overrideParams: Partial) => ({ - 'security-rule': { - ...getPrebuiltRuleMock(), - ...overrideParams, - }, + 'security-rule': getPrebuiltRuleMock(overrideParams), type: 'security-rule', references: [], coreMigrationVersion: '8.6.0', diff --git a/x-pack/test/security_solution_api_integration/tsconfig.json b/x-pack/test/security_solution_api_integration/tsconfig.json index 18e019202355c..d60124a7cc19f 100644 --- a/x-pack/test/security_solution_api_integration/tsconfig.json +++ b/x-pack/test/security_solution_api_integration/tsconfig.json @@ -41,5 +41,6 @@ "@kbn/safer-lodash-set", "@kbn/stack-connectors-plugin", "@kbn/ftr-common-functional-services", + "@kbn/actions-plugin", ] } From d84ac07b2e82714d88eb17dc5419d74971e09606 Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Mon, 12 Feb 2024 14:09:49 +0100 Subject: [PATCH 13/16] [Fleet][EDR Workflows] turnOffAgentTamperProtections method on AgentPolicy (#176408) This PR introduces an additional method on AgentPolicy that allows us to turn off agent tamper protection from all the policies. Previous approach: https://github.com/elastic/kibana/pull/176220 --- x-pack/plugins/fleet/server/mocks/index.ts | 2 +- x-pack/plugins/fleet/server/plugin.ts | 3 +- .../server/services/agent_policy.test.ts | 91 +++++++++++ .../fleet/server/services/agent_policy.ts | 70 ++++++++- x-pack/plugins/fleet/server/services/index.ts | 2 +- .../turn_off_agent_policy_features.test.ts | 142 +++++------------- .../turn_off_agent_policy_features.ts | 82 +++------- .../security_solution/server/plugin.ts | 1 - 8 files changed, 225 insertions(+), 168 deletions(-) diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index 857882c57525f..063261c307646 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -177,7 +177,7 @@ export const createMockAgentPolicyService = (): jest.Mocked { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 26c1a3dcee268..b297bb6b128c2 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -11,6 +11,8 @@ import { securityMock } from '@kbn/security-plugin/server/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { Logger } from '@kbn/core/server'; +import type { SavedObjectError } from '@kbn/core-saved-objects-common'; + import { PackagePolicyRestrictionRelatedError, FleetUnauthorizedError, @@ -1006,6 +1008,95 @@ describe('agent policy', () => { }); }); + describe('turnOffAgentTamperProtections', () => { + const createPolicySO = (id: string, isProtected: boolean, error?: SavedObjectError) => ({ + id, + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + attributes: { + is_protected: isProtected, + }, + references: [], + score: 1, + ...(error ? { error } : {}), + }); + + const createMockSoClientThatReturns = (policies: Array>) => { + const mockSoClient = savedObjectsClientMock.create(); + + const resolvedValue = { + saved_objects: policies, + page: 1, + per_page: 10, + total: policies.length, + }; + mockSoClient.find.mockResolvedValue(resolvedValue); + return mockSoClient; + }; + + it('should return if all policies are compliant', async () => { + const mockSoClient = createMockSoClientThatReturns([]); + + expect(await agentPolicyService.turnOffAgentTamperProtections(mockSoClient)).toEqual({ + failedPolicies: [], + updatedPolicies: null, + }); + expect(mockSoClient.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('should bulk update policies that are not compliant', async () => { + const mockSoClient = createMockSoClientThatReturns([ + createPolicySO('policy1', true), + createPolicySO('policy2', true), + createPolicySO('policy3', false), + ]); + + mockSoClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [createPolicySO('policy1', false), createPolicySO('policy2', false)], + }); + + const expectedResponse = expect.arrayContaining([ + expect.objectContaining({ + id: 'policy1', + attributes: expect.objectContaining({ is_protected: false }), + }), + expect.objectContaining({ + id: 'policy2', + attributes: expect.objectContaining({ is_protected: false }), + }), + ]); + + expect(await agentPolicyService.turnOffAgentTamperProtections(mockSoClient)).toEqual({ + failedPolicies: [], + updatedPolicies: expectedResponse, + }); + + expect(mockSoClient.bulkUpdate).toHaveBeenCalledWith(expectedResponse); + }); + + it('should return failed policies if bulk update fails', async () => { + const mockSoClient = createMockSoClientThatReturns([ + createPolicySO('policy1', true), + createPolicySO('policy2', true), + createPolicySO('policy3', false), + ]); + mockSoClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [ + createPolicySO('policy1', false, { error: 'Oops!', message: 'Ooops!', statusCode: 500 }), + createPolicySO('policy2', false), + ], + }); + expect(await agentPolicyService.turnOffAgentTamperProtections(mockSoClient)).toEqual({ + failedPolicies: [ + expect.objectContaining({ + id: 'policy1', + error: expect.objectContaining({ message: 'Ooops!' }), + }), + ], + updatedPolicies: [expect.objectContaining({ id: 'policy2' })], + }); + }); + }); + describe('deleteFleetServerPoliciesForPolicyId', () => { it('should call audit logger', async () => { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 6795d81f28946..a532aab68b228 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { omit, isEqual, keyBy, groupBy, pick } from 'lodash'; +import { omit, isEqual, keyBy, groupBy, pick, chunk } from 'lodash'; import { v5 as uuidv5 } from 'uuid'; import { safeDump } from 'js-yaml'; import pMap from 'p-map'; @@ -22,6 +22,10 @@ import type { BulkResponseItem } from '@elastic/elasticsearch/lib/api/typesWithB import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; +import { asyncForEach } from '@kbn/std'; + +import type { SavedObjectError } from '@kbn/core-saved-objects-common'; + import { policyHasEndpointSecurity } from '../../common/services'; import { populateAssignedAgentsCount } from '../routes/agent_policy/handlers'; @@ -1303,6 +1307,70 @@ class AgentPolicyService { })); } + public async turnOffAgentTamperProtections(soClient: SavedObjectsClientContract): Promise<{ + updatedPolicies: Array> | null; + failedPolicies: Array<{ id: string; error: Error | SavedObjectError }>; + }> { + const { saved_objects: agentPoliciesWithEnabledAgentTamperProtection } = + await soClient.find({ + type: SAVED_OBJECT_TYPE, + page: 1, + perPage: SO_SEARCH_LIMIT, + filter: normalizeKuery(SAVED_OBJECT_TYPE, 'ingest-agent-policies.is_protected: true'), + fields: ['revision'], + }); + + if (agentPoliciesWithEnabledAgentTamperProtection.length === 0) { + return { + updatedPolicies: null, + failedPolicies: [], + }; + } + + const { saved_objects: updatedAgentPolicies } = + await soClient.bulkUpdate( + agentPoliciesWithEnabledAgentTamperProtection.map((agentPolicy) => { + const { id, attributes } = agentPolicy; + return { + id, + type: SAVED_OBJECT_TYPE, + attributes: { + is_protected: false, + revision: attributes.revision + 1, + updated_at: new Date().toISOString(), + updated_by: 'system', + }, + }; + }) + ); + + const failedPolicies: Array<{ + id: string; + error: Error | SavedObjectError; + }> = []; + + updatedAgentPolicies.forEach((policy) => { + if (policy.error) { + failedPolicies.push({ + id: policy.id, + error: policy.error, + }); + } + }); + + const updatedPoliciesSuccess = updatedAgentPolicies.filter((policy) => !policy.error); + + const config = appContextService.getConfig(); + const batchSize = config?.setup?.agentPolicySchemaUpgradeBatchSize ?? 100; + const policyIds = updatedPoliciesSuccess.map((policy) => policy.id); + await asyncForEach( + chunk(policyIds, batchSize), + async (policyIdsBatch) => await this.deployPolicies(soClient, policyIdsBatch) + ); + + return { updatedPolicies: updatedPoliciesSuccess, failedPolicies }; + } + private checkTamperProtectionLicense(agentPolicy: { is_protected?: boolean }): void { if (agentPolicy?.is_protected && !licenseService.isPlatinum()) { throw new FleetUnauthorizedError('Tamper protection requires Platinum license'); diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index ff219fd0d1ab6..6d7ca261e3bf3 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -19,7 +19,7 @@ export interface AgentPolicyServiceInterface { list: typeof agentPolicyService['list']; getFullAgentPolicy: typeof agentPolicyService['getFullAgentPolicy']; getByIds: typeof agentPolicyService['getByIDs']; - bumpRevision: typeof agentPolicyService['bumpRevision']; + turnOffAgentTamperProtections: typeof agentPolicyService['turnOffAgentTamperProtections']; } // Agent services diff --git a/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts index b54221f300e27..08c4cb69e44e7 100644 --- a/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts @@ -7,29 +7,25 @@ import { createMockEndpointAppContextServiceStartContract } from '../mocks'; import type { Logger } from '@kbn/logging'; -import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { EndpointInternalFleetServicesInterface } from '../services/fleet'; import { ALL_APP_FEATURE_KEYS } from '@kbn/security-solution-features/keys'; import type { AppFeaturesService } from '../../lib/app_features_service/app_features_service'; import { createAppFeaturesServiceMock } from '../../lib/app_features_service/mocks'; import { turnOffAgentPolicyFeatures } from './turn_off_agent_policy_features'; -import { FleetAgentPolicyGenerator } from '../../../common/endpoint/data_generators/fleet_agent_policy_generator'; -import type { AgentPolicy, GetAgentPoliciesResponseItem } from '@kbn/fleet-plugin/common'; describe('Turn Off Agent Policy Features Migration', () => { - let esClient: ElasticsearchClient; let fleetServices: EndpointInternalFleetServicesInterface; let appFeatureService: AppFeaturesService; let logger: Logger; const callTurnOffAgentPolicyFeatures = () => - turnOffAgentPolicyFeatures(esClient, fleetServices, appFeatureService, logger); + turnOffAgentPolicyFeatures(fleetServices, appFeatureService, logger); beforeEach(() => { const endpointContextStartContract = createMockEndpointAppContextServiceStartContract(); - ({ esClient, logger } = endpointContextStartContract); + ({ logger } = endpointContextStartContract); appFeatureService = endpointContextStartContract.appFeaturesService; fleetServices = endpointContextStartContract.endpointFleetServicesFactory.asInternalUser(); @@ -39,7 +35,9 @@ describe('Turn Off Agent Policy Features Migration', () => { it('should do nothing', async () => { await callTurnOffAgentPolicyFeatures(); - expect(fleetServices.agentPolicy.list as jest.Mock).not.toHaveBeenCalled(); + expect( + fleetServices.agentPolicy.turnOffAgentTamperProtections as jest.Mock + ).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenLastCalledWith( 'App feature [endpoint_agent_tamper_protection] is enabled. Nothing to do!' ); @@ -47,121 +45,55 @@ describe('Turn Off Agent Policy Features Migration', () => { }); describe('and `agentTamperProtection` is disabled', () => { - let policyGenerator: FleetAgentPolicyGenerator; - let page1Items: GetAgentPoliciesResponseItem[] = []; - let page2Items: GetAgentPoliciesResponseItem[] = []; - let page3Items: GetAgentPoliciesResponseItem[] = []; - let bulkUpdateResponse: AgentPolicy[]; - - const generatePolicyMock = (): GetAgentPoliciesResponseItem => { - return policyGenerator.generate({ is_protected: true }); - }; - beforeEach(() => { - policyGenerator = new FleetAgentPolicyGenerator('seed'); - const agentPolicyListSrv = fleetServices.agentPolicy.list as jest.Mock; - appFeatureService = createAppFeaturesServiceMock( ALL_APP_FEATURE_KEYS.filter((key) => key !== 'endpoint_agent_tamper_protection') ); - - page1Items = [generatePolicyMock(), generatePolicyMock()]; - page2Items = [generatePolicyMock(), generatePolicyMock()]; - page3Items = [generatePolicyMock()]; - - agentPolicyListSrv - .mockImplementationOnce(async () => { - return { - total: 2500, - page: 1, - perPage: 1000, - items: page1Items, - }; - }) - .mockImplementationOnce(async () => { - return { - total: 2500, - page: 2, - perPage: 1000, - items: page2Items, - }; - }) - .mockImplementationOnce(async () => { - return { - total: 2500, - page: 3, - perPage: 1000, - items: page3Items, - }; - }); - - bulkUpdateResponse = [ - page1Items[0], - page1Items[1], - page2Items[0], - page2Items[1], - page3Items[0], - ]; - - (fleetServices.agentPolicy.bumpRevision as jest.Mock).mockImplementation(async () => { - return bulkUpdateResponse; - }); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should update only policies that have protections turn on', async () => { + it('should log proper message if all agent policies are already protected', async () => { + (fleetServices.agentPolicy.turnOffAgentTamperProtections as jest.Mock).mockResolvedValueOnce({ + updatedPolicies: null, + failedPolicies: [], + }); await callTurnOffAgentPolicyFeatures(); + expect(logger.info).toHaveBeenCalledWith('All agent policies are compliant, nothing to do!'); + }); - expect(fleetServices.agentPolicy.list as jest.Mock).toHaveBeenCalledTimes(3); - - const updates = Array.from({ length: 5 }, (_, i) => ({ - soClient: fleetServices.internalSoClient, - esClient, - id: bulkUpdateResponse![i].id, - })); - - expect(fleetServices.agentPolicy.bumpRevision as jest.Mock).toHaveBeenCalledTimes(5); - updates.forEach((args, i) => { - expect(fleetServices.agentPolicy.bumpRevision as jest.Mock).toHaveBeenNthCalledWith( - i + 1, - args.soClient, - args.esClient, - args.id, - { removeProtection: true, user: { username: 'elastic' } } - ); + it('should log proper message if all agent policies are updated successfully', async () => { + (fleetServices.agentPolicy.turnOffAgentTamperProtections as jest.Mock).mockResolvedValueOnce({ + updatedPolicies: [{ id: 'policy 1' }, { id: 'policy 2' }], + failedPolicies: [], }); - - expect(logger.info).toHaveBeenCalledWith( - 'App feature [endpoint_agent_tamper_protection] is disabled. Checking fleet agent policies for compliance' + await callTurnOffAgentPolicyFeatures(); + expect(logger.info).toHaveBeenLastCalledWith( + 'Done - 2 out of 2 were successful. No errors encountered.' ); + }); - expect(logger.info).toHaveBeenCalledWith( - `Found 5 policies that need updates:\n${bulkUpdateResponse! - .map( - (policy) => - `Policy [${policy.id}][${policy.name}] updated to disable agent tamper protection.` - ) - .join('\n')}` + it('should log proper message if all agent policies fail to update', async () => { + (fleetServices.agentPolicy.turnOffAgentTamperProtections as jest.Mock).mockResolvedValueOnce({ + updatedPolicies: null, + failedPolicies: [ + { id: 'policy1', error: 'error1' }, + { id: 'policy2', error: 'error2' }, + ], + }); + await callTurnOffAgentPolicyFeatures(); + expect(logger.error).toHaveBeenLastCalledWith( + 'Done - all 2 failed to update. Errors encountered:\nPolicy [policy1] failed to update due to error: error1\nPolicy [policy2] failed to update due to error: error2' ); - expect(logger.info).toHaveBeenCalledWith('Done. All updates applied successfully'); }); - it('should log failures', async () => { - (fleetServices.agentPolicy.bumpRevision as jest.Mock).mockImplementationOnce(async () => { - throw new Error('oh noo'); + it('should log proper message if some agent policies fail to update', async () => { + (fleetServices.agentPolicy.turnOffAgentTamperProtections as jest.Mock).mockResolvedValueOnce({ + updatedPolicies: [{ id: 'policy3' }], + failedPolicies: [{ id: 'policy1', error: 'error1' }], }); await callTurnOffAgentPolicyFeatures(); - - expect(logger.error).toHaveBeenCalledWith( - `Done - 1 out of 5 were successful. Errors encountered:\nPolicy [${ - bulkUpdateResponse![0].id - }] failed to update due to error: Error: oh noo` + expect(logger.error).toHaveBeenLastCalledWith( + 'Done - 1 out of 2 were successful. Errors encountered:\nPolicy [policy1] failed to update due to error: error1' ); - - expect(fleetServices.agentPolicy.bumpRevision as jest.Mock).toHaveBeenCalledTimes(5); }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts index 0d1b01d68b765..01994a683bb92 100644 --- a/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts +++ b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts @@ -5,16 +5,12 @@ * 2.0. */ -import type { Logger, ElasticsearchClient } from '@kbn/core/server'; -import type { AgentPolicy } from '@kbn/fleet-plugin/common'; -import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import type { Logger } from '@kbn/core/server'; import { AppFeatureSecurityKey } from '@kbn/security-solution-features/keys'; -import pMap from 'p-map'; import type { EndpointInternalFleetServicesInterface } from '../services/fleet'; import type { AppFeaturesService } from '../../lib/app_features_service/app_features_service'; export const turnOffAgentPolicyFeatures = async ( - esClient: ElasticsearchClient, fleetServices: EndpointInternalFleetServicesInterface, appFeaturesService: AppFeaturesService, logger: Logger @@ -34,59 +30,29 @@ export const turnOffAgentPolicyFeatures = async ( ); const { agentPolicy: agentPolicyService, internalSoClient } = fleetServices; - const updates: AgentPolicy[] = []; - const messages: string[] = []; - const perPage = 1000; - let hasMoreData = true; - let total = 0; - let page = 1; - - do { - const currentPage = page++; - const { items, total: totalPolicies } = await agentPolicyService.list(internalSoClient, { - page: currentPage, - kuery: 'ingest-agent-policies.is_protected: true', - perPage, - }); - - total = totalPolicies; - hasMoreData = currentPage * perPage < total; - - for (const item of items) { - messages.push( - `Policy [${item.id}][${item.name}] updated to disable agent tamper protection.` - ); - - updates.push({ ...item, is_protected: false }); - } - } while (hasMoreData); - - if (updates.length > 0) { - logger.info(`Found ${updates.length} policies that need updates:\n${messages.join('\n')}`); - const policyUpdateErrors: Array<{ id: string; error: Error }> = []; - await pMap(updates, async (update) => { - try { - return await agentPolicyService.bumpRevision(internalSoClient, esClient, update.id, { - user: { username: 'elastic' } as AuthenticatedUser, - removeProtection: true, - }); - } catch (error) { - policyUpdateErrors.push({ error, id: update.id }); - } - }); - - if (policyUpdateErrors.length > 0) { - logger.error( - `Done - ${policyUpdateErrors.length} out of ${ - updates.length - } were successful. Errors encountered:\n${policyUpdateErrors - .map((e) => `Policy [${e.id}] failed to update due to error: ${e.error}`) - .join('\n')}` - ); - } else { - logger.info(`Done. All updates applied successfully`); - } + const { updatedPolicies, failedPolicies } = + await agentPolicyService.turnOffAgentTamperProtections(internalSoClient); + + if (!updatedPolicies && !failedPolicies.length) { + log.info(`All agent policies are compliant, nothing to do!`); + } else if (updatedPolicies && failedPolicies.length) { + const totalPolicies = updatedPolicies.length + failedPolicies.length; + logger.error( + `Done - ${ + failedPolicies.length + } out of ${totalPolicies} were successful. Errors encountered:\n${failedPolicies + .map((e) => `Policy [${e.id}] failed to update due to error: ${e.error}`) + .join('\n')}` + ); + } else if (updatedPolicies) { + logger.info( + `Done - ${updatedPolicies.length} out of ${updatedPolicies.length} were successful. No errors encountered.` + ); } else { - logger.info(`Done. Checked ${total} policies and no updates needed`); + logger.error( + `Done - all ${failedPolicies.length} failed to update. Errors encountered:\n${failedPolicies + .map((e) => `Policy [${e.id}] failed to update due to error: ${e.error}`) + .join('\n')}` + ); } }; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 5b8ec7edd36ab..93f49275491ee 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -568,7 +568,6 @@ export class Plugin implements ISecuritySolutionPlugin { ); turnOffAgentPolicyFeatures( - core.elasticsearch.client.asInternalUser, endpointFleetServicesFactory.asInternalUser(), appFeaturesService, logger From 82d639d4f8283b902a5006178b48ac38dd953c76 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 12 Feb 2024 13:44:23 +0000 Subject: [PATCH 14/16] skip failing es promotion suite (#176697) --- .../transform/creation/index_pattern/creation_index_pattern.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts index a18252b47bdbe..df51c048a87e3 100644 --- a/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts @@ -527,7 +527,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await transform.sourceSelection.selectSource(testData.source); }); - it('navigates through the wizard and sets all needed fields', async () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/176697 + it.skip('navigates through the wizard and sets all needed fields', async () => { await transform.testExecution.logTestStep('displays the define step'); await transform.wizard.assertDefineStepActive(); From 29580f32023f0e080e0e8410ba247da7fc61a4d9 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 12 Feb 2024 13:44:45 +0000 Subject: [PATCH 15/16] skip failing es promotion suite (#176698) --- .../transform/creation/index_pattern/creation_index_pattern.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts index df51c048a87e3..b6653078e18c1 100644 --- a/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation/index_pattern/creation_index_pattern.ts @@ -528,6 +528,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/176697 + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/176698 it.skip('navigates through the wizard and sets all needed fields', async () => { await transform.testExecution.logTestStep('displays the define step'); await transform.wizard.assertDefineStepActive(); From a515c2ead058c14e85b84c7aaef7c4bf0332d30b Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 12 Feb 2024 16:31:42 +0200 Subject: [PATCH 16/16] [ES|QL] Displays correctly warnings with additional info (#176660) ## Summary In some cases the warnings come in the format: `java.lang.IllegalArgumentException: warning message` We don 't treat these cases correctly and as a result the user gets a warning which is not helpful at all. This PR fixes it. **Before** Screenshot 2024-02-12 at 8 55 14 AM **With this PR** image ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../kbn-text-based-editor/src/helpers.test.ts | 27 ++++++++++++++++++- packages/kbn-text-based-editor/src/helpers.ts | 6 +++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/kbn-text-based-editor/src/helpers.test.ts b/packages/kbn-text-based-editor/src/helpers.test.ts index 8ba691bb4e3ee..86a76e81c36de 100644 --- a/packages/kbn-text-based-editor/src/helpers.test.ts +++ b/packages/kbn-text-based-editor/src/helpers.test.ts @@ -90,7 +90,7 @@ describe('helpers', function () { ]); }); - it('should return the correct array of warnings if multiple warnins are detected', function () { + it('should return the correct array of warnings if multiple warnings are detected', function () { const warning = '299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Line 1:52: evaluation of [date_parse(geo.dest)] failed, treating result as null. Only first 20 failures recorded.", 299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Line 1:84: evaluation of [date_parse(geo.src)] failed, treating result as null. Only first 20 failures recorded."'; expect(parseWarning(warning)).toEqual([ @@ -115,6 +115,31 @@ describe('helpers', function () { ]); }); + it('should return the correct array of warnings if the message contains additional info', function () { + const warning = + '299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Line 1:52: evaluation of [date_parse(geo.dest)] failed, treating result as null. Only first 20 failures recorded.", 299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Line 1:84: java.lang.IllegalArgumentException: evaluation of [date_parse(geo.src)] failed, treating result as null. Only first 20 failures recorded."'; + expect(parseWarning(warning)).toEqual([ + { + endColumn: 138, + endLineNumber: 1, + message: + 'evaluation of [date_parse(geo.dest)] failed, treating result as null. Only first 20 failures recorded.', + severity: 4, + startColumn: 52, + startLineNumber: 1, + }, + { + endColumn: 169, + endLineNumber: 1, + message: + 'evaluation of [date_parse(geo.src)] failed, treating result as null. Only first 20 failures recorded.', + severity: 4, + startColumn: 84, + startLineNumber: 1, + }, + ]); + }); + it('should return the correct array of warnings if multiple warnins are detected without line indicators', function () { const warning = '299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Field [geo.coordinates] cannot be retrieved, it is unsupported or not indexed; returning null.", 299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Field [ip_range] cannot be retrieved, it is unsupported or not indexed; returning null.", 299 Elasticsearch-8.10.0-SNAPSHOT-adb9fce96079b421c2575f0d2d445f492eb5f075 "Field [timestamp_range] cannot be retrieved, it is unsupported or not indexed; returning null."'; diff --git a/packages/kbn-text-based-editor/src/helpers.ts b/packages/kbn-text-based-editor/src/helpers.ts index 80fd76bd4fd15..3d5a3290fc8e5 100644 --- a/packages/kbn-text-based-editor/src/helpers.ts +++ b/packages/kbn-text-based-editor/src/helpers.ts @@ -53,8 +53,10 @@ export const parseWarning = (warning: string): MonacoMessage[] => { // if there's line number encoded in the message use it as new positioning // and replace the actual message without it if (/Line (\d+):(\d+):/.test(warningMessage)) { - const [encodedLine, encodedColumn, innerMessage] = warningMessage.split(':'); - warningMessage = innerMessage; + const [encodedLine, encodedColumn, innerMessage, additionalInfoMessage] = + warningMessage.split(':'); + // sometimes the warning comes to the format java.lang.IllegalArgumentException: warning message + warningMessage = additionalInfoMessage ?? innerMessage; if (!Number.isNaN(Number(encodedColumn))) { startColumn = Number(encodedColumn); startLineNumber = Number(encodedLine.replace('Line ', ''));