From dd48ebf8f0c7f2c3fce7697bc73ce39981631897 Mon Sep 17 00:00:00 2001 From: Bryan Clement Date: Wed, 28 Apr 2021 19:54:09 -0700 Subject: [PATCH] [Asset management] Text updates (#98192) * updated scheduled query activation toggle text and interval header in query group * added id validation for schedule queries * fixed up agent resolution to ignore inactive agents, and properly pull all agents * nixed unused file * more validation for query fields * added status table to the results data tab, added more validation * updated wording * added error notifications for failed queries * pr feedback and cleanup * fix up last hook * use the pluralize macro, removed rbac tags Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../osquery/common/schemas/common/schemas.ts | 10 ++ .../create_action_request_body_schema.ts | 17 ++ .../common/schemas/routes/action/index.ts | 8 + .../action_results/use_action_results.ts | 14 +- .../public/actions/use_action_details.ts | 12 +- .../osquery/public/actions/use_all_actions.ts | 12 +- .../agent_policies/use_agent_policies.ts | 12 +- .../public/agent_policies/use_agent_policy.ts | 12 +- .../osquery/public/agents/use_agent_groups.ts | 12 +- .../public/agents/use_agent_policies.ts | 12 +- .../osquery/public/agents/use_agent_status.ts | 12 +- .../osquery/public/agents/use_all_agents.ts | 12 +- .../public/agents/use_osquery_policies.ts | 27 ++- .../common/hooks/use_osquery_integration.tsx | 12 +- .../osquery/public/common/validations.ts | 17 ++ .../live_queries/agent_results/index.tsx | 2 +- .../public/live_queries/form/index.tsx | 37 +++- .../form/live_query_query_field.tsx | 70 +------- .../osquery/public/queries/edit/tabs.tsx | 2 +- .../public/queries/form/code_editor_field.tsx | 7 +- .../osquery/public/results/results_table.tsx | 164 ++++++++++++++---- .../osquery/public/results/translations.ts | 8 + .../osquery/public/results/use_all_results.ts | 12 +- .../form/add_query_flyout.tsx | 4 + .../form/confirmation_modal.tsx | 2 +- .../form/edit_query_flyout.tsx | 4 + .../form/translations.ts | 12 ++ .../form/validations.ts | 48 +++++ .../scheduled_query_group_queries_table.tsx | 2 +- .../plugins/osquery/public/shared_imports.ts | 1 + .../osquery/server/lib/parse_agent_groups.ts | 86 ++++++--- .../routes/action/create_action_route.ts | 33 ++-- 32 files changed, 532 insertions(+), 163 deletions(-) create mode 100644 x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts create mode 100644 x-pack/plugins/osquery/common/schemas/routes/action/index.ts create mode 100644 x-pack/plugins/osquery/public/common/validations.ts create mode 100644 x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts create mode 100644 x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts diff --git a/x-pack/plugins/osquery/common/schemas/common/schemas.ts b/x-pack/plugins/osquery/common/schemas/common/schemas.ts index ffcadc7cfea8f..f5d0a357b85b8 100644 --- a/x-pack/plugins/osquery/common/schemas/common/schemas.ts +++ b/x-pack/plugins/osquery/common/schemas/common/schemas.ts @@ -12,6 +12,16 @@ export type Name = t.TypeOf; export const nameOrUndefined = t.union([name, t.undefined]); export type NameOrUndefined = t.TypeOf; +export const agentSelection = t.type({ + agents: t.array(t.string), + allAgentsSelected: t.boolean, + platformsSelected: t.array(t.string), + policiesSelected: t.array(t.string), +}); +export type AgentSelection = t.TypeOf; +export const agentSelectionOrUndefined = t.union([agentSelection, t.undefined]); +export type AgentSelectionOrUndefined = t.TypeOf; + export const description = t.string; export type Description = t.TypeOf; export const descriptionOrUndefined = t.union([description, t.undefined]); diff --git a/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts b/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts new file mode 100644 index 0000000000000..bcbd528c4e749 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts @@ -0,0 +1,17 @@ +/* + * 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 * as t from 'io-ts'; + +import { query, agentSelection } from '../../common/schemas'; + +export const createActionRequestBodySchema = t.type({ + agentSelection, + query, +}); + +export type CreateActionRequestBodySchema = t.OutputOf; diff --git a/x-pack/plugins/osquery/common/schemas/routes/action/index.ts b/x-pack/plugins/osquery/common/schemas/routes/action/index.ts new file mode 100644 index 0000000000000..286aa2e5128b2 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/action/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './create_action_request_body_schema'; diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts index 7cad8ca3fc498..1f6da0b3a2a0e 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_results.ts +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -8,6 +8,7 @@ import { flatten, reverse, uniqBy } from 'lodash/fp'; import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -32,7 +33,7 @@ export interface ResultsArgs { totalCount: number; } -interface UseActionResults { +export interface UseActionResults { actionId: string; activePage: number; agentIds?: string[]; @@ -55,7 +56,10 @@ export const useActionResults = ({ skip = false, isLive = false, }: UseActionResults) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actionResults', { actionId }], @@ -120,6 +124,12 @@ export const useActionResults = ({ refetchInterval: isLive ? 1000 : false, keepPreviousData: true, enabled: !skip && !!agentIds?.length, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.action_results.fetchError', { + defaultMessage: 'Error while fetching action results', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/actions/use_action_details.ts b/x-pack/plugins/osquery/public/actions/use_action_details.ts index 2e5fa79cae992..bb260cd78ca76 100644 --- a/x-pack/plugins/osquery/public/actions/use_action_details.ts +++ b/x-pack/plugins/osquery/public/actions/use_action_details.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -32,7 +33,10 @@ interface UseActionDetails { } export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseActionDetails) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actionDetails', { actionId, filterQuery }], @@ -57,6 +61,12 @@ export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseAct }, { enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.action_details.fetchError', { + defaultMessage: 'Error while fetching action details', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/actions/use_all_actions.ts b/x-pack/plugins/osquery/public/actions/use_all_actions.ts index a58f45b8e99a2..375d108c4dd8b 100644 --- a/x-pack/plugins/osquery/public/actions/use_all_actions.ts +++ b/x-pack/plugins/osquery/public/actions/use_all_actions.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -47,7 +48,10 @@ export const useAllActions = ({ filterQuery, skip = false, }: UseAllActions) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actions', { activePage, direction, limit, sortField }], @@ -78,6 +82,12 @@ export const useAllActions = ({ { keepPreviousData: true, enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.all_actions.fetchError', { + defaultMessage: 'Error while fetching actions', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts index 95323dd23f4d2..d4bd0a1f4277f 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService, @@ -15,7 +16,10 @@ import { } from '../../../fleet/common'; export const useAgentPolicies = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentPolicies'], @@ -30,6 +34,12 @@ export const useAgentPolicies = () => { placeholderData: [], keepPreviousData: true, select: (response) => response.items, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_policies.fetchError', { + defaultMessage: 'Error while fetching agent policies', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts index 5fdc317d3f6f1..e87d8d1c9f28e 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService } from '../../../fleet/common'; @@ -16,7 +17,10 @@ interface UseAgentPolicy { } export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentPolicy', { policyId }], @@ -25,6 +29,12 @@ export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => { enabled: !skip, keepPreviousData: true, select: (response) => response.item, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.agent_policy_details.fetchError', { + defaultMessage: 'Error while fetching agent policy details', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts index 0853891f1919d..44737af9d3477 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts @@ -6,6 +6,7 @@ */ import { useState } from 'react'; import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { useAgentPolicies } from './use_agent_policies'; @@ -24,7 +25,10 @@ interface UseAgentGroups { } export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies); const [platforms, setPlatforms] = useState([]); @@ -96,6 +100,12 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA }, { enabled: !osqueryPoliciesLoading && !agentPoliciesLoading, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_groups.fetchError', { + defaultMessage: 'Error while fetching agent groups', + }), + }), } ); diff --git a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts index c8b3ef064c038..ecb95fff8838e 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts @@ -7,17 +7,27 @@ import { mapKeys } from 'lodash'; import { useQueries, UseQueryResult } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService, GetOneAgentPolicyResponse } from '../../../fleet/common'; export const useAgentPolicies = (policyIds: string[] = []) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; const agentResponse = useQueries( policyIds.map((policyId) => ({ queryKey: ['agentPolicy', policyId], queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)), enabled: policyIds.length > 0, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.action_policy_details.fetchError', { + defaultMessage: 'Error while fetching policy details', + }), + }), })) ) as Array>; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_status.ts b/x-pack/plugins/osquery/public/agents/use_agent_status.ts index c26adb908f6be..4954eb0dc80c4 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_status.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_status.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { useQuery } from 'react-query'; import { GetAgentStatusResponse, agentRouteService } from '../../../fleet/common'; @@ -16,7 +17,10 @@ interface UseAgentStatus { } export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentStatus', policyId], @@ -34,6 +38,12 @@ export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { { enabled: !skip, select: (response) => response.results, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_status.fetchError', { + defaultMessage: 'Error while fetching agent status', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index e10bc2a0d9bf6..674deb3b339bd 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { useQuery } from 'react-query'; import { GetAgentsResponse, agentRouteService } from '../../../fleet/common'; @@ -27,7 +28,10 @@ export const useAllAgents = ( opts: RequestOptions = { perPage: 9000 } ) => { const { perPage } = opts; - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; const { isLoading: agentsLoading, data: agentData } = useQuery( ['agents', osqueryPolicies, searchValue, perPage], () => { @@ -52,6 +56,12 @@ export const useAllAgents = ( }, { enabled: !osqueryPoliciesLoading, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agents.fetchError', { + defaultMessage: 'Error while fetching agents', + }), + }), } ); diff --git a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts index 2937c57b50a3d..0eb94af73e3a8 100644 --- a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts @@ -5,15 +5,21 @@ * 2.0. */ +import { uniq } from 'lodash'; import { useQuery } from 'react-query'; +import { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { packagePolicyRouteService, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common'; import { OSQUERY_INTEGRATION_NAME } from '../../common'; export const useOsqueryPolicies = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; - const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies } = useQuery( + const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies = [] } = useQuery( ['osqueryPolicies'], () => http.get(packagePolicyRouteService.getListPath(), { @@ -21,8 +27,19 @@ export const useOsqueryPolicies = () => { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, }, }), - { select: (data) => data.items.map((p: { policy_id: string }) => p.policy_id) } + { + select: (response) => + uniq(response.items.map((p: { policy_id: string }) => p.policy_id)), + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.osquery_policies.fetchError', { + defaultMessage: 'Error while fetching osquery policies', + }), + }), + } ); - - return { osqueryPoliciesLoading, osqueryPolicies }; + return useMemo(() => ({ osqueryPoliciesLoading, osqueryPolicies }), [ + osqueryPoliciesLoading, + osqueryPolicies, + ]); }; diff --git a/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx b/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx index d8bed30b969ad..ccfb407eab58b 100644 --- a/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx +++ b/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { find } from 'lodash/fp'; import { useQuery } from 'react-query'; @@ -13,7 +14,10 @@ import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { useKibana } from '../lib/kibana'; export const useOsqueryIntegration = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( 'integrations', @@ -26,6 +30,12 @@ export const useOsqueryIntegration = () => { { select: ({ response }: GetPackagesResponse) => find(['name', OSQUERY_INTEGRATION_NAME], response), + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.osquery_integration.fetchError', { + defaultMessage: 'Error while fetching osquery integration', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/common/validations.ts b/x-pack/plugins/osquery/public/common/validations.ts new file mode 100644 index 0000000000000..7ab9de52e35ad --- /dev/null +++ b/x-pack/plugins/osquery/public/common/validations.ts @@ -0,0 +1,17 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { ValidationFunc, fieldValidators } from '../shared_imports'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const queryFieldValidation: ValidationFunc = fieldValidators.emptyField( + i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyQueryError', { + defaultMessage: 'Query is a required field', + }) +); diff --git a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx index 272e65d9cc0fa..d1ef18e2e12ea 100644 --- a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx @@ -22,7 +22,7 @@ const QueryAgentResultsComponent = () => { {data?.actionDetails._source?.data?.query} - + ); }; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 056bbc75f3b76..5d1b616c7d88a 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -12,14 +12,18 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useMutation } from 'react-query'; -import { UseField, Form, FormData, useForm, useFormData } from '../../shared_imports'; +import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } 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 '../../queries/edit/tabs'; +import { queryFieldValidation } from '../../common/validations'; +import { fieldValidators } from '../../shared_imports'; const FORM_ID = 'liveQueryForm'; +export const MAX_QUERY_LENGTH = 2000; + interface LiveQueryFormProps { defaultValue?: Partial | undefined; onSubmit?: (payload: Record) => Promise; @@ -50,9 +54,27 @@ const LiveQueryFormComponent: React.FC = ({ } ); + const formSchema = { + 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 }, + ], + }, + }; + const { form } = useForm({ id: FORM_ID, - // schema: formSchema, + schema: formSchema, onSubmit: (payload) => { return mutateAsync(payload); }, @@ -60,10 +82,7 @@ const LiveQueryFormComponent: React.FC = ({ stripEmptyFields: false, }, defaultValue: defaultValue ?? { - query: { - id: null, - query: '', - }, + query: '', }, }); @@ -85,16 +104,16 @@ const LiveQueryFormComponent: React.FC = ({ [agentSelection] ); - const queryValueProvided = useMemo(() => !!query?.query?.length, [query]); + const queryValueProvided = useMemo(() => !!query?.length, [query]); const queryStatus = useMemo(() => { if (!agentSelected) return 'disabled'; - if (isError) return 'danger'; + if (isError || !form.getFields().query.isValid) return 'danger'; if (isLoading) return 'loading'; if (isSuccess) return 'complete'; return 'incomplete'; - }, [agentSelected, isError, isLoading, isSuccess]); + }, [agentSelected, isError, isLoading, isSuccess, form]); const resultsStatus = useMemo(() => (queryStatus === 'complete' ? 'incomplete' : 'disabled'), [ queryStatus, diff --git a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx index 68207200dc789..07c13b930e143 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx @@ -5,86 +5,32 @@ * 2.0. */ -// import { find } from 'lodash/fp'; -// import { EuiCodeBlock, EuiSuperSelect, EuiText, EuiSpacer } from '@elastic/eui'; import React, { useCallback } from 'react'; -// import { useQuery } from 'react-query'; +import { EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../shared_imports'; -// import { useKibana } from '../../common/lib/kibana'; import { OsqueryEditor } from '../../editor'; interface LiveQueryQueryFieldProps { disabled?: boolean; - field: FieldHook<{ - id: string | null; - query: string; - }>; + field: FieldHook; } const LiveQueryQueryFieldComponent: React.FC = ({ disabled, field }) => { - // const { http } = useKibana().services; - // const { data } = useQuery('savedQueryList', () => - // http.get('/internal/osquery/saved_query', { - // query: { - // pageIndex: 0, - // pageSize: 100, - // sortField: 'updated_at', - // sortDirection: 'desc', - // }, - // }) - // ); - - // const queryOptions = - // // @ts-expect-error update types - // data?.saved_objects.map((savedQuery) => ({ - // value: savedQuery, - // inputDisplay: savedQuery.attributes.name, - // dropdownDisplay: ( - // <> - // {savedQuery.attributes.name} - // - //

{savedQuery.attributes.description}

- //
- // - // {savedQuery.attributes.query} - // - // - // ), - // })) ?? []; - - const { value, setValue } = field; - - // const handleSavedQueryChange = useCallback( - // (newValue) => { - // setValue({ - // id: newValue.id, - // query: newValue.attributes.query, - // }); - // }, - // [setValue] - // ); + const { value, setValue, errors } = field; + const error = errors[0]?.message; const handleEditorChange = useCallback( (newValue) => { - setValue({ - id: null, - query: newValue, - }); + setValue(newValue); }, [setValue] ); return ( - <> - {/* - */} - - + + + ); }; diff --git a/x-pack/plugins/osquery/public/queries/edit/tabs.tsx b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx index 1a6b317653c98..f86762e76834b 100644 --- a/x-pack/plugins/osquery/public/queries/edit/tabs.tsx +++ b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx @@ -36,7 +36,7 @@ const ResultTabsComponent: React.FC = ({ actionId, agentIds, is content: ( <> - + ), }, diff --git a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx index a56e747355c5b..77ffdc4457d3d 100644 --- a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx +++ b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx @@ -31,15 +31,16 @@ const OsquerySchemaLink = React.memo(() => ( OsquerySchemaLink.displayName = 'OsquerySchemaLink'; const CodeEditorFieldComponent: React.FC = ({ field }) => { - const { value, label, labelAppend, helpText, setValue } = field; + const { value, label, labelAppend, helpText, setValue, errors } = field; + const error = errors[0]?.message; return ( } helpText={helpText} - // isInvalid={typeof error === 'string'} - // error={error} + isInvalid={typeof error === 'string'} + error={error} fullWidth > diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index d82c45d802520..8b613a336ae73 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -12,6 +12,10 @@ import { EuiDataGridProps, EuiDataGridColumn, EuiLink, + EuiTextColor, + EuiBasicTable, + EuiBasicTableColumn, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; @@ -20,16 +24,89 @@ import { pagePathGetters } from '../../../fleet/public'; import { useAllResults } from './use_all_results'; import { Direction, ResultEdges } from '../../common/search_strategy'; import { useKibana } from '../common/lib/kibana'; +import { useActionResults } from '../action_results/use_action_results'; +import { generateEmptyDataMessage } from './translations'; const DataContext = createContext([]); interface ResultsTableComponentProps { actionId: string; - agentId?: string; + selectedAgent?: string; + agentIds?: string[]; isLive?: boolean; } -const ResultsTableComponent: React.FC = ({ actionId, isLive }) => { +interface SummaryTableValue { + total: number | string; + pending: number | string; + responded: number; + failed: number; +} + +const ResultsTableComponent: React.FC = ({ + actionId, + agentIds, + isLive, +}) => { + const { + // @ts-expect-error update types + data: { aggregations }, + } = useActionResults({ + actionId, + activePage: 0, + agentIds, + limit: 0, + direction: Direction.asc, + sortField: '@timestamp', + isLive, + }); + + const notRespondedCount = useMemo(() => { + if (!agentIds || !aggregations.totalResponded) { + return '-'; + } + + return agentIds.length - aggregations.totalResponded; + }, [aggregations.totalResponded, agentIds]); + + const summaryColumns: Array> = useMemo( + () => [ + { + field: 'total', + name: 'Agents queried', + }, + { + field: 'responded', + name: 'Successful', + }, + { + field: 'pending', + name: 'Not yet responded', + }, + { + field: 'failed', + name: 'Failed', + // eslint-disable-next-line react/display-name + render: (failed: number) => ( + {failed} + ), + }, + ], + [] + ); + + const summaryItems = useMemo( + () => [ + { + total: agentIds?.length ?? '-', + pending: notRespondedCount, + responded: aggregations.totalResponded, + failed: aggregations.failed, + }, + ], + [aggregations, agentIds, notRespondedCount] + ); + const { getUrlForApp } = useKibana().services.application; const getFleetAppUrl = useCallback( @@ -115,30 +192,41 @@ const ResultsTableComponent: React.FC = ({ actionId, const newColumns = keys(allResultsData?.edges[0]?.fields) .sort() - .reduce((acc, fieldName) => { - if (fieldName === 'agent.name') { - acc.push({ - id: fieldName, - displayAsText: i18n.translate('xpack.osquery.liveQueryResults.table.agentColumnTitle', { - defaultMessage: 'agent', - }), - defaultSortDirection: Direction.asc, - }); + .reduce( + (acc, fieldName) => { + const { data, seen } = acc; + if (fieldName === 'agent.name') { + data.push({ + id: fieldName, + displayAsText: i18n.translate( + 'xpack.osquery.liveQueryResults.table.agentColumnTitle', + { + defaultMessage: 'agent', + } + ), + defaultSortDirection: Direction.asc, + }); - return acc; - } - - if (fieldName.startsWith('osquery.')) { - acc.push({ - id: fieldName, - displayAsText: fieldName.split('.')[1], - defaultSortDirection: Direction.asc, - }); - return acc; - } + return acc; + } - return acc; - }, [] as EuiDataGridColumn[]); + if (fieldName.startsWith('osquery.')) { + const displayAsText = fieldName.split('.')[1]; + if (!seen.has(displayAsText)) { + data.push({ + id: fieldName, + displayAsText, + defaultSortDirection: Direction.asc, + }); + seen.add(displayAsText); + } + return acc; + } + + return acc; + }, + { data: [], seen: new Set() } as { data: EuiDataGridColumn[]; seen: Set } + ).data; if (!isEqual(columns, newColumns)) { setColumns(newColumns); @@ -149,16 +237,24 @@ const ResultsTableComponent: React.FC = ({ actionId, return ( // @ts-expect-error update types - + + + {columns.length > 0 ? ( + + ) : ( +
+ {generateEmptyDataMessage(aggregations.totalResponded)} +
+ )}
); }; diff --git a/x-pack/plugins/osquery/public/results/translations.ts b/x-pack/plugins/osquery/public/results/translations.ts index 0f785f0c1f4d1..8e77e78ec76e2 100644 --- a/x-pack/plugins/osquery/public/results/translations.ts +++ b/x-pack/plugins/osquery/public/results/translations.ts @@ -7,6 +7,14 @@ import { i18n } from '@kbn/i18n'; +export const generateEmptyDataMessage = (agentsResponded: number): string => { + return i18n.translate('xpack.osquery.results.multipleAgentsResponded', { + defaultMessage: + '{agentsResponded, plural, one {# agent has} other {# agents have}} responded, but no osquery data has been reported.', + values: { agentsResponded }, + }); +}; + export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { defaultMessage: `An error has occurred on all results search`, }); diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts index 7140f80f510f4..afeb7dadb030c 100644 --- a/x-pack/plugins/osquery/public/results/use_all_results.ts +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -51,7 +52,10 @@ export const useAllResults = ({ skip = false, isLive = false, }: UseAllResults) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['allActionResults', { actionId, activePage, direction, limit, sortField }], @@ -82,6 +86,12 @@ export const useAllResults = ({ { refetchInterval: isLive ? 1000 : false, enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.results.fetchError', { + defaultMessage: 'Error while fetching results', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx index b2cfa05e0fc63..808431b68c4ba 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx @@ -23,6 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { CodeEditorField } from '../../queries/form/code_editor_field'; +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; import { Form, useForm, FormData, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; const FORM_ID = 'addQueryFlyoutForm'; @@ -50,12 +51,14 @@ const AddQueryFlyoutComponent: React.FC = ({ onSave, onClos label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), + validations: idFieldValidations.map((validator) => ({ validator })), }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { defaultMessage: 'Query', }), + validations: [{ validator: queryFieldValidation }], }, interval: { type: FIELD_TYPES.NUMBER, @@ -65,6 +68,7 @@ const AddQueryFlyoutComponent: React.FC = ({ onSave, onClos defaultMessage: 'Interval (s)', } ), + validations: [{ validator: intervalFieldValidation }], }, }, }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx index e686038430829..65379c9e23626 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx @@ -74,7 +74,7 @@ const ConfirmDeployAgentPolicyModalComponent: React.FC ); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx index 41846636eccd4..767eda01c06df 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx @@ -25,6 +25,7 @@ import { i18n } from '@kbn/i18n'; import { PackagePolicyInputStream } from '../../../../fleet/common'; import { CodeEditorField } from '../../queries/form/code_editor_field'; import { Form, useForm, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; const FORM_ID = 'editQueryFlyoutForm'; @@ -64,12 +65,14 @@ export const EditQueryFlyout: React.FC = ({ label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), + validations: idFieldValidations.map((validator) => ({ validator })), }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { defaultMessage: 'Query', }), + validations: [{ validator: queryFieldValidation }], }, interval: { type: FIELD_TYPES.NUMBER, @@ -79,6 +82,7 @@ export const EditQueryFlyout: React.FC = ({ defaultMessage: 'Interval (s)', } ), + validations: [{ validator: intervalFieldValidation }], }, }, }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts new file mode 100644 index 0000000000000..5d00d60ffd8b8 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts @@ -0,0 +1,12 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const INVALID_ID_ERROR = i18n.translate('xpack.osquery.agents.failSearchDescription', { + defaultMessage: `Failed to fetch agents`, +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts new file mode 100644 index 0000000000000..95e3000476a08 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts @@ -0,0 +1,48 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { ValidationFunc, fieldValidators } from '../../shared_imports'; +export { queryFieldValidation } from '../../common/validations'; + +const idPattern = /^[a-zA-Z0-9-_]+$/; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const idSchemaValidation: ValidationFunc = ({ value }) => { + const valueIsValid = idPattern.test(value); + if (!valueIsValid) { + return { + message: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIdError', { + defaultMessage: 'Characters must be alphanumeric, _, or -', + }), + }; + } +}; + +export const idFieldValidations = [ + fieldValidators.emptyField( + i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyIdError', { + defaultMessage: 'ID is required', + }) + ), + idSchemaValidation, +]; + +export const intervalFieldValidation: ValidationFunc< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + string, + number +> = fieldValidators.numberGreaterThanField({ + than: 0, + message: i18n.translate( + 'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIntervalField', + { + defaultMessage: 'A positive interval value is required', + } + ), +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index d501f56b789d7..90ec7e0c2717b 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -148,7 +148,7 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC Promise<{ results: string[]; total: number }> +) => { + const { results, total } = await generator(1, PER_PAGE); + const totalPages = Math.ceil(total / PER_PAGE); + let currPage = 2; + while (currPage <= totalPages) { + const { results: additionalResults } = await generator(currPage++, PER_PAGE); + results.push(...additionalResults); + } + return uniq(results); +}; + export const parseAgentSelection = async ( esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, context: OsqueryAppContext, agentSelection: AgentSelection ) => { - let selectedAgents: string[] = []; + const selectedAgents: Set = new Set(); + const addAgent = selectedAgents.add.bind(selectedAgents); const { allAgentsSelected, platformsSelected, policiesSelected, agents } = agentSelection; const agentService = context.service.getAgentService(); - if (agentService) { + const packagePolicyService = context.service.getPackagePolicyService(); + const kueryFragments = ['active:true']; + + if (agentService && packagePolicyService) { + const osqueryPolicies = await aggregateResults(async (page, perPage) => { + const { items, total } = await packagePolicyService.list(soClient, { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, + perPage, + page, + }); + return { results: items.map((it) => it.policy_id), total }; + }); + kueryFragments.push(`policy_id:(${uniq(osqueryPolicies).join(',')})`); if (allAgentsSelected) { - // TODO: actually fetch all the agents - const { agents: fetchedAgents } = await agentService.listAgents(esClient, { - perPage: 9000, - showInactive: true, + const kuery = kueryFragments.join(' and '); + const fetchedAgents = await aggregateResults(async (page, perPage) => { + const res = await agentService.listAgents(esClient, { + perPage, + page, + kuery, + showInactive: true, + }); + return { results: res.agents.map((agent) => agent.id), total: res.total }; }); - selectedAgents.push(...fetchedAgents.map((a) => a.id)); + fetchedAgents.forEach(addAgent); } else { if (platformsSelected.length > 0 || policiesSelected.length > 0) { - const kueryFragments = []; + const groupFragments = []; if (platformsSelected.length) { - kueryFragments.push( - ...platformsSelected.map((platform) => `local_metadata.os.platform:${platform}`) - ); + groupFragments.push(`local_metadata.os.platform:(${platformsSelected.join(',')})`); } if (policiesSelected.length) { - kueryFragments.push(...policiesSelected.map((policy) => `policy_id:${policy}`)); + groupFragments.push(`policy_id:(${policiesSelected.join(',')})`); } - const kuery = kueryFragments.join(' or '); - // TODO: actually fetch all the agents - const { agents: fetchedAgents } = await agentService.listAgents(esClient, { - kuery, - perPage: 9000, - showInactive: true, + kueryFragments.push(`(${groupFragments.join(' or ')})`); + const kuery = kueryFragments.join(' and '); + const fetchedAgents = await aggregateResults(async (page, perPage) => { + const res = await agentService.listAgents(esClient, { + perPage, + page, + kuery, + showInactive: true, + }); + return { results: res.agents.map((agent) => agent.id), total: res.total }; }); - selectedAgents.push(...fetchedAgents.map((a) => a.id)); + fetchedAgents.forEach(addAgent); } - selectedAgents.push(...agents); - selectedAgents = Array.from(new Set(selectedAgents)); } } - return selectedAgents; + + agents.forEach(addAgent); + + return Array.from(selectedAgents); }; diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 8e741c6a9e3ca..9dcd020f0734e 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -7,29 +7,42 @@ import uuid from 'uuid'; import moment from 'moment'; -import { schema } from '@kbn/config-schema'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { parseAgentSelection, AgentSelection } from '../../lib/parse_agent_groups'; +import { buildRouteValidation } from '../../utils/build_validation/route_validation'; +import { + createActionRequestBodySchema, + CreateActionRequestBodySchema, +} from '../../../common/schemas/routes/action/create_action_request_body_schema'; export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( { path: '/internal/osquery/action', validate: { - params: schema.object({}, { unknowns: 'allow' }), - body: schema.object({}, { unknowns: 'allow' }), - }, - options: { - tags: ['access:osquery', 'access:osquery_write'], + body: buildRouteValidation< + typeof createActionRequestBodySchema, + CreateActionRequestBodySchema + >(createActionRequestBodySchema), }, }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; const { agentSelection } = request.body as { agentSelection: AgentSelection }; - const selectedAgents = await parseAgentSelection(esClient, osqueryContext, agentSelection); + const selectedAgents = await parseAgentSelection( + esClient, + soClient, + osqueryContext, + agentSelection + ); + + if (!selectedAgents.length) { + throw new Error('No agents found for selection, aborting.'); + } const action = { action_id: uuid.v4(), @@ -39,10 +52,8 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon input_type: 'osquery', agents: selectedAgents, data: { - // @ts-expect-error update validation - id: request.body.query.id ?? uuid.v4(), - // @ts-expect-error update validation - query: request.body.query.query, + id: uuid.v4(), + query: request.body.query, }, }; const actionResponse = await esClient.index<{}, {}>({