[Security Solution] Add Osquery markdown plugin (#95149)

This commit is contained in:
Patryk Kopyciński 2022-09-11 20:20:58 +02:00 committed by GitHub
parent 95beca7d72
commit a171f93995
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 722 additions and 304 deletions

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { EuiAccordionProps } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import {
EuiButton,
@ -13,16 +12,15 @@ import {
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiAccordion,
EuiCard,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { useForm as useHookForm, FormProvider } from 'react-hook-form';
import { isEmpty, map, find, pickBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { SavedQuerySOFormData } from '../../saved_queries/form/use_saved_query_form';
import type {
EcsMappingFormField,
@ -33,8 +31,6 @@ import { convertECSMappingToObject } from '../../../common/schemas/common/utils'
import { useKibana } from '../../common/lib/kibana';
import { ResultTabs } from '../../routes/saved_queries/edit/tabs';
import { SavedQueryFlyout } from '../../saved_queries';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
import { usePacks } from '../../packs/use_packs';
import { PackQueriesStatusTable } from './pack_queries_status_table';
import { useCreateLiveQuery } from '../use_create_live_query_action';
@ -99,13 +95,6 @@ const StyledEuiCard = styled(EuiCard)`
}
`;
const StyledEuiAccordion = styled(EuiAccordion)`
${({ isDisabled }: { isDisabled?: boolean }) => isDisabled && 'display: none;'}
.euiAccordion__button {
color: ${({ theme }) => theme.eui.euiColorPrimary};
}
`;
type FormType = 'simple' | 'steps';
interface LiveQueryFormProps {
@ -123,7 +112,6 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
defaultValue,
onSuccess,
queryField = true,
ecsMappingField = true,
formType = 'steps',
enabled = true,
hideAgentsField = false,
@ -161,8 +149,6 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[permissions]
);
const [advancedContentState, setAdvancedContentState] =
useState<EuiAccordionProps['forceState']>('closed');
const [showSavedQueryFlyout, setShowSavedQueryFlyout] = useState(false);
const [queryType, setQueryType] = useState<string>('query');
const [isLive, setIsLive] = useState(false);
@ -208,43 +194,14 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[queryStatus]
);
const handleSavedQueryChange = useCallback(
(savedQuery) => {
if (savedQuery) {
setValue('query', savedQuery.query);
setValue('savedQueryId', savedQuery.savedQueryId);
setValue(
'ecs_mapping',
!isEmpty(savedQuery.ecs_mapping)
? map(savedQuery.ecs_mapping, (value, key) => ({
key,
result: {
type: Object.keys(value)[0],
value: Object.values(value)[0] as string,
},
}))
: [defaultEcsFormData]
);
if (!isEmpty(savedQuery.ecs_mapping)) {
setAdvancedContentState('open');
}
} else {
setValue('savedQueryId', null);
}
},
[setValue]
);
const onSubmit = useCallback(
// not sure why, but submitOnCmdEnter doesn't have proper form values so I am passing them in manually
async (values: LiveQueryFormFields = watchedValues) => {
async (values: LiveQueryFormFields) => {
const serializedData = pickBy(
{
agentSelection: values.agentSelection,
saved_query_id: values.savedQueryId,
query: values.query,
pack_id: packId?.length ? packId[0] : undefined,
pack_id: values?.packId?.length ? values?.packId[0] : undefined,
...(values.ecs_mapping
? { ecs_mapping: convertECSMappingToObject(values.ecs_mapping) }
: {}),
@ -259,25 +216,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
} catch (e) {}
}
},
[errors, mutateAsync, packId, watchedValues]
);
const commands = useMemo(
() => [
{
name: 'submitOnCmdEnter',
bindKey: { win: 'ctrl+enter', mac: 'cmd+enter' },
// @ts-expect-error update types - explanation in onSubmit()
exec: () => handleSubmit(onSubmit)(watchedValues),
},
],
[handleSubmit, onSubmit, watchedValues]
);
const queryComponentProps = useMemo(
() => ({
commands,
}),
[commands]
[errors, mutateAsync]
);
const serializedData: SavedQuerySOFormData = useMemo(
@ -285,23 +224,6 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[watchedValues]
);
const handleToggle = useCallback((isOpen) => {
const newState = isOpen ? 'open' : 'closed';
setAdvancedContentState(newState);
}, []);
const ecsFieldProps = useMemo(
() => ({
isDisabled: !permissions.writeLiveQueries,
}),
[permissions.writeLiveQueries]
);
const isSavedQueryDisabled = useMemo(
() => !permissions.runSavedQueries || !permissions.readSavedQueries,
[permissions.readSavedQueries, permissions.runSavedQueries]
);
const { data: packsData, isFetched: isPackDataFetched } = usePacks({});
const selectedPackData = useMemo(
@ -309,6 +231,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[packId, packsData]
);
const handleSubmitForm = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]);
const submitButtonContent = useMemo(
() => (
<EuiFlexItem>
@ -330,7 +254,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
<EuiButton
id="submit-button"
disabled={!enabled || isSubmitting}
onClick={handleSubmit(onSubmit)}
onClick={handleSubmitForm}
>
<FormattedMessage
id="xpack.osquery.liveQueryForm.form.submitButtonLabel"
@ -349,53 +273,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
handleShowSaveQueryFlyout,
enabled,
isSubmitting,
handleSubmit,
onSubmit,
]
);
const queryFieldStepContent = useMemo(
() => (
<>
{queryField && (
<>
{!isSavedQueryDisabled && (
<>
<SavedQueriesDropdown
disabled={isSavedQueryDisabled}
onChange={handleSavedQueryChange}
/>
</>
)}
<LiveQueryQueryField {...queryComponentProps} queryType={queryType} />
</>
)}
{ecsMappingField && (
<>
<EuiSpacer size="m" />
<StyledEuiAccordion
id="advanced"
forceState={advancedContentState}
onToggle={handleToggle}
buttonContent="Advanced"
>
<EuiSpacer size="xs" />
<ECSMappingEditorField euiFieldProps={ecsFieldProps} />
</StyledEuiAccordion>
</>
)}
</>
),
[
queryField,
isSavedQueryDisabled,
handleSavedQueryChange,
queryComponentProps,
queryType,
ecsMappingField,
advancedContentState,
handleToggle,
ecsFieldProps,
handleSubmitForm,
]
);
@ -589,7 +467,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
</>
) : (
<>
<EuiFlexItem>{queryFieldStepContent}</EuiFlexItem>
<EuiFlexItem>
<LiveQueryQueryField handleSubmitForm={handleSubmitForm} />
</EuiFlexItem>
{submitButtonContent}
<EuiFlexItem>{resultsStepContent}</EuiFlexItem>
</>

View file

@ -5,33 +5,45 @@
* 2.0.
*/
import { EuiCodeBlock, EuiFormRow } from '@elastic/eui';
import React from 'react';
import { isEmpty, map } from 'lodash';
import type { EuiAccordionProps } from '@elastic/eui';
import { EuiCodeBlock, EuiFormRow, EuiAccordion, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { useController } from 'react-hook-form';
import { useController, useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import type { EuiCodeEditorProps } from '../../shared_imports';
import { OsqueryEditor } from '../../editor';
import { useKibana } from '../../common/lib/kibana';
import { MAX_QUERY_LENGTH } from '../../packs/queries/validations';
import { ECSMappingEditorField } from '../../packs/queries/lazy_ecs_mapping_editor_field';
import type { SavedQueriesDropdownProps } from '../../saved_queries/saved_queries_dropdown';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
const StyledEuiAccordion = styled(EuiAccordion)`
${({ isDisabled }: { isDisabled?: boolean }) => isDisabled && 'display: none;'}
.euiAccordion__button {
color: ${({ theme }) => theme.eui.euiColorPrimary};
}
`;
const StyledEuiCodeBlock = styled(EuiCodeBlock)`
min-height: 100px;
`;
interface LiveQueryQueryFieldProps {
export interface LiveQueryQueryFieldProps {
disabled?: boolean;
commands?: EuiCodeEditorProps['commands'];
queryType: string;
handleSubmitForm?: () => void;
}
const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({
disabled,
commands,
queryType,
handleSubmitForm,
}) => {
const formContext = useFormContext();
const [advancedContentState, setAdvancedContentState] =
useState<EuiAccordionProps['forceState']>('closed');
const permissions = useKibana().services.application.capabilities.osquery;
const queryType = formContext?.watch('queryType', 'query');
const {
field: { onChange, value },
@ -43,7 +55,7 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({
message: i18n.translate('xpack.osquery.pack.queryFlyoutForm.emptyQueryError', {
defaultMessage: 'Query is a required field',
}),
value: queryType === 'query',
value: queryType !== 'pack',
},
maxLength: {
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
@ -56,27 +68,108 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({
defaultValue: '',
});
const handleSavedQueryChange: SavedQueriesDropdownProps['onChange'] = useCallback(
(savedQuery) => {
if (savedQuery) {
formContext?.setValue('query', savedQuery.query);
formContext?.setValue('savedQueryId', savedQuery.savedQueryId);
if (!isEmpty(savedQuery.ecs_mapping)) {
formContext?.setValue(
'ecs_mapping',
map(savedQuery.ecs_mapping, (ecsValue, key) => ({
key,
result: {
type: Object.keys(ecsValue)[0],
value: Object.values(ecsValue)[0] as string,
},
}))
);
} else {
formContext?.resetField('ecs_mapping');
}
if (!isEmpty(savedQuery.ecs_mapping)) {
setAdvancedContentState('open');
}
} else {
formContext?.setValue('savedQueryId', null);
}
},
[formContext]
);
const handleToggle = useCallback((isOpen) => {
const newState = isOpen ? 'open' : 'closed';
setAdvancedContentState(newState);
}, []);
const ecsFieldProps = useMemo(
() => ({
isDisabled: !permissions.writeLiveQueries,
}),
[permissions.writeLiveQueries]
);
const isSavedQueryDisabled = useMemo(
() => !permissions.runSavedQueries || !permissions.readSavedQueries,
[permissions.readSavedQueries, permissions.runSavedQueries]
);
const commands = useMemo(
() =>
handleSubmitForm
? [
{
name: 'submitOnCmdEnter',
bindKey: { win: 'ctrl+enter', mac: 'cmd+enter' },
exec: handleSubmitForm,
},
]
: [],
[handleSubmitForm]
);
return (
<EuiFormRow
isInvalid={!!error?.message}
error={error?.message}
fullWidth
isDisabled={!permissions.writeLiveQueries || disabled}
>
{!permissions.writeLiveQueries || disabled ? (
<StyledEuiCodeBlock
language="sql"
fontSize="m"
paddingSize="m"
transparentBackground={!value.length}
>
{value}
</StyledEuiCodeBlock>
) : (
<OsqueryEditor defaultValue={value} onChange={onChange} commands={commands} />
<>
{!isSavedQueryDisabled && (
<SavedQueriesDropdown disabled={isSavedQueryDisabled} onChange={handleSavedQueryChange} />
)}
</EuiFormRow>
<EuiFormRow
isInvalid={!!error?.message}
error={error?.message}
fullWidth
isDisabled={!permissions.writeLiveQueries || disabled}
>
{!permissions.writeLiveQueries || disabled ? (
<StyledEuiCodeBlock
language="sql"
fontSize="m"
paddingSize="m"
transparentBackground={!value.length}
>
{value}
</StyledEuiCodeBlock>
) : (
<OsqueryEditor defaultValue={value} onChange={onChange} commands={commands} />
)}
</EuiFormRow>
<EuiSpacer size="m" />
<StyledEuiAccordion
id="advanced"
forceState={advancedContentState}
onToggle={handleToggle}
buttonContent="Advanced"
>
<EuiSpacer size="xs" />
<ECSMappingEditorField euiFieldProps={ecsFieldProps} />
</StyledEuiAccordion>
</>
);
};
export const LiveQueryQueryField = React.memo(LiveQueryQueryFieldComponent);
// eslint-disable-next-line import/no-default-export
export { LiveQueryQueryField as default };

View file

@ -18,7 +18,7 @@ import {
trim,
get,
} from 'lodash';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiFormLabel,
@ -625,25 +625,6 @@ export const ECSMappingEditorForm: React.FC<ECSMappingEditorFormProps> = ({
defaultValue: '',
});
const MultiFields = useMemo(
() => (
<div>
<OsqueryColumnField
item={item}
index={index}
isLastItem={isLastItem}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
// @ts-expect-error update types
options: osquerySchemaOptions,
isDisabled,
}}
/>
</div>
),
[item, index, isLastItem, osquerySchemaOptions, isDisabled]
);
const ecsComboBoxEuiFieldProps = useMemo(() => ({ isDisabled }), [isDisabled]);
const handleDeleteClick = useCallback(() => {
@ -676,7 +657,19 @@ export const ECSMappingEditorForm: React.FC<ECSMappingEditorFormProps> = ({
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexStart" gutterSize="s" wrap>
<ECSFieldWrapper>{MultiFields}</ECSFieldWrapper>
<ECSFieldWrapper>
<OsqueryColumnField
item={item}
index={index}
isLastItem={isLastItem}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
// @ts-expect-error update types
options: osquerySchemaOptions,
isDisabled,
}}
/>
</ECSFieldWrapper>
{!isDisabled && (
<EuiFlexItem grow={false}>
<StyledButtonWrapper>
@ -742,7 +735,7 @@ export const ECSMappingEditorField = React.memo(
const fieldsToValidate = prepareEcsFieldsToValidate(fields);
// it is always at least 2 - empty fields
if (fieldsToValidate.length > 2) {
setTimeout(async () => await trigger('ecs_mapping'), 0);
setTimeout(() => trigger('ecs_mapping'), 0);
}
}, [fields, query, trigger]);
@ -977,7 +970,7 @@ export const ECSMappingEditorField = React.memo(
);
}, [query]);
useLayoutEffect(() => {
useEffect(() => {
const ecsList = formData?.ecs_mapping;
const lastEcs = formData?.ecs_mapping?.[itemsList?.current.length - 1];
@ -986,15 +979,16 @@ export const ECSMappingEditorField = React.memo(
return;
}
// // list contains ecs already, and the last item has values provided
// list contains ecs already, and the last item has values provided
if (
ecsList?.length === itemsList.current.length &&
lastEcs?.key?.length &&
lastEcs?.result?.value?.length
(ecsList?.length === itemsList.current.length &&
lastEcs?.key?.length &&
lastEcs?.result?.value?.length) ||
!fields?.length
) {
return append(defaultEcsFormData);
}
}, [append, euiFieldProps?.isDisabled, formData]);
}, [append, fields, formData]);
return (
<>

View file

@ -26,7 +26,11 @@ import {
LazyOsqueryManagedPolicyEditExtension,
LazyOsqueryManagedCustomButtonExtension,
} from './fleet_integration';
import { getLazyOsqueryAction, useIsOsqueryAvailableSimple } from './shared_components';
import {
getLazyOsqueryAction,
getLazyLiveQueryField,
useIsOsqueryAvailableSimple,
} from './shared_components';
export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> {
private kibanaVersion: string;
@ -94,8 +98,10 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
OsqueryAction: getLazyOsqueryAction({
...core,
...plugins,
storage: this.storage,
kibanaVersion: this.kibanaVersion,
}),
LiveQueryField: getLazyLiveQueryField({
...core,
...plugins,
}),
isOsqueryAvailable: useIsOsqueryAvailableSimple,
};

View file

@ -27,7 +27,7 @@ const StyledEuiCodeBlock = styled(EuiCodeBlock)`
}
`;
interface SavedQueriesDropdownProps {
export interface SavedQueriesDropdownProps {
disabled?: boolean;
onChange: (
value:

View file

@ -6,4 +6,5 @@
*/
export { getLazyOsqueryAction } from './lazy_osquery_action';
export { getLazyLiveQueryField } from './lazy_live_query_field';
export { useIsOsqueryAvailableSimple } from './osquery_action/use_is_osquery_available_simple';

View file

@ -0,0 +1,39 @@
/*
* 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 { UseFormReturn } from 'react-hook-form';
import { FormProvider } from 'react-hook-form';
import type { LiveQueryQueryFieldProps } from '../live_queries/form/live_query_query_field';
import type { ServicesWrapperProps } from './services_wrapper';
import ServicesWrapper from './services_wrapper';
export const getLazyLiveQueryField =
(services: ServicesWrapperProps['services']) =>
// eslint-disable-next-line react/display-name
({
formMethods,
...props
}: LiveQueryQueryFieldProps & {
formMethods: UseFormReturn<{
label: string;
query: string;
ecs_mapping: Record<string, unknown>;
}>;
}) => {
const LiveQueryField = lazy(() => import('../live_queries/form/live_query_query_field'));
return (
<Suspense fallback={null}>
<ServicesWrapper services={services}>
<FormProvider {...formMethods}>
<LiveQueryField {...props} />
</FormProvider>
</ServicesWrapper>
</Suspense>
);
};

View file

@ -6,15 +6,20 @@
*/
import React, { lazy, Suspense } from 'react';
import ServicesWrapper from './services_wrapper';
import type { ServicesWrapperProps } from './services_wrapper';
import type { OsqueryActionProps } from './osquery_action';
// @ts-expect-error update types
// eslint-disable-next-line react/display-name
export const getLazyOsqueryAction = (services) => (props) => {
const OsqueryAction = lazy(() => import('./osquery_action'));
export const getLazyOsqueryAction =
// eslint-disable-next-line react/display-name
(services: ServicesWrapperProps['services']) => (props: OsqueryActionProps) => {
const OsqueryAction = lazy(() => import('./osquery_action'));
return (
<Suspense fallback={null}>
<OsqueryAction services={services} {...props} />
</Suspense>
);
};
return (
<Suspense fallback={null}>
<ServicesWrapper services={services}>
<OsqueryAction {...props} />
</ServicesWrapper>
</Suspense>
);
};

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import { EuiErrorBoundary, EuiLoadingContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { EuiLoadingContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import React, { useMemo } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import type { CoreStart } from '@kbn/core/public';
import {
AGENT_STATUS_ERROR,
EMPTY_PROMPT,
@ -16,17 +15,14 @@ import {
PERMISSION_DENIED,
SHORT_EMPTY_TITLE,
} from './translations';
import { KibanaContextProvider, useKibana } from '../../common/lib/kibana';
import { useKibana } from '../../common/lib/kibana';
import { LiveQuery } from '../../live_queries';
import { queryClient } from '../../query_client';
import { OsqueryIcon } from '../../components/osquery_icon';
import { KibanaThemeProvider } from '../../shared_imports';
import { useIsOsqueryAvailable } from './use_is_osquery_available';
import type { StartPlugins } from '../../types';
interface OsqueryActionProps {
export interface OsqueryActionProps {
agentId?: string;
defaultValues?: {};
formType: 'steps' | 'simple';
hideAgentsField?: boolean;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
@ -35,6 +31,7 @@ interface OsqueryActionProps {
const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
agentId,
formType = 'simple',
defaultValues,
hideAgentsField,
addToTimeline,
}) => {
@ -54,7 +51,7 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
const { osqueryAvailable, agentFetched, isLoading, policyFetched, policyLoading, agentData } =
useIsOsqueryAvailable(agentId);
if (!agentId || (agentFetched && !agentData)) {
if (agentId && agentFetched && !agentData) {
return emptyPrompt;
}
@ -77,15 +74,15 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
);
}
if (isLoading) {
if (agentId && isLoading) {
return <EuiLoadingContent lines={10} />;
}
if (!policyFetched && policyLoading) {
if (agentId && !policyFetched && policyLoading) {
return <EuiLoadingContent lines={10} />;
}
if (!osqueryAvailable) {
if (agentId && !osqueryAvailable) {
return (
<EuiEmptyPrompt
icon={<OsqueryIcon />}
@ -96,7 +93,7 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
);
}
if (agentData?.status !== 'online') {
if (agentId && agentData?.status !== 'online') {
return (
<EuiEmptyPrompt
icon={<OsqueryIcon />}
@ -113,38 +110,14 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
agentId={agentId}
hideAgentsField={hideAgentsField}
addToTimeline={addToTimeline}
{...defaultValues}
/>
);
};
OsqueryActionComponent.displayName = 'OsqueryAction';
export const OsqueryAction = React.memo(OsqueryActionComponent);
type OsqueryActionWrapperProps = { services: CoreStart & StartPlugins } & OsqueryActionProps;
const OsqueryActionWrapperComponent: React.FC<OsqueryActionWrapperProps> = ({
services,
agentId,
formType,
hideAgentsField = false,
addToTimeline,
}) => (
<KibanaThemeProvider theme$={services.theme.theme$}>
<KibanaContextProvider services={services}>
<EuiErrorBoundary>
<QueryClientProvider client={queryClient}>
<OsqueryAction
agentId={agentId}
formType={formType}
hideAgentsField={hideAgentsField}
addToTimeline={addToTimeline}
/>
</QueryClientProvider>
</EuiErrorBoundary>
</KibanaContextProvider>
</KibanaThemeProvider>
);
const OsqueryActionWrapper = React.memo(OsqueryActionWrapperComponent);
// eslint-disable-next-line import/no-default-export
export { OsqueryActionWrapper as default };
export { OsqueryAction as default };

View file

@ -81,13 +81,6 @@ describe('Osquery Action', () => {
const { getByText } = renderWithContext(<OsqueryAction agentId={'test'} formType={'steps'} />);
expect(getByText(EMPTY_PROMPT)).toBeInTheDocument();
});
it('should return empty prompt when no agentId', async () => {
spyOsquery();
mockKibana();
const { getByText } = renderWithContext(<OsqueryAction agentId={''} formType={'steps'} />);
expect(getByText(EMPTY_PROMPT)).toBeInTheDocument();
});
it('should return permission denied when agentFetched and agentData available', async () => {
spyOsquery({ agentData: {} });
mockKibana();

View file

@ -0,0 +1,36 @@
/*
* 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 { EuiErrorBoundary } from '@elastic/eui';
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import type { CoreStart } from '@kbn/core/public';
import { KibanaContextProvider } from '../common/lib/kibana';
import { queryClient } from '../query_client';
import { KibanaThemeProvider } from '../shared_imports';
import type { StartPlugins } from '../types';
export interface ServicesWrapperProps {
services: CoreStart & StartPlugins;
children: React.ReactNode;
}
const ServicesWrapperComponent: React.FC<ServicesWrapperProps> = ({ services, children }) => (
<KibanaThemeProvider theme$={services.theme.theme$}>
<KibanaContextProvider services={services}>
<EuiErrorBoundary>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</EuiErrorBoundary>
</KibanaContextProvider>
</KibanaThemeProvider>
);
const ServicesWrapper = React.memo(ServicesWrapperComponent);
// eslint-disable-next-line import/no-default-export
export { ServicesWrapper as default };

View file

@ -16,12 +16,13 @@ import type {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '@kbn/triggers-actions-ui-plugin/public';
import type { getLazyOsqueryAction } from './shared_components';
import type { getLazyLiveQueryField, getLazyOsqueryAction } from './shared_components';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface OsqueryPluginSetup {}
export interface OsqueryPluginStart {
OsqueryAction?: ReturnType<typeof getLazyOsqueryAction>;
LiveQueryField?: ReturnType<typeof getLazyLiveQueryField>;
isOsqueryAvailable: (props: { agentId: string }) => boolean;
}

View file

@ -7,10 +7,11 @@
import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
import { ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import React, { useMemo } from 'react';
import React, { createContext, useMemo } from 'react';
import styled from 'styled-components';
import type { GetBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import * as i18n from './translations';
import { useRuleWithFallback } from '../../../detections/containers/detection_engine/rules/use_rule_with_fallback';
import { MarkdownRenderer } from '../markdown_editor';
@ -22,6 +23,8 @@ export const Indent = styled.div`
word-break: break-word;
`;
export const BasicAlertDataContext = createContext<Partial<GetBasicDataFromDetailsData>>({});
const InvestigationGuideViewComponent: React.FC<{
data: TimelineEventsDetailsItem[];
}> = ({ data }) => {
@ -32,13 +35,14 @@ const InvestigationGuideViewComponent: React.FC<{
: item?.originalValue ?? null;
}, [data]);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const basicAlertData = useBasicDataFromDetailsData(data);
if (!maybeRule?.note) {
return null;
}
return (
<>
<BasicAlertDataContext.Provider value={basicAlertData}>
<EuiSpacer size="l" />
<EuiTitle size="xxxs" data-test-subj="summary-view-guide">
<h5>{i18n.INVESTIGATION_GUIDE}</h5>
@ -51,7 +55,7 @@ const InvestigationGuideViewComponent: React.FC<{
</LineClamp>
</EuiText>
</Indent>
</>
</BasicAlertDataContext.Provider>
);
};

View file

@ -5,37 +5,27 @@
* 2.0.
*/
import type { EuiLinkAnchorProps } from '@elastic/eui';
import {
getDefaultEuiMarkdownParsingPlugins,
getDefaultEuiMarkdownProcessingPlugins,
getDefaultEuiMarkdownUiPlugins,
} from '@elastic/eui';
// Remove after this issue is resolved: https://github.com/elastic/eui/issues/4688
import type { Options as Remark2RehypeOptions } from 'mdast-util-to-hast';
import type { FunctionComponent } from 'react';
import type rehype2react from 'rehype-react';
import type { Plugin, PluggableList } from 'unified';
import * as timelineMarkdownPlugin from './timeline';
import * as osqueryMarkdownPlugin from './osquery';
export const { uiPlugins, parsingPlugins, processingPlugins } = {
uiPlugins: getDefaultEuiMarkdownUiPlugins(),
parsingPlugins: getDefaultEuiMarkdownParsingPlugins(),
processingPlugins: getDefaultEuiMarkdownProcessingPlugins() as [
[Plugin, Remark2RehypeOptions],
[
typeof rehype2react,
Parameters<typeof rehype2react>[0] & {
components: { a: FunctionComponent<EuiLinkAnchorProps>; timeline: unknown };
}
],
...PluggableList
],
processingPlugins: getDefaultEuiMarkdownProcessingPlugins(),
};
uiPlugins.push(timelineMarkdownPlugin.plugin);
uiPlugins.push(osqueryMarkdownPlugin.plugin);
parsingPlugins.push(timelineMarkdownPlugin.parser);
parsingPlugins.push(osqueryMarkdownPlugin.parser);
// This line of code is TS-compatible and it will break if [1][1] change in the future.
processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer;
processingPlugins[1][1].components.osquery = osqueryMarkdownPlugin.renderer;

View file

@ -0,0 +1,273 @@
/*
* 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 { pickBy, isEmpty } from 'lodash';
import type { Plugin } from 'unified';
import React, { useContext, useMemo, useState, useCallback } from 'react';
import type { RemarkTokenizer } from '@elastic/eui';
import {
EuiSpacer,
EuiCodeBlock,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiModalFooter,
EuiButton,
EuiButtonEmpty,
} from '@elastic/eui';
import { useForm, FormProvider } from 'react-hook-form';
import styled from 'styled-components';
import type { EuiMarkdownEditorUiPluginEditorProps } from '@elastic/eui/src/components/markdown_editor/markdown_types';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '../../../../lib/kibana';
import { LabelField } from './label_field';
import OsqueryLogo from './osquery_icon/osquery.svg';
import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout';
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
import { convertECSMappingToObject } from './utils';
const StyledEuiButton = styled(EuiButton)`
> span > img {
margin-block-end: 0;
}
`;
const OsqueryEditorComponent = ({
node,
onSave,
onCancel,
}: EuiMarkdownEditorUiPluginEditorProps<{
configuration: {
label?: string;
query: string;
ecs_mapping: { [key: string]: {} };
};
}>) => {
const isEditMode = node != null;
const { osquery } = useKibana().services;
const formMethods = useForm<{
label: string;
query: string;
ecs_mapping: Record<string, unknown>;
}>({
defaultValues: {
label: node?.configuration?.label,
query: node?.configuration?.query,
ecs_mapping: node?.configuration?.ecs_mapping,
},
});
const onSubmit = useCallback(
(data) => {
onSave(
`!{osquery${JSON.stringify(
pickBy(
{
query: data.query,
label: data.label,
ecs_mapping: convertECSMappingToObject(data.ecs_mapping),
},
(value) => !isEmpty(value)
)
)}}`,
{
block: true,
}
);
},
[onSave]
);
const OsqueryActionForm = useMemo(() => {
if (osquery?.LiveQueryField) {
const { LiveQueryField } = osquery;
return (
<FormProvider {...formMethods}>
<LabelField />
<EuiSpacer size="m" />
<LiveQueryField formMethods={formMethods} />
</FormProvider>
);
}
return null;
}, [formMethods, osquery]);
return (
<>
<EuiModalHeader>
<EuiModalHeaderTitle>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.editModalTitle"
defaultMessage="Edit query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.addModalTitle"
defaultMessage="Add query"
/>
)}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<>{OsqueryActionForm}</>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>
{i18n.translate('xpack.securitySolution.markdown.osquery.modalCancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton onClick={formMethods.handleSubmit(onSubmit)} fill>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel"
defaultMessage="Add query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel"
defaultMessage="Save changes"
/>
)}
</EuiButton>
</EuiModalFooter>
</>
);
};
const OsqueryEditor = React.memo(OsqueryEditorComponent);
export const plugin = {
name: 'osquery',
button: {
label: 'Osquery',
iconType: 'logoOsquery',
},
helpText: (
<div>
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
{'!{osquery{options}}'}
</EuiCodeBlock>
<EuiSpacer size="s" />
</div>
),
editor: OsqueryEditor,
};
export const parser: Plugin = function () {
const Parser = this.Parser;
const tokenizers = Parser.prototype.blockTokenizers;
const methods = Parser.prototype.blockMethods;
const tokenizeOsquery: RemarkTokenizer = function (eat, value, silent) {
if (value.startsWith('!{osquery') === false) return false;
const nextChar = value[9];
if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a osquery
if (silent) {
return true;
}
// is there a configuration?
const hasConfiguration = nextChar === '{';
let match = '!{osquery';
let configuration = {};
if (hasConfiguration) {
let configurationString = '';
let openObjects = 0;
for (let i = 9; i < value.length; i++) {
const char = value[i];
if (char === '{') {
openObjects++;
configurationString += char;
} else if (char === '}') {
openObjects--;
if (openObjects === -1) {
break;
}
configurationString += char;
} else {
configurationString += char;
}
}
match += configurationString;
try {
configuration = JSON.parse(configurationString);
} catch (e) {
const now = eat.now();
this.file.fail(`Unable to parse osquery JSON configuration: ${e}`, {
line: now.line,
column: now.column + 9,
});
}
}
match += '}';
return eat(match)({
type: 'osquery',
configuration,
});
};
tokenizers.osquery = tokenizeOsquery;
methods.splice(methods.indexOf('text'), 0, 'osquery');
};
// receives the configuration from the parser and renders
const RunOsqueryButtonRenderer = ({
configuration,
}: {
configuration: {
label?: string;
query: string;
ecs_mapping: { [key: string]: {} };
};
}) => {
const [showFlyout, setShowFlyout] = useState(false);
const { agentId } = useContext(BasicAlertDataContext);
const handleOpen = useCallback(() => setShowFlyout(true), [setShowFlyout]);
const handleClose = useCallback(() => setShowFlyout(false), [setShowFlyout]);
return (
<>
<StyledEuiButton iconType={OsqueryLogo} onClick={handleOpen}>
{configuration.label ??
i18n.translate('xpack.securitySolution.markdown.osquery.runOsqueryButtonLabel', {
defaultMessage: 'Run Osquery',
})}
</StyledEuiButton>
{showFlyout && (
<OsqueryFlyout
defaultValues={{
query: configuration.query,
ecs_mapping: configuration.ecs_mapping,
queryField: false,
}}
agentId={agentId}
onClose={handleClose}
/>
)}
</>
);
};
export { RunOsqueryButtonRenderer as renderer };

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.
*/
import React, { useMemo } from 'react';
import { useController } from 'react-hook-form';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface QueryDescriptionFieldProps {
euiFieldProps?: Record<string, unknown>;
}
const LabelFieldComponent = ({ euiFieldProps }: QueryDescriptionFieldProps) => {
const {
field: { onChange, value, name: fieldName },
fieldState: { error },
} = useController({
name: 'label',
defaultValue: '',
});
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={i18n.translate('xpack.securitySolution.markdown.osquery.labelFieldText', {
defaultMessage: 'Label',
})}
error={error?.message}
isInvalid={hasError}
fullWidth
>
<EuiFieldText
isInvalid={hasError}
onChange={onChange}
value={value}
name={fieldName}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export const LabelField = React.memo(LabelFieldComponent);

View file

@ -0,0 +1,19 @@
/*
* 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 from 'react';
import type { EuiIconProps } from '@elastic/eui';
import { EuiIcon } from '@elastic/eui';
import OsqueryLogo from './osquery.svg';
export type OsqueryIconProps = Omit<EuiIconProps, 'type'>;
const OsqueryIconComponent: React.FC<OsqueryIconProps> = (props) => (
<EuiIcon size="xl" type={OsqueryLogo} {...props} />
);
export const OsqueryIcon = React.memo(OsqueryIconComponent);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="256px" height="255px" viewBox="0 0 256 255" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M255.214617,0.257580247 L255.214617,63.993679 L191.609679,127.598617 L191.609679,63.7297778 L255.214617,0.257580247" fill="#A596FF"></path>
<path d="M128.006321,0.257580247 L128.006321,63.993679 L191.611259,127.598617 L191.611259,63.7297778 L128.006321,0.257580247" fill="#000000"></path>
<path d="M255.345778,254.803753 L191.609679,254.803753 L128.004741,191.198815 L191.872,191.198815 L255.345778,254.803753" fill="#A596FF"></path>
<path d="M255.345778,127.595457 L191.609679,127.595457 L128.004741,191.200395 L191.872,191.200395 L255.345778,127.595457" fill="#000000"></path>
<path d="M0.801185185,254.936494 L0.801185185,191.198815 L64.4061235,127.593877 L64.4061235,191.462716 L0.801185185,254.936494" fill="#A596FF"></path>
<path d="M128.009481,254.936494 L128.009481,191.198815 L64.4045432,127.593877 L64.4045432,191.462716 L128.009481,254.936494" fill="#000000"></path>
<path d="M0.671604938,0.385580247 L64.4077037,0.385580247 L128.012642,63.9905185 L64.1453827,63.9905185 L0.671604938,0.385580247" fill="#A596FF"></path>
<path d="M0.671604938,127.593877 L64.4077037,127.593877 L128.012642,63.9889383 L64.1453827,63.9889383 L0.671604938,127.593877" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,31 @@
/*
* 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 { isEmpty, reduce } from 'lodash';
export const convertECSMappingToObject = (
ecsMapping: Array<{
key: string;
result: {
type: string;
value: string;
};
}>
) =>
reduce(
ecsMapping,
(acc, value) => {
if (!isEmpty(value?.key) && !isEmpty(value.result?.type) && !isEmpty(value.result?.value)) {
acc[value.key] = {
[value.result.type]: value.result.value,
};
}
return acc;
},
{} as Record<string, { field?: string; value?: string }>
);

View file

@ -25,16 +25,19 @@ const OsqueryActionWrapper = styled.div`
`;
export interface OsqueryFlyoutProps {
agentId: string;
agentId?: string;
defaultValues?: {};
onClose: () => void;
}
const TimelineComponent = React.memo((props) => {
return <EuiButtonEmpty {...props} size="xs" />;
});
const TimelineComponent = React.memo((props) => <EuiButtonEmpty {...props} size="xs" />);
TimelineComponent.displayName = 'TimelineComponent';
export const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({ agentId, onClose }) => {
export const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
agentId,
defaultValues,
onClose,
}) => {
const {
services: { osquery, timelines },
} = useKibana();
@ -70,30 +73,38 @@ export const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({ agentId,
},
[getAddToTimelineButton]
);
// @ts-expect-error
const { OsqueryAction } = osquery;
return (
<EuiFlyout
ownFocus
maskProps={{ style: 'z-index: 5000' }} // For an edge case to display above the timeline flyout
size="m"
onClose={onClose}
>
<EuiFlyoutHeader hasBorder data-test-subj="flyout-header-osquery">
<EuiTitle>
<h2>{ACTION_OSQUERY}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<OsqueryActionWrapper data-test-subj="flyout-body-osquery">
<OsqueryAction agentId={agentId} formType="steps" addToTimeline={handleAddToTimeline} />
</OsqueryActionWrapper>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<OsqueryEventDetailsFooter handleClick={onClose} data-test-subj="flyout-footer-osquery" />
</EuiFlyoutFooter>
</EuiFlyout>
);
if (osquery?.OsqueryAction) {
return (
<EuiFlyout
ownFocus
maskProps={{ style: 'z-index: 5000' }} // For an edge case to display above the timeline flyout
size="m"
onClose={onClose}
>
<EuiFlyoutHeader hasBorder data-test-subj="flyout-header-osquery">
<EuiTitle>
<h2>{ACTION_OSQUERY}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<OsqueryActionWrapper data-test-subj="flyout-body-osquery">
<osquery.OsqueryAction
agentId={agentId}
formType="steps"
defaultValues={defaultValues}
addToTimeline={handleAddToTimeline}
/>
</OsqueryActionWrapper>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<OsqueryEventDetailsFooter handleClick={onClose} data-test-subj="flyout-footer-osquery" />
</EuiFlyoutFooter>
</EuiFlyout>
);
}
return null;
};
export const OsqueryFlyout = React.memo(OsqueryFlyoutComponent);

View file

@ -11,8 +11,9 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str
import { getFieldValue } from '../../../../detections/components/host_isolation/helpers';
import { DEFAULT_ALERTS_INDEX, DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants';
interface GetBasicDataFromDetailsData {
export interface GetBasicDataFromDetailsData {
alertId: string;
agentId?: string;
isAlert: boolean;
hostName: string;
ruleName: string;
@ -31,6 +32,11 @@ export const useBasicDataFromDetailsData = (
const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, data), [data]);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, data),
[data]
);
const hostName = useMemo(
() => getFieldValue({ category: 'host', field: 'host.name' }, data),
[data]
@ -44,17 +50,18 @@ export const useBasicDataFromDetailsData = (
return useMemo(
() => ({
alertId,
agentId,
isAlert,
hostName,
ruleName,
timestamp,
}),
[alertId, hostName, isAlert, ruleName, timestamp]
[agentId, alertId, hostName, isAlert, ruleName, timestamp]
);
};
/*
The referenced alert _index in the flyout uses the `.internal.` such as
The referenced alert _index in the flyout uses the `.internal.` such as
`.internal.alerts-security.alerts-spaceId` in the alert page flyout and
.internal.preview.alerts-security.alerts-spaceId` in the rule creation preview flyout
but we always want to use their respective aliase indices rather than accessing their backing .internal. indices.