Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Detections] Fix rule creation/editing forms #130825

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,15 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
// ----------------------------------
// -- EFFECTS
// ----------------------------------
useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in my previous comment, please don't put the useEffect back. We can discuss over Zoom how to solve your form without counting on this.

if (!isMounted.current) {
return;
}

// Whenever the "defaultValue" prop changes, reinitialize our ref
defaultValueDeserialized.current = defaultValueInitialized;
}, [defaultValueInitialized]);

useEffect(() => {
isMounted.current = true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ import {
SHOWING_RULES_TEXT,
} from '../../screens/alerts_detection_rules';
import {
// ABOUT_CONTINUE_BTN,
// ABOUT_EDIT_BUTTON,
ABOUT_CONTINUE_BTN,
ABOUT_EDIT_BUTTON,
ACTIONS_THROTTLE_INPUT,
CUSTOM_QUERY_INPUT,
// DEFINE_CONTINUE_BUTTON,
// DEFINE_EDIT_BUTTON,
DEFINE_CONTINUE_BUTTON,
DEFINE_EDIT_BUTTON,
DEFINE_INDEX_INPUT,
DEFAULT_RISK_SCORE_INPUT,
RULE_DESCRIPTION_INPUT,
Expand Down Expand Up @@ -134,7 +134,6 @@ describe('Custom query rules', () => {
fillAboutRuleAndContinue(this.rule);
fillScheduleRuleAndContinue(this.rule);

/* Commenting this piece of code due to the following issue: https://github.com/elastic/kibana/issues/130767
// expect define step to repopulate
cy.get(DEFINE_EDIT_BUTTON).click();
cy.get(CUSTOM_QUERY_INPUT).should('have.value', this.rule.customQuery);
Expand All @@ -145,7 +144,7 @@ describe('Custom query rules', () => {
cy.get(ABOUT_EDIT_BUTTON).click();
cy.get(RULE_NAME_INPUT).invoke('val').should('eql', this.rule.name);
cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true });
cy.get(ABOUT_CONTINUE_BTN).should('not.exist'); */
cy.get(ABOUT_CONTINUE_BTN).should('not.exist');

createAndEnableRule();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,11 @@ describe('indicator match', () => {
selectIndicatorMatchType();
});

// Unskip once https://github.com/elastic/kibana/issues/130770 is fixed
it.skip('Has a default set of *:*', () => {
it('Has a default set of *:*', () => {
getCustomQueryInput().should('have.text', '*:*');
});

// Unskip once https://github.com/elastic/kibana/issues/1307707 is fixed
it.skip('Shows invalidation text if text is removed', () => {
it('Shows invalidation text if text is removed', () => {
getCustomQueryInput().type('{selectall}{del}');
getCustomQueryInvalidationText().should('exist');
});
Expand Down Expand Up @@ -401,8 +399,7 @@ describe('indicator match', () => {
});

describe('Schedule', () => {
// Unskip once https://github.com/elastic/kibana/issues/1307707 is fixed
it.skip('IM rule has 1h time interval and lookback by default', () => {
it('IM rule has 1h time interval and lookback by default', () => {
visitWithoutDateRange(RULE_CREATION);
selectIndicatorMatchType();
fillDefineIndicatorMatchRuleAndContinue(getNewThreatIndicatorRule());
Expand All @@ -421,8 +418,7 @@ describe('indicator match', () => {
deleteAlertsAndRules();
});

// Unskip once https://github.com/elastic/kibana/issues/1307707 is fixed
it.skip('Creates and enables a new Indicator Match rule', () => {
it('Creates and enables a new Indicator Match rule', () => {
visitWithoutDateRange(RULE_CREATION);
selectIndicatorMatchType();
fillDefineIndicatorMatchRuleAndContinue(getNewThreatIndicatorRule());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
query: newQuery,
language: 'eql',
},
saved_id: null,
});
},
[setValue, onValiditingChange]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import * as i18n from './translations';
export interface FieldValueQueryBar {
filters: Filter[];
query: Query;
saved_id?: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI there is an option you can pass to the form to not strip undefined value (https://docs.elastic.dev/form-lib/core/use-form#stripemptyfields)

saved_id: string | null;
}
interface QueryBarDefineRuleProps {
browserFields: BrowserFields;
Expand Down Expand Up @@ -176,7 +176,7 @@ export const QueryBarDefineRule = ({
query: '',
language: 'kuery',
},
saved_id: undefined,
saved_id: null,
});
}
}
Expand Down Expand Up @@ -210,7 +210,7 @@ export const QueryBarDefineRule = ({
? [...newFilters, getDataProviderFilter(dataProvidersDsl)]
: newFilters,
query: newQuery,
saved_id: undefined,
saved_id: null,
});
},
[browserFields, indexPattern, setFieldValue]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('query_preview/helpers', () => {
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -91,7 +91,7 @@ describe('query_preview/helpers', () => {
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -107,7 +107,7 @@ describe('query_preview/helpers', () => {
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -123,7 +123,7 @@ describe('query_preview/helpers', () => {
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -137,7 +137,7 @@ describe('query_preview/helpers', () => {
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: ['test-ml-job-id'],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -151,7 +151,7 @@ describe('query_preview/helpers', () => {
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -165,7 +165,7 @@ describe('query_preview/helpers', () => {
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(true);
});
Expand All @@ -181,7 +181,7 @@ describe('query_preview/helpers', () => {
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
queryBar: { filters: [], query: { query: '', language: 'testlang' } },
queryBar: { filters: [], query: { query: '', language: 'testlang' }, saved_id: null },
});
expect(isDisabled).toEqual(false);
});
Expand All @@ -195,7 +195,11 @@ describe('query_preview/helpers', () => {
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
queryBar: { filters: [], query: { query: 'any where true', language: 'testlang' } },
queryBar: {
filters: [],
query: { query: 'any where true', language: 'testlang' },
saved_id: null,
},
});
expect(isDisabled).toEqual(false);
});
Expand Down Expand Up @@ -234,6 +238,7 @@ describe('query_preview/helpers', () => {
{
query: { query: 'host.name:*', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
saved_id: null,
},
['foo-*'],
'query'
Expand All @@ -260,6 +265,7 @@ describe('query_preview/helpers', () => {
{
query: { query: 'host.name:*', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
saved_id: null,
},
['foo-*'],
'saved_query'
Expand All @@ -286,6 +292,7 @@ describe('query_preview/helpers', () => {
{
query: { query: 'host.name:*', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
saved_id: null,
},
['foo-*'],
'threshold'
Expand All @@ -312,6 +319,7 @@ describe('query_preview/helpers', () => {
{
query: { query: 'file where true', language: 'eql' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
saved_id: null,
},
['foo-*'],
'eql'
Expand All @@ -329,6 +337,7 @@ describe('query_preview/helpers', () => {
{
query: { query: 'host.name:', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
saved_id: null,
},
['foo-*'],
'threshold'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ const defaultProps: RulePreviewProps = {
query: {
filters: [],
query: { query: 'file.hash.md5:*', language: 'kuery' },
saved_id: null,
},
threatQuery: {
filters: [],
query: { query: 'threat.indicator.file.hash.md5:*', language: 'kuery' },
saved_id: null,
},
threshold: {
field: ['agent.hostname'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { ThreatMatchInput } from '../threatmatch_input';
import { BrowserField, BrowserFields, useFetchIndex } from '../../../../common/containers/source';
import { RulePreview } from '../rule_preview';
import { getIsRulePreviewDisabled } from '../rule_preview/helpers';
import { usePrevious } from './use_previous';

const CommonUseField = getUseField({ component: Field });

Expand All @@ -73,12 +74,12 @@ export const stepDefineDefaultValue: DefineStepRule = {
queryBar: {
query: { query: '', language: 'kuery' },
filters: [],
saved_id: undefined,
saved_id: null,
},
threatQueryBar: {
query: { query: DEFAULT_THREAT_MATCH_QUERY, language: 'kuery' },
filters: [],
saved_id: undefined,
saved_id: null,
},
requiredFields: [],
relatedIntegrations: [],
Expand Down Expand Up @@ -108,6 +109,11 @@ const threatQueryBarDefaultValue: DefineStepRule['queryBar'] = {
query: { ...stepDefineDefaultValue.queryBar.query, query: '*:*' },
};

const defaultCustomQuery = {
forNormalRules: stepDefineDefaultValue.queryBar,
forThreatMatchRules: threatQueryBarDefaultValue,
};

export const MyLabelButton = styled(EuiButtonEmpty)`
height: 18px;
font-size: 12px;
Expand Down Expand Up @@ -192,6 +198,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
const machineLearningJobId = formMachineLearningJobId ?? initialState.machineLearningJobId;
const anomalyThreshold = formAnomalyThreshold ?? initialState.anomalyThreshold;
const ruleType = formRuleType || initialState.ruleType;
const previousRuleType = usePrevious(ruleType);
const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index);
const fields: Readonly<BrowserFields> = aggregatableFields(browserFields);

Expand All @@ -214,36 +221,55 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
}, [threatIndex, threatIndicesConfig]);

/**
* When a rule type is changed to or from a threat match this will modify the
* default query string to either:
* * from the empty string '' to '*:*' if the rule type is "threatMatchRule"
* * from '*:*' back to the empty string '' if the rule type is not "threatMatchRule"
* This calls queryBar.reset() in both cases to not trigger validation errors as
* the user has not entered data into those areas yet.
* If the user has entered data then through reference compares we can detect reliably if
* the user has changed data.
* * queryBar.value === defaultQueryBar (Has the user changed the input of '' yet?)
* * queryBar.value === threatQueryBarDefaultValue (Has the user changed the input of '*:*' yet?)
* This is a stronger guarantee than "isPristine" off of the forms as that value can be reset
* if you go to step 2) and then back to step 1) or the form is reset in another way. Using
* the reference compare we know factually if the data is changed as the references must change
* in the form libraries form the initial defaults.
* When the user changes rule type to or from "threat_match" this will modify the
* default "Custom query" string to either:
* * from '' to '*:*' if the type is switched to "threat_match"
* * from '*:*' back to '' if the type is switched back from "threat_match" to another one
*/
useEffect(() => {
const { queryBar } = getFields();
if (queryBar != null) {
const { queryBar: defaultQueryBar } = stepDefineDefaultValue;
if (isThreatMatchRule(ruleType) && queryBar.value === defaultQueryBar) {
if (queryBar == null) {
return;
}

// NOTE: Below this code does two things that are worth commenting.

// 1. If the user enters some text in the "Custom query" form field, we want
// to keep it even if the user switched to another rule type. So we want to
// be able to figure out if the field has been modified.
// - The forms library provides properties (isPristine, isModified, isDirty)
// for that but they can't be used in our case: their values can be reset
// if you go to step 2 and then back to step 1 or the form is reset in another way.
// - That's why we compare the actual value of the field with default ones.
// NOTE: It's important to do a deep object comparison by value.
// Don't do it by reference because the forms lib can change it internally.

// 2. We call queryBar.reset() in both cases to not trigger validation errors
// as the user has not entered data into those areas yet.

// If the user switched rule type to "threat_match" from any other one,
// but hasn't changed the custom query used for normal rules (''),
// we reset the custom query to the default used for "threat_match" rules ('*:*').
if (isThreatMatchRule(ruleType) && !isThreatMatchRule(previousRuleType)) {
if (isEqual(queryBar.value, defaultCustomQuery.forNormalRules)) {
queryBar.reset({
defaultValue: threatQueryBarDefaultValue,
defaultValue: defaultCustomQuery.forThreatMatchRules,
});
} else if (queryBar.value === threatQueryBarDefaultValue) {
return;
}
}

// If the user switched rule type from "threat_match" to any other one,
// but hasn't changed the custom query used for "threat_match" rules ('*:*'),
// we reset the custom query to another default value ('').
if (!isThreatMatchRule(ruleType) && isThreatMatchRule(previousRuleType)) {
if (isEqual(queryBar.value, defaultCustomQuery.forThreatMatchRules)) {
queryBar.reset({
defaultValue: defaultQueryBar,
defaultValue: defaultCustomQuery.forNormalRules,
});
}
}
}, [ruleType, getFields]);
}, [ruleType, previousRuleType, getFields]);

const handleSubmit = useCallback(() => {
if (onSubmit) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* 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 { useEffect, useRef } from 'react';

export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();

useEffect(() => {
ref.current = value;
}, [value]);

return ref.current;
};
Loading