mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] [Attack discovery] Alerts filtering (#205070)
## [Security Solution] [Attack discovery] Alerts filtering  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:  ### After (feature flag enabled) The new Attack discovery settings flyout replaces the modal:  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:  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:  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:  2) Relative date ranges:  3) Absolute date ranges:  #### 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:   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:
parent
71144eded7
commit
681d40eee6
61 changed files with 2380 additions and 77 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
@ -20,5 +20,6 @@ export type AssistantFeatureKey = keyof AssistantFeatures;
|
|||
*/
|
||||
export const defaultAssistantFeatures = Object.freeze({
|
||||
assistantModelEvaluation: false,
|
||||
attackDiscoveryAlertFiltering: false,
|
||||
defendInsights: false,
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -22,10 +22,13 @@ paths:
|
|||
properties:
|
||||
assistantModelEvaluation:
|
||||
type: boolean
|
||||
attackDiscoveryAlertFiltering:
|
||||
type: boolean
|
||||
defendInsights:
|
||||
type: boolean
|
||||
required:
|
||||
- assistantModelEvaluation
|
||||
- attackDiscoveryAlertFiltering
|
||||
- defendInsights
|
||||
'400':
|
||||
description: Generic Error
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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('');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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: '',
|
||||
});
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
};
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
||||
};
|
|
@ -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)}
|
||||
`;
|
|
@ -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',
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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)}
|
||||
`;
|
|
@ -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',
|
||||
});
|
|
@ -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 },
|
||||
];
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
|
@ -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);
|
|
@ -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);
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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';
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
||||
};
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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 }));
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue