diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts index 57c7a42f2a481..83625897bc96c 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts @@ -11,9 +11,20 @@ import { Inspect, Maybe, PageInfoPaginated } from '../../common'; import { RequestOptionsPaginated } from '../..'; import { Agent } from '../../../shared_imports'; +export interface AggregationDataPoint { + key: string; +} + +export interface AgentAggregation { + [key: string]: { + buckets: AggregationDataPoint[]; + }; +} + export interface AgentsStrategyResponse extends IEsSearchResponse { edges: Agent[]; totalCount: number; + aggregations?: AgentAggregation; pageInfo: PageInfoPaginated; inspect?: Maybe; } diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts index 567990aca0537..4bf06472125f5 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts @@ -36,6 +36,7 @@ export type FactoryQueryTypes = OsqueryQueries; export interface RequestBasicOptions extends IEsSearchRequest { filterQuery: ESQuery | string | undefined; + aggregations?: { [key: string]: string }; docValueFields?: DocValueFields[]; factoryQueryType?: FactoryQueryTypes; } diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index e41b74c672e9b..c3cafbf717c8e 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -6,25 +6,45 @@ */ import { find } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import React, { Fragment, useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { EuiBasicTable, EuiBasicTableColumn, EuiBasicTableProps, EuiTableSelectionType, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiOverlayMask, + EuiSelectable, + EuiSelectableOption, + EuiButton, + EuiButtonEmpty, EuiHealth, } from '@elastic/eui'; import { useAllAgents } from './use_all_agents'; +import { useAgentGroups, ALL_AGENTS_GROUP_KEY } from './use_agent_groups'; import { Direction } from '../../common/search_strategy'; import { Agent } from '../../common/shared_imports'; +export interface AgentsSelection { + agents: string[]; + allAgentsSelected: boolean; + platformsSelected: string[]; + policiesSelected: string[]; +} + interface AgentsTableProps { - selectedAgents: string[]; - onChange: (payload: string[]) => void; + agentSelection: AgentsSelection; + onChange: (payload: AgentsSelection) => void; } -const AgentsTableComponent: React.FC = ({ selectedAgents, onChange }) => { +type GroupOption = EuiSelectableOption<{ type: string }>; + +const AgentsTableComponent: React.FC = ({ agentSelection, onChange }) => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(5); const [sortField, setSortField] = useState('upgraded_at'); @@ -32,6 +52,10 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh const [selectedItems, setSelectedItems] = useState([]); const tableRef = useRef>(null); + const [isModalVisible, setIsModalVisible] = useState(false); + const closeModal = useCallback(() => setIsModalVisible(false), [setIsModalVisible]); + const showModal = useCallback(() => setIsModalVisible(true), [setIsModalVisible]); + const onTableChange: EuiBasicTableProps['onChange'] = useCallback( ({ page = {}, sort = {} }) => { const { index: newPageIndex, size: newPageSize } = page; @@ -46,24 +70,69 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh [] ); - const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback( - (newSelectedItems) => { - setSelectedItems(newSelectedItems); - - if (onChange) { - // @ts-expect-error update types - onChange(newSelectedItems.map((item) => item._id)); - } - }, - [onChange] - ); - const renderStatus = (online: string) => { const color = online ? 'success' : 'danger'; const label = online ? 'Online' : 'Offline'; return {label}; }; + const { loading: groupsLoading, groups } = useAgentGroups(); + const [selectedGroups, setSelectedGroups] = useState([]); + const [allAgentsSelected, setAllAgentsSelected] = useState(false); + const [groupOptions, setGroupOptions] = useState([]); + useEffect(() => { + const opts: GroupOption[] = [{ label: ALL_AGENTS_GROUP_KEY, type: 'all' }]; + if (!allAgentsSelected) { + const selectedSet = new Set(selectedGroups); + const generateOption = (type: string) => (name: string) => { + const option: GroupOption = { label: name, type }; + if (selectedSet.has(name)) { + option.checked = 'on'; + } + return option; + }; + const platformOptions = groups.platforms.map(generateOption('platform')); + opts.push(...platformOptions); + const policyOptions = groups.policies.map(generateOption('policy')); + opts.push(...policyOptions); + } else { + opts[0].checked = 'on'; + } + setGroupOptions(opts); + }, [groups.policies, groups.platforms, selectedGroups, allAgentsSelected]); + + const onGroupChange = useCallback( + (newOptions: GroupOption[]) => { + const selectedPlatforms: string[] = []; + const selectedPolicies: string[] = []; + newOptions.forEach((opt) => { + if (opt.checked === 'on') { + switch (opt.type) { + case 'platform': + selectedPlatforms.push(opt.label); + break; + case 'policy': + selectedPolicies.push(opt.label); + break; + default: + break; + } + } + }); + const selected = newOptions.filter((el) => el.checked === 'on').map((el) => el.label); + setSelectedGroups(selected); + const allSelected = selected.some((el) => el === ALL_AGENTS_GROUP_KEY); + setAllAgentsSelected(allSelected); + onChange({ + ...agentSelection, + allAgentsSelected: allSelected, + platformsSelected: selectedPlatforms, + policiesSelected: selectedPolicies, + }); + }, + [onChange, agentSelection] + ); + const { data = {} } = useAllAgents({ activePage: pageIndex, limit: pageSize, @@ -71,6 +140,17 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh sortField, }); + const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback( + (newSelectedItems) => { + setSelectedItems(newSelectedItems); + onChange({ + ...agentSelection, + agents: newSelectedItems.map((item: { _id: string }) => item._id), + }); + }, + [onChange, agentSelection] + ); + const columns: Array> = useMemo( () => [ { @@ -79,6 +159,12 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh sortable: true, truncateText: true, }, + { + field: 'local_metadata.os.family', + name: 'platform', + sortable: true, + truncateText: true, + }, { field: 'local_metadata.host.name', name: 'hostname', @@ -94,6 +180,12 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh ], [] ); + const searchProps = useMemo( + () => ({ + 'data-test-subj': 'selectableSearchHere', + }), + [] + ); const pagination = useMemo( () => ({ @@ -128,6 +220,7 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh ); useEffect(() => { + const selectedAgents = agentSelection?.agents; if ( selectedAgents?.length && // @ts-expect-error update types @@ -140,23 +233,83 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh ); } // @ts-expect-error update types - }, [selectedAgents, data.agents, selectedItems.length]); + }, [agentSelection, data.agents, selectedItems.length]); + + let modal; + + if (isModalVisible) { + modal = ( + + + + Select Agents + + + + {groupsLoading ? null : ( + + {(list, search) => ( + + {search} + {list} + + )} + + )} + {allAgentsSelected || selectedGroups?.length ? null : ( + + ref={tableRef} + // @ts-expect-error update types + // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop + items={data.agents ?? []} + itemId="_id" + columns={columns} + pagination={pagination} + sorting={sorting} + isSelectable={true} + selection={selection} + onChange={onTableChange} + rowHeader="firstName" + /> + )} + + + + Cancel + + + Save + + + + + ); + } + + let buttonText; + if (allAgentsSelected) { + buttonText = 'All Agents Selected'; + } else if (selectedGroups.length) { + const numGroups = selectedGroups.length; + buttonText = `${numGroups} Agent Group${numGroups > 1 ? 's' : ''} Selected`; + } else if (agentSelection?.agents?.length) { + const numAgents = agentSelection.agents.length; + buttonText = `${numAgents} Agent${numAgents > 1 ? 's' : ''} Selected`; + } else { + buttonText = 'Select Agents'; + } return ( - - ref={tableRef} - // @ts-expect-error update types - // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop - items={data.agents ?? []} - itemId="_id" - columns={columns} - pagination={pagination} - sorting={sorting} - isSelectable={true} - selection={selection} - onChange={onTableChange} - rowHeader="firstName" - /> +
+ {buttonText} + {modal} +
); }; diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts index 0d9d9a8a12b8f..2499bd1652bff 100644 --- a/x-pack/plugins/osquery/public/agents/translations.ts +++ b/x-pack/plugins/osquery/public/agents/translations.ts @@ -14,3 +14,11 @@ export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearch export const FAIL_ALL_AGENTS = i18n.translate('xpack.osquery.agents.failSearchDescription', { defaultMessage: `Failed to fetch agents`, }); + +export const ERROR_AGENT_GROUPS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { + defaultMessage: `An error has occurred while fetching agent groups.`, +}); + +export const FAIL_AGENT_GROUPS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { + defaultMessage: `An error has occurred while fetching agent groups.`, +}); diff --git a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts new file mode 100644 index 0000000000000..adfbe6cc28379 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts @@ -0,0 +1,92 @@ +/* + * 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 { useEffect, useState, useRef } from 'react'; +import { useKibana } from '../common/lib/kibana'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; + +import { + OsqueryQueries, + AgentsRequestOptions, + AgentsStrategyResponse, +} from '../../common/search_strategy'; + +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { generateTablePaginationOptions } from './helpers'; +import * as i18n from './translations'; + +export const ALL_AGENTS_GROUP_KEY = 'All agents'; +export const useAgentGroups = () => { + const { data, notifications } = useKibana().services; + const [loading, setLoading] = useState(true); + const [platforms, setPlatforms] = useState([]); + const [policies, setPolicies] = useState([]); + + const abortCtrl = useRef(new AbortController()); + useEffect(() => { + let didCancel = false; + const searchSubscription$ = data.search + .search( + { + filterQuery: undefined, + factoryQueryType: OsqueryQueries.agents, + aggregations: { + platforms: 'local_metadata.os.platform', + policies: 'policy_id', + }, + pagination: generateTablePaginationOptions(0, 9000), + sort: { + direction: 'asc', + field: 'local_metadata.os.platform', + }, + } as AgentsRequestOptions, + { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + } + ) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + if (response.aggregations) { + const aggs = response.aggregations; + setPlatforms(aggs.platforms.buckets.map((o) => o.key)); + setPolicies(aggs.policies.buckets.map((o) => o.key)); + } + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_AGENT_GROUPS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_AGENT_GROUPS, text: msg.message }); + } + }, + }); + const abort = abortCtrl.current; + return () => { + didCancel = true; + abort.abort(); + }; + }, [setPolicies, setPlatforms, data.search, notifications.toasts]); + return { + loading, + groups: { + [ALL_AGENTS_GROUP_KEY]: [], + platforms, + policies, + }, + }; +}; diff --git a/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx index 7a93b5d2491db..4bc9262af7613 100644 --- a/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx +++ b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx @@ -7,10 +7,10 @@ import React, { useCallback } from 'react'; import { FieldHook } from '../../shared_imports'; -import { AgentsTable } from '../../agents/agents_table'; +import { AgentsTable, AgentsSelection } from '../../agents/agents_table'; interface AgentsTableFieldProps { - field: FieldHook; + field: FieldHook; } const AgentsTableFieldComponent: React.FC = ({ field }) => { @@ -24,7 +24,7 @@ const AgentsTableFieldComponent: React.FC = ({ field }) = [value, setValue] ); - return ; + return ; }; export const AgentsTableField = React.memo(AgentsTableFieldComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/index.tsx b/x-pack/plugins/osquery/public/live_query/form/index.tsx index 38677f4a323d1..b584d60bf63d0 100644 --- a/x-pack/plugins/osquery/public/live_query/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_query/form/index.tsx @@ -32,7 +32,7 @@ const LiveQueryFormComponent: React.FC = ({ onSubmit }) => { return (
- + {'Send query'} 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 4ec5bc2a192cc..5f0deb67c6a39 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 @@ -11,6 +11,13 @@ import moment from 'moment'; import { IRouter } from '../../../../../../src/core/server'; +export interface AgentsSelection { + agents: string[]; + allAgentsSelected: boolean; + platformsSelected: string[]; + policiesSelected: string[]; +} + export const createActionRoute = (router: IRouter) => { router.post( { @@ -22,14 +29,64 @@ export const createActionRoute = (router: IRouter) => { }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; + const selectedAgents: string[] = []; + const { + agentSelection: { allAgentsSelected, platformsSelected, policiesSelected, agents }, + } = request.body as { agentSelection: AgentsSelection }; + const extractIds = ({ body }) => + body.hits.hits.map((o) => o._source.local_metadata.elastic.agent.id); + if (allAgentsSelected) { + // make a query for all agent ids + const ids = extractIds( + await esClient.search<{}, {}>({ + index: '.fleet-agents', + body: { + _source: 'local_metadata.elastic.agent.id', + size: 9000, + query: { + match_all: {}, + }, + }, + }) + ); + selectedAgents.push(...ids); + } else if (platformsSelected.length > 0 || policiesSelected.length > 0) { + const filters: Array<{ + term: { [key: string]: string }; + }> = platformsSelected.map((platform) => ({ + term: { 'local_metadata.os.platform': platform }, + })); + filters.push(...policiesSelected.map((policyId) => ({ term: { policyId } }))); + const query = { + index: '.fleet-agents', + body: { + _source: 'local_metadata.elastic.agent.id', + size: 9000, + query: { + bool: { + filter: [ + { + bool: { + should: filters, + }, + }, + ], + }, + }, + }, + }; + const ids = extractIds(await esClient.search<{}, {}>(query)); + selectedAgents.push(...ids); + } else { + selectedAgents.push(...agents); + } const action = { action_id: uuid.v4(), '@timestamp': moment().toISOString(), expiration: moment().add(2, 'days').toISOString(), type: 'INPUT_ACTION', input_type: 'osquery', - // @ts-expect-error update validation - agents: request.body.agents, + agents: selectedAgents, data: { id: uuid.v4(), // @ts-expect-error update validation diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts index 1f7fbccb68682..395b3d6504fa8 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts @@ -38,6 +38,7 @@ export const allAgents: OsqueryFactory = { ...response, inspect, edges: response.rawResponse.hits.hits.map((hit) => ({ _id: hit._id, ...hit._source })), + aggregations: response.rawResponse.aggregations, totalCount: response.rawResponse.hits.total, pageInfo: { activePage: activePage ?? 0, diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts index 4ad6022017966..1b1e296abece9 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts @@ -14,6 +14,7 @@ export const buildAgentsQuery = ({ filterQuery, pagination: { cursorStart, querySize }, sort, + aggregations, }: AgentsRequestOptions): ISearchRequestParams => { // const filter = [...createQueryFilterClauses(filterQuery)]; @@ -29,6 +30,7 @@ export const buildAgentsQuery = ({ }, }, }, + aggs: {}, track_total_hits: true, sort: [ { @@ -42,5 +44,16 @@ export const buildAgentsQuery = ({ }, }; + if (aggregations) { + Object.keys(aggregations).reduce((acc, aggKey) => { + acc[aggKey] = { + terms: { + field: aggregations[aggKey], + }, + }; + return acc; + }, dslQuery.body.aggs as { [key: string]: { terms: { field: string } } }); + } + return dslQuery; };