[CTI] Enable IM Rule Preview (#126651)

This commit is contained in:
Ece Özalp 2022-03-08 13:18:26 -05:00 committed by GitHub
parent 574a38fcd5
commit b5e194532d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 115 additions and 76 deletions

View file

@ -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;
}

View file

@ -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} />
</>
);
};

View file

@ -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>

View file

@ -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',
{

View file

@ -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,
};
};

View file

@ -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 && (

View file

@ -22,6 +22,7 @@ import { transformOutput } from './transforms';
const emptyPreviewRule: PreviewResponse = {
previewId: undefined,
logs: [],
isAborted: false,
};
export const usePreviewRule = (timeframe: Unit = 'h') => {

View file

@ -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) {

View file

@ -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 };
};

View file

@ -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 });
});