Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions oas_docs/output/kibana.serverless.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60615,6 +60615,8 @@ paths:
ids:
items:
type: string
maxItems: 1000
minItems: 1
nullable: true
type: array
description: The IDs of the Timelines to export.
Expand Down
2 changes: 2 additions & 0 deletions oas_docs/output/kibana.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66462,6 +66462,8 @@ paths:
ids:
items:
type: string
maxItems: 1000
minItems: 1
nullable: true
type: array
description: The IDs of the Timelines to export.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ export type ExportTimelinesRequestQueryInput = z.input<typeof ExportTimelinesReq

export type ExportTimelinesRequestBody = z.infer<typeof ExportTimelinesRequestBody>;
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<typeof ExportTimelinesRequestBody>;
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ paths:
ids:
nullable: true
type: array
minItems: 1
maxItems: 1000
items:
type: string
responses:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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<Array<TimelineResponse | ExportTimelineNotFoundError>> => {
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<Note[]>((acc, note) => [...acc, ...note], []);

const myPinnedEventIds = pinnedEvents.reduce<PinnedEvent[]>(
(acc, pinnedEventId) => [...acc, ...pinnedEventId],
[]
);
const { notes, pinnedEvents } = await getTimelineNotesAndPinnedEvents(request, exportedIds);

const myResponse = exportedIds.reduce<TimelineResponse[]>((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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ describe('export timelines', () => {
let clients: MockClients;
let context: SecuritySolutionRequestHandlerContextMock;

const registerRoute = (overrides?: Partial<ReturnType<typeof createMockConfig>>) => {
const routeConfig = { ...createMockConfig(), ...overrides };
exportTimelinesRoute(server.router, routeConfig);
return routeConfig;
};

beforeEach(() => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());
Expand All @@ -62,7 +68,6 @@ describe('export timelines', () => {
(convertSavedObjectToSavedPinnedEvent as unknown as jest.Mock).mockReturnValue(
mockPinnedEvents()
);
exportTimelinesRoute(server.router, createMockConfig());
});

afterEach(() => {
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -59,7 +61,7 @@ export const exportTimelinesRoute = (router: SecuritySolutionPluginRouter, confi

const responseBody = await getExportTimelineByObjectIds({
frameworkRequest,
ids: request.body?.ids,
ids: normalizedIds,
});

return response.ok({
Expand Down
Loading
Loading