[SecuritySolution] Service Flyout (#206268)

## Summary

* Rename  `entities_types`=> `entity_types`
* Create service entity flyout
* Modify `service.name` links in the app to open the service flyout

### How to reproduce it
* Start Kibana with service data, enable the risk score and entity store
* Navigate to Entity Analytics, Alerts and Timeline pages
* Click on the service name link
* It should open the flyout


### Service Flyout over different pages
![Screenshot 2025-01-13 at 16 25
26](https://github.com/user-attachments/assets/7487f73b-dd20-4efb-a950-60dcdece58de)
![Screenshot 2025-01-13 at 16 25
40](https://github.com/user-attachments/assets/b570e1b0-3f5e-4136-abb4-cfea6445d672)
![Screenshot 2025-01-13 at 16 25
53](https://github.com/user-attachments/assets/b5b4009e-fac9-44b5-a3f5-19051ae6b6d5)



### Checklist

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Pablo Machado 2025-01-20 12:17:50 +01:00 committed by GitHub
parent 805830085e
commit f0292b59e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 3174 additions and 69 deletions

View file

@ -37,6 +37,7 @@ import type { UrlEcs } from './url';
import type { UserEcs } from './user';
import type { WinlogEcs } from './winlog';
import type { ZeekEcs } from './zeek';
import type { ServiceEcs } from './service';
export * from './ecs_fields';
export { EventCategory, EventCode };
@ -74,6 +75,7 @@ export type {
UserEcs,
WinlogEcs,
ZeekEcs,
ServiceEcs,
};
// Security Solution Extension of the Elastic Common Schema
@ -97,6 +99,7 @@ export interface EcsSecurityExtension {
tls?: TlsEcs;
url?: UrlEcs;
user?: UserEcs;
service?: ServiceEcs;
// Security Specific Ecs
// exists only in security solution Ecs definition

View file

@ -0,0 +1,25 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export interface ServiceEcs {
address?: string[];
environment?: string[];
ephemeral_id?: string[];
id?: string[];
name?: string[];
node?: {
name: string[];
roles: string[];
role: string[];
};
roles?: string[];
state?: string[];
type?: string[];
version?: string[];
}

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import type { CspBenchmarkRulesStates } from '../schema/rules/latest';
interface BuildEntityAlertsQueryParams {
field: 'user.name' | 'host.name';
field: string;
to: string;
from: string;
queryValue?: string;

View file

@ -8,7 +8,7 @@
import { buildGenericEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { useMisconfigurationPreview } from './use_misconfiguration_preview';
export const useHasMisconfigurations = (field: 'host.name' | 'user.name', value: string) => {
export const useHasMisconfigurations = (field: string, value: string) => {
const { data } = useMisconfigurationPreview({
query: buildGenericEntityFlyoutPreviewQuery(field, value),
sort: [],

View file

@ -9,7 +9,7 @@ import { buildGenericEntityFlyoutPreviewQuery } from '@kbn/cloud-security-postur
import { useVulnerabilitiesPreview } from './use_vulnerabilities_preview';
import { hasVulnerabilitiesData } from '../utils/vulnerability_helpers';
export const useHasVulnerabilities = (field: 'host.name' | 'user.name', value: string) => {
export const useHasVulnerabilities = (field: string, value: string) => {
const { data: vulnerabilitiesData } = useVulnerabilitiesPreview({
query: buildGenericEntityFlyoutPreviewQuery(field, value),
sort: [],

View file

@ -45,6 +45,7 @@ import {
userAuthenticationsSchema,
usersSchema,
} from './users/users';
import { observedServiceDetailsSchema } from './services';
export * from './first_seen_last_seen/first_seen_last_seen';
@ -52,6 +53,8 @@ export * from './hosts/hosts';
export * from './users/users';
export * from './services';
export * from './network/network';
export * from './related_entities/related_entities';
@ -76,6 +79,7 @@ export const searchStrategyRequestSchema = z.discriminatedUnion('factoryQueryTyp
observedUserDetailsSchema,
managedUserDetailsSchema,
userAuthenticationsSchema,
observedServiceDetailsSchema,
riskScoreRequestOptionsSchema,
riskScoreKpiRequestOptionsSchema,
relatedHostsRequestOptionsSchema,

View file

@ -19,6 +19,10 @@ export enum UsersQueries {
authentications = 'authentications',
}
export enum ServicesQueries {
observedDetails = 'observedServiceDetails',
}
export enum NetworkQueries {
details = 'networkDetails',
dns = 'dns',
@ -51,6 +55,7 @@ export enum RelatedEntitiesQueries {
export type FactoryQueryTypes =
| HostsQueries
| UsersQueries
| ServicesQueries
| NetworkQueries
| EntityRiskQueries
| CtiQueries

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './observed_details';

View file

@ -0,0 +1,27 @@
/*
* 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 { z } from '@kbn/zod';
import { requestBasicOptionsSchema } from '../model/request_basic_options';
import { inspect } from '../model/inspect';
import { timerange } from '../model/timerange';
import { ServicesQueries } from '../model/factory_query_type';
export const observedServiceDetailsSchema = requestBasicOptionsSchema.extend({
serviceName: z.string(),
skip: z.boolean().optional(),
timerange,
inspect,
factoryQueryType: z.literal(ServicesQueries.observedDetails),
});
export type ObservedServiceDetailsRequestOptionsInput = z.input<
typeof observedServiceDetailsSchema
>;
export type ObservedServiceDetailsRequestOptions = z.infer<typeof observedServiceDetailsSchema>;

View file

@ -75,6 +75,8 @@ import type {
NetworkTopNFlowRequestOptionsInput,
NetworkUsersRequestOptions,
NetworkUsersRequestOptionsInput,
ObservedServiceDetailsRequestOptions,
ObservedServiceDetailsRequestOptionsInput,
ObservedUserDetailsRequestOptions,
ObservedUserDetailsRequestOptionsInput,
RelatedHostsRequestOptions,
@ -85,6 +87,7 @@ import type {
RiskScoreKpiRequestOptionsInput,
RiskScoreRequestOptions,
RiskScoreRequestOptionsInput,
ServicesQueries,
ThreatIntelSourceRequestOptions,
ThreatIntelSourceRequestOptionsInput,
UserAuthenticationsRequestOptions,
@ -97,12 +100,14 @@ import type {
EntityType,
RiskScoreStrategyResponse,
} from './risk_score';
import type { ObservedServiceDetailsStrategyResponse } from './services';
export * from './cti';
export * from './hosts';
export * from './risk_score';
export * from './network';
export * from './users';
export * from './services';
export * from './first_last_seen';
export * from './related_entities';
@ -110,6 +115,7 @@ export type FactoryQueryTypes =
| HostsQueries
| UsersQueries
| NetworkQueries
| ServicesQueries
| EntityRiskQueries
| CtiQueries
| typeof FirstLastSeenQuery
@ -133,6 +139,8 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ
? UserAuthenticationsStrategyResponse
: T extends UsersQueries.users
? UsersStrategyResponse
: T extends ServicesQueries.observedDetails
? ObservedServiceDetailsStrategyResponse
: T extends NetworkQueries.details
? NetworkDetailsStrategyResponse
: T extends NetworkQueries.dns
@ -183,6 +191,8 @@ export type StrategyRequestInputType<T extends FactoryQueryTypes> = T extends Ho
? ManagedUserDetailsRequestOptionsInput
: T extends UsersQueries.users
? UsersRequestOptionsInput
: T extends ServicesQueries.observedDetails
? ObservedServiceDetailsRequestOptionsInput
: T extends NetworkQueries.details
? NetworkDetailsRequestOptionsInput
: T extends NetworkQueries.dns
@ -233,6 +243,8 @@ export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQu
? ManagedUserDetailsRequestOptions
: T extends UsersQueries.users
? UsersRequestOptions
: T extends ServicesQueries.observedDetails
? ObservedServiceDetailsRequestOptions
: T extends NetworkQueries.details
? NetworkDetailsRequestOptions
: T extends NetworkQueries.dns

View file

@ -0,0 +1,53 @@
/*
* 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 { ServiceEcs } from '@kbn/securitysolution-ecs';
import type { CommonFields, Maybe } from '../../..';
export interface ServiceItem {
service?: Maybe<ServiceEcs>;
}
export interface ServiceAggEsItem {
service_id?: ServiceBuckets;
service_name?: ServiceBuckets;
service_address?: ServiceBuckets;
service_environment?: ServiceBuckets;
service_ephemeral_id?: ServiceBuckets;
service_node_name?: ServiceBuckets;
service_node_role?: ServiceBuckets;
service_node_roles?: ServiceBuckets;
service_state?: ServiceBuckets;
service_type?: ServiceBuckets;
service_version?: ServiceBuckets;
}
export interface ServiceBuckets {
buckets: Array<{
key: string;
doc_count: number;
}>;
}
export interface AllServicesAggEsItem {
key: string;
domain?: ServicesDomainHitsItem;
lastSeen?: { value_as_string: string };
}
type ServiceFields = CommonFields &
Partial<{
[Property in keyof ServiceEcs as `service.${Property}`]: unknown[];
}>;
interface ServicesDomainHitsItem {
hits: {
hits: Array<{
fields: ServiceFields;
}>;
};
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './observed_details';
export * from './common';
export { ServicesQueries } from '../../../api/search_strategy';

View file

@ -0,0 +1,16 @@
/*
* 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 { IEsSearchResponse } from '@kbn/search-types';
import type { Inspect, Maybe } from '../../../common';
import type { ServiceItem } from '../common';
export interface ObservedServiceDetailsStrategyResponse extends IEsSearchResponse {
serviceDetails: ServiceItem;
inspect?: Maybe<Inspect>;
}

View file

@ -39,6 +39,7 @@ import { SeverityBadge } from '../../../common/components/severity_badge';
import { ALERT_PREVIEW_BANNER } from '../../../flyout/document_details/preview/constants';
import { FILTER_OPEN, FILTER_ACKNOWLEDGED } from '../../../../common/types';
import { useNonClosedAlerts } from '../../hooks/use_non_closed_alerts';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
enum KIBANA_ALERTS {
SEVERITY = 'kibana.alert.severity',
@ -76,7 +77,7 @@ interface AlertsDetailsFields {
}
export const AlertsDetailsTable = memo(
({ field, value }: { field: 'host.name' | 'user.name'; value: string }) => {
({ field, value }: { field: CloudPostureEntityIdentifier; value: string }) => {
useEffect(() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.COUNT,

View file

@ -16,6 +16,7 @@ import { CspInsightLeftPanelSubTab } from '../../../flyout/entity_details/shared
import { MisconfigurationFindingsDetailsTable } from './misconfiguration_findings_details_table';
import { VulnerabilitiesFindingsDetailsTable } from './vulnerabilities_findings_details_table';
import { AlertsDetailsTable } from './alerts_findings_details_table';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
/**
* Insights view displayed in the document details expandable flyout left section
@ -42,7 +43,7 @@ function isCspFlyoutPanelProps(
}
export const InsightsTabCsp = memo(
({ value, field }: { value: string; field: 'host.name' | 'user.name' }) => {
({ value, field }: { value: string; field: CloudPostureEntityIdentifier }) => {
const panels = useExpandableFlyoutState();
let hasMisconfigurationFindings = false;

View file

@ -34,6 +34,7 @@ import { useGetNavigationUrlParams } from '@kbn/cloud-security-posture/src/hooks
import { SecurityPageName } from '@kbn/deeplinks-security';
import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations';
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
type MisconfigurationSortFieldType =
| MISCONFIGURATION.RESULT_EVALUATION
@ -92,7 +93,7 @@ const getFindingsStats = (
* Insights view displayed in the document details expandable flyout left section
*/
export const MisconfigurationFindingsDetailsTable = memo(
({ field, value }: { field: 'host.name' | 'user.name'; value: string }) => {
({ field, value }: { field: CloudPostureEntityIdentifier; value: string }) => {
useEffect(() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.COUNT,
@ -178,7 +179,7 @@ export const MisconfigurationFindingsDetailsTable = memo(
return getNavUrlParams({ 'rule.id': ruleId, 'resource.id': resourceId }, 'configurations');
};
const getFindingsPageUrl = (name: string, queryField: 'host.name' | 'user.name') => {
const getFindingsPageUrl = (name: string, queryField: CloudPostureEntityIdentifier) => {
return getNavUrlParams({ [queryField]: name }, 'configurations', ['rule.name']);
};

View file

@ -36,7 +36,9 @@ import { METRIC_TYPE } from '@kbn/analytics';
import { SecurityPageName } from '@kbn/deeplinks-security';
import { useGetNavigationUrlParams } from '@kbn/cloud-security-posture/src/hooks/use_get_navigation_url_params';
import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities';
import { EntityIdentifierFields } from '../../../../common/entity_analytics/types';
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
type VulnerabilitySortFieldType =
| 'score'
@ -123,7 +125,7 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
const getNavUrlParams = useGetNavigationUrlParams();
const getVulnerabilityUrl = (name: string, queryField: 'host.name' | 'user.name') => {
const getVulnerabilityUrl = (name: string, queryField: CloudPostureEntityIdentifier) => {
return getNavUrlParams({ [queryField]: name }, 'vulnerabilities');
};
@ -237,7 +239,7 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
<EuiPanel hasShadow={false}>
<SecuritySolutionLinkAnchor
deepLinkId={SecurityPageName.cloudSecurityPostureFindings}
path={`${getVulnerabilityUrl(value, 'host.name')}`}
path={`${getVulnerabilityUrl(value, EntityIdentifierFields.hostName)}`}
target={'_blank'}
external={false}
onClick={() => {

View file

@ -12,6 +12,7 @@ import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities';
import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations';
import type { EntityIdentifierFields } from '../../../common/entity_analytics/types';
import { MisconfigurationsPreview } from './misconfiguration/misconfiguration_preview';
import { VulnerabilitiesPreview } from './vulnerabilities/vulnerabilities_preview';
import { AlertsPreview } from './alerts/alerts_preview';
@ -20,6 +21,11 @@ import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../overview/component
import { useNonClosedAlerts } from '../hooks/use_non_closed_alerts';
import type { EntityDetailsPath } from '../../flyout/entity_details/shared/components/left_panel/left_panel_header';
export type CloudPostureEntityIdentifier = Extract<
EntityIdentifierFields,
EntityIdentifierFields.hostName | EntityIdentifierFields.userName
>;
export const EntityInsight = <T,>({
value,
field,
@ -28,7 +34,7 @@ export const EntityInsight = <T,>({
openDetailsPanel,
}: {
value: string;
field: 'host.name' | 'user.name';
field: CloudPostureEntityIdentifier;
isPreviewMode?: boolean;
isLinkEnabled: boolean;
openDetailsPanel: (path: EntityDetailsPath) => void;

View file

@ -11,6 +11,7 @@ import { MisconfigurationsPreview } from './misconfiguration_preview';
import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview';
import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview';
import { TestProviders } from '../../../common/mock/test_providers';
import { EntityIdentifierFields } from '../../../../common/entity_analytics/types';
// Mock hooks
jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview');
@ -33,7 +34,7 @@ describe('MisconfigurationsPreview', () => {
<TestProviders>
<MisconfigurationsPreview
value="host1"
field="host.name"
field={EntityIdentifierFields.hostName}
isLinkEnabled={true}
openDetailsPanel={mockOpenLeftPanel}
/>

View file

@ -25,6 +25,7 @@ import {
CspInsightLeftPanelSubTab,
EntityDetailsLeftPanelTab,
} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
export const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => {
if (passedFindingsStats === 0 && failedFindingsStats === 0) return [];
@ -95,7 +96,7 @@ export const MisconfigurationsPreview = ({
openDetailsPanel,
}: {
value: string;
field: 'host.name' | 'user.name';
field: CloudPostureEntityIdentifier;
isPreviewMode?: boolean;
isLinkEnabled: boolean;
openDetailsPanel: (path: EntityDetailsPath) => void;

View file

@ -11,6 +11,7 @@ import { VulnerabilitiesPreview } from './vulnerabilities_preview';
import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview';
import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview';
import { TestProviders } from '../../../common/mock/test_providers';
import { EntityIdentifierFields } from '../../../../common/entity_analytics/types';
// Mock hooks
jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview');
@ -33,7 +34,7 @@ describe('VulnerabilitiesPreview', () => {
<TestProviders>
<VulnerabilitiesPreview
value="host1"
field="host.name"
field={EntityIdentifierFields.hostName}
isLinkEnabled={true}
openDetailsPanel={mockOpenLeftPanel}
/>

View file

@ -28,6 +28,7 @@ import {
CspInsightLeftPanelSubTab,
EntityDetailsLeftPanelTab,
} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
const VulnerabilitiesCount = ({
vulnerabilitiesTotal,
@ -70,7 +71,7 @@ export const VulnerabilitiesPreview = ({
openDetailsPanel,
}: {
value: string;
field: 'host.name' | 'user.name';
field: CloudPostureEntityIdentifier;
isPreviewMode?: boolean;
isLinkEnabled: boolean;
openDetailsPanel: (path: EntityDetailsPath) => void;

View file

@ -16,6 +16,7 @@ import { useGlobalTime } from '../../common/containers/use_global_time';
import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../overview/components/detection_response/alerts_by_status/types';
import { useNonClosedAlerts } from './use_non_closed_alerts';
import { useHasRiskScore } from './use_risk_score_data';
import type { CloudPostureEntityIdentifier } from '../components/entity_insight';
export const useNavigateEntityInsight = ({
field,
@ -23,7 +24,7 @@ export const useNavigateEntityInsight = ({
subTab,
queryIdExtension,
}: {
field: 'host.name' | 'user.name';
field: CloudPostureEntityIdentifier;
value: string;
subTab: string;
queryIdExtension: string;

View file

@ -10,6 +10,7 @@ import { FILTER_CLOSED } from '@kbn/securitysolution-data-table/common/types';
import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index';
import { useAlertsByStatus } from '../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
import type { ParsedAlertsData } from '../../overview/components/detection_response/alerts_by_status/types';
import type { CloudPostureEntityIdentifier } from '../components/entity_insight';
export const useNonClosedAlerts = ({
field,
@ -18,7 +19,7 @@ export const useNonClosedAlerts = ({
from,
queryId,
}: {
field: 'host.name' | 'user.name';
field: CloudPostureEntityIdentifier;
value: string;
to: string;
from: string;

View file

@ -15,12 +15,13 @@ import {
import { useRiskScore } from '../../entity_analytics/api/hooks/use_risk_score';
import { FIRST_RECORD_PAGINATION } from '../../entity_analytics/common';
import { EntityType } from '../../../common/entity_analytics/types';
import type { CloudPostureEntityIdentifier } from '../components/entity_insight';
export const useHasRiskScore = ({
field,
value,
}: {
field: 'host.name' | 'user.name';
field: CloudPostureEntityIdentifier;
value: string;
}) => {
const isHostNameField = field === 'host.name';

View file

@ -122,6 +122,34 @@ const UserDetailsLinkComponent: React.FC<{
export const UserDetailsLink = React.memo(UserDetailsLinkComponent);
const ServiceDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
serviceName?: string;
onClick?: (e: SyntheticEvent) => void;
}> = ({ children, onClick: onClickParam, serviceName }) => {
const { telemetry } = useKibana().services;
const onClick = useCallback(
(e: SyntheticEvent) => {
telemetry.reportEvent(EntityEventTypes.EntityDetailsClicked, { entity: EntityType.service });
if (onClickParam) {
onClickParam(e);
}
},
[onClickParam, telemetry]
);
return onClickParam ? (
<LinkAnchor data-test-subj="service-link-anchor" onClick={onClick}>
{children ? children : serviceName}
</LinkAnchor>
) : (
serviceName
);
};
export const ServiceDetailsLink = React.memo(ServiceDetailsLinkComponent);
export interface HostDetailsLinkProps {
children?: React.ReactNode;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
@ -222,6 +250,8 @@ export const EntityDetailsLink = ({
return <HostDetailsLink {...props} hostTab={tab as HostsTableType} hostName={entityName} />;
} else if (entityType === EntityType.user) {
return <UserDetailsLink {...props} userTab={tab as UsersTableType} userName={entityName} />;
} else if (entityType === EntityType.service) {
return <ServiceDetailsLink serviceName={entityName} onClick={props.onClick} />;
}
return entityName;

View file

@ -12,6 +12,7 @@ import { ThemeProvider } from 'styled-components';
import { euiLightVars } from '@kbn/ui-theme';
import { TestProvider } from '@kbn/expandable-flyout/src/test/provider';
import { EntityType } from '../../../../common/entity_analytics/types';
import { StorybookProviders } from '../../../common/mock/storybook_providers';
import { AssetCriticalitySelector } from './asset_criticality_selector';
import type { State } from './use_asset_criticality';
@ -43,7 +44,7 @@ export const Default: Story<void> = () => {
<div style={{ maxWidth: '300px' }}>
<AssetCriticalitySelector
criticality={criticality}
entity={{ type: 'host' as const, name: 'My test Host' }}
entity={{ type: EntityType.host, name: 'My test Host' }}
/>
</div>
</TestProvider>
@ -58,7 +59,7 @@ export const Compressed: Story<void> = () => {
<div style={{ maxWidth: '300px' }}>
<AssetCriticalitySelector
criticality={criticality}
entity={{ type: 'host' as const, name: 'My test Host' }}
entity={{ type: EntityType.host as const, name: 'My test Host' }}
compressed
/>
</div>
@ -74,7 +75,7 @@ export const Loading: Story<void> = () => {
<div style={{ maxWidth: '300px' }}>
<AssetCriticalitySelector
criticality={criticalityLoading}
entity={{ type: 'host' as const, name: 'My test Host' }}
entity={{ type: EntityType.host as const, name: 'My test Host' }}
/>
</div>
</TestProvider>

View file

@ -10,6 +10,7 @@ import { render } from '@testing-library/react';
import React from 'react';
import { AssetCriticalitySelector } from './asset_criticality_selector';
import type { State } from './use_asset_criticality';
import { EntityType } from '../../../../common/entity_analytics/types';
const criticality = {
status: 'create',
@ -27,7 +28,7 @@ describe('AssetCriticalitySelector', () => {
const { getByTestId } = render(
<AssetCriticalitySelector
criticality={criticality}
entity={{ type: 'host' as const, name: 'My test Host' }}
entity={{ type: EntityType.host, name: 'My test Host' }}
/>,
{
wrapper: TestProviders,
@ -41,7 +42,7 @@ describe('AssetCriticalitySelector', () => {
const { getByTestId } = render(
<AssetCriticalitySelector
criticality={criticality}
entity={{ type: 'host' as const, name: 'My test Host' }}
entity={{ type: EntityType.host, name: 'My test Host' }}
compressed
/>,
{

View file

@ -35,6 +35,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import useToggle from 'react-use/lib/useToggle';
import { EntityTypeToIdentifierField } from '../../../../common/entity_analytics/types';
import { PICK_ASSET_CRITICALITY } from './translations';
import { AssetCriticalityBadge } from './asset_criticality_badge';
import type { Entity, State } from './use_asset_criticality';
@ -59,7 +60,7 @@ const AssetCriticalitySelectorComponent: React.FC<{
const onSave = (value: CriticalityLevelWithUnassigned) => {
criticality.mutation.mutate({
criticalityLevel: value,
idField: `${entity.type}.name`,
idField: EntityTypeToIdentifierField[entity.type],
idValue: entity.name,
});
toggleModal(false);

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { EntityType } from '../../../../common/entity_analytics/types';
import {
renderMutation,
renderQuery,
@ -71,7 +72,7 @@ describe('useAssetCriticality', () => {
mockFetchAssetCriticalityPrivileges.mockResolvedValue({ has_all_required: true });
mockDeleteAssetCriticality.mockResolvedValue({});
mockCreateAssetCriticality.mockResolvedValue({});
const entity: Entity = { name: 'test_entity_name', type: 'host' };
const entity: Entity = { name: 'test_entity_name', type: EntityType.host };
const { mutation } = await renderWrappedHook(() => useAssetCriticalityData({ entity }));
@ -90,7 +91,7 @@ describe('useAssetCriticality', () => {
mockFetchAssetCriticalityPrivileges.mockResolvedValue({ has_all_required: true });
mockDeleteAssetCriticality.mockResolvedValue({});
mockCreateAssetCriticality.mockResolvedValue({});
const entity: Entity = { name: 'test_entity_name', type: 'host' };
const entity: Entity = { name: 'test_entity_name', type: EntityType.host };
const { mutation } = await renderWrappedHook(() => useAssetCriticalityData({ entity }));

View file

@ -8,6 +8,8 @@
import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SecurityAppError } from '@kbn/securitysolution-t-grid';
import type { EntityType } from '../../../../common/entity_analytics/types';
import { EntityTypeToIdentifierField } from '../../../../common/entity_analytics/types';
import type { EntityAnalyticsPrivileges } from '../../../../common/api/entity_analytics';
import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types';
import { useHasSecurityCapability } from '../../../helper_hooks';
@ -58,7 +60,11 @@ export const useAssetCriticalityData = ({
const privileges = useAssetCriticalityPrivileges(entity.name);
const query = useQuery<AssetCriticalityRecord | null, { body: { statusCode: number } }>({
queryKey: QUERY_KEY,
queryFn: () => fetchAssetCriticality({ idField: `${entity.type}.name`, idValue: entity.name }),
queryFn: () =>
fetchAssetCriticality({
idField: EntityTypeToIdentifierField[entity.type],
idValue: entity.name,
}),
retry: (failureCount, error) => error.body.statusCode === 404 && failureCount > 0,
enabled,
});
@ -128,5 +134,5 @@ export interface ModalState {
export interface Entity {
name: string;
type: 'host' | 'user';
type: EntityType;
}

View file

@ -7,6 +7,7 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { CloudPostureEntityIdentifier } from '../../../cloud_security_posture/components/entity_insight';
import type { EntityType } from '../../../../common/search_strategy';
import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
import { PREFIX } from '../../../flyout/shared/test_ids';
@ -38,7 +39,7 @@ export const getInsightsInputTab = ({
fieldName,
}: {
name: string;
fieldName: 'host.name' | 'user.name';
fieldName: CloudPostureEntityIdentifier;
}) => {
return {
id: EntityDetailsLeftPanelTab.CSP_INSIGHTS,

View file

@ -25,12 +25,8 @@ import { useRiskContributingAlerts } from '../../../../hooks/use_risk_contributi
import { PreferenceFormattedDate } from '../../../../../common/components/formatted_date';
import { useRiskScore } from '../../../../api/hooks/use_risk_score';
import type { EntityRiskScore } from '../../../../../../common/search_strategy';
import {
buildHostNamesFilter,
buildUserNamesFilter,
EntityType,
} from '../../../../../../common/search_strategy';
import type { EntityRiskScore, EntityType } from '../../../../../../common/search_strategy';
import { buildEntityNameFilter } from '../../../../../../common/search_strategy';
import { AssetCriticalityBadge } from '../../../asset_criticality';
import { RiskInputsUtilityBar } from '../../components/utility_bar';
import { ActionColumn } from '../../components/action_column';
@ -58,12 +54,7 @@ export const RiskInputsTab = <T extends EntityType>({
const [selectedItems, setSelectedItems] = useState<InputAlert[]>([]);
const nameFilterQuery = useMemo(() => {
// TODO Add support for services on a follow-up PR
if (entityType === EntityType.host) {
return buildHostNamesFilter([entityName]);
} else if (entityType === EntityType.user) {
return buildUserNamesFilter([entityName]);
}
return buildEntityNameFilter(entityType, [entityName]);
}, [entityName, entityType]);
const {

View file

@ -34,7 +34,7 @@ export const getEntityType = (record: Entity): EntityType => {
export const EntityIconByType: Record<EntityType, IconType> = {
[EntityType.user]: 'user',
[EntityType.host]: 'storage',
[EntityType.service]: 'gear',
[EntityType.service]: 'node',
[EntityType.universal]: 'globe', // random value since we don't support universal entity type
};

View file

@ -183,7 +183,10 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
[rawFilteredQuery]
);
const entity = useMemo(() => ({ type: 'host' as const, name: detailName }), [detailName]);
const entity = useMemo(
() => ({ type: EntityType.host as const, name: detailName }),
[detailName]
);
const privileges = useAssetCriticalityPrivileges(entity.name);
const refetchRiskScore = useRefetchOverviewPageRiskScore(HOST_OVERVIEW_RISK_SCORE_QUERY_ID);

View file

@ -183,7 +183,7 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({
[detailName]
);
const entity = useMemo(() => ({ type: 'user' as const, name: detailName }), [detailName]);
const entity = useMemo(() => ({ type: EntityType.user, name: detailName }), [detailName]);
const privileges = useAssetCriticalityPrivileges(entity.name);
const refetchRiskScore = useRefetchOverviewPageRiskScore(USER_OVERVIEW_RISK_SCORE_QUERY_ID);

View file

@ -7,7 +7,7 @@
import React, { useMemo, useState } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types';
import {
getRiskInputTab,
getInsightsInputTab,
@ -61,7 +61,7 @@ export const HostDetailsPanel = ({
// Determine if the Insights tab should be included
const insightsTab =
hasMisconfigurationFindings || hasVulnerabilitiesFindings || hasNonClosedAlerts
? [getInsightsInputTab({ name, fieldName: 'host.name' })]
? [getInsightsInputTab({ name, fieldName: EntityIdentifierFields.hostName })]
: [];
return [[...riskScoreTab, ...insightsTab], EntityDetailsLeftPanelTab.RISK_INPUTS, () => {}];
}, [

View file

@ -12,7 +12,7 @@ import { EntityInsight } from '../../../cloud_security_posture/components/entity
import { AssetCriticalityAccordion } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector';
import { FlyoutRiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary';
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types';
import type { HostItem } from '../../../../common/search_strategy';
import { ObservedEntity } from '../shared/components/observed_entity';
import { HOST_PANEL_OBSERVED_HOST_QUERY_ID, HOST_PANEL_RISK_SCORE_QUERY_ID } from '.';
@ -66,12 +66,12 @@ export const HostPanelContent = ({
</>
)}
<AssetCriticalityAccordion
entity={{ name: hostName, type: 'host' }}
entity={{ name: hostName, type: EntityType.host }}
onChange={onAssetCriticalityChange}
/>
<EntityInsight
value={hostName}
field={'host.name'}
field={EntityIdentifierFields.hostName}
isPreviewMode={isPreviewMode}
openDetailsPanel={openDetailsPanel}
isLinkEnabled={isLinkEnabled}

View file

@ -33,7 +33,7 @@ import { useObservedHost } from './hooks/use_observed_host';
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
import { HostPreviewPanelFooter } from '../host_preview/footer';
import { useNavigateToHostDetails } from './hooks/use_navigate_to_host_details';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types';
export interface HostPanelProps extends Record<string, unknown> {
contextID: string;
@ -98,7 +98,7 @@ export const HostPanel = ({
const { hasVulnerabilitiesFindings } = useHasVulnerabilities('host.name', hostName);
const { hasNonClosedAlerts } = useNonClosedAlerts({
field: 'host.name',
field: EntityIdentifierFields.hostName,
value: hostName,
to,
from,

View file

@ -13,6 +13,7 @@ import type {
HostRiskScore,
EntityType,
UserRiskScore,
ServiceRiskScore,
} from '../../../../common/search_strategy';
import { HostPolicyResponseActionStatus, RiskSeverity } from '../../../../common/search_strategy';
import { RiskCategories } from '../../../../common/entity_analytics/risk_engine';
@ -86,6 +87,40 @@ const hostRiskScore: HostRiskScore = {
oldestAlertTimestamp: '1989-11-08T23:00:00.000Z',
};
const serviceRiskScore: ServiceRiskScore = {
'@timestamp': '1989-11-08T23:00:00.000Z',
service: {
name: 'test',
risk: {
rule_risks: [],
calculated_score_norm: 70,
multipliers: [],
calculated_level: RiskSeverity.High,
'@timestamp': '',
id_field: '',
id_value: '',
calculated_score: 0,
category_1_count: 5,
category_1_score: 20,
category_2_count: 1,
category_2_score: 10,
notes: [],
inputs: [
{
id: '_id',
index: '_index',
category: RiskCategories.category_1,
description: 'Alert from Rule: My rule',
risk_score: 30,
timestamp: '2021-08-19T18:55:59.000Z',
},
],
},
},
alertsCount: 0,
oldestAlertTimestamp: '1989-11-08T23:00:00.000Z',
};
export const mockUserRiskScoreState: RiskScoreState<EntityType.user> = {
data: [userRiskScore],
inspect: {
@ -116,6 +151,21 @@ export const mockHostRiskScoreState: RiskScoreState<EntityType.host> = {
error: undefined,
};
export const mockServiceRiskScoreState: RiskScoreState<EntityType.service> = {
data: [serviceRiskScore],
inspect: {
dsl: [],
response: [],
},
isInspected: false,
refetch: () => {},
totalCount: 0,
isAuthorized: true,
hasEngineBeenInstalled: true,
loading: false,
error: undefined,
};
const hostMetadata: HostMetadataInterface = {
'@timestamp': 1036358673463478,

View file

@ -0,0 +1,54 @@
/*
* 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 { TestProviders } from '../../../common/mock';
import { render } from '@testing-library/react';
import React from 'react';
import { ServiceDetailsPanel } from '.';
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
describe('LeftPanel', () => {
it('renders', () => {
const { queryByText } = render(
<ServiceDetailsPanel
path={{
tab: EntityDetailsLeftPanelTab.RISK_INPUTS,
}}
isRiskScoreExist
service={{ name: 'test service', email: [] }}
scopeId={'scopeId'}
/>,
{
wrapper: TestProviders,
}
);
const tabElement = queryByText('Risk contributions');
expect(tabElement).toBeInTheDocument();
});
it('does not render the tab if tab is not found', () => {
const { queryByText } = render(
<ServiceDetailsPanel
path={{
tab: EntityDetailsLeftPanelTab.RISK_INPUTS,
}}
isRiskScoreExist={false}
service={{ name: 'test service', email: [] }}
scopeId={'scopeId'}
/>,
{
wrapper: TestProviders,
}
);
const tabElement = queryByText('Risk Inputs');
expect(tabElement).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,93 @@
/*
* 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 React, { useMemo } from 'react';
import type { FlyoutPanelProps, PanelPath } from '@kbn/expandable-flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useTabs } from './tabs';
import type {
EntityDetailsLeftPanelTab,
LeftPanelTabsType,
} from '../shared/components/left_panel/left_panel_header';
import { LeftPanelHeader } from '../shared/components/left_panel/left_panel_header';
import { LeftPanelContent } from '../shared/components/left_panel/left_panel_content';
interface ServiceParam {
name: string;
email: string[];
}
export interface ServiceDetailsPanelProps extends Record<string, unknown> {
isRiskScoreExist: boolean;
service: ServiceParam;
path?: PanelPath;
scopeId: string;
}
export interface ServiceDetailsExpandableFlyoutProps extends FlyoutPanelProps {
key: 'service_details';
params: ServiceDetailsPanelProps;
}
export const ServiceDetailsPanelKey: ServiceDetailsExpandableFlyoutProps['key'] = 'service_details';
export const ServiceDetailsPanel = ({
isRiskScoreExist,
service,
path,
scopeId,
}: ServiceDetailsPanelProps) => {
const tabs = useTabs(service.name, scopeId);
const { selectedTabId, setSelectedTabId } = useSelectedTab(isRiskScoreExist, service, tabs, path);
if (!selectedTabId) {
return null;
}
return (
<>
<LeftPanelHeader
selectedTabId={selectedTabId}
setSelectedTabId={setSelectedTabId}
tabs={tabs}
/>
<LeftPanelContent selectedTabId={selectedTabId} tabs={tabs} />
</>
);
};
const useSelectedTab = (
isRiskScoreExist: boolean,
service: ServiceParam,
tabs: LeftPanelTabsType,
path: PanelPath | undefined
) => {
const { openLeftPanel } = useExpandableFlyoutApi();
const selectedTabId = useMemo(() => {
const defaultTab = tabs.length > 0 ? tabs[0].id : undefined;
if (!path) return defaultTab;
return tabs.find((tab) => tab.id === path.tab)?.id ?? defaultTab;
}, [path, tabs]);
const setSelectedTabId = (tabId: EntityDetailsLeftPanelTab) => {
openLeftPanel({
id: ServiceDetailsPanelKey,
params: {
service,
isRiskScoreExist,
path: {
tab: tabId,
},
},
});
};
return { setSelectedTabId, selectedTabId };
};
ServiceDetailsPanel.displayName = 'ServiceDetailsPanel';

View file

@ -0,0 +1,22 @@
/*
* 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 { useMemo } from 'react';
import { getRiskInputTab } from '../../../entity_analytics/components/entity_details_flyout';
import { EntityType } from '../../../../common/entity_analytics/types';
import type { LeftPanelTabsType } from '../shared/components/left_panel/left_panel_header';
export const useTabs = (name: string, scopeId: string): LeftPanelTabsType =>
useMemo(() => {
return [
getRiskInputTab({
entityName: name,
entityType: EntityType.service,
scopeId,
}),
];
}, [name, scopeId]);

View file

@ -0,0 +1,85 @@
/*
* 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 { EuiHorizontalRule } from '@elastic/eui';
import React from 'react';
import type { ServiceItem } from '../../../../common/search_strategy';
import { AssetCriticalityAccordion } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector';
import { FlyoutRiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary';
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
import { EntityType } from '../../../../common/entity_analytics/types';
import { SERVICE_PANEL_RISK_SCORE_QUERY_ID } from '.';
import { FlyoutBody } from '../../shared/components/flyout_body';
import { ObservedEntity } from '../shared/components/observed_entity';
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
import { useObservedServiceItems } from './hooks/use_observed_service_items';
import type { EntityDetailsPath } from '../shared/components/left_panel/left_panel_header';
export const OBSERVED_SERVICE_QUERY_ID = 'observedServiceDetailsQuery';
interface ServicePanelContentProps {
serviceName: string;
observedService: ObservedEntityData<ServiceItem>;
riskScoreState: RiskScoreState<EntityType.service>;
recalculatingScore: boolean;
contextID: string;
scopeId: string;
isDraggable: boolean;
onAssetCriticalityChange: () => void;
openDetailsPanel: (path: EntityDetailsPath) => void;
isPreviewMode?: boolean;
isLinkEnabled: boolean;
}
export const ServicePanelContent = ({
serviceName,
observedService,
riskScoreState,
recalculatingScore,
contextID,
scopeId,
isDraggable,
openDetailsPanel,
onAssetCriticalityChange,
isPreviewMode,
isLinkEnabled,
}: ServicePanelContentProps) => {
const observedFields = useObservedServiceItems(observedService);
return (
<FlyoutBody>
{riskScoreState.hasEngineBeenInstalled && riskScoreState.data?.length !== 0 && (
<>
<FlyoutRiskSummary
riskScoreData={riskScoreState}
recalculatingScore={recalculatingScore}
queryId={SERVICE_PANEL_RISK_SCORE_QUERY_ID}
openDetailsPanel={openDetailsPanel}
isPreviewMode={isPreviewMode}
isLinkEnabled={isLinkEnabled}
entityType={EntityType.service}
/>
<EuiHorizontalRule />
</>
)}
<AssetCriticalityAccordion
entity={{ name: serviceName, type: EntityType.service }}
onChange={onAssetCriticalityChange}
/>
<ObservedEntity
observedData={observedService}
contextID={contextID}
scopeId={scopeId}
isDraggable={isDraggable}
observedFields={observedFields}
queryId={OBSERVED_SERVICE_QUERY_ID}
/>
<EuiHorizontalRule margin="m" />
</FlyoutBody>
);
};

View file

@ -0,0 +1,62 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../common/mock';
import { ServicePanelHeader } from './header';
import { mockObservedService } from './mocks';
const mockProps = {
serviceName: 'test',
observedService: mockObservedService,
};
jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
describe('ServicePanelHeader', () => {
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<ServicePanelHeader {...mockProps} />
</TestProviders>
);
expect(getByTestId('service-panel-header')).toBeInTheDocument();
});
it('renders observed badge when lastSeen is defined', () => {
const { getByTestId } = render(
<TestProviders>
<ServicePanelHeader {...mockProps} />
</TestProviders>
);
expect(getByTestId('service-panel-header-observed-badge')).toBeInTheDocument();
});
it('does not render observed badge when lastSeen date is undefined', () => {
const { queryByTestId } = render(
<TestProviders>
<ServicePanelHeader
{...{
...mockProps,
observedService: {
...mockObservedService,
lastSeen: {
isLoading: false,
date: undefined,
},
},
}}
/>
</TestProviders>
);
expect(queryByTestId('service-panel-header-observed-badge')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 { EuiSpacer, EuiBadge, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useMemo } from 'react';
import { EntityType } from '../../../../common/search_strategy';
import { EntityIconByType } from '../../../entity_analytics/components/entity_store/helpers';
import type { ServiceItem } from '../../../../common/search_strategy/security_solution/services/common';
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
import { FlyoutHeader } from '../../shared/components/flyout_header';
import { FlyoutTitle } from '../../shared/components/flyout_title';
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
interface ServicePanelHeaderProps {
serviceName: string;
observedService: ObservedEntityData<ServiceItem>;
}
export const ServicePanelHeader = ({ serviceName, observedService }: ServicePanelHeaderProps) => {
const lastSeenDate = useMemo(
() => observedService.lastSeen.date && new Date(observedService.lastSeen.date),
[observedService.lastSeen]
);
return (
<FlyoutHeader data-test-subj="service-panel-header">
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
<EuiFlexItem grow={false}>
<EuiText size="xs" data-test-subj={'service-panel-header-lastSeen'}>
{lastSeenDate && <PreferenceFormattedDate value={lastSeenDate} />}
<EuiSpacer size="xs" />
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FlyoutTitle title={serviceName} iconType={EntityIconByType[EntityType.service]} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
{observedService.lastSeen.date && (
<EuiBadge data-test-subj="service-panel-header-observed-badge" color="hollow">
<FormattedMessage
id="xpack.securitySolution.flyout.entityDetails.service.observedBadge"
defaultMessage="Observed"
/>
</EuiBadge>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</FlyoutHeader>
);
};

View file

@ -0,0 +1,72 @@
/*
* 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 { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details';
import { TestProviders } from '../../../../common/mock';
import { renderHook, act } from '@testing-library/react';
import { useSearchStrategy } from '../../../../common/containers/use_search_strategy';
jest.mock('../../../../common/containers/use_search_strategy', () => ({
useSearchStrategy: jest.fn(),
}));
const mockUseSearchStrategy = useSearchStrategy as jest.Mock;
const mockSearch = jest.fn();
const defaultProps = {
endDate: '2020-07-08T08:20:18.966Z',
indexNames: ['fakebeat-*'],
skip: false,
startDate: '2020-07-07T08:20:18.966Z',
userName: 'myUserName',
};
describe('useUserDetails', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseSearchStrategy.mockReturnValue({
loading: false,
result: {
overviewNetwork: {},
},
search: mockSearch,
refetch: jest.fn(),
inspect: {},
});
});
it('runs search', () => {
renderHook(() => useObservedUserDetails(defaultProps), {
wrapper: TestProviders,
});
expect(mockSearch).toHaveBeenCalled();
});
it('does not run search when skip = true', () => {
const props = {
...defaultProps,
skip: true,
};
renderHook(() => useObservedUserDetails(props), {
wrapper: TestProviders,
});
expect(mockSearch).not.toHaveBeenCalled();
});
it('skip = true will cancel any running request', () => {
const props = {
...defaultProps,
};
const { rerender } = renderHook(() => useObservedUserDetails(props), {
wrapper: TestProviders,
});
props.skip = true;
act(() => rerender());
expect(mockUseSearchStrategy).toHaveBeenCalledTimes(2);
expect(mockUseSearchStrategy.mock.calls[1][0].abort).toEqual(true);
});
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useSearchStrategy } from '../../../../common/containers/use_search_strategy';
import type { inputsModel } from '../../../../common/store';
import type { InspectResponse } from '../../../../types';
import { ServicesQueries } from '../../../../../common/search_strategy/security_solution/services';
import type { ServiceItem } from '../../../../../common/search_strategy/security_solution/services/common';
import { OBSERVED_SERVICE_QUERY_ID } from '../content';
export interface ServiceDetailsArgs {
id: string;
inspect: InspectResponse;
serviceDetails: ServiceItem;
refetch: inputsModel.Refetch;
startDate: string;
endDate: string;
}
interface UseServiceDetails {
endDate: string;
serviceName: string;
id?: string;
indexNames: string[];
skip?: boolean;
startDate: string;
}
export const useObservedServiceDetails = ({
endDate,
serviceName,
indexNames,
id = OBSERVED_SERVICE_QUERY_ID,
skip = false,
startDate,
}: UseServiceDetails): [boolean, ServiceDetailsArgs] => {
const {
loading,
result: response,
search,
refetch,
inspect,
} = useSearchStrategy<ServicesQueries.observedDetails>({
factoryQueryType: ServicesQueries.observedDetails,
initialResult: {
serviceDetails: {},
},
errorMessage: i18n.translate('xpack.securitySolution.serviceDetails.failSearchDescription', {
defaultMessage: `Failed to run search on service details`,
}),
abort: skip,
});
const serviceDetailsResponse = useMemo(
() => ({
endDate,
serviceDetails: response.serviceDetails,
id,
inspect,
refetch,
startDate,
}),
[endDate, id, inspect, refetch, response.serviceDetails, startDate]
);
const serviceDetailsRequest = useMemo(
() => ({
defaultIndex: indexNames,
factoryQueryType: ServicesQueries.observedDetails,
serviceName,
timerange: {
interval: '12h',
from: startDate,
to: endDate,
},
}),
[endDate, indexNames, startDate, serviceName]
);
useEffect(() => {
if (!skip) {
search(serviceDetailsRequest);
}
}, [serviceDetailsRequest, search, skip]);
return [loading, serviceDetailsResponse];
};

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SERVICE_ID = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.idLabel',
{
defaultMessage: 'Service ID',
}
);
export const SERVICE_NAME = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.nameLabel',
{
defaultMessage: 'Name',
}
);
export const FIRST_SEEN = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.firstSeenLabel',
{
defaultMessage: 'First seen',
}
);
export const LAST_SEEN = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.lastSeenLabel',
{
defaultMessage: 'Last seen',
}
);
export const ADDRESS = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.addressLabel',
{
defaultMessage: 'Address',
}
);
export const ENVIRONMENT = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.environmentLabel',
{
defaultMessage: 'Environment',
}
);
export const EPHEMERAL_ID = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.ephemeralIdLabel',
{
defaultMessage: 'Ephemeral ID',
}
);
export const NODE_NAME = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.nodeNameLabel',
{
defaultMessage: 'Node name',
}
);
export const NODE_ROLES = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.nodeRolesLabel',
{
defaultMessage: 'Node roles',
}
);
export const NODE_ROLE = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.nodeRoleLabel',
{
defaultMessage: 'Node role',
}
);
export const STATE = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.stateLabel',
{
defaultMessage: 'State',
}
);
export const TYPE = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.typeLabel',
{
defaultMessage: 'Type',
}
);
export const VERSION = i18n.translate(
'xpack.securitySolution.flyout.entityDetails.service.versionLabel',
{
defaultMessage: 'Version',
}
);

View file

@ -0,0 +1,163 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { useNavigateToServiceDetails } from './use_navigate_to_service_details';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import {
CspInsightLeftPanelSubTab,
EntityDetailsLeftPanelTab,
} from '../../shared/components/left_panel/left_panel_header';
import { ServiceDetailsPanelKey } from '../../service_details_left';
import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock';
import { ServicePanelKey } from '../../shared/constants';
jest.mock('@kbn/expandable-flyout');
jest.mock('../../../../common/hooks/use_experimental_features');
const mockedTelemetry = createTelemetryServiceMock();
jest.mock('../../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
...original.useKibana(),
services: {
...original.useKibana().services,
telemetry: mockedTelemetry,
},
}),
};
});
const mockProps = {
serviceName: 'testService',
scopeId: 'testScopeId',
isRiskScoreExist: false,
hasMisconfigurationFindings: false,
hasNonClosedAlerts: false,
contextID: 'testContextID',
isPreviewMode: false,
email: ['test@test.com'],
};
const tab = EntityDetailsLeftPanelTab.RISK_INPUTS;
const subTab = CspInsightLeftPanelSubTab.MISCONFIGURATIONS;
const mockOpenLeftPanel = jest.fn();
const mockOpenFlyout = jest.fn();
describe('useNavigateToServiceDetails', () => {
describe('when preview navigation is enabled', () => {
beforeEach(() => {
jest.clearAllMocks();
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({
openLeftPanel: mockOpenLeftPanel,
openFlyout: mockOpenFlyout,
});
});
it('returns callback that opens details panel when not in preview mode', () => {
const { result } = renderHook(() => useNavigateToServiceDetails(mockProps));
expect(result.current.isLinkEnabled).toBe(true);
result.current.openDetailsPanel({ tab, subTab });
expect(result.current.isLinkEnabled).toBe(true);
result.current.openDetailsPanel({ tab, subTab });
expect(mockOpenLeftPanel).toHaveBeenCalledWith({
id: ServiceDetailsPanelKey,
params: {
service: {
name: mockProps.serviceName,
},
scopeId: mockProps.scopeId,
isRiskScoreExist: mockProps.isRiskScoreExist,
path: { tab, subTab },
},
});
});
it('returns callback that opens flyout when in preview mode', () => {
const { result } = renderHook(() =>
useNavigateToServiceDetails({ ...mockProps, isPreviewMode: true })
);
expect(result.current.isLinkEnabled).toBe(true);
result.current.openDetailsPanel({ tab, subTab });
expect(mockOpenFlyout).toHaveBeenCalledWith({
right: {
id: ServicePanelKey,
params: {
contextID: mockProps.contextID,
scopeId: mockProps.scopeId,
serviceName: mockProps.serviceName,
},
},
left: {
id: ServiceDetailsPanelKey,
params: {
service: {
name: mockProps.serviceName,
},
scopeId: mockProps.scopeId,
isRiskScoreExist: mockProps.isRiskScoreExist,
path: { tab, subTab },
},
},
});
expect(mockOpenLeftPanel).not.toHaveBeenCalled();
});
});
describe('when preview navigation is disabled', () => {
beforeEach(() => {
jest.clearAllMocks();
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({
openLeftPanel: mockOpenLeftPanel,
openFlyout: mockOpenFlyout,
});
});
it('returns callback that opens details panel when not in preview mode', () => {
const { result } = renderHook(() => useNavigateToServiceDetails(mockProps));
expect(result.current.isLinkEnabled).toBe(true);
result.current.openDetailsPanel({ tab, subTab });
expect(mockOpenLeftPanel).toHaveBeenCalledWith({
id: ServiceDetailsPanelKey,
params: {
service: {
name: mockProps.serviceName,
},
scopeId: mockProps.scopeId,
isRiskScoreExist: mockProps.isRiskScoreExist,
path: { tab, subTab },
},
});
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
it('returns empty callback and isLinkEnabled is false when in preview mode', () => {
const { result } = renderHook(() =>
useNavigateToServiceDetails({ ...mockProps, isPreviewMode: true })
);
expect(result.current.isLinkEnabled).toBe(false);
result.current.openDetailsPanel({ tab, subTab });
expect(mockOpenLeftPanel).not.toHaveBeenCalled();
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,105 @@
/*
* 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useCallback } from 'react';
import { EntityType } from '../../../../../common/search_strategy';
import type { EntityDetailsPath } from '../../shared/components/left_panel/left_panel_header';
import { useKibana } from '../../../../common/lib/kibana';
import { EntityEventTypes } from '../../../../common/lib/telemetry';
import { ServiceDetailsPanelKey } from '../../service_details_left';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { ServicePanelKey } from '../../shared/constants';
interface UseNavigateToServiceDetailsParams {
serviceName: string;
email?: string[];
scopeId: string;
contextID: string;
isDraggable?: boolean;
isRiskScoreExist: boolean;
isPreviewMode?: boolean;
}
interface UseNavigateToServiceDetailsResult {
/**
* Opens the service details panel
*/
openDetailsPanel: (path: EntityDetailsPath) => void;
/**
* Whether the link is enabled
*/
isLinkEnabled: boolean;
}
export const useNavigateToServiceDetails = ({
serviceName,
scopeId,
contextID,
isDraggable,
isRiskScoreExist,
isPreviewMode,
}: UseNavigateToServiceDetailsParams): UseNavigateToServiceDetailsResult => {
const { telemetry } = useKibana().services;
const { openLeftPanel, openFlyout } = useExpandableFlyoutApi();
const isNewNavigationEnabled = useIsExperimentalFeatureEnabled(
'newExpandableFlyoutNavigationEnabled'
);
const isLinkEnabled = !isPreviewMode || (isNewNavigationEnabled && isPreviewMode);
const openDetailsPanel = useCallback(
(path: EntityDetailsPath) => {
telemetry.reportEvent(EntityEventTypes.RiskInputsExpandedFlyoutOpened, {
entity: EntityType.service,
});
const left = {
id: ServiceDetailsPanelKey,
params: {
isRiskScoreExist,
scopeId,
service: {
name: serviceName,
},
path,
},
};
const right = {
id: ServicePanelKey,
params: {
contextID,
serviceName,
scopeId,
isDraggable,
},
};
// When new navigation is enabled, navigation in preview is enabled and open a new flyout
if (isNewNavigationEnabled && isPreviewMode) {
openFlyout({ right, left });
} else if (!isPreviewMode) {
// When not in preview mode, open left panel as usual
openLeftPanel(left);
}
},
[
contextID,
isDraggable,
isNewNavigationEnabled,
isPreviewMode,
isRiskScoreExist,
openFlyout,
openLeftPanel,
scopeId,
serviceName,
telemetry,
]
);
return { openDetailsPanel, isLinkEnabled };
};

View file

@ -0,0 +1,90 @@
/*
* 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 { useMemo } from 'react';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { inputsSelectors } from '../../../../common/store';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
import type { ServiceItem } from '../../../../../common/search_strategy';
import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../common/search_strategy';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
import { isActiveTimeline } from '../../../../helpers';
import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters';
import { useObservedServiceDetails } from './observed_service_details';
export const useObservedService = (
serviceName: string,
scopeId: string
): Omit<ObservedEntityData<ServiceItem>, 'anomalies'> => {
const timelineTime = useDeepEqualSelector((state) =>
inputsSelectors.timelineTimeRangeSelector(state)
);
const globalTime = useGlobalTime();
const isActiveTimelines = isActiveTimeline(scopeId);
const { to, from } = isActiveTimelines ? timelineTime : globalTime;
const { isInitializing, setQuery, deleteQuery } = globalTime;
const { selectedPatterns } = useTimelineDataFilters(isActiveTimeline(scopeId));
const [
loadingObservedService,
{ serviceDetails: observedServiceDetails, inspect, refetch, id: queryId },
] = useObservedServiceDetails({
endDate: to,
startDate: from,
serviceName,
indexNames: selectedPatterns,
skip: isInitializing,
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId,
loading: loadingObservedService,
});
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
field: 'service.name',
value: serviceName,
defaultIndex: selectedPatterns,
order: Direction.asc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
field: 'service.name',
value: serviceName,
defaultIndex: selectedPatterns,
order: Direction.desc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
return useMemo(
() => ({
details: observedServiceDetails,
isLoading: loadingObservedService || loadingLastSeen || loadingFirstSeen,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
},
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
}),
[
firstSeen,
lastSeen,
loadingFirstSeen,
loadingLastSeen,
loadingObservedService,
observedServiceDetails,
]
);
};

View file

@ -0,0 +1,103 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { mockObservedService } from '../mocks';
import { TestProviders } from '../../../../common/mock';
import { useObservedServiceItems } from './use_observed_service_items';
describe('useObservedServiceItems', () => {
it('returns observed service fields', () => {
const { result } = renderHook(() => useObservedServiceItems(mockObservedService), {
wrapper: TestProviders,
});
expect(result.current).toEqual([
{
field: 'service.id',
label: 'Service ID',
getValues: expect.any(Function),
},
{
field: 'service.name',
getValues: expect.any(Function),
label: 'Name',
},
{
field: 'service.address',
getValues: expect.any(Function),
label: 'Address',
},
{
field: 'service.environment',
getValues: expect.any(Function),
label: 'Environment',
},
{
field: 'service.ephemeral_id',
getValues: expect.any(Function),
label: 'Ephemeral ID',
},
{
field: 'service.node.name',
getValues: expect.any(Function),
label: 'Node name',
},
{
field: 'service.node.roles',
getValues: expect.any(Function),
label: 'Node roles',
},
{
field: 'service.node.role',
getValues: expect.any(Function),
label: 'Node role',
},
{
field: 'service.state',
getValues: expect.any(Function),
label: 'State',
},
{
field: 'service.type',
getValues: expect.any(Function),
label: 'Type',
},
{
field: 'service.version',
getValues: expect.any(Function),
label: 'Version',
},
{
label: 'First seen',
render: expect.any(Function),
},
{
label: 'Last seen',
render: expect.any(Function),
},
]);
expect(
result.current.map(({ getValues }) => getValues && getValues(mockObservedService))
).toEqual([
['test id'], // id
['test name', 'another test name'], // name
['test address'], // address
['test environment'], // environment
['test ephemeral_id'], // ephemeral_id
['test node name'], // node name
['test node roles'], // node roles
['test node role'], // node roles
['test state'], // state
['test type'], // type
['test version'], // version
undefined,
undefined,
]);
});
});

View file

@ -0,0 +1,107 @@
/*
* 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 React from 'react';
import type { ServiceItem } from '../../../../../common/search_strategy';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import * as i18n from './translations';
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
import type { EntityTableRows } from '../../shared/components/entity_table/types';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
const basicServiceFields: EntityTableRows<ObservedEntityData<ServiceItem>> = [
{
label: i18n.SERVICE_ID,
getValues: (serviceData: ObservedEntityData<ServiceItem>) => serviceData.details.service?.id,
field: 'service.id',
},
{
label: i18n.SERVICE_NAME,
getValues: (serviceData: ObservedEntityData<ServiceItem>) => serviceData.details.service?.name,
field: 'service.name',
},
{
label: i18n.ADDRESS,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.address,
field: 'service.address',
},
{
label: i18n.ENVIRONMENT,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.environment,
field: 'service.environment',
},
{
label: i18n.EPHEMERAL_ID,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.ephemeral_id,
field: 'service.ephemeral_id',
},
{
label: i18n.NODE_NAME,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.node?.name,
field: 'service.node.name',
},
{
label: i18n.NODE_ROLES,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.node?.roles,
field: 'service.node.roles',
},
{
label: i18n.NODE_ROLE,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.node?.role,
field: 'service.node.role',
},
{
label: i18n.STATE,
getValues: (serviceData: ObservedEntityData<ServiceItem>) => serviceData.details.service?.state,
field: 'service.state',
},
{
label: i18n.TYPE,
getValues: (serviceData: ObservedEntityData<ServiceItem>) => serviceData.details.service?.type,
field: 'service.type',
},
{
label: i18n.VERSION,
getValues: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.details.service?.version,
field: 'service.version',
},
{
label: i18n.FIRST_SEEN,
render: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.firstSeen.date ? (
<FormattedRelativePreferenceDate value={serviceData.firstSeen.date} />
) : (
getEmptyTagValue()
),
},
{
label: i18n.LAST_SEEN,
render: (serviceData: ObservedEntityData<ServiceItem>) =>
serviceData.lastSeen.date ? (
<FormattedRelativePreferenceDate value={serviceData.lastSeen.date} />
) : (
getEmptyTagValue()
),
},
];
export const useObservedServiceItems = (
serviceData: ObservedEntityData<ServiceItem>
): EntityTableRows<ObservedEntityData<ServiceItem>> => {
if (!serviceData.details) {
return [];
}
return basicServiceFields;
};

View file

@ -0,0 +1,97 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../common/mock';
import type { ServicePanelProps } from '.';
import { ServicePanel } from '.';
import type {
FlyoutPanelProps,
ExpandableFlyoutState,
ExpandableFlyoutApi,
} from '@kbn/expandable-flyout';
import {
useExpandableFlyoutApi,
useExpandableFlyoutState,
useExpandableFlyoutHistory,
} from '@kbn/expandable-flyout';
import { mockObservedService } from './mocks';
import { mockServiceRiskScoreState } from '../mocks';
const mockProps: ServicePanelProps = {
serviceName: 'test',
contextID: 'test-service-panel',
scopeId: 'test-scope-id',
isDraggable: false,
};
jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
const mockedUseRiskScore = jest.fn().mockReturnValue(mockServiceRiskScoreState);
jest.mock('../../../entity_analytics/api/hooks/use_risk_score', () => ({
useRiskScore: () => mockedUseRiskScore(),
}));
const mockedUseObservedService = jest.fn().mockReturnValue(mockObservedService);
jest.mock('./hooks/use_observed_service', () => ({
useObservedService: () => mockedUseObservedService(),
}));
const mockedUseIsExperimentalFeatureEnabled = jest.fn().mockReturnValue(true);
jest.mock('../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: () => mockedUseIsExperimentalFeatureEnabled(),
}));
const flyoutContextValue = {
closeLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutApi;
const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[];
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
useExpandableFlyoutState: jest.fn(),
}));
describe('ServicePanel', () => {
beforeEach(() => {
mockedUseRiskScore.mockReturnValue(mockServiceRiskScoreState);
mockedUseObservedService.mockReturnValue(mockObservedService);
jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory);
jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState);
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
});
it('renders', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<ServicePanel {...mockProps} />
</TestProviders>
);
expect(getByTestId('service-panel-header')).toBeInTheDocument();
expect(queryByTestId('securitySolutionFlyoutLoading')).not.toBeInTheDocument();
expect(getByTestId('securitySolutionFlyoutNavigationExpandDetailButton')).toBeInTheDocument();
});
it('renders loading state when observed service is loading', () => {
mockedUseObservedService.mockReturnValue({
...mockObservedService,
isLoading: true,
});
const { getByTestId } = render(
<TestProviders>
<ServicePanel {...mockProps} />
</TestProviders>
);
expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,137 @@
/*
* 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 React, { useCallback, useMemo } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { TableId } from '@kbn/securitysolution-data-table';
import { noop } from 'lodash/fp';
import { buildEntityNameFilter } from '../../../../common/search_strategy';
import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id';
import type { Refetch } from '../../../common/types';
import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab';
import { useCalculateEntityRiskScore } from '../../../entity_analytics/api/hooks/use_calculate_entity_risk_score';
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
import { useQueryInspector } from '../../../common/components/page/manage_query';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { FlyoutLoading } from '../../shared/components/flyout_loading';
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
import { ServicePanelContent } from './content';
import { ServicePanelHeader } from './header';
import { useObservedService } from './hooks/use_observed_service';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
import { useNavigateToServiceDetails } from './hooks/use_navigate_to_service_details';
export interface ServicePanelProps extends Record<string, unknown> {
contextID: string;
scopeId: string;
serviceName: string;
isDraggable?: boolean;
}
export interface ServicePanelExpandableFlyoutProps extends FlyoutPanelProps {
key: 'service-panel';
params: ServicePanelProps;
}
export const SERVICE_PANEL_RISK_SCORE_QUERY_ID = 'servicePanelRiskScoreQuery';
const FIRST_RECORD_PAGINATION = {
cursorStart: 0,
querySize: 1,
};
export const ServicePanel = ({
contextID,
scopeId,
serviceName,
isDraggable,
}: ServicePanelProps) => {
const serviceNameFilterQuery = useMemo(
() => (serviceName ? buildEntityNameFilter(EntityType.service, [serviceName]) : undefined),
[serviceName]
);
const riskScoreState = useRiskScore({
riskEntity: EntityType.service,
filterQuery: serviceNameFilterQuery,
onlyLatest: false,
pagination: FIRST_RECORD_PAGINATION,
});
const { inspect, refetch, loading } = riskScoreState;
const { setQuery, deleteQuery } = useGlobalTime();
const observedService = useObservedService(serviceName, scopeId);
const { data: serviceRisk } = riskScoreState;
const serviceRiskData = serviceRisk && serviceRisk.length > 0 ? serviceRisk[0] : undefined;
const isRiskScoreExist = !!serviceRiskData?.service.risk;
const refetchRiskInputsTab = useRefetchQueryById(RISK_INPUTS_TAB_QUERY_ID) ?? noop;
const refetchRiskScore = useCallback(() => {
refetch();
(refetchRiskInputsTab as Refetch)();
}, [refetch, refetchRiskInputsTab]);
const { isLoading: recalculatingScore, calculateEntityRiskScore } = useCalculateEntityRiskScore(
EntityType.service,
serviceName,
{ onSuccess: refetchRiskScore }
);
useQueryInspector({
deleteQuery,
inspect,
loading,
queryId: SERVICE_PANEL_RISK_SCORE_QUERY_ID,
refetch,
setQuery,
});
const { openDetailsPanel, isLinkEnabled } = useNavigateToServiceDetails({
serviceName,
scopeId,
contextID,
isDraggable,
isRiskScoreExist,
});
const openPanelFirstTab = useCallback(
() =>
openDetailsPanel({
tab: EntityDetailsLeftPanelTab.RISK_INPUTS,
}),
[openDetailsPanel]
);
if (observedService.isLoading) {
return <FlyoutLoading />;
}
return (
<>
<FlyoutNavigation
flyoutIsExpandable={isRiskScoreExist}
expandDetails={openPanelFirstTab}
isPreview={scopeId === TableId.rulePreview}
/>
<ServicePanelHeader serviceName={serviceName} observedService={observedService} />
<ServicePanelContent
serviceName={serviceName}
observedService={observedService}
riskScoreState={riskScoreState}
recalculatingScore={recalculatingScore}
onAssetCriticalityChange={calculateEntityRiskScore}
contextID={contextID}
scopeId={scopeId}
isDraggable={!!isDraggable}
openDetailsPanel={openDetailsPanel}
isLinkEnabled={isLinkEnabled}
/>
</>
);
};
ServicePanel.displayName = 'ServicePanel';

View file

@ -0,0 +1,41 @@
/*
* 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 { ServiceItem } from '../../../../../common/search_strategy';
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
const observedServiceDetails: ServiceItem = {
service: {
id: ['test id'],
name: ['test name', 'another test name'],
address: ['test address'],
environment: ['test environment'],
ephemeral_id: ['test ephemeral_id'],
node: {
name: ['test node name'],
roles: ['test node roles'],
role: ['test node role'],
},
roles: ['test roles'],
state: ['test state'],
type: ['test type'],
version: ['test version'],
},
};
export const mockObservedService: ObservedEntityData<ServiceItem> = {
details: observedServiceDetails,
isLoading: false,
firstSeen: {
isLoading: false,
date: '2023-02-23T20:03:17.489Z',
},
lastSeen: {
isLoading: false,
date: '2023-02-23T20:03:17.489Z',
},
};

View file

@ -22,6 +22,6 @@ export interface EntityAnomalies {
export interface ObservedEntityData<T> extends BasicEntityData {
firstSeen: FirstLastSeenData;
lastSeen: FirstLastSeenData;
anomalies: EntityAnomalies;
anomalies?: EntityAnomalies;
details: T;
}

View file

@ -7,6 +7,7 @@
import { EntityType } from '../../../../common/entity_analytics/types';
import type { HostPanelExpandableFlyoutProps } from '../host_right';
import type { ServicePanelExpandableFlyoutProps } from '../service_right';
import type { UserPanelExpandableFlyoutProps } from '../user_right';
export const ONE_WEEK_IN_HOURS = 24 * 7;
@ -25,11 +26,12 @@ export const MANAGED_USER_QUERY_ID = 'managedUserDetailsQuery';
export const HostPanelKey: HostPanelExpandableFlyoutProps['key'] = 'host-panel';
export const UserPanelKey: UserPanelExpandableFlyoutProps['key'] = 'user-panel';
export const ServicePanelKey: ServicePanelExpandableFlyoutProps['key'] = 'service-panel';
export const EntityPanelKeyByType: Record<EntityType, string | undefined> = {
[EntityType.host]: HostPanelKey,
[EntityType.user]: UserPanelKey,
[EntityType.service]: undefined, // TODO create service flyout
[EntityType.service]: ServicePanelKey,
[EntityType.universal]: undefined, // TODO create universal flyout?
};
@ -37,6 +39,6 @@ export const EntityPanelKeyByType: Record<EntityType, string | undefined> = {
export const EntityPanelParamByType: Record<EntityType, string | undefined> = {
[EntityType.host]: 'hostName',
[EntityType.user]: 'userName',
[EntityType.service]: undefined, // TODO create service flyout
[EntityType.service]: 'serviceName',
[EntityType.universal]: undefined, // TODO create universal flyout?
};

View file

@ -21,7 +21,7 @@ import type {
import { ENTRA_TAB_TEST_ID, OKTA_TAB_TEST_ID } from './test_ids';
import { AssetDocumentTab } from './tabs/asset_document';
import { DocumentDetailsProvider } from '../../document_details/shared/context';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types';
import type { LeftPanelTabsType } from '../shared/components/left_panel/left_panel_header';
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
@ -57,7 +57,7 @@ export const useTabs = (
}
if (hasMisconfigurationFindings || hasNonClosedAlerts) {
tabs.push(getInsightsInputTab({ name, fieldName: 'user.name' }));
tabs.push(getInsightsInputTab({ name, fieldName: EntityIdentifierFields.userName }));
}
return tabs;

View file

@ -17,7 +17,7 @@ import { FlyoutRiskSummary } from '../../../entity_analytics/components/risk_sum
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
import { ManagedUser } from './components/managed_user';
import type { ManagedUserData } from './types';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types';
import { USER_PANEL_RISK_SCORE_QUERY_ID } from '.';
import { FlyoutBody } from '../../shared/components/flyout_body';
import { ObservedEntity } from '../shared/components/observed_entity';
@ -75,12 +75,12 @@ export const UserPanelContent = ({
</>
)}
<AssetCriticalityAccordion
entity={{ name: userName, type: 'user' }}
entity={{ name: userName, type: EntityType.user }}
onChange={onAssetCriticalityChange}
/>
<EntityInsight
value={userName}
field={'user.name'}
field={EntityIdentifierFields.userName}
isPreviewMode={isPreviewMode}
isLinkEnabled={isLinkEnabled}
openDetailsPanel={openDetailsPanel}

View file

@ -32,7 +32,7 @@ import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_
import { UserPreviewPanelFooter } from '../user_preview/footer';
import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../overview/components/detection_response/alerts_by_status/types';
import { useNavigateToUserDetails } from './hooks/use_navigate_to_user_details';
import { EntityType } from '../../../../common/entity_analytics/types';
import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types';
export interface UserPanelProps extends Record<string, unknown> {
contextID: string;
@ -99,7 +99,7 @@ export const UserPanel = ({
const { hasMisconfigurationFindings } = useHasMisconfigurations('user.name', userName);
const { hasNonClosedAlerts } = useNonClosedAlerts({
field: 'user.name',
field: EntityIdentifierFields.userName,
value: userName,
to,
from,
@ -121,7 +121,7 @@ export const UserPanel = ({
scopeId,
contextID,
isDraggable,
isRiskScoreExist: !!userRiskData?.user?.risk,
isRiskScoreExist,
hasMisconfigurationFindings,
hasNonClosedAlerts,
isPreviewMode,

View file

@ -46,7 +46,11 @@ import { HostDetailsPanel, HostDetailsPanelKey } from './entity_details/host_det
import { NetworkPanel, NetworkPanelKey, NetworkPreviewPanelKey } from './network_details';
import type { AnalyzerPanelExpandableFlyoutProps } from './document_details/analyzer_panels';
import { AnalyzerPanel } from './document_details/analyzer_panels';
import { UserPanelKey, HostPanelKey } from './entity_details/shared/constants';
import { UserPanelKey, HostPanelKey, ServicePanelKey } from './entity_details/shared/constants';
import type { ServicePanelExpandableFlyoutProps } from './entity_details/service_right';
import { ServicePanel } from './entity_details/service_right';
import type { ServiceDetailsExpandableFlyoutProps } from './entity_details/service_details_left';
import { ServiceDetailsPanel, ServiceDetailsPanelKey } from './entity_details/service_details_left';
/**
* List of all panels that will be used within the document details expandable flyout.
@ -159,6 +163,17 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
<NetworkPanel {...(props as NetworkExpandableFlyoutProps).params} isPreviewMode />
),
},
{
key: ServicePanelKey,
component: (props) => <ServicePanel {...(props as ServicePanelExpandableFlyoutProps).params} />,
},
{
key: ServiceDetailsPanelKey,
component: (props) => (
<ServiceDetailsPanel {...(props as ServiceDetailsExpandableFlyoutProps).params} />
),
},
];
export const SECURITY_SOLUTION_ON_CLOSE_EVENT = `expandable-flyout-on-close-${Flyouts.securitySolution}`;

View file

@ -13,6 +13,7 @@ import { isEmpty, isNumber } from 'lodash/fp';
import React from 'react';
import { css } from '@emotion/css';
import type { FieldSpec } from '@kbn/data-plugin/common';
import { EntityTypeToIdentifierField } from '../../../../../../common/entity_analytics/types';
import { getAgentTypeForAgentIdField } from '../../../../../common/lib/endpoint/utils/get_agent_type_for_agent_id_field';
import {
ALERT_HOST_CRITICALITY,
@ -35,20 +36,19 @@ import {
EVENT_MODULE_FIELD_NAME,
EVENT_URL_FIELD_NAME,
GEO_FIELD_TYPE,
HOST_NAME_FIELD_NAME,
IP_FIELD_TYPE,
MESSAGE_FIELD_NAME,
REFERENCE_URL_FIELD_NAME,
RULE_REFERENCE_FIELD_NAME,
SIGNAL_RULE_NAME_FIELD_NAME,
SIGNAL_STATUS_FIELD_NAME,
USER_NAME_FIELD_NAME,
} from './constants';
import { renderEventModule, RenderRuleName, renderUrl } from './formatted_field_helpers';
import { RuleStatus } from './rule_status';
import { HostName } from './host_name';
import { UserName } from './user_name';
import { AssetCriticalityLevel } from './asset_criticality_level';
import { ServiceName } from './service_name';
// simple black-list to prevent dragging and dropping fields such as message name
const columnNamesNotDraggable = [MESSAGE_FIELD_NAME];
@ -177,7 +177,7 @@ const FormattedFieldValueComponent: React.FC<{
value={`${value}`}
/>
);
} else if (fieldName === HOST_NAME_FIELD_NAME) {
} else if (fieldName === EntityTypeToIdentifierField.host) {
return (
<HostName
Component={Component}
@ -193,7 +193,7 @@ const FormattedFieldValueComponent: React.FC<{
value={value}
/>
);
} else if (fieldName === USER_NAME_FIELD_NAME) {
} else if (fieldName === EntityTypeToIdentifierField.user) {
return (
<UserName
Component={Component}
@ -209,6 +209,22 @@ const FormattedFieldValueComponent: React.FC<{
value={value}
/>
);
} else if (fieldName === EntityTypeToIdentifierField.service) {
return (
<ServiceName
Component={Component}
contextId={contextId}
eventId={eventId}
fieldName={fieldName}
fieldType={fieldType}
isAggregatable={isAggregatable}
isDraggable={isDraggable}
isButton={isButton}
onClick={onClick}
title={title}
value={value}
/>
);
} else if (fieldFormat === BYTES_FORMAT) {
return (
<Bytes

View file

@ -0,0 +1,132 @@
/*
* 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 React, { useCallback, useContext, useMemo } from 'react';
import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { isString } from 'lodash/fp';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { EntityType } from '../../../../../../common/search_strategy';
import { EntityDetailsLink } from '../../../../../common/components/links';
import { ServicePanelKey } from '../../../../../flyout/entity_details/shared/constants';
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
import { DefaultDraggable } from '../../../../../common/components/draggables';
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
import { useIsInSecurityApp } from '../../../../../common/hooks/is_in_security_app';
interface Props {
contextId: string;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
eventId: string;
fieldName: string;
fieldType: string;
isAggregatable: boolean;
isDraggable: boolean;
isButton?: boolean;
onClick?: () => void;
value: string | number | undefined | null;
title?: string;
}
const ServiceNameComponent: React.FC<Props> = ({
fieldName,
Component,
contextId,
eventId,
fieldType,
isAggregatable,
isDraggable,
isButton,
onClick,
title,
value,
}) => {
const eventContext = useContext(StatefulEventContext);
const serviceName = `${value}`;
const isInTimelineContext = serviceName && eventContext?.timelineID;
const { openFlyout } = useExpandableFlyoutApi();
const isInSecurityApp = useIsInSecurityApp();
const openServiceDetailsSidePanel = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault();
if (onClick) {
onClick();
}
if (!eventContext || !isInTimelineContext) {
return;
}
const { timelineID } = eventContext;
openFlyout({
right: {
id: ServicePanelKey,
params: {
serviceName,
contextID: contextId,
scopeId: timelineID,
isDraggable,
},
},
});
},
[contextId, eventContext, isDraggable, isInTimelineContext, onClick, openFlyout, serviceName]
);
const content = useMemo(
() => (
<EntityDetailsLink
Component={Component}
entityName={serviceName}
isButton={isButton}
onClick={isInTimelineContext || !isInSecurityApp ? openServiceDetailsSidePanel : undefined}
title={title}
entityType={EntityType.service}
>
<TruncatableText data-test-subj="draggable-truncatable-content">
{serviceName}
</TruncatableText>
</EntityDetailsLink>
),
[
serviceName,
isButton,
isInTimelineContext,
openServiceDetailsSidePanel,
Component,
title,
isInSecurityApp,
]
);
return isString(value) && serviceName.length > 0 ? (
isDraggable ? (
<DefaultDraggable
field={fieldName}
id={`event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}`}
fieldType={fieldType}
isAggregatable={isAggregatable}
isDraggable={isDraggable}
tooltipContent={fieldName}
value={serviceName}
>
{content}
</DefaultDraggable>
) : (
content
)
) : (
getEmptyTagValue()
);
};
export const ServiceName = React.memo(ServiceNameComponent);
ServiceName.displayName = 'ServiceName';

View file

@ -6,6 +6,7 @@
*/
import type { EntityDescription } from '../types';
import { getCommonFieldDescriptions } from './common';
import { collectValues as collect, newestValue } from './field_utils';
export const SERVICE_DEFINITION_VERSION = '1.0.0';
@ -25,8 +26,10 @@ export const serviceEntityEngineDescription: EntityDescription = {
collect({ source: 'service.id' }),
collect({ source: 'service.node.name' }),
collect({ source: 'service.node.roles' }),
collect({ source: 'service.node.role' }),
newestValue({ source: 'service.state' }),
collect({ source: 'service.type' }),
newestValue({ source: 'service.version' }),
...getCommonFieldDescriptions('service'),
],
};

View file

@ -271,7 +271,7 @@ describe('EntityStoreDataClient', () => {
installed: true,
},
{
id: 'security_host_test',
id: 'indexTemplates_id',
installed: true,
resource: 'index_template',
},

View file

@ -505,8 +505,8 @@ export class EntityStoreDataClient {
resource: EngineComponentResourceEnum.ingest_pipeline,
...pipeline,
})),
...definition.state.components.indexTemplates.map(({ installed }) => ({
id,
...definition.state.components.indexTemplates.map(({ installed, id: templateId }) => ({
id: templateId,
installed,
resource: EngineComponentResourceEnum.index_template,
})),

View file

@ -536,9 +536,21 @@ describe('getUnitedEntityDefinition', () => {
"service.node.name": Object {
"type": "keyword",
},
"service.node.role": Object {
"type": "keyword",
},
"service.node.roles": Object {
"type": "keyword",
},
"service.risk.calculated_level": Object {
"type": "keyword",
},
"service.risk.calculated_score": Object {
"type": "float",
},
"service.risk.calculated_score_norm": Object {
"type": "float",
},
"service.state": Object {
"type": "keyword",
},
@ -630,6 +642,14 @@ describe('getUnitedEntityDefinition', () => {
"destination": "service.node.roles",
"source": "service.node.roles",
},
Object {
"aggregation": Object {
"limit": 10,
"type": "terms",
},
"destination": "service.node.role",
"source": "service.node.role",
},
Object {
"aggregation": Object {
"sort": Object {
@ -658,6 +678,56 @@ describe('getUnitedEntityDefinition', () => {
"destination": "service.version",
"source": "service.version",
},
Object {
"aggregation": Object {
"sort": Object {
"@timestamp": "asc",
},
"type": "top_value",
},
"destination": "entity.source",
"source": "_index",
},
Object {
"aggregation": Object {
"sort": Object {
"@timestamp": "desc",
},
"type": "top_value",
},
"destination": "asset.criticality",
"source": "asset.criticality",
},
Object {
"aggregation": Object {
"sort": Object {
"@timestamp": "desc",
},
"type": "top_value",
},
"destination": "service.risk.calculated_level",
"source": "service.risk.calculated_level",
},
Object {
"aggregation": Object {
"sort": Object {
"@timestamp": "desc",
},
"type": "top_value",
},
"destination": "service.risk.calculated_score",
"source": "service.risk.calculated_score",
},
Object {
"aggregation": Object {
"sort": Object {
"@timestamp": "desc",
},
"type": "top_value",
},
"destination": "service.risk.calculated_score_norm",
"source": "service.risk.calculated_score_norm",
},
],
"name": "Security 'service' Entity Store Definition",
"type": "service",

View file

@ -15,6 +15,7 @@ import { riskScoreFactory } from './risk_score';
import { usersFactory } from './users';
import { firstLastSeenFactory } from './last_first_seen';
import { relatedEntitiesFactory } from './related_entities';
import { servicesFactory } from './services';
export const securitySolutionFactory: Record<
FactoryQueryTypes,
@ -22,6 +23,7 @@ export const securitySolutionFactory: Record<
> = {
...hostsFactory,
...usersFactory,
...servicesFactory,
...networkFactory,
...ctiFactoryTypes,
...riskScoreFactory,

View file

@ -0,0 +1,19 @@
/*
* 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 { ServicesQueries } from '../../../../../common/api/search_strategy';
import type { FactoryQueryTypes } from '../../../../../common/search_strategy/security_solution';
import type { SecuritySolutionFactory } from '../types';
import { observedServiceDetails } from './observed_details';
export const servicesFactory: Record<
ServicesQueries,
SecuritySolutionFactory<FactoryQueryTypes>
> = {
[ServicesQueries.observedDetails]: observedServiceDetails,
};

View file

@ -0,0 +1,219 @@
/*
* 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 { IEsSearchResponse } from '@kbn/search-types';
import type { ObservedServiceDetailsRequestOptions } from '../../../../../../../common/api/search_strategy';
import { ServicesQueries } from '../../../../../../../common/search_strategy/security_solution/services';
export const mockOptions: ObservedServiceDetailsRequestOptions = {
defaultIndex: ['test_indices*'],
factoryQueryType: ServicesQueries.observedDetails,
filterQuery:
'{"bool":{"must":[],"filter":[{"match_all":{}},{"match_phrase":{"service.name":{"query":"test_service"}}}],"should":[],"must_not":[]}}',
timerange: {
interval: '12h',
from: '2020-09-02T15:17:13.678Z',
to: '2020-09-03T15:17:13.678Z',
},
params: {},
serviceName: 'bastion00.siem.estc.dev',
} as ObservedServiceDetailsRequestOptions;
export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
rawResponse: {
took: 1,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 1,
failed: 0,
},
hits: {
max_score: null,
hits: [],
},
aggregations: {
aggregations: {
service_id: {
doc_count_error_upper_bound: -1,
sum_other_doc_count: 117,
buckets: [
{
key: 'I30s36URfOdZ7gtpC4dum',
doc_count: 3,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_name: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'Service-alarm',
doc_count: 147,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_address: {
doc_count_error_upper_bound: -1,
sum_other_doc_count: 117,
buckets: [
{
key: '15.103.138.105',
doc_count: 3,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_environment: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'development',
doc_count: 57,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_ephemeral_id: {
doc_count_error_upper_bound: -1,
sum_other_doc_count: 117,
buckets: [
{
key: 'EV8lINfcelHgHrJMwuNvQ',
doc_count: 3,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_node_name: {
doc_count_error_upper_bound: -1,
sum_other_doc_count: 117,
buckets: [
{
key: 'corny-edger',
doc_count: 3,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_node_roles: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'data',
doc_count: 42,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
{
key: 'ingest',
doc_count: 54,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
{
key: 'master',
doc_count: 51,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_node_role: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'ingest',
doc_count: 30,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_state: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'running',
doc_count: 51,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_type: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'system',
doc_count: 147,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
service_version: {
doc_count_error_upper_bound: -1,
sum_other_doc_count: 117,
buckets: [
{
key: '2.1.9',
doc_count: 3,
timestamp: {
value: 1736851996820,
value_as_string: '2025-01-14T10:53:16.820Z',
},
},
],
},
},
},
},
isPartial: false,
isRunning: false,
total: 2,
loaded: 2,
};

View file

@ -0,0 +1,431 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`serviceDetails search strategy parse should parse data correctly 1`] = `
Object {
"inspect": Object {
"dsl": Array [
"{
\\"allow_no_indices\\": true,
\\"index\\": [
\\"test_indices*\\"
],
\\"ignore_unavailable\\": true,
\\"track_total_hits\\": false,
\\"body\\": {
\\"aggregations\\": {
\\"service_id\\": {
\\"terms\\": {
\\"field\\": \\"service.id\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_name\\": {
\\"terms\\": {
\\"field\\": \\"service.name\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_address\\": {
\\"terms\\": {
\\"field\\": \\"service.address\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_environment\\": {
\\"terms\\": {
\\"field\\": \\"service.environment\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_ephemeral_id\\": {
\\"terms\\": {
\\"field\\": \\"service.ephemeral_id\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_node_name\\": {
\\"terms\\": {
\\"field\\": \\"service.node.name\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_node_roles\\": {
\\"terms\\": {
\\"field\\": \\"service.node.roles\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_node_role\\": {
\\"terms\\": {
\\"field\\": \\"service.node.role\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_state\\": {
\\"terms\\": {
\\"field\\": \\"service.state\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_type\\": {
\\"terms\\": {
\\"field\\": \\"service.type\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
},
\\"service_version\\": {
\\"terms\\": {
\\"field\\": \\"service.version\\",
\\"size\\": 10,
\\"order\\": {
\\"timestamp\\": \\"desc\\"
}
},
\\"aggs\\": {
\\"timestamp\\": {
\\"max\\": {
\\"field\\": \\"@timestamp\\"
}
}
}
}
},
\\"query\\": {
\\"bool\\": {
\\"filter\\": [
{
\\"bool\\": {
\\"must\\": [],
\\"filter\\": [
{
\\"match_all\\": {}
},
{
\\"match_phrase\\": {
\\"service.name\\": {
\\"query\\": \\"test_service\\"
}
}
}
],
\\"should\\": [],
\\"must_not\\": []
}
},
{
\\"term\\": {
\\"service.name\\": \\"bastion00.siem.estc.dev\\"
}
},
{
\\"range\\": {
\\"@timestamp\\": {
\\"format\\": \\"strict_date_optional_time\\",
\\"gte\\": \\"2020-09-02T15:17:13.678Z\\",
\\"lte\\": \\"2020-09-03T15:17:13.678Z\\"
}
}
}
]
}
},
\\"size\\": 0
}
}",
],
},
"isPartial": false,
"isRunning": false,
"loaded": 2,
"rawResponse": Object {
"_shards": Object {
"failed": 0,
"skipped": 1,
"successful": 2,
"total": 2,
},
"aggregations": Object {
"aggregations": Object {
"service_address": Object {
"buckets": Array [
Object {
"doc_count": 3,
"key": "15.103.138.105",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": -1,
"sum_other_doc_count": 117,
},
"service_environment": Object {
"buckets": Array [
Object {
"doc_count": 57,
"key": "development",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"service_ephemeral_id": Object {
"buckets": Array [
Object {
"doc_count": 3,
"key": "EV8lINfcelHgHrJMwuNvQ",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": -1,
"sum_other_doc_count": 117,
},
"service_id": Object {
"buckets": Array [
Object {
"doc_count": 3,
"key": "I30s36URfOdZ7gtpC4dum",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": -1,
"sum_other_doc_count": 117,
},
"service_name": Object {
"buckets": Array [
Object {
"doc_count": 147,
"key": "Service-alarm",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"service_node_name": Object {
"buckets": Array [
Object {
"doc_count": 3,
"key": "corny-edger",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": -1,
"sum_other_doc_count": 117,
},
"service_node_role": Object {
"buckets": Array [
Object {
"doc_count": 30,
"key": "ingest",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"service_node_roles": Object {
"buckets": Array [
Object {
"doc_count": 42,
"key": "data",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
Object {
"doc_count": 54,
"key": "ingest",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
Object {
"doc_count": 51,
"key": "master",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"service_state": Object {
"buckets": Array [
Object {
"doc_count": 51,
"key": "running",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"service_type": Object {
"buckets": Array [
Object {
"doc_count": 147,
"key": "system",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"service_version": Object {
"buckets": Array [
Object {
"doc_count": 3,
"key": "2.1.9",
"timestamp": Object {
"value": 1736851996820,
"value_as_string": "2025-01-14T10:53:16.820Z",
},
},
],
"doc_count_error_upper_bound": -1,
"sum_other_doc_count": 117,
},
},
},
"hits": Object {
"hits": Array [],
"max_score": null,
},
"timed_out": false,
"took": 1,
},
"serviceDetails": Object {},
"total": 2,
}
`;

View file

@ -0,0 +1,232 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`buildServiceDetailsQuery build query from options correctly 1`] = `
Object {
"allow_no_indices": true,
"body": Object {
"aggregations": Object {
"service_address": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.address",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_environment": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.environment",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_ephemeral_id": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.ephemeral_id",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_id": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.id",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_name": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.name",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_node_name": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.node.name",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_node_role": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.node.role",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_node_roles": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.node.roles",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_state": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.state",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_type": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.type",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
"service_version": Object {
"aggs": Object {
"timestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "service.version",
"order": Object {
"timestamp": "desc",
},
"size": 10,
},
},
},
"query": Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_all": Object {},
},
Object {
"match_phrase": Object {
"service.name": Object {
"query": "test_service",
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"term": Object {
"service.name": "bastion00.siem.estc.dev",
},
},
Object {
"range": Object {
"@timestamp": Object {
"format": "strict_date_optional_time",
"gte": "2020-09-02T15:17:13.678Z",
"lte": "2020-09-03T15:17:13.678Z",
},
},
},
],
},
},
"size": 0,
},
"ignore_unavailable": true,
"index": Array [
"test_indices*",
],
"track_total_hits": false,
}
`;

View file

@ -0,0 +1,33 @@
/*
* 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 { ServiceAggEsItem } from '../../../../../../common/search_strategy/security_solution/services/common';
import { fieldNameToAggField, formatServiceItem } from './helpers';
describe('helpers', () => {
it('it convert field name to aggregation field name', () => {
expect(fieldNameToAggField('service.node.role')).toBe('service_node_role');
});
it('it formats ServiceItem', () => {
const serviceId = '123';
const aggregations: ServiceAggEsItem = {
service_id: {
buckets: [
{
key: serviceId,
doc_count: 1,
},
],
},
};
expect(formatServiceItem(aggregations)).toEqual({
service: { id: [serviceId] },
});
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 { set } from '@kbn/safer-lodash-set/fp';
import { get, has } from 'lodash/fp';
import type {
ServiceAggEsItem,
ServiceBuckets,
ServiceItem,
} from '../../../../../../common/search_strategy/security_solution/services/common';
export const SERVICE_FIELDS = [
'service.id',
'service.name',
'service.address',
'service.environment',
'service.ephemeral_id',
'service.node.name',
'service.node.roles',
'service.node.role',
'service.state',
'service.type',
'service.version',
];
export const fieldNameToAggField = (fieldName: string) => fieldName.replace(/\./g, '_');
export const formatServiceItem = (aggregations: ServiceAggEsItem): ServiceItem => {
return SERVICE_FIELDS.reduce<ServiceItem>((flattenedFields, fieldName) => {
const aggField = fieldNameToAggField(fieldName);
if (has(aggField, aggregations)) {
const data: ServiceBuckets = get(aggField, aggregations);
const fieldValue = data.buckets.map((obj) => obj.key);
return set(fieldName, fieldValue, flattenedFields);
}
return flattenedFields;
}, {});
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as buildQuery from './query.observed_service_details.dsl';
import { observedServiceDetails } from '.';
import { mockOptions, mockSearchStrategyResponse } from './__mocks__';
describe('serviceDetails search strategy', () => {
const buildServiceDetailsQuery = jest.spyOn(buildQuery, 'buildObservedServiceDetailsQuery');
afterEach(() => {
buildServiceDetailsQuery.mockClear();
});
describe('buildDsl', () => {
test('should build dsl query', () => {
observedServiceDetails.buildDsl(mockOptions);
expect(buildServiceDetailsQuery).toHaveBeenCalledWith(mockOptions);
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await observedServiceDetails.parse(mockOptions, mockSearchStrategyResponse);
expect(result).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { IEsSearchResponse } from '@kbn/search-types';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import type { SecuritySolutionFactory } from '../../types';
import { buildObservedServiceDetailsQuery } from './query.observed_service_details.dsl';
import type { ServicesQueries } from '../../../../../../common/search_strategy/security_solution/services';
import type { ObservedServiceDetailsStrategyResponse } from '../../../../../../common/search_strategy/security_solution/services/observed_details';
import { formatServiceItem } from './helpers';
export const observedServiceDetails: SecuritySolutionFactory<ServicesQueries.observedDetails> = {
buildDsl: (options) => buildObservedServiceDetailsQuery(options),
parse: async (
options,
response: IEsSearchResponse<unknown>
): Promise<ObservedServiceDetailsStrategyResponse> => {
const aggregations = response.rawResponse.aggregations;
const inspect = {
dsl: [inspectStringifyObject(buildObservedServiceDetailsQuery(options))],
};
if (aggregations == null) {
return { ...response, inspect, serviceDetails: {} };
}
return {
...response,
inspect,
serviceDetails: formatServiceItem(aggregations),
};
},
};

View file

@ -0,0 +1,15 @@
/*
* 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 { buildObservedServiceDetailsQuery } from './query.observed_service_details.dsl';
import { mockOptions } from './__mocks__';
describe('buildServiceDetailsQuery', () => {
test('build query from options correctly', () => {
expect(buildObservedServiceDetailsQuery(mockOptions)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { ISearchRequestParams } from '@kbn/search-types';
import type { ObservedServiceDetailsRequestOptions } from '../../../../../../common/api/search_strategy';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
import { buildFieldsTermAggregation } from '../../hosts/details/helpers';
import { SERVICE_FIELDS } from './helpers';
export const buildObservedServiceDetailsQuery = ({
serviceName,
defaultIndex,
timerange: { from, to },
filterQuery,
}: ObservedServiceDetailsRequestOptions): ISearchRequestParams => {
const filter: QueryDslQueryContainer[] = [
...createQueryFilterClauses(filterQuery),
{ term: { 'service.name': serviceName } },
{
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: from,
lte: to,
},
},
},
];
const dslQuery = {
allow_no_indices: true,
index: defaultIndex,
ignore_unavailable: true,
track_total_hits: false,
body: {
aggregations: {
...buildFieldsTermAggregation(SERVICE_FIELDS),
},
query: { bool: { filter } },
size: 0,
},
};
return dslQuery;
};