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}",