mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[CTI] Enable IM Rule Preview (#126651)
This commit is contained in:
parent
574a38fcd5
commit
b5e194532d
10 changed files with 115 additions and 76 deletions
|
@ -428,9 +428,11 @@ export interface RulePreviewLogs {
|
|||
errors: string[];
|
||||
warnings: string[];
|
||||
startedAt?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
previewId: string | undefined;
|
||||
logs: RulePreviewLogs[] | undefined;
|
||||
isAborted: boolean | undefined;
|
||||
}
|
||||
|
|
|
@ -92,6 +92,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
|
|||
previewId,
|
||||
logs,
|
||||
hasNoiseWarning,
|
||||
isAborted,
|
||||
} = usePreviewRoute({
|
||||
index,
|
||||
isDisabled,
|
||||
|
@ -159,7 +160,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
|
|||
index={index}
|
||||
/>
|
||||
)}
|
||||
<PreviewLogsComponent logs={logs} hasNoiseWarning={hasNoiseWarning} />
|
||||
<PreviewLogsComponent logs={logs} hasNoiseWarning={hasNoiseWarning} isAborted={isAborted} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,9 +13,11 @@ import * as i18n from './translations';
|
|||
interface PreviewLogsComponentProps {
|
||||
logs: RulePreviewLogs[];
|
||||
hasNoiseWarning: boolean;
|
||||
isAborted: boolean;
|
||||
}
|
||||
|
||||
interface SortedLogs {
|
||||
duration: number;
|
||||
startedAt?: string;
|
||||
logs: string[];
|
||||
}
|
||||
|
@ -25,12 +27,25 @@ interface LogAccordionProps {
|
|||
isError?: boolean;
|
||||
}
|
||||
|
||||
const addLogs = (startedAt: string | undefined, logs: string[], allLogs: SortedLogs[]) =>
|
||||
logs.length ? [{ startedAt, logs }, ...allLogs] : allLogs;
|
||||
const CustomWarning: React.FC<{ message: string }> = ({ message }) => (
|
||||
<EuiCallOut color={'warning'} iconType="alert" data-test-subj={'preview-abort'}>
|
||||
<EuiText>
|
||||
<p>{message}</p>
|
||||
</EuiText>
|
||||
</EuiCallOut>
|
||||
);
|
||||
|
||||
const addLogs = (
|
||||
startedAt: string | undefined,
|
||||
logs: string[],
|
||||
duration: number,
|
||||
allLogs: SortedLogs[]
|
||||
) => (logs.length ? [{ startedAt, logs, duration }, ...allLogs] : allLogs);
|
||||
|
||||
export const PreviewLogsComponent: React.FC<PreviewLogsComponentProps> = ({
|
||||
logs,
|
||||
hasNoiseWarning,
|
||||
isAborted,
|
||||
}) => {
|
||||
const sortedLogs = useMemo(
|
||||
() =>
|
||||
|
@ -39,8 +54,8 @@ export const PreviewLogsComponent: React.FC<PreviewLogsComponentProps> = ({
|
|||
warnings: SortedLogs[];
|
||||
}>(
|
||||
({ errors, warnings }, curr) => ({
|
||||
errors: addLogs(curr.startedAt, curr.errors, errors),
|
||||
warnings: addLogs(curr.startedAt, curr.warnings, warnings),
|
||||
errors: addLogs(curr.startedAt, curr.errors, curr.duration, errors),
|
||||
warnings: addLogs(curr.startedAt, curr.warnings, curr.duration, warnings),
|
||||
}),
|
||||
{ errors: [], warnings: [] }
|
||||
),
|
||||
|
@ -49,19 +64,32 @@ export const PreviewLogsComponent: React.FC<PreviewLogsComponentProps> = ({
|
|||
return (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
{hasNoiseWarning ?? <CalloutGroup logs={[i18n.QUERY_PREVIEW_NOISE_WARNING]} />}
|
||||
{hasNoiseWarning ?? <CustomWarning message={i18n.QUERY_PREVIEW_NOISE_WARNING} />}
|
||||
<LogAccordion logs={sortedLogs.errors} isError />
|
||||
<LogAccordion logs={sortedLogs.warnings} />
|
||||
<LogAccordion logs={sortedLogs.warnings}>
|
||||
{isAborted && <CustomWarning message={i18n.PREVIEW_TIMEOUT_WARNING} />}
|
||||
</LogAccordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LogAccordion: React.FC<LogAccordionProps> = ({ logs, isError }) => {
|
||||
const LogAccordion: React.FC<LogAccordionProps> = ({ logs, isError, children }) => {
|
||||
const firstLog = logs[0];
|
||||
const restOfLogs = logs.slice(1);
|
||||
return firstLog ? (
|
||||
if (!(children || firstLog)) return null;
|
||||
|
||||
const restOfLogs = children ? logs : logs.slice(1);
|
||||
const bannerElement = children ?? (
|
||||
<CalloutGroup
|
||||
logs={firstLog.logs}
|
||||
startedAt={firstLog.startedAt}
|
||||
isError={isError}
|
||||
duration={firstLog.duration}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalloutGroup logs={firstLog.logs} startedAt={firstLog.startedAt} isError={isError} />
|
||||
{bannerElement}
|
||||
{restOfLogs.length > 0 ? (
|
||||
<EuiAccordion
|
||||
id={isError ? 'previewErrorAccordion' : 'previewWarningAccordion'}
|
||||
|
@ -74,6 +102,7 @@ const LogAccordion: React.FC<LogAccordionProps> = ({ logs, isError }) => {
|
|||
key={`accordion-log-${key}`}
|
||||
logs={log.logs}
|
||||
startedAt={log.startedAt}
|
||||
duration={log.duration}
|
||||
isError={isError}
|
||||
/>
|
||||
))}
|
||||
|
@ -81,14 +110,15 @@ const LogAccordion: React.FC<LogAccordionProps> = ({ logs, isError }) => {
|
|||
) : null}
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
export const CalloutGroup: React.FC<{
|
||||
logs: string[];
|
||||
duration: number;
|
||||
startedAt?: string;
|
||||
isError?: boolean;
|
||||
}> = ({ logs, startedAt, isError }) => {
|
||||
}> = ({ logs, startedAt, isError, duration }) => {
|
||||
return logs.length > 0 ? (
|
||||
<>
|
||||
{logs.map((log, i) => (
|
||||
|
@ -97,7 +127,7 @@ export const CalloutGroup: React.FC<{
|
|||
color={isError ? 'danger' : 'warning'}
|
||||
iconType="alert"
|
||||
data-test-subj={isError ? 'preview-error' : 'preview-warning'}
|
||||
title={startedAt != null ? `[${startedAt}]` : null}
|
||||
title={`${startedAt ? `[${startedAt}] ` : ''}[${duration}ms]`}
|
||||
>
|
||||
<EuiText>
|
||||
<p>{log}</p>
|
||||
|
|
|
@ -30,6 +30,13 @@ export const QUERY_PREVIEW_BUTTON = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const PREVIEW_TIMEOUT_WARNING = i18n.translate(
|
||||
'xpack.securitySolution.stepDefineRule.previewTimeoutWarning',
|
||||
{
|
||||
defaultMessage: 'Preview timed out after 60 seconds',
|
||||
}
|
||||
);
|
||||
|
||||
export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate(
|
||||
'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel',
|
||||
{
|
||||
|
|
|
@ -45,10 +45,12 @@ export const usePreviewRoute = ({
|
|||
|
||||
const { isLoading, response, rule, setRule } = usePreviewRule(timeFrame);
|
||||
const [logs, setLogs] = useState<RulePreviewLogs[]>(response.logs ?? []);
|
||||
const [isAborted, setIsAborted] = useState<boolean>(!!response.isAborted);
|
||||
const [hasNoiseWarning, setHasNoiseWarning] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLogs(response.logs ?? []);
|
||||
setIsAborted(!!response.isAborted);
|
||||
}, [response]);
|
||||
|
||||
const addNoiseWarning = useCallback(() => {
|
||||
|
@ -58,6 +60,7 @@ export const usePreviewRoute = ({
|
|||
const clearPreview = useCallback(() => {
|
||||
setRule(null);
|
||||
setLogs([]);
|
||||
setIsAborted(false);
|
||||
setIsRequestTriggered(false);
|
||||
setHasNoiseWarning(false);
|
||||
}, [setRule]);
|
||||
|
@ -120,5 +123,6 @@ export const usePreviewRoute = ({
|
|||
isPreviewRequestInProgress: isLoading,
|
||||
previewId: response.previewId ?? '',
|
||||
logs,
|
||||
isAborted,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui';
|
||||
import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import React, { FC, memo, useCallback, useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
|
@ -190,7 +190,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
|
|||
const machineLearningJobId = formMachineLearningJobId ?? initialState.machineLearningJobId;
|
||||
const anomalyThreshold = formAnomalyThreshold ?? initialState.anomalyThreshold;
|
||||
const ruleType = formRuleType || initialState.ruleType;
|
||||
const isPreviewRouteEnabled = useMemo(() => ruleType !== 'threat_match', [ruleType]);
|
||||
const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index);
|
||||
const aggregatableFields = Object.entries(browserFields).reduce<BrowserFields>(
|
||||
(groupAcc, [groupName, groupValue]) => {
|
||||
|
@ -504,31 +503,27 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
|
|||
}}
|
||||
/>
|
||||
</Form>
|
||||
{isPreviewRouteEnabled && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<RulePreview
|
||||
index={index}
|
||||
isDisabled={getIsRulePreviewDisabled({
|
||||
ruleType,
|
||||
isQueryBarValid,
|
||||
isThreatQueryBarValid,
|
||||
index,
|
||||
threatIndex,
|
||||
threatMapping: formThreatMapping,
|
||||
machineLearningJobId,
|
||||
})}
|
||||
query={formQuery}
|
||||
ruleType={ruleType}
|
||||
threatIndex={threatIndex}
|
||||
threatQuery={formThreatQuery}
|
||||
threatMapping={formThreatMapping}
|
||||
threshold={formThreshold}
|
||||
machineLearningJobId={machineLearningJobId}
|
||||
anomalyThreshold={anomalyThreshold}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<EuiSpacer size="s" />
|
||||
<RulePreview
|
||||
index={index}
|
||||
isDisabled={getIsRulePreviewDisabled({
|
||||
ruleType,
|
||||
isQueryBarValid,
|
||||
isThreatQueryBarValid,
|
||||
index,
|
||||
threatIndex,
|
||||
threatMapping: formThreatMapping,
|
||||
machineLearningJobId,
|
||||
})}
|
||||
query={formQuery}
|
||||
ruleType={ruleType}
|
||||
threatIndex={threatIndex}
|
||||
threatQuery={formThreatQuery}
|
||||
threatMapping={formThreatMapping}
|
||||
threshold={formThreshold}
|
||||
machineLearningJobId={machineLearningJobId}
|
||||
anomalyThreshold={anomalyThreshold}
|
||||
/>
|
||||
</StepContentWrapper>
|
||||
|
||||
{!isUpdateView && (
|
||||
|
|
|
@ -22,6 +22,7 @@ import { transformOutput } from './transforms';
|
|||
const emptyPreviewRule: PreviewResponse = {
|
||||
previewId: undefined,
|
||||
logs: [],
|
||||
isAborted: false,
|
||||
};
|
||||
|
||||
export const usePreviewRule = (timeframe: Unit = 'h') => {
|
||||
|
|
|
@ -47,6 +47,9 @@ import {
|
|||
} from '../../rule_types';
|
||||
import { createSecurityRuleTypeWrapper } from '../../rule_types/create_security_rule_type_wrapper';
|
||||
import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants';
|
||||
import { RuleExecutionContext, StatusChangeArgs } from '../../rule_execution_log';
|
||||
|
||||
const PREVIEW_TIMEOUT_SECONDS = 60;
|
||||
|
||||
export const previewRulesRoute = async (
|
||||
router: SecuritySolutionPluginRouter,
|
||||
|
@ -87,13 +90,7 @@ export const previewRulesRoute = async (
|
|||
].includes(invocationCount)
|
||||
) {
|
||||
return response.ok({
|
||||
body: { logs: [{ errors: ['Invalid invocation count'], warnings: [] }] },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.body.type === 'threat_match') {
|
||||
return response.ok({
|
||||
body: { logs: [{ errors: ['Preview for rule type not supported'], warnings: [] }] },
|
||||
body: { logs: [{ errors: ['Invalid invocation count'], warnings: [], duration: 0 }] },
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -112,9 +109,11 @@ export const previewRulesRoute = async (
|
|||
const spaceId = siemClient.getSpaceId();
|
||||
const previewId = uuid.v4();
|
||||
const username = security?.authc.getCurrentUser(request)?.username;
|
||||
const previewRuleExecutionLogger = createPreviewRuleExecutionLogger();
|
||||
const loggedStatusChanges: Array<RuleExecutionContext & StatusChangeArgs> = [];
|
||||
const previewRuleExecutionLogger = createPreviewRuleExecutionLogger(loggedStatusChanges);
|
||||
const runState: Record<string, unknown> = {};
|
||||
const logs: RulePreviewLogs[] = [];
|
||||
let isAborted = false;
|
||||
|
||||
const previewRuleTypeWrapper = createSecurityRuleTypeWrapper({
|
||||
...securityRuleTypeOptions,
|
||||
|
@ -158,6 +157,12 @@ export const previewRulesRoute = async (
|
|||
) => {
|
||||
let statePreview = runState as TState;
|
||||
|
||||
const abortController = new AbortController();
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
isAborted = true;
|
||||
}, PREVIEW_TIMEOUT_SECONDS * 1000);
|
||||
|
||||
const startedAt = moment();
|
||||
const parsedDuration = parseDuration(internalRule.schedule.interval) ?? 0;
|
||||
startedAt.subtract(moment.duration(parsedDuration * (invocationCount - 1)));
|
||||
|
@ -175,7 +180,11 @@ export const previewRulesRoute = async (
|
|||
updatedBy: username ?? 'preview-updated-by',
|
||||
};
|
||||
|
||||
while (invocationCount > 0) {
|
||||
let invocationStartTime;
|
||||
|
||||
while (invocationCount > 0 && !isAborted) {
|
||||
invocationStartTime = moment();
|
||||
|
||||
statePreview = (await executor({
|
||||
alertId: previewId,
|
||||
createdBy: rule.createdBy,
|
||||
|
@ -188,10 +197,9 @@ export const previewRulesRoute = async (
|
|||
shouldWriteAlerts,
|
||||
shouldStopExecution: () => false,
|
||||
alertFactory,
|
||||
// Just use es client always for preview
|
||||
search: createAbortableEsClientFactory({
|
||||
scopedClusterClient: context.core.elasticsearch.client,
|
||||
abortController: new AbortController(),
|
||||
abortController,
|
||||
}),
|
||||
savedObjectsClient: context.core.savedObjects.client,
|
||||
scopedClusterClient: context.core.elasticsearch.client,
|
||||
|
@ -204,12 +212,11 @@ export const previewRulesRoute = async (
|
|||
updatedBy: rule.updatedBy,
|
||||
})) as TState;
|
||||
|
||||
// Save and reset error and warning logs
|
||||
const errors = previewRuleExecutionLogger.logged.statusChanges
|
||||
const errors = loggedStatusChanges
|
||||
.filter((item) => item.newStatus === RuleExecutionStatus.failed)
|
||||
.map((item) => item.message ?? 'Unkown Error');
|
||||
|
||||
const warnings = previewRuleExecutionLogger.logged.statusChanges
|
||||
const warnings = loggedStatusChanges
|
||||
.filter((item) => item.newStatus === RuleExecutionStatus['partial failure'])
|
||||
.map((item) => item.message ?? 'Unknown Warning');
|
||||
|
||||
|
@ -217,9 +224,14 @@ export const previewRulesRoute = async (
|
|||
errors,
|
||||
warnings,
|
||||
startedAt: startedAt.toDate().toISOString(),
|
||||
duration: moment().diff(invocationStartTime, 'milliseconds'),
|
||||
});
|
||||
|
||||
previewRuleExecutionLogger.clearLogs();
|
||||
loggedStatusChanges.length = 0;
|
||||
|
||||
if (errors.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
previousStartedAt = startedAt.toDate();
|
||||
startedAt.add(parseInterval(internalRule.schedule.interval));
|
||||
|
@ -301,6 +313,7 @@ export const previewRulesRoute = async (
|
|||
body: {
|
||||
previewId,
|
||||
logs,
|
||||
isAborted,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
@ -13,19 +13,11 @@ import {
|
|||
|
||||
export interface IPreviewRuleExecutionLogger {
|
||||
factory: RuleExecutionLogForExecutorsFactory;
|
||||
|
||||
logged: {
|
||||
statusChanges: Array<RuleExecutionContext & StatusChangeArgs>;
|
||||
};
|
||||
|
||||
clearLogs(): void;
|
||||
}
|
||||
|
||||
export const createPreviewRuleExecutionLogger = () => {
|
||||
let logged: IPreviewRuleExecutionLogger['logged'] = {
|
||||
statusChanges: [],
|
||||
};
|
||||
|
||||
export const createPreviewRuleExecutionLogger = (
|
||||
loggedStatusChanges: Array<RuleExecutionContext & StatusChangeArgs>
|
||||
) => {
|
||||
const factory: RuleExecutionLogForExecutorsFactory = (
|
||||
savedObjectsClient,
|
||||
eventLogService,
|
||||
|
@ -36,17 +28,11 @@ export const createPreviewRuleExecutionLogger = () => {
|
|||
context,
|
||||
|
||||
logStatusChange(args: StatusChangeArgs): Promise<void> {
|
||||
logged.statusChanges.push({ ...context, ...args });
|
||||
loggedStatusChanges.push({ ...context, ...args });
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const clearLogs = (): void => {
|
||||
logged = {
|
||||
statusChanges: [],
|
||||
};
|
||||
};
|
||||
|
||||
return { factory, logged, clearLogs };
|
||||
return { factory };
|
||||
};
|
||||
|
|
|
@ -65,7 +65,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
.send(getSimplePreviewRule('', 3))
|
||||
.expect(200);
|
||||
const { logs } = getSimpleRulePreviewOutput(undefined, [
|
||||
{ errors: ['Invalid invocation count'], warnings: [] },
|
||||
{ errors: ['Invalid invocation count'], warnings: [], duration: 0 },
|
||||
]);
|
||||
expect(body).to.eql({ logs });
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue