diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40ac6909..0f7bb500 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
here.
+## Unreleased
+
+### Added
+- Added the `state_v2` field to session states. Added the new session state `CLOSED_TP_BENIGN`.
+- Added support for the `ON` filter in file event queries.
+
+### Fixed
+- A bug where the SDK's V2 Watchlist methods were returning the wrong models.
+
## 2.7.0 - 2025-11-13
### Updated
diff --git a/docs/integration-guides/index.md b/docs/integration-guides/index.md
deleted file mode 100644
index 7ea69115..00000000
--- a/docs/integration-guides/index.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Integration Guides
-
-Here you'll find guides to integrating Code42 with various 3rd party platforms.
diff --git a/docs/integration-guides/sentinel/azure-sentinel-data-collector.md b/docs/integration-guides/sentinel/azure-sentinel-data-collector.md
deleted file mode 100644
index 988e3332..00000000
--- a/docs/integration-guides/sentinel/azure-sentinel-data-collector.md
+++ /dev/null
@@ -1,153 +0,0 @@
-### Prepare your environment
-
-- [Install py42 into your Python environment](https://py42docs.code42.com/en/stable/userguides/gettingstarted.html#installation)
-- [Create a Code42 API Client](https://support.code42.com/hc/en-us/articles/14827617150231) to authenticate py42. The `Alerts Read` permission is required to query alerts.
-- Gather your Azure Log Analytics `Workspace ID` and either `Primary Key` or `Secondary Key` to authenticate requests pushing data into Sentinel.
-
-
-
-### Writing the script
-
-First, import the py42 SDK client and required classes for building your Alert query:
-
-```python
-import py42.sdk
-from py42.sdk.queries.alerts.alert_query import AlertQuery
-from py42.sdk.queries.alerts.filters import DateObserved
-```
-
-Then construct your query. This example will search for Alerts created within the past 3 days. See
-[py42 documentation](https://py42docs.code42.com/en/stable/userguides/searches.html#search-alerts) for details on how
-to customize your own query.
-
-```python
-from datetime import datetime, timedelta
-
-date_filter = DateObserved.on_or_after(datetime.utcnow() - timedelta(days=3))
-alert_query = AlertQuery(date_filter)
-```
-
-
-
-Initialize the py42 SDK object with your API client details:
-
-```python
-sdk = py42.sdk.from_api_client(
- host_address="https://console.us.code42.com",
- client_id="",
- client_secret=""
-)
-```
-
-Fetch the alerts with your query:
-
-```python
-response = sdk.alerts.search(query)
-```
-
-
-
-The Sentinel HTTP endpoint accepts a list of JSON objects in the request body, to prepare the py42 response for sending
-to Sentinel, you just need to extract the `alerts` data from the py42 response and convert it to a JSON string:
-
-```python
-import json
-
-response = sdk.alerts.search(query)
-body = json.dumps(response.data["alerts"])
-```
-
-Then we can use the [Python example](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-collector-api#python-sample)
-from the Data Collector API documentation to authenticate and send the Incydr Alerts to Sentinel. The example is
-recreated here with the py42 code replacing the example data:
-
-```python
-import json
-import requests
-import datetime
-import hashlib
-import hmac
-import base64
-
-import py42.sdk
-from py42.sdk.queries.alerts.alert_query import AlertQuery
-from py42.sdk.queries.alerts.filters import DateObserved
-
-# Update the customer ID to your Log Analytics workspace ID
-customer_id = 'xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
-
-# For the shared key, use either the primary or the secondary Connected Sources client authentication key
-shared_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
-
-# This creates a new Custom Log type for Incydr Alerts
-log_type = 'IncydrAlert'
-
-
-#####################
-##Incydr Alert Query#
-#####################
-
-date_filter = DateObserved.on_or_after(datetime.datetime.utcnow() - datetime.timedelta(days=3))
-alert_query = AlertQuery(date_filter)
-
-sdk = py42.sdk.from_api_client(
- host_address="https://console.us.code42.com",
- client_id="",
- client_secret=""
-)
-response = sdk.alerts.search(query)
-body = json.dumps(response.data["alerts"])
-
-#####################
-##Sentinel Functions#
-#####################
-
-# Build the API signature
-def build_signature(customer_id, shared_key, date, content_length, method, content_type, resource):
- x_headers = 'x-ms-date:' + date
- string_to_hash = method + "\n" + str(content_length) + "\n" + content_type + "\n" + x_headers + "\n" + resource
- bytes_to_hash = bytes(string_to_hash, encoding="utf-8")
- decoded_key = base64.b64decode(shared_key)
- encoded_hash = base64.b64encode(hmac.new(decoded_key, bytes_to_hash, digestmod=hashlib.sha256).digest()).decode()
- authorization = "SharedKey {}:{}".format(customer_id,encoded_hash)
- return authorization
-
-# Build and send a request to the POST API
-def post_data(customer_id, shared_key, body, log_type):
- method = 'POST'
- content_type = 'application/json'
- resource = '/api/logs'
- rfc1123date = datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
- content_length = len(body)
- signature = build_signature(customer_id, shared_key, rfc1123date, content_length, method, content_type, resource)
- uri = 'https://' + customer_id + '.ods.opinsights.azure.com' + resource + '?api-version=2016-04-01'
-
- headers = {
- 'content-type': content_type,
- 'Authorization': signature,
- 'Log-Type': log_type,
- 'x-ms-date': rfc1123date
- }
-
- response = requests.post(uri,data=body, headers=headers)
- if (response.status_code >= 200 and response.status_code <= 299):
- print('Accepted')
- else:
- print("Response code: {}".format(response.status_code))
-
-post_data(customer_id, shared_key, body, log_type)
-```
-
-After the script is run, you should be able to query the `IncydrAlert_CL` log table and see the Incydr Alert data.
diff --git a/docs/integration-guides/sentinel/azure-sentinel-log-analytics.md b/docs/integration-guides/sentinel/azure-sentinel-log-analytics.md
deleted file mode 100644
index 01c1353a..00000000
--- a/docs/integration-guides/sentinel/azure-sentinel-log-analytics.md
+++ /dev/null
@@ -1,69 +0,0 @@
-
-### Prepare your environment
-
-- Install the [Azure Log Analytics Agent](https://learn.microsoft.com/en-us/azure/azure-monitor/agents/log-analytics-agent)
-- Install the [Code42 CLI](https://clidocs.code42.com/en/stable/userguides/gettingstarted.html)
-- Configure a Code42 CLI [profile](https://clidocs.code42.com/en/stable/userguides/profile.html) for the `omsagent` user:
- 1. Become the `omsagent` user: `sudo su omsagent`
- 2. Create a new Code42 CLI profile: `code42 profile create-api-client --name sentinel --server --api-client-id --secret `
- 3. Test the command you'll be using to ingest Inycdr data, for example: `code42 alerts search --begin 3d --format raw-json`
-
-
-
-### Configure the Log Analytics Agent (`omsagent`)
-
-Following the steps from [Azure Documentation](https://learn.microsoft.com/en-us/azure/azure-monitor/agents/data-sources-json),
-below is an example config that ingests Incydr Alerts using the Code42 CLI. The configuration examples can be added to
-`/etc/opt/microsoft/omsagent//conf/omsagent.conf` or in their own separate file in the
-`/etc/opt/microsoft/omsagent//conf/omsagent.d/` folder.
-
-#### The input configuration
-
-```
-
- type exec
- command code42 alerts search -b 1d --format raw-json --checkpoint sentinel
- format json
- tag oms.api.incydr
- run_interval 1h
-
-```
-
-The `code42` command has two important options, the `--format raw-json` output causes the CLI to write each Alert JSON
-object to stdout on its own line, and the `--checkpoint sentinel` option tells the CLI to store the last retrieved alert
-timestamp in the CLI profile, and subsequent runs will use that checkpoint date as the starting point for the next query.
-This prevents duplicate alerts from being ingested, as the query will only search for alerts _after_ the last seen one.
-
-The `tag` config value defines what Custom Log table the events will be written to. Whatever is after `oms.api.` in the
-tag will become a new Custom Log table (in this example the table name becomes `incydr_CL`).
-
-#### The output configuration
-
-```
-
- type out_oms_api
- log_level info
-
- buffer_chunk_limit 5m
- buffer_type file
- buffer_path /var/opt/microsoft/omsagent//state/out_oms_api_incydr*.buffer
- buffer_queue_limit 10
- flush_interval 20s
- retry_limit 10
- retry_wait 30s
-
-```
-
-Once the configuration is saved, restart the `omsagent` by running: `/opt/microsoft/omsagent/bin/service_control restart`.
-
-You should start seeing Incydr alerts being populated in the `incydr_CL` table shortly.
-
-For issues troubleshooting ingest using the Log Analytics agent, see [Microsoft's Troubleshooting FAQ](https://github.com/microsoft/OMS-Agent-for-Linux/blob/master/docs/Troubleshooting.md).
diff --git a/docs/integration-guides/sentinel/introduction.md b/docs/integration-guides/sentinel/introduction.md
deleted file mode 100644
index e325e0af..00000000
--- a/docs/integration-guides/sentinel/introduction.md
+++ /dev/null
@@ -1,10 +0,0 @@
-Microsoft Sentinel can accept arbitrary json data as Custom Log sources, so you can ingest almost any type of Incydr
-data for querying within Sentinel, including Alerts, File Events, and Audit Log events.
-
-Sentinel has two methods of ingesting JSON data as Custom Log sources: the data collector HTTP API, and the Log
-Analytics Linux agent. While the examples below will focus on Incydr Alerts, the patterns can be re-used for almost any
-type of data you can pull from the Code42 API or CLI.
-
-[Data collector API](azure-sentinel-data-collector.md)
-
-[Log Analytics Agent](azure-sentinel-log-analytics.md)
diff --git a/docs/sdk/enums.md b/docs/sdk/enums.md
index f0cc4dc4..fce7e7ff 100644
--- a/docs/sdk/enums.md
+++ b/docs/sdk/enums.md
@@ -555,6 +555,7 @@ Devices has been replaced by [Agents](#agents)
* **IN_PROGRESS** = `"IN_PROGRESS"`
* **CLOSED** = `"CLOSED"`
* **CLOSED_TP** = `"CLOSED_TP"`
+* **CLOSED_TP_BENIGN** = `"CLOSED_TP_BENIGN"`
* **CLOSED_FP** = `"CLOSED_FP"`
* **OPEN_NEW_DATA** = `"OPEN_NEW_DATA"`
diff --git a/mkdocs.yml b/mkdocs.yml
index 6dffd7c6..dff770d8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -91,12 +91,6 @@ nav:
- Alerts (Deprecated): 'cli/cmds/alerts.md'
- Devices (Deprecated): 'cli/cmds/devices.md'
- Risk Profiles (Deprecated): 'cli/cmds/risk_profiles.md'
- - Guides:
- - Introduction: 'integration-guides/index.md'
- - Microsoft Sentinel:
- - Introduction: 'integration-guides/sentinel/introduction.md'
- - Data Collector API: 'integration-guides/sentinel/azure-sentinel-data-collector.md'
- - Log Analytics Agent: 'integration-guides/sentinel/azure-sentinel-log-analytics.md'
markdown_extensions:
- attr_list
diff --git a/pyproject.toml b/pyproject.toml
index 2c8bc007..2a5359ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,7 +28,7 @@ dependencies = [
"requests",
"requests-toolbelt",
"rich",
- "pydantic>=2.11,<2.12",
+ "pydantic>=2.11",
"pydantic-settings",
"isodate",
"python-dateutil",
diff --git a/src/_incydr_cli/cmds/sessions.py b/src/_incydr_cli/cmds/sessions.py
index dab039f3..b27433c0 100644
--- a/src/_incydr_cli/cmds/sessions.py
+++ b/src/_incydr_cli/cmds/sessions.py
@@ -322,7 +322,7 @@ def bulk_update_state(
Bulk update the state of multiple sessions. Optionally attach a note.
NEW_STATE specifies the new state to which sessions will be updated.
- Must be one of the following: 'OPEN', 'IN_PROGRESS', 'CLOSED', 'CLOSED_TP', 'CLOSED_FP', 'OPEN_NEW_DATA'
+ Must be one of the following: 'OPEN', 'IN_PROGRESS', 'CLOSED', 'CLOSED_TP', 'CLOSED_TP_BENIGN', 'CLOSED_FP', 'OPEN_NEW_DATA'
Takes a single arg `FILE` which specifies the path to the file (use "-" to read from stdin).
File format can either be CSV or [JSON Lines format](https://jsonlines.org) (Default is CSV).
diff --git a/src/_incydr_cli/cmds/trusted_activities.py b/src/_incydr_cli/cmds/trusted_activities.py
index 689527cd..fbdbaec4 100644
--- a/src/_incydr_cli/cmds/trusted_activities.py
+++ b/src/_incydr_cli/cmds/trusted_activities.py
@@ -334,7 +334,7 @@ def _output_trusted_activity(
t.add_column("Action Groups")
# exclude activity action groups from the info panel
- include = list(TrustedActivity.__fields__.keys())
+ include = list(TrustedActivity.model_fields.keys())
include.remove("activity_action_groups")
t.add_row(
model_as_card(
diff --git a/src/_incydr_cli/cmds/watchlists.py b/src/_incydr_cli/cmds/watchlists.py
index 830728ac..eaf39733 100644
--- a/src/_incydr_cli/cmds/watchlists.py
+++ b/src/_incydr_cli/cmds/watchlists.py
@@ -29,9 +29,9 @@
from _incydr_sdk.utils import model_as_card
from _incydr_sdk.watchlists.models.responses import IncludedDepartment
from _incydr_sdk.watchlists.models.responses import IncludedDirectoryGroup
-from _incydr_sdk.watchlists.models.responses import Watchlist
from _incydr_sdk.watchlists.models.responses import WatchlistActor
from _incydr_sdk.watchlists.models.responses import WatchlistUser
+from _incydr_sdk.watchlists.models.responses import WatchlistV2
MAX_USER_DISPLAY_COUNT = 25
@@ -115,7 +115,7 @@ def list_(
actor = user
client = Client()
watchlists = client.watchlists.v2.iter_all(actor_id=actor)
- _output_results(watchlists, Watchlist, format_, columns)
+ _output_results(watchlists, WatchlistV2, format_, columns)
@watchlists.command(cls=IncydrCommand)
diff --git a/src/_incydr_sdk/enums/sessions.py b/src/_incydr_sdk/enums/sessions.py
index 080b1ddc..561b426d 100644
--- a/src/_incydr_sdk/enums/sessions.py
+++ b/src/_incydr_sdk/enums/sessions.py
@@ -10,6 +10,7 @@ class SessionStates(_Enum):
IN_PROGRESS = "IN_PROGRESS"
CLOSED = "CLOSED"
CLOSED_TP = "CLOSED_TP"
+ CLOSED_TP_BENIGN = "CLOSED_TP_BENIGN"
CLOSED_FP = "CLOSED_FP"
OPEN_NEW_DATA = "OPEN_NEW_DATA"
diff --git a/src/_incydr_sdk/queries/file_events.py b/src/_incydr_sdk/queries/file_events.py
index a4e426c3..e88caa80 100644
--- a/src/_incydr_sdk/queries/file_events.py
+++ b/src/_incydr_sdk/queries/file_events.py
@@ -29,6 +29,7 @@
from _incydr_sdk.file_events.models.response import SavedSearch
from _incydr_sdk.file_events.models.response import SearchFilterGroup
from _incydr_sdk.file_events.models.response import SearchFilterGroupV2
+from _incydr_sdk.queries.utils import parse_ts_to_date_str
from _incydr_sdk.queries.utils import parse_ts_to_ms_str
_term_enum_map = {
@@ -347,6 +348,33 @@ def date_range(self, term: str, start_date=None, end_date=None):
)
return self
+ def on(self, term: str, date=None):
+ """
+ Adds a date-based filter for the specified term.
+
+ When passed as part of a query, returns events on the specified date.
+
+ Example:
+ `EventQuery(**kwargs).date_range(term="event.inserted", start_date="P1D")` creates a query that returns all events inserted into Forensic Search within the past day.
+
+ **Parameters**:
+
+ * **term**: `str` - The term which corresponds to a file event field.
+ * **date**: `int`, `float`, `str`, `datetime` - The date to query for events. Defaults to None.
+ """
+ self.groups.append(
+ FilterGroup(
+ filters=[
+ Filter(
+ term=term,
+ operator=Operator.ON,
+ value=parse_ts_to_date_str(date),
+ )
+ ]
+ )
+ )
+ return self
+
def matches_any(self):
"""
Sets operator to combine multiple filters to `OR`.
@@ -439,10 +467,10 @@ def _validate_duration_str(iso_duration_str):
def _create_filter_group(filter_group: SearchFilterGroup) -> FilterGroup:
filters = [
- Filter.construct(value=f.value, operator=f.operator, term=f.term)
+ Filter.model_construct(value=f.value, operator=f.operator, term=f.term)
for f in filter_group.filters
]
- return FilterGroup.construct(
+ return FilterGroup.model_construct(
filterClause=filter_group.filter_clause, filters=filters
)
@@ -451,7 +479,7 @@ def _create_filter_group_v2(filter_group_v2: SearchFilterGroupV2) -> FilterGroup
subgroups = []
for subgroup in filter_group_v2.subgroups:
subgroups.append(_handle_filter_group_type(subgroup))
- return FilterGroupV2.construct(
+ return FilterGroupV2.model_construct(
subgroupClause=filter_group_v2.subgroup_clause, subgroups=subgroups
)
diff --git a/src/_incydr_sdk/queries/utils.py b/src/_incydr_sdk/queries/utils.py
index a22a6b68..1e30f986 100644
--- a/src/_incydr_sdk/queries/utils.py
+++ b/src/_incydr_sdk/queries/utils.py
@@ -12,6 +12,27 @@
DATE_STR_FORMAT = "%Y-%m-%d"
+def parse_ts_to_date_str(timestamp: Union[str, int, float, datetime, date]):
+ """
+ Parse int/float/str/datetime timestamp to string milliseconds precision.
+
+ Args:
+ timestamp (str or int or float or datetime): A POSIX timestamp.
+
+ **Returns**:
+ (str): A str representing the given date. Example output looks like
+ '2020-03-25'.
+ """
+ # convert str/int/float values to datetime
+ if isinstance(timestamp, (int, float)):
+ timestamp = datetime.fromtimestamp(timestamp, tz=timezone.utc)
+ elif isinstance(timestamp, str):
+ timestamp = parse_str_to_dt(timestamp)
+ timestamp.replace(tzinfo=timezone.utc)
+ # parse datetime to string
+ return timestamp.strftime(DATE_STR_FORMAT)
+
+
def parse_ts_to_ms_str(timestamp: Union[str, int, float, datetime, date]):
"""
Parse int/float/str/datetime timestamp to string milliseconds precision.
diff --git a/src/_incydr_sdk/sessions/models/models.py b/src/_incydr_sdk/sessions/models/models.py
index b997e677..a040a7fa 100644
--- a/src/_incydr_sdk/sessions/models/models.py
+++ b/src/_incydr_sdk/sessions/models/models.py
@@ -48,7 +48,12 @@ class Score(Model):
class State(Model):
source_timestamp: Optional[int] = Field(None, alias="sourceTimestamp")
- state: SessionStates = None
+ state: SessionStates = Field(None, description="Deprecated. Use state_v2 instead.")
+ state_v2: str = Field(
+ None,
+ alias="stateV2",
+ description="The state assigned to the session. The value is an item from the SessionStates enum. Clients should be tolerant of additional values that may be added in the future.",
+ )
user_id: Optional[str] = Field(
None, alias="userId", description="A User ID. (Deprecated)"
)
diff --git a/src/_incydr_sdk/utils.py b/src/_incydr_sdk/utils.py
index 093988e3..2d8d7d19 100644
--- a/src/_incydr_sdk/utils.py
+++ b/src/_incydr_sdk/utils.py
@@ -182,7 +182,11 @@ class Parent(BaseModel):
model = type(model)
for name, field in model.model_fields.items():
model_field_type = _get_model_type(field.annotation)
- if _is_single(field.annotation) and issubclass(model_field_type, BaseModel):
+ if (
+ _is_single(field.annotation)
+ and isinstance(model_field_type, type)
+ and issubclass(model_field_type, BaseModel)
+ ):
for child_name in flatten_fields(model_field_type):
yield f"{name}.{child_name}"
else:
@@ -239,13 +243,17 @@ def _is_single(type) -> bool:
return True
-def _get_model_type(type) -> Type[BaseModel]:
+def _get_model_type(inputType) -> Type[BaseModel]:
"""Given a type annotation, gets the type that subclasses BaseModel"""
- if issubclass(type, BaseModel):
- return type
- elif get_origin(type):
+ if isinstance(inputType, type) and issubclass(inputType, BaseModel):
+ return inputType
+ elif get_origin(inputType):
return next(
- (_get_model_type(item) for item in get_args(type) if _get_model_type(item)),
+ (
+ _get_model_type(item)
+ for item in get_args(inputType)
+ if _get_model_type(item)
+ ),
None,
)
return None
diff --git a/src/_incydr_sdk/watchlists/client.py b/src/_incydr_sdk/watchlists/client.py
index cc3f9d01..00ded10d 100644
--- a/src/_incydr_sdk/watchlists/client.py
+++ b/src/_incydr_sdk/watchlists/client.py
@@ -19,10 +19,10 @@
from _incydr_sdk.watchlists.models.responses import IncludedDepartmentsList
from _incydr_sdk.watchlists.models.responses import IncludedDirectoryGroup
from _incydr_sdk.watchlists.models.responses import IncludedDirectoryGroupsList
-from _incydr_sdk.watchlists.models.responses import Watchlist
from _incydr_sdk.watchlists.models.responses import WatchlistActor
from _incydr_sdk.watchlists.models.responses import WatchlistMembersListV2
-from _incydr_sdk.watchlists.models.responses import WatchlistsPage
+from _incydr_sdk.watchlists.models.responses import WatchlistsPageV2
+from _incydr_sdk.watchlists.models.responses import WatchlistV2
class WatchlistsClient:
@@ -62,7 +62,7 @@ def __init__(self, parent):
def get_page(
self, page_num: int = 1, page_size: int = None, actor_id: str = None
- ) -> WatchlistsPage:
+ ) -> WatchlistsPageV2:
"""
Get a page of watchlists.
@@ -81,11 +81,11 @@ def get_page(
page=page_num, pageSize=page_size, actorId=actor_id
)
response = self._parent.session.get(self._uri, params=data.dict())
- return WatchlistsPage.parse_response(response)
+ return WatchlistsPageV2.parse_response(response)
def iter_all(
self, page_size: int = None, actor_id: str = None
- ) -> Iterator[Watchlist]:
+ ) -> Iterator[WatchlistV2]:
"""
Iterate over all watchlists.
@@ -102,7 +102,7 @@ def iter_all(
if len(page.watchlists) < page_size:
break
- def get(self, watchlist_id: str) -> Watchlist:
+ def get(self, watchlist_id: str) -> WatchlistV2:
"""
Get a single watchlist.
@@ -113,11 +113,11 @@ def get(self, watchlist_id: str) -> Watchlist:
**Returns**: A [`Watchlist`][watchlist-model] object.
"""
response = self._parent.session.get(f"{self._uri}/{watchlist_id}")
- return Watchlist.parse_response(response)
+ return WatchlistV2.parse_response(response)
def create(
self, watchlist_type: WatchlistType, title: str = None, description: str = None
- ) -> Watchlist:
+ ) -> WatchlistV2:
"""
Create a new watchlist.
@@ -137,7 +137,7 @@ def create(
description=description, title=title, watchlistType=watchlist_type
)
response = self._parent.session.post(url=self._uri, json=data.dict())
- watchlist = Watchlist.parse_response(response)
+ watchlist = WatchlistV2.parse_response(response)
self._watchlist_type_id_map[watchlist_type] = watchlist.watchlist_id
return watchlist
@@ -155,7 +155,7 @@ def delete(self, watchlist_id: str):
def update(
self, watchlist_id: str, title: str = None, description: str = None
- ) -> Watchlist:
+ ) -> WatchlistV2:
"""
Update a custom watchlist.
@@ -177,7 +177,7 @@ def update(
response = self._parent.session.patch(
f"{self._uri}/{watchlist_id}", params=query, json=data.dict()
)
- return Watchlist.parse_response(response)
+ return WatchlistV2.parse_response(response)
def get_member(self, watchlist_id: str, actor_id: str) -> WatchlistActor:
"""
diff --git a/src/_incydr_sdk/watchlists/models/responses.py b/src/_incydr_sdk/watchlists/models/responses.py
index 48f6b345..9b2fe54f 100644
--- a/src/_incydr_sdk/watchlists/models/responses.py
+++ b/src/_incydr_sdk/watchlists/models/responses.py
@@ -222,6 +222,61 @@ class IncludedActorsList(ResponseModel):
)
+class WatchlistStatsV2(ResponseModel):
+ """
+ A model representing stats for a watchlist.
+
+ **Fields**:
+
+ * **included_actors_count**: `int` - The number of actors explicitly included on the watchlist.
+ * **included_departments_count**: `int` - The number of departments explicitly included on the watchlist.
+ * **included_directory_groups_count**: `int` - The number of directory groups explicitly included on the watchlist.
+ * **excluded_actors_count**: `int` - The number of actors explicitly excluded from the watchlist.
+ * **excluded_departments_count**: `int` - The number of departments explicitly excluded from the watchlist.
+ * **excluded_directory_groups_count**: `int` - The number of directory groups explicitly excluded from the watchlist.
+ """
+
+ included_departments_count: Optional[int] = Field(
+ None,
+ description="The number of departments explicitly included on the watchlist.",
+ alias="includedDepartmentsCount",
+ table=lambda included_departments_count: included_departments_count or 0,
+ )
+ included_directory_groups_count: Optional[int] = Field(
+ None,
+ description="The number of directory groups explicitly included on the watchlist.",
+ alias="includedDirectoryGroupsCount",
+ table=lambda included_directory_groups_count: included_directory_groups_count
+ or 0,
+ )
+ included_actors_count: Optional[int] = Field(
+ None,
+ description="The number of actors explicitly included on the watchlist.",
+ alias="includedActorsCount",
+ table=lambda included_users_count: included_users_count or 0,
+ )
+ excluded_actors_count: Optional[int] = Field(
+ None,
+ description="The number of actors explicitly excluded from the watchlist.",
+ alias="excludedActorsCount",
+ # displays a value of None as 0
+ table=lambda excluded_users_count: excluded_users_count or 0,
+ )
+ excluded_departments_count: Optional[int] = Field(
+ None,
+ description="The number of departments explicitly excluded from the watchlist.",
+ alias="excludedDepartmentsCount",
+ table=lambda included_departments_count: included_departments_count or 0,
+ )
+ excluded_directory_groups_count: Optional[int] = Field(
+ None,
+ description="The number of directory groups explicitly excluded from the watchlist.",
+ alias="excludedDirectoryGroupsCount",
+ table=lambda included_directory_groups_count: included_directory_groups_count
+ or 0,
+ )
+
+
class WatchlistStats(ResponseModel):
"""
A model representing stats for a watchlist.
@@ -338,6 +393,34 @@ class Watchlist(ResponseModel):
)
+class WatchlistV2(ResponseModel):
+ """
+ A model representing an Incydr Watchlist.
+
+ **Fields**:
+
+ * **description**: `str` - Optional description for a custom watchlist.
+ * **list_type**: [`WatchlistType`][watchlist-types] - The watchlist type.
+ * **stats**: `WatchlistStatsV2` - Watchlist membership information.
+ * **tenant_id**: `str` - A unique tenant ID.
+ * **title**: `str` - Title for a custom watchlist.
+ * **watchlist_id**: `str` - A unique watchlist ID.
+ """
+
+ description: Optional[str] = Field(
+ None, description="Description for a custom watchlist."
+ )
+ list_type: Union[WatchlistType, str] = Field(alias="listType")
+ stats: Optional[WatchlistStatsV2] = None
+ tenant_id: Optional[str] = Field(
+ None, description="A unique tenant ID.", alias="tenantId"
+ )
+ title: Optional[str] = Field(None, description="Title for a custom watchlist.")
+ watchlist_id: Optional[str] = Field(
+ None, description="A unique watchlist ID.", alias="watchlistId"
+ )
+
+
class WatchlistsPage(ResponseModel):
"""
A model representing a page of `Watchlist` objects.
@@ -357,3 +440,24 @@ class WatchlistsPage(ResponseModel):
watchlists: Optional[List[Watchlist]] = Field(
None, description="The list of watchlists."
)
+
+
+class WatchlistsPageV2(ResponseModel):
+ """
+ A model representing a page of `Watchlist` objects.
+
+ **Fields**:
+
+ * **total_count**: `int` - Total count of watchlists found by the query.
+ * **watchlists**: `List[WatchlistV2]` - The list `n` number of watchlists retrieved from the query, where `n=page_size`.
+ """
+
+ total_count: Optional[int] = Field(
+ None,
+ description="The total count of all watchlists.",
+ examples=[10],
+ alias="totalCount",
+ )
+ watchlists: Optional[List[WatchlistV2]] = Field(
+ None, description="The list of watchlists."
+ )
diff --git a/src/incydr/models.py b/src/incydr/models.py
index 44e59aa7..1c33b612 100644
--- a/src/incydr/models.py
+++ b/src/incydr/models.py
@@ -46,7 +46,9 @@
from _incydr_sdk.watchlists.models.responses import WatchlistMembersList
from _incydr_sdk.watchlists.models.responses import WatchlistMembersListV2
from _incydr_sdk.watchlists.models.responses import WatchlistsPage
+from _incydr_sdk.watchlists.models.responses import WatchlistsPageV2
from _incydr_sdk.watchlists.models.responses import WatchlistUser
+from _incydr_sdk.watchlists.models.responses import WatchlistV2
__all__ = [
@@ -82,7 +84,9 @@
"DepartmentsPage",
"DirectoryGroupsPage",
"DirectoryGroup",
+ "WatchlistV2",
"Watchlist",
+ "WatchlistsPageV2",
"WatchlistsPage",
"WatchlistMembersListV2",
"WatchlistMembersList",
diff --git a/tests/queries/test_event_query.py b/tests/queries/test_event_query.py
index 4320df67..09a8f113 100644
--- a/tests/queries/test_event_query.py
+++ b/tests/queries/test_event_query.py
@@ -388,3 +388,17 @@ def test_subquery_handles_nested_subquery():
assert isinstance(q.groups[0].subgroups[0].subgroups[0], FilterGroup)
assert q.groups[0].subgroups[0].subgroups[0].filters[0].term == "term"
assert q.groups[0].subgroups[0].subgroups[0].filters[0].value == "value"
+
+
+def test_on_filter_creates_correct_filter():
+ q = EventQuery().on(term="date_term", date=datetime(2025, 1, 1, 1, 1, 1, 1))
+ expected = FilterGroup(
+ filters=[
+ Filter(
+ term="date_term",
+ operator="ON",
+ value="2025-01-01",
+ )
+ ]
+ )
+ assert q.groups.pop() == expected
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 3a39880a..29ffec4b 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -59,6 +59,7 @@
{
"sourceTimestamp": POSIX_TS,
"state": "OPEN",
+ "stateV2": "OPEN",
"userId": "string",
}
],
diff --git a/tests/test_watchlists.py b/tests/test_watchlists.py
index 492ef4d3..043fadb2 100644
--- a/tests/test_watchlists.py
+++ b/tests/test_watchlists.py
@@ -23,13 +23,61 @@
from _incydr_sdk.watchlists.models.responses import WatchlistMembersList
from _incydr_sdk.watchlists.models.responses import WatchlistMembersListV2
from _incydr_sdk.watchlists.models.responses import WatchlistsPage
+from _incydr_sdk.watchlists.models.responses import WatchlistsPageV2
from _incydr_sdk.watchlists.models.responses import WatchlistUser
+from _incydr_sdk.watchlists.models.responses import WatchlistV2
from tests.conftest import TEST_TOKEN
TEST_WATCHLIST_ID = "1c7dd799-1aa0-4f3a-bae8-1d3242fc2af6"
TEST_ID = "b799bf9c-8838-480f-85dc-438d74e3ca0d"
TEST_WATCHLIST_1 = {
+ "description": None,
+ "listType": "DEPARTING_EMPLOYEE",
+ "stats": {
+ "includedDepartmentsCount": 2,
+ "includedDirectoryGroupsCount": 1,
+ "includedActorsCount": 42,
+ "excludedActorsCount": 4,
+ "excludedDepartmentsCount": 0,
+ "excludedDirectoryGroupsCount": 0,
+ },
+ "tenantId": "code-42",
+ "title": "departing employee",
+ "watchlistId": TEST_WATCHLIST_ID,
+}
+TEST_WATCHLIST_2 = {
+ "description": "custom watchlist",
+ "listType": "CUSTOM",
+ "stats": {
+ "includedDepartmentsCount": 0,
+ "includedDirectoryGroupsCount": 10,
+ "includedActorsCount": 43,
+ "excludedActorsCount": 13,
+ "excludedDepartmentsCount": 0,
+ "excludedDirectoryGroupsCount": 0,
+ },
+ "tenantId": "code-42",
+ "title": "test",
+ "watchlistId": "1-watchlist-43",
+}
+
+TEST_WATCHLIST_3 = {
+ "description": None,
+ "listType": "NEW_EMPLOYEE",
+ "stats": {
+ "includedDepartmentsCount": 0,
+ "includedDirectoryGroupsCount": 1,
+ "includedActorsCount": 10,
+ "excludedActorsCount": 1,
+ "excludedDepartmentsCount": 0,
+ "excludedDirectoryGroupsCount": 0,
+ },
+ "tenantId": "code-42",
+ "title": None,
+ "watchlistId": "1-watchlist-44",
+}
+TEST_WATCHLIST_V1_1 = {
"description": None,
"listType": "DEPARTING_EMPLOYEE",
"stats": {
@@ -42,7 +90,7 @@
"title": "departing employee",
"watchlistId": TEST_WATCHLIST_ID,
}
-TEST_WATCHLIST_2 = {
+TEST_WATCHLIST_V1_2 = {
"description": "custom watchlist",
"listType": "CUSTOM",
"stats": {
@@ -55,8 +103,7 @@
"title": "test",
"watchlistId": "1-watchlist-43",
}
-
-TEST_WATCHLIST_3 = {
+TEST_WATCHLIST_V1_3 = {
"description": None,
"listType": "NEW_EMPLOYEE",
"stats": {
@@ -69,7 +116,6 @@
"title": None,
"watchlistId": "1-watchlist-44",
}
-
TEST_USER_1 = {
"addedTime": "2022-07-18T16:39:51.356082Z",
"userId": TEST_ID,
@@ -125,7 +171,7 @@ def mock_create_custom(httpserver_auth: HTTPServer):
}
httpserver_auth.expect_request(
"/v1/watchlists", method="POST", json=data
- ).respond_with_json(TEST_WATCHLIST_2)
+ ).respond_with_json(TEST_WATCHLIST_V1_2)
@pytest.fixture
@@ -145,7 +191,7 @@ def mock_create_departing_employee(httpserver_auth: HTTPServer):
data = {"description": None, "title": None, "watchlistType": "DEPARTING_EMPLOYEE"}
httpserver_auth.expect_request(
"/v1/watchlists", method="POST", json=data
- ).respond_with_json(TEST_WATCHLIST_1)
+ ).respond_with_json(TEST_WATCHLIST_V1_1)
@pytest.fixture
@@ -172,7 +218,7 @@ def mock_delete_v2(httpserver_auth: HTTPServer):
@pytest.fixture
def mock_get_all(httpserver_auth: HTTPServer):
- data = {"watchlists": [TEST_WATCHLIST_1, TEST_WATCHLIST_2], "totalCount": 2}
+ data = {"watchlists": [TEST_WATCHLIST_V1_1, TEST_WATCHLIST_V1_2], "totalCount": 2}
query = {"page": 1, "pageSize": 100}
@@ -354,7 +400,7 @@ def mock_get_directory_group_v2(httpserver_auth: HTTPServer):
def test_get_page_when_default_params_returns_expected_data_v2(mock_get_all_v2):
c = Client()
page = c.watchlists.v2.get_page()
- assert isinstance(page, WatchlistsPage)
+ assert isinstance(page, WatchlistsPageV2)
assert page.watchlists[0].json() == json.dumps(
TEST_WATCHLIST_1, separators=(",", ":")
)
@@ -377,7 +423,7 @@ def test_get_page_when_custom_params_returns_expected_data_v2(
c = Client()
page = c.watchlists.v2.get_page(page_num=2, page_size=42, actor_id="user-42")
- assert isinstance(page, WatchlistsPage)
+ assert isinstance(page, WatchlistsPageV2)
assert page.watchlists[0].json() == json.dumps(
TEST_WATCHLIST_1, separators=(",", ":")
)
@@ -415,7 +461,7 @@ def test_iter_all_when_default_params_returns_expected_data_v2(
expected = [TEST_WATCHLIST_1, TEST_WATCHLIST_2, TEST_WATCHLIST_3]
for item in iterator:
total += 1
- assert isinstance(item, Watchlist)
+ assert isinstance(item, WatchlistV2)
assert item.json() == json.dumps(expected.pop(0), separators=(",", ":"))
assert total == 3
@@ -427,7 +473,7 @@ def test_get_returns_expected_data_v2(httpserver_auth: HTTPServer):
c = Client()
watchlist = c.watchlists.v2.get(TEST_WATCHLIST_ID)
- assert isinstance(watchlist, Watchlist)
+ assert isinstance(watchlist, WatchlistV2)
assert watchlist.watchlist_id == TEST_WATCHLIST_ID
assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":"))
@@ -437,7 +483,7 @@ def test_create_when_required_params_returns_expected_data_v2(
):
c = Client()
watchlist = c.watchlists.v2.create(WatchlistType.DEPARTING_EMPLOYEE)
- assert isinstance(watchlist, Watchlist)
+ assert isinstance(watchlist, WatchlistV2)
assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":"))
@@ -446,7 +492,7 @@ def test_create_when_all_params_returns_expected_data_v2(mock_create_custom_v2):
watchlist = c.watchlists.v2.create(
"CUSTOM", title="test", description="custom watchlist"
)
- assert isinstance(watchlist, Watchlist)
+ assert isinstance(watchlist, WatchlistV2)
assert watchlist.json() == json.dumps(TEST_WATCHLIST_2, separators=(",", ":"))
@@ -479,7 +525,7 @@ def test_update_when_all_params_returns_expected_data_v2(httpserver_auth: HTTPSe
response = c.watchlists.v2.update(
TEST_WATCHLIST_ID, title="updated title", description="updated description"
)
- assert isinstance(response, Watchlist)
+ assert isinstance(response, WatchlistV2)
assert response.json() == json.dumps(watchlist, separators=(",", ":"))
@@ -497,7 +543,7 @@ def test_update_when_one_param_returns_expected_data_v2(httpserver_auth: HTTPSer
c = Client()
response = c.watchlists.v2.update(TEST_WATCHLIST_ID, title="updated title")
- assert isinstance(response, Watchlist)
+ assert isinstance(response, WatchlistV2)
assert response.json() == json.dumps(watchlist, separators=(",", ":"))
@@ -807,16 +853,16 @@ def test_get_page_when_default_params_returns_expected_data(mock_get_all):
page = c.watchlists.v1.get_page()
assert isinstance(page, WatchlistsPage)
assert page.watchlists[0].json() == json.dumps(
- TEST_WATCHLIST_1, separators=(",", ":")
+ TEST_WATCHLIST_V1_1, separators=(",", ":")
)
assert page.watchlists[1].json() == json.dumps(
- TEST_WATCHLIST_2, separators=(",", ":")
+ TEST_WATCHLIST_V1_2, separators=(",", ":")
)
assert page.total_count == len(page.watchlists) == 2
def test_get_page_when_custom_params_returns_expected_data(httpserver_auth: HTTPServer):
- data = {"watchlists": [TEST_WATCHLIST_1, TEST_WATCHLIST_2], "totalCount": 2}
+ data = {"watchlists": [TEST_WATCHLIST_V1_1, TEST_WATCHLIST_V1_2], "totalCount": 2}
query = {"page": 2, "pageSize": 42, "userId": "user-42"}
@@ -828,10 +874,10 @@ def test_get_page_when_custom_params_returns_expected_data(httpserver_auth: HTTP
page = c.watchlists.v1.get_page(page_num=2, page_size=42, user_id="user-42")
assert isinstance(page, WatchlistsPage)
assert page.watchlists[0].json() == json.dumps(
- TEST_WATCHLIST_1, separators=(",", ":")
+ TEST_WATCHLIST_V1_1, separators=(",", ":")
)
assert page.watchlists[1].json() == json.dumps(
- TEST_WATCHLIST_2, separators=(",", ":")
+ TEST_WATCHLIST_V1_2, separators=(",", ":")
)
assert page.total_count == len(page.watchlists) == 2
@@ -848,8 +894,8 @@ def test_iter_all_when_default_params_returns_expected_data(
"pageSize": 2,
}
- data_1 = {"watchlists": [TEST_WATCHLIST_1, TEST_WATCHLIST_2], "totalCount": 2}
- data_2 = {"watchlists": [TEST_WATCHLIST_3], "totalCount": 1}
+ data_1 = {"watchlists": [TEST_WATCHLIST_V1_1, TEST_WATCHLIST_V1_2], "totalCount": 2}
+ data_2 = {"watchlists": [TEST_WATCHLIST_V1_3], "totalCount": 1}
httpserver_auth.expect_ordered_request(
"/v1/watchlists", method="GET", query_string=urlencode(query_1)
@@ -861,7 +907,7 @@ def test_iter_all_when_default_params_returns_expected_data(
client = Client()
iterator = client.watchlists.v1.iter_all(page_size=2)
total = 0
- expected = [TEST_WATCHLIST_1, TEST_WATCHLIST_2, TEST_WATCHLIST_3]
+ expected = [TEST_WATCHLIST_V1_1, TEST_WATCHLIST_V1_2, TEST_WATCHLIST_V1_3]
for item in iterator:
total += 1
assert isinstance(item, Watchlist)
@@ -872,13 +918,13 @@ def test_iter_all_when_default_params_returns_expected_data(
def test_get_returns_expected_data(httpserver_auth: HTTPServer):
httpserver_auth.expect_request(
f"/v1/watchlists/{TEST_WATCHLIST_ID}", method="GET"
- ).respond_with_json(TEST_WATCHLIST_1)
+ ).respond_with_json(TEST_WATCHLIST_V1_1)
c = Client()
watchlist = c.watchlists.v1.get(TEST_WATCHLIST_ID)
assert isinstance(watchlist, Watchlist)
assert watchlist.watchlist_id == TEST_WATCHLIST_ID
- assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":"))
+ assert watchlist.json() == json.dumps(TEST_WATCHLIST_V1_1, separators=(",", ":"))
def test_create_when_required_params_returns_expected_data(
@@ -887,7 +933,7 @@ def test_create_when_required_params_returns_expected_data(
c = Client()
watchlist = c.watchlists.v1.create(WatchlistType.DEPARTING_EMPLOYEE)
assert isinstance(watchlist, Watchlist)
- assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":"))
+ assert watchlist.json() == json.dumps(TEST_WATCHLIST_V1_1, separators=(",", ":"))
def test_create_when_all_params_returns_expected_data(mock_create_custom):
@@ -896,7 +942,7 @@ def test_create_when_all_params_returns_expected_data(mock_create_custom):
"CUSTOM", title="test", description="custom watchlist"
)
assert isinstance(watchlist, Watchlist)
- assert watchlist.json() == json.dumps(TEST_WATCHLIST_2, separators=(",", ":"))
+ assert watchlist.json() == json.dumps(TEST_WATCHLIST_V1_2, separators=(",", ":"))
def test_create_when_custom_and_no_title_raises_error(httpserver_auth: HTTPServer):
@@ -914,7 +960,7 @@ def test_delete_returns_expected_data(mock_delete):
def test_update_when_all_params_returns_expected_data(httpserver_auth: HTTPServer):
query = {"paths": ["title", "description"]}
data = {"description": "updated description", "title": "updated title"}
- watchlist = TEST_WATCHLIST_2.copy()
+ watchlist = TEST_WATCHLIST_V1_2.copy()
watchlist["title"] = "updated title"
watchlist["description"] = "updated description"
httpserver_auth.expect_request(
@@ -935,7 +981,7 @@ def test_update_when_all_params_returns_expected_data(httpserver_auth: HTTPServe
def test_update_when_one_param_returns_expected_data(httpserver_auth: HTTPServer):
query = {"paths": ["title"]}
data = {"title": "updated title", "description": None}
- watchlist = TEST_WATCHLIST_2.copy()
+ watchlist = TEST_WATCHLIST_V1_2.copy()
watchlist["title"] = "updated title"
httpserver_auth.expect_request(
f"/v1/watchlists/{TEST_WATCHLIST_ID}",