[SecuritySolution] Re-score entities after an asset criticality bulk upload (#187577)

## Summary

* Create new API `/internal/risk_score/engine/schedule_now`
* Fix storybook, it was broken due to some mock changes
* Update status API to return task status
* Add schedule risk engine panel to bulk asset criticality final step


![Screenshot 2024-08-27 at 10 06
25](https://github.com/user-attachments/assets/bc29a9ea-5ae5-49ce-8289-83aa43c8d4b1)


### How to test it?
* Enable asset criticality in stack management / Advanced settings
* Enable the risk engine
* Open the asset criticality page inside the Manage section
* Upload a valid file
* Verify that the new panel is present and that you can schedule the
risk engine run by pressing the button

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Mark Hopkin <mark.hopkin@elastic.co>
Co-authored-by: natasha-moore-elastic <137783811+natasha-moore-elastic@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2024-09-05 12:16:38 +02:00 committed by GitHub
parent d0f5d79527
commit d18c833e0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1132 additions and 80 deletions

View file

@ -0,0 +1,31 @@
/*
* 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: Risk Scoring API
* version: 2023-10-31
*/
import { z } from '@kbn/zod';
export type RiskEngineScheduleNowResponse = z.infer<typeof RiskEngineScheduleNowResponse>;
export const RiskEngineScheduleNowResponse = z.object({
success: z.boolean().optional(),
});
export type RiskEngineScheduleNowErrorResponse = z.infer<typeof RiskEngineScheduleNowErrorResponse>;
export const RiskEngineScheduleNowErrorResponse = z.object({
message: z.string(),
full_error: z.string(),
});
export type ScheduleRiskEngineNowResponse = z.infer<typeof ScheduleRiskEngineNowResponse>;
export const ScheduleRiskEngineNowResponse = RiskEngineScheduleNowResponse;

View file

@ -0,0 +1,62 @@
openapi: 3.0.0
info:
version: '2023-10-31'
title: Risk Scoring API
description: These APIs allow the consumer to manage Entity Risk Scores within Entity Analytics.
servers:
- url: 'http://{kibana_host}:{port}'
variables:
kibana_host:
default: localhost
port:
default: '5601'
paths:
/api/risk_score/engine/schedule_now:
post:
x-labels: [ess, serverless]
x-codegen-enabled: true
operationId: ScheduleRiskEngineNow
summary: Schedule the risk engine to run as soon as possible
requestBody:
content:
application/json: {}
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineScheduleNowResponse'
'400':
description: Task manager is unavailable
content:
application/json:
schema:
$ref: '../common/common.schema.yaml#/components/schemas/TaskManagerUnavailableResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineScheduleNowErrorResponse'
components:
schemas:
RiskEngineScheduleNowResponse:
type: object
properties:
success:
type: boolean
RiskEngineScheduleNowErrorResponse:
type: object
required:
- message
- full_error
properties:
message:
type: string
full_error:
type: string

View file

@ -21,6 +21,26 @@ export const RiskEngineStatus = z.enum(['NOT_INSTALLED', 'DISABLED', 'ENABLED'])
export type RiskEngineStatusEnum = typeof RiskEngineStatus.enum;
export const RiskEngineStatusEnum = RiskEngineStatus.enum;
export type RiskEngineTaskStatusValues = z.infer<typeof RiskEngineTaskStatusValues>;
export const RiskEngineTaskStatusValues = z.enum([
'idle',
'claiming',
'running',
'failed',
'should_delete',
'unrecognized',
'dead_letter',
]);
export type RiskEngineTaskStatusValuesEnum = typeof RiskEngineTaskStatusValues.enum;
export const RiskEngineTaskStatusValuesEnum = RiskEngineTaskStatusValues.enum;
export type RiskEngineTaskStatus = z.infer<typeof RiskEngineTaskStatus>;
export const RiskEngineTaskStatus = z.object({
status: RiskEngineTaskStatusValues,
runAt: z.string().datetime(),
startedAt: z.string().datetime().optional(),
});
export type RiskEngineStatusResponse = z.infer<typeof RiskEngineStatusResponse>;
export const RiskEngineStatusResponse = z.object({
legacy_risk_engine_status: RiskEngineStatus,
@ -29,6 +49,7 @@ export const RiskEngineStatusResponse = z.object({
* Indicates whether the maximum amount of risk engines has been reached
*/
is_max_amount_of_risk_engines_reached: z.boolean(),
risk_engine_task_status: RiskEngineTaskStatus.optional(),
});
export type GetRiskEngineStatusResponse = z.infer<typeof GetRiskEngineStatusResponse>;

View file

@ -27,6 +27,33 @@ components:
- 'NOT_INSTALLED'
- 'DISABLED'
- 'ENABLED'
RiskEngineTaskStatusValues:
type: string
enum:
- 'idle'
- 'claiming'
- 'running'
- 'failed'
- 'should_delete'
- 'unrecognized'
- 'dead_letter'
RiskEngineTaskStatus:
type: object
required:
- status
- runAt
properties:
status:
$ref: '#/components/schemas/RiskEngineTaskStatusValues'
runAt:
type: string
format: date-time
startedAt:
type: string
format: date-time
RiskEngineStatusResponse:
type: object
required:
@ -41,3 +68,5 @@ components:
is_max_amount_of_risk_engines_reached:
description: Indicates whether the maximum amount of risk engines has been reached
type: boolean
risk_engine_task_status:
$ref: '#/components/schemas/RiskEngineTaskStatus'

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { INTERNAL_RISK_SCORE_URL } from '../risk_score/constants';
import { INTERNAL_RISK_SCORE_URL, PUBLIC_RISK_SCORE_URL } from '../risk_score/constants';
export const RISK_ENGINE_URL = `${INTERNAL_RISK_SCORE_URL}/engine` as const;
export const RISK_ENGINE_STATUS_URL = `${RISK_ENGINE_URL}/status` as const;
export const RISK_ENGINE_INIT_URL = `${RISK_ENGINE_URL}/init` as const;
@ -13,6 +13,10 @@ export const RISK_ENGINE_DISABLE_URL = `${RISK_ENGINE_URL}/disable` as const;
export const RISK_ENGINE_PRIVILEGES_URL = `${RISK_ENGINE_URL}/privileges` as const;
export const RISK_ENGINE_SETTINGS_URL = `${RISK_ENGINE_URL}/settings` as const;
// Public Risk Score routes
export const PUBLIC_RISK_ENGINE_URL = `${PUBLIC_RISK_SCORE_URL}/engine` as const;
export const RISK_ENGINE_SCHEDULE_NOW_URL = `${RISK_ENGINE_URL}/schedule_now` as const;
export const MAX_SPACES_COUNT = 1;
type ClusterPrivilege = 'manage_index_templates' | 'manage_transform';

View file

@ -9,6 +9,7 @@
* Internal Risk Score routes
*/
export const INTERNAL_RISK_SCORE_URL = '/internal/risk_score' as const;
export const PUBLIC_RISK_SCORE_URL = '/api/risk_score' as const;
export const DEV_TOOL_PREBUILT_CONTENT =
`${INTERNAL_RISK_SCORE_URL}/prebuilt_content/dev_tool/{console_id}` as const;
export const devToolPrebuiltContentUrl = (spaceId: string, consoleId: string) =>

View file

@ -256,6 +256,34 @@ paths:
summary: List Asset Criticality Records
tags:
- Security Solution Entity Analytics API
/api/risk_score/engine/schedule_now:
post:
operationId: ScheduleRiskEngineNow
requestBody:
content:
application/json: {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineScheduleNowResponse'
description: Successful response
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/TaskManagerUnavailableResponse'
description: Task manager is unavailable
default:
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineScheduleNowErrorResponse'
description: Unexpected error
summary: Schedule the risk engine to run as soon as possible
tags:
- Security Solution Entity Analytics API
components:
schemas:
AssetCriticalityBulkUploadErrorItem:
@ -328,6 +356,33 @@ components:
- host.name
- user.name
type: string
RiskEngineScheduleNowErrorResponse:
type: object
properties:
full_error:
type: string
message:
type: string
required:
- message
- full_error
RiskEngineScheduleNowResponse:
type: object
properties:
success:
type: boolean
TaskManagerUnavailableResponse:
description: Task manager is unavailable
type: object
properties:
message:
type: string
status_code:
minimum: 400
type: integer
required:
- status_code
- message
securitySchemes:
BasicAuth:
scheme: basic

View file

@ -256,6 +256,34 @@ paths:
summary: List Asset Criticality Records
tags:
- Security Solution Entity Analytics API
/api/risk_score/engine/schedule_now:
post:
operationId: ScheduleRiskEngineNow
requestBody:
content:
application/json: {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineScheduleNowResponse'
description: Successful response
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/TaskManagerUnavailableResponse'
description: Task manager is unavailable
default:
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineScheduleNowErrorResponse'
description: Unexpected error
summary: Schedule the risk engine to run as soon as possible
tags:
- Security Solution Entity Analytics API
components:
schemas:
AssetCriticalityBulkUploadErrorItem:
@ -328,6 +356,33 @@ components:
- host.name
- user.name
type: string
RiskEngineScheduleNowErrorResponse:
type: object
properties:
full_error:
type: string
message:
type: string
required:
- message
- full_error
RiskEngineScheduleNowResponse:
type: object
properties:
success:
type: boolean
TaskManagerUnavailableResponse:
description: Task manager is unavailable
type: object
properties:
message:
type: string
status_code:
minimum: 400
type: integer
required:
- status_code
- message
securitySchemes:
BasicAuth:
scheme: basic

View file

@ -14,17 +14,39 @@ import {
ViewDashboardSteps,
} from './types';
import { ProductLine, ProductTier } from './configs';
import { useCurrentUser, useKibana } from '../../../lib/kibana';
import type { AppContextTestRender } from '../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../mock/endpoint';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
const mockedUseKibana = mockUseKibana();
const mockedStorageGet = jest.fn();
const mockedStorageSet = jest.fn();
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
return {
...original,
useCurrentUser: jest.fn().mockReturnValue({ fullName: 'UserFullName' }),
useKibana: () => ({
mockedUseKibana,
services: {
...mockedUseKibana.services,
storage: {
...mockedUseKibana.services.storage,
get: mockedStorageGet,
set: mockedStorageSet,
},
},
}),
};
});
jest.mock('./toggle_panel');
jest.mock('../../../lib/kibana');
jest.mock('../../../hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false),
}));
(useCurrentUser as jest.Mock).mockReturnValue({ fullName: 'UserFullName' });
describe('OnboardingComponent', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
@ -89,7 +111,7 @@ describe('OnboardingComponent', () => {
describe('AVC 2024 Results banner', () => {
beforeEach(() => {
(useKibana().services.storage.get as jest.Mock).mockReturnValue(true);
mockedStorageGet.mockReturnValue(true);
});
afterEach(() => {
jest.clearAllMocks();
@ -112,14 +134,11 @@ describe('OnboardingComponent', () => {
render();
renderResult.getByTestId('euiDismissCalloutButton').click();
expect(renderResult.queryByTestId('avcResultsBanner')).toBeNull();
expect(useKibana().services.storage.set).toHaveBeenCalledWith(
'securitySolution.showAvcBanner',
false
);
expect(mockedStorageSet).toHaveBeenCalledWith('securitySolution.showAvcBanner', false);
});
it('should stay dismissed if it has been closed once', () => {
(useKibana().services.storage.get as jest.Mock).mockReturnValueOnce(false);
mockedStorageGet.mockReturnValueOnce(false);
render();
expect(renderResult.queryByTestId('avcResultsBanner')).toBeNull();
});

View file

@ -27,21 +27,10 @@ export const localStorageMock = (): IStorage => {
};
};
const createStorageMock = (storeMock: IStorage): Storage => {
const storage = new Storage(storeMock);
return {
store: storeMock,
get: jest.fn((...args) => storage.get(...args)),
clear: jest.fn((...args) => storage.clear(...args)),
set: jest.fn((...args) => storage.set(...args)),
remove: jest.fn((...args) => storage.remove(...args)),
} as Storage;
};
export const createSecuritySolutionStorageMock = () => {
const localStorage = localStorageMock();
return {
localStorage,
storage: createStorageMock(localStorage),
storage: new Storage(localStorage),
};
};

View file

@ -6,8 +6,9 @@
*/
import { useMemo } from 'react';
import type { UploadAssetCriticalityRecordsResponse } from '../../../common/api/entity_analytics/asset_criticality/upload_asset_criticality_csv.gen';
import type { RiskEngineScheduleNowResponse } from '../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen';
import type { DisableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_disable_route.gen';
import type { UploadAssetCriticalityRecordsResponse } from '../../../common/api/entity_analytics/asset_criticality/upload_asset_criticality_csv.gen';
import type { RiskEngineStatusResponse } from '../../../common/api/entity_analytics/risk_engine/engine_status_route.gen';
import type { InitRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_init_route.gen';
import type { EnableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_enable_route.gen';
@ -38,6 +39,7 @@ import {
ASSET_CRITICALITY_PUBLIC_CSV_UPLOAD_URL,
RISK_SCORE_ENTITY_CALCULATION_URL,
API_VERSIONS,
RISK_ENGINE_SCHEDULE_NOW_URL,
} from '../../../common/constants';
import type { SnakeToCamelCase } from '../common/utils';
import { useKibana } from '../../common/lib/kibana/kibana_react';
@ -104,6 +106,15 @@ export const useEntityAnalyticsRoutes = () => {
method: 'POST',
});
/**
* Enable risk score engine
*/
const scheduleNowRiskEngine = () =>
http.fetch<RiskEngineScheduleNowResponse>(RISK_ENGINE_SCHEDULE_NOW_URL, {
version: API_VERSIONS.public.v1,
method: 'POST',
});
/**
* Calculate and stores risk score for an entity
*/
@ -235,6 +246,7 @@ export const useEntityAnalyticsRoutes = () => {
initRiskEngine,
enableRiskEngine,
disableRiskEngine,
scheduleNowRiskEngine,
fetchRiskEnginePrivileges,
fetchAssetCriticalityPrivileges,
createAssetCriticality,

View file

@ -4,8 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import type { RiskEngineStatusResponse } from '../../../../common/api/entity_analytics/risk_engine/engine_status_route.gen';
import { RiskEngineStatusEnum } from '../../../../common/api/entity_analytics/risk_engine/engine_status_route.gen';
import { useEntityAnalyticsRoutes } from '../api';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
@ -36,31 +38,47 @@ export const useIsNewRiskScoreModuleInstalled = (): RiskScoreModuleStatus => {
return { isLoading: false, installed: !!riskEngineStatus?.isNewRiskScoreModuleInstalled };
};
export const useRiskEngineStatus = () => {
interface RiskEngineStatus extends RiskEngineStatusResponse {
isUpdateAvailable: boolean;
isNewRiskScoreModuleInstalled: boolean;
isNewRiskScoreModuleAvailable: boolean;
}
export const useRiskEngineStatus = (
queryOptions: Pick<
UseQueryOptions<unknown, unknown, RiskEngineStatus, string[]>,
'refetchInterval' | 'structuralSharing'
> = {}
) => {
const isNewRiskScoreModuleAvailable = useIsExperimentalFeatureEnabled('riskScoringRoutesEnabled');
const { fetchRiskEngineStatus } = useEntityAnalyticsRoutes();
return useQuery(FETCH_RISK_ENGINE_STATUS, async ({ signal }) => {
if (!isNewRiskScoreModuleAvailable) {
return useQuery(
FETCH_RISK_ENGINE_STATUS,
async ({ signal }) => {
if (!isNewRiskScoreModuleAvailable) {
return {
isUpdateAvailable: false,
isNewRiskScoreModuleInstalled: false,
isNewRiskScoreModuleAvailable,
risk_engine_status: null,
legacy_risk_engine_status: null,
is_max_amount_of_risk_engines_reached: false,
risk_engine_task_status: null,
};
}
const response = await fetchRiskEngineStatus({ signal });
const isUpdateAvailable =
response?.legacy_risk_engine_status === RiskEngineStatusEnum.ENABLED &&
response.risk_engine_status === RiskEngineStatusEnum.NOT_INSTALLED;
const isNewRiskScoreModuleInstalled =
response.risk_engine_status !== RiskEngineStatusEnum.NOT_INSTALLED;
return {
isUpdateAvailable: false,
isNewRiskScoreModuleInstalled: false,
isUpdateAvailable,
isNewRiskScoreModuleInstalled,
isNewRiskScoreModuleAvailable,
risk_engine_status: null,
legacy_risk_engine_status: null,
is_max_amount_of_risk_engines_reached: false,
...response,
};
}
const response = await fetchRiskEngineStatus({ signal });
const isUpdateAvailable =
response?.legacy_risk_engine_status === RiskEngineStatusEnum.ENABLED &&
response.risk_engine_status === RiskEngineStatusEnum.NOT_INSTALLED;
const isNewRiskScoreModuleInstalled =
response.risk_engine_status !== RiskEngineStatusEnum.NOT_INSTALLED;
return {
isUpdateAvailable,
isNewRiskScoreModuleInstalled,
isNewRiskScoreModuleAvailable,
...response,
};
});
},
queryOptions
);
};

View file

@ -0,0 +1,67 @@
/*
* 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 { act } from '@testing-library/react-hooks';
import { useScheduleNowRiskEngineMutation } from './use_schedule_now_risk_engine_mutation';
import { renderMutation } from '../../../management/hooks/test_utils';
import { RISK_ENGINE_SCHEDULE_NOW_URL } from '../../../../common/constants';
const mockFetch = jest.fn();
jest.mock('../../../common/lib/kibana/kibana_react', () => {
const original = jest.requireActual('../../../common/lib/kibana/kibana_react');
return {
...original,
useKibana: () => ({
...original.useKibana(),
services: {
...original.useKibana().services,
http: { fetch: mockFetch },
},
}),
};
});
const mockInvalidateRiskEngineStatusQuery = jest.fn();
jest.mock('./use_risk_engine_status', () => ({
useInvalidateRiskEngineStatusQuery: () => mockInvalidateRiskEngineStatusQuery,
}));
const mockedScheduledResponse = { test: 'response' };
describe('Schedule rule run hook', () => {
beforeEach(() => {
mockFetch.mockClear();
mockInvalidateRiskEngineStatusQuery.mockClear();
});
it('schedules risk engine run by calling the API', async () => {
mockFetch.mockResolvedValue(mockedScheduledResponse);
const result: ReturnType<typeof useScheduleNowRiskEngineMutation> = await renderMutation(() =>
useScheduleNowRiskEngineMutation()
);
await act(async () => {
const res = await result.mutateAsync();
expect(res).toEqual(mockedScheduledResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(RISK_ENGINE_SCHEDULE_NOW_URL, {
method: 'POST',
version: '2023-10-31',
});
});
});
it('should invalidate the status API when the schedule run is called', async () => {
mockFetch.mockResolvedValue(mockedScheduledResponse);
const result: ReturnType<typeof useScheduleNowRiskEngineMutation> = await renderMutation(() =>
useScheduleNowRiskEngineMutation()
);
await result.mutateAsync();
expect(mockInvalidateRiskEngineStatusQuery).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type {
RiskEngineScheduleNowResponse,
RiskEngineScheduleNowErrorResponse,
} from '../../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen';
import { RISK_ENGINE_SCHEDULE_NOW_URL } from '../../../../common/constants';
import { useEntityAnalyticsRoutes } from '../api';
import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status';
export const SCHEDULE_NOW_RISK_ENGINE_MUTATION_KEY = ['POST', RISK_ENGINE_SCHEDULE_NOW_URL];
export const useScheduleNowRiskEngineMutation = (options?: UseMutationOptions<{}>) => {
const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery();
const { scheduleNowRiskEngine } = useEntityAnalyticsRoutes();
return useMutation<RiskEngineScheduleNowResponse, { body: RiskEngineScheduleNowErrorResponse }>(
() => scheduleNowRiskEngine(),
{
...options,
mutationKey: SCHEDULE_NOW_RISK_ENGINE_MUTATION_KEY,
onSettled: (...args) => {
invalidateRiskEngineStatusQuery();
if (options?.onSettled) {
options.onSettled(...args);
}
},
}
);
};

View file

@ -16,10 +16,10 @@ import {
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { BulkUpsertAssetCriticalityRecordsResponse } from '../../../../../common/entity_analytics/asset_criticality/types';
import { buildAnnotationsFromError } from '../helpers';
import { ScheduleRiskEngineCallout } from './schedule_risk_engine_callout';
export const AssetCriticalityResultStep: React.FC<{
result?: BulkUpsertAssetCriticalityRecordsResponse;
@ -59,10 +59,12 @@ export const AssetCriticalityResultStep: React.FC<{
<>
<EuiCallOut
data-test-subj="asset-criticality-result-step-success"
title={i18n.translate(
'xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.successTitle',
{ defaultMessage: 'Success' }
)}
title={
<FormattedMessage
defaultMessage="success"
id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.successTitle"
/>
}
color="success"
iconType="checkInCircleFilled"
>
@ -71,6 +73,8 @@ export const AssetCriticalityResultStep: React.FC<{
id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.successMessage"
/>
</EuiCallOut>
<EuiSpacer size="s" />
<ScheduleRiskEngineCallout />
<ResultStepFooter onReturn={onReturn} />
</>
);

View file

@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { TestProviders } from '../../../../common/mock';
import { ScheduleRiskEngineCallout } from './schedule_risk_engine_callout';
const oneHourFromNow = () => {
const date = new Date();
date.setHours(date.getHours() + 1);
return date;
};
const thirtyMinutesFromNow = () => {
const date = new Date();
date.setMinutes(date.getMinutes() + 30);
return date;
};
const mockUseRiskEngineStatus = jest.fn();
jest.mock('../../../api/hooks/use_risk_engine_status', () => {
const originalModule = jest.requireActual('../../../api/hooks/use_risk_engine_status');
return {
...originalModule,
useRiskEngineStatus: () => mockUseRiskEngineStatus(),
};
});
const mockScheduleNowRiskEngine = jest.fn();
jest.mock('../../../api/hooks/use_schedule_now_risk_engine_mutation', () => {
return {
useScheduleNowRiskEngineMutation: () => ({
isLoading: false,
mutate: mockScheduleNowRiskEngine,
}),
};
});
jest.useFakeTimers();
describe('ScheduleRiskEngineCallout', () => {
it('should show the remaining time for the next risk engine run', async () => {
mockUseRiskEngineStatus.mockReturnValue({
data: {
isNewRiskScoreModuleInstalled: true,
risk_engine_task_status: {
status: 'idle',
runAt: oneHourFromNow().toISOString(),
},
},
});
const { getByText } = render(<ScheduleRiskEngineCallout />, {
wrapper: TestProviders,
});
expect(getByText('The next scheduled engine run is in:')).toBeInTheDocument();
await waitFor(() => {
expect(getByText('an hour')).toBeInTheDocument();
});
});
it('should show "now running" status when the risk engine status is "running"', () => {
mockUseRiskEngineStatus.mockReturnValue({
data: {
isNewRiskScoreModuleInstalled: true,
risk_engine_task_status: {
status: 'running',
runAt: oneHourFromNow().toISOString(),
},
},
});
const { getByText } = render(<ScheduleRiskEngineCallout />, {
wrapper: TestProviders,
});
expect(getByText('Now running')).toBeInTheDocument();
});
it('should show "now running" status when the next schedule run is in the past', () => {
mockUseRiskEngineStatus.mockReturnValue({
data: {
isNewRiskScoreModuleInstalled: true,
risk_engine_task_status: {
status: 'idle',
runAt: new Date().toISOString(), // past date
},
},
});
jest.advanceTimersByTime(100); // advance time
const { getByText } = render(<ScheduleRiskEngineCallout />, {
wrapper: TestProviders,
});
expect(getByText('Now running')).toBeInTheDocument();
});
it('should update the count down time when time has passed', () => {
mockUseRiskEngineStatus.mockReturnValueOnce({
data: {
isNewRiskScoreModuleInstalled: true,
risk_engine_task_status: {
status: 'idle',
runAt: oneHourFromNow(),
},
},
});
const { getByText, rerender } = render(<ScheduleRiskEngineCallout />, {
wrapper: TestProviders,
});
expect(getByText('an hour')).toBeInTheDocument();
// simulate useQuery re-render after fetching data
mockUseRiskEngineStatus.mockReturnValueOnce({
data: {
isNewRiskScoreModuleInstalled: true,
risk_engine_task_status: {
status: 'idle',
runAt: thirtyMinutesFromNow(),
},
},
});
rerender(<ScheduleRiskEngineCallout />);
expect(getByText('30 minutes')).toBeInTheDocument();
});
it('should call the run risk engine api when button is clicked', () => {
mockUseRiskEngineStatus.mockReturnValue({
data: {
isNewRiskScoreModuleInstalled: true,
risk_engine_task_status: {
status: 'idle',
runAt: new Date().toISOString(), // past date
},
},
});
const { getByText } = render(<ScheduleRiskEngineCallout />, {
wrapper: TestProviders,
});
fireEvent.click(getByText('Recalculate entity risk scores now'));
expect(mockScheduleNowRiskEngine).toHaveBeenCalled();
});
it('should not show the callout if the risk engine is not installed', () => {
mockUseRiskEngineStatus.mockReturnValue({
data: {
isNewRiskScoreModuleInstalled: false,
},
});
const { queryByTestId } = render(<ScheduleRiskEngineCallout />, {
wrapper: TestProviders,
});
expect(queryByTestId('risk-engine-callout')).toBeNull();
});
});

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiHorizontalRule,
EuiText,
EuiFlexItem,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { formatTimeFromNow } from '../helpers';
import { useScheduleNowRiskEngineMutation } from '../../../api/hooks/use_schedule_now_risk_engine_mutation';
import { useRiskEngineStatus } from '../../../api/hooks/use_risk_engine_status';
const TEN_SECONDS = 10000;
export const ScheduleRiskEngineCallout: React.FC = () => {
const { data: riskEngineStatus, isLoading: isRiskEngineStatusLoading } = useRiskEngineStatus({
refetchInterval: TEN_SECONDS,
structuralSharing: false, // Force the component to rerender after every Risk Engine Status API call
});
const { addSuccess, addError } = useAppToasts();
const { isLoading: isLoadingRiskEngineSchedule, mutate: scheduleRiskEngineMutation } =
useScheduleNowRiskEngineMutation({
onSuccess: () =>
addSuccess(
i18n.translate(
'xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.successMessage',
{
defaultMessage: 'Risk engine run scheduled',
}
)
),
onError: (error) =>
addError(error, {
title: i18n.translate(
'xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.errorMessage',
{
defaultMessage: 'Risk engine schedule failed',
}
),
}),
});
const { status, runAt } = riskEngineStatus?.risk_engine_task_status || {};
const isRunning = useMemo(
() => status === 'running' || (!!runAt && new Date(runAt) < new Date()),
[runAt, status]
);
const countDownText = useMemo(
() =>
isRunning
? i18n.translate(
'xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.nowRunningMessage',
{
defaultMessage: 'Now running',
}
)
: formatTimeFromNow(riskEngineStatus?.risk_engine_task_status?.runAt),
[isRunning, riskEngineStatus?.risk_engine_task_status?.runAt]
);
const scheduleRiskEngine = useCallback(() => {
scheduleRiskEngineMutation();
}, [scheduleRiskEngineMutation]);
if (!riskEngineStatus?.isNewRiskScoreModuleInstalled) {
return null;
}
return (
<EuiCallOut
data-test-subj="risk-engine-callout"
title={
<FormattedMessage
defaultMessage="Entity risk score"
id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.calloutTitle"
/>
}
color="primary"
iconType="iInCircle"
>
<FormattedMessage
defaultMessage="The assigned criticality levels will impact entity risk scores on the next engine run."
id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.calloutText"
/>
<EuiHorizontalRule />
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiText size="xs">
<FormattedMessage
defaultMessage="The next scheduled engine run is in:"
id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.scheduleText"
/>
<b>{` ${countDownText}`}</b>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="play"
size="xs"
onClick={scheduleRiskEngine}
isLoading={isLoadingRiskEngineSchedule || isRiskEngineStatusLoading || isRunning}
>
<FormattedMessage
defaultMessage="Recalculate entity risk scores now"
id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.riskEngine.runNowButton"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import moment from 'moment';
import type {
FilePickerState,
ValidationStepState,
@ -49,3 +50,12 @@ export const buildAnnotationsFromError = (
return annotations;
};
export const formatTimeFromNow = (time: string | undefined): string => {
if (!time) {
return '';
}
const scheduleTime = moment(time);
return scheduleTime.fromNow(true);
};

View file

@ -16,4 +16,5 @@ export enum RiskEngineAuditActions {
RISK_ENGINE_CONFIGURATION_GET = 'risk_engine_configuration_get',
RISK_ENGINE_DISABLE_LEGACY_ENGINE = 'risk_engine_disable_legacy_engine',
RISK_ENGINE_REMOVE_TASK = 'risk_engine_remove_task',
RISK_ENGINE_SCHEDULE_NOW = 'risk_engine_schedule_now',
}

View file

@ -24,6 +24,7 @@ import type { RiskScoreDataClient } from '../risk_score/risk_score_data_client';
import { removeRiskScoringTask, startRiskScoringTask } from '../risk_score/tasks';
import { RiskEngineAuditActions } from './audit';
import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../audit';
import { getRiskScoringTaskStatus, scheduleNow } from '../risk_score/tasks/risk_scoring_task';
interface InitOpts {
namespace: string;
@ -109,11 +110,22 @@ export class RiskEngineDataClient {
savedObjectsClient: this.options.soClient,
});
public async getStatus({ namespace }: { namespace: string }) {
public async getStatus({
namespace,
taskManager,
}: {
namespace: string;
taskManager?: TaskManagerStartContract;
}) {
const riskEngineStatus = await this.getCurrentStatus();
const legacyRiskEngineStatus = await this.getLegacyStatus({ namespace });
const isMaxAmountOfRiskEnginesReached = await this.getIsMaxAmountOfRiskEnginesReached();
const taskStatus =
riskEngineStatus === 'ENABLED' && taskManager
? await getRiskScoringTaskStatus({ namespace, taskManager })
: undefined;
this.options.auditLogger?.log({
message: 'User checked if the risk engine is enabled',
event: {
@ -124,7 +136,12 @@ export class RiskEngineDataClient {
},
});
return { riskEngineStatus, legacyRiskEngineStatus, isMaxAmountOfRiskEnginesReached };
return {
riskEngineStatus,
legacyRiskEngineStatus,
isMaxAmountOfRiskEnginesReached,
taskStatus,
};
}
public async enableRiskEngine({ taskManager }: { taskManager: TaskManagerStartContract }) {
@ -199,6 +216,32 @@ export class RiskEngineDataClient {
});
}
public async scheduleNow({ taskManager }: { taskManager: TaskManagerStartContract }) {
const riskEngineStatus = await this.getCurrentStatus();
if (riskEngineStatus !== 'ENABLED') {
throw new Error(
`The risk engine must be enable to schedule a run. Current status: ${riskEngineStatus}`
);
}
this.options.auditLogger?.log({
message: 'User scheduled a risk engine run',
event: {
action: RiskEngineAuditActions.RISK_ENGINE_SCHEDULE_NOW,
category: AUDIT_CATEGORY.DATABASE,
type: AUDIT_TYPE.ACCESS,
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
return scheduleNow({
taskManager,
namespace: this.options.namespace,
logger: this.options.logger,
});
}
/**
* Delete all risk engine resources.
*

View file

@ -11,15 +11,17 @@ import { riskEngineStatusRoute } from './status';
import { riskEnginePrivilegesRoute } from './privileges';
import { riskEngineSettingsRoute } from './settings';
import type { EntityAnalyticsRoutesDeps } from '../../types';
import { riskEngineScheduleNowRoute } from './schedule_now';
export const registerRiskEngineRoutes = ({
router,
getStartServices,
}: EntityAnalyticsRoutesDeps) => {
riskEngineStatusRoute(router);
riskEngineStatusRoute(router, getStartServices);
riskEngineInitRoute(router, getStartServices);
riskEngineEnableRoute(router, getStartServices);
riskEngineDisableRoute(router, getStartServices);
riskEngineScheduleNowRoute(router, getStartServices);
riskEngineSettingsRoute(router);
riskEnginePrivilegesRoute(router, getStartServices);
};

View file

@ -0,0 +1,76 @@
/*
* 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 { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { RiskEngineScheduleNowResponse } from '../../../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen';
import {
API_VERSIONS,
APP_ID,
RISK_ENGINE_SCHEDULE_NOW_URL,
} from '../../../../../common/constants';
import { TASK_MANAGER_UNAVAILABLE_ERROR } from './translations';
import { withRiskEnginePrivilegeCheck } from '../risk_engine_privileges';
import type { EntityAnalyticsRoutesDeps } from '../../types';
import { RiskEngineAuditActions } from '../audit';
import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit';
export const riskEngineScheduleNowRoute = (
router: EntityAnalyticsRoutesDeps['router'],
getStartServices: EntityAnalyticsRoutesDeps['getStartServices']
) => {
router.versioned
.post({
access: 'public',
path: RISK_ENGINE_SCHEDULE_NOW_URL,
options: {
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
})
.addVersion(
{ version: API_VERSIONS.public.v1, validate: {} },
withRiskEnginePrivilegeCheck(getStartServices, async (context, request, response) => {
const securitySolution = await context.securitySolution;
securitySolution.getAuditLogger()?.log({
message: 'User attempted to schedule the risk engine.',
event: {
action: RiskEngineAuditActions.RISK_ENGINE_SCHEDULE_NOW,
category: AUDIT_CATEGORY.DATABASE,
type: AUDIT_TYPE.CHANGE,
outcome: AUDIT_OUTCOME.UNKNOWN,
},
});
const siemResponse = buildSiemResponse(response);
const [_, { taskManager }] = await getStartServices();
const riskEngineClient = securitySolution.getRiskEngineDataClient();
if (!taskManager) {
return siemResponse.error({
statusCode: 400,
body: TASK_MANAGER_UNAVAILABLE_ERROR,
});
}
try {
await riskEngineClient.scheduleNow({ taskManager });
const body: RiskEngineScheduleNowResponse = { success: true };
return response.ok({ body });
} catch (e) {
const error = transformError(e);
return siemResponse.error({
statusCode: error.statusCode,
body: { message: error.message, full_error: JSON.stringify(e) },
bypassErrorFormat: true,
});
}
})
);
};

View file

@ -12,7 +12,10 @@ import type { RiskEngineStatusResponse } from '../../../../../common/api/entity_
import { RISK_ENGINE_STATUS_URL, APP_ID } from '../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../types';
export const riskEngineStatusRoute = (router: EntityAnalyticsRoutesDeps['router']) => {
export const riskEngineStatusRoute = (
router: EntityAnalyticsRoutesDeps['router'],
getStartServices: EntityAnalyticsRoutesDeps['getStartServices']
) => {
router.versioned
.get({
access: 'internal',
@ -29,20 +32,27 @@ export const riskEngineStatusRoute = (router: EntityAnalyticsRoutesDeps['router'
const securitySolution = await context.securitySolution;
const riskEngineClient = securitySolution.getRiskEngineDataClient();
const spaceId = securitySolution.getSpaceId();
const [_, { taskManager }] = await getStartServices();
try {
const { riskEngineStatus, legacyRiskEngineStatus, isMaxAmountOfRiskEnginesReached } =
await riskEngineClient.getStatus({
namespace: spaceId,
});
return response.ok({
body: {
risk_engine_status: riskEngineStatus,
legacy_risk_engine_status: legacyRiskEngineStatus,
is_max_amount_of_risk_engines_reached: isMaxAmountOfRiskEnginesReached,
},
const {
riskEngineStatus,
legacyRiskEngineStatus,
isMaxAmountOfRiskEnginesReached,
taskStatus,
} = await riskEngineClient.getStatus({
namespace: spaceId,
taskManager,
});
const body: RiskEngineStatusResponse = {
risk_engine_status: riskEngineStatus,
legacy_risk_engine_status: legacyRiskEngineStatus,
is_max_amount_of_risk_engines_reached: isMaxAmountOfRiskEnginesReached,
risk_engine_task_status: taskStatus,
};
return response.ok({ body });
} catch (e) {
const error = transformError(e);

View file

@ -19,8 +19,11 @@ import {
startRiskScoringTask,
removeRiskScoringTask,
runTask,
getRiskScoringTaskStatus,
scheduleNow,
} from './risk_scoring_task';
import type { ConfigType } from '../../../../config';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
const ISO_8601_PATTERN = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
@ -589,4 +592,63 @@ describe('Risk Scoring Task', () => {
});
});
});
describe('getRiskScoringTaskStatus()', () => {
it('returns the task status', async () => {
const runAt = new Date();
const startedAt = new Date();
mockTaskManagerStart.get.mockResolvedValueOnce(
Promise.resolve({
id: '123',
scheduledAt: new Date(),
attempts: 0,
status: TaskStatus.Idle,
startedAt,
runAt,
retryAt: new Date(),
ownerId: null,
taskType: 'test',
params: {},
state: {},
})
);
const status = await getRiskScoringTaskStatus({
taskManager: mockTaskManagerStart,
namespace: 'default',
});
expect(status).toEqual({
runAt: runAt.toISOString(),
status: 'idle',
startedAt: startedAt.toISOString(),
});
});
});
describe('scheduleNow()', () => {
it('schedules the task to run now', async () => {
await scheduleNow({
taskManager: mockTaskManagerStart,
logger: mockLogger,
namespace: 'default',
});
expect(mockTaskManagerStart.runSoon).toHaveBeenCalledWith(
'risk_engine:risk_scoring:default:0.0.1'
);
});
it('logs an error if the task could not be scheduled', async () => {
mockTaskManagerStart.runSoon.mockRejectedValueOnce(new Error('whoops'));
await expect(
scheduleNow({
taskManager: mockTaskManagerStart,
logger: mockLogger,
namespace: 'default',
})
).rejects.toThrowError('whoops');
});
});
});

View file

@ -12,6 +12,7 @@ import type {
ConcreteTaskInstance,
TaskManagerSetupContract,
TaskManagerStartContract,
TaskStatus,
} from '@kbn/task-manager-plugin/server';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server';
import type { AuditLogger } from '@kbn/security-plugin-types-server';
@ -134,6 +135,29 @@ export const registerRiskScoringTask = ({
});
};
export interface RiskScoringTaskStatus {
status: TaskStatus;
runAt: string; // next schedule run
startedAt: string | undefined; // only available if task is running
}
export const getRiskScoringTaskStatus = async ({
namespace,
taskManager,
}: {
namespace: string;
taskManager: TaskManagerStartContract;
}): Promise<RiskScoringTaskStatus> => {
const taskId = getTaskId(namespace);
const task = await taskManager.get(taskId);
return {
status: task.status,
runAt: task.runAt.toISOString(),
startedAt: task.startedAt?.toISOString(),
};
};
export const startRiskScoringTask = async ({
logger,
namespace,
@ -332,6 +356,26 @@ export const runTask = async ({
throw e;
}
};
export const scheduleNow = async ({
logger,
namespace,
taskManager,
}: {
logger: Logger;
namespace: string;
taskManager: TaskManagerStartContract;
}) => {
const taskId = getTaskId(namespace);
const log = logFactory(logger, taskId);
log('attempting to schedule task to run now');
try {
await taskManager.runSoon(taskId);
} catch (e) {
logger.warn(`[task ${taskId}]: error scheduling task now, received ${e.message}`);
throw e;
}
};
const createTaskRunnerFactory =
({

View file

@ -961,6 +961,13 @@ detection engine rules.
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send(props.body as object);
},
scheduleRiskEngineNow() {
return supertest
.post('/api/risk_score/engine/schedule_now')
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
/**
* Find and/or aggregate detection alerts that match the given query.
*/

View file

@ -19,5 +19,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./asset_criticality_privileges'));
loadTestFile(require.resolve('./asset_criticality_csv_upload'));
loadTestFile(require.resolve('./risk_score_entity_calculation'));
loadTestFile(require.resolve('./risk_engine_schedule_now'));
});
}

View file

@ -351,11 +351,13 @@ export default ({ getService }: FtrProviderContext) => {
const status2 = await riskEngineRoutes.getStatus();
expect(status2.body).to.eql({
risk_engine_status: 'ENABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
is_max_amount_of_risk_engines_reached: true,
});
expect(status2.body.risk_engine_status).to.be('ENABLED');
expect(status2.body.legacy_risk_engine_status).to.be('NOT_INSTALLED');
expect(status2.body.is_max_amount_of_risk_engines_reached).to.be(true);
expect(status2.body.risk_engine_task_status.runAt).to.be.a('string');
expect(status2.body.risk_engine_task_status.status).to.be('idle');
expect(status2.body.risk_engine_task_status.startedAt).to.be(undefined);
await riskEngineRoutes.disable();
const status3 = await riskEngineRoutes.getStatus();
@ -369,11 +371,13 @@ export default ({ getService }: FtrProviderContext) => {
await riskEngineRoutes.enable();
const status4 = await riskEngineRoutes.getStatus();
expect(status4.body).to.eql({
risk_engine_status: 'ENABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
is_max_amount_of_risk_engines_reached: true,
});
expect(status4.body.risk_engine_status).to.be('ENABLED');
expect(status4.body.legacy_risk_engine_status).to.be('NOT_INSTALLED');
expect(status4.body.is_max_amount_of_risk_engines_reached).to.be(true);
expect(status4.body.risk_engine_task_status.runAt).to.be.a('string');
expect(status4.body.risk_engine_task_status.status).to.be('idle');
expect(status4.body.risk_engine_task_status.startedAt).to.be(undefined);
});
it('should return status of legacy risk engine', async () => {
@ -390,11 +394,13 @@ export default ({ getService }: FtrProviderContext) => {
const status2 = await riskEngineRoutes.getStatus();
expect(status2.body).to.eql({
risk_engine_status: 'ENABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
is_max_amount_of_risk_engines_reached: true,
});
expect(status2.body.risk_engine_status).to.be('ENABLED');
expect(status2.body.legacy_risk_engine_status).to.be('NOT_INSTALLED');
expect(status2.body.is_max_amount_of_risk_engines_reached).to.be(true);
expect(status2.body.risk_engine_task_status.runAt).to.be.a('string');
expect(status2.body.risk_engine_task_status.status).to.be('idle');
expect(status2.body.risk_engine_task_status.startedAt).to.be(undefined);
});
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { deleteAllAlerts, deleteAllRules } from '../../../../../common/utils/security_solution';
import {
buildDocument,
cleanRiskEngine,
clearLegacyDashboards,
clearLegacyTransforms,
createAndSyncRuleAndAlertsFactory,
riskEngineRouteHelpersFactory,
waitForRiskScoresToBePresent,
} from '../../utils';
import { FtrProviderContext } from '../../../../ftr_provider_context';
import { dataGeneratorFactory } from '../../../detections_response/utils';
export default ({ getService }: FtrProviderContext) => {
const es = getService('es');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest);
const log = getService('log');
describe('@ess @serverless @serverlessQA init_and_status_apis', () => {
const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log });
const { indexListOfDocuments } = dataGeneratorFactory({
es,
index: 'ecs_compliant',
log,
});
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/ecs_compliant');
});
afterEach(async () => {
await cleanRiskEngine({ kibanaServer, es, log });
await clearLegacyTransforms({ es, log });
await clearLegacyDashboards({ supertest, log });
await deleteAllAlerts(supertest, log, es);
await deleteAllRules(supertest, log);
});
it('should run the risk engine when "scheduleNow" is called', async () => {
const firstDocumentId = uuidv4();
await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, firstDocumentId)]);
await createAndSyncRuleAndAlerts({ query: `id: ${firstDocumentId}` });
await riskEngineRoutes.init();
await waitForRiskScoresToBePresent({ es, log, scoreCount: 1 });
const secondDocumentId = uuidv4();
await indexListOfDocuments([buildDocument({ host: { name: 'host-2' } }, secondDocumentId)]);
await createAndSyncRuleAndAlerts({
query: `id: ${secondDocumentId}`,
});
await riskEngineRoutes.scheduleNow();
// Should index 2 more document on the second run
await waitForRiskScoresToBePresent({ es, log, scoreCount: 3 });
});
});
};

View file

@ -22,6 +22,7 @@ import {
RISK_ENGINE_ENABLE_URL,
RISK_ENGINE_STATUS_URL,
RISK_ENGINE_PRIVILEGES_URL,
RISK_ENGINE_SCHEDULE_NOW_URL,
} from '@kbn/security-solution-plugin/common/constants';
import { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
import { removeLegacyTransforms } from '@kbn/security-solution-plugin/server/lib/entity_analytics/utils/transforms';
@ -572,6 +573,15 @@ export const riskEngineRouteHelpersFactory = (supertest: SuperTest.Agent, namesp
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send()
.expect(expectStatusCode),
scheduleNow: async (expectStatusCode: number = 200) =>
await supertest
.post(routeWithNamespace(RISK_ENGINE_SCHEDULE_NOW_URL, namespace))
.set('kbn-xsrf', 'true')
.set('elastic-api-version', '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send()
.expect(expectStatusCode),
});
interface Credentials {