Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
20 changes: 15 additions & 5 deletions api_client/python/timesketch_api_client/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ def _extract_chips(self, query_filter):

self.add_chip(chip)

def _execute_query(self, file_name="", count=False):
def _execute_query(self, file_name="", count=False, stream=False):
"""Execute a search request and store the results.

Args:
Expand All @@ -502,6 +502,9 @@ def _execute_query(self, file_name="", count=False):
set to True, the results will be stored in the
search object, and the number of events will be
returned.
stream (bool): Optional boolean that determines whether
we want to stream the results to a file. This is
useful for large exports.

Returns:
A dict with the search results or the total number of events
Expand All @@ -528,7 +531,7 @@ def _execute_query(self, file_name="", count=False):
}

response = self.api.session.post(
f"{self.api.api_root}/{self.resource_uri}", json=form_data
f"{self.api.api_root}/{self.resource_uri}", json=form_data, stream=stream
)
if not error.check_return_status(response, logger):
error.error_message(
Expand All @@ -537,7 +540,11 @@ def _execute_query(self, file_name="", count=False):

if file_name:
with open(file_name, "wb") as fw:
fw.write(response.content)
if stream:
for chunk in response.iter_content(chunk_size=8192):
fw.write(chunk)
else:
fw.write(response.content)
return None

response_json = error.get_response_json(response, logger)
Expand Down Expand Up @@ -1091,20 +1098,23 @@ def to_dict(self):

return self._raw_response

def to_file(self, file_name):
def to_file(self, file_name, stream=False):
"""Saves the content of the query to a file.

Args:
file_name (str): Full path to a file that will store the results
of the query to as a ZIP file. The ZIP file will contain a
METADATA file and a CSV with the results from the query.
stream (bool): Optional boolean that determines whether
we want to stream the results to a file. This is
useful for large exports.

Returns:
Boolean that determines if it was successful.
"""
old_scrolling = self.scrolling
self._scrolling = True
self._execute_query(file_name=file_name)
self._execute_query(file_name=file_name, stream=stream)
self._scrolling = old_scrolling
return True

Expand Down
50 changes: 37 additions & 13 deletions cli_client/python/timesketch_cli_client/commands/sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,12 @@ def create_sketch(
) -> None:
"""Creates a new sketch.

Creates a new Timesketch sketch with the specified name and optional description.
Creates a new Timesketch sketch with the specified name and optional
description.

Args:
ctx (click.Context): The Click context object, containing the API client.
ctx (click.Context): The Click context object, containing the API
client.
name (str): The name of the new sketch.
description (Optional[str]): The description of the new sketch
(defaults to the name if not provided).
Expand All @@ -148,43 +150,65 @@ def create_sketch(

@sketch_group.command("export", help="Export a sketch")
@click.option("--filename", required=True, help="Filename to export to.")
@click.option(
"--stream", is_flag=True, help="Stream the download to avoid memory issues."
)
@click.option(
"--use_sketch_export",
is_flag=True,
help="Use the sketch export functionality instead of search.",
)
@click.pass_context
def export_sketch(ctx: click.Context, filename: str) -> None:
def export_sketch(
ctx: click.Context, filename: str, stream: bool, use_sketch_export: bool
) -> None:
"""Export a sketch to a file.

Exports all events within the active sketch to a specified file.
By default, this command uses the search-based export, which fetches
all events from the sketch and saves them to a ZIP file containing
a CSV of the results and metadata.

If the `--use_sketch_export` flag is provided, it uses the full sketch
export functionality. This creates a comprehensive ZIP file that includes
not only all events but also stories (as HTML), aggregations, views,
and metadata associated with the sketch.

The export process can take a significant amount of time depending on the
sketch size.

Args:
ctx (click.Context): The Click context object, containing the sketch.
filename (str): The name of the file to export the sketch data to.
stream (bool): Whether to stream the download (recommended for large
exports to avoid memory issues).
use_sketch_export (bool): Whether to use the full sketch export
functionality instead of the default search-based event export.

Raises:
click.exceptions.Exit: If a ValueError occurs during the export process.
click.exceptions.Exit: If an error occurs during the export process.

Outputs:
Text: Messages indicating the start, progress, and completion of the
export process, including the time taken.
Error message: If a ValueError occurs during export.
Error message: If an error occurs during export.
"""
sketch = ctx.obj.sketch
click.echo("Executing export . . . ")
click.echo("Depending on the sketch size, this can take a while")
# start counting the time the export took
start_time = time.time()
try:
search_obj = search.Search(sketch=sketch)

click.echo(f"Number of events in that sketch: {search_obj.expected_size}")
if use_sketch_export:
sketch.export(filename)
else:
search_obj = search.Search(sketch=sketch)
click.echo(f"Number of events in that sketch: {search_obj.expected_size}")
search_obj.to_file(filename, stream=stream)

search_obj.to_file(filename)
# Using the sketch.export function could be an alternative here
# TODO: https://github.com/google/timesketch/issues/2344
end_time = time.time()
click.echo(f"Export took {end_time - start_time} seconds")
click.echo("Finish")
except ValueError as e:
except Exception as e: # pylint: disable=broad-except
click.echo(f"Error: {e}")
ctx.exit(1)

Expand Down
8 changes: 7 additions & 1 deletion docs/developers/api-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,16 @@ To get the results as:

Or if you want to store the results as a file:

```
```python
search_obj.to_file('/tmp/myresults.zip')
```

For large results, you can use the `stream` parameter to avoid loading the whole result into memory:

```python
search_obj.to_file('/tmp/myresults.zip', stream=True)
```

(use the ZIP ending, since the resulting file will be a ZIP file with both the
results as a CSV file and a METADATA file.

Expand Down
24 changes: 23 additions & 1 deletion docs/guides/user/cli-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,29 @@ Running `sketch unarchive` will set the archive flag to the sketch.

### Export a sketch

Running `sketch export` will export the complete Sketch to a file.
Running `sketch export` will export events from the sketch to a ZIP file.

By default, this command uses the search-based export, which fetches
all events from the sketch and saves them to a ZIP file containing
a CSV of the results and metadata.

If the `--use_sketch_export` flag is provided, it uses the full sketch
export functionality. This creates a comprehensive ZIP file that includes
not only all events but also stories (as HTML), aggregations, views,
and metadata associated with the sketch.

```bash
timesketch sketch export --filename my_export.zip
```

Options:
- `--stream`: Stream the download. This is useful for large exports to avoid memory issues.
- `--use_sketch_export`: Use the full sketch export functionality (same as Web UI export) instead of just exporting events. This export includes metadata, stories, and views in addition to events.

Example:
```bash
timesketch sketch export --filename my_export.zip --stream
```

### Labels

Expand Down
13 changes: 9 additions & 4 deletions docs/guides/user/sketch-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,13 @@ Unarchiving a sketch restores it to a fully active and writable state. This acti

### Export

Export will export the following items:
Export allows you to download sketch data. In the Web UI, this will export a
comprehensive ZIP file containing:

- events (starred, tagged, tagged_event_stats, comments, ...)
- stories as html
- views (as csv)
- Events (starred, tagged, tagged_event_stats, comments, ...)
- Stories as HTML
- Views (as CSV)
- Metadata

This is equivalent to the `--use_sketch_export` option in the [CLI tool](cli-client.md#export-a-sketch).
By default, the CLI tool exports just the events from the sketch.
61 changes: 60 additions & 1 deletion end_to_end_tests/cli_client_e2e_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# /usr/local/google/home/jaegeral/dev/timesketch/end_to_end_tests/cli_client_e2e_test.py
# Copyright 2026 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""End to end tests for Timesketch CLI client commands."""

import os
import uuid
from click.testing import CliRunner

from timesketch_cli_client.commands.sketch import sketch_group
Expand Down Expand Up @@ -97,6 +111,51 @@ def test_cli_sketch_describe(self):
"Sketch description not found or incorrect in 'sketch describe' output.",
)

def test_cli_sketch_export(self):
"""Tests 'timesketch sketch export'."""
sketch_name = f"cli_client_e2e_test_export_{uuid.uuid4().hex}"
active_sketch = self.api.create_sketch(name=sketch_name)
self.import_timeline("evtx_part.csv", sketch=active_sketch) # ensure some data

cli_ctx_obj = E2ECliContextObject(
api_client=self.api,
sketch_instance=active_sketch,
output_format="text",
)

# Test default export
with self.runner.isolated_filesystem():
filename = "export.zip"
result = self.runner.invoke(
sketch_group, ["export", "--filename", filename], obj=cli_ctx_obj
)
self.assertions.assertEqual(result.exit_code, 0, f"Output: {result.output}")
self.assertions.assertTrue(os.path.exists(filename))

# Test streaming export
filename_stream = "export_stream.zip"
result_stream = self.runner.invoke(
sketch_group,
["export", "--filename", filename_stream, "--stream"],
obj=cli_ctx_obj,
)
self.assertions.assertEqual(
result_stream.exit_code, 0, f"Output: {result_stream.output}"
)
self.assertions.assertTrue(os.path.exists(filename_stream))

# Test use_sketch_export
filename_full = "export_full.zip"
result_full = self.runner.invoke(
sketch_group,
["export", "--filename", filename_full, "--use_sketch_export"],
obj=cli_ctx_obj,
)
self.assertions.assertEqual(
result_full.exit_code, 0, f"Output: {result_full.output}"
)
self.assertions.assertTrue(os.path.exists(filename_full))


# Register the new test class with the test manager
manager.EndToEndTestManager.register_test(CliClientE2ETest)
Loading