Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8a24d97
Added mapping for actor and target entity id, related.entity
kfirpeled Jun 18, 2025
c3bb2e7
actions borealis theme fix - search button badge number layout
kfirpeled Jun 19, 2025
9214242
Add index pattern support to the API
kfirpeled Jun 22, 2025
48a0cb0
Merge branch 'main' into cspm/related-alert-support
kfirpeled Jun 27, 2025
606f051
Merge branch 'main' into cspm/related-alert-support
kfirpeled Jun 29, 2025
ed9205b
Added UT and E2E tests
kfirpeled Jun 29, 2025
92a7117
loadIfNeeded
kfirpeled Jun 29, 2025
caa5b25
typecheck fix
kfirpeled Jun 29, 2025
8e82b5c
fixed lint issue
kfirpeled Jun 29, 2025
460ab62
fix e2e tests
kfirpeled Jun 29, 2025
b6e9c7b
Merge branch 'main' into cspm/related-alert-support
kfirpeled Jul 5, 2025
1e484e4
Merge branch 'main' into cspm/related-alert-support
kfirpeled Jul 7, 2025
356fea4
Merge branch 'main' into cspm/related-alert-support
kfirpeled Jul 15, 2025
c4d5f57
Merge branch 'main' into cspm/related-alert-support
kfirpeled Jul 16, 2025
f379f27
Merge remote-tracking branch 'origin' into cspm/related-alert-support
kfirpeled Jul 27, 2025
2be5e57
Merge branch 'main' into cspm/related-alert-support
kfirpeled Aug 7, 2025
a57c3a9
Revert "Added mapping for actor and target entity id, related.entity"
kfirpeled Aug 7, 2025
f2ddbd7
removed previous unload
kfirpeled Aug 7, 2025
aaed013
minor fixes
kfirpeled Aug 7, 2025
8e14441
Merge branch 'main' into cspm/related-alert-support
kfirpeled Aug 7, 2025
664dfa1
refactored buildEsqlQuery
kfirpeled Aug 7, 2025
7dc5aec
fixed UT
kfirpeled Aug 7, 2025
cf31f60
fixing tests
kfirpeled Aug 8, 2025
200734a
fixing tests
kfirpeled Aug 8, 2025
d94c2e0
added tests when mappings are missing
kfirpeled Aug 8, 2025
b5d2fff
Merge branch 'main' into cspm/related-alert-support
kfirpeled Aug 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export interface GraphInvestigationProps {
* The initial state to use for the graph investigation view.
*/
initialState: {
/**
* The index patterns to use for the graph investigation view.
*/
indexPatterns?: string[];

/**
* The data view to use for the graph investigation view.
*/
Expand Down Expand Up @@ -145,7 +150,7 @@ type EsQuery = UseFetchGraphDataParams['req']['query']['esQuery'];
*/
export const GraphInvestigation = memo<GraphInvestigationProps>(
({
initialState: { dataView, originEventIds, timeRange: initialTimeRange },
initialState: { indexPatterns, dataView, originEventIds, timeRange: initialTimeRange },
showInvestigateInTimeline = false,
showToggleSearch = false,
onInvestigateInTimeline,
Expand Down Expand Up @@ -211,6 +216,7 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
req: {
query: {
originEventIds,
indexPatterns,
esQuery,
start: timeRange.from,
end: timeRange.to,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ export type {
EntityNodeViewModel,
NodeProps,
} from './types';
export { isEntityNode, getNodeDocumentMode, hasNodeDocumentsData } from './utils';
export {
isEntityNode,
getNodeDocumentMode,
hasNodeDocumentsData,
getSingleDocumentData,
} from './utils';
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export const isEntityNode = (node: NodeViewModel) =>
export const isStackedLabel = (node: NodeViewModel): boolean =>
!(node.shape === 'label' && Boolean(node.parentId));

/**
* Type guard: Returns true if node.documentsData is a non-empty array.
* This only narrows node.documentsData to a non-empty array, not to a specific document type.
*/
export const hasNodeDocumentsData = (
node: NodeViewModel
): node is NodeViewModel & {
documentsData: [NodeDocumentDataViewModel, ...NodeDocumentDataViewModel[]];
} => {
return Array.isArray(node.documentsData) && node.documentsData.length > 0;
};

/**
* Returns the node document mode, or 'na' if documentsData is missing or empty.
* When this function returns a value other than 'na', documentsData is guaranteed to be a non-empty array.
Expand All @@ -41,7 +53,7 @@ export const getNodeDocumentMode = (
}

// Single alert contains both event's document data and alert's document data.
if (node.documentsData.find((doc) => doc.type === 'alert') && node.documentsData.length < 2) {
if (node.documentsData.find((doc) => doc.type === 'alert') && node.documentsData.length <= 2) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kfirpeled why did you change it to <=2?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in a single alert use case, you can have a correlated event that the alert was triggered upon. So eventually you will have 2 documents data

return 'single-alert';
} else if (node.documentsData.length === 1 && node.documentsData[0].type === 'event') {
return 'single-event';
Expand All @@ -57,14 +69,24 @@ export const getNodeDocumentMode = (
};

/**
* Type guard: Returns true if node.documentsData is a non-empty array.
* This only narrows node.documentsData to a non-empty array, not to a specific document type.
* Returns the single document data for a node if it is in single-* mode.
* If the node is not in one of these modes, or if it has no documentsData, it returns undefined.
*/
export function hasNodeDocumentsData(node: NodeViewModel): node is NodeViewModel & {
documentsData: [NodeDocumentDataViewModel, ...NodeDocumentDataViewModel[]];
} {
return Array.isArray(node.documentsData) && node.documentsData.length > 0;
}
export const getSingleDocumentData = (
node: NodeViewModel
): NodeDocumentDataViewModel | undefined => {
const mode = getNodeDocumentMode(node);
if (!hasNodeDocumentsData(node) || (mode !== 'single-alert' && mode !== 'single-event')) {
return undefined;
}

// For single-alert we might have both event and alert documents. We prefer to return the alert document if it exists.
const documentData =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we are going to store in the documentsData array the event itself which we get from the log-* and also the alert we found in the alerts-* index?
What is the id of the node going to be? the event id we constructed a(something)-b(something1)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we are going to store in the documentsData array the event itself which we get from the log-* and also the alert we found in the alerts-* index?

correct

What is the id of the node going to be? the event id we constructed a(something)-b(something1)?

correct, that wasn't changed as part of this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-08-10 at 15 37 37

node.documentsData.find((doc) => doc.type === 'alert') ??
node.documentsData.find((doc) => doc.type === 'event');

return documentData;
};

const FETCH_GRAPH_FAILED_TEXT = i18n.translate(
'securitySolutionPackages.csp.graph.investigation.errorFetchingGraphData',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,10 @@ describe('fetchGraph', () => {
`WITH targetEntityName = entity.name, targetEntityType = entity.type, targetSourceIndex = entity.source`
);

expect(query).not.toContain(`actorsDocData = VALUES(actorDocData)`);
expect(query).not.toContain(`targetsDocData = VALUES(targetDocData)`);
expect(query).toContain(`EVAL actorDocData = TO_STRING(null)`);
expect(query).toContain(`EVAL targetDocData = TO_STRING(null)`);
expect(query).toContain(`actorsDocData = VALUES(actorDocData)`);
expect(query).toContain(`targetsDocData = VALUES(targetDocData)`);
expect(result).toEqual([{ id: 'dummy' }]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface BuildEsqlQueryParams {
originEventIds: OriginEventId[];
originAlertIds: OriginEventId[];
isEnrichPolicyExists: boolean;
enrichPolicyName: string;
spaceId: string;
alertsMappingsIncluded: boolean;
}

Expand All @@ -46,6 +46,7 @@ export const fetchGraph = async ({
esQuery?: EsQuery;
}): Promise<EsqlToRecords<GraphEdge>> => {
const originAlertIds = originEventIds.filter((originEventId) => originEventId.isAlert);

// FROM clause currently doesn't support parameters, Therefore, we validate the index patterns to prevent injection attacks.
// Regex to match invalid characters in index patterns: upper case characters, \, /, ?, ", <, >, |, (space), #, or ,
indexPatterns.forEach((indexPattern, idx) => {
Expand All @@ -68,7 +69,7 @@ export const fetchGraph = async ({
originEventIds,
originAlertIds,
isEnrichPolicyExists,
enrichPolicyName: getEnrichPolicyId(spaceId),
spaceId,
alertsMappingsIncluded,
});

Expand Down Expand Up @@ -162,54 +163,20 @@ const buildEsqlQuery = ({
originEventIds,
originAlertIds,
isEnrichPolicyExists,
enrichPolicyName,
spaceId,
alertsMappingsIncluded,
}: BuildEsqlQueryParams): string => {
const SECURITY_ALERTS_PARTIAL_IDENTIFIER = '.alerts-security.alerts-';
const enrichPolicyName = getEnrichPolicyId(spaceId);

const originEventClause =
originEventIds.length > 0
? `event.id in (${originEventIds.map((_id, idx) => `?og_id${idx}`).join(', ')})`
: 'false';

const originAlertClause =
originAlertIds.length > 0
? `event.id in (${originAlertIds.map((_id, idx) => `?og_alrt_id${idx}`).join(', ')})`
: 'false';

const formattedIndexPatterns = indexPatterns
const query = `FROM ${indexPatterns
.filter((indexPattern) => indexPattern.length > 0)
.join(',');

if (isEnrichPolicyExists) {
return `FROM ${formattedIndexPatterns} METADATA _id, _index

| ENRICH ${enrichPolicyName} ON actor.entity.id WITH actorEntityName = entity.name, actorEntityType = entity.type
| ENRICH ${enrichPolicyName} ON target.entity.id WITH targetEntityName = entity.name, targetEntityType = entity.type

.join(',')} METADATA _id, _index
| WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL
// Origin event and alerts allow us to identify the start position of graph traversal
| EVAL isOrigin = ${originEventClause}
| EVAL isOriginAlert = isOrigin AND ${originAlertClause}

// We format it as JSON string, the best alternative so far. Tried to use tuple using MV_APPEND
// but it flattens the data and we lose the structure
// Aggregate document's data for popover expansion and metadata enhancements
| EVAL isAlert = _index LIKE "*${SECURITY_ALERTS_PARTIAL_IDENTIFIER}*"
| EVAL docType = CASE (isAlert, "${DOCUMENT_TYPE_ALERT}", "${DOCUMENT_TYPE_EVENT}")
| EVAL docData = CONCAT("{",
"\\"id\\":\\"", _id, "\\"",
",\\"type\\":\\"", docType, "\\"",
",\\"index\\":\\"", _index, "\\"",
"}")
${
alertsMappingsIncluded
? `CASE (isAlert, CONCAT(",\\"alert\\":", "{",
"\\"ruleName\\":\\"", kibana.alert.rule.name, "\\"",
"}"), ""),`
: ''
}

${
isEnrichPolicyExists
? `| ENRICH ${enrichPolicyName} ON actor.entity.id WITH actorEntityName = entity.name, actorEntityType = entity.type
| ENRICH ${enrichPolicyName} ON target.entity.id WITH targetEntityName = entity.name, targetEntityType = entity.type
// Contact actor and target entities data
| EVAL actorDocData = CONCAT("{",
"\\"id\\":\\"", actor.entity.id, "\\"",
Expand All @@ -226,56 +193,51 @@ const buildEsqlQuery = ({
"\\"name\\":\\"", targetEntityName, "\\"",
",\\"type\\":\\"", targetEntityType, "\\"",
"}",
"}")

| STATS badge = COUNT(*),
docs = VALUES(docData),
actorsDocData = VALUES(actorDocData),
targetsDocData = VALUES(targetDocData),
isAlert = MV_MAX(VALUES(isAlert))
BY actorIds = actor.entity.id,
action = event.action,
targetIds = target.entity.id,
isOrigin,
isOriginAlert

| LIMIT 1000
| SORT isOrigin DESC, action`;
} else {
// Query WITHOUT entity enrichment - simpler case
return `FROM ${formattedIndexPatterns} METADATA _id, _index

| WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL
"}")`
: `| EVAL actorDocData = TO_STRING(null)
| EVAL targetDocData = TO_STRING(null)`
}
// Origin event and alerts allow us to identify the start position of graph traversal
| EVAL isOrigin = ${originEventClause}
| EVAL isOriginAlert = isOrigin AND ${originAlertClause}

// Aggregate document's data for popover expansion and metadata enhancements
| EVAL isOrigin = ${
originEventIds.length > 0
? `event.id in (${originEventIds.map((_id, idx) => `?og_id${idx}`).join(', ')})`
: 'false'
}
| EVAL isOriginAlert = isOrigin AND ${
originAlertIds.length > 0
? `event.id in (${originAlertIds.map((_id, idx) => `?og_alrt_id${idx}`).join(', ')})`
: 'false'
}
| EVAL isAlert = _index LIKE "*${SECURITY_ALERTS_PARTIAL_IDENTIFIER}*"
// Aggregate document's data for popover expansion and metadata enhancements
// We format it as JSON string, the best alternative so far. Tried to use tuple using MV_APPEND
// but it flattens the data and we lose the structure
| EVAL docType = CASE (isAlert, "${DOCUMENT_TYPE_ALERT}", "${DOCUMENT_TYPE_EVENT}")
| EVAL docData = CONCAT("{",
"\\"id\\":\\"", _id, "\\"",
",\\"type\\":\\"", docType, "\\"",
",\\"index\\":\\"", _index, "\\"",
"}")
${
// ESQL complains about missing field's mapping when we don't fetch from alerts index
alertsMappingsIncluded
? `CASE (isAlert, CONCAT(",\\"alert\\":", "{",
"\\"ruleName\\":\\"", kibana.alert.rule.name, "\\"",
"}"), ""),`
: ''
}

"}")
| STATS badge = COUNT(*),
docs = VALUES(docData),
actorsDocData = VALUES(actorDocData),
targetsDocData = VALUES(targetDocData),
isAlert = MV_MAX(VALUES(isAlert))
BY actorIds = actor.entity.id,
action = event.action,
targetIds = target.entity.id,
isOrigin,
isOriginAlert

| LIMIT 1000
| SORT isOrigin DESC, action`;
}
| SORT isOrigin DESC, action, actorIds`;

return query;
};
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('getGraph', () => {

expect(fetchGraph).toHaveBeenCalledWith(
expect.objectContaining({
indexPatterns: ['logs-*'],
indexPatterns: [`.alerts-security.alerts-defaultSpace`, 'logs-*'],
})
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const getGraph = async ({
showUnknownTarget,
nodesLimit,
}: GetGraphParams): Promise<Pick<GraphResponse, 'nodes' | 'edges' | 'messages'>> => {
indexPatterns = indexPatterns ?? ['logs-*'];
indexPatterns = indexPatterns ?? [`.alerts-security.alerts-${spaceId}`, 'logs-*'];

logger.trace(
`Fetching graph for [originEventIds: ${originEventIds.join(
Expand Down
Loading