From 79096beea5a63d994ea69fe98dfc4f6103817ec6 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Fri, 12 Apr 2024 19:11:44 +0200 Subject: [PATCH] [SecuritySolutions] Create Asset Criticality CSV upload page (#179891) ## Summary Create a new Asset Criticality page for updating asset criticality by file upload. Flaky test runner: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5662 Server side PR: https://github.com/elastic/kibana/pull/179930 https://github.com/elastic/kibana/assets/1490444/f524b5e8-8efa-40c7-8e43-45cf43decefb The new page has three steps. You can access the page by going to Security -> Manage -> Asset Criticality. ### File picker Step: ### File validation step ### Result step ### 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] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) a-docker) - [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)) ## How to test it? * Open the page * Upload a valid CSV file * Check if everything is ok on the validation step * Click Assign * Check if the success message is displayed * Open the alert flyout for an updated asset and check if it has the new value ## What is not included? * Serverless * Disable the feature when asset criticality advanced setting is disabled ## Code owners files:
elastic/docs * packages/kbn-doc-links/src/get_doc_links.ts * packages/kbn-doc-links/src/types.ts
elastic/security-defend-workflows * x-pack/plugins/security_solution/public/management/links.ts
elastic/security-detection-engine * x-pack/test/security_solution_cypress/cypress/urls/navigation.ts
elastic/security-detections-response * x-pack/test/security_solution_cypress/cypress/fixtures/asset_criticality.csv
elastic/security-engineering-productivity * x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts * x-pack/test/security_solution_cypress/cypress/fixtures/asset_criticality.csv * x-pack/test/security_solution_cypress/cypress/screens/asset_criticality.ts * x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality.ts * x-pack/test/security_solution_cypress/cypress/urls/navigation.ts
elastic/security-threat-hunting * x-pack/test/security_solution_cypress/cypress/fixtures/asset_criticality.csv
elastic/security-threat-hunting-investigations * x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx * x-pack/test/security_solution_cypress/cypress/urls/navigation.ts
--------- Co-authored-by: Mark Hopkin --- packages/deeplinks/security/deep_links.ts | 1 + packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../security_solution/common/constants.ts | 2 + .../public/app/translations.ts | 7 + .../public/common/icons/asset_criticality.tsx | 43 ++++ .../public/common/lib/telemetry/constants.ts | 3 + .../events/entity_analytics/index.ts | 115 +++++++++ .../events/entity_analytics/types.ts | 47 +++- .../lib/telemetry/events/telemetry_events.ts | 6 + .../lib/telemetry/telemetry_client.mock.ts | 3 + .../common/lib/telemetry/telemetry_client.ts | 21 +- .../public/common/lib/telemetry/types.ts | 13 +- .../common/mock/storybook_providers.tsx | 1 + .../public/entity_analytics/api/api.ts | 21 ++ ...sset_criticality_file_uploader.stories.tsx | 223 ++++++++++++++++++ .../asset_criticality_file_uploader.tsx | 158 +++++++++++++ .../components/file_picker_step.test.tsx | 75 ++++++ .../components/file_picker_step.tsx | 168 +++++++++++++ .../components/result_step.test.tsx | 72 ++++++ .../components/result_step.tsx | 142 +++++++++++ .../components/validation_step.test.tsx | 100 ++++++++ .../components/validation_step.tsx | 203 ++++++++++++++++ .../constants.ts | 9 + .../helpers.test.ts | 49 ++++ .../helpers.ts | 51 ++++ .../hooks.test.ts | 91 +++++++ .../asset_criticality_file_uploader/hooks.ts | 167 +++++++++++++ .../asset_criticality_file_uploader/index.ts | 8 + .../reducer.test.ts | 146 ++++++++++++ .../reducer.ts | 107 +++++++++ .../asset_criticality_file_uploader/types.ts | 35 +++ .../validations.test.ts | 84 +++++++ .../validations.ts | 100 ++++++++ .../pages/asset_criticality_upload_page.tsx | 110 +++++++++ .../public/entity_analytics/routes.tsx | 51 +++- .../public/management/links.ts | 24 +- .../public/resolver/view/panels/node_list.tsx | 1 - .../asset_criticality_upload_page.cy.ts | 49 ++++ .../cypress/screens/asset_criticality.ts | 19 ++ .../cypress/tasks/asset_criticality.ts | 22 ++ .../cypress/urls/navigation.ts | 2 + 42 files changed, 2538 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/icons/asset_criticality.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.test.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.test.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.test.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/constants.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.test.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/index.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.test.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/types.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.test.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/screens/asset_criticality.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality.ts diff --git a/packages/deeplinks/security/deep_links.ts b/packages/deeplinks/security/deep_links.ts index a6b79c632c38b..2b384a9f1ff4a 100644 --- a/packages/deeplinks/security/deep_links.ts +++ b/packages/deeplinks/security/deep_links.ts @@ -83,5 +83,6 @@ export enum SecurityPageName { usersRisk = 'users-risk', entityAnalytics = 'entity_analytics', entityAnalyticsManagement = 'entity_analytics-management', + entityAnalyticsAssetClassification = 'entity_analytics-asset-classification', coverageOverview = 'coverage-overview', } diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 7e3a622eff928..86cf281399298 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -483,6 +483,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D hostRiskScore: `${SECURITY_SOLUTION_DOCS}host-risk-score.html`, userRiskScore: `${SECURITY_SOLUTION_DOCS}user-risk-score.html`, entityRiskScoring: `${SECURITY_SOLUTION_DOCS}entity-risk-scoring.html`, + assetCriticality: `${SECURITY_SOLUTION_DOCS}asset-criticality.html`, }, detectionEngineOverview: `${SECURITY_SOLUTION_DOCS}detection-engine-overview.html`, }, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index a1d4bd8863d11..c76cae116d6ac 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -358,6 +358,7 @@ export interface DocLinks { readonly hostRiskScore: string; readonly userRiskScore: string; readonly entityRiskScoring: string; + readonly assetCriticality: string; }; readonly detectionEngineOverview: string; }; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 34c19e04bc1de..d1e6ed3b9785b 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -119,6 +119,8 @@ export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const; export const RESPONSE_ACTIONS_HISTORY_PATH = `${MANAGEMENT_PATH}/response_actions_history` as const; export const ENTITY_ANALYTICS_PATH = '/entity_analytics' as const; export const ENTITY_ANALYTICS_MANAGEMENT_PATH = `/entity_analytics_management` as const; +export const ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH = + `/entity_analytics_asset_criticality` as const; export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const; export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const; export const APP_DETECTION_RESPONSE_PATH = `${APP_PATH}${DETECTION_RESPONSE_PATH}` as const; diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 516239d164632..cb0e72066eb8f 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -25,6 +25,13 @@ export const ENTITY_ANALYTICS_RISK_SCORE = i18n.translate( } ); +export const ASSET_CRITICALITY = i18n.translate( + 'xpack.securitySolution.navigation.assetCriticality', + { + defaultMessage: 'Asset criticality', + } +); + export const DETECTION_RESPONSE = i18n.translate( 'xpack.securitySolution.navigation.detectionResponse', { diff --git a/x-pack/plugins/security_solution/public/common/icons/asset_criticality.tsx b/x-pack/plugins/security_solution/public/common/icons/asset_criticality.tsx new file mode 100644 index 0000000000000..2f8442658e99a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/icons/asset_criticality.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SVGProps } from 'react'; +import React from 'react'; +export const IconAssetCriticality: React.FC> = ({ ...props }) => ( + + + + + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts index 928ece1c9e70f..8a2c9549cc54f 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts @@ -45,6 +45,9 @@ export enum TelemetryEventTypes { AssistantMessageSent = 'Assistant Message Sent', AssistantQuickPrompt = 'Assistant Quick Prompt', AssistantSettingToggled = 'Assistant Setting Toggled', + AssetCriticalityCsvPreviewGenerated = 'Asset Criticality Csv Preview Generated', + AssetCriticalityFileSelected = 'Asset Criticality File Selected', + AssetCriticalityCsvImported = 'Asset Criticality CSV Imported', InsightsGenerated = 'Insights Generated', EntityDetailsClicked = 'Entity Details Clicked', EntityAlertsClicked = 'Entity Alerts Clicked', diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts index 78968de060186..a3ea313ac20c1 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts @@ -99,3 +99,118 @@ export const addRiskInputToTimelineClickedEvent: TelemetryEvent = { }, }, }; + +export const assetCriticalityFileSelectedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.AssetCriticalityFileSelected, + schema: { + valid: { + type: 'boolean', + _meta: { + description: 'If the file is valid', + optional: false, + }, + }, + errorCode: { + type: 'keyword', + _meta: { + description: 'Error code if the file is invalid', + optional: true, + }, + }, + file: { + properties: { + size: { + type: 'long', + _meta: { + description: 'File size in bytes', + optional: false, + }, + }, + }, + }, + }, +}; + +export const assetCriticalityCsvPreviewGeneratedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.AssetCriticalityCsvPreviewGenerated, + schema: { + file: { + properties: { + size: { + type: 'long', + _meta: { + description: 'File size in bytes', + optional: false, + }, + }, + }, + }, + processing: { + properties: { + startTime: { + type: 'date', + _meta: { + description: 'Processing start time', + optional: false, + }, + }, + endTime: { + type: 'date', + _meta: { + description: 'Processing end time', + optional: false, + }, + }, + tookMs: { + type: 'long', + _meta: { + description: 'Processing time in milliseconds', + optional: false, + }, + }, + }, + }, + stats: { + properties: { + validLines: { + type: 'long', + _meta: { + description: 'Number of valid lines', + optional: false, + }, + }, + invalidLines: { + type: 'long', + _meta: { + description: 'Number of invalid lines', + optional: false, + }, + }, + totalLines: { + type: 'long', + _meta: { + description: 'Total number of lines', + optional: false, + }, + }, + }, + }, + }, +}; + +export const assetCriticalityCsvImportedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.AssetCriticalityCsvImported, + schema: { + file: { + properties: { + size: { + type: 'long', + _meta: { + description: 'File size in bytes', + optional: false, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts index 7da80b09cf602..9d38694324e55 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts @@ -29,13 +29,46 @@ export interface ReportAddRiskInputToTimelineClickedParams { quantity: number; } +export interface ReportAssetCriticalityFileSelectedParams { + valid: boolean; + errorCode?: string; + file: { + size: number; + }; +} + +export interface ReportAssetCriticalityCsvPreviewGeneratedParams { + file: { + size: number; + }; + processing: { + startTime: string; + endTime: string; + tookMs: number; + }; + stats: { + validLines: number; + invalidLines: number; + totalLines: number; + }; +} + +export interface ReportAssetCriticalityCsvImportedParams { + file: { + size: number; + }; +} + export type ReportEntityAnalyticsTelemetryEventParams = | ReportEntityDetailsClickedParams | ReportEntityAlertsClickedParams | ReportEntityRiskFilteredParams | ReportToggleRiskSummaryClickedParams | ReportRiskInputsExpandedFlyoutOpenedParams - | ReportAddRiskInputToTimelineClickedParams; + | ReportAddRiskInputToTimelineClickedParams + | ReportAssetCriticalityCsvPreviewGeneratedParams + | ReportAssetCriticalityFileSelectedParams + | ReportAssetCriticalityCsvImportedParams; export type EntityAnalyticsTelemetryEvent = | { @@ -61,4 +94,16 @@ export type EntityAnalyticsTelemetryEvent = | { eventType: TelemetryEventTypes.RiskInputsExpandedFlyoutOpened; schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.AssetCriticalityCsvPreviewGenerated; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.AssetCriticalityFileSelected; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.AssetCriticalityCsvImported; + schema: RootSchema; }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts index 1c8232f74e583..a220ced601b13 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts @@ -18,6 +18,9 @@ import { addRiskInputToTimelineClickedEvent, RiskInputsExpandedFlyoutOpenedEvent, toggleRiskSummaryClickedEvent, + assetCriticalityCsvPreviewGeneratedEvent, + assetCriticalityFileSelectedEvent, + assetCriticalityCsvImportedEvent, } from './entity_analytics'; import { assistantInvokedEvent, @@ -152,6 +155,9 @@ export const telemetryEvents = [ entityClickedEvent, entityAlertsClickedEvent, entityRiskFilteredEvent, + assetCriticalityCsvPreviewGeneratedEvent, + assetCriticalityFileSelectedEvent, + assetCriticalityCsvImportedEvent, toggleRiskSummaryClickedEvent, RiskInputsExpandedFlyoutOpenedEvent, addRiskInputToTimelineClickedEvent, diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts index 52398b20149ad..4d5b1bed53b1b 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts @@ -29,5 +29,8 @@ export const createTelemetryClientMock = (): jest.Mocked = reportAddRiskInputToTimelineClicked: jest.fn(), reportDetailsFlyoutOpened: jest.fn(), reportDetailsFlyoutTabClicked: jest.fn(), + reportAssetCriticalityCsvPreviewGenerated: jest.fn(), + reportAssetCriticalityFileSelected: jest.fn(), + reportAssetCriticalityCsvImported: jest.fn(), reportInsightsGenerated: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts index 90d304dfe64a9..49bed3a64289d 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts @@ -29,9 +29,12 @@ import type { ReportToggleRiskSummaryClickedParams, ReportDetailsFlyoutOpenedParams, ReportDetailsFlyoutTabClickedParams, + ReportAssetCriticalityCsvPreviewGeneratedParams, + ReportAssetCriticalityFileSelectedParams, + ReportAssetCriticalityCsvImportedParams, + ReportAddRiskInputToTimelineClickedParams, } from './types'; import { TelemetryEventTypes } from './constants'; -import type { ReportAddRiskInputToTimelineClickedParams } from './events/entity_analytics/types'; /** * Client which aggregate all the available telemetry tracking functions @@ -94,6 +97,22 @@ export class TelemetryClient implements TelemetryClientStart { }); }; + public reportAssetCriticalityCsvPreviewGenerated = ( + params: ReportAssetCriticalityCsvPreviewGeneratedParams + ) => { + this.analytics.reportEvent(TelemetryEventTypes.AssetCriticalityCsvPreviewGenerated, params); + }; + + public reportAssetCriticalityFileSelected = ( + params: ReportAssetCriticalityFileSelectedParams + ) => { + this.analytics.reportEvent(TelemetryEventTypes.AssetCriticalityFileSelected, params); + }; + + public reportAssetCriticalityCsvImported = (params: ReportAssetCriticalityCsvImportedParams) => { + this.analytics.reportEvent(TelemetryEventTypes.AssetCriticalityCsvImported, params); + }; + public reportMLJobUpdate = (params: ReportMLJobUpdateParams) => { this.analytics.reportEvent(TelemetryEventTypes.MLJobUpdate, params); }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts index 678fda2c170c0..b70000d7c05e2 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -35,6 +35,9 @@ import type { ReportEntityRiskFilteredParams, ReportRiskInputsExpandedFlyoutOpenedParams, ReportToggleRiskSummaryClickedParams, + ReportAssetCriticalityCsvPreviewGeneratedParams, + ReportAssetCriticalityFileSelectedParams, + ReportAssetCriticalityCsvImportedParams, } from './events/entity_analytics/types'; import type { AssistantTelemetryEvent, @@ -62,6 +65,9 @@ export type { ReportRiskInputsExpandedFlyoutOpenedParams, ReportToggleRiskSummaryClickedParams, ReportAddRiskInputToTimelineClickedParams, + ReportAssetCriticalityCsvPreviewGeneratedParams, + ReportAssetCriticalityFileSelectedParams, + ReportAssetCriticalityCsvImportedParams, } from './events/entity_analytics/types'; export * from './events/document_details/types'; @@ -129,7 +135,12 @@ export interface TelemetryClientStart { reportToggleRiskSummaryClicked(params: ReportToggleRiskSummaryClickedParams): void; reportRiskInputsExpandedFlyoutOpened(params: ReportRiskInputsExpandedFlyoutOpenedParams): void; reportAddRiskInputToTimelineClicked(params: ReportAddRiskInputToTimelineClickedParams): void; - + // Entity Analytics Asset Criticality + reportAssetCriticalityFileSelected(params: ReportAssetCriticalityFileSelectedParams): void; + reportAssetCriticalityCsvPreviewGenerated( + params: ReportAssetCriticalityCsvPreviewGeneratedParams + ): void; + reportAssetCriticalityCsvImported(params: ReportAssetCriticalityCsvImportedParams): void; reportCellActionClicked(params: ReportCellActionClickedParams): void; reportAnomaliesCountClicked(params: ReportAnomaliesCountClickedParams): void; 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 6417180b8fd70..05bc70589a4b3 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 @@ -62,6 +62,7 @@ const coreMock = { settings: { client: { get: () => {}, + get$: () => new Subject(), set: () => {}, }, }, diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index b7435609d9daa..fb14efde91a26 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -6,6 +6,7 @@ */ import { useMemo } from 'react'; +import type { AssetCriticalityCsvUploadResponse } from '../../../common/entity_analytics/asset_criticality/types'; import type { AssetCriticalityRecord } from '../../../common/api/entity_analytics/asset_criticality'; import type { RiskScoreEntity } from '../../../common/search_strategy'; import { @@ -19,6 +20,7 @@ import { ASSET_CRITICALITY_URL, RISK_SCORE_INDEX_STATUS_API_URL, RISK_ENGINE_SETTINGS_URL, + ASSET_CRITICALITY_CSV_UPLOAD_URL, } from '../../../common/constants'; import type { @@ -155,6 +157,24 @@ export const useEntityAnalyticsRoutes = () => { }); }; + const uploadAssetCriticalityFile = async ( + fileContent: string, + fileName: string + ): Promise => { + const file = new File([new Blob([fileContent])], fileName, { type: 'text/csv' }); + const body = new FormData(); + body.append('file', file); + + return http.fetch(ASSET_CRITICALITY_CSV_UPLOAD_URL, { + version: '1', + method: 'POST', + headers: { + 'Content-Type': undefined, // Lets the browser set the appropriate content type + }, + body, + }); + }; + const getRiskScoreIndexStatus = ({ query, signal, @@ -196,6 +216,7 @@ export const useEntityAnalyticsRoutes = () => { createAssetCriticality, deleteAssetCriticality, fetchAssetCriticality, + uploadAssetCriticalityFile, getRiskScoreIndexStatus, fetchRiskEngineSettings, }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.stories.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.stories.tsx new file mode 100644 index 0000000000000..17b2ada6cb28b --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.stories.tsx @@ -0,0 +1,223 @@ +/* + * 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 { Story } from '@storybook/react'; +import { addDecorator } from '@storybook/react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { euiLightVars } from '@kbn/ui-theme'; +import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { StorybookProviders } from '../../../common/mock/storybook_providers'; +import { AssetCriticalityFileUploader } from './asset_criticality_file_uploader'; +import { AssetCriticalityResultStep } from './components/result_step'; +import { AssetCriticalityValidationStep } from './components/validation_step'; +import { AssetCriticalityFilePickerStep } from './components/file_picker_step'; + +addDecorator((storyFn) => ( + ({ eui: euiLightVars, darkMode: false })}>{storyFn()} +)); + +const validLinesAsText = `user,user-001,low_impact\nuser-002,medium_impact\nuser,user-003,medium_impact\nhost,host-001,extreme_impact\nhost,host-002,extreme_impact`; +const invalidLinesAsText = `user,user-001,wow_impact\ntest,user-002,medium_impact\nuser,user-003,medium_impact,extra_column`; + +export default { + component: AssetCriticalityFileUploader, + title: 'Entity Analytics/AssetCriticalityFileUploader', +}; + +export const Default: Story = () => { + return ( + + +
+ + + +
+
+
+ ); +}; + +export const FilePickerStep: Story = () => { + return ( + + +
+ {'Loading state'} + + + + {}} isLoading={true} /> + + + + {'With Error message'} + + + + {}} + isLoading={false} + errorMessage="An error message" + /> + +
+
+
+ ); +}; + +export const ValidationStep: Story = () => { + return ( + + +
+ {'Initial state'} + + + + {}} + onReturn={() => {}} + /> + + + + {'Loading state'} + + + + {}} + onReturn={() => {}} + /> + +
+
+
+ ); +}; + +export const ResultsStep: Story = () => { + return ( + + +
+ {'Success'} + + + + {}} + validLinesAsText={validLinesAsText} + result={{ + errors: [], + stats: { + total: 10, + successful: 10, + failed: 0, + }, + }} + /> + + + + {'Partial error'} + + + + {}} + validLinesAsText={validLinesAsText} + result={{ + errors: [ + { message: 'error message 1', index: 1 }, + { message: 'error message 2', index: 3 }, + { message: 'error message 3', index: 5 }, + ], + stats: { + total: 5, + successful: 2, + failed: 3, + }, + }} + /> + + + + + {'Complete failure'} + + + + {}} + validLinesAsText="" + errorMessage="Something went wrong" + /> + +
+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.tsx new file mode 100644 index 0000000000000..ddffe01955b29 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/asset_criticality_file_uploader.tsx @@ -0,0 +1,158 @@ +/* + * 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 { EuiSpacer, EuiStepsHorizontal } from '@elastic/eui'; +import React, { useCallback, useReducer } from 'react'; +import { useKibana } from '../../../common/lib/kibana/kibana_react'; +import { AssetCriticalityFilePickerStep } from './components/file_picker_step'; +import { AssetCriticalityValidationStep } from './components/validation_step'; +import { INITIAL_STATE, reducer } from './reducer'; +import { isFilePickerStep, isResultStep, isValidationStep } from './helpers'; +import { AssetCriticalityResultStep } from './components/result_step'; +import { useEntityAnalyticsRoutes } from '../../api/api'; +import { useFileValidation, useNavigationSteps } from './hooks'; +import type { OnCompleteParams } from './types'; + +export const AssetCriticalityFileUploader: React.FC = () => { + const [state, dispatch] = useReducer(reducer, INITIAL_STATE); + const { uploadAssetCriticalityFile } = useEntityAnalyticsRoutes(); + const { telemetry } = useKibana().services; + + const onValidationComplete = useCallback( + ({ validatedFile, processingStartTime, processingEndTime, tookMs }: OnCompleteParams) => { + telemetry.reportAssetCriticalityCsvPreviewGenerated({ + file: { + size: validatedFile.size, + }, + processing: { + startTime: processingStartTime, + endTime: processingEndTime, + tookMs, + }, + stats: { + validLines: validatedFile.validLines.count, + invalidLines: validatedFile.invalidLines.count, + totalLines: validatedFile.validLines.count + validatedFile.invalidLines.count, + }, + }); + + dispatch({ + type: 'fileValidated', + payload: { + validatedFile: { + name: validatedFile.name, + size: validatedFile.size, + validLines: { + text: validatedFile.validLines.text, + count: validatedFile.validLines.count, + }, + invalidLines: { + text: validatedFile.invalidLines.text, + count: validatedFile.invalidLines.count, + errors: validatedFile.invalidLines.errors, + }, + }, + }, + }); + }, + [telemetry] + ); + const onValidationError = useCallback((message) => { + dispatch({ type: 'fileError', payload: { message } }); + }, []); + + const validateFile = useFileValidation({ + onError: onValidationError, + onComplete: onValidationComplete, + }); + + const goToFirstStep = useCallback(() => { + dispatch({ type: 'resetState' }); + }, []); + + const onFileChange = useCallback( + (fileList: FileList | null) => { + const file = fileList?.item(0); + + if (!file) { + // file removed + goToFirstStep(); + return; + } + + dispatch({ + type: 'loadingFile', + payload: { fileName: file.name }, + }); + + validateFile(file); + }, + [validateFile, goToFirstStep] + ); + + const onUploadFile = useCallback(async () => { + if (isValidationStep(state)) { + dispatch({ + type: 'uploadingFile', + }); + + try { + const result = await uploadAssetCriticalityFile( + state.validatedFile.validLines.text, + state.validatedFile.name + ); + + dispatch({ + type: 'fileUploaded', + payload: { response: result }, + }); + } catch (e) { + dispatch({ + type: 'fileUploaded', + payload: { errorMessage: e.message }, + }); + } + } + }, [state, uploadAssetCriticalityFile]); + + const steps = useNavigationSteps(state, goToFirstStep); + + return ( +
+ + + +
+ {isFilePickerStep(state) && ( + + )} + + {isValidationStep(state) && ( + + )} + + {isResultStep(state) && ( + + )} +
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.test.tsx new file mode 100644 index 0000000000000..bab3c7ca07ab2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 { render, fireEvent } from '@testing-library/react'; +import { AssetCriticalityFilePickerStep } from './file_picker_step'; +import { TestProviders } from '../../../../common/mock'; + +describe('AssetCriticalityFilePickerStep', () => { + const mockOnFileChange = jest.fn(); + const mockErrorMessage = 'Sample error message'; + const mockIsLoading = false; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render without errors', () => { + const { queryByTestId } = render( + , + { wrapper: TestProviders } + ); + + expect(queryByTestId('asset-criticality-file-picker')).toBeInTheDocument(); + }); + + it('should call onFileChange when file is selected', () => { + const { getByTestId } = render( + , + { wrapper: TestProviders } + ); + + const file = new File(['sample file content'], 'sample.csv', { type: 'text/csv' }); + fireEvent.change(getByTestId('asset-criticality-file-picker'), { target: { files: [file] } }); + + expect(mockOnFileChange).toHaveBeenCalledWith([file]); + }); + + it('should display error message when errorMessage prop is provided', () => { + const { getByText } = render( + , + { wrapper: TestProviders } + ); + + expect(getByText(mockErrorMessage)).toBeInTheDocument(); + }); + + it('should display loading indicator when isLoading prop is true', () => { + const { container } = render( + , + { wrapper: TestProviders } + ); + + expect(container.querySelector('.euiProgress')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.tsx new file mode 100644 index 0000000000000..094b43595aa3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/file_picker_step.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCodeBlock, + EuiFilePicker, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { euiThemeVars } from '@kbn/ui-theme'; +import { + CRITICALITY_CSV_MAX_SIZE_BYTES, + ValidCriticalityLevels, +} from '../../../../../common/entity_analytics/asset_criticality'; +import { useFormatBytes } from '../../../../common/components/formatted_bytes'; +import { SUPPORTED_FILE_EXTENSIONS, SUPPORTED_FILE_TYPES } from '../constants'; + +interface AssetCriticalityFilePickerStepProps { + onFileChange: (fileList: FileList | null) => void; + isLoading: boolean; + errorMessage?: string; +} + +const sampleCSVContent = `user,user-001,low_impact\nuser,user-002,medium_impact\nhost,host-001,extreme_impact`; + +const listStyle = css` + list-style-type: disc; + margin-bottom: ${euiThemeVars.euiSizeL}; + margin-left: ${euiThemeVars.euiSizeL}; + line-height: ${euiThemeVars.euiLineHeight}; +`; + +export const AssetCriticalityFilePickerStep: React.FC = + React.memo(({ onFileChange, errorMessage, isLoading }) => { + const formatBytes = useFormatBytes(); + const { euiTheme } = useEuiTheme(); + return ( + <> + + + +

+ +

+
+ + +
    +
  • + +
  • +
  • + +
  • +
+ + +

+ +

+
+ + +
    +
  • + {'host'}, + user: {'user'}, + }} + /> +
  • +
  • + { + {'Host.name'}, + userName: {'User.name'}, + }} + /> + } +
  • +
  • + {ValidCriticalityLevels.join(', ')}, + }} + /> +
  • +
+ + +

+ +

+
+ + + {sampleCSVContent} + +
+ + + +
+ {errorMessage && ( + + {errorMessage} + + )} + + ); + }); + +AssetCriticalityFilePickerStep.displayName = 'AssetCriticalityFilePickerStep'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.test.tsx new file mode 100644 index 0000000000000..46da41ab41d14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { render } from '@testing-library/react'; +import { AssetCriticalityResultStep } from './result_step'; +import { TestProviders } from '../../../../common/mock'; + +describe('AssetCriticalityResultStep', () => { + const mockValidLinesAsText = 'valid lines as text'; + + it('renders successful result', () => { + const { getByTestId } = render( + , + { wrapper: TestProviders } + ); + + expect(getByTestId('asset-criticality-result-step-success')).toBeInTheDocument(); + }); + + it('renders unsuccessful result', () => { + const { getByTestId } = render( + , + { wrapper: TestProviders } + ); + + expect(getByTestId('asset-criticality-result-step-error')).toBeInTheDocument(); + }); + + it('renders partial successful result', () => { + const { getByTestId } = render( + , + { wrapper: TestProviders } + ); + + expect(getByTestId('asset-criticality-result-step-warning')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx new file mode 100644 index 0000000000000..c620e020cd5d1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx @@ -0,0 +1,142 @@ +/* + * 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 { + EuiButtonEmpty, + EuiCallOut, + EuiCodeBlock, + EuiFlexGroup, + EuiHorizontalRule, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import type { AssetCriticalityCsvUploadResponse } from '../../../../../common/entity_analytics/asset_criticality/types'; +import { buildAnnotationsFromError } from '../helpers'; + +export const AssetCriticalityResultStep: React.FC<{ + result?: AssetCriticalityCsvUploadResponse; + validLinesAsText: string; + errorMessage?: string; + onReturn: () => void; +}> = React.memo(({ result, validLinesAsText, errorMessage, onReturn }) => { + const { euiTheme } = useEuiTheme(); + + if (errorMessage !== undefined) { + return ( + <> + + } + color="danger" + iconType="error" + > + {errorMessage} + + + + ); + } + + if (result === undefined) { + return null; + } + + if (result.stats.failed === 0) { + return ( + <> + + + + + + ); + } + + const annotations = buildAnnotationsFromError(result.errors); + + return ( + <> + + } + color="warning" + iconType="warning" + > + +

+ +

+

+ +

+ + + {validLinesAsText} + +
+ + + ); +}); + +AssetCriticalityResultStep.displayName = 'AssetCriticalityResultStep'; + +const ResultStepFooter = ({ onReturn }: { onReturn: () => void }) => ( + <> + + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.test.tsx new file mode 100644 index 0000000000000..c20a0b6e0aaea --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 { render, fireEvent } from '@testing-library/react'; +import { + AssetCriticalityValidationStep, + type AssetCriticalityValidationStepProps, +} from './validation_step'; +import { TestProviders } from '../../../../common/mock'; + +import { downloadBlob } from '../../../../common/utils/download_blob'; + +jest.mock('../../../../common/utils/download_blob'); + +jest.mock('../../../../common/lib/kibana/kibana_react', () => ({ + useKibana: () => ({ + services: { + telemetry: { + reportAssetCriticalityCsvImported: jest.fn(), + }, + }, + }), +})); + +describe('AssetCriticalityValidationStep', () => { + const mockOnConfirm = jest.fn(); + const mockOnReturn = jest.fn(); + + const defaultProps: AssetCriticalityValidationStepProps = { + validatedFile: { + name: 'test.csv', + size: 100, + validLines: { + text: 'Valid lines as text', + count: 10, + }, + invalidLines: { + text: 'Invalid lines as text', + count: 5, + errors: [], + }, + }, + onConfirm: mockOnConfirm, + onReturn: mockOnReturn, + isLoading: false, + }; + + it('renders the component with correct counts and file name', () => { + const { container } = render(, { + wrapper: TestProviders, + }); + + expect(container).toHaveTextContent('10 asset criticalities will be assigned'); + expect(container).toHaveTextContent("5 lines are invalid and won't be assigned"); + expect(container).toHaveTextContent('test.csv preview'); + }); + + it('calls onConfirm when assign button is clicked', () => { + const { getByText } = render(, { + wrapper: TestProviders, + }); + const confirmButton = getByText('Assign'); + fireEvent.click(confirmButton); + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + it('calls onReturn when "back" button is clicked', () => { + const { getByText } = render(, { + wrapper: TestProviders, + }); + const returnButton = getByText('Back'); + fireEvent.click(returnButton); + expect(mockOnReturn).toHaveBeenCalled(); + }); + + it('calls onReturn when "choose another file" button is clicked', () => { + const { getByText } = render(, { + wrapper: TestProviders, + }); + const returnButton = getByText('Choose another file'); + fireEvent.click(returnButton); + expect(mockOnReturn).toHaveBeenCalled(); + }); + + it('downloads the invalid lines as text when Download CSV is clicked', () => { + const { getByText } = render(, { + wrapper: TestProviders, + }); + const downloadButton = getByText('Download CSV'); + fireEvent.click(downloadButton); + expect(downloadBlob).toHaveBeenCalledWith( + new Blob(['Invalid lines as text']), + 'invalid_asset_criticality.csv' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.tsx new file mode 100644 index 0000000000000..0c8150d85a369 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/validation_step.tsx @@ -0,0 +1,203 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { downloadBlob } from '../../../../common/utils/download_blob'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import type { ValidatedFile } from '../types'; +import { buildAnnotationsFromError } from '../helpers'; + +export interface AssetCriticalityValidationStepProps { + validatedFile: ValidatedFile; + isLoading: boolean; + onConfirm: () => void; + onReturn: () => void; +} + +const CODE_BLOCK_HEIGHT = 250; +const INVALID_FILE_NAME = `invalid_asset_criticality.csv`; + +export const AssetCriticalityValidationStep: React.FC = + React.memo(({ validatedFile, isLoading, onConfirm, onReturn }) => { + const { validLines, invalidLines, size: fileSize, name: fileName } = validatedFile; + const { euiTheme } = useEuiTheme(); + const { telemetry } = useKibana().services; + const annotations = buildAnnotationsFromError(invalidLines.errors); + + const onConfirmClick = () => { + telemetry.reportAssetCriticalityCsvImported({ + file: { + size: fileSize, + }, + }); + onConfirm(); + }; + + return ( + <> + + + + + {validLines.count > 0 && ( + <> + + + + + + + + + {validLines.count}, + }} + /> + + + + + + + + + + + + + + {validLines.text} + + + + )} + + {invalidLines.count > 0 && ( + <> + + + + + + + + + {invalidLines.count}, + }} + /> + + + + + + + {invalidLines.text && ( + { + if (invalidLines.text.length > 0) { + downloadBlob(new Blob([invalidLines.text]), INVALID_FILE_NAME); + } + }} + > + + + )} + + + + + + {invalidLines.text} + + + + )} + + + + + + + + + + + + + + + + + + + ); + }); + +AssetCriticalityValidationStep.displayName = 'AssetCriticalityValidationStep'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/constants.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/constants.ts new file mode 100644 index 0000000000000..c64128274aa3d --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/constants.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export const SUPPORTED_FILE_TYPES = ['text/csv', 'text/plain', 'text/tab-separated-values']; +export const SUPPORTED_FILE_EXTENSIONS = ['CSV', 'TXT', 'TSV']; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.test.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.test.ts new file mode 100644 index 0000000000000..6e93d36dba3f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildAnnotationsFromError, getStepStatus } from './helpers'; +import { FileUploaderSteps } from './types'; + +describe('helpers', () => { + describe('getStepStatus', () => { + it('should return "disabled" for other steps when currentStep is 3', () => { + const step = FileUploaderSteps.VALIDATION; + const currentStep = FileUploaderSteps.RESULT; + const status = getStepStatus(step, currentStep); + expect(status).toBe('disabled'); + }); + + it('should return "current" if step is equal to currentStep', () => { + const step = FileUploaderSteps.RESULT; + const currentStep = FileUploaderSteps.RESULT; + const status = getStepStatus(step, currentStep); + expect(status).toBe('current'); + }); + + it('should return "complete" if step is less than currentStep', () => { + const step = FileUploaderSteps.FILE_PICKER; + const currentStep = FileUploaderSteps.VALIDATION; + const status = getStepStatus(step, currentStep); + expect(status).toBe('complete'); + }); + }); + + describe('buildAnnotationsFromError', () => { + it('should return annotations from errors', () => { + const errors = [ + { index: 0, message: 'error 1' }, + { index: 1, message: 'error 2' }, + ]; + const annotations = { + 0: 'error 1', + 1: 'error 2', + }; + + expect(buildAnnotationsFromError(errors)).toEqual(annotations); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.ts new file mode 100644 index 0000000000000..adcdc478e0b5e --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/helpers.ts @@ -0,0 +1,51 @@ +/* + * 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 { + FilePickerState, + ValidationStepState, + ResultStepState, + ReducerState, +} from './reducer'; +import { FileUploaderSteps } from './types'; + +export const getStepStatus = (step: FileUploaderSteps, currentStep: FileUploaderSteps) => { + if (step < FileUploaderSteps.RESULT && currentStep === FileUploaderSteps.RESULT) { + return 'disabled'; + } + + if (currentStep === step) { + return 'current'; + } + + if (currentStep > step) { + return 'complete'; + } + + return 'disabled'; +}; + +export const isFilePickerStep = (state: ReducerState): state is FilePickerState => + state.step === FileUploaderSteps.FILE_PICKER; + +export const isValidationStep = (state: ReducerState): state is ValidationStepState => + state.step === FileUploaderSteps.VALIDATION; + +export const isResultStep = (state: ReducerState): state is ResultStepState => + state.step === FileUploaderSteps.RESULT; + +export const buildAnnotationsFromError = ( + errors: Array<{ message: string; index: number }> +): Record => { + const annotations: Record = {}; + + errors.forEach((e) => { + annotations[e.index] = e.message; + }); + + return annotations; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.test.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.test.ts new file mode 100644 index 0000000000000..675535365a0b0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.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 { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock'; +import { TestProviders } from '@kbn/timelines-plugin/public/mock'; +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useFileValidation } from './hooks'; +import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__'; + +const mockedUseKibana = mockUseKibana(); +const mockedTelemetry = createTelemetryServiceMock(); + +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + telemetry: mockedTelemetry, + }, + }), + }; +}); + +describe('useFileValidation', () => { + const validLine = 'user,user-001,low_impact'; + const invalidLine = 'user,user-001,low_impact,extra_field'; + + test('should call onError when an error occurs', () => { + const onErrorMock = jest.fn(); + const onCompleteMock = jest.fn(); + + const { result } = renderHook( + () => useFileValidation({ onError: onErrorMock, onComplete: onCompleteMock }), + { wrapper: TestProviders } + ); + result.current(new File([invalidLine], 'test.csv')); + + expect(onErrorMock).toHaveBeenCalled(); + expect(onCompleteMock).not.toHaveBeenCalled(); + }); + + test('should call onComplete when file validation is complete', async () => { + const onErrorMock = jest.fn(); + const onCompleteMock = jest.fn(); + const fileName = 'test.csv'; + + const { result } = renderHook( + () => useFileValidation({ onError: onErrorMock, onComplete: onCompleteMock }), + { wrapper: TestProviders } + ); + result.current(new File([`${validLine}\n${invalidLine}`], fileName, { type: 'text/csv' })); + + await waitFor(() => { + expect(onErrorMock).not.toHaveBeenCalled(); + expect(onCompleteMock).toHaveBeenCalledWith( + expect.objectContaining({ + validatedFile: { + name: fileName, + size: 61, + validLines: { + text: validLine, + count: 1, + }, + invalidLines: { + text: invalidLine, + count: 1, + errors: [ + { + message: 'Expected 3 columns, got 4', + index: 1, + }, + ], + }, + }, + processingEndTime: expect.any(String), + processingStartTime: expect.any(String), + tookMs: expect.any(Number), + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.ts new file mode 100644 index 0000000000000..107ba6348ac70 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/hooks.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { ParseConfig } from 'papaparse'; +import { unparse, parse } from 'papaparse'; +import { useCallback, useMemo } from 'react'; +import type { EuiStepHorizontalProps } from '@elastic/eui/src/components/steps/step_horizontal'; +import { noop } from 'lodash/fp'; +import { useFormatBytes } from '../../../common/components/formatted_bytes'; +import { validateParsedContent, validateFile } from './validations'; +import { useKibana } from '../../../common/lib/kibana'; +import type { OnCompleteParams } from './types'; +import type { ReducerState } from './reducer'; +import { getStepStatus, isValidationStep } from './helpers'; + +interface UseFileChangeCbParams { + onError: (errorMessage: string, file: File) => void; + onComplete: (param: OnCompleteParams) => void; +} + +export const useFileValidation = ({ onError, onComplete }: UseFileChangeCbParams) => { + const formatBytes = useFormatBytes(); + const { telemetry } = useKibana().services; + + const onErrorWrapper = useCallback( + ( + error: { + message: string; + code?: string; + }, + file: File + ) => { + telemetry.reportAssetCriticalityFileSelected({ + valid: false, + errorCode: error.code, + file: { + size: file.size, + }, + }); + onError(error.message, file); + }, + [onError, telemetry] + ); + + return useCallback( + (file: File) => { + const processingStartTime = Date.now(); + const fileValidation = validateFile(file, formatBytes); + if (!fileValidation.valid) { + onErrorWrapper( + { + message: fileValidation.errorMessage, + code: fileValidation.code, + }, + file + ); + return; + } + + telemetry.reportAssetCriticalityFileSelected({ + valid: true, + file: { + size: file.size, + }, + }); + + const parserConfig: ParseConfig = { + dynamicTyping: true, + skipEmptyLines: true, + complete(parsedFile, returnedFile) { + if (parsedFile.data.length === 0) { + onErrorWrapper( + { + message: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityFileUploader.emptyFileError', + { defaultMessage: 'The file is empty' } + ), + }, + file + ); + return; + } + + const { invalid, valid, errors } = validateParsedContent(parsedFile.data); + const validLinesAsText = unparse(valid); + const invalidLinesAsText = unparse(invalid); + const processingEndTime = Date.now(); + const tookMs = processingEndTime - processingStartTime; + onComplete({ + processingStartTime: new Date(processingStartTime).toISOString(), + processingEndTime: new Date(processingEndTime).toISOString(), + tookMs, + validatedFile: { + name: returnedFile?.name ?? '', + size: returnedFile?.size ?? 0, + validLines: { + text: validLinesAsText, + count: valid.length, + }, + invalidLines: { + text: invalidLinesAsText, + count: invalid.length, + errors, + }, + }, + }); + }, + error(parserError) { + onErrorWrapper({ message: parserError.message }, file); + }, + }; + + parse(file, parserConfig); + }, + [formatBytes, telemetry, onErrorWrapper, onComplete] + ); +}; + +export const useNavigationSteps = ( + state: ReducerState, + goToFirstStep: () => void +): Array> => { + return useMemo( + () => [ + { + title: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.selectFileStepTitle', + { + defaultMessage: 'Select a file', + } + ), + status: getStepStatus(1, state.step), + onClick: () => { + if (isValidationStep(state)) { + goToFirstStep(); // User can only go back to the first step from the second step + } + }, + }, + { + title: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.fileValidationStepTitle', + { + defaultMessage: 'File validation', + } + ), + status: getStepStatus(2, state.step), + onClick: noop, // Prevents the user from navigating by clicking on the step + }, + { + title: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.resultsStepTitle', + { + defaultMessage: 'Results', + } + ), + status: getStepStatus(3, state.step), + onClick: noop, // Prevents the user from navigating by clicking on the step + }, + ], + [goToFirstStep, state] + ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/index.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/index.ts new file mode 100644 index 0000000000000..4bf76e26239c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './asset_criticality_file_uploader'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.test.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.test.ts new file mode 100644 index 0000000000000..68f7814d3113a --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { AssetCriticalityCsvUploadResponse } from '../../../../common/api/entity_analytics'; +import type { ReducerAction, ReducerState, ValidationStepState } from './reducer'; +import { reducer } from './reducer'; +import { FileUploaderSteps } from './types'; + +describe('reducer', () => { + const initialState: ReducerState = { + isLoading: false, + step: FileUploaderSteps.FILE_PICKER, + }; + + const validatedFile = { + name: 'test.csv', + size: 100, + validLines: { + text: 'valid lines', + count: 10, + }, + invalidLines: { + text: 'invalid lines', + count: 0, + errors: [], + }, + }; + + it('should handle "uploadingFile" action', () => { + const action: ReducerAction = { type: 'uploadingFile' }; + const state: ValidationStepState = { + validatedFile, + isLoading: false, + step: FileUploaderSteps.VALIDATION, + }; + const nextState = reducer(state, action) as ValidationStepState; + + expect(nextState.isLoading).toBe(true); + }); + + it('should handle "fileUploaded" action with response', () => { + const response: AssetCriticalityCsvUploadResponse = { + errors: [], + stats: { + total: 10, + successful: 10, + failed: 0, + }, + }; + const state: ValidationStepState = { + validatedFile, + isLoading: true, + step: FileUploaderSteps.VALIDATION, + }; + + const action: ReducerAction = { type: 'fileUploaded', payload: { response } }; + const nextState = reducer(state, action); + + expect(nextState).toEqual({ + step: FileUploaderSteps.RESULT, + fileUploadResponse: response, + fileUploadError: undefined, + validLinesAsText: validatedFile.validLines.text, + }); + }); + + it('should handle "fileUploaded" action with errorMessage', () => { + const errorMessage = 'File upload failed'; + const state: ValidationStepState = { + validatedFile, + isLoading: true, + step: FileUploaderSteps.VALIDATION, + }; + + const action: ReducerAction = { type: 'fileUploaded', payload: { errorMessage } }; + const nextState = reducer(state, action); + + expect(nextState).toEqual({ + step: FileUploaderSteps.RESULT, + fileUploadResponse: undefined, + fileUploadError: errorMessage, + validLinesAsText: validatedFile.validLines.text, + }); + }); + + it('should handle "loadingFile" action', () => { + const fileName = 'file.csv'; + const action: ReducerAction = { type: 'loadingFile', payload: { fileName } }; + const nextState = reducer(initialState, action); + + expect(nextState).toEqual({ + isLoading: true, + step: FileUploaderSteps.FILE_PICKER, + fileName, + }); + }); + + it('should handle "fileValidated" action', () => { + const action: ReducerAction = { + type: 'fileValidated', + payload: { validatedFile }, + }; + const nextState = reducer({ ...initialState, isLoading: true }, action); + + expect(nextState).toEqual({ + isLoading: false, + step: FileUploaderSteps.VALIDATION, + validatedFile, + }); + }); + + it('should handle "fileError" action', () => { + const message = 'File error'; + const action: ReducerAction = { type: 'fileError', payload: { message } }; + const nextState = reducer( + { + step: 9999, + isLoading: true, + validatedFile: { + name: '', + size: 0, + validLines: { text: '', count: 0 }, + invalidLines: { text: '', count: 0, errors: [] }, + }, + }, + action + ); + + expect(nextState).toEqual({ + isLoading: false, + step: FileUploaderSteps.FILE_PICKER, + fileError: message, + }); + }); + + it('should handle "resetState" action', () => { + const action: ReducerAction = { type: 'resetState' }; + const nextState = reducer({ step: 9999, isLoading: true, validatedFile }, action); + + expect(nextState).toEqual(initialState); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.ts new file mode 100644 index 0000000000000..0868caf2d624b --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/reducer.ts @@ -0,0 +1,107 @@ +/* + * 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 { AssetCriticalityCsvUploadResponse } from '../../../../common/entity_analytics/asset_criticality/types'; +import { FileUploaderSteps } from './types'; +import type { ValidatedFile } from './types'; +import { isFilePickerStep, isValidationStep } from './helpers'; + +export interface FilePickerState { + isLoading: boolean; + step: FileUploaderSteps.FILE_PICKER; + fileError?: string; + fileName?: string; +} + +export interface ValidationStepState { + isLoading: boolean; + step: FileUploaderSteps.VALIDATION; + fileError?: string; + validatedFile: ValidatedFile; +} + +export interface ResultStepState { + step: FileUploaderSteps.RESULT; + fileUploadResponse?: AssetCriticalityCsvUploadResponse; + fileUploadError?: string; + validLinesAsText: string; +} + +export type ReducerState = FilePickerState | ValidationStepState | ResultStepState; + +export type ReducerAction = + | { type: 'loadingFile'; payload: { fileName: string } } + | { type: 'resetState' } + | { + type: 'fileValidated'; + payload: { + validatedFile: ValidatedFile; + }; + } + | { type: 'fileError'; payload: { message: string } } + | { type: 'uploadingFile' } + | { + type: 'fileUploaded'; + payload: { response?: AssetCriticalityCsvUploadResponse; errorMessage?: string }; + }; + +export const INITIAL_STATE: FilePickerState = { + isLoading: false, + step: FileUploaderSteps.FILE_PICKER, +}; + +export const reducer = (state: ReducerState, action: ReducerAction): ReducerState => { + switch (action.type) { + case 'resetState': + return INITIAL_STATE; + + case 'loadingFile': + if (isFilePickerStep(state)) { + return { + ...state, + isLoading: true, + fileName: action.payload.fileName, + }; + } + break; + + case 'fileError': + return { + isLoading: false, + step: FileUploaderSteps.FILE_PICKER, + fileError: action.payload.message, + }; + + case 'fileValidated': + return { + isLoading: false, + step: FileUploaderSteps.VALIDATION, + ...action.payload, + }; + + case 'uploadingFile': + if (isValidationStep(state)) { + return { + ...state, + isLoading: true, + }; + } + break; + + case 'fileUploaded': + if (isValidationStep(state)) { + return { + fileUploadResponse: action.payload.response, + fileUploadError: action.payload.errorMessage, + validLinesAsText: state.validatedFile.validLines.text, + step: FileUploaderSteps.RESULT, + }; + } + break; + } + return state; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/types.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/types.ts new file mode 100644 index 0000000000000..c89c62ad10d68 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/types.ts @@ -0,0 +1,35 @@ +/* + * 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 { RowValidationErrors } from './validations'; + +export interface ValidatedFile { + name: string; + size: number; + validLines: { + text: string; + count: number; + }; + invalidLines: { + text: string; + count: number; + errors: RowValidationErrors[]; + }; +} + +export interface OnCompleteParams { + processingStartTime: string; + processingEndTime: string; + tookMs: number; + validatedFile: ValidatedFile; +} + +export enum FileUploaderSteps { + FILE_PICKER = 1, + VALIDATION = 2, + RESULT = 3, +} diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.test.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.test.ts new file mode 100644 index 0000000000000..4e742d4d92505 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { validateParsedContent, validateFile } from './validations'; + +const formatBytes = (bytes: number) => bytes.toString(); + +describe('validateParsedContent', () => { + it('should return empty arrays when data is empty', () => { + const result = validateParsedContent([]); + + expect(result).toEqual({ + valid: [], + invalid: [], + errors: [], + }); + }); + + it('should return valid and invalid data based on row validation', () => { + const data = [ + ['host', 'host-2', 'invalid_criticality'], // invalid + ['user', 'user-1', 'low_criticality', 'invalid column'], // invalid + ['host', 'host-1', 'low_impact'], // valid + ]; + + const result = validateParsedContent(data); + + expect(result).toEqual({ + valid: [data[2]], + invalid: [data[0], data[1]], + errors: [ + { + message: + 'Invalid criticality level "invalid_criticality", expected one of extreme_impact, high_impact, medium_impact, low_impact', + index: 1, + }, + { + message: 'Expected 3 columns, got 4', + index: 2, + }, + ], + }); + }); +}); + +describe('validateFile', () => { + it('should return valid if the file is valid', () => { + const file = new File(['file content'], 'test.csv', { type: 'text/csv' }); + + const result = validateFile(file, formatBytes); + + expect(result.valid).toBe(true); + }); + + it('should return an error message if the file type is invalid', () => { + const file = new File(['file content'], 'test.txt', { type: 'invalid-type' }); + + const result = validateFile(file, formatBytes) as { + valid: false; + errorMessage: string; + }; + + expect(result.valid).toBe(false); + expect(result.errorMessage).toBe( + 'Invalid file format selected. Please choose a CSV, TXT, TSV file and try again' + ); + }); + + it('should return an error message if the file size is 0', () => { + const file = new File([], 'test.txt', { type: 'text/csv' }); + + const result = validateFile(file, formatBytes) as { + valid: false; + errorMessage: string; + }; + + expect(result.valid).toBe(false); + expect(result.errorMessage).toBe('The selected file is empty.'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.ts new file mode 100644 index 0000000000000..06018a2496768 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/validations.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + CRITICALITY_CSV_MAX_SIZE_BYTES, + CRITICALITY_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE, + parseAssetCriticalityCsvRow, +} from '../../../../common/entity_analytics/asset_criticality'; +import { SUPPORTED_FILE_EXTENSIONS, SUPPORTED_FILE_TYPES } from './constants'; + +export interface RowValidationErrors { + message: string; + index: number; +} + +export const validateParsedContent = ( + data: string[][] +): { valid: string[][]; invalid: string[][]; errors: RowValidationErrors[] } => { + if (data.length === 0) { + return { valid: [], invalid: [], errors: [] }; + } + + let errorIndex = 1; // Error index starts from 1 because EuiCodeBlock line numbers start from 1 + const { valid, invalid, errors } = data.reduce<{ + valid: string[][]; + invalid: string[][]; + errors: RowValidationErrors[]; + }>( + (acc, row) => { + const parsedRow = parseAssetCriticalityCsvRow(row); + if (parsedRow.valid) { + acc.valid.push(row); + } else { + acc.invalid.push(row); + acc.errors.push({ message: parsedRow.error, index: errorIndex }); + errorIndex++; + } + + return acc; + }, + { valid: [], invalid: [], errors: [] } + ); + + return { valid, invalid, errors }; +}; + +export const validateFile = ( + file: File, + formatBytes: (bytes: number) => string +): { valid: false; errorMessage: string; code: string } | { valid: true } => { + if (!SUPPORTED_FILE_TYPES.includes(file.type)) { + return { + valid: false, + code: 'unsupported_file_type', + errorMessage: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unsupportedFileTypeError', + { + defaultMessage: `Invalid file format selected. Please choose a {supportedFileExtensions} file and try again`, + values: { supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS.join(', ') }, + } + ), + }; + } + + if (file.size === 0) { + return { + valid: false, + code: 'empty_file', + errorMessage: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.emptyFileErrorMessage', + { + defaultMessage: `The selected file is empty.`, + } + ), + }; + } + + if (file.size > CRITICALITY_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE) { + return { + valid: false, + code: 'file_size_exceeds_limit', + errorMessage: i18n.translate( + 'xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.fileSizeExceedsLimitErrorMessage', + { + defaultMessage: 'File size {fileSize} exceeds maximum file size of {maxFileSize}', + values: { + fileSize: formatBytes(file.size), + maxFileSize: formatBytes(CRITICALITY_CSV_MAX_SIZE_BYTES), + }, + } + ), + }; + } + return { valid: true }; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx new file mode 100644 index 0000000000000..dbbd6f6b09cd3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx @@ -0,0 +1,110 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiLink, + EuiPageHeader, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { AssetCriticalityFileUploader } from '../components/asset_criticality_file_uploader/asset_criticality_file_uploader'; +import { useKibana } from '../../common/lib/kibana'; + +export const AssetCriticalityUploadPage = () => { + const { docLinks } = useKibana().services; + const entityAnalyticsLinks = docLinks.links.securitySolution.entityAnalytics; + return ( + <> + + } + /> + + + + + +

+ +

+
+ + + + + + +
+ + + + + + +

+ +

+
+ + + + + + +

+ +

+
+ + + + + +
+
+
+ + ); +}; + +AssetCriticalityUploadPage.displayName = 'AssetCriticalityUploadPage'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx b/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx index 284cec4bf1aee..048b37915e0f4 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx @@ -13,11 +13,16 @@ import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { SpyRoute } from '../common/utils/route/spy_routes'; import { NotFoundPage } from '../app/404'; -import { ENTITY_ANALYTICS_MANAGEMENT_PATH, SecurityPageName } from '../../common/constants'; +import { + ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH, + ENTITY_ANALYTICS_MANAGEMENT_PATH, + SecurityPageName, +} from '../../common/constants'; import { EntityAnalyticsManagementPage } from './pages/entity_analytics_management_page'; import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; +import { AssetCriticalityUploadPage } from './pages/asset_criticality_upload_page'; -const EntityAnalyticsTelemetry = () => ( +const EntityAnalyticsManagementTelemetry = () => ( @@ -26,22 +31,52 @@ const EntityAnalyticsTelemetry = () => ( ); -const EntityAnalyticsContainer: React.FC = () => { +const EntityAnalyticsManagementContainer: React.FC = React.memo(() => { return ( - + ); -}; +}); +EntityAnalyticsManagementContainer.displayName = 'EntityAnalyticsManagementContainer'; -const EntityAnalytics = React.memo(EntityAnalyticsContainer); +const EntityAnalyticsAssetClassificationTelemetry = () => ( + + + + + + +); + +const EntityAnalyticsAssetClassificationContainer: React.FC = React.memo(() => { + return ( + + + + + ); +}); -const renderEntityAnalyticsRoutes = () => ; +EntityAnalyticsAssetClassificationContainer.displayName = + 'EntityAnalyticsAssetClassificationContainer'; export const routes = [ { path: ENTITY_ANALYTICS_MANAGEMENT_PATH, - render: renderEntityAnalyticsRoutes, + component: EntityAnalyticsManagementContainer, + }, + { + path: ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH, + component: EntityAnalyticsAssetClassificationContainer, }, ]; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 919291c482996..6bf7e06c6beed 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -16,6 +16,7 @@ import { import { BLOCKLIST_PATH, ENDPOINTS_PATH, + ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH, ENTITY_ANALYTICS_MANAGEMENT_PATH, EVENT_FILTERS_PATH, HOST_ISOLATION_EXCEPTIONS_PATH, @@ -36,6 +37,7 @@ import { RESPONSE_ACTIONS_HISTORY, TRUSTED_APPLICATIONS, ENTITY_ANALYTICS_RISK_SCORE, + ASSET_CRITICALITY, } from '../app/translations'; import { licenseService } from '../common/hooks/use_license'; import type { LinkItem } from '../common/links/types'; @@ -50,13 +52,17 @@ import { IconSavedObject } from '../common/icons/saved_object'; import { IconDashboards } from '../common/icons/dashboards'; import { IconEntityAnalytics } from '../common/icons/entity_analytics'; import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { IconAssetCriticality } from '../common/icons/asset_criticality'; const categories = [ { label: i18n.translate('xpack.securitySolution.appLinks.category.entityAnalytics', { defaultMessage: 'Entity Analytics', }), - linkIds: [SecurityPageName.entityAnalyticsManagement], + linkIds: [ + SecurityPageName.entityAnalyticsManagement, + SecurityPageName.entityAnalyticsAssetClassification, + ], }, { label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { @@ -180,6 +186,22 @@ export const links: LinkItem = { experimentalKey: 'riskScoringRoutesEnabled', licenseType: 'platinum', }, + { + id: SecurityPageName.entityAnalyticsAssetClassification, + title: ASSET_CRITICALITY, + description: i18n.translate( + 'xpack.securitySolution.appLinks.assetClassificationDescription', + { + defaultMessage: 'Represents the criticality of an asset to your business infrastructure.', + } + ), + landingIcon: IconAssetCriticality, + path: ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH, + skipUrlState: true, + hideTimeline: true, + capabilities: [`${SERVER_APP_ID}.entity-analytics`], + licenseType: 'platinum', + }, { id: SecurityPageName.responseActionsHistory, title: RESPONSE_ACTIONS_HISTORY, diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx index 30d4d93820905..34e9d5a6e3415 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx @@ -84,7 +84,6 @@ export const NodeList = memo(({ id }: { id: string }) => { const processTableView: ProcessTableView[] = useSelector( useCallback( (state: State) => { - // console.log('lol WAT'); const { processNodePositions } = selectors.layout(state.analyzer[id]); const view: ProcessTableView[] = []; for (const treeNode of processNodePositions.keys()) { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts new file mode 100644 index 0000000000000..9a31f3f06edad --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + FILE_PICKER, + INVALID_LINES_MESSAGE, + PAGE_TITLE, + RESULT_STEP, + VALID_LINES_MESSAGE, +} from '../../screens/asset_criticality'; +import { enableAssetCriticality } from '../../tasks/api_calls/kibana_advanced_settings'; +import { clickAssignButton, uploadAssetCriticalityFile } from '../../tasks/asset_criticality'; +import { login } from '../../tasks/login'; +import { visit } from '../../tasks/navigation'; +import { ENTITY_ANALYTICS_ASSET_CRITICALITY_URL } from '../../urls/navigation'; + +describe( + 'Asset Criticality Upload page', + { + tags: ['@ess'], + }, + () => { + beforeEach(() => { + login(); + enableAssetCriticality(); + visit(ENTITY_ANALYTICS_ASSET_CRITICALITY_URL); + }); + + it('renders page as expected', () => { + cy.get(PAGE_TITLE).should('have.text', 'Asset criticality'); + }); + + it('uploads a file', () => { + uploadAssetCriticalityFile(); + + cy.get(FILE_PICKER).should('not.visible'); + cy.get(VALID_LINES_MESSAGE).should('have.text', '4 asset criticalities will be assigned'); + cy.get(INVALID_LINES_MESSAGE).should('have.text', "1 line is invalid and won't be assigned"); + + clickAssignButton(); + + cy.get(RESULT_STEP).should('be.visible'); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality.ts b/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality.ts new file mode 100644 index 0000000000000..6af6826227b6c --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality.ts @@ -0,0 +1,19 @@ +/* + * 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 { getDataTestSubjectSelector } from '../helpers/common'; + +export const PAGE_TITLE = getDataTestSubjectSelector('assetCriticalityUploadPage'); +export const FILE_PICKER = getDataTestSubjectSelector('asset-criticality-file-picker'); +export const ASSIGN_BUTTON = getDataTestSubjectSelector('asset-criticality-assign-button'); +export const RESULT_STEP = getDataTestSubjectSelector('asset-criticality-result-step-success'); +export const VALID_LINES_MESSAGE = getDataTestSubjectSelector( + 'asset-criticality-validLinesMessage' +); +export const INVALID_LINES_MESSAGE = getDataTestSubjectSelector( + 'asset-criticality-invalidLinesMessage' +); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality.ts b/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality.ts new file mode 100644 index 0000000000000..6a1aeeaaee039 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ASSIGN_BUTTON, FILE_PICKER } from '../screens/asset_criticality'; + +export const clickAssignButton = () => { + cy.get(ASSIGN_BUTTON).click(); +}; + +export const uploadAssetCriticalityFile = () => { + cy.get(FILE_PICKER).selectFile({ + contents: Cypress.Buffer.from( + 'user,user-001,medium_impact\nuser,user-002,medium_impact\nhost,host-001,extreme_impact\nhost,host-002,extreme_impact\nhost,host-003,invalid_value' + ), + fileName: 'asset_criticality.csv', + lastModified: Date.now(), + }); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts b/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts index 7acaf9b277954..e6c3b84ba3577 100644 --- a/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts +++ b/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts @@ -69,6 +69,8 @@ export const ALERTS_URL = '/app/security/alerts'; export const EXCEPTIONS_URL = '/app/security/exceptions'; export const CREATE_RULE_URL = '/app/security/rules/create'; export const ENTITY_ANALYTICS_MANAGEMENT_URL = '/app/security/entity_analytics_management'; +export const ENTITY_ANALYTICS_ASSET_CRITICALITY_URL = + '/app/security/entity_analytics_asset_criticality'; export const exceptionsListDetailsUrl = (listId: string) => `/app/security/exceptions/details/${listId}`;