Skip to content

add subgroups for search filters #156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 23, 2025
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
The intended audience of this file is for `incydr` SDK and CLI consumers -- as such, changes that don't affect
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
here.

## 2.6.0 - 2025-07-23

### Added
- Support for subgroups in file event queries and saved searches. See [this documentation](https://support.code42.com/hc/en-us/articles/14827671672087-Forensic-Search-reference#h_01JKEF6ESSMTEGFG28WZM6TNDR) for more details about this type of query.
- New methods for EventQuery() to enable more flexible filtering:
- `is_any`
- `is_none`
- `date_range`
- `subquery`
- New methods to download files by XFC content ID.
- `sdk.files.download_file_by_xfc_content_id` and `sdk.files.stream_file_by_xfc_content_id`
- `incydr files download-by-xfc-id`

### Fixed
- An issue where in some cases saved searches could not be retrieved.

## 2.5.0 - 2025-06-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/clients/file_event_queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Use the `EventQuery` class to create a query for searching and filtering file ev

::: _incydr_sdk.queries.file_events.EventQuery
:docstring:
:members: equals not_equals exists does_not_exist greater_than less_than matches_any
:members: equals not_equals exists does_not_exist greater_than less_than matches_any is_any is_none date_range subquery

## Query Building

Expand Down
14 changes: 14 additions & 0 deletions src/_incydr_cli/cmds/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,17 @@ def download(sha256: str, path: str):
"""
client = Client()
client.files.v1.download_file_by_sha256(sha256, path)


@files.command(cls=IncydrCommand)
@click.argument("XFC_ID")
@path_option
@logging_options
def download_by_xfc_id(xfc_id: str, path: str):
"""
Download the file matching the given XFC content ID hash to the target path.
"""
client = Client()
client.files.v1.download_file_by_xfc_content_id(
xfc_content_id=xfc_id, target_path=path
)
2 changes: 1 addition & 1 deletion src/_incydr_sdk/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2022-present Code42 Software <[email protected]>
#
# SPDX-License-Identifier: MIT
__version__ = "2.5.0"
__version__ = "2.6.0"
3 changes: 1 addition & 2 deletions src/_incydr_sdk/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ class IncydrSettings(BaseSettings):
Usage:

>> import incydr
>>> client = incydr.Client()
>>> client.settings.page_size = 10
>>> client = incydr.Client(page_size = 10)

Settings can also be loaded from shell environment variables or .env files. Just prefix a setting's attribute name
with `INCYDR_` when configuring via enviroment vars.
Expand Down
2 changes: 2 additions & 0 deletions src/_incydr_sdk/enums/file_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ class Operator(_Enum):
# all valid filter operators for querying file events
IS = "IS"
IS_NOT = "IS_NOT"
IS_ANY = "IS_ANY"
IS_NONE = "IS_NONE"
EXISTS = "EXISTS"
DOES_NOT_EXIST = "DOES_NOT_EXIST"
GREATER_THAN = "GREATER_THAN"
Expand Down
7 changes: 5 additions & 2 deletions src/_incydr_sdk/file_events/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
class InvalidQueryException(IncydrException):
"""Raised when the file events search endpoint returns a 400."""

def __init__(self, query=None):
def __init__(self, query=None, exception=None):
self.query = query
self.message = (
"400 Response Error: Invalid query. Please double check your query filters are valid. "
"\nTip: Make sure you're specifying your filter fields in dot notation. "
"\nFor example, filter by 'file.archiveId' to filter by the archiveId field within the file object.)"
)
if "problems" in exception.response.json().keys():
self.message += f"\nRaw problem data from the response: {exception.response.json()['problems']}"
self.original_exception = exception
super().__init__(self.message)


Expand Down Expand Up @@ -65,7 +68,7 @@ def search(self, query: EventQuery) -> FileEventsPage:
response = self._parent.session.post("/v2/file-events", json=query.dict())
except HTTPError as err:
if err.response.status_code == 400:
raise InvalidQueryException(query)
raise InvalidQueryException(query=query, exception=err)
raise err
page = FileEventsPage.parse_response(response)
query.page_token = page.next_pg_token
Expand Down
5 changes: 5 additions & 0 deletions src/_incydr_sdk/file_events/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,11 @@ class Event(Model):
example="GIT_PUSH",
title="The method of file movement. For example: UPLOADED, DOWNLOADED, EMAILED.",
)
xfc_event_id: Optional[str] = Field(
None,
alias="xfcEventId",
description="The identifier for the exfiltrated file collection data associated with this event.",
)


class Git(Model):
Expand Down
17 changes: 15 additions & 2 deletions src/_incydr_sdk/file_events/models/response.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from datetime import datetime
from typing import List
from typing import Optional
Expand Down Expand Up @@ -32,6 +34,17 @@ class SearchFilterGroup(ResponseModel):
)


class SearchFilterGroupV2(ResponseModel):
subgroup_clause: Optional[str] = Field(
alias="subgroupClause",
description="Grouping clause for subgroups.",
example="AND",
)
subgroups: Optional[List[Union[SearchFilterGroup, SearchFilterGroupV2]]] = Field(
description="One or more FilterGroups to be combined in a query, or a FilterSubgroupV2"
)


class QueryProblem(ResponseModel):
"""
A model containing data on a query problem.
Expand Down Expand Up @@ -99,7 +112,7 @@ class SavedSearch(ResponseModel):
* **created_by_username**: `str` - The username of the user who created the saved search.
* **creation_timestamp**: `datetime` - The time at which the saved search was created.
* **group_clause**: `GroupClause` - `AND` or `OR`. Grouping clause for any specified groups. Defaults to `AND`.
* **groups**: `List[SearchFilterGroup]` - One or more FilterGroups to be combined in a query.
* **groups**: `List[Union[SearchFilterGroup, SearchFilterGroupV2]]` - One or more FilterGroups or FilterGroupV2s to be combined in a query.
* **id**: `str` - The ID for the saved search.
* **modified_by_uid**: `str` - The ID of the user who last modified the saved search.
* **modified_by_username**: `str` - The username of the user who last modified the saved search.
Expand Down Expand Up @@ -139,7 +152,7 @@ class SavedSearch(ResponseModel):
description="Grouping clause for any specified groups.",
example="OR",
)
groups: Optional[List[SearchFilterGroup]] = Field(
groups: Optional[List[Union[SearchFilterGroup, SearchFilterGroupV2]]] = Field(
description="One or more FilterGroups to be combined in a query."
)
id: Optional[str] = Field(
Expand Down
43 changes: 43 additions & 0 deletions src/_incydr_sdk/files/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,46 @@ def stream_file_by_sha256(self, sha256: str):
return self._parent.session.get(
f"/v1/files/get-file-by-sha256/{sha256}", stream=True
)

def download_file_by_xfc_content_id(
self, xfc_content_id: str, target_path: Path
) -> Path:
"""Download a file that matches the given XFC content ID.

**Parameters:**

* **xfc_content_id**: `str` (required) The XFC content ID for file you wish to download.
* **target_path**: `Path | str` a string or `pathlib.Path` object that represents the target file path and
name to which the file will be saved to.

**Returns**: A `pathlib.Path` object representing the location of the downloaded file.
"""
target = Path(
target_path
) # ensure that target is a path even if we're given a string
response = self._parent.session.get(
f"/v1/files/get-file-by-xfc-content-id/{xfc_content_id}"
)
target.write_bytes(response.content)
return target

def stream_file_by_xfc_content_id(self, xfc_content_id: str):
"""Stream a file that matches the given XFC content ID.

**Example usage:**
```
>>> with sdk.files.v1.stream_file_by_xfc_content_id("content_id_example") as response:
>>> with open("./testfile.zip", "wb") as file:
>>> for chunk in response.iter_content(chunk_size=128):
>>> file.write(chunk)
```

**Parameters:**

* **xfc_content_id**: `str` (required) The XFC content ID for file you wish to download.

**Returns**: A `requests.Response` object with a stream of the requested file.
"""
return self._parent.session.get(
f"/v1/files/get-file-by-xfc-content-id/{xfc_content_id}", stream=True
)
Loading