From 28db91718b5b277c9df16248cb8942a71814b695 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 00:28:59 -0400 Subject: [PATCH 01/10] [APM] APM-Fleet integration version check & upgrade message (#115297) (#115484) Co-authored-by: Oliver Gupte --- x-pack/plugins/apm/common/fleet.ts | 2 + .../components/app/Settings/schema/index.tsx | 4 +- .../schema/migrated/card_footer_content.tsx | 47 +++++++++++++ .../migrated/successful_migration_card.tsx | 30 ++++++++ .../migrated/upgrade_available_card.tsx | 51 ++++++++++++++ .../app/Settings/schema/schema_overview.tsx | 68 +++++-------------- .../public/components/shared/Links/kibana.ts | 11 +++ .../get_apm_package_policy_definition.ts | 7 +- x-pack/plugins/apm/server/routes/fleet.ts | 6 +- 9 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/Settings/schema/migrated/card_footer_content.tsx create mode 100644 x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx create mode 100644 x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx diff --git a/x-pack/plugins/apm/common/fleet.ts b/x-pack/plugins/apm/common/fleet.ts index 618cd20d66159..97551cc16b4be 100644 --- a/x-pack/plugins/apm/common/fleet.ts +++ b/x-pack/plugins/apm/common/fleet.ts @@ -6,3 +6,5 @@ */ export const POLICY_ELASTIC_AGENT_ON_CLOUD = 'policy-elastic-agent-on-cloud'; + +export const SUPPORTED_APM_PACKAGE_VERSION = '7.16.0'; diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx index ac32e22fa3ded..b13046d34be94 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx @@ -54,7 +54,8 @@ export function Schema() { const isLoading = status !== FETCH_STATUS.SUCCESS; const cloudApmMigrationEnabled = !!data.cloud_apm_migration_enabled; const hasCloudAgentPolicy = !!data.has_cloud_agent_policy; - const hasCloudApmPackagePolicy = !!data.has_cloud_apm_package_policy; + const cloudApmPackagePolicy = data.cloud_apm_package_policy; + const hasCloudApmPackagePolicy = !!cloudApmPackagePolicy; const hasRequiredRole = !!data.has_required_role; function updateLocalStorage(newStatus: FETCH_STATUS) { @@ -90,6 +91,7 @@ export function Schema() { cloudApmMigrationEnabled={cloudApmMigrationEnabled} hasCloudAgentPolicy={hasCloudAgentPolicy} hasRequiredRole={hasRequiredRole} + cloudApmPackagePolicy={cloudApmPackagePolicy} /> {isSwitchActive && ( + + {i18n.translate( + 'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText', + { defaultMessage: 'View the APM integration in Fleet' } + )} + + + +

+ + {i18n.translate( + 'xpack.apm.settings.schema.success.returnText.serviceInventoryLink', + { defaultMessage: 'Service inventory' } + )} + + ), + }} + /> +

+
+ + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx new file mode 100644 index 0000000000000..839479fbbf652 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx @@ -0,0 +1,30 @@ +/* + * 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 { EuiCard, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CardFooterContent } from './card_footer_content'; + +export function SuccessfulMigrationCard() { + return ( + } + title={i18n.translate('xpack.apm.settings.schema.success.title', { + defaultMessage: 'Elastic Agent successfully setup!', + })} + description={i18n.translate( + 'xpack.apm.settings.schema.success.description', + { + defaultMessage: + 'Your APM integration is now setup and ready to receive data from your currently instrumented agents. Feel free to review the policies applied to your integtration.', + } + )} + footer={} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx new file mode 100644 index 0000000000000..8c10236335961 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EuiCard, EuiIcon, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { useUpgradeApmPackagePolicyHref } from '../../../../shared/Links/kibana'; +import { CardFooterContent } from './card_footer_content'; + +export function UpgradeAvailableCard({ + apmPackagePolicyId, +}: { + apmPackagePolicyId: string | undefined; +}) { + const upgradeApmPackagePolicyHref = + useUpgradeApmPackagePolicyHref(apmPackagePolicyId); + + return ( + } + title={i18n.translate( + 'xpack.apm.settings.schema.upgradeAvailable.title', + { + defaultMessage: 'APM integration upgrade available!', + } + )} + description={ + + {i18n.translate( + 'xpack.apm.settings.schema.upgradeAvailable.upgradePackagePolicyLink', + { defaultMessage: 'Upgrade your APM integration' } + )} + + ), + }} + /> + } + footer={} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx index 0031c102e8ae5..cead6cd8a6fb4 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx @@ -19,11 +19,14 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { APMLink } from '../../../shared/Links/apm/APMLink'; +import semverLt from 'semver/functions/lt'; +import { SUPPORTED_APM_PACKAGE_VERSION } from '../../../../../common/fleet'; +import { PackagePolicy } from '../../../../../../fleet/common/types'; import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; -import { useFleetCloudAgentPolicyHref } from '../../../shared/Links/kibana'; import rocketLaunchGraphic from './blog-rocket-720x420.png'; import { MigrationInProgressPanel } from './migration_in_progress_panel'; +import { UpgradeAvailableCard } from './migrated/upgrade_available_card'; +import { SuccessfulMigrationCard } from './migrated/successful_migration_card'; interface Props { onSwitch: () => void; @@ -34,6 +37,7 @@ interface Props { cloudApmMigrationEnabled: boolean; hasCloudAgentPolicy: boolean; hasRequiredRole: boolean; + cloudApmPackagePolicy: PackagePolicy | undefined; } export function SchemaOverview({ onSwitch, @@ -44,10 +48,13 @@ export function SchemaOverview({ cloudApmMigrationEnabled, hasCloudAgentPolicy, hasRequiredRole, + cloudApmPackagePolicy, }: Props) { - const fleetCloudAgentPolicyHref = useFleetCloudAgentPolicyHref(); const isDisabled = !cloudApmMigrationEnabled || !hasCloudAgentPolicy || !hasRequiredRole; + const packageVersion = cloudApmPackagePolicy?.package?.version; + const isUpgradeAvailable = + packageVersion && semverLt(packageVersion, SUPPORTED_APM_PACKAGE_VERSION); if (isLoading) { return ( @@ -76,54 +83,13 @@ export function SchemaOverview({ - - } - title={i18n.translate('xpack.apm.settings.schema.success.title', { - defaultMessage: 'Elastic Agent successfully setup!', - })} - description={i18n.translate( - 'xpack.apm.settings.schema.success.description', - { - defaultMessage: - 'Your APM integration is now setup and ready to receive data from your currently instrumented agents. Feel free to review the policies applied to your integtration.', - } - )} - footer={ -
- - {i18n.translate( - 'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText', - { defaultMessage: 'View the APM integration in Fleet' } - )} - - - -

- - {i18n.translate( - 'xpack.apm.settings.schema.success.returnText.serviceInventoryLink', - { defaultMessage: 'Service inventory' } - )} - - ), - }} - /> -

-
-
- } - /> + {isUpgradeAvailable ? ( + + ) : ( + + )}
diff --git a/x-pack/plugins/apm/public/components/shared/Links/kibana.ts b/x-pack/plugins/apm/public/components/shared/Links/kibana.ts index bfb7cf849f567..c0bdf3a98aa31 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/kibana.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/kibana.ts @@ -26,3 +26,14 @@ export function useFleetCloudAgentPolicyHref() { } = useApmPluginContext(); return basePath.prepend('/app/fleet#/policies/policy-elastic-agent-on-cloud'); } + +export function useUpgradeApmPackagePolicyHref(packagePolicyId = '') { + const { + core: { + http: { basePath }, + }, + } = useApmPluginContext(); + return basePath.prepend( + `/app/fleet/policies/policy-elastic-agent-on-cloud/upgrade-package-policy/${packagePolicyId}?from=integrations-policy-list` + ); +} diff --git a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts index 64b071b67d2bd..98b6a6489c47b 100644 --- a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { POLICY_ELASTIC_AGENT_ON_CLOUD } from '../../../common/fleet'; +import { + POLICY_ELASTIC_AGENT_ON_CLOUD, + SUPPORTED_APM_PACKAGE_VERSION, +} from '../../../common/fleet'; import { APMPluginSetupDependencies } from '../../types'; import { APM_PACKAGE_NAME } from './get_cloud_apm_package_policy'; @@ -36,7 +39,7 @@ export function getApmPackagePolicyDefinition( ], package: { name: APM_PACKAGE_NAME, - version: '0.4.0', + version: SUPPORTED_APM_PACKAGE_VERSION, title: 'Elastic APM', }, }; diff --git a/x-pack/plugins/apm/server/routes/fleet.ts b/x-pack/plugins/apm/server/routes/fleet.ts index 2884c08ceb9a1..e18aefcd6e0d8 100644 --- a/x-pack/plugins/apm/server/routes/fleet.ts +++ b/x-pack/plugins/apm/server/routes/fleet.ts @@ -92,7 +92,7 @@ const fleetAgentsRoute = createApmServerRoute({ }); const saveApmServerSchemaRoute = createApmServerRoute({ - endpoint: 'POST /internal/apm/fleet/apm_server_schema', + endpoint: 'POST /api/apm/fleet/apm_server_schema', options: { tags: ['access:apm', 'access:apm_write'] }, params: t.type({ body: t.type({ @@ -143,11 +143,13 @@ const getMigrationCheckRoute = createApmServerRoute({ fleetPluginStart, }) : undefined; + const apmPackagePolicy = getApmPackagePolicy(cloudAgentPolicy); return { has_cloud_agent_policy: !!cloudAgentPolicy, - has_cloud_apm_package_policy: !!getApmPackagePolicy(cloudAgentPolicy), + has_cloud_apm_package_policy: !!apmPackagePolicy, cloud_apm_migration_enabled: cloudApmMigrationEnabled, has_required_role: hasRequiredRole, + cloud_apm_package_policy: apmPackagePolicy, }; }, }); From ad9f36e61bb7d735f60dfe4a988c155baeb4427e Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 01:18:06 -0400 Subject: [PATCH 02/10] [Uptime] Fix unhandled promise rejection failure (#114883) (#115491) * Fix unhandled promise rejection failure. * Mock monaco to avoid editor-related errors failing test. * Update assertion. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Justin Kambic --- .../components/fleet_package/custom_fields.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx index 26ee26cc8ed7f..62c6f5598adb4 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx @@ -313,11 +313,11 @@ describe.skip('', () => { // resolve errors fireEvent.click(monitorType); - waitFor(() => { - expect(getByText('http')).toBeInTheDocument(); - expect(getByText('tcp')).toBeInTheDocument(); - expect(getByText('icmp')).toBeInTheDocument(); - expect(queryByText('browser')).not.toBeInTheDocument(); + await waitFor(() => { + expect(getByText('HTTP')).toBeInTheDocument(); + expect(getByText('TCP')).toBeInTheDocument(); + expect(getByText('ICMP')).toBeInTheDocument(); + expect(queryByText('Browser')).not.toBeInTheDocument(); }); }); }); From f9a08d4495a1b91b13b33ae2063cd4493deef859 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 02:01:45 -0400 Subject: [PATCH 03/10] [Security Solution] Skip flakey test Configures a new connector.Cases connectors Configures a new connector (#115440) (#115480) Co-authored-by: Kevin Logan <56395104+kevinlog@users.noreply.github.com> --- .../cypress/integration/cases/connectors.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index 287d86c6fba9e..69b623de0b43c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -20,7 +20,8 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { CASES_URL } from '../../urls/navigation'; -describe('Cases connectors', () => { +// Skipping flakey test: https://github.com/elastic/kibana/issues/115438 +describe.skip('Cases connectors', () => { const configureResult = { connector: { id: 'e271c3b8-f702-4fbc-98e0-db942b573bbd', From 3e17aff5476fb3466a564e7921f8067af347c3b5 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 03:33:51 -0400 Subject: [PATCH 04/10] [APM] Add readme for @elastic/apm-generator (#115368) (#115506) Co-authored-by: Dario Gieselaar --- packages/elastic-apm-generator/README.md | 93 ++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/elastic-apm-generator/README.md b/packages/elastic-apm-generator/README.md index e69de29bb2d1d..e43187a8155d3 100644 --- a/packages/elastic-apm-generator/README.md +++ b/packages/elastic-apm-generator/README.md @@ -0,0 +1,93 @@ +# @elastic/apm-generator + +`@elastic/apm-generator` is an experimental tool to generate synthetic APM data. It is intended to be used for development and testing of the Elastic APM app in Kibana. + +At a high-level, the module works by modeling APM events/metricsets with [a fluent API](https://en.wikipedia.org/wiki/Fluent_interface). The models can then be serialized and converted to Elasticsearch documents. In the future we might support APM Server as an output as well. + +## Usage + +This section assumes that you've installed Kibana's dependencies by running `yarn kbn bootstrap` in the repository's root folder. + +This library can currently be used in two ways: + +- Imported as a Node.js module, for instance to be used in Kibana's functional test suite. +- With a command line interface, to index data based on some example scenarios. + +### Using the Node.js module + +#### Concepts + +- `Service`: a logical grouping for a monitored service. A `Service` object contains fields like `service.name`, `service.environment` and `agent.name`. +- `Instance`: a single instance of a monitored service. E.g., the workload for a monitored service might be spread across multiple containers. An `Instance` object contains fields like `service.node.name` and `container.id`. +- `Timerange`: an object that will return an array of timestamps based on an interval and a rate. These timestamps can be used to generate events/metricsets. +- `Transaction`, `Span`, `APMError` and `Metricset`: events/metricsets that occur on an instance. For more background, see the [explanation of the APM data model](https://www.elastic.co/guide/en/apm/get-started/7.15/apm-data-model.html) + + +#### Example + +```ts +import { service, timerange, toElasticsearchOutput } from '@elastic/apm-generator'; + +const instance = service('synth-go', 'production', 'go') + .instance('instance-a'); + +const from = new Date('2021-01-01T12:00:00.000Z').getTime(); +const to = new Date('2021-01-01T12:00:00.000Z').getTime() - 1; + +const traceEvents = timerange(from, to) + .interval('1m') + .rate(10) + .flatMap(timestamp => instance.transaction('GET /api/product/list') + .timestamp(timestamp) + .duration(1000) + .success() + .children( + instance.span('GET apm-*/_search', 'db', 'elasticsearch') + .timestamp(timestamp + 50) + .duration(900) + .destination('elasticsearch') + .success() + ).serialize() + ); + +const metricsets = timerange(from, to) + .interval('30s') + .rate(1) + .flatMap(timestamp => instance.appMetrics({ + 'system.memory.actual.free': 800, + 'system.memory.total': 1000, + 'system.cpu.total.norm.pct': 0.6, + 'system.process.cpu.total.norm.pct': 0.7, + }).timestamp(timestamp) + .serialize() + ); + +const esEvents = toElasticsearchOutput(traceEvents.concat(metricsets)); +``` + +#### Generating metricsets + +`@elastic/apm-generator` can also automatically generate transaction metrics, span destination metrics and transaction breakdown metrics based on the generated trace events. If we expand on the previous example: + +```ts +import { getTransactionMetrics, getSpanDestinationMetrics, getBreakdownMetrics } from '@elastic/apm-generator'; + +const esEvents = toElasticsearchOutput([ + ...traceEvents, + ...getTransactionMetrics(traceEvents), + ...getSpanDestinationMetrics(traceEvents), + ...getBreakdownMetrics(traceEvents) +]); +``` + +### CLI + +Via the CLI, you can upload examples. The supported examples are listed in `src/lib/es.ts`. A `--target` option that specifies the Elasticsearch URL should be defined when running the `example` command. Here's an example: + +`$ node packages/elastic-apm-generator/src/scripts/es.js example simple-trace --target=http://admin:changeme@localhost:9200` + +The following options are supported: +- `to`: the end of the time range, in ISO format. By default, the current time will be used. +- `from`: the start of the time range, in ISO format. By default, `to` minus 15 minutes will be used. +- `apm-server-version`: the version used in the index names bootstrapped by APM Server, e.g. `7.16.0`. __If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.__ + From 374398c53bc710f5b5e4d072122ecf0e80beccd3 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 04:36:06 -0400 Subject: [PATCH 05/10] [Security Solution] fix endpoint list agent status logic (#115286) (#115498) Co-authored-by: Joey F. Poon --- .../server/endpoint/routes/metadata/handlers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 027107bcf1a59..e98cdc4f11404 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -48,6 +48,7 @@ import { } from './support/query_strategies'; import { NotFoundError } from '../../errors'; import { EndpointHostUnEnrolledError } from '../../services/metadata'; +import { getAgentStatus } from '../../../../../fleet/common/services/agent_status'; export interface MetadataRequestContext { esClient?: IScopedClusterClient; @@ -522,10 +523,11 @@ async function queryUnitedIndex( const agentPolicy = agentPoliciesMap[agent.policy_id!]; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const endpointPolicy = endpointPoliciesMap[agent.policy_id!]; + const fleetAgentStatus = getAgentStatus(agent as Agent); + return { metadata, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - host_status: fleetAgentStatusToEndpointHostStatus(agent.last_checkin_status!), + host_status: fleetAgentStatusToEndpointHostStatus(fleetAgentStatus), policy_info: { agent: { applied: { From d92578780150fc0296e1b25a7e1e5b36d07c9464 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 04:46:57 -0400 Subject: [PATCH 06/10] [Security Solution][Endpoint]Activity Log API/UX changes (#114905) (#115492) * rename legacy actions/responses fixes elastic/security-team/issues/1702 * use correct name for responses index refs elastic/kibana/pull/113621 * extract helper method to utils * append endpoint responses docs to activity log * Show completed responses on activity log fixes elastic/security-team/issues/1703 * remove width restriction on date picker * add a simple test to verify endpoint responses fixes elastic/security-team/issues/1702 * find unique action_ids from `.fleet-actions` and `.logs-endpoint.actions-default` indices fixes elastic/security-team/issues/1702 * do not filter out endpoint only actions/responses that did not make it to Fleet review comments * use a constant to manage various doc types review comments * refactor `getActivityLog` Simplify `getActivityLog` so it is easier to reason with. review comments * skip this for now will mock this better in a new PR * improve types * display endpoint actions similar to fleet actions, but with success icon color * Correctly do mocks for tests * Include only errored endpoint actions, remove successful duplicates fixes elastic/security-team/issues/1703 * Update tests to use non duplicate action_ids review comments fixes elastic/security-team/issues/1703 * show correct action title review fixes * statusCode constant review change * rename review changes * Update translations.ts refs https://github.com/elastic/kibana/pull/114905/commits/74a8340b5eb2e31faba67a4fbe656f74fe52d0a2 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Ashokaditya --- .../common/endpoint/constants.ts | 4 +- .../common/endpoint/types/actions.ts | 33 ++- .../activity_log_date_range_picker/index.tsx | 1 - .../view/details/components/log_entry.tsx | 101 ++++++- .../components/log_entry_timeline_icon.tsx | 21 +- .../view/details/endpoints.stories.tsx | 14 +- .../pages/endpoint_hosts/view/index.test.tsx | 95 +++++-- .../pages/endpoint_hosts/view/translations.ts | 36 +++ .../endpoint/routes/actions/audit_log.test.ts | 213 ++++++++++++-- .../endpoint/routes/actions/isolation.ts | 29 +- .../server/endpoint/routes/actions/mocks.ts | 32 +++ .../server/endpoint/services/actions.ts | 146 ++++------ .../endpoint/utils/audit_log_helpers.ts | 266 ++++++++++++++++++ .../server/endpoint/utils/index.ts | 2 + .../endpoint/utils/yes_no_data_stream.test.ts | 100 +++++++ .../endpoint/utils/yes_no_data_stream.ts | 59 ++++ 16 files changed, 949 insertions(+), 203 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 6e9123da2dd9b..178a2b68a4aab 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -10,7 +10,7 @@ export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions'; export const ENDPOINT_ACTIONS_INDEX = `${ENDPOINT_ACTIONS_DS}-default`; export const ENDPOINT_ACTION_RESPONSES_DS = '.logs-endpoint.action.responses'; -export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTIONS_DS}-default`; +export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTION_RESPONSES_DS}-default`; export const eventsIndexPattern = 'logs-endpoint.events.*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; @@ -60,3 +60,5 @@ export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; + +export const failedFleetActionErrorCode = '424'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index bc46ca2f5b451..fb29297eb5929 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -10,6 +10,13 @@ import { ActionStatusRequestSchema, HostIsolationRequestSchema } from '../schema export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; +export const ActivityLogItemTypes = { + ACTION: 'action' as const, + RESPONSE: 'response' as const, + FLEET_ACTION: 'fleetAction' as const, + FLEET_RESPONSE: 'fleetResponse' as const, +}; + interface EcsError { code?: string; id?: string; @@ -87,8 +94,24 @@ export interface EndpointActionResponse { action_data: EndpointActionData; } +export interface EndpointActivityLogAction { + type: typeof ActivityLogItemTypes.ACTION; + item: { + id: string; + data: LogsEndpointAction; + }; +} + +export interface EndpointActivityLogActionResponse { + type: typeof ActivityLogItemTypes.RESPONSE; + item: { + id: string; + data: LogsEndpointActionResponse; + }; +} + export interface ActivityLogAction { - type: 'action'; + type: typeof ActivityLogItemTypes.FLEET_ACTION; item: { // document _id id: string; @@ -97,7 +120,7 @@ export interface ActivityLogAction { }; } export interface ActivityLogActionResponse { - type: 'response'; + type: typeof ActivityLogItemTypes.FLEET_RESPONSE; item: { // document id id: string; @@ -105,7 +128,11 @@ export interface ActivityLogActionResponse { data: EndpointActionResponse; }; } -export type ActivityLogEntry = ActivityLogAction | ActivityLogActionResponse; +export type ActivityLogEntry = + | ActivityLogAction + | ActivityLogActionResponse + | EndpointActivityLogAction + | EndpointActivityLogActionResponse; export interface ActivityLog { page: number; pageSize: number; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx index 05887d82cacad..a57fa8d8e4ce5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx @@ -32,7 +32,6 @@ interface Range { const DatePickerWrapper = styled.div` width: ${(props) => props.theme.eui.fractions.single.percentage}; - max-width: 350px; `; const StickyFlexItem = styled(EuiFlexItem)` background: ${(props) => `${props.theme.eui.euiHeaderBackgroundColor}`}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx index bbe0a6f3afcd1..79af2ecb354fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -9,24 +9,34 @@ import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; import { EuiComment, EuiText, EuiAvatarProps, EuiCommentProps, IconType } from '@elastic/eui'; -import { Immutable, ActivityLogEntry } from '../../../../../../../common/endpoint/types'; +import { + Immutable, + ActivityLogEntry, + ActivityLogItemTypes, +} from '../../../../../../../common/endpoint/types'; import { FormattedRelativePreferenceDate } from '../../../../../../common/components/formatted_date'; import { LogEntryTimelineIcon } from './log_entry_timeline_icon'; +import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; import * as i18 from '../../translations'; const useLogEntryUIProps = ( - logEntry: Immutable + logEntry: Immutable, + theme: ReturnType ): { actionEventTitle: string; + avatarColor: EuiAvatarProps['color']; + avatarIconColor: EuiAvatarProps['iconColor']; avatarSize: EuiAvatarProps['size']; commentText: string; commentType: EuiCommentProps['type']; displayComment: boolean; displayResponseEvent: boolean; + failedActionEventTitle: string; iconType: IconType; isResponseEvent: boolean; isSuccessful: boolean; + isCompleted: boolean; responseEventTitle: string; username: string | React.ReactNode; } => { @@ -34,15 +44,19 @@ const useLogEntryUIProps = ( let iconType: IconType = 'dot'; let commentType: EuiCommentProps['type'] = 'update'; let commentText: string = ''; + let avatarColor: EuiAvatarProps['color'] = theme.euiColorLightestShade; + let avatarIconColor: EuiAvatarProps['iconColor']; let avatarSize: EuiAvatarProps['size'] = 's'; + let failedActionEventTitle: string = ''; let isIsolateAction: boolean = false; let isResponseEvent: boolean = false; let isSuccessful: boolean = false; + let isCompleted: boolean = false; let displayComment: boolean = false; let displayResponseEvent: boolean = true; let username: EuiCommentProps['username'] = ''; - if (logEntry.type === 'action') { + if (logEntry.type === ActivityLogItemTypes.FLEET_ACTION) { avatarSize = 'm'; commentType = 'regular'; commentText = logEntry.item.data.data.comment?.trim() ?? ''; @@ -59,13 +73,51 @@ const useLogEntryUIProps = ( displayComment = true; } } - } else if (logEntry.type === 'response') { + } + if (logEntry.type === ActivityLogItemTypes.ACTION) { + avatarSize = 'm'; + commentType = 'regular'; + commentText = logEntry.item.data.EndpointActions.data.comment?.trim() ?? ''; + displayResponseEvent = false; + iconType = 'lockOpen'; + username = logEntry.item.data.user.id; + avatarIconColor = theme.euiColorVis9_behindText; + failedActionEventTitle = i18.ACTIVITY_LOG.LogEntry.action.failedEndpointReleaseAction; + if (logEntry.item.data.EndpointActions.data) { + const data = logEntry.item.data.EndpointActions.data; + if (data.command === 'isolate') { + iconType = 'lock'; + failedActionEventTitle = i18.ACTIVITY_LOG.LogEntry.action.failedEndpointIsolateAction; + } + if (commentText) { + displayComment = true; + } + } + } else if (logEntry.type === ActivityLogItemTypes.FLEET_RESPONSE) { isResponseEvent = true; if (logEntry.item.data.action_data.command === 'isolate') { isIsolateAction = true; } if (!!logEntry.item.data.completed_at && !logEntry.item.data.error) { isSuccessful = true; + } else { + avatarColor = theme.euiColorVis9_behindText; + } + } else if (logEntry.type === ActivityLogItemTypes.RESPONSE) { + iconType = 'check'; + isResponseEvent = true; + if (logEntry.item.data.EndpointActions.data.command === 'isolate') { + isIsolateAction = true; + } + if (logEntry.item.data.EndpointActions.completed_at) { + isCompleted = true; + if (!logEntry.item.data.error) { + isSuccessful = true; + avatarColor = theme.euiColorVis0_behindText; + } else { + isSuccessful = false; + avatarColor = theme.euiColorVis9_behindText; + } } } @@ -75,13 +127,23 @@ const useLogEntryUIProps = ( const getResponseEventTitle = () => { if (isIsolateAction) { - if (isSuccessful) { + if (isCompleted) { + if (isSuccessful) { + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndSuccessful; + } + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndUnsuccessful; + } else if (isSuccessful) { return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful; } else { return i18.ACTIVITY_LOG.LogEntry.response.isolationFailed; } } else { - if (isSuccessful) { + if (isCompleted) { + if (isSuccessful) { + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndSuccessful; + } + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndUnsuccessful; + } else if (isSuccessful) { return i18.ACTIVITY_LOG.LogEntry.response.unisolationSuccessful; } else { return i18.ACTIVITY_LOG.LogEntry.response.unisolationFailed; @@ -91,18 +153,22 @@ const useLogEntryUIProps = ( return { actionEventTitle, + avatarColor, + avatarIconColor, avatarSize, commentText, commentType, displayComment, displayResponseEvent, + failedActionEventTitle, iconType, isResponseEvent, isSuccessful, + isCompleted, responseEventTitle: getResponseEventTitle(), username, }; - }, [logEntry]); + }, [logEntry, theme]); }; const StyledEuiComment = styled(EuiComment)` @@ -126,28 +192,41 @@ const StyledEuiComment = styled(EuiComment)` `; export const LogEntry = memo(({ logEntry }: { logEntry: Immutable }) => { + const theme = useEuiTheme(); const { actionEventTitle, + avatarColor, + avatarIconColor, avatarSize, commentText, commentType, displayComment, displayResponseEvent, + failedActionEventTitle, iconType, isResponseEvent, - isSuccessful, responseEventTitle, username, - } = useLogEntryUIProps(logEntry); + } = useLogEntryUIProps(logEntry, theme); return ( } - event={{displayResponseEvent ? responseEventTitle : actionEventTitle}} + event={ + + {displayResponseEvent + ? responseEventTitle + : failedActionEventTitle + ? failedActionEventTitle + : actionEventTitle} + + } timelineIcon={ - + } data-test-subj="timelineEntry" > diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx index 3ff311cd8a139..25e7c7d2c4a49 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx @@ -7,32 +7,27 @@ import React, { memo } from 'react'; import { EuiAvatar, EuiAvatarProps } from '@elastic/eui'; -import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; export const LogEntryTimelineIcon = memo( ({ + avatarColor, + avatarIconColor, avatarSize, - isResponseEvent, - isSuccessful, iconType, + isResponseEvent, }: { + avatarColor: EuiAvatarProps['color']; + avatarIconColor?: EuiAvatarProps['iconColor']; avatarSize: EuiAvatarProps['size']; - isResponseEvent: boolean; - isSuccessful: boolean; iconType: EuiAvatarProps['iconType']; + isResponseEvent: boolean; }) => { - const euiTheme = useEuiTheme(); - return ( ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx index 123a51e5a52bd..717368a1ff3a0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx @@ -8,7 +8,11 @@ import React, { ComponentType } from 'react'; import moment from 'moment'; -import { ActivityLog, Immutable } from '../../../../../../common/endpoint/types'; +import { + ActivityLog, + Immutable, + ActivityLogItemTypes, +} from '../../../../../../common/endpoint/types'; import { EndpointDetailsFlyoutTabs } from './components/endpoint_details_tabs'; import { EndpointActivityLog } from './endpoint_activity_log'; import { EndpointDetailsFlyout } from '.'; @@ -26,7 +30,7 @@ export const dummyEndpointActivityLog = ( endDate: moment().toString(), data: [ { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { @@ -44,7 +48,7 @@ export const dummyEndpointActivityLog = ( }, }, { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { @@ -63,7 +67,7 @@ export const dummyEndpointActivityLog = ( }, }, { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { @@ -82,7 +86,7 @@ export const dummyEndpointActivityLog = ( }, }, { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index b2c438659b771..727c2e8a35024 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -42,6 +42,7 @@ import { import { getCurrentIsolationRequestState } from '../store/selectors'; import { licenseService } from '../../../../common/hooks/use_license'; import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; +import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator'; import { APP_PATH, MANAGEMENT_PATH, @@ -807,7 +808,7 @@ describe('when on the endpoint list page', () => { let renderResult: ReturnType; const agentId = 'some_agent_id'; - let getMockData: () => ActivityLog; + let getMockData: (option?: { hasLogsEndpointActionResponses?: boolean }) => ActivityLog; beforeEach(async () => { window.IntersectionObserver = jest.fn(() => ({ root: null, @@ -828,10 +829,15 @@ describe('when on the endpoint list page', () => { }); const fleetActionGenerator = new FleetActionGenerator('seed'); - const responseData = fleetActionGenerator.generateResponse({ + const endpointActionGenerator = new EndpointActionGenerator('seed'); + const endpointResponseData = endpointActionGenerator.generateResponse({ + agent: { id: agentId }, + }); + const fleetResponseData = fleetActionGenerator.generateResponse({ agent_id: agentId, }); - const actionData = fleetActionGenerator.generate({ + + const fleetActionData = fleetActionGenerator.generate({ agents: [agentId], data: { comment: 'some comment', @@ -844,35 +850,49 @@ describe('when on the endpoint list page', () => { }, }); - getMockData = () => ({ - page: 1, - pageSize: 50, - startDate: 'now-1d', - endDate: 'now', - data: [ - { - type: 'response', - item: { - id: 'some_id_0', - data: responseData, + getMockData = (hasLogsEndpointActionResponses?: { + hasLogsEndpointActionResponses?: boolean; + }) => { + const response: ActivityLog = { + page: 1, + pageSize: 50, + startDate: 'now-1d', + endDate: 'now', + data: [ + { + type: 'fleetResponse', + item: { + id: 'some_id_1', + data: fleetResponseData, + }, }, - }, - { - type: 'action', - item: { - id: 'some_id_1', - data: actionData, + { + type: 'fleetAction', + item: { + id: 'some_id_2', + data: fleetActionData, + }, }, - }, - { - type: 'action', + { + type: 'fleetAction', + item: { + id: 'some_id_3', + data: isolatedActionData, + }, + }, + ], + }; + if (hasLogsEndpointActionResponses) { + response.data.unshift({ + type: 'response', item: { - id: 'some_id_3', - data: isolatedActionData, + id: 'some_id_0', + data: endpointResponseData, }, - }, - ], - }); + }); + } + return response; + }; renderResult = render(); await reactTestingLibrary.act(async () => { @@ -912,6 +932,25 @@ describe('when on the endpoint list page', () => { expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null); }); + it('should display log accurately with endpoint responses', async () => { + const activityLogTab = await renderResult.findByTestId('activity_log'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(activityLogTab); + }); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged( + 'success', + getMockData({ hasLogsEndpointActionResponses: true }) + ); + }); + const logEntries = await renderResult.queryAllByTestId('timelineEntry'); + expect(logEntries.length).toEqual(4); + expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null); + expect(`${logEntries[1]} .euiCommentTimeline__icon--update`).not.toBe(null); + expect(`${logEntries[2]} .euiCommentTimeline__icon--regular`).not.toBe(null); + }); + it('should display empty state when API call has failed', async () => { const activityLogTab = await renderResult.findByTestId('activity_log'); reactTestingLibrary.act(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts index c8a29eed3fda7..9cd55a70005ec 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -56,8 +56,44 @@ export const ACTIVITY_LOG = { defaultMessage: 'submitted request: Release host', } ), + failedEndpointReleaseAction: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.failedEndpointReleaseAction', + { + defaultMessage: 'failed to submit request: Release host', + } + ), + failedEndpointIsolateAction: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.failedEndpointIsolateAction', + { + defaultMessage: 'failed to submit request: Isolate host', + } + ), }, response: { + isolationCompletedAndSuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.isolationCompletedAndSuccessful', + { + defaultMessage: 'Host isolation request completed by Endpoint', + } + ), + isolationCompletedAndUnsuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.isolationCompletedAndUnsuccessful', + { + defaultMessage: 'Host isolation request completed by Endpoint with errors', + } + ), + unisolationCompletedAndSuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationCompletedAndSuccessful', + { + defaultMessage: 'Release request completed by Endpoint', + } + ), + unisolationCompletedAndUnsuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationCompletedAndUnsuccessful', + { + defaultMessage: 'Release request completed by Endpoint with errors', + } + ), isolationSuccessful: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.isolationSuccessful', { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts index 4bd63c83169e5..5ce7962000788 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts @@ -30,9 +30,15 @@ import { } from '../../mocks'; import { registerActionAuditLogRoutes } from './audit_log'; import uuid from 'uuid'; -import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; +import { mockAuditLogSearchResult, Results } from './mocks'; import { SecuritySolutionRequestHandlerContext } from '../../../types'; -import { ActivityLog } from '../../../../common/endpoint/types'; +import { + ActivityLog, + EndpointAction, + EndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; describe('Action Log API', () => { describe('schema', () => { @@ -93,17 +99,30 @@ describe('Action Log API', () => { }); describe('response', () => { - const mockID = 'XYZABC-000'; - const actionID = 'some-known-actionid'; + const mockAgentID = 'XYZABC-000'; let endpointAppContextService: EndpointAppContextService; + const fleetActionGenerator = new FleetActionGenerator('seed'); + const endpointActionGenerator = new EndpointActionGenerator('seed'); // convenience for calling the route and handler for audit log let getActivityLog: ( params: EndpointActionLogRequestParams, query?: EndpointActionLogRequestQuery ) => Promise>; - // convenience for injecting mock responses for actions index and responses - let havingActionsAndResponses: (actions: MockAction[], responses: MockResponse[]) => void; + + // convenience for injecting mock action requests and responses + // for .logs-endpoint and .fleet indices + let mockActions: ({ + numActions, + hasFleetActions, + hasFleetResponses, + hasResponses, + }: { + numActions: number; + hasFleetActions?: boolean; + hasFleetResponses?: boolean; + hasResponses?: boolean; + }) => void; let havingErrors: () => void; @@ -149,12 +168,113 @@ describe('Action Log API', () => { return mockResponse; }; - havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { - esClientMock.asCurrentUser.search = jest.fn().mockImplementation((req) => { - const items: any[] = - req.index === '.fleet-actions' ? actions.splice(0, 50) : responses.splice(0, 1000); + // some arbitrary ids for needed actions + const getMockActionIds = (numAction: number): string[] => { + return [...Array(numAction).keys()].map(() => Math.random().toString(36).split('.')[1]); + }; + + // create as many actions as needed + const getEndpointActionsData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + endpointActionGenerator.generate({ + agent: { id: mockAgentID }, + EndpointActions: { + action_id: actionId, + }, + }) + ); + return data; + }; + // create as many responses as needed + const getEndpointResponseData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + endpointActionGenerator.generateResponse({ + agent: { id: mockAgentID }, + EndpointActions: { + action_id: actionId, + }, + }) + ); + return data; + }; + // create as many fleet actions as needed + const getFleetResponseData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + fleetActionGenerator.generateResponse({ + agent_id: mockAgentID, + action_id: actionId, + }) + ); + return data; + }; + // create as many fleet responses as needed + const getFleetActionData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + fleetActionGenerator.generate({ + agents: [mockAgentID], + action_id: actionId, + data: { + comment: 'some comment', + }, + }) + ); + return data; + }; + + // mock actions and responses results in a single response + mockActions = ({ + numActions, + hasFleetActions = false, + hasFleetResponses = false, + hasResponses = false, + }: { + numActions: number; + hasFleetActions?: boolean; + hasFleetResponses?: boolean; + hasResponses?: boolean; + }) => { + esClientMock.asCurrentUser.search = jest.fn().mockImplementationOnce(() => { + let actions: Results[] = []; + let fleetActions: Results[] = []; + let responses: Results[] = []; + let fleetResponses: Results[] = []; + + const actionIds = getMockActionIds(numActions); + + actions = getEndpointActionsData(actionIds).map((e) => ({ + _index: '.ds-.logs-endpoint.actions-default-2021.19.10-000001', + _source: e, + })); + + if (hasFleetActions) { + fleetActions = getFleetActionData(actionIds).map((e) => ({ + _index: '.fleet-actions-7', + _source: e, + })); + } - return Promise.resolve(mockSearchResult(items.map((x) => x.build()))); + if (hasFleetResponses) { + fleetResponses = getFleetResponseData(actionIds).map((e) => ({ + _index: '.ds-.fleet-actions-results-2021.19.10-000001', + _source: e, + })); + } + + if (hasResponses) { + responses = getEndpointResponseData(actionIds).map((e) => ({ + _index: '.ds-.logs-endpoint.action.responses-default-2021.19.10-000001', + _source: e, + })); + } + + const results = mockAuditLogSearchResult([ + ...actions, + ...fleetActions, + ...responses, + ...fleetResponses, + ]); + + return Promise.resolve(results); }); }; @@ -172,45 +292,80 @@ describe('Action Log API', () => { }); it('should return an empty array when nothing in audit log', async () => { - havingActionsAndResponses([], []); - const response = await getActivityLog({ agent_id: mockID }); + mockActions({ numActions: 0 }); + + const response = await getActivityLog({ agent_id: mockAgentID }); expect(response.ok).toBeCalled(); expect((response.ok.mock.calls[0][0]?.body as ActivityLog).data).toHaveLength(0); }); - it('should have actions and action responses', async () => { - havingActionsAndResponses( - [ - aMockAction().withAgent(mockID).withAction('isolate').withID(actionID), - aMockAction().withAgent(mockID).withAction('unisolate'), - ], - [aMockResponse(actionID, mockID).forAction(actionID).forAgent(mockID)] - ); - const response = await getActivityLog({ agent_id: mockID }); + it('should return fleet actions, fleet responses and endpoint responses', async () => { + mockActions({ + numActions: 2, + hasFleetActions: true, + hasFleetResponses: true, + hasResponses: true, + }); + + const response = await getActivityLog({ agent_id: mockAgentID }); + const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; + expect(response.ok).toBeCalled(); + expect(responseBody.data).toHaveLength(6); + + expect( + responseBody.data.filter((e) => (e.item.data as EndpointActionResponse).completed_at) + ).toHaveLength(2); + expect( + responseBody.data.filter((e) => (e.item.data as EndpointAction).expiration) + ).toHaveLength(2); + }); + + it('should return only fleet actions and no responses', async () => { + mockActions({ numActions: 2, hasFleetActions: true }); + + const response = await getActivityLog({ agent_id: mockAgentID }); const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; + expect(response.ok).toBeCalled(); + expect(responseBody.data).toHaveLength(2); + + expect( + responseBody.data.filter((e) => (e.item.data as EndpointAction).expiration) + ).toHaveLength(2); + }); + + it('should only have fleet data', async () => { + mockActions({ numActions: 2, hasFleetActions: true, hasFleetResponses: true }); + const response = await getActivityLog({ agent_id: mockAgentID }); + const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; expect(response.ok).toBeCalled(); - expect(responseBody.data).toHaveLength(3); - expect(responseBody.data.filter((e) => e.type === 'response')).toHaveLength(1); - expect(responseBody.data.filter((e) => e.type === 'action')).toHaveLength(2); + expect(responseBody.data).toHaveLength(4); + + expect( + responseBody.data.filter((e) => (e.item.data as EndpointAction).expiration) + ).toHaveLength(2); + expect( + responseBody.data.filter((e) => (e.item.data as EndpointActionResponse).completed_at) + ).toHaveLength(2); }); it('should throw errors when no results for some agentID', async () => { havingErrors(); try { - await getActivityLog({ agent_id: mockID }); + await getActivityLog({ agent_id: mockAgentID }); } catch (error) { - expect(error.message).toEqual(`Error fetching actions log for agent_id ${mockID}`); + expect(error.message).toEqual(`Error fetching actions log for agent_id ${mockAgentID}`); } }); it('should return date ranges if present in the query', async () => { - havingActionsAndResponses([], []); + mockActions({ numActions: 0 }); + const startDate = new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(); const endDate = new Date().toISOString(); const response = await getActivityLog( - { agent_id: mockID }, + { agent_id: mockAgentID }, { page: 1, page_size: 50, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index e12299bedbb34..02f0cb4867646 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -17,6 +17,7 @@ import { ENDPOINT_ACTION_RESPONSES_DS, ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE, + failedFleetActionErrorCode, } from '../../../../common/endpoint/constants'; import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; import { @@ -33,6 +34,7 @@ import { getMetadataForEndpoints } from '../../services'; import { EndpointAppContext } from '../../types'; import { APP_ID } from '../../../../common/constants'; import { userCanIsolate } from '../../../../common/endpoint/actions'; +import { doLogsEndpointActionDsExists } from '../../utils'; /** * Registers the Host-(un-)isolation routes @@ -78,7 +80,7 @@ const createFailedActionResponseEntry = async ({ body: { ...doc, error: { - code: '424', + code: failedFleetActionErrorCode, message: 'Failed to deliver action request to fleet', }, }, @@ -88,31 +90,6 @@ const createFailedActionResponseEntry = async ({ } }; -const doLogsEndpointActionDsExists = async ({ - context, - logger, - dataStreamName, -}: { - context: SecuritySolutionRequestHandlerContext; - logger: Logger; - dataStreamName: string; -}): Promise => { - try { - const esClient = context.core.elasticsearch.client.asInternalUser; - const doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ - name: dataStreamName, - }); - return doesIndexTemplateExist.statusCode === 404 ? false : true; - } catch (error) { - const errorType = error?.type ?? ''; - if (errorType !== 'resource_not_found_exception') { - logger.error(error); - throw error; - } - return false; - } -}; - export const isolationRequestHandler = function ( endpointContext: EndpointAppContext, isolate: boolean diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts index 34f7d140a78de..b50d80a9bae71 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -13,11 +13,43 @@ import { ApiResponse } from '@elastic/elasticsearch'; import moment from 'moment'; import uuid from 'uuid'; import { + LogsEndpointAction, + LogsEndpointActionResponse, EndpointAction, EndpointActionResponse, ISOLATION_ACTIONS, } from '../../../../common/endpoint/types'; +export interface Results { + _index: string; + _source: + | LogsEndpointAction + | LogsEndpointActionResponse + | EndpointAction + | EndpointActionResponse; +} +export const mockAuditLogSearchResult = (results?: Results[]) => { + const response = { + body: { + hits: { + total: { value: results?.length ?? 0, relation: 'eq' }, + hits: + results?.map((a: Results) => ({ + _index: a._index, + _id: Math.random().toString(36).split('.')[1], + _score: 0.0, + _source: a._source, + })) ?? [], + }, + }, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }; + return response; +}; + export const mockSearchResult = (results: any = []): ApiResponse => { return { body: { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts index 711d78ba51b59..d59ecb674196c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts @@ -6,15 +6,28 @@ */ import { ElasticsearchClient, Logger } from 'kibana/server'; +import { SearchHit, SearchResponse } from '@elastic/elasticsearch/api/types'; +import { ApiResponse } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; import { SecuritySolutionRequestHandlerContext } from '../../types'; import { ActivityLog, + ActivityLogEntry, EndpointAction, + LogsEndpointAction, EndpointActionResponse, EndpointPendingActions, + LogsEndpointActionResponse, } from '../../../common/endpoint/types'; -import { catchAndWrapError } from '../utils'; +import { + catchAndWrapError, + categorizeActionResults, + categorizeResponseResults, + getActionRequestsResult, + getActionResponsesResult, + getTimeSortedData, + getUniqueLogData, +} from '../utils'; import { EndpointMetadataService } from './metadata'; const PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME = 300000; // 300k ms === 5 minutes @@ -38,9 +51,9 @@ export const getAuditLogResponse = async ({ }): Promise => { const size = Math.floor(pageSize / 2); const from = page <= 1 ? 0 : page * size - size + 1; - const esClient = context.core.elasticsearch.client.asCurrentUser; + const data = await getActivityLog({ - esClient, + context, from, size, startDate, @@ -59,7 +72,7 @@ export const getAuditLogResponse = async ({ }; const getActivityLog = async ({ - esClient, + context, size, from, startDate, @@ -67,83 +80,39 @@ const getActivityLog = async ({ elasticAgentId, logger, }: { - esClient: ElasticsearchClient; + context: SecuritySolutionRequestHandlerContext; elasticAgentId: string; size: number; from: number; startDate: string; endDate: string; logger: Logger; -}) => { - const options = { - headers: { - 'X-elastic-product-origin': 'fleet', - }, - ignore: [404], - }; - - let actionsResult; - let responsesResult; - const dateFilters = [ - { range: { '@timestamp': { gte: startDate } } }, - { range: { '@timestamp': { lte: endDate } } }, - ]; +}): Promise => { + let actionsResult: ApiResponse, unknown>; + let responsesResult: ApiResponse, unknown>; try { // fetch actions with matching agent_id - const baseActionFilters = [ - { term: { agents: elasticAgentId } }, - { term: { input_type: 'endpoint' } }, - { term: { type: 'INPUT_ACTION' } }, - ]; - const actionsFilters = [...baseActionFilters, ...dateFilters]; - actionsResult = await esClient.search( - { - index: AGENT_ACTIONS_INDEX, - size, - from, - body: { - query: { - bool: { - // @ts-ignore - filter: actionsFilters, - }, - }, - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - }, - }, - options - ); - const actionIds = actionsResult?.body?.hits?.hits?.map( - (e) => (e._source as EndpointAction).action_id - ); + const { actionIds, actionRequests } = await getActionRequestsResult({ + context, + logger, + elasticAgentId, + startDate, + endDate, + size, + from, + }); + actionsResult = actionRequests; - // fetch responses with matching `action_id`s - const baseResponsesFilter = [ - { term: { agent_id: elasticAgentId } }, - { terms: { action_id: actionIds } }, - ]; - const responsesFilters = [...baseResponsesFilter, ...dateFilters]; - responsesResult = await esClient.search( - { - index: AGENT_ACTIONS_RESULTS_INDEX, - size: 1000, - body: { - query: { - bool: { - filter: responsesFilters, - }, - }, - }, - }, - options - ); + // fetch responses with matching unique set of `action_id`s + responsesResult = await getActionResponsesResult({ + actionIds: [...new Set(actionIds)], // de-dupe `action_id`s + context, + logger, + elasticAgentId, + startDate, + endDate, + }); } catch (error) { logger.error(error); throw error; @@ -153,21 +122,26 @@ const getActivityLog = async ({ throw new Error(`Error fetching actions log for agent_id ${elasticAgentId}`); } - const responses = responsesResult?.body?.hits?.hits?.length - ? responsesResult?.body?.hits?.hits?.map((e) => ({ - type: 'response', - item: { id: e._id, data: e._source }, - })) - : []; - const actions = actionsResult?.body?.hits?.hits?.length - ? actionsResult?.body?.hits?.hits?.map((e) => ({ - type: 'action', - item: { id: e._id, data: e._source }, - })) - : []; - const sortedData = ([...responses, ...actions] as ActivityLog['data']).sort((a, b) => - new Date(b.item.data['@timestamp']) > new Date(a.item.data['@timestamp']) ? 1 : -1 - ); + // label record as `action`, `fleetAction` + const responses = categorizeResponseResults({ + results: responsesResult?.body?.hits?.hits as Array< + SearchHit + >, + }); + + // label record as `response`, `fleetResponse` + const actions = categorizeActionResults({ + results: actionsResult?.body?.hits?.hits as Array< + SearchHit + >, + }); + + // filter out the duplicate endpoint actions that also have fleetActions + // include endpoint actions that have no fleet actions + const uniqueLogData = getUniqueLogData([...responses, ...actions]); + + // sort by @timestamp in desc order, newest first + const sortedData = getTimeSortedData(uniqueLogData); return sortedData; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts new file mode 100644 index 0000000000000..f75b265bf24d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts @@ -0,0 +1,266 @@ +/* + * 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 { Logger } from 'kibana/server'; +import { SearchRequest } from 'src/plugins/data/public'; +import { SearchHit, SearchResponse } from '@elastic/elasticsearch/api/types'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; +import { + ENDPOINT_ACTIONS_INDEX, + ENDPOINT_ACTION_RESPONSES_INDEX, + failedFleetActionErrorCode, +} from '../../../common/endpoint/constants'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { + ActivityLog, + ActivityLogAction, + EndpointActivityLogAction, + ActivityLogActionResponse, + EndpointActivityLogActionResponse, + ActivityLogItemTypes, + EndpointAction, + LogsEndpointAction, + EndpointActionResponse, + LogsEndpointActionResponse, + ActivityLogEntry, +} from '../../../common/endpoint/types'; +import { doesLogsEndpointActionsIndexExist } from '../utils'; + +const actionsIndices = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; +const responseIndices = [AGENT_ACTIONS_RESULTS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX]; +export const logsEndpointActionsRegex = new RegExp(`(^\.ds-\.logs-endpoint\.actions-default-).+`); +export const logsEndpointResponsesRegex = new RegExp( + `(^\.ds-\.logs-endpoint\.action\.responses-default-).+` +); +const queryOptions = { + headers: { + 'X-elastic-product-origin': 'fleet', + }, + ignore: [404], +}; + +const getDateFilters = ({ startDate, endDate }: { startDate: string; endDate: string }) => { + return [ + { range: { '@timestamp': { gte: startDate } } }, + { range: { '@timestamp': { lte: endDate } } }, + ]; +}; + +export const getUniqueLogData = (activityLogEntries: ActivityLogEntry[]): ActivityLogEntry[] => { + // find the error responses for actions that didn't make it to fleet index + const onlyResponsesForFleetErrors = activityLogEntries + .filter( + (e) => + e.type === ActivityLogItemTypes.RESPONSE && + e.item.data.error?.code === failedFleetActionErrorCode + ) + .map( + (e: ActivityLogEntry) => (e.item.data as LogsEndpointActionResponse).EndpointActions.action_id + ); + + // all actions and responses minus endpoint actions. + const nonEndpointActionsDocs = activityLogEntries.filter( + (e) => e.type !== ActivityLogItemTypes.ACTION + ); + + // only endpoint actions that match the error responses + const onlyEndpointActionsDocWithoutFleetActions = activityLogEntries + .filter((e) => e.type === ActivityLogItemTypes.ACTION) + .filter((e: ActivityLogEntry) => + onlyResponsesForFleetErrors.includes( + (e.item.data as LogsEndpointAction).EndpointActions.action_id + ) + ); + + // join the error actions and the rest + return [...nonEndpointActionsDocs, ...onlyEndpointActionsDocWithoutFleetActions]; +}; + +export const categorizeResponseResults = ({ + results, +}: { + results: Array>; +}): Array => { + return results?.length + ? results?.map((e) => { + const isResponseDoc: boolean = matchesIndexPattern({ + regexPattern: logsEndpointResponsesRegex, + index: e._index, + }); + return isResponseDoc + ? { + type: ActivityLogItemTypes.RESPONSE, + item: { id: e._id, data: e._source as LogsEndpointActionResponse }, + } + : { + type: ActivityLogItemTypes.FLEET_RESPONSE, + item: { id: e._id, data: e._source as EndpointActionResponse }, + }; + }) + : []; +}; + +export const categorizeActionResults = ({ + results, +}: { + results: Array>; +}): Array => { + return results?.length + ? results?.map((e) => { + const isActionDoc: boolean = matchesIndexPattern({ + regexPattern: logsEndpointActionsRegex, + index: e._index, + }); + return isActionDoc + ? { + type: ActivityLogItemTypes.ACTION, + item: { id: e._id, data: e._source as LogsEndpointAction }, + } + : { + type: ActivityLogItemTypes.FLEET_ACTION, + item: { id: e._id, data: e._source as EndpointAction }, + }; + }) + : []; +}; + +export const getTimeSortedData = (data: ActivityLog['data']): ActivityLog['data'] => { + return data.sort((a, b) => + new Date(b.item.data['@timestamp']) > new Date(a.item.data['@timestamp']) ? 1 : -1 + ); +}; + +export const getActionRequestsResult = async ({ + context, + logger, + elasticAgentId, + startDate, + endDate, + size, + from, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + elasticAgentId: string; + startDate: string; + endDate: string; + size: number; + from: number; +}): Promise<{ + actionIds: string[]; + actionRequests: ApiResponse, unknown>; +}> => { + const dateFilters = getDateFilters({ startDate, endDate }); + const baseActionFilters = [ + { term: { agents: elasticAgentId } }, + { term: { input_type: 'endpoint' } }, + { term: { type: 'INPUT_ACTION' } }, + ]; + const actionsFilters = [...baseActionFilters, ...dateFilters]; + + const hasLogsEndpointActionsIndex = await doesLogsEndpointActionsIndexExist({ + context, + logger, + indexName: ENDPOINT_ACTIONS_INDEX, + }); + + const actionsSearchQuery: SearchRequest = { + index: hasLogsEndpointActionsIndex ? actionsIndices : AGENT_ACTIONS_INDEX, + size, + from, + body: { + query: { + bool: { + filter: actionsFilters, + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }; + + let actionRequests: ApiResponse, unknown>; + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + actionRequests = await esClient.search(actionsSearchQuery, queryOptions); + const actionIds = actionRequests?.body?.hits?.hits?.map((e) => { + return logsEndpointActionsRegex.test(e._index) + ? (e._source as LogsEndpointAction).EndpointActions.action_id + : (e._source as EndpointAction).action_id; + }); + + return { actionIds, actionRequests }; + } catch (error) { + logger.error(error); + throw error; + } +}; + +export const getActionResponsesResult = async ({ + context, + logger, + elasticAgentId, + actionIds, + startDate, + endDate, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + elasticAgentId: string; + actionIds: string[]; + startDate: string; + endDate: string; +}): Promise, unknown>> => { + const dateFilters = getDateFilters({ startDate, endDate }); + const baseResponsesFilter = [ + { term: { agent_id: elasticAgentId } }, + { terms: { action_id: actionIds } }, + ]; + const responsesFilters = [...baseResponsesFilter, ...dateFilters]; + + const hasLogsEndpointActionResponsesIndex = await doesLogsEndpointActionsIndexExist({ + context, + logger, + indexName: ENDPOINT_ACTION_RESPONSES_INDEX, + }); + + const responsesSearchQuery: SearchRequest = { + index: hasLogsEndpointActionResponsesIndex ? responseIndices : AGENT_ACTIONS_RESULTS_INDEX, + size: 1000, + body: { + query: { + bool: { + filter: responsesFilters, + }, + }, + }, + }; + + let actionResponses: ApiResponse, unknown>; + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + actionResponses = await esClient.search(responsesSearchQuery, queryOptions); + } catch (error) { + logger.error(error); + throw error; + } + return actionResponses; +}; + +const matchesIndexPattern = ({ + regexPattern, + index, +}: { + regexPattern: RegExp; + index: string; +}): boolean => regexPattern.test(index); diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/utils/index.ts index 34cabf79aff0e..6c40073f8c654 100644 --- a/x-pack/plugins/security_solution/server/endpoint/utils/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/utils/index.ts @@ -7,3 +7,5 @@ export * from './fleet_agent_status_to_endpoint_host_status'; export * from './wrap_errors'; +export * from './audit_log_helpers'; +export * from './yes_no_data_stream'; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts new file mode 100644 index 0000000000000..d2894c8c64c14 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { + elasticsearchServiceMock, + savedObjectsClientMock, + loggingSystemMock, +} from 'src/core/server/mocks'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { createRouteHandlerContext } from '../mocks'; +import { + doLogsEndpointActionDsExists, + doesLogsEndpointActionsIndexExist, +} from './yes_no_data_stream'; + +describe('Accurately answers if index template for data stream exists', () => { + let ctxt: jest.Mocked; + + beforeEach(() => { + ctxt = createRouteHandlerContext( + elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClientMock.create() + ); + }); + + const mockEsApiResponse = (response: { body: boolean; statusCode: number }) => { + return jest.fn().mockImplementationOnce(() => Promise.resolve(response)); + }; + + it('Returns FALSE for a non-existent data stream index template', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = mockEsApiResponse({ + body: false, + statusCode: 404, + }); + const doesItExist = await doLogsEndpointActionDsExists({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + dataStreamName: '.test-stream.name', + }); + expect(doesItExist).toBeFalsy(); + }); + + it('Returns TRUE for an existing index', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = mockEsApiResponse({ + body: true, + statusCode: 200, + }); + const doesItExist = await doLogsEndpointActionDsExists({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + dataStreamName: '.test-stream.name', + }); + expect(doesItExist).toBeTruthy(); + }); +}); + +describe('Accurately answers if index exists', () => { + let ctxt: jest.Mocked; + + beforeEach(() => { + ctxt = createRouteHandlerContext( + elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClientMock.create() + ); + }); + + const mockEsApiResponse = (response: { body: boolean; statusCode: number }) => { + return jest.fn().mockImplementationOnce(() => Promise.resolve(response)); + }; + + it('Returns FALSE for a non-existent index', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.exists = mockEsApiResponse({ + body: false, + statusCode: 404, + }); + const doesItExist = await doesLogsEndpointActionsIndexExist({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + indexName: '.test-index.name-default', + }); + expect(doesItExist).toBeFalsy(); + }); + + it('Returns TRUE for an existing index', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.exists = mockEsApiResponse({ + body: true, + statusCode: 200, + }); + const doesItExist = await doesLogsEndpointActionsIndexExist({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + indexName: '.test-index.name-default', + }); + expect(doesItExist).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts new file mode 100644 index 0000000000000..dea2e46c3c258 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.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 { Logger } from 'src/core/server'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; + +export const doLogsEndpointActionDsExists = async ({ + context, + logger, + dataStreamName, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + dataStreamName: string; +}): Promise => { + try { + const esClient = context.core.elasticsearch.client.asInternalUser; + const doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ + name: dataStreamName, + }); + return doesIndexTemplateExist.statusCode === 404 ? false : true; + } catch (error) { + const errorType = error?.type ?? ''; + if (errorType !== 'resource_not_found_exception') { + logger.error(error); + throw error; + } + return false; + } +}; + +export const doesLogsEndpointActionsIndexExist = async ({ + context, + logger, + indexName, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + indexName: string; +}): Promise => { + try { + const esClient = context.core.elasticsearch.client.asInternalUser; + const doesIndexExist = await esClient.indices.exists({ + index: indexName, + }); + return doesIndexExist.statusCode === 404 ? false : true; + } catch (error) { + const errorType = error?.type ?? ''; + if (errorType !== 'index_not_found_exception') { + logger.error(error); + throw error; + } + return false; + } +}; From 78c2a33243690975b048da2aebaf925a93705972 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 05:51:56 -0400 Subject: [PATCH 07/10] [Security Solution][Endpoint] Fix unhandled promise rejections in skipped tests (#115354) (#115399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix errors and comment code in middleware (pending to fix this) * Fix endpoint list middleware test * Fix policy TA layout test * Fix test returning missing promise Co-authored-by: David Sánchez --- .../search_exceptions.test.tsx | 1 - .../management/pages/endpoint_hosts/mocks.ts | 39 ++++++------- .../endpoint_hosts/store/middleware.test.ts | 4 +- .../policy_trusted_apps_layout.test.tsx | 56 +++++++++++++------ 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx index d7db249475df7..084978d35d03a 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx @@ -20,7 +20,6 @@ jest.mock('../../../common/components/user_privileges/use_endpoint_privileges'); let onSearchMock: jest.Mock; const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 describe('Search exceptions', () => { let appTestContext: AppContextTestRender; let renderResult: ReturnType; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts index e0b5837c2f78a..c724773593f53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -122,30 +122,27 @@ export const endpointActivityLogHttpMock = const responseData = fleetActionGenerator.generateResponse({ agent_id: endpointMetadata.agent.id, }); - return { - body: { - page: 1, - pageSize: 50, - startDate: 'now-1d', - endDate: 'now', - data: [ - { - type: 'response', - item: { - id: '', - data: responseData, - }, + page: 1, + pageSize: 50, + startDate: 'now-1d', + endDate: 'now', + data: [ + { + type: 'response', + item: { + id: '', + data: responseData, }, - { - type: 'action', - item: { - id: '', - data: actionData, - }, + }, + { + type: 'action', + item: { + id: '', + data: actionData, }, - ], - }, + }, + ], }; }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 43fa4e104067f..81c4dc6f2f7de 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -61,8 +61,7 @@ jest.mock('../../../../common/lib/kibana'); type EndpointListStore = Store, Immutable>; -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -describe.skip('endpoint list middleware', () => { +describe('endpoint list middleware', () => { const getKibanaServicesMock = KibanaServices.get as jest.Mock; let fakeCoreStart: jest.Mocked; let depsStart: DepsStartMock; @@ -390,7 +389,6 @@ describe.skip('endpoint list middleware', () => { it('should call get Activity Log API with correct paging options', async () => { dispatchUserChangedUrl(); - const updatePagingDispatched = waitForAction('endpointDetailsActivityLogUpdatePaging'); dispatchGetActivityLogPaging({ page: 3 }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx index e519d19d60fdc..43e19c00bcc8e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx @@ -19,20 +19,14 @@ import { createLoadedResourceState, isLoadedResourceState } from '../../../../.. import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; import { policyListApiPathHandlers } from '../../../store/test_mock_utils'; -import { licenseService } from '../../../../../../common/hooks/use_license'; +import { + EndpointPrivileges, + useEndpointPrivileges, +} from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; jest.mock('../../../../trusted_apps/service'); -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); +jest.mock('../../../../../../common/components/user_privileges/use_endpoint_privileges'); +const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; let mockedContext: AppContextTestRender; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; @@ -42,8 +36,17 @@ let coreStart: AppContextTestRender['coreStart']; let http: typeof coreStart.http; const generator = new EndpointDocGenerator(); -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -describe.skip('Policy trusted apps layout', () => { +describe('Policy trusted apps layout', () => { + const loadedUserEndpointPrivilegesState = ( + endpointOverrides: Partial = {} + ): EndpointPrivileges => ({ + loading: false, + canAccessFleet: true, + canAccessEndpointManagement: true, + isPlatinumPlus: true, + ...endpointOverrides, + }); + beforeEach(() => { mockedContext = createAppRootMockRenderer(); http = mockedContext.coreStart.http; @@ -59,6 +62,14 @@ describe.skip('Policy trusted apps layout', () => { }); } + // GET Agent status for agent policy + if (path === '/api/fleet/agent-status') { + return Promise.resolve({ + results: { events: 0, total: 5, online: 3, error: 1, offline: 1 }, + success: true, + }); + } + // Get package data // Used in tests that route back to the list if (policyListApiHandlers[path]) { @@ -78,6 +89,10 @@ describe.skip('Policy trusted apps layout', () => { render = () => mockedContext.render(); }); + afterAll(() => { + mockUseEndpointPrivileges.mockReset(); + }); + afterEach(() => reactTestingLibrary.cleanup()); it('should renders layout with no existing TA data', async () => { @@ -121,7 +136,11 @@ describe.skip('Policy trusted apps layout', () => { }); it('should hide assign button on empty state with unassigned policies when downgraded to a gold or below license', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + mockUseEndpointPrivileges.mockReturnValue( + loadedUserEndpointPrivilegesState({ + isPlatinumPlus: false, + }) + ); const component = render(); mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); @@ -133,8 +152,13 @@ describe.skip('Policy trusted apps layout', () => { }); expect(component.queryByTestId('assign-ta-button')).toBeNull(); }); + it('should hide the `Assign trusted applications` button when there is data and the license is downgraded to gold or below', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + mockUseEndpointPrivileges.mockReturnValue( + loadedUserEndpointPrivilegesState({ + isPlatinumPlus: false, + }) + ); TrustedAppsHttpServiceMock.mockImplementation(() => { return { getTrustedAppsList: () => getMockListResponse(), From 2d953bc83c34cb427030815bf976cc7d1660f594 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 06:12:26 -0400 Subject: [PATCH 08/10] Fix alerts Count table title overflow wraps prematurely (#115364) (#115510) Co-authored-by: Pablo Machado --- .../components/alerts_kpis/alerts_count_panel/index.tsx | 2 +- .../components/alerts_kpis/alerts_histogram_panel/index.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 29324d186784e..c8d45ca67068a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -94,7 +94,7 @@ export const AlertsCountPanel = memo( {i18n.COUNT_TABLE_TITLE}} titleSize="s" hideSubtitle > diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 0613c619d89b9..07fa81f27684c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -257,7 +257,11 @@ export const AlertsHistogramPanel = memo( }, [showLinkToAlerts, goToDetectionEngine, formatUrl]); const titleText = useMemo( - () => (onlyField == null ? title : i18n.TOP(onlyField)), + () => ( + + {onlyField == null ? title : i18n.TOP(onlyField)} + + ), [onlyField, title] ); From 65786cf25123381e5124c76687f7b0a15600b68e Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 06:17:37 -0400 Subject: [PATCH 09/10] [Security Solution][Endpoint] Change Trusted Apps to use `item_id` as its identifier and Enable Trusted Apps filtering by id in the UI (#115276) (#115509) * Add `item_id` to list of searchable fields * trusted apps api changes to use `item_id` instead of SO `id` * Change Policy Details Trusted App "View all details" action URL to show TA list filtered by the TA id Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> --- .../list/policy_trusted_apps_list.test.tsx | 2 +- .../list/policy_trusted_apps_list.tsx | 2 +- .../pages/trusted_apps/constants.ts | 1 + .../routes/trusted_apps/handlers.test.ts | 5 +- .../endpoint/routes/trusted_apps/mapping.ts | 2 +- .../routes/trusted_apps/service.test.ts | 24 +++++- .../endpoint/routes/trusted_apps/service.ts | 77 +++++++++++++------ 7 files changed, 84 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index a8d3cc1505463..b5bfc16db2899 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -207,7 +207,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { expect(appTestContext.coreStart.application.navigateToApp).toHaveBeenCalledWith( APP_ID, expect.objectContaining({ - path: '/administration/trusted_apps?show=edit&id=89f72d8a-05b5-4350-8cad-0dc3661d6e67', + path: '/administration/trusted_apps?filter=89f72d8a-05b5-4350-8cad-0dc3661d6e67', }) ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx index f6afd9d502486..7b1f8753831c8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx @@ -113,7 +113,7 @@ export const PolicyTrustedAppsList = memo( for (const trustedApp of trustedAppItems) { const isGlobal = trustedApp.effectScope.type === 'global'; - const viewUrlPath = getTrustedAppsListPath({ id: trustedApp.id, show: 'edit' }); + const viewUrlPath = getTrustedAppsListPath({ filter: trustedApp.id }); const assignedPoliciesMenuItems: ArtifactEntryCollapsibleCardProps['policies'] = trustedApp.effectScope.type === 'global' ? undefined diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts index 0602ae18c1408..beefb8587d787 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts @@ -8,6 +8,7 @@ export const SEARCHABLE_FIELDS: Readonly = [ `name`, `description`, + 'item_id', `entries.value`, `entries.entries.value`, ]; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 547c1f6a2e5ff..614ad4fb548ea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -110,7 +110,7 @@ const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } const packagePolicyClient = createPackagePolicyServiceMock() as jest.Mocked; -describe('handlers', () => { +describe('TrustedApps API Handlers', () => { beforeEach(() => { packagePolicyClient.getByIDs.mockReset(); }); @@ -195,6 +195,7 @@ describe('handlers', () => { const mockResponse = httpServerMock.createResponseFactory(); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await deleteTrustedAppHandler( createHandlerContextMock(), @@ -582,7 +583,7 @@ describe('handlers', () => { }); it('should return 404 if trusted app does not exist', async () => { - exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await updateHandler( createHandlerContextMock(), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index 2c085c14db009..08c1a3a809d4a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -122,7 +122,7 @@ export const exceptionListItemToTrustedApp = ( const grouped = entriesToConditionEntriesMap(exceptionListItem.entries); return { - id: exceptionListItem.id, + id: exceptionListItem.item_id, version: exceptionListItem._version || '', name: exceptionListItem.name, description: exceptionListItem.description, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index dce84df735929..c57416ff1c974 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -85,9 +85,10 @@ const TRUSTED_APP: TrustedApp = { ], }; -describe('service', () => { +describe('TrustedApps service', () => { beforeEach(() => { exceptionsListClient.deleteExceptionListItem.mockReset(); + exceptionsListClient.getExceptionListItem.mockReset(); exceptionsListClient.createExceptionListItem.mockReset(); exceptionsListClient.findExceptionListItem.mockReset(); exceptionsListClient.createTrustedAppsList.mockReset(); @@ -96,6 +97,7 @@ describe('service', () => { describe('deleteTrustedApp', () => { it('should delete existing trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); expect(await deleteTrustedApp(exceptionsListClient, { id: '123' })).toBeUndefined(); @@ -107,6 +109,7 @@ describe('service', () => { }); it('should throw for non existing trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); await expect(deleteTrustedApp(exceptionsListClient, { id: '123' })).rejects.toBeInstanceOf( @@ -393,7 +396,7 @@ describe('service', () => { }); it('should throw a Not Found error if trusted app is not found prior to making update', async () => { - exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await expect( updateTrustedApp( exceptionsListClient, @@ -489,5 +492,22 @@ describe('service', () => { TrustedAppNotFoundError ); }); + + it('should try to find trusted app by `itemId` and then by `id`', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); + await getTrustedApp(exceptionsListClient, '123').catch(() => Promise.resolve()); + + expect(exceptionsListClient.getExceptionListItem).toHaveBeenCalledTimes(2); + expect(exceptionsListClient.getExceptionListItem).toHaveBeenNthCalledWith(1, { + itemId: '123', + id: undefined, + namespaceType: 'agnostic', + }); + expect(exceptionsListClient.getExceptionListItem).toHaveBeenNthCalledWith(2, { + itemId: undefined, + id: '123', + namespaceType: 'agnostic', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index 856a615c1ffa2..7a4b2372ece8f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -15,13 +15,13 @@ import { DeleteTrustedAppsRequestParams, GetOneTrustedAppResponse, GetTrustedAppsListRequest, - GetTrustedAppsSummaryResponse, GetTrustedAppsListResponse, + GetTrustedAppsSummaryRequest, + GetTrustedAppsSummaryResponse, PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, PutTrustedAppUpdateRequest, PutTrustedAppUpdateResponse, - GetTrustedAppsSummaryRequest, TrustedApp, } from '../../../../common/endpoint/types'; @@ -33,8 +33,8 @@ import { } from './mapping'; import { TrustedAppNotFoundError, - TrustedAppVersionConflictError, TrustedAppPolicyNotExistsError, + TrustedAppVersionConflictError, } from './errors'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { PackagePolicy } from '../../../../../fleet/common'; @@ -87,30 +87,61 @@ const isUserTryingToModifyEffectScopeWithoutPermissions = ( } }; -export const deleteTrustedApp = async ( +/** + * Attempts to first fine the ExceptionItem using `item_id` and if not found, then a second attempt wil be done + * against the Saved Object `id`. + * @param exceptionsListClient + * @param id + */ +export const findTrustedAppExceptionItemByIdOrItemId = async ( exceptionsListClient: ExceptionListClient, - { id }: DeleteTrustedAppsRequestParams -) => { - const exceptionListItem = await exceptionsListClient.deleteExceptionListItem({ - id, + id: string +): Promise => { + const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ + itemId: id, + id: undefined, + namespaceType: 'agnostic', + }); + + if (trustedAppExceptionItem) { + return trustedAppExceptionItem; + } + + return exceptionsListClient.getExceptionListItem({ itemId: undefined, + id, namespaceType: 'agnostic', }); +}; - if (!exceptionListItem) { +export const deleteTrustedApp = async ( + exceptionsListClient: ExceptionListClient, + { id }: DeleteTrustedAppsRequestParams +): Promise => { + const trustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); + + if (!trustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); } + + await exceptionsListClient.deleteExceptionListItem({ + id: trustedAppExceptionItem.id, + itemId: undefined, + namespaceType: 'agnostic', + }); }; export const getTrustedApp = async ( exceptionsListClient: ExceptionListClient, id: string ): Promise => { - const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ - itemId: '', - id, - namespaceType: 'agnostic', - }); + const trustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); if (!trustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); @@ -189,19 +220,18 @@ export const updateTrustedApp = async ( updatedTrustedApp: PutTrustedAppUpdateRequest, isAtLeastPlatinum: boolean ): Promise => { - const currentTrustedApp = await exceptionsListClient.getExceptionListItem({ - itemId: '', - id, - namespaceType: 'agnostic', - }); + const currentTrustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); - if (!currentTrustedApp) { + if (!currentTrustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); } if ( isUserTryingToModifyEffectScopeWithoutPermissions( - exceptionListItemToTrustedApp(currentTrustedApp), + exceptionListItemToTrustedApp(currentTrustedAppExceptionItem), updatedTrustedApp, isAtLeastPlatinum ) @@ -226,7 +256,10 @@ export const updateTrustedApp = async ( try { updatedTrustedAppExceptionItem = await exceptionsListClient.updateExceptionListItem( - updatedTrustedAppToUpdateExceptionListItemOptions(currentTrustedApp, updatedTrustedApp) + updatedTrustedAppToUpdateExceptionListItemOptions( + currentTrustedAppExceptionItem, + updatedTrustedApp + ) ); } catch (e) { if (e?.output?.statusCode === 409) { From 8fec45c45972495ff9f8e95b555509501c2eeadc Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 06:47:44 -0400 Subject: [PATCH 10/10] [Discover] Enable description for saved search modal (#114257) (#115514) * [Discover] enable description for saved search * [Discover] remove i18n translations for removed description * [Discover] apply Tim's suggestion * [Discover] update snapshot * [Discover] reorder top nav buttons in tests * [Description] fix description save action Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> --- .../components/top_nav/discover_topnav.test.tsx | 2 +- .../components/top_nav/get_top_nav_links.test.ts | 16 +++++++++------- .../main/components/top_nav/get_top_nav_links.ts | 4 +++- .../main/components/top_nav/on_save_search.tsx | 10 +++++----- .../embeddable/saved_search_embeddable.tsx | 4 ++++ .../plugins/translations/translations/ja-JP.json | 1 - .../plugins/translations/translations/zh-CN.json | 1 - 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx index 4b572f6e348b8..808346b53304c 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx @@ -42,7 +42,7 @@ describe('Discover topnav component', () => { const props = getProps(true); const component = shallowWithIntl(); const topMenuConfig = component.props().config.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['options', 'new', 'save', 'open', 'share', 'inspect']); + expect(topMenuConfig).toEqual(['options', 'new', 'open', 'share', 'inspect', 'save']); }); test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts index d31ac6e0f2fea..20c5b9bae332d 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts @@ -53,13 +53,6 @@ test('getTopNavLinks result', () => { "run": [Function], "testId": "discoverNewButton", }, - Object { - "description": "Save Search", - "id": "save", - "label": "Save", - "run": [Function], - "testId": "discoverSaveButton", - }, Object { "description": "Open Saved Search", "id": "open", @@ -81,6 +74,15 @@ test('getTopNavLinks result', () => { "run": [Function], "testId": "openInspectorButton", }, + Object { + "description": "Save Search", + "emphasize": true, + "iconType": "save", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "discoverSaveButton", + }, ] `); }); diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts index 81be662470306..44d2999947f41 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts @@ -76,6 +76,8 @@ export const getTopNavLinks = ({ defaultMessage: 'Save Search', }), testId: 'discoverSaveButton', + iconType: 'save', + emphasize: true, run: () => onSaveSearch({ savedSearch, services, indexPattern, navigateTo, state }), }; @@ -153,9 +155,9 @@ export const getTopNavLinks = ({ return [ ...(services.capabilities.advancedSettings.save ? [options] : []), newSearch, - ...(services.capabilities.discover.save ? [saveSearch] : []), openSearch, shareSearch, inspectSearch, + ...(services.capabilities.discover.save ? [saveSearch] : []), ]; }; diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx index 18766b5df7f33..25b04e12c650a 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx @@ -98,16 +98,19 @@ export async function onSaveSearch({ const onSave = async ({ newTitle, newCopyOnSave, + newDescription, isTitleDuplicateConfirmed, onTitleDuplicate, }: { newTitle: string; newCopyOnSave: boolean; + newDescription: string; isTitleDuplicateConfirmed: boolean; onTitleDuplicate: () => void; }) => { const currentTitle = savedSearch.title; savedSearch.title = newTitle; + savedSearch.description = newDescription; const saveOptions: SaveSavedSearchOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, @@ -136,14 +139,11 @@ export async function onSaveSearch({ onClose={() => {}} title={savedSearch.title ?? ''} showCopyOnSave={!!savedSearch.id} + description={savedSearch.description} objectType={i18n.translate('discover.localMenu.saveSaveSearchObjectType', { defaultMessage: 'search', })} - description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { - defaultMessage: - 'Save your Discover search so you can use it in visualizations and dashboards', - })} - showDescription={false} + showDescription={true} /> ); showSaveModal(saveModal, services.core.i18n.Context); diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index 8849806cf5959..89c47559d7b4c 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -402,6 +402,10 @@ export class SavedSearchEmbeddable return this.inspectorAdapters; } + public getDescription() { + return this.savedSearch.description; + } + public destroy() { super.destroy(); if (this.searchProps) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 113eae5d08e07..f909a03909e3f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2534,7 +2534,6 @@ "discover.localMenu.openSavedSearchDescription": "保存された検索を開きます", "discover.localMenu.openTitle": "開く", "discover.localMenu.optionsDescription": "オプション", - "discover.localMenu.saveSaveSearchDescription": "ビジュアライゼーションとダッシュボードで使用できるように Discover の検索を保存します", "discover.localMenu.saveSaveSearchObjectType": "検索", "discover.localMenu.saveSearchDescription": "検索を保存します", "discover.localMenu.saveTitle": "保存", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 15933599699eb..4de407cf8e464 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2560,7 +2560,6 @@ "discover.localMenu.openSavedSearchDescription": "打开已保存搜索", "discover.localMenu.openTitle": "打开", "discover.localMenu.optionsDescription": "选项", - "discover.localMenu.saveSaveSearchDescription": "保存您的 Discover 搜索,以便可以在可视化和仪表板中使用该搜索", "discover.localMenu.saveSaveSearchObjectType": "搜索", "discover.localMenu.saveSearchDescription": "保存搜索", "discover.localMenu.saveTitle": "保存",