Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a420be2
Pin entities
albertoblaz Feb 2, 2026
3dd196b
Merge branch 'main' into graph-ungroup
albertoblaz Feb 3, 2026
88fb679
Extend API to pin events/alerts
albertoblaz Feb 3, 2026
02d0607
Add a max limit to pinnedIds
albertoblaz Feb 4, 2026
22c50a4
Merge branch 'main' into graph-ungroup
albertoblaz Feb 4, 2026
9a635bb
Encapsulate ESQL helper
albertoblaz Feb 4, 2026
073c9b2
Replace event.id with _id
albertoblaz Feb 4, 2026
f957495
Merge branch 'main' into graph-ungroup
albertoblaz Feb 4, 2026
c15f8ec
Merge branch 'main' into graph-ungroup
albertoblaz Feb 9, 2026
daaaf90
Merge branch 'main' into graph-ungroup
albertoblaz Feb 10, 2026
dc28d49
Refine comment
albertoblaz Feb 10, 2026
506c58c
Set pinnedIds maxSize to GRAPH_NODES_LIMIT
albertoblaz Feb 10, 2026
bf53bcf
Refactor search_filters: get rid of flatten
albertoblaz Feb 10, 2026
fba883f
Revert "Set pinnedIds maxSize to GRAPH_NODES_LIMIT"
albertoblaz Feb 11, 2026
4849924
Set API-only limit for pinnedIds
albertoblaz Feb 11, 2026
1bd8bc0
Pin only filtered actor/target entities, not events
albertoblaz Feb 11, 2026
502894d
Move buildPinnedEsql back to fetch_graph.ts
albertoblaz Feb 11, 2026
104c2f7
Show pinned elements first
albertoblaz Feb 11, 2026
b24b6da
Add API tests for pinning
albertoblaz Feb 11, 2026
6bb04bc
Clean-up previously created spaces
albertoblaz Feb 11, 2026
0550c0b
Merge branch 'main' into graph-ungroup
albertoblaz Feb 11, 2026
00bf4a7
Delete unused `DOC_ID`
albertoblaz Feb 12, 2026
f4af09c
Merge remote-tracking branch 'upstream/main' into graph-ungroup
albertoblaz Feb 19, 2026
a269bbe
Fix FTR tests flakiness when adding filters
albertoblaz Feb 19, 2026
c7fbe32
Move pinnedIds inside query field
albertoblaz Feb 19, 2026
abe1c09
Sort events by action, pinning, isOrigin
albertoblaz Feb 19, 2026
90fd0f4
Merge branch 'main' into graph-ungroup
albertoblaz Feb 19, 2026
652f449
Fix type issues
albertoblaz Feb 19, 2026
2f51c5a
Merge branch 'main' into graph-ungroup
albertoblaz Feb 19, 2026
e0c0bdd
Fix tests after refactor
albertoblaz Feb 19, 2026
5a4f749
Merge branch 'main' into graph-ungroup
albertoblaz Feb 19, 2026
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 @@ -12,6 +12,7 @@
export const graphRequestSchema = schema.object({
nodesLimit: schema.maybe(schema.number()),
showUnknownTarget: schema.maybe(schema.boolean()),
pinnedEntityIds: schema.maybe(schema.arrayOf(schema.string())),
query: schema.object({
originEventIds: schema.arrayOf(
schema.object({ id: schema.string(), isAlert: schema.boolean() })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { Panel } from '@xyflow/react';
import { getEsQueryConfig } from '@kbn/data-service';
import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui';
import useSessionStorage from 'react-use/lib/useSessionStorage';
import {
GRAPH_ACTOR_ENTITY_FIELDS,
GRAPH_TARGET_ENTITY_FIELDS,
} from '@kbn/cloud-security-posture-common/constants';
import { Graph, isEntityNode, type NodeProps } from '../../..';
import { Callout } from '../callout/callout';
import { type UseFetchGraphDataParams, useFetchGraphData } from '../../hooks/use_fetch_graph_data';
Expand All @@ -30,7 +34,11 @@ import { analyzeDocuments } from '../node/label_node/analyze_documents';
import { EVENT_ID, GRAPH_NODES_LIMIT, TOGGLE_SEARCH_BAR_STORAGE_KEY } from '../../common/constants';
import { Actions } from '../controls/actions';
import { AnimatedSearchBarContainer, useBorder } from './styles';
import { CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER, addFilter } from './search_filters';
import {
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
addFilter,
getFilterValues,
} from './search_filters';
import { useEntityNodeExpandPopover } from '../popovers/node_expand/use_entity_node_expand_popover';
import { useLabelNodeExpandPopover } from '../popovers/node_expand/use_label_node_expand_popover';
import type { NodeViewModel } from '../types';
Expand Down Expand Up @@ -284,6 +292,13 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
return lastValidEsQuery.current;
}, [dataView, kquery, notifications, searchFilters, uiSettings]);

const pinnedEntities = useMemo(() => {
return getFilterValues(searchFilters, [
...GRAPH_ACTOR_ENTITY_FIELDS,
...GRAPH_TARGET_ENTITY_FIELDS,
]).map(String);
Comment on lines +296 to +299
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reads all kinds of entity IDs from applied filters (user.entity.id, service.target.entity.id, ...). Reads also from event.id, though we might read from _id instead. @kfirpeled WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed to _id

Copy link
Contributor

Choose a reason for hiding this comment

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

It is a nice workaround, but it is a workaround.
Eventually, we wish to manage state of list of nodes that are pinned

Since we don't have design yet for pinning an event. From your work here, I assume that if we had the ability to pin an event/alert we will add a filter of _id: eventDocId/alertDocId

That filter is really adds anything to the graph, except for pinning the node. So that's why it is a workaround for a missing pinning capability.

Just be aware that we haven't discussed it yet, to add a filter in order to pin a node. While it is true for actors and targets, it is not true for alert and event.

We previously did it for POC purposes, but it wasn't a requirement

}, [searchFilters]);

const { data, refresh, isFetching, isError, error } = useFetchGraphData({
req: {
query: {
Expand All @@ -294,6 +309,7 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
end: timeRange.to,
},
nodesLimit: GRAPH_NODES_LIMIT,
pinnedEntityIds: pinnedEntities,
},
options: {
refetchOnWindowFocus: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
addFilter,
containsFilter,
removeFilter,
getFilterValues,
} from './search_filters';

const dataViewId = 'test-data-view';
Expand Down Expand Up @@ -303,4 +304,72 @@ describe('search_filters', () => {
expect(newFilters[1].meta.params).toHaveLength(2);
});
});

describe('getFilterValues', () => {
it('should return empty array for empty filters', () => {
const filters: Filter[] = [];

expect(getFilterValues(filters, key)).toEqual([]);
});

it('should return value from phrase filter with matching key', () => {
const filters: Filter[] = [buildFilterMock(key, value)];

expect(getFilterValues(filters, key)).toEqual([value]);
});

it('should return empty array when key does not match', () => {
const filters: Filter[] = [buildFilterMock('other-key', value)];

expect(getFilterValues(filters, key)).toEqual([]);
});

it('should return values from combined filter', () => {
const filters: Filter[] = [
buildCombinedFilterMock([buildFilterMock(key, 'value1'), buildFilterMock(key, 'value2')]),
];

expect(getFilterValues(filters, key)).toEqual(['value1', 'value2']);
});

it('should skip disabled filters', () => {
const disabledFilter = buildFilterMock(key, value);
disabledFilter.meta.disabled = true;
const filters: Filter[] = [disabledFilter, buildFilterMock(key, 'enabled-value')];

expect(getFilterValues(filters, key)).toEqual(['enabled-value']);
});

it('should handle multiple keys', () => {
const filters: Filter[] = [
buildFilterMock('key1', 'value1'),
buildFilterMock('key2', 'value2'),
buildFilterMock('key3', 'value3'),
];

expect(getFilterValues(filters, ['key1', 'key2'])).toEqual(['value1', 'value2']);
});

it('should return values from mixed phrase and combined filters', () => {
const filters: Filter[] = [
buildFilterMock(key, 'phrase-value'),
buildCombinedFilterMock([
buildFilterMock(key, 'combined-value1'),
buildFilterMock('other-key', 'other-value'),
]),
];

expect(getFilterValues(filters, key)).toEqual(['phrase-value', 'combined-value1']);
});

it('should handle readonly string array for keys', () => {
const readonlyKeys = ['key1', 'key2'] as const;
const filters: Filter[] = [
buildFilterMock('key1', 'value1'),
buildFilterMock('key2', 'value2'),
];

expect(getFilterValues(filters, readonlyKeys)).toEqual(['value1', 'value2']);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* 2.0.
*/

import { flatten } from 'lodash';
import {
BooleanRelation,
FilterStateStore,
Expand All @@ -16,6 +17,7 @@ import type { Filter, PhraseFilter } from '@kbn/es-query';
import type {
CombinedFilter,
PhraseFilterMetaParams,
PhraseFilterValue,
} from '@kbn/es-query/src/filters/build_filters';

export const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation';
Expand Down Expand Up @@ -167,3 +169,47 @@ export const removeFilter = (filters: Filter[], key: string, value: string) => {

return filters;
};

/**
* Helper function to extract filter value(s) from a single filter.
* Handles both simple phrase filters and combined filters recursively.
*/
const getFilterValue = (
filter: Filter,
keys: string[]
): PhraseFilterValue[] | PhraseFilterValue | null => {
if (isCombinedFilter(filter)) {
return flatten(
filter.meta.params
.map((param) => getFilterValue(param, keys))
.filter((value): value is PhraseFilterValue | PhraseFilterValue[] => value !== null)
);
}

return filter.meta.key && keys.includes(filter.meta.key)
? (filter.meta.params as PhraseFilterMetaParams)?.query
: null;
};

/**
* Extracts all values from filters that match the specified keys.
* Handles both simple phrase filters and combined filters.
* Skips disabled filters.
*
* @param filters - The list of filters to extract values from.
* @param key - The key or array of keys to match against filter keys.
* @returns An array of all values from matching filters.
*/
export const getFilterValues = (
filters: Filter[],
key: string | readonly string[]
): PhraseFilterValue[] => {
const keys = Array.isArray(key) ? key : [key];

return flatten(
filters
.filter((filter) => !filter.meta.disabled)
.map((filter) => getFilterValue(filter, keys as string[]))
.filter((value): value is PhraseFilterValue | PhraseFilterValue[] => value !== null)
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,14 @@ export const useFetchGraphData = ({
options,
}: UseFetchGraphDataParams): UseFetchGraphDataResult => {
const queryClient = useQueryClient();
const { pinnedEntityIds } = req;
const { esQuery, originEventIds, start, end } = req.query;
const {
services: { http },
} = useKibana();
const QUERY_KEY = useMemo(
() => ['useFetchGraphData', originEventIds, start, end, esQuery],
[end, esQuery, originEventIds, start]
() => ['useFetchGraphData', originEventIds, start, end, esQuery, pinnedEntityIds],
[end, esQuery, originEventIds, start, pinnedEntityIds]
);

const { isLoading, isError, data, isFetching, error } = useQuery<GraphResponse>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,90 @@ describe('fetchGraph', () => {
expect(hasTargetCheck).toBe(false);
});
});

describe('Pinned entity IDs', () => {
it('should include pinned entity parameters when pinnedEntityIds are provided', async () => {
const pinnedEntityIds = ['entity-1', 'entity-2'];
const validIndexPatterns = ['valid_index'];
const params = {
esClient,
logger,
start: 0,
end: 1000,
originEventIds: [] as OriginEventId[],
showUnknownTarget: false,
indexPatterns: validIndexPatterns,
spaceId: 'default',
esQuery: undefined as EsQuery | undefined,
pinnedEntityIds,
};

await fetchGraph(params);

expect(esClient.asCurrentUser.helpers.esql).toBeCalledTimes(1);
const esqlCallArgs = esClient.asCurrentUser.helpers.esql.mock.calls[0];
const query = esqlCallArgs[0].query;

// Verify EVAL pinned CASE statement is present
expect(query).toContain('EVAL pinned = CASE(');
expect(query).toContain('actorEntityId IN (?pinned_id0, ?pinned_id1)');
expect(query).toContain('targetEntityId IN (?pinned_id0, ?pinned_id1)');
});

it('should use null fallback for pinned when no pinnedEntityIds are provided', async () => {
const validIndexPatterns = ['valid_index'];
const params = {
esClient,
logger,
start: 0,
end: 1000,
originEventIds: [] as OriginEventId[],
showUnknownTarget: false,
indexPatterns: validIndexPatterns,
spaceId: 'default',
esQuery: undefined as EsQuery | undefined,
pinnedEntityIds: undefined,
};

await fetchGraph(params);

expect(esClient.asCurrentUser.helpers.esql).toBeCalledTimes(1);
const esqlCallArgs = esClient.asCurrentUser.helpers.esql.mock.calls[0];
const query = esqlCallArgs[0].query;

// Verify fallback EVAL pinned = TO_STRING(null) is present
expect(query).toContain('EVAL pinned = TO_STRING(null)');

// Verify no pinned_id params
const pinnedParams = esqlCallArgs[0].params
// @ts-ignore: field is typed as Record<string, string>[]
?.filter((p) => Object.keys(p)[0]?.startsWith('pinned_id'));
expect(pinnedParams).toHaveLength(0);
});

it('should use null fallback for pinned when pinnedEntityIds is empty array', async () => {
const validIndexPatterns = ['valid_index'];
const params = {
esClient,
logger,
start: 0,
end: 1000,
originEventIds: [] as OriginEventId[],
showUnknownTarget: false,
indexPatterns: validIndexPatterns,
spaceId: 'default',
esQuery: undefined as EsQuery | undefined,
pinnedEntityIds: [],
};

await fetchGraph(params);

expect(esClient.asCurrentUser.helpers.esql).toBeCalledTimes(1);
const esqlCallArgs = esClient.asCurrentUser.helpers.esql.mock.calls[0];
const query = esqlCallArgs[0].query;

// Verify fallback EVAL pinned = TO_STRING(null) is present
expect(query).toContain('EVAL pinned = TO_STRING(null)');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface BuildEsqlQueryParams {
isEnrichPolicyExists: boolean;
spaceId: string;
alertsMappingsIncluded: boolean;
pinnedEntityIds?: string[];
}

export const fetchGraph = async ({
Expand All @@ -51,6 +52,7 @@ export const fetchGraph = async ({
indexPatterns,
spaceId,
esQuery,
pinnedEntityIds,
}: {
esClient: IScopedClusterClient;
logger: Logger;
Expand All @@ -61,6 +63,7 @@ export const fetchGraph = async ({
indexPatterns: string[];
spaceId: string;
esQuery?: EsQuery;
pinnedEntityIds?: string[];
}): Promise<EsqlToRecords<GraphEdge>> => {
const originAlertIds = originEventIds.filter((originEventId) => originEventId.isAlert);

Expand Down Expand Up @@ -93,6 +96,7 @@ export const fetchGraph = async ({
isEnrichPolicyExists,
spaceId,
alertsMappingsIncluded,
pinnedEntityIds,
});

logger.trace(`Executing query [${query}]`);
Expand All @@ -109,6 +113,7 @@ export const fetchGraph = async ({
...originEventIds
.filter((originEventId) => originEventId.isAlert)
.map((originEventId, idx) => ({ [`og_alrt_id${idx}`]: originEventId.id })),
...(pinnedEntityIds ?? []).map((id, idx) => ({ [`pinned_id${idx}`]: id })),
],
})
.toRecords<GraphEdge>();
Expand Down Expand Up @@ -282,6 +287,7 @@ const buildEsqlQuery = ({
isEnrichPolicyExists,
spaceId,
alertsMappingsIncluded,
pinnedEntityIds,
}: BuildEsqlQueryParams): string => {
const SECURITY_ALERTS_PARTIAL_IDENTIFIER = '.alerts-security.alerts-';
const enrichPolicyName = getEnrichPolicyId(spaceId);
Expand Down Expand Up @@ -312,6 +318,21 @@ const buildEsqlQuery = ({
const actorFieldHintCases = generateFieldHintCases(GRAPH_ACTOR_ENTITY_FIELDS, 'actorEntityId');
const targetFieldHintCases = generateFieldHintCases(GRAPH_TARGET_ENTITY_FIELDS, 'targetEntityId');

// Generate pinned entity evaluation
// This checks if the current actor or target entity ID matches any of the pinned entity IDs
const pinnedEntityEval =
pinnedEntityIds && pinnedEntityIds.length > 0
? `| EVAL pinned = CASE(
actorEntityId IN (${pinnedEntityIds
.map((_id, idx) => `?pinned_id${idx}`)
.join(', ')}), actorEntityId,
targetEntityId IN (${pinnedEntityIds
.map((_id, idx) => `?pinned_id${idx}`)
.join(', ')}), targetEntityId,
null
)`
: '| EVAL pinned = TO_STRING(null)';
Copy link
Contributor

Choose a reason for hiding this comment

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

why TO_STRING?


const query = `FROM ${indexPatterns
.filter((indexPattern) => indexPattern.length > 0)
.join(',')} METADATA _id, _index
Expand All @@ -325,6 +346,7 @@ const buildEsqlQuery = ({
${targetEntityIdEvals}
| MV_EXPAND actorEntityId
| MV_EXPAND targetEntityId
${pinnedEntityEval}
| EVAL actorEntityFieldHint = CASE(
${actorFieldHintCases},
""
Expand Down Expand Up @@ -452,7 +474,8 @@ ${buildEnrichedEntityFieldsEsql()}
targetEntityType,
targetEntitySubType,
isOrigin,
isOriginAlert
isOriginAlert,
pinned
| LIMIT 1000
| SORT action DESC, isOrigin`;

Expand Down
Loading
Loading