[Osquery] Fix ECS mapping editor issues (#132307)

This commit is contained in:
Patryk Kopyciński 2022-05-18 11:52:01 +02:00 committed by GitHub
parent d638b188dc
commit df38c6fc46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 180 additions and 101 deletions

View file

@ -4,7 +4,8 @@
"execTimeout": 120000,
"pageLoadTimeout": 12000,
"retries": {
"runMode": 0
"runMode": 1,
"openMode": 0
},
"screenshotsFolder": "../../../target/kibana-osquery/cypress/screenshots",
"trashAssetsBeforeRuns": false,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
import { login } from '../../tasks/login';
import { navigateTo } from '../../tasks/navigation';
import {
@ -24,11 +25,19 @@ import { getAdvancedButton } from '../../screens/integrations';
import { ROLES } from '../../test';
describe('ALL - Live Query', () => {
before(() => {
runKbnArchiverScript(ArchiverMethod.LOAD, 'ecs_mapping_1');
});
beforeEach(() => {
login(ROLES.soc_manager);
navigateTo('/app/osquery');
});
after(() => {
runKbnArchiverScript(ArchiverMethod.UNLOAD, 'ecs_mapping_1');
});
it('should run query and enable ecs mapping', () => {
const cmd = Cypress.platform === 'darwin' ? '{meta}{enter}' : '{ctrl}{enter}';
cy.contains('New live query').click();
@ -65,4 +74,22 @@ describe('ALL - Live Query', () => {
.react('EuiIconTip', { props: { type: 'indexMapping' } })
.should('exist');
});
it('should run customized saved query', () => {
cy.contains('New live query').click();
selectAllAgents();
cy.react('SavedQueriesDropdown').type('NOMAPPING{downArrow}{enter}');
cy.getReact('SavedQueriesDropdown').getCurrentState().should('have.length', 1);
inputQuery('{selectall}{backspace}{selectall}{backspace}select * from users');
cy.wait(1000);
submitQuery();
checkResults();
navigateTo('/app/osquery');
cy.react('EuiButtonIcon', { props: { iconType: 'play' } })
.eq(0)
.should('be.visible')
.click();
cy.react('ReactAce', { props: { value: 'select * from users' } }).should('exist');
});
});

View file

@ -110,7 +110,7 @@ describe('ALL - Packs', () => {
`pack_${PACK_NAME}_${SAVED_QUERY_ID}`
);
});
it('by clicking in Lens button', () => {
it.skip('by clicking in Lens button', () => {
let lensUrl = '';
cy.window().then((win) => {
cy.stub(win, 'open')
@ -160,8 +160,8 @@ describe('ALL - Packs', () => {
.contains(/^Save and deploy changes$/)
.click();
cy.contains(`${PACK_NAME}`).click();
cy.contains(`${PACK_NAME} details`);
cy.contains(/^No items found/);
cy.contains(`${PACK_NAME} details`).should('exist');
cy.contains(/^No items found/).should('exist');
});
it('enable changing saved queries and ecs_mappings', () => {
@ -171,12 +171,13 @@ describe('ALL - Packs', () => {
findAndClickButton('Add query');
getSavedQueriesDropdown().type('Multiple {downArrow} {enter}');
cy.contains('Custom key/value pairs');
cy.contains('Days of uptime');
cy.contains('List of keywords used to tag each');
cy.contains('Seconds of uptime');
cy.contains('Client network address.');
cy.contains('Total uptime seconds');
cy.contains('Custom key/value pairs').should('exist');
cy.contains('Days of uptime').should('exist');
cy.contains('List of keywords used to tag each').should('exist');
cy.contains('Seconds of uptime').should('exist');
cy.contains('Client network address.').should('exist');
cy.contains('Total uptime seconds').should('exist');
cy.getBySel('ECSMappingEditorForm').should('have.length', 4);
getSavedQueriesDropdown().type('NOMAPPING {downArrow} {enter}');
cy.contains('Custom key/value pairs').should('not.exist');
@ -185,17 +186,19 @@ describe('ALL - Packs', () => {
cy.contains('Seconds of uptime').should('not.exist');
cy.contains('Client network address.').should('not.exist');
cy.contains('Total uptime seconds').should('not.exist');
cy.getBySel('ECSMappingEditorForm').should('have.length', 1);
getSavedQueriesDropdown().type('ONE_MAPPING {downArrow} {enter}');
cy.contains('Name of the continent');
cy.contains('Seconds of uptime');
cy.contains('Name of the continent').should('exist');
cy.contains('Seconds of uptime').should('exist');
cy.getBySel('ECSMappingEditorForm').should('have.length', 2);
findAndClickButton('Save');
cy.react('CustomItemAction', {
props: { index: 0, item: { id: 'ONE_MAPPING_CHANGED' } },
}).click();
cy.contains('Name of the continent');
cy.contains('Seconds of uptime');
cy.contains('Name of the continent').should('exist');
cy.contains('Seconds of uptime').should('exist');
});
it('to click delete button', () => {
@ -231,7 +234,7 @@ describe('ALL - Packs', () => {
cy.getBySel('toastCloseButton').click();
cy.contains(REMOVING_PACK).click();
cy.contains(`${REMOVING_PACK} details`);
cy.contains(`${REMOVING_PACK} details`).should('exist');
findAndClickButton('Edit');
cy.react('EuiComboBoxInput', { props: { value: AGENT_NAME } }).should('exist');
@ -246,7 +249,7 @@ describe('ALL - Packs', () => {
closeModalIfVisible();
navigateTo('app/osquery/packs');
cy.contains(REMOVING_PACK).click();
cy.contains(`${REMOVING_PACK} details`);
cy.contains(`${REMOVING_PACK} details`).should('exist');
cy.wait(1000);
findAndClickButton('Edit');
cy.react('EuiComboBoxInput', { props: { value: '' } }).should('exist');

View file

@ -20,6 +20,9 @@ export const selectAllAgents = () => {
cy.contains('1 agent selected.');
};
export const clearInputQuery = () =>
cy.get(LIVE_QUERY_EDITOR).click().type(`{selectall}{backspace}`);
export const inputQuery = (query: string) => cy.get(LIVE_QUERY_EDITOR).type(query);
export const submitQuery = () => cy.contains('Submit').click();

View file

@ -37,8 +37,6 @@ export const useAgentPolicy = ({ policyId, skip, silent }: UseAgentPolicy) => {
defaultMessage: 'Error while fetching agent policy details',
}),
}),
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
};

View file

@ -14,7 +14,6 @@ import {
EuiAccordion,
EuiAccordionProps,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
@ -23,17 +22,16 @@ import styled from 'styled-components';
import { pickBy, isEmpty, map } from 'lodash';
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports';
import { UseField, Form, FormData, useForm, useFormData } from '../../shared_imports';
import { AgentsTableField } from './agents_table_field';
import { LiveQueryQueryField } from './live_query_query_field';
import { useKibana } from '../../common/lib/kibana';
import { ResultTabs } from '../../routes/saved_queries/edit/tabs';
import { queryFieldValidation } from '../../common/validations';
import { fieldValidators } from '../../shared_imports';
import { SavedQueryFlyout } from '../../saved_queries';
import { useErrorToast } from '../../common/hooks/use_error_toast';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
import { liveQueryFormSchema } from './schema';
const FORM_ID = 'liveQueryForm';
@ -44,8 +42,6 @@ const StyledEuiAccordion = styled(EuiAccordion)`
}
`;
export const MAX_QUERY_LENGTH = 2000;
const GhostFormField = () => <></>;
type FormType = 'simple' | 'steps';
@ -100,46 +96,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
}
);
const formSchema = {
agentSelection: {
defaultValue: {
agents: [],
allAgentsSelected: false,
platformsSelected: [],
policiesSelected: [],
},
type: FIELD_TYPES.JSON,
validations: [],
},
savedQueryId: {
type: FIELD_TYPES.TEXT,
validations: [],
},
query: {
type: FIELD_TYPES.TEXT,
validations: [
{
validator: fieldValidators.maxLengthField({
length: MAX_QUERY_LENGTH,
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
defaultMessage: 'Query is too large (max {maxLength} characters)',
values: { maxLength: MAX_QUERY_LENGTH },
}),
}),
},
{ validator: queryFieldValidation },
],
},
ecs_mapping: {
defaultValue: [],
type: FIELD_TYPES.JSON,
validations: [],
},
};
const { form } = useForm({
id: FORM_ID,
schema: formSchema,
schema: liveQueryFormSchema,
onSubmit: async (formData, isValid) => {
if (isValid) {
try {
@ -181,11 +140,14 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
const actionId = useMemo(() => data?.actions[0].action_id, [data?.actions]);
const agentIds = useMemo(() => data?.actions[0].agents, [data?.actions]);
// eslint-disable-next-line @typescript-eslint/naming-convention
const [{ agentSelection, ecs_mapping, query, savedQueryId }] = useFormData({
form,
watch: ['agentSelection', 'ecs_mapping', 'query', 'savedQueryId'],
});
const [{ agentSelection, ecs_mapping: ecsMapping, query, savedQueryId }, formDataSerializer] =
useFormData({
form,
});
/* recalculate the form data when ecs_mapping changes */
// eslint-disable-next-line react-hooks/exhaustive-deps
const serializedFormData = useMemo(() => formDataSerializer(), [ecsMapping, formDataSerializer]);
const agentSelected = useMemo(
() =>
@ -260,8 +222,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
);
const flyoutFormDefaultValue = useMemo(
() => ({ savedQueryId, query, ecs_mapping }),
[savedQueryId, ecs_mapping, query]
() => ({ savedQueryId, query, ecs_mapping: serializedFormData.ecs_mapping }),
[savedQueryId, serializedFormData.ecs_mapping, query]
);
const handleToggle = useCallback((isOpen) => {

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const MAX_QUERY_LENGTH = 2000;
import { i18n } from '@kbn/i18n';
import { FIELD_TYPES } from '../../shared_imports';
import { queryFieldValidation } from '../../common/validations';
import { fieldValidators } from '../../shared_imports';
export const liveQueryFormSchema = {
agentSelection: {
defaultValue: {
agents: [],
allAgentsSelected: false,
platformsSelected: [],
policiesSelected: [],
},
type: FIELD_TYPES.JSON,
validations: [],
},
savedQueryId: {
type: FIELD_TYPES.TEXT,
validations: [],
},
query: {
type: FIELD_TYPES.TEXT,
validations: [
{
validator: fieldValidators.maxLengthField({
length: MAX_QUERY_LENGTH,
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
defaultMessage: 'Query is too large (max {maxLength} characters)',
values: { maxLength: MAX_QUERY_LENGTH },
}),
}),
},
{ validator: queryFieldValidation },
],
},
ecs_mapping: {
defaultValue: [],
type: FIELD_TYPES.JSON,
},
};

View file

@ -136,7 +136,7 @@ const ECSFieldWrapper = styled(EuiFlexItem)`
max-width: 100%;
`;
const singleSelection = { asPlainText: true };
const SINGLE_SELECTION = { asPlainText: true };
const ECSSchemaOptions = ECSSchema.map((ecs) => ({
label: ecs.field,
@ -162,6 +162,7 @@ const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
);
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
const [formData] = useFormData();
const handleChange = useCallback(
(newSelectedOptions) => {
@ -229,6 +230,12 @@ const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
return text;
}, [selectedOptions]);
const availableECSSchemaOptions = useMemo(() => {
const currentFormECSFieldValues = map(formData.ecs_mapping, 'key');
return ECSSchemaOptions.filter(({ label }) => !currentFormECSFieldValues.includes(label));
}, [formData.ecs_mapping]);
useEffect(() => {
// @ts-expect-error update types
setSelected(() => {
@ -236,7 +243,16 @@ const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
const selectedOption = find(ECSSchemaOptions, ['label', field.value]);
return selectedOption ? [selectedOption] : [];
return selectedOption
? [selectedOption]
: [
{
label: field.value,
value: {
value: field.value,
},
},
];
});
}, [field.value]);
@ -253,9 +269,9 @@ const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
<EuiComboBox
prepend={prepend}
fullWidth
singleSelection={singleSelection}
singleSelection={SINGLE_SELECTION}
// @ts-expect-error update types
options={ECSSchemaOptions}
options={availableECSSchemaOptions}
selectedOptions={selectedOptions}
onChange={handleChange}
data-test-subj="ECS-field-input"
@ -317,6 +333,7 @@ interface OsqueryColumnFieldProps {
resultType: FieldHook<string>;
resultValue: FieldHook<string | string[]>;
euiFieldProps: EuiComboBoxProps<OsquerySchemaOption>;
item: ArrayItem;
idAria?: string;
}
@ -325,6 +342,7 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
resultValue,
euiFieldProps = {},
idAria,
item,
}) => {
const inputRef = useRef<HTMLInputElement>();
const { setValue } = resultValue;
@ -334,6 +352,7 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
const [selectedOptions, setSelected] = useState<
Array<EuiComboBoxOptionOption<OsquerySchemaOption>>
>([]);
const [formData] = useFormData();
const renderOsqueryOption = useCallback(
(option, searchValue, contentClassName) => (
@ -370,14 +389,25 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
[setValue, setSelected]
);
const isSingleSelection = useMemo(() => {
const ecsKey = get(formData, item.path)?.key;
if (ecsKey?.length && typeValue === 'value') {
const ecsKeySchemaOption = find(ECSSchemaOptions, ['label', ecsKey]);
return ecsKeySchemaOption?.value?.normalization !== 'array';
}
return true;
}, [typeValue, formData, item.path]);
const onTypeChange = useCallback(
(newType) => {
if (newType !== typeValue) {
setType(newType);
setValue(newType === 'value' && euiFieldProps.singleSelection === false ? [] : '');
setValue(newType === 'value' && isSingleSelection === false ? [] : '');
}
},
[typeValue, setType, setValue, euiFieldProps.singleSelection]
[typeValue, setType, setValue, isSingleSelection]
);
const handleCreateOption = useCallback(
@ -386,7 +416,7 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
if (!trimmedNewOption.length) return;
if (euiFieldProps.singleSelection === false) {
if (isSingleSelection === false) {
setValue([trimmedNewOption]);
if (resultValue.value.length) {
setValue([...castArray(resultValue.value), trimmedNewOption]);
@ -399,7 +429,7 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
setValue(trimmedNewOption);
}
},
[euiFieldProps.singleSelection, resultValue.value, setValue]
[isSingleSelection, resultValue.value, setValue]
);
const Prepend = useMemo(
@ -421,14 +451,14 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
);
useEffect(() => {
if (euiFieldProps?.singleSelection && isArray(resultValue.value)) {
if (isSingleSelection && isArray(resultValue.value)) {
setValue(resultValue.value.join(' '));
}
if (!euiFieldProps?.singleSelection && !isArray(resultValue.value)) {
if (!isSingleSelection && !isArray(resultValue.value)) {
setValue(resultValue.value.length ? [resultValue.value] : []);
}
}, [euiFieldProps?.singleSelection, resultValue.value, setValue]);
}, [isSingleSelection, resultValue.value, setValue]);
useEffect(() => {
setSelected(() => {
@ -471,6 +501,7 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
rowHeight={32}
isClearable
{...euiFieldProps}
singleSelection={isSingleSelection ? SINGLE_SELECTION : false}
options={(typeValue === 'field' && euiFieldProps.options) || EMPTY_ARRAY}
/>
</EuiFlexItem>
@ -566,7 +597,6 @@ const osqueryResultFieldValidator = async (
},
}
),
__isBlocking__: false,
}
: undefined;
};
@ -586,8 +616,6 @@ export const ECSMappingEditorForm: React.FC<ECSMappingEditorFormProps> = ({
isLastItem,
onDelete,
}) => {
const multipleValuesField = useRef(false);
const MultiFields = useMemo(
() => (
<UseMultiFields
@ -625,19 +653,18 @@ export const ECSMappingEditorForm: React.FC<ECSMappingEditorFormProps> = ({
{(fields) => (
<OsqueryColumnField
{...fields}
item={item}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
// @ts-expect-error update types
options: osquerySchemaOptions,
isDisabled,
// @ts-expect-error update types
singleSelection: !multipleValuesField.current ? { asPlainText: true } : false,
}}
/>
)}
</UseMultiFields>
),
[item.path, osquerySchemaOptions, isLastItem, isDisabled]
[item, osquerySchemaOptions, isLastItem, isDisabled]
);
const ecsComboBoxEuiFieldProps = useMemo(() => ({ isDisabled }), [isDisabled]);
@ -738,7 +765,7 @@ export const ECSMappingEditorField = React.memo(
({ euiFieldProps }: ECSMappingEditorFieldProps) => {
const lastItemPath = useRef<string>();
const onAdd = useRef<FormArrayField['addItem']>();
const osquerySchemaOptions = useRef<OsquerySchemaOption[]>([]);
const [osquerySchemaOptions, setOsquerySchemaOptions] = useState<OsquerySchemaOption[]>([]);
const [{ query, ...formData }, formDataSerializer, isMounted] = useFormData();
useEffect(() => {
@ -917,10 +944,13 @@ export const ECSMappingEditorField = React.memo(
.flat();
// Remove column duplicates by keeping the column from the table that appears last in the query
osquerySchemaOptions.current = sortedUniqBy(
const newOptions = sortedUniqBy(
orderBy(suggestions, ['value.suggestion_label', 'value.tableOrder'], ['asc', 'desc']),
'label'
);
setOsquerySchemaOptions((prevValue) =>
!deepEqual(prevValue, newOptions) ? newOptions : prevValue
);
}, [query]);
useLayoutEffect(() => {
@ -999,7 +1029,7 @@ export const ECSMappingEditorField = React.memo(
{items.map((item, index) => (
<ECSMappingEditorForm
key={item.id}
osquerySchemaOptions={osquerySchemaOptions.current}
osquerySchemaOptions={osquerySchemaOptions}
item={item}
isLastItem={index === items.length - 1}
onDelete={removeItem}

View file

@ -73,6 +73,5 @@ export const createFormSchema = (ids: Set<string>) => ({
ecs_mapping: {
defaultValue: [],
type: FIELD_TYPES.JSON,
validations: [],
},
});

View file

@ -26,9 +26,7 @@ interface PlaygroundFlyoutProps {
}
const PlaygroundFlyoutComponent: React.FC<PlaygroundFlyoutProps> = ({ enabled, onClose }) => {
const [{ query, ecs_mapping: ecsMapping, id }, formDataSerializer] = useFormData({
watch: ['query', 'ecs_mapping', 'savedQueryId'],
});
const [{ query, ecs_mapping: ecsMapping, id }, formDataSerializer] = useFormData();
/* recalculate the form data when ecs_mapping changes */
// eslint-disable-next-line react-hooks/exhaustive-deps

View file

@ -40,11 +40,22 @@ interface SavedQueriesDropdownProps {
) => void;
}
interface SelectedOption {
label: string;
value: {
savedQueryId: string;
id: string;
description: string;
query: string;
ecs_mapping: Record<string, unknown>;
};
}
const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
disabled,
onChange,
}) => {
const [selectedOptions, setSelectedOptions] = useState([]);
const [selectedOptions, setSelectedOptions] = useState<SelectedOption[]>([]);
const [{ savedQueryId }] = useFormData();
@ -109,17 +120,13 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
const savedQueryOption = find(['value.savedQueryId', savedQueryId], queryOptions);
if (savedQueryOption) {
handleSavedQueryChange([savedQueryOption]);
setSelectedOptions([savedQueryOption]);
}
}
}, [savedQueryId, handleSavedQueryChange, queryOptions]);
}, [savedQueryId, queryOptions]);
useEffect(() => {
if (
selectedOptions.length &&
// @ts-expect-error update types
selectedOptions[0].value.savedQueryId !== savedQueryId
) {
if (selectedOptions.length && selectedOptions[0].value.savedQueryId !== savedQueryId) {
setSelectedOptions([]);
}
}, [savedQueryId, selectedOptions]);
@ -152,4 +159,6 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
);
};
SavedQueriesDropdownComponent.displayName = 'SavedQueriesDropdown';
export const SavedQueriesDropdown = React.memo(SavedQueriesDropdownComponent);