mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
## 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:
parent
ebb4f503a5
commit
70a5bb33c4
30 changed files with 541 additions and 122 deletions
|
@ -46,6 +46,7 @@ export type FieldMap<T extends string = string> = Record<
|
||||||
array?: boolean;
|
array?: boolean;
|
||||||
doc_values?: boolean;
|
doc_values?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
fields?: Record<string, { type: string }>;
|
||||||
format?: string;
|
format?: string;
|
||||||
ignore_above?: number;
|
ignore_above?: number;
|
||||||
multi_fields?: MultiField[];
|
multi_fields?: MultiField[];
|
||||||
|
|
|
@ -59,6 +59,8 @@ export type GetRuleMigrationRequestQuery = z.infer<typeof GetRuleMigrationReques
|
||||||
export const GetRuleMigrationRequestQuery = z.object({
|
export const GetRuleMigrationRequestQuery = z.object({
|
||||||
page: z.coerce.number().optional(),
|
page: z.coerce.number().optional(),
|
||||||
per_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(),
|
search_term: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type GetRuleMigrationRequestQueryInput = z.input<typeof GetRuleMigrationRequestQuery>;
|
export type GetRuleMigrationRequestQueryInput = z.input<typeof GetRuleMigrationRequestQuery>;
|
||||||
|
@ -154,7 +156,13 @@ export type InstallMigrationRulesRequestParamsInput = z.input<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type InstallMigrationRulesRequestBody = z.infer<typeof InstallMigrationRulesRequestBody>;
|
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<
|
export type InstallMigrationRulesRequestBodyInput = z.input<
|
||||||
typeof InstallMigrationRulesRequestBody
|
typeof InstallMigrationRulesRequestBody
|
||||||
>;
|
>;
|
||||||
|
|
|
@ -133,6 +133,19 @@ paths:
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: number
|
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
|
- name: search_term
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
|
@ -180,10 +193,18 @@ paths:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- ids
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
description: The rule migration id
|
description: The rule migration id
|
||||||
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
|
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Indicates whether installed rules should be enabled
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: Indicates rules migrations have been installed correctly.
|
description: Indicates rules migrations have been installed correctly.
|
||||||
|
|
|
@ -22,13 +22,17 @@ export const isMigrationCustomRule = (rule?: ElasticRule): rule is MigrationCust
|
||||||
!isMigrationPrebuiltRule(rule) &&
|
!isMigrationPrebuiltRule(rule) &&
|
||||||
!!(rule?.title && rule?.description && rule?.query && rule?.query_language);
|
!!(rule?.title && rule?.description && rule?.query && rule?.query_language);
|
||||||
|
|
||||||
export const convertMigrationCustomRuleToSecurityRulePayload = (rule: MigrationCustomRule) => {
|
export const convertMigrationCustomRuleToSecurityRulePayload = (
|
||||||
|
rule: MigrationCustomRule,
|
||||||
|
enabled: boolean
|
||||||
|
) => {
|
||||||
return {
|
return {
|
||||||
type: rule.query_language,
|
type: rule.query_language,
|
||||||
language: rule.query_language,
|
language: rule.query_language,
|
||||||
query: rule.query,
|
query: rule.query,
|
||||||
name: rule.title,
|
name: rule.title,
|
||||||
description: rule.description,
|
description: rule.description,
|
||||||
|
enabled,
|
||||||
|
|
||||||
...DEFAULT_TRANSLATION_FIELDS,
|
...DEFAULT_TRANSLATION_FIELDS,
|
||||||
severity: (rule.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY,
|
severity: (rule.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY,
|
||||||
|
|
|
@ -120,6 +120,10 @@ export interface GetRuleMigrationParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
/** Optional number of documents per page to retrieve */
|
/** Optional number of documents per page to retrieve */
|
||||||
perPage?: number;
|
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 */
|
/** Optional search term to filter documents */
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
/** Optional AbortSignal for cancelling request */
|
/** Optional AbortSignal for cancelling request */
|
||||||
|
@ -130,12 +134,24 @@ export const getRuleMigrations = async ({
|
||||||
migrationId,
|
migrationId,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
signal,
|
signal,
|
||||||
}: GetRuleMigrationParams): Promise<GetRuleMigrationResponse> => {
|
}: GetRuleMigrationParams): Promise<GetRuleMigrationResponse> => {
|
||||||
return KibanaServices.get().http.get<GetRuleMigrationResponse>(
|
return KibanaServices.get().http.get<GetRuleMigrationResponse>(
|
||||||
replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }),
|
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;
|
migrationId: string;
|
||||||
/** The rule ids to install */
|
/** The rule ids to install */
|
||||||
ids: string[];
|
ids: string[];
|
||||||
|
/** Optional indicator to enable the installed rule */
|
||||||
|
enabled?: boolean;
|
||||||
/** Optional AbortSignal for cancelling request */
|
/** Optional AbortSignal for cancelling request */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
@ -170,11 +188,12 @@ export interface InstallRulesParams {
|
||||||
export const installMigrationRules = async ({
|
export const installMigrationRules = async ({
|
||||||
migrationId,
|
migrationId,
|
||||||
ids,
|
ids,
|
||||||
|
enabled,
|
||||||
signal,
|
signal,
|
||||||
}: InstallRulesParams): Promise<InstallMigrationRulesResponse> => {
|
}: InstallRulesParams): Promise<InstallMigrationRulesResponse> => {
|
||||||
return KibanaServices.get().http.post<InstallMigrationRulesResponse>(
|
return KibanaServices.get().http.post<InstallMigrationRulesResponse>(
|
||||||
replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }),
|
replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }),
|
||||||
{ version: '1', body: JSON.stringify(ids), signal }
|
{ version: '1', body: JSON.stringify({ ids, enabled }), signal }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -84,7 +84,8 @@ export const MigrationRuleDetailsFlyout: React.FC<MigrationRuleDetailsFlyoutProp
|
||||||
const rule = useMemo(() => {
|
const rule = useMemo(() => {
|
||||||
if (isMigrationCustomRule(ruleMigration.elastic_rule)) {
|
if (isMigrationCustomRule(ruleMigration.elastic_rule)) {
|
||||||
return convertMigrationCustomRuleToSecurityRulePayload(
|
return convertMigrationCustomRuleToSecurityRulePayload(
|
||||||
ruleMigration.elastic_rule
|
ruleMigration.elastic_rule,
|
||||||
|
false
|
||||||
) as RuleResponse; // TODO: we need to adjust RuleOverviewTab to allow partial RuleResponse as a parameter;
|
) as RuleResponse; // TODO: we need to adjust RuleOverviewTab to allow partial RuleResponse as a parameter;
|
||||||
}
|
}
|
||||||
return matchedPrebuiltRule;
|
return matchedPrebuiltRule;
|
||||||
|
|
|
@ -6,7 +6,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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';
|
import * as i18n from './translations';
|
||||||
|
|
||||||
export interface BulkActionsProps {
|
export interface BulkActionsProps {
|
||||||
|
@ -29,13 +35,14 @@ export const BulkActions: React.FC<BulkActionsProps> = React.memo(
|
||||||
installSelectedRule,
|
installSelectedRule,
|
||||||
}) => {
|
}) => {
|
||||||
const disableInstallTranslatedRulesButton = isTableLoading || !numberOfTranslatedRules;
|
const disableInstallTranslatedRulesButton = isTableLoading || !numberOfTranslatedRules;
|
||||||
const showInstallSelectedRulesButton =
|
const showInstallSelectedRulesButton = isTableLoading || numberOfSelectedRules > 0;
|
||||||
disableInstallTranslatedRulesButton && numberOfSelectedRules > 0;
|
|
||||||
return (
|
return (
|
||||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
|
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
|
||||||
{showInstallSelectedRulesButton ? (
|
{showInstallSelectedRulesButton ? (
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
<EuiButton
|
<EuiButtonEmpty
|
||||||
|
iconType="plusInCircle"
|
||||||
|
color={'primary'}
|
||||||
onClick={installSelectedRule}
|
onClick={installSelectedRule}
|
||||||
disabled={isTableLoading}
|
disabled={isTableLoading}
|
||||||
data-test-subj="installSelectedRulesButton"
|
data-test-subj="installSelectedRulesButton"
|
||||||
|
@ -43,7 +50,7 @@ export const BulkActions: React.FC<BulkActionsProps> = React.memo(
|
||||||
>
|
>
|
||||||
{i18n.INSTALL_SELECTED_RULES(numberOfSelectedRules)}
|
{i18n.INSTALL_SELECTED_RULES(numberOfSelectedRules)}
|
||||||
{isTableLoading && <EuiLoadingSpinner size="s" />}
|
{isTableLoading && <EuiLoadingSpinner size="s" />}
|
||||||
</EuiButton>
|
</EuiButtonEmpty>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
) : null}
|
) : null}
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CriteriaWithPagination } from '@elastic/eui';
|
import type { CriteriaWithPagination, EuiTableSelectionType } from '@elastic/eui';
|
||||||
import {
|
import {
|
||||||
EuiSkeletonLoading,
|
EuiSkeletonLoading,
|
||||||
EuiSkeletonTitle,
|
EuiSkeletonTitle,
|
||||||
|
@ -14,6 +14,7 @@ import {
|
||||||
EuiFlexItem,
|
EuiFlexItem,
|
||||||
EuiSpacer,
|
EuiSpacer,
|
||||||
EuiBasicTable,
|
EuiBasicTable,
|
||||||
|
EuiButton,
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
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 * as logicI18n from '../../logic/translations';
|
||||||
import { BulkActions } from './bulk_actions';
|
import { BulkActions } from './bulk_actions';
|
||||||
import { SearchField } from './search_field';
|
import { SearchField } from './search_field';
|
||||||
|
import { SiemMigrationRuleTranslationResult } from '../../../../../common/siem_migrations/constants';
|
||||||
|
import * as i18n from './translations';
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 10;
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
const DEFAULT_SORT_FIELD = 'translation_result';
|
||||||
|
const DEFAULT_SORT_DIRECTION = 'desc';
|
||||||
|
|
||||||
export interface MigrationRulesTableProps {
|
export interface MigrationRulesTableProps {
|
||||||
/**
|
/**
|
||||||
|
@ -49,6 +54,8 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
|
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
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 [searchTerm, setSearchTerm] = useState<string | undefined>();
|
||||||
|
|
||||||
const { data: translationStats, isLoading: isStatsLoading } =
|
const { data: translationStats, isLoading: isStatsLoading } =
|
||||||
|
@ -64,10 +71,33 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
migrationId,
|
migrationId,
|
||||||
page: pageIndex,
|
page: pageIndex,
|
||||||
perPage: pageSize,
|
perPage: pageSize,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedRuleMigrations, setSelectedRuleMigrations] = useState<RuleMigration[]>([]);
|
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(() => {
|
const pagination = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
@ -77,11 +107,25 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
};
|
};
|
||||||
}, [pageIndex, pageSize, total]);
|
}, [pageIndex, pageSize, total]);
|
||||||
|
|
||||||
|
const sorting = useMemo(() => {
|
||||||
|
return {
|
||||||
|
sort: {
|
||||||
|
field: sortField,
|
||||||
|
direction: sortDirection,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [sortDirection, sortField]);
|
||||||
|
|
||||||
const onTableChange = useCallback(({ page, sort }: CriteriaWithPagination<RuleMigration>) => {
|
const onTableChange = useCallback(({ page, sort }: CriteriaWithPagination<RuleMigration>) => {
|
||||||
if (page) {
|
if (page) {
|
||||||
setPageIndex(page.index);
|
setPageIndex(page.index);
|
||||||
setPageSize(page.size);
|
setPageSize(page.size);
|
||||||
}
|
}
|
||||||
|
if (sort) {
|
||||||
|
const { field, direction } = sort;
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection(direction);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOnSearch = useCallback((value: string) => {
|
const handleOnSearch = useCallback((value: string) => {
|
||||||
|
@ -94,10 +138,10 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
|
|
||||||
const [isTableLoading, setTableLoading] = useState(false);
|
const [isTableLoading, setTableLoading] = useState(false);
|
||||||
const installSingleRule = useCallback(
|
const installSingleRule = useCallback(
|
||||||
async (migrationRule: RuleMigration, enable?: boolean) => {
|
async (migrationRule: RuleMigration, enabled = false) => {
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
try {
|
try {
|
||||||
await installMigrationRules([migrationRule.id]);
|
await installMigrationRules({ ids: [migrationRule.id], enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE });
|
addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE });
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -107,6 +151,24 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
[addError, installMigrationRules]
|
[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(
|
const installTranslatedRules = useCallback(
|
||||||
async (enable?: boolean) => {
|
async (enable?: boolean) => {
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
|
@ -121,12 +183,45 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
[addError, installTranslatedMigrationRules]
|
[addError, installTranslatedMigrationRules]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLoading = isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading;
|
||||||
|
|
||||||
const ruleActionsFactory = useCallback(
|
const ruleActionsFactory = useCallback(
|
||||||
(ruleMigration: RuleMigration, closeRulePreview: () => void) => {
|
(ruleMigration: RuleMigration, closeRulePreview: () => void) => {
|
||||||
// TODO: Add flyout action buttons
|
const canMigrationRuleBeInstalled =
|
||||||
return null;
|
!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 {
|
const {
|
||||||
|
@ -143,8 +238,6 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
installMigrationRule: installSingleRule,
|
installMigrationRule: installSingleRule,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EuiSkeletonLoading
|
<EuiSkeletonLoading
|
||||||
|
@ -168,8 +261,9 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
<BulkActions
|
<BulkActions
|
||||||
isTableLoading={isLoading}
|
isTableLoading={isLoading}
|
||||||
numberOfTranslatedRules={translationStats?.rules.installable ?? 0}
|
numberOfTranslatedRules={translationStats?.rules.installable ?? 0}
|
||||||
numberOfSelectedRules={0}
|
numberOfSelectedRules={selectedRuleMigrations.length}
|
||||||
installTranslatedRule={installTranslatedRules}
|
installTranslatedRule={installTranslatedRules}
|
||||||
|
installSelectedRule={installSelectedRule}
|
||||||
/>
|
/>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
|
@ -178,12 +272,9 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
|
||||||
loading={isTableLoading}
|
loading={isTableLoading}
|
||||||
items={ruleMigrations}
|
items={ruleMigrations}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
|
sorting={sorting}
|
||||||
onChange={onTableChange}
|
onChange={onTableChange}
|
||||||
selection={{
|
selection={tableSelection}
|
||||||
selectable: () => true,
|
|
||||||
onSelectionChange: setSelectedRuleMigrations,
|
|
||||||
initialSelected: selectedRuleMigrations,
|
|
||||||
}}
|
|
||||||
itemId={'id'}
|
itemId={'id'}
|
||||||
data-test-subj={'rules-translation-table'}
|
data-test-subj={'rules-translation-table'}
|
||||||
columns={rulesColumns}
|
columns={rulesColumns}
|
||||||
|
|
|
@ -80,3 +80,31 @@ export const INSTALL_TRANSLATED_ARIA_LABEL = i18n.translate(
|
||||||
defaultMessage: 'Install all translated rules',
|
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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -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%',
|
||||||
|
};
|
||||||
|
};
|
|
@ -8,6 +8,7 @@
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
|
|
||||||
export * from './actions';
|
export * from './actions';
|
||||||
|
export * from './author';
|
||||||
export * from './name';
|
export * from './name';
|
||||||
export * from './risk_score';
|
export * from './risk_score';
|
||||||
export * from './severity';
|
export * from './severity';
|
||||||
|
|
|
@ -12,12 +12,11 @@ import * as i18n from './translations';
|
||||||
import type { TableColumn } from './constants';
|
import type { TableColumn } from './constants';
|
||||||
|
|
||||||
interface NameProps {
|
interface NameProps {
|
||||||
name: string;
|
|
||||||
rule: RuleMigration;
|
rule: RuleMigration;
|
||||||
openMigrationRuleDetails: (rule: RuleMigration) => void;
|
openMigrationRuleDetails: (rule: RuleMigration) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Name = ({ name, rule, openMigrationRuleDetails }: NameProps) => {
|
const Name = ({ rule, openMigrationRuleDetails }: NameProps) => {
|
||||||
return (
|
return (
|
||||||
<EuiLink
|
<EuiLink
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -25,7 +24,7 @@ const Name = ({ name, rule, openMigrationRuleDetails }: NameProps) => {
|
||||||
}}
|
}}
|
||||||
data-test-subj="ruleName"
|
data-test-subj="ruleName"
|
||||||
>
|
>
|
||||||
{name}
|
{rule.elastic_rule?.title}
|
||||||
</EuiLink>
|
</EuiLink>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -36,10 +35,10 @@ export const createNameColumn = ({
|
||||||
openMigrationRuleDetails: (rule: RuleMigration) => void;
|
openMigrationRuleDetails: (rule: RuleMigration) => void;
|
||||||
}): TableColumn => {
|
}): TableColumn => {
|
||||||
return {
|
return {
|
||||||
field: 'original_rule.title',
|
field: 'elastic_rule.title',
|
||||||
name: i18n.COLUMN_NAME,
|
name: i18n.COLUMN_NAME,
|
||||||
render: (value: RuleMigration['original_rule']['title'], rule: RuleMigration) => (
|
render: (_, rule: RuleMigration) => (
|
||||||
<Name name={value} rule={rule} openMigrationRuleDetails={openMigrationRuleDetails} />
|
<Name rule={rule} openMigrationRuleDetails={openMigrationRuleDetails} />
|
||||||
),
|
),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
truncateText: true,
|
truncateText: true,
|
||||||
|
|
|
@ -22,6 +22,6 @@ export const createRiskScoreColumn = (): TableColumn => {
|
||||||
),
|
),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
truncateText: true,
|
truncateText: true,
|
||||||
width: '75px',
|
width: '10%',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,9 +8,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||||
import { DEFAULT_TRANSLATION_SEVERITY } from '../../../../../common/siem_migrations/constants';
|
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 { SeverityBadge } from '../../../../common/components/severity_badge';
|
||||||
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
|
||||||
import type { TableColumn } from './constants';
|
import type { TableColumn } from './constants';
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
|
|
||||||
|
@ -19,8 +17,7 @@ export const createSeverityColumn = (): TableColumn => {
|
||||||
field: 'elastic_rule.severity',
|
field: 'elastic_rule.severity',
|
||||||
name: i18n.COLUMN_SEVERITY,
|
name: i18n.COLUMN_SEVERITY,
|
||||||
render: (value?: Severity) => <SeverityBadge value={value ?? DEFAULT_TRANSLATION_SEVERITY} />,
|
render: (value?: Severity) => <SeverityBadge value={value ?? DEFAULT_TRANSLATION_SEVERITY} />,
|
||||||
sortable: ({ elastic_rule: elasticRule }: RuleMigration) =>
|
sortable: true,
|
||||||
getNormalizedSeverity((elasticRule?.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY),
|
|
||||||
truncateText: true,
|
truncateText: true,
|
||||||
width: '12%',
|
width: '12%',
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,8 +18,8 @@ export const createStatusColumn = (): TableColumn => {
|
||||||
render: (value: RuleMigration['translation_result'], rule: RuleMigration) => (
|
render: (value: RuleMigration['translation_result'], rule: RuleMigration) => (
|
||||||
<StatusBadge value={value} installedRuleId={rule.elastic_rule?.id} />
|
<StatusBadge value={value} installedRuleId={rule.elastic_rule?.id} />
|
||||||
),
|
),
|
||||||
sortable: false,
|
sortable: true,
|
||||||
truncateText: true,
|
truncateText: true,
|
||||||
width: '12%',
|
width: '15%',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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(
|
export const ACTIONS_VIEW_LABEL = i18n.translate(
|
||||||
'xpack.securitySolution.siemMigrations.rules.tableColumn.actionsViewLabel',
|
'xpack.securitySolution.siemMigrations.rules.tableColumn.actionsViewLabel',
|
||||||
{
|
{
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -8,9 +8,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { euiLightVars } from '@kbn/ui-theme';
|
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 type { RuleMigrationTranslationResult } from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
||||||
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
|
|
||||||
import { convertTranslationResultIntoText } from '../../utils/helpers';
|
import { convertTranslationResultIntoText } from '../../utils/helpers';
|
||||||
|
import * as i18n from './translations';
|
||||||
|
|
||||||
|
const statusTextWrapperClassName = css`
|
||||||
|
width: 100%;
|
||||||
|
display: inline-grid;
|
||||||
|
`;
|
||||||
|
|
||||||
const { euiColorVis0, euiColorVis7, euiColorVis9 } = euiLightVars;
|
const { euiColorVis0, euiColorVis7, euiColorVis9 } = euiLightVars;
|
||||||
const statusToColorMap: Record<RuleMigrationTranslationResult, string> = {
|
const statusToColorMap: Record<RuleMigrationTranslationResult, string> = {
|
||||||
|
@ -28,17 +35,28 @@ interface StatusBadgeProps {
|
||||||
export const StatusBadge: React.FC<StatusBadgeProps> = React.memo(
|
export const StatusBadge: React.FC<StatusBadgeProps> = React.memo(
|
||||||
({ value, installedRuleId, 'data-test-subj': dataTestSubj = 'translation-result' }) => {
|
({ value, installedRuleId, 'data-test-subj': dataTestSubj = 'translation-result' }) => {
|
||||||
const translationResult = installedRuleId ? 'full' : value ?? 'untranslatable';
|
const translationResult = installedRuleId ? 'full' : value ?? 'untranslatable';
|
||||||
const displayValue = convertTranslationResultIntoText(translationResult);
|
const displayValue = installedRuleId
|
||||||
|
? i18n.RULE_STATUS_INSTALLED
|
||||||
|
: convertTranslationResultIntoText(translationResult);
|
||||||
const color = statusToColorMap[translationResult];
|
const color = statusToColorMap[translationResult];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HealthTruncateText
|
<EuiToolTip content={displayValue}>
|
||||||
healthColor={color}
|
{installedRuleId ? (
|
||||||
tooltipContent={displayValue}
|
<EuiFlexGroup gutterSize="xs" alignItems="center">
|
||||||
dataTestSubj={dataTestSubj}
|
<EuiFlexItem grow={false}>
|
||||||
>
|
<EuiIcon type={'check'} color={statusToColorMap.full} />
|
||||||
{displayValue}
|
</EuiFlexItem>
|
||||||
</HealthTruncateText>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -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',
|
||||||
|
}
|
||||||
|
);
|
|
@ -10,6 +10,7 @@ import type { RuleMigration } from '../../../../common/siem_migrations/model/rul
|
||||||
import type { TableColumn } from '../components/rules_table_columns';
|
import type { TableColumn } from '../components/rules_table_columns';
|
||||||
import {
|
import {
|
||||||
createActionsColumn,
|
createActionsColumn,
|
||||||
|
createAuthorColumn,
|
||||||
createNameColumn,
|
createNameColumn,
|
||||||
createRiskScoreColumn,
|
createRiskScoreColumn,
|
||||||
createSeverityColumn,
|
createSeverityColumn,
|
||||||
|
@ -33,6 +34,7 @@ export const useMigrationRulesTableColumns = ({
|
||||||
createStatusColumn(),
|
createStatusColumn(),
|
||||||
createRiskScoreColumn(),
|
createRiskScoreColumn(),
|
||||||
createSeverityColumn(),
|
createSeverityColumn(),
|
||||||
|
createAuthorColumn(),
|
||||||
createActionsColumn({
|
createActionsColumn({
|
||||||
disableActions,
|
disableActions,
|
||||||
openMigrationRuleDetails,
|
openMigrationRuleDetails,
|
||||||
|
|
|
@ -18,6 +18,8 @@ export const useGetMigrationRules = (params: {
|
||||||
migrationId: string;
|
migrationId: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
|
sortField: string;
|
||||||
|
sortDirection: 'asc' | 'desc';
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { addError } = useAppToasts();
|
const { addError } = useAppToasts();
|
||||||
|
|
|
@ -23,8 +23,8 @@ export const useInstallMigrationRules = (migrationId: string) => {
|
||||||
const invalidateGetMigrationTranslationStats =
|
const invalidateGetMigrationTranslationStats =
|
||||||
useInvalidateGetMigrationTranslationStats(migrationId);
|
useInvalidateGetMigrationTranslationStats(migrationId);
|
||||||
|
|
||||||
return useMutation<InstallMigrationRulesResponse, Error, string[]>(
|
return useMutation<InstallMigrationRulesResponse, Error, { ids: string[]; enabled: boolean }>(
|
||||||
(ids: string[]) => installMigrationRules({ migrationId, ids }),
|
({ ids, enabled = false }) => installMigrationRules({ migrationId, ids, enabled }),
|
||||||
{
|
{
|
||||||
mutationKey: INSTALL_MIGRATION_RULES_MUTATION_KEY,
|
mutationKey: INSTALL_MIGRATION_RULES_MUTATION_KEY,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
|
@ -39,13 +39,20 @@ export const registerSiemRuleMigrationsGetRoute = (
|
||||||
},
|
},
|
||||||
withLicense(async (context, req, res): Promise<IKibanaResponse<GetRuleMigrationResponse>> => {
|
withLicense(async (context, req, res): Promise<IKibanaResponse<GetRuleMigrationResponse>> => {
|
||||||
const { migration_id: migrationId } = req.params;
|
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 {
|
try {
|
||||||
const ctx = await context.resolve(['securitySolution']);
|
const ctx = await context.resolve(['securitySolution']);
|
||||||
const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient();
|
const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient();
|
||||||
|
|
||||||
const options: RuleMigrationGetOptions = {
|
const options: RuleMigrationGetOptions = {
|
||||||
filters: { searchTerm },
|
filters: { searchTerm },
|
||||||
|
sort: { sortField, sortDirection },
|
||||||
size: perPage,
|
size: perPage,
|
||||||
from: page && perPage ? page * perPage : 0,
|
from: page && perPage ? page * perPage : 0,
|
||||||
};
|
};
|
||||||
|
|
|
@ -40,7 +40,7 @@ export const registerSiemRuleMigrationsInstallRoute = (
|
||||||
withLicense(
|
withLicense(
|
||||||
async (context, req, res): Promise<IKibanaResponse<InstallMigrationRulesResponse>> => {
|
async (context, req, res): Promise<IKibanaResponse<InstallMigrationRulesResponse>> => {
|
||||||
const { migration_id: migrationId } = req.params;
|
const { migration_id: migrationId } = req.params;
|
||||||
const ids = req.body;
|
const { ids, enabled = false } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ctx = await context.resolve(['core', 'alerting', 'securitySolution']);
|
const ctx = await context.resolve(['core', 'alerting', 'securitySolution']);
|
||||||
|
@ -52,6 +52,7 @@ export const registerSiemRuleMigrationsInstallRoute = (
|
||||||
await installTranslated({
|
await installTranslated({
|
||||||
migrationId,
|
migrationId,
|
||||||
ids,
|
ids,
|
||||||
|
enabled,
|
||||||
securitySolutionContext,
|
securitySolutionContext,
|
||||||
savedObjectsClient,
|
savedObjectsClient,
|
||||||
rulesClient,
|
rulesClient,
|
||||||
|
|
|
@ -50,6 +50,7 @@ export const registerSiemRuleMigrationsInstallTranslatedRoute = (
|
||||||
|
|
||||||
await installTranslated({
|
await installTranslated({
|
||||||
migrationId,
|
migrationId,
|
||||||
|
enabled: false,
|
||||||
securitySolutionContext,
|
securitySolutionContext,
|
||||||
savedObjectsClient,
|
savedObjectsClient,
|
||||||
rulesClient,
|
rulesClient,
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {
|
||||||
|
|
||||||
const installPrebuiltRules = async (
|
const installPrebuiltRules = async (
|
||||||
rulesToInstall: StoredRuleMigration[],
|
rulesToInstall: StoredRuleMigration[],
|
||||||
|
enabled: boolean,
|
||||||
securitySolutionContext: SecuritySolutionApiRequestHandlerContext,
|
securitySolutionContext: SecuritySolutionApiRequestHandlerContext,
|
||||||
rulesClient: RulesClient,
|
rulesClient: RulesClient,
|
||||||
savedObjectsClient: SavedObjectsClientContract,
|
savedObjectsClient: SavedObjectsClientContract,
|
||||||
|
@ -41,7 +42,7 @@ const installPrebuiltRules = async (
|
||||||
if (item.current) {
|
if (item.current) {
|
||||||
acc.installed.push(item.current);
|
acc.installed.push(item.current);
|
||||||
} else {
|
} else {
|
||||||
acc.installable.push(item.target);
|
acc.installable.push({ ...item.target, enabled });
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
|
@ -85,6 +86,7 @@ const installPrebuiltRules = async (
|
||||||
|
|
||||||
export const installCustomRules = async (
|
export const installCustomRules = async (
|
||||||
rulesToInstall: StoredRuleMigration[],
|
rulesToInstall: StoredRuleMigration[],
|
||||||
|
enabled: boolean,
|
||||||
detectionRulesClient: IDetectionRulesClient,
|
detectionRulesClient: IDetectionRulesClient,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
): Promise<UpdateRuleMigrationInput[]> => {
|
): Promise<UpdateRuleMigrationInput[]> => {
|
||||||
|
@ -96,8 +98,11 @@ export const installCustomRules = async (
|
||||||
if (!isMigrationCustomRule(rule.elastic_rule)) {
|
if (!isMigrationCustomRule(rule.elastic_rule)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payloadRule = convertMigrationCustomRuleToSecurityRulePayload(rule.elastic_rule);
|
const payloadRule = convertMigrationCustomRuleToSecurityRulePayload(
|
||||||
const createdRule = await detectionRulesClient.createPrebuiltRule({
|
rule.elastic_rule,
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
const createdRule = await detectionRulesClient.createCustomRule({
|
||||||
params: payloadRule,
|
params: payloadRule,
|
||||||
});
|
});
|
||||||
rulesToUpdate.push({
|
rulesToUpdate.push({
|
||||||
|
@ -131,6 +136,11 @@ interface InstallTranslatedProps {
|
||||||
*/
|
*/
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the installed migration rules should be enabled
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The security solution context
|
* The security solution context
|
||||||
*/
|
*/
|
||||||
|
@ -155,6 +165,7 @@ interface InstallTranslatedProps {
|
||||||
export const installTranslated = async ({
|
export const installTranslated = async ({
|
||||||
migrationId,
|
migrationId,
|
||||||
ids,
|
ids,
|
||||||
|
enabled,
|
||||||
securitySolutionContext,
|
securitySolutionContext,
|
||||||
rulesClient,
|
rulesClient,
|
||||||
savedObjectsClient,
|
savedObjectsClient,
|
||||||
|
@ -186,6 +197,7 @@ export const installTranslated = async ({
|
||||||
|
|
||||||
const updatedPrebuiltRules = await installPrebuiltRules(
|
const updatedPrebuiltRules = await installPrebuiltRules(
|
||||||
prebuiltRulesToInstall,
|
prebuiltRulesToInstall,
|
||||||
|
enabled,
|
||||||
securitySolutionContext,
|
securitySolutionContext,
|
||||||
rulesClient,
|
rulesClient,
|
||||||
savedObjectsClient,
|
savedObjectsClient,
|
||||||
|
@ -194,6 +206,7 @@ export const installTranslated = async ({
|
||||||
|
|
||||||
const updatedCustomRules = await installCustomRules(
|
const updatedCustomRules = await installCustomRules(
|
||||||
customRulesToInstall,
|
customRulesToInstall,
|
||||||
|
enabled,
|
||||||
detectionRulesClient,
|
detectionRulesClient,
|
||||||
logger
|
logger
|
||||||
);
|
);
|
||||||
|
|
|
@ -15,10 +15,7 @@ import type {
|
||||||
QueryDslQueryContainer,
|
QueryDslQueryContainer,
|
||||||
} from '@elastic/elasticsearch/lib/api/types';
|
} from '@elastic/elasticsearch/lib/api/types';
|
||||||
import type { StoredRuleMigration } from '../types';
|
import type { StoredRuleMigration } from '../types';
|
||||||
import {
|
import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants';
|
||||||
SiemMigrationRuleTranslationResult,
|
|
||||||
SiemMigrationStatus,
|
|
||||||
} from '../../../../../common/siem_migrations/constants';
|
|
||||||
import type {
|
import type {
|
||||||
ElasticRule,
|
ElasticRule,
|
||||||
RuleMigration,
|
RuleMigration,
|
||||||
|
@ -26,6 +23,8 @@ import type {
|
||||||
RuleMigrationTranslationStats,
|
RuleMigrationTranslationStats,
|
||||||
} from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
} from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
||||||
import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client';
|
import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client';
|
||||||
|
import { getSortingOptions, type RuleMigrationSort } from './sort';
|
||||||
|
import { conditions as searchConditions } from './search';
|
||||||
|
|
||||||
export type CreateRuleMigrationInput = Omit<
|
export type CreateRuleMigrationInput = Omit<
|
||||||
RuleMigration,
|
RuleMigration,
|
||||||
|
@ -47,6 +46,7 @@ export interface RuleMigrationFilters {
|
||||||
}
|
}
|
||||||
export interface RuleMigrationGetOptions {
|
export interface RuleMigrationGetOptions {
|
||||||
filters?: RuleMigrationFilters;
|
filters?: RuleMigrationFilters;
|
||||||
|
sort?: RuleMigrationSort;
|
||||||
from?: number;
|
from?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
@ -120,13 +120,19 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
|
||||||
/** Retrieves an array of rule documents of a specific migrations */
|
/** Retrieves an array of rule documents of a specific migrations */
|
||||||
async get(
|
async get(
|
||||||
migrationId: string,
|
migrationId: string,
|
||||||
{ filters = {}, from, size }: RuleMigrationGetOptions = {}
|
{ filters = {}, sort = {}, from, size }: RuleMigrationGetOptions = {}
|
||||||
): Promise<{ total: number; data: StoredRuleMigration[] }> {
|
): Promise<{ total: number; data: StoredRuleMigration[] }> {
|
||||||
const index = await this.getIndexName();
|
const index = await this.getIndexName();
|
||||||
const query = this.getFilterQuery(migrationId, { ...filters });
|
const query = this.getFilterQuery(migrationId, { ...filters });
|
||||||
|
|
||||||
const result = await this.esClient
|
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) => {
|
.catch((error) => {
|
||||||
this.logger.error(`Error searching rule migrations: ${error.message}`);
|
this.logger.error(`Error searching rule migrations: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -238,8 +244,8 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
|
||||||
const query = this.getFilterQuery(migrationId);
|
const query = this.getFilterQuery(migrationId);
|
||||||
|
|
||||||
const aggregations = {
|
const aggregations = {
|
||||||
prebuilt: { filter: conditions.isPrebuilt() },
|
prebuilt: { filter: searchConditions.isPrebuilt() },
|
||||||
installable: { filter: { bool: { must: conditions.isInstallable() } } },
|
installable: { filter: { bool: { must: searchConditions.isInstallable() } } },
|
||||||
};
|
};
|
||||||
const result = await this.esClient
|
const result = await this.esClient
|
||||||
.search({ index, query, aggregations, _source: false })
|
.search({ index, query, aggregations, _source: false })
|
||||||
|
@ -351,47 +357,14 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
|
||||||
filter.push({ terms: { _id: ids } });
|
filter.push({ terms: { _id: ids } });
|
||||||
}
|
}
|
||||||
if (installable) {
|
if (installable) {
|
||||||
filter.push(...conditions.isInstallable());
|
filter.push(...searchConditions.isInstallable());
|
||||||
}
|
}
|
||||||
if (prebuilt) {
|
if (prebuilt) {
|
||||||
filter.push(conditions.isPrebuilt());
|
filter.push(searchConditions.isPrebuilt());
|
||||||
}
|
}
|
||||||
if (searchTerm?.length) {
|
if (searchTerm?.length) {
|
||||||
filter.push(conditions.matchTitle(searchTerm));
|
filter.push(searchConditions.matchTitle(searchTerm));
|
||||||
}
|
}
|
||||||
return { bool: { filter } };
|
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()];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
@ -19,14 +19,14 @@ export const ruleMigrationsFieldMap: FieldMap<SchemaFieldMapKeys<Omit<RuleMigrat
|
||||||
original_rule: { type: 'nested', required: true },
|
original_rule: { type: 'nested', required: true },
|
||||||
'original_rule.vendor': { type: 'keyword', required: true },
|
'original_rule.vendor': { type: 'keyword', required: true },
|
||||||
'original_rule.id': { 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.description': { type: 'text', required: false },
|
||||||
'original_rule.query': { type: 'text', required: true },
|
'original_rule.query': { type: 'text', required: true },
|
||||||
'original_rule.query_language': { type: 'keyword', required: true },
|
'original_rule.query_language': { type: 'keyword', required: true },
|
||||||
'original_rule.annotations': { type: 'nested', required: false },
|
'original_rule.annotations': { type: 'nested', required: false },
|
||||||
'original_rule.annotations.mitre_attack': { type: 'keyword', array: true, required: false },
|
'original_rule.annotations.mitre_attack': { type: 'keyword', array: true, required: false },
|
||||||
elastic_rule: { type: 'nested', 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.integration_ids': { type: 'keyword', array: true, required: false },
|
||||||
'elastic_rule.query': { type: 'text', required: true },
|
'elastic_rule.query': { type: 'text', required: true },
|
||||||
'elastic_rule.query_language': { type: 'keyword', required: true },
|
'elastic_rule.query_language': { type: 'keyword', required: true },
|
||||||
|
|
|
@ -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()];
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue