diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx index 0d3e06a31759a..4618961c66628 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx @@ -25,6 +25,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import styled from 'styled-components'; +import type { EuiAccordionProps } from '@elastic/eui/src/components/accordion'; + import type { Agent, AgentPolicy, PackagePolicy } from '../../../../../types'; import type { FleetServerAgentComponentUnit } from '../../../../../../../../common/types/models/agent'; import { useLink, useUIExtension } from '../../../../../hooks'; @@ -69,11 +71,20 @@ const StyledEuiLink = styled(EuiLink)` font-size: ${(props) => props.theme.eui.euiFontSizeS}; `; -const CollapsablePanel: React.FC<{ id: string; title: React.ReactNode }> = ({ - id, - title, - children, -}) => { +const CollapsablePanel: React.FC<{ + id: string; + title: React.ReactNode; + 'data-test-subj'?: string; +}> = ({ id, title, children, 'data-test-subj': dataTestSubj }) => { + const arrowProps = useMemo(() => { + if (dataTestSubj) { + return { + 'data-test-subj': `${dataTestSubj}-openCloseToggle`, + }; + } + return undefined; + }, [dataTestSubj]); + return ( = ({ arrowDisplay="left" buttonClassName="ingest-integration-title-button" buttonContent={title} + arrowProps={arrowProps} + data-test-subj={dataTestSubj} > {children} @@ -92,7 +105,8 @@ export const AgentDetailsIntegration: React.FunctionComponent<{ agent: Agent; agentPolicy: AgentPolicy; packagePolicy: PackagePolicy; -}> = memo(({ agent, agentPolicy, packagePolicy }) => { + 'data-test-subj'?: string; +}> = memo(({ agent, agentPolicy, packagePolicy, 'data-test-subj': dataTestSubj }) => { const { getHref } = useLink(); const theme = useEuiTheme(); @@ -204,6 +218,7 @@ export const AgentDetailsIntegration: React.FunctionComponent<{ return (

@@ -234,7 +249,12 @@ export const AgentDetailsIntegration: React.FunctionComponent<{ {showNeedsAttentionBadge && ( - + - {(agentPolicy.package_policies as PackagePolicy[]).map((packagePolicy) => { + {(agentPolicy.package_policies as PackagePolicy[]).map((packagePolicy, index) => { + const testSubj = (packagePolicy.package?.name ?? 'packagePolicy') + '-' + index; + return ( - + ); diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts index d377e8ccd7dfd..e040fa4811677 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts @@ -5,18 +5,102 @@ * 2.0. */ +/* eslint-disable max-classes-per-file */ + import type { DeepPartial } from 'utility-types'; -import { merge } from 'lodash'; +import { merge, set } from 'lodash'; import { gte } from 'semver'; import type { EndpointCapabilities } from '../service/response_actions/constants'; import { BaseDataGenerator } from './base_data_generator'; import type { HostMetadataInterface, OSFields } from '../types'; import { EndpointStatus, HostPolicyResponseActionStatus } from '../types'; +export interface GetCustomEndpointMetadataGeneratorOptions { + /** Version for agent/endpoint. Defaults to the stack version */ + version: string; + /** OS type for the generated endpoint hosts */ + os: 'macOS' | 'windows' | 'linux'; +} + /** * Metadata generator for docs that are sent by the Endpoint running on hosts */ export class EndpointMetadataGenerator extends BaseDataGenerator { + /** + * Returns a Custom `EndpointMetadataGenerator` subclass that will generate specific + * documents based on input arguments + */ + static custom({ + version, + os, + }: Partial = {}): typeof EndpointMetadataGenerator { + return class extends EndpointMetadataGenerator { + generate(overrides: DeepPartial = {}): HostMetadataInterface { + if (version) { + set(overrides, 'agent.version', version); + } + + if (os) { + switch (os) { + case 'linux': + set(overrides, 'host.os', EndpointMetadataGenerator.linuxOSFields); + break; + + case 'macOS': + set(overrides, 'host.os', EndpointMetadataGenerator.macOSFields); + break; + + default: + set(overrides, 'host.os', EndpointMetadataGenerator.windowsOSFields); + } + } + + return super.generate(overrides); + } + }; + } + + public static get windowsOSFields(): OSFields { + return { + name: 'Windows', + full: 'Windows 10', + version: '10.0', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Pro', + }, + }; + } + + public static get linuxOSFields(): OSFields { + return { + Ext: { + variant: 'Debian', + }, + kernel: '4.19.0-21-cloud-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30)', + name: 'Linux', + family: 'debian', + type: 'linux', + version: '10.12', + platform: 'debian', + full: 'Debian 10.12', + }; + } + + public static get macOSFields(): OSFields { + return { + name: 'macOS', + full: 'macOS Monterey', + version: '12.6.1', + platform: 'macOS', + family: 'Darwin', + Ext: { + variant: 'Darwin', + }, + }; + } + /** Generate an Endpoint host metadata document */ generate(overrides: DeepPartial = {}): HostMetadataInterface { const ts = overrides['@timestamp'] ?? new Date().getTime(); @@ -102,16 +186,7 @@ export class EndpointMetadataGenerator extends BaseDataGenerator { protected randomOsFields(): OSFields { return this.randomChoice([ - { - name: 'Windows', - full: 'Windows 10', - version: '10.0', - platform: 'Windows', - family: 'windows', - Ext: { - variant: 'Windows Pro', - }, - }, + EndpointMetadataGenerator.windowsOSFields, { name: 'Windows', full: 'Windows Server 2016', @@ -142,18 +217,8 @@ export class EndpointMetadataGenerator extends BaseDataGenerator { variant: 'Windows Server Release 2', }, }, - { - Ext: { - variant: 'Debian', - }, - kernel: '4.19.0-21-cloud-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30)', - name: 'Linux', - family: 'debian', - type: 'linux', - version: '10.12', - platform: 'debian', - full: 'Debian 10.12', - }, + EndpointMetadataGenerator.linuxOSFields, + EndpointMetadataGenerator.macOSFields, ]); } } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_policy_response_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_policy_response_generator.ts new file mode 100644 index 0000000000000..779bda3a3320c --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_policy_response_generator.ts @@ -0,0 +1,528 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DeepPartial } from '@kbn/utility-types'; +import { mergeWith } from 'lodash'; +import { BaseDataGenerator } from './base_data_generator'; +import type { HostPolicyResponse, HostPolicyResponseAppliedAction } from '../types'; +import { HostPolicyResponseActionStatus } from '../types'; + +const mergeAndReplaceArrays = (destinationObj: T, srcObj: S): T => { + const customizer = (objValue: T[keyof T], srcValue: S[keyof S]) => { + if (Array.isArray(objValue)) { + return srcValue; + } + }; + + return mergeWith(destinationObj, srcObj, customizer); +}; + +export class EndpointPolicyResponseGenerator extends BaseDataGenerator { + generate(overrides: DeepPartial = {}): HostPolicyResponse { + const ts = overrides['@timestamp'] ?? new Date().getTime(); + const agentVersion = overrides.agent?.id ?? this.seededUUIDv4(); + const overallStatus = + overrides.Endpoint?.policy?.applied?.status ?? this.randomHostPolicyResponseActionStatus(); + + // Keep track of used action key'd by their `name` + const usedActions: Record = {}; + const generateActionNames = (): string[] => { + // At least 3, but no more than 15 + const actions = this.randomActionList(Math.max(3, this.randomN(15))); + + return actions.map((action) => { + const actionName = action.name; + + // If action is not yet in the list, add it + if (!usedActions[actionName]) { + usedActions[actionName] = action; + } + + return actionName; + }); + }; + + const policyResponse: HostPolicyResponse = { + data_stream: { + type: 'metrics', + dataset: 'endpoint.policy', + namespace: 'default', + }, + '@timestamp': ts, + agent: { + id: agentVersion, + version: '8.8.0', + }, + elastic: { + agent: { + id: agentVersion, + }, + }, + ecs: { + version: '1.4.0', + }, + host: { + id: this.seededUUIDv4(), + }, + Endpoint: { + policy: { + applied: { + id: this.seededUUIDv4(), + status: overallStatus, + version: 3, + name: 'Protect the worlds information from attack', + endpoint_policy_version: 2, + actions: [], // populated down below + response: { + configurations: { + events: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + logging: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + malware: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + streaming: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + behavior_protection: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + attack_surface_reduction: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + antivirus_registration: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + host_isolation: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + response_actions: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + ransomware: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + memory_protection: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + }, + diagnostic: { + behavior_protection: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + malware: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + ransomware: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + memory_protection: { + concerned_actions: generateActionNames(), + status: HostPolicyResponseActionStatus.success, + }, + }, + }, + artifacts: { + global: { + version: '1.4.0', + identifiers: [ + { + name: 'endpointpe-model', + sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + }, + ], + }, + user: { + version: '1.4.0', + identifiers: [ + { + name: 'user-model', + sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + }, + ], + }, + }, + }, + }, + }, + event: { + created: ts, + id: this.seededUUIDv4(), + kind: 'state', + category: ['host'], + type: ['change'], + module: 'endpoint', + action: 'endpoint_policy_response', + dataset: 'endpoint.policy', + }, + }; + + policyResponse.Endpoint.policy.applied.actions = Object.values(usedActions); + + if (overallStatus !== HostPolicyResponseActionStatus.success) { + const appliedPolicy = policyResponse.Endpoint.policy.applied; + + appliedPolicy.status = overallStatus; + + // set one of the configs responses to also be the same as overall status + const config = this.randomChoice(Object.values(appliedPolicy.response.configurations)); + config.status = overallStatus; + + // ensure at least one of the action for this config. has the same status + const actionName = this.randomChoice(config.concerned_actions); + usedActions[actionName].status = overallStatus; + usedActions[actionName].message = `Failed. ${usedActions[actionName].message.replace( + /successfully /i, + '' + )}`; + } + + return mergeAndReplaceArrays(policyResponse, overrides); + } + + private randomHostPolicyResponseActionStatus(): HostPolicyResponseActionStatus { + return this.randomChoice([ + HostPolicyResponseActionStatus.failure, + HostPolicyResponseActionStatus.success, + HostPolicyResponseActionStatus.warning, + HostPolicyResponseActionStatus.unsupported, + ]); + } + + private randomActionList(count: number = 5): HostPolicyResponseAppliedAction[] { + const usedAction: Record = {}; + + return Array.from({ length: count }, () => { + let tries = 10; // Number of times we try to get a unique action + let action: undefined | HostPolicyResponseAppliedAction; + + while (!action || tries > 0) { + --tries; + action = this.randomAction(); + + if (!usedAction[action.name]) { + usedAction[action.name] = true; + return action; + } + + // try again. action has already been used + action = undefined; + } + + // Last effort to ensure we do return an action + return action ?? this.randomAction(); + }); + } + + private randomAction(status?: HostPolicyResponseActionStatus): HostPolicyResponseAppliedAction { + const action = this.randomChoice([ + { + name: 'configure_antivirus_registration', + message: 'Antivirus registration is not possible on servers', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_ransomware', + message: 'Successfully enabled ransomware prevention with mbr enabled and canaries enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_credential_hardening', + message: 'Successfully read credential protection configuration', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_memory_threat', + message: + 'Successfully enabled memory threat prevention with memory scanning enabled and shellcode protection enabled including trampoline monitoring', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_diagnostic_memory_threat', + message: 'Successfully disabled memory threat protection', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_host_isolation', + message: 'Host is not isolated', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_malicious_behavior', + message: 'Enabled 313 out of 313 malicious behavior rules', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_diagnostic_malicious_behavior', + message: 'Diagnostic rules not enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_user_notification', + message: 'Successfully configured user notification', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_malware', + message: 'Successfully enabled malware prevention', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_diagnostic_malware', + message: 'Successfully disabled malware protection', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_diagnostic_rollback', + message: 'Diagnostic rollback is disabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_rollback', + message: 'Rollback is disabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_kernel', + message: 'Successfully configured kernel', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_output', + message: 'Successfully configured output connection', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_alerts', + message: 'Successfully configured alerts', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_logging', + message: 'Successfully configured logging', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'load_config', + message: 'Successfully parsed configuration', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'download_user_artifacts', + message: 'Successfully downloaded user artifacts', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'download_global_artifacts', + message: 'Global artifacts are available for use', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'connect_kernel', + message: 'Successfully connected to kernel minifilter component', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_process_events', + message: 'Successfully started process event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_sync_image_load_events', + message: 'Successfully started sync image load event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_async_image_load_events', + message: 'Successfully started async image load event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_file_write_events', + message: 'Successfully started file write event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_file_open_events', + message: 'Successfully stopped file open event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_network_events', + message: 'Successfully started network event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_registry_events', + message: 'Successfully started registry event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_thread_events', + message: 'Successfully configured thread events', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_file_access_events', + message: 'Successfully configured file access event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_registry_access_events', + message: 'Successfully configured registry access event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'detect_process_handle_events', + message: 'Successfully started process handle event reporting', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_callstacks', + message: 'Successfully configured callstacks', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_file_events', + message: 'Success enabling file events; current state is enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_network_events', + message: 'Success enabling network events; current state is enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_process_events', + message: 'Success enabling process events; current state is enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_imageload_events', + message: + 'Success enabling image load events; current state is enabled Source configuration changed.', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_dns_events', + message: 'Success enabling dns events; current state is enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_registry_events', + message: 'Success enabling registry events; current state is enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_security_events', + message: 'Success enabling security events; current state is enabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_threat_intelligence_api_events', + message: 'Success disabling injection api events; current state is disabled', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_diagnostic_ransomware', + message: 'Successfully disabled ransomware protection', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'configure_response_actions', + message: 'Successfully configured fleet API for response actions', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'agent_connectivity', + message: 'Failed to connect to Agent', + status: HostPolicyResponseActionStatus.success, + }, + { + name: 'workflow', + message: 'Successfully executed all workflows', + status: HostPolicyResponseActionStatus.success, + }, + ]); + + if (status) { + action.status = status; + } + + return action; + } + + /** + * Generates a Policy Response for `connect_kernel`, which is a typical error on MacOS when + * no system extension failure + */ + generateConnectKernelFailure( + overrides: DeepPartial = {} + ): HostPolicyResponse { + const policyResponse = this.generate( + mergeAndReplaceArrays( + { + // using `success` below for status only so that we don't get back any other errors + Endpoint: { policy: { applied: { status: HostPolicyResponseActionStatus.success } } }, + }, + overrides + ) + ); + + const appliedPolicy = policyResponse.Endpoint.policy.applied; + const actionMessage = 'Failed to connected to kernel minifilter component'; + + appliedPolicy.status = HostPolicyResponseActionStatus.failure; + + // Adjust connect_kernel action to represent a Macos system extension failure + const connectKernelAction = appliedPolicy.actions.find( + (action) => action.name === 'connect_kernel' + ) ?? { + name: 'connect_kernel', + message: actionMessage, + status: HostPolicyResponseActionStatus.failure, + }; + const needsToBeAdded = connectKernelAction.message === ''; + + if (needsToBeAdded) { + appliedPolicy.actions.push(connectKernelAction); + appliedPolicy.response.configurations.malware.concerned_actions.push( + connectKernelAction.name + ); + appliedPolicy.response.configurations.malware.status = HostPolicyResponseActionStatus.failure; + } else { + connectKernelAction.message = actionMessage; + connectKernelAction.status = HostPolicyResponseActionStatus.failure; + + // Find every response config with this action and set it to failure + Object.values(appliedPolicy.response.configurations).forEach((responseConfig) => { + if (responseConfig.concerned_actions.includes(connectKernelAction.name)) { + responseConfig.status = HostPolicyResponseActionStatus.failure; + } + }); + } + + return policyResponse; + } +} diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts index ea5c377102b17..2df3db5b70eeb 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts @@ -96,6 +96,7 @@ export class FleetAgentGenerator extends BaseDataGenerator { }, } : { extra: 'payload' }; + const agentId = overrides?._source?.agent?.id ?? this.randomUUID(); return merge< estypes.SearchHit, @@ -111,7 +112,7 @@ export class FleetAgentGenerator extends BaseDataGenerator { active: true, enrolled_at: now, agent: { - id: this.randomUUID(), + id: agentId, version: this.randomVersion(), }, local_metadata: { @@ -120,7 +121,7 @@ export class FleetAgentGenerator extends BaseDataGenerator { 'build.original': `8.0.0-SNAPSHOT (build: ${this.randomString( 5 )} at 2021-05-07 18:42:49 +0000 UTC)`, - id: this.randomUUID(), + id: agentId, log_level: 'info', snapshot: true, upgradeable: true, diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_policy_response.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_policy_response.ts new file mode 100644 index 0000000000000..101853db3f3c3 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_policy_response.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Client } from '@elastic/elasticsearch'; +import type { HostPolicyResponse } from '../types'; +import { wrapErrorAndRejectPromise } from './utils'; +import { POLICY_RESPONSE_INDEX } from '../constants'; + +export interface IndexedEndpointPolicyResponse { + policyResponses: HostPolicyResponse[]; + /** Index (actual, not an alias) where document was created */ + index: string; + /** The document's id in ES */ + id: string; +} + +export const indexEndpointPolicyResponse = async ( + esClient: Client, + policyResponse: HostPolicyResponse +): Promise => { + const { _index: index, _id: id } = await esClient + .index({ + index: POLICY_RESPONSE_INDEX, + body: policyResponse, + op_type: 'create', + refresh: 'wait_for', + }) + .catch(wrapErrorAndRejectPromise); + + const response: IndexedEndpointPolicyResponse = { + policyResponses: [policyResponse], + index, + id, + }; + + return response; +}; + +export const deleteIndexedEndpointPolicyResponse = async ( + esClient: Client, + indexedData: IndexedEndpointPolicyResponse +) => { + await esClient + .delete( + { + index: indexedData.index, + id: indexedData.id, + refresh: 'wait_for', + }, + { + ignore: [404], + } + ) + .catch(wrapErrorAndRejectPromise); +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts index 056b44310dfb2..0b988d831c923 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts @@ -51,6 +51,7 @@ export const indexFleetAgentForHost = async ( local_metadata: { elastic: { agent: { + id: endpointHost.agent.id, version: kibanaVersion, }, }, diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 9cda7554f0819..d867c25ececf7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable max-classes-per-file */ + import type seedrandom from 'seedrandom'; import { assertNever } from '@kbn/std'; import type { @@ -348,6 +350,25 @@ export class EndpointDocGenerator extends BaseDataGenerator { this.commonInfo = this.createHostData(); } + /** + * Get a custom `EndpointDocGenerator` subclass that customizes certain fields based on input arguments + */ + public static custom({ + CustomMetadataGenerator, + }: Partial<{ + CustomMetadataGenerator: typeof EndpointMetadataGenerator; + }> = {}): typeof EndpointDocGenerator { + return class extends EndpointDocGenerator { + constructor(...options: ConstructorParameters) { + if (CustomMetadataGenerator) { + options[1] = CustomMetadataGenerator; + } + + super(...options); + } + }; + } + /** * Creates new random IP addresses for the host to simulate new DHCP assignment */ @@ -1900,7 +1921,8 @@ export class EndpointDocGenerator extends BaseDataGenerator { status: status(), }, }, - }, + // TODO:PT refactor to use EndpointPolicyResponse Generator + } as HostPolicyResponse['Endpoint']['policy']['applied']['response'], artifacts: { global: { version: '1.4.0', diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index d9d0677ed8f17..eb4d5c7421eef 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -212,6 +212,8 @@ export interface OSFields { platform: string; family: string; Ext: OSFieldsExt; + kernel?: string; + type?: string; } /** @@ -1228,6 +1230,19 @@ export interface HostPolicyResponse { events: HostPolicyResponseConfigurationStatus; logging: HostPolicyResponseConfigurationStatus; streaming: HostPolicyResponseConfigurationStatus; + behavior_protection: HostPolicyResponseConfigurationStatus; + attack_surface_reduction: HostPolicyResponseConfigurationStatus; + antivirus_registration: HostPolicyResponseConfigurationStatus; + host_isolation: HostPolicyResponseConfigurationStatus; + response_actions: HostPolicyResponseConfigurationStatus; + ransomware: HostPolicyResponseConfigurationStatus; + memory_protection: HostPolicyResponseConfigurationStatus; + }; + diagnostic: { + behavior_protection: HostPolicyResponseConfigurationStatus; + malware: HostPolicyResponseConfigurationStatus; + ransomware: HostPolicyResponseConfigurationStatus; + memory_protection: HostPolicyResponseConfigurationStatus; }; }; artifacts: { diff --git a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts index f2ecd2010663a..a48498a7ee43b 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts @@ -10,6 +10,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; +import type { IndexedEndpointPolicyResponse } from '../../../common/endpoint/data_loaders/index_endpoint_policy_response'; +import type { HostPolicyResponse } from '../../../common/endpoint/types'; +import type { IndexEndpointHostsCyTaskOptions } from './types'; import type { DeleteIndexedFleetEndpointPoliciesResponse, IndexedFleetEndpointPolicyResponse, @@ -79,7 +82,7 @@ declare global { task( name: 'indexEndpointHosts', - arg?: { count?: number }, + arg?: IndexEndpointHostsCyTaskOptions, options?: Partial ): Chainable; @@ -100,6 +103,18 @@ declare global { arg: IndexedEndpointRuleAlerts['alerts'], options?: Partial ): Chainable; + + task( + name: 'indexEndpointPolicyResponse', + arg: HostPolicyResponse, + options?: Partial + ): Chainable; + + task( + name: 'deleteIndexedEndpointPolicyResponse', + arg: IndexedEndpointPolicyResponse, + options?: Partial + ): Chainable; } } } diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/policy_response.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/policy_response.cy.ts new file mode 100644 index 0000000000000..3100d4a64fec7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/policy_response.cy.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CyIndexEndpointHosts } from '../../tasks/index_endpoint_hosts'; +import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts'; +import { navigateToEndpointPolicyResponse } from '../../screens/endpoints'; +import type { HostMetadata } from '../../../../../common/endpoint/types'; +import type { IndexedEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; +import { login } from '../../tasks/login'; +import { navigateToFleetAgentDetails } from '../../screens/fleet'; +import { EndpointPolicyResponseGenerator } from '../../../../../common/endpoint/data_generators/endpoint_policy_response_generator'; +import { descriptions } from '../../../components/policy_response/policy_response_friendly_names'; + +describe('Endpoint Policy Response', () => { + let loadedEndpoint: CyIndexEndpointHosts; + let endpointMetadata: HostMetadata; + let loadedPolicyResponse: IndexedEndpointPolicyResponse; + + before(() => { + const policyResponseGenerator = new EndpointPolicyResponseGenerator(); + + indexEndpointHosts({ count: 1, os: 'macOS' }).then((indexEndpoints) => { + loadedEndpoint = indexEndpoints; + endpointMetadata = loadedEndpoint.data.hosts[0]; + + const policyResponseDoc = policyResponseGenerator.generateConnectKernelFailure({ + agent: endpointMetadata.agent, + elastic: endpointMetadata.elastic, + Endpoint: { + policy: { + applied: { + id: endpointMetadata.Endpoint.policy.applied.id, + version: endpointMetadata.Endpoint.policy.applied.version, + name: endpointMetadata.Endpoint.policy.applied.name, + }, + }, + }, + }); + + cy.task('indexEndpointPolicyResponse', policyResponseDoc).then((indexedPolicyResponse) => { + loadedPolicyResponse = indexedPolicyResponse; + }); + }); + }); + + after(() => { + if (loadedEndpoint) { + loadedEndpoint.cleanup(); + } + + if (loadedPolicyResponse) { + cy.task('deleteIndexedEndpointPolicyResponse', loadedPolicyResponse); + } + }); + + beforeEach(() => { + login(); + }); + + describe('from Fleet Agent Details page', () => { + it('should display policy response with errors', () => { + navigateToFleetAgentDetails(endpointMetadata.agent.id); + + cy.getByTestSubj('endpoint-0-accordion').then(($accordion) => { + cy.wrap($accordion) + .findByTestSubj('endpoint-0-accordion-needsAttention') + .should('be.visible'); + + cy.wrap($accordion).findByTestSubj('endpoint-0-accordion-openCloseToggle').click(); + cy.wrap($accordion) + .findByTestSubj('endpointPolicyResponseErrorCallOut') + .should('be.visible') + .findByTestSubj('endpointPolicyResponseMessage') + .should('include.text', descriptions.get('macos_system_ext')); + }); + }); + }); + + describe('from Endpoint List page', () => { + it('should display policy response with errors', () => { + navigateToEndpointPolicyResponse(endpointMetadata.agent.id); + + cy.getByTestSubj('endpointDetailsPolicyResponseFlyoutBody') + .findByTestSubj('endpointPolicyResponseErrorCallOut') + .should('be.visible') + .findByTestSubj('endpointPolicyResponseMessage') + .should('include.text', descriptions.get('macos_system_ext')); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts index 199659142adc0..32a12168aadb0 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts @@ -5,7 +5,19 @@ * 2.0. */ +import { APP_PATH } from '../../../../common/constants'; +import { getEndpointDetailsPath } from '../../common/routing'; + export const AGENT_HOSTNAME_CELL = 'hostnameCellLink'; export const AGENT_POLICY_CELL = 'policyNameCellLink'; export const TABLE_ROW_ACTIONS = 'endpointTableRowActions'; export const TABLE_ROW_ACTIONS_MENU = 'tableRowActionsMenuPanel'; + +export const navigateToEndpointPolicyResponse = ( + endpointAgentId: string +): Cypress.Chainable => { + return cy.visit( + APP_PATH + + getEndpointDetailsPath({ name: 'endpointPolicyResponse', selected_endpoint: endpointAgentId }) + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/fleet.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/fleet.ts index b68cac5c6d495..0269779d41bf1 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/screens/fleet.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/fleet.ts @@ -5,5 +5,19 @@ * 2.0. */ +import { FLEET_BASE_PATH } from '@kbn/fleet-plugin/public/constants'; + export const FLEET_REASSIGN_POLICY_MODAL = 'agentReassignPolicyModal'; export const FLEET_REASSIGN_POLICY_MODAL_CONFIRM_BUTTON = 'confirmModalConfirmButton'; + +export const navigateToFleetAgentDetails = ( + agentId: string +): Cypress.Chainable => { + // FYI: attempted to use fleet's `pagePathGetters()`, but got compile + // errors due to it pulling too many modules + const response = cy.visit(`${FLEET_BASE_PATH}/agents/${agentId}`); + + cy.getByTestSubj('agentPolicyNameLink').should('be.visible'); + + return response; +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 8229613289d2b..b31ee2ec25874 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -8,6 +8,13 @@ // / import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; +import type { IndexedEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; +import { + deleteIndexedEndpointPolicyResponse, + indexEndpointPolicyResponse, +} from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; +import type { HostPolicyResponse } from '../../../../common/endpoint/types'; +import type { IndexEndpointHostsCyTaskOptions } from '../types'; import type { IndexedEndpointRuleAlerts, DeletedIndexedEndpointRuleAlerts, @@ -86,10 +93,14 @@ export const dataLoaders = ( return null; }, - indexEndpointHosts: async (options: { count?: number }) => { + indexEndpointHosts: async (options: IndexEndpointHostsCyTaskOptions = {}) => { const { kbnClient, esClient } = await stackServicesPromise; + const { count: numHosts, version, os } = options; + return cyLoadEndpointDataHandler(esClient, kbnClient, { - numHosts: options.count, + numHosts, + version, + os, }); }, @@ -115,5 +126,19 @@ export const dataLoaders = ( const { esClient, log } = await stackServicesPromise; return deleteIndexedEndpointRuleAlerts(esClient, data, log); }, + + indexEndpointPolicyResponse: async ( + policyResponse: HostPolicyResponse + ): Promise => { + const { esClient } = await stackServicesPromise; + return indexEndpointPolicyResponse(esClient, policyResponse); + }, + + deleteIndexedEndpointPolicyResponse: async ( + indexedData: IndexedEndpointPolicyResponse + ): Promise => { + const { esClient } = await stackServicesPromise; + return deleteIndexedEndpointPolicyResponse(esClient, indexedData).then(() => null); + }, }); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts b/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts index 55ab851df6503..cfff2e9a2a4b0 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts @@ -5,13 +5,10 @@ * 2.0. */ -/* eslint-disable max-classes-per-file */ - import type { Client } from '@elastic/elasticsearch'; import type { KbnClient } from '@kbn/test'; -import type seedrandom from 'seedrandom'; -import { kibanaPackageJson } from '@kbn/repo-info'; import pRetry from 'p-retry'; +import { kibanaPackageJson } from '@kbn/repo-info'; import { STARTED_TRANSFORM_STATES } from '../../../../../common/constants'; import { ENDPOINT_ALERTS_INDEX, @@ -26,18 +23,19 @@ import { POLICY_RESPONSE_INDEX, } from '../../../../../common/endpoint/constants'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; +import type { GetCustomEndpointMetadataGeneratorOptions } from '../../../../../common/endpoint/data_generators/endpoint_metadata_generator'; import { EndpointMetadataGenerator } from '../../../../../common/endpoint/data_generators/endpoint_metadata_generator'; import { indexHostsAndAlerts } from '../../../../../common/endpoint/index_data'; import type { IndexedHostsAndAlertsResponse } from '../../../../../common/endpoint/index_data'; -interface CyLoadEndpointDataOptions { +export interface CyLoadEndpointDataOptions + extends Pick { numHosts: number; numHostDocs: number; alertsPerHost: number; enableFleetIntegration: boolean; generatorSeed: string; waitUntilTransformed: boolean; - customIndexFn: () => Promise; } /** @@ -58,9 +56,14 @@ export const cyLoadEndpointDataHandler = async ( enableFleetIntegration = true, generatorSeed = `cy.${Math.random()}`, waitUntilTransformed = true, - customIndexFn, + version = kibanaPackageJson.version, + os, } = options; + const DocGenerator = EndpointDocGenerator.custom({ + CustomMetadataGenerator: EndpointMetadataGenerator.custom({ version, os }), + }); + if (waitUntilTransformed) { // need this before indexing docs so that the united transform doesn't // create a checkpoint with a timestamp after the doc timestamps @@ -69,23 +72,21 @@ export const cyLoadEndpointDataHandler = async ( } // load data into the system - const indexedData = customIndexFn - ? await customIndexFn() - : await indexHostsAndAlerts( - esClient as Client, - kbnClient, - generatorSeed, - numHosts, - numHostDocs, - METADATA_DATASTREAM, - POLICY_RESPONSE_INDEX, - ENDPOINT_EVENTS_INDEX, - ENDPOINT_ALERTS_INDEX, - alertsPerHost, - enableFleetIntegration, - undefined, - CurrentKibanaVersionDocGenerator - ); + const indexedData = await indexHostsAndAlerts( + esClient as Client, + kbnClient, + generatorSeed, + numHosts, + numHostDocs, + METADATA_DATASTREAM, + POLICY_RESPONSE_INDEX, + ENDPOINT_EVENTS_INDEX, + ENDPOINT_ALERTS_INDEX, + alertsPerHost, + enableFleetIntegration, + undefined, + DocGenerator + ); if (waitUntilTransformed) { await startTransform(esClient, metadataTransformPrefix); @@ -103,20 +104,6 @@ export const cyLoadEndpointDataHandler = async ( return indexedData; }; -// Document Generator override that uses a custom Endpoint Metadata generator and sets the -// `agent.version` to the current version -const CurrentKibanaVersionDocGenerator = class extends EndpointDocGenerator { - constructor(seedValue: string | seedrandom.prng) { - const MetadataGenerator = class extends EndpointMetadataGenerator { - protected randomVersion(): string { - return kibanaPackageJson.version; - } - }; - - super(seedValue, MetadataGenerator); - } -}; - const stopTransform = async (esClient: Client, transformId: string): Promise => { await esClient.transform.stopTransform({ transform_id: `${transformId}*`, diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_hosts.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_hosts.ts index 0821764bf860b..710edb2d4b974 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_hosts.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_hosts.ts @@ -5,20 +5,19 @@ * 2.0. */ +import type { IndexEndpointHostsCyTaskOptions } from '../types'; import type { IndexedHostsAndAlertsResponse, DeleteIndexedHostsAndAlertsResponse, } from '../../../../common/endpoint/index_data'; -interface CyIndexEndpointHosts { +export interface CyIndexEndpointHosts { data: IndexedHostsAndAlertsResponse; cleanup: () => Cypress.Chainable; } export const indexEndpointHosts = ( - options: { - count?: number; - } = {} + options: IndexEndpointHostsCyTaskOptions = {} ): Cypress.Chainable => { return cy.task('indexEndpointHosts', options, { timeout: 120000 }).then((indexHosts) => { return { diff --git a/x-pack/plugins/security_solution/public/management/cypress/types.ts b/x-pack/plugins/security_solution/public/management/cypress/types.ts index f06c1ebf92b75..0741f7fab1ad0 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/types.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/types.ts @@ -7,6 +7,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { CyLoadEndpointDataOptions } from './support/plugin_handlers/endpoint_data_loader'; + type PossibleChainable = | Cypress.Chainable | ((args?: any) => Cypress.Chainable) @@ -37,3 +39,7 @@ export type ReturnTypeFromChainable = C extends Cyp : C extends (args?: any) => Promise> ? ValueFromPromiseChainable : never; + +export type IndexEndpointHostsCyTaskOptions = Partial< + { count: number } & Pick +>; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts index 0c8722be6706a..6e333afed2cad 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { PolicyResponderScreen } from './policy_responder'; import { ActionResponderScreen } from './actions_responder'; import { SCREEN_ROW_MAX_WIDTH } from '../../common/screen/constants'; import { ColumnLayoutFormatter } from '../../common/screen/column_layout_formatter'; @@ -18,6 +19,7 @@ import { RunServiceStatus } from './components/run_service_status_formatter'; export class MainScreen extends ScreenBaseClass { private readonly loadEndpointsScreen: LoadEndpointsScreen; private readonly actionsResponderScreen: ActionResponderScreen; + private readonly policyResponseScreen: PolicyResponderScreen; private actionColumnWidthPrc = 30; private runningStateColumnWidthPrc = 70; @@ -26,6 +28,7 @@ export class MainScreen extends ScreenBaseClass { super(); this.loadEndpointsScreen = new LoadEndpointsScreen(this.emulatorContext); this.actionsResponderScreen = new ActionResponderScreen(this.emulatorContext); + this.policyResponseScreen = new PolicyResponderScreen(this.emulatorContext); } protected header(title: string = '', subTitle: string = ''): string | DataFormatter { @@ -41,7 +44,7 @@ export class MainScreen extends ScreenBaseClass { } private getMenuOptions(): ChoiceMenuFormatter { - return new ChoiceMenuFormatter(['Load endpoints', 'Actions Responder']); + return new ChoiceMenuFormatter(['Load endpoints', 'Actions Responder', 'Policy Responder']); } private runStateView(): ColumnLayoutFormatter { @@ -82,12 +85,22 @@ export class MainScreen extends ScreenBaseClass { }); return; + // Action Responder case '2': this.pause(); this.actionsResponderScreen.show({ resume: true }).then(() => { this.show({ resume: true }); }); return; + + // Policy Responder + case '3': + this.pause(); + this.policyResponseScreen.show({ resume: true }).then(() => { + this.show({ resume: true }); + }); + return; + case 'E': this.hide(); return; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/policy_responder.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/policy_responder.ts new file mode 100644 index 0000000000000..0ebb8c749e1de --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/policy_responder.ts @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DeepPartial } from '@kbn/utility-types'; +import { grey } from 'chalk'; +import { layout } from '../../common/screen/layout'; +import { indexEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; +import { EndpointPolicyResponseGenerator } from '../../../../common/endpoint/data_generators/endpoint_policy_response_generator'; +import type { HostInfo, HostPolicyResponse } from '../../../../common/endpoint/types'; +import { + fetchEndpointMetadataList, + sendEndpointMetadataUpdate, +} from '../../common/endpoint_metadata_services'; +import { TOOL_TITLE } from '../constants'; +import type { DataFormatter } from '../../common/screen'; +import { ChoiceMenuFormatter, ScreenBaseClass } from '../../common/screen'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import { HostPolicyResponseActionStatus } from '../../../../common/endpoint/types'; + +const policyResponseGenerator = new EndpointPolicyResponseGenerator(); + +const macOsSysExtTypeLabel = 'macOS System Ext. Failure'; + +const policyResponseStatusesTypes: Readonly< + Record +> = Object.freeze({ + Success: HostPolicyResponseActionStatus.success, + Failure: HostPolicyResponseActionStatus.failure, + Warning: HostPolicyResponseActionStatus.warning, + Random: undefined, + [macOsSysExtTypeLabel]: HostPolicyResponseActionStatus.failure, +}); + +interface PolicyResponseOptions { + agentId: string; + hostMetadata: HostInfo; + responseType: string; +} + +export class PolicyResponderScreen extends ScreenBaseClass { + private choices: ChoiceMenuFormatter = new ChoiceMenuFormatter( + [ + { + title: 'Setup', + key: '1', + }, + { + title: 'Send', + key: '2', + }, + ], + { layout: 'horizontal' } + ); + + private options: PolicyResponseOptions | undefined = undefined; + + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + } + + protected header() { + return super.header(TOOL_TITLE, 'Policy Responder'); + } + + protected body(): string | DataFormatter { + const selectedHost = this.options?.hostMetadata + ? this.options.hostMetadata.metadata.host.hostname + : grey('None configured'); + const responseType = this.options?.responseType + ? this.options.responseType + : grey('None configured'); + + return layout`Send a policy response for a given Endpoint host. + + Selected Host: ${selectedHost} + Response Type: ${responseType} + +Options: +${this.choices} +`; + } + + protected onEnterChoice(choice: string) { + const choiceValue = choice.trim().toUpperCase(); + + switch (choiceValue) { + case '1': + this.configView(); + break; + + case '2': + this.sendPolicyResponse(); + break; + + default: + super.onEnterChoice(choice); + } + } + + private async configView() { + let hostMetadata: HostInfo | undefined; + + const userChoices = await this.prompt>({ + questions: [ + { + type: 'input', + name: 'agentId', + message: 'Agent id or host name: ', + validate: async (input: string): Promise => { + const agentValue = input.trim(); + + if (!agentValue) { + return 'Value is required'; + } + + const { data } = await fetchEndpointMetadataList(this.emulatorContext.getKbnClient(), { + kuery: `united.endpoint.agent.id: "${input}" or united.endpoint.host.hostname: "${input}"`, + pageSize: 1, + }); + + hostMetadata = data[0]; + + if (!hostMetadata) { + return `Endpoint "${input}" not found!`; + } + + return true; + }, + }, + { + type: 'list', + name: 'responseType', + message: 'Policy response type: ', + choices: Object.keys(policyResponseStatusesTypes), + default: 'Success', + }, + ], + }); + + if (hostMetadata) { + this.options = { + ...userChoices, + hostMetadata, + }; + + const runNow = await this.prompt<{ confirm: boolean }>({ + questions: [ + { + type: 'confirm', + name: 'confirm', + message: 'Send it? ', + default: 'y', + }, + ], + }); + + if (runNow.confirm) { + this.onEnterChoice('2'); + return; + } + } + + this.reRender(); + } + + private async sendPolicyResponse() { + if (!this.options || !this.options.hostMetadata) { + this.reRender(); + this.showMessage('No host configured!', 'red'); + return; + } + + this.showMessage('Sending policy response...'); + + const esClient = this.emulatorContext.getEsClient(); + const { responseType, hostMetadata } = this.options; + const lastAppliedPolicy = hostMetadata.metadata.Endpoint.policy.applied; + const overallStatus: HostPolicyResponseActionStatus | undefined = + policyResponseStatusesTypes[responseType]; + + const policyApplied: Partial = { + ...(overallStatus ? { status: overallStatus } : {}), + name: lastAppliedPolicy.name, + endpoint_policy_version: lastAppliedPolicy.endpoint_policy_version, + id: lastAppliedPolicy.id, + version: lastAppliedPolicy.version, + }; + + const policyResponseOverrides: DeepPartial = { + agent: hostMetadata.metadata.agent, + Endpoint: { + policy: { + applied: policyApplied, + }, + }, + }; + + const policyResponse = + responseType === macOsSysExtTypeLabel + ? policyResponseGenerator.generateConnectKernelFailure(policyResponseOverrides) + : policyResponseGenerator.generate(policyResponseOverrides); + + // Create policy response and update the host's metadata. + await indexEndpointPolicyResponse(esClient, policyResponse); + await sendEndpointMetadataUpdate(esClient, hostMetadata.metadata.agent.id, { + Endpoint: { + policy: { + applied: policyApplied, + }, + }, + }); + + this.reRender(); + this.showMessage('Successful', 'green', true); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts index 79b292314c696..9658ea46a09d8 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts @@ -5,14 +5,11 @@ * 2.0. */ -/* eslint-disable max-classes-per-file */ - import type { Client } from '@elastic/elasticsearch'; import type { KbnClient } from '@kbn/test'; import pMap from 'p-map'; import type { CreatePackagePolicyResponse } from '@kbn/fleet-plugin/common'; import type { ToolingLog } from '@kbn/tooling-log'; -import type seedrandom from 'seedrandom'; import { kibanaPackageJson } from '@kbn/repo-info'; import { indexAlerts } from '../../../../common/endpoint/data_loaders/index_alerts'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; @@ -27,17 +24,11 @@ import { ENDPOINT_ALERTS_INDEX, ENDPOINT_EVENTS_INDEX } from '../../common/const let WAS_FLEET_SETUP_DONE = false; -const CurrentKibanaVersionDocGenerator = class extends EndpointDocGenerator { - constructor(seedValue: string | seedrandom.prng) { - const MetadataGenerator = class extends EndpointMetadataGenerator { - protected randomVersion(): string { - return kibanaPackageJson.version; - } - }; - - super(seedValue, MetadataGenerator); - } -}; +const CurrentKibanaVersionDocGenerator = EndpointDocGenerator.custom({ + CustomMetadataGenerator: EndpointMetadataGenerator.custom({ + version: kibanaPackageJson.version, + }), +}); export const loadEndpointsIfNoneExist = async ( esClient: Client, diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/layout.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/layout.ts new file mode 100644 index 0000000000000..d2350e617a85c --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/layout.ts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { DataFormatter } from './data_formatter'; + +class LayoutFormatter extends DataFormatter { + constructor(private readonly layout: string) { + super(); + } + + protected getOutput(): string { + return this.layout; + } +} + +/** + * A Tagged Template literal processor to assist with creating layouts for CLI screens + * + * @example + * + * layout` + * My Tool ${new Date().toIsoString()} + * ------------------------------------------------------ + * `; + * // Output: + * // + * // MyTool 2023-04-07T15:03:05.300Z + * // ------------------------------------------------------ + */ +export const layout = (strings: TemplateStringsArray, ...values: any): LayoutFormatter => { + const valuesLength = values.length; + + const output = strings.reduce((out, string, index) => { + const value = index < valuesLength ? values[index] : ''; + + return out + string + (value instanceof DataFormatter ? value.output : `${value}`); + }, ''); + + return new LayoutFormatter(output); +};