[Security Solution][Detection Engine] Fixes indicator matches mapping UI where invalid list values can cause overwrites of other values (#89066)

## Summary

This fixes the ReactJS keys to not use array indexes for the ReactJS keys which fixes  https://github.com/elastic/kibana/issues/84893 as well as a few other bugs that I will show below. The fix for the ReactJS keys is to add a unique id version 4 `uuid.v4()` to the incoming threat_mapping and the entities. On save out to elastic I remove the id. This is considered [better practices for ReactJS keys](https://reactjs.org/docs/lists-and-keys.html)

Down the road we might augment the arrays to have that id information but for now I add them when we get the data and then remove them as we save the data.

This PR also:
* Fixes tech debt around the hooks to remove the disabling of the `react-hooks/exhaustive-deps` in a few areas
* Fixes one React Hook misnamed that would not have triggered React linter rules (_useRuleAsyn)
* Adds 23 new Cypress e2e tests
* Adds a new pattern of dealing with on button clicks for the Cypress tests that are make it less flakey
```ts
cy.get(`button[title="${indexField}"]`)
      .should('be.visible')
      .then(([e]) => e.click());
```
* Adds several new utilities to Cypress for testing rows for indicator matches and other Cypress utils to improve velocity and ergonomics
```ts
fillIndicatorMatchRow
getDefineContinueButton
getIndicatorInvalidationText
getIndicatorIndexComboField
getIndicatorDeleteButton
getIndicatorOrButton
getIndicatorAndButton
``` 

## Bug 1
Deleting row 1 can cause row 2 to be cleared out or only partial data to stick around.

Before:
![im_bug_1](https://user-images.githubusercontent.com/1151048/105916137-c57b1d80-5fed-11eb-95b7-ad25b71cf4b8.gif)

After:
![im_fix_1_1](https://user-images.githubusercontent.com/1151048/105917509-9fef1380-5fef-11eb-98eb-025c226f79fe.gif)

## Bug 2 
Deleting row 2 in the middle of 3 rows did not shift the value up correctly

Before:
![im_bug_2](https://user-images.githubusercontent.com/1151048/105917584-c01ed280-5fef-11eb-8c5b-fefb36f81008.gif)

After: 
![im_fix_2](https://user-images.githubusercontent.com/1151048/105917650-e0e72800-5fef-11eb-9fd3-020d52e4e3b1.gif)

## Bug 3
When using OR with values it does not shift up correctly similar to AND

Before:
![im_bug_3](https://user-images.githubusercontent.com/1151048/105917691-f2303480-5fef-11eb-9368-b11d23159606.gif)

After: 
![im_fix_3](https://user-images.githubusercontent.com/1151048/105917714-f9574280-5fef-11eb-9be4-1f56c207525a.gif)

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Frank Hassanabad 2021-01-29 19:16:19 -07:00 committed by GitHub
parent 2a913e4eb1
commit 2f80e44d3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 859 additions and 203 deletions

View file

@ -5,7 +5,7 @@
*/
import { formatMitreAttackDescription } from '../../helpers/rules';
import { newThreatIndicatorRule } from '../../objects/rule';
import { indexPatterns, newThreatIndicatorRule } from '../../objects/rule';
import {
ALERT_RULE_METHOD,
@ -70,7 +70,24 @@ import {
createAndActivateRule,
fillAboutRuleAndContinue,
fillDefineIndicatorMatchRuleAndContinue,
fillIndexAndIndicatorIndexPattern,
fillIndicatorMatchRow,
fillScheduleRuleAndContinue,
getCustomIndicatorQueryInput,
getCustomQueryInput,
getCustomQueryInvalidationText,
getDefineContinueButton,
getIndexPatternClearButton,
getIndexPatternInvalidationText,
getIndicatorAndButton,
getIndicatorAtLeastOneInvalidationText,
getIndicatorDeleteButton,
getIndicatorIndex,
getIndicatorIndexComboField,
getIndicatorIndicatorIndex,
getIndicatorInvalidationText,
getIndicatorMappingComboField,
getIndicatorOrButton,
selectIndicatorMatchType,
waitForAlertsToPopulate,
waitForTheRuleToBeExecuted,
@ -92,14 +109,6 @@ describe('Detection rules, Indicator Match', () => {
cleanKibana();
esArchiverLoad('threat_indicator');
esArchiverLoad('threat_data');
});
afterEach(() => {
esArchiverUnload('threat_indicator');
esArchiverUnload('threat_data');
});
it('Creates and activates a new Indicator Match rule', () => {
loginAndWaitForPageWithoutDateRange(DETECTIONS_URL);
waitForAlertsPanelToBeLoaded();
waitForAlertsIndexToBeCreated();
@ -107,89 +116,330 @@ describe('Detection rules, Indicator Match', () => {
waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded();
goToCreateNewRule();
selectIndicatorMatchType();
fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule);
fillAboutRuleAndContinue(newThreatIndicatorRule);
fillScheduleRuleAndContinue(newThreatIndicatorRule);
createAndActivateRule();
});
cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)');
afterEach(() => {
esArchiverUnload('threat_indicator');
esArchiverUnload('threat_data');
});
changeToThreeHundredRowsPerPage();
waitForRulesToBeLoaded();
cy.get(RULES_TABLE).then(($table) => {
cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules);
});
filterByCustomRules();
cy.get(RULES_TABLE).then(($table) => {
cy.wrap($table.find(RULES_ROW).length).should('eql', 1);
});
cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name);
cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore);
cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity);
cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true');
goToRuleDetails();
cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`);
cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description);
cy.get(ABOUT_DETAILS).within(() => {
getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity);
getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore);
getDetails(REFERENCE_URLS_DETAILS).should((details) => {
expect(removeExternalLinkText(details.text())).equal(expectedUrls);
describe('Creating new indicator match rules', () => {
describe('Index patterns', () => {
it('Contains a predefined index pattern', () => {
getIndicatorIndex().should('have.text', indexPatterns.join(''));
});
getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives);
getDetails(MITRE_ATTACK_DETAILS).should((mitre) => {
expect(removeExternalLinkText(mitre.text())).equal(expectedMitre);
it('Does NOT show invalidation text on initial page load if indicator index pattern is filled out', () => {
getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`);
getDefineContinueButton().click();
getIndexPatternInvalidationText().should('not.exist');
});
getDetails(TAGS_DETAILS).should('have.text', expectedTags);
});
cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true });
cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(INDEX_PATTERNS_DETAILS).should(
'have.text',
newThreatIndicatorRule.index!.join('')
);
getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*');
getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match');
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
getDetails(INDICATOR_INDEX_PATTERNS).should(
'have.text',
newThreatIndicatorRule.indicatorIndexPattern.join('')
);
getDetails(INDICATOR_MAPPING).should(
'have.text',
`${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}`
);
getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*');
it('Shows invalidation text when you try to continue without filling it out', () => {
getIndexPatternClearButton().click();
getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`);
getDefineContinueButton().click();
getIndexPatternInvalidationText().should('exist');
});
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should(
'have.text',
`${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}`
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should(
'have.text',
`${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}`
);
describe('Indicator index patterns', () => {
it('Contains empty index pattern', () => {
getIndicatorIndicatorIndex().should('have.text', '');
});
it('Does NOT show invalidation text on initial page load', () => {
getIndexPatternInvalidationText().should('not.exist');
});
it('Shows invalidation text if you try to continue without filling it out', () => {
getDefineContinueButton().click();
getIndexPatternInvalidationText().should('exist');
});
});
waitForTheRuleToBeExecuted();
waitForAlertsToPopulate();
describe('custom query input', () => {
it('Has a default set of *:*', () => {
getCustomQueryInput().should('have.text', '*:*');
});
cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts);
cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name);
cy.get(ALERT_RULE_VERSION).first().should('have.text', '1');
cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match');
cy.get(ALERT_RULE_SEVERITY)
.first()
.should('have.text', newThreatIndicatorRule.severity.toLowerCase());
cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore);
it('Shows invalidation text if text is removed', () => {
getCustomQueryInput().type('{selectall}{del}');
getCustomQueryInvalidationText().should('exist');
});
});
describe('custom indicator query input', () => {
it('Has a default set of *:*', () => {
getCustomIndicatorQueryInput().should('have.text', '*:*');
});
it('Shows invalidation text if text is removed', () => {
getCustomIndicatorQueryInput().type('{selectall}{del}');
getCustomQueryInvalidationText().should('exist');
});
});
describe('Indicator mapping', () => {
beforeEach(() => {
fillIndexAndIndicatorIndexPattern(
newThreatIndicatorRule.index,
newThreatIndicatorRule.indicatorIndexPattern
);
});
it('Does NOT show invalidation text on initial page load', () => {
getIndicatorInvalidationText().should('not.exist');
});
it('Shows invalidation text when you try to press continue without filling anything out', () => {
getDefineContinueButton().click();
getIndicatorAtLeastOneInvalidationText().should('exist');
});
it('Shows invalidation text when the "AND" button is pressed and both the mappings are blank', () => {
getIndicatorAndButton().click();
getIndicatorInvalidationText().should('exist');
});
it('Shows invalidation text when the "OR" button is pressed and both the mappings are blank', () => {
getIndicatorOrButton().click();
getIndicatorInvalidationText().should('exist');
});
it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => {
fillIndicatorMatchRow({
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
});
getDefineContinueButton().click();
getIndicatorInvalidationText().should('not.exist');
});
it('Shows invalidation text when there is an invalid "index field" and a valid "indicator index field"', () => {
fillIndicatorMatchRow({
indexField: 'non-existent-value',
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
validColumns: 'indicatorField',
});
getDefineContinueButton().click();
getIndicatorInvalidationText().should('exist');
});
it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => {
fillIndicatorMatchRow({
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: 'non-existent-value',
validColumns: 'indexField',
});
getDefineContinueButton().click();
getIndicatorInvalidationText().should('exist');
});
it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => {
fillIndicatorMatchRow({
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
});
getIndicatorAndButton().click();
fillIndicatorMatchRow({
rowNumber: 2,
indexField: 'agent.name',
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
validColumns: 'indicatorField',
});
getIndicatorDeleteButton().click();
getIndicatorIndexComboField().should('have.text', 'agent.name');
getIndicatorMappingComboField().should(
'have.text',
newThreatIndicatorRule.indicatorIndexField
);
getIndicatorIndexComboField(2).should('not.exist');
getIndicatorMappingComboField(2).should('not.exist');
});
it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => {
fillIndicatorMatchRow({
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: 'non-existent-value',
validColumns: 'indexField',
});
getIndicatorAndButton().click();
fillIndicatorMatchRow({
rowNumber: 2,
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: 'second-non-existent-value',
validColumns: 'indexField',
});
getIndicatorDeleteButton().click();
getIndicatorMappingComboField().should('have.text', 'second-non-existent-value');
getIndicatorIndexComboField(2).should('not.exist');
getIndicatorMappingComboField(2).should('not.exist');
});
it('Deletes the first row when you have two rows. Both rows have valid "indicator index fields" and invalid "index fields". The second row should become the first row', () => {
fillIndicatorMatchRow({
indexField: 'non-existent-value',
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
validColumns: 'indicatorField',
});
getIndicatorAndButton().click();
fillIndicatorMatchRow({
rowNumber: 2,
indexField: 'second-non-existent-value',
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
validColumns: 'indicatorField',
});
getIndicatorDeleteButton().click();
getIndicatorIndexComboField().should('have.text', 'second-non-existent-value');
getIndicatorIndexComboField(2).should('not.exist');
getIndicatorMappingComboField(2).should('not.exist');
});
it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => {
fillIndicatorMatchRow({
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
});
getIndicatorDeleteButton().click();
getIndicatorIndexComboField().should('text', 'Search');
getIndicatorMappingComboField().should('text', 'Search');
getIndicatorIndexComboField(2).should('not.exist');
getIndicatorMappingComboField(2).should('not.exist');
});
it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => {
fillIndicatorMatchRow({
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
});
getIndicatorAndButton().click();
fillIndicatorMatchRow({
rowNumber: 2,
indexField: 'non-existent-value',
indicatorIndexField: 'non-existent-value',
validColumns: 'none',
});
getIndicatorAndButton().click();
fillIndicatorMatchRow({
rowNumber: 3,
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
});
getIndicatorDeleteButton(2).click();
getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping);
getIndicatorMappingComboField(1).should('text', newThreatIndicatorRule.indicatorIndexField);
getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping);
getIndicatorMappingComboField(2).should('text', newThreatIndicatorRule.indicatorIndexField);
getIndicatorIndexComboField(3).should('not.exist');
getIndicatorMappingComboField(3).should('not.exist');
});
it('Can add two OR rows and delete the second row. The first row has invalid data and the second row has valid data. The first row is deleted and the second row shifts up correctly.', () => {
fillIndicatorMatchRow({
indexField: 'non-existent-value-one',
indicatorIndexField: 'non-existent-value-two',
validColumns: 'none',
});
getIndicatorOrButton().click();
fillIndicatorMatchRow({
rowNumber: 2,
indexField: newThreatIndicatorRule.indicatorMapping,
indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
});
getIndicatorDeleteButton().click();
getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping);
getIndicatorMappingComboField().should('text', newThreatIndicatorRule.indicatorIndexField);
getIndicatorIndexComboField(2).should('not.exist');
getIndicatorMappingComboField(2).should('not.exist');
});
});
it('Creates and activates a new Indicator Match rule', () => {
fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule);
fillAboutRuleAndContinue(newThreatIndicatorRule);
fillScheduleRuleAndContinue(newThreatIndicatorRule);
createAndActivateRule();
cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)');
changeToThreeHundredRowsPerPage();
waitForRulesToBeLoaded();
cy.get(RULES_TABLE).then(($table) => {
cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules);
});
filterByCustomRules();
cy.get(RULES_TABLE).then(($table) => {
cy.wrap($table.find(RULES_ROW).length).should('eql', 1);
});
cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name);
cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore);
cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity);
cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true');
goToRuleDetails();
cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`);
cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description);
cy.get(ABOUT_DETAILS).within(() => {
getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity);
getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore);
getDetails(REFERENCE_URLS_DETAILS).should((details) => {
expect(removeExternalLinkText(details.text())).equal(expectedUrls);
});
getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives);
getDetails(MITRE_ATTACK_DETAILS).should((mitre) => {
expect(removeExternalLinkText(mitre.text())).equal(expectedMitre);
});
getDetails(TAGS_DETAILS).should('have.text', expectedTags);
});
cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true });
cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(INDEX_PATTERNS_DETAILS).should(
'have.text',
newThreatIndicatorRule.index!.join('')
);
getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*');
getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match');
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
getDetails(INDICATOR_INDEX_PATTERNS).should(
'have.text',
newThreatIndicatorRule.indicatorIndexPattern.join('')
);
getDetails(INDICATOR_MAPPING).should(
'have.text',
`${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}`
);
getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should(
'have.text',
`${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}`
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should(
'have.text',
`${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}`
);
});
waitForTheRuleToBeExecuted();
waitForAlertsToPopulate();
cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts);
cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name);
cy.get(ALERT_RULE_VERSION).first().should('have.text', '1');
cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match');
cy.get(ALERT_RULE_SEVERITY)
.first()
.should('have.text', newThreatIndicatorRule.severity.toLowerCase());
cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore);
});
});
});

View file

@ -38,6 +38,22 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]';
export const THREAT_MATCH_QUERY_INPUT =
'[data-test-subj="detectionEngineStepDefineThreatRuleQueryBar"] [data-test-subj="queryInput"]';
export const THREAT_MATCH_AND_BUTTON = '[data-test-subj="andButton"]';
export const THREAT_ITEM_ENTRY_DELETE_BUTTON = '[data-test-subj="itemEntryDeleteButton"]';
export const THREAT_MATCH_OR_BUTTON = '[data-test-subj="orButton"]';
export const THREAT_COMBO_BOX_INPUT = '[data-test-subj="fieldAutocompleteComboBox"]';
export const INVALID_MATCH_CONTENT = 'All matches require both a field and threat index field.';
export const AT_LEAST_ONE_VALID_MATCH = 'At least one indicator match is required.';
export const AT_LEAST_ONE_INDEX_PATTERN = 'A minimum of one index pattern is required.';
export const CUSTOM_QUERY_REQUIRED = 'A custom query is required.';
export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]';
export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]';

View file

@ -63,13 +63,20 @@ import {
EQL_QUERY_PREVIEW_HISTOGRAM,
EQL_QUERY_VALIDATION_SPINNER,
COMBO_BOX_CLEAR_BTN,
COMBO_BOX_RESULT,
MITRE_ATTACK_TACTIC_DROPDOWN,
MITRE_ATTACK_TECHNIQUE_DROPDOWN,
MITRE_ATTACK_SUBTECHNIQUE_DROPDOWN,
MITRE_ATTACK_ADD_TACTIC_BUTTON,
MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON,
MITRE_ATTACK_ADD_TECHNIQUE_BUTTON,
THREAT_COMBO_BOX_INPUT,
THREAT_ITEM_ENTRY_DELETE_BUTTON,
THREAT_MATCH_AND_BUTTON,
INVALID_MATCH_CONTENT,
THREAT_MATCH_OR_BUTTON,
AT_LEAST_ONE_VALID_MATCH,
AT_LEAST_ONE_INDEX_PATTERN,
CUSTOM_QUERY_REQUIRED,
} from '../screens/create_new_rule';
import { TOAST_ERROR } from '../screens/shared';
import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline';
@ -144,7 +151,7 @@ export const fillAboutRuleAndContinue = (
rule: CustomRule | MachineLearningRule | ThresholdRule | ThreatIndicatorRule
) => {
fillAboutRule(rule);
cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true });
getAboutContinueButton().should('exist').click({ force: true });
};
export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => {
@ -222,7 +229,7 @@ export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => {
cy.get(COMBO_BOX_INPUT).type(`${rule.timestampOverride}{enter}`);
});
cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true });
getAboutContinueButton().should('exist').click({ force: true });
};
export const fillDefineCustomRuleWithImportedQueryAndContinue = (
@ -282,19 +289,132 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => {
cy.get(EQL_QUERY_INPUT).should('not.exist');
};
export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => {
const INDEX_PATTERNS = 0;
const INDICATOR_INDEX_PATTERN = 2;
const INDICATOR_MAPPING = 3;
const INDICATOR_INDEX_FIELD = 4;
/**
* Fills in the indicator match rows for tests by giving it an optional rowNumber,
* a indexField, a indicatorIndexField, and an optional validRows which indicates
* which row is valid or not.
*
* There are special tricks below with Eui combo box:
* cy.get(`button[title="${indexField}"]`)
* .should('be.visible')
* .then(([e]) => e.click());
*
* To first ensure the button is there before clicking on the button. There are
* race conditions where if the Eui drop down button from the combo box is not
* visible then the click handler is not there either, and when we click on it
* that will cause the item to _not_ be selected. Using a {enter} with the combo
* box also does not select things from EuiCombo boxes either, so I have to click
* the actual contents of the EuiCombo box to select things.
*/
export const fillIndicatorMatchRow = ({
rowNumber,
indexField,
indicatorIndexField,
validColumns,
}: {
rowNumber?: number; // default is 1
indexField: string;
indicatorIndexField: string;
validColumns?: 'indexField' | 'indicatorField' | 'both' | 'none'; // default is both are valid entries
}) => {
const computedRowNumber = rowNumber == null ? 1 : rowNumber;
const computedValueRows = validColumns == null ? 'both' : validColumns;
const OFFSET = 2;
cy.get(COMBO_BOX_INPUT)
.eq(computedRowNumber * OFFSET + 1)
.type(indexField);
if (computedValueRows === 'indexField' || computedValueRows === 'both') {
cy.get(`button[title="${indexField}"]`)
.should('be.visible')
.then(([e]) => e.click());
}
cy.get(COMBO_BOX_INPUT)
.eq(computedRowNumber * OFFSET + 2)
.type(indicatorIndexField);
cy.get(COMBO_BOX_CLEAR_BTN).click();
cy.get(COMBO_BOX_INPUT).eq(INDEX_PATTERNS).type(`${rule.index}{enter}`);
cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_PATTERN).type(`${rule.indicatorIndexPattern}{enter}`);
cy.get(COMBO_BOX_INPUT).eq(INDICATOR_MAPPING).type(`${rule.indicatorMapping}{enter}`);
cy.get(COMBO_BOX_RESULT).first().click();
cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_FIELD).type(`${rule.indicatorIndexField}{enter}`);
cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
if (computedValueRows === 'indicatorField' || computedValueRows === 'both') {
cy.get(`button[title="${indicatorIndexField}"]`)
.should('be.visible')
.then(([e]) => e.click());
}
};
/**
* Fills in both the index pattern and the indicator match index pattern.
* @param indexPattern The index pattern.
* @param indicatorIndex The indicator index pattern.
*/
export const fillIndexAndIndicatorIndexPattern = (
indexPattern?: string[],
indicatorIndex?: string[]
) => {
getIndexPatternClearButton().click();
getIndicatorIndex().type(`${indexPattern}{enter}`);
getIndicatorIndicatorIndex().type(`${indicatorIndex}{enter}`);
};
/** Returns the indicator index drop down field. Pass in row number, default is 1 */
export const getIndicatorIndexComboField = (row = 1) =>
cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 2);
/** Returns the indicator mapping drop down field. Pass in row number, default is 1 */
export const getIndicatorMappingComboField = (row = 1) =>
cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 1);
/** Returns the indicator matches DELETE button for the mapping. Pass in row number, default is 1 */
export const getIndicatorDeleteButton = (row = 1) =>
cy.get(THREAT_ITEM_ENTRY_DELETE_BUTTON).eq(row - 1);
/** Returns the indicator matches AND button for the mapping */
export const getIndicatorAndButton = () => cy.get(THREAT_MATCH_AND_BUTTON);
/** Returns the indicator matches OR button for the mapping */
export const getIndicatorOrButton = () => cy.get(THREAT_MATCH_OR_BUTTON);
/** Returns the invalid match content. */
export const getIndicatorInvalidationText = () => cy.contains(INVALID_MATCH_CONTENT);
/** Returns that at least one valid match is required content */
export const getIndicatorAtLeastOneInvalidationText = () => cy.contains(AT_LEAST_ONE_VALID_MATCH);
/** Returns that at least one index pattern is required content */
export const getIndexPatternInvalidationText = () => cy.contains(AT_LEAST_ONE_INDEX_PATTERN);
/** Returns the continue button on the step of about */
export const getAboutContinueButton = () => cy.get(ABOUT_CONTINUE_BTN);
/** Returns the continue button on the step of define */
export const getDefineContinueButton = () => cy.get(DEFINE_CONTINUE_BUTTON);
/** Returns the indicator index pattern */
export const getIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(0);
/** Returns the indicator's indicator index */
export const getIndicatorIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(2);
/** Returns the index pattern's clear button */
export const getIndexPatternClearButton = () => cy.get(COMBO_BOX_CLEAR_BTN);
/** Returns the custom query input */
export const getCustomQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(0);
/** Returns the custom query input */
export const getCustomIndicatorQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(1);
/** Returns custom query required content */
export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQUIRED);
/**
* Fills in the define indicator match rules and then presses the continue button
* @param rule The rule to use to fill in everything
*/
export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => {
fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern);
fillIndicatorMatchRow({
indexField: rule.indicatorMapping,
indicatorIndexField: rule.indicatorIndexField,
});
getDefineContinueButton().should('exist').click({ force: true });
cy.get(CUSTOM_QUERY_INPUT).should('not.exist');
};
@ -304,7 +424,7 @@ export const fillDefineMachineLearningRuleAndContinue = (rule: MachineLearningRu
cy.get(ANOMALY_THRESHOLD_INPUT).type(`{selectall}${machineLearningRule.anomalyScoreThreshold}`, {
force: true,
});
cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
getDefineContinueButton().should('exist').click({ force: true });
cy.get(MACHINE_LEARNING_DROPDOWN).should('not.exist');
};

View file

@ -22,6 +22,7 @@ describe('EntryItem', () => {
const wrapper = mount(
<EntryItem
entry={{
id: '123',
field: undefined,
value: undefined,
type: 'mapping',
@ -54,6 +55,7 @@ describe('EntryItem', () => {
const wrapper = mount(
<EntryItem
entry={{
id: '123',
field: getField('ip'),
type: 'mapping',
value: getField('ip'),
@ -84,6 +86,7 @@ describe('EntryItem', () => {
expect(mockOnChange).toHaveBeenCalledWith(
{
id: '123',
field: 'machine.os',
type: 'mapping',
value: 'ip',
@ -97,6 +100,7 @@ describe('EntryItem', () => {
const wrapper = mount(
<EntryItem
entry={{
id: '123',
field: getField('ip'),
type: 'mapping',
value: getField('ip'),
@ -125,6 +129,9 @@ describe('EntryItem', () => {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange([{ label: 'is not' }]);
expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0);
expect(mockOnChange).toHaveBeenCalledWith(
{ id: '123', field: 'ip', type: 'mapping', value: '' },
0
);
});
});

View file

@ -75,7 +75,11 @@ export const EntryItem: React.FC<EntryItemProps> = ({
</EuiFormRow>
);
} else {
return comboBox;
return (
<EuiFormRow label={''} data-test-subj="entryItemFieldInputFormRow">
{comboBox}
</EuiFormRow>
);
}
}, [handleFieldChange, indexPattern, entry, showLabel]);
@ -101,7 +105,11 @@ export const EntryItem: React.FC<EntryItemProps> = ({
</EuiFormRow>
);
} else {
return comboBox;
return (
<EuiFormRow label={''} data-test-subj="threatFieldInputFormRow">
{comboBox}
</EuiFormRow>
);
}
}, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]);

View file

@ -21,6 +21,10 @@ import {
} from './helpers';
import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('123'),
}));
const getMockIndexPattern = (): IndexPattern =>
({
id: '1234',
@ -29,6 +33,7 @@ const getMockIndexPattern = (): IndexPattern =>
} as IndexPattern);
const getMockEntry = (): FormattedEntry => ({
id: '123',
field: getField('ip'),
value: getField('ip'),
type: 'mapping',
@ -42,6 +47,7 @@ describe('Helpers', () => {
afterEach(() => {
moment.tz.setDefault('Browser');
jest.clearAllMocks();
});
describe('#getFormattedEntry', () => {
@ -70,6 +76,7 @@ describe('Helpers', () => {
const output = getFormattedEntry(payloadIndexPattern, payloadIndexPattern, payloadItem, 0);
const expected: FormattedEntry = {
entryIndex: 0,
id: '123',
field: {
name: 'machine.os.raw.text',
type: 'string',
@ -94,6 +101,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
id: '123',
entryIndex: 0,
field: undefined,
value: undefined,
@ -109,6 +117,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
id: '123',
entryIndex: 0,
field: {
name: 'machine.os',
@ -134,6 +143,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, threatIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
id: '123',
entryIndex: 0,
field: {
name: 'machine.os',
@ -170,6 +180,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
id: '123',
field: {
name: 'machine.os',
type: 'string',
@ -194,6 +205,7 @@ describe('Helpers', () => {
entryIndex: 0,
},
{
id: '123',
field: {
name: 'ip',
type: 'ip',
@ -249,9 +261,10 @@ describe('Helpers', () => {
const payloadItem = getMockEntry();
const payloadIFieldType = getField('ip');
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
const expected: { updatedEntry: Entry; index: number } = {
const expected: { updatedEntry: Entry & { id: string }; index: number } = {
index: 0,
updatedEntry: {
id: '123',
field: 'ip',
type: 'mapping',
value: 'ip',

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import uuid from 'uuid';
import {
ThreatMap,
threatMap,
@ -12,6 +13,7 @@ import {
import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common';
import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types';
import { addIdToItem } from '../../utils/add_remove_id_to_item';
/**
* Formats the entry into one that is easily usable for the UI.
@ -24,7 +26,8 @@ export const getFormattedEntry = (
indexPattern: IndexPattern,
threatIndexPatterns: IndexPattern,
item: Entry,
itemIndex: number
itemIndex: number,
uuidGen: () => string = uuid.v4
): FormattedEntry => {
const { fields } = indexPattern;
const { fields: threatFields } = threatIndexPatterns;
@ -34,7 +37,9 @@ export const getFormattedEntry = (
const [threatFoundField] = threatFields.filter(
({ name }) => threatField != null && threatField === name
);
const maybeId: typeof item & { id?: string } = item;
return {
id: maybeId.id ?? uuidGen(),
field: foundField,
type: 'mapping',
value: threatFoundField,
@ -90,10 +95,11 @@ export const getEntryOnFieldChange = (
const { entryIndex } = item;
return {
updatedEntry: {
id: item.id,
field: newField != null ? newField.name : '',
type: 'mapping',
value: item.value != null ? item.value.name : '',
},
} as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere
index: entryIndex,
};
};
@ -112,30 +118,33 @@ export const getEntryOnThreatFieldChange = (
const { entryIndex } = item;
return {
updatedEntry: {
id: item.id,
field: item.field != null ? item.field.name : '',
type: 'mapping',
value: newField != null ? newField.name : '',
},
} as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere
index: entryIndex,
};
};
export const getDefaultEmptyEntry = (): EmptyEntry => ({
field: '',
type: 'mapping',
value: '',
});
export const getDefaultEmptyEntry = (): EmptyEntry => {
return addIdToItem({
field: '',
type: 'mapping',
value: '',
});
};
export const getNewItem = (): ThreatMap => {
return {
return addIdToItem({
entries: [
{
addIdToItem({
field: '',
type: 'mapping',
value: '',
},
}),
],
};
});
};
export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => {

View file

@ -158,43 +158,45 @@ export const ThreatMatchComponent = ({
}, []);
return (
<EuiFlexGroup gutterSize="s" direction="column">
{entries.map((entryListItem, index) => (
<EuiFlexItem grow={1} key={`${index}`}>
<EuiFlexGroup gutterSize="s" direction="column">
{index !== 0 &&
(andLogicIncluded ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none" direction="row">
<MyInvisibleAndBadge grow={false}>
<MyAndBadge includeAntennas type="and" />
</MyInvisibleAndBadge>
<EuiFlexItem grow={false}>
<MyAndBadge type="or" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : (
<EuiFlexItem grow={false}>
<MyAndBadge type="or" />
</EuiFlexItem>
))}
<EuiFlexItem grow={false}>
<ListItemComponent
key={`${index}`}
listItem={entryListItem}
listId={`${index}`}
indexPattern={indexPatterns}
threatIndexPatterns={threatIndexPatterns}
listItemIndex={index}
andLogicIncluded={andLogicIncluded}
isOnlyItem={entries.length === 1}
onDeleteEntryItem={handleDeleteEntryItem}
onChangeEntryItem={handleEntryItemChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
))}
{entries.map((entryListItem, index) => {
const key = (entryListItem as typeof entryListItem & { id?: string }).id ?? `${index}`;
return (
<EuiFlexItem grow={1} key={key}>
<EuiFlexGroup gutterSize="s" direction="column">
{index !== 0 &&
(andLogicIncluded ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none" direction="row">
<MyInvisibleAndBadge grow={false}>
<MyAndBadge includeAntennas type="and" />
</MyInvisibleAndBadge>
<EuiFlexItem grow={false}>
<MyAndBadge type="or" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : (
<EuiFlexItem grow={false}>
<MyAndBadge type="or" />
</EuiFlexItem>
))}
<EuiFlexItem grow={false}>
<ListItemComponent
key={key}
listItem={entryListItem}
indexPattern={indexPatterns}
threatIndexPatterns={threatIndexPatterns}
listItemIndex={index}
andLogicIncluded={andLogicIncluded}
isOnlyItem={entries.length === 1}
onDeleteEntryItem={handleDeleteEntryItem}
onChangeEntryItem={handleEntryItemChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
<MyButtonsContainer data-test-subj={'andOrOperatorButtons'}>
<EuiFlexGroup gutterSize="s">

View file

@ -68,7 +68,6 @@ describe('ListItemComponent', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
@ -102,7 +101,6 @@ describe('ListItemComponent', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
@ -134,7 +132,6 @@ describe('ListItemComponent', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
@ -168,7 +165,6 @@ describe('ListItemComponent', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
@ -210,7 +206,6 @@ describe('ListItemComponent', () => {
const wrapper = mount(
<ListItemComponent
listItem={item}
listId={'123'}
listItemIndex={0}
indexPattern={
{
@ -242,7 +237,6 @@ describe('ListItemComponent', () => {
const wrapper = mount(
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
@ -274,7 +268,6 @@ describe('ListItemComponent', () => {
const wrapper = mount(
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
@ -308,7 +301,6 @@ describe('ListItemComponent', () => {
const wrapper = mount(
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
@ -341,7 +333,6 @@ describe('ListItemComponent', () => {
const wrapper = mount(
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{

View file

@ -22,7 +22,6 @@ const MyOverflowContainer = styled(EuiFlexItem)`
interface ListItemProps {
listItem: ThreatMapEntries;
listId: string;
listItemIndex: number;
indexPattern: IndexPattern;
threatIndexPatterns: IndexPattern;
@ -35,7 +34,6 @@ interface ListItemProps {
export const ListItemComponent = React.memo<ListItemProps>(
({
listItem,
listId,
listItemIndex,
indexPattern,
threatIndexPatterns,
@ -88,7 +86,7 @@ export const ListItemComponent = React.memo<ListItemProps>(
<MyOverflowContainer grow={6}>
<EuiFlexGroup gutterSize="s" direction="column">
{entries.map((item, index) => (
<EuiFlexItem key={`${listId}-${index}`} grow={1}>
<EuiFlexItem key={item.id} grow={1}>
<EuiFlexGroup gutterSize="xs" alignItems="center" direction="row">
<MyOverflowContainer grow={1}>
<EntryItem

View file

@ -9,6 +9,10 @@ import { State, reducer } from './reducer';
import { getDefaultEmptyEntry } from './helpers';
import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('123'),
}));
const initialState: State = {
andLogicIncluded: false,
entries: [],
@ -22,6 +26,10 @@ const getEntry = (): ThreatMapEntry => ({
});
describe('reducer', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('#setEntries', () => {
test('should return "andLogicIncluded" ', () => {
const update = reducer()(initialState, {

View file

@ -7,6 +7,7 @@ import { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/s
import { IFieldType } from '../../../../../../../src/plugins/data/common';
export interface FormattedEntry {
id: string;
field: IFieldType | undefined;
type: 'mapping';
value: IFieldType | undefined;

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { addIdToItem, removeIdFromItem } from './add_remove_id_to_item';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('123'),
}));
describe('add_remove_id_to_item', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('addIdToItem', () => {
test('it adds an id to an empty item', () => {
expect(addIdToItem({})).toEqual({ id: '123' });
});
test('it adds a complex object', () => {
expect(
addIdToItem({
field: '',
type: 'mapping',
value: '',
})
).toEqual({
id: '123',
field: '',
type: 'mapping',
value: '',
});
});
test('it adds an id to an existing item', () => {
expect(addIdToItem({ test: '456' })).toEqual({ id: '123', test: '456' });
});
test('it does not change the id if it already exists', () => {
expect(addIdToItem({ id: '456' })).toEqual({ id: '456' });
});
test('it returns the same reference if it has an id already', () => {
const obj = { id: '456' };
expect(addIdToItem(obj)).toBe(obj);
});
test('it returns a new reference if it adds an id to an item', () => {
const obj = { test: '456' };
expect(addIdToItem(obj)).not.toBe(obj);
});
});
describe('removeIdFromItem', () => {
test('it removes an id from an item', () => {
expect(removeIdFromItem({ id: '456' })).toEqual({});
});
test('it returns a new reference if it removes an id from an item', () => {
const obj = { id: '123', test: '456' };
expect(removeIdFromItem(obj)).not.toBe(obj);
});
test('it does not effect an item without an id', () => {
expect(removeIdFromItem({ test: '456' })).toEqual({ test: '456' });
});
test('it returns the same reference if it does not have an id already', () => {
const obj = { test: '456' };
expect(removeIdFromItem(obj)).toBe(obj);
});
});
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import uuid from 'uuid';
/**
* This is useful for when you have arrays without an ID and need to add one for
* ReactJS keys. I break the types slightly by introducing an id to an arbitrary item
* but then cast it back to the regular type T.
* Usage of this could be considered tech debt as I am adding an ID when the backend
* could be doing the same thing but it depends on how you want to model your data and
* if you view modeling your data with id's to please ReactJS a good or bad thing.
* @param item The item to add an id to.
*/
type NotArray<T> = T extends unknown[] ? never : T;
export const addIdToItem = <T>(item: NotArray<T>): T => {
const maybeId: typeof item & { id?: string } = item;
if (maybeId.id != null) {
return item;
} else {
return { ...item, id: uuid.v4() };
}
};
/**
* This is to reverse the id you added to your arrays for ReactJS keys.
* @param item The item to remove the id from.
*/
export const removeIdFromItem = <T>(
item: NotArray<T>
):
| T
| Pick<
T & {
id?: string | undefined;
},
Exclude<keyof T, 'id'>
> => {
const maybeId: typeof item & { id?: string } = item;
if (maybeId.id != null) {
const { id, ...noId } = maybeId;
return noId;
} else {
return item;
}
};

View file

@ -50,7 +50,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => {
const abortCtrl = new AbortController();
setLoading(true);
async function fetchData() {
const fetchData = async () => {
try {
const privilege = await getUserPrivilege({
signal: abortCtrl.signal,
@ -89,15 +89,14 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => {
if (isSubscribed) {
setLoading(false);
}
}
};
fetchData();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [dispatchToaster]);
return { loading, ...privilegeUser };
};

View file

@ -46,7 +46,7 @@ export const useQueryAlerts = <Hit, Aggs>(
let isSubscribed = true;
const abortCtrl = new AbortController();
async function fetchData() {
const fetchData = async () => {
try {
setLoading(true);
const alertResponse = await fetchQueryAlerts<Hit, Aggs>({
@ -77,7 +77,7 @@ export const useQueryAlerts = <Hit, Aggs>(
if (isSubscribed) {
setLoading(false);
}
}
};
fetchData();
return () => {

View file

@ -106,8 +106,7 @@ export const useSignalIndex = (): ReturnSignalIndex => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [dispatchToaster]);
return { loading, ...signalIndex };
};

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { flow } from 'fp-ts/lib/function';
import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item';
import {
CreateRulesSchema,
UpdateRulesSchema,
} from '../../../../../common/detection_engine/schemas/request';
import { Rule } from './types';
// These are a collection of transforms that are UI specific and useful for UI concerns
// that are inserted between the API and the actual user interface. In some ways these
// might be viewed as technical debt or to compensate for the differences and preferences
// of how ReactJS might prefer data vs. how we want to model data. Each function should have
// a description giving context around the transform.
/**
* Transforms the output of rules to compensate for technical debt or UI concerns such as
* ReactJS preferences for having ids within arrays if the data is not modeled that way.
*
* If you add a new transform of the output called "myNewTransform" do it
* in the form of:
* flow(removeIdFromThreatMatchArray, myNewTransform)(rule)
*
* @param rule The rule to transform the output of
* @returns The rule transformed from the output
*/
export const transformOutput = (
rule: CreateRulesSchema | UpdateRulesSchema
): CreateRulesSchema | UpdateRulesSchema => flow(removeIdFromThreatMatchArray)(rule);
/**
* Transforms the output of rules to compensate for technical debt or UI concerns such as
* ReactJS preferences for having ids within arrays if the data is not modeled that way.
*
* If you add a new transform of the input called "myNewTransform" do it
* in the form of:
* flow(addIdToThreatMatchArray, myNewTransform)(rule)
*
* @param rule The rule to transform the output of
* @returns The rule transformed from the output
*/
export const transformInput = (rule: Rule): Rule => flow(addIdToThreatMatchArray)(rule);
/**
* This adds an id to the incoming threat match arrays as ReactJS prefers to have
* an id added to them for use as a stable id. Later if we decide to change the data
* model to have id's within the array then this code should be removed. If not, then
* this code should stay as an adapter for ReactJS.
*
* This does break the type system slightly as we are lying a bit to the type system as we return
* the same rule as we have previously but are augmenting the arrays with an id which TypeScript
* doesn't mind us doing here. However, downstream you will notice that you have an id when the type
* does not indicate it. In that case just cast this temporarily if you're using the id. If you're not,
* you can ignore the id and just use the normal TypeScript with ReactJS.
*
* @param rule The rule to add an id to the threat matches.
* @returns rule The rule but with id added to the threat array and entries
*/
export const addIdToThreatMatchArray = (rule: Rule): Rule => {
if (rule.type === 'threat_match' && rule.threat_mapping != null) {
const threatMapWithId = rule.threat_mapping.map((mapping) => {
const newEntries = mapping.entries.map((entry) => addIdToItem(entry));
return addIdToItem({ entries: newEntries });
});
return { ...rule, threat_mapping: threatMapWithId };
} else {
return rule;
}
};
/**
* This removes an id from the threat match arrays as ReactJS prefers to have
* an id added to them for use as a stable id. Later if we decide to change the data
* model to have id's within the array then this code should be removed. If not, then
* this code should stay as an adapter for ReactJS.
*
* @param rule The rule to remove an id from the threat matches.
* @returns rule The rule but with id removed from the threat array and entries
*/
export const removeIdFromThreatMatchArray = (
rule: CreateRulesSchema | UpdateRulesSchema
): CreateRulesSchema | UpdateRulesSchema => {
if (rule.type === 'threat_match' && rule.threat_mapping != null) {
const threatMapWithoutId = rule.threat_mapping.map((mapping) => {
const newEntries = mapping.entries.map((entry) => removeIdFromItem(entry));
const newMapping = removeIdFromItem(mapping);
return { ...newMapping, entries: newEntries };
});
return { ...rule, threat_mapping: threatMapWithoutId };
} else {
return rule;
}
};

View file

@ -11,6 +11,7 @@ import { CreateRulesSchema } from '../../../../../common/detection_engine/schema
import { createRule } from './api';
import * as i18n from './translations';
import { transformOutput } from './transforms';
interface CreateRuleReturn {
isLoading: boolean;
@ -29,11 +30,11 @@ export const useCreateRule = (): ReturnCreateRule => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setIsSaved(false);
async function saveRule() {
const saveRule = async () => {
if (rule != null) {
try {
setIsLoading(true);
await createRule({ rule, signal: abortCtrl.signal });
await createRule({ rule: transformOutput(rule), signal: abortCtrl.signal });
if (isSubscribed) {
setIsSaved(true);
}
@ -46,15 +47,14 @@ export const useCreateRule = (): ReturnCreateRule => {
setIsLoading(false);
}
}
}
};
saveRule();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rule]);
}, [rule, dispatchToaster]);
return [{ isLoading, isSaved }, setRule];
};

View file

@ -262,8 +262,14 @@ export const usePrePackagedRules = ({
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUserCRUD, hasIndexWrite, isAuthenticated, hasEncryptionKey, isSignalIndexExists]);
}, [
canUserCRUD,
hasIndexWrite,
isAuthenticated,
hasEncryptionKey,
isSignalIndexExists,
dispatchToaster,
]);
const prePackagedRuleStatus = useMemo(
() =>

View file

@ -8,6 +8,7 @@ import { useEffect, useState } from 'react';
import { errorToToaster, useStateToaster } from '../../../../common/components/toasters';
import { fetchRuleById } from './api';
import { transformInput } from './transforms';
import * as i18n from './translations';
import { Rule } from './types';
@ -28,13 +29,15 @@ export const useRule = (id: string | undefined): ReturnRule => {
let isSubscribed = true;
const abortCtrl = new AbortController();
async function fetchData(idToFetch: string) {
const fetchData = async (idToFetch: string) => {
try {
setLoading(true);
const ruleResponse = await fetchRuleById({
id: idToFetch,
signal: abortCtrl.signal,
});
const ruleResponse = transformInput(
await fetchRuleById({
id: idToFetch,
signal: abortCtrl.signal,
})
);
if (isSubscribed) {
setRule(ruleResponse);
}
@ -47,7 +50,7 @@ export const useRule = (id: string | undefined): ReturnRule => {
if (isSubscribed) {
setLoading(false);
}
}
};
if (id != null) {
fetchData(id);
}
@ -55,8 +58,7 @@ export const useRule = (id: string | undefined): ReturnRule => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
}, [id, dispatchToaster]);
return [loading, rule];
};

View file

@ -6,12 +6,14 @@
import { useEffect, useCallback } from 'react';
import { flow } from 'fp-ts/lib/function';
import { useAsync, withOptionalSignal } from '../../../../shared_imports';
import { useHttp } from '../../../../common/lib/kibana';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { pureFetchRuleById } from './api';
import { Rule } from './types';
import * as i18n from './translations';
import { transformInput } from './transforms';
export interface UseRuleAsync {
error: unknown;
@ -20,11 +22,15 @@ export interface UseRuleAsync {
rule: Rule | null;
}
const _fetchRule = withOptionalSignal(pureFetchRuleById);
const _useRuleAsync = () => useAsync(_fetchRule);
const _fetchRule = flow(withOptionalSignal(pureFetchRuleById), async (rule: Promise<Rule>) =>
transformInput(await rule)
);
/** This does not use "_useRuleAsyncInternal" as that would deactivate the useHooks linter rule, so instead it has the word "Internal" post-pended */
const useRuleAsyncInternal = () => useAsync(_fetchRule);
export const useRuleAsync = (ruleId: string): UseRuleAsync => {
const { start, loading, result, error } = _useRuleAsync();
const { start, loading, result, error } = useRuleAsyncInternal();
const http = useHttp();
const { addError } = useAppToasts();

View file

@ -64,8 +64,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus =
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
}, [id, dispatchToaster]);
return [loading, ruleStatus, fetchRuleStatus.current];
};
@ -122,8 +121,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rules]);
}, [rules, dispatchToaster]);
return { loading, rulesStatuses };
};

View file

@ -26,7 +26,7 @@ export const useTags = (): ReturnTags => {
let isSubscribed = true;
const abortCtrl = new AbortController();
async function fetchData() {
const fetchData = async () => {
setLoading(true);
try {
const fetchTagsResult = await fetchTags({
@ -44,7 +44,7 @@ export const useTags = (): ReturnTags => {
if (isSubscribed) {
setLoading(false);
}
}
};
fetchData();
reFetchTags.current = fetchData;
@ -53,8 +53,7 @@ export const useTags = (): ReturnTags => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [dispatchToaster]);
return [loading, tags, reFetchTags.current];
};

View file

@ -9,6 +9,8 @@ import { useEffect, useState, Dispatch } from 'react';
import { errorToToaster, useStateToaster } from '../../../../common/components/toasters';
import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request';
import { transformOutput } from './transforms';
import { updateRule } from './api';
import * as i18n from './translations';
@ -29,11 +31,11 @@ export const useUpdateRule = (): ReturnUpdateRule => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setIsSaved(false);
async function saveRule() {
const saveRule = async () => {
if (rule != null) {
try {
setIsLoading(true);
await updateRule({ rule, signal: abortCtrl.signal });
await updateRule({ rule: transformOutput(rule), signal: abortCtrl.signal });
if (isSubscribed) {
setIsSaved(true);
}
@ -46,15 +48,14 @@ export const useUpdateRule = (): ReturnUpdateRule => {
setIsLoading(false);
}
}
}
};
saveRule();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rule]);
}, [rule, dispatchToaster]);
return [{ isLoading, isSaved }, setRule];
};