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..d07af8c878c51 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; import { ESQuery } from '../../typed_json'; import { @@ -36,6 +37,7 @@ export type FactoryQueryTypes = OsqueryQueries; export interface RequestBasicOptions extends IEsSearchRequest { filterQuery: ESQuery | string | undefined; + aggregations?: Record; docValueFields?: DocValueFields[]; factoryQueryType?: FactoryQueryTypes; } diff --git a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx index 1880cec0ae8e2..660b837da6d93 100644 --- a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx +++ b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx @@ -22,6 +22,7 @@ import { useActionResults } from './use_action_results'; import { useAllResults } from '../results/use_all_results'; import { Direction, ResultEdges } from '../../common/search_strategy'; import { useRouterNavigate } from '../common/lib/kibana'; +import { useOsqueryPolicies } from '../agents/use_osquery_policies'; const DataContext = createContext([]); @@ -91,12 +92,8 @@ const ActionResultsTableComponent: React.FC = ({ action setVisibleColumns, ]); - const { data: agentsData } = useAllAgents({ - activePage: 0, - limit: 1000, - direction: Direction.desc, - sortField: 'updated_at', - }); + const osqueryPolicyData = useOsqueryPolicies(); + const { agents } = useAllAgents(osqueryPolicyData); const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( () => ({ rowIndex, columnId }) => { @@ -134,8 +131,7 @@ const ActionResultsTableComponent: React.FC = ({ action if (columnId === 'agent_status') { const agentIdValue = value.fields?.agent_id[0]; - // @ts-expect-error update types - const agent = find(['_id', agentIdValue], agentsData?.agents); + const agent = find(['_id', agentIdValue], agents); const online = agent?.active; const color = online ? 'success' : 'danger'; const label = online ? 'Online' : 'Offline'; @@ -144,8 +140,7 @@ const ActionResultsTableComponent: React.FC = ({ action if (columnId === 'agent') { const agentIdValue = value.fields?.agent_id[0]; - // @ts-expect-error update types - const agent = find(['_id', agentIdValue], agentsData?.agents); + const agent = find(['_id', agentIdValue], agents); const agentName = agent?.local_metadata.host.name; // eslint-disable-next-line react-hooks/rules-of-hooks @@ -162,8 +157,7 @@ const ActionResultsTableComponent: React.FC = ({ action return '-'; }, - // @ts-expect-error update types - [actionId, agentsData?.agents, pagination.pageIndex, pagination.pageSize] + [actionId, agents, pagination.pageIndex, pagination.pageSize] ); const tableSorting: EuiDataGridSorting = useMemo( diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index e41b74c672e9b..5f1b6a0d2f0b1 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -5,158 +5,222 @@ * 2.0. */ -import { find } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { - EuiBasicTable, - EuiBasicTableColumn, - EuiBasicTableProps, - EuiTableSelectionType, - EuiHealth, -} from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; import { useAllAgents } from './use_all_agents'; -import { Direction } from '../../common/search_strategy'; +import { useAgentGroups } from './use_agent_groups'; +import { useOsqueryPolicies } from './use_osquery_policies'; import { Agent } from '../../common/shared_imports'; +import { + getNumAgentsInGrouping, + generateAgentCheck, + getNumOverlapped, + generateColorPicker, +} from './helpers'; -interface AgentsTableProps { - selectedAgents: string[]; - onChange: (payload: string[]) => void; +import { + ALL_AGENTS_LABEL, + AGENT_PLATFORMS_LABEL, + AGENT_POLICY_LABEL, + SELECT_AGENT_LABEL, + AGENT_SELECTION_LABEL, + generateSelectedAgentsMessage, +} from './translations'; + +import { AGENT_GROUP_KEY, SelectedGroups, AgentOptionValue, GroupOptionValue } from './types'; + +export interface AgentsSelection { + agents: string[]; + allAgentsSelected: boolean; + platformsSelected: string[]; + policiesSelected: string[]; } -const AgentsTableComponent: React.FC = ({ selectedAgents, onChange }) => { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('upgraded_at'); - const [sortDirection, setSortDirection] = useState(Direction.asc); - const [selectedItems, setSelectedItems] = useState([]); - const tableRef = useRef>(null); - - const onTableChange: EuiBasicTableProps['onChange'] = useCallback( - ({ page = {}, sort = {} }) => { - const { index: newPageIndex, size: newPageSize } = page; - - const { field: newSortField, direction: newSortDirection } = sort; +interface AgentsTableProps { + agentSelection: AgentsSelection; + onChange: (payload: AgentsSelection) => void; +} - setPageIndex(newPageIndex); - setPageSize(newPageSize); - setSortField(newSortField); - setSortDirection(newSortDirection); - }, - [] - ); +type GroupOption = EuiComboBoxOptionOption; - const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback( - (newSelectedItems) => { - setSelectedItems(newSelectedItems); +const getColor = generateColorPicker(); - if (onChange) { - // @ts-expect-error update types - onChange(newSelectedItems.map((item) => item._id)); - } - }, - [onChange] +const AgentsTableComponent: React.FC = ({ onChange }) => { + const osqueryPolicyData = useOsqueryPolicies(); + const { loading: groupsLoading, totalCount: totalNumAgents, groups } = useAgentGroups( + osqueryPolicyData ); + const { agents } = useAllAgents(osqueryPolicyData); + const [loading, setLoading] = useState(true); + const [options, setOptions] = useState([]); + const [selectedOptions, setSelectedOptions] = useState([]); + const [numAgentsSelected, setNumAgentsSelected] = useState(0); - const renderStatus = (online: string) => { - const color = online ? 'success' : 'danger'; - const label = online ? 'Online' : 'Offline'; - return {label}; - }; - - const { data = {} } = useAllAgents({ - activePage: pageIndex, - limit: pageSize, - direction: sortDirection, - sortField, - }); - - const columns: Array> = useMemo( - () => [ - { - field: 'local_metadata.elastic.agent.id', - name: 'id', - sortable: true, - truncateText: true, - }, - { - field: 'local_metadata.host.name', - name: 'hostname', - truncateText: true, - }, - + useEffect(() => { + const allAgentsLabel = ALL_AGENTS_LABEL; + const opts: GroupOption[] = [ { - field: 'active', - name: 'Online', - dataType: 'boolean', - render: (active: string) => renderStatus(active), - }, - ], - [] - ); - - const pagination = useMemo( - () => ({ - pageIndex, - pageSize, - // @ts-expect-error update types - totalItemCount: data.totalCount ?? 0, - pageSizeOptions: [3, 5, 8], - }), - // @ts-expect-error update types - [pageIndex, pageSize, data.totalCount] - ); - - const sorting = useMemo( - () => ({ - sort: { - field: sortField, - direction: sortDirection, + label: allAgentsLabel, + options: [ + { + label: allAgentsLabel, + value: { groupType: AGENT_GROUP_KEY.All, size: totalNumAgents }, + color: getColor(AGENT_GROUP_KEY.All), + }, + ], }, - }), - [sortDirection, sortField] - ); + ]; + + if (groups.platforms.length > 0) { + const groupType = AGENT_GROUP_KEY.Platform; + opts.push({ + label: AGENT_PLATFORMS_LABEL, + options: groups.platforms.map(({ name, size }) => ({ + label: name, + color: getColor(groupType), + value: { groupType, size }, + })), + }); + } - const selection: EuiBasicTableProps['selection'] = useMemo( - () => ({ - selectable: (agent: Agent) => agent.active, - selectableMessage: (selectable: boolean) => (!selectable ? 'User is currently offline' : ''), - onSelectionChange, - initialSelected: selectedItems, - }), - [onSelectionChange, selectedItems] - ); + if (groups.policies.length > 0) { + const groupType = AGENT_GROUP_KEY.Policy; + opts.push({ + label: AGENT_POLICY_LABEL, + options: groups.policies.map(({ name, size }) => ({ + label: name, + color: getColor(groupType), + value: { groupType, size }, + })), + }); + } - useEffect(() => { - if ( - selectedAgents?.length && - // @ts-expect-error update types - data.agents?.length && - selectedItems.length !== selectedAgents.length - ) { - tableRef?.current?.setSelection( - // @ts-expect-error update types - selectedAgents.map((agentId) => find({ _id: agentId }, data.agents)) - ); + if (agents && agents.length > 0) { + const groupType = AGENT_GROUP_KEY.Agent; + opts.push({ + label: AGENT_SELECTION_LABEL, + options: (agents as Agent[]).map((agent: Agent) => ({ + label: agent.local_metadata.host.hostname, + color: getColor(groupType), + value: { + groupType, + groups: { policy: agent.policy_id ?? '', platform: agent.local_metadata.os.platform }, + id: agent.local_metadata.elastic.agent.id, + online: agent.active, + }, + })), + }); } - // @ts-expect-error update types - }, [selectedAgents, data.agents, selectedItems.length]); + setLoading(false); + setOptions(opts); + }, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents]); + + const onSelection = useCallback( + (selection: GroupOption[]) => { + // TODO?: optimize this by making it incremental + const newAgentSelection: AgentsSelection = { + agents: [], + allAgentsSelected: false, + platformsSelected: [], + policiesSelected: [], + }; + // parse through the selections to be able to determine how many are actually selected + const selectedAgents = []; + const selectedGroups: SelectedGroups = { + policy: {}, + platform: {}, + }; + + // TODO: clean this up, make it less awkward + for (const opt of selection) { + const groupType = opt.value?.groupType; + let value; + switch (groupType) { + case AGENT_GROUP_KEY.All: + newAgentSelection.allAgentsSelected = true; + break; + case AGENT_GROUP_KEY.Platform: + value = opt.value as GroupOptionValue; + if (!newAgentSelection.allAgentsSelected) { + // we don't need to calculate diffs when all agents are selected + selectedGroups.platform[opt.label] = value.size; + } + newAgentSelection.platformsSelected.push(opt.label); + break; + case AGENT_GROUP_KEY.Policy: + value = opt.value as GroupOptionValue; + if (!newAgentSelection.allAgentsSelected) { + // we don't need to calculate diffs when all agents are selected + selectedGroups.policy[opt.label] = value.size ?? 0; + } + newAgentSelection.policiesSelected.push(opt.label); + break; + case AGENT_GROUP_KEY.Agent: + value = opt.value as AgentOptionValue; + if (!newAgentSelection.allAgentsSelected) { + // we don't need to count how many agents are selected if they are all selected + selectedAgents.push(opt.value); + } + // TODO: fix this casting by updating the opt type to be a union + newAgentSelection.agents.push(value.id as string); + break; + default: + // this should never happen! + // eslint-disable-next-line no-console + console.error(`unknown group type ${groupType}`); + } + } + if (newAgentSelection.allAgentsSelected) { + setNumAgentsSelected(totalNumAgents); + } else { + const checkAgent = generateAgentCheck(selectedGroups); + setNumAgentsSelected( + // filter out all the agents counted by selected policies and platforms + selectedAgents.filter((a) => checkAgent(a as AgentOptionValue)).length + + // add the number of agents added via policy and platform groups + getNumAgentsInGrouping(selectedGroups) - + // subtract the number of agents double counted by policy/platform selections + getNumOverlapped(selectedGroups, groups.overlap) + ); + } + onChange(newAgentSelection); + setSelectedOptions(selection); + }, + [groups, onChange, totalNumAgents] + ); + const renderOption = useCallback((option, searchValue, contentClassName) => { + const { label, value } = option; + return value?.groupType === AGENT_GROUP_KEY.Agent ? ( + + + {label} + + + ) : ( + + {label} +   + ({value?.size}) + + ); + }, []); 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" - /> +
+

{SELECT_AGENT_LABEL}

+ {numAgentsSelected > 0 ? {generateSelectedAgentsMessage(numAgentsSelected)} : ''} +   + +
); }; diff --git a/x-pack/plugins/osquery/public/agents/helpers.test.ts b/x-pack/plugins/osquery/public/agents/helpers.test.ts new file mode 100644 index 0000000000000..3efd1b877d1a0 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/helpers.test.ts @@ -0,0 +1,222 @@ +/* + * 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 { getNumOverlapped, getNumAgentsInGrouping, processAggregations } from './helpers'; +import { Overlap, SelectedGroups } from './types'; + +describe('processAggregations', () => { + it('should handle empty inputs properly', () => { + const input = {}; + const { platforms, policies, overlap } = processAggregations(input); + expect(platforms).toEqual([]); + expect(policies).toEqual([]); + expect(overlap).toEqual({}); + }); + it('should handle platforms with no policies', () => { + const input = { + platforms: { + buckets: [ + { + key: 'darwin', + doc_count: 200, + policies: { + buckets: [], + }, + }, + ], + }, + }; + const { platforms, policies, overlap } = processAggregations(input); + expect(platforms).toEqual([ + { + name: 'darwin', + size: 200, + }, + ]); + expect(policies).toEqual([]); + expect(overlap).toEqual({}); + }); + it('should handle policies with no platforms', () => { + const input = { + policies: { + buckets: [ + { + key: '8cd01a60-8a74-11eb-86cb-c58693443a4f', + doc_count: 100, + }, + { + key: '8cd06880-8a74-11eb-86cb-c58693443a4f', + doc_count: 100, + }, + ], + }, + }; + const { platforms, policies, overlap } = processAggregations(input); + expect(platforms).toEqual([]); + expect(policies).toEqual([ + { + name: '8cd01a60-8a74-11eb-86cb-c58693443a4f', + size: 100, + }, + { + name: '8cd06880-8a74-11eb-86cb-c58693443a4f', + size: 100, + }, + ]); + expect(overlap).toEqual({}); + }); + it('should parse aggregation responses down into metadata objects', () => { + const input = { + policies: { + buckets: [ + { + key: '8cd01a60-8a74-11eb-86cb-c58693443a4f', + doc_count: 100, + }, + { + key: '8cd06880-8a74-11eb-86cb-c58693443a4f', + doc_count: 100, + }, + ], + }, + platforms: { + buckets: [ + { + key: 'darwin', + doc_count: 200, + policies: { + buckets: [ + { + key: '8cd01a60-8a74-11eb-86cb-c58693443a4f', + doc_count: 100, + }, + { + key: '8cd06880-8a74-11eb-86cb-c58693443a4f', + doc_count: 100, + }, + ], + }, + }, + ], + }, + }; + const { platforms, policies, overlap } = processAggregations(input); + expect(platforms).toEqual([ + { + name: 'darwin', + size: 200, + }, + ]); + expect(policies).toEqual([ + { + name: '8cd01a60-8a74-11eb-86cb-c58693443a4f', + size: 100, + }, + { + name: '8cd06880-8a74-11eb-86cb-c58693443a4f', + size: 100, + }, + ]); + expect(overlap).toEqual({ + darwin: { + '8cd06880-8a74-11eb-86cb-c58693443a4f': 100, + '8cd01a60-8a74-11eb-86cb-c58693443a4f': 100, + }, + }); + }); +}); + +describe('getNumAgentsInGrouping', () => { + it('should handle empty objects', () => { + const selectedGroups: SelectedGroups = {}; + expect(getNumAgentsInGrouping(selectedGroups)).toEqual(0); + }); + + it('should add up the quantities for the selected groups', () => { + const selectedGroups: SelectedGroups = { + platform: { + linux: 35, + }, + policy: { + policy_id1: 40, + }, + }; + expect(getNumAgentsInGrouping(selectedGroups)).toEqual(75); + }); +}); + +describe('getNumOverlapped', () => { + const overlap: Overlap = { + darwin: { + policy_id1: 15, + policy_id2: 35, + }, + linux: { + policy_id1: 25, + policy_id2: 10, + }, + }; + + it('should add up the quantities associated with a platform/policy selection', () => { + const selectedGroups: SelectedGroups = { + platform: { + linux: 35, + }, + policy: { + policy_id1: 40, + }, + }; + + const computedOverlap = getNumOverlapped(selectedGroups, overlap); + expect(computedOverlap).toBe(25); + }); + + it('should gracefully handle empty objects', () => { + const selectedGroups: SelectedGroups = {}; + + const computedOverlap = getNumOverlapped(selectedGroups, overlap); + expect(computedOverlap).toBe(0); + }); + + it('should gracefully handle missing platforms', () => { + const selectedGroups: SelectedGroups = { + policy: { + policy_id1: 40, + policy_id3: 40, + }, + }; + const computedOverlap = getNumOverlapped(selectedGroups, overlap); + expect(computedOverlap).toBe(0); + }); + + it('should gracefully handle missing policies', () => { + const selectedGroups: SelectedGroups = { + platform: { + linux: 35, + windows: 40, + }, + }; + const computedOverlap = getNumOverlapped(selectedGroups, overlap); + expect(computedOverlap).toBe(0); + }); + + it('should gracefully handle missing group selections', () => { + const selectedGroups: SelectedGroups = { + platform: { + linux: 35, + windows: 40, + }, + policy: { + policy_id1: 40, + policy_id3: 40, + }, + }; + + const computedOverlap = getNumOverlapped(selectedGroups, overlap); + expect(computedOverlap).toBe(25); + }); +}); diff --git a/x-pack/plugins/osquery/public/agents/helpers.ts b/x-pack/plugins/osquery/public/agents/helpers.ts index fef17aadb62be..830fca5f57caa 100644 --- a/x-pack/plugins/osquery/public/agents/helpers.ts +++ b/x-pack/plugins/osquery/public/agents/helpers.ts @@ -5,15 +5,97 @@ * 2.0. */ +import { Aggregate, TermsAggregate } from '@elastic/elasticsearch/api/types'; +import { euiPaletteColorBlindBehindText } from '@elastic/eui'; import { PaginationInputPaginated, FactoryQueryTypes, StrategyResponseType, Inspect, } from '../../common/search_strategy'; +import { + AGENT_GROUP_KEY, + SelectedGroups, + Overlap, + Group, + AgentOptionValue, + AggregationDataPoint, +} from './types'; export type InspectResponse = Inspect & { response: string[] }; +export const getNumOverlapped = ( + { policy = {}, platform = {} }: SelectedGroups, + overlap: Overlap +) => { + let sum = 0; + Object.keys(platform).forEach((plat) => { + const policies = overlap[plat] ?? {}; + Object.keys(policy).forEach((pol) => { + sum += policies[pol] ?? 0; + }); + }); + return sum; +}; +export const processAggregations = (aggs: Record) => { + const platforms: Group[] = []; + const overlap: Overlap = {}; + const platformTerms = aggs.platforms as TermsAggregate; + const policyTerms = aggs.policies as TermsAggregate; + + const policies = policyTerms?.buckets.map((o) => ({ name: o.key, size: o.doc_count })) ?? []; + + if (platformTerms?.buckets) { + for (const { key, doc_count: size, policies: platformPolicies } of platformTerms.buckets) { + platforms.push({ name: key, size }); + if (platformPolicies?.buckets && policies.length > 0) { + overlap[key] = platformPolicies.buckets.reduce((acc: { [key: string]: number }, pol) => { + acc[pol.key] = pol.doc_count; + return acc; + }, {} as { [key: string]: number }); + } + } + } + + return { + platforms, + overlap, + policies, + }; +}; +export const generateColorPicker = () => { + const visColorsBehindText = euiPaletteColorBlindBehindText(); + const typeColors = new Map(); + return (type: AGENT_GROUP_KEY) => { + if (!typeColors.has(type)) { + typeColors.set(type, visColorsBehindText[typeColors.size]); + } + return typeColors.get(type); + }; +}; + +export const getNumAgentsInGrouping = (selectedGroups: SelectedGroups) => { + let sum = 0; + Object.keys(selectedGroups).forEach((g) => { + const group = selectedGroups[g]; + sum += Object.keys(group).reduce((acc, k) => acc + group[k], 0); + }); + return sum; +}; + +export const generateAgentCheck = (selectedGroups: SelectedGroups) => { + return ({ groups }: AgentOptionValue) => { + return Object.keys(groups) + .map((group) => { + const selectedGroup = selectedGroups[group]; + const agentGroup = groups[group]; + // check if the agent platform/policy is selected + return selectedGroup[agentGroup]; + }) + .every((a) => !a); + }; +}; + export const generateTablePaginationOptions = ( activePage: number, limit: number, diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts index 0d9d9a8a12b8f..af99a73d63de2 100644 --- a/x-pack/plugins/osquery/public/agents/translations.ts +++ b/x-pack/plugins/osquery/public/agents/translations.ts @@ -7,6 +7,42 @@ import { i18n } from '@kbn/i18n'; +export const generateSelectedAgentsMessage = (numAgents: number): string => { + if (numAgents === 0) { + return ''; + } else if (numAgents === 1) { + return i18n.translate('xpack.osquery.agents.oneSelectedAgentText', { + defaultMessage: '{numAgents} agent selected.', + values: { numAgents }, + }); + } else { + return i18n.translate('xpack.osquery.agents.mulitpleSelectedAgentsText', { + defaultMessage: '{numAgents} agents selected.', + values: { numAgents }, + }); + } +}; + +export const ALL_AGENTS_LABEL = i18n.translate('xpack.osquery.agents.allAgentsLabel', { + defaultMessage: `All agents`, +}); + +export const AGENT_PLATFORMS_LABEL = i18n.translate('xpack.osquery.agents.platformLabel', { + defaultMessage: `Platform`, +}); + +export const AGENT_POLICY_LABEL = i18n.translate('xpack.osquery.agents.policyLabel', { + defaultMessage: `Policy`, +}); + +export const AGENT_SELECTION_LABEL = i18n.translate('xpack.osquery.agents.selectionLabel', { + defaultMessage: `Agents`, +}); + +export const SELECT_AGENT_LABEL = i18n.translate('xpack.osquery.agents.selectAgentLabel', { + defaultMessage: `Select Agents`, +}); + export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { defaultMessage: `An error has occurred on all agents search`, }); diff --git a/x-pack/plugins/osquery/public/agents/types.ts b/x-pack/plugins/osquery/public/agents/types.ts new file mode 100644 index 0000000000000..2fa8ddaf345cd --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/types.ts @@ -0,0 +1,50 @@ +/* + * 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 { TermsAggregate } from '@elastic/elasticsearch/api/types'; + +interface BaseDataPoint { + key: string; + doc_count: number; +} + +export type AggregationDataPoint = BaseDataPoint & { + [key: string]: TermsAggregate; +}; + +export interface Group { + name: string; + size: number; +} +export interface Overlap { + [platform: string]: { [policy: string]: number }; +} + +export interface SelectedGroups { + [groupType: string]: { [groupName: string]: number }; +} + +interface BaseGroupOption { + groupType: AGENT_GROUP_KEY; +} + +export type AgentOptionValue = BaseGroupOption & { + groups: { [groupType: string]: string }; + online: boolean; + id: string; +}; + +export type GroupOptionValue = BaseGroupOption & { + size: number; +}; + +export enum AGENT_GROUP_KEY { + All, + Platform, + Policy, + Agent, +} 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..0eaca65d02d4b --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts @@ -0,0 +1,101 @@ +/* + * 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 { useState } from 'react'; +import { useQuery } from 'react-query'; +import { useKibana } from '../common/lib/kibana'; + +import { + OsqueryQueries, + AgentsRequestOptions, + AgentsStrategyResponse, +} from '../../common/search_strategy'; + +import { generateTablePaginationOptions, processAggregations } from './helpers'; +import { Overlap, Group } from './types'; + +interface UseAgentGroups { + osqueryPolicies: string[]; + osqueryPoliciesLoading: boolean; +} + +export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => { + const { data } = useKibana().services; + + const [platforms, setPlatforms] = useState([]); + const [policies, setPolicies] = useState([]); + const [loading, setLoading] = useState(true); + const [overlap, setOverlap] = useState(() => ({})); + const [totalCount, setTotalCount] = useState(0); + useQuery( + ['agentGroups'], + async () => { + const responseData = await data.search + .search( + { + filterQuery: { terms: { policy_id: osqueryPolicies } }, + factoryQueryType: OsqueryQueries.agents, + aggregations: { + platforms: { + terms: { + field: 'local_metadata.os.platform', + }, + aggs: { + policies: { + terms: { + field: 'policy_id', + }, + }, + }, + }, + policies: { + terms: { + field: 'policy_id', + }, + }, + }, + pagination: generateTablePaginationOptions(0, 9000), + sort: { + direction: 'asc', + field: 'local_metadata.os.platform', + }, + } as AgentsRequestOptions, + { + strategy: 'osquerySearchStrategy', + } + ) + .toPromise(); + + if (responseData.rawResponse.aggregations) { + const { + platforms: newPlatforms, + overlap: newOverlap, + policies: newPolicies, + } = processAggregations(responseData.rawResponse.aggregations); + + setPlatforms(newPlatforms); + setOverlap(newOverlap); + setPolicies(newPolicies); + } + + setLoading(false); + setTotalCount(responseData.totalCount); + }, + { + enabled: !osqueryPoliciesLoading, + } + ); + + return { + loading, + totalCount, + groups: { + platforms, + policies, + overlap, + }, + }; +}; 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 663c6936fe55b..607f9ae007692 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -5,94 +5,32 @@ * 2.0. */ -import deepEqual from 'fast-deep-equal'; -import { useEffect, useState } from 'react'; import { useQuery } from 'react-query'; -import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; -import { - PageInfoPaginated, - OsqueryQueries, - AgentsRequestOptions, - AgentsStrategyResponse, - Direction, -} from '../../common/search_strategy'; -import { ESTermQuery } from '../../common/typed_json'; -import { Agent } from '../../common/shared_imports'; - -import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; - -export interface AgentsArgs { - agents: Agent[]; - id: string; - inspect: InspectResponse; - isInspected: boolean; - pageInfo: PageInfoPaginated; - totalCount: number; -} interface UseAllAgents { - activePage: number; - direction: Direction; - limit: number; - sortField: string; - filterQuery?: ESTermQuery | string; - skip?: boolean; + osqueryPolicies: string[]; + osqueryPoliciesLoading: boolean; } -export const useAllAgents = ({ - activePage, - direction, - limit, - sortField, - filterQuery, - skip = false, -}: UseAllAgents) => { - const { data } = useKibana().services; - - const [agentsRequest, setHostRequest] = useState(null); - - const response = useQuery( - ['agents', { activePage, direction, limit, sortField }], +export const useAllAgents = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAllAgents) => { + // TODO: properly fetch these in an async manner + const { http } = useKibana().services; + const { isLoading: agentsLoading, data: agentData } = useQuery( + ['agents', osqueryPolicies], async () => { - if (!agentsRequest) return Promise.resolve(); - - const responseData = await data.search - .search(agentsRequest, { - strategy: 'osquerySearchStrategy', - }) - .toPromise(); - - return { - ...responseData, - agents: responseData.edges, - inspect: getInspectResponse(responseData), - }; + return await http.get('/api/fleet/agents', { + query: { + kuery: osqueryPolicies.map((p) => `policy_id:${p}`).join(' or '), + perPage: 9000, + }, + }); }, { - enabled: !skip && !!agentsRequest, + enabled: !osqueryPoliciesLoading, } ); - useEffect(() => { - setHostRequest((prevRequest) => { - const myRequest = { - ...(prevRequest ?? {}), - factoryQueryType: OsqueryQueries.agents, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - sort: { - direction, - field: sortField, - }, - }; - if (!deepEqual(prevRequest, myRequest)) { - return myRequest; - } - return prevRequest; - }); - }, [activePage, direction, filterQuery, limit, sortField]); - - return response; + return { agentsLoading, agents: agentData?.list }; }; diff --git a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts new file mode 100644 index 0000000000000..f786e9167d2f8 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts @@ -0,0 +1,28 @@ +/* + * 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 { useQuery } from 'react-query'; +import { useKibana } from '../common/lib/kibana'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common'; + +export const useOsqueryPolicies = () => { + const { http } = useKibana().services; + + const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies } = useQuery( + ['osqueryPolicies'], + async () => { + return await http.get('/api/fleet/package_policies', { + query: { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:osquery_manager`, + }, + }); + }, + { select: (data) => data.items.map((p: { policy_id: string }) => p.policy_id) } + ); + + return { osqueryPoliciesLoading, osqueryPolicies }; +}; 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 4a69e2fc0e76d..7e19bee530ec5 100644 --- a/x-pack/plugins/osquery/public/live_query/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_query/form/index.tsx @@ -40,7 +40,7 @@ const LiveQueryFormComponent: React.FC = ({ defaultValue, on return (
- + diff --git a/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts b/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts new file mode 100644 index 0000000000000..975770e594367 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/parse_agent_groups.ts @@ -0,0 +1,59 @@ +/* + * 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 type { ElasticsearchClient } from 'src/core/server'; +import { OsqueryAppContext } from './osquery_app_context_services'; + +export interface AgentSelection { + agents: string[]; + allAgentsSelected: boolean; + platformsSelected: string[]; + policiesSelected: string[]; +} + +export const parseAgentSelection = async ( + esClient: ElasticsearchClient, + context: OsqueryAppContext, + agentSelection: AgentSelection +) => { + let selectedAgents: string[] = []; + const { allAgentsSelected, platformsSelected, policiesSelected, agents } = agentSelection; + const agentService = context.service.getAgentService(); + if (agentService) { + if (allAgentsSelected) { + // TODO: actually fetch all the agents + const { agents: fetchedAgents } = await agentService.listAgents(esClient, { + perPage: 9000, + showInactive: true, + }); + selectedAgents.push(...fetchedAgents.map((a) => a.id)); + } else { + if (platformsSelected.length > 0 || policiesSelected.length > 0) { + const kueryFragments = []; + if (platformsSelected.length) { + kueryFragments.push( + ...platformsSelected.map((platform) => `local_metadata.os.platform:${platform}`) + ); + } + if (policiesSelected.length) { + kueryFragments.push(...policiesSelected.map((policy) => `policy_id:${policy}`)); + } + const kuery = kueryFragments.join(' or '); + // TODO: actually fetch all the agents + const { agents: fetchedAgents } = await agentService.listAgents(esClient, { + kuery, + perPage: 9000, + showInactive: true, + }); + selectedAgents.push(...fetchedAgents.map((a) => a.id)); + } + selectedAgents.push(...agents); + selectedAgents = Array.from(new Set(selectedAgents)); + } + } + return 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 25212bc3bf5cc..7068243cc0fb7 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 @@ -12,8 +12,11 @@ import moment from 'moment'; import { IRouter } from '../../../../../../src/core/server'; import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; -export const createActionRoute = (router: IRouter) => { +import { parseAgentSelection, AgentSelection } from '../../lib/parse_agent_groups'; + +export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( { path: '/internal/osquery/action', @@ -24,7 +27,8 @@ export const createActionRoute = (router: IRouter) => { }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; - + const { agentSelection } = request.body as { agentSelection: AgentSelection }; + const selectedAgents = await parseAgentSelection(esClient, osqueryContext, agentSelection); // @ts-expect-error update validation if (request.body.pack_id) { const savedObjectsClient = context.core.savedObjects.client; @@ -72,8 +76,7 @@ export const createActionRoute = (router: IRouter) => { 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: query.id, // @ts-expect-error update validation @@ -103,8 +106,7 @@ export const createActionRoute = (router: IRouter) => { expiration: moment().add(2, 'days').toISOString(), type: 'INPUT_ACTION', input_type: 'osquery', - // @ts-expect-error update validation - agents: request.body.agents, + agents: selectedAgents, data: { // @ts-expect-error update validation id: request.body.query.id ?? uuid.v4(), diff --git a/x-pack/plugins/osquery/server/routes/action/index.ts b/x-pack/plugins/osquery/server/routes/action/index.ts index 37e04fac5b986..fcf89d79dd0ee 100644 --- a/x-pack/plugins/osquery/server/routes/action/index.ts +++ b/x-pack/plugins/osquery/server/routes/action/index.ts @@ -7,7 +7,8 @@ import { IRouter } from '../../../../../../src/core/server'; import { createActionRoute } from './create_action_route'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; -export const initActionRoutes = (router: IRouter) => { - createActionRoute(router); +export const initActionRoutes = (router: IRouter, context: OsqueryAppContext) => { + createActionRoute(router, context); }; diff --git a/x-pack/plugins/osquery/server/routes/index.ts b/x-pack/plugins/osquery/server/routes/index.ts index 29df227583992..59d4085a77be1 100644 --- a/x-pack/plugins/osquery/server/routes/index.ts +++ b/x-pack/plugins/osquery/server/routes/index.ts @@ -13,7 +13,7 @@ import { OsqueryAppContext } from '../lib/osquery_app_context_services'; import { initPackRoutes } from './pack'; export const defineRoutes = (router: IRouter, context: OsqueryAppContext) => { - initActionRoutes(router); + initActionRoutes(router, context); initPackRoutes(router); initSavedQueryRoutes(router); initScheduledQueryRoutes(router, context); 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..52101462270c7 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 @@ -7,15 +7,18 @@ import { ISearchRequestParams } from '../../../../../../../../src/plugins/data/common'; import { AgentsRequestOptions } from '../../../../../common/search_strategy'; -// import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; +import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; export const buildAgentsQuery = ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars filterQuery, pagination: { cursorStart, querySize }, sort, + aggregations, }: AgentsRequestOptions): ISearchRequestParams => { - // const filter = [...createQueryFilterClauses(filterQuery)]; + const filter = [ + { term: { active: { value: 'true' } } }, + ...createQueryFilterClauses(filterQuery), + ]; const dslQuery = { allowNoIndices: true, @@ -23,12 +26,11 @@ export const buildAgentsQuery = ({ ignoreUnavailable: true, body: { query: { - term: { - active: { - value: 'true', - }, + bool: { + filter, }, }, + aggs: aggregations, track_total_hits: true, sort: [ {