From c8858b9b92884b507c89ed3758bfaed44ff0ac06 Mon Sep 17 00:00:00 2001 From: Agustina Nahir Ruidiaz Date: Mon, 30 Mar 2026 15:45:29 +0200 Subject: [PATCH 1/3] improve timeline export --- .../export_timelines_route.gen.ts | 2 +- .../export_timelines_route.schema.yaml | 2 + .../export_timelines/helpers.test.ts | 80 +++++++++++++++++++ .../timelines/export_timelines/helpers.ts | 54 ++++++++----- .../timelines/export_timelines/index.test.ts | 56 ++++++++++++- .../timelines/export_timelines/index.ts | 6 +- .../timeline/tests/timeline_privileges.ts | 57 +++++++++++++ 7 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.test.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.gen.ts index a49cff33089ea..4d0ebbd7f8c13 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.gen.ts @@ -27,6 +27,6 @@ export type ExportTimelinesRequestQueryInput = z.input; export const ExportTimelinesRequestBody = z.object({ - ids: z.array(z.string()).nullable().optional(), + ids: z.array(z.string()).min(1).max(1000).nullable().optional(), }); export type ExportTimelinesRequestBodyInput = z.input; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.schema.yaml index 24387adf1f2a5..00a7a41684797 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route.schema.yaml @@ -33,6 +33,8 @@ paths: ids: nullable: true type: array + minItems: 1 + maxItems: 1000 items: type: string responses: diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.test.ts new file mode 100644 index 0000000000000..6d9a648fda950 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { FrameworkRequest } from '../../../../framework'; +import type { TimelineResponse } from '../../../../../../common/api/timeline'; +import { getExportTimelineByObjectIds } from './helpers'; +import { getSelectedTimelines } from '../../../saved_object/timelines'; +import * as noteLib from '../../../saved_object/notes'; +import * as pinnedEventLib from '../../../saved_object/pinned_events'; + +jest.mock('../../../saved_object/timelines', () => ({ + getSelectedTimelines: jest.fn(), +})); + +jest.mock('../../../saved_object/notes', () => ({ + getNotesByTimelineId: jest.fn(), +})); + +jest.mock('../../../saved_object/pinned_events', () => ({ + getAllPinnedEventsByTimelineId: jest.fn(), +})); + +describe('export timelines helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('enriches notes and pinned events in bounded batches', async () => { + const timelines = Array.from({ length: 11 }, (_, index) => ({ + savedObjectId: `timeline-${index}`, + status: 'active', + })) as unknown as TimelineResponse[]; + + (getSelectedTimelines as jest.Mock).mockResolvedValue({ + timelines, + errors: [], + }); + + const pendingNotes: Array<() => void> = []; + const pendingPinnedEvents: Array<() => void> = []; + (noteLib.getNotesByTimelineId as jest.Mock).mockImplementation((_, timelineId: string) => { + return new Promise((resolve) => { + pendingNotes.push(() => resolve([{ timelineId }])); + }); + }); + (pinnedEventLib.getAllPinnedEventsByTimelineId as jest.Mock).mockImplementation( + (_, timelineId: string) => { + return new Promise((resolve) => { + pendingPinnedEvents.push(() => resolve([{ timelineId, eventId: `event-${timelineId}` }])); + }); + } + ); + + const exportPromise = getExportTimelineByObjectIds({ + frameworkRequest: {} as FrameworkRequest, + ids: timelines.map((timeline) => timeline.savedObjectId), + }); + + await Promise.resolve(); + + expect(noteLib.getNotesByTimelineId).toHaveBeenCalledTimes(10); + expect(pinnedEventLib.getAllPinnedEventsByTimelineId).toHaveBeenCalledTimes(10); + + pendingNotes.splice(0, 10).forEach((resolveNote) => resolveNote()); + pendingPinnedEvents.splice(0, 10).forEach((resolvePinnedEvent) => resolvePinnedEvent()); + await new Promise(process.nextTick); + + expect(noteLib.getNotesByTimelineId).toHaveBeenCalledTimes(11); + expect(pinnedEventLib.getAllPinnedEventsByTimelineId).toHaveBeenCalledTimes(11); + + pendingNotes.forEach((resolveNote) => resolveNote()); + pendingPinnedEvents.forEach((resolvePinnedEvent) => resolvePinnedEvent()); + + await expect(exportPromise).resolves.toContain('timeline-0'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.ts index d20d06f97b733..30769788e3c4e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/helpers.ts @@ -22,6 +22,8 @@ import * as pinnedEventLib from '../../../saved_object/pinned_events'; import { getSelectedTimelines } from '../../../saved_object/timelines'; +const EXPORT_TIMELINE_ENRICHMENT_BATCH_SIZE = 10; + const getGlobalEventNotesByTimelineId = (currentNotes: Note[]): ExportedNotes => { const initialNotes: ExportedNotes = { eventNotes: [], @@ -42,34 +44,50 @@ const getPinnedEventsIdsByTimelineId = (currentPinnedEvents: PinnedEvent[]): str return currentPinnedEvents.map((event) => event.eventId) ?? []; }; +const getTimelineNotesAndPinnedEvents = async ( + request: FrameworkRequest, + exportedIds: string[] +): Promise<{ notes: Note[]; pinnedEvents: PinnedEvent[] }> => { + const notes: Note[] = []; + const pinnedEvents: PinnedEvent[] = []; + + for (let index = 0; index < exportedIds.length; index += EXPORT_TIMELINE_ENRICHMENT_BATCH_SIZE) { + const timelineIdsBatch = exportedIds.slice( + index, + index + EXPORT_TIMELINE_ENRICHMENT_BATCH_SIZE + ); + const [batchNotes, batchPinnedEvents] = await Promise.all([ + Promise.all( + timelineIdsBatch.map((timelineId) => noteLib.getNotesByTimelineId(request, timelineId)) + ), + Promise.all( + timelineIdsBatch.map((timelineId) => + pinnedEventLib.getAllPinnedEventsByTimelineId(request, timelineId) + ) + ), + ]); + notes.push(...batchNotes.flat()); + pinnedEvents.push(...batchPinnedEvents.flat()); + } + + return { notes, pinnedEvents }; +}; + const getTimelinesFromObjects = async ( request: FrameworkRequest, ids?: string[] | null ): Promise> => { const { timelines, errors } = await getSelectedTimelines(request, ids); const exportedIds = timelines.map((t) => t.savedObjectId); + const timelinesById = new Map(timelines.map((timeline) => [timeline.savedObjectId, timeline])); - const [notes, pinnedEvents] = await Promise.all([ - Promise.all(exportedIds.map((timelineId) => noteLib.getNotesByTimelineId(request, timelineId))), - Promise.all( - exportedIds.map((timelineId) => - pinnedEventLib.getAllPinnedEventsByTimelineId(request, timelineId) - ) - ), - ]); - - const myNotes = notes.reduce((acc, note) => [...acc, ...note], []); - - const myPinnedEventIds = pinnedEvents.reduce( - (acc, pinnedEventId) => [...acc, ...pinnedEventId], - [] - ); + const { notes, pinnedEvents } = await getTimelineNotesAndPinnedEvents(request, exportedIds); const myResponse = exportedIds.reduce((acc, timelineId) => { - const myTimeline = timelines.find((t) => t.savedObjectId === timelineId); + const myTimeline = timelinesById.get(timelineId); if (myTimeline != null) { - const timelineNotes = myNotes.filter((n) => n.timelineId === timelineId); - const timelinePinnedEventIds = myPinnedEventIds.filter((p) => p.timelineId === timelineId); + const timelineNotes = notes.filter((n) => n.timelineId === timelineId); + const timelinePinnedEventIds = pinnedEvents.filter((p) => p.timelineId === timelineId); const exportedTimeline = omit(['status', 'excludedRowRendererIds'], myTimeline); return [ ...acc, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.test.ts index f3697b72133c0..91ab80fb7eefb 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.test.ts @@ -52,6 +52,12 @@ describe('export timelines', () => { let clients: MockClients; let context: SecuritySolutionRequestHandlerContextMock; + const registerRoute = (overrides?: Partial>) => { + const routeConfig = { ...createMockConfig(), ...overrides }; + exportTimelinesRoute(server.router, routeConfig); + return routeConfig; + }; + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -62,7 +68,6 @@ describe('export timelines', () => { (convertSavedObjectToSavedPinnedEvent as unknown as jest.Mock).mockReturnValue( mockPinnedEvents() ); - exportTimelinesRoute(server.router, createMockConfig()); }); afterEach(() => { @@ -72,6 +77,7 @@ describe('export timelines', () => { describe('status codes', () => { test('returns 200 when finding selected timelines', async () => { + registerRoute(); const response = await server.inject( getExportTimelinesRequest(), requestContextMock.convertContext(context) @@ -80,6 +86,7 @@ describe('export timelines', () => { }); test('catch error when status search throws error', async () => { + registerRoute(); clients.savedObjectsClient.bulkGet.mockReset(); clients.savedObjectsClient.bulkGet.mockRejectedValue(new Error('Test error')); const response = await server.inject( @@ -92,10 +99,56 @@ describe('export timelines', () => { status_code: 500, }); }); + + test('returns 400 when requested ids exceed export size limit', async () => { + const config = registerRoute({ maxTimelineImportExportSize: 1 }); + const response = await server.inject( + requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + query: { + file_name: 'mock_export_timeline.ndjson', + }, + body: { + ids: ['id-1', 'id-2'], + }, + }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: `Can't export more than ${config.maxTimelineImportExportSize} timelines`, + status_code: 400, + }); + }); + + test('deduplicates ids before applying export size limit', async () => { + registerRoute({ maxTimelineImportExportSize: 1 }); + const response = await server.inject( + requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + query: { + file_name: 'mock_export_timeline.ndjson', + }, + body: { + ids: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', 'f0e58720-57b6-11ea-b88d-3f1a31716be8'], + }, + }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(clients.savedObjectsClient.bulkGet).toHaveBeenCalledWith([ + { id: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', type: 'siem-ui-timeline' }, + ]); + }); }); describe('request validation', () => { test('return validation error for request body', async () => { + registerRoute(); const request = requestMock.create({ method: 'get', path: TIMELINE_EXPORT_URL, @@ -109,6 +162,7 @@ describe('export timelines', () => { }); test('return validation error for request params', async () => { + registerRoute(); const request = requestMock.create({ method: 'get', path: TIMELINE_EXPORT_URL, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts index 76ea6054fb314..1e1afcadcc153 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts @@ -49,8 +49,10 @@ export const exportTimelinesRoute = (router: SecuritySolutionPluginRouter, confi const frameworkRequest = await buildFrameworkRequest(context, request); const exportSizeLimit = config.maxTimelineImportExportSize; + const normalizedIds = + request.body?.ids != null ? [...new Set(request.body.ids)] : request.body?.ids; - if (request.body?.ids != null && request.body.ids.length > exportSizeLimit) { + if (normalizedIds != null && normalizedIds.length > exportSizeLimit) { return siemResponse.error({ statusCode: 400, body: `Can't export more than ${exportSizeLimit} timelines`, @@ -59,7 +61,7 @@ export const exportTimelinesRoute = (router: SecuritySolutionPluginRouter, confi const responseBody = await getExportTimelineByObjectIds({ frameworkRequest, - ids: request.body?.ids, + ids: normalizedIds, }); return response.ok({ diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/investigation/timeline/tests/timeline_privileges.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/investigation/timeline/tests/timeline_privileges.ts index 0fc048d1850ed..da9d31dae2667 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/investigation/timeline/tests/timeline_privileges.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/investigation/timeline/tests/timeline_privileges.ts @@ -8,6 +8,7 @@ import type TestAgent from 'supertest/lib/agent'; import expect from '@kbn/expect'; import type { CreateTimelinesResponse } from '@kbn/security-solution-plugin/common/api/timeline'; +import { TIMELINE_EXPORT_URL } from '@kbn/security-solution-plugin/common/constants'; import type { FtrProviderContextWithSpaces } from '../../../../ftr_provider_context_with_spaces'; import { getTimelines, @@ -28,10 +29,17 @@ const canWriteRoles = [roles.secAllV1, roles.secTimelineAllV2]; const canWriteOrReadRoles = [...canOnlyReadRoles, ...canWriteRoles]; const cannotAccessRoles = [roles.secNoneV1, roles.secTimelineNoneV2]; const cannotWriteRoles = [...canOnlyReadRoles, ...cannotAccessRoles]; +const MAX_TIMELINE_EXPORT_IDS = 1000; export default function ({ getService }: FtrProviderContextWithSpaces) { const utils = getService('securitySolutionUtils'); const supertestCache = new Map<(typeof roles.roles)[number]['name'], TestAgent>(); + const exportTimeline = async (supertest: TestAgent, ids: string[]) => + supertest + .post(`${TIMELINE_EXPORT_URL}?file_name=timelines_export.ndjson`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send({ ids }); describe('Timeline privileges', () => { before(async () => { @@ -88,6 +96,55 @@ export default function ({ getService }: FtrProviderContextWithSpaces) { }); }); + describe('export timelines', () => { + let getTimelineId = () => ''; + + before(async () => { + const superTest = supertestCache.get(roles.secTimelineAllV2.name)!; + const { + body: { savedObjectId }, + } = await createBasicTimeline(superTest, 'timeline for export'); + getTimelineId = () => savedObjectId; + }); + + canWriteOrReadRoles.forEach((role) => { + it(`role "${role.name}" can export timelines`, async () => { + const superTest = supertestCache.get(role.name)!; + const exportTimelineResponse = await exportTimeline(superTest, [getTimelineId()]); + expect(exportTimelineResponse.status).to.be(200); + }); + }); + + cannotAccessRoles.forEach((role) => { + it(`role "${role.name}" cannot export timelines`, async () => { + const superTest = supertestCache.get(role.name)!; + const exportTimelineResponse = await exportTimeline(superTest, [getTimelineId()]); + expect(exportTimelineResponse.status).to.be(403); + }); + }); + + it('rejects export requests above the max ids limit for read roles', async () => { + const superTest = supertestCache.get(roles.secTimelineReadV2.name)!; + const oversizedIds = Array.from( + { length: MAX_TIMELINE_EXPORT_IDS + 1 }, + (_, index) => `non-existent-timeline-${index}` + ); + const exportTimelineResponse = await exportTimeline(superTest, oversizedIds); + expect(exportTimelineResponse.status).to.be(400); + }); + + it('accepts duplicate timeline ids for read roles', async () => { + const superTest = supertestCache.get(roles.secTimelineReadV2.name)!; + const timelineId = getTimelineId(); + const exportTimelineResponse = await exportTimeline(superTest, [ + timelineId, + timelineId, + timelineId, + ]); + expect(exportTimelineResponse.status).to.be(200); + }); + }); + describe('create and delete timelines', () => { canWriteRoles.forEach((role) => { it(`role "${role.name}" can create and delete timelines`, async () => { From 08f0f006d6c79a103d6aff778441463be51497c6 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:27:21 +0000 Subject: [PATCH 2/3] Changes from yarn openapi:bundle --- ...ecurity_solution_timeline_api_2023_10_31.bundled.schema.yaml | 2 ++ ...ecurity_solution_timeline_api_2023_10_31.bundled.schema.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 5b2c5e5a6420a..b29df5c589bbf 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -550,6 +550,8 @@ paths: ids: items: type: string + maxItems: 1000 + minItems: 1 nullable: true type: array description: The IDs of the Timelines to export. diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 3434efbcedf37..aa4f0cee70e71 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -550,6 +550,8 @@ paths: ids: items: type: string + maxItems: 1000 + minItems: 1 nullable: true type: array description: The IDs of the Timelines to export. From dd257494a96c28695effaf728de1f5819282ebed Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:09:15 +0000 Subject: [PATCH 3/3] Changes from make api-docs --- oas_docs/output/kibana.serverless.yaml | 2 ++ oas_docs/output/kibana.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index c493727e6f0d4..50fbf98bd37ac 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -60607,6 +60607,8 @@ paths: ids: items: type: string + maxItems: 1000 + minItems: 1 nullable: true type: array description: The IDs of the Timelines to export. diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 94ba7bb6b49a7..b5a61a40a72e4 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -66454,6 +66454,8 @@ paths: ids: items: type: string + maxItems: 1000 + minItems: 1 nullable: true type: array description: The IDs of the Timelines to export.