Skip to content

Commit d85c1bd

Browse files
Merge pull request #168 from code42/feature/aggregate-file-events
INTEG-3135 - grouped searches
2 parents 6a53f09 + d891394 commit d85c1bd

13 files changed

Lines changed: 459 additions & 21 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929

3030
- name: Install hatch
3131
run: |
32-
pip install hatch==1.14.0
32+
pip install hatch
3333
pip install .
3434
3535
- name: Run tests

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ jobs:
2323
- name: Install click
2424
run: pip install click==8.1.8
2525
- name: Install hatch
26-
run: pip install hatch==1.14.0
26+
run: pip install hatch
2727
- name: Build docs
2828
run: hatch run docs:build

.github/workflows/style.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
run: pip install click===8.1.8
2626

2727
- name: Install hatch
28-
run: pip install hatch==1.14.0
28+
run: pip install hatch
2929

3030
- name: Run style checks
3131
run: hatch run style:check

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
1010
here.
1111

12+
## Unreleased
13+
14+
### Added
15+
- Added the `sdk.file-events.v2.search_groups` method to get approximate aggregate file event counts by a given grouping term.
16+
- Added the `GroupingEventQuery` class, used to make these queries.
17+
- Added the cli command `incydr file-events search-groups` to get approximate aggregate file event counts by a given grouping term.
18+
19+
1220
## 2.11.0 - 2026-02-10
1321

1422
### Added

docs/sdk/clients/file_event_queries.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Use the `EventQuery` class to create a query for searching and filtering file ev
88
:docstring:
99
:members: equals not_equals exists does_not_exist greater_than less_than matches_any is_any is_none date_range subquery
1010

11+
## GroupingEventQuery Class
12+
13+
Use the `GroupingEventQuery` class to create a query for searching for approximate event counts, grouped by a term called the `grouping_term`. `GroupingEventQuery` supports all of the same operators as `EventQuery`, with the addition of `group_by` and `maximum_size`, which can be used to control the grouping term and the maximum size of the response.
14+
15+
::: _incydr_sdk.queries.file_events.GroupingEventQuery
16+
:docstring:
17+
:members: group_by maximum_size
18+
1119
## Query Building
1220

1321
The `EventQuery` class can be imported directly from the `incydr` module.

docs/sdk/models.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,16 @@ Devices has been replaced by [Agents](#agents).
171171
::: incydr.models.SavedSearch
172172
:docstring:
173173

174+
### `GroupedFileEventResponse` model
175+
176+
::: incydr.models.GroupedFileEventResponse
177+
:docstring:
178+
179+
### `FileEventGroup` model
180+
181+
::: incydr.models.FileEventGroup
182+
:docstring:
183+
174184
## Roles
175185
---
176186

src/_incydr_cli/cmds/file_events.py

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131
from _incydr_sdk.enums.file_events import RiskIndicators
3232
from _incydr_sdk.enums.file_events import RiskSeverity
3333
from _incydr_sdk.file_events.models.event import FileEventV2
34+
from _incydr_sdk.file_events.models.response import FileEventGroup
3435
from _incydr_sdk.file_events.models.response import SavedSearch
3536
from _incydr_sdk.queries.file_events import EventQuery
37+
from _incydr_sdk.queries.file_events import GroupingEventQuery
3638
from _incydr_sdk.utils import model_as_card
3739

3840

@@ -100,14 +102,15 @@ def search(
100102
elif advanced_query:
101103
if not isinstance(advanced_query, str):
102104
advanced_query = advanced_query.read()
103-
query = EventQuery.parse_raw(advanced_query)
105+
query = EventQuery.model_validate_json(advanced_query)
104106
else:
105107
if not start:
106108
raise BadOptionUsage(
107109
"start",
108110
"--start option required if not using --saved-search or --advanced-query options.",
109111
)
110112
query = _create_query(
113+
cls=EventQuery,
111114
start=start,
112115
end=end,
113116
event_action=event_action,
@@ -191,6 +194,115 @@ def yield_all_events(q: EventQuery):
191194
console.print("No results found.")
192195

193196

197+
@file_events.command(cls=IncydrCommand)
198+
@click.option(
199+
"--group-by",
200+
default=None,
201+
help="(required) The term by which approximate counts will be grouped. Example: `user.email`.",
202+
required=True,
203+
)
204+
@table_format_option
205+
@columns_option
206+
@output_options
207+
@advanced_query_option
208+
@saved_search_option
209+
@event_filter_options
210+
@logging_options
211+
def search_groups(
212+
format_: TableFormat,
213+
columns: Optional[str],
214+
output: Optional[str],
215+
certs: Optional[str],
216+
ignore_cert_validation: Optional[bool],
217+
advanced_query: Optional[Union[str, File]],
218+
saved_search: Optional[str],
219+
start: Optional[str],
220+
end: Optional[str],
221+
event_action: Optional[str],
222+
username: Optional[str],
223+
md5: Optional[str],
224+
sha256: Optional[str],
225+
source_category: Optional[str],
226+
destination_category: Optional[str],
227+
file_name: Optional[str],
228+
file_directory: Optional[str],
229+
file_category: Optional[str],
230+
risk_indicator: Optional[RiskIndicators],
231+
risk_severity: Optional[RiskSeverity],
232+
risk_score: Optional[int],
233+
group_by: Optional[str],
234+
):
235+
"""
236+
Retrieve approximate aggregated file event counts. Various options are provided to filter query results.
237+
238+
Use the `--saved-search` or the `--advanced-query` option if the available filters don't satisfy your requirements.
239+
240+
Results will be output to the console by default, use the `--output` option to send data to a server.
241+
242+
This method returns approximate counts, grouped by the provided term. To obtain full event details, use the `search` method.
243+
"""
244+
if output:
245+
format_ = TableFormat.json_lines
246+
247+
client = Client()
248+
249+
if saved_search:
250+
saved_search = client.file_events.v2.get_saved_search(saved_search)
251+
query = GroupingEventQuery.from_saved_search(saved_search)
252+
elif advanced_query:
253+
if not isinstance(advanced_query, str):
254+
advanced_query = advanced_query.read()
255+
query = GroupingEventQuery.model_validate_json(advanced_query)
256+
else:
257+
if not start:
258+
raise BadOptionUsage(
259+
"start",
260+
"--start option required if not using --saved-search or --advanced-query options.",
261+
)
262+
query = _create_query(
263+
cls=GroupingEventQuery,
264+
start=start,
265+
end=end,
266+
event_action=event_action,
267+
username=username,
268+
md5=md5,
269+
sha256=sha256,
270+
source_category=source_category,
271+
destination_category=destination_category,
272+
file_name=file_name,
273+
file_directory=file_directory,
274+
file_category=file_category,
275+
risk_indicator=risk_indicator,
276+
risk_severity=risk_severity,
277+
risk_score=risk_score,
278+
)
279+
280+
query.group_by(group_by).maximum_size(10000)
281+
282+
groups = client.file_events.v2.search_groups(query).groups or []
283+
284+
if output:
285+
logger = get_server_logger(output, certs, ignore_cert_validation)
286+
for group in groups:
287+
logger.info(json.dumps(group.dict()))
288+
return
289+
290+
if format_ == TableFormat.csv:
291+
render.csv(FileEventGroup, groups, columns=columns, flat=True)
292+
elif format_ == TableFormat.table:
293+
render.table(FileEventGroup, groups, columns=columns, flat=False)
294+
else:
295+
printed = False
296+
for group in groups:
297+
printed = True
298+
if format_ == TableFormat.json_pretty:
299+
console.print_json(data=group)
300+
else:
301+
click.echo(json.dumps(group.dict()))
302+
if not printed:
303+
console.print("No results found.")
304+
305+
194306
@file_events.command()
195307
@click.argument("checkpoint-name")
196308
def clear_checkpoint(checkpoint_name: str):
@@ -262,8 +374,8 @@ def list_saved_searches(
262374
}
263375

264376

265-
def _create_query(**kwargs):
266-
query = EventQuery(start_date=kwargs["start"], end_date=kwargs["end"])
377+
def _create_query(cls, **kwargs):
378+
query = cls(start_date=kwargs["start"], end_date=kwargs["end"])
267379
for k, v in kwargs.items():
268380
if v:
269381
if k in ["start", "end"]:

src/_incydr_sdk/file_events/client.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from ..exceptions import IncydrException
99
from .models.response import FileEventsPage
10+
from .models.response import GroupedFileEventResponse
1011
from .models.response import SavedSearch
1112
from _incydr_sdk.queries.file_events import EventQuery
13+
from _incydr_sdk.queries.file_events import GroupingEventQuery
1214

1315

1416
class InvalidQueryException(IncydrException):
@@ -74,6 +76,28 @@ def search(self, query: EventQuery) -> FileEventsPage:
7476
query.page_token = page.next_pg_token
7577
return page
7678

79+
def search_groups(self, query: GroupingEventQuery) -> GroupedFileEventResponse:
80+
"""
81+
Search for file event counts by a grouping term.
82+
83+
**Parameters**:
84+
85+
* **query**: `GroupingEventQuery` (required) - The query object to group file events by a given field.
86+
87+
**Returns**: A [`GroupedFileEventResponse`][groupedfileeventresponse-model] object."""
88+
self._mount_retry_adapter()
89+
90+
try:
91+
response = self._parent.session.post(
92+
"/v2/file-events/grouping", json=query.dict()
93+
)
94+
except HTTPError as err:
95+
if err.response.status_code == 400:
96+
raise InvalidQueryException(query=query, exception=err)
97+
raise err
98+
response = GroupedFileEventResponse.parse_response(response)
99+
return response
100+
77101
def list_saved_searches(self) -> List[SavedSearch]:
78102
"""
79103
Get all saved searches.

src/_incydr_sdk/file_events/models/response.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,40 @@ class SavedSearch(ResponseModel):
218218
description="Search term for sorting.",
219219
examples=["event.id"],
220220
)
221+
222+
223+
class FileEventGroup(ResponseModel):
224+
"""A model representing a single group in a grouped response.
225+
226+
**Fields:**
227+
228+
* **value**: `str` - The value of the term for this group.
229+
* **doc_count**: `int` - The approximate count of hits matching this value for your query.
230+
"""
231+
232+
value: Optional[str] = Field(
233+
None, description="The value of the term for this group."
234+
)
235+
doc_count: Optional[int] = Field(
236+
None,
237+
description="The approximate count of hits matching this value for your query.",
238+
alias="docCount",
239+
)
240+
241+
242+
class GroupedFileEventResponse(ResponseModel):
243+
"""A model representing a response of grouped file events.
244+
245+
**Fields:**
246+
247+
* **groups**: `List[FileEventGroup]` - A list of file event counts by grouping term and doc count.
248+
* **problems**: `List[QueryProblem]` - List of problems in the request. A problem with a search request could be an invalid filter value, an operator that can't be used on a term, etc.
249+
"""
250+
251+
groups: Optional[List[FileEventGroup]] = Field(
252+
None, description="A list of file event counts by grouping term and doc count."
253+
)
254+
problems: Optional[List[QueryProblem]] = Field(
255+
None,
256+
description="List of problems in the request. A problem with a search request could be an invalid filter value, an operator that can't be used on a term, etc.",
257+
)

0 commit comments

Comments
 (0)