Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
68c1e6f
chg: [importer] reuse existing sketch if name is given
cvandeplas Dec 11, 2025
6a9b8a0
fix: [importer] include code assistant feedback
cvandeplas Dec 11, 2025
f802039
Merge branch 'master' into feature_sketch_by_name
jaegeral Dec 17, 2025
8a77154
Merge branch 'master' into feature_sketch_by_name
jkppr Dec 17, 2025
b40d9cc
Merge remote-tracking branch 'origin/master' into feature_sketch_by_name
cvandeplas Dec 18, 2025
40ae458
Merge branch 'master' into feature_sketch_by_name
jaegeral Jan 6, 2026
f54bc7b
wip: initial work on sketch_by_name
cvandeplas Jan 6, 2026
7cb77dc
Merge branch 'feature_sketch_by_name' of https://github.com/cvandepla…
cvandeplas Jan 6, 2026
7ffcaa8
Merge branch 'google:master' into feature_sketch_by_name
cvandeplas Jun 25, 2026
dd434b3
chg: [api_client] unit tests
cvandeplas Jun 25, 2026
ae0760b
docs: document --sketch-name and --sketch-strategy flags
cvandeplas Jun 25, 2026
90ee406
Merge branch 'master' into feature_sketch_by_name
cvandeplas Jun 25, 2026
cbcac8c
Merge branch 'master' into feature_sketch_by_name
jkppr Jun 30, 2026
a547165
fix:(api_client): use search query and scope
cvandeplas Jul 2, 2026
0856768
fix(importer): try catch non-int
cvandeplas Jul 2, 2026
9bb5d8c
fix(api_client): remove unnecessary cache refresh
cvandeplas Jul 2, 2026
57673f8
fix: make black and pylint happy
cvandeplas Jul 2, 2026
814211d
Merge branch 'master' into feature_sketch_by_name
cvandeplas Jul 2, 2026
b9feb62
Merge branch 'master' into feature_sketch_by_name
jaegeral Aug 12, 2026
762d6be
fix(importer): implement codestyle suggestions
cvandeplas Aug 27, 2026
1732736
Merge branch 'master' into feature_sketch_by_name
cvandeplas Aug 27, 2026
78a8aad
Merge branch 'master' into feature_sketch_by_name
jkppr Sep 3, 2026
3bcb6dd
Merge branch 'master' into feature_sketch_by_name
cvandeplas Sep 9, 2026
87d7622
fix(linting): fix linting issues in timesketch_importer
cvandeplas Sep 9, 2026
1709f7a
Merge branch 'master' into feature_sketch_by_name
jkppr Oct 9, 2026
582edb6
fix(importer): harden sketch reuse by name
jkppr Oct 9, 2026
9f0dc12
Merge branch 'master' into feature_sketch_by_name
jkppr Oct 9, 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
70 changes: 68 additions & 2 deletions api_client/python/timesketch_api_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,47 @@ def get_sketch(self, sketch_id):
"""
return sketch.Sketch(sketch_id, api=self)

def get_sketches_by_name(
self, sketch_name: str, include_archived: bool = True
) -> list[sketch.Sketch]:
"""Get all sketches with an exact matching name.

The lookup runs server side with the "search" scope, i.e. it covers
all sketches the user can read (owned, shared and public). The server
search is a partial, case-insensitive match on name and description,
so the results are filtered here for an exact, case-sensitive name
match.

Args:
sketch_name (str): The name of the sketch to find.
Warning: Timesketch allows multiple sketches with the same name.
The caller is responsible for handling this potential conflict.
include_archived (bool): If archived sketches should be returned.
Defaults to True.

Raises:
KeyError: If no sketch with the specified name is found.

Returns:
list[sketch.Sketch]: A list of sketch objects.
"""
# We still need to verify the match for the sketch name, as the search_query
# also matches on partial (ILIKE) matches by default
sketches = [
sketch_obj
for sketch_obj in self.list_sketches(
search_query=sketch_name,
scope="search",
include_archived=include_archived,
)
if sketch_obj.name == sketch_name
]

if not sketches:
raise KeyError(f"Sketch with name '{sketch_name}' not found.")

return sketches

def get_aggregator_info(self, name="", as_pandas=False):
"""Returns information about available aggregators.

Expand Down Expand Up @@ -734,12 +775,19 @@ def get_aggregator_info(self, name="", as_pandas=False):

return pandas.DataFrame(lines)

def list_sketches(self, per_page=50, scope="user", include_archived=True):
def list_sketches(
self,
per_page=50,
scope=None,
include_archived=True,
search_query=None,
):
"""Get a list of all open sketches that the user has access to.

Args:
per_page (int): Number of items per page when paginating. Default is 50.
scope (str): What scope to get sketches as. Default to user.
scope (str): What scope to get sketches as. Defaults to "search" if
search_query is specified, otherwise to "user".
user: sketches owned by the user
recent: sketches that the user has actively searched in
shared: sketches shared with the user (but not owned by them)
Expand All @@ -748,15 +796,33 @@ def list_sketches(self, per_page=50, scope="user", include_archived=True):
search: pass additional search query
all: all sketches the user has access to (owned and shared)
include_archived (bool): If archived sketches should be returned.
search_query (str): A query string to search for sketches by name
or description. Only supported with the "search" scope. The
server performs a partial, case-insensitive match, so results
may include sketches whose name only contains the query.

Raises:
ValueError: If search_query is used with a scope other than "search".

Yields:
Sketch objects instances.
"""
if scope is None:
scope = "search" if search_query else "user"

if search_query and scope != "search":
raise ValueError(
f"search_query is only supported with scope 'search', not '{scope}'."
)

url_params = {
"per_page": per_page,
"scope": scope,
"include_archived": include_archived,
}

if search_query:
url_params["search_query"] = search_query
# Start with the first page
page = 1
has_next_page = True
Expand Down
73 changes: 73 additions & 0 deletions api_client/python/timesketch_api_client/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,79 @@ def test_get_sketches(self):
self.assertEqual(len(sketches), 1)
self.assertIsInstance(sketches[0], sketch_lib.Sketch)

def test_get_sketches_by_name(self):
"""Test to get sketches by name."""
sketches = self.api_client.get_sketches_by_name("test")
self.assertIsInstance(sketches, list)
self.assertEqual(len(sketches), 1)
self.assertIsInstance(sketches[0], sketch_lib.Sketch)
self.assertEqual(sketches[0].name, "test")

def test_get_sketches_by_name_not_found(self):
"""Test that get_sketches_by_name raises KeyError for unknown name."""
with self.assertRaises(KeyError):
self.api_client.get_sketches_by_name("nonexistent_sketch")

@mock.patch("requests.Session", test_lib.mock_session)
def test_get_sketches_by_name_duplicates(self):
"""Test get_sketches_by_name returns multiple sketches with same name."""
with mock.patch.object(self.api_client, "list_sketches") as mock_list:
sketch_1 = sketch_lib.Sketch(
sketch_id=1, api=self.api_client, sketch_name="duplicate"
)
sketch_2 = sketch_lib.Sketch(
sketch_id=2, api=self.api_client, sketch_name="duplicate"
)
mock_list.return_value = iter([sketch_1, sketch_2])

sketches = self.api_client.get_sketches_by_name("duplicate")
self.assertIsInstance(sketches, list)
self.assertEqual(len(sketches), 2)
self.assertEqual(sketches[0].id, 1)
self.assertEqual(sketches[1].id, 2)

@mock.patch("requests.Session", test_lib.mock_session)
def test_get_sketches_by_name_case_sensitive(self):
"""Test that get_sketches_by_name matching is case-sensitive."""
with mock.patch.object(self.api_client, "list_sketches") as mock_list:
sketch_1 = sketch_lib.Sketch(
sketch_id=1, api=self.api_client, sketch_name="My Sketch"
)
mock_list.return_value = iter([sketch_1])

with self.assertRaises(KeyError):
self.api_client.get_sketches_by_name("my sketch")

def test_get_sketches_by_name_include_archived(self):
"""Test that get_sketches_by_name passes include_archived through."""
with mock.patch.object(self.api_client, "list_sketches") as mock_list:
mock_list.return_value = iter(
[sketch_lib.Sketch(sketch_id=1, api=self.api_client, sketch_name="a")]
)
self.api_client.get_sketches_by_name("a", include_archived=False)
mock_list.assert_called_once_with(
search_query="a", scope="search", include_archived=False
)

def test_list_sketches_scope_defaults(self):
"""Test the scope that list_sketches sends to the server by default."""
with mock.patch.object(
self.api_client, "fetch_resource_data", return_value={}
) as mock_fetch:
list(self.api_client.list_sketches())
self.assertEqual(mock_fetch.call_args.kwargs["params"]["scope"], "user")
self.assertNotIn("search_query", mock_fetch.call_args.kwargs["params"])

list(self.api_client.list_sketches(search_query="foo"))
params = mock_fetch.call_args.kwargs["params"]
self.assertEqual(params["scope"], "search")
self.assertEqual(params["search_query"], "foo")

def test_list_sketches_search_query_with_wrong_scope(self):
"""Test that search_query cannot be combined with a non-search scope."""
with self.assertRaises(ValueError):
list(self.api_client.list_sketches(scope="user", search_query="foo"))


class TimesketchApiRetryTest(unittest.TestCase):
"""Test TimesketchApi client retry logic."""
Expand Down
36 changes: 36 additions & 0 deletions api_client/python/timesketch_api_client/sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,42 @@ def last_activity(self):
meta = data.get("meta", {})
return meta.get("last_activity", "")

@property
def created_at(self):
"""Property that returns sketch creation time.

Returns:
str: Sketch creation time as string.
"""
data = self.lazyload_data()
objects = data.get("objects")
if not objects:
return ""
data_object = objects[0]
created_at_string = data_object.get("created_at", "")
return created_at_string

@property
def creator(self):
"""Property that returns sketch creator.

Returns:
str: Sketch creator as string.
"""
data = self.lazyload_data()
objects = data.get("objects")
if not objects:
return ""
data_object = objects[0]
try:
username_string = data_object.get("user").get("username")
if not username_string:
return ""
return username_string
except (AttributeError, TypeError) as e:
logger.error("Error getting sketch creator: %s", e)
return ""

@property
def my_acl(self):
"""Property that returns back the ACL for the current user."""
Expand Down
10 changes: 10 additions & 0 deletions api_client/python/timesketch_api_client/sketch_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ def test_add_event_attributes(self):
response = self.sketch.add_event_attributes(events)
self.assertEqual(response, expected_response)

def test_created_at(self):
"""Test the created_at property."""
created_at = self.sketch.created_at
self.assertEqual(created_at, "2025-12-11T07:39:20.000000")

def test_creator(self):
"""Test the creator property."""
creator = self.sketch.creator
self.assertEqual(creator, "testuser")

def test_add_event_attributes_invalid(self):
"""Confirm an exception is raised when events isn't a list."""
events = {"_id": "1", "_type": "_doc", "index": "1", "attributes": []}
Expand Down
2 changes: 2 additions & 0 deletions api_client/python/timesketch_api_client/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ def json(self):
"id": 1,
"name": "test",
"description": "test",
"created_at": "2025-12-11T07:39:20.000000",
"user": {"username": "testuser"},
"timelines": [
{
"id": 1,
Expand Down
17 changes: 17 additions & 0 deletions docs/guides/user/upload-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ Other parameters suggested to be set are `sketch_id` (if it isn't provided a
new sketch will be created) and `timeline_name` (otherwise a default name
will be chosen).

Alternatively, you can use `--sketch-name` to target a sketch by name. If a
sketch with that name already exists, the importer will reuse it instead of
creating a new one. Since sketch names are not unique in Timesketch, you can
control how duplicates are handled with the `--sketch-strategy` flag:

- `ask` (default): prompts the user to select the correct sketch interactively.
If no terminal is available (e.g. in scripts or pipelines), the importer
exits with an error listing the matching sketch IDs instead.
- `newest`: automatically picks the most recently created sketch (highest ID).
- `oldest`: automatically picks the earliest created sketch (lowest ID).

Example:

```shell
$ timesketch_importer.py --sketch-name "My Investigation" --sketch-strategy newest path_to_my_file.csv
```

Additionally, for Plaso files, you can filter events during import using the
`--plaso-event-filter` option, providing a standard Plaso filter string
(e.g., `'data_type is "fs:stat"'`).
Expand Down
2 changes: 2 additions & 0 deletions importer_client/python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
[
"pandas",
"xlrd",
# TODO: Pin to "timesketch-api-client>20260611" with the next API
# client release. The importer needs get_sketches_by_name().
"timesketch-api-client",
"pyyaml",
]
Expand Down
Loading
Loading