Security Entity Analytics - Privileged user monitoring - Privileged access detection support (#224008)

# Overview

This pull request adds capabilities associated with the [Privileged
Access Detection (PAD) anomaly integration
package](https://www.elastic.co/docs/reference/integrations/pad) as a
first-class-citizen into the Entity Analytics Privileged User Monitoring
feature.

# How to test

- Pull this branch into your local machine
- Ensure that the security experimental flag
`privilegeMonitoringEnabled` has been enabled, for example by setting
`xpack.securitySolution.enableExperimental:
[privilegeMonitoringEnabled]` in your `config/kibana.dev.yml` file
- Start Elasticsearch and Kibana
- From the
[security-documents-generator](https://github.com/elastic/security-documents-generator)
repository, run the following command: `yarn start
privileged-user-monitoring` (ensuring your config is pointing to your
locally running Elastic cluster). This will load "source" events that
are anomalous in nature. You can run this command more than once if
desired to upload more than 10 users.
- Grab the CSV file that the generator created in its console output.
- Open the Entity analytics page and navigate to dashboards (by clicking
"Go to dashboards")
- Add the privileged users from the previous step to the privileged
users index using the CSV File Upload option.
> [!NOTE]  
> Any errors regarding risk scoring are unrelated to this PR, and are
being resolved separately
- You should see a panel that says "Enable Privileged access detection".
Click "Install", and you'll meet a loading state.
- Once complete, you shouldn't see any results. That's because, even
though we **install** the ML jobs by default, we don't **run** them by
default.
- Click "ML Job Settings", and note that only `pad` jobs should be
displaying in this callout. Feel free to test this callout's links and
filtering options.
- Click "Run job" next to the job called
`pad_linux_high_count_privileged_process_events_by_user`. This is the
job for which we have anomaly data.
- Click away from the callout. **You still shouldn't see data.** That's
because there just aren't any anomalies "today".
- Change the global date filter at the top of the screen to "Last 30
days".
- You should see something similar to this:

<img width="1441" alt="Screenshot 2025-06-16 at 12 50 25 AM"
src="https://github.com/user-attachments/assets/2b3f11f2-f45d-4716-bb8e-79d2b585aa3e"
/>

- Congrats for making it this far! Some things to play around with:
- Click around on the anomaly filters (i.e., click 25-50), and notice
that the results will change.
- Ensure that the ordering of the users is based on the **highest single
anomaly score in any visible bucket**. Meaning, if user `samwise` has
only one anomaly, but its score is 99, and user `frodo` has dozens of
anomalies, but no higher than, say, 80, `samwise` will be on the top of
the list.
  - Click the user names to open the appropriate user flyout
- Change the global time filter to change ranges, and ensure the data
shows up appropriately. The buckets will try to roughly show 30 buckets
total, but will have a maximum of 3 hours, meaning if your range is too
small, it may show fewer than 30 buckets. This is intentional behavior,
as the PAD jobs have an anomaly job window of 3 hours.
- Click the button that says "View all in Anomaly Explorer", which uses
the currently selected global time range, and compare results
> [!WARNING]  
> Remember that the users in the privileged user monitoring table are
only those that you designated as privileged users in a previous step.
In contrast, the Anomaly Explorer page shows **all** users.
Additionally, note that there may be very slight differences between the
swimlanes, because ES|QL calculates the bucket dates slightly
differently than the Anomaly Explorer. **This should not affect the
results themselves, only the buckets that an individual anomaly score
might fall in.** You might see a single anomaly fall into one visual
bucket instead of another, but the date should be correct.

# Helpful hints

If you'd like an easy way to "reset" the Privileged Access Detection
package and delete its associated jobs (and anomaly data), so that you
can redo the onboarding flow, you can run the below commands (changing
any credentials as necessary):

```shell
curl "http://localhost:5601/api/fleet/epm/packages/pad/0.5.0" \
  -X 'DELETE' \
  -H 'elastic-api-version: 2023-10-31' \
  -H 'kbn-xsrf:true' \
  --user elastic:changeme

curl "http://localhost:5601/internal/ml/jobs/delete_jobs" \
  -H 'kbn-xsrf:true' \
  --user elastic:changeme \
  -X 'POST' \
  -H 'elastic-api-version: 1' \
  -H 'x-elastic-internal-origin:kibana' \
  -H 'Content-Type: application/json' \
  --data-raw '{
    "jobIds": [
      "pad_linux_high_count_privileged_process_events_by_user",
      "pad_linux_high_median_process_command_line_entropy_by_user",
      "pad_linux_rare_process_executed_by_user",
      "pad_okta_high_sum_concurrent_sessions_by_user",
      "pad_okta_rare_host_name_by_user",
      "pad_okta_rare_region_name_by_user",
      "pad_okta_rare_source_ip_by_user",
      "pad_okta_spike_in_group_application_assignment_changes",
      "pad_okta_spike_in_group_lifecycle_changes",
      "pad_okta_spike_in_group_membership_changes",
      "pad_okta_spike_in_group_privilege_changes",
      "pad_okta_spike_in_user_lifecycle_management_changes",
      "pad_windows_high_count_group_management_events",
      "pad_windows_high_count_special_logon_events",
      "pad_windows_high_count_special_privilege_use_events",
      "pad_windows_high_count_user_account_management_events",
      "pad_windows_rare_device_by_user",
      "pad_windows_rare_group_name_by_user",
      "pad_windows_rare_privilege_assigned_to_user",
      "pad_windows_rare_region_name_by_user",
      "pad_windows_rare_source_ip_by_user"
    ],
    "deleteUserAnnotations": true,
    "deleteAlertingRules": false
  }
'
```

# What's left?
- This PR does not implement "proactive" permissions checks. Instead,
relevant permission issues will cause requests/queries to fail, which
are appropriately shown in the UI for troubleshooting. This proactive
check will be tackled in [this (private) followup
issue](https://github.com/elastic/security-team/issues/12822).
- There is not yet a way to "upgrade" the package directly from the
Privileged User Monitoring screen. This would be a nice addition later
on, instead of requiring users to navigate to the integration page. This
behavior will be tackled in [this (private) followup
issue](https://github.com/elastic/security-team/issues/12823).

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jared Burgett 2025-06-22 00:36:09 -05:00 committed by GitHub
parent 5d696d579d
commit 1b7cb0f29b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2645 additions and 35 deletions

View file

@ -11317,6 +11317,72 @@ paths:
summary: List all monitored users
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/install:
post:
operationId: InstallPrivilegedAccessDetectionPackage
responses:
'200':
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
description: Successful response
summary: Installs the privileged access detection package for the Entity Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/status:
get:
operationId: GetPrivilegedAccessDetectionPackageStatus
responses:
'200':
content:
application/json:
schema:
type: object
properties:
jobs:
items:
type: object
properties:
description:
type: string
job_id:
type: string
state:
enum:
- closing
- closed
- opened
- failed
- opening
type: string
required:
- job_id
- state
type: array
ml_module_setup_status:
enum:
- complete
- incomplete
type: string
package_installation_status:
enum:
- complete
- incomplete
type: string
required:
- package_installation_status
- ml_module_setup_status
- jobs
description: Privileged access detection status retrieved
summary: Gets the status of the privileged access detection package for the Entity Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_store/enable:
post:
operationId: InitEntityStore

View file

@ -13476,6 +13476,72 @@ paths:
summary: List all monitored users
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/install:
post:
operationId: InstallPrivilegedAccessDetectionPackage
responses:
'200':
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
description: Successful response
summary: Installs the privileged access detection package for the Entity Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/status:
get:
operationId: GetPrivilegedAccessDetectionPackageStatus
responses:
'200':
content:
application/json:
schema:
type: object
properties:
jobs:
items:
type: object
properties:
description:
type: string
job_id:
type: string
state:
enum:
- closing
- closed
- opened
- failed
- opening
type: string
required:
- job_id
- state
type: array
ml_module_setup_status:
enum:
- complete
- incomplete
type: string
package_installation_status:
enum:
- complete
- incomplete
type: string
required:
- package_installation_status
- ml_module_setup_status
- jobs
description: Privileged access detection status retrieved
summary: Gets the status of the privileged access detection package for the Entity Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_store/enable:
post:
operationId: InitEntityStore

View file

@ -0,0 +1,24 @@
/*
* 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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Install privileged access detection package
* version: 2023-10-31
*/
import { z } from '@kbn/zod';
export type InstallPrivilegedAccessDetectionPackageResponse = z.infer<
typeof InstallPrivilegedAccessDetectionPackageResponse
>;
export const InstallPrivilegedAccessDetectionPackageResponse = z.object({
message: z.string(),
});

View file

@ -0,0 +1,24 @@
openapi: 3.0.0
info:
title: Install privileged access detection package
version: "2023-10-31"
paths:
/api/entity_analytics/privileged_user_monitoring/pad/install:
post:
x-labels: [ess, serverless]
x-codegen-enabled: true
operationId: InstallPrivilegedAccessDetectionPackage
summary: Installs the privileged access detection package for the Entity Analytics privileged user monitoring experience
responses:
"200":
description: Successful response
content:
application/json:
schema:
type: object
required:
- message
properties:
message:
type: string

View file

@ -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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Get the status of the privileged access detection package
* version: 2023-10-31
*/
import { z } from '@kbn/zod';
export type GetPrivilegedAccessDetectionPackageStatusResponse = z.infer<
typeof GetPrivilegedAccessDetectionPackageStatusResponse
>;
export const GetPrivilegedAccessDetectionPackageStatusResponse = z.object({
package_installation_status: z.enum(['complete', 'incomplete']),
ml_module_setup_status: z.enum(['complete', 'incomplete']),
jobs: z.array(
z.object({
job_id: z.string(),
description: z.string().optional(),
state: z.enum(['closing', 'closed', 'opened', 'failed', 'opening']),
})
),
});

View file

@ -0,0 +1,45 @@
openapi: 3.0.0
info:
title: Get the status of the privileged access detection package
version: "2023-10-31"
paths:
/api/entity_analytics/privileged_user_monitoring/pad/status:
get:
x-labels: [ess, serverless]
x-codegen-enabled: true
operationId: GetPrivilegedAccessDetectionPackageStatus
summary: Gets the status of the privileged access detection package for the Entity Analytics privileged user monitoring experience
responses:
"200":
description: Privileged access detection status retrieved
content:
application/json:
schema:
type: object
required:
- package_installation_status
- ml_module_setup_status
- jobs
properties:
package_installation_status:
type: string
enum: [complete, incomplete]
ml_module_setup_status:
type: string
enum: [complete, incomplete]
jobs:
type: array
items:
type: object
required:
- job_id
- state
properties:
job_id:
type: string
description:
type: string
state:
type: string
enum: [closing, closed, opened, failed, opening]

View file

@ -265,6 +265,8 @@ import type {
} from './entity_analytics/monitoring/search_indices.gen';
import type { InitMonitoringEngineResponse } from './entity_analytics/privilege_monitoring/engine/init.gen';
import type { PrivMonHealthResponse } from './entity_analytics/privilege_monitoring/health.gen';
import type { InstallPrivilegedAccessDetectionPackageResponse } from './entity_analytics/privilege_monitoring/privileged_access_detection/install.gen';
import type { GetPrivilegedAccessDetectionPackageStatusResponse } from './entity_analytics/privilege_monitoring/privileged_access_detection/status.gen';
import type {
CreatePrivMonUserRequestBodyInput,
CreatePrivMonUserResponse,
@ -1429,6 +1431,20 @@ finalize it.
})
.catch(catchAxiosErrorFormatAndThrow);
}
async getPrivilegedAccessDetectionPackageStatus() {
this.log.info(
`${new Date().toISOString()} Calling API GetPrivilegedAccessDetectionPackageStatus`
);
return this.kbnClient
.request<GetPrivilegedAccessDetectionPackageStatusResponse>({
path: '/api/entity_analytics/privileged_user_monitoring/pad/status',
headers: {
[ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
},
method: 'GET',
})
.catch(catchAxiosErrorFormatAndThrow);
}
async getProtectionUpdatesNote(props: GetProtectionUpdatesNoteProps) {
this.log.info(`${new Date().toISOString()} Calling API GetProtectionUpdatesNote`);
return this.kbnClient
@ -1867,6 +1883,20 @@ providing you with the most current and effective threat detection capabilities.
})
.catch(catchAxiosErrorFormatAndThrow);
}
async installPrivilegedAccessDetectionPackage() {
this.log.info(
`${new Date().toISOString()} Calling API InstallPrivilegedAccessDetectionPackage`
);
return this.kbnClient
.request<InstallPrivilegedAccessDetectionPackageResponse>({
path: '/api/entity_analytics/privileged_user_monitoring/pad/install',
headers: {
[ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
},
method: 'POST',
})
.catch(catchAxiosErrorFormatAndThrow);
}
async internalUploadAssetCriticalityRecords(props: InternalUploadAssetCriticalityRecordsProps) {
this.log.info(`${new Date().toISOString()} Calling API InternalUploadAssetCriticalityRecords`);
return this.kbnClient

View file

@ -476,6 +476,76 @@ paths:
summary: List all monitored users
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/install:
post:
operationId: InstallPrivilegedAccessDetectionPackage
responses:
'200':
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
description: Successful response
summary: >-
Installs the privileged access detection package for the Entity
Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/status:
get:
operationId: GetPrivilegedAccessDetectionPackageStatus
responses:
'200':
content:
application/json:
schema:
type: object
properties:
jobs:
items:
type: object
properties:
description:
type: string
job_id:
type: string
state:
enum:
- closing
- closed
- opened
- failed
- opening
type: string
required:
- job_id
- state
type: array
ml_module_setup_status:
enum:
- complete
- incomplete
type: string
package_installation_status:
enum:
- complete
- incomplete
type: string
required:
- package_installation_status
- ml_module_setup_status
- jobs
description: Privileged access detection status retrieved
summary: >-
Gets the status of the privileged access detection package for the
Entity Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_store/enable:
post:
operationId: InitEntityStore

View file

@ -476,6 +476,76 @@ paths:
summary: List all monitored users
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/install:
post:
operationId: InstallPrivilegedAccessDetectionPackage
responses:
'200':
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
description: Successful response
summary: >-
Installs the privileged access detection package for the Entity
Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_analytics/privileged_user_monitoring/pad/status:
get:
operationId: GetPrivilegedAccessDetectionPackageStatus
responses:
'200':
content:
application/json:
schema:
type: object
properties:
jobs:
items:
type: object
properties:
description:
type: string
job_id:
type: string
state:
enum:
- closing
- closed
- opened
- failed
- opening
type: string
required:
- job_id
- state
type: array
ml_module_setup_status:
enum:
- complete
- incomplete
type: string
package_installation_status:
enum:
- complete
- incomplete
type: string
required:
- package_installation_status
- ml_module_setup_status
- jobs
description: Privileged access detection status retrieved
summary: >-
Gets the status of the privileged access detection package for the
Entity Analytics privileged user monitoring experience
tags:
- Security Entity Analytics API
/api/entity_store/enable:
post:
operationId: InitEntityStore

View file

@ -0,0 +1,127 @@
/*
* 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, screen, waitFor } from '@testing-library/react';
import { PrivilegedAccessDetectionsPanel } from '.';
import { TestProviders } from '../../../../../common/mock';
import { useQueryToggle } from '../../../../../common/containers/query_toggle';
import { usePrivilegedAccessDetectionRoutes } from './pad_routes';
import userEvent from '@testing-library/user-event';
jest.mock('../../../../../common/containers/query_toggle', () => ({
useQueryToggle: jest.fn(),
}));
jest.mock('./pad_routes', () => ({
usePrivilegedAccessDetectionRoutes: jest.fn(),
}));
const mockAllRoutes = {
getPrivilegedAccessDetectionStatus: jest.fn(),
setupPrivilegedAccessDetectionMlModule: jest.fn(),
installPrivilegedAccessDetectionPackage: jest.fn(),
};
const promiseThatNeverSettles = () => new Promise(() => {});
describe('PrivilegedAccessDetectionsPanel', () => {
const mockUseQueryToggle = useQueryToggle as jest.Mock;
const mockUsePrivilegedAccessDetectionRoutes = usePrivilegedAccessDetectionRoutes as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryToggle.mockReturnValue({
toggleStatus: true,
setToggleStatus: jest.fn(),
});
mockUsePrivilegedAccessDetectionRoutes.mockReturnValue({
...mockAllRoutes,
});
});
it(`renders a loading element when we don't yet have a status`, async () => {
mockUsePrivilegedAccessDetectionRoutes.mockReturnValue({
...mockAllRoutes,
getPrivilegedAccessDetectionStatus: () => promiseThatNeverSettles(),
});
render(<PrivilegedAccessDetectionsPanel spaceId={'default'} />, { wrapper: TestProviders });
await waitFor(() => expect(screen.getByTestId('pad-loading-status')).toBeInTheDocument());
});
it(`renders the enablement prompt if the package hasn't been installed`, async () => {
mockUsePrivilegedAccessDetectionRoutes.mockReturnValue({
...mockAllRoutes,
getPrivilegedAccessDetectionStatus: () => ({
package_installation_status: 'incomplete',
ml_module_setup_status: 'incomplete',
jobs: [],
}),
});
render(<PrivilegedAccessDetectionsPanel spaceId={'default'} />, { wrapper: TestProviders });
await waitFor(() =>
expect(screen.getByText('Enable Privileged access detection.')).toBeInTheDocument()
);
});
it(`renders enablement even if only the ML module hasn't been set up`, async () => {
mockUsePrivilegedAccessDetectionRoutes.mockReturnValue({
...mockAllRoutes,
getPrivilegedAccessDetectionStatus: () => ({
package_installation_status: 'complete',
ml_module_setup_status: 'incomplete',
jobs: [],
}),
});
render(<PrivilegedAccessDetectionsPanel spaceId={'default'} />, { wrapper: TestProviders });
await waitFor(() =>
expect(screen.getByText('Enable Privileged access detection.')).toBeInTheDocument()
);
});
it(`once installation is complete, ensure the header is visible`, async () => {
mockUsePrivilegedAccessDetectionRoutes.mockReturnValue({
...mockAllRoutes,
getPrivilegedAccessDetectionStatus: () => ({
package_installation_status: 'complete',
ml_module_setup_status: 'complete',
jobs: [],
}),
});
render(<PrivilegedAccessDetectionsPanel spaceId={'default'} />, { wrapper: TestProviders });
await waitFor(() =>
expect(screen.getByText('Top privileged access detection anomalies')).toBeInTheDocument()
);
});
it('shows a loading state while installation is in progress', async () => {
mockUsePrivilegedAccessDetectionRoutes.mockReturnValue({
...mockAllRoutes,
getPrivilegedAccessDetectionStatus: () => ({
package_installation_status: 'incomplete',
ml_module_setup_status: 'incomplete',
jobs: [],
}),
installPrivilegedAccessDetectionPackage: () => promiseThatNeverSettles(),
});
render(<PrivilegedAccessDetectionsPanel spaceId={'default'} />, { wrapper: TestProviders });
await waitFor(() =>
expect(screen.getByText('Enable Privileged access detection.')).toBeInTheDocument()
);
await userEvent.click(screen.getByTestId('privilegedUserMonitoringEnablementButton'));
await waitFor(() =>
expect(screen.getByText('Installing Privileged access detection package')).toBeInTheDocument()
);
});
});

View file

@ -0,0 +1,155 @@
/*
* 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 { EuiCallOut, EuiEmptyPrompt, EuiFlexGroup, EuiPanel, EuiProgress } from '@elastic/eui';
import React from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { PrivilegedAccessDetectionMLPopover } from './pad_ml_popover';
import { HeaderSection } from '../../../../../common/components/header_section';
import { PRIVILEGED_USER_ACTIVITY_QUERY_ID } from '../privileged_user_activity/constants';
import { useQueryToggle } from '../../../../../common/containers/query_toggle';
import { PrivilegedAccessDetectionChart } from './pad_chart';
import { usePrivilegedAccessDetectionRoutes } from './pad_routes';
import { PrivilegedAccessDetectionInstallPrompt } from './pad_install_prompt';
import { PrivilegedAccessDetectionViewAllAnomaliesButton } from './pad_view_all_anomalies_button';
const TITLE = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.title',
{ defaultMessage: 'Top privileged access detection anomalies' }
);
const PRIVILEGED_ACCESS_DETECTIONS_QUERY_ID = 'privileged-access-detection-query';
const PRIVILEGED_ACCESS_DETECTIONS_STATUS_REFRESH_INTERVAL_IN_MS = 10_000;
export const PrivilegedAccessDetectionsPanel: React.FC<{ spaceId: string }> = ({ spaceId }) => {
const { toggleStatus, setToggleStatus } = useQueryToggle(PRIVILEGED_USER_ACTIVITY_QUERY_ID);
const {
getPrivilegedAccessDetectionStatus,
setupPrivilegedAccessDetectionMlModule,
installPrivilegedAccessDetectionPackage,
} = usePrivilegedAccessDetectionRoutes();
const {
data: padInstallationStatus,
error: padInstallationStatusError,
refetch: refetchInstallationStatus,
} = useQuery(['padInstallationStatus'], getPrivilegedAccessDetectionStatus, {
refetchInterval: PRIVILEGED_ACCESS_DETECTIONS_STATUS_REFRESH_INTERVAL_IN_MS,
});
const setupMlModuleMutation = useMutation({ mutationFn: setupPrivilegedAccessDetectionMlModule });
const installPrivilegedAccessDetectionPackageMutation = useMutation({
mutationFn: installPrivilegedAccessDetectionPackage,
});
const currentlyInstalling =
installPrivilegedAccessDetectionPackageMutation.isLoading || setupMlModuleMutation.isLoading;
const install = async () => {
await installPrivilegedAccessDetectionPackageMutation.mutateAsync();
await setupMlModuleMutation.mutateAsync();
await refetchInstallationStatus();
};
const packageInstallationComplete =
padInstallationStatus?.package_installation_status === 'complete' &&
padInstallationStatus?.ml_module_setup_status === 'complete';
return (
<>
{padInstallationStatusError && (
<EuiCallOut
title={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.errorStatus',
{
defaultMessage:
'There was an error retrieving the status of the privileged access detection package.',
}
)}
color="danger"
iconType="error"
/>
)}
{!padInstallationStatus && !padInstallationStatusError && (
<EuiPanel data-test-subj={'pad-loading-status'} hasShadow={false} hasBorder={true}>
<EuiProgress size="xs" color="accent" />
</EuiPanel>
)}
{padInstallationStatus && !packageInstallationComplete && !currentlyInstalling && (
<PrivilegedAccessDetectionInstallPrompt
installationErrorOccurred={
!!installPrivilegedAccessDetectionPackageMutation.error || !!setupMlModuleMutation.error
}
install={install}
/>
)}
{currentlyInstalling && (
<>
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiEmptyPrompt
hasBorder={false}
iconType="logoSecurity"
title={
<h2>
{i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.privilegedAccessDetection',
{ defaultMessage: 'Privileged access detection' }
)}
</h2>
}
body={
<>
<p>
{i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.installingPrivilegedAccessDetection',
{ defaultMessage: 'Installing Privileged access detection package' }
)}
</p>
<EuiProgress size="s" color="accent" />
</>
}
/>
</EuiPanel>
</>
)}
{packageInstallationComplete && (
<EuiPanel hasBorder hasShadow={false} data-test-subj="privileged-access-detections-panel">
<HeaderSection
toggleStatus={toggleStatus}
toggleQuery={setToggleStatus}
id={PRIVILEGED_ACCESS_DETECTIONS_QUERY_ID}
showInspectButton={false}
title={TITLE}
titleSize="s"
outerDirection="column"
hideSubtitle
>
{toggleStatus && (
<EuiFlexGroup gutterSize="s">
<PrivilegedAccessDetectionMLPopover />
<PrivilegedAccessDetectionViewAllAnomaliesButton />
</EuiFlexGroup>
)}
</HeaderSection>
{toggleStatus && (
<>
<PrivilegedAccessDetectionChart
jobIds={padInstallationStatus.jobs.map((eachJob) => eachJob.job_id)}
spaceId={spaceId}
/>
</>
)}
</EuiPanel>
)}
</>
);
};

View file

@ -0,0 +1,171 @@
/*
* 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 {
usePadTopAnomalousUsersEsqlSource,
usePadAnomalyDataEsqlSource,
} from './pad_esql_source_query_hooks';
const trimEsql = (str: string) => str.replace(/[\n]/g, '').replace(/\s\s+/g, ' ').trim();
jest.mock('./pad_heatmap_interval_hooks', () => ({
useIntervalForHeatmap: () => 24,
}));
describe('the source queries for privileged access detection', () => {
describe('the top anomalous users ESQL query', () => {
it('includes all of the jobs passed in, and respects the provided usersLimit', () => {
const query = usePadTopAnomalousUsersEsqlSource({
jobIds: ['jobOne', 'jobTwo'],
anomalyBands: [],
spaceId: 'default',
usersLimit: 100,
});
expect(trimEsql(query)).toEqual(
trimEsql(`
FROM .ml-anomalies-shared
| WHERE job_id IN ("jobOne", "jobTwo")
| WHERE record_score IS NOT NULL AND user.name IS NOT NULL
| RENAME @timestamp AS event_timestamp
| LOOKUP JOIN .entity_analytics.monitoring.users-default ON user.name
| RENAME event_timestamp AS @timestamp
| WHERE user.is_privileged == true
| STATS max_record_score = MAX(record_score), user.is_privileged = TOP(user.is_privileged, 100, "desc") by user.name
| WHERE user.is_privileged == true
| SORT max_record_score DESC
| KEEP user.name
| LIMIT 100
`)
);
});
it('has only the hidden anomaly bands', () => {
const query = usePadTopAnomalousUsersEsqlSource({
jobIds: ['job'],
anomalyBands: [
{
start: 0,
end: 25,
color: '#ffffff',
hidden: true,
},
{
start: 25,
end: 50,
color: '#ffffff',
hidden: true,
},
{
start: 50,
end: 75,
color: '#ffffff',
hidden: false,
},
],
spaceId: 'default',
usersLimit: 100,
});
expect(trimEsql(query)).toEqual(
trimEsql(`
FROM .ml-anomalies-shared
| WHERE job_id IN ("job")
| WHERE record_score IS NOT NULL AND user.name IS NOT NULL
| WHERE record_score < 0 OR record_score >= 25
| WHERE record_score < 25 OR record_score >= 50
| RENAME @timestamp AS event_timestamp
| LOOKUP JOIN .entity_analytics.monitoring.users-default ON user.name
| RENAME event_timestamp AS @timestamp
| WHERE user.is_privileged == true
| STATS max_record_score = MAX(record_score), user.is_privileged = TOP(user.is_privileged, 100, "desc") by user.name
| WHERE user.is_privileged == true
| SORT max_record_score DESC
| KEEP user.name
| LIMIT 100
`)
);
});
});
describe('the anomalies query', () => {
it('has the provided jobs and usernames, and uses the 24h interval coming from the heatmap interval', () => {
const query = usePadAnomalyDataEsqlSource({
jobIds: ['jobOne', 'jobTwo'],
anomalyBands: [],
spaceId: 'default',
userNames: ['cloud', 'squall', 'zidane'],
});
expect(trimEsql(query ?? fail('Query must not be undefined'))).toEqual(
trimEsql(`
FROM .ml-anomalies-shared
| WHERE job_id IN ("jobOne", "jobTwo")
| WHERE record_score IS NOT NULL AND user.name IS NOT NULL AND user.name IN ("cloud", "squall", "zidane")
| RENAME @timestamp AS event_timestamp
| LOOKUP JOIN .entity_analytics.monitoring.users-default ON user.name
| RENAME event_timestamp AS @timestamp
| WHERE user.is_privileged == true
| EVAL user_name_to_record_score = CONCAT(user.name, " : ", TO_STRING(record_score))
| STATS user_name_to_record_score = VALUES(user_name_to_record_score) BY @timestamp = BUCKET(@timestamp, 24h)
| MV_EXPAND user_name_to_record_score
| DISSECT user_name_to_record_score """%{user.name} : %{record_score}"""
| EVAL record_score = TO_DOUBLE(record_score)
| KEEP @timestamp, user.name, record_score
| STATS record_score = MAX(record_score) BY @timestamp, user.name
| SORT record_score DESC
`)
);
});
it('has only the hidden anomaly bands', () => {
const query = usePadAnomalyDataEsqlSource({
jobIds: ['job'],
anomalyBands: [
{
start: 0,
end: 25,
color: '#ffffff',
hidden: true,
},
{
start: 25,
end: 50,
color: '#ffffff',
hidden: true,
},
{
start: 50,
end: 75,
color: '#ffffff',
hidden: false,
},
],
spaceId: 'default',
userNames: ['ramza'],
});
expect(trimEsql(query ?? fail('Query must not be undefined'))).toEqual(
trimEsql(`
FROM .ml-anomalies-shared
| WHERE job_id IN ("job")
| WHERE record_score IS NOT NULL AND user.name IS NOT NULL AND user.name IN ("ramza")
| WHERE record_score < 0 OR record_score >= 25
| WHERE record_score < 25 OR record_score >= 50
| RENAME @timestamp AS event_timestamp
| LOOKUP JOIN .entity_analytics.monitoring.users-default ON user.name
| RENAME event_timestamp AS @timestamp
| WHERE user.is_privileged == true
| EVAL user_name_to_record_score = CONCAT(user.name, " : ", TO_STRING(record_score))
| STATS user_name_to_record_score = VALUES(user_name_to_record_score) BY @timestamp = BUCKET(@timestamp, 24h)
| MV_EXPAND user_name_to_record_score
| DISSECT user_name_to_record_score """%{user.name} : %{record_score}"""
| EVAL record_score = TO_DOUBLE(record_score)
| KEEP @timestamp, user.name, record_score
| STATS record_score = MAX(record_score) BY @timestamp, user.name
| SORT record_score DESC
`)
);
});
});
});

View file

@ -0,0 +1,83 @@
/*
* 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 { useIntervalForHeatmap } from './pad_heatmap_interval_hooks';
import { getPrivilegedMonitorUsersJoin } from '../../../../helpers';
import type { AnomalyBand } from '../pad_anomaly_bands';
const getHiddenBandsFilters = (anomalyBands: AnomalyBand[]) => {
const hiddenBands = anomalyBands.filter((each) => each.hidden);
const recordScoreFilterClause = (eachHiddenBand: AnomalyBand) =>
`| WHERE record_score < ${eachHiddenBand.start} OR record_score >= ${eachHiddenBand.end} `;
return hiddenBands.map(recordScoreFilterClause).join('');
};
/**
* Currently, this query utilizes the `TOP` ES|QL command to filter for privileged users, and this effectively puts a cap on the number of data
* sources, per `user.name`, this query supports. Right now, we have a total of 3 possible values ('integration', 'api', and 'csv'), so 100
* is more than enough, and should likely suffice with the current architecture. If the `VALUES` ES|QL command is ever officially supported, this limitation would go away.
*/
const numberOfSupportedDataSources = 100;
export const usePadTopAnomalousUsersEsqlSource = ({
jobIds,
anomalyBands,
spaceId,
usersLimit,
}: {
jobIds: string[];
anomalyBands: AnomalyBand[];
spaceId: string;
usersLimit: number;
}) => {
const formattedJobIds = jobIds.map((each) => `"${each}"`).join(', ');
return `FROM .ml-anomalies-shared
| WHERE job_id IN (${formattedJobIds})
| WHERE record_score IS NOT NULL AND user.name IS NOT NULL
${getHiddenBandsFilters(anomalyBands)}
${getPrivilegedMonitorUsersJoin(spaceId)}
| STATS max_record_score = MAX(record_score), user.is_privileged = TOP(user.is_privileged, ${numberOfSupportedDataSources}, "desc") by user.name
| WHERE user.is_privileged == true
| SORT max_record_score DESC
| KEEP user.name
| LIMIT ${usersLimit}`;
// NOTE: the final `WHERE user.is_privileged == true` should not be necessary, as we've already performed the join and filtered by privileged users by that point. I believe this is a bug in ES|QL. This workaround doesn't cause any issues, however.
};
export const usePadAnomalyDataEsqlSource = ({
jobIds,
anomalyBands,
spaceId,
userNames,
}: {
jobIds: string[];
anomalyBands: AnomalyBand[];
spaceId: string;
userNames?: string[];
}) => {
const interval = useIntervalForHeatmap();
if (!userNames) return undefined;
const formattedJobIds = jobIds.map((each) => `"${each}"`).join(', ');
const formattedUserNames = userNames.map((each) => `"${each}"`).join(', ');
return `FROM .ml-anomalies-shared
| WHERE job_id IN (${formattedJobIds})
| WHERE record_score IS NOT NULL AND user.name IS NOT NULL AND user.name IN (${formattedUserNames})
${getHiddenBandsFilters(anomalyBands)}
${getPrivilegedMonitorUsersJoin(spaceId)}
| EVAL user_name_to_record_score = CONCAT(user.name, " : ", TO_STRING(record_score))
| STATS user_name_to_record_score = VALUES(user_name_to_record_score) BY @timestamp = BUCKET(@timestamp, ${interval}h)
| MV_EXPAND user_name_to_record_score
| DISSECT user_name_to_record_score """%{user.name} : %{record_score}"""
| EVAL record_score = TO_DOUBLE(record_score)
| KEEP @timestamp, user.name, record_score
| STATS record_score = MAX(record_score) BY @timestamp, user.name
| SORT record_score DESC
`;
};

View file

@ -0,0 +1,58 @@
/*
* 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 { useGlobalTime } from '../../../../../../../common/containers/use_global_time';
import moment from 'moment';
import { useIntervalForHeatmap } from './pad_heatmap_interval_hooks';
jest.mock('../../../../../../../common/containers/use_global_time', () => ({
useGlobalTime: jest.fn(),
}));
describe('useIntervalForHeatmap', () => {
const mockUseGlobalTime = useGlobalTime as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockUseGlobalTime.mockReturnValue({
from: 0,
to: 0,
});
});
it('returns a minimum of three hours for our bucket ranges, no matter how short the query range', () => {
const now = moment.now().valueOf();
mockUseGlobalTime.mockReturnValue({
from: now,
to: now,
});
const interval = useIntervalForHeatmap();
expect(interval).toEqual(3);
});
it('in the case of a 30 day interval, returns buckets that are 24 hours in length, as we want to compute 30 buckets', () => {
const now = moment.now().valueOf();
const thirtyDaysAgo = moment().subtract(30, 'days').valueOf();
mockUseGlobalTime.mockReturnValue({
from: thirtyDaysAgo,
to: now,
});
const interval = useIntervalForHeatmap();
expect(interval).toEqual(24);
});
it('in the case of a 90 day interval, returns buckets that are 24 hours in length, as we still want to compute 30 buckets', () => {
const now = moment.now().valueOf();
const thirtyDaysAgo = moment().subtract(90, 'days').valueOf();
mockUseGlobalTime.mockReturnValue({
from: thirtyDaysAgo,
to: now,
});
const interval = useIntervalForHeatmap();
expect(interval).toEqual(72);
});
});

View file

@ -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 { useGlobalTime } from '../../../../../../../common/containers/use_global_time';
/**
* This function computes the appropriate interval (length of time, in hours) of each bucket of the heatmap in a given timerange.
* At most, we will compute 30 buckets, and the interval will be evenly distributed across those 30.
* However, the lowest possible interval that will return is 3 (which equates to 3 hours), which means it is possible
* for fewer buckets than 30.
*
* @return a number representing the number of hours per interval.
*/
export const useIntervalForHeatmap = () => {
const { from, to } = useGlobalTime();
const millisecondsToHours = (millis: number) => {
return Number((millis / (1000 * 60 * 60)).toFixed(0));
};
const maximumNumberOfBuckets = 30;
const minimumNumberOfBucketInterval = 3;
const hoursInRange = millisecondsToHours(new Date(to).getTime() - new Date(from).getTime());
const bucketInterval = Number((hoursInRange / maximumNumberOfBuckets).toFixed(0));
return bucketInterval < minimumNumberOfBucketInterval
? minimumNumberOfBucketInterval
: bucketInterval;
};

View file

@ -0,0 +1,144 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { getESQLResults } from '@kbn/esql-utils';
import { i18n } from '@kbn/i18n';
import { useEsqlGlobalFilterQuery } from '../../../../../../../common/hooks/esql/use_esql_global_filter';
import { esqlResponseToRecords } from '../../../../../../../common/utils/esql';
import { useKibana } from '../../../../../../../common/lib/kibana';
import { useErrorToast } from '../../../../../../../common/hooks/use_error_toast';
import type { AnomalyBand } from '../pad_anomaly_bands';
import {
usePadAnomalyDataEsqlSource,
usePadTopAnomalousUsersEsqlSource,
} from './pad_esql_source_query_hooks';
interface ESQLRawAnomalyRecord extends Record<string, string | number> {
'@timestamp': number | string;
record_score: number;
'user.name': string;
}
/**
* An Anomaly record that ensures consistent timestamps as milliseconds since Epoch time.
*/
export interface ESQLAnomalyRecord {
'@timestamp': number;
record_score: number;
'user.name': string;
}
const usePrivilegedAccessDetectionTopUsersQuery = (params: {
jobIds: string[];
spaceId: string;
anomalyBands: AnomalyBand[];
}) => {
const search = useKibana().services.data.search.search;
const filterQuery = useEsqlGlobalFilterQuery();
const padTopAnomalousUsersEsqlSource = usePadTopAnomalousUsersEsqlSource({
...params,
usersLimit: 10,
});
const { isLoading, data, isError } = useQuery(
[filterQuery, padTopAnomalousUsersEsqlSource],
async ({ signal }) => {
return esqlResponseToRecords<{ 'user.name': string }>(
(
await getESQLResults({
esqlQuery: padTopAnomalousUsersEsqlSource,
search,
signal,
filter: filterQuery,
})
)?.response
);
}
);
return {
isLoading,
userNames: data?.map((each) => each['user.name']),
isError,
};
};
export const usePrivilegedAccessDetectionAnomaliesQuery = (params: {
jobIds: string[];
spaceId: string;
anomalyBands: AnomalyBand[];
}) => {
const search = useKibana().services.data.search.search;
const filterQuery = useEsqlGlobalFilterQuery();
const {
userNames,
isError: isTopUsersError,
isLoading: isTopUsersLoading,
} = usePrivilegedAccessDetectionTopUsersQuery(params);
const padAnomalyDataEsqlSource = usePadAnomalyDataEsqlSource({ ...params, userNames });
const {
isLoading: isAnomaliesLoading,
data,
error,
isError: isAnomaliesError,
refetch,
} = useQuery<{
anomalyRecords: ESQLAnomalyRecord[];
userNames: string[];
}>(
[filterQuery, padAnomalyDataEsqlSource, userNames],
async ({ signal }) => {
if (!padAnomalyDataEsqlSource || !userNames || userNames.length === 0) {
return { anomalyRecords: [], userNames: [] };
}
const anomalyRecords = esqlResponseToRecords<ESQLRawAnomalyRecord>(
(
await getESQLResults({
esqlQuery: padAnomalyDataEsqlSource,
search,
signal,
filter: filterQuery,
})
).response
).map((eachRawRecord) => ({
...eachRawRecord,
'@timestamp': new Date(eachRawRecord['@timestamp']).getTime(),
}));
return {
anomalyRecords: anomalyRecords ?? [],
userNames: userNames ?? [],
};
},
{
enabled: !!padAnomalyDataEsqlSource && !!userNames,
keepPreviousData: true,
}
);
useErrorToast(
i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.queryError',
{
defaultMessage: 'There was an error loading privileged access detection data',
}
),
error
);
return {
data,
isLoading: isTopUsersLoading || isAnomaliesLoading,
isError: isTopUsersError || isAnomaliesError,
refetch,
error,
};
};

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiFlexGroup } from '@elastic/eui';
import { PrivilegedAccessDetectionSeverityFilter } from './pad_chart_severity_filter';
import { usePrivilegedAccessDetectionAnomaliesQuery } from './hooks/pad_query_hooks';
import { useAnomalyBands } from './pad_anomaly_bands';
import { UserNameList } from './pad_user_name_list';
import { PrivilegedAccessDetectionHeatmap } from './pad_heatmap';
export interface PrivilegedAccessDetectionChartProps {
jobIds: string[];
spaceId: string;
}
export const PrivilegedAccessDetectionChart: React.FC<PrivilegedAccessDetectionChartProps> = ({
jobIds,
spaceId,
}) => {
const { bands, toggleHiddenBand } = useAnomalyBands();
const { data, isLoading, isError } = usePrivilegedAccessDetectionAnomaliesQuery({
jobIds,
anomalyBands: bands,
spaceId,
});
return (
<>
<PrivilegedAccessDetectionSeverityFilter
anomalyBands={bands}
toggleHiddenBand={toggleHiddenBand}
/>
<EuiFlexGroup>
<UserNameList userNames={data?.userNames ?? []} />
<PrivilegedAccessDetectionHeatmap
anomalyBands={bands}
records={data?.anomalyRecords ?? []}
userNames={data?.userNames ?? []}
isLoading={isLoading}
isError={isError}
/>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,54 @@
/*
* 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 { useEuiTheme } from '@elastic/eui';
import { useState } from 'react';
export interface AnomalyBand {
start: number;
end: number;
color: string;
hidden: boolean;
}
/**
* Uses the `start` and `end` values of the band to determine equality, as the `color` value could theoretically change across renders (though not in practice)
*/
const bandsAreEqual = (a: AnomalyBand, b: AnomalyBand) => a.start === b.start && a.end === b.end;
export const useAnomalyBands: () => {
bands: AnomalyBand[];
toggleHiddenBand: (bandToToggle: AnomalyBand) => void;
} = () => {
const { euiTheme } = useEuiTheme();
const bandDefinitions = [
{ start: 0, end: 3, color: '#E5F6Fa' }, // TODO, this color needs to align with the usage in ML, ongoing discussion in this ticket: https://github.com/elastic/kibana/issues/217508. Issue to track this todo: https://github.com/elastic/security-team/issues/12810
{ start: 3, end: 25, color: euiTheme.colors.severity.neutral },
{ start: 25, end: 50, color: euiTheme.colors.severity.warning },
{ start: 50, end: 75, color: euiTheme.colors.severity.risk },
{ start: 75, end: 100, color: euiTheme.colors.severity.danger },
];
const [bands, setBands] = useState<AnomalyBand[]>(
bandDefinitions.map((each) => ({ ...each, hidden: false }))
);
const toggleHiddenBand = (bandToToggle: AnomalyBand) => {
setBands((currentBands) =>
currentBands.map((band) => ({
...band,
hidden: bandsAreEqual(bandToToggle, band) ? !band.hidden : band.hidden,
}))
);
};
return {
bands,
toggleHiddenBand,
};
};

View file

@ -0,0 +1,68 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiIcon,
EuiPanel,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import type { AnomalyBand } from './pad_anomaly_bands';
interface SeverityFilterProps {
anomalyBands: AnomalyBand[];
toggleHiddenBand: (band: AnomalyBand) => void;
}
export const PrivilegedAccessDetectionSeverityFilter: React.FC<SeverityFilterProps> = ({
anomalyBands,
toggleHiddenBand,
}) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup>
<EuiPanel grow={false} hasBorder hasShadow={false}>
<EuiFlexGroup alignItems={'center'}>
<p css={{ fontWeight: euiTheme.font.weight.bold }}>{'Anomaly score'}</p>
{anomalyBands.map((band) => {
if (band.hidden) {
return (
<EuiFlexItem
key={`${band.start}-${band.end}`}
css={{ cursor: 'pointer' }}
onClick={() => toggleHiddenBand(band)}
grow={false}
>
<EuiFlexGroup alignItems={'center'} gutterSize={'xs'}>
<EuiIcon type={'eyeClosed'} />
<EuiText size={'s'} color={euiTheme.colors.textSubdued}>
<p>{`${band.start}-${band.end}`}</p>
</EuiText>
</EuiFlexGroup>
</EuiFlexItem>
);
}
return (
<EuiHealth
key={`${band.start}-${band.end}`}
css={{ cursor: 'pointer' }}
onClick={() => toggleHiddenBand(band)}
color={band.color}
>{`${band.start}-${band.end}`}</EuiHealth>
);
})}
</EuiFlexGroup>
</EuiPanel>
<EuiFlexItem />
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,21 @@
/*
* 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 padChartStyling = {
heightOfNoResults: 300,
heightOfXAxisLegend: 28,
heightOfTopLegend: 32,
heightOfEachCell: 40,
heightOfUserNamesList: (userNames: string[]) =>
userNames.length > 0
? userNames.length * padChartStyling.heightOfEachCell
: padChartStyling.heightOfNoResults,
heightOfHeatmap: (userNames: string[]) =>
userNames.length > 0
? userNames.length * padChartStyling.heightOfEachCell + padChartStyling.heightOfXAxisLegend
: padChartStyling.heightOfNoResults,
};

View file

@ -0,0 +1,208 @@
/*
* 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 {
Chart,
Heatmap,
type HeatmapStyle,
niceTimeFormatter,
type RecursivePartial,
ScaleType,
Settings,
} from '@elastic/charts';
import {
EuiFlexGroup,
EuiFlexItem,
EuiImage,
EuiText,
EuiTitle,
EuiLoadingChart,
EuiCallOut,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useIntervalForHeatmap } from './hooks/pad_heatmap_interval_hooks';
import { padChartStyling } from './pad_chart_styling';
import type { ESQLAnomalyRecord } from './hooks/pad_query_hooks';
import { useGlobalTime } from '../../../../../../common/containers/use_global_time';
import type { AnomalyBand } from './pad_anomaly_bands';
import illustration from '../../../../../../common/images/illustration_product_no_results_magnifying_glass.svg';
const heatmapComponentStyle: RecursivePartial<HeatmapStyle> = {
brushTool: {
visible: false,
},
cell: {
maxWidth: 'fill',
label: {
visible: false,
},
border: {
stroke: 'transparent',
strokeWidth: 0,
},
},
xAxisLabel: {
fontSize: 12,
padding: { top: 10, bottom: 10 },
},
yAxisLabel: {
visible: false, // We do not show the yAxisLabel, as we instead render the user names separately in order to link to the User flyout
fontSize: 14,
width: 'auto',
padding: { left: 10, right: 10 },
},
};
interface PrivilegedAccessDetectionHeatmapProps {
records: ESQLAnomalyRecord[];
anomalyBands: AnomalyBand[];
userNames: string[];
isLoading: boolean;
isError: boolean;
}
const PrivilegedAccessDetectionHeatmapNoResults: React.FC = () => {
return (
<EuiFlexGroup css={{ maxWidth: '600px' }}>
<EuiFlexItem>
<EuiText size="s">
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.noResultsTitle"
defaultMessage="No privileged access detection results match your search criteria"
/>
</h3>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.noResultsDescription"
defaultMessage={`Now that you've got the privileged access detection anomaly jobs installed, you can click "ML job settings" above to configure and run them within your environment.`}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiImage
size="200px"
alt={i18n.translate(
'xpack.securitySolution.privilegedUserMonitoring.privilegedAccessDetection.emptyState.illustrationAlt',
{
defaultMessage: 'No results',
}
)}
url={illustration}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const useGlobalTimeInMillis = () => {
const { from, to } = useGlobalTime();
return {
from: new Date(from).getTime(),
to: new Date(to).getTime(),
};
};
const useXDomainFromGlobalTime = () => {
const { from, to } = useGlobalTimeInMillis();
return {
min: from,
max: to,
};
};
const useTimeFormatter = () => {
const { from, to } = useGlobalTimeInMillis();
return (value: string | number) =>
niceTimeFormatter([from, to])(value, {
timeZone: 'UTC',
});
};
export const PrivilegedAccessDetectionHeatmap: React.FC<PrivilegedAccessDetectionHeatmapProps> = ({
records,
anomalyBands,
userNames,
isLoading,
isError,
}) => {
const intervalForHeatmap = useIntervalForHeatmap();
const timeFormatter = useTimeFormatter();
const xDomain = useXDomainFromGlobalTime();
return (
<EuiFlexItem
css={{
marginTop: `${padChartStyling.heightOfTopLegend}px`,
height: `${padChartStyling.heightOfHeatmap(userNames)}px`,
}}
>
{isLoading && (
<EuiFlexGroup justifyContent={'center'} alignItems={'center'}>
<EuiLoadingChart size="xl" />
</EuiFlexGroup>
)}
{isError && (
<EuiCallOut
title={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.anomalyDetectionDataError',
{
defaultMessage:
'There was an error retrieving privileged access detection anomaly data.',
}
)}
color="danger"
iconType="error"
/>
)}
{!isLoading && !isError && (
<Chart>
<Settings
theme={{ heatmap: heatmapComponentStyle }}
noResults={<PrivilegedAccessDetectionHeatmapNoResults />}
xDomain={xDomain}
/>
<Heatmap
id={'privileged-access-detection-heatmap-chart'}
xScale={{
type: ScaleType.Time,
interval: {
type: 'fixed',
value: intervalForHeatmap,
unit: 'h',
},
}}
colorScale={{
type: 'bands',
bands: anomalyBands,
}}
data={records}
name={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetections.maxAnomalyScore',
{ defaultMessage: 'Max anomaly score' }
)}
xAccessor="@timestamp"
xAxisLabelName={''}
xAxisLabelFormatter={timeFormatter}
yAccessor="user.name"
yAxisLabelName={'user.name'}
ySortPredicate="numDesc"
valueAccessor="record_score"
/>
</Chart>
)}
</EuiFlexItem>
);
};

View file

@ -0,0 +1,65 @@
/*
* 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import { padChartStyling } from './pad_chart_styling';
import { UserPanelKey } from '../../../../../../flyout/entity_details/shared/constants';
const PRIVILEGED_ACCESS_DETECTION_TABLE_ID = 'PadAnomalies-table';
export const UserNameList: React.FC<{ userNames: string[] }> = ({ userNames }) => {
const { openFlyout } = useExpandableFlyoutApi();
const openUserFlyout = (userName: string) => {
openFlyout({
right: {
id: UserPanelKey,
params: {
contextID: PRIVILEGED_ACCESS_DETECTION_TABLE_ID,
userName,
scopeId: PRIVILEGED_ACCESS_DETECTION_TABLE_ID,
},
},
});
};
return (
<EuiFlexItem
css={css`
margin-top: ${padChartStyling.heightOfTopLegend}px;
height: ${padChartStyling.heightOfUserNamesList(userNames)}px;
`}
grow={false}
>
<EuiFlexGroup gutterSize={'none'} direction={'column'} justifyContent={'center'}>
{userNames.map((userName) => (
<EuiFlexItem
key={userName}
css={css`
justify-content: center;
height: ${padChartStyling.heightOfEachCell}px;
`}
grow={false}
>
<EuiText textAlign={'right'}>
<EuiLink
onClick={() => {
openUserFlyout(userName);
}}
>
{userName}
</EuiLink>
</EuiText>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,114 @@
/*
* 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,
EuiCallOut,
EuiEmptyPrompt,
EuiImage,
EuiToolTip,
EuiPanel,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useState } from 'react';
import { MlNodeAvailableWarningShared } from '@kbn/ml-plugin/public';
import { i18n } from '@kbn/i18n';
import dashboardEnableImg from '../../../../images/entity_store_dashboard.png';
export const PrivilegedAccessDetectionInstallPrompt: React.FC<{
installationErrorOccurred: boolean;
install: () => Promise<void>;
}> = ({ installationErrorOccurred, install }) => {
const [mlNodesAvailable, setMlNodesAvailable] = useState(false);
const privilegedAccessDetectionInstallTooltipText = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.installTooltip',
{ defaultMessage: 'Install and enable privileged access detection anomaly jobs' }
);
const privilegedAccessDetectionInstallTooltipUnavailableText = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.installUnavailableTooltip',
{ defaultMessage: 'Unable to install jobs due to no ML node currently available.' }
);
return (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiEmptyPrompt
hasBorder={false}
layout="horizontal"
actions={
<EuiToolTip
content={
mlNodesAvailable
? privilegedAccessDetectionInstallTooltipText
: privilegedAccessDetectionInstallTooltipUnavailableText
}
>
<EuiButton
color="primary"
disabled={!mlNodesAvailable}
fill
onClick={() => install()}
data-test-subj={`privilegedUserMonitoringEnablementButton`}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.enableButton"
defaultMessage="Install"
/>
</EuiButton>
</EuiToolTip>
}
icon={
<EuiImage
size="l"
hasShadow
src={dashboardEnableImg}
alt={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.installAndEnable',
{ defaultMessage: 'Install and Enable.' }
)}
/>
}
data-test-subj="privilegedUserMonitoringEnablementPanel"
title={
<h2>
{i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.topPrivilegedAccessDetectionAnomalies.enablePrivilegedAccessDetection',
{ defaultMessage: 'Enable Privileged access detection.' }
)}
</h2>
}
body={
<>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessEnablementDescription"
defaultMessage={
'Detect anomalous privileged access activity in Windows, Linux and Okta system logs.'
}
/>
</p>
<MlNodeAvailableWarningShared size="s" nodeAvailableCallback={setMlNodesAvailable} />
{installationErrorOccurred && (
<EuiCallOut
title={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.installErrorStatus',
{
defaultMessage:
'There was an error installing the privileged access detection package.',
}
)}
color="danger"
iconType="error"
/>
)}
</>
}
/>
</EuiPanel>
);
};

View file

@ -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 { useSecurityJobs } from '../../../../../../../common/components/ml_popover/hooks/use_security_jobs';
import { searchFilter } from '../../../../../../../common/components/ml_popover/helpers';
export const usePadMlJobs = (searchValue: string) => {
const {
isMlAdmin,
loading: isLoadingSecurityJobs,
jobs,
refetch: refreshJobs,
} = useSecurityJobs();
const allPrivilegedAccessDetectionJobs = jobs.filter((job) => job.groups.includes('pad'));
const filteredPrivilegedAccessDetectionJobs = searchFilter(
allPrivilegedAccessDetectionJobs,
searchValue
);
return {
jobs: filteredPrivilegedAccessDetectionJobs,
refreshJobs,
isMlAdmin,
isLoadingSecurityJobs,
};
};

View file

@ -0,0 +1,120 @@
/*
* 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, { useCallback, useState } from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiPopover, EuiFieldSearch } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { MLJobsAwaitingNodeWarning, MlNodeAvailableWarningShared } from '@kbn/ml-plugin/public';
import { PrivilegedAccessDetectionMLPopoverHeader } from './pad_ml_popover_header';
import { useEnableDataFeed } from '../../../../../../common/components/ml_popover/hooks/use_enable_data_feed';
import type { SecurityJob } from '../../../../../../common/components/ml_popover/types';
import { JobsTable } from '../../../../../../common/components/ml_popover/jobs_table/jobs_table';
import { usePadMlJobs } from './hooks/pad_get_jobs_hooks';
export const PrivilegedAccessDetectionMLPopover: React.FC = () => {
const {
enableDatafeed,
disableDatafeed,
isLoading: isLoadingEnableDataFeed,
} = useEnableDataFeed();
const [searchValue, setSearchValue] = useState('');
const { jobs, refreshJobs, isLoadingSecurityJobs, isMlAdmin } = usePadMlJobs(searchValue);
const handleJobStateChange = useCallback(
async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => {
if (enable) {
await enableDatafeed(job, latestTimestampMs);
} else {
await disableDatafeed(job);
}
refreshJobs();
},
[refreshJobs, enableDatafeed, disableDatafeed]
);
const [mlNodesAvailable, setMlNodesAvailable] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const installedJobsIds = jobs.filter((job) => job.isInstalled).map((job) => job.id);
if (!isMlAdmin) return null;
return (
<>
<EuiPopover
anchorPosition="downRight"
id="privileged-access-detections-popover"
button={
<EuiButtonEmpty
aria-expanded={isPopoverOpen}
aria-haspopup="true"
aria-label={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.popupAria',
{ defaultMessage: 'Privileged Access Detection popup' }
)}
color="primary"
data-test-subj="privileged-access-detections-popover-button"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
refreshJobs();
}}
>
{i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.padMlJobsPopoverText',
{ defaultMessage: 'Privileged access detection ML Jobs' }
)}
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(!isPopoverOpen)}
repositionOnScroll
>
<EuiFlexGroup
direction={'column'}
css={css`
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 15px;
`}
>
<PrivilegedAccessDetectionMLPopoverHeader />
<EuiFieldSearch
placeholder={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.searchPlaceholder',
{ defaultMessage: 'e.g., Linux, Okta, etc.' }
)}
value={searchValue}
fullWidth
onChange={(e) => setSearchValue(e.target.value)}
isClearable={true}
aria-label={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.searchAria',
{ defaultMessage: 'Privileged access detection search box' }
)}
/>
<MLJobsAwaitingNodeWarning jobIds={installedJobsIds} />
<MlNodeAvailableWarningShared size="s" nodeAvailableCallback={setMlNodesAvailable} />
<JobsTable
isLoading={isLoadingSecurityJobs || isLoadingEnableDataFeed}
jobs={jobs}
onJobStateChange={handleJobStateChange}
mlNodesAvailable={mlNodesAvailable}
/>
</EuiFlexGroup>
</EuiPopover>
</>
);
};

View file

@ -0,0 +1,50 @@
/*
* 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, { useCallback } from 'react';
import { useNavigation } from '@kbn/security-solution-navigation';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiText } from '@elastic/eui';
import { useIntegrationLinkState } from '../../../../../../common/hooks/integrations/use_integration_link_state';
import { ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH } from '../../../../../../../common/constants';
import { usePrivilegedAccessDetectionIntegration } from '../../../../privileged_user_monitoring_onboarding/hooks/use_integrations';
import { INTEGRATION_APP_ID } from '../../../../../../common/lib/integrations/constants';
import { addPathParamToUrl } from '../../../../../../common/utils/integrations';
export const PrivilegedAccessDetectionMLPopoverHeader: React.FC = () => {
const state = useIntegrationLinkState(ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH);
const { navigateTo } = useNavigation();
const padPackage = usePrivilegedAccessDetectionIntegration();
const navigateToPadIntegration = useCallback(() => {
navigateTo({
appId: INTEGRATION_APP_ID,
path: addPathParamToUrl(
`/detail/${padPackage?.name}-${padPackage?.version}/overview`,
ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH
),
state,
});
}, [navigateTo, padPackage, state]);
return (
<EuiText>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.padMlJobsDescription"
defaultMessage="Run privileged access detection jobs to monitor anomalous behaviors of privileged users in your environment. Note that some jobs may require additional manual steps configured in order to fully function. See the {integrationLink} for details"
values={{
integrationLink: (
<EuiLink external onClick={navigateToPadIntegration}>
{'Privileged access detection integration'}
</EuiLink>
),
}}
/>
</p>
</EuiText>
);
};

View file

@ -0,0 +1,55 @@
/*
* 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 { useMemo } from 'react';
import { API_VERSIONS } from '../../../../../../common/entity_analytics/constants';
import { useKibana } from '../../../../../common/lib/kibana';
import type { GetPrivilegedAccessDetectionPackageStatusResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/privileged_access_detection/status.gen';
const PRIVILEGED_ACCESS_DETECTION_INDEX_PATTERN =
'logs-*,ml_okta_multiple_user_sessions_pad.all,ml_windows_privilege_type_pad.all';
export const usePrivilegedAccessDetectionRoutes = () => {
const http = useKibana().services.http;
return useMemo(() => {
const getPrivilegedAccessDetectionStatus = async () => {
return http.fetch<GetPrivilegedAccessDetectionPackageStatusResponse>(
'/api/entity_analytics/privileged_user_monitoring/pad/status',
{
method: 'GET',
version: API_VERSIONS.public.v1,
}
);
};
const installPrivilegedAccessDetectionPackage = async () => {
return http.fetch('/api/entity_analytics/privileged_user_monitoring/pad/install', {
method: 'POST',
version: API_VERSIONS.public.v1,
});
};
const setupPrivilegedAccessDetectionMlModule = async () => {
return http.fetch('/internal/ml/modules/setup/pad-ml', {
version: '1',
method: 'POST',
body: JSON.stringify({
indexPatternName: PRIVILEGED_ACCESS_DETECTION_INDEX_PATTERN,
useDedicatedIndex: false,
startDatafeed: false,
}),
});
};
return {
getPrivilegedAccessDetectionStatus,
installPrivilegedAccessDetectionPackage,
setupPrivilegedAccessDetectionMlModule,
};
}, [http]);
};

View file

@ -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 { EuiButton, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public';
import { useKibana } from '../../../../../common/lib/kibana';
import { useGlobalTime } from '../../../../../common/containers/use_global_time';
const usePadMlAnomalyExplorerUrl = () => {
const { from, to } = useGlobalTime();
const { services } = useKibana();
return useMlHref(
services.ml,
services.http.basePath.get(),
{
page: ML_PAGES.ANOMALY_EXPLORER,
pageState: {
jobIds: ['pad'],
timeRange: { from, to },
mlExplorerSwimlane: {
viewByFieldName: 'user.name',
},
},
},
[from, to]
);
};
export const PrivilegedAccessDetectionViewAllAnomaliesButton: React.FC = () => {
const anomalyExplorerUrl = usePadMlAnomalyExplorerUrl();
return (
<EuiButton color={'primary'} fill={false} iconType={'anomalySwimLane'}>
<EuiLink href={anomalyExplorerUrl} external={false} target="_blank">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.privilegedAccessDetection.anomalyExplorer"
defaultMessage="View all in Anomaly Explorer"
/>
</EuiLink>
</EuiButton>
);
};

View file

@ -11,6 +11,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { useSpaceId } from '../../../common/hooks/use_space_id';
import { RiskLevelsPrivilegedUsersPanel } from './components/risk_level_panel';
import { UserActivityPrivilegedUsersPanel } from './components/privileged_user_activity';
import { PrivilegedAccessDetectionsPanel } from './components/privileged_access_detection';
export interface OnboardingCallout {
userCount: number;
@ -82,11 +83,7 @@ export const PrivilegedUserMonitoring = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel hasShadow={false} hasBorder={true}>
<span>{'TODO: Top privileged access detections'}</span>
</EuiPanel>
</EuiFlexItem>
{spaceId && <PrivilegedAccessDetectionsPanel spaceId={spaceId} />}
<EuiFlexItem>
<UserActivityPrivilegedUsersPanel />
</EuiFlexItem>

View file

@ -8,6 +8,10 @@
import { useGetPackageInfoByKeyQuery } from '@kbn/fleet-plugin/public';
import type { GetInfoResponse } from '@kbn/fleet-plugin/common';
const isGetInfoResponse = (
integration: GetInfoResponse | undefined
): integration is GetInfoResponse => integration !== undefined;
export const useEntityAnalyticsIntegrations = () => {
const { data: okta } = useGetPackageInfoByKeyQuery(
'entityanalytics_okta',
@ -28,11 +32,20 @@ export const useEntityAnalyticsIntegrations = () => {
}
);
const integrations = [okta, ad]
.filter<GetInfoResponse>(
(integration): integration is GetInfoResponse => integration !== undefined
)
.map(({ item }) => item);
return integrations;
return [okta, ad].filter<GetInfoResponse>(isGetInfoResponse).map(({ item }) => item);
};
export const usePrivilegedAccessDetectionIntegration = () => {
const { data: pad } = useGetPackageInfoByKeyQuery(
'pad',
undefined, // When package version is undefined it gets the latest version
{
prerelease: true, // This is a technical preview package, delete this line when it is GA
},
{
suspense: false,
}
);
return isGetInfoResponse(pad) ? pad.item : undefined;
};

View file

@ -38,7 +38,7 @@ export const PrivilegedUserMonitoringOnboardingPanel = ({
gutterSize="xl"
alignItems="center"
>
<EuiFlexItem grow={1} paddingSize="xl">
<EuiFlexItem grow={1}>
<EuiPanel paddingSize="s" hasShadow={false} hasBorder={false} color="subdued">
<EuiFlexGroup justifyContent="spaceBetween" direction="column">
<EuiFlexGroup gutterSize={'m'} alignItems={'center'}>

View file

@ -47,6 +47,7 @@ import { AssetInventoryDataClientMock } from '../../../asset_inventory/asset_inv
import { privilegeMonitorDataClientMock } from '../../../entity_analytics/privilege_monitoring/privilege_monitoring_data_client.mock';
import { createProductFeaturesServiceMock } from '../../../product_features_service/mocks';
import type { EndpointAppContextService } from '../../../../endpoint/endpoint_app_context_services';
import { padPackageInstallationClientMock } from '../../../entity_analytics/privilege_monitoring/privileged_access_detection/pad_package_installation_client.mock';
export const createMockClients = () => {
const core = coreMock.createRequestHandlerContext();
@ -80,6 +81,7 @@ export const createMockClients = () => {
assetCriticalityDataClient: assetCriticalityDataClientMock.create(),
entityStoreDataClient: entityStoreDataClientMock.create(),
privilegeMonitorDataClient: privilegeMonitorDataClientMock.create(),
padPackageInstallationClient: padPackageInstallationClientMock.create(),
internalFleetServices: {
packages: packageServiceMock.createClient(),
@ -191,6 +193,7 @@ const createSecuritySolutionRequestContextMock = (
getEntityStoreApiKeyManager: jest.fn(),
getEntityStoreDataClient: jest.fn(() => clients.entityStoreDataClient),
getPrivilegeMonitoringDataClient: jest.fn(() => clients.privilegeMonitorDataClient),
getPadPackageInstallationClient: jest.fn(() => clients.padPackageInstallationClient),
getMonitoringEntitySourceDataClient: jest.fn(),
getSiemRuleMigrationsClient: jest.fn(() => clients.siemRuleMigrationsClient),
getInferenceClient: jest.fn(() => clients.getInferenceClient()),

View file

@ -19,28 +19,7 @@ export const PRIVILEGED_MONITOR_USERS_INDEX_MAPPING: MappingProperties = {
'user.name': {
type: 'keyword',
},
'labels.is_privileged': {
type: 'boolean',
},
};
export const PRIVILEGED_MONITOR_GROUPS_INDEX_MAPPING: MappingProperties = {
'event.ingested': {
type: 'date',
},
'@timestamp': {
type: 'date',
},
'group.name': {
type: 'keyword',
},
indexPattern: {
type: 'keyword',
},
nameMatcher: {
type: 'keyword',
},
'labels.is_privileged': {
'user.is_privileged': {
type: 'boolean',
},
};

View file

@ -0,0 +1,15 @@
/*
* 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 { PadPackageInstallationClient } from './pad_package_installation_client';
const createPadPackageInstallationClientMock = () =>
({
init: jest.fn(),
} as unknown as jest.Mocked<PadPackageInstallationClient>);
export const padPackageInstallationClientMock = { create: createPadPackageInstallationClientMock };

View file

@ -0,0 +1,191 @@
/*
* 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 {
BulkInstallResponse,
IBulkInstallPackageError,
} from '@kbn/fleet-plugin/server/services/epm/packages';
import {
bulkInstallPackages,
getInstalledPackages,
getPackages,
} from '@kbn/fleet-plugin/server/services/epm/packages';
import type {
ElasticsearchClient,
IScopedClusterClient,
Logger,
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { DataViewsService } from '@kbn/data-views-plugin/common';
import type { MlJobStats } from '@elastic/elasticsearch/lib/api/types';
import type { Installable, RegistrySearchResult } from '@kbn/fleet-plugin/common';
import type { GetPrivilegedAccessDetectionPackageStatusResponse } from '../../../../../common/api/entity_analytics/privilege_monitoring/privileged_access_detection/status.gen';
interface PadPackageInstallationClientOpts {
logger: Logger;
clusterClient: IScopedClusterClient;
namespace: string;
soClient: SavedObjectsClientContract;
dataViewsService: DataViewsService;
}
export interface PadMlJob {
jobId: string;
description: string | undefined;
state: string;
}
interface JobStatsByJobId {
[key: string]: MlJobStats;
}
export class PadPackageInstallationClient {
private readonly esClient: ElasticsearchClient;
private readonly soClient: SavedObjectsClientContract;
constructor(private readonly opts: PadPackageInstallationClientOpts) {
this.esClient = opts.clusterClient.asCurrentUser;
this.soClient = opts.soClient;
}
private log(level: Exclude<keyof Logger, 'get' | 'log' | 'isLevelEnabled'>, msg: string) {
this.opts.logger[level](
`[Privileged access detection] [namespace: ${this.opts.namespace}] ${msg}`
);
}
private async getCurrentlyInstalledPADPackage() {
const installedPadPackages = await getInstalledPackages({
savedObjectsClient: this.soClient,
esClient: this.esClient,
nameQuery: 'pad',
perPage: 100,
sortOrder: 'asc',
});
return installedPadPackages.items.find((installedPackage) => installedPackage.name === 'pad');
}
private async getJobs() {
const jobs = (await this.esClient.ml.getJobs({ job_id: 'pad' })).jobs.filter(
(each) => each.custom_settings.created_by === 'ml-module-pad'
);
const jobStatsByJobId = (await this.esClient.ml.getJobStats({ job_id: 'pad' })).jobs.reduce(
(accumulator, nextJobStats) => ({ ...accumulator, [nextJobStats.job_id]: nextJobStats }),
{} as JobStatsByJobId
);
return jobs.map((eachJob) => ({
job_id: eachJob.job_id,
description: eachJob.description,
state: jobStatsByJobId[eachJob.job_id].state,
}));
}
public async getStatus(): Promise<GetPrivilegedAccessDetectionPackageStatusResponse> {
const packageInstalled = !!(await this.getCurrentlyInstalledPADPackage());
const packageInstallationStatus = packageInstalled ? 'complete' : 'incomplete';
if (!packageInstalled) {
// even if there happen to be jobs that match our search criteria, if the package is not installed, we consider the ML installation incomplete and the jobs to not be associated with our privileged access detection usage
return {
package_installation_status: packageInstallationStatus,
ml_module_setup_status: 'incomplete',
jobs: [],
};
}
try {
const jobs = await this.getJobs();
const mlModuleSetupStatus = jobs.length > 0 ? 'complete' : 'incomplete';
return {
package_installation_status: packageInstallationStatus,
ml_module_setup_status: mlModuleSetupStatus,
jobs,
};
} catch (e) {
this.log(
'info',
'The privileged access detection package is installed, but the ML jobs are not yet set up.'
);
return {
package_installation_status: packageInstallationStatus,
ml_module_setup_status: 'incomplete',
jobs: [],
};
}
}
private async getPrivilegedAccessDetectionPackageFromRegistry() {
return (
await getPackages({
savedObjectsClient: this.soClient,
category: 'security',
prerelease: true,
})
).find((availablePackage) => availablePackage.name === 'pad');
}
public async installPrivilegedAccessDetectionPackage() {
const alreadyInstalledPadPackage = await this.getCurrentlyInstalledPADPackage();
if (alreadyInstalledPadPackage) {
return {
message: 'Privileged access detection package was already installed.',
};
}
const availablePadPackage = await this.getPrivilegedAccessDetectionPackageFromRegistry();
if (!availablePadPackage) {
this.log('info', 'Privileged access detection package was not found');
throw new Error('Privileged access detection package was not found.');
}
const installationResponse = await this.installPackage(availablePadPackage);
if (!installationResponse || this.isInstallError(installationResponse)) {
throw new Error(
`Failed to install privileged access detection package. ${installationResponse?.error}`
);
}
return {
message: 'Successfully installed privileged access detection package.',
};
}
/**
* A type guard function to determine if the bulk package installation response is an error type
*
* @param potentialInstallResponseError the `BulkInstallResponse` to check type of
*/
private isInstallError = (
potentialInstallResponseError: BulkInstallResponse
): potentialInstallResponseError is IBulkInstallPackageError => {
return (potentialInstallResponseError as IBulkInstallPackageError).error !== undefined;
};
private async installPackage(installablePackage: Installable<RegistrySearchResult>) {
this.log(
'info',
`Installing Privileged Access Detection package: ${installablePackage.name} ${installablePackage.version}`
);
const bulkInstallResponse = await bulkInstallPackages({
savedObjectsClient: this.soClient,
packagesToInstall: [
{ name: installablePackage.name, version: installablePackage.version, prerelease: true },
],
esClient: this.esClient,
spaceId: this.opts.namespace,
skipIfInstalled: true,
force: true,
});
return bulkInstallResponse.length > 0 ? bulkInstallResponse[0] : undefined;
}
}

View file

@ -0,0 +1,63 @@
/*
* 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 { IKibanaResponse, Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { GetPrivilegedAccessDetectionPackageStatusResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/privileged_access_detection/status.gen';
import { API_VERSIONS, APP_ID } from '../../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../../types';
export const padGetStatusRoute = (
router: EntityAnalyticsRoutesDeps['router'],
logger: Logger,
config: EntityAnalyticsRoutesDeps['config']
) => {
router.versioned
.get({
access: 'public',
path: '/api/entity_analytics/privileged_user_monitoring/pad/status',
security: {
authz: {
requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`],
},
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {},
},
async (
context,
request,
response
): Promise<IKibanaResponse<GetPrivilegedAccessDetectionPackageStatusResponse>> => {
const siemResponse = buildSiemResponse(response);
const secSol = await context.securitySolution;
try {
const clientResponse = await secSol.getPadPackageInstallationClient().getStatus();
return response.ok({
body: {
...clientResponse,
},
});
} catch (e) {
const error = transformError(e);
logger.error(`Error with PAD installation: ${error.message}`);
return siemResponse.error({
statusCode: error.statusCode,
body: error.message,
});
}
}
);
};

View file

@ -0,0 +1,65 @@
/*
* 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 { IKibanaResponse, Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { InstallPrivilegedAccessDetectionPackageResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/privileged_access_detection/install.gen';
import { API_VERSIONS, APP_ID } from '../../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../../types';
export const padInstallRoute = (
router: EntityAnalyticsRoutesDeps['router'],
logger: Logger,
config: EntityAnalyticsRoutesDeps['config']
) => {
router.versioned
.post({
access: 'public',
path: '/api/entity_analytics/privileged_user_monitoring/pad/install',
security: {
authz: {
requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`],
},
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {},
},
async (
context,
request,
response
): Promise<IKibanaResponse<InstallPrivilegedAccessDetectionPackageResponse>> => {
const siemResponse = buildSiemResponse(response);
const secSol = await context.securitySolution;
try {
const clientResponse = await secSol
.getPadPackageInstallationClient()
.installPrivilegedAccessDetectionPackage();
return response.ok({
body: {
...clientResponse,
},
});
} catch (e) {
const error = transformError(e);
logger.error(`Error PAD installation: ${error.message}`);
return siemResponse.error({
statusCode: error.statusCode,
body: error.message,
});
}
}
);
};

View file

@ -19,6 +19,9 @@ import {
uploadUsersCSVRoute,
} from './users';
import { padInstallRoute } from './privileged_access_detection/pad_install';
import { padGetStatusRoute } from './privileged_access_detection/pad_get_installation_status';
export const registerPrivilegeMonitoringRoutes = ({
router,
logger,
@ -26,6 +29,8 @@ export const registerPrivilegeMonitoringRoutes = ({
}: EntityAnalyticsRoutesDeps) => {
initPrivilegeMonitoringEngineRoute(router, logger, config);
healthCheckPrivilegeMonitoringRoute(router, logger, config);
padInstallRoute(router, logger, config);
padGetStatusRoute(router, logger, config);
searchPrivilegeMonitoringIndicesRoute(router, logger, config);
monitoringEntitySourceRoute(router, logger, config);
createUserRoute(router, logger);

View file

@ -12,6 +12,7 @@ import type { KibanaRequest, Logger, RequestHandlerContext } from '@kbn/core/ser
import type { BuildFlavor } from '@kbn/config';
import { EntityDiscoveryApiKeyType } from '@kbn/entityManager-plugin/server/saved_objects';
import { MonitoringEntitySourceDataClient } from './lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client';
import { PadPackageInstallationClient } from './lib/entity_analytics/privilege_monitoring/privileged_access_detection/pad_package_installation_client';
import { DEFAULT_SPACE_ID } from '../common/constants';
import type { Immutable } from '../common/endpoint/types';
import type { EndpointAuthz } from '../common/endpoint/types/authz';
@ -275,6 +276,15 @@ export class RequestContextFactory implements IRequestContextFactory {
soClient: coreContext.savedObjects.client,
});
}),
getPadPackageInstallationClient: memoize(() => {
return new PadPackageInstallationClient({
clusterClient: coreContext.elasticsearch.client,
soClient: coreContext.savedObjects.client,
logger: options.logger,
namespace: getSpaceId(),
dataViewsService,
});
}),
getEntityStoreDataClient: memoize(() => {
// why are we defining this here, but other places we do it inline?
const clusterClient = coreContext.elasticsearch.client;

View file

@ -22,6 +22,7 @@ import type { Readable } from 'stream';
import type { AuditLogger } from '@kbn/security-plugin-types-server';
import type { InferenceClient } from '@kbn/inference-common';
import type { DataViewsService } from '@kbn/data-views-plugin/common';
import type { PadPackageInstallationClient } from './lib/entity_analytics/privilege_monitoring/privileged_access_detection/pad_package_installation_client';
import type { EndpointAppContextService } from './endpoint/endpoint_app_context_services';
import type { Immutable } from '../common/endpoint/types';
import { AppClient } from './client';
@ -71,6 +72,7 @@ export interface SecuritySolutionApiRequestHandlerContext {
getEntityStoreDataClient: () => EntityStoreDataClient;
getPrivilegeMonitoringDataClient: () => PrivilegeMonitoringDataClient;
getMonitoringEntitySourceDataClient: () => MonitoringEntitySourceDataClient;
getPadPackageInstallationClient: () => PadPackageInstallationClient;
getSiemRuleMigrationsClient: () => SiemRuleMigrationsClient;
getInferenceClient: () => InferenceClient;
getAssetInventoryClient: () => AssetInventoryDataClient;

View file

@ -966,6 +966,18 @@ finalize it.
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.query(props.query);
},
getPrivilegedAccessDetectionPackageStatus(kibanaSpace: string = 'default') {
return supertest
.get(
routeWithNamespace(
'/api/entity_analytics/privileged_user_monitoring/pad/status',
kibanaSpace
)
)
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
getProtectionUpdatesNote(
props: GetProtectionUpdatesNoteProps,
kibanaSpace: string = 'default'
@ -1341,6 +1353,18 @@ providing you with the most current and effective threat detection capabilities.
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send(props.body as object);
},
installPrivilegedAccessDetectionPackage(kibanaSpace: string = 'default') {
return supertest
.post(
routeWithNamespace(
'/api/entity_analytics/privileged_user_monitoring/pad/install',
kibanaSpace
)
)
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
internalUploadAssetCriticalityRecords(kibanaSpace: string = 'default') {
return supertest
.post(routeWithNamespace('/internal/asset_criticality/upload_csv', kibanaSpace))

View file

@ -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 '@kbn/expect';
import { FtrProviderContext } from '../../../../../ftr_provider_context';
import { dataViewRouteHelpersFactory } from '../../../utils/data_view';
export default ({ getService }: FtrProviderContext) => {
const api = getService('securitySolutionApi');
const supertest = getService('supertest');
const log = getService('log');
const kibanaServer = getService('kibanaServer');
const uninstallPackage = async () => {
try {
await kibanaServer.request({
method: 'DELETE',
path: '/api/fleet/epm/packages/pad/0.5.0',
retries: 1,
});
} catch (e) {
log.info('No existing package found for deletion, continuing');
}
};
const deleteMLJobs = async () => {
try {
return await kibanaServer.request({
method: 'POST',
path: '/internal/ml/jobs/delete_jobs',
body: {
jobIds: [
'pad_linux_high_count_privileged_process_events_by_user',
'pad_linux_high_median_process_command_line_entropy_by_user',
'pad_linux_rare_process_executed_by_user',
'pad_okta_high_sum_concurrent_sessions_by_user',
'pad_okta_rare_host_name_by_user',
'pad_okta_rare_region_name_by_user',
'pad_okta_rare_source_ip_by_user',
'pad_okta_spike_in_group_application_assignment_changes',
'pad_okta_spike_in_group_lifecycle_changes',
'pad_okta_spike_in_group_membership_changes',
'pad_okta_spike_in_group_privilege_changes',
'pad_okta_spike_in_user_lifecycle_management_changes',
'pad_windows_high_count_group_management_events',
'pad_windows_high_count_special_logon_events',
'pad_windows_high_count_special_privilege_use_events',
'pad_windows_high_count_user_account_management_events',
'pad_windows_rare_device_by_user',
'pad_windows_rare_group_name_by_user',
'pad_windows_rare_privilege_assigned_to_user',
'pad_windows_rare_region_name_by_user',
'pad_windows_rare_source_ip_by_user',
],
deleteUserAnnotations: true,
deleteAlertingRules: false,
},
headers: {
'elastic-api-version': '1',
},
});
} catch (e) {
log.info('Job deletion unsuccessful, but continuing');
}
};
const simulateMlModuleSetupFromUI = async () => {
try {
return await kibanaServer.request({
method: 'POST',
path: '/internal/ml/modules/setup/pad-ml',
body: {
indexPatternName:
'logs-*,ml_okta_multiple_user_sessions_pad.all,ml_windows_privilege_type_pad.all',
useDedicatedIndex: false,
startDatafeed: false,
},
headers: {
'elastic-api-version': '1',
},
});
} catch (e) {
log.error(e);
throw Error('Failed to setup ML module');
}
};
describe('@ess @serverless @skipInServerlessMKI Entity Privilege Monitoring APIs', () => {
const dataView = dataViewRouteHelpersFactory(supertest);
before(async () => {
await dataView.create('security-solution');
await uninstallPackage();
await deleteMLJobs();
});
after(async () => {
await dataView.delete('security-solution');
await uninstallPackage();
await deleteMLJobs();
});
describe('privileged access detection status and installation APIs', () => {
it('should be able to successfully install the package', async () => {
const statusResponseBeforeInstallation =
await api.getPrivilegedAccessDetectionPackageStatus();
if (statusResponseBeforeInstallation.status !== 200) {
log.error(`Retrieving status failed`);
log.error(JSON.stringify(statusResponseBeforeInstallation.body));
}
expect(statusResponseBeforeInstallation.status).eql(200);
const {
package_installation_status: packageInstallationStatusBeforeInstallation,
ml_module_setup_status: mlModuleSetupStatusBeforeInstallation,
} = statusResponseBeforeInstallation.body;
expect(packageInstallationStatusBeforeInstallation).eql('incomplete');
expect(mlModuleSetupStatusBeforeInstallation).eql('incomplete');
const installationResponse = await api.installPrivilegedAccessDetectionPackage('default');
expect(installationResponse.status).eql(200);
expect(installationResponse.body.message).eql(
'Successfully installed privileged access detection package.'
);
const mlModuleSetupResponse = await simulateMlModuleSetupFromUI();
expect(mlModuleSetupResponse.status).eql(200);
log.info('Privileged access detection installation was successful');
const statusResponseAfterInstallation =
await api.getPrivilegedAccessDetectionPackageStatus();
if (statusResponseAfterInstallation.status !== 200) {
log.error(`Retrieving status failed`);
log.error(JSON.stringify(statusResponseAfterInstallation.body));
}
expect(statusResponseAfterInstallation.status).eql(200);
const {
package_installation_status: packageInstallationStatusAfterInstallation,
ml_module_setup_status: mlModuleSetupStatusAfterInstallation,
jobs: jobsAfterInstallation,
} = statusResponseAfterInstallation.body;
expect(packageInstallationStatusAfterInstallation).eql('complete');
expect(mlModuleSetupStatusAfterInstallation).eql('complete');
expect(jobsAfterInstallation.length).greaterThan(20);
});
});
});
};