-
Notifications
You must be signed in to change notification settings - Fork 8.5k
[Security Solution][Entity Analytics][Threat Hunting][WIP] Introduce threat hunting home experience #241940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Security Solution][Entity Analytics][Threat Hunting][WIP] Introduce threat hunting home experience #241940
Conversation
|
Pinging @elastic/security-entity-analytics (Team:Entity Analytics) |
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR introduces a new experimental Threat Hunting feature for Entity Analytics, providing an AI-powered interface for analyzing and investigating entity threats. The feature is controlled by the entityThreatHuntingEnabled experimental flag.
Key changes:
- Adds a new
ThreatHuntingHomePagecomponent with AI hypotheses section, entity risk levels visualization, and entities table - Conditionally renders the new threat hunting page or legacy overview based on experimental flag
- Enhances entities list table with new action buttons for timeline navigation and AI assistant
- Updates navigation links to include threat hunting as a new entry point
Reviewed Changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| routes.tsx | Adds conditional rendering logic to show ThreatHuntingHomePage when experimental flag is enabled |
| routes.test.tsx | Tests the conditional rendering behavior based on the experimental flag |
| threat_hunting_home_page.tsx | New page component implementing the threat hunting UI with AI hypotheses, risk levels, and entities table |
| threat_hunting_home_page.test.tsx | Comprehensive test coverage for the new threat hunting page component |
| links.ts | Adds threat hunting navigation link and updates entity analytics links structure |
| threat_hunting_entity_risk_levels.tsx | Component displaying entity risk levels with donut chart and table visualization |
| threat_hunting_entities_table.tsx | Wrapper component for entities table in threat hunting context |
| use_entities_list_columns.tsx | Enhances entities table with timeline and AI assistant action buttons when flag enabled |
| entities_list.tsx | Adds rowHeight configuration for new action buttons |
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Show resolved
Hide resolved
...ugins/security_solution/public/entity_analytics/components/threat_hunting_entities_table.tsx
Outdated
Show resolved
Hide resolved
...s/security_solution/public/entity_analytics/components/threat_hunting_entity_risk_levels.tsx
Outdated
Show resolved
Hide resolved
...ecurity/plugins/security_solution/public/entity_analytics/pages/threat_hunting_home_page.tsx
Outdated
Show resolved
Hide resolved
...ecurity/plugins/security_solution/public/entity_analytics/pages/threat_hunting_home_page.tsx
Outdated
Show resolved
Hide resolved
...s/security_solution/public/entity_analytics/components/threat_hunting_entity_risk_levels.tsx
Outdated
Show resolved
Hide resolved
...ecurity/plugins/security_solution/public/entity_analytics/pages/threat_hunting_home_page.tsx
Outdated
Show resolved
Hide resolved
...ecurity/plugins/security_solution/public/entity_analytics/pages/threat_hunting_home_page.tsx
Outdated
Show resolved
Hide resolved
|
🤖 Jobs for this PR can be triggered through checkboxes. 🚧
ℹ️ To trigger the CI, please tick the checkbox below 👇
|
|
Project deployments require a Github label, please add one or more of |
…na into ea-14550-nlth-ui
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 14 comments.
Comments suppressed due to low confidence (1)
x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_score_kpi.tsx:117
- The dependency array for the
useEffecthook has been updated to includeriskEntities(line 113), butriskEntitiesis recalculated on every render based onmultipleEntitiesandsingleEntity. This could cause unnecessary re-executions of the search effect.
Consider memoizing riskEntities with useMemo or deriving it directly in the dependency array from the stable rest parameters to avoid unnecessary effect triggers:
const riskEntitiesMemo = useMemo(() => {
if (multipleEntities && multipleEntities.length > 0) {
return multipleEntities;
}
return singleEntity ? [singleEntity] : [];
}, [multipleEntities, singleEntity]);Then use riskEntitiesMemo in the effect dependencies.
const riskEntities = useMemo(() => {
if (multipleEntities && multipleEntities.length > 0) {
return multipleEntities;
}
return singleEntity ? [singleEntity] : [];
}, [multipleEntities, singleEntity]);
const primaryEntity = riskEntities[0] ?? singleEntity;
const shouldSkip = skip || riskEntities.length === 0;
useEffect(() => {
if (!shouldSkip && defaultIndex && !isStatusLoading && riskEngineHasBeenEnabled) {
search({
filterQuery,
defaultIndex: [defaultIndex],
entity: primaryEntity,
entities: riskEntities,
timerange: requestTimerange,
});
}
}, [
defaultIndex,
search,
filterQuery,
shouldSkip,
primaryEntity,
riskEntities,
requestTimerange,
isStatusLoading,
riskEngineHasBeenEnabled,
]);
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
...y/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
Show resolved
Hide resolved
...rity_solution/public/entity_analytics/components/threat_hunting/entity_risk_levels/index.tsx
Outdated
Show resolved
Hide resolved
...ck/solutions/security/plugins/security_solution/common/api/search_strategy/risk_score/kpi.ts
Show resolved
Hide resolved
.../server/search_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts
Outdated
Show resolved
Hide resolved
...solution/public/entity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx
Outdated
Show resolved
Hide resolved
.../public/asset_inventory/hooks/use_asset_inventory_url_state/use_asset_inventory_url_state.ts
Outdated
Show resolved
Hide resolved
.../security/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_score_kpi.tsx
Show resolved
Hide resolved
...y/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
Show resolved
Hide resolved
...s/security_solution/server/search_strategy/security_solution/factory/risk_score/kpi/index.ts
Outdated
Show resolved
Hide resolved
…r panel to accent
…ity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx Co-authored-by: Copilot <[email protected]>
…et_inventory/hooks/use_asset_inventory_url_state/use_asset_inventory_url_state.ts Co-authored-by: Copilot <[email protected]>
…et_inventory/components/asset_inventory_data_table.tsx Co-authored-by: Copilot <[email protected]>
…ity_analytics/components/threat_hunting/entity_risk_levels/index.tsx Co-authored-by: Copilot <[email protected]>
…rch_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts Co-authored-by: Copilot <[email protected]>
…ity_analytics/components/threat_hunting/entities_table.tsx Co-authored-by: Copilot <[email protected]>
…ity_analytics/components/entity_store/hooks/use_entities_list_columns.tsx Co-authored-by: Copilot <[email protected]>
…rch_strategy/security_solution/factory/risk_score/kpi/index.ts Co-authored-by: Copilot <[email protected]>
…na into ea-14550-nlth-ui
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.
.../server/search_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts
Show resolved
Hide resolved
...y/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
Outdated
Show resolved
Hide resolved
...ugins/security_solution/public/entity_analytics/components/threat_hunting/entities_table.tsx
Outdated
Show resolved
Hide resolved
| const usePageSizePreference = (localStorageKey?: string) => { | ||
| const [pageSize, setPageSize] = useState<number>(() => { | ||
| if (!localStorageKey || typeof window === 'undefined') { | ||
| return DEFAULT_VISIBLE_ROWS_PER_PAGE; | ||
| } | ||
|
|
||
| const storedValue = window.localStorage.getItem(localStorageKey); | ||
| const parsedValue = storedValue != null ? Number(JSON.parse(storedValue)) : NaN; | ||
|
|
||
| return Number.isFinite(parsedValue) && parsedValue > 0 | ||
| ? parsedValue | ||
| : DEFAULT_VISIBLE_ROWS_PER_PAGE; | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (!localStorageKey || typeof window === 'undefined') { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| window.localStorage.setItem(localStorageKey, JSON.stringify(pageSize)); | ||
| } catch (error) { | ||
| // noop - best effort persistence | ||
| } | ||
| }, [localStorageKey, pageSize]); | ||
|
|
||
| const setPageSizePreference = useCallback((value: SetStateAction<number | undefined>) => { | ||
| setPageSize((previous) => { | ||
| const resolvedValue = typeof value === 'function' ? value(previous) : value; | ||
| const nextValue = resolvedValue ?? DEFAULT_VISIBLE_ROWS_PER_PAGE; | ||
| return nextValue > 0 ? nextValue : previous; | ||
| }); | ||
| }, []); | ||
|
|
||
| return { pageSize, setPageSize: setPageSizePreference }; | ||
| }; |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The usePageSizePreference hook is duplicating functionality that already exists in use_page_size.ts. Both hooks:
- Store/retrieve page size from localStorage
- Handle validation and defaults
- Provide the same interface
Consider removing this inline implementation and importing usePageSize from ./use_page_size instead, or if the optional localStorageKey parameter is needed, refactor use_page_size.ts to accept an optional key parameter rather than duplicating the entire hook logic.
| const supportedEntities = requestedEntities.filter( | ||
| (entityType) => EntityTypeToLevelField[entityType] !== RiskScoreFields.unsupported | ||
| ); | ||
|
|
||
| const entitiesToProcess = supportedEntities.length > 0 ? supportedEntities : requestedEntities; | ||
|
|
||
| const accumulateBuckets = ( | ||
| accumulator: Record<RiskSeverity, number>, | ||
| buckets: AggBucket[] | ||
| ): Record<RiskSeverity, number> => { | ||
| const result = { ...accumulator }; | ||
| buckets.forEach((bucket) => { | ||
| const key = bucket.key; | ||
| const currentTotal = result[key] ?? 0; | ||
| const bucketValue = getOr(0, 'unique_entries.value', bucket); | ||
| result[key] = currentTotal + bucketValue; | ||
| }); | ||
| return result; | ||
| }; | ||
|
|
||
| const rawAggregations = ( | ||
| response.rawResponse as { | ||
| aggregations?: Record<string, AggregationBucket>; | ||
| } | ||
| ).aggregations; | ||
|
|
||
| let aggregatedResult: Record<RiskSeverity, number> = {} as Record<RiskSeverity, number>; | ||
|
|
||
| if (entitiesToProcess.length <= 1) { | ||
| const riskBuckets = rawAggregations?.risk?.buckets ?? []; | ||
| aggregatedResult = accumulateBuckets(aggregatedResult, riskBuckets); | ||
| } else { | ||
| entitiesToProcess.forEach((entityType) => { | ||
| const buckets = rawAggregations?.[entityType]?.buckets ?? []; | ||
| aggregatedResult = accumulateBuckets(aggregatedResult, buckets); | ||
| }); |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parser has the same issue as the DSL builder. When all requested entities are unsupported, entitiesToProcess will include unsupported entity types (line 55), and the code will attempt to access aggregation buckets using unsupported entity type names as keys (line 84).
Since the DSL builder won't create aggregations for these entity types (or will create invalid ones), rawAggregations?.[entityType] will be undefined, resulting in empty buckets. While this doesn't crash, it's inefficient and inconsistent.
The parser should use the same logic as recommended for the DSL: if supportedEntities.length === 0, return empty results early rather than falling back to unsupported entities.
| riskEngineHasBeenEnabled && | ||
| primaryEntity |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant null check at line 103. The condition primaryEntity is already guaranteed to be truthy because:
primaryEntity = riskEntities[0] ?? singleEntity(line 94)- The check
shouldSkip = skip || riskEntities.length === 0(line 95) returns early ifriskEntitiesis empty - If
riskEntities[0]exists (length > 0), thenprimaryEntitywill be truthy - The only case where
primaryEntitycould be falsy is whenriskEntities.length === 0, but that's already handled byshouldSkip
Remove the primaryEntity check from line 103 as it's redundant with the shouldSkip check.
| riskEngineHasBeenEnabled && | |
| primaryEntity | |
| riskEngineHasBeenEnabled |
| const identifierField = EntityTypeToIdentifierField[entityType]; | ||
| const rawIdentifier = get(identifierField, record); | ||
| const identifierCandidates = toArray(rawIdentifier); | ||
|
|
||
| // Use entity.name as primary display name (same as used in the name column) | ||
| // Fall back to identifier or entity.id if name is not available | ||
| const displayName = | ||
| record.entity?.name || | ||
| identifierCandidates.find( | ||
| (value): value is string => typeof value === 'string' && value.length > 0 | ||
| ) || | ||
| record.entity?.id; | ||
|
|
||
| const flyoutKey = EntityPanelKeyByType[entityType]; | ||
|
|
||
| const onClick = () => { | ||
| const id = EntityPanelKeyByType[entityType]; | ||
|
|
||
| if (id) { | ||
| openRightPanel({ | ||
| id, | ||
| params: { | ||
| [EntityPanelParamByType[entityType] ?? '']: value, | ||
| contextID: ENTITIES_LIST_TABLE_ID, | ||
| scopeId: ENTITIES_LIST_TABLE_ID, | ||
| }, | ||
| }); | ||
| if (!flyoutKey || !displayName) { | ||
| return; | ||
| } | ||
|
|
||
| openRightPanel({ | ||
| id: flyoutKey, | ||
| params: { | ||
| [EntityPanelParamByType[entityType] ?? '']: displayName, | ||
| contextID: ENTITIES_LIST_TABLE_ID, | ||
| scopeId: ENTITIES_LIST_TABLE_ID, | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| if (!value || !EntityPanelKeyByType[entityType]) { | ||
| const timelineIdentifier = identifierCandidates.find( | ||
| (value): value is string => typeof value === 'string' && value.length > 0 | ||
| ); |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate identifier extraction logic. The code extracts identifierCandidates (lines 72-74) and then searches for a valid string identifier twice:
- Lines 80-82: To populate
displayName - Lines 102-104: To populate
timelineIdentifier
Both searches perform the exact same operation. Extract this once and reuse:
const validIdentifier = identifierCandidates.find(
(value): value is string => typeof value === 'string' && value.length > 0
);
const displayName = record.entity?.name || validIdentifier || record.entity?.id;
const timelineIdentifier = validIdentifier;…rch_strategy/security_solution/factory/risk_score/kpi/query.kpi_risk_score.dsl.ts Co-authored-by: Copilot <[email protected]>
…et_inventory/components/asset_inventory_data_table.tsx Co-authored-by: Copilot <[email protected]>
…ity_analytics/components/threat_hunting/entities_table.tsx Co-authored-by: Copilot <[email protected]>
…na into ea-14550-nlth-ui
|
/ci |
💔 Build Failed
Failed CI StepsHistory
|
|
Closing this as a new PR is created instead : #243311 |
Summary
entityThreatHuntingEnabledflag is onScreenshot of the UI :