[Rules migration] Add sorting functionality to rules migration table (#11379) (#203396)

## Summary

[Internal link](https://github.com/elastic/security-team/issues/10820)
to the feature details

These changes add sorting functionality to the migration rules table. It
is possible to sort migration rules by next columns: `Updated`, `Name`,
`Status`, `Risk Score`, `Severity` and `Author`.

### Other changes

Next fixes and adjustments were also implemented as part of this PR:
* `Installed` status in migration rules table to indicate whether the
rule was installed
* Rules selection and installation of selected rules
* Disable selection for not fully translated rules
* `Author` column to show whether the translated rule matched one of the
existing Elastic prebuilt rules
* `Install and enable` and `Install without enabling` buttons within the
migration rule details flyout
This commit is contained in:
Ievgen Sorokopud 2024-12-09 20:21:16 +01:00 committed by GitHub
parent ebb4f503a5
commit 70a5bb33c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 541 additions and 122 deletions

View file

@ -46,6 +46,7 @@ export type FieldMap<T extends string = string> = Record<
array?: boolean;
doc_values?: boolean;
enabled?: boolean;
fields?: Record<string, { type: string }>;
format?: string;
ignore_above?: number;
multi_fields?: MultiField[];

View file

@ -59,6 +59,8 @@ export type GetRuleMigrationRequestQuery = z.infer<typeof GetRuleMigrationReques
export const GetRuleMigrationRequestQuery = z.object({
page: z.coerce.number().optional(),
per_page: z.coerce.number().optional(),
sort_field: NonEmptyString.optional(),
sort_direction: z.enum(['asc', 'desc']).optional(),
search_term: z.string().optional(),
});
export type GetRuleMigrationRequestQueryInput = z.input<typeof GetRuleMigrationRequestQuery>;
@ -154,7 +156,13 @@ export type InstallMigrationRulesRequestParamsInput = z.input<
>;
export type InstallMigrationRulesRequestBody = z.infer<typeof InstallMigrationRulesRequestBody>;
export const InstallMigrationRulesRequestBody = z.array(NonEmptyString);
export const InstallMigrationRulesRequestBody = z.object({
ids: z.array(NonEmptyString),
/**
* Indicates whether installed rules should be enabled
*/
enabled: z.boolean().optional(),
});
export type InstallMigrationRulesRequestBodyInput = z.input<
typeof InstallMigrationRulesRequestBody
>;

View file

@ -133,6 +133,19 @@ paths:
required: false
schema:
type: number
- name: sort_field
in: query
required: false
schema:
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
- name: sort_direction
in: query
required: false
schema:
type: string
enum:
- asc
- desc
- name: search_term
in: query
required: false
@ -180,10 +193,18 @@ paths:
content:
application/json:
schema:
type: array
items:
description: The rule migration id
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
type: object
required:
- ids
properties:
ids:
type: array
items:
description: The rule migration id
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
enabled:
type: boolean
description: Indicates whether installed rules should be enabled
responses:
200:
description: Indicates rules migrations have been installed correctly.

View file

@ -22,13 +22,17 @@ export const isMigrationCustomRule = (rule?: ElasticRule): rule is MigrationCust
!isMigrationPrebuiltRule(rule) &&
!!(rule?.title && rule?.description && rule?.query && rule?.query_language);
export const convertMigrationCustomRuleToSecurityRulePayload = (rule: MigrationCustomRule) => {
export const convertMigrationCustomRuleToSecurityRulePayload = (
rule: MigrationCustomRule,
enabled: boolean
) => {
return {
type: rule.query_language,
language: rule.query_language,
query: rule.query,
name: rule.title,
description: rule.description,
enabled,
...DEFAULT_TRANSLATION_FIELDS,
severity: (rule.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY,

View file

@ -120,6 +120,10 @@ export interface GetRuleMigrationParams {
page?: number;
/** Optional number of documents per page to retrieve */
perPage?: number;
/** Optional field of the rule migration object to sort results by */
sortField?: string;
/** Optional direction to sort results by */
sortDirection?: 'asc' | 'desc';
/** Optional search term to filter documents */
searchTerm?: string;
/** Optional AbortSignal for cancelling request */
@ -130,12 +134,24 @@ export const getRuleMigrations = async ({
migrationId,
page,
perPage,
sortField,
sortDirection,
searchTerm,
signal,
}: GetRuleMigrationParams): Promise<GetRuleMigrationResponse> => {
return KibanaServices.get().http.get<GetRuleMigrationResponse>(
replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }),
{ version: '1', query: { page, per_page: perPage, search_term: searchTerm }, signal }
{
version: '1',
query: {
page,
per_page: perPage,
sort_field: sortField,
sort_direction: sortDirection,
search_term: searchTerm,
},
signal,
}
);
};
@ -163,6 +179,8 @@ export interface InstallRulesParams {
migrationId: string;
/** The rule ids to install */
ids: string[];
/** Optional indicator to enable the installed rule */
enabled?: boolean;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
@ -170,11 +188,12 @@ export interface InstallRulesParams {
export const installMigrationRules = async ({
migrationId,
ids,
enabled,
signal,
}: InstallRulesParams): Promise<InstallMigrationRulesResponse> => {
return KibanaServices.get().http.post<InstallMigrationRulesResponse>(
replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }),
{ version: '1', body: JSON.stringify(ids), signal }
{ version: '1', body: JSON.stringify({ ids, enabled }), signal }
);
};

View file

@ -84,7 +84,8 @@ export const MigrationRuleDetailsFlyout: React.FC<MigrationRuleDetailsFlyoutProp
const rule = useMemo(() => {
if (isMigrationCustomRule(ruleMigration.elastic_rule)) {
return convertMigrationCustomRuleToSecurityRulePayload(
ruleMigration.elastic_rule
ruleMigration.elastic_rule,
false
) as RuleResponse; // TODO: we need to adjust RuleOverviewTab to allow partial RuleResponse as a parameter;
}
return matchedPrebuiltRule;

View file

@ -6,7 +6,13 @@
*/
import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import * as i18n from './translations';
export interface BulkActionsProps {
@ -29,13 +35,14 @@ export const BulkActions: React.FC<BulkActionsProps> = React.memo(
installSelectedRule,
}) => {
const disableInstallTranslatedRulesButton = isTableLoading || !numberOfTranslatedRules;
const showInstallSelectedRulesButton =
disableInstallTranslatedRulesButton && numberOfSelectedRules > 0;
const showInstallSelectedRulesButton = isTableLoading || numberOfSelectedRules > 0;
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
{showInstallSelectedRulesButton ? (
<EuiFlexItem grow={false}>
<EuiButton
<EuiButtonEmpty
iconType="plusInCircle"
color={'primary'}
onClick={installSelectedRule}
disabled={isTableLoading}
data-test-subj="installSelectedRulesButton"
@ -43,7 +50,7 @@ export const BulkActions: React.FC<BulkActionsProps> = React.memo(
>
{i18n.INSTALL_SELECTED_RULES(numberOfSelectedRules)}
{isTableLoading && <EuiLoadingSpinner size="s" />}
</EuiButton>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { CriteriaWithPagination } from '@elastic/eui';
import type { CriteriaWithPagination, EuiTableSelectionType } from '@elastic/eui';
import {
EuiSkeletonLoading,
EuiSkeletonTitle,
@ -14,6 +14,7 @@ import {
EuiFlexItem,
EuiSpacer,
EuiBasicTable,
EuiButton,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
@ -30,8 +31,12 @@ import { useGetMigrationPrebuiltRules } from '../../logic/use_get_migration_preb
import * as logicI18n from '../../logic/translations';
import { BulkActions } from './bulk_actions';
import { SearchField } from './search_field';
import { SiemMigrationRuleTranslationResult } from '../../../../../common/siem_migrations/constants';
import * as i18n from './translations';
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT_FIELD = 'translation_result';
const DEFAULT_SORT_DIRECTION = 'desc';
export interface MigrationRulesTableProps {
/**
@ -49,6 +54,8 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [sortField, setSortField] = useState<keyof RuleMigration>(DEFAULT_SORT_FIELD);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION);
const [searchTerm, setSearchTerm] = useState<string | undefined>();
const { data: translationStats, isLoading: isStatsLoading } =
@ -64,10 +71,33 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
migrationId,
page: pageIndex,
perPage: pageSize,
sortField,
sortDirection,
searchTerm,
});
const [selectedRuleMigrations, setSelectedRuleMigrations] = useState<RuleMigration[]>([]);
const tableSelection: EuiTableSelectionType<RuleMigration> = useMemo(
() => ({
selectable: (item: RuleMigration) => {
return (
!item.elastic_rule?.id &&
item.translation_result === SiemMigrationRuleTranslationResult.FULL
);
},
selectableMessage: (selectable: boolean, item: RuleMigration) => {
if (selectable) {
return '';
}
return item.elastic_rule?.id
? i18n.ALREADY_TRANSLATED_RULE_TOOLTIP
: i18n.NOT_FULLY_TRANSLATED_RULE_TOOLTIP;
},
onSelectionChange: setSelectedRuleMigrations,
selected: selectedRuleMigrations,
}),
[selectedRuleMigrations]
);
const pagination = useMemo(() => {
return {
@ -77,11 +107,25 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
};
}, [pageIndex, pageSize, total]);
const sorting = useMemo(() => {
return {
sort: {
field: sortField,
direction: sortDirection,
},
};
}, [sortDirection, sortField]);
const onTableChange = useCallback(({ page, sort }: CriteriaWithPagination<RuleMigration>) => {
if (page) {
setPageIndex(page.index);
setPageSize(page.size);
}
if (sort) {
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
}
}, []);
const handleOnSearch = useCallback((value: string) => {
@ -94,10 +138,10 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
const [isTableLoading, setTableLoading] = useState(false);
const installSingleRule = useCallback(
async (migrationRule: RuleMigration, enable?: boolean) => {
async (migrationRule: RuleMigration, enabled = false) => {
setTableLoading(true);
try {
await installMigrationRules([migrationRule.id]);
await installMigrationRules({ ids: [migrationRule.id], enabled });
} catch (error) {
addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE });
} finally {
@ -107,6 +151,24 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
[addError, installMigrationRules]
);
const installSelectedRule = useCallback(
async (enabled = false) => {
setTableLoading(true);
try {
await installMigrationRules({
ids: selectedRuleMigrations.map((rule) => rule.id),
enabled,
});
} catch (error) {
addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE });
} finally {
setTableLoading(false);
setSelectedRuleMigrations([]);
}
},
[addError, installMigrationRules, selectedRuleMigrations]
);
const installTranslatedRules = useCallback(
async (enable?: boolean) => {
setTableLoading(true);
@ -121,12 +183,45 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
[addError, installTranslatedMigrationRules]
);
const isLoading = isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading;
const ruleActionsFactory = useCallback(
(ruleMigration: RuleMigration, closeRulePreview: () => void) => {
// TODO: Add flyout action buttons
return null;
const canMigrationRuleBeInstalled =
!isLoading &&
!ruleMigration.elastic_rule?.id &&
ruleMigration.translation_result === SiemMigrationRuleTranslationResult.FULL;
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton
disabled={!canMigrationRuleBeInstalled}
onClick={() => {
installSingleRule(ruleMigration);
closeRulePreview();
}}
data-test-subj="installMigrationRuleFromFlyoutButton"
>
{i18n.INSTALL_WITHOUT_ENABLING_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
disabled={!canMigrationRuleBeInstalled}
onClick={() => {
installSingleRule(ruleMigration, true);
closeRulePreview();
}}
fill
data-test-subj="installAndEnableMigrationRuleFromFlyoutButton"
>
{i18n.INSTALL_AND_ENABLE_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
},
[]
[installSingleRule, isLoading]
);
const {
@ -143,8 +238,6 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
installMigrationRule: installSingleRule,
});
const isLoading = isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading;
return (
<>
<EuiSkeletonLoading
@ -168,8 +261,9 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
<BulkActions
isTableLoading={isLoading}
numberOfTranslatedRules={translationStats?.rules.installable ?? 0}
numberOfSelectedRules={0}
numberOfSelectedRules={selectedRuleMigrations.length}
installTranslatedRule={installTranslatedRules}
installSelectedRule={installSelectedRule}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -178,12 +272,9 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
loading={isTableLoading}
items={ruleMigrations}
pagination={pagination}
sorting={sorting}
onChange={onTableChange}
selection={{
selectable: () => true,
onSelectionChange: setSelectedRuleMigrations,
initialSelected: selectedRuleMigrations,
}}
selection={tableSelection}
itemId={'id'}
data-test-subj={'rules-translation-table'}
columns={rulesColumns}

View file

@ -80,3 +80,31 @@ export const INSTALL_TRANSLATED_ARIA_LABEL = i18n.translate(
defaultMessage: 'Install all translated rules',
}
);
export const ALREADY_TRANSLATED_RULE_TOOLTIP = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.alreadyTranslatedTooltip',
{
defaultMessage: 'Already translated migration rule',
}
);
export const NOT_FULLY_TRANSLATED_RULE_TOOLTIP = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.notFullyTranslatedTooltip',
{
defaultMessage: 'Not fully translated migration rule',
}
);
export const INSTALL_WITHOUT_ENABLING_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.installWithoutEnablingButtonLabel',
{
defaultMessage: 'Install without enabling',
}
);
export const INSTALL_AND_ENABLE_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.installAndEnableButtonLabel',
{
defaultMessage: 'Install and enable',
}
);

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import * as i18n from './translations';
import type { TableColumn } from './constants';
const Author = ({ isPrebuiltRule }: { isPrebuiltRule: boolean }) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
{isPrebuiltRule && (
<EuiFlexItem grow={false}>
<EuiIcon type="logoElastic" size="m" />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
{isPrebuiltRule ? i18n.ELASTIC_AUTHOR_TITLE : i18n.CUSTOM_AUTHOR_TITLE}
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const createAuthorColumn = (): TableColumn => {
return {
field: 'elastic_rule.prebuilt_rule_id',
name: i18n.COLUMN_AUTHOR,
render: (_, rule: RuleMigration) => {
return <Author isPrebuiltRule={!!rule.elastic_rule?.prebuilt_rule_id} />;
},
sortable: true,
width: '10%',
};
};

View file

@ -8,6 +8,7 @@
export * from './constants';
export * from './actions';
export * from './author';
export * from './name';
export * from './risk_score';
export * from './severity';

View file

@ -12,12 +12,11 @@ import * as i18n from './translations';
import type { TableColumn } from './constants';
interface NameProps {
name: string;
rule: RuleMigration;
openMigrationRuleDetails: (rule: RuleMigration) => void;
}
const Name = ({ name, rule, openMigrationRuleDetails }: NameProps) => {
const Name = ({ rule, openMigrationRuleDetails }: NameProps) => {
return (
<EuiLink
onClick={() => {
@ -25,7 +24,7 @@ const Name = ({ name, rule, openMigrationRuleDetails }: NameProps) => {
}}
data-test-subj="ruleName"
>
{name}
{rule.elastic_rule?.title}
</EuiLink>
);
};
@ -36,10 +35,10 @@ export const createNameColumn = ({
openMigrationRuleDetails: (rule: RuleMigration) => void;
}): TableColumn => {
return {
field: 'original_rule.title',
field: 'elastic_rule.title',
name: i18n.COLUMN_NAME,
render: (value: RuleMigration['original_rule']['title'], rule: RuleMigration) => (
<Name name={value} rule={rule} openMigrationRuleDetails={openMigrationRuleDetails} />
render: (_, rule: RuleMigration) => (
<Name rule={rule} openMigrationRuleDetails={openMigrationRuleDetails} />
),
sortable: true,
truncateText: true,

View file

@ -22,6 +22,6 @@ export const createRiskScoreColumn = (): TableColumn => {
),
sortable: true,
truncateText: true,
width: '75px',
width: '10%',
};
};

View file

@ -8,9 +8,7 @@
import React from 'react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { DEFAULT_TRANSLATION_SEVERITY } from '../../../../../common/siem_migrations/constants';
import { getNormalizedSeverity } from '../../../../detection_engine/rule_management_ui/components/rules_table/helpers';
import { SeverityBadge } from '../../../../common/components/severity_badge';
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import type { TableColumn } from './constants';
import * as i18n from './translations';
@ -19,8 +17,7 @@ export const createSeverityColumn = (): TableColumn => {
field: 'elastic_rule.severity',
name: i18n.COLUMN_SEVERITY,
render: (value?: Severity) => <SeverityBadge value={value ?? DEFAULT_TRANSLATION_SEVERITY} />,
sortable: ({ elastic_rule: elasticRule }: RuleMigration) =>
getNormalizedSeverity((elasticRule?.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY),
sortable: true,
truncateText: true,
width: '12%',
};

View file

@ -18,8 +18,8 @@ export const createStatusColumn = (): TableColumn => {
render: (value: RuleMigration['translation_result'], rule: RuleMigration) => (
<StatusBadge value={value} installedRuleId={rule.elastic_rule?.id} />
),
sortable: false,
sortable: true,
truncateText: true,
width: '12%',
width: '15%',
};
};

View file

@ -14,6 +14,27 @@ export const COLUMN_ACTIONS = i18n.translate(
}
);
export const COLUMN_AUTHOR = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.authorLabel',
{
defaultMessage: 'Author',
}
);
export const ELASTIC_AUTHOR_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.elasticAuthorTitle',
{
defaultMessage: 'Elastic',
}
);
export const CUSTOM_AUTHOR_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.customAuthorTitle',
{
defaultMessage: 'Custom',
}
);
export const ACTIONS_VIEW_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.actionsViewLabel',
{

View file

@ -1,19 +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 React from 'react';
import { shallow } from 'enzyme';
import { StatusBadge } from '.';
describe('StatusBadge', () => {
it('renders correctly', () => {
const wrapper = shallow(<StatusBadge value="full" />);
expect(wrapper.find('HealthTruncateText')).toHaveLength(1);
});
});

View file

@ -8,9 +8,16 @@
import React from 'react';
import { euiLightVars } from '@kbn/ui-theme';
import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiIcon, EuiToolTip } from '@elastic/eui';
import { css } from '@emotion/css';
import type { RuleMigrationTranslationResult } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
import { convertTranslationResultIntoText } from '../../utils/helpers';
import * as i18n from './translations';
const statusTextWrapperClassName = css`
width: 100%;
display: inline-grid;
`;
const { euiColorVis0, euiColorVis7, euiColorVis9 } = euiLightVars;
const statusToColorMap: Record<RuleMigrationTranslationResult, string> = {
@ -28,17 +35,28 @@ interface StatusBadgeProps {
export const StatusBadge: React.FC<StatusBadgeProps> = React.memo(
({ value, installedRuleId, 'data-test-subj': dataTestSubj = 'translation-result' }) => {
const translationResult = installedRuleId ? 'full' : value ?? 'untranslatable';
const displayValue = convertTranslationResultIntoText(translationResult);
const displayValue = installedRuleId
? i18n.RULE_STATUS_INSTALLED
: convertTranslationResultIntoText(translationResult);
const color = statusToColorMap[translationResult];
return (
<HealthTruncateText
healthColor={color}
tooltipContent={displayValue}
dataTestSubj={dataTestSubj}
>
{displayValue}
</HealthTruncateText>
<EuiToolTip content={displayValue}>
{installedRuleId ? (
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={'check'} color={statusToColorMap.full} />
</EuiFlexItem>
<EuiFlexItem grow={false}>{displayValue}</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiHealth color={color} data-test-subj={dataTestSubj}>
<div className={statusTextWrapperClassName}>
<span className="eui-textTruncate">{displayValue}</span>
</div>
</EuiHealth>
)}
</EuiToolTip>
);
}
);

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const RULE_STATUS_INSTALLED = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.status.installedLabel',
{
defaultMessage: 'Installed',
}
);

View file

@ -10,6 +10,7 @@ import type { RuleMigration } from '../../../../common/siem_migrations/model/rul
import type { TableColumn } from '../components/rules_table_columns';
import {
createActionsColumn,
createAuthorColumn,
createNameColumn,
createRiskScoreColumn,
createSeverityColumn,
@ -33,6 +34,7 @@ export const useMigrationRulesTableColumns = ({
createStatusColumn(),
createRiskScoreColumn(),
createSeverityColumn(),
createAuthorColumn(),
createActionsColumn({
disableActions,
openMigrationRuleDetails,

View file

@ -18,6 +18,8 @@ export const useGetMigrationRules = (params: {
migrationId: string;
page?: number;
perPage?: number;
sortField: string;
sortDirection: 'asc' | 'desc';
searchTerm?: string;
}) => {
const { addError } = useAppToasts();

View file

@ -23,8 +23,8 @@ export const useInstallMigrationRules = (migrationId: string) => {
const invalidateGetMigrationTranslationStats =
useInvalidateGetMigrationTranslationStats(migrationId);
return useMutation<InstallMigrationRulesResponse, Error, string[]>(
(ids: string[]) => installMigrationRules({ migrationId, ids }),
return useMutation<InstallMigrationRulesResponse, Error, { ids: string[]; enabled: boolean }>(
({ ids, enabled = false }) => installMigrationRules({ migrationId, ids, enabled }),
{
mutationKey: INSTALL_MIGRATION_RULES_MUTATION_KEY,
onError: (error) => {

View file

@ -39,13 +39,20 @@ export const registerSiemRuleMigrationsGetRoute = (
},
withLicense(async (context, req, res): Promise<IKibanaResponse<GetRuleMigrationResponse>> => {
const { migration_id: migrationId } = req.params;
const { page, per_page: perPage, search_term: searchTerm } = req.query;
const {
page,
per_page: perPage,
sort_field: sortField,
sort_direction: sortDirection,
search_term: searchTerm,
} = req.query;
try {
const ctx = await context.resolve(['securitySolution']);
const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient();
const options: RuleMigrationGetOptions = {
filters: { searchTerm },
sort: { sortField, sortDirection },
size: perPage,
from: page && perPage ? page * perPage : 0,
};

View file

@ -40,7 +40,7 @@ export const registerSiemRuleMigrationsInstallRoute = (
withLicense(
async (context, req, res): Promise<IKibanaResponse<InstallMigrationRulesResponse>> => {
const { migration_id: migrationId } = req.params;
const ids = req.body;
const { ids, enabled = false } = req.body;
try {
const ctx = await context.resolve(['core', 'alerting', 'securitySolution']);
@ -52,6 +52,7 @@ export const registerSiemRuleMigrationsInstallRoute = (
await installTranslated({
migrationId,
ids,
enabled,
securitySolutionContext,
savedObjectsClient,
rulesClient,

View file

@ -50,6 +50,7 @@ export const registerSiemRuleMigrationsInstallTranslatedRoute = (
await installTranslated({
migrationId,
enabled: false,
securitySolutionContext,
savedObjectsClient,
rulesClient,

View file

@ -27,6 +27,7 @@ import {
const installPrebuiltRules = async (
rulesToInstall: StoredRuleMigration[],
enabled: boolean,
securitySolutionContext: SecuritySolutionApiRequestHandlerContext,
rulesClient: RulesClient,
savedObjectsClient: SavedObjectsClientContract,
@ -41,7 +42,7 @@ const installPrebuiltRules = async (
if (item.current) {
acc.installed.push(item.current);
} else {
acc.installable.push(item.target);
acc.installable.push({ ...item.target, enabled });
}
return acc;
},
@ -85,6 +86,7 @@ const installPrebuiltRules = async (
export const installCustomRules = async (
rulesToInstall: StoredRuleMigration[],
enabled: boolean,
detectionRulesClient: IDetectionRulesClient,
logger: Logger
): Promise<UpdateRuleMigrationInput[]> => {
@ -96,8 +98,11 @@ export const installCustomRules = async (
if (!isMigrationCustomRule(rule.elastic_rule)) {
return;
}
const payloadRule = convertMigrationCustomRuleToSecurityRulePayload(rule.elastic_rule);
const createdRule = await detectionRulesClient.createPrebuiltRule({
const payloadRule = convertMigrationCustomRuleToSecurityRulePayload(
rule.elastic_rule,
enabled
);
const createdRule = await detectionRulesClient.createCustomRule({
params: payloadRule,
});
rulesToUpdate.push({
@ -131,6 +136,11 @@ interface InstallTranslatedProps {
*/
ids?: string[];
/**
* Indicates whether the installed migration rules should be enabled
*/
enabled: boolean;
/**
* The security solution context
*/
@ -155,6 +165,7 @@ interface InstallTranslatedProps {
export const installTranslated = async ({
migrationId,
ids,
enabled,
securitySolutionContext,
rulesClient,
savedObjectsClient,
@ -186,6 +197,7 @@ export const installTranslated = async ({
const updatedPrebuiltRules = await installPrebuiltRules(
prebuiltRulesToInstall,
enabled,
securitySolutionContext,
rulesClient,
savedObjectsClient,
@ -194,6 +206,7 @@ export const installTranslated = async ({
const updatedCustomRules = await installCustomRules(
customRulesToInstall,
enabled,
detectionRulesClient,
logger
);

View file

@ -15,10 +15,7 @@ import type {
QueryDslQueryContainer,
} from '@elastic/elasticsearch/lib/api/types';
import type { StoredRuleMigration } from '../types';
import {
SiemMigrationRuleTranslationResult,
SiemMigrationStatus,
} from '../../../../../common/siem_migrations/constants';
import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants';
import type {
ElasticRule,
RuleMigration,
@ -26,6 +23,8 @@ import type {
RuleMigrationTranslationStats,
} from '../../../../../common/siem_migrations/model/rule_migration.gen';
import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client';
import { getSortingOptions, type RuleMigrationSort } from './sort';
import { conditions as searchConditions } from './search';
export type CreateRuleMigrationInput = Omit<
RuleMigration,
@ -47,6 +46,7 @@ export interface RuleMigrationFilters {
}
export interface RuleMigrationGetOptions {
filters?: RuleMigrationFilters;
sort?: RuleMigrationSort;
from?: number;
size?: number;
}
@ -120,13 +120,19 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
/** Retrieves an array of rule documents of a specific migrations */
async get(
migrationId: string,
{ filters = {}, from, size }: RuleMigrationGetOptions = {}
{ filters = {}, sort = {}, from, size }: RuleMigrationGetOptions = {}
): Promise<{ total: number; data: StoredRuleMigration[] }> {
const index = await this.getIndexName();
const query = this.getFilterQuery(migrationId, { ...filters });
const result = await this.esClient
.search<RuleMigration>({ index, query, sort: '_doc', from, size })
.search<RuleMigration>({
index,
query,
sort: sort.sortField ? getSortingOptions(sort) : undefined,
from,
size,
})
.catch((error) => {
this.logger.error(`Error searching rule migrations: ${error.message}`);
throw error;
@ -238,8 +244,8 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
const query = this.getFilterQuery(migrationId);
const aggregations = {
prebuilt: { filter: conditions.isPrebuilt() },
installable: { filter: { bool: { must: conditions.isInstallable() } } },
prebuilt: { filter: searchConditions.isPrebuilt() },
installable: { filter: { bool: { must: searchConditions.isInstallable() } } },
};
const result = await this.esClient
.search({ index, query, aggregations, _source: false })
@ -351,47 +357,14 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
filter.push({ terms: { _id: ids } });
}
if (installable) {
filter.push(...conditions.isInstallable());
filter.push(...searchConditions.isInstallable());
}
if (prebuilt) {
filter.push(conditions.isPrebuilt());
filter.push(searchConditions.isPrebuilt());
}
if (searchTerm?.length) {
filter.push(conditions.matchTitle(searchTerm));
filter.push(searchConditions.matchTitle(searchTerm));
}
return { bool: { filter } };
}
}
const conditions = {
isFullyTranslated(): QueryDslQueryContainer {
return { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } };
},
isNotInstalled(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { bool: { must_not: { exists: { field: 'elastic_rule.id' } } } },
},
};
},
isPrebuilt(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } },
},
};
},
matchTitle(title: string): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { match: { 'elastic_rule.title': title } },
},
};
},
isInstallable(): QueryDslQueryContainer[] {
return [this.isFullyTranslated(), this.isNotInstalled()];
},
};

View file

@ -19,14 +19,14 @@ export const ruleMigrationsFieldMap: FieldMap<SchemaFieldMapKeys<Omit<RuleMigrat
original_rule: { type: 'nested', required: true },
'original_rule.vendor': { type: 'keyword', required: true },
'original_rule.id': { type: 'keyword', required: true },
'original_rule.title': { type: 'text', required: true },
'original_rule.title': { type: 'text', required: true, fields: { keyword: { type: 'keyword' } } },
'original_rule.description': { type: 'text', required: false },
'original_rule.query': { type: 'text', required: true },
'original_rule.query_language': { type: 'keyword', required: true },
'original_rule.annotations': { type: 'nested', required: false },
'original_rule.annotations.mitre_attack': { type: 'keyword', array: true, required: false },
elastic_rule: { type: 'nested', required: false },
'elastic_rule.title': { type: 'text', required: true },
'elastic_rule.title': { type: 'text', required: true, fields: { keyword: { type: 'keyword' } } },
'elastic_rule.integration_ids': { type: 'keyword', array: true, required: false },
'elastic_rule.query': { type: 'text', required: true },
'elastic_rule.query_language': { type: 'keyword', required: true },

View file

@ -0,0 +1,42 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { SiemMigrationRuleTranslationResult } from '../../../../../common/siem_migrations/constants';
export const conditions = {
isFullyTranslated(): QueryDslQueryContainer {
return { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } };
},
isNotInstalled(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { bool: { must_not: { exists: { field: 'elastic_rule.id' } } } },
},
};
},
isPrebuilt(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } },
},
};
},
matchTitle(title: string): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { match: { 'elastic_rule.title': title } },
},
};
},
isInstallable(): QueryDslQueryContainer[] {
return [this.isFullyTranslated(), this.isNotInstalled()];
},
};

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
export interface RuleMigrationSort {
sortField?: string;
sortDirection?: estypes.SortOrder;
}
const sortMissingValue = (direction: estypes.SortOrder = 'asc') =>
direction === 'desc' ? '_last' : '_first';
const sortingOptions = {
matchedPrebuiltRule(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
return [
{
'elastic_rule.prebuilt_rule_id': {
order: direction,
nested: { path: 'elastic_rule' },
missing: sortMissingValue(direction),
},
},
];
},
severity(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
const field = 'elastic_rule.severity';
return [
{
_script: {
order: direction,
type: 'number',
script: {
source: `
if (doc.containsKey('${field}') && !doc['${field}'].empty) {
def value = doc['${field}'].value.toLowerCase();
if (value == 'critical') { return 3 }
if (value == 'high') { return 2 }
if (value == 'medium') { return 1 }
if (value == 'low') { return 0 }
}
return -1;
`,
lang: 'painless',
},
nested: { path: 'elastic_rule' },
},
},
];
},
status(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
const field = 'translation_result';
const installedRuleField = 'elastic_rule.id';
return [
{
_script: {
order: direction,
type: 'number',
script: {
source: `
if (doc.containsKey('${field}') && !doc['${field}'].empty) {
def value = doc['${field}'].value.toLowerCase();
if (value == 'full') { return 2 }
if (value == 'partial') { return 1 }
if (value == 'untranslatable') { return 0 }
}
return -1;
`,
lang: 'painless',
},
},
},
{
_script: {
order: direction,
type: 'number',
script: {
source: `
if (doc.containsKey('${installedRuleField}') && !doc['${installedRuleField}'].empty) {
return 0;
}
return -1;
`,
lang: 'painless',
},
nested: { path: 'elastic_rule' },
},
},
];
},
updated(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
return [{ updated_at: direction }];
},
name(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
return [
{ 'elastic_rule.title.keyword': { order: direction, nested: { path: 'elastic_rule' } } },
];
},
};
const DEFAULT_SORTING: estypes.Sort = [
...sortingOptions.status('desc'),
...sortingOptions.matchedPrebuiltRule('desc'),
...sortingOptions.severity(),
...sortingOptions.updated(),
];
const sortingOptionsMap: {
[key: string]: (direction?: estypes.SortOrder) => estypes.SortCombinations[];
} = {
'elastic_rule.title': sortingOptions.name,
'elastic_rule.severity': sortingOptions.severity,
'elastic_rule.prebuilt_rule_id': sortingOptions.matchedPrebuiltRule,
translation_result: sortingOptions.status,
updated_at: sortingOptions.updated,
};
export const getSortingOptions = (sort?: RuleMigrationSort): estypes.Sort => {
if (!sort?.sortField) {
return DEFAULT_SORTING;
}
return sortingOptionsMap[sort.sortField]?.(sort.sortDirection) ?? DEFAULT_SORTING;
};