[Security Solution] [Attack discovery] Alerts filtering (#205070)

## [Security Solution] [Attack discovery] Alerts filtering

![00_alerts_filtering](https://github.com/user-attachments/assets/1a81413b-b8f4-4965-a006-25fb529668a6)

This PR enhances _Attack discovery_ by providing users additional control over which alerts are included as context to the large language model (LLM).

Using the new resizeable _Attack discovery settings flyout_, users may:

- Filter alerts via a search bar and filters
- Control the time window (previously fixed to `Last 24 hrs`)

### Before (feature flag disabled)

Previously, users could only set the number of alerts sent as context to the LLM via a modal:

![01_before_full_page](https://github.com/user-attachments/assets/65eaf604-3bdf-41bd-a726-f03ba5d630d5)

### After (feature flag enabled)

The new Attack discovery settings flyout replaces the modal:

![02_alert_summary_full_page](https://github.com/user-attachments/assets/613c292b-c6ec-4dc6-aea3-b2eddbacd614)

It has two tabs, _Alert summary_ and _Alerts preview_.

### Alert summary

The _Alert summary_ Lens embeddable counts the selected field name via an ES|QL query:

![03_alert_summary_cropped](https://github.com/user-attachments/assets/6f5de0e4-3da6-4937-a3cd-9a0f80df16b6)

The Alert summary query is an aggregation. It does NOT display the details of individual alerts.

### Alerts preview

The _Alerts preview_ Lens embeddable shows a preview of the actual alerts that will be sent as context via an ES|QL query:

![05_alerts_preview_cropped](https://github.com/user-attachments/assets/6db23931-3fe6-46d2-8b9a-6cc7a9d8720c)

Users may resize the settings flyout to view all the fields in the Alerts preview.

### Feature flag

Enable the `attackDiscoveryAlertFiltering` feature flag via the following setting in `kibana.dev.yml`:

```yaml
xpack.securitySolution.enableExperimental:
  - 'attackDiscoveryAlertFiltering'
```

Enabling the feature flag:

- Replaces the `Settings` modal with the `Attack discovery settings` flyout
- Includes additional `start`, `end`, and `filters` parameters in requests to generate Attack discoveries
- Enables new loading messages

### Details

#### Loading messages

The loading messages displayed when generating Attack discoveries were updated to render three types of date ranges:

1) The default date range (`Last 24 hours`), which displays the same message seen in previous versions:

![06_loading_default_date_range](https://github.com/user-attachments/assets/b376a87c-b4b8-42d8-bcbf-ddf79cc82800)

2) Relative date ranges:

![07_loading_relative_date_range](https://github.com/user-attachments/assets/d0b6bddd-7722-4181-a99c-7450d07a6624)

3) Absolute date ranges:

![08_loading_absolute_date_range](https://github.com/user-attachments/assets/a542a921-eeaa-4ced-9568-25e63a47d42d)

#### Filtering preferences

Alert filtering preferences are stored in local storage.

This PR adds the following new local storage keys:

```
elasticAssistantDefault.attackDiscovery.default.end
elasticAssistantDefault.attackDiscovery.default.filters
elasticAssistantDefault.attackDiscovery.default.query
elasticAssistantDefault.attackDiscovery.default.start
```

Users may use the `Reset` button in the Attack discovery settings flyout to restore the above to their defaults.

#### Known limitations

The following known limitations in this PR may be mitigated in follow-up PRs:

#### Table cell hover actions are disabled

Table cell actions, i.e. `Filter for` and `Filter out` are disabled in the `Alert summary` and `Alerts preview` tables.

The actions are disabled because custom cell hover actions registered in `x-pack/solutions/security/plugins/security_solution/public/app/actions/register.ts` do NOT appear to receive field metadata (i.e. the name of the field being hovered over) when the action is triggered. This limitation also appears to apply to ad hoc ES|QL visualizations created via Lens in Kibana's _Dashboard_ app.

##### Default table sort indicators are hidden

The `Alert summary` and `Alerts preview` tables are sorted descending by Count, and Risk score, respectively, via their ES|QL queries.

The tables _should_ display default sort indicators, as illustrated by the screenshots below:

![09_alert_summary_with_sort_indicator](https://github.com/user-attachments/assets/c4e78144-f516-40f8-b6da-7c8c808841c4)

![10_alerts_preview_with_sort_indicator](https://github.com/user-attachments/assets/c0061134-4734-462f-8eb0-978b2b02fb1e)

The default indicators are hidden in this PR as a workaround for an  error that occurs in `EuiDataGrid` when switching tabs when the column sort indicators are enabled:

```
TypeError: Cannot read properties of undefined (reading 'split')
```

To re-enable the sort indicators, `DEFAULT_ALERT_SUMMARY_SORT` and `DEFAULT_ALERTS_PREVIEW_SORT` must respectively be passed as the `sorting` prop to the `PreviewTab` in `x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx`, as illustrated by the following code:

```typescript
  <PreviewTab
    dataTestSubj={ALERT_SUMMARY_TEST_SUBJ}
    embeddableId={SUMMARY_TAB_EMBEDDABLE_ID}
    end={end}
    filters={filters}
    getLensAttributes={getAlertSummaryLensAttributes}
    getPreviewEsqlQuery={getAlertSummaryEsqlQuery}
    maxAlerts={maxAlerts}
    query={query}
    setTableStackBy0={setAlertSummaryStackBy0}
    start={start}
    sorting={DEFAULT_ALERT_SUMMARY_SORT} // <-- enables the sort indicator
    tableStackBy0={alertSummaryStackBy0}
  />
```

##### Selected date range not persisted

The `start` and `end` date range selected when a user starts generation are not (yet) persisted in Elasticsearch. As a result, the loading message always displays the currently configured range, rather than the range selected at the start of generation.
This commit is contained in:
Andrew Macri 2024-12-24 05:49:10 -05:00 committed by GitHub
parent 71144eded7
commit 681d40eee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 2380 additions and 77 deletions

View file

@ -13,6 +13,7 @@ describe('getOpenAndAcknowledgedAlertsQuery', () => {
const anonymizationFields = [
{ id: 'field1', field: 'field1', allowed: true, anonymized: false },
{ id: 'field2', field: 'field2', allowed: true, anonymized: false },
{ id: 'field3', field: 'field3', allowed: false, anonymized: false },
];
const size = 10;

View file

@ -7,6 +7,34 @@
import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen';
export const DEFAULT_END = 'now';
export const DEFAULT_START = 'now-24h';
interface GetOpenAndAcknowledgedAlertsQuery {
allow_no_indices: boolean;
body: {
fields: Array<{
field: string;
include_unmapped: boolean;
}>;
query: {
bool: {
filter: Array<Record<string, unknown>>;
};
};
runtime_mappings: Record<string, unknown>;
size: number;
sort: Array<{
[key: string]: {
order: string;
};
}>;
_source: boolean;
};
ignore_unavailable: boolean;
index: string[];
}
/**
* This query returns open and acknowledged (non-building block) alerts in the last 24 hours.
*
@ -15,12 +43,18 @@ import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fie
export const getOpenAndAcknowledgedAlertsQuery = ({
alertsIndexPattern,
anonymizationFields,
end,
filter,
size,
start,
}: {
alertsIndexPattern: string;
anonymizationFields: AnonymizationFieldResponse[];
end?: string | null;
filter?: Record<string, unknown> | null;
size: number;
}) => ({
start?: string | null;
}): GetOpenAndAcknowledgedAlertsQuery => ({
allow_no_indices: true,
body: {
fields: anonymizationFields
@ -53,11 +87,12 @@ export const getOpenAndAcknowledgedAlertsQuery = ({
minimum_should_match: 1,
},
},
...(filter != null ? [filter] : []),
{
range: {
'@timestamp': {
gte: 'now-24h',
lte: 'now',
gte: start != null ? start : DEFAULT_START,
lte: end != null ? end : DEFAULT_END,
format: 'strict_date_optional_time',
},
},

View file

@ -20,5 +20,6 @@ export type AssistantFeatureKey = keyof AssistantFeatures;
*/
export const defaultAssistantFeatures = Object.freeze({
assistantModelEvaluation: false,
attackDiscoveryAlertFiltering: false,
defendInsights: false,
});

View file

@ -28,11 +28,14 @@ export const AttackDiscoveryPostRequestBody = z.object({
* LLM API configuration.
*/
apiConfig: ApiConfig,
end: z.string().optional(),
filter: z.object({}).catchall(z.unknown()).optional(),
langSmithProject: z.string().optional(),
langSmithApiKey: z.string().optional(),
model: z.string().optional(),
replacements: Replacements.optional(),
size: z.number(),
start: z.string().optional(),
subAction: z.enum(['invokeAI', 'invokeStream']),
});
export type AttackDiscoveryPostRequestBodyInput = z.input<typeof AttackDiscoveryPostRequestBody>;

View file

@ -38,6 +38,11 @@ paths:
apiConfig:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
description: LLM API configuration.
end:
type: string
filter:
type: object
additionalProperties: true
langSmithProject:
type: string
langSmithApiKey:
@ -48,6 +53,8 @@ paths:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
size:
type: number
start:
type: string
subAction:
type: string
enum:

View file

@ -19,5 +19,6 @@ import { z } from '@kbn/zod';
export type GetCapabilitiesResponse = z.infer<typeof GetCapabilitiesResponse>;
export const GetCapabilitiesResponse = z.object({
assistantModelEvaluation: z.boolean(),
attackDiscoveryAlertFiltering: z.boolean(),
defendInsights: z.boolean(),
});

View file

@ -22,10 +22,13 @@ paths:
properties:
assistantModelEvaluation:
type: boolean
attackDiscoveryAlertFiltering:
type: boolean
defendInsights:
type: boolean
required:
- assistantModelEvaluation
- attackDiscoveryAlertFiltering
- defendInsights
'400':
description: Generic Error

View file

@ -41,3 +41,10 @@ export { getRawDataOrDefault } from './impl/alerts/helpers/get_raw_data_or_defau
/** Return true if the provided size is out of range */
export { sizeIsOutOfRange } from './impl/alerts/helpers/size_is_out_of_range';
export {
/** The default (relative) end of the date range (i.e. `now`) */
DEFAULT_END,
/** The default (relative) start of the date range (i.e. `now-24h`) */
DEFAULT_START,
} from './impl/alerts/get_open_and_acknowledged_alerts_query';

View file

@ -10,10 +10,14 @@ import { KnowledgeBaseConfig } from '../assistant/types';
export const ATTACK_DISCOVERY_STORAGE_KEY = 'attackDiscovery';
export const DEFEND_INSIGHTS_STORAGE_KEY = 'defendInsights';
export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault';
export const END_LOCAL_STORAGE_KEY = 'end';
export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId';
export const FILTERS_LOCAL_STORAGE_KEY = 'filters';
export const MAX_ALERTS_LOCAL_STORAGE_KEY = 'maxAlerts';
export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase';
export const QUERY_LOCAL_STORAGE_KEY = 'query';
export const SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY = 'showSettingsTour';
export const START_LOCAL_STORAGE_KEY = 'start';
export const STREAMING_LOCAL_STORAGE_KEY = 'streaming';
export const TRACE_OPTIONS_SESSION_STORAGE_KEY = 'traceOptions';
export const CONVERSATION_TABLE_SESSION_STORAGE_KEY = 'conversationTable';

View file

@ -84,11 +84,19 @@ export {
DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS,
DEFAULT_LATEST_ALERTS,
DEFEND_INSIGHTS_STORAGE_KEY,
/** The end of the date range of alerts, sent as context to the LLM */
END_LOCAL_STORAGE_KEY,
/** Search bar filters that apply to the alerts sent as context to the LLM */
FILTERS_LOCAL_STORAGE_KEY,
KNOWLEDGE_BASE_LOCAL_STORAGE_KEY,
/** The local storage key that specifies the maximum number of alerts to send as context */
MAX_ALERTS_LOCAL_STORAGE_KEY,
/** Search bar query that apply to the alerts sent as context to the LLM */
QUERY_LOCAL_STORAGE_KEY,
/** The local storage key that specifies whether the settings tour should be shown */
SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY,
/** The start of the date range of alerts, sent as context to the LLM */
START_LOCAL_STORAGE_KEY,
} from './impl/assistant_context/constants';
export { useLoadConnectors } from './impl/connectorland/use_load_connectors';

View file

@ -26,12 +26,15 @@ import type { GraphState } from './types';
export interface GetDefaultAttackDiscoveryGraphParams {
alertsIndexPattern?: string;
anonymizationFields: AnonymizationFieldResponse[];
end?: string;
esClient: ElasticsearchClient;
filter?: Record<string, unknown>;
llm: ActionsClientLlm;
logger?: Logger;
onNewReplacements?: (replacements: Replacements) => void;
replacements?: Replacements;
size: number;
start?: string;
}
export type DefaultAttackDiscoveryGraph = ReturnType<typeof getDefaultAttackDiscoveryGraph>;
@ -46,19 +49,22 @@ export type DefaultAttackDiscoveryGraph = ReturnType<typeof getDefaultAttackDisc
export const getDefaultAttackDiscoveryGraph = ({
alertsIndexPattern,
anonymizationFields,
end,
esClient,
filter,
llm,
logger,
onNewReplacements,
replacements,
size,
start,
}: GetDefaultAttackDiscoveryGraphParams): CompiledStateGraph<
GraphState,
Partial<GraphState>,
'generate' | 'refine' | 'retrieve_anonymized_alerts' | '__start__'
> => {
try {
const graphState = getDefaultGraphState();
const graphState = getDefaultGraphState({ end, filter, start });
// get nodes:
const retrieveAnonymizedAlertsNode = getRetrieveAnonymizedAlertsNode({

View file

@ -21,36 +21,48 @@ export class AnonymizedAlertsRetriever extends BaseRetriever {
#alertsIndexPattern?: string;
#anonymizationFields?: AnonymizationFieldResponse[];
#end?: string | null;
#esClient: ElasticsearchClient;
#filter?: Record<string, unknown> | null;
#onNewReplacements?: (newReplacements: Replacements) => void;
#replacements?: Replacements;
#size?: number;
#start?: string | null;
constructor({
alertsIndexPattern,
anonymizationFields,
fields,
end,
esClient,
filter,
onNewReplacements,
replacements,
size,
start,
}: {
alertsIndexPattern?: string;
anonymizationFields?: AnonymizationFieldResponse[];
fields?: CustomRetrieverInput;
end?: string | null;
esClient: ElasticsearchClient;
fields?: CustomRetrieverInput;
filter?: Record<string, unknown> | null;
onNewReplacements?: (newReplacements: Replacements) => void;
replacements?: Replacements;
size?: number;
start?: string | null;
}) {
super(fields);
this.#alertsIndexPattern = alertsIndexPattern;
this.#anonymizationFields = anonymizationFields;
this.#end = end;
this.#esClient = esClient;
this.#filter = filter;
this.#onNewReplacements = onNewReplacements;
this.#replacements = replacements;
this.#size = size;
this.#start = start;
}
async _getRelevantDocuments(
@ -60,10 +72,13 @@ export class AnonymizedAlertsRetriever extends BaseRetriever {
const anonymizedAlerts = await getAnonymizedAlerts({
alertsIndexPattern: this.#alertsIndexPattern,
anonymizationFields: this.#anonymizationFields,
end: this.#end,
esClient: this.#esClient,
filter: this.#filter,
onNewReplacements: this.#onNewReplacements,
replacements: this.#replacements,
size: this.#size,
start: this.#start,
});
return anonymizedAlerts.map((alert) => ({

View file

@ -21,17 +21,23 @@ import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/s
export const getAnonymizedAlerts = async ({
alertsIndexPattern,
anonymizationFields,
end,
esClient,
filter,
onNewReplacements,
replacements,
size,
start,
}: {
alertsIndexPattern?: string;
anonymizationFields?: AnonymizationFieldResponse[];
end?: string | null;
esClient: ElasticsearchClient;
filter?: Record<string, unknown> | null;
onNewReplacements?: (replacements: Replacements) => void;
replacements?: Replacements;
size?: number;
start?: string | null;
}): Promise<string[]> => {
if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) {
return [];
@ -40,7 +46,10 @@ export const getAnonymizedAlerts = async ({
const query = getOpenAndAcknowledgedAlertsQuery({
alertsIndexPattern,
anonymizationFields: anonymizationFields ?? [],
end,
filter,
size,
start,
});
const result = await esClient.search<SearchResponse>(query);

View file

@ -36,17 +36,23 @@ export const getRetrieveAnonymizedAlertsNode = ({
onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements
};
const retriever = new AnonymizedAlertsRetriever({
alertsIndexPattern,
anonymizationFields,
esClient,
onNewReplacements: localOnNewReplacements,
replacements,
size,
});
const retrieveAnonymizedAlerts = async (state: GraphState): Promise<GraphState> => {
logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS---');
const { end, filter, start } = state;
const retriever = new AnonymizedAlertsRetriever({
alertsIndexPattern,
anonymizationFields,
end,
esClient,
filter,
onNewReplacements: localOnNewReplacements,
replacements,
size,
start,
});
const documents = await retriever
.withConfig({ runName: 'runAnonymizedAlertsRetriever' })
.invoke('');

View file

@ -18,7 +18,17 @@ import { getDefaultAttackDiscoveryPrompt } from '../nodes/helpers/get_default_at
import { getDefaultRefinePrompt } from '../nodes/refine/helpers/get_default_refine_prompt';
import type { GraphState } from '../types';
export const getDefaultGraphState = (): StateGraphArgs<GraphState>['channels'] => ({
export interface Options {
end?: string;
filter?: Record<string, unknown> | null;
start?: string;
}
export const getDefaultGraphState = ({
end,
filter,
start,
}: Options | undefined = {}): StateGraphArgs<GraphState>['channels'] => ({
attackDiscoveries: {
value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x,
default: () => null,
@ -39,10 +49,18 @@ export const getDefaultGraphState = (): StateGraphArgs<GraphState>['channels'] =
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
end: {
value: (x?: string | null, y?: string | null) => y ?? x,
default: () => end,
},
errors: {
value: (x: string[], y?: string[]) => y ?? x,
default: () => [],
},
filter: {
value: (x?: Record<string, unknown> | null, y?: Record<string, unknown> | null) => y ?? x,
default: () => filter,
},
generationAttempts: {
value: (x: number, y?: number) => y ?? x,
default: () => 0,
@ -79,6 +97,10 @@ export const getDefaultGraphState = (): StateGraphArgs<GraphState>['channels'] =
value: (x: Replacements, y?: Replacements) => y ?? x,
default: () => ({}),
},
start: {
value: (x?: string | null, y?: string | null) => y ?? x,
default: () => start,
},
unrefinedResults: {
value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x,
default: () => null,

View file

@ -14,7 +14,9 @@ export interface GraphState {
anonymizedAlerts: Document[];
combinedGenerations: string;
combinedRefinements: string;
end?: string | null;
errors: string[];
filter?: Record<string, unknown> | null;
generationAttempts: number;
generations: string[];
hallucinationFailures: number;
@ -24,5 +26,6 @@ export interface GraphState {
refinements: string[];
refinePrompt: string;
replacements: Replacements;
start?: string | null;
unrefinedResults: AttackDiscovery[] | null;
}

View file

@ -30,25 +30,31 @@ export const invokeAttackDiscoveryGraph = async ({
anonymizationFields,
apiConfig,
connectorTimeout,
end,
esClient,
filter,
langSmithProject,
langSmithApiKey,
latestReplacements,
logger,
onNewReplacements,
size,
start,
}: {
actionsClient: PublicMethodsOf<ActionsClient>;
alertsIndexPattern: string;
anonymizationFields: AnonymizationFieldResponse[];
apiConfig: ApiConfig;
connectorTimeout: number;
end?: string;
esClient: ElasticsearchClient;
filter?: Record<string, unknown>;
langSmithProject?: string;
langSmithApiKey?: string;
latestReplacements: Replacements;
logger: Logger;
onNewReplacements: (newReplacements: Replacements) => void;
start?: string;
size: number;
}): Promise<{
anonymizedAlerts: Document[];
@ -86,12 +92,15 @@ export const invokeAttackDiscoveryGraph = async ({
const graph = getDefaultAttackDiscoveryGraph({
alertsIndexPattern,
anonymizationFields,
end,
esClient,
filter,
llm,
logger,
onNewReplacements,
replacements: latestReplacements,
size,
start,
});
logger?.debug(() => 'invokeAttackDiscoveryGraph: invoking the Attack discovery graph');

View file

@ -91,10 +91,13 @@ export const postAttackDiscoveryRoute = (
const {
apiConfig,
anonymizationFields,
end,
filter,
langSmithApiKey,
langSmithProject,
replacements,
size,
start,
} = request.body;
if (
@ -133,13 +136,16 @@ export const postAttackDiscoveryRoute = (
anonymizationFields,
apiConfig,
connectorTimeout: CONNECTOR_TIMEOUT,
end,
esClient,
filter,
langSmithProject,
langSmithApiKey,
latestReplacements,
logger,
onNewReplacements,
size,
start,
})
.then(({ anonymizedAlerts, attackDiscoveries }) =>
updateAttackDiscoveries({

View file

@ -114,6 +114,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
assistantModelEvaluation: false,
/**
* Enables filtering of Attack Discovery alerts in a flyout
*/
attackDiscoveryAlertFiltering: false,
/**
* Enables the Managed User section inside the new user details flyout.
*/

View file

@ -9,13 +9,13 @@ import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { Header } from '.';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import { TestProviders } from '../../../common/mock';
import { Header } from '.';
jest.mock('../../../assistant/use_assistant_availability');
describe('Header', () => {
describe('Actions', () => {
beforeEach(() => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
@ -36,6 +36,7 @@ describe('Header', () => {
onCancel={jest.fn()}
onGenerate={jest.fn()}
onConnectorIdSelected={jest.fn()}
openFlyout={jest.fn()}
setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()}
/>
</TestProviders>
@ -61,6 +62,7 @@ describe('Header', () => {
onCancel={jest.fn()}
onGenerate={jest.fn()}
onConnectorIdSelected={jest.fn()}
openFlyout={jest.fn()}
setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()}
/>
</TestProviders>
@ -86,6 +88,7 @@ describe('Header', () => {
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={onGenerate}
openFlyout={jest.fn()}
setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()}
/>
</TestProviders>
@ -113,6 +116,7 @@ describe('Header', () => {
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
openFlyout={jest.fn()}
setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()}
/>
</TestProviders>
@ -139,6 +143,7 @@ describe('Header', () => {
onCancel={onCancel}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
openFlyout={jest.fn()}
setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()}
/>
</TestProviders>
@ -165,6 +170,7 @@ describe('Header', () => {
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
openFlyout={jest.fn()}
setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()}
/>
</TestProviders>

View file

@ -6,9 +6,16 @@
*/
import type { EuiButtonProps } from '@elastic/eui';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui';
import {
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { ConnectorSelectorInline } from '@kbn/elastic-assistant';
import { ConnectorSelectorInline, useAssistantContext } from '@kbn/elastic-assistant';
import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common';
import { noop } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
@ -26,6 +33,7 @@ interface Props {
onGenerate: () => void;
onCancel: () => void;
onConnectorIdSelected: (connectorId: string) => void;
openFlyout: () => void;
setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>;
stats: AttackDiscoveryStats | null;
}
@ -39,9 +47,14 @@ const HeaderComponent: React.FC<Props> = ({
onGenerate,
onConnectorIdSelected,
onCancel,
openFlyout,
setLocalStorageAttackDiscoveryMaxAlerts,
stats,
}) => {
const {
assistantFeatures: { attackDiscoveryAlertFiltering },
} = useAssistantContext();
const { euiTheme } = useEuiTheme();
const disabled = connectorId == null;
@ -78,23 +91,20 @@ const HeaderComponent: React.FC<Props> = ({
<EuiFlexGroup
alignItems="center"
css={css`
gap: ${euiTheme.size.m};
margin-top: ${euiTheme.size.m};
`}
data-test-subj="header"
gutterSize="none"
>
<EuiFlexItem grow={false}>
<SettingsModal
connectorId={connectorId}
isLoading={isLoading}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts}
/>
</EuiFlexItem>
<StatusBell stats={stats} />
{connectorsAreConfigured && (
<EuiFlexItem grow={false}>
<EuiFlexItem
css={css`
margin-left: ${euiTheme.size.s};
margin-right: ${euiTheme.size.s};
`}
grow={false}
>
<ConnectorSelectorInline
onConnectorSelected={noop}
onConnectorIdSelected={onConnectorIdSelected}
@ -104,6 +114,32 @@ const HeaderComponent: React.FC<Props> = ({
</EuiFlexItem>
)}
<EuiFlexItem
css={css`
margin-right: ${euiTheme.size.m};
`}
grow={false}
>
{attackDiscoveryAlertFiltering ? (
<EuiToolTip content={i18n.SETTINGS} data-test-subj="openAlertSelectionToolTip">
<EuiButtonIcon
aria-label={i18n.SETTINGS}
color="text"
data-test-subj="openAlertSelection"
iconType="gear"
onClick={openFlyout}
/>
</EuiToolTip>
) : (
<SettingsModal
connectorId={connectorId}
isLoading={isLoading}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts}
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={connectorId == null ? i18n.SELECT_A_CONNECTOR : null}

View file

@ -28,7 +28,7 @@ import useLocalStorage from 'react-use/lib/useLocalStorage';
import { AlertsSettings } from './alerts_settings';
import { useSpaceId } from '../../../../common/hooks/use_space_id';
import { Footer } from './footer';
import { Footer } from '../../settings_flyout/footer';
import { getIsTourEnabled } from './is_tour_enabled';
import * as i18n from './translations';

View file

@ -27,3 +27,10 @@ export const SELECT_A_CONNECTOR = i18n.translate(
defaultMessage: 'Select a connector',
}
);
export const SETTINGS = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.header.settingsButtonLabel',
{
defaultMessage: 'Settings',
}
);

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { Query } from '@kbn/es-query';
export const getInitialIsOpen = (index: number) => index < 3;
interface ErrorWithStringMessage {
@ -130,3 +132,8 @@ export const getSize = ({
return isNaN(size) || size <= 0 ? defaultMaxAlerts : size;
};
export const getDefaultQuery = (): Query => ({
language: 'kuery',
query: '',
});

View file

@ -6,41 +6,102 @@
*/
import { EuiEmptyPrompt, EuiLoadingLogo, EuiSpacer } from '@elastic/eui';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common';
import { css } from '@emotion/react';
import {
ATTACK_DISCOVERY_STORAGE_KEY,
DEFAULT_ASSISTANT_NAMESPACE,
DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS,
END_LOCAL_STORAGE_KEY,
FILTERS_LOCAL_STORAGE_KEY,
MAX_ALERTS_LOCAL_STORAGE_KEY,
QUERY_LOCAL_STORAGE_KEY,
START_LOCAL_STORAGE_KEY,
useAssistantContext,
useLoadConnectors,
} from '@kbn/elastic-assistant';
import type { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common';
import type { Filter, Query } from '@kbn/es-query';
import { uniq } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { SecurityPageName } from '../../../common/constants';
import { HeaderPage } from '../../common/components/header_page';
import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query';
import { useSpaceId } from '../../common/hooks/use_space_id';
import { useKibana } from '../../common/lib/kibana';
import { convertToBuildEsQuery } from '../../common/lib/kuery';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { Header } from './header';
import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getSize, showLoading } from './helpers';
import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getDefaultQuery, getSize, showLoading } from './helpers';
import { LoadingCallout } from './loading_callout';
import { deserializeQuery } from './local_storage/deserialize_query';
import { deserializeFilters } from './local_storage/deserialize_filters';
import { PageTitle } from './page_title';
import { Results } from './results';
import { SettingsFlyout } from './settings_flyout';
import { parseFilterQuery } from './settings_flyout/parse_filter_query';
import { useSourcererDataView } from '../../sourcerer/containers';
import { useAttackDiscovery } from './use_attack_discovery';
export const ID = 'attackDiscoveryQuery';
const AttackDiscoveryPageComponent: React.FC = () => {
const {
services: { uiSettings },
} = useKibana();
const spaceId = useSpaceId() ?? 'default';
const { http } = useAssistantContext();
const {
assistantFeatures: { attackDiscoveryAlertFiltering },
http,
} = useAssistantContext();
const { data: aiConnectors } = useLoadConnectors({
http,
});
// for showing / hiding anonymized data:
const [showAnonymized, setShowAnonymized] = useState<boolean>(false);
// showing / hiding the flyout:
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const openFlyout = useCallback(() => setShowFlyout(true), []);
// time selection:
const [start, setStart] = useLocalStorage<string>(
`${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${START_LOCAL_STORAGE_KEY}`,
DEFAULT_START
);
const [end, setEnd] = useLocalStorage<string>(
`${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${END_LOCAL_STORAGE_KEY}`,
DEFAULT_END
);
// search bar query:
const [query, setQuery] = useLocalStorage<Query>(
`${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${QUERY_LOCAL_STORAGE_KEY}`,
getDefaultQuery(),
{
raw: false,
serializer: (value: Query) => JSON.stringify(value),
deserializer: deserializeQuery,
}
);
// search bar filters:
const [filters, setFilters] = useLocalStorage<Filter[]>(
`${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${FILTERS_LOCAL_STORAGE_KEY}`,
[],
{
raw: false,
serializer: (value: Filter[]) => JSON.stringify(value),
deserializer: deserializeFilters,
}
);
const onToggleShowAnonymized = useCallback(() => setShowAnonymized((current) => !current), []);
// get the last selected connector ID from local storage:
@ -123,7 +184,51 @@ const AttackDiscoveryPageComponent: React.FC = () => {
const pageTitle = useMemo(() => <PageTitle />, []);
const onGenerate = useCallback(async () => fetchAttackDiscoveries(), [fetchAttackDiscoveries]);
const { sourcererDataView } = useSourcererDataView();
// filterQuery is the combined search bar query and filters in ES format:
const [filterQuery, kqlError] = useMemo(
() =>
convertToBuildEsQuery({
config: getEsQueryConfig(uiSettings),
dataViewSpec: sourcererDataView,
queries: [query ?? getDefaultQuery()], // <-- search bar query
filters: filters ?? [], // <-- search bar filters
}),
[filters, query, sourcererDataView, uiSettings]
);
// renders a toast if the filter query is invalid:
useInvalidFilterQuery({
id: ID,
filterQuery,
kqlError,
query,
startDate: start,
endDate: end,
});
const onGenerate = useCallback(async () => {
const size = alertsContextCount ?? DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS;
const filter = parseFilterQuery({ filterQuery, kqlError });
return attackDiscoveryAlertFiltering // feature flag enabled?
? fetchAttackDiscoveries({
end,
filter, // <-- combined search bar query and filters
size,
start,
})
: fetchAttackDiscoveries({ size }); // <-- NO filtering / time ranges, feature flag is off
}, [
alertsContextCount,
attackDiscoveryAlertFiltering,
end,
fetchAttackDiscoveries,
filterQuery,
kqlError,
start,
]);
useEffect(() => {
setSelectedConnectorReplacements(replacements);
@ -147,6 +252,8 @@ const AttackDiscoveryPageComponent: React.FC = () => {
const connectorsAreConfigured = aiConnectors != null && aiConnectors.length > 0;
const attackDiscoveriesCount = selectedConnectorAttackDiscoveries.length;
const onClose = useCallback(() => setShowFlyout(false), []);
return (
<div
css={css`
@ -161,13 +268,14 @@ const AttackDiscoveryPageComponent: React.FC = () => {
<Header
connectorId={connectorId}
connectorsAreConfigured={connectorsAreConfigured}
isLoading={isLoading}
// disable header actions before post request has completed
isDisabledActions={isLoadingPost}
isLoading={isLoading}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
onCancel={onCancel}
onConnectorIdSelected={onConnectorIdSelected}
onGenerate={onGenerate}
onCancel={onCancel}
openFlyout={openFlyout}
setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts}
stats={stats}
/>
@ -185,9 +293,11 @@ const AttackDiscoveryPageComponent: React.FC = () => {
}) ? (
<LoadingCallout
alertsContextCount={alertsContextCount}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
approximateFutureTime={approximateFutureTime}
connectorIntervals={connectorIntervals}
end={end}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
start={start}
/>
) : (
<Results
@ -208,6 +318,21 @@ const AttackDiscoveryPageComponent: React.FC = () => {
showAnonymized={showAnonymized}
/>
)}
{showFlyout && (
<SettingsFlyout
end={end}
filters={filters}
onClose={onClose}
query={query}
setEnd={setEnd}
setFilters={setFilters}
setQuery={setQuery}
setStart={setStart}
start={start}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts}
/>
)}
</>
)}
<SpyRoute pageName={SecurityPageName.attackDiscovery} />

View file

@ -23,14 +23,18 @@ interface Props {
alertsContextCount: number | null;
approximateFutureTime: Date | null;
connectorIntervals: GenerationInterval[];
end?: string | null;
localStorageAttackDiscoveryMaxAlerts: string | undefined;
start?: string | null;
}
const LoadingCalloutComponent: React.FC<Props> = ({
alertsContextCount,
localStorageAttackDiscoveryMaxAlerts,
approximateFutureTime,
connectorIntervals,
end,
localStorageAttackDiscoveryMaxAlerts,
start,
}) => {
const { euiTheme } = useEuiTheme();
const { theme } = useKibana().services;
@ -50,12 +54,14 @@ const LoadingCalloutComponent: React.FC<Props> = ({
>
<LoadingMessages
alertsContextCount={alertsContextCount}
end={end}
localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts}
start={start}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
[alertsContextCount, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts]
[alertsContextCount, end, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts, start]
);
const isDarkMode = theme.getTheme().darkMode === true;

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
export const getFormattedDate = ({
date,
dateFormat,
}: {
date: string | null | undefined;
dateFormat: string;
}): string | null => {
if (date == null) {
return null;
}
// strictly parse the date, which will fail for dates like formatted like 'now':
const strictParsed = moment(date, moment.ISO_8601, true);
if (!strictParsed.isValid()) {
return date; // return the original date if it cannot be parsed
}
// return the formatted date per the time zone:
return moment(date).format(dateFormat);
};

View file

@ -0,0 +1,36 @@
/*
* 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 { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common';
import {
AI_IS_CURRENTLY_ANALYZING,
AI_IS_CURRENTLY_ANALYZING_FROM,
AI_IS_CURRENTLY_ANALYZING_RANGE,
} from '../../translations';
export const getLoadingMessage = ({
alertsCount,
end,
start,
}: {
alertsCount: number;
end?: string | null;
start?: string | null;
}): string => {
if (start === DEFAULT_START && end === DEFAULT_END) {
return AI_IS_CURRENTLY_ANALYZING(alertsCount);
}
if (end != null && start != null) {
return AI_IS_CURRENTLY_ANALYZING_RANGE({ alertsCount, end, start });
} else if (start != null) {
return AI_IS_CURRENTLY_ANALYZING_FROM({ alertsCount, from: start });
} else {
return AI_IS_CURRENTLY_ANALYZING(alertsCount);
}
};

View file

@ -7,27 +7,47 @@
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant';
import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, useAssistantContext } from '@kbn/elastic-assistant';
import React from 'react';
import { useKibana } from '../../../../common/lib/kibana';
import { useDateFormat, useKibana } from '../../../../common/lib/kibana';
import { getFormattedDate } from './get_formatted_time';
import { getLoadingCalloutAlertsCount } from './get_loading_callout_alerts_count';
import { getLoadingMessage } from './get_loading_message';
import * as i18n from '../translations';
const TEXT_COLOR = '#343741';
interface Props {
alertsContextCount: number | null;
end?: string | null;
localStorageAttackDiscoveryMaxAlerts: string | undefined;
start?: string | null;
}
const LoadingMessagesComponent: React.FC<Props> = ({
alertsContextCount,
end,
localStorageAttackDiscoveryMaxAlerts,
start,
}) => {
const { theme } = useKibana().services;
const {
assistantFeatures: { attackDiscoveryAlertFiltering },
} = useAssistantContext();
const isDarkMode = theme.getTheme().darkMode === true;
const { theme } = useKibana().services;
const dateFormat = useDateFormat();
const formattedStart = getFormattedDate({
date: start,
dateFormat,
});
const formattedEnd = getFormattedDate({
date: end,
dateFormat,
});
const alertsCount = getLoadingCalloutAlertsCount({
alertsContextCount,
@ -35,6 +55,16 @@ const LoadingMessagesComponent: React.FC<Props> = ({
localStorageAttackDiscoveryMaxAlerts,
});
const loadingMessage = attackDiscoveryAlertFiltering
? getLoadingMessage({
alertsCount,
end: formattedEnd,
start: formattedStart,
})
: getLoadingMessage({ alertsCount }); // <-- NO time range, feature flag is off
const isDarkMode = theme.getTheme().darkMode === true;
return (
<EuiFlexGroup data-test-subj="loadingMessages" direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
@ -59,7 +89,7 @@ const LoadingMessagesComponent: React.FC<Props> = ({
data-test-subj="aisCurrentlyAnalyzing"
size="s"
>
{i18n.AI_IS_CURRENTLY_ANALYZING(alertsCount)}
{loadingMessage}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,64 @@
/*
* 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 FROM_TODAY = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTodayLabel',
{
defaultMessage: 'from Today',
}
);
export const FROM_THIS_WEEK = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromThisWeekLabel',
{
defaultMessage: 'from This week',
}
);
export const FROM_THE_LAST_15_MINUTES = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast15MinutesLabel',
{
defaultMessage: 'from the Last 15 minutes',
}
);
export const FROM_THE_LAST_30_MINUTES = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast30MinutesLabel',
{
defaultMessage: 'from the Last 30 minutes',
}
);
export const FROM_THE_LAST_1_HOUR = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast1HourLabel',
{
defaultMessage: 'from the Last 1 hour',
}
);
export const FROM_THE_LAST_24_HOURS = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast24HoursLabel',
{
defaultMessage: 'from the Last 24 hours',
}
);
export const FROM_THE_LAST_7_DAYS = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast7DaysLabel',
{
defaultMessage: 'from the Last 7 days',
}
);
export const FROM_THE_LAST_30_DAYS = i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast30DaysLabel',
{
defaultMessage: 'from the Last 30 days',
}
);

View file

@ -16,6 +16,38 @@ export const AI_IS_CURRENTLY_ANALYZING = (alertsCount: number) =>
}
);
export const AI_IS_CURRENTLY_ANALYZING_FROM = ({
alertsCount,
from,
}: {
alertsCount: number;
from: string;
}) =>
i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.aiIsCurrentlyAnalyzingFromLabel',
{
defaultMessage: `AI is analyzing up to {alertsCount} {alertsCount, plural, =1 {alert} other {alerts}} {from} to generate discoveries.`,
values: { alertsCount, from },
}
);
export const AI_IS_CURRENTLY_ANALYZING_RANGE = ({
alertsCount,
end,
start,
}: {
alertsCount: number;
end: string;
start: string;
}) =>
i18n.translate(
'xpack.securitySolution.attackDiscovery.loadingCallout.aiIsCurrentlyAnalyzingRangeLabel',
{
defaultMessage: `AI is analyzing up to {alertsCount} {alertsCount, plural, =1 {alert} other {alerts}} from {start} to {end} generate discoveries.`,
values: { alertsCount, end, start },
}
);
export const ATTACK_DISCOVERY_GENERATION_IN_PROGRESS = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.loadingCallout.attackDiscoveryGenerationInProgressLabel',
{

View file

@ -0,0 +1,34 @@
/*
* 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 { FilterStateStore, type Filter } from '@kbn/es-query';
import { z } from '@kbn/zod';
const filtersSchema = z.array(
z.object({
$state: z
.union([
z.object({
store: z.nativeEnum(FilterStateStore),
}),
z.undefined(),
])
.optional(),
meta: z.object({}).catchall(z.unknown()),
query: z.union([z.record(z.string(), z.any()), z.undefined()]).optional(),
})
);
export type FiltersSchema = z.infer<typeof filtersSchema>;
export const deserializeFilters = (value: string): Filter[] => {
try {
return filtersSchema.parse(JSON.parse(value));
} catch {
return [];
}
};

View file

@ -0,0 +1,24 @@
/*
* 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 { Query } from '@kbn/es-query';
import { z } from '@kbn/zod';
import { getDefaultQuery } from '../../helpers';
const querySchema = z.object({
query: z.union([z.string(), z.object({}).catchall(z.unknown())]),
language: z.string(),
});
export const deserializeQuery = (value: string): Query => {
try {
return querySchema.parse(JSON.parse(value));
} catch {
return getDefaultQuery();
}
};

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
export const ATTACK_DISCOVERY_ONLY = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.noAlerts.attackDiscoveryOnlyLabel',
{
defaultMessage: 'Attack Discovery only analyzes alerts from the past 24 hours.',
defaultMessage: 'There were no matching alerts in the configured time range.',
}
);

View file

@ -0,0 +1,198 @@
/*
* 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 { OnTimeChangeProps } from '@elastic/eui';
import { EuiSuperDatePicker, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/react';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { Filter, Query } from '@kbn/es-query';
import { debounce } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
import { useKibana } from '../../../../../common/lib/kibana';
import { getCommonTimeRanges } from '../helpers/get_common_time_ranges';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { SourcererScopeName } from '../../../../../sourcerer/store/model';
import * as i18n from '../translations';
import { useDataView } from '../use_data_view';
export const MAX_ALERTS = 500;
export const MIN_ALERTS = 50;
export const STEP = 50;
export const NO_INDEX_PATTERNS: DataView[] = [];
interface Props {
end: string;
filters: Filter[];
query: Query;
setEnd: React.Dispatch<React.SetStateAction<string>>;
setFilters: React.Dispatch<React.SetStateAction<Filter[]>>;
setQuery: React.Dispatch<React.SetStateAction<Query>>;
setStart: React.Dispatch<React.SetStateAction<string>>;
start: string;
}
const AlertSelectionQueryComponent: React.FC<Props> = ({
end,
filters,
query,
setEnd,
setFilters,
setQuery,
setStart,
start,
}) => {
const {
unifiedSearch: {
ui: { SearchBar },
},
} = useKibana().services;
// get the sourcerer `DataViewSpec` for alerts:
const { sourcererDataView, loading: isLoadingIndexPattern } = useSourcererDataView(
SourcererScopeName.detections
);
// create a `DataView` from the `DataViewSpec`:
const alertsDataView = useDataView({
dataViewSpec: sourcererDataView,
loading: isLoadingIndexPattern,
});
// create a container for the alerts `DataView`, as required by the search bar:
const indexPatterns: DataView[] = useMemo(
() => (alertsDataView ? [alertsDataView] : NO_INDEX_PATTERNS),
[alertsDataView]
);
// Users accumulate an "unsubmitted" query as they type in the search bar,
// but have not pressed the 'Enter' key to submit the query, (which would
// call `onQuerySubmit`).
//
// This unsubmitted query is stored in `unSubmittedQuery`.
//
// To match the behavior of Discover, `setQuery` must be called with the
// `unSubmittedQuery` query when:
//
// 1) The user selects a new time range
// 2) The user clicks the refresh button
//
// Also to match the behavior of Discover, we must NOT call `setQuery` with
// the `unSubmittedQuery` query when the user clicks the `Save` button.
const [unSubmittedQuery, setUnSubmittedQuery] = React.useState<Query['query'] | undefined>(
undefined
);
/**
* `debouncedOnQueryChange` is called by the `SearchBar` as the user types in the input
*/
const debouncedOnQueryChange = useCallback((inputQuery: Query['query'] | undefined) => {
const debouncedFunction = debounce(100, (debouncedQuery: Query['query'] | undefined) => {
setUnSubmittedQuery(debouncedQuery);
});
return debouncedFunction(inputQuery);
}, []);
// get the common time ranges for the date picker:
const commonlyUsedRanges = useMemo(() => getCommonTimeRanges(), []);
/**
* `onTimeChange` is called by the `EuiSuperDatePicker` when the user:
* 1) selects a new time range
* 2) clicks the refresh button
*/
const onTimeChange = useCallback(
({ start: startDate, end: endDate }: OnTimeChangeProps) => {
if (unSubmittedQuery != null) {
const newUnSubmittedQuery: Query = {
query: unSubmittedQuery,
language: 'kuery',
};
setQuery(newUnSubmittedQuery); // <-- set the query to the unsubmitted query
}
setStart(startDate);
setEnd(endDate);
},
[setEnd, setQuery, setStart, unSubmittedQuery]
);
/**
* `onFiltersUpdated` is called by the `SearchBar` when the filters, (which
* appear belew the `SearchBar` input), are updated.
*/
const onFiltersUpdated = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters);
},
[setFilters]
);
/**
* `onQuerySubmit` is called by the `SearchBar` when the user presses `Enter`
*/
const onQuerySubmit = useCallback(
({ query: newQuery }: { query?: Query | undefined }) => {
if (newQuery != null) {
setQuery(newQuery);
}
},
[setQuery]
);
return (
<>
<div
css={css`
.uniSearchBar {
padding: 0;
}
`}
data-test-subj="alertSelectionQuery"
>
<SearchBar
appName="siem"
data-test-subj="alertSelectionSearchBar"
indexPatterns={indexPatterns}
filters={filters}
saveQueryMenuVisibility="hidden"
showDatePicker={false}
showFilterBar={true}
showQueryInput={true}
showSubmitButton={false}
isLoading={isLoadingIndexPattern}
onFiltersUpdated={onFiltersUpdated}
onQueryChange={({ query: debouncedQuery }) => {
debouncedOnQueryChange(debouncedQuery?.query);
}}
onQuerySubmit={onQuerySubmit}
placeholder={i18n.FILTER_YOUR_DATA}
query={query}
/>
</div>
<EuiSpacer size="xs" />
<EuiSpacer size="s" />
<EuiSuperDatePicker
commonlyUsedRanges={commonlyUsedRanges}
data-test-subj="alertSelectionDatePicker"
end={end}
isDisabled={false}
onTimeChange={onTimeChange}
showUpdateButton="iconOnly"
start={start}
/>
</>
);
};
AlertSelectionQueryComponent.displayName = 'AlertSelectionQuery';
export const AlertSelectionQuery = React.memo(AlertSelectionQueryComponent);

View file

@ -0,0 +1,71 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { SingleRangeChangeEvent } from '@kbn/elastic-assistant';
import { AlertsRange } from '@kbn/elastic-assistant';
import React, { useCallback } from 'react';
import * as i18n from '../translations';
export const MAX_ALERTS = 500;
export const MIN_ALERTS = 50;
export const STEP = 50;
export const NO_INDEX_PATTERNS: DataView[] = [];
interface Props {
maxAlerts: number;
setMaxAlerts: React.Dispatch<React.SetStateAction<string>>;
}
const AlertSelectionRangeComponent: React.FC<Props> = ({ maxAlerts, setMaxAlerts }) => {
// called when the slider changes the number of alerts to analyze:
const onChangeAlertsRange = useCallback(
(e: SingleRangeChangeEvent) => {
setMaxAlerts(e.currentTarget.value);
},
[setMaxAlerts]
);
return (
<EuiFlexGroup data-test-subj="alertSelectionRange" direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiTitle data-test-subj="title" size="xs">
<h3>{i18n.SET_NUMBER_OF_ALERTS_TO_ANALYZE}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertsRange
maxAlerts={MAX_ALERTS}
minAlerts={MIN_ALERTS}
onChange={onChangeAlertsRange}
step={STEP}
value={maxAlerts}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued" data-test-subj="sendFewerAlerts" size="xs">
<span>{i18n.SEND_FEWER_ALERTS}</span>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};
AlertSelectionRangeComponent.displayName = 'AlertSelectionRange';
export const AlertSelectionRange = React.memo(AlertSelectionRangeComponent);

View file

@ -0,0 +1,17 @@
/*
* 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 const getEsqlKeepStatement = (tableStackBy0: string): string => {
// renames the table stack by field to 'Rule name'
const renameAsRuleName = `| RENAME kibana.alert.rule.name AS \`Rule name\`
| KEEP \`Rule name\`, Count`;
// just keeps the table stack by field:
const keepTableStackBy0 = `| KEEP \`${tableStackBy0}\`, Count`;
return tableStackBy0 === 'kibana.alert.rule.name' ? renameAsRuleName : keepTableStackBy0;
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getEsqlKeepStatement } from './get_esql_keep_statement';
export const getAlertSummaryEsqlQuery = ({
alertsIndexPattern,
maxAlerts,
tableStackBy0,
}: {
alertsIndexPattern: string;
maxAlerts: number;
tableStackBy0: string;
}): string => `FROM ${alertsIndexPattern} METADATA _id, _index, _version
| WHERE kibana.alert.workflow_status IN ("open", "acknowledged") AND kibana.alert.rule.building_block_type IS NULL
| SORT kibana.alert.risk_score DESC, @timestamp DESC
| LIMIT ${maxAlerts}
| STATS Count = count() by \`${tableStackBy0}\`
| SORT Count DESC
${getEsqlKeepStatement(tableStackBy0)}
`;

View file

@ -0,0 +1,88 @@
/*
* 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 { LensAttributes } from '../../../../../../common/components/visualization_actions/types';
import { getFirstColumnName } from '../../helpers/get_first_column_name';
import * as i18n from '../../translations';
import type { Sorting } from '../../types';
const LAYER_ID = '094d6c10-a28a-4780-8a0c-5789b73e4cef';
export const DEFAULT_PAGE_SIZE = 5;
export const getAlertSummaryLensAttributes = ({
defaultPageSize = DEFAULT_PAGE_SIZE,
esqlQuery,
sorting,
tableStackBy0,
}: {
defaultPageSize?: number;
esqlQuery: string;
sorting?: Sorting;
tableStackBy0: string;
}): LensAttributes => ({
references: [],
state: {
adHocDataViews: {},
datasourceStates: {
textBased: {
layers: {
[LAYER_ID]: {
columns: [
{
columnId: 'tableStackBy0',
fieldName: getFirstColumnName(tableStackBy0),
},
{
columnId: 'count',
fieldName: 'Count',
inMetricDimension: true,
meta: {
type: 'number',
esType: 'long',
},
},
],
index: 'F2772070-4F12-4603-A318-82F98BA69DAB',
query: {
esql: esqlQuery,
},
timeField: '@timestamp',
},
},
},
},
filters: [], // empty, because filters are applied directly to the lens.EmbeddableComponent
query: {
language: 'kuery',
query: '', // empty, because the query from the query bar is applied directly to the lens.EmbeddableComponent
},
visualization: {
columns: [
{
columnId: 'tableStackBy0',
width: 300,
},
{
columnId: 'count',
summaryRow: 'sum',
},
],
layerId: LAYER_ID,
layerType: 'data',
paging: {
enabled: true,
size: defaultPageSize,
},
sorting: {
...sorting,
},
},
},
title: i18n.ALERTS_SUMMARY,
visualizationType: 'lnsDatatable',
});

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.
*/
export const getEsqlKeepStatement = (tableStackBy0: string): string => {
const commonFields = ['@timestamp', 'host.name', 'user.name'];
// renames the rule name and risk score fields to 'Rule name' and 'Risk score':
const renameRuleNameAndRiskScore = `| RENAME kibana.alert.rule.name AS \`Rule name\`, kibana.alert.risk_score AS \`Risk score\`
| KEEP \`Rule name\`, \`Risk score\`, ${commonFields.join(', ')}`;
// renames the risk score field to 'Risk score' and keeps the table stack by field:
const renameRiskScoreKeepTableStackBy0 = `| RENAME kibana.alert.risk_score AS \`Risk score\`
| KEEP \`${tableStackBy0}\`, \`Risk score\`, ${commonFields.join(', ')}`;
return tableStackBy0 === 'kibana.alert.rule.name'
? renameRuleNameAndRiskScore
: renameRiskScoreKeepTableStackBy0;
};

View file

@ -0,0 +1,23 @@
/*
* 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 { getEsqlKeepStatement } from './get_esql_keep_statement';
export const getAlertsPreviewEsqlQuery = ({
alertsIndexPattern,
maxAlerts,
tableStackBy0,
}: {
alertsIndexPattern: string;
maxAlerts: number;
tableStackBy0: string;
}): string => `FROM ${alertsIndexPattern} METADATA _id, _index, _version
| WHERE kibana.alert.workflow_status IN ("open", "acknowledged") AND kibana.alert.rule.building_block_type IS NULL
| SORT kibana.alert.risk_score DESC, @timestamp DESC
| LIMIT ${maxAlerts}
${getEsqlKeepStatement(tableStackBy0)}
`;

View file

@ -0,0 +1,120 @@
/*
* 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 { LensAttributes } from '../../../../../../common/components/visualization_actions/types';
import { getFirstColumnName } from '../../helpers/get_first_column_name';
import * as i18n from '../../translations';
import type { Sorting } from '../../types';
const LAYER_ID = '320760EB-4185-43EB-985B-94B9240C57E7';
export const DEFAULT_PAGE_SIZE = 10;
export const getAlertsPreviewLensAttributes = ({
defaultPageSize = DEFAULT_PAGE_SIZE,
esqlQuery,
sorting,
tableStackBy0,
}: {
defaultPageSize?: number;
esqlQuery: string;
sorting?: Sorting;
tableStackBy0: string;
}): LensAttributes => ({
references: [],
state: {
adHocDataViews: {},
datasourceStates: {
textBased: {
layers: {
[LAYER_ID]: {
columns: [
{
columnId: 'tableStackBy0',
fieldName: getFirstColumnName(tableStackBy0),
},
{
columnId: '@timestamp',
fieldName: '@timestamp',
meta: {
type: 'date',
esType: 'date',
},
},
{
columnId: 'kibana.alert.risk_score',
fieldName: 'Risk score',
meta: {
type: 'number',
esType: 'long',
},
inMetricDimension: true,
},
{
columnId: 'host.name',
fieldName: 'host.name',
meta: {
type: 'string',
esType: 'keyword',
},
},
{
columnId: 'user.name',
fieldName: 'user.name',
meta: {
type: 'string',
esType: 'keyword',
},
},
],
index: '31734563-1D31-4A8C-804A-CA17540A793E',
query: {
esql: esqlQuery,
},
timeField: '@timestamp',
},
},
},
},
filters: [], // empty, because filters are applied directly to the lens.EmbeddableComponent
query: {
language: 'kuery',
query: '', // empty, because the query from the query bar is applied directly to the lens.EmbeddableComponent
},
visualization: {
columns: [
{
columnId: 'tableStackBy0',
width: 220,
},
{
columnId: '@timestamp',
},
{
columnId: 'kibana.alert.risk_score',
},
{
columnId: 'host.name',
},
{
columnId: 'user.name',
},
],
layerId: LAYER_ID,
layerType: 'data',
paging: {
enabled: true,
size: defaultPageSize,
},
sorting: {
...sorting,
},
},
},
title: i18n.ALERTS_PREVIEW,
visualizationType: 'lnsDatatable',
});

View file

@ -0,0 +1,37 @@
/*
* 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 i18n from './translations';
export interface TimeRangeLabel {
start:
| 'now/d'
| 'now/w'
| 'now-15m'
| 'now-30m'
| 'now-1h'
| 'now-24h'
| 'now-7d'
| 'now-30d'
| 'now-90d'
| 'now-1y';
end: 'now';
label: string;
}
export const getCommonTimeRanges = (): TimeRangeLabel[] => [
{ start: 'now/d', end: 'now', label: i18n.TODAY },
{ start: 'now/w', end: 'now', label: i18n.THIS_WEEK },
{ start: 'now-15m', end: 'now', label: i18n.LAST_15_MINUTES },
{ start: 'now-30m', end: 'now', label: i18n.LAST_30_MINUTES },
{ start: 'now-1h', end: 'now', label: i18n.LAST_1_HOUR },
{ start: 'now-24h', end: 'now', label: i18n.LAST_24_HOURS },
{ start: 'now-7d', end: 'now', label: i18n.LAST_7_DAYS },
{ start: 'now-30d', end: 'now', label: i18n.LAST_30_DAYS },
{ start: 'now-90d', end: 'now', label: i18n.LAST_90_DAYS },
{ start: 'now-1y', end: 'now', label: i18n.LAST_1_YEAR },
];

View file

@ -0,0 +1,78 @@
/*
* 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 LAST_1_HOUR = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last1HourLabel',
{
defaultMessage: 'Last 1 hour',
}
);
export const LAST_1_YEAR = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last1YearLabel',
{
defaultMessage: 'Last 1 year',
}
);
export const LAST_15_MINUTES = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last15MinutesLabel',
{
defaultMessage: 'Last 15 minutes',
}
);
export const LAST_7_DAYS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last7DaysLabel',
{
defaultMessage: 'Last 7 days',
}
);
export const LAST_24_HOURS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last24HoursLabel',
{
defaultMessage: 'Last 24 hours',
}
);
export const LAST_30_DAYS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last30DaysLabel',
{
defaultMessage: 'Last 30 days',
}
);
export const LAST_30_MINUTES = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last30MinutesLabel',
{
defaultMessage: 'Last 30 minutes',
}
);
export const LAST_90_DAYS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last90DaysLabel',
{
defaultMessage: 'Last 90 days',
}
);
export const THIS_WEEK = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.thisWeekLabel',
{
defaultMessage: 'This week',
}
);
export const TODAY = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.todayLabel',
{
defaultMessage: 'Today',
}
);

View file

@ -0,0 +1,9 @@
/*
* 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 const getFirstColumnName = (tableStackBy0: string): string =>
tableStackBy0 === 'kibana.alert.rule.name' ? 'Rule name' : tableStackBy0;

View file

@ -0,0 +1,20 @@
/*
* 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 { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant';
/**
* ensures maxAlerts is a positive number, otherwise returns the default value
*/
export const getMaxAlerts = (maxAlerts: string): number => {
const defaultMaxAlerts = Number(DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS);
const numericMaxAlerts = Number(maxAlerts);
const isMaxAlertsValid = Number.isInteger(numericMaxAlerts) && numericMaxAlerts > 0;
return isMaxAlertsValid ? numericMaxAlerts : defaultMaxAlerts;
};

View file

@ -0,0 +1,109 @@
/*
* 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 } from '@elastic/eui';
import type { Filter, Query } from '@kbn/es-query';
import React from 'react';
import { getAlertSummaryEsqlQuery } from '../../alert_summary_tab/get_alert_summary_esql_query';
import { getAlertSummaryLensAttributes } from '../../alert_summary_tab/get_alert_summary_lens_attributes';
import { getAlertsPreviewEsqlQuery } from '../../alerts_preview_tab/get_alerts_preview_esql_query';
import { getAlertsPreviewLensAttributes } from '../../alerts_preview_tab/get_alerts_preview_lens_attributes';
import { PreviewTab } from '../../preview_tab';
import * as i18n from '../../translations';
import type { Sorting } from '../../types';
const SUMMARY_TAB_EMBEDDABLE_ID = 'alertSummaryEmbeddable--id';
const PREVIEW_TAB_EMBEDDABLE_ID = 'alertsPreviewEmbeddable--id';
export const ALERT_SUMMARY_TEST_SUBJ = 'alertSummaryPreviewTab';
export const ALERTS_PREVIEW_TEST_SUBJ = 'alertsPreviewTab';
export const DEFAULT_ALERT_SUMMARY_SORT: Sorting = {
columnId: 'count',
direction: 'desc',
};
export const DEFAULT_ALERTS_PREVIEW_SORT: Sorting = {
columnId: 'kibana.alert.risk_score',
direction: 'desc',
};
export interface TabInfo {
content: JSX.Element;
id: string;
name: string;
}
interface GetTabs {
alertsPreviewStackBy0: string;
alertSummaryStackBy0: string;
end: string;
filters: Filter[];
maxAlerts: number;
query: Query;
setAlertsPreviewStackBy0: React.Dispatch<React.SetStateAction<string>>;
setAlertSummaryStackBy0: React.Dispatch<React.SetStateAction<string>>;
start: string;
}
export const getTabs = ({
alertsPreviewStackBy0,
alertSummaryStackBy0,
end,
filters,
maxAlerts,
query,
setAlertsPreviewStackBy0,
setAlertSummaryStackBy0,
start,
}: GetTabs): TabInfo[] => [
{
id: 'attackDiscoverySettingsAlertSummaryTab--id',
name: i18n.ALERT_SUMMARY,
content: (
<>
<EuiSpacer />
<PreviewTab
dataTestSubj={ALERT_SUMMARY_TEST_SUBJ}
embeddableId={SUMMARY_TAB_EMBEDDABLE_ID}
end={end}
filters={filters}
getLensAttributes={getAlertSummaryLensAttributes}
getPreviewEsqlQuery={getAlertSummaryEsqlQuery}
maxAlerts={maxAlerts}
query={query}
setTableStackBy0={setAlertSummaryStackBy0}
start={start}
tableStackBy0={alertSummaryStackBy0}
/>
</>
),
},
{
id: 'attackDiscoverySettingsAlertsPreviewTab--id',
name: i18n.ALERTS_PREVIEW,
content: (
<>
<EuiSpacer />
<PreviewTab
dataTestSubj={ALERTS_PREVIEW_TEST_SUBJ}
embeddableId={PREVIEW_TAB_EMBEDDABLE_ID}
end={end}
filters={filters}
getLensAttributes={getAlertsPreviewLensAttributes}
getPreviewEsqlQuery={getAlertsPreviewEsqlQuery}
maxAlerts={maxAlerts}
query={query}
setTableStackBy0={setAlertsPreviewStackBy0}
start={start}
tableStackBy0={alertsPreviewStackBy0}
/>
</>
),
},
];

View file

@ -0,0 +1,138 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiText, EuiSpacer } from '@elastic/eui';
import type { Filter, Query } from '@kbn/es-query';
import React, { useMemo, useState } from 'react';
import { AlertSelectionQuery } from './alert_selection_query';
import { AlertSelectionRange } from './alert_selection_range';
import { getTabs } from './helpers/get_tabs';
import * as i18n from './translations';
interface Props {
alertsPreviewStackBy0: string;
alertSummaryStackBy0: string;
end: string;
filters: Filter[];
maxAlerts: number;
query: Query;
setAlertsPreviewStackBy0: React.Dispatch<React.SetStateAction<string>>;
setAlertSummaryStackBy0: React.Dispatch<React.SetStateAction<string>>;
setEnd: React.Dispatch<React.SetStateAction<string>>;
setFilters: React.Dispatch<React.SetStateAction<Filter[]>>;
setMaxAlerts: React.Dispatch<React.SetStateAction<string>>;
setQuery: React.Dispatch<React.SetStateAction<Query>>;
setStart: React.Dispatch<React.SetStateAction<string>>;
start: string;
}
const AlertSelectionComponent: React.FC<Props> = ({
alertsPreviewStackBy0,
alertSummaryStackBy0,
end,
filters,
maxAlerts,
query,
setAlertsPreviewStackBy0,
setAlertSummaryStackBy0,
setEnd,
setFilters,
setMaxAlerts,
setQuery,
setStart,
start,
}) => {
const tabs = useMemo(
() =>
getTabs({
alertsPreviewStackBy0,
alertSummaryStackBy0,
end,
filters,
maxAlerts,
query,
setAlertsPreviewStackBy0,
setAlertSummaryStackBy0,
start,
}),
[
alertsPreviewStackBy0,
alertSummaryStackBy0,
end,
filters,
maxAlerts,
query,
setAlertsPreviewStackBy0,
setAlertSummaryStackBy0,
start,
]
);
const [selectedTabId, setSelectedTabId] = useState(tabs[0].id);
const selectedTabContent = useMemo(
() => tabs.find((obj) => obj.id === selectedTabId)?.content,
[selectedTabId, tabs]
);
return (
<EuiFlexGroup data-test-subj="alertSelection" direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiText data-test-subj="customizeAlerts" size="s">
<p>{i18n.CUSTOMIZE_THE_ALERTS}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertSelectionQuery
end={end}
filters={filters}
query={query}
setEnd={setEnd}
setFilters={setFilters}
setQuery={setQuery}
setStart={setStart}
start={start}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertSelectionRange maxAlerts={maxAlerts} setMaxAlerts={setMaxAlerts} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer />
</EuiFlexItem>
<EuiTabs data-test-subj="tabs">
{tabs.map((tab) => (
<EuiTab
key={tab.id}
isSelected={tab.id === selectedTabId}
onClick={() => setSelectedTabId(tab.id)}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
{selectedTabContent}
</EuiFlexGroup>
);
};
AlertSelectionComponent.displayName = 'AlertSelection';
export const AlertSelection = React.memo(AlertSelectionComponent);

View file

@ -0,0 +1,217 @@
/*
* 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 { Filter, Query, TimeRange } from '@kbn/es-query';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiEmptyPrompt,
EuiSpacer,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
import { useEuiComboBoxReset } from '../../../../../common/components/use_combo_box_reset';
import { StackByComboBox } from '../../../../../detections/components/alerts_kpis/common/components';
import { useSignalIndex } from '../../../../../detections/containers/detection_engine/alerts/use_signal_index';
import type { LensAttributes } from '../../../../../common/components/visualization_actions/types';
import { useKibana } from '../../../../../common/lib/kibana';
import * as i18n from '../translations';
import type { Sorting } from '../types';
export const ATTACK_DISCOVERY_SETTINGS_ALERTS_COUNT_ID = 'attack-discovery-settings-alerts-count';
export const RESET_FIELD = 'kibana.alert.rule.name';
const DEFAULT_DATA_TEST_SUBJ = 'previewTab';
const VIEW_MODE = 'view';
interface Props {
dataTestSubj?: string;
embeddableId: string;
end: string;
filters: Filter[];
getLensAttributes: ({
defaultPageSize,
esqlQuery,
sorting,
tableStackBy0,
}: {
defaultPageSize?: number;
esqlQuery: string;
sorting?: Sorting;
tableStackBy0: string;
}) => LensAttributes;
getPreviewEsqlQuery: ({
alertsIndexPattern,
maxAlerts,
tableStackBy0,
}: {
alertsIndexPattern: string;
maxAlerts: number;
tableStackBy0: string;
}) => string;
maxAlerts: number;
query: Query;
setTableStackBy0: React.Dispatch<React.SetStateAction<string>>;
sorting?: Sorting;
start: string;
tableStackBy0: string;
}
const PreviewTabComponent = ({
dataTestSubj = DEFAULT_DATA_TEST_SUBJ,
embeddableId,
end,
filters,
getLensAttributes,
getPreviewEsqlQuery,
maxAlerts,
query,
setTableStackBy0,
sorting,
start,
tableStackBy0,
}: Props) => {
const { lens } = useKibana().services;
const {
euiTheme: { font },
} = useEuiTheme();
const { signalIndexName } = useSignalIndex();
const {
comboboxRef: stackByField0ComboboxRef,
setComboboxInputRef: setStackByField0ComboboxInputRef,
} = useEuiComboBoxReset();
const onSelect = useCallback((value: string) => setTableStackBy0(value), [setTableStackBy0]);
const timeRange: TimeRange = useMemo(() => ({ from: start, to: end }), [end, start]);
const esqlQuery = useMemo(
() =>
getPreviewEsqlQuery({
alertsIndexPattern: signalIndexName ?? '',
maxAlerts,
tableStackBy0,
}),
[getPreviewEsqlQuery, maxAlerts, signalIndexName, tableStackBy0]
);
const attributes = useMemo(
() =>
getLensAttributes({
esqlQuery,
sorting,
tableStackBy0: tableStackBy0.trim(),
}),
[esqlQuery, getLensAttributes, sorting, tableStackBy0]
);
const onReset = useCallback(() => setTableStackBy0(RESET_FIELD), [setTableStackBy0]);
const actions = useMemo(
() => [
<EuiButtonEmpty color="primary" data-test-subj="reset" onClick={onReset}>
{i18n.RESET}
</EuiButtonEmpty>,
],
[onReset]
);
const body = useMemo(
() => (
<EuiText data-test-subj="body" size="s">
{i18n.SELECT_A_FIELD}
</EuiText>
),
[]
);
const EmptyPrompt = useMemo(
() =>
isEmpty(tableStackBy0.trim()) ? (
<EuiEmptyPrompt data-test-subj="emptyPrompt" actions={actions} body={body} />
) : null,
[actions, body, tableStackBy0]
);
if (signalIndexName == null) {
return null;
}
return (
<EuiFlexGroup data-test-subj={dataTestSubj} direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<StackByComboBox
aria-label={i18n.SELECT_FIELD}
data-test-subj="selectField"
inputRef={setStackByField0ComboboxInputRef}
onSelect={onSelect}
prepend={''}
ref={stackByField0ComboboxRef}
selected={tableStackBy0}
useLensCompatibleFields={true}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{EmptyPrompt ??
(attributes && (
<div
css={`
.euiDataGridHeader {
background: none;
border-top: none;
}
.euiDataGridHeaderCell {
font-size: ${font.scale.s}${font.defaultUnits};
}
.euiDataGridFooter {
background: none;
}
.euiDataGridRowCell {
font-size: ${font.scale.s}${font.defaultUnits};
}
.expExpressionRenderer__expression {
padding: 0 !important;
}
`}
data-test-subj="embeddableContainer"
>
<lens.EmbeddableComponent
attributes={attributes}
disableTriggers={false}
filters={filters}
hidePanelTitles={true}
id={embeddableId}
query={query}
timeRange={timeRange}
viewMode={VIEW_MODE}
withDefaultActions={true}
/>
</div>
))}
</EuiFlexItem>
</EuiFlexGroup>
);
};
PreviewTabComponent.displayName = 'PreviewTab';
export const PreviewTab = React.memo(PreviewTabComponent);

View file

@ -0,0 +1,81 @@
/*
* 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 ALERTS_PREVIEW = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertsPreviewTabLabel',
{
defaultMessage: 'Alerts preview',
}
);
export const ALERTS_SUMMARY = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertsSummaryTitle',
{
defaultMessage: 'Alerts summary',
}
);
export const CUSTOMIZE_THE_ALERTS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.customizeTheAlertsLabel',
{
defaultMessage:
'Customize the set of alerts that will be analyzed when generating Attack discoveries.',
}
);
export const ALERT_SUMMARY = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertSummaryTabLabel',
{
defaultMessage: 'Alert summary',
}
);
export const FILTER_YOUR_DATA = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.filterYourDataPlaceholder',
{
defaultMessage: 'Filter your data using KQL syntax',
}
);
export const SELECT_FIELD = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertsTable.selectFieldLabel',
{
defaultMessage: 'Select field',
}
);
export const RESET = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.resetLabel',
{
defaultMessage: 'Reset',
}
);
export const SEND_FEWER_ALERTS = i18n.translate(
'xpack.securitySolution.attackDiscovery.alertSelection.alertSelection.selectFewerAlertsLabel',
{
defaultMessage:
"Send fewer alerts if the model's context window is small or more if it is larger.",
}
);
export const SELECT_A_FIELD = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.selectAFieldEmptyText',
{
defaultMessage: 'Select a field',
}
);
export const SET_NUMBER_OF_ALERTS_TO_ANALYZE = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.setNumberOfAlertsToAnalyzeTitle',
{
defaultMessage: 'Set number of alerts to analyze',
}
);

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 interface Sorting {
columnId: string;
direction: 'asc' | 'desc';
}

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import { useEffect, useState } from 'react';
import { useKibana } from '../../../../../common/lib/kibana';
export const useDataView = ({
dataViewSpec,
loading,
}: {
dataViewSpec: DataViewSpec;
loading: boolean;
}): DataView | undefined => {
const { dataViews } = useKibana().services;
const [dataView, setDataView] = useState<DataView | undefined>(undefined);
useEffect(() => {
let active = true;
async function createDataView() {
if (!loading) {
try {
const dv = await dataViews.create(dataViewSpec);
if (active) {
setDataView(dv);
}
} catch {
if (active) {
setDataView(undefined);
}
}
}
}
createDataView();
return () => {
active = false;
};
}, [dataViewSpec, dataViews, loading]);
return dataView;
};

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 type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { css } from '@emotion/react';
import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules';
import React, { useMemo } from 'react';
import * as uuid from 'uuid';
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../../common/constants';
import { useKibana } from '../../../../common/lib/kibana';
interface Props {
query: Pick<QueryDslQueryContainer, 'bool' | 'ids'>;
size: number;
}
const configId = ALERTS_TABLE_REGISTRY_CONFIG_IDS.RULE_DETAILS; // show the same row-actions as in the case view
const AlertsPreviewComponent: React.FC<Props> = ({ query, size }) => {
const { triggersActionsUi } = useKibana().services;
const alertStateProps = useMemo(
() => ({
alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry,
configurationId: configId,
consumers: [AlertConsumers.SIEM],
id: `attack-discovery-alerts-preview-${uuid.v4()}`,
initialPageSize: size,
query,
ruleTypeIds: SECURITY_SOLUTION_RULE_TYPE_IDS,
showAlertStatusWithFlapping: false,
}),
[query, size, triggersActionsUi.alertsTableConfigurationRegistry]
);
return (
<div
css={css`
width: 100%;
`}
data-test-subj="alertsPreview"
>
{triggersActionsUi.getAlertsStateTable(alertStateProps)}
</div>
);
};
export const AlertsPreview = React.memo(AlertsPreviewComponent);

View file

@ -0,0 +1,42 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import { Footer } from '.';
describe('Footer', () => {
const closeModal = jest.fn();
const onReset = jest.fn();
const onSave = jest.fn();
beforeEach(() => jest.clearAllMocks());
it('calls onReset when the reset button is clicked', () => {
render(<Footer closeModal={closeModal} onReset={onReset} onSave={onSave} />);
fireEvent.click(screen.getByTestId('reset'));
expect(onReset).toHaveBeenCalled();
});
it('calls closeModal when the cancel button is clicked', () => {
render(<Footer closeModal={closeModal} onReset={onReset} onSave={onSave} />);
fireEvent.click(screen.getByTestId('cancel'));
expect(closeModal).toHaveBeenCalled();
});
it('calls onSave when the save button is clicked', () => {
render(<Footer closeModal={closeModal} onReset={onReset} onSave={onSave} />);
fireEvent.click(screen.getByTestId('save'));
expect(onSave).toHaveBeenCalled();
});
});

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 { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import * as i18n from '../../header/settings_modal/translations';
interface Props {
closeModal: () => void;
onReset: () => void;
onSave: () => void;
}
const FooterComponent: React.FC<Props> = ({ closeModal, onReset, onSave }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup
alignItems="center"
data-test-subj="footer"
gutterSize="none"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="reset" flush="both" onClick={onReset} size="s">
{i18n.RESET}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem
css={css`
margin-right: ${euiTheme.size.s};
`}
grow={false}
>
<EuiButtonEmpty data-test-subj="cancel" onClick={closeModal} size="s">
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton data-test-subj="save" fill onClick={onSave} size="s">
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};
FooterComponent.displayName = 'Footer';
export const Footer = React.memo(FooterComponent);

View file

@ -0,0 +1,159 @@
/*
* 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 {
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFlyoutResizable,
EuiSpacer,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant';
import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common';
import type { Filter, Query } from '@kbn/es-query';
import React, { useCallback, useMemo, useState } from 'react';
import { AlertSelection } from './alert_selection';
import { Footer } from './footer';
import * as i18n from './translations';
import { getDefaultQuery } from '../helpers';
import { getMaxAlerts } from './alert_selection/helpers/get_max_alerts';
export const DEFAULT_STACK_BY_FIELD = 'kibana.alert.rule.name';
const MIN_WIDTH = 448; // px
interface Props {
end: string | undefined;
filters: Filter[] | undefined;
localStorageAttackDiscoveryMaxAlerts: string | undefined;
onClose: () => void;
query: Query | undefined;
setEnd: React.Dispatch<React.SetStateAction<string | undefined>>;
setFilters: React.Dispatch<React.SetStateAction<Filter[] | undefined>>;
setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>;
setQuery: React.Dispatch<React.SetStateAction<Query | undefined>>;
setStart: React.Dispatch<React.SetStateAction<string | undefined>>;
start: string | undefined;
}
const SettingsFlyoutComponent: React.FC<Props> = ({
end,
filters,
setLocalStorageAttackDiscoveryMaxAlerts,
localStorageAttackDiscoveryMaxAlerts,
onClose,
query,
setEnd,
setFilters,
setQuery,
setStart,
start,
}) => {
const flyoutTitleId = useGeneratedHtmlId({
prefix: 'attackDiscoverySettingsFlyoutTitle',
});
const [alertSummaryStackBy0, setAlertSummaryStackBy0] = useState<string>(DEFAULT_STACK_BY_FIELD);
const [alertsPreviewStackBy0, setAlertsPreviewStackBy0] =
useState<string>(DEFAULT_STACK_BY_FIELD);
// local state:
const [localEnd, setLocalEnd] = useState<string>(end ?? DEFAULT_END);
const [localFilters, setLocalFilters] = useState<Filter[]>(filters ?? []);
const [localQuery, setLocalQuery] = useState<Query>(query ?? getDefaultQuery());
const [localStart, setLocalStart] = useState<string>(start ?? DEFAULT_START);
const [localMaxAlerts, setLocalMaxAlerts] = useState(
localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`
);
const onReset = useCallback(() => {
// reset local state:
setAlertSummaryStackBy0(DEFAULT_STACK_BY_FIELD);
setAlertsPreviewStackBy0(DEFAULT_STACK_BY_FIELD);
setLocalEnd(DEFAULT_END);
setLocalFilters([]);
setLocalQuery(getDefaultQuery());
setLocalStart(DEFAULT_START);
setLocalMaxAlerts(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`);
}, []);
const onSave = useCallback(() => {
// copy local state:
setEnd(localEnd);
setFilters(localFilters);
setQuery(localQuery);
setStart(localStart);
setLocalStorageAttackDiscoveryMaxAlerts(localMaxAlerts);
onClose();
}, [
localEnd,
localFilters,
localMaxAlerts,
localQuery,
localStart,
onClose,
setEnd,
setFilters,
setLocalStorageAttackDiscoveryMaxAlerts,
setQuery,
setStart,
]);
const numericMaxAlerts = useMemo(() => getMaxAlerts(localMaxAlerts), [localMaxAlerts]);
return (
<EuiFlyoutResizable
aria-labelledby={flyoutTitleId}
data-test-subj="settingsFlyout"
minWidth={MIN_WIDTH}
onClose={onClose}
paddingSize="m"
side="right"
size="s"
type="overlay"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle data-test-subj="title" size="m">
<h2 id={flyoutTitleId}>{i18n.ATTACK_DISCOVERY_SETTINGS}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiSpacer size="s" />
<AlertSelection
alertsPreviewStackBy0={alertsPreviewStackBy0}
alertSummaryStackBy0={alertSummaryStackBy0}
end={localEnd}
filters={localFilters}
maxAlerts={numericMaxAlerts}
query={localQuery}
setAlertsPreviewStackBy0={setAlertsPreviewStackBy0}
setAlertSummaryStackBy0={setAlertSummaryStackBy0}
setEnd={setLocalEnd}
setFilters={setLocalFilters}
setMaxAlerts={setLocalMaxAlerts}
setQuery={setLocalQuery}
setStart={setLocalStart}
start={localStart}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<Footer closeModal={onClose} onReset={onReset} onSave={onSave} />
</EuiFlyoutFooter>
</EuiFlyoutResizable>
);
};
SettingsFlyoutComponent.displayName = 'SettingsFlyoutComponent';
export const SettingsFlyout = React.memo(SettingsFlyoutComponent);

View file

@ -0,0 +1,38 @@
/*
* 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 { has, isEqual } from 'lodash/fp';
export const EMPTY_BOOL_FILTER_QUERY = { bool: { must: [], filter: [], should: [], must_not: [] } };
export const isEmptyBoolFilterQuery = (filterQuery: Record<string, unknown> | undefined): boolean =>
filterQuery == null || isEqual(EMPTY_BOOL_FILTER_QUERY, filterQuery);
export const parseFilterQuery = ({
filterQuery,
kqlError,
}: {
filterQuery: string | undefined;
kqlError: Error | undefined;
}): Record<string, unknown> | undefined => {
if (kqlError != null || filterQuery == null) {
return undefined;
}
try {
const parsedQuery = JSON.parse(filterQuery);
const queryIsValid = has('bool', parsedQuery) && !isEmptyBoolFilterQuery(parsedQuery);
if (queryIsValid) {
return parsedQuery;
}
} catch {
// ignore
}
return undefined;
};

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 { i18n } from '@kbn/i18n';
export const ATTACK_DISCOVERY_SETTINGS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.attackDiscoverySettingsTitle',
{
defaultMessage: 'Attack discovery settings',
}
);

View file

@ -155,11 +155,10 @@ describe('useAttackDiscovery', () => {
await act(async () => {
await result.current.fetchAttackDiscoveries();
});
expect(mockedUseKibana.services.http.fetch).toHaveBeenCalledWith(
expect(mockedUseKibana.services.http.post).toHaveBeenCalledWith(
'/internal/elastic_assistant/attack_discovery',
{
body: `{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"replacements":{},"size":${SIZE},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}`,
method: 'POST',
version: '1',
}
);
@ -172,7 +171,7 @@ describe('useAttackDiscovery', () => {
it('handles fetch errors correctly', async () => {
const errorMessage = 'Fetch error';
const error = new Error(errorMessage);
(mockedUseKibana.services.http.fetch as jest.Mock).mockRejectedValue(error);
(mockedUseKibana.services.http.post as jest.Mock).mockRejectedValue(error);
const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE }));

View file

@ -16,6 +16,7 @@ import {
AttackDiscoveryPostResponse,
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
} from '@kbn/elastic-assistant-common';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields';
@ -25,13 +26,20 @@ import { getErrorToastText } from '../helpers';
import { CONNECTOR_ERROR, ERROR_GENERATING_ATTACK_DISCOVERIES } from '../translations';
import { getGenAiConfig, getRequestBody } from './helpers';
interface FetchAttackDiscoveriesOptions {
end?: string;
filter?: Record<string, unknown>;
size?: number;
start?: string;
}
export interface UseAttackDiscovery {
alertsContextCount: number | null;
approximateFutureTime: Date | null;
attackDiscoveries: AttackDiscoveries;
didInitialFetch: boolean;
failureReason: string | null;
fetchAttackDiscoveries: () => Promise<void>;
fetchAttackDiscoveries: (options?: FetchAttackDiscoveriesOptions) => Promise<void>;
generationIntervals: GenerationInterval[] | undefined;
isLoading: boolean;
isLoadingPost: boolean;
@ -151,37 +159,59 @@ export const useAttackDiscovery = ({
}, [connectorId, pollData]);
/** The callback when users click the Generate button */
const fetchAttackDiscoveries = useCallback(async () => {
try {
if (requestBody.apiConfig.connectorId === '' || requestBody.apiConfig.actionTypeId === '') {
throw new Error(CONNECTOR_ERROR);
}
setLoadingConnectorId?.(connectorId ?? null);
// sets isLoading to true
setPollStatus('running');
setIsLoadingPost(true);
setApproximateFutureTime(null);
// call the internal API to generate attack discoveries:
const rawResponse = await http.fetch('/internal/elastic_assistant/attack_discovery', {
body: JSON.stringify(requestBody),
method: 'POST',
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
});
setIsLoadingPost(false);
const parsedResponse = AttackDiscoveryPostResponse.safeParse(rawResponse);
const fetchAttackDiscoveries = useCallback(
async (options: FetchAttackDiscoveriesOptions | undefined) => {
try {
if (options?.size != null) {
setAlertsContextCount(options.size);
}
if (!parsedResponse.success) {
throw new Error('Failed to parse the response');
const end = options?.end;
const filter = !isEmpty(options?.filter) ? options?.filter : undefined;
const start = options?.start;
const bodyWithOverrides = {
...requestBody,
end,
filter,
size,
start,
};
if (
bodyWithOverrides.apiConfig.connectorId === '' ||
bodyWithOverrides.apiConfig.actionTypeId === ''
) {
throw new Error(CONNECTOR_ERROR);
}
setLoadingConnectorId?.(connectorId ?? null);
// sets isLoading to true
setPollStatus('running');
setIsLoadingPost(true);
setApproximateFutureTime(null);
// call the internal API to generate attack discoveries:
const rawResponse = await http.post('/internal/elastic_assistant/attack_discovery', {
body: JSON.stringify(bodyWithOverrides),
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
});
setIsLoadingPost(false);
const parsedResponse = AttackDiscoveryPostResponse.safeParse(rawResponse);
if (!parsedResponse.success) {
throw new Error('Failed to parse the response');
}
} catch (error) {
setIsLoadingPost(false);
setIsLoading(false);
toasts?.addDanger(error, {
title: ERROR_GENERATING_ATTACK_DISCOVERIES,
text: getErrorToastText(error),
});
}
} catch (error) {
setIsLoadingPost(false);
setIsLoading(false);
toasts?.addDanger(error, {
title: ERROR_GENERATING_ATTACK_DISCOVERIES,
text: getErrorToastText(error),
});
}
}, [connectorId, http, requestBody, setLoadingConnectorId, setPollStatus, toasts]);
},
[connectorId, http, requestBody, setLoadingConnectorId, setPollStatus, size, toasts]
);
return {
alertsContextCount,

View file

@ -573,6 +573,7 @@ export class Plugin implements ISecuritySolutionPlugin {
plugins.elasticAssistant.registerTools(APP_UI_ID, assistantTools);
const features = {
assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation,
attackDiscoveryAlertFiltering: config.experimentalFeatures.attackDiscoveryAlertFiltering,
};
plugins.elasticAssistant.registerFeatures(APP_UI_ID, features);
plugins.elasticAssistant.registerFeatures('management', features);