[Osquery] Add ECS mapping editor (#107706)

This commit is contained in:
Patryk Kopyciński 2021-08-16 02:17:07 +03:00 committed by GitHub
parent 0828788b66
commit c347a7e5e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1065 additions and 102 deletions

View file

@ -309,6 +309,7 @@
"nock": "12.0.3",
"node-fetch": "^2.6.1",
"node-forge": "^0.10.0",
"node-sql-parser": "^3.6.1",
"nodemailer": "^6.6.2",
"normalize-path": "^3.0.0",
"object-hash": "^1.3.1",

View file

@ -73,6 +73,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0'];
export const LICENSE_OVERRIDES = {
'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts
'@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint
'node-sql-parser@3.6.1': ['(GPL-2.0 OR MIT)'], // GPL-2.0* https://github.com/taozhi8833998/node-sql-parser
'@elastic/ems-client@7.14.0': ['Elastic License 2.0'],
'@elastic/eui@36.0.0': ['SSPL-1.0 OR Elastic License 2.0'],

View file

@ -44,6 +44,16 @@ export interface OsqueryManagerPackagePolicyConfigRecord {
interval: OsqueryManagerPackagePolicyConfigRecordEntry;
platform?: OsqueryManagerPackagePolicyConfigRecordEntry;
version?: OsqueryManagerPackagePolicyConfigRecordEntry;
ecs_mapping?:
| {
value: Record<
string,
{
field: string;
}
>;
}
| undefined;
}
export interface OsqueryManagerPackagePolicyInputStream

View file

@ -15,6 +15,7 @@ import {
useUiSetting$,
withKibana,
reactRouterNavigate,
FieldIcon,
} from '../../../../../../../src/plugins/kibana_react/public';
import { StartServices } from '../../../types';
@ -47,4 +48,5 @@ export {
useUiSetting,
useUiSetting$,
withKibana,
FieldIcon,
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback, useRef } from 'react';
import { EuiCodeEditor } from '@elastic/eui';
import 'brace/theme/tomorrow';
@ -22,27 +22,34 @@ const EDITOR_PROPS = {
interface OsqueryEditorProps {
defaultValue: string;
disabled?: boolean;
onChange: (newValue: string) => void;
}
const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({
defaultValue,
// disabled,
onChange,
}) => (
<EuiCodeEditor
value={defaultValue}
mode="osquery"
// isReadOnly={disabled}
theme="tomorrow"
onChange={onChange}
name="osquery_editor"
setOptions={EDITOR_SET_OPTIONS}
editorProps={EDITOR_PROPS}
height="150px"
width="100%"
/>
);
const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({ defaultValue, onChange }) => {
const editorValue = useRef(defaultValue ?? '');
const handleChange = useCallback((newValue: string) => {
editorValue.current = newValue;
}, []);
const onBlur = useCallback(() => {
onChange(editorValue.current.replaceAll('\n', ' ').replaceAll(' ', ' '));
}, [onChange]);
return (
<EuiCodeEditor
onBlur={onBlur}
value={defaultValue}
mode="osquery"
onChange={handleChange}
theme="tomorrow"
name="osquery_editor"
setOptions={EDITOR_SET_OPTIONS}
editorProps={EDITOR_PROPS}
height="150px"
width="100%"
/>
);
};
export const OsqueryEditor = React.memo(OsqueryEditorComponent);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -20,11 +20,8 @@ let osqueryTables: TablesJSON | null = null;
export const getOsqueryTables = () => {
if (!osqueryTables) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
osqueryTables = normalizeTables(require('./osquery_schema/v4.8.0.json'));
osqueryTables = normalizeTables(require('../common/schemas/osquery/v4.9.0.json'));
}
return osqueryTables;
};
export const getOsqueryTableNames = () =>
flatMap(getOsqueryTables(), (table) => {
return table.name;
});
export const getOsqueryTableNames = () => flatMap(getOsqueryTables(), (table) => table.name);

View file

@ -58,7 +58,7 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ disa
/>
<EuiSpacer />
<EuiFormRow fullWidth labelAppend={<OsquerySchemaLink />}>
{!permissions.writeLiveQueries ? (
{!permissions.writeLiveQueries || disabled ? (
<StyledEuiCodeBlock
language="sql"
fontSize="m"
@ -68,7 +68,7 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ disa
{value}
</StyledEuiCodeBlock>
) : (
<OsqueryEditor defaultValue={value} disabled={disabled} onChange={handleEditorChange} />
<OsqueryEditor defaultValue={value} onChange={handleEditorChange} />
)}
</EuiFormRow>
</>

View file

@ -35,6 +35,12 @@ interface GetNewStreamProps {
platform?: string | undefined;
version?: string | undefined;
scheduledQueryGroupId?: string;
ecs_mapping?: Record<
string,
{
field: string;
}
>;
}
interface GetNewStreamReturn extends Omit<OsqueryManagerPackagePolicyInputStream, 'id'> {
@ -65,6 +71,11 @@ const getNewStream = (payload: GetNewStreamProps) =>
if (payload.version && draft.vars) {
draft.vars.version = { type: 'text', value: payload.version };
}
if (payload.ecs_mapping && draft.vars) {
draft.vars.ecs_mapping = {
value: payload.ecs_mapping,
};
}
return draft;
}
);
@ -146,6 +157,14 @@ const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
delete draft[0].streams[showEditQueryFlyout].vars.version;
}
if (updatedQuery.ecs_mapping) {
draft[0].streams[showEditQueryFlyout].vars.ecs_mapping = {
value: updatedQuery.ecs_mapping,
};
} else {
delete draft[0].streams[showEditQueryFlyout].vars.ecs_mapping;
}
return draft;
})
);

View file

@ -0,0 +1,829 @@
/*
* 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 { produce } from 'immer';
import { find, sortBy, isArray, map } from 'lodash';
import React, {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useImperativeHandle,
MutableRefObject,
} from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiComboBox,
EuiComboBoxProps,
EuiComboBoxOptionOption,
EuiSpacer,
EuiTitle,
EuiText,
EuiIcon,
} from '@elastic/eui';
import { Parser, Select } from 'node-sql-parser';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import deepmerge from 'deepmerge';
import ECSSchema from '../../common/schemas/ecs/v1.11.0.json';
import osquerySchema from '../../common/schemas/osquery/v4.9.0.json';
import { FieldIcon } from '../../common/lib/kibana';
import {
FIELD_TYPES,
Form,
FormData,
FieldHook,
getFieldValidityAndErrorMessage,
useForm,
useFormData,
Field,
getUseField,
fieldValidators,
ValidationFuncArg,
} from '../../shared_imports';
export const CommonUseField = getUseField({ component: Field });
const typeMap = {
binary: 'binary',
half_float: 'number',
scaled_float: 'number',
float: 'number',
integer: 'number',
long: 'number',
short: 'number',
byte: 'number',
text: 'string',
keyword: 'string',
'': 'string',
geo_point: 'geo_point',
date: 'date',
ip: 'ip',
boolean: 'boolean',
constant_keyword: 'string',
};
const StyledFieldIcon = styled(FieldIcon)`
width: 32px;
padding: 0 4px;
`;
const StyledFieldSpan = styled.span`
padding-top: 0 !important;
padding-bottom: 0 !important;
`;
// align the icon to the inputs
const StyledButtonWrapper = styled.div`
margin-top: 32px;
`;
const singleSelection = { asPlainText: true };
const ECSSchemaOptions = ECSSchema.map((ecs) => ({
label: ecs.field,
value: ecs,
}));
type ECSSchemaOption = typeof ECSSchemaOptions[0];
interface ECSComboboxFieldProps {
field: FieldHook<string>;
euiFieldProps: EuiComboBoxProps<ECSSchemaOption>;
idAria?: string;
}
export const ECSComboboxField: React.FC<ECSComboboxFieldProps> = ({
field,
euiFieldProps = {},
idAria,
...rest
}) => {
const { setValue } = field;
const [selectedOptions, setSelected] = useState<Array<EuiComboBoxOptionOption<ECSSchemaOption>>>(
[]
);
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
const handleChange = useCallback(
(newSelectedOptions) => {
setSelected(newSelectedOptions);
setValue(newSelectedOptions[0]?.label ?? '');
},
[setSelected, setValue]
);
// TODO: Create own component for this.
const renderOption = useCallback(
(option, searchValue, contentClassName) => (
<EuiFlexGroup
className={`${contentClassName} euiSuggestItem`}
alignItems="center"
gutterSize="xs"
>
<EuiFlexItem grow={false}>
{
// @ts-expect-error update types
<FieldIcon type={typeMap[option.value.type] ?? option.value.type} />
}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StyledFieldSpan className="euiSuggestItem__label euiSuggestItem__labelDisplay--expand">
{option.value.field}
</StyledFieldSpan>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="euiSuggestItem__description euiSuggestItem__description--truncate">
{option.value.description}
</span>
</EuiFlexItem>
</EuiFlexGroup>
),
[]
);
const prepend = useMemo(
() => (
<StyledFieldIcon
size="l"
type={
// @ts-expect-error update types
selectedOptions[0]?.value?.type === 'keyword' ? 'string' : selectedOptions[0]?.value?.type
}
/>
),
[selectedOptions]
);
useEffect(() => {
// @ts-expect-error update types
setSelected(() => {
if (!field.value.length) return [];
const selectedOption = find(ECSSchemaOptions, ['label', field.value]);
return selectedOption ? [selectedOption] : [];
});
}, [field.value]);
return (
<EuiFormRow
label={field.label}
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
describedByIds={describedByIds}
{...rest}
>
<EuiComboBox
prepend={prepend}
fullWidth
singleSelection={singleSelection}
// @ts-expect-error update types
options={ECSSchemaOptions}
selectedOptions={selectedOptions}
onChange={handleChange}
renderOption={renderOption}
rowHeight={32}
isClearable
{...euiFieldProps}
/>
</EuiFormRow>
);
};
interface OsqueryColumnFieldProps {
field: FieldHook<string>;
euiFieldProps: EuiComboBoxProps<OsquerySchemaOption>;
idAria?: string;
}
export const OsqueryColumnField: React.FC<OsqueryColumnFieldProps> = ({
field,
euiFieldProps = {},
idAria,
...rest
}) => {
const { setErrors, setValue } = field;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
const [selectedOptions, setSelected] = useState<
Array<EuiComboBoxOptionOption<OsquerySchemaOption>>
>([]);
const renderOsqueryOption = useCallback(
(option, searchValue, contentClassName) => (
<EuiFlexGroup
className={`${contentClassName} euiSuggestItem`}
alignItems="center"
gutterSize="xs"
>
<EuiFlexItem grow={false}>
<StyledFieldSpan className="euiSuggestItem__label euiSuggestItem__labelDisplay--expand">
{option.value.suggestion_label}
</StyledFieldSpan>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="euiSuggestItem__description euiSuggestItem__description--truncate">
{option.value.description}
</span>
</EuiFlexItem>
</EuiFlexGroup>
),
[]
);
const onCreateOsqueryOption = useCallback(
(searchValue = []) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const newOption = {
label: searchValue,
};
// Select the option.
setSelected([newOption]);
setValue(newOption.label);
},
[setValue, setSelected]
);
const handleChange = useCallback(
(newSelectedOptions) => {
setSelected(newSelectedOptions);
setValue(newSelectedOptions[0]?.label ?? '');
},
[setValue, setSelected]
);
useEffect(() => {
setSelected(() => {
if (!field.value.length) return [];
const selectedOption = find(euiFieldProps?.options, ['label', field.value]);
return selectedOption ? [selectedOption] : [{ label: field.value }];
});
}, [euiFieldProps?.options, setSelected, field.value, setErrors]);
return (
<EuiFormRow
label={field.label}
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
describedByIds={describedByIds}
{...rest}
>
<EuiComboBox
fullWidth
singleSelection={singleSelection}
selectedOptions={selectedOptions}
onChange={handleChange}
onCreateOption={onCreateOsqueryOption}
renderOption={renderOsqueryOption}
rowHeight={32}
isClearable
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export interface ECSMappingEditorFieldRef {
validate: () => Promise<
| Record<
string,
{
field: string;
}
>
| false
| {}
>;
}
export interface ECSMappingEditorFieldProps {
field: FieldHook<string>;
query: string;
fieldRef: MutableRefObject<ECSMappingEditorFieldRef>;
}
interface ECSMappingEditorFormProps {
osquerySchemaOptions: OsquerySchemaOption[];
defaultValue?: FormData;
onAdd?: (payload: FormData) => void;
onChange?: (payload: FormData) => void;
onDelete?: (key: string) => void;
}
const getEcsFieldValidator = (editForm: boolean) => (
args: ValidationFuncArg<ECSMappingEditorFormData, ECSMappingEditorFormData['key']>
) => {
const fieldRequiredError = fieldValidators.emptyField(
i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.ecsFieldRequiredErrorMessage',
{
defaultMessage: 'ECS field is required.',
}
)
)(args);
if (fieldRequiredError && (!!(!editForm && args.formData.value?.field.length) || editForm)) {
return fieldRequiredError;
}
return undefined;
};
const getOsqueryResultFieldValidator = (
osquerySchemaOptions: OsquerySchemaOption[],
editForm: boolean
) => (
args: ValidationFuncArg<ECSMappingEditorFormData, ECSMappingEditorFormData['value']['field']>
) => {
const fieldRequiredError = fieldValidators.emptyField(
i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.osqueryResultFieldRequiredErrorMessage',
{
defaultMessage: 'Osquery result is required.',
}
)
)(args);
if (fieldRequiredError && ((!editForm && args.formData.key.length) || editForm)) {
return fieldRequiredError;
}
if (!args.value.length) return;
const osqueryColumnExists = find(osquerySchemaOptions, ['label', args.value]);
return !osqueryColumnExists
? {
code: 'ERR_FIELD_FORMAT',
path: args.path,
message: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.osqueryResultFieldValueMissingErrorMessage',
{
defaultMessage: 'The current query does not return a {columnName} field',
values: {
columnName: args.value,
},
}
),
}
: undefined;
};
const FORM_DEFAULT_VALUE = {
key: '',
value: { field: '' },
};
interface ECSMappingEditorFormData {
key: string;
value: {
field: string;
};
}
interface ECSMappingEditorFormRef {
validate: () => Promise<{
data: ECSMappingEditorFormData | {};
isValid: boolean;
}>;
}
export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappingEditorFormProps>(
({ osquerySchemaOptions, defaultValue, onAdd, onChange, onDelete }, ref) => {
const editForm = !!defaultValue;
const currentFormData = useRef(defaultValue);
const formSchema = {
key: {
label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.ecsFieldLabel', {
defaultMessage: 'ECS field',
}),
type: FIELD_TYPES.COMBO_BOX,
fieldsToValidateOnChange: ['value.field'],
validations: [
{
validator: getEcsFieldValidator(editForm),
},
],
},
'value.field': {
label: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.osqueryResultFieldLabel',
{
defaultMessage: 'Osquery result',
}
),
type: FIELD_TYPES.COMBO_BOX,
fieldsToValidateOnChange: ['key'],
validations: [
{
validator: getOsqueryResultFieldValidator(osquerySchemaOptions, editForm),
},
],
},
};
const { form } = useForm({
schema: formSchema,
defaultValue: defaultValue ?? FORM_DEFAULT_VALUE,
});
const { submit, reset, validate, __validateFields } = form;
const [formData] = useFormData({ form });
const handleSubmit = useCallback(async () => {
validate();
__validateFields(['value.field']);
const { data, isValid } = await submit();
if (isValid) {
if (onAdd) {
onAdd(data);
}
reset();
}
return { data, isValid };
}, [validate, __validateFields, submit, onAdd, reset]);
const handleDeleteClick = useCallback(() => {
if (defaultValue?.key && onDelete) {
onDelete(defaultValue.key);
}
}, [defaultValue, onDelete]);
useImperativeHandle(
ref,
() => ({
validate: async () => {
if (!editForm && deepEqual(formData, FORM_DEFAULT_VALUE)) {
return { data: {}, isValid: true };
}
__validateFields(['value.field']);
const isValid = await validate();
return { data: formData?.key?.length ? { [formData.key]: formData.value } : {}, isValid };
},
}),
[__validateFields, editForm, formData, validate]
);
useEffect(() => {
if (onChange && !deepEqual(formData, currentFormData.current)) {
currentFormData.current = formData;
onChange(formData);
}
}, [defaultValue, formData, onChange]);
useEffect(() => {
if (defaultValue) {
validate();
__validateFields(['value.field']);
}
}, [defaultValue, osquerySchemaOptions, validate, __validateFields]);
return (
<Form form={form}>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem>
<CommonUseField
path="value.field"
component={OsqueryColumnField}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
label: 'Osquery result',
options: osquerySchemaOptions,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem grow={false}>
<StyledButtonWrapper>
<EuiIcon type="arrowRight" />
</StyledButtonWrapper>
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField path="key" component={ECSComboboxField} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StyledButtonWrapper>
{defaultValue ? (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.deleteECSMappingRowButtonAriaLabel',
{
defaultMessage: 'Delete ECS mapping row',
}
)}
iconType="trash"
color="danger"
onClick={handleDeleteClick}
/>
) : (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.addECSMappingRowButtonAriaLabel',
{
defaultMessage: 'Add ECS mapping row',
}
)}
iconType="plus"
color="primary"
onClick={handleSubmit}
/>
)}
</StyledButtonWrapper>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
</Form>
);
}
);
interface OsquerySchemaOption {
label: string;
value: {
name: string;
description: string;
table: string;
suggestion_label: string;
};
}
interface OsqueryColumn {
name: string;
description: string;
type: string;
hidden: boolean;
required: boolean;
index: boolean;
}
export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEditorFieldProps) => {
const { setValue, value = {} } = field;
const [osquerySchemaOptions, setOsquerySchemaOptions] = useState<OsquerySchemaOption[]>([]);
const formRefs = useRef<Record<string, ECSMappingEditorFormRef>>({});
useImperativeHandle(
fieldRef,
() => ({
validate: async () => {
const validations = await Promise.all(
Object.values(formRefs.current).map(async (formRef) => {
const { data, isValid } = await formRef.validate();
return [data, isValid];
})
);
if (find(validations, (result) => result[1] === false)) {
return false;
}
return deepmerge.all(map(validations, '[0]'));
},
}),
[]
);
useEffect(() => {
setOsquerySchemaOptions((currentValue) => {
if (!query?.length) {
return currentValue;
}
const parser = new Parser();
let ast: Select;
try {
const parsedQuery = parser.astify(query);
ast = (isArray(parsedQuery) ? parsedQuery[0] : parsedQuery) as Select;
} catch (e) {
return currentValue;
}
const astOsqueryTables: Record<string, OsqueryColumn[]> = ast?.from?.reduce((acc, table) => {
const osqueryTable = find(osquerySchema, ['name', table.table]);
if (osqueryTable) {
acc[table.as ?? table.table] = osqueryTable.columns;
}
return acc;
}, {});
// Table doesn't exist in osquery schema
if (
!isArray(ast?.columns) &&
ast?.columns !== '*' &&
!astOsqueryTables[ast?.from && ast?.from[0].table]
) {
return currentValue;
}
/*
Simple query
select * from users;
*/
if (ast?.columns === '*' && ast.from?.length && astOsqueryTables[ast.from[0].table]) {
const tableName = ast.from[0].as ?? ast.from[0].table;
return astOsqueryTables[ast.from[0].table].map((osqueryColumn) => ({
label: osqueryColumn.name,
value: {
name: osqueryColumn.name,
description: osqueryColumn.description,
table: tableName,
suggestion_label: osqueryColumn.name,
},
}));
}
/*
Advanced query
select i.*, p.resident_size, p.user_time, p.system_time, time.minutes as counter from osquery_info i, processes p, time where p.pid = i.pid;
*/
const suggestions =
isArray(ast?.columns) &&
ast?.columns
?.map((column) => {
if (column.expr.column === '*') {
return astOsqueryTables[column.expr.table].map((osqueryColumn) => ({
label: osqueryColumn.name,
value: {
name: osqueryColumn.name,
description: osqueryColumn.description,
table: column.expr.table,
suggestion_label: `${osqueryColumn.name}`,
},
}));
}
if (astOsqueryTables && astOsqueryTables[column.expr.table]) {
const osqueryColumn = find(astOsqueryTables[column.expr.table], [
'name',
column.expr.column,
]);
if (osqueryColumn) {
const label = column.as ?? column.expr.column;
return [
{
label: column.as ?? column.expr.column,
value: {
name: osqueryColumn.name,
description: osqueryColumn.description,
table: column.expr.table,
suggestion_label: `${label}`,
},
},
];
}
}
return [];
})
.flat();
// @ts-expect-error update types
return sortBy(suggestions, 'value.suggestion_label');
});
}, [query]);
const handleAddRow = useCallback(
(newRow) => {
if (newRow?.key && newRow?.value) {
setValue(
produce((draft) => {
draft[newRow.key] = newRow.value;
return draft;
})
);
}
},
[setValue]
);
const handleUpdateRow = useCallback(
(currentKey: string) => (updatedRow: FormData) => {
if (updatedRow?.key && updatedRow?.value) {
setValue(
produce((draft) => {
if (currentKey !== updatedRow.key) {
delete draft[currentKey];
}
draft[updatedRow.key] = updatedRow.value;
return draft;
})
);
}
},
[setValue]
);
const handleDeleteRow = useCallback(
(key) => {
if (key) {
setValue(
produce((draft) => {
if (draft[key]) {
delete draft[key];
}
return draft;
})
);
}
},
[setValue]
);
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.form.ecsMappingSection.title"
defaultMessage="ECS mapping"
/>
</h5>
</EuiTitle>
<EuiText color="subdued">
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.form.ecsMappingSection.description"
defaultMessage="Use the fields below to map results from this query to ECS fields."
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{Object.entries(value).map(([ecsKey, ecsValue]) => (
<ECSMappingEditorForm
// eslint-disable-next-line
ref={(formRef) => {
if (formRef) {
formRefs.current[ecsKey] = formRef;
}
}}
key={ecsKey}
osquerySchemaOptions={osquerySchemaOptions}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
defaultValue={{
key: ecsKey,
value: ecsValue,
}}
onChange={handleUpdateRow(ecsKey)}
onDelete={handleDeleteRow}
/>
))}
<ECSMappingEditorForm
// eslint-disable-next-line
ref={(formRef) => {
if (formRef) {
formRefs.current.new = formRef;
}
}}
osquerySchemaOptions={osquerySchemaOptions}
onAdd={handleAddRow}
/>
</>
);
};
// eslint-disable-next-line import/no-default-export
export default ECSMappingEditorField;

View file

@ -0,0 +1,21 @@
/*
* 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 React, { lazy, Suspense } from 'react';
import type {
ECSMappingEditorFieldProps,
ECSMappingEditorFieldRef,
} from './ecs_mapping_editor_field';
const LazyECSMappingEditorField = lazy(() => import('./ecs_mapping_editor_field'));
export type { ECSMappingEditorFieldProps, ECSMappingEditorFieldRef };
export const ECSMappingEditorField = (props: ECSMappingEditorFieldProps) => (
<Suspense fallback={null}>
<LazyECSMappingEditorField {...props} />
</Suspense>
);

View file

@ -20,22 +20,23 @@ import {
EuiButton,
EuiText,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { satisfies } from 'semver';
import { OsqueryManagerPackagePolicyConfigRecord } from '../../../common/types';
import { CodeEditorField } from '../../saved_queries/form/code_editor_field';
import { Form, getUseField, Field } from '../../shared_imports';
import { Form, getUseField, Field, useFormData } from '../../shared_imports';
import { PlatformCheckBoxGroupField } from './platform_checkbox_group_field';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from './constants';
import {
UseScheduledQueryGroupQueryFormProps,
ScheduledQueryGroupFormData,
useScheduledQueryGroupQueryForm,
} from './use_scheduled_query_group_query_form';
import { ManageIntegrationLink } from '../../components/manage_integration_link';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
import { ECSMappingEditorField, ECSMappingEditorFieldRef } from './lazy_ecs_mapping_editor_field';
const CommonUseField = getUseField({ component: Field });
@ -43,7 +44,7 @@ interface QueryFlyoutProps {
uniqueQueryIds: string[];
defaultValue?: UseScheduledQueryGroupQueryFormProps['defaultValue'] | undefined;
integrationPackageVersion?: string | undefined;
onSave: (payload: OsqueryManagerPackagePolicyConfigRecord) => Promise<void>;
onSave: (payload: ScheduledQueryGroupFormData) => Promise<void>;
onClose: () => void;
}
@ -54,18 +55,25 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
onSave,
onClose,
}) => {
const ecsFieldRef = useRef<ECSMappingEditorFieldRef>();
const [isEditMode] = useState(!!defaultValue);
const { form } = useScheduledQueryGroupQueryForm({
uniqueQueryIds,
defaultValue,
handleSubmit: (payload, isValid) =>
new Promise((resolve) => {
if (isValid) {
onSave(payload);
handleSubmit: async (payload, isValid) => {
const ecsFieldValue = await ecsFieldRef?.current?.validate();
return new Promise((resolve) => {
if (isValid && ecsFieldValue) {
onSave({
...payload,
ecs_mapping: ecsFieldValue,
});
onClose();
}
resolve();
}),
});
},
});
/* Platform and version fields are supported since osquery_manager@0.3.0 */
@ -76,6 +84,11 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
const { submit, setFieldValue, reset } = form;
const [{ query }] = useFormData({
form,
watch: ['query'],
});
const handleSetQueryValue = useCallback(
(savedQuery) => {
if (!savedQuery) {
@ -182,6 +195,16 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="ecs_mapping"
component={ECSMappingEditorField}
query={query}
fieldRef={ecsFieldRef}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
{!isFieldSupported ? (
<EuiCallOut

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { isArray, xor } from 'lodash';
import { isArray, isEmpty, xor } from 'lodash';
import uuid from 'uuid';
import { produce } from 'immer';
@ -19,10 +19,7 @@ const FORM_ID = 'editQueryFlyoutForm';
export interface UseScheduledQueryGroupQueryFormProps {
uniqueQueryIds: string[];
defaultValue?: OsqueryManagerPackagePolicyConfigRecord | undefined;
handleSubmit: FormConfig<
OsqueryManagerPackagePolicyConfigRecord,
ScheduledQueryGroupFormData
>['onSubmit'];
handleSubmit: FormConfig<ScheduledQueryGroupFormData, ScheduledQueryGroupFormData>['onSubmit'];
}
export interface ScheduledQueryGroupFormData {
@ -31,6 +28,14 @@ export interface ScheduledQueryGroupFormData {
interval: number;
platform?: string | undefined;
version?: string[] | undefined;
ecs_mapping?:
| Record<
string,
{
field: string;
}
>
| undefined;
}
export const useScheduledQueryGroupQueryForm = ({
@ -51,6 +56,7 @@ export const useScheduledQueryGroupQueryForm = ({
id: FORM_ID + uuid.v4(),
onSubmit: async (formData, isValid) => {
if (isValid && handleSubmit) {
// @ts-expect-error update types
return handleSubmit(formData, isValid);
}
},
@ -76,6 +82,9 @@ export const useScheduledQueryGroupQueryForm = ({
draft.version = draft.version[0];
}
}
if (isEmpty(draft.ecs_mapping)) {
delete draft.ecs_mapping;
}
return draft;
}),
deserializer: (payload) => {
@ -87,6 +96,7 @@ export const useScheduledQueryGroupQueryForm = ({
interval: parseInt(payload.interval.value, 10),
platform: payload.platform?.value,
version: payload.version?.value ? [payload.version?.value] : [],
ecs_mapping: payload.ecs_mapping?.value ?? {},
};
},
schema: formSchema,

View file

@ -25,6 +25,7 @@ export {
useFormData,
ValidationError,
ValidationFunc,
ValidationFuncArg,
VALIDATION_TYPES,
} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';

View file

@ -6,4 +6,4 @@
*/
require('../../../../../src/setup_node_env');
require('./script');
require('./ecs_formatter');

View file

@ -0,0 +1,40 @@
/*
* 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 { map, partialRight, pick } from 'lodash';
import { promises as fs } from 'fs';
import path from 'path';
import { run } from '@kbn/dev-utils';
const ECS_COLUMN_SCHEMA_FIELDS = ['field', 'type', 'description'];
run(
async ({ flags }) => {
const schemaPath = path.resolve(`../public/common/schemas/ecs/`);
const schemaFile = path.join(schemaPath, flags.schema_version as string);
const schemaData = await require(schemaFile);
const formattedSchema = map(schemaData, partialRight(pick, ECS_COLUMN_SCHEMA_FIELDS));
await fs.writeFile(
path.join(schemaPath, `v${flags.schema_version}-formatted.json`),
JSON.stringify(formattedSchema)
);
},
{
description: `
Script for formatting generated osquery API schema JSON file.
`,
flags: {
string: ['schema_version'],
help: `
--schema_version The semver string for the schema file located in public/common/schemas/ecs/
`,
},
}
);

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
require('../../../../../src/setup_node_env');
require('./osquery_formatter');

View file

@ -0,0 +1,40 @@
/*
* 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 { map, partialRight, pick } from 'lodash';
import { promises as fs } from 'fs';
import path from 'path';
import { run } from '@kbn/dev-utils';
const OSQUERY_COLUMN_SCHEMA_FIELDS = ['name', 'description', 'platforms', 'columns'];
run(
async ({ flags }) => {
const schemaPath = path.resolve(`../public/common/schemas/osquery/`);
const schemaFile = path.join(schemaPath, flags.schema_version as string);
const schemaData = await require(schemaFile);
const formattedSchema = map(schemaData, partialRight(pick, OSQUERY_COLUMN_SCHEMA_FIELDS));
await fs.writeFile(
path.join(schemaPath, `v${flags.schema_version}-formatted.json`),
JSON.stringify(formattedSchema)
);
},
{
description: `
Script for formatting generated osquery API schema JSON file.
`,
flags: {
string: ['schema_version'],
help: `
--schema_version The semver string for the schema file located in public/common/schemas/osquery/
`,
},
}
);

View file

@ -1,55 +0,0 @@
/*
* 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 { promises as fs } from 'fs';
import path from 'path';
import { run } from '@kbn/dev-utils';
interface DestField {
[key: string]: boolean | DestField;
}
run(
async ({ flags }) => {
const schemaPath = path.resolve('./public/editor/osquery_schema/');
const schemaFile = path.join(schemaPath, flags.schema_version as string);
const schemaData = await require(schemaFile);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function pullFields(destSchema: DestField, source: { [key: string]: any }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dest: { [key: string]: any } = {};
Object.keys(destSchema).forEach((key) => {
switch (typeof source[key]) {
case 'object':
dest[key] = pullFields(destSchema[key] as DestField, source[key]);
break;
default:
dest[key] = source[key];
}
});
return dest;
}
const mapFunc = pullFields.bind(null, { name: true });
const formattedSchema = schemaData.map(mapFunc);
await fs.writeFile(
path.join(schemaPath, `${flags.schema_version}-formatted.json`),
JSON.stringify(formattedSchema)
);
},
{
description: `
Script for formatting generated osquery API schema JSON file.
`,
flags: {
string: ['schema_version'],
help: `
--schema_version The semver string for the schema file located in public/editor/osquery_schema
`,
},
}
);

View file

@ -12,7 +12,9 @@
"public/**/*",
"scripts/**/*",
"server/**/*",
"../../../typings/**/*"
"../../../typings/**/*",
// ECS and Osquery schema files
"public/common/schemas/*/**.json",
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },

View file

@ -8359,7 +8359,7 @@ better-opn@^2.0.0:
dependencies:
open "^7.0.3"
big-integer@^1.6.16:
big-integer@^1.6.16, big-integer@^1.6.48:
version "1.6.48"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e"
integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==
@ -20390,6 +20390,13 @@ node-sass@^4.14.1:
stdout-stream "^1.4.0"
"true-case-path" "^1.0.2"
node-sql-parser@^3.6.1:
version "3.6.1"
resolved "https://registry.yarnpkg.com/node-sql-parser/-/node-sql-parser-3.6.1.tgz#6f096e9df1f19d1e2daa658d864bd68b0e2cd2c6"
integrity sha512-AseDvELmUvL22L6C63DsTuzF+0i/HBIHjJq/uxC7jV3PGpAUib5Oe6oz4sgAniSUMPSZQbZmRore6Na68Sg4Tg==
dependencies:
big-integer "^1.6.48"
node-status-codes@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f"