diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc21bd6..aca603e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,18 @@ how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 2.12.0 - 2026-05-04 ### Added - Added the `sdk.file-events.v2.search_groups` method to get approximate aggregate file event counts by a given grouping term. - Added the `GroupingEventQuery` class, used to make these queries. - Added the cli command `incydr file-events search-groups` to get approximate aggregate file event counts by a given grouping term. - +- Added the `type` parameter to session search methods and commands, allowing users to filter results to STANDARD or ACCOUNT_TAKE_OVER. +- Added the `is_high_value` option to trusted activity methods in the SDK, and the `--high-value` option to trusted activity methods in the CLI. +- Added the ability to specify domain trust for browser destinations, allowing users to specify when users should be allowed to use certain destinations when logged-in using a trusted domain. +- Added the ability to specify trust for file-transfer tools when adding a trusted domain. +- Added the `risk-indicator-categories` client to the SDK, allowing the listing of risk indicator categories, subcategories, and risk indicators. +- Added the `risk-indicator-categories` command to the CLI, allowing the listing of risk indicator categories. ## 2.11.0 - 2026-02-10 diff --git a/docs/cli/cmds/risk-indicator-categories.md b/docs/cli/cmds/risk-indicator-categories.md new file mode 100644 index 00000000..5edc1a76 --- /dev/null +++ b/docs/cli/cmds/risk-indicator-categories.md @@ -0,0 +1,6 @@ +# Risk Indicator Categories Commands + +::: mkdocs-click + :module: _incydr_cli.cmds.risk_indicator_categories + :command: risk_indicator_categories + :list_subcommands: diff --git a/docs/sdk/client.md b/docs/sdk/client.md index 0467b56a..dcf6b2b1 100644 --- a/docs/sdk/client.md +++ b/docs/sdk/client.md @@ -3,4 +3,4 @@ ::: incydr.Client :docstring: - :members: settings session request_history actors agents alerts alert_rules audit_log cases customer departments devices directory_groups file_events sessions trusted_activities users risk_profiles watchlists + :members: settings session request_history actors agents alerts alert_rules audit_log cases customer departments devices directory_groups file_events sessions trusted_activities users risk_profiles watchlists risk_indicator_categories diff --git a/docs/sdk/clients/risk_indicator_categories.md b/docs/sdk/clients/risk_indicator_categories.md new file mode 100644 index 00000000..8a2f3520 --- /dev/null +++ b/docs/sdk/clients/risk_indicator_categories.md @@ -0,0 +1,5 @@ +# Risk Indicator Categories + +::: _incydr_sdk.risk_indicator_categories.client.RiskIndicatorCategoriesV1 + :docstring: + :members: diff --git a/docs/sdk/enums.md b/docs/sdk/enums.md index fce7e7ff..105969e4 100644 --- a/docs/sdk/enums.md +++ b/docs/sdk/enums.md @@ -604,6 +604,8 @@ Devices has been replaced by [Agents](#agents) * **FILE_UPLOAD** = `"FILE_UPLOAD"` * **GIT_PUSH** = `"GIT_PUSH"` * **GIT_REPOSITORY_URI** = `"GIT_REPOSITORY_URI"` +* **USER_ACCOUNT_UPLOAD** = `"USER_ACCOUNT_UPLOAD"` +* **FILE_TRANSFER** = `"FILE_TRANSFER"` ### Cloud Sync Apps @@ -631,6 +633,30 @@ Devices has been replaced by [Agents](#agents) * **GMAIL** = `"GMAIL"` * **OFFICE_365** = `"OFFICE_365"` +* **GOOGLE_DRIVE** = `"GOOGLE_DRIVE"` + + +### Browser Destinations + +::: incydr.enums.trusted_activities.BrowserDestination + :docstring: + +* **AIRTABLE** = `"AIRTABLE"` +* **AMAZON_WEB_SERVICES** = `"AMAZON_WEB_SERVICES"` +* **BLACKBOX** = `"BLACKBOX"` +* **BOX** = `"BOX"` +* **CHATGPT** = `"CHATGPT"` +* **CLAUDE** = `"CLAUDE"` +* **CONCUR** = `"CONCUR"` +* **CURSOR** = `"CURSOR"` +* **DROPBOX** = `"DROPBOX"` +* **GOOGLE_WORKSPACE** = `"GOOGLE_WORKSPACE"` +* **MICROSOFT_365** = `"MICROSOFT_365"` +* **NOTTA** = `"NOTTA"` +* **OTTER** = `"OTTER"` +* **PERPLEXITY** = `"PERPLEXITY"` +* **SLACK** = `"SLACK"` +* **YOU_DOT_COM** = `"YOU_DOT_COM"` ### Principal Types diff --git a/docs/sdk/models.md b/docs/sdk/models.md index dd4015fe..1f7f40a4 100644 --- a/docs/sdk/models.md +++ b/docs/sdk/models.md @@ -329,3 +329,26 @@ ExcludedUsersList is deprecated. Use ExcludedActorsList instead. ::: incydr.models.IncludedDirectoryGroup :docstring: + +## Risk Indicator Categories +--- + +### `RiskIndicator` model + +::: incydr.models.RiskIndicator + :docstring: + +### `RiskIndicatorSubcategory` model + +::: incydr.models.RiskIndicatorSubcategory + :docstring: + +### `RiskIndicatorCategory` model + +::: incydr.models.RiskIndicatorCategory + :docstring: + +### `RiskIndicatorCategoriesResponsePage` model + +::: incydr.models.RiskIndicatorCategoriesResponsePage + :docstring: diff --git a/mkdocs.yml b/mkdocs.yml index dff770d8..a27fff55 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,7 @@ nav: - File Event Querying: 'sdk/clients/file_event_queries.md' - Legal Hold: 'sdk/clients/legal_hold.md' - Orgs: 'sdk/clients/orgs.md' + - Risk Indicator Categories: 'sdk/clients/risk_indicator_categories.md' - Sessions: 'sdk/clients/sessions.md' - Trusted Activites: 'sdk/clients/trusted_activities.md' - Users: 'sdk/clients/users.md' @@ -84,6 +85,7 @@ nav: - Files: 'cli/cmds/files.md' - Legal Hold: 'cli/cmds/legal_hold.md' - Orgs: 'cli/cmds/orgs.md' + - Risk Indicator Categories: 'cli/cmds/risk_indicator_categories.md' - Sessions: 'cli/cmds/sessions.md' - Trusted Activites: 'cli/cmds/trusted_activities.md' - Users: 'cli/cmds/users.md' diff --git a/src/_incydr_cli/cmds/risk_indicator_categories.py b/src/_incydr_cli/cmds/risk_indicator_categories.py new file mode 100644 index 00000000..e41496d5 --- /dev/null +++ b/src/_incydr_cli/cmds/risk_indicator_categories.py @@ -0,0 +1,103 @@ +import itertools +from typing import Iterator +from typing import Optional + +import click + +from _incydr_cli import console +from _incydr_cli import logging_options +from _incydr_cli import render +from _incydr_cli.cmds.options.output_options import columns_option +from _incydr_cli.cmds.options.output_options import table_format_option +from _incydr_cli.cmds.options.output_options import TableFormat +from _incydr_cli.core import IncydrCommand +from _incydr_cli.core import IncydrGroup +from _incydr_sdk.core.client import Client +from _incydr_sdk.risk_indicator_categories.models import RiskIndicator + + +@click.group(cls=IncydrGroup) +@logging_options +def risk_indicator_categories(): + """View and manage risk indicators.""" + + +@risk_indicator_categories.command("list", cls=IncydrCommand) +@table_format_option +@columns_option +@logging_options +def list_categories( + format_: Optional[TableFormat] = None, + columns: Optional[str] = None, +): + """ + List Risk Indicators by category and subcategory. + """ + client = Client() + categories = client.risk_indicator_categories.v1.list_categories().categories + + if format_ == TableFormat.table: + columns = columns or [ + "id", + "name", + "description", + "category_name", + "category_id", + "subcategory_name", + "subcategory_id", + "type", + ] + render.table( + RiskIndicatorTableEntry, + iter_risk_indicator_table_entries(categories), + columns=columns, + flat=False, + ) + elif format_ == TableFormat.csv: + render.csv( + RiskIndicatorTableEntry, + iter_risk_indicator_table_entries(categories), + columns=columns, + flat=True, + ) + else: + printed = False + for indicator in iter_risk_indicator_table_entries(categories): + printed = True + if format_ == TableFormat.json_pretty: + console.print_json(indicator.json()) + else: + click.echo(indicator.json()) + if not printed: + console.print("No results found.") + + +class RiskIndicatorTableEntry(RiskIndicator): + category_name: str + category_id: str + category_description: Optional[str] + subcategory_name: str + subcategory_id: str + subcategory_description: Optional[str] + type: str + + +def iter_risk_indicator_table_entries(categories) -> Iterator[RiskIndicatorTableEntry]: + for category in categories: + for subcategory in category.subcategories: + for indicator, indicator_type in itertools.chain( + ((i, "standard") for i in subcategory.standard_indicators), + ((i, "custom") for i in subcategory.custom_indicators), + ): + yield RiskIndicatorTableEntry( + id=indicator.id, + name=indicator.name, + description=indicator.description, + category_name=category.name, + category_id=category.id, + category_description=category.description, + subcategory_name=subcategory.name, + subcategory_id=subcategory.id, + subcategory_description=subcategory.description, + type=indicator_type, + ) diff --git a/src/_incydr_cli/cmds/sessions.py b/src/_incydr_cli/cmds/sessions.py index b27433c0..5b9712f6 100644 --- a/src/_incydr_cli/cmds/sessions.py +++ b/src/_incydr_cli/cmds/sessions.py @@ -72,6 +72,11 @@ def sessions(): help="Limit search to sessions beginning before this date and time. " "Accepts a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format.", ) +@click.option( + "--type", + default=None, + help="Limit search to sessions of this type. Acceptable types are STANDARD or ACCOUNT_TAKE_OVER", +) @click.option( "--no-alerts", is_flag=True, @@ -123,6 +128,7 @@ def search( actor_id: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None, + type: Optional[str] = None, no_alerts: bool = False, risk_indicators: Optional[str] = None, state: Optional[List[str]] = None, @@ -162,6 +168,7 @@ def search( sessions_gen = client.sessions.v1.iter_all( actor_id=actor_id, start_time=start, + type=type, end_time=end, has_alerts=not no_alerts, risk_indicators=risk_indicators.split(",") if risk_indicators else None, diff --git a/src/_incydr_cli/cmds/trusted_activities.py b/src/_incydr_cli/cmds/trusted_activities.py index fbdbaec4..b6106cb0 100644 --- a/src/_incydr_cli/cmds/trusted_activities.py +++ b/src/_incydr_cli/cmds/trusted_activities.py @@ -13,10 +13,21 @@ from _incydr_cli.core import IncydrGroup from _incydr_sdk.core.client import Client from _incydr_sdk.enums.trusted_activities import ActivityType +from _incydr_sdk.enums.trusted_activities import BrowserDestination +from _incydr_sdk.enums.trusted_activities import CloudShareApps +from _incydr_sdk.enums.trusted_activities import CloudSyncApps +from _incydr_sdk.enums.trusted_activities import EmailServices from _incydr_sdk.trusted_activities.client import MissingActivityActionGroupsError from _incydr_sdk.trusted_activities.models import TrustedActivity from _incydr_sdk.utils import model_as_card +high_value_option = click.option( + "--high-value", + is_flag=True, + default=False, + help="Indicate that this resource is a high-value source.", +) + @click.group(cls=IncydrGroup) @logging_options @@ -150,6 +161,12 @@ def add(): default=False, help="Trust file upload events to where the tab URL or title includes this domain.", ) +@click.option( + "--file-transfer", + is_flag=True, + default=False, + help="Trust file upload events to this domain using file transfer tools.", +) @click.option( "--git-push", is_flag=True, @@ -159,7 +176,7 @@ def add(): @click.option( "--cloud-sync", "cloud_sync_services", - type=click.Choice(["BOX", "GOOGLE_DRIVE", "ICLOUD", "ONE_DRIVE"]), + type=click.Choice([x.value for x in CloudSyncApps]), default=[], help="Specify which cloud sync service(s) to trust.", multiple=True, @@ -167,7 +184,7 @@ def add(): @click.option( "--cloud-share", "cloud_share_services", - type=click.Choice(["BOX", "GOOGLE_DRIVE", "ONE_DRIVE"]), + type=click.Choice([x.value for x in CloudShareApps]), default=[], help="Specify which cloud share service(s) to trust.", multiple=True, @@ -175,21 +192,33 @@ def add(): @click.option( "--email-share", "email_share_services", - type=click.Choice(["GMAIL", "MICROSOFT_365"]), + type=click.Choice([x.value for x in EmailServices]), default=[], help="Specify which email share service(s) to trust.", multiple=True, ) +@click.option( + "--browser-destination", + "browser_destinations", + type=click.Choice([x.value for x in BrowserDestination]), + default=[], + help="Specify which browser destinations to trust when users are logged in with this domain.", + multiple=True, +) +@high_value_option @single_format_option @logging_options def domain_( domain: str, description: str = None, file_upload: bool = False, + file_transfer: bool = False, git_push: bool = False, cloud_sync_services: str = None, cloud_share_services: str = None, email_share_services: str = None, + browser_destinations: str = None, + high_value: bool = None, format_: SingleFormat = None, ): """ @@ -198,19 +227,23 @@ def domain_( The following activities can be configured: * `--file-upload` - Trust file uploads to this domain. Defaults to false. + * `--file-transfer` - Trust file uploads to this domain using file transfer tools. Defaults to false. * `--git-push` - Trust git push events to this domain. Defaults to false. - * `--cloud-sync-services` [`BOX|GOOGLE_DRIVE|ICLOUD|ONE_DRIVE`] - Trust cloud sync activity from the specified service(s) if the username signed into the sync app is on this domain. + * `--cloud-sync` [`BOX|GOOGLE_DRIVE|ICLOUD|ONE_DRIVE`] - Trust cloud sync activity from the specified service(s) if the username signed into the sync app is on this domain. If you want to only trust activity for a specific corporate account, add a trusted account name instead. - * `--cloud-share-services` [`BOX|GOOGLE_DRIVE|ONE_DRIVE`] - Trust cloud share activity from the specified service(s) if the user its shared with is on this domain. + * `--cloud-share` [`BOX|GOOGLE_DRIVE|ONE_DRIVE`] - Trust cloud share activity from the specified service(s) if the user its shared with is on this domain. You must have a cloud connector configured for your tenant to support this trusted action. - * `--email-share-services` [`GMAIL|MICROSOFT_365`] - Trust email share activity from the specified service(s) if the email recipient is on this domain. + * `--email-share` [`GMAIL|OFFICE_365|GOOGLE_DRIVE`] - Trust email share activity from the specified service(s) if the email recipient is on this domain. You must have an email connector configured for your tenant to support this trusted action. + * `--browser-destination` [`AIRTABLE|AMAZON_WEB_SERVICES|BLACKBOX|BOX|CHATGPT|CLAUDE|CONCUR|CURSOR|DROPBOX|GOOGLE_WORKSPACE|MICROSOFT_365|NOTTA|OTTER|PERPLEXITY|SLACK|YOU_DOT_COM`] + Trust these destinations when users log in using this configured domain. + * `--high-value` - Indicate that this domain is a high value source. - Multiple options can be supplied to specify cloud-share, cloud-sync, and email-share services. + Multiple options can be supplied to specify cloud-share, cloud-sync, email-share services, and browser-destinations. For example, the following command will create a trusted domain that trusts file-uploads to the domain and cloud sync events from `BOX` and `ICLOUD`. - trusted-activities add domain --file-upload --cloud-sync-services BOX --cloud-sync-services ICLOUD + trusted-activities add domain --file-upload --cloud-sync BOX --cloud-sync ICLOUD """ client = Client() @@ -223,6 +256,9 @@ def domain_( cloud_sync_services=cloud_sync_services, cloud_share_services=cloud_share_services, email_share_services=email_share_services, + file_transfer_tools=file_transfer, + browser_destinations=browser_destinations, + is_high_value=high_value, ) except MissingActivityActionGroupsError: raise click.UsageError( @@ -234,29 +270,35 @@ def domain_( @add.command("url-path", cls=IncydrCommand) @click.argument("url_path") @click.option("--description", default=None, help="Optional description.") +@high_value_option @single_format_option @logging_options def url_path_( url_path: str, description: str = None, + high_value: bool = False, format_: SingleFormat = None, ): """ Trust browser uploads to only part of a domain by trusting a specific `URL_PATH` (ex: `my-domain.com/path`). """ client = Client() - activity = client.trusted_activities.v2.add_url_path(url_path, description) + activity = client.trusted_activities.v2.add_url_path( + url_path, description, is_high_value=high_value + ) _output_trusted_activity(activity, format_, client.settings.use_rich) @add.command(cls=IncydrCommand) @click.argument("workspace_name") @click.option("--description", default=None, help="Optional description.") +@high_value_option @single_format_option @logging_options def slack_workspace( workspace_name: str, description: str = None, + high_value: bool = False, format_: SingleFormat = None, ): """ @@ -264,7 +306,7 @@ def slack_workspace( """ client = Client() activity = client.trusted_activities.v2.add_slack_workspace( - workspace_name, description=description + workspace_name, description=description, is_high_value=high_value ) _output_trusted_activity(activity, format_, client.settings.use_rich) @@ -284,6 +326,7 @@ def slack_workspace( default=False, help="Trust OneDrive as a cloud sync service.", ) +@high_value_option @single_format_option @logging_options def account( @@ -291,6 +334,7 @@ def account( description: str = None, dropbox: bool = False, one_drive: bool = False, + high_value: bool = False, format_: SingleFormat = None, ): """ @@ -300,7 +344,11 @@ def account( """ client = Client() activity = client.trusted_activities.v2.add_account_name( - account_name, description=description, dropbox=dropbox, one_drive=one_drive + account_name, + description=description, + dropbox=dropbox, + one_drive=one_drive, + is_high_value=high_value, ) _output_trusted_activity(activity, format_, client.settings.use_rich) @@ -308,11 +356,13 @@ def account( @add.command(cls=IncydrCommand) @click.argument("git_uri") @click.option("--description", default=None, help="Optional description.") +@high_value_option @single_format_option @logging_options def git_repo( git_uri: str, description: str = None, + high_value: bool = False, format_: SingleFormat = None, ): """ @@ -320,7 +370,7 @@ def git_repo( """ client = Client() activity = client.trusted_activities.v2.add_git_repository( - git_uri, description=description + git_uri, description=description, is_high_value=high_value ) _output_trusted_activity(activity, format_, client.settings.use_rich) diff --git a/src/_incydr_cli/main.py b/src/_incydr_cli/main.py index fbdf0fd0..f7b25a67 100644 --- a/src/_incydr_cli/main.py +++ b/src/_incydr_cli/main.py @@ -21,6 +21,7 @@ from _incydr_cli.cmds.files import files as files_client from _incydr_cli.cmds.legal_hold import legal_hold from _incydr_cli.cmds.orgs import orgs +from _incydr_cli.cmds.risk_indicator_categories import risk_indicator_categories from _incydr_cli.cmds.risk_profiles import risk_profiles from _incydr_cli.cmds.sessions import sessions from _incydr_cli.cmds.trusted_activities import trusted_activities @@ -87,6 +88,7 @@ def incydr(version, python, script_dir): incydr.add_command(files_client) incydr.add_command(cases) incydr.add_command(risk_profiles) +incydr.add_command(risk_indicator_categories) incydr.add_command(sessions) incydr.add_command(trusted_activities) incydr.add_command(users) diff --git a/src/_incydr_sdk/__version__.py b/src/_incydr_sdk/__version__.py index e3c379d8..482653ef 100644 --- a/src/_incydr_sdk/__version__.py +++ b/src/_incydr_sdk/__version__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2022-present Code42 Software # # SPDX-License-Identifier: MIT -__version__ = "2.11.0" +__version__ = "2.12.0" diff --git a/src/_incydr_sdk/core/client.py b/src/_incydr_sdk/core/client.py index 487b9e71..0748c8b0 100644 --- a/src/_incydr_sdk/core/client.py +++ b/src/_incydr_sdk/core/client.py @@ -24,6 +24,7 @@ from _incydr_sdk.files.client import FilesClient from _incydr_sdk.legal_hold.client import LegalHoldClient from _incydr_sdk.orgs.client import OrgsClient +from _incydr_sdk.risk_indicator_categories.client import RiskIndicatorCategories from _incydr_sdk.risk_profiles.client import RiskProfiles from _incydr_sdk.sessions.client import SessionsClient from _incydr_sdk.trusted_activities.client import TrustedActivitiesClient @@ -114,6 +115,7 @@ def response_hook(response, *args, **kwargs): self._trusted_activities = TrustedActivitiesClient(self) self._users = UsersClient(self) self._risk_profiles = RiskProfiles(self) + self._risk_indicator_categories = RiskIndicatorCategories(self) self._watchlists = WatchlistsClient(self) if not skip_auth: @@ -366,6 +368,17 @@ def risk_profiles(self): """ return self._risk_profiles + @property + def risk_indicator_categories(self): + """ + Property returning a [`RiskIndicatorCategories`](../risk_indicator_categories) client for interacting + with `/v*/risk_indicator_categories` API endpoints. + + Usage: + >>> client.risk_indicator_categories.v1.list_categories(active=True) + """ + return self._risk_indicator_categories + @property def watchlists(self): """ diff --git a/src/_incydr_sdk/enums/trusted_activities.py b/src/_incydr_sdk/enums/trusted_activities.py index a0241529..dde07e05 100644 --- a/src/_incydr_sdk/enums/trusted_activities.py +++ b/src/_incydr_sdk/enums/trusted_activities.py @@ -12,6 +12,8 @@ class ActivityType(_Enum): FILE_UPLOAD = "FILE_UPLOAD" GIT_PUSH = "GIT_PUSH" GIT_REPOSITORY_URI = "GIT_REPOSITORY_URI" + USER_ACCOUNT_UPLOAD = "USER_ACCOUNT_UPLOAD" + FILE_TRANSFER = "FILE_TRANSFER" class CloudSyncApps(_Enum): @@ -28,6 +30,7 @@ class CloudShareApps(_Enum): class EmailServices(_Enum): + GOOGLE_DRIVE = "GOOGLE_DRIVE" GMAIL = "GMAIL" OFFICE_365 = "OFFICE_365" @@ -57,3 +60,22 @@ class SortKeys(_Enum): UPDATED_BY_PRINCIPAL_NAME = "UPDATED_BY_PRINCIPAL_NAME" UPDATE_TIME = "UPDATE_TIME" VALUE = "VALUE" + + +class BrowserDestination(_Enum): + AIRTABLE = "AIRTABLE" + AMAZON_WEB_SERVICES = "AMAZON_WEB_SERVICES" + BLACKBOX = "BLACKBOX" + BOX = "BOX" + CHATGPT = "CHATGPT" + CLAUDE = "CLAUDE" + CONCUR = "CONCUR" + CURSOR = "CURSOR" + DROPBOX = "DROPBOX" + GOOGLE_WORKSPACE = "GOOGLE_WORKSPACE" + MICROSOFT_365 = "MICROSOFT_365" + NOTTA = "NOTTA" + OTTER = "OTTER" + PERPLEXITY = "PERPLEXITY" + SLACK = "SLACK" + YOU_DOT_COM = "YOU_DOT_COM" diff --git a/src/_incydr_sdk/risk_indicator_categories/client.py b/src/_incydr_sdk/risk_indicator_categories/client.py new file mode 100644 index 00000000..722fede7 --- /dev/null +++ b/src/_incydr_sdk/risk_indicator_categories/client.py @@ -0,0 +1,84 @@ +from _incydr_sdk.enums import SortDirection +from _incydr_sdk.risk_indicator_categories.models import ( + RiskIndicatorCategoriesResponsePage, +) +from _incydr_sdk.risk_indicator_categories.models import RiskIndicatorCategory +from _incydr_sdk.risk_indicator_categories.models import RiskIndicatorSubcategory + + +class RiskIndicatorCategories: + def __init__(self, parent): + self._parent = parent + self._v1 = None + + @property + def v1(self): + if self._v1 is None: + self._v1 = RiskIndicatorCategoriesV1(self._parent) + return self._v1 + + +class RiskIndicatorCategoriesV1: + """ + Client for `/v1/risk-indicator-categories` endpoints. + + Usage example: + + >>> import incydr + >>> client = incydr.Client(**kwargs) + >>> client.risk_indicators.v1.list_categories() + """ + + def __init__(self, parent): + self._parent = parent + + def list_categories( + self, active: bool = None, sort_direction: SortDirection = None + ) -> RiskIndicatorCategoriesResponsePage: + """ + Returns all risk indicator categories, including their subcategories and associated risk indicators. + Filter results by passing the appropriate parameters: + + **Parameters**: + + * **active**: `bool` - When provided, returns only those risk indicators which match the provided value (true or false). When not provided, returns both. + * **sort_direction**: `SortDirection` - The order in which to sort the returned list. + + **Returns**: A [`RiskIndicatorCategoriesResponsePage`][riskindicatorcategoriesresponsepage-model] object. + """ + response = self._parent.session.get( + "/v1/risk-indicator-categories", + params={"isActive": active, "sort_direction": sort_direction}, + ) + return RiskIndicatorCategoriesResponsePage.parse_response(response) + + def get_category(self, id: str) -> RiskIndicatorCategory: + """ + Returns a single risk indicator category, including its subcategories and associated risk indicators. + + **Parameters**: + + * **id**: `str` - The unique ID of the category you wish to retrieve. + + **Returns**: A [`RiskIndicatorCategory`][riskindicatorcategory-model] object. + """ + response = self._parent.session.get(f"/v1/risk-indicator-categories/{id}") + return RiskIndicatorCategory.parse_response(response) + + def get_subcategory( + self, category_id: str, subcategory_id: str + ) -> RiskIndicatorSubcategory: + """ + Returns a single risk indicator category, including its subcategories and associated risk indicators. + + **Parameters**: + + * **category_id**: `str` - The unique ID of the category in which the subcategory lives. + * **subcategory_id**: `str` - The unique ID of the subcategory you wish to retrieve. + + **Returns**: A [`RiskIndicatorSubcategory`][riskindicatorsubcategory-model] object. + """ + response = self._parent.session.get( + f"/v1/risk-indicator-categories/{category_id}/subcategories/{subcategory_id}" + ) + return RiskIndicatorSubcategory.parse_response(response) diff --git a/src/_incydr_sdk/risk_indicator_categories/models.py b/src/_incydr_sdk/risk_indicator_categories/models.py new file mode 100644 index 00000000..379fb2b0 --- /dev/null +++ b/src/_incydr_sdk/risk_indicator_categories/models.py @@ -0,0 +1,75 @@ +from typing import List +from typing import Optional + +from pydantic import Field + +from _incydr_sdk.core.models import Model +from _incydr_sdk.core.models import ResponseModel + + +class RiskIndicator(Model): + """ + A model representing a Risk Indicator. + + **Fields**: + + * **id**: `str` - The unique ID of the indicator. + * **name**: `str` - The name of the indicator. + * **description**: `Optional[str]` - The description of the indicator. + """ + + id: str + name: str + description: Optional[str] = None + + +class RiskIndicatorSubcategory(ResponseModel): + """ + A model representing a Risk Indicator Subcategory. + + **Fields**: + + * **id**: `str` - The unique ID of the subcategory. + * **name**: `str` - The name of the subcategory. + * **description**: `Optional[str]` - The description of the subcategory. + * **standard_indicators**: `List[RiskIndicator]` - A list of standard risk indicators. + * **custom_indicators**: `List[RiskIndicator]` - A list of custom risk indicators. + """ + + id: str + name: str + description: Optional[str] = None + standard_indicators: List[RiskIndicator] = Field([], alias="standardIndicators") + custom_indicators: List[RiskIndicator] = Field([], alias="customIndicators") + + +class RiskIndicatorCategory(ResponseModel): + """ + A model representing a Risk Indicator Category. + + **Fields**: + + * **id**: `str` - The unique ID of the category. + * **name**: `str` - The name of the category. + * **description**: `Optional[str]` - The description of the category. + * **subcategories**: `List[RiskIndicatorSubcategory]` - A list of Risk Indicator Subcategories + """ + + id: str + name: str + description: Optional[str] = None + subcategories: List[RiskIndicatorSubcategory] + + +class RiskIndicatorCategoriesResponsePage(ResponseModel): + """ + A model representing a page of Risk Indicator Categories. + + **Fields**: + + * **categories**: `Optional[List[RiskIndicatorCategory]]` - A list of Risk Indicator Categories. + """ + + categories: Optional[List[RiskIndicatorCategory]] = Field( + None, description="A list of Risk Indicator Categories." + ) diff --git a/src/_incydr_sdk/sessions/client.py b/src/_incydr_sdk/sessions/client.py index 80e4dd6a..2bdc2cdb 100644 --- a/src/_incydr_sdk/sessions/client.py +++ b/src/_incydr_sdk/sessions/client.py @@ -37,6 +37,7 @@ def get_page( actor_id: str = None, start_time: Union[str, datetime, int] = None, end_time: Union[str, datetime, int] = None, + type: str = None, has_alerts: bool = True, sort_key: Optional[SortKeys] = None, risk_indicators: List[str] = None, @@ -59,6 +60,7 @@ def get_page( * **actor_id**: `str | None` - Only include items generated by this actor. * **start_time**: `datetime | str | int | None` - Only include items beginning on or after this date and time. Can be a date-formatted string, a `datetime` instance, or a POSIX `int` timestamp. * **end_time**: `datetime | str | int | None` - Only include items beginning before this date and time. Can be a date-formatted string, a `datetime` instance, or a POSIX `int` timestamp. + * **type**: `str` - Only include items matching this type. Examples include STANDARD, ACCOUNT_TAKE_OVER. * **has_alerts**: `bool` - Only include items that have a matching alert status. Defaults to `True`. * **sort_key**: [`SortKeys`][items-sort-keys] - `end_time` or `score`. Value on which the results will be sorted. Defaults to `end time`. * **risk_indicators**: `List[str] | None` - List of risk indicator IDs that must be present on the items before they are returned. @@ -93,6 +95,7 @@ def get_page( actor_id=actor_id, on_or_after=start_time, before=end_time, + type=type, has_alerts=str(has_alerts).lower() if has_alerts is not None else None, order_by=sort_key, risk_indicators=risk_indicators, @@ -113,6 +116,7 @@ def iter_all( actor_id: str = None, start_time: Union[str, datetime, int] = None, end_time: Union[str, datetime, int] = None, + type: str = None, has_alerts: bool = True, sort_key: Optional[SortKeys] = None, risk_indicators: List[str] = None, @@ -136,6 +140,7 @@ def iter_all( actor_id=actor_id, start_time=start_time, end_time=end_time, + type=type, has_alerts=has_alerts, sort_key=sort_key, risk_indicators=risk_indicators, @@ -217,6 +222,7 @@ def update_state_by_criteria( actor_id: str = None, start_time: Union[str, datetime, int] = None, end_time: Union[str, datetime, int] = None, + type: str = None, has_alerts: bool = True, risk_indicators: List[str] = None, states: List[SessionStates] = None, @@ -236,6 +242,7 @@ def update_state_by_criteria( * **actor_id**: `str | None` - The ID of the actor to limit the search to. * **start_time**: `datetime | str | int | None` - Only include items beginning on or after this date and time. Can be a date-formatted string, a `datetime` instance, or a POSIX `int` timestamp. * **end_time**: `datetime | str | int | None` - Only include items beginning before this date and time. Can be a date-formatted string, a `datetime` instance, or a POSIX `int` timestamp. + * **type**: `str` - Only include items matching this type. Examples include STANDARD, ACCOUNT_TAKE_OVER. * **has_alerts**: `bool` - Only include items that have a matching alert status. Defaults to `True`. * **sort_key**: [`SortKeys`][items-sort-keys] - `end_time` or `score`. Value on which the results will be sorted. Defaults to `end time`. * **risk_indicators**: `List[str] | None` - List of risk indicator IDs that must be present on the items before they are returned. @@ -270,6 +277,7 @@ def update_state_by_criteria( actor_id=actor_id, on_or_after=start_time, before=end_time, + type=type, has_alerts=str(has_alerts).lower() if has_alerts is not None else None, risk_indicators=risk_indicators, state=states, diff --git a/src/_incydr_sdk/sessions/models/models.py b/src/_incydr_sdk/sessions/models/models.py index a040a7fa..17b45559 100644 --- a/src/_incydr_sdk/sessions/models/models.py +++ b/src/_incydr_sdk/sessions/models/models.py @@ -71,6 +71,7 @@ class SessionsCriteriaRequest(BaseModel): actor_id: Optional[str] = None on_or_after: Optional[int] = None before: Optional[int] = None + type: Optional[str] = None has_alerts: Optional[str] = None risk_indicators: Optional[List[str]] = None state: Optional[List[SessionStates]] = None diff --git a/src/_incydr_sdk/sessions/models/response.py b/src/_incydr_sdk/sessions/models/response.py index bb573da9..ec37f73a 100644 --- a/src/_incydr_sdk/sessions/models/response.py +++ b/src/_incydr_sdk/sessions/models/response.py @@ -20,6 +20,7 @@ class Session(ResponseModel): **Fields**: * **actor_id**: `str` The ID of the actor that generated the session. + * **type**: `str` The type of the session. * **begin_time**: `datetime` The date and time when this session began. * **content_inspection_results**: `List[ContentInspectionResult]` The results of content inspection. * **context_summary**: `str` An English summary of the contextual aspects of this session is any were identified. @@ -42,6 +43,7 @@ class Session(ResponseModel): """ actor_id: Optional[str] = Field(None, alias="actorId") + type: Optional[str] = Field(None) begin_time: Optional[int] = Field(None, alias="beginTime") content_inspection_results: Optional[ContentInspectionResult] = Field( None, alias="contentInspectionResults" diff --git a/src/_incydr_sdk/trusted_activities/client.py b/src/_incydr_sdk/trusted_activities/client.py index d147b596..e56b35c2 100644 --- a/src/_incydr_sdk/trusted_activities/client.py +++ b/src/_incydr_sdk/trusted_activities/client.py @@ -6,6 +6,7 @@ from _incydr_sdk.enums import SortDirection from _incydr_sdk.enums.trusted_activities import ActivityType +from _incydr_sdk.enums.trusted_activities import BrowserDestination from _incydr_sdk.enums.trusted_activities import CloudShareApps from _incydr_sdk.enums.trusted_activities import CloudSyncApps from _incydr_sdk.enums.trusted_activities import EmailServices @@ -124,6 +125,9 @@ def add_domain( cloud_share_services: List[CloudShareApps] = None, email_share_services: List[EmailServices] = None, git_push: bool = None, + browser_destinations: List[BrowserDestination] = None, + file_transfer_tools: bool = None, + is_high_value: bool = None, ) -> TrustedActivity: """ Trust activity across an entire domain. @@ -140,9 +144,14 @@ def add_domain( Supported cloud storage services for file sharing are `BOX`, `GOOGLE_DRIVE` and/or `ONE_DRIVE`. You must have a cloud connector configured for your tenant to support this trusted action. * **email_share_services**: `List[EmailServices]` - Activity is trusted if the email recipient is on this domain. - Supported email services are `GMAIL` and/or `MICROSOFT_365`. You must have an email connector configured + Supported email services are `GMAIL`, `GOOGLE_DRIVE`, and `OFFICE_365`. You must have an email connector configured for your tenant to support this trusted action. * **git_push**: `bool` - Whether to trust Git push events to this domain. + * **browser_destinations**: `List[BrowserDestination]` - Activity is trusted if the user is logging in to one of these + browser destinations using this domain. Examples include `CHATGPT`, `CLAUDE`, `AMAZON_WEB_SERVICES`. For the complete list + consult the documentation at https://developer.code42.com/api/#tag/Trusted-Activities/operation/createTrustResource + * **file_transfer_tools**: `bool` - Whether to trust uploads to this domain using file-transfer tools. + * **is_high_value**: `bool` - Indicates whether or not this should be a high-value source. Defaults to not sending an indication. **Returns**: A [`TrustedActivity`][trustedactivity-model] object representing the newly created trusted activity. @@ -158,6 +167,10 @@ def add_domain( if git_push: activity_actions.append(ActivityAction(type=ActivityType.GIT_PUSH)) + # FILE TRANSFER TOOLS + if file_transfer_tools: + activity_actions.append(ActivityAction(type=ActivityType.FILE_TRANSFER)) + # CLOUD SYNC SERVICES services = [ ( @@ -175,6 +188,11 @@ def add_domain( EmailServices, ActivityType.EMAIL, ), # # EMAIL_SHARE_SERVICES + ( + browser_destinations, + BrowserDestination, + ActivityType.USER_ACCOUNT_UPLOAD, + ), # # USER_ACCOUNT_UPLOAD ] for element in services: service, enum, activity_type = element @@ -194,6 +212,7 @@ def add_domain( type=ActivityType.DOMAIN, value=domain, description=description, + isHighValueSource=is_high_value, activityActionGroups=[ ActivityActionGroup(activityActions=activity_actions, name=Name.DEFAULT) ], @@ -208,6 +227,7 @@ def add_url_path( self, url: str, description: str = None, + is_high_value: bool = None, ) -> TrustedActivity: """ Trust browser uploads to only part of a domain by including a specific path. For example: `github.com/company` will only trust uploads to the `company` repository. @@ -216,6 +236,7 @@ def add_url_path( * **url**: `str` (required) - URL path to trust (ex: `example.com/path`). * **description**: `str` - Optional description of the trusted activity. + * **is_high_value**: `bool` - Indicates whether or not this should be a high-value source. Defaults to not sending an indication. **Returns**: A [`TrustedActivity`][trustedactivity-model] object representing the newly created trusted activity. @@ -225,6 +246,7 @@ def add_url_path( type=ActivityType.URL_PATH, value=url, description=description, + isHighValueSource=is_high_value, activityActionGroups=[], ) @@ -237,6 +259,7 @@ def add_slack_workspace( self, workspace_name: str, description: str = None, + is_high_value: bool = None, ) -> TrustedActivity: """ Trust activity uploaded through a Slack workspace. @@ -245,6 +268,7 @@ def add_slack_workspace( * **workspace_name**: `str` (required) - Name of the Slack workspace to trust. * **description**: `str` - Optional description of the trusted activity. + * **is_high_value**: `bool` - Indicates whether or not this should be a high-value source. Defaults to not sending an indication. **Returns**: A [`TrustedActivity`][trustedactivity-model] object representing the newly created trusted activity. @@ -254,6 +278,7 @@ def add_slack_workspace( type=ActivityType.SLACK, value=workspace_name, description=description, + isHighValueSource=is_high_value, activityActionGroups=[], ) @@ -268,6 +293,7 @@ def add_account_name( description: str = None, dropbox: bool = False, one_drive: bool = False, + is_high_value: bool = None, ) -> TrustedActivity: """ Trust activity for a specific corporate account for cloud sync apps installed on user devices. @@ -278,6 +304,7 @@ def add_account_name( * **description**: `str` - Optional description of the trusted activity. * **dropbox**: `bool` - Whether to trust Dropbox as a cloud sync service. Defaults to False. * **one_drive** `bool` - Whether to trust OneDrive as a cloud sync service. Defaults to False. + * **is_high_value**: `bool` - Indicates whether or not this should be a high-value source. Defaults to not sending an indication. At least 1 activity action group (dropbox, one_drive) is required to be trusted. @@ -310,6 +337,7 @@ def add_account_name( type=ActivityType.ACCOUNT_NAME, value=account_name, description=description, + isHighValueSource=is_high_value, activityActionGroups=[activity_action_group], ) @@ -322,6 +350,7 @@ def add_git_repository( self, git_uri: str, description: str = None, + is_high_value: bool = None, ) -> TrustedActivity: """ Trust file uploads to a git repository. @@ -330,6 +359,7 @@ def add_git_repository( * **git_uri**: `str` (required) - Git URI to trust (ex: `bitbucket.org:exampleent/myrepo`). * **description**: `str` - Optional description of the trusted activity. + * **is_high_value**: `bool` - Indicates whether or not this should be a high-value source. Defaults to not sending an indication. **Returns**: A [`TrustedActivity`][trustedactivity-model] object representing the newly created trusted activity. @@ -344,6 +374,7 @@ def add_git_repository( type=ActivityType.GIT_REPOSITORY_URI, value=git_uri, description=description, + isHighValueSource=is_high_value, activityActionGroups=[activity_action_group], ) diff --git a/src/_incydr_sdk/trusted_activities/models.py b/src/_incydr_sdk/trusted_activities/models.py index 9af0ef19..32ed9f02 100644 --- a/src/_incydr_sdk/trusted_activities/models.py +++ b/src/_incydr_sdk/trusted_activities/models.py @@ -171,4 +171,5 @@ class CreateTrustedActivityRequest(Model): type: Optional[str] value: Optional[str] description: Optional[str] + isHighValueSource: Optional[bool] activityActionGroups: Optional[List[ActivityActionGroup]] diff --git a/src/incydr/enums/trusted_activities.py b/src/incydr/enums/trusted_activities.py index 58ef0118..9173f607 100644 --- a/src/incydr/enums/trusted_activities.py +++ b/src/incydr/enums/trusted_activities.py @@ -1,4 +1,5 @@ from _incydr_sdk.enums.trusted_activities import ActivityType +from _incydr_sdk.enums.trusted_activities import BrowserDestination from _incydr_sdk.enums.trusted_activities import CloudShareApps from _incydr_sdk.enums.trusted_activities import CloudSyncApps from _incydr_sdk.enums.trusted_activities import EmailServices @@ -12,4 +13,5 @@ "EmailServices", "PrincipalType", "SortKeys", + "BrowserDestination", ] diff --git a/src/incydr/models.py b/src/incydr/models.py index cf7ab167..6f22228b 100644 --- a/src/incydr/models.py +++ b/src/incydr/models.py @@ -25,6 +25,12 @@ from _incydr_sdk.file_events.models.response import FileEventsPage from _incydr_sdk.file_events.models.response import GroupedFileEventResponse from _incydr_sdk.file_events.models.response import SavedSearch +from _incydr_sdk.risk_indicator_categories.models import RiskIndicator +from _incydr_sdk.risk_indicator_categories.models import ( + RiskIndicatorCategoriesResponsePage, +) +from _incydr_sdk.risk_indicator_categories.models import RiskIndicatorCategory +from _incydr_sdk.risk_indicator_categories.models import RiskIndicatorSubcategory from _incydr_sdk.risk_profiles.models import RiskProfile from _incydr_sdk.risk_profiles.models import RiskProfilesPage from _incydr_sdk.sessions.models.response import Session @@ -107,6 +113,10 @@ "AuditEventsPage", "RiskProfilesPage", "RiskProfile", + "RiskIndicator", + "RiskIndicatorSubcategory", + "RiskIndicatorCategory", + "RiskIndicatorCategoriesResponsePage", ] diff --git a/tests/test_risk_indicator_categories.py b/tests/test_risk_indicator_categories.py new file mode 100644 index 00000000..c933a516 --- /dev/null +++ b/tests/test_risk_indicator_categories.py @@ -0,0 +1,190 @@ +import json +from csv import DictReader +from io import StringIO +from urllib.parse import urlencode + +import pytest +from pytest_httpserver import HTTPServer + +from _incydr_cli import render as render_module +from _incydr_cli.cmds import risk_indicator_categories as risk_indicator_categories_cmd +from _incydr_cli.main import incydr +from _incydr_sdk.enums import SortDirection +from _incydr_sdk.risk_indicator_categories.models import ( + RiskIndicatorCategoriesResponsePage, +) +from _incydr_sdk.risk_indicator_categories.models import RiskIndicatorCategory +from _incydr_sdk.risk_indicator_categories.models import RiskIndicatorSubcategory +from incydr import Client + +TEST_CATEGORY_ID = "cat-1" +TEST_SUBCATEGORY_ID = "sub-1" + +TEST_INDICATOR_STANDARD = { + "id": "ind-std-1", + "name": "Standard indicator", + "description": "Standard description", +} + +TEST_INDICATOR_CUSTOM = { + "id": "ind-cust-1", + "name": "Custom indicator", + "description": None, +} + +TEST_SUBCATEGORY_PAYLOAD = { + "id": TEST_SUBCATEGORY_ID, + "name": "Cloud storage", + "description": "Cloud-related indicators", + "standardIndicators": [TEST_INDICATOR_STANDARD], + "customIndicators": [TEST_INDICATOR_CUSTOM], +} + +TEST_CATEGORY_PAYLOAD = { + "id": TEST_CATEGORY_ID, + "name": "Data exfiltration", + "description": "Category description", + "subcategories": [TEST_SUBCATEGORY_PAYLOAD], +} + +TEST_LIST_RESPONSE = {"categories": [TEST_CATEGORY_PAYLOAD]} + + +def test_list_categories_when_default_params_returns_expected_data( + httpserver_auth: HTTPServer, +): + httpserver_auth.expect_request("/v1/risk-indicator-categories").respond_with_json( + TEST_LIST_RESPONSE + ) + + client = Client() + page = client.risk_indicator_categories.v1.list_categories() + assert isinstance(page, RiskIndicatorCategoriesResponsePage) + assert len(page.categories) == 1 + cat = page.categories[0] + assert isinstance(cat, RiskIndicatorCategory) + assert cat.json() == json.dumps(TEST_CATEGORY_PAYLOAD, separators=(",", ":")) + + +def test_list_categories_when_active_and_sort_returns_expected_data( + httpserver_auth: HTTPServer, +): + query = urlencode({"isActive": True, "sort_direction": SortDirection.ASC.value}) + httpserver_auth.expect_request( + "/v1/risk-indicator-categories", query_string=query + ).respond_with_json(TEST_LIST_RESPONSE) + + client = Client() + page = client.risk_indicator_categories.v1.list_categories( + active=True, sort_direction=SortDirection.ASC + ) + assert isinstance(page, RiskIndicatorCategoriesResponsePage) + assert page.categories[0].id == TEST_CATEGORY_ID + + +def test_get_category_returns_expected_data(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/risk-indicator-categories/{TEST_CATEGORY_ID}" + ).respond_with_json(TEST_CATEGORY_PAYLOAD) + + client = Client() + category = client.risk_indicator_categories.v1.get_category(TEST_CATEGORY_ID) + assert isinstance(category, RiskIndicatorCategory) + assert category.json() == json.dumps(TEST_CATEGORY_PAYLOAD, separators=(",", ":")) + + +def test_get_subcategory_returns_expected_data(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/risk-indicator-categories/{TEST_CATEGORY_ID}/subcategories/{TEST_SUBCATEGORY_ID}" + ).respond_with_json(TEST_SUBCATEGORY_PAYLOAD) + + client = Client() + sub = client.risk_indicator_categories.v1.get_subcategory( + TEST_CATEGORY_ID, TEST_SUBCATEGORY_ID + ) + assert isinstance(sub, RiskIndicatorSubcategory) + assert sub.json() == json.dumps(TEST_SUBCATEGORY_PAYLOAD, separators=(",", ":")) + + +# ************************************************ CLI ************************************************ + + +def test_cli_list_when_default_params_makes_expected_call( + httpserver_auth: HTTPServer, runner +): + httpserver_auth.expect_request( + "/v1/risk-indicator-categories", method="GET" + ).respond_with_json(TEST_LIST_RESPONSE) + + result = runner.invoke(incydr, ["risk-indicator-categories", "list"]) + httpserver_auth.check() + assert result.exit_code == 0 + assert "Data exfiltration" in result.output + assert "Cloud storage" in result.output + assert "Standard indicator" in result.output + assert "Custom indicator" in result.output + + +def test_cli_list_json_lines_outputs_one_record_per_indicator( + httpserver_auth: HTTPServer, runner +): + httpserver_auth.expect_request( + "/v1/risk-indicator-categories", method="GET" + ).respond_with_json(TEST_LIST_RESPONSE) + + result = runner.invoke( + incydr, ["risk-indicator-categories", "list", "-f", "json-lines"] + ) + httpserver_auth.check() + assert result.exit_code == 0 + lines = [ln for ln in result.output.strip().splitlines() if ln.strip()] + assert len(lines) == 2 + rows = [json.loads(ln) for ln in lines] + by_id = {r["id"]: r for r in rows} + assert by_id["ind-std-1"]["type"] == "standard" + assert by_id["ind-std-1"]["category_name"] == "Data exfiltration" + assert by_id["ind-std-1"]["subcategory_name"] == "Cloud storage" + assert by_id["ind-cust-1"]["type"] == "custom" + assert by_id["ind-cust-1"]["description"] is None + + +def test_cli_list_csv_outputs_header_and_indicator_rows( + httpserver_auth: HTTPServer, runner, monkeypatch +): + """CSV uses render.csv default file= bound at import time; patch so output is readable.""" + buf = StringIO() + real_csv = render_module.csv + + def csv_to_buffer(model, models, columns=None, flat=False, file=None): + return real_csv(model, models, columns=columns, flat=flat, file=buf) + + monkeypatch.setattr(risk_indicator_categories_cmd.render, "csv", csv_to_buffer) + + httpserver_auth.expect_request( + "/v1/risk-indicator-categories", method="GET" + ).respond_with_json(TEST_LIST_RESPONSE) + + result = runner.invoke(incydr, ["risk-indicator-categories", "list", "-f", "csv"]) + httpserver_auth.check() + assert result.exit_code == 0 + reader = DictReader(StringIO(buf.getvalue())) + rows = list(reader) + assert len(rows) == 2 + by_id = {r["id"]: r for r in rows} + assert by_id["ind-std-1"]["type"] == "standard" + assert by_id["ind-cust-1"]["type"] == "custom" + assert by_id["ind-std-1"]["category_name"] == "Data exfiltration" + + +@pytest.mark.parametrize("format_", ["table", "csv", "json-pretty", "json-lines"]) +def test_cli_list_when_empty_returns_no_results( + httpserver_auth: HTTPServer, runner, format_ +): + httpserver_auth.expect_request( + "/v1/risk-indicator-categories", method="GET" + ).respond_with_json({"categories": []}) + + result = runner.invoke(incydr, ["risk-indicator-categories", "list", "-f", format_]) + httpserver_auth.check() + assert result.exit_code == 0 + assert "No results found" in result.output diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 29ffec4b..9f8f1eb3 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -30,6 +30,7 @@ TEST_SESSION = { "actorId": TEST_SESSION_ID, + "type": "STANDARD", "beginTime": POSIX_TS, "contentInspectionResults": {"detectedOnAlerts": ["PII"]}, "contextSummary": "string", diff --git a/tests/test_trusted_activities.py b/tests/test_trusted_activities.py index b5f74989..53d803f6 100644 --- a/tests/test_trusted_activities.py +++ b/tests/test_trusted_activities.py @@ -181,6 +181,7 @@ def test_add_domain_when_default_params_returns_expected_data( test_data = { "type": activity_type, "value": domain, + "isHighValueSource": None, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -238,6 +239,144 @@ def test_add_domain_when_no_trusted_actions_raises_error(httpserver_auth: HTTPSe assert "At least 1 action for the domain must be trusted." in str(e.value) +def test_add_domain_when_file_transfer_tools_returns_expected_data( + httpserver_auth: HTTPServer, +): + domain = "transfer.example.com" + activity_type = ActivityType.DOMAIN + activity_action_groups = [ + { + "name": "DEFAULT", + "activityActions": [ + {"type": "FILE_TRANSFER", "providers": None}, + ], + } + ] + + test_data = { + "type": activity_type, + "value": domain, + "isHighValueSource": None, + "description": "Description", + "activityActionGroups": activity_action_groups, + } + + test_response = TEST_TRUSTED_ACTIVITY_1.copy() + test_response.update(test_data) + test_response.update({"type": activity_type}) + + httpserver_auth.expect_request( + uri="/v2/trusted-activities", method="POST", json=test_data + ).respond_with_json(test_response) + + client = Client() + trusted_activity = client.trusted_activities.v2.add_domain( + domain=domain, + description="Description", + file_transfer_tools=True, + ) + assert isinstance(trusted_activity, TrustedActivity) + assert trusted_activity.type == activity_type + assert trusted_activity.value == domain + assert ( + json.loads(trusted_activity.json())["activityActionGroups"] + == activity_action_groups + ) + + +def test_add_domain_when_browser_destinations_returns_expected_data( + httpserver_auth: HTTPServer, +): + domain = "browser.example.com" + activity_type = ActivityType.DOMAIN + activity_action_groups = [ + { + "name": "DEFAULT", + "activityActions": [ + { + "type": "USER_ACCOUNT_UPLOAD", + "providers": [ + {"name": "SLACK"}, + {"name": "CHATGPT"}, + ], + }, + ], + } + ] + + test_data = { + "type": activity_type, + "value": domain, + "isHighValueSource": None, + "description": None, + "activityActionGroups": activity_action_groups, + } + + test_response = TEST_TRUSTED_ACTIVITY_1.copy() + test_response.update(test_data) + test_response.update({"type": activity_type}) + + httpserver_auth.expect_request( + uri="/v2/trusted-activities", method="POST", json=test_data + ).respond_with_json(test_response) + + client = Client() + trusted_activity = client.trusted_activities.v2.add_domain( + domain=domain, + browser_destinations=["SLACK", "CHATGPT"], + ) + assert isinstance(trusted_activity, TrustedActivity) + assert trusted_activity.type == activity_type + assert trusted_activity.value == domain + assert ( + json.loads(trusted_activity.json())["activityActionGroups"] + == activity_action_groups + ) + + +def test_add_domain_when_is_high_value_returns_expected_data( + httpserver_auth: HTTPServer, +): + domain = "highvalue.example.com" + activity_type = ActivityType.DOMAIN + activity_action_groups = [ + { + "name": "DEFAULT", + "activityActions": [ + {"type": "FILE_UPLOAD", "providers": None}, + ], + } + ] + + test_data = { + "type": activity_type, + "value": domain, + "isHighValueSource": True, + "description": "Description", + "activityActionGroups": activity_action_groups, + } + + test_response = TEST_TRUSTED_ACTIVITY_1.copy() + test_response.update(test_data) + test_response.update({"type": activity_type}) + + httpserver_auth.expect_request( + uri="/v2/trusted-activities", method="POST", json=test_data + ).respond_with_json(test_response) + + client = Client() + trusted_activity = client.trusted_activities.v2.add_domain( + domain=domain, + description="Description", + file_upload=True, + is_high_value=True, + ) + assert isinstance(trusted_activity, TrustedActivity) + assert trusted_activity.type == activity_type + assert trusted_activity.value == domain + assert trusted_activity.is_high_value_source is True + + def test_add_url_path_when_default_params_returns_expected_data( httpserver_auth: HTTPServer, ): @@ -248,6 +387,7 @@ def test_add_url_path_when_default_params_returns_expected_data( test_data = { "type": activity_type, "value": url, + "isHighValueSource": None, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -281,6 +421,7 @@ def test_add_slack_workspace_when_default_params_returns_expected_data( test_data = { "type": activity_type, "value": workspace_name, + "isHighValueSource": None, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -324,6 +465,7 @@ def test_add_account_name_when_default_params_returns_expected_data( test_data = { "type": activity_type, "value": account_name, + "isHighValueSource": None, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -382,6 +524,7 @@ def test_add_git_repository_when_default_params_returns_expected_data( test_data = { "type": activity_type, "value": git_uri, + "isHighValueSource": None, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -549,6 +692,7 @@ def test_cli_add_domain_makes_expected_call(httpserver_auth, runner): test_data = { "type": activity_type, "value": domain, + "isHighValueSource": False, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -601,6 +745,7 @@ def test_cli_add_url_path_makes_expected_call(httpserver_auth, runner): test_data = { "type": activity_type, "value": url, + "isHighValueSource": False, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -628,6 +773,7 @@ def test_cli_add_slack_workspace_makes_expected_call(httpserver_auth, runner): test_data = { "type": activity_type, "value": workspace_name, + "isHighValueSource": False, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -672,6 +818,7 @@ def test_cli_add_account_name_makes_expected_call(httpserver_auth, runner): test_data = { "type": activity_type, "value": account_name, + "isHighValueSource": False, "description": "Description", "activityActionGroups": activity_action_groups, } @@ -715,6 +862,7 @@ def test_cli_git_repo_makes_expected_call(httpserver_auth, runner): test_data = { "type": activity_type, "value": git_uri, + "isHighValueSource": False, "description": "Description", "activityActionGroups": activity_action_groups, }