Risk engine initialisation, update from legacy risk engine workflow and status change (#162400)

## Risk engine initialisation, update from legacy risk engine workflow
and status change



dfb75d4a-f447-4346-9760-d0e9685cce39


Green areas it is what was implemented
<img width="1449" alt="Screenshot 2023-08-01 at 15 07 01"
src="4d87887f-1163-45eb-a4e9-a77a685f6565">


This pr has:
- Upgrade workflow. If the user has a risk host or user transforms, we
will show the panel with a call to action for the upgrade.
- Introduce new Saved object to save the configuration of risk engine
- API which is described bellow

It required experiment enabled - **riskScoringRoutesEnabled**
## New API

### /engine/status

#### GET
Get the status of the Risk Engine

##### Description:
Returns the status of both the legacy transform-based risk engine, as
well as the new risk engine

##### Responses

```json
{
  "legacy_risk_engine_status": "NOT_INSTALLED" , "ENABLED"
  ,
  "risk_engine_status": "NOT_INSTALLED" , "ENABLED" , "DISABLED"
}
```

### /engine/init

#### POST
Initialize the Risk Engine

##### Description:
Initializes the Risk Engine by creating the necessary indices and
mappings, removing old transforms, creating saved object configuration

##### Responses

```json
{
  "result": {
    "risk_engine_enabled": true,
    "risk_engine_resources_installed": true,
    "risk_engine_configuration_created": true,
    "legacy_risk_engine_disabled": true,
    "errors": [
      "string"
    ]
  }
}
```

### /engine/enable

#### POST
Enable the Risk Engine
##### Description:
Change saved object configuration and in the future here we will start
task

### /engine/disable

#### POST
Disable the Risk Engine
Change saved object configuration and in the future here we will stop
task

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ryland Herrick <ryalnd@gmail.com>
This commit is contained in:
Khristinin Nikita 2023-08-04 18:03:46 +02:00 committed by GitHub
parent 0144696e73
commit 2bd52fc421
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2507 additions and 369 deletions

View file

@ -42,6 +42,25 @@
}
}
},
"url": {
"dynamic": false,
"properties": {
"slug": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"accessDate": {
"type": "date"
},
"createDate": {
"type": "date"
}
}
},
"usage-counters": {
"dynamic": false,
"properties": {
@ -131,25 +150,6 @@
}
}
},
"url": {
"dynamic": false,
"properties": {
"slug": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"accessDate": {
"type": "date"
},
"createDate": {
"type": "date"
}
}
},
"index-pattern": {
"dynamic": false,
"properties": {
@ -1407,6 +1407,14 @@
"dynamic": false,
"properties": {}
},
"infrastructure-monitoring-log-view": {
"dynamic": false,
"properties": {
"name": {
"type": "text"
}
}
},
"canvas-element": {
"dynamic": false,
"properties": {
@ -2262,14 +2270,6 @@
}
}
},
"infrastructure-monitoring-log-view": {
"dynamic": false,
"properties": {
"name": {
"type": "text"
}
}
},
"ml-job": {
"properties": {
"job_id": {
@ -2938,6 +2938,14 @@
}
}
},
"risk-engine-configuration": {
"dynamic": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"infrastructure-ui-source": {
"dynamic": false,
"properties": {}

View file

@ -8,7 +8,10 @@
import { chunk } from 'lodash';
import type { ToolingLog } from '@kbn/tooling-log';
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core-saved-objects-api-server';
import type {
SavedObjectsBulkDeleteResponse,
SavedObjectsFindResponse,
} from '@kbn/core-saved-objects-api-server';
import { KbnClientRequester, uriencode } from './kbn_client_requester';
@ -30,6 +33,11 @@ interface SavedObjectResponse<Attributes extends Record<string, any>> {
version?: string;
}
interface FindOptions {
type: string;
space?: string;
}
interface GetOptions {
type: string;
id: string;
@ -152,6 +160,22 @@ export class KbnClientSavedObjects {
return data;
}
/**
* Find saved objects
*/
public async find<Attributes extends Record<string, any>>(options: FindOptions) {
this.log.debug('Find saved objects: %j', options);
const { data } = await this.requester.request<SavedObjectsFindResponse<Attributes>>({
description: 'find saved objects',
path: options.space
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_find?type=${options.type}`
: uriencode`/internal/ftr/kbn_client_so/_find?type=${options.type}`,
method: 'GET',
});
return data;
}
/**
* Create a saved object
*/

View file

@ -126,6 +126,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"osquery-pack-asset": "b14101d3172c4b60eb5404696881ce5275c84152",
"osquery-saved-query": "44f1161e165defe3f9b6ad643c68c542a765fcdb",
"query": "8db5d48c62d75681d80d82a42b5642f60d068202",
"risk-engine-configuration": "1b8b175e29ea5311408125c92c6247f502b2d79d",
"rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f",
"sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5",
"search": "8d5184dd5b986d57250b6ffd9ae48a1925e4c7a3",

View file

@ -104,6 +104,7 @@ const previouslyRegisteredTypes = [
'search-telemetry',
'security-rule',
'security-solution-signals-migration',
'risk-engine-configuration',
'server',
'siem-detection-engine-rule-actions',
'siem-detection-engine-rule-execution-info',

View file

@ -246,6 +246,7 @@ describe('split .kibana index into multiple system indices', () => {
"osquery-pack-asset",
"osquery-saved-query",
"query",
"risk-engine-configuration",
"rules-settings",
"sample-data-telemetry",
"search-session",

View file

@ -253,6 +253,12 @@ export const RISK_SCORE_CREATE_STORED_SCRIPT = `${INTERNAL_RISK_SCORE_URL}/store
export const RISK_SCORE_DELETE_STORED_SCRIPT = `${INTERNAL_RISK_SCORE_URL}/stored_scripts/delete`;
export const RISK_SCORE_PREVIEW_URL = `${INTERNAL_RISK_SCORE_URL}/preview`;
export const RISK_ENGINE_URL = `${INTERNAL_RISK_SCORE_URL}/engine`;
export const RISK_ENGINE_STATUS_URL = `${RISK_ENGINE_URL}/status`;
export const RISK_ENGINE_INIT_URL = `${RISK_ENGINE_URL}/init`;
export const RISK_ENGINE_ENABLE_URL = `${RISK_ENGINE_URL}/enable`;
export const RISK_ENGINE_DISABLE_URL = `${RISK_ENGINE_URL}/disable`;
/**
* Public Risk Score routes
*/

View file

@ -9,3 +9,17 @@ export enum RiskScoreEntity {
host = 'host',
user = 'user',
}
export enum RiskEngineStatus {
NOT_INSTALLED = 'NOT_INSTALLED',
DISABLED = 'DISABLED',
ENABLED = 'ENABLED',
}
export interface InitRiskEngineResult {
legacyRiskEngineDisabled: boolean;
riskEngineResourcesInstalled: boolean;
riskEngineConfigurationCreated: boolean;
riskEngineEnabled: boolean;
errors: string[];
}

View file

@ -12,83 +12,142 @@ import {
USER_RISK_PREVIEW_TABLE,
USER_RISK_PREVIEW_TABLE_ROWS,
RISK_PREVIEW_ERROR,
RISK_PREVIEW_ERROR_BUTTON,
LOCAL_QUERY_BAR_SELECTOR,
RISK_SCORE_ERROR_PANEL,
RISK_SCORE_STATUS,
} from '../../screens/entity_analytics_management';
import { deleteRiskScore, installRiskScoreModule } from '../../tasks/api_calls/risk_scores';
import { RiskScoreEntity } from '../../tasks/risk_scores/common';
import { login, visit, visitWithoutDateRange } from '../../tasks/login';
import { cleanKibana } from '../../tasks/common';
import { ENTITY_ANALYTICS_MANAGEMENT_URL, ALERTS_URL } from '../../urls/navigation';
import { getNewRule } from '../../objects/rule';
import { createRule } from '../../tasks/api_calls/rules';
import {
deleteConfiguration,
interceptRiskPreviewError,
interceptRiskPreviewSuccess,
interceptRiskInitError,
} from '../../tasks/api_calls/risk_engine';
import { updateDateRangeInLocalDatePickers } from '../../tasks/date_picker';
import { fillLocalSearchBar, submitLocalSearch } from '../../tasks/search_bar';
import {
riskEngineStatusChange,
updateRiskEngine,
updateRiskEngineConfirm,
previewErrorButtonClick,
} from '../../tasks/entity_analytics';
describe('Entity analytics management page', () => {
before(() => {
cleanKibana();
cy.task('esArchiverLoad', 'all_users');
});
beforeEach(() => {
login();
visitWithoutDateRange(ALERTS_URL);
createRule(getNewRule({ query: 'user.name:* or host.name:*', risk_score: 70 }));
visit(ENTITY_ANALYTICS_MANAGEMENT_URL);
});
after(() => {
cy.task('esArchiverUnload', 'all_users');
});
it('renders page as expected', () => {
cy.get(PAGE_TITLE).should('have.text', 'Entity Risk Score');
});
describe('Risk preview', () => {
it('risk scores reacts on change in datepicker', () => {
const START_DATE = 'Jan 18, 2019 @ 20:33:29.186';
const END_DATE = 'Jan 19, 2019 @ 20:33:29.186';
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
updateDateRangeInLocalDatePickers(LOCAL_QUERY_BAR_SELECTOR, START_DATE, END_DATE);
cy.get(HOST_RISK_PREVIEW_TABLE).contains('No items found');
cy.get(USER_RISK_PREVIEW_TABLE).contains('No items found');
describe(
'Entity analytics management page',
{ env: { ftrConfig: { enableExperimental: ['riskScoringRoutesEnabled'] } } },
() => {
before(() => {
cleanKibana();
cy.task('esArchiverLoad', 'all_users');
});
it('risk scores reacts on change in search bar query', () => {
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
fillLocalSearchBar('host.name: "test-host1"');
submitLocalSearch(LOCAL_QUERY_BAR_SELECTOR);
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1);
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).contains('test-host1');
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).contains('test1');
beforeEach(() => {
login();
visitWithoutDateRange(ALERTS_URL);
createRule(getNewRule({ query: 'user.name:* or host.name:*', risk_score: 70 }));
deleteConfiguration();
visit(ENTITY_ANALYTICS_MANAGEMENT_URL);
});
it('show error panel if API returns error and then try to refetch data', () => {
cy.intercept('POST', '/internal/risk_score/preview', {
statusCode: 500,
after(() => {
cy.task('esArchiverUnload', 'all_users');
});
it('renders page as expected', () => {
cy.get(PAGE_TITLE).should('have.text', 'Entity Risk Score');
});
describe('Risk preview', () => {
it('risk scores reacts on change in datepicker', () => {
const START_DATE = 'Jan 18, 2019 @ 20:33:29.186';
const END_DATE = 'Jan 19, 2019 @ 20:33:29.186';
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
updateDateRangeInLocalDatePickers(LOCAL_QUERY_BAR_SELECTOR, START_DATE, END_DATE);
cy.get(HOST_RISK_PREVIEW_TABLE).contains('No items found');
cy.get(USER_RISK_PREVIEW_TABLE).contains('No items found');
});
cy.get(RISK_PREVIEW_ERROR).contains('Preview failed');
it('risk scores reacts on change in search bar query', () => {
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
cy.intercept('POST', '/internal/risk_score/preview', {
statusCode: 200,
body: {
scores: { host: [], user: [] },
},
fillLocalSearchBar('host.name: "test-host1"');
submitLocalSearch(LOCAL_QUERY_BAR_SELECTOR);
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1);
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).contains('test-host1');
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).contains('test1');
});
cy.get(RISK_PREVIEW_ERROR_BUTTON).click();
it('show error panel if API returns error and then try to refetch data', () => {
interceptRiskPreviewError();
cy.get(RISK_PREVIEW_ERROR).should('not.exist');
cy.get(RISK_PREVIEW_ERROR).contains('Preview failed');
interceptRiskPreviewSuccess();
previewErrorButtonClick();
cy.get(RISK_PREVIEW_ERROR).should('not.exist');
});
});
});
});
describe('Risk engine', () => {
it('should init, disable and enable risk engine', () => {
cy.get(RISK_SCORE_STATUS).should('have.text', 'Off');
// init
riskEngineStatusChange();
cy.get(RISK_SCORE_STATUS).should('have.text', 'On');
// disable
riskEngineStatusChange();
cy.get(RISK_SCORE_STATUS).should('have.text', 'Off');
// enable
riskEngineStatusChange();
cy.get(RISK_SCORE_STATUS).should('have.text', 'On');
});
it('should show error panel if API returns error ', () => {
cy.get(RISK_SCORE_STATUS).should('have.text', 'Off');
interceptRiskInitError();
// init
riskEngineStatusChange();
cy.get(RISK_SCORE_ERROR_PANEL).contains('Sorry, there was an error');
});
it('should update if there legacy risk score installed', () => {
installRiskScoreModule();
visit(ENTITY_ANALYTICS_MANAGEMENT_URL);
cy.get(RISK_SCORE_STATUS).should('not.exist');
updateRiskEngine();
updateRiskEngineConfirm();
cy.get(RISK_SCORE_STATUS).should('have.text', 'On');
deleteRiskScore({ riskScoreEntity: RiskScoreEntity.host, spaceId: 'default' });
});
});
}
);

View file

@ -22,3 +22,15 @@ export const RISK_PREVIEW_ERROR = '[data-test-subj="risk-preview-error"]';
export const RISK_PREVIEW_ERROR_BUTTON = '[data-test-subj="risk-preview-error-button"]';
export const LOCAL_QUERY_BAR_SELECTOR = getDataTestSubjectSelector('risk-score-preview-search-bar');
export const RISK_SCORE_ERROR_PANEL = '[data-test-subj="risk-score-error-panel"]';
export const RISK_SCORE_UPDATE_CANCEL = '[data-test-subj="risk-score-update-cancel"]';
export const RISK_SCORE_UPDATE_CONFIRM = '[data-test-subj="risk-score-update-confirm"]';
export const RISK_SCORE_UDATE_BUTTON = '[data-test-subj="risk-score-update-button"]';
export const RISK_SCORE_STATUS = '[data-test-subj="risk-score-status"]';
export const RISK_SCORE_SWITCH = '[data-test-subj="risk-score-switch"]';

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const deleteConfiguration = () => {
cy.request({
method: 'GET',
url: `/api/saved_objects/_find?type=risk-engine-configuration`,
failOnStatusCode: false,
headers: { 'kbn-xsrf': 'cypress-creds-via-config' },
}).then((res) => {
const savedObjectId = res?.body?.saved_objects?.[0]?.id;
if (savedObjectId) {
return cy.request({
method: 'DELETE',
url: `/api/saved_objects/risk-engine-configuration/${savedObjectId}`,
failOnStatusCode: false,
headers: { 'kbn-xsrf': 'cypress-creds-via-config' },
});
}
});
};
export const interceptRiskPreviewError = () => {
cy.intercept('POST', '/internal/risk_score/preview', {
statusCode: 500,
});
};
export const interceptRiskPreviewSuccess = () => {
cy.intercept('POST', '/internal/risk_score/preview', {
statusCode: 200,
body: {
scores: { host: [], user: [] },
},
});
};
export const interceptRiskInitError = () => {
cy.intercept('POST', '/internal/risk_score/engine/init', {
statusCode: 500,
});
};

View file

@ -290,3 +290,16 @@ export const interceptInstallRiskScoreModule = () => {
export const waitForInstallRiskScoreModule = () => {
cy.wait(['@install'], { requestTimeout: 50000 });
};
export const installRiskScoreModule = () => {
cy.request({
url: RISK_SCORE_URL,
method: 'POST',
body: {
riskScoreEntity: 'host',
},
headers: { 'kbn-xsrf': 'cypress-creds' },
})
.its('status')
.should('eql', 200);
};

View file

@ -12,6 +12,12 @@ import {
ANOMALIES_TABLE_NEXT_PAGE_BUTTON,
} from '../screens/entity_analytics';
import { ENTITY_ANALYTICS_URL } from '../urls/navigation';
import {
RISK_SCORE_UPDATE_CONFIRM,
RISK_SCORE_UDATE_BUTTON,
RISK_SCORE_SWITCH,
RISK_PREVIEW_ERROR_BUTTON,
} from '../screens/entity_analytics_management';
import { visit } from './login';
@ -31,3 +37,20 @@ export const enableJob = () => {
export const navigateToNextPage = () => {
cy.get(ANOMALIES_TABLE_NEXT_PAGE_BUTTON).click();
};
export const riskEngineStatusChange = () => {
cy.get(RISK_SCORE_SWITCH).should('not.have.attr', 'disabled');
cy.get(RISK_SCORE_SWITCH).click();
};
export const updateRiskEngine = () => {
cy.get(RISK_SCORE_UDATE_BUTTON).click();
};
export const updateRiskEngineConfirm = () => {
cy.get(RISK_SCORE_UPDATE_CONFIRM).click();
};
export const previewErrorButtonClick = () => {
cy.get(RISK_PREVIEW_ERROR_BUTTON).click();
};

View file

@ -5,10 +5,22 @@
* 2.0.
*/
import { RISK_SCORE_PREVIEW_URL } from '../../../common/constants';
import {
RISK_ENGINE_STATUS_URL,
RISK_SCORE_PREVIEW_URL,
RISK_ENGINE_ENABLE_URL,
RISK_ENGINE_DISABLE_URL,
RISK_ENGINE_INIT_URL,
} from '../../../common/constants';
import { KibanaServices } from '../../common/lib/kibana';
import type { CalculateScoresResponse } from '../../../server/lib/risk_engine/types';
import type {
CalculateScoresResponse,
EnableRiskEngineResponse,
GetRiskEngineStatusResponse,
InitRiskEngineResponse,
DisableRiskEngineResponse,
} from '../../../server/lib/risk_engine/types';
import type { RiskScorePreviewRequestSchema } from '../../../common/risk_engine/risk_score_preview/request_schema';
/**
@ -27,3 +39,44 @@ export const fetchRiskScorePreview = async ({
signal,
});
};
/**
* Fetches risks engine status
*/
export const fetchRiskEngineStatus = async ({
signal,
}: {
signal?: AbortSignal;
}): Promise<GetRiskEngineStatusResponse> => {
return KibanaServices.get().http.fetch<GetRiskEngineStatusResponse>(RISK_ENGINE_STATUS_URL, {
method: 'GET',
signal,
});
};
/**
* Init risk score engine
*/
export const initRiskEngine = async (): Promise<InitRiskEngineResponse> => {
return KibanaServices.get().http.fetch<InitRiskEngineResponse>(RISK_ENGINE_INIT_URL, {
method: 'POST',
});
};
/**
* Enable risk score engine
*/
export const enableRiskEngine = async (): Promise<EnableRiskEngineResponse> => {
return KibanaServices.get().http.fetch<EnableRiskEngineResponse>(RISK_ENGINE_ENABLE_URL, {
method: 'POST',
});
};
/**
* Disable risk score engine
*/
export const disableRiskEngine = async (): Promise<DisableRiskEngineResponse> => {
return KibanaServices.get().http.fetch<DisableRiskEngineResponse>(RISK_ENGINE_DISABLE_URL, {
method: 'POST',
});
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { disableRiskEngine } from '../api';
import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status';
import type {
EnableRiskEngineResponse,
EnableDisableRiskEngineErrorResponse,
} from '../../../../server/lib/risk_engine/types';
export const DISABLE_RISK_ENGINE_MUTATION_KEY = ['POST', 'DISABLE_RISK_ENGINE'];
export const useDisableRiskEngineMutation = (options?: UseMutationOptions<{}>) => {
const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery();
return useMutation<EnableRiskEngineResponse, EnableDisableRiskEngineErrorResponse>(
() => disableRiskEngine(),
{
...options,
mutationKey: DISABLE_RISK_ENGINE_MUTATION_KEY,
onSettled: (...args) => {
invalidateRiskEngineStatusQuery();
if (options?.onSettled) {
options.onSettled(...args);
}
},
}
);
};

View file

@ -0,0 +1,34 @@
/*
* 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 { enableRiskEngine } from '../api';
import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status';
import type {
EnableRiskEngineResponse,
EnableDisableRiskEngineErrorResponse,
} from '../../../../server/lib/risk_engine/types';
export const ENABLE_RISK_ENGINE_MUTATION_KEY = ['POST', 'ENABLE_RISK_ENGINE'];
export const useEnableRiskEngineMutation = (options?: UseMutationOptions<{}>) => {
const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery();
return useMutation<EnableRiskEngineResponse, EnableDisableRiskEngineErrorResponse>(
() => enableRiskEngine(),
{
...options,
mutationKey: ENABLE_RISK_ENGINE_MUTATION_KEY,
onSettled: (...args) => {
invalidateRiskEngineStatusQuery();
if (options?.onSettled) {
options.onSettled(...args);
}
},
}
);
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { initRiskEngine } from '../api';
import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status';
import type {
InitRiskEngineResponse,
InitRiskEngineError,
} from '../../../../server/lib/risk_engine/types';
export const INIT_RISK_ENGINE_STATUS_KEY = ['POST', 'INIT_RISK_ENGINE'];
export const useInitRiskEngineMutation = (options?: UseMutationOptions<{}>) => {
const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery();
return useMutation<InitRiskEngineResponse, InitRiskEngineError>(() => initRiskEngine(), {
...options,
mutationKey: INIT_RISK_ENGINE_STATUS_KEY,
onSettled: (...args) => {
invalidateRiskEngineStatusQuery();
if (options?.onSettled) {
options.onSettled(...args);
}
},
});
};

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { fetchRiskEngineStatus } from '../api';
import { RiskEngineStatus } from '../../../../common/risk_engine/types';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
const FETCH_RISK_ENGINE_STATUS = ['GET', 'FETCH_RISK_ENGINE_STATUS'];
export const useInvalidateRiskEngineStatusQuery = () => {
const queryClient = useQueryClient();
return useCallback(() => {
queryClient.invalidateQueries(FETCH_RISK_ENGINE_STATUS, {
refetchType: 'active',
});
}, [queryClient]);
};
export const useRiskEngineStatus = () => {
const isRiskEngineEnabled = useIsExperimentalFeatureEnabled('riskScoringRoutesEnabled');
return useQuery(FETCH_RISK_ENGINE_STATUS, async ({ signal }) => {
if (!isRiskEngineEnabled) {
return null;
}
const response = await fetchRiskEngineStatus({ signal });
const isUpdateAvailable =
response?.legacy_risk_engine_status === RiskEngineStatus.ENABLED &&
response.risk_engine_status === RiskEngineStatus.NOT_INSTALLED;
return {
isUpdateAvailable,
...response,
};
});
};

View file

@ -15,6 +15,18 @@ import {
EuiSpacer,
EuiSwitch,
EuiTitle,
EuiLoadingSpinner,
EuiBadge,
EuiButtonEmpty,
EuiButton,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiText,
EuiCallOut,
EuiAccordion,
} from '@elastic/eui';
import {
DETECTION_ENTITY_DASHBOARD,
@ -22,6 +34,11 @@ import {
RISKY_USERS_DOC_LINK,
} from '../../../common/constants';
import * as i18n from '../translations';
import { useRiskEngineStatus } from '../api/hooks/use_risk_engine_status';
import { useInitRiskEngineMutation } from '../api/hooks/use_init_risk_engine_mutation';
import { useEnableRiskEngineMutation } from '../api/hooks/use_enable_risk_engine_mutation';
import { useDisableRiskEngineMutation } from '../api/hooks/use_disable_risk_engine_mutation';
import { RiskEngineStatus } from '../../../common/risk_engine/types';
const docsLinks = [
{
@ -40,40 +57,205 @@ const docsLinks = [
const MIN_WIDTH_TO_PREVENT_LABEL_FROM_MOVING = '50px';
export const RiskScoreEnableSection = () => {
const [checked, setChecked] = useState(false);
const RiskScoreErrorPanel = ({ errors }: { errors: string[] }) => (
<>
<EuiSpacer size="m" />
<EuiCallOut
title={i18n.ERROR_PANEL_TITLE}
color="danger"
iconType="error"
data-test-subj="risk-score-error-panel"
>
<p>{i18n.ERROR_PANEL_MESSAGE}</p>
<EuiAccordion id={'risk-engine-erros'} buttonContent={i18n.ERROR_PANEL_ERRORS}>
<>
{errors.map((error) => (
<div key={error}>
<EuiText size="s">{error}</EuiText>
<EuiSpacer size="s" />
</div>
))}
</>
</EuiAccordion>
</EuiCallOut>
</>
);
export const RiskScoreEnableSection = () => {
const [isModalVisible, setIsModalVisible] = useState(false);
const { data: riskEngineStatus } = useRiskEngineStatus();
const initRiskEngineMutation = useInitRiskEngineMutation({
onSettled: () => {
setIsModalVisible(false);
},
});
const enableRiskEngineMutation = useEnableRiskEngineMutation();
const disableRiskEngineMutation = useDisableRiskEngineMutation();
const currentRiskEngineStatus = riskEngineStatus?.risk_engine_status;
const closeModal = () => setIsModalVisible(false);
const showModal = () => setIsModalVisible(true);
const isLoading =
initRiskEngineMutation.isLoading ||
enableRiskEngineMutation.isLoading ||
disableRiskEngineMutation.isLoading;
const isUpdateAvailable = riskEngineStatus?.isUpdateAvailable;
const btnIsDisabled = !currentRiskEngineStatus || isLoading;
const onSwitchClick = () => {
if (btnIsDisabled) {
return;
}
if (currentRiskEngineStatus === RiskEngineStatus.NOT_INSTALLED) {
initRiskEngineMutation.mutate();
} else if (currentRiskEngineStatus === RiskEngineStatus.ENABLED) {
disableRiskEngineMutation.mutate();
} else if (currentRiskEngineStatus === RiskEngineStatus.DISABLED) {
enableRiskEngineMutation.mutate();
}
};
let modal;
if (isModalVisible) {
modal = (
<EuiModal onClose={closeModal}>
{initRiskEngineMutation.isLoading ? (
<EuiModalHeader>
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiLoadingSpinner size="m" />
<EuiModalHeaderTitle>{i18n.UPDATING_RISK_ENGINE}</EuiModalHeaderTitle>
</EuiFlexGroup>
</EuiModalHeader>
) : (
<>
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.UPDATE_RISK_ENGINE_MODAL_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>
<p>
<b>{i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_1}</b>
{i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_2}
</p>
<EuiSpacer size="s" />
<p>
<b>{i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_1}</b>
{i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_2}
</p>
</EuiText>
<EuiSpacer />
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
color="primary"
data-test-subj="risk-score-update-cancel"
onClick={closeModal}
>
{i18n.UPDATE_RISK_ENGINE_MODAL_BUTTON_NO}
</EuiButtonEmpty>
<EuiButton
color="primary"
data-test-subj="risk-score-update-confirm"
onClick={() => initRiskEngineMutation.mutate()}
fill
>
{i18n.UPDATE_RISK_ENGINE_MODAL_BUTTON_YES}
</EuiButton>
</EuiModalFooter>
</>
)}
</EuiModal>
);
}
let initRiskEngineErrors: string[] = [];
if (initRiskEngineMutation.isError) {
const errorBody = initRiskEngineMutation.error.body.message;
if (errorBody?.full_error?.errors) {
initRiskEngineErrors = errorBody.full_error?.errors;
} else {
initRiskEngineErrors = [errorBody];
}
}
return (
<>
<>
<EuiTitle>
<h2>{i18n.RISK_SCORE_MODULE_STATUS}</h2>
</EuiTitle>
{initRiskEngineMutation.isError && <RiskScoreErrorPanel errors={initRiskEngineErrors} />}
{disableRiskEngineMutation.isError && (
<RiskScoreErrorPanel errors={[disableRiskEngineMutation.error.body.message.message]} />
)}
{enableRiskEngineMutation.isError && (
<RiskScoreErrorPanel errors={[enableRiskEngineMutation.error.body.message.message]} />
)}
<EuiSpacer size="m" />
<EuiFlexItem grow={0}>
{modal}
<EuiHorizontalRule margin="s" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>{i18n.ENTITY_RISK_SCORING}</EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems={'center'}>
<EuiFlexItem css={{ minWidth: MIN_WIDTH_TO_PREVENT_LABEL_FROM_MOVING }}>
{checked ? (
<EuiHealth color="success">{i18n.RISK_SCORE_MODULE_STATUS_ON}</EuiHealth>
) : (
<EuiHealth color="subdued">{i18n.RISK_SCORE_MODULE_STATUS_OFF}</EuiHealth>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
label={''}
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
compressed
aria-describedby={'switchRiskModule'}
/>
</EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems={'baseline'}>
{i18n.ENTITY_RISK_SCORING}
{isUpdateAvailable && <EuiBadge color="success">{i18n.UPDATE_AVAILABLE}</EuiBadge>}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isUpdateAvailable && (
<EuiFlexGroup gutterSize="s" alignItems={'center'}>
<EuiFlexItem>
{initRiskEngineMutation.isLoading && <EuiLoadingSpinner size="m" />}
</EuiFlexItem>
<EuiButtonEmpty
disabled={initRiskEngineMutation.isLoading}
color={'primary'}
onClick={showModal}
data-test-subj="risk-score-update-button"
>
{i18n.START_UPDATE}
</EuiButtonEmpty>
</EuiFlexGroup>
)}
{!isUpdateAvailable && (
<EuiFlexGroup gutterSize="s" alignItems={'center'}>
<EuiFlexItem>{isLoading && <EuiLoadingSpinner size="m" />}</EuiFlexItem>
<EuiFlexItem
css={{ minWidth: MIN_WIDTH_TO_PREVENT_LABEL_FROM_MOVING }}
data-test-subj="risk-score-status"
>
{currentRiskEngineStatus === RiskEngineStatus.ENABLED ? (
<EuiHealth color="success">{i18n.RISK_SCORE_MODULE_STATUS_ON}</EuiHealth>
) : (
<EuiHealth color="subdued">{i18n.RISK_SCORE_MODULE_STATUS_OFF}</EuiHealth>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
label={''}
data-test-subj="risk-score-switch"
checked={currentRiskEngineStatus === RiskEngineStatus.ENABLED}
onChange={onSwitchClick}
compressed
disabled={btnIsDisabled}
aria-describedby={'switchRiskModule'}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="s" />
</EuiFlexItem>

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCallOut, EuiText, EuiFlexGroup, EuiSpacer } from '@elastic/eui';
import * as i18n from '../translations';
import { SecuritySolutionLinkButton } from '../../common/components/links';
import { SecurityPageName } from '../../../common/constants';
export const RiskScoreUpdatePanel = () => {
return (
<EuiCallOut title={i18n.UPDATE_PANEL_TITLE} color="primary" iconType="starEmpty">
<EuiText>{i18n.UPDATE_PANEL_MESSAGE}</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="m">
<SecuritySolutionLinkButton
color="primary"
fill
deepLinkId={SecurityPageName.entityAnalyticsManagement}
>
{i18n.UPDATE_PANEL_GO_TO_MANAGE}
</SecuritySolutionLinkButton>
</EuiFlexGroup>
</EuiCallOut>
);
};

View file

@ -130,3 +130,117 @@ export const PREVIEW_QUERY_ERROR_TITLE = i18n.translate(
defaultMessage: 'Invalid query',
}
);
export const UPDATE_AVAILABLE = i18n.translate('xpack.securitySolution.riskScore.updateAvailable', {
defaultMessage: 'Update available',
});
export const START_UPDATE = i18n.translate('xpack.securitySolution.riskScore.startUpdate', {
defaultMessage: 'Start update',
});
export const UPDATING_RISK_ENGINE = i18n.translate(
'xpack.securitySolution.riskScore.updatingRiskEngine',
{
defaultMessage: 'Updating risk engine...',
}
);
export const UPDATE_RISK_ENGINE_MODAL_TITLE = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModa.title',
{
defaultMessage: 'Do you want to update the entity risk engine?',
}
);
export const UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_1 = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModal.existingUserHost_1',
{
defaultMessage: 'Existing user and host risk score transforms will be deleted',
}
);
export const UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_2 = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModal.existingUserHost_2',
{
defaultMessage: ', as they are no longer required.',
}
);
export const UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_1 = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModal.existingData_1',
{
defaultMessage: 'None of your risk score data will be deleted',
}
);
export const UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_2 = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModal.existingData_2',
{
defaultMessage: ', you will need to remove any old risk score data manually.',
}
);
export const UPDATE_RISK_ENGINE_MODAL_BUTTON_NO = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModal.buttonNo',
{
defaultMessage: 'No, not yet',
}
);
export const UPDATE_RISK_ENGINE_MODAL_BUTTON_YES = i18n.translate(
'xpack.securitySolution.riskScore.updateRiskEngineModal.buttonYes',
{
defaultMessage: 'Yes, update now!',
}
);
export const ERROR_PANEL_TITLE = i18n.translate(
'xpack.securitySolution.riskScore.errorPanel.title',
{
defaultMessage: 'Sorry, there was an error',
}
);
export const ERROR_PANEL_MESSAGE = i18n.translate(
'xpack.securitySolution.riskScore.errorPanel.message',
{
defaultMessage: 'Something went wrong. Try again later.',
}
);
export const ERROR_PANEL_ERRORS = i18n.translate(
'xpack.securitySolution.riskScore.errorPanel.errors',
{
defaultMessage: 'Errors',
}
);
export const UPDATE_PANEL_TITLE = i18n.translate(
'xpack.securitySolution.riskScore.updatePanel.title',
{
defaultMessage: 'New entity risk scoring engine available',
}
);
export const UPDATE_PANEL_MESSAGE = i18n.translate(
'xpack.securitySolution.riskScore.updatePanel.message',
{
defaultMessage:
'A new entity risk scoring engine is available. Update now to get the latest features.',
}
);
export const UPDATE_PANEL_GO_TO_MANAGE = i18n.translate(
'xpack.securitySolution.riskScore.updatePanel.goToManage',
{
defaultMessage: 'Manage',
}
);
export const UPDATE_PANEL_GO_TO_DISMISS = i18n.translate(
'xpack.securitySolution.riskScore.updatePanel.Dismiss',
{
defaultMessage: 'Dismiss',
}
);

View file

@ -32,6 +32,8 @@ import type { UsersComponentsQueryProps } from '../../../users/pages/navigation/
import type { HostsComponentsQueryProps } from '../../../hosts/pages/navigation/types';
import { useDashboardHref } from '../../../../common/hooks/use_dashboard_href';
import { RiskScoresNoDataDetected } from '../risk_score_onboarding/risk_score_no_data_detected';
import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status';
import { RiskScoreUpdatePanel } from '../../../../entity_analytics/components/risk_score_update_panel';
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
margin-top: ${({ theme }) => theme.eui.euiSizeL};
@ -91,6 +93,8 @@ const RiskDetailsTabBodyComponent: React.FC<
timerange,
});
const { data: riskScoreEngineStatus } = useRiskEngineStatus();
const rules = useMemo(() => {
const lastRiskItem = data && data.length > 0 ? data[data.length - 1] : null;
if (lastRiskItem) {
@ -133,6 +137,10 @@ const RiskDetailsTabBodyComponent: React.FC<
return <>{'TODO: Add RiskScore Upsell'}</>;
}
if (riskScoreEngineStatus?.isUpdateAvailable) {
return <RiskScoreUpdatePanel />;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore

View file

@ -16,6 +16,9 @@ import type { inputsModel } from '../../../../common/store';
import { REQUEST_NAMES, useFetch } from '../../../../common/hooks/use_fetch';
import { useRiskScoreToastContent } from './use_risk_score_toast_content';
import { installRiskScoreModule } from './utils';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { SecuritySolutionLinkButton } from '../../../../common/components/links';
import { SecurityPageName } from '../../../../../common/constants';
const RiskScoreEnableButtonComponent = ({
refetch,
@ -35,6 +38,7 @@ const RiskScoreEnableButtonComponent = ({
const { http, notifications, theme, dashboard } = useKibana().services;
const { renderDocLink, renderDashboardLink } = useRiskScoreToastContent(riskScoreEntity);
const { fetch, isLoading } = useFetch(REQUEST_NAMES.ENABLE_RISK_SCORE, installRiskScoreModule);
const isRiskEngineEnabled = useIsExperimentalFeatureEnabled('riskScoringRoutesEnabled');
const onBoardingRiskScore = useCallback(() => {
fetch({
@ -64,19 +68,34 @@ const RiskScoreEnableButtonComponent = ({
]);
return (
<EuiButton
color="primary"
fill
onClick={onBoardingRiskScore}
isLoading={isLoading}
data-test-subj={`enable_${riskScoreEntity}_risk_score`}
disabled={disabled}
>
<FormattedMessage
id="xpack.securitySolution.riskScore.enableButtonTitle"
defaultMessage="Enable"
/>
</EuiButton>
<>
{isRiskEngineEnabled ? (
<SecuritySolutionLinkButton
color="primary"
fill
deepLinkId={SecurityPageName.entityAnalyticsManagement}
>
<FormattedMessage
id="xpack.securitySolution.riskScore.enableButtonTitle"
defaultMessage="Enable"
/>
</SecuritySolutionLinkButton>
) : (
<EuiButton
color="primary"
fill
onClick={onBoardingRiskScore}
isLoading={isLoading}
data-test-subj={`enable_${riskScoreEntity}_risk_score`}
disabled={disabled}
>
<FormattedMessage
id="xpack.securitySolution.riskScore.enableButtonTitle"
defaultMessage="Enable"
/>
</EuiButton>
)}
</>
);
};

View file

@ -6,6 +6,7 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { EuiPanel } from '@elastic/eui';
import { noop } from 'lodash/fp';
import { EnableRiskScore } from '../../../components/risk_score/enable_risk_score';
import type { HostsComponentsQueryProps } from './types';
@ -22,6 +23,8 @@ import {
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { EMPTY_SEVERITY_COUNT, RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskScoresNoDataDetected } from '../../../components/risk_score/risk_score_onboarding/risk_score_no_data_detected';
import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status';
import { RiskScoreUpdatePanel } from '../../../../entity_analytics/components/risk_score_update_panel';
const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable);
@ -46,6 +49,8 @@ export const HostRiskScoreQueryTabBody = ({
getHostRiskScoreFilterQuerySelector(state, hostsModel.HostsType.page)
);
const { data: riskScoreEngineStatus } = useRiskEngineStatus();
const pagination = useMemo(
() => ({
cursorStart: activePage * limit,
@ -95,14 +100,20 @@ export const HostRiskScoreQueryTabBody = ({
return <>{'TODO: Add RiskScore Upsell'}</>;
}
if (riskScoreEngineStatus?.isUpdateAvailable) {
return <RiskScoreUpdatePanel />;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore
{...status}
entityType={RiskScoreEntity.host}
refetch={refetch}
timerange={timerange}
/>
<EuiPanel hasBorder>
<EnableRiskScore
{...status}
entityType={RiskScoreEntity.host}
refetch={refetch}
timerange={timerange}
/>
</EuiPanel>
);
}

View file

@ -24,6 +24,8 @@ import {
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { EMPTY_SEVERITY_COUNT, RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskScoresNoDataDetected } from '../../../components/risk_score/risk_score_onboarding/risk_score_no_data_detected';
import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status';
import { RiskScoreUpdatePanel } from '../../../../entity_analytics/components/risk_score_update_panel';
const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable);
@ -36,6 +38,7 @@ export const UserRiskScoreQueryTabBody = ({
startDate: from,
type,
}: UsersComponentsQueryProps) => {
const { data: riskScoreEngineStatus } = useRiskEngineStatus();
const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []);
const { activePage, limit, sort } = useDeepEqualSelector((state: State) =>
getUserRiskScoreSelector(state)
@ -97,6 +100,10 @@ export const UserRiskScoreQueryTabBody = ({
return <>{'TODO: Add RiskScore Upsell'}</>;
}
if (riskScoreEngineStatus?.isUpdateAvailable) {
return <RiskScoreUpdatePanel />;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore

View file

@ -49,7 +49,6 @@ import { IconPipeline } from '../common/icons/pipeline';
import { IconSavedObject } from '../common/icons/saved_object';
import { IconDashboards } from '../common/icons/dashboards';
import { IconEntityAnalytics } from '../common/icons/entity_analytics';
import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client';
const categories = [
@ -177,6 +176,7 @@ export const links: LinkItem = {
path: ENTITY_ANALYTICS_MANAGEMENT_PATH,
skipUrlState: true,
hideTimeline: true,
capabilities: [`${SERVER_APP_ID}.entity-analytics`],
experimentalKey: 'riskScoringRoutesEnabled',
},
{
@ -218,7 +218,7 @@ export const getManagementFilteredLinks = async (
fleetAuthz && currentUser
? calculateEndpointAuthz(licenseService, fleetAuthz, currentUser.roles)
: getEndpointAuthzInitialState();
const showEntityAnalytics = licenseService.isPlatinumPlus();
const showHostIsolationExceptions =
canAccessHostIsolationExceptions || // access host isolation exceptions is a paid feature, always show the link.
// read host isolation exceptions is not a paid feature, to allow deleting exceptions after a downgrade scenario.
@ -256,5 +256,9 @@ export const getManagementFilteredLinks = async (
linksToExclude.push(SecurityPageName.blocklist);
}
if (!showEntityAnalytics) {
linksToExclude.push(SecurityPageName.entityAnalyticsManagement);
}
return excludeLinks(linksToExclude);
};

View file

@ -35,6 +35,7 @@ import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use
import { getRiskEntityTranslation } from './translations';
import { useKibana } from '../../../../common/lib/kibana';
import { useGlobalFilterQuery } from '../../../../common/hooks/use_global_filter_query';
import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status';
const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskScoreEntity }) => {
const { deleteQuery, setQuery, from, to } = useGlobalTime();
@ -125,6 +126,8 @@ const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskSc
includeAlertsCount: true,
});
const { data: riskScoreEngineStatus } = useRiskEngineStatus();
useQueryInspector({
queryId: entity.tableQueryId,
loading: isTableLoading,
@ -149,6 +152,10 @@ const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskSc
isDeprecated: isDeprecated && !isTableLoading,
};
if (riskScoreEngineStatus?.isUpdateAvailable) {
return null;
}
if (status.isDisabled || status.isDeprecated) {
return (
<EnableRiskScore

View file

@ -25,10 +25,17 @@ import { EntityAnalyticsAnomalies } from '../components/entity_analytics/anomali
import { SiemSearchBar } from '../../common/components/search_bar';
import { InputsModelId } from '../../common/store/inputs/constants';
import { FiltersGlobal } from '../../common/components/filters_global';
import { useRiskEngineStatus } from '../../entity_analytics/api/hooks/use_risk_engine_status';
import { RiskScoreUpdatePanel } from '../../entity_analytics/components/risk_score_update_panel';
import { useHasSecurityCapability } from '../../helper_hooks';
const EntityAnalyticsComponent = () => {
const { data: riskScoreEngineStatus } = useRiskEngineStatus();
const { indicesExist, loading: isSourcererLoading, indexPattern } = useSourcererDataView();
const { isPlatinumOrTrialLicense, capabilitiesFetched } = useMlCapabilities();
const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics');
const isRiskScoreModuleLicenseAvailable =
isPlatinumOrTrialLicense && hasEntityAnalyticsCapability;
return (
<>
@ -48,6 +55,12 @@ const EntityAnalyticsComponent = () => {
<EuiLoadingSpinner size="l" data-test-subj="entityAnalyticsLoader" />
) : (
<EuiFlexGroup direction="column" data-test-subj="entityAnalyticsSections">
{riskScoreEngineStatus?.isUpdateAvailable && isRiskScoreModuleLicenseAvailable && (
<EuiFlexItem>
<RiskScoreUpdatePanel />
</EuiFlexItem>
)}
<EuiFlexItem>
<EntityAnalyticsHeader />
</EuiFlexItem>

View file

@ -10,9 +10,50 @@ import {
createOrUpdateIlmPolicy,
createOrUpdateIndexTemplate,
} from '@kbn/alerting-plugin/server';
import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import {
loggingSystemMock,
elasticsearchServiceMock,
savedObjectsClientMock,
} from '@kbn/core/server/mocks';
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import { RiskEngineDataClient } from './risk_engine_data_client';
import { createDataStream } from './utils/create_datastream';
import * as savedObjectConfig from './utils/saved_object_configuration';
const getSavedObjectConfiguration = (attributes = {}) => ({
page: 1,
per_page: 20,
total: 1,
saved_objects: [
{
type: 'risk-engine-configuration',
id: 'de8ca330-2d26-11ee-bc86-f95bf6192ee6',
namespaces: ['default'],
attributes: {
enabled: false,
...attributes,
},
references: [],
managed: false,
updated_at: '2023-07-28T09:52:28.768Z',
created_at: '2023-07-28T09:12:26.083Z',
version: 'WzE4MzIsMV0=',
coreMigrationVersion: '8.8.0',
score: 0,
},
],
});
const transformsMock = {
count: 1,
transforms: [
{
id: 'ml_hostriskscore_pivot_transform_default',
dest: { index: '' },
source: { index: '' },
},
],
};
jest.mock('@kbn/alerting-plugin/server', () => ({
createOrUpdateComponentTemplate: jest.fn(),
@ -28,6 +69,7 @@ describe('RiskEngineDataClient', () => {
let riskEngineDataClient: RiskEngineDataClient;
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const mockSavedObjectClient = savedObjectsClientMock.create();
const totalFieldsLimit = 1000;
beforeEach(() => {
@ -290,11 +332,344 @@ describe('RiskEngineDataClient', () => {
const error = new Error('There error');
(createOrUpdateIlmPolicy as jest.Mock).mockRejectedValue(error);
await riskEngineDataClient.initializeResources({ namespace: 'default' });
try {
await riskEngineDataClient.initializeResources({ namespace: 'default' });
} catch (e) {
expect(logger.error).toHaveBeenCalledWith(
`Error initializing risk engine resources: ${error.message}`
);
}
});
});
expect(logger.error).toHaveBeenCalledWith(
`Error initializing risk engine resources: ${error.message}`
describe('getStatus', () => {
it('should return initial status', async () => {
const status = await riskEngineDataClient.getStatus({
namespace: 'default',
savedObjectsClient: mockSavedObjectClient,
});
expect(status).toEqual({
riskEngineStatus: 'NOT_INSTALLED',
legacyRiskEngineStatus: 'NOT_INSTALLED',
});
});
describe('saved object exists and transforms not', () => {
beforeEach(() => {
mockSavedObjectClient.find.mockResolvedValue(getSavedObjectConfiguration());
});
afterEach(() => {
mockSavedObjectClient.find.mockReset();
});
it('should return status with enabled true', async () => {
mockSavedObjectClient.find.mockResolvedValue(
getSavedObjectConfiguration({
enabled: true,
})
);
const status = await riskEngineDataClient.getStatus({
namespace: 'default',
savedObjectsClient: mockSavedObjectClient,
});
expect(status).toEqual({
riskEngineStatus: 'ENABLED',
legacyRiskEngineStatus: 'NOT_INSTALLED',
});
});
it('should return status with enabled false', async () => {
mockSavedObjectClient.find.mockResolvedValue(getSavedObjectConfiguration());
const status = await riskEngineDataClient.getStatus({
namespace: 'default',
savedObjectsClient: mockSavedObjectClient,
});
expect(status).toEqual({
riskEngineStatus: 'DISABLED',
legacyRiskEngineStatus: 'NOT_INSTALLED',
});
});
});
describe('legacy transforms', () => {
it('should fetch transforms', async () => {
await riskEngineDataClient.getStatus({
namespace: 'default',
savedObjectsClient: mockSavedObjectClient,
});
expect(esClient.transform.getTransform).toHaveBeenCalledTimes(4);
expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(1, {
transform_id: 'ml_hostriskscore_pivot_transform_default',
});
expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(2, {
transform_id: 'ml_hostriskscore_latest_transform_default',
});
expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(3, {
transform_id: 'ml_userriskscore_pivot_transform_default',
});
expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(4, {
transform_id: 'ml_userriskscore_latest_transform_default',
});
});
it('should return that legacy transform enabled if at least on transform exist', async () => {
esClient.transform.getTransform.mockResolvedValueOnce(transformsMock);
const status = await riskEngineDataClient.getStatus({
namespace: 'default',
savedObjectsClient: mockSavedObjectClient,
});
expect(status).toEqual({
riskEngineStatus: 'NOT_INSTALLED',
legacyRiskEngineStatus: 'ENABLED',
});
esClient.transform.getTransformStats.mockReset();
});
});
});
describe('enableRiskEngine', () => {
afterEach(() => {
mockSavedObjectClient.find.mockReset();
});
it('should return error if saved object not exist', async () => {
mockSavedObjectClient.find.mockResolvedValueOnce({
page: 1,
per_page: 20,
total: 0,
saved_objects: [],
});
expect.assertions(1);
try {
await riskEngineDataClient.enableRiskEngine({
savedObjectsClient: mockSavedObjectClient,
user: { username: 'elastic' } as AuthenticatedUser,
});
} catch (e) {
expect(e.message).toEqual('There no saved object configuration for risk engine');
}
});
it('should update saved object attrubute', async () => {
mockSavedObjectClient.find.mockResolvedValueOnce(getSavedObjectConfiguration());
await riskEngineDataClient.enableRiskEngine({
savedObjectsClient: mockSavedObjectClient,
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(mockSavedObjectClient.update).toHaveBeenCalledWith(
'risk-engine-configuration',
'de8ca330-2d26-11ee-bc86-f95bf6192ee6',
{
enabled: true,
},
{
refresh: 'wait_for',
}
);
});
});
describe('disableRiskEngine', () => {
afterEach(() => {
mockSavedObjectClient.find.mockReset();
});
it('should return error if saved object not exist', async () => {
mockSavedObjectClient.find.mockResolvedValueOnce({
page: 1,
per_page: 20,
total: 0,
saved_objects: [],
});
expect.assertions(1);
try {
await riskEngineDataClient.disableRiskEngine({
savedObjectsClient: mockSavedObjectClient,
user: { username: 'elastic' } as AuthenticatedUser,
});
} catch (e) {
expect(e.message).toEqual('There no saved object configuration for risk engine');
}
});
it('should update saved object attrubute', async () => {
mockSavedObjectClient.find.mockResolvedValueOnce(getSavedObjectConfiguration());
await riskEngineDataClient.disableRiskEngine({
savedObjectsClient: mockSavedObjectClient,
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(mockSavedObjectClient.update).toHaveBeenCalledWith(
'risk-engine-configuration',
'de8ca330-2d26-11ee-bc86-f95bf6192ee6',
{
enabled: false,
},
{
refresh: 'wait_for',
}
);
});
});
describe('init', () => {
const initializeResourcesMock = jest.spyOn(
RiskEngineDataClient.prototype,
'initializeResources'
);
const enableRiskEngineMock = jest.spyOn(RiskEngineDataClient.prototype, 'enableRiskEngine');
const disableLegacyRiskEngineMock = jest.spyOn(
RiskEngineDataClient.prototype,
'disableLegacyRiskEngine'
);
beforeEach(() => {
disableLegacyRiskEngineMock.mockImplementation(() => Promise.resolve(true));
initializeResourcesMock.mockImplementation(() => {
return Promise.resolve();
});
enableRiskEngineMock.mockImplementation(() => {
return Promise.resolve(getSavedObjectConfiguration().saved_objects[0]);
});
jest.spyOn(savedObjectConfig, 'initSavedObjects').mockImplementation(() => {
return Promise.resolve(getSavedObjectConfiguration().saved_objects[0]);
});
});
afterEach(() => {
initializeResourcesMock.mockReset();
enableRiskEngineMock.mockReset();
disableLegacyRiskEngineMock.mockReset();
});
it('success', async () => {
const initResult = await riskEngineDataClient.init({
savedObjectsClient: mockSavedObjectClient,
namespace: 'default',
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(initResult).toEqual({
errors: [],
legacyRiskEngineDisabled: true,
riskEngineConfigurationCreated: true,
riskEngineEnabled: true,
riskEngineResourcesInstalled: true,
});
});
it('should catch error for disableLegacyRiskEngine, but continue', async () => {
disableLegacyRiskEngineMock.mockImplementation(() => {
throw new Error('Error disableLegacyRiskEngineMock');
});
const initResult = await riskEngineDataClient.init({
savedObjectsClient: mockSavedObjectClient,
namespace: 'default',
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(initResult).toEqual({
errors: ['Error disableLegacyRiskEngineMock'],
legacyRiskEngineDisabled: false,
riskEngineConfigurationCreated: true,
riskEngineEnabled: true,
riskEngineResourcesInstalled: true,
});
});
it('should catch error for resource init', async () => {
disableLegacyRiskEngineMock.mockImplementationOnce(() => {
throw new Error('Error disableLegacyRiskEngineMock');
});
const initResult = await riskEngineDataClient.init({
savedObjectsClient: mockSavedObjectClient,
namespace: 'default',
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(initResult).toEqual({
errors: ['Error disableLegacyRiskEngineMock'],
legacyRiskEngineDisabled: false,
riskEngineConfigurationCreated: true,
riskEngineEnabled: true,
riskEngineResourcesInstalled: true,
});
});
it('should catch error for initializeResources and stop', async () => {
initializeResourcesMock.mockImplementationOnce(() => {
throw new Error('Error initializeResourcesMock');
});
const initResult = await riskEngineDataClient.init({
savedObjectsClient: mockSavedObjectClient,
namespace: 'default',
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(initResult).toEqual({
errors: ['Error initializeResourcesMock'],
legacyRiskEngineDisabled: true,
riskEngineConfigurationCreated: false,
riskEngineEnabled: false,
riskEngineResourcesInstalled: false,
});
});
it('should catch error for initSavedObjects and stop', async () => {
jest.spyOn(savedObjectConfig, 'initSavedObjects').mockImplementationOnce(() => {
throw new Error('Error initSavedObjects');
});
const initResult = await riskEngineDataClient.init({
savedObjectsClient: mockSavedObjectClient,
namespace: 'default',
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(initResult).toEqual({
errors: ['Error initSavedObjects'],
legacyRiskEngineDisabled: true,
riskEngineConfigurationCreated: false,
riskEngineEnabled: false,
riskEngineResourcesInstalled: true,
});
});
it('should catch error for enableRiskEngineMock and stop', async () => {
enableRiskEngineMock.mockImplementationOnce(() => {
throw new Error('Error enableRiskEngineMock');
});
const initResult = await riskEngineDataClient.init({
savedObjectsClient: mockSavedObjectClient,
namespace: 'default',
user: { username: 'elastic' } as AuthenticatedUser,
});
expect(initResult).toEqual({
errors: ['Error enableRiskEngineMock'],
legacyRiskEngineDisabled: true,
riskEngineConfigurationCreated: true,
riskEngineEnabled: false,
riskEngineResourcesInstalled: true,
});
});
});
});

View file

@ -6,6 +6,7 @@
*/
import type { Metadata } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import type { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
import {
createOrUpdateComponentTemplate,
@ -15,6 +16,7 @@ import {
import { mappingFromFieldMap } from '@kbn/alerting-plugin/common';
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
import type { Logger, ElasticsearchClient } from '@kbn/core/server';
import {
riskScoreFieldMap,
getIndexPattern,
@ -26,6 +28,20 @@ import {
import { createDataStream } from './utils/create_datastream';
import type { RiskEngineDataWriter as Writer } from './risk_engine_data_writer';
import { RiskEngineDataWriter } from './risk_engine_data_writer';
import type { InitRiskEngineResult } from '../../../common/risk_engine/types';
import { RiskEngineStatus } from '../../../common/risk_engine/types';
import { getLegacyTransforms, removeLegacyTransforms } from './utils/risk_engine_transforms';
import {
updateSavedObjectAttribute,
getConfiguration,
initSavedObjects,
} from './utils/saved_object_configuration';
import type { UpdateConfigOpts, SavedObjectsClients } from './utils/saved_object_configuration';
interface InitOpts extends SavedObjectsClients {
namespace: string;
user: AuthenticatedUser | null | undefined;
}
interface InitializeRiskEngineResourcesOpts {
namespace?: string;
@ -41,6 +57,49 @@ export class RiskEngineDataClient {
private writerCache: Map<string, Writer> = new Map();
constructor(private readonly options: RiskEngineDataClientOpts) {}
public async init({ namespace, savedObjectsClient, user }: InitOpts) {
const result: InitRiskEngineResult = {
legacyRiskEngineDisabled: false,
riskEngineResourcesInstalled: false,
riskEngineConfigurationCreated: false,
riskEngineEnabled: false,
errors: [] as string[],
};
try {
result.legacyRiskEngineDisabled = await this.disableLegacyRiskEngine({ namespace });
} catch (e) {
result.legacyRiskEngineDisabled = false;
result.errors.push(e.message);
}
try {
await this.initializeResources({ namespace });
result.riskEngineResourcesInstalled = true;
} catch (e) {
result.errors.push(e.message);
return result;
}
try {
await initSavedObjects({ savedObjectsClient, user });
result.riskEngineConfigurationCreated = true;
} catch (e) {
result.errors.push(e.message);
return result;
}
try {
await this.enableRiskEngine({ savedObjectsClient, user });
result.riskEngineEnabled = true;
} catch (e) {
result.errors.push(e.message);
return result;
}
return result;
}
public async getWriter({ namespace }: { namespace: string }): Promise<Writer> {
if (this.writerCache.get(namespace)) {
return this.writerCache.get(namespace) as Writer;
@ -57,10 +116,85 @@ export class RiskEngineDataClient {
index,
logger: this.options.logger,
});
this.writerCache.set(namespace, writer);
return writer;
}
public async getStatus({
savedObjectsClient,
namespace,
}: SavedObjectsClients & {
namespace: string;
}) {
const riskEngineStatus = await this.getCurrentStatus({ savedObjectsClient });
const legacyRiskEngineStatus = await this.getLegacyStatus({ namespace });
return { riskEngineStatus, legacyRiskEngineStatus };
}
public async enableRiskEngine({ savedObjectsClient, user }: UpdateConfigOpts) {
// code to run task
return updateSavedObjectAttribute({
savedObjectsClient,
user,
attributes: {
enabled: true,
},
});
}
public async disableRiskEngine({ savedObjectsClient, user }: UpdateConfigOpts) {
// code to stop task
return updateSavedObjectAttribute({
savedObjectsClient,
user,
attributes: {
enabled: false,
},
});
}
public async disableLegacyRiskEngine({ namespace }: { namespace: string }) {
const legacyRiskEngineStatus = await this.getLegacyStatus({ namespace });
if (legacyRiskEngineStatus === RiskEngineStatus.NOT_INSTALLED) {
return true;
}
const esClient = await this.options.elasticsearchClientPromise;
await removeLegacyTransforms({
esClient,
namespace,
});
const newlegacyRiskEngineStatus = await this.getLegacyStatus({ namespace });
return newlegacyRiskEngineStatus === RiskEngineStatus.NOT_INSTALLED;
}
private async getCurrentStatus({ savedObjectsClient }: SavedObjectsClients) {
const configuration = await getConfiguration({ savedObjectsClient });
if (configuration) {
return configuration.enabled ? RiskEngineStatus.ENABLED : RiskEngineStatus.DISABLED;
}
return RiskEngineStatus.NOT_INSTALLED;
}
private async getLegacyStatus({ namespace }: { namespace: string }) {
const esClient = await this.options.elasticsearchClientPromise;
const transforms = await getLegacyTransforms({ namespace, esClient });
if (transforms.length === 0) {
return RiskEngineStatus.NOT_INSTALLED;
}
return RiskEngineStatus.ENABLED;
}
public async initializeResources({
namespace = DEFAULT_NAMESPACE_STRING,
}: InitializeRiskEngineResourcesOpts) {
@ -139,6 +273,7 @@ export class RiskEngineDataClient {
await this.initializeWriter(namespace, indexPatterns.alias);
} catch (error) {
this.options.logger.error(`Error initializing risk engine resources: ${error.message}`);
throw error;
}
}
}

View file

@ -6,3 +6,7 @@
*/
export { riskScorePreviewRoute } from './risk_score_preview_route';
export { riskEngineInitRoute } from './risk_engine_init_route';
export { riskEngineEnableRoute } from './risk_engine_enable_route';
export { riskEngineDisableRoute } from './risk_engine_disable_route';
export { riskEngineStatusRoute } from './risk_engine_status_route';

View file

@ -0,0 +1,52 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import { RISK_ENGINE_DISABLE_URL, APP_ID } from '../../../../common/constants';
import type { SetupPlugins } from '../../../plugin';
import type { SecuritySolutionPluginRouter } from '../../../types';
export const riskEngineDisableRoute = (
router: SecuritySolutionPluginRouter,
logger: Logger,
security: SetupPlugins['security']
) => {
router.post(
{
path: RISK_ENGINE_DISABLE_URL,
validate: {},
options: {
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const securitySolution = await context.securitySolution;
const soClient = (await context.core).savedObjects.client;
const riskEngineClient = securitySolution.getRiskEngineDataClient();
const user = security?.authc.getCurrentUser(request);
try {
await riskEngineClient.disableRiskEngine({
savedObjectsClient: soClient,
user,
});
return response.ok({ body: { success: true } });
} catch (e) {
const error = transformError(e);
return siemResponse.error({
statusCode: error.statusCode,
body: { message: error.message, full_error: JSON.stringify(e) },
});
}
}
);
};

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import { RISK_ENGINE_ENABLE_URL, APP_ID } from '../../../../common/constants';
import type { SetupPlugins } from '../../../plugin';
import type { SecuritySolutionPluginRouter } from '../../../types';
export const riskEngineEnableRoute = (
router: SecuritySolutionPluginRouter,
logger: Logger,
security: SetupPlugins['security']
) => {
router.post(
{
path: RISK_ENGINE_ENABLE_URL,
validate: {},
options: {
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const securitySolution = await context.securitySolution;
const soClient = (await context.core).savedObjects.client;
const riskEngineClient = securitySolution.getRiskEngineDataClient();
const user = security?.authc.getCurrentUser(request);
try {
await riskEngineClient.enableRiskEngine({
savedObjectsClient: soClient,
user,
});
return response.ok({ body: { success: true } });
} catch (e) {
const error = transformError(e);
return siemResponse.error({
statusCode: error.statusCode,
body: { message: error.message, full_error: JSON.stringify(e) },
});
}
}
);
};

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 type { Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import { RISK_ENGINE_INIT_URL, APP_ID } from '../../../../common/constants';
import type { SetupPlugins } from '../../../plugin';
import type { SecuritySolutionPluginRouter } from '../../../types';
export const riskEngineInitRoute = (
router: SecuritySolutionPluginRouter,
logger: Logger,
security: SetupPlugins['security']
) => {
router.post(
{
path: RISK_ENGINE_INIT_URL,
validate: {},
options: {
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const securitySolution = await context.securitySolution;
const soClient = (await context.core).savedObjects.client;
const riskEngineClient = securitySolution.getRiskEngineDataClient();
const spaceId = securitySolution.getSpaceId();
const user = security?.authc.getCurrentUser(request);
try {
const initResult = await riskEngineClient.init({
savedObjectsClient: soClient,
namespace: spaceId,
user,
});
const initResultResponse = {
risk_engine_enabled: initResult.riskEngineEnabled,
risk_engine_resources_installed: initResult.riskEngineResourcesInstalled,
risk_engine_configuration_created: initResult.riskEngineConfigurationCreated,
legacy_risk_engine_disabled: initResult.legacyRiskEngineDisabled,
errors: initResult.errors,
};
if (
!initResult.riskEngineEnabled ||
!initResult.riskEngineResourcesInstalled ||
!initResult.riskEngineConfigurationCreated
) {
return siemResponse.error({
statusCode: 400,
body: {
message: initResultResponse.errors.join('\n'),
full_error: initResultResponse,
},
});
}
return response.ok({ body: { result: initResultResponse } });
} catch (e) {
const error = transformError(e);
return siemResponse.error({
statusCode: error.statusCode,
body: { message: error.message, full_error: JSON.stringify(e) },
});
}
}
);
};

View file

@ -0,0 +1,53 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import { RISK_ENGINE_STATUS_URL, APP_ID } from '../../../../common/constants';
import type { SecuritySolutionPluginRouter } from '../../../types';
export const riskEngineStatusRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => {
router.get(
{
path: RISK_ENGINE_STATUS_URL,
validate: {},
options: {
tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const securitySolution = await context.securitySolution;
const soClient = (await context.core).savedObjects.client;
const riskEngineClient = securitySolution.getRiskEngineDataClient();
const spaceId = securitySolution.getSpaceId();
try {
const result = await riskEngineClient.getStatus({
savedObjectsClient: soClient,
namespace: spaceId,
});
return response.ok({
body: {
risk_engine_status: result.riskEngineStatus,
legacy_risk_engine_status: result.legacyRiskEngineStatus,
},
});
} catch (e) {
const error = transformError(e);
return siemResponse.error({
statusCode: error.statusCode,
body: { message: error.message, full_error: JSON.stringify(e) },
});
}
}
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './risk_engine_configuration_type';

View file

@ -0,0 +1,28 @@
/*
* 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 { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import type { SavedObjectsType } from '@kbn/core/server';
export const riskEngineConfigurationTypeName = 'risk-engine-configuration';
export const riskEngineConfigurationTypeMappings: SavedObjectsType['mappings'] = {
dynamic: false,
properties: {
enabled: {
type: 'boolean',
},
},
};
export const riskEngineConfigurationType: SavedObjectsType = {
name: riskEngineConfigurationTypeName,
indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX,
hidden: false,
namespaceType: 'multiple-isolated',
mappings: riskEngineConfigurationTypeMappings,
};

View file

@ -44,6 +44,56 @@ paths:
$ref: '#/components/schemas/RiskScoresPreviewResponse'
'400':
description: Invalid request
/engine/status:
get:
summary: Get the status of the Risk Engine
description: Returns the status of both the legacy transform-based risk engine, as well as the new risk engine
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineStatusResponse'
/engine/init:
post:
summary: Initialize the Risk Engine
description: Initializes the Risk Engine by creating the necessary indices and mappings, removing old transforms, and starting the new risk engine
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineInitResponse'
/engine/enable:
post:
summary: Enable the Risk Engine
requestBody:
content:
application/json: {}
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineEnableResponse'
/engine/disable:
post:
summary: Disable the Risk Engine
requestBody:
content:
application/json: {}
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RiskEngineDisableResponse'
components:
schemas:
@ -153,7 +203,46 @@ components:
description: A list of risk scores
items:
$ref: '#/components/schemas/RiskScore'
RiskEngineStatusResponse:
type: object
properties:
legacy_risk_engine_status:
$ref: '#/components/schemas/RiskEngineStatus'
risk_engine_status:
$ref: '#/components/schemas/RiskEngineStatus'
RiskEngineInitResponse:
type: object
properties:
result:
type: object
properties:
risk_engine_enabled:
type: boolean
risk_engine_resources_installed:
type: boolean
risk_engine_configuration_created:
type: boolean
legacy_risk_engine_disabled:
type: boolean
errors:
type: array
items:
type: string
RiskEngineEnableResponse:
type: object
properties:
success:
type: boolean
RiskEngineDisableResponse:
type: object
properties:
success:
type: boolean
AfterKeys:
type: object
properties:
@ -326,3 +415,22 @@ components:
- type: 'global_identifier'
host: 0.5
user: 0.1
RiskEngineStatus:
type: string
enum:
- 'NOT_INSTALLED'
- 'DISABLED'
- 'ENABLED'
RiskEngineInitStep:
type: object
required:
- type
- success
properties:
type:
type: string
success:
type: boolean
error:
type: string

View file

@ -14,6 +14,7 @@ import type {
RiskCategories,
RiskWeights,
} from '../../../common/risk_engine';
import type { RiskEngineStatus } from '../../../common/risk_engine/types';
export interface CalculateScoresParams {
afterKeys: AfterKeys;
@ -57,6 +58,49 @@ export interface CalculateScoresResponse {
};
}
export interface GetRiskEngineStatusResponse {
legacy_risk_engine_status: RiskEngineStatus;
risk_engine_status: RiskEngineStatus;
}
interface InitRiskEngineResultResponse {
risk_engine_enabled: boolean;
risk_engine_resources_installed: boolean;
risk_engine_configuration_created: boolean;
legacy_risk_engine_disabled: boolean;
errors: string[];
}
export interface InitRiskEngineResponse {
result: InitRiskEngineResultResponse;
}
export interface InitRiskEngineError {
body: {
message: {
message: string;
full_error: InitRiskEngineResultResponse | undefined;
} & string;
};
}
export interface EnableDisableRiskEngineErrorResponse {
body: {
message: {
message: string;
full_error: string;
};
};
}
export interface EnableRiskEngineResponse {
success: boolean;
}
export interface DisableRiskEngineResponse {
success: boolean;
}
export interface SimpleRiskInput {
id: string;
index: string;
@ -117,3 +161,7 @@ export interface RiskScoreBucket {
};
inputs: SearchResponse;
}
export interface RiskEngineConfiguration {
enabled: boolean;
}

View file

@ -0,0 +1,69 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import type {
TransformGetTransformResponse,
TransformGetTransformTransformSummary,
} from '@elastic/elasticsearch/lib/api/types';
import { RiskScoreEntity } from '../../../../common/search_strategy';
import {
getRiskScorePivotTransformId,
getRiskScoreLatestTransformId,
} from '../../../../common/utils/risk_score_modules';
export const getLegacyTransforms = async ({
namespace,
esClient,
}: {
namespace: string;
esClient: ElasticsearchClient;
}) => {
const getTransformStatsRequests: Array<Promise<TransformGetTransformResponse>> = [];
[RiskScoreEntity.host, RiskScoreEntity.user].forEach((entity) => {
getTransformStatsRequests.push(
esClient.transform.getTransform({
transform_id: getRiskScorePivotTransformId(entity, namespace),
})
);
getTransformStatsRequests.push(
esClient.transform.getTransform({
transform_id: getRiskScoreLatestTransformId(entity, namespace),
})
);
});
const results = await Promise.allSettled(getTransformStatsRequests);
const transforms = results.reduce((acc, result) => {
if (result.status === 'fulfilled' && result.value?.transforms?.length > 0) {
acc.push(...result.value.transforms);
}
return acc;
}, [] as TransformGetTransformTransformSummary[]);
return transforms;
};
export const removeLegacyTransforms = async ({
namespace,
esClient,
}: {
namespace: string;
esClient: ElasticsearchClient;
}): Promise<void> => {
const transforms = await getLegacyTransforms({ namespace, esClient });
const stopTransformRequests = transforms.map((t) =>
esClient.transform.deleteTransform({
transform_id: t.id,
force: true,
})
);
await Promise.allSettled(stopTransformRequests);
};

View file

@ -0,0 +1,89 @@
/*
* 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 { SavedObject, SavedObjectsClientContract } from '@kbn/core/server';
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import type { RiskEngineConfiguration } from '../types';
import { riskEngineConfigurationTypeName } from '../saved_object';
export interface SavedObjectsClients {
savedObjectsClient: SavedObjectsClientContract;
}
export interface UpdateConfigOpts extends SavedObjectsClients {
user: AuthenticatedUser | null | undefined;
}
const getConfigurationSavedObject = async ({
savedObjectsClient,
}: SavedObjectsClients): Promise<SavedObject<RiskEngineConfiguration> | undefined> => {
const savedObjectsResponse = await savedObjectsClient.find<RiskEngineConfiguration>({
type: riskEngineConfigurationTypeName,
});
return savedObjectsResponse.saved_objects?.[0];
};
export const updateSavedObjectAttribute = async ({
savedObjectsClient,
attributes,
user,
}: UpdateConfigOpts & {
attributes: {
enabled: boolean;
};
}) => {
const savedObjectConfiguration = await getConfigurationSavedObject({
savedObjectsClient,
});
if (!savedObjectConfiguration) {
throw new Error('There no saved object configuration for risk engine');
}
const result = await savedObjectsClient.update(
riskEngineConfigurationTypeName,
savedObjectConfiguration.id,
{
...attributes,
},
{
refresh: 'wait_for',
}
);
return result;
};
export const initSavedObjects = async ({ savedObjectsClient, user }: UpdateConfigOpts) => {
const configuration = await getConfigurationSavedObject({ savedObjectsClient });
if (configuration) {
return configuration;
}
const result = await savedObjectsClient.create(riskEngineConfigurationTypeName, {
enabled: false,
});
return result;
};
export const getConfiguration = async ({
savedObjectsClient,
}: SavedObjectsClients): Promise<RiskEngineConfiguration | null> => {
try {
const savedObjectConfiguration = await getConfigurationSavedObject({
savedObjectsClient,
});
const configuration = savedObjectConfiguration?.attributes;
if (configuration) {
return configuration;
}
return null;
} catch (e) {
return null;
}
};

View file

@ -170,10 +170,6 @@ export class Plugin implements ISecuritySolutionPlugin {
.then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser),
});
if (experimentalFeatures.riskScoringPersistence) {
this.riskEngineDataClient.initializeResources({});
}
const requestContextFactory = new RequestContextFactory({
config,
logger,

View file

@ -74,7 +74,13 @@ import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_r
import { registerDashboardsRoutes } from '../lib/dashboards/routes';
import { registerTagsRoutes } from '../lib/tags/routes';
import { setAlertTagsRoute } from '../lib/detection_engine/routes/signals/set_alert_tags_route';
import { riskScorePreviewRoute } from '../lib/risk_engine/routes';
import {
riskScorePreviewRoute,
riskEngineDisableRoute,
riskEngineInitRoute,
riskEngineEnableRoute,
riskEngineStatusRoute,
} from '../lib/risk_engine/routes';
import { riskScoreCalculationRoute } from '../lib/risk_engine/routes/risk_score_calculation_route';
export const initRoutes = (
@ -177,5 +183,9 @@ export const initRoutes = (
if (config.experimentalFeatures.riskScoringRoutesEnabled) {
riskScorePreviewRoute(router, logger);
riskScoreCalculationRoute(router, logger);
riskEngineInitRoute(router, logger, security);
riskEngineEnableRoute(router, logger, security);
riskEngineStatusRoute(router, logger);
riskEngineDisableRoute(router, logger, security);
}
};

View file

@ -13,6 +13,7 @@ import { legacyType as legacyRuleActionsType } from './lib/detection_engine/rule
import { prebuiltRuleAssetType } from './lib/detection_engine/prebuilt_rules';
import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects';
import { manifestType } from './endpoint/lib/artifacts/saved_object_mappings';
import { riskEngineConfigurationType } from './lib/risk_engine/saved_object';
const types = [
noteType,
@ -22,6 +23,7 @@ const types = [
timelineType,
manifestType,
signalsMigrationType,
riskEngineConfigurationType,
];
export const savedObjectTypes = types.map((type) => type.name);

View file

@ -37,7 +37,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./throttle'));
loadTestFile(require.resolve('./ignore_fields'));
loadTestFile(require.resolve('./migrations'));
loadTestFile(require.resolve('./risk_engine/risk_engine_install_resources'));
loadTestFile(require.resolve('./risk_engine/risk_engine_status'));
loadTestFile(require.resolve('./risk_engine/risk_score_preview'));
loadTestFile(require.resolve('./risk_engine/risk_score_calculation'));
loadTestFile(require.resolve('./set_alert_tags'));

View file

@ -1,227 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const es = getService('es');
describe('Risk Engine - Install Resources', () => {
it('should install resources on startup', async () => {
const ilmPolicyName = '.risk-score-ilm-policy';
const componentTemplateName = '.risk-score-mappings';
const indexTemplateName = '.risk-score.risk-score-default-index-template';
const indexName = 'risk-score.risk-score-default';
const ilmPolicy = await es.ilm.getLifecycle({
name: ilmPolicyName,
});
expect(ilmPolicy[ilmPolicyName].policy).to.eql({
_meta: {
managed: true,
},
phases: {
hot: {
min_age: '0ms',
actions: {
rollover: {
max_age: '30d',
max_primary_shard_size: '50gb',
},
},
},
},
});
const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({
name: componentTemplateName,
});
expect(componentTemplates1.length).to.eql(1);
const componentTemplate = componentTemplates1[0];
expect(componentTemplate.name).to.eql(componentTemplateName);
expect(componentTemplate.component_template.template.mappings).to.eql({
dynamic: 'strict',
properties: {
'@timestamp': {
type: 'date',
},
host: {
properties: {
name: {
type: 'keyword',
},
risk: {
properties: {
calculated_level: {
type: 'keyword',
},
calculated_score: {
type: 'float',
},
calculated_score_norm: {
type: 'float',
},
category_1_score: {
type: 'float',
},
id_field: {
type: 'keyword',
},
id_value: {
type: 'keyword',
},
notes: {
type: 'keyword',
},
inputs: {
properties: {
id: {
type: 'keyword',
},
index: {
type: 'keyword',
},
category: {
type: 'keyword',
},
description: {
type: 'keyword',
},
risk_score: {
type: 'float',
},
timestamp: {
type: 'date',
},
},
type: 'object',
},
},
type: 'object',
},
},
},
user: {
properties: {
name: {
type: 'keyword',
},
risk: {
properties: {
calculated_level: {
type: 'keyword',
},
calculated_score: {
type: 'float',
},
calculated_score_norm: {
type: 'float',
},
category_1_score: {
type: 'float',
},
id_field: {
type: 'keyword',
},
id_value: {
type: 'keyword',
},
notes: {
type: 'keyword',
},
inputs: {
properties: {
id: {
type: 'keyword',
},
index: {
type: 'keyword',
},
category: {
type: 'keyword',
},
description: {
type: 'keyword',
},
risk_score: {
type: 'float',
},
timestamp: {
type: 'date',
},
},
type: 'object',
},
},
type: 'object',
},
},
},
},
});
const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({
name: indexTemplateName,
});
expect(indexTemplates.length).to.eql(1);
const indexTemplate = indexTemplates[0];
expect(indexTemplate.name).to.eql(indexTemplateName);
expect(indexTemplate.index_template.index_patterns).to.eql(['risk-score.risk-score-default']);
expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']);
expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false);
expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true);
expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default');
expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a(
'string'
);
expect(indexTemplate.index_template.template!.settings).to.eql({
index: {
lifecycle: {
name: '.risk-score-ilm-policy',
},
mapping: {
total_fields: {
limit: '1000',
},
},
hidden: 'true',
auto_expand_replicas: '0-1',
},
});
const dsResponse = await es.indices.get({
index: indexName,
});
const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName);
expect(dataStream?.mappings?._meta?.managed).to.eql(true);
expect(dataStream?.mappings?._meta?.namespace).to.eql('default');
expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string');
expect(dataStream?.mappings?.dynamic).to.eql('false');
expect(dataStream?.settings?.index?.lifecycle).to.eql({
name: '.risk-score-ilm-policy',
});
expect(dataStream?.settings?.index?.mapping).to.eql({
total_fields: {
limit: '1000',
},
});
expect(dataStream?.settings?.index?.hidden).to.eql('true');
expect(dataStream?.settings?.index?.number_of_shards).to.eql(1);
expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1');
});
});
};

View file

@ -0,0 +1,389 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import {
RISK_ENGINE_INIT_URL,
RISK_ENGINE_DISABLE_URL,
RISK_ENGINE_ENABLE_URL,
RISK_ENGINE_STATUS_URL,
} from '@kbn/security-solution-plugin/common/constants';
import { riskEngineConfigurationTypeName } from '@kbn/security-solution-plugin/server/lib/risk_engine/saved_object';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
cleanRiskEngineConfig,
legacyTransformIds,
createTransforms,
clearLegacyTransforms,
} from './utils';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const es = getService('es');
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
describe('Risk Engine', () => {
afterEach(async () => {
await cleanRiskEngineConfig({
kibanaServer,
});
await clearLegacyTransforms({
es,
});
});
const initRiskEngine = async () =>
await supertest.post(RISK_ENGINE_INIT_URL).set('kbn-xsrf', 'true').send().expect(200);
const getRiskEngineStatus = async () =>
await supertest.get(RISK_ENGINE_STATUS_URL).set('kbn-xsrf', 'true').send().expect(200);
const enableRiskEngine = async () =>
await supertest.post(RISK_ENGINE_ENABLE_URL).set('kbn-xsrf', 'true').send().expect(200);
const disableRiskEngine = async () =>
await supertest.post(RISK_ENGINE_DISABLE_URL).set('kbn-xsrf', 'true').send().expect(200);
describe('init api', () => {
it('should return response with success status', async () => {
const response = await initRiskEngine();
expect(response.body).to.eql({
result: {
errors: [],
legacy_risk_engine_disabled: true,
risk_engine_configuration_created: true,
risk_engine_enabled: true,
risk_engine_resources_installed: true,
},
});
});
it('should install resources on init call', async () => {
const ilmPolicyName = '.risk-score-ilm-policy';
const componentTemplateName = '.risk-score-mappings';
const indexTemplateName = '.risk-score.risk-score-default-index-template';
const indexName = 'risk-score.risk-score-default';
await initRiskEngine();
const ilmPolicy = await es.ilm.getLifecycle({
name: ilmPolicyName,
});
expect(ilmPolicy[ilmPolicyName].policy).to.eql({
_meta: {
managed: true,
},
phases: {
hot: {
min_age: '0ms',
actions: {
rollover: {
max_age: '30d',
max_primary_shard_size: '50gb',
},
},
},
},
});
const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({
name: componentTemplateName,
});
expect(componentTemplates1.length).to.eql(1);
const componentTemplate = componentTemplates1[0];
expect(componentTemplate.name).to.eql(componentTemplateName);
expect(componentTemplate.component_template.template.mappings).to.eql({
dynamic: 'strict',
properties: {
'@timestamp': {
type: 'date',
},
host: {
properties: {
name: {
type: 'keyword',
},
risk: {
properties: {
calculated_level: {
type: 'keyword',
},
calculated_score: {
type: 'float',
},
calculated_score_norm: {
type: 'float',
},
category_1_score: {
type: 'float',
},
id_field: {
type: 'keyword',
},
id_value: {
type: 'keyword',
},
notes: {
type: 'keyword',
},
inputs: {
properties: {
id: {
type: 'keyword',
},
index: {
type: 'keyword',
},
category: {
type: 'keyword',
},
description: {
type: 'keyword',
},
risk_score: {
type: 'float',
},
timestamp: {
type: 'date',
},
},
type: 'object',
},
},
type: 'object',
},
},
},
user: {
properties: {
name: {
type: 'keyword',
},
risk: {
properties: {
calculated_level: {
type: 'keyword',
},
calculated_score: {
type: 'float',
},
calculated_score_norm: {
type: 'float',
},
category_1_score: {
type: 'float',
},
id_field: {
type: 'keyword',
},
id_value: {
type: 'keyword',
},
notes: {
type: 'keyword',
},
inputs: {
properties: {
id: {
type: 'keyword',
},
index: {
type: 'keyword',
},
category: {
type: 'keyword',
},
description: {
type: 'keyword',
},
risk_score: {
type: 'float',
},
timestamp: {
type: 'date',
},
},
type: 'object',
},
},
type: 'object',
},
},
},
},
});
const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({
name: indexTemplateName,
});
expect(indexTemplates.length).to.eql(1);
const indexTemplate = indexTemplates[0];
expect(indexTemplate.name).to.eql(indexTemplateName);
expect(indexTemplate.index_template.index_patterns).to.eql([
'risk-score.risk-score-default',
]);
expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']);
expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false);
expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true);
expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default');
expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a(
'string'
);
expect(indexTemplate.index_template.template!.settings).to.eql({
index: {
lifecycle: {
name: '.risk-score-ilm-policy',
},
mapping: {
total_fields: {
limit: '1000',
},
},
hidden: 'true',
auto_expand_replicas: '0-1',
},
});
const dsResponse = await es.indices.get({
index: indexName,
});
const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName);
expect(dataStream?.mappings?._meta?.managed).to.eql(true);
expect(dataStream?.mappings?._meta?.namespace).to.eql('default');
expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string');
expect(dataStream?.mappings?.dynamic).to.eql('false');
expect(dataStream?.settings?.index?.lifecycle).to.eql({
name: '.risk-score-ilm-policy',
});
expect(dataStream?.settings?.index?.mapping).to.eql({
total_fields: {
limit: '1000',
},
});
expect(dataStream?.settings?.index?.hidden).to.eql('true');
expect(dataStream?.settings?.index?.number_of_shards).to.eql(1);
expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1');
});
it('should create configuration saved object', async () => {
await initRiskEngine();
const response = await kibanaServer.savedObjects.find({
type: riskEngineConfigurationTypeName,
});
expect(response?.saved_objects?.[0]?.attributes).to.eql({
enabled: true,
});
});
it('should create configuration saved object only once', async () => {
await initRiskEngine();
const firstResponse = await kibanaServer.savedObjects.find({
type: riskEngineConfigurationTypeName,
});
await initRiskEngine();
const secondResponse = await kibanaServer.savedObjects.find({
type: riskEngineConfigurationTypeName,
});
expect(secondResponse?.saved_objects?.length).to.eql(1);
expect(secondResponse?.saved_objects?.[0]?.id).to.eql(
firstResponse?.saved_objects?.[0]?.id
);
});
it('should remove legacy risk score transform if it exists', async () => {
await createTransforms({ es });
for (const transformId of legacyTransformIds) {
const tr = await es.transform.getTransform({
transform_id: transformId,
});
expect(tr?.transforms?.[0]?.id).to.eql(transformId);
}
await initRiskEngine();
for (const transformId of legacyTransformIds) {
try {
await es.transform.getTransform({
transform_id: transformId,
});
} catch (err) {
expect(err).to.not.be(undefined);
}
}
});
});
describe('status api', () => {
it('should disable / enable risk engige', async () => {
const status1 = await getRiskEngineStatus();
expect(status1.body).to.eql({
risk_engine_status: 'NOT_INSTALLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
});
await initRiskEngine();
const status2 = await getRiskEngineStatus();
expect(status2.body).to.eql({
risk_engine_status: 'ENABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
});
await disableRiskEngine();
const status3 = await getRiskEngineStatus();
expect(status3.body).to.eql({
risk_engine_status: 'DISABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
});
await enableRiskEngine();
const status4 = await getRiskEngineStatus();
expect(status4.body).to.eql({
risk_engine_status: 'ENABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
});
});
it('should return status of legacy risk engine', async () => {
await createTransforms({ es });
const status1 = await getRiskEngineStatus();
expect(status1.body).to.eql({
risk_engine_status: 'NOT_INSTALLED',
legacy_risk_engine_status: 'ENABLED',
});
await initRiskEngine();
const status2 = await getRiskEngineStatus();
expect(status2.body).to.eql({
risk_engine_status: 'ENABLED',
legacy_risk_engine_status: 'NOT_INSTALLED',
});
});
});
});
};

View file

@ -13,7 +13,8 @@ import type {
EcsRiskScore,
RiskScore,
} from '@kbn/security-solution-plugin/server/lib/risk_engine/types';
import { riskEngineConfigurationTypeName } from '@kbn/security-solution-plugin/server/lib/risk_engine/saved_object';
import type { KbnClient } from '@kbn/test';
import {
createRule,
waitForSignalsToBePresent,
@ -136,3 +137,78 @@ export const waitForRiskScoresToBePresent = async (
log
);
};
export const getRiskEngineConfigSO = async ({ kibanaServer }: { kibanaServer: KbnClient }) => {
const soResponse = await kibanaServer.savedObjects.find({
type: riskEngineConfigurationTypeName,
});
return soResponse?.saved_objects?.[0];
};
export const cleanRiskEngineConfig = async ({
kibanaServer,
}: {
kibanaServer: KbnClient;
}): Promise<void> => {
const so = await getRiskEngineConfigSO({ kibanaServer });
if (so) {
await kibanaServer.savedObjects.delete({
type: riskEngineConfigurationTypeName,
id: so.id,
});
}
};
export const legacyTransformIds = [
'ml_hostriskscore_pivot_transform_default',
'ml_hostriskscore_latest_transform_default',
'ml_userriskscore_pivot_transform_default',
'ml_userriskscore_latest_transform_default',
];
export const clearLegacyTransforms = async ({ es }: { es: Client }): Promise<void> => {
const transforms = legacyTransformIds.map((transform) =>
es.transform.deleteTransform({
transform_id: transform,
})
);
try {
await Promise.all(transforms);
} catch (e) {
//
}
};
export const createTransforms = async ({ es }: { es: Client }): Promise<void> => {
const transforms = legacyTransformIds.map((transform) =>
es.transform.putTransform({
transform_id: transform,
source: {
index: ['.alerts-security.alerts-default'],
},
dest: {
index: 'ml_host_risk_score_default',
},
pivot: {
group_by: {
'host.name': {
terms: {
field: 'host.name',
},
},
},
aggregations: {
'@timestamp': {
max: {
field: '@timestamp',
},
},
},
},
settings: {},
})
);
await Promise.all(transforms);
};

View file

@ -49,7 +49,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertDetailsPageEnabled',
'chartEmbeddablesEnabled',
'riskScoringRoutesEnabled',
])}`,
// mock cloud to enable the guided onboarding tour in e2e tests
'--xpack.cloud.id=test',