mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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  ### 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:
parent
d0f5d79527
commit
d18c833e0d
30 changed files with 1132 additions and 80 deletions
|
@ -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;
|
|
@ -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
|
|
@ -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>;
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,10 +38,23 @@ 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 }) => {
|
||||
return useQuery(
|
||||
FETCH_RISK_ENGINE_STATUS,
|
||||
async ({ signal }) => {
|
||||
if (!isNewRiskScoreModuleAvailable) {
|
||||
return {
|
||||
isUpdateAvailable: false,
|
||||
|
@ -48,6 +63,7 @@ export const useRiskEngineStatus = () => {
|
|||
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 });
|
||||
|
@ -62,5 +78,7 @@ export const useRiskEngineStatus = () => {
|
|||
isNewRiskScoreModuleAvailable,
|
||||
...response,
|
||||
};
|
||||
});
|
||||
},
|
||||
queryOptions
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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({
|
||||
const {
|
||||
riskEngineStatus,
|
||||
legacyRiskEngineStatus,
|
||||
isMaxAmountOfRiskEnginesReached,
|
||||
taskStatus,
|
||||
} = await riskEngineClient.getStatus({
|
||||
namespace: spaceId,
|
||||
taskManager,
|
||||
});
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
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);
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 =
|
||||
({
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue