Skip to content

Commit 0671bc8

Browse files
kfirpeledNicholasPeretti
authored andcommitted
[Cloud Security] Show related alert's when fetching CDR graph (elastic#224783)
## Summary Closes elastic#221037 , shows alerts on the graph by querying both the logs and the alerts indices - [x] Graph API - new optional `indexPatterns` parameters to switch data views (not in use in the UI atm). Defaults to `.alerts-security.alerts-<spaceId>, logs-*` - [x] Visualize loaded alerts that are identified with alerts in graph preview and graph investigation ~Depends on elastic#224483 `actor` and `target` are not part of ECS yet. And to ease our development process we wish to push forward with this feature in mind. This feature supports both cases when alert's index mappings contains definition for `actor` and `target`, and also when its not. In this PR, we add mappings of `actor` and `target` to the es_archive of the alerts. This way we are able to test the functionality of this feature instead of being blocked by elastic#224483. <details> <summary>Video 🎥 </summary> https://github.com/user-attachments/assets/bcc86214-6e88-46f3-a990-300bbdc28125 </details> <details> <summary>Screenshots 📸 </summary> **Before (ignore label alignments - screenshot is from a local environment)** ![Screenshot 2025-06-29 at 19 33 00](https://github.com/user-attachments/assets/39b014ce-6b70-44cc-a486-906d39c205fe) **After (another event is identified with alert - marking it as such and expands the _alert_ details)** ![Screenshot 2025-06-29 at 19 32 30](https://github.com/user-attachments/assets/824d1d6f-9c17-4c4a-a8a7-18e65b89dbb2) **Before network page - preview** ![Screenshot 2025-06-29 at 19 40 59](https://github.com/user-attachments/assets/50716acc-b2bd-4d42-93e0-eb31cfa6fe9c) **After network page - preview identifies if event contains alert** ![Screenshot 2025-06-29 at 19 40 29](https://github.com/user-attachments/assets/531cec9f-2fb3-4a90-9cc1-1a73684f3612) </details> ### How to test locally 1. Edit `kibana.dev.yml` and add: ```yml uiSettings.overrides.securitySolution:enableGraphVisualization: true ``` 2. Start elasticsearch and kibana locally 3. To add mock data run the following: ```bash node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/logs_gcp_audit \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/security_alerts_modified_mappings \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 ``` 3. Open `Alerts` page in kibana. Update the date-picker to include data from a year ago. Then check one of the alerts details opening the right-side flyout and find the "Graph preview" section in it. 4. Expand graph to show related alerts 5. Enable Asset Inventory in the `Inventory` page (if you don't see the page enable the feature flag in the advanced settings) 6. Add entities mock data ``` node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_api/es_archives/entity_store \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 ``` 7. Open `Alerts` page in kibana. Check that the graph shows the admin entity with it's label ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
1 parent a8a521b commit 0671bc8

File tree

16 files changed

+10577
-125
lines changed

16 files changed

+10577
-125
lines changed

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export interface GraphInvestigationProps {
8686
* The initial state to use for the graph investigation view.
8787
*/
8888
initialState: {
89+
/**
90+
* The index patterns to use for the graph investigation view.
91+
*/
92+
indexPatterns?: string[];
93+
8994
/**
9095
* The data view to use for the graph investigation view.
9196
*/
@@ -145,7 +150,7 @@ type EsQuery = UseFetchGraphDataParams['req']['query']['esQuery'];
145150
*/
146151
export const GraphInvestigation = memo<GraphInvestigationProps>(
147152
({
148-
initialState: { dataView, originEventIds, timeRange: initialTimeRange },
153+
initialState: { indexPatterns, dataView, originEventIds, timeRange: initialTimeRange },
149154
showInvestigateInTimeline = false,
150155
showToggleSearch = false,
151156
onInvestigateInTimeline,
@@ -211,6 +216,7 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
211216
req: {
212217
query: {
213218
originEventIds,
219+
indexPatterns,
214220
esQuery,
215221
start: timeRange.from,
216222
end: timeRange.to,

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ export type {
1818
EntityNodeViewModel,
1919
NodeProps,
2020
} from './types';
21-
export { isEntityNode, getNodeDocumentMode, hasNodeDocumentsData } from './utils';
21+
export {
22+
isEntityNode,
23+
getNodeDocumentMode,
24+
hasNodeDocumentsData,
25+
getSingleDocumentData,
26+
} from './utils';

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/utils.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ export const isEntityNode = (node: NodeViewModel) =>
2323
export const isStackedLabel = (node: NodeViewModel): boolean =>
2424
!(node.shape === 'label' && Boolean(node.parentId));
2525

26+
/**
27+
* Type guard: Returns true if node.documentsData is a non-empty array.
28+
* This only narrows node.documentsData to a non-empty array, not to a specific document type.
29+
*/
30+
export const hasNodeDocumentsData = (
31+
node: NodeViewModel
32+
): node is NodeViewModel & {
33+
documentsData: [NodeDocumentDataViewModel, ...NodeDocumentDataViewModel[]];
34+
} => {
35+
return Array.isArray(node.documentsData) && node.documentsData.length > 0;
36+
};
37+
2638
/**
2739
* Returns the node document mode, or 'na' if documentsData is missing or empty.
2840
* When this function returns a value other than 'na', documentsData is guaranteed to be a non-empty array.
@@ -41,7 +53,7 @@ export const getNodeDocumentMode = (
4153
}
4254

4355
// Single alert contains both event's document data and alert's document data.
44-
if (node.documentsData.find((doc) => doc.type === 'alert') && node.documentsData.length < 2) {
56+
if (node.documentsData.find((doc) => doc.type === 'alert') && node.documentsData.length <= 2) {
4557
return 'single-alert';
4658
} else if (node.documentsData.length === 1 && node.documentsData[0].type === 'event') {
4759
return 'single-event';
@@ -57,14 +69,24 @@ export const getNodeDocumentMode = (
5769
};
5870

5971
/**
60-
* Type guard: Returns true if node.documentsData is a non-empty array.
61-
* This only narrows node.documentsData to a non-empty array, not to a specific document type.
72+
* Returns the single document data for a node if it is in single-* mode.
73+
* If the node is not in one of these modes, or if it has no documentsData, it returns undefined.
6274
*/
63-
export function hasNodeDocumentsData(node: NodeViewModel): node is NodeViewModel & {
64-
documentsData: [NodeDocumentDataViewModel, ...NodeDocumentDataViewModel[]];
65-
} {
66-
return Array.isArray(node.documentsData) && node.documentsData.length > 0;
67-
}
75+
export const getSingleDocumentData = (
76+
node: NodeViewModel
77+
): NodeDocumentDataViewModel | undefined => {
78+
const mode = getNodeDocumentMode(node);
79+
if (!hasNodeDocumentsData(node) || (mode !== 'single-alert' && mode !== 'single-event')) {
80+
return undefined;
81+
}
82+
83+
// For single-alert we might have both event and alert documents. We prefer to return the alert document if it exists.
84+
const documentData =
85+
node.documentsData.find((doc) => doc.type === 'alert') ??
86+
node.documentsData.find((doc) => doc.type === 'event');
87+
88+
return documentData;
89+
};
6890

6991
const FETCH_GRAPH_FAILED_TEXT = i18n.translate(
7092
'securitySolutionPackages.csp.graph.investigation.errorFetchingGraphData',

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,10 @@ describe('fetchGraph', () => {
228228
`WITH targetEntityName = entity.name, targetEntityType = entity.type, targetSourceIndex = entity.source`
229229
);
230230

231-
expect(query).not.toContain(`actorsDocData = VALUES(actorDocData)`);
232-
expect(query).not.toContain(`targetsDocData = VALUES(targetDocData)`);
231+
expect(query).toContain(`EVAL actorDocData = TO_STRING(null)`);
232+
expect(query).toContain(`EVAL targetDocData = TO_STRING(null)`);
233+
expect(query).toContain(`actorsDocData = VALUES(actorDocData)`);
234+
expect(query).toContain(`targetsDocData = VALUES(targetDocData)`);
233235
expect(result).toEqual([{ id: 'dummy' }]);
234236
});
235237

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts

Lines changed: 35 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface BuildEsqlQueryParams {
2020
originEventIds: OriginEventId[];
2121
originAlertIds: OriginEventId[];
2222
isEnrichPolicyExists: boolean;
23-
enrichPolicyName: string;
23+
spaceId: string;
2424
alertsMappingsIncluded: boolean;
2525
}
2626

@@ -46,6 +46,7 @@ export const fetchGraph = async ({
4646
esQuery?: EsQuery;
4747
}): Promise<EsqlToRecords<GraphEdge>> => {
4848
const originAlertIds = originEventIds.filter((originEventId) => originEventId.isAlert);
49+
4950
// FROM clause currently doesn't support parameters, Therefore, we validate the index patterns to prevent injection attacks.
5051
// Regex to match invalid characters in index patterns: upper case characters, \, /, ?, ", <, >, |, (space), #, or ,
5152
indexPatterns.forEach((indexPattern, idx) => {
@@ -68,7 +69,7 @@ export const fetchGraph = async ({
6869
originEventIds,
6970
originAlertIds,
7071
isEnrichPolicyExists,
71-
enrichPolicyName: getEnrichPolicyId(spaceId),
72+
spaceId,
7273
alertsMappingsIncluded,
7374
});
7475

@@ -162,54 +163,20 @@ const buildEsqlQuery = ({
162163
originEventIds,
163164
originAlertIds,
164165
isEnrichPolicyExists,
165-
enrichPolicyName,
166+
spaceId,
166167
alertsMappingsIncluded,
167168
}: BuildEsqlQueryParams): string => {
168169
const SECURITY_ALERTS_PARTIAL_IDENTIFIER = '.alerts-security.alerts-';
170+
const enrichPolicyName = getEnrichPolicyId(spaceId);
169171

170-
const originEventClause =
171-
originEventIds.length > 0
172-
? `event.id in (${originEventIds.map((_id, idx) => `?og_id${idx}`).join(', ')})`
173-
: 'false';
174-
175-
const originAlertClause =
176-
originAlertIds.length > 0
177-
? `event.id in (${originAlertIds.map((_id, idx) => `?og_alrt_id${idx}`).join(', ')})`
178-
: 'false';
179-
180-
const formattedIndexPatterns = indexPatterns
172+
const query = `FROM ${indexPatterns
181173
.filter((indexPattern) => indexPattern.length > 0)
182-
.join(',');
183-
184-
if (isEnrichPolicyExists) {
185-
return `FROM ${formattedIndexPatterns} METADATA _id, _index
186-
187-
| ENRICH ${enrichPolicyName} ON actor.entity.id WITH actorEntityName = entity.name, actorEntityType = entity.type
188-
| ENRICH ${enrichPolicyName} ON target.entity.id WITH targetEntityName = entity.name, targetEntityType = entity.type
189-
174+
.join(',')} METADATA _id, _index
190175
| WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL
191-
// Origin event and alerts allow us to identify the start position of graph traversal
192-
| EVAL isOrigin = ${originEventClause}
193-
| EVAL isOriginAlert = isOrigin AND ${originAlertClause}
194-
195-
// We format it as JSON string, the best alternative so far. Tried to use tuple using MV_APPEND
196-
// but it flattens the data and we lose the structure
197-
// Aggregate document's data for popover expansion and metadata enhancements
198-
| EVAL isAlert = _index LIKE "*${SECURITY_ALERTS_PARTIAL_IDENTIFIER}*"
199-
| EVAL docType = CASE (isAlert, "${DOCUMENT_TYPE_ALERT}", "${DOCUMENT_TYPE_EVENT}")
200-
| EVAL docData = CONCAT("{",
201-
"\\"id\\":\\"", _id, "\\"",
202-
",\\"type\\":\\"", docType, "\\"",
203-
",\\"index\\":\\"", _index, "\\"",
204-
"}")
205-
${
206-
alertsMappingsIncluded
207-
? `CASE (isAlert, CONCAT(",\\"alert\\":", "{",
208-
"\\"ruleName\\":\\"", kibana.alert.rule.name, "\\"",
209-
"}"), ""),`
210-
: ''
211-
}
212-
176+
${
177+
isEnrichPolicyExists
178+
? `| ENRICH ${enrichPolicyName} ON actor.entity.id WITH actorEntityName = entity.name, actorEntityType = entity.type
179+
| ENRICH ${enrichPolicyName} ON target.entity.id WITH targetEntityName = entity.name, targetEntityType = entity.type
213180
// Contact actor and target entities data
214181
| EVAL actorDocData = CONCAT("{",
215182
"\\"id\\":\\"", actor.entity.id, "\\"",
@@ -226,56 +193,51 @@ const buildEsqlQuery = ({
226193
"\\"name\\":\\"", targetEntityName, "\\"",
227194
",\\"type\\":\\"", targetEntityType, "\\"",
228195
"}",
229-
"}")
230-
231-
| STATS badge = COUNT(*),
232-
docs = VALUES(docData),
233-
actorsDocData = VALUES(actorDocData),
234-
targetsDocData = VALUES(targetDocData),
235-
isAlert = MV_MAX(VALUES(isAlert))
236-
BY actorIds = actor.entity.id,
237-
action = event.action,
238-
targetIds = target.entity.id,
239-
isOrigin,
240-
isOriginAlert
241-
242-
| LIMIT 1000
243-
| SORT isOrigin DESC, action`;
244-
} else {
245-
// Query WITHOUT entity enrichment - simpler case
246-
return `FROM ${formattedIndexPatterns} METADATA _id, _index
247-
248-
| WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL
196+
"}")`
197+
: `| EVAL actorDocData = TO_STRING(null)
198+
| EVAL targetDocData = TO_STRING(null)`
199+
}
249200
// Origin event and alerts allow us to identify the start position of graph traversal
250-
| EVAL isOrigin = ${originEventClause}
251-
| EVAL isOriginAlert = isOrigin AND ${originAlertClause}
252-
253-
// Aggregate document's data for popover expansion and metadata enhancements
201+
| EVAL isOrigin = ${
202+
originEventIds.length > 0
203+
? `event.id in (${originEventIds.map((_id, idx) => `?og_id${idx}`).join(', ')})`
204+
: 'false'
205+
}
206+
| EVAL isOriginAlert = isOrigin AND ${
207+
originAlertIds.length > 0
208+
? `event.id in (${originAlertIds.map((_id, idx) => `?og_alrt_id${idx}`).join(', ')})`
209+
: 'false'
210+
}
254211
| EVAL isAlert = _index LIKE "*${SECURITY_ALERTS_PARTIAL_IDENTIFIER}*"
212+
// Aggregate document's data for popover expansion and metadata enhancements
213+
// We format it as JSON string, the best alternative so far. Tried to use tuple using MV_APPEND
214+
// but it flattens the data and we lose the structure
255215
| EVAL docType = CASE (isAlert, "${DOCUMENT_TYPE_ALERT}", "${DOCUMENT_TYPE_EVENT}")
256216
| EVAL docData = CONCAT("{",
257217
"\\"id\\":\\"", _id, "\\"",
258218
",\\"type\\":\\"", docType, "\\"",
259219
",\\"index\\":\\"", _index, "\\"",
260-
"}")
261220
${
221+
// ESQL complains about missing field's mapping when we don't fetch from alerts index
262222
alertsMappingsIncluded
263223
? `CASE (isAlert, CONCAT(",\\"alert\\":", "{",
264224
"\\"ruleName\\":\\"", kibana.alert.rule.name, "\\"",
265225
"}"), ""),`
266226
: ''
267227
}
268-
228+
"}")
269229
| STATS badge = COUNT(*),
270230
docs = VALUES(docData),
231+
actorsDocData = VALUES(actorDocData),
232+
targetsDocData = VALUES(targetDocData),
271233
isAlert = MV_MAX(VALUES(isAlert))
272234
BY actorIds = actor.entity.id,
273235
action = event.action,
274236
targetIds = target.entity.id,
275237
isOrigin,
276238
isOriginAlert
277-
278239
| LIMIT 1000
279-
| SORT isOrigin DESC, action`;
280-
}
240+
| SORT isOrigin DESC, action, actorIds`;
241+
242+
return query;
281243
};

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ describe('getGraph', () => {
9292

9393
expect(fetchGraph).toHaveBeenCalledWith(
9494
expect.objectContaining({
95-
indexPatterns: ['logs-*'],
95+
indexPatterns: [`.alerts-security.alerts-defaultSpace`, 'logs-*'],
9696
})
9797
);
9898
});

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const getGraph = async ({
3636
showUnknownTarget,
3737
nodesLimit,
3838
}: GetGraphParams): Promise<Pick<GraphResponse, 'nodes' | 'edges' | 'messages'>> => {
39-
indexPatterns = indexPatterns ?? ['logs-*'];
39+
indexPatterns = indexPatterns ?? [`.alerts-security.alerts-${spaceId}`, 'logs-*'];
4040

4141
logger.trace(
4242
`Fetching graph for [originEventIds: ${originEventIds.join(

0 commit comments

Comments
 (0)