diff --git a/Packs/Reco/.pack-ignore b/Packs/Reco/.pack-ignore index e69de29bb2d1..cbb7ca145ad1 100644 --- a/Packs/Reco/.pack-ignore +++ b/Packs/Reco/.pack-ignore @@ -0,0 +1,3 @@ +[known_words] +SCIM +backoff diff --git a/Packs/Reco/Integrations/Reco/README.md b/Packs/Reco/Integrations/Reco/README.md index 57b9e002aefa..7e39b65cee8d 100644 --- a/Packs/Reco/Integrations/Reco/README.md +++ b/Packs/Reco/Integrations/Reco/README.md @@ -1,586 +1,940 @@ -Reco is a Saas data security solution that protects your data from accidental leaks and malicious attacks. -This integration was integrated and tested with version 2023.34.0 of Reco. +Reco is the leader in SaaS & AI Security — securing AI sprawl across SaaS apps and agents. This integration connects Reco's SaaS & AI Security platform to Cortex XSOAR, enabling real-time threat response, posture management, AI governance, and identity risk workflows. + +This integration was integrated and tested with Reco External API v1. ## Configure Reco in Cortex | **Parameter** | **Description** | **Required** | | --- | --- | --- | -| Server URL (e.g. https://host.reco.ai/api/v1) | | True | -| JWT app token | | True | -| Trust any certificate (not secure) | | False | -| Use system proxy settings | | False | -| Incident type | | False | -| Fetch incidents | | False | -| Max fetch | | False | -| Source | Incidents SaaS Source | False | -| Before | Created At time before which incidents will be fetched | False | -| After | Created At time after which incidents will be fetched | False | -| Risk level | Risk level of the incidents to fetch | False | -| First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days) | | False | +| Server URL (e.g. https://host.reco.ai/api/v1) | Base URL of your Reco instance | True | +| JWT app token | API Token (Bearer) | True | +| Trust any certificate (not secure) | Skip TLS verification | False | +| Use system proxy settings | Route requests through the system proxy | False | +| Incident type | Incident type to map Reco alerts to | False | +| Fetch incidents | Enable automatic incident fetching | False | +| Max fetch | Maximum incidents to fetch per run (up to 500) | False | +| Source | Filter fetched incidents by SaaS source | False | +| Before | Fetch incidents created before this timestamp | False | +| After | Fetch incidents created after this timestamp | False | +| Minimum risk level (e.g. MEDIUM fetches medium and higher) | The minimum severity threshold for fetched incidents. Accepts a single value: LOW, MEDIUM, HIGH, or CRITICAL (or numeric equivalents 10, 20, 30, 40). Alerts at or above this severity are fetched. | False | +| First fetch timestamp | How far back to fetch on first run (e.g. `7 days`, `12 hours`) | False | + +## SCIM v2 Filters + +All `reco-list-*` commands accept an optional `filters` argument using SCIM v2 syntax: + +| Operator | Meaning | Example | +| --- | --- | --- | +| `eq` | Equals | `severity eq "HIGH"` | +| `ne` | Not equals | `status ne "CLOSED"` | +| `co` | Contains | `email co "@example.com"` | +| `sw` | Starts with | `name sw "John"` | +| `gt` / `ge` | Greater than / or equal | `createdAt gt "2024-01-01T00:00:00Z"` | +| `lt` / `le` | Less than / or equal | `lastSeen le "2024-12-31T23:59:59Z"` | +| `in` | Matches any listed value | `severity in ["HIGH","CRITICAL"]` | +| `not in` | Excludes listed values | `status not in ["CLOSED"]` | +| `and` / `or` / `not` | Logical operators | `isAdmin eq true and hasMfa eq false` | + +Pagination is embedded in the filter string: `limit eq 100 and page eq 1`. ## Commands -You can execute these commands from the CLI, as part of an automation, or in a playbook. -After you successfully execute a command, a DBot message appears in the War Room with the command details. +### reco-add-comment-to-alert -### reco-add-exclusion-filter +*** +Add a comment to an alert in Reco. + +#### Base Command + +`reco-add-comment-to-alert` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| alert_id | Alert ID to add the comment to. | Required | +| comment | Comment text. | Required | + +### reco-update-incident-timeline *** -Add exclusion filter to Reco Classifier +Add a comment to an incident timeline in Reco. #### Base Command -`reco-add-exclusion-filter` +`reco-update-incident-timeline` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| incident_id | Incident ID. | Required | +| comment | Comment text. | Required | + +### reco-resolve-visibility-event + +*** +Resolve an event in a Reco Finding. + +#### Base Command + +`reco-resolve-visibility-event` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| values_to_add | Values to add to the exclusion filter (split by ','). | Required | -| key_to_add | key too add to the exclusion filter (e.g. "CASE_SENSITIVE_TERMS", "LOCATION_CASE_INSENSITIVE_TERMS", "OWNERS", "FILE_IDS", "LOCATIONS"). | Required | +| entity_id | Entity ID of the file to resolve. | Required | +| label_name | Label name to resolve (e.g. `Accessible to All Org Users`). | Required | + +### reco-get-risky-users + +*** +List all accounts flagged as risky (auto-paginates all results). + +#### Base Command + +`reco-get-risky-users` #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.RiskyUsers.id | String | Account ID | +| Reco.RiskyUsers.name | String | Account display name | +| Reco.RiskyUsers.accountEmail | String | Account email address | +| Reco.RiskyUsers.permissions | String | Account permissions (ADMIN / PRIVILEGED / STANDARD) | +| Reco.RiskyUsers.hasMfa | String | MFA status (MFA / NOMFA / NA) | +| Reco.RiskyUsers.openAlerts | Number | Number of open alerts for this account | +| Reco.RiskyUsers.isAdmin | Boolean | Whether the account has admin privileges | +| Reco.RiskyUsers.isRiskyUser | Boolean | Whether the account is flagged as risky | +| Reco.RiskyUsers.lastSeen | Date | Last activity timestamp | -### reco-update-incident-timeline +### reco-add-risky-user-label *** -Update incident timelines +Tag a user as risky in Reco. #### Base Command -`reco-update-incident-timeline` +`reco-add-risky-user-label` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| comment | Comment to add to the incident. | Required | -| incident_id | Incident ID to add the comment to. | Required | +| email_address | Email address of the user to tag as risky. | Required | + +### reco-add-leaving-org-user-label + +*** +Tag a user as a departing employee in Reco. + +#### Base Command + +`reco-add-leaving-org-user-label` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| email_address | Email address of the user to tag as departing. | Required | + +### reco-get-assets-user-has-access-to + +*** +List files a user has access to. + +#### Base Command + +`reco-get-assets-user-has-access-to` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| email_address | User email address. | Required | +| only_sensitive | Return only sensitive assets. | Optional | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Assets | Unknown | Assets the user has access to | -### reco-add-comment-to-alert +### reco-get-sensitive-assets-by-name *** -Add a comment to an alert in Reco +Find sensitive assets by name. #### Base Command -`reco-add-comment-to-alert` +`reco-get-sensitive-assets-by-name` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| comment | Comment to add to the incident. | Required | -| alert_id | Alert ID to add the comment to. | Required | +| asset_name | Asset name to search for. | Required | +| regex_search | Use substring/contains matching instead of exact match. | Optional | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.SensitiveAssets.id | String | Asset ID | +| Reco.SensitiveAssets.name | String | Asset name | +| Reco.SensitiveAssets.owner | String | Asset owner | +| Reco.SensitiveAssets.url | String | Asset URL | +| Reco.SensitiveAssets.sensitivityLevel | Number | Sensitivity level (30=HIGH, 40=CRITICAL) | +| Reco.SensitiveAssets.permissionVisibility | String | Permission visibility (PUBLIC / INTERNAL / RESTRICTED) | +| Reco.SensitiveAssets.location | String | File path | +| Reco.SensitiveAssets.dataCategories | Unknown | Detected data categories | -### reco-resolve-visibility-event +### reco-get-sensitive-assets-by-id *** -Resolve an event in Reco Finding. Reco Findings contains aggregations of events. This command resolves the event in the Reco Finding. +Find sensitive assets by ID. #### Base Command -`reco-resolve-visibility-event` +`reco-get-sensitive-assets-by-id` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| entity_id | entity id of the file to resolve. | Required | -| label_name | label name to resolve (e.g. "Accessible to All Org Users", "Accessible by General Public"). | Required | +| asset_id | Asset ID. | Required | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.SensitiveAssets.id | String | Asset ID | +| Reco.SensitiveAssets.name | String | Asset name | +| Reco.SensitiveAssets.owner | String | Asset owner | +| Reco.SensitiveAssets.url | String | Asset URL | +| Reco.SensitiveAssets.sensitivityLevel | Number | Sensitivity level (30=HIGH, 40=CRITICAL) | +| Reco.SensitiveAssets.permissionVisibility | String | Permission visibility (PUBLIC / INTERNAL / RESTRICTED) | +| Reco.SensitiveAssets.location | String | File path | +| Reco.SensitiveAssets.dataCategories | Unknown | Detected data categories | -### reco-get-risky-users +### reco-get-assets-by-id *** -Get Risky Users from Reco +Find any asset by ID. #### Base Command -`reco-get-risky-users` +`reco-get-assets-by-id` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | +| asset_id | Asset ID. | Required | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.RiskyUsers | unknown | Risky Users | +| Reco.SensitiveAssets.id | String | Asset ID | +| Reco.SensitiveAssets.name | String | Asset name | +| Reco.SensitiveAssets.owner | String | Asset owner | +| Reco.SensitiveAssets.url | String | Asset URL | +| Reco.SensitiveAssets.sensitivityLevel | Number | Sensitivity level (30=HIGH, 40=CRITICAL) | +| Reco.SensitiveAssets.permissionVisibility | String | Permission visibility (PUBLIC / INTERNAL / RESTRICTED) | +| Reco.SensitiveAssets.location | String | File path | +| Reco.SensitiveAssets.dataCategories | Unknown | Detected data categories | -### reco-add-risky-user-label +### reco-get-link-to-user-overview-page *** -Tag a user as risky in Reco +Generate a deep link to the Reco UI overview page for an entity. #### Base Command -`reco-add-risky-user-label` +`reco-get-link-to-user-overview-page` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| entity | Entity type (e.g. `RM_LINK_TYPE_USER`). | Required | +| param | Entity ID or email. | Optional | + +### reco-get-3rd-parties-accessible-to-data-list + +*** +List third-party domains that have access to sensitive data. + +#### Base Command + +`reco-get-3rd-parties-accessible-to-data-list` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| email_address | Email address of the user to add to the risky users list in Reco. | Required | +| last_interaction_time_in_days | Include domains with activity within this many days. | Required | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Domains.domain | String | Third-party domain | +| Reco.Domains.last_activity | String | Last interaction date | +| Reco.Domains.files_num | Number | Number of files accessible | +| Reco.Domains.users_with_access_num | Number | Number of users with access | -### reco-get-assets-user-has-access-to +### reco-get-sensitive-assets-with-public-link *** -Get all files user has access to from Reco +List sensitive assets exposed via a public link. #### Base Command -`reco-get-assets-user-has-access-to` +`reco-get-sensitive-assets-with-public-link` + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Assets.asset_id | String | Asset ID | +| Reco.Assets.asset | Unknown | Asset metadata | +| Reco.Assets.data_category | String | Primary data category | +| Reco.Assets.last_access_date | String | Last access date | + +### reco-get-files-shared-with-3rd-parties + +*** +List files shared with a specific third-party domain. + +#### Base Command + +`reco-get-files-shared-with-3rd-parties` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| email_address | Email address of the user. | Required | -| only_sensitive | Return only sensitive assets owned by this user. | Optional | +| domain | Third-party domain to query. | Required | +| last_interaction_time_in_days | Include files with activity within this many days. | Required | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.Assets | unknown | Assets user has access to | +| Reco.Assets.asset_id | String | Asset ID | +| Reco.Assets.location | String | File location | +| Reco.Assets.file_owner | String | File owner | +| Reco.Assets.domain | String | Third-party domain | +| Reco.Assets.last_access_date | String | Last access date | -### reco-add-leaving-org-user-label +### reco-change-alert-status *** -Tag a user as leaving org user in Reco +Update the status of a Reco alert. #### Base Command -`reco-add-leaving-org-user-label` +`reco-change-alert-status` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| email_address | Email address of the user to tag as levaing org user. | Required | +| alert_id | Alert ID. | Required | +| status | New status. Possible values: `ALERT_STATUS_NEW`, `ALERT_STATUS_IN_PROGRESS`, `ALERT_STATUS_CLOSED`. | Required | + +### reco-get-user-context-by-email-address + +*** +Get identity context for a user by email address. + +#### Base Command + +`reco-get-user-context-by-email-address` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| email_address | User email address. | Required | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.User.id | String | Identity ID | +| Reco.User.email | String | Primary email address | +| Reco.User.name | String | Full name | +| Reco.User.departments | String | Departments | +| Reco.User.jobTitles | String | Job titles | +| Reco.User.isFormer | Boolean | Whether the user is a former employee | +| Reco.User.isInternal | Boolean | Whether the user is an internal employee | +| Reco.User.openAlerts | Number | Number of open alerts | +| Reco.User.lastSeen | Date | Last activity timestamp | -### reco-get-sensitive-assets-by-name +### reco-get-files-exposed-to-email-address *** -Get all sensitive assets from Reco by name +List files accessible to a specific email address. #### Base Command -`reco-get-sensitive-assets-by-name` +`reco-get-files-exposed-to-email-address` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| asset_name | Asset name to search for. | Required | -| regex_search | Return only sensitive assets owned by this user. | Optional | +| email_address | Email address. | Required | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Assets.asset_id | String | Asset ID | +| Reco.Assets.location | String | File location | +| Reco.Assets.email_account | String | Email account with access | +| Reco.Assets.file_owner | String | File owner | -### reco-get-sensitive-assets-by-id +### reco-get-assets-shared-externally *** -Get all sensitive assets from Reco by id +List files an owner has shared outside the organization. #### Base Command -`reco-get-sensitive-assets-by-id` +`reco-get-assets-shared-externally` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| asset_id | Asset id to search for. | Required | +| email_address | File owner email address. | Required | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.SensitiveAssets.file_name | String | The name of the asset | -| Reco.SensitiveAssets.file_owner | String | The owner of the asset | -| Reco.SensitiveAssets.file_url | Unknown | Json string of the asset's url and the name | -| Reco.SensitiveAssets.currently_permitted_users | String | List of currently permitted users | -| Reco.SensitiveAssets.visibility | String | Visibility of the asset | -| Reco.SensitiveAssets.location | String | The path of the asset | -| Reco.SensitiveAssets.source | String | SaaS tool source of the asset | -| Reco.SensitiveAssets.sensitivity_level | Number | The sensitivity level of the asset | +| Reco.Assets.asset_id | String | Asset ID | +| Reco.Assets.file_owner | String | File owner | +| Reco.Assets.last_access_date | String | Last access date | -### reco-get-link-to-user-overview-page +### reco-get-private-email-list-with-access *** -Generate a magic link for reco UI (overview page) +List private (non-corporate) email addresses with file access. #### Base Command -`reco-get-link-to-user-overview-page` +`reco-get-private-email-list-with-access` + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.privateEmails.email_account | String | Private email account | +| Reco.privateEmails.primary_email | String | Associated corporate email | +| Reco.privateEmails.files_num | Number | Number of files accessible | +| Reco.privateEmails.user_category | String | User category | + +### reco-get-alert-ai-summary + +*** +Get an AI-generated summary of an alert. + +#### Base Command + +`reco-get-alert-ai-summary` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| entity | Entity Type (RM_LINK_TYPE_USER). | Required | -| param | Entity ID (user email). | Optional | +| alert_id | Alert ID. | Required | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.AlertSummary.markdown | String | Markdown-formatted alert summary | -### reco-get-3rd-parties-accessible-to-data-list +### reco-get-apps *** -Get 3rd parties accessible to sensitive assets +List all discovered SaaS applications (auto-paginates all results). #### Base Command -`reco-get-3rd-parties-accessible-to-data-list` +`reco-get-apps` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| last_interaction_time_in_days | Last interaction time in days. | Required | +| before | Filter apps last seen before this date. | Optional | +| after | Filter apps last seen after this date. | Optional | +| limit | Page size (omit for all results). | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.Domains.domain | String | The domain of the 3rd party | -| Reco.Domains.last_activity | String | The last interaction time with the 3rd party | -| Reco.Domains.files_num | Number | The number of files the 3rd party has access to | -| Reco.Domains.users_with_access_num | Number | The number of users the 3rd party has access to | +| Reco.Apps.id | String | The unique identifier of the application. | +| Reco.Apps.name | String | The name of the application. | +| Reco.Apps.category | String | The category of the application. | +| Reco.Apps.usersCount | Number | The number of users with access to the application. | +| Reco.Apps.authorization | String | The authorization/sanction status of the application. | +| Reco.Apps.authType | String | The authentication type used by the application. | +| Reco.Apps.isUsingAi | Boolean | Whether the application uses AI. | +| Reco.Apps.isShadowApp | Boolean | Whether the application is a shadow/unmanaged app. | +| Reco.Apps.vendorGrade | String | The vendor security grade of the application. | +| Reco.Apps.aiCapability | String | The AI capability description for the application. | +| Reco.Apps.lastSeen | Date | The last activity timestamp for the application. | -### reco-get-sensitive-assets-with-public-link +### reco-set-app-authorization-status *** -Get all sensitive assets with public link from Reco +Update the authorization status of an application. #### Base Command -`reco-get-sensitive-assets-with-public-link` +`reco-set-app-authorization-status` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | +| app_id | Application ID. | Required | +| authorization_status | Authorization status. Possible values: `AUTH_STATUS_SANCTIONED`, `AUTH_STATUS_UNSANCTIONED`, `AUTH_STATUS_TO_REVIEW`, `AUTH_STATUS_ACCEPTED_RISK`, `AUTH_STATUS_EVALUATING`, `AUTH_STATUS_UNDER_INVESTIGATION`, `AUTH_STATUS_INVESTIGATED`, `AUTH_STATUS_CLOUD_INVENTORY`, `AUTH_STATUS_SYSTEM_SANCTIONED`. | Required | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.Assets.asset_id | String | The asset id | -| Reco.Assets.asset | Unknown | Json string of the asset's url and the name | -| Reco.Assets.data_category | String | The data category of the asset | -| Reco.Assets.data_categories | String | The data categories of the asset | -| Reco.SensitiveAssets.location | String | The path of the asset | -| Reco.SensitiveAssets.source | String | SaaS tool source of the asset | -| Reco.Assets.last_access_date | String | The last access date of the asset | +| Reco.AppAuthorization.app_id | String | The application ID that was updated. | +| Reco.AppAuthorization.authorization_status | String | The authorization status that was set. | +| Reco.AppAuthorization.updated | Boolean | Whether the update was successful. | +| Reco.AppAuthorization.rows_affected | Number | Number of rows affected by the update operation. | -### reco-get-files-shared-with-3rd-parties +#### Command example + +``` +!reco-set-app-authorization-status app_id="microsoft.com" authorization_status="AUTH_STATUS_SANCTIONED" +``` + +### reco-add-exclusion-filter *** -Get files shared with 3rd parties +Add values to a Reco classifier exclusion list. #### Base Command -`reco-get-files-shared-with-3rd-parties` +`reco-add-exclusion-filter` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| last_interaction_time_in_days | Last interaction time in days. | Required | -| domain | Domain to search. | Required | +| key_to_add | Exclusion key (e.g. `CASE_SENSITIVE_TERMS`, `OWNERS`, `FILE_IDS`, `LOCATIONS`). | Required | +| values_to_add | Comma-separated values to add. | Required | + +--- + +## List Commands (External API) + +All commands below accept `filters` (SCIM v2 expression) and `limit` (default 1000). + +### reco-list-events + +*** +List SaaS activity events. + +#### Base Command + +`reco-list-events` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "actor.email eq "user@example.com" and eventTime gt "2024-01-01T00:00:00Z""). | Optional | +| limit | The maximum number of events to return. Default is 1000. | Optional | #### Context Output -| **Path** | **Type** | **Description** | -|------------------------------|----------|-------------------------------------------------------------| -| Reco.Assets.asset_id | String | The asset id of the file | -| Reco.Assets.location | String | The location of the file | -| Reco.Assets.users | String | Users the file is shared with | -| Reco.Assets.file_owner | String | File Owner | -| Reco.Assets.asset | Unknown | The asset metadata | -| Reco.Assets.data_category | String | The data category of the assets the 3rd party has access to | -| Reco.Assets.last_access_date | String | The last access date of the asset | -| Reco.Assets.domain | String | The domain of the 3rd party | +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Events.id | String | Event ID | +| Reco.Events.eventType | String | Event type code | +| Reco.Events.formattedEventType | String | Human-readable event type | +| Reco.Events.application | String | Source SaaS application | +| Reco.Events.actorEmail | String | Actor email address | +| Reco.Events.actorName | String | Actor display name | +| Reco.Events.eventTime | Date | Event timestamp | +| Reco.Events.outcomeString | String | Event outcome description | -### reco-change-alert-status +### reco-list-posture-issues *** -update alert status in Reco +List security posture issues. #### Base Command -`reco-change-alert-status` +`reco-list-posture-issues` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| alert_id | alert id to get. | Required | -| status | status to set the alert to (e.g. "ALERT_STATUS_NEW", "ALERT_STATUS_IN_PROGRESS", "ALERT_STATUS_CLOSED"). Possible values are: ALERT_STATUS_NEW, ALERT_STATUS_IN_PROGRESS, ALERT_STATUS_CLOSED. | Required | +| filters | The SCIM v2 filter expression (e.g. "severity eq "HIGH""). | Optional | +| limit | The maximum number of posture issues to return. Default is 1000. | Optional | #### Context Output -There is no context output for this command. +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.PostureIssues.id | String | Issue ID | +| Reco.PostureIssues.name | String | Issue name | +| Reco.PostureIssues.severity | String | Severity (LOW/MEDIUM/HIGH/CRITICAL) | +| Reco.PostureIssues.checkStatus | String | Check status | +| Reco.PostureIssues.scorePercentage | Number | Compliance score percentage | +| Reco.PostureIssues.checkedInstance | Unknown | The SaaS instance this issue was checked against | +| Reco.PostureIssues.url | String | Link to issue in Reco UI | -### reco-get-user-context-by-email-address +### reco-list-accounts *** -Get user context by email address from Reco. +List SaaS accounts. #### Base Command -`reco-get-user-context-by-email-address` +`reco-list-accounts` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| email_address | user email address. | Required | +| filters | The SCIM v2 filter expression (e.g. "isRiskyUser eq true" or "accountEmail co "@example.com""). | Optional | +| limit | The maximum number of accounts to return. Default is 1000. | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.User.email_account | String | The email of the user. | -| Reco.User.departments | String | User departments. | -| Reco.User.job_titles | String | Job Title. | -| Reco.User.category | String | Category. | -| Reco.User.groups | String | The groups user is member of. | -| Reco.User.full_name | String | The user full name. | -| Reco.User.labels | Unknown | User Labels. | +| Reco.Accounts.id | String | Account ID | +| Reco.Accounts.name | String | Account display name | +| Reco.Accounts.accountEmail | String | Account email address | +| Reco.Accounts.permissions | String | Permission level | +| Reco.Accounts.hasMfa | String | MFA status | +| Reco.Accounts.openAlerts | Number | Open alerts count | +| Reco.Accounts.isAdmin | Boolean | Admin flag | +| Reco.Accounts.isRiskyUser | Boolean | Risky user flag | +| Reco.Accounts.lastSeen | Date | Last activity | -### reco-get-files-exposed-to-email-address +### reco-list-devices *** -Get files exposed to a specific email address +List managed and unmanaged devices. #### Base Command -`reco-get-files-exposed-to-email-address` +`reco-list-devices` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "isUnmanaged eq true" or "devicePlatform eq "Windows""). | Optional | +| limit | The maximum number of devices to return. Default is 1000. | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.Assets.asset_id | String | The asset id | -| Reco.Assets.asset | Unknown | Json string of the asset's url and the name | -| Reco.Assets.data_category | String | The data category of the asset | -| Reco.Assets.data_categories | String | The data categories of the asset | -| Reco.Assets.location | String | The path of the asset. | -| Reco.Assets.source | String | SaaS tool source of the asset. | -| Reco.Assets.last_access_date | String | The last access date of the asset | -| Reco.Assets.email_account | String | The last access date of the asset | -| Reco.Assets.file_owner | String | SaaS tool source of the asset | +| Reco.Devices.id | String | Device ID | +| Reco.Devices.name | String | Device name | +| Reco.Devices.devicePlatform | String | Device platform (Windows, macOS, iOS, Android, etc.) | +| Reco.Devices.os | String | Operating system of the device | +| Reco.Devices.osVersion | String | Operating system version | +| Reco.Devices.isUnmanaged | Boolean | Whether the device is unmanaged (not enrolled in MDM) | +| Reco.Devices.hasNonCompliant | Boolean | Whether the device has non-compliant policies | +| Reco.Devices.lastSeen | Date | Last activity timestamp | -### reco-get-assets-shared-externally +### reco-list-ai-agents *** -Get files exposed to a specific email address +List detected AI agents. #### Base Command -`reco-get-assets-shared-externally` +`reco-list-ai-agents` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "authorization eq "AUTH_STATUS_UNSANCTIONED""). | Optional | +| limit | The maximum number of AI agents to return. Default is 1000. | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.Assets.asset_id | String | The asset id | -| Reco.Assets.asset | Unknown | Json string of the asset's url and the name | -| Reco.Assets.data_category | String | The data category of the asset | -| Reco.Assets.data_categories | String | The data categories of the asset | -| Reco.SensitiveAssets.location | String | The path of the asset | -| Reco.SensitiveAssets.source | String | SaaS tool source of the asset | -| Reco.Assets.last_access_date | String | The last access date of the asset | -| Reco.Assets.file_owner | String | SaaS tool source of the asset | +| Reco.AiAgents.id | String | AI agent ID | +| Reco.AiAgents.name | String | AI agent name | +| Reco.AiAgents.vendor | String | Vendor of the AI agent | +| Reco.AiAgents.type | String | Type of AI agent | +| Reco.AiAgents.authorization | String | Authorization/sanction status of the AI agent | +| Reco.AiAgents.agentStatus | String | Current status of the AI agent | +| Reco.AiAgents.risk | Number | Risk level of the AI agent (0=NA, 1=LOW, 2=MEDIUM, 3=HIGH, 4=CRITICAL) | +| Reco.AiAgents.lastUsage | Date | Last usage timestamp | -### reco-get-private-email-list-with-access +### reco-list-groups *** -Get private email list with access +List SaaS groups. #### Base Command -`reco-get-private-email-list-with-access` +`reco-list-groups` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "name co "Engineering""). | Optional | +| limit | The maximum number of groups to return. Default is 1000. | Optional | #### Context Output -| **Path** | **Type** | **Description** | -| --- |----------|---------------------------| -| Reco.privateEmails.email_account | String | The email account | -| Reco.privateEmails.primary_email | String | The primary email account | -| Reco.privateEmails.files_num | String | Number of files | -| Reco.privateEmails.user_category | String | The category of the user | +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Groups.id | String | Group ID | +| Reco.Groups.name | String | Group name | +| Reco.Groups.email | String | Group email address | +| Reco.Groups.membersCount | Number | Number of members in the group | +| Reco.Groups.appsCount | Number | Number of apps the group has access to | -### reco-get-assets-by-id +### reco-list-saas-to-saas *** -Get all assets from Reco by id +List SaaS-to-SaaS OAuth grants. #### Base Command -`reco-get-assets-by-id` +`reco-list-saas-to-saas` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| asset_id | Asset id to search for. | Required | +| filters | The SCIM v2 filter expression (e.g. "authorization eq "AUTH_STATUS_UNSANCTIONED" or permissionRisk eq "30""). | Optional | +| limit | The maximum number of SaaS-to-SaaS grants to return. Default is 1000. | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.SensitiveAssets.file_name | String | The name of the asset | -| Reco.SensitiveAssets.file_owner | String | The owner of the asset | -| Reco.SensitiveAssets.file_url | Unknown | Json string of the asset's url and the name | -| Reco.SensitiveAssets.currently_permitted_users | String | List of currently permitted users | -| Reco.SensitiveAssets.visibility | String | Visibility of the asset | -| Reco.SensitiveAssets.location | String | The path of the asset | -| Reco.SensitiveAssets.source | String | SaaS tool source of the asset | -| Reco.SensitiveAssets.sensitivity_level | Number | The sensitivity level of the asset | +| Reco.SaasToSaas.id | String | SaaS-to-SaaS grant ID | +| Reco.SaasToSaas.plugin | String | The plugin or app name receiving the grant | +| Reco.SaasToSaas.authorization | String | Authorization status of the grant | +| Reco.SaasToSaas.permissionRisk | String | Permission risk level (10=LOW, 20=MEDIUM, 30=HIGH) | +| Reco.SaasToSaas.accounts | Number | Number of accounts with this grant | +| Reco.SaasToSaas.aiCapability | String | AI capability of the third-party app | +| Reco.SaasToSaas.lastSeen | Date | Last activity timestamp for this grant | -### reco-get-alert-ai-summary +### reco-list-ip-addresses *** -Get alert AI summary +List observed IP addresses. #### Base Command -`reco-get-alert-ai-summary` +`reco-list-ip-addresses` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| alert_id | Alert id to get. | Required | +| filters | The SCIM v2 filter expression (e.g. "country eq "CN" or hasVpn eq true"). | Optional | +| limit | The maximum number of IP addresses to return. Default is 1000. | Optional | #### Context Output -| **Path** | **Type** | **Description** | -|----------------------------| --- |-------------------| -| Reco.AlertSummary.markdown | String | The alert summary | +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.IpAddresses.ipAddress | String | The IP address or CIDR range | +| Reco.IpAddresses.country | String | Country of the IP address | +| Reco.IpAddresses.asnName | String | ASN name of the IP address | +| Reco.IpAddresses.eventsCount | Number | Number of events from this IP | +| Reco.IpAddresses.usersCount | Number | Number of users seen from this IP | +| Reco.IpAddresses.hasVpn | Boolean | Whether the IP is associated with a VPN | +| Reco.IpAddresses.hasProxy | Boolean | Whether the IP is associated with a proxy | +| Reco.IpAddresses.lastEventTime | Date | Last event timestamp from this IP | -### reco-get-apps +### reco-list-business-units *** -Get app discovery data from Reco. Fetches all available apps using pagination. +List external business units. #### Base Command -`reco-get-apps` +`reco-list-business-units` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| before | Before date filter (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ format). | Optional | -| after | After date filter (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ format). | Optional | -| limit | Page size for pagination (default 1000). The command fetches all available apps across all pages. | Optional | +| filters | The SCIM v2 filter expression (e.g. "name eq "Finance""). | Optional | +| limit | The maximum number of business units to return. Default is 1000. | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.Apps.app_name | String | The name of the application | -| Reco.Apps.app_id | String | The unique identifier of the application | -| Reco.Apps.category | String | The category of the application | -| Reco.Apps.risk_score | Number | The risk score of the application | -| Reco.Apps.users_count | Number | The number of users with access to the application | -| Reco.Apps.data_access | String | The data access level of the application | -| Reco.Apps.updated_at | Date | The last update timestamp of the application | -| Reco.Apps.created_at | Date | The creation timestamp of the application | -| Reco.Apps.status | String | The status of the application | +| Reco.BusinessUnits.id | String | Business unit ID | +| Reco.BusinessUnits.name | String | Business unit name | +| Reco.BusinessUnits.manager | String | Manager of the business unit | +| Reco.BusinessUnits.createdAt | Date | Creation timestamp of the business unit | -### reco-set-app-authorization-status +### reco-list-audit-logs *** -Set authorization status for an application in Reco. +List Reco platform audit logs. #### Base Command -`reco-set-app-authorization-status` +`reco-list-audit-logs` #### Input | **Argument Name** | **Description** | **Required** | | --- | --- | --- | -| app_id | Application ID to update authorization status for. | Required | -| authorization_status | Authorization status to set for the application. Possible values are: AUTH_STATUS_UNSPECIFIED, AUTH_STATUS_TO_REVIEW, AUTH_STATUS_SANCTIONED, AUTH_STATUS_UNSANCTIONED, AUTH_STATUS_ACCEPTED_RISK, AUTH_STATUS_EVALUATING, AUTH_STATUS_UNDER_INVESTIGATION, AUTH_STATUS_INVESTIGATED, AUTH_STATUS_CLOUD_INVENTORY, AUTH_STATUS_SYSTEM_SANCTIONED. | Required | +| filters | The SCIM v2 filter expression (e.g. "userEmail eq "admin@example.com" and action eq "DELETE""). | Optional | +| limit | The maximum number of audit log entries to return. Default is 1000. | Optional | #### Context Output | **Path** | **Type** | **Description** | | --- | --- | --- | -| Reco.AppAuthorization.app_id | String | The application ID that was updated | -| Reco.AppAuthorization.authorization_status | String | The authorization status that was set | -| Reco.AppAuthorization.updated | Boolean | Whether the update was successful | -| Reco.AppAuthorization.rows_affected | Number | Number of rows affected by the update operation | +| Reco.AuditLogs.id | String | Audit log entry ID | +| Reco.AuditLogs.userEmail | String | Email of the user who performed the action | +| Reco.AuditLogs.module | String | Module where the action was performed | +| Reco.AuditLogs.action | String | Action performed | +| Reco.AuditLogs.objectName | String | Name of the object affected | +| Reco.AuditLogs.timestamp | Date | Timestamp of the audit log entry | +| Reco.AuditLogs.remoteAddr | String | Remote IP address of the actor | -#### Command example +### reco-list-posture-checks -``` -!reco-set-app-authorization-status app_id="microsoft.com" authorization_status="AUTH_STATUS_SANCTIONED" -``` +*** +List posture check definitions. -#### Context Example - -```json -{ - "Reco": { - "AppAuthorization": { - "app_id": "microsoft.com", - "authorization_status": "AUTH_STATUS_SANCTIONED", - "updated": true, - "rows_affected": 1 - } - } -} -``` +#### Base Command + +`reco-list-posture-checks` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "severity eq "HIGH" and apps co "Google""). | Optional | +| limit | The maximum number of posture check definitions to return. Default is 1000. | Optional | + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.PostureChecks.id | String | Posture check ID | +| Reco.PostureChecks.name | String | Posture check name | +| Reco.PostureChecks.severity | String | Severity of the posture check | +| Reco.PostureChecks.policyType | String | Policy type of the posture check | +| Reco.PostureChecks.apps | Unknown | Applications this posture check applies to | +| Reco.PostureChecks.type | String | Type of posture check (built-in or custom) | + +### reco-list-threat-detection-policies + +*** +List threat detection policies. + +#### Base Command + +`reco-list-threat-detection-policies` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "severity eq "HIGH" and status eq "ON""). | Optional | +| limit | The maximum number of policies to return. Default is 1000. | Optional | + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.ThreatDetectionPolicies.id | String | Policy ID | +| Reco.ThreatDetectionPolicies.name | String | Policy name | +| Reco.ThreatDetectionPolicies.severity | String | Severity of the policy | +| Reco.ThreatDetectionPolicies.status | String | Status of the policy (ON, OFF, or PREVIEW) | +| Reco.ThreatDetectionPolicies.apps | Unknown | Applications monitored by the policy | +| Reco.ThreatDetectionPolicies.openAlerts | Number | Number of open alerts triggered by this policy | +| Reco.ThreatDetectionPolicies.type | String | Type of policy (built-in or custom) | + +### reco-list-exclusions + +*** +List alert suppression exclusion rules. -#### Human Readable Output +#### Base Command + +`reco-list-exclusions` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "policyName co "MFA""). | Optional | +| limit | The maximum number of exclusions to return. Default is 1000. | Optional | + +#### Context Output ->App microsoft.com authorization status updated successfully to AUTH_STATUS_SANCTIONED +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.Exclusions.id | String | Exclusion rule ID | +| Reco.Exclusions.name | String | Exclusion rule name | +| Reco.Exclusions.policyName | String | Name of the policy this exclusion applies to | +| Reco.Exclusions.apps | Unknown | Applications this exclusion applies to | +| Reco.Exclusions.createdBy | String | User who created the exclusion | +| Reco.Exclusions.createdAt | Date | Creation timestamp of the exclusion | + +### reco-list-app-instances + +*** +List integrated app instances (app portfolio). Only returns instances with an active integration status. + +#### Base Command + +`reco-list-app-instances` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| filters | The SCIM v2 filter expression (e.g. "isUsingAi eq true"). | Optional | +| limit | The maximum number of app instances to return. Default is 1000. | Optional | + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| Reco.AppInstances.id | String | App instance ID | +| Reco.AppInstances.name | String | App instance name | +| Reco.AppInstances.instanceType | String | Instance type | +| Reco.AppInstances.accountsCount | Number | Number of accounts | +| Reco.AppInstances.isUsingAi | Boolean | Whether AI features are used | +| Reco.AppInstances.saasToSaasCount | Number | SaaS-to-SaaS grant count | +| Reco.AppInstances.filesCount | Number | File count | diff --git a/Packs/Reco/Integrations/Reco/Reco.py b/Packs/Reco/Integrations/Reco/Reco.py index 4f703e0adef0..eec3dc34e9ce 100644 --- a/Packs/Reco/Integrations/Reco/Reco.py +++ b/Packs/Reco/Integrations/Reco/Reco.py @@ -2,6 +2,7 @@ from CommonServerPython import * # noqa: F401 import base64 import json +import time from datetime import datetime, timedelta import dateutil.parser @@ -22,21 +23,59 @@ FILTER_RELATIONSHIP_AND = "FILTER_RELATIONSHIP_AND" PAGE_SIZE = 1000 +MAX_PAGES = 1000 # hard cap on _paginate_all iterations; guards against an unbounded loop DEMISTO_OCCURRED_FORMAT = "%Y-%m-%dT%H:%M:%SZ" RECO_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" DEMISTO_INFORMATIONAL = 0.5 -RECO_API_TIMEOUT_IN_SECONDS = 180 # Increase timeout for RECO API +RECO_API_TIMEOUT_IN_SECONDS = 180 RECO_ACTIVE_INCIDENTS_VIEW = "active_incidents_view" +RATE_LIMIT_MAX_RETRIES = 3 +RATE_LIMIT_RETRY_BASE_DELAY = 2 # seconds; doubles on each attempt ALERT_VIEW_WITH_SHARED_STATUS = "ALERT_VIEW_WITH_SHARED_STATUS" RECO_TIMELINE_EVENT_TYPE = "TIMELINE_EVENT_TYPE_USER_COMMENT" CREATED_AT_FIELD = "created_at" STEP_FETCH = "fetch" STEP_INIT = "init" - -def create_filter(field, value): - return {"field": field, "stringContains": {"value": value}} +# Base path for the Reco external API (relative to /api/v1) +EXTERNAL_API_BASE = "/external-api" + +# Maps numeric or string risk level values to the severity name expected by the external API. +RISK_LEVEL_TO_SEVERITY_NAME: dict[str, str] = { + "10": "LOW", + "LOW": "LOW", + "low": "LOW", + "20": "MEDIUM", + "MEDIUM": "MEDIUM", + "medium": "MEDIUM", + "30": "HIGH", + "HIGH": "HIGH", + "high": "HIGH", + "40": "CRITICAL", + "CRITICAL": "CRITICAL", + "critical": "CRITICAL", +} + +# Ascending severity order, used to expand a minimum risk level into "that level and above". +SEVERITY_ORDER: list[str] = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] + + +def parse_minimum_risk_level(risk_level_param: str | None) -> list[str] | None: + """Parse a single risk_level value into the list of severity names at or above it. + + Accepts a numeric value (10/20/30/40) or a name (LOW/MEDIUM/HIGH/CRITICAL). + Example: "MEDIUM" -> ["MEDIUM", "HIGH", "CRITICAL"] (fetches medium severity and higher). + """ + if not risk_level_param: + return None + value = str(risk_level_param).strip() + severity_name = RISK_LEVEL_TO_SEVERITY_NAME.get(value) or RISK_LEVEL_TO_SEVERITY_NAME.get(value.upper()) + if not severity_name: + demisto.debug(f"Unknown risk level value '{value}', using upper-cased as-is") + return [value.upper()] + min_index = SEVERITY_ORDER.index(severity_name) + return SEVERITY_ORDER[min_index:] def extract_response(response: Any) -> list[dict[str, Any]]: @@ -44,7 +83,7 @@ def extract_response(response: Any) -> list[dict[str, Any]]: demisto.error(f"got bad response, {response}") raise Exception(f"got bad response, {response}") else: - demisto.info(f"Count of entites: {response.get('getTableResponse').get('totalNumberOfResults')}") + demisto.info(f"Count of entities: {response.get('getTableResponse').get('totalNumberOfResults')}") entities = response.get("getTableResponse", {}).get("data", {}).get("rows", []) demisto.info(f"Got {len(entities)} entities") return entities @@ -62,132 +101,236 @@ def __init__(self, api_token: str, base_url: str, verify: bool, proxy): }, ) + # --- External API helpers --- + + def _rate_limited_request(self, method: str, url_suffix: str, **kwargs) -> dict[str, Any]: + """Wrap _http_request with automatic 429 retry and exponential backoff. + + Respects a Retry-After header when present; otherwise backs off + RATE_LIMIT_RETRY_BASE_DELAY * 2^attempt seconds between attempts. + Raises on the last attempt or on any non-429 error. + """ + for attempt in range(RATE_LIMIT_MAX_RETRIES): + try: + return self._http_request(method=method, url_suffix=url_suffix, **kwargs) + except DemistoException as exc: + is_rate_limited = exc.res is not None and getattr(exc.res, "status_code", None) == 429 + if is_rate_limited and attempt < RATE_LIMIT_MAX_RETRIES - 1: + retry_after = int( + exc.res.headers.get( # type: ignore[union-attr] + "Retry-After", + RATE_LIMIT_RETRY_BASE_DELAY * (2**attempt), + ) + ) + demisto.debug( + f"Rate limited (429) on {url_suffix}, " + f"retrying in {retry_after}s (attempt {attempt + 1}/{RATE_LIMIT_MAX_RETRIES})" + ) + time.sleep(retry_after) # pylint: disable=E9003 + else: + raise + raise DemistoException(f"Max retries ({RATE_LIMIT_MAX_RETRIES}) exceeded for {url_suffix}") + + def _external_api_list( + self, + endpoint: str, + filters: str = "", + count: int = PAGE_SIZE, + start_index: int = 0, + ) -> dict[str, Any]: + """GET /external-api/{endpoint} with SCIM filter and pagination params.""" + params: dict[str, Any] = {"count": count, "startIndex": start_index} + if filters: + params["filters"] = filters + return self._rate_limited_request( + method="GET", + url_suffix=f"{EXTERNAL_API_BASE}/{endpoint}", + params=params, + timeout=RECO_API_TIMEOUT_IN_SECONDS, + ) + + def _external_api_post(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: + """POST /external-api/{endpoint}.""" + return self._rate_limited_request( + method="POST", + url_suffix=f"{EXTERNAL_API_BASE}/{endpoint}", + data=json.dumps(body), + timeout=RECO_API_TIMEOUT_IN_SECONDS, + ) + + def _external_api_put(self, url_suffix: str, body: dict[str, Any]) -> dict[str, Any]: + """PUT to an arbitrary external-api path suffix.""" + return self._rate_limited_request( + method="PUT", + url_suffix=url_suffix, + data=json.dumps(body), + timeout=RECO_API_TIMEOUT_IN_SECONDS, + ) + + def _paginate_all( + self, + endpoint: str, + item_key: str, + filters: str = "", + page_size: int = 1000, + ) -> list[dict[str, Any]]: + """Fetch every page from a list endpoint, returning all items combined. + + Uses the `totalResults` field in the response to know when to stop. + Stops early if an empty page is returned (defensive guard against + totalResults being stale or the server having fewer items than reported). + Capped at MAX_PAGES to guard against an unbounded loop if the server + never reports totalResults or an empty page. + """ + all_items: list[dict[str, Any]] = [] + start_index = 0 + for _page in range(MAX_PAGES): + response = self._external_api_list( + endpoint, + filters=filters, + count=page_size, + start_index=start_index, + ) + page_items = response.get(item_key, []) + if not page_items: + break + all_items.extend(page_items) + total = int(response.get("totalResults", 0) or response.get("total_results", 0) or 0) + demisto.debug(f"_paginate_all {endpoint}: fetched {len(all_items)}/{total}") + if total and len(all_items) >= total: + break + start_index += len(page_items) + else: + demisto.debug(f"_paginate_all {endpoint}: reached MAX_PAGES ({MAX_PAGES}) cap, stopping") + return all_items + + # --- Alerts (external API) --- + def get_alerts( self, - risk_level: int | None = None, + risk_levels: list[str] | None = None, source: str | None = None, before: datetime | None = None, after: datetime | None = None, - limit: int = 1000, + limit: int = PAGE_SIZE, ) -> list[dict[str, Any]]: + """List threat alerts via the external API. + + risk_levels: list of severity names, e.g. ["HIGH", "CRITICAL"]. Multiple + values are combined with OR so a single call fetches all matching severities. """ - Fetch alerts from Reco API - :param risk_level: The risk level of the incidents to fetch - :param source: The source of the incidents to fetch - :param before: The maximum date of the incidents to fetch - :param after: The minimum date of the incidents to fetch - :param limit: int - :return: dict - """ - demisto.info("Get alerts, enter") - alerts: list[dict[str, Any]] = [] - params: dict[str, Any] = { - "getTableRequest": { - "tableName": ALERT_VIEW_WITH_SHARED_STATUS, - "scope": "data", - "pageSize": limit, - "fieldFilters": { - "relationship": FILTER_RELATIONSHIP_AND, - "filters": {"filters": []}, - }, - "fieldSorts": {"sorts": [{"sortBy": "updated_at", "sortDirection": "SORT_DIRECTION_ASC"}]}, - } - } - if risk_level: - params["getTableRequest"]["fieldFilters"]["filters"]["filters"].append( - {"field": "risk_level", "stringEquals": {"value": risk_level}} - ) + filter_parts: list[str] = [] + + if risk_levels: + if len(risk_levels) == 1: + filter_parts.append(f'severity eq "{risk_levels[0]}"') + else: + # Use the SCIM `in` operator - cleaner than a chain of OR clauses + vals = ", ".join(f'"{r}"' for r in risk_levels) + filter_parts.append(f"severity in [{vals}]") + if source: - params["getTableRequest"]["fieldFilters"]["filters"]["filters"].append( - {"field": "short_extraction_source", "stringEquals": {"value": source}} - ) - if before: - params["getTableRequest"]["fieldFilters"]["filters"]["filters"].append( - { - "field": CREATED_AT_FIELD, - "before": {"value": before.strftime("%Y-%m-%dT%H:%M:%SZ")}, - } - ) + filter_parts.append(f'apps co "{source}"') if after: - params["getTableRequest"]["fieldFilters"]["filters"]["filters"].append( - { - "field": CREATED_AT_FIELD, - "after": {"value": after.strftime("%Y-%m-%dT%H:%M:%SZ")}, - } - ) + filter_parts.append(f'createdAt gt "{after.strftime(DEMISTO_OCCURRED_FORMAT)}"') + if before: + filter_parts.append(f'createdAt lt "{before.strftime(DEMISTO_OCCURRED_FORMAT)}"') + + scim_filter = " and ".join(filter_parts) + demisto.debug(f"get_alerts SCIM filter: {scim_filter!r}") - demisto.debug(f"params: {params}") try: - response = self._http_request( - method="PUT", - url_suffix="/policy-subsystem/alert-inbox/table", - data=json.dumps(params), + response = self._external_api_list("alerts/list", filters=scim_filter, count=limit) + alerts = response.get("alerts", []) + demisto.info(f"get_alerts: fetched {len(alerts)} alerts (total={response.get('totalResults', '?')})") + return alerts + except Exception as e: + demisto.error(f"get_alerts error: {str(e)}") + return [] + + def get_single_alert(self, alert_id: str) -> dict[str, Any]: + """Get full alert detail including policy violations via the external API.""" + try: + response = self._rate_limited_request( + method="GET", + url_suffix=f"{EXTERNAL_API_BASE}/alert-details/{alert_id}", timeout=RECO_API_TIMEOUT_IN_SECONDS, ) - alerts = extract_response(response) + return response.get("alert", {}) except Exception as e: - demisto.error(f"Findings Request ReadTimeout error: {str(e)}") - demisto.info(f"done fetching RECO alerts, fetched {len(alerts)} alerts.") - return alerts + demisto.error(f"get_single_alert({alert_id}) error: {str(e)}") + raise - def get_single_alert(self, alert_id: str) -> Any: - """ - Fetch a single alert from Reco API - :param alert_id: The id of the alert to fetch - :return: dict - """ - demisto.info(f"Get single alert, enter, alert_id: {alert_id}") - alert: dict[str, Any] = {} + def get_alert_ai_summary(self, alert_id: str) -> dict[str, Any]: # pragma: no cover + """Get alert AI summary (internal API - no external equivalent yet).""" try: - response = self._http_request( + return self._http_request( method="GET", - url_suffix=f"/policy-subsystem/alert-inbox/{alert_id}", + url_suffix=f"/alert/summarize/{alert_id}", timeout=RECO_API_TIMEOUT_IN_SECONDS, ) - if response.get("alert") is None: - demisto.info(f"got bad response, {response}") - else: - demisto.info(f"got good response, {response}") - alert = response.get("alert", {}) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e - - demisto.info(f"done fetching RECO alert, fetched {alert}") - return alert + demisto.error(f"get_alert_ai_summary error: {str(e)}") + raise - def update_reco_incident_timeline(self, incident_id: str, comment: str) -> Any: - """ - Update timeline of an incident. - """ - demisto.info("Update incident timeline, enter") + def change_alert_status(self, alert_id: str, status: str) -> Any: + """Change alert status (internal API - no external equivalent yet).""" try: - response = self._http_request( - method="POST", - url_suffix="/share-service/share-comment", + return self._http_request( + method="PUT", + url_suffix=f"/policy-subsystem/alert-inbox/{alert_id}/status/{status}", timeout=RECO_API_TIMEOUT_IN_SECONDS, - data=json.dumps( - { - "content": comment, - "entityId": incident_id, # alert_id - "entityType": "alert", - } - ), ) except Exception as e: - demisto.error(f"Update incident timeline error: {str(e)}") - raise e + demisto.error(f"change_alert_status error: {str(e)}") + raise - demisto.info(f"Comment added to timeline of incident {incident_id}") - return response + # --- Comments (external API) --- - def resolve_visibility_event(self, entity_id: str, label_name: str) -> Any: - """Resolve visibility event. - :param entity_id: The entry id of the visibility event to resolve - :param label_name: The label name of the visibility event to resolve + def update_reco_incident_timeline(self, incident_id: str, comment: str) -> Any: + """Add a comment to an alert entity via the external API.""" + body: dict[str, Any] = { + "entityId": incident_id, + "entityType": "alert", + "content": comment, + } + try: + return self._external_api_post("comments/create", body) + except Exception as e: + demisto.error(f"update_reco_incident_timeline error: {str(e)}") + raise + + # --- Labels (external API) --- + + def add_entity_label( + self, + entity_id: str, + entity_type: str, + label_name: str, + instance_id: str = "", + ) -> Any: + """Add a label to any Reco entity via the external API. + + entity_type: "account", "identity", "app", "posture", "saas-to-saas", + "ip-address", "ai-agent", "device" """ + body: dict[str, Any] = { + "entityId": entity_id, + "entityType": entity_type, + "labelName": label_name, + } + if instance_id: + body["instanceId"] = instance_id try: - response = self._http_request( + return self._external_api_post("labels/add", body) + except Exception as e: + demisto.error(f"add_entity_label error: {str(e)}") + raise + + def resolve_visibility_event(self, entity_id: str, label_name: str) -> Any: + """Resolve a visibility event (internal API - no external equivalent).""" + try: + return self._http_request( method="PUT", url_suffix="/set-label-status", timeout=RECO_API_TIMEOUT_IN_SECONDS, @@ -207,94 +350,106 @@ def resolve_visibility_event(self, entity_id: str, label_name: str) -> Any: ), ) except Exception as e: - demisto.error(f"Resolve visibility event error: {str(e)}") - raise e + demisto.error(f"resolve_visibility_event error: {str(e)}") + raise - demisto.info(f"Visibility event {entity_id} resolved") - return response - - def get_risky_users(self) -> list[dict[str, Any]]: - """Get risky users. Returns a list of risky users with analysis.""" - return self.get_identities(email_address=None, label=RISKY_USER) + # --- Identities / Users (external API) --- def get_identities(self, email_address: Optional[str] = None, label: Optional[str] = None) -> list[dict[str, Any]]: - """ - Get identities from Reco with specified filters. + """List identities from the external API, optionally filtered by email.""" + filter_parts: list[str] = [] + if email_address: + filter_parts.append(f'email co "{email_address}"') + # label filter is handled separately via get_risky_users (ListAccounts) + scim_filter = " and ".join(filter_parts) + try: + response = self._external_api_list("users/list", filters=scim_filter, count=PAGE_SIZE) + return response.get("users", []) + except Exception as e: + demisto.error(f"get_identities error: {str(e)}") + raise - :param email_address: Optional email substring to filter identities. - :param label: Optional label value to filter identities. - :return: A dictionary representing the getTableRequest payload. - """ - params: Dict[str, Any] = { - "getTableRequest": { - "tableName": "RISK_MANAGEMENT_VIEW_IDENTITIES", - "pageSize": 50, - "fieldSorts": {"sorts": [{"sortBy": "primary_email_address", "sortDirection": "SORT_DIRECTION_ASC"}]}, - "fieldFilters": { - "relationship": "FILTER_RELATIONSHIP_AND", - "fieldFilterGroups": {"fieldFilters": []}, - "forceEstimateSize": True, - }, - } - } + def get_risky_users(self) -> list[dict[str, Any]]: + """List ALL accounts flagged as risky via the external API (auto-paginated).""" + try: + return self._paginate_all("accounts/list", "accounts", filters="isRiskyUser eq true") + except Exception as e: + demisto.error(f"get_risky_users error: {str(e)}") + raise - # Add label filter if provided - if label is not None: - label_filter = { - "relationship": "FILTER_RELATIONSHIP_OR", - "filters": { - "filters": [ - { - "field": "labels", - "labelNameEquals": { - "keys": ["identity_id"], - "value": [label], - "filterColumn": "label_name", - "entryTypes": ["ENTRY_TYPE_IDENTITY"], - }, - } - ] - }, - } - params["getTableRequest"]["fieldFilters"]["fieldFilterGroups"]["fieldFilters"].append(label_filter) + def get_user_context_by_email_address(self, email_address: str) -> list[dict[str, Any]]: + """Get identity context for an email address via the external API.""" + try: + response = self._external_api_list( + "users/list", + filters=f'email co "{email_address}"', + count=10, + ) + return response.get("users", []) + except Exception as e: + demisto.error(f"get_user_context_by_email_address error: {str(e)}") + raise - # Add email address filter if provided - if email_address: - email_filter = { - "relationship": "FILTER_RELATIONSHIP_OR", - "filters": { - "filters": [create_filter("full_name", email_address), create_filter("primary_email_address", email_address)] - }, - } - params["getTableRequest"]["fieldFilters"]["fieldFilterGroups"]["fieldFilters"].append(email_filter) + # --- Apps (external API) --- + def get_app_discovery( + self, + before: datetime | None = None, + after: datetime | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """List discovered apps via the external API. Auto-paginates all results when limit is None.""" + filter_parts: list[str] = [] + if after: + filter_parts.append(f'lastSeen gt "{after.strftime(DEMISTO_OCCURRED_FORMAT)}"') + if before: + filter_parts.append(f'lastSeen lt "{before.strftime(DEMISTO_OCCURRED_FORMAT)}"') + scim_filter = " and ".join(filter_parts) try: - response = self._http_request( - method="PUT", - url_suffix="/risk-management/get-risk-management-table", - timeout=RECO_API_TIMEOUT_IN_SECONDS, - data=json.dumps(params), - ) - return extract_response(response) + if limit is None: + return self._paginate_all("apps/list", "apps", filters=scim_filter) + response = self._external_api_list("apps/list", filters=scim_filter, count=limit) + return response.get("apps", []) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_app_discovery error: {str(e)}") + raise - def get_alert_ai_summary(self, alert_id: str) -> dict[str, Any]: # pragma: no cover - """Get alert AI summary.""" + def set_app_authorization_status(self, app_id: str, authorization_status: str) -> Any: + """Update app authorization status via the external API.""" try: - response = self._http_request( - method="GET", - url_suffix=f"/alert/summarize/{alert_id}", - timeout=RECO_API_TIMEOUT_IN_SECONDS, + return self._external_api_put( + f"{EXTERNAL_API_BASE}/apps/{app_id}/auth-status", + {"authorizationStatus": authorization_status}, ) - return response except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"set_app_authorization_status error: {str(e)}") + raise + + # --- Files / Assets (external API) --- + + def get_sensitive_assets_information( + self, asset_name: str | None, asset_id: str | None, sensitive_only: bool, regex_search: bool + ) -> list[dict[str, Any]]: + """List files from the external API, optionally filtered by name or id (auto-paginated).""" + filter_parts: list[str] = [] + if asset_name: + op = "co" if regex_search else "eq" + filter_parts.append(f'name {op} "{asset_name}"') + elif asset_id: + filter_parts.append(f'id eq "{asset_id}"') + if sensitive_only: + filter_parts.append('sensitivityLevel in ["30","40"]') + scim_filter = " and ".join(filter_parts) + try: + return self._paginate_all("files/list", "files", filters=scim_filter) + except Exception as e: + demisto.error(f"get_sensitive_assets_information error: {str(e)}") + raise + + # --- Internal API methods (data risk management - no external equivalent) --- def get_exposed_publicly_files_at_risk(self) -> list[dict[str, Any]]: - """Get exposed publicly files at risk. Returns a list of exposed publicly files at risk with analysis.""" + """Get exposed publicly files at risk (internal API).""" params: Dict[str, Any] = { "getTableRequest": { "tableName": "DATA_RISK_MANAGEMENT_VIEW_BREAKDOWN_EXPOSED_PUBLICLY", @@ -315,11 +470,11 @@ def get_exposed_publicly_files_at_risk(self) -> list[dict[str, Any]]: ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_exposed_publicly_files_at_risk error: {str(e)}") + raise def get_files_exposed_to_email(self, email_account) -> list[dict[str, Any]]: - """Get files exposed to email. Returns a list of files exposed to email with analysis.""" + """Get files exposed to an email account (internal API).""" params = { "getTableRequest": { "tableName": "data_posture_view_files_by_emails_slider", @@ -360,11 +515,11 @@ def get_files_exposed_to_email(self, email_account) -> list[dict[str, Any]]: ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_files_exposed_to_email error: {str(e)}") + raise def get_list_of_private_emails_with_access(self) -> list[dict[str, Any]]: - """Get files exposed to email. Returns a list of private email addresses with access.""" + """Get private email addresses with file access (internal API).""" params = { "getTableRequest": { "tableName": "data_posture_view_private_email_with_access", @@ -386,10 +541,16 @@ def get_list_of_private_emails_with_access(self) -> list[dict[str, Any]]: ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_list_of_private_emails_with_access error: {str(e)}") + raise + + @staticmethod + def get_date_time_before_days_formatted(last_interaction_time_before_in_days: int) -> str: + thirty_days_ago = datetime.utcnow() - timedelta(days=last_interaction_time_before_in_days) + return thirty_days_ago.strftime("%Y-%m-%dT%H:%M:%S.999Z") def get_3rd_parties_risk_list(self, last_interaction_time_before_in_days: int) -> list[dict[str, Any]]: + """Get 3rd party domains with file access (internal API).""" formatted_date = self.get_date_time_before_days_formatted(last_interaction_time_before_in_days) params = { "getTableRequest": { @@ -428,19 +589,11 @@ def get_3rd_parties_risk_list(self, last_interaction_time_before_in_days: int) - ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e - - @staticmethod - def get_date_time_before_days_formatted(last_interaction_time_before_in_days: int) -> str: - # Calculate the date 30 days ago - thirty_days_ago = datetime.utcnow() - timedelta(days=last_interaction_time_before_in_days) - # Format the date in the desired format - formatted_date = thirty_days_ago.strftime("%Y-%m-%dT%H:%M:%S.999Z") - return formatted_date + demisto.error(f"get_3rd_parties_risk_list error: {str(e)}") + raise def get_files_shared_with_3rd_parties(self, domain: str, last_interaction_time_before_in_days: int) -> list[dict[str, Any]]: - """Get files shared with 3rd parties. Returns a list of files at risk with analysis.""" + """Get files shared with a specific 3rd party domain (internal API).""" formatted_date = self.get_date_time_before_days_formatted(last_interaction_time_before_in_days) params = { "getTableRequest": { @@ -475,11 +628,11 @@ def get_files_shared_with_3rd_parties(self, domain: str, last_interaction_time_b ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_files_shared_with_3rd_parties error: {str(e)}") + raise def get_assets_user_has_access(self, email_address: str, only_sensitive: bool) -> list[dict[str, Any]]: - """Get assets user has access to. Returns a list of assets.""" + """Get assets a user has access to (internal API).""" params: dict[str, Any] = { "getTableRequest": { "tableName": "files_view", @@ -504,22 +657,14 @@ def get_assets_user_has_access(self, email_address: str, only_sensitive: bool) - }, } } - if only_sensitive is True: params["getTableRequest"]["fieldFilters"]["fieldFilterGroups"]["fieldFilters"].append( { "relationship": "FILTER_RELATIONSHIP_OR", "filters": { "filters": [ - { - "field": "sensitivity_level", - # 30 confidential - "stringEquals": {"value": "30"}, - }, - { - "field": "sensitivity_level", - "stringEquals": {"value": "40"}, - }, + {"field": "sensitivity_level", "stringEquals": {"value": "30"}}, + {"field": "sensitivity_level", "stringEquals": {"value": "40"}}, ] }, } @@ -533,11 +678,11 @@ def get_assets_user_has_access(self, email_address: str, only_sensitive: bool) - ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_assets_user_has_access error: {str(e)}") + raise def get_assets_shared_externally(self, email_address: str) -> list[dict[str, Any]]: - """Get assets user has access to. Returns a list of assets.""" + """Get assets an owner has shared externally (internal API).""" params: dict[str, Any] = { "getTableRequest": { "tableName": "files_view", @@ -579,393 +724,129 @@ def get_assets_shared_externally(self, email_address: str) -> list[dict[str, Any ) return extract_response(response) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e - - def get_user_context_by_email_address(self, email_address: str) -> list[dict[str, Any]]: - """Get user context by email address. Returns a dict of user context.""" - identities = self.get_identities(email_address=email_address) - if not identities: - return [] - identity_ids = [] - for user in identities: - user_as_dict = parse_table_row_to_dict(user.get("cells", {})) - identity_id = user_as_dict.get("identity_id") - if identity_id: - identity_ids.append(identity_id) - - params: Dict[str, Any] = { - "getTableRequest": { - "tableName": "RISK_MANAGEMENT_VIEW_IDENTITIES", - "pageSize": 1, - "fieldSorts": {"sorts": []}, - "fieldFilters": { - "relationship": "FILTER_RELATIONSHIP_OR", - "forceEstimateSize": True, - "filters": {"filters": []} if identity_ids else {}, - }, - } - } - - # Add filters for multiple identity_ids - if identity_ids: - identity_filters = [{"field": "identity_id", "stringEquals": {"value": identity_id}} for identity_id in identity_ids] - params["getTableRequest"]["fieldFilters"]["filters"]["filters"] = identity_filters - - response = self._http_request( - method="PUT", - url_suffix="/risk-management/get-risk-management-table", - timeout=RECO_API_TIMEOUT_IN_SECONDS * 2, - data=json.dumps(params), - ) - return extract_response(response) - - def get_sensitive_assets_information( - self, asset_name: str | None, asset_id: str | None, sensitive_only: bool, regex_search: bool - ) -> list[dict[str, Any]]: - """Get sensitive assets' information. Returns a list of assets.""" - filter = "regexCaseInsensitive" if regex_search else "stringEquals" - field_to_search = "file_name" if asset_name else "asset_id" - value_to_search = asset_name if asset_name else asset_id - params: dict[str, Any] = { - "getTableRequest": { - "tableName": "files_view", - "pageSize": PAGE_SIZE, - "fieldFilters": { - "relationship": "FILTER_RELATIONSHIP_AND", - "fieldFilterGroups": { - "fieldFilters": [ - { - "relationship": "FILTER_RELATIONSHIP_OR", - "filters": { - "filters": [ - { - "field": field_to_search, - filter: {"value": value_to_search}, - } - ] - }, - } - ] - }, - }, - } - } - if sensitive_only: - params["getTableRequest"]["fieldFilters"]["fieldFilterGroups"]["fieldFilters"].append( - { - "relationship": "FILTER_RELATIONSHIP_OR", - "filters": { - "filters": [ - { - "field": "sensitivity_level", - "stringEquals": {"value": "30"}, - }, - { - "field": "sensitivity_level", - "stringEquals": {"value": "40"}, - }, - ] - }, - } - ) - try: - response = self._http_request( - method="PUT", - url_suffix="/asset-management/query", - timeout=RECO_API_TIMEOUT_IN_SECONDS * 2, - data=json.dumps(params), - ) - return extract_response(response) - except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e + demisto.error(f"get_assets_shared_externally error: {str(e)}") + raise def get_link_to_user_overview_page(self, link_type: str, entity_id: str) -> str: - """ - Get link to user overview page. - :param link_type: The link type to get (RM_LINK_TYPE_USER). - :param entity_id: The entity id. In case of user, it's the user's email address. - :return: dict - """ - demisto.info(f"Getting link to {link_type} overview page for {entity_id}") + """Get a magic link to a Reco UI overview page (internal API).""" try: response = self._http_request( method="GET", url_suffix=f"/risk-management/risk-management/link?link_type={link_type}¶m={entity_id}", timeout=RECO_API_TIMEOUT_IN_SECONDS, ) - if response.get("link") is None: - demisto.info(f"got bad response, {response}") - else: - demisto.info(f"got good response, {response}") - link = response.get("link", None) + link = response.get("link", None) except Exception as e: - demisto.error(f"Validate API key ReadTimeout error: {str(e)}") - raise e - - demisto.info(f"Got link: {link}") # pylint: disable=E0606 - return link + demisto.error(f"get_link_to_user_overview_page error: {str(e)}") + raise + return link # type: ignore[return-value] def add_exclusion_filter(self, key_to_add: str, values_to_add: list[str]): + """Add exclusion filter values (internal API).""" body = {"environmentName": "string", "keyToAddTo": key_to_add, "valuesToAdd": values_to_add} - try: - response = self._http_request( + return self._http_request( method="POST", url_suffix="/algo/add_values_to_data_type_exclude_analyzer", timeout=RECO_API_TIMEOUT_IN_SECONDS * 2, data=json.dumps(body), ) - return response - except Exception as e: - demisto.error(f"Can't add exclusion filter: {str(e)}") - raise e - - def set_entry_label_relations(self, entry_id: str, label_name: str, label_status: str, entry_type: str) -> Any: - """Set entry label relations. - :param entry_id: The entry id to set (email_address, asset_id etc.) - :param label_name: The label name to set - :param label_status: The label_status to set. Can be one of the following: - LABEL_STATUS_INACTIVE, - LABEL_STATUS_ACTIVE, - LABEL_STATUS_RESOLVED, - LABEL_STATUS_FALSE_POSITIVE, - LABEL_STATUS_PENDING - :param entry_type: The entry type to set. Can be one of the following: ENTRY_TYPE_INCIDENT, - ENTRY_TYPE_PROCESS, - ENTRY_TYPE_EVENT, - ENTRY_TYPE_USER, - ENTRY_TYPE_ASSET, - ENTRY_TYPE_PLAYBOOK - """ - try: - response = self._http_request( - method="PUT", - url_suffix="/entry-label-relations", - timeout=RECO_API_TIMEOUT_IN_SECONDS, - data=json.dumps( - { - "labelRelations": [ - { - "labelName": label_name, - "entryId": entry_id, - "count": 1, - "confidence": 1, - "entryType": entry_type, - "labelStatus": label_status, - "attributes": {}, - } - ] - } - ), - ) except Exception as e: - demisto.error(f"Set entry label relations error: {str(e)}") - raise e - demisto.info(f"Label {label_name} set to {label_status} for event {entry_id}") - return response + demisto.error(f"add_exclusion_filter error: {str(e)}") + raise - def change_alert_status(self, alert_id: str, status: str) -> Any: - """Change alert status.""" - try: - response = self._http_request( - method="PUT", - url_suffix=f"/policy-subsystem/alert-inbox/{alert_id}/status/{status}", - timeout=RECO_API_TIMEOUT_IN_SECONDS, - ) - except Exception as e: - demisto.error(f"Change alert status error: {str(e)}") - raise e - demisto.info(f"Alert {alert_id} status changed to {status}") - return response + # --- New external API list methods --- - def validate_api_key(self) -> str: - """ - Validate API key - :return: bool - """ - demisto.info("Validate API key, enter") - invalid_token_string = "Invalid token" - try: - response = self._http_request( - method="GET", - url_suffix="/policy-subsystem/alert-inbox?limit=1", - timeout=RECO_API_TIMEOUT_IN_SECONDS, - ) - if response.get("alerts") is None: - demisto.info(f"got bad response, {response}") - else: - demisto.info(f"got good response, {response}") - return "ok" - except Exception as e: - demisto.error(f"Validate API key Error: {str(e)}") - raise e - return invalid_token_string + def list_events(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List SaaS events via the external API.""" + return self._external_api_list("events/list", filters=filters, count=count) - def get_app_discovery( - self, - before: datetime | None = None, - after: datetime | None = None, - limit: int = 1000, - ) -> list[dict[str, Any]]: - """ - Fetch app discovery data from Reco API with pagination support - :param before: The maximum date of the apps to fetch - :param after: The minimum date of the apps to fetch - :param limit: int (page size for each request) - :return: list - """ - demisto.info("Get app discovery, enter") - all_apps: list[dict[str, Any]] = [] - page_size = min(limit, PAGE_SIZE) # Use PAGE_SIZE (1000) as max page size - page_number = 0 + def list_posture_issues(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List posture issues via the external API.""" + return self._external_api_list("posture-issues/list", filters=filters, count=count) - # First, get the total count to determine how many pages we need - count_params = self._create_app_discovery_params(page_size, page_number, before, after) + def list_accounts(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List accounts via the external API.""" + return self._external_api_list("accounts/list", filters=filters, count=count) - demisto.info("Getting total count of apps from Reco API") - try: - count_response = self._http_request( - method="PUT", - url_suffix="/asset-management/count", - data=json.dumps(count_params), - timeout=RECO_API_TIMEOUT_IN_SECONDS, - ) - total_count = count_response.get("getTableResponse", {}).get("totalNumberOfResults", 0) - demisto.debug(f"Total number of apps: {total_count}") - except Exception as e: - demisto.error(f"Failed to get app count: {str(e)}") - total_count = 0 + def list_devices(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List devices via the external API.""" + return self._external_api_list("devices/list", filters=filters, count=count) - if total_count == 0: - demisto.info("No apps found") - return all_apps + def list_ai_agents(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List AI agents via the external API.""" + return self._external_api_list("ai-agents/list", filters=filters, count=count) - # Calculate total pages needed - total_pages = (total_count + page_size - 1) // page_size - demisto.info(f"Will fetch {total_pages} pages with page size {page_size}") + def list_integrations(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List connected integrations via the external API.""" + return self._external_api_list("integrations/list", filters=filters, count=count) - # Fetch all pages - for page_number in range(total_pages): - demisto.info(f"Fetching page {page_number + 1} of {total_pages}") + def list_groups(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List SaaS groups via the external API.""" + return self._external_api_list("groups/list", filters=filters, count=count) - params = self._create_app_discovery_params(page_size, page_number, before, after) + def list_saas_to_saas(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List SaaS-to-SaaS grants via the external API.""" + return self._external_api_list("saas-to-saas/list", filters=filters, count=count) - try: - response = self._http_request( - method="PUT", - url_suffix="/asset-management/query", - data=json.dumps(params), - timeout=RECO_API_TIMEOUT_IN_SECONDS, - ) - apps = extract_response(response) + def list_ip_addresses(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List observed IP addresses via the external API.""" + return self._external_api_list("ip-addresses/list", filters=filters, count=count) - # Add metadata to each app - for app in apps: - if isinstance(app, dict): - app["total_apps_count"] = total_count - app["data_source"] = "app_discovery" - app["page_number"] = page_number + def list_business_units(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List external business units via the external API.""" + return self._external_api_list("business-units/list", filters=filters, count=count) - all_apps.extend(apps) - demisto.info(f"Fetched {len(apps)} apps from page {page_number + 1}") + def list_audit_logs(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List platform audit logs via the external API.""" + return self._external_api_list("audit-logs/list", filters=filters, count=count) - except Exception as e: - demisto.error(f"App Discovery Request error on page {page_number + 1}: {str(e)}") - # Continue with next page instead of failing completely - continue + def list_posture_checks(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List posture check definitions via the external API.""" + return self._external_api_list("posture-checks/list", filters=filters, count=count) - demisto.info(f"Done fetching RECO app discovery, fetched {len(all_apps)} total apps") - return all_apps + def list_threat_detection_policies(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List threat detection policies via the external API.""" + return self._external_api_list("policies/list", filters=filters, count=count) - def _create_app_discovery_params( - self, page_size: int, page_number: int, before: datetime | None = None, after: datetime | None = None - ) -> dict[str, Any]: - """Create request parameters for app discovery with pagination.""" - params: dict[str, Any] = { - "getTableRequest": { - "tableName": "app_discovery", - "pageSize": page_size, - "pageNumber": page_number, - "fieldSorts": {"sorts": [{"sortBy": "updated_at", "sortDirection": "SORT_DIRECTION_ASC"}]}, - } - } + def list_exclusions(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List alert suppression exclusion rules via the external API.""" + return self._external_api_list("exclusions/list", filters=filters, count=count) - # Add time filters if provided - if before or after: - params["getTableRequest"]["fieldFilters"] = { - "relationship": FILTER_RELATIONSHIP_AND, - "filters": {"filters": []}, - } + def list_app_instances(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List integrated app instances (portfolio) via the external API.""" + return self._external_api_list("app-instances/list", filters=filters, count=count) - if before: - params["getTableRequest"]["fieldFilters"]["filters"]["filters"].append( - { - "field": "updated_at", - "before": {"value": before.strftime("%Y-%m-%dT%H:%M:%SZ")}, - } - ) - if after: - params["getTableRequest"]["fieldFilters"]["filters"]["filters"].append( - { - "field": "updated_at", - "after": {"value": after.strftime("%Y-%m-%dT%H:%M:%SZ")}, - } - ) + def list_labeled_netapp_files(self, filters: str = "", count: int = PAGE_SIZE) -> dict[str, Any]: + """List NetApp files carrying an active label via the external API.""" + return self._external_api_list("labeled-netapp-files/list", filters=filters, count=count) - return params - - def set_app_authorization_status(self, app_id: str, authorization_status: str) -> Any: - """ - Set authorization status for an application in Reco - :param app_id: The application ID to update - :param authorization_status: The authorization status to set - :return: dict - """ - demisto.info(f"Setting app authorization status for {app_id} to {authorization_status}") - - params = {"appAuth": [{"appId": app_id, "authorizationStatus": authorization_status}]} + # --- API key validation --- + def validate_api_key(self) -> str: + """Validate the API token by making a minimal external API call.""" try: - response = self._http_request( - method="PUT", - url_suffix="/app-risk-management/insert-risk-management-app", - data=json.dumps(params), - timeout=RECO_API_TIMEOUT_IN_SECONDS, - ) - - # Validate response format - if not isinstance(response, dict) or "rows" not in response: - demisto.error(f"Unexpected response format: {response}") - raise Exception(f"Unexpected response format: {response}") - - rows_updated = response.get("rows", 0) - if rows_updated != 1: - demisto.debug(f"Expected 1 row to be updated, but got {rows_updated}") - + self._external_api_list("alerts/list", count=1) + return "ok" except Exception as e: - demisto.error(f"Set app authorization status error: {str(e)}") - raise e + demisto.error(f"validate_api_key error: {str(e)}") + raise + - demisto.debug(f"App {app_id} authorization status updated to {authorization_status}. Rows updated: {rows_updated}") - return response +# --- Helpers --- def parse_table_row_to_dict(alert: list[dict[str, Any]]) -> dict[str, Any]: + """Decode a table row returned by Reco's internal Table API (base64-encoded cells).""" if alert is None: return {} - alert_as_dict = {} for obj in alert: key = obj.get("key", None) value = obj.get("value", None) - if key is None: - continue - if value is None: + if key is None or value is None: continue obj[key] = base64.b64decode(value).decode("utf-8") - # Remove " from the beginning and end of the string if key != "labels": obj[key] = obj[key].replace('"', "") if key in ["updated_at", "created_at", "event_time"]: @@ -979,232 +860,244 @@ def parse_table_row_to_dict(alert: list[dict[str, Any]]) -> dict[str, Any]: try: obj[key] = int(obj[key]) except Exception: - demisto.info(f"Could not parse risk level {obj[key]} to int") + pass if key in ["group", "job_title", "departments", "labels"]: try: obj[key] = json.loads(obj[key]) except Exception: pass alert_as_dict[key] = obj[key] - return alert_as_dict +# --- Alert fetching --- + + def get_alerts( reco_client: RecoClient, - risk_level: int | None = None, + risk_levels: list[str] | None = None, source: str | None = None, before: datetime | None = None, after: datetime | None = None, - limit: int = 1000, + limit: int = PAGE_SIZE, ) -> list[Any]: - """Get alerts from Reco. - :param reco_client: The Reco client - :param risk_level: The risk level to filter by - :param source: The source to filter by - :param before: The before time to filter by - :param after: The after time to filter by - :param limit: The limit of alerts to get - :return: The alerts""" - alerts = reco_client.get_alerts(risk_level, source, before, after, limit) - alerts_data = [] - for alert in alerts: - alert_as_dict = parse_table_row_to_dict(alert.get("cells", {})) - alert_id = alert_as_dict.get("id", None) - if alert_id is not None: - alert_id = base64.b64decode(alert_id).decode("utf-8") - single_alert = reco_client.get_single_alert(alert_id) - violations = single_alert["policyViolations"] - for violation in violations: - violation_data = json.loads(base64.b64decode(violation["jsonData"])) - if "violation" in violation_data: - violation_data.pop("violation") - violation["jsonData"] = violation_data - single_alert["policyViolations"] = json.loads(json.dumps(single_alert["policyViolations"])) - alerts_data.append(single_alert) - else: - demisto.info(f"Got alert without id: {alert_as_dict}") - return alerts_data - - -def get_risky_users_from_reco(reco_client: RecoClient) -> CommandResults: - """Get risky users from Reco.""" - risky_users = reco_client.get_risky_users() - users = [] - for user in risky_users: - user_as_dict = parse_table_row_to_dict(user.get("cells", {})) - users.append(user_as_dict) - return CommandResults( - readable_output=tableToMarkdown( - "Risky Users", - users, - headers=user_as_dict.keys(), - ), - outputs_prefix="Reco.RiskyUsers", - outputs_key_field="primary_email_address", - outputs=users, - raw_response=risky_users, - ) - - -def add_risky_user_label(reco_client: RecoClient, email_address: str) -> CommandResults: - """Add a risky user to Reco.""" - - users = reco_client.get_identities(email_address) - for user in users: - user_as_dict = parse_table_row_to_dict(user.get("cells", {})) - raw_response = reco_client.set_entry_label_relations( - user_as_dict["identity_id"], RISKY_USER, LABEL_STATUS_ACTIVE, ENTRY_TYPE_IDENTITY - ) + """Fetch alert summaries then enrich each with full detail (policy violations). - return CommandResults( - raw_response=raw_response, - readable_output=f"User {email_address} labeled as risky", - ) - - -def add_leaving_org_user(reco_client: RecoClient, email_address: str) -> CommandResults: # pragma: no cover - """Tag user as leaving org.""" - users = reco_client.get_identities(email_address) - for user in users: - user_as_dict = parse_table_row_to_dict(user.get("cells", {})) - raw_response = reco_client.set_entry_label_relations( - user_as_dict["identity_id"], LEAVING_ORG_USER, LABEL_STATUS_ACTIVE, ENTRY_TYPE_IDENTITY - ) - - return CommandResults( - raw_response=raw_response, - readable_output=f"User {email_address} labeled as leaving org user", - ) - - -def get_alert_ai_summary(reco_client: RecoClient, alert_id: str) -> CommandResults: - response = reco_client.get_alert_ai_summary(alert_id) - content = json.dumps(response) - if response.get("markdown"): - content = str(response.get("markdown")) + Returns a list of ThreatAlertDetail dicts from the external API. + """ + raw_alerts = reco_client.get_alerts(risk_levels, source, before, after, limit) + alerts_data: list[Any] = [] - return CommandResults( - readable_output=content, - outputs_prefix="Reco.AlertSummary", - outputs_key_field="alert_id", - outputs=response, - raw_response=response, - ) + for raw in raw_alerts: + alert_id = raw.get("id") + if not alert_id: + demisto.debug(f"Alert without id: {raw}") + continue + try: + detail = reco_client.get_single_alert(alert_id) + if not detail: + continue + # policy violations arrive as JSON strings (not base64) from the external API + for violation in detail.get("policyViolations", []): + json_data = violation.get("jsonData", "{}") + if isinstance(json_data, str): + try: + parsed = json.loads(json_data) + parsed.pop("violation", None) + violation["jsonData"] = parsed + except json.JSONDecodeError: + pass + alerts_data.append(detail) + except Exception as e: + demisto.error(f"Failed to enrich alert {alert_id}: {str(e)}") + demisto.info(f"get_alerts: enriched {len(alerts_data)}/{len(raw_alerts)} alerts") + return alerts_data -def enrich_incident(reco_client: RecoClient, single_incident: dict[str, Any]) -> dict[str, Any]: # pragma: no cover - alert_as_dict = parse_table_row_to_dict(single_incident.get("cells", {})) - return { - "name": alert_as_dict.get("incident_description", ""), - "occurred": alert_as_dict.get("event_time", ""), - "dbotMirrorId": alert_as_dict.get("incident_id", ""), - "rawJSON": json.dumps(alert_as_dict), - "severity": map_reco_score_to_demisto_score(reco_score=alert_as_dict.get("risk_level", DEMISTO_INFORMATIONAL)), - } +# --- Score mapping --- -def map_reco_score_to_demisto_score( - reco_score: int, -) -> int | float: # pylint: disable=E1136 - """Map Reco numeric risk score (10-40) to Demisto severity.""" - # demisto_unknown = 0 (commented because of linter issues) +def map_reco_score_to_demisto_score(reco_score: int) -> int | float: demisto_informational = 0.5 - # demisto_low = 1 (commented because of linter issues) demisto_medium = 2 demisto_high = 3 demisto_critical = 4 - - # Reco API returns risk_level in range 10-40. Normalize to tier keys. - MAPPING = { - 40: demisto_critical, - 30: demisto_high, - 20: demisto_medium, - 10: demisto_informational, - 0: demisto_informational, - } + MAPPING = {40: demisto_critical, 30: demisto_high, 20: demisto_medium, 10: demisto_informational, 0: demisto_informational} tier = min(40, max(0, (reco_score // 10) * 10)) return MAPPING.get(tier, demisto_informational) -def map_reco_alert_score_to_demisto_score( - reco_score: str, -) -> int | float: # pylint: disable=E1136 - # demisto_unknown = 0 (commented because of linter issues) +def map_reco_alert_score_to_demisto_score(reco_score: str) -> int | float: demisto_informational = 0.5 - # demisto_low = 1 (commented because of linter issues) demisto_medium = 2 demisto_high = 3 demisto_critical = 4 - - # LHS is Reco score - MAPPING = { - "CRITICAL": demisto_critical, - "HIGH": demisto_high, - "MEDIUM": demisto_medium, - "LOW": demisto_informational, - } - - return MAPPING[reco_score] + MAPPING = {"CRITICAL": demisto_critical, "HIGH": demisto_high, "MEDIUM": demisto_medium, "LOW": demisto_informational} + return MAPPING.get(reco_score, demisto_informational) def _reco_risk_to_demisto_severity(raw_risk: Any) -> int | float: - """Normalize Reco risk_level to Demisto severity. Accepts int, str numbers ('10', '30'), or str labels ('HIGH', 'LOW').""" + """Normalize Reco risk level to XSOAR severity. Handles int, numeric str, or name str.""" if raw_risk is None: return map_reco_score_to_demisto_score(10) if isinstance(raw_risk, int): - risk_level = min(40, max(10, raw_risk)) - return map_reco_score_to_demisto_score(risk_level) + return map_reco_score_to_demisto_score(min(40, max(10, raw_risk))) if isinstance(raw_risk, str): stripped = raw_risk.strip().upper() if stripped in ("CRITICAL", "HIGH", "MEDIUM", "LOW"): return map_reco_alert_score_to_demisto_score(stripped) try: - risk_level = min(40, max(10, int(raw_risk))) - return map_reco_score_to_demisto_score(risk_level) + return map_reco_score_to_demisto_score(min(40, max(10, int(raw_risk)))) except (TypeError, ValueError): pass try: - risk_level = min(40, max(10, int(raw_risk))) - return map_reco_score_to_demisto_score(risk_level) + return map_reco_score_to_demisto_score(min(40, max(10, int(raw_risk)))) except (TypeError, ValueError): return map_reco_score_to_demisto_score(10) -def parse_incidents_objects( - reco_client: RecoClient, incidents_raw: list[dict[str, Any]] -) -> list[dict[str, Any]]: # pragma: no cover - demisto.info("parse_incidents_objects enter") +# --- Incident parsing --- + + +def parse_alerts_to_incidents(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Convert ThreatAlertDetail dicts (external API) to XSOAR incident format. + + Also handles the legacy internal-API shape (cells[]) for backward compatibility. + """ incidents = [] - for single_incident in incidents_raw: - incident = enrich_incident(reco_client, single_incident=single_incident) + for alert in alerts: + # Support legacy row format (cells[]) and modern external API dicts + alert_dict = parse_table_row_to_dict(alert.get("cells", {})) if alert.get("cells") else alert + occurred = alert_dict.get("created_at") or alert_dict.get("createdAt", "") + raw_risk = alert_dict.get("risk_level") or alert_dict.get("riskLevel") or alert_dict.get("severity") + incident = { + "name": alert_dict.get("description", ""), + "occurred": occurred, + "dbotMirrorId": alert_dict.get("id", ""), + "rawJSON": json.dumps(alert), + "severity": _reco_risk_to_demisto_severity(raw_risk), + } incidents.append(incident) - - demisto.info(f"get_incidents_from_alerts: Got {len(incidents)} incidents") return incidents +def get_max_fetch(max_fetch: int) -> int: + return min(max_fetch, 500) + + +# --- Fetch incidents --- + + +def fetch_incidents( + reco_client: RecoClient, + last_run: dict[str, Any], + max_fetch: int, + risk_levels: list[str] | None = None, + source: str | None = None, + before: datetime | None = None, + after: datetime | None = None, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + demisto.info(f"fetch-incidents called {max_fetch=}") + next_run: dict[str, Any] = {} + + last_run_time = last_run.get("lastRun", None) + if last_run_time is not None: + after = dateutil.parser.parse(last_run_time) + + alerts = get_alerts(reco_client, risk_levels, source, before, after, max_fetch) + incidents = parse_alerts_to_incidents(alerts) + + existing_incidents = last_run.get("incident_ids", []) + incidents = [ + incident + for incident in incidents + if (incident.get("severity", 0) > DEMISTO_INFORMATIONAL) + and (incident.get("dbotMirrorId", None) not in existing_incidents) + ] + + incidents_sorted = sorted(incidents, key=lambda k: k["occurred"]) + if incidents_sorted: + last_occurred_dt = dateutil.parser.parse(incidents_sorted[-1]["occurred"]) + next_run["lastRun"] = (last_occurred_dt + timedelta(seconds=1)).strftime(DEMISTO_OCCURRED_FORMAT) + else: + next_run["lastRun"] = last_run_time + next_run["incident_ids"] = existing_incidents + [inc["dbotMirrorId"] for inc in incidents] + + return next_run, incidents + + +# --- Command functions --- + + +def get_risky_users_from_reco(reco_client: RecoClient) -> CommandResults: + """Return accounts flagged as risky.""" + accounts = reco_client.get_risky_users() + return CommandResults( + readable_output=tableToMarkdown( + "Risky Users", + accounts, + headers=["id", "name", "accountEmail", "permissions", "openAlerts", "roles", "isAdmin", "lastSeen"], + ), + outputs_prefix="Reco.RiskyUsers", + outputs_key_field="accountEmail", + outputs=accounts, + raw_response=accounts, + ) + + +def add_risky_user_label(reco_client: RecoClient, email_address: str) -> CommandResults: + """Tag an identity as risky via the external API labels endpoint.""" + identities = reco_client.get_identities(email_address=email_address) + if not identities: + return CommandResults(readable_output=f"No identity found for {email_address}") + raw_response = None + for identity in identities: + identity_id = identity.get("id") + if identity_id: + raw_response = reco_client.add_entity_label(identity_id, "identity", RISKY_USER) + return CommandResults( + raw_response=raw_response, + readable_output=f"User {email_address} labeled as risky", + ) + + +def add_leaving_org_user(reco_client: RecoClient, email_address: str) -> CommandResults: # pragma: no cover + """Tag an identity as a leaving-org user via the external API labels endpoint.""" + identities = reco_client.get_identities(email_address=email_address) + if not identities: + return CommandResults(readable_output=f"No identity found for {email_address}") + raw_response = None + for identity in identities: + identity_id = identity.get("id") + if identity_id: + raw_response = reco_client.add_entity_label(identity_id, "identity", LEAVING_ORG_USER) + return CommandResults( + raw_response=raw_response, + readable_output=f"User {email_address} labeled as leaving org user", + ) + + +def get_alert_ai_summary(reco_client: RecoClient, alert_id: str) -> CommandResults: + response = reco_client.get_alert_ai_summary(alert_id) + content = str(response.get("markdown")) if response.get("markdown") else json.dumps(response) + return CommandResults( + readable_output=content, + outputs_prefix="Reco.AlertSummary", + outputs_key_field="alert_id", + outputs=response, + raw_response=response, + ) + + def get_assets_user_has_access(reco_client: RecoClient, email_address: str, only_sensitive: bool) -> CommandResults: - """Get assets from Reco. If only_sensitive is True, only sensitive assets will be returned.""" assets = reco_client.get_assets_user_has_access(email_address, only_sensitive) - assets_list = [] - for asset in assets: - asset_as_dict = parse_table_row_to_dict(asset.get("cells", {})) - assets_list.append(asset_as_dict) + assets_list = [parse_table_row_to_dict(a.get("cells", {})) for a in assets] return CommandResults( readable_output=tableToMarkdown( "Assets", assets_list, - headers=[ - "file_name", - "file_owner", - "file_url", - "currently_permitted_users", - "visibility", - "location", - "source", - ], + headers=["file_name", "file_owner", "file_url", "currently_permitted_users", "visibility", "location", "source"], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_value", @@ -1214,25 +1107,13 @@ def get_assets_user_has_access(reco_client: RecoClient, email_address: str, only def get_sensitive_assets_shared_with_public_link(reco_client: RecoClient) -> CommandResults: - """Get sensitive assets shared with public link from Reco.""" assets = reco_client.get_exposed_publicly_files_at_risk() - assets_list = [] - for asset in assets: - asset_as_dict = parse_table_row_to_dict(asset.get("cells", {})) - assets_list.append(asset_as_dict) + assets_list = [parse_table_row_to_dict(a.get("cells", {})) for a in assets] return CommandResults( readable_output=tableToMarkdown( "Assets", assets_list, - headers=[ - "asset_id", - "asset", - "data_category", - "data_categories", - "last_access_date", - "visibility", - "location", - ], + headers=["asset_id", "asset", "data_category", "data_categories", "last_access_date", "visibility", "location"], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_id", @@ -1241,13 +1122,9 @@ def get_sensitive_assets_shared_with_public_link(reco_client: RecoClient) -> Com ) -def get_assets_shared_externally_command(reco_client: RecoClient, email_address) -> CommandResults: - """Get assets shared externally .""" +def get_assets_shared_externally_command(reco_client: RecoClient, email_address: str) -> CommandResults: assets = reco_client.get_assets_shared_externally(email_address) - assets_list = [] - for asset in assets: - asset_as_dict = parse_table_row_to_dict(asset.get("cells", {})) - assets_list.append(asset_as_dict) + assets_list = [parse_table_row_to_dict(a.get("cells", {})) for a in assets] return CommandResults( readable_output=tableToMarkdown( "Assets", @@ -1271,12 +1148,8 @@ def get_assets_shared_externally_command(reco_client: RecoClient, email_address) def get_files_exposed_to_email_command(reco_client: RecoClient, email_account: str) -> CommandResults: - """Get files exposed to email. Returns a list of files exposed to email with analysis.""" assets = reco_client.get_files_exposed_to_email(email_account) - assets_list = [] - for asset in assets: - asset_as_dict = parse_table_row_to_dict(asset.get("cells", {})) - assets_list.append(asset_as_dict) + assets_list = [parse_table_row_to_dict(a.get("cells", {})) for a in assets] return CommandResults( readable_output=tableToMarkdown( "Assets", @@ -1301,22 +1174,13 @@ def get_files_exposed_to_email_command(reco_client: RecoClient, email_account: s def get_3rd_parties_list(reco_client: RecoClient, last_interaction_time_in_days: int) -> CommandResults: - """Get 3rd parties list from Reco.""" domains = reco_client.get_3rd_parties_risk_list(last_interaction_time_in_days) - domains_list = [] - for domain in domains: - domain_as_dict = parse_table_row_to_dict(domain.get("cells", {})) - domains_list.append(domain_as_dict) + domains_list = [parse_table_row_to_dict(d.get("cells", {})) for d in domains] return CommandResults( readable_output=tableToMarkdown( "Domains", domains_list, - headers=[ - "domain", - "last_activity", - "files_num", - "users_with_access_num", - ], + headers=["domain", "last_activity", "files_num", "users_with_access_num"], ), outputs_prefix="Reco.Domains", outputs_key_field="domain", @@ -1328,26 +1192,13 @@ def get_3rd_parties_list(reco_client: RecoClient, last_interaction_time_in_days: def get_files_shared_with_3rd_parties( reco_client: RecoClient, domain: str, last_interaction_time_before_in_days: int ) -> CommandResults: - """Get files shared with 3rd parties from Reco.""" files = reco_client.get_files_shared_with_3rd_parties(domain, last_interaction_time_before_in_days) - files_list = [] - for file in files: - file_as_dict = parse_table_row_to_dict(file.get("cells", {})) - files_list.append(file_as_dict) + files_list = [parse_table_row_to_dict(f.get("cells", {})) for f in files] return CommandResults( readable_output=tableToMarkdown( "Files", files_list, - headers=[ - "domain", - "location", - "users", - "file_owner", - "data_category", - "asset", - "last_access_date", - "asset_id", - ], + headers=["domain", "location", "users", "file_owner", "data_category", "asset", "last_access_date", "asset_id"], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_id", @@ -1356,236 +1207,362 @@ def get_files_shared_with_3rd_parties( ) +def assets_to_command_result(files: list[dict[str, Any]]) -> CommandResults: + """Convert File objects (external API) to CommandResults for asset commands.""" + return CommandResults( + readable_output=tableToMarkdown( + "Assets", + files, + headers=["id", "name", "owner", "url", "sensitivityLevel", "permissionVisibility", "location", "dataCategories"], + ), + outputs_prefix="Reco.SensitiveAssets", + outputs_key_field="id", + outputs=files, + raw_response=files, + ) + + def get_sensitive_assets_by_name(reco_client: RecoClient, asset_name: str, regex_search: bool) -> CommandResults: - """Get sensitive assets from Reco. If contains is True, the asset name will be searched as a regex.""" - assets = reco_client.get_sensitive_assets_information(asset_name, None, True, regex_search) - return assets_to_command_result(assets) + files = reco_client.get_sensitive_assets_information(asset_name, None, True, regex_search) + return assets_to_command_result(files) def get_assets_by_id(reco_client: RecoClient, asset_id: str) -> CommandResults: - """Get assets from Reco by file id.""" - assets = reco_client.get_sensitive_assets_information(None, asset_id, False, False) - return assets_to_command_result(assets) + files = reco_client.get_sensitive_assets_information(None, asset_id, False, False) + return assets_to_command_result(files) -def get_user_context_by_email_address(reco_client: RecoClient, email_address: str) -> CommandResults: - users_context = reco_client.get_user_context_by_email_address(email_address) - user_as_dict = None - headers = [] - if len(users_context) > 0: - user_as_dict = parse_table_row_to_dict(users_context[0].get("cells", {})) - headers = list(user_as_dict.keys()) +def get_sensitive_assets_by_id(reco_client: RecoClient, asset_id: str) -> CommandResults: + files = reco_client.get_sensitive_assets_information(None, asset_id, True, False) + return assets_to_command_result(files) + +def get_user_context_by_email_address(reco_client: RecoClient, email_address: str) -> CommandResults: + """Return identity context for an email address (external API).""" + users = reco_client.get_user_context_by_email_address(email_address) + user_data = users[0] if users else None return CommandResults( - readable_output=tableToMarkdown("User", user_as_dict, headers=headers), + readable_output=tableToMarkdown("User", user_data, headers=list(user_data.keys()) if user_data else []), outputs_prefix="Reco.User", - outputs_key_field="email_address", - outputs=user_as_dict, - raw_response=users_context, + outputs_key_field="email", + outputs=user_data, + raw_response=users, ) def add_exclusion_filter(reco_client: RecoClient, key_to_add: str, values: list[str]) -> CommandResults: # pragma: no cover - """Add exclusion filter to Reco.""" response = reco_client.add_exclusion_filter(key_to_add, values) return CommandResults(raw_response=response, readable_output="Exclusion filter added successfully") def change_alert_status(reco_client: RecoClient, alert_id: str, status: str) -> CommandResults: # pragma: no cover - """Change alert status.""" response = reco_client.change_alert_status(alert_id, status) return CommandResults(raw_response=response, readable_output=f"Alert {alert_id} status changed successfully to {status}") -def assets_to_command_result(assets: list[dict[str, Any]]) -> CommandResults: - """Convert assets to CommandResults.""" - assets_list = [] - for asset in assets: - asset_as_dict = parse_table_row_to_dict(asset.get("cells", {})) - assets_list.append(asset_as_dict) +def get_private_email_list_with_access(reco_client: RecoClient) -> CommandResults: + result = reco_client.get_list_of_private_emails_with_access() + identities_list = [parse_table_row_to_dict(i.get("cells", {})) for i in result] return CommandResults( readable_output=tableToMarkdown( - "Assets", - assets_list, - headers=[ - "file_name", - "file_owner", - "file_url", - "currently_permitted_users", - "visibility", - "location", - "source", - "sensitivity_level", - ], + "PrivateEmails", + identities_list, + headers=["email_account", "primary_email", "files_num", "user_category"], ), - outputs_prefix="Reco.SensitiveAssets", - outputs_key_field="asset_value", - outputs=assets_list, - raw_response=assets, + outputs_prefix="Reco.privateEmails", + outputs_key_field="email_account", + outputs=identities_list, + raw_response=result, ) -def get_sensitive_assets_by_id(reco_client: RecoClient, asset_id: str) -> CommandResults: - """Get sensitive assets from Reco by file id.""" - assets = reco_client.get_sensitive_assets_information(None, asset_id, True, False) - return assets_to_command_result(assets) - - def get_link_to_user_overview_page(reco_client: RecoClient, entity: str, link_type: str) -> CommandResults: link = reco_client.get_link_to_user_overview_page(link_type, entity) return CommandResults(outputs_prefix="Reco.Link", outputs={"link": link}, raw_response=link) -def fetch_incidents( - reco_client: RecoClient, - last_run: dict[str, Any], - max_fetch: int, - risk_level: int | None = None, - source: str | None = None, - before: datetime | None = None, - after: datetime | None = None, -) -> tuple[dict[str, Any], list[dict[str, Any]]]: - demisto.info(f"fetch-incidents called {max_fetch=}") - next_run = {} - incidents = [] - last_run_time = last_run.get("lastRun", None) - if last_run_time is not None: - after = dateutil.parser.parse(last_run_time) +def get_apps_command( + reco_client: RecoClient, before: datetime | None = None, after: datetime | None = None, limit: int = PAGE_SIZE +) -> CommandResults: + """List discovered apps from the external API.""" + apps = reco_client.get_app_discovery(before=before, after=after, limit=limit) + headers = ["id", "name", "category", "usersCount", "authorization", "isUsingAi", "vendorGrade", "aiCapability", "lastSeen"] + return CommandResults( + readable_output=tableToMarkdown("App Discovery", apps, headers=headers), + outputs_prefix="Reco.Apps", + outputs_key_field="id", + outputs=apps, + raw_response=apps, + ) - alerts = get_alerts(reco_client, risk_level, source, before, after, max_fetch) - incidents = parse_alerts_to_incidents(alerts) - existing_incidents = last_run.get("incident_ids", []) - incidents = [ - incident - for incident in incidents - if (incident.get("severity", 0) > DEMISTO_INFORMATIONAL) - and (incident.get("dbotMirrorId", None) not in existing_incidents) - ] # type: ignore +def set_app_authorization_status_command(reco_client: RecoClient, app_id: str, authorization_status: str) -> CommandResults: + """Update app authorization status via the external API.""" + reco_client.set_app_authorization_status(app_id, authorization_status) + return CommandResults( + readable_output=f"App {app_id} authorization status updated to {authorization_status}", + outputs_prefix="Reco.AppAuthorization", + outputs={"app_id": app_id, "authorization_status": authorization_status, "updated": True}, + ) - incidents_sorted = sorted(incidents, key=lambda k: k["occurred"]) - if incidents_sorted: - # Use the latest timestamp and add a 1-second buffer - last_occurred = incidents_sorted[-1]["occurred"] - last_occurred_dt = dateutil.parser.parse(last_occurred) - last_occurred_dt_plus_1 = last_occurred_dt + timedelta(seconds=1) - next_run["lastRun"] = last_occurred_dt_plus_1.strftime(DEMISTO_OCCURRED_FORMAT) - else: - next_run["lastRun"] = last_run_time - next_run["incident_ids"] = existing_incidents + [incident["dbotMirrorId"] for incident in incidents] - return next_run, incidents +# --- New command functions (external API) --- -def parse_alerts_to_incidents(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Map alert rows (ALERT_VIEW_WITH_SHARED_STATUS uses snake_case) to XSOAR incidents.""" - alerts_as_incidents = [] - for alert in alerts: - alert_dict = parse_table_row_to_dict(alert.get("cells", {})) if alert.get("cells") else alert - occurred = alert_dict.get("created_at") or alert_dict.get("createdAt", "") - raw_risk = alert_dict.get("risk_level") or alert_dict.get("riskLevel") - incident = { - "name": alert_dict.get("description", ""), - "occurred": occurred, - "dbotMirrorId": alert_dict.get("id", ""), - "rawJSON": json.dumps(alert), - "severity": _reco_risk_to_demisto_severity(raw_risk), - } - alerts_as_incidents.append(incident) - return alerts_as_incidents +def list_events_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List SaaS events from the external API.""" + response = reco_client.list_events(filters=filters, count=limit) + events = response.get("events", []) + flat: list[dict[str, Any]] = [] + for e in events: + row = dict(e) + actor = e.get("actor") or {} + row["actorEmail"] = actor.get("email", "") + row["actorName"] = actor.get("name", "") + flat.append(row) + return CommandResults( + readable_output=tableToMarkdown( + "Events", + flat, + headers=["id", "eventType", "formattedEventType", "application", "actorEmail", "eventTime", "outcomeString"], + ), + outputs_prefix="Reco.Events", + outputs_key_field="id", + outputs=events, + raw_response=response, + ) -def get_max_fetch(max_fetch: int) -> int: - if max_fetch > 500: - return 500 - return max_fetch +def list_posture_issues_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List posture issues from the external API.""" + response = reco_client.list_posture_issues(filters=filters, count=limit) + issues = response.get("issues", []) + return CommandResults( + readable_output=tableToMarkdown( + "Posture Issues", + issues, + headers=["id", "name", "severity", "checkStatus", "scorePercentage", "checkedInstance", "url"], + ), + outputs_prefix="Reco.PostureIssues", + outputs_key_field="id", + outputs=issues, + raw_response=response, + ) -def get_private_email_list_with_access(reco_client): - result = reco_client.get_list_of_private_emails_with_access() - identities_list = [] - for identity in result: - asset_as_dict = parse_table_row_to_dict(identity.get("cells", {})) - identities_list.append(asset_as_dict) +def list_accounts_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List accounts from the external API.""" + response = reco_client.list_accounts(filters=filters, count=limit) + accounts = response.get("accounts", []) return CommandResults( readable_output=tableToMarkdown( - "PrivateEmails", - identities_list, - headers=[ - "email_account", - "primary_email", - "files_num", - "user_category", - ], + "Accounts", + accounts, + headers=["id", "name", "accountEmail", "permissions", "hasMfa", "openAlerts", "isAdmin", "isRiskyUser", "lastSeen"], ), - outputs_prefix="Reco.privateEmails", - outputs_key_field="email_account", - outputs=identities_list, - raw_response=result, + outputs_prefix="Reco.Accounts", + outputs_key_field="id", + outputs=accounts, + raw_response=response, ) -def get_apps_command( - reco_client: RecoClient, before: datetime | None = None, after: datetime | None = None, limit: int = 1000 -) -> CommandResults: - """Get app discovery data from Reco.""" - apps = reco_client.get_app_discovery(before=before, after=after, limit=limit) - apps_list = [] - for app in apps: - app_as_dict = parse_table_row_to_dict(app.get("cells", {})) - apps_list.append(app_as_dict) +def list_devices_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List devices from the external API.""" + response = reco_client.list_devices(filters=filters, count=limit) + devices = response.get("devices", []) + return CommandResults( + readable_output=tableToMarkdown( + "Devices", + devices, + headers=["id", "name", "devicePlatform", "os", "osVersion", "isUnmanaged", "hasNonCompliant", "lastSeen"], + ), + outputs_prefix="Reco.Devices", + outputs_key_field="id", + outputs=devices, + raw_response=response, + ) - # Define headers for the table based on common app discovery fields - headers = ["app_name", "app_id", "category", "risk_score", "users_count", "data_access", "updated_at", "created_at", "status"] +def list_ai_agents_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List AI agents from the external API.""" + response = reco_client.list_ai_agents(filters=filters, count=limit) + agents = response.get("aiAgents", []) return CommandResults( readable_output=tableToMarkdown( - "App Discovery", - apps_list, - headers=headers, + "AI Agents", + agents, + headers=["id", "name", "vendor", "type", "authorization", "agentStatus", "risk", "lastUsage"], ), - outputs_prefix="Reco.Apps", - outputs_key_field="app_id", - outputs=apps_list, - raw_response=apps, + outputs_prefix="Reco.AiAgents", + outputs_key_field="id", + outputs=agents, + raw_response=response, ) -def set_app_authorization_status_command(reco_client: RecoClient, app_id: str, authorization_status: str) -> CommandResults: - """Set app authorization status in Reco.""" - response = reco_client.set_app_authorization_status(app_id, authorization_status) +def list_groups_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List SaaS groups from the external API.""" + response = reco_client.list_groups(filters=filters, count=limit) + groups = response.get("groups", []) + return CommandResults( + readable_output=tableToMarkdown( + "Groups", + groups, + headers=["id", "name", "email", "membersCount", "appsCount"], + ), + outputs_prefix="Reco.Groups", + outputs_key_field="id", + outputs=groups, + raw_response=response, + ) - rows_updated = response.get("rows", 0) - success = rows_updated == 1 - if success: - readable_message = f"App {app_id} authorization status updated successfully to {authorization_status}" - else: - readable_message = f"App {app_id} authorization status update completed with {rows_updated} rows affected" +def list_saas_to_saas_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List SaaS-to-SaaS grants from the external API.""" + response = reco_client.list_saas_to_saas(filters=filters, count=limit) + grants = response.get("saasToSaas", []) + return CommandResults( + readable_output=tableToMarkdown( + "SaaS-to-SaaS Grants", + grants, + headers=["id", "plugin", "authorization", "permissionRisk", "accounts", "aiCapability", "lastSeen"], + ), + outputs_prefix="Reco.SaasToSaas", + outputs_key_field="id", + outputs=grants, + raw_response=response, + ) + +def list_ip_addresses_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List observed IP addresses from the external API.""" + response = reco_client.list_ip_addresses(filters=filters, count=limit) + ips = response.get("ipAddresses", []) return CommandResults( + readable_output=tableToMarkdown( + "IP Addresses", + ips, + headers=["ipAddress", "country", "asnName", "eventsCount", "usersCount", "hasVpn", "hasProxy", "lastEventTime"], + ), + outputs_prefix="Reco.IpAddresses", + outputs_key_field="ipAddress", + outputs=ips, raw_response=response, - readable_output=readable_message, - outputs_prefix="Reco.AppAuthorization", - outputs={ - "app_id": app_id, - "authorization_status": authorization_status, - "updated": success, - "rows_affected": rows_updated, - }, ) -def main() -> None: # pragma: no cover - """main function, parses params and runs command functions +def list_business_units_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List external business units from the external API.""" + response = reco_client.list_business_units(filters=filters, count=limit) + bus = response.get("businessUnits", []) + return CommandResults( + readable_output=tableToMarkdown( + "Business Units", + bus, + headers=["id", "name", "manager", "createdAt"], + ), + outputs_prefix="Reco.BusinessUnits", + outputs_key_field="id", + outputs=bus, + raw_response=response, + ) - :return: - :rtype: - """ + +def list_audit_logs_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List platform audit logs from the external API.""" + response = reco_client.list_audit_logs(filters=filters, count=limit) + logs = response.get("auditLogs", []) + return CommandResults( + readable_output=tableToMarkdown( + "Audit Logs", + logs, + headers=["id", "userEmail", "module", "action", "objectName", "timestamp", "remoteAddr"], + ), + outputs_prefix="Reco.AuditLogs", + outputs_key_field="id", + outputs=logs, + raw_response=response, + ) + + +def list_posture_checks_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List posture check definitions from the external API.""" + response = reco_client.list_posture_checks(filters=filters, count=limit) + checks = response.get("postureChecks", []) + return CommandResults( + readable_output=tableToMarkdown( + "Posture Checks", + checks, + headers=["id", "name", "severity", "policyType", "apps", "type"], + ), + outputs_prefix="Reco.PostureChecks", + outputs_key_field="id", + outputs=checks, + raw_response=response, + ) + + +def list_threat_detection_policies_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List threat detection policies from the external API.""" + response = reco_client.list_threat_detection_policies(filters=filters, count=limit) + policies = response.get("policies", []) + return CommandResults( + readable_output=tableToMarkdown( + "Threat Detection Policies", + policies, + headers=["id", "name", "severity", "status", "apps", "openAlerts", "type"], + ), + outputs_prefix="Reco.ThreatDetectionPolicies", + outputs_key_field="id", + outputs=policies, + raw_response=response, + ) + + +def list_exclusions_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List alert suppression exclusion rules from the external API.""" + response = reco_client.list_exclusions(filters=filters, count=limit) + exclusions = response.get("exclusions", []) + return CommandResults( + readable_output=tableToMarkdown( + "Exclusions", + exclusions, + headers=["id", "name", "policyName", "apps", "createdBy", "createdAt"], + ), + outputs_prefix="Reco.Exclusions", + outputs_key_field="id", + outputs=exclusions, + raw_response=response, + ) + + +def list_app_instances_command(reco_client: RecoClient, filters: str = "", limit: int = PAGE_SIZE) -> CommandResults: + """List integrated app instances (portfolio) from the external API.""" + response = reco_client.list_app_instances(filters=filters, count=limit) + instances = response.get("appInstances", []) + return CommandResults( + readable_output=tableToMarkdown( + "App Instances", + instances, + headers=["id", "name", "instanceType", "accountsCount", "isUsingAi", "saasToSaasCount", "filesCount"], + ), + outputs_prefix="Reco.AppInstances", + outputs_key_field="id", + outputs=instances, + raw_response=response, + ) + + +# --- Main --- + + +def main() -> None: # pragma: no cover try: command = demisto.command() demisto.debug(f"Reco Command being called is {command}") params = demisto.params() + args = demisto.args() api_url = params.get("url") api_token = params.get("api_token") verify_certificate = not params.get("insecure", False) @@ -1604,156 +1581,221 @@ def main() -> None: # pragma: no cover verify=verify_certificate, proxy=proxy, ) + if command == "fetch-incidents": - risk_level = params.get("risk_level") + # risk_level accepts a single value; alerts at or above it are fetched (PRO-303) + risk_levels = parse_minimum_risk_level(params.get("risk_level")) source = params.get("source") before = params.get("before") - after = params.get("after") + after: datetime | None = None - # How much time before the first fetch to retrieve incidents if arg := params.get("first_fetch"): - first_fetch_time_stamp = dateparser.parse(arg) - if first_fetch_time_stamp: - after = first_fetch_time_stamp + first_fetch_ts = dateparser.parse(arg) + if first_fetch_ts: + after = first_fetch_ts next_run, incidents = fetch_incidents( reco_client, last_run=demisto.getLastRun(), max_fetch=max_fetch, - risk_level=risk_level, + risk_levels=risk_levels, source=source, before=before, after=after, ) demisto.setLastRun(next_run) demisto.incidents(incidents) + elif command == "reco-add-comment-to-alert": - incident_id = demisto.args()["alert_id"] + incident_id = args["alert_id"] response = reco_client.update_reco_incident_timeline( incident_id=incident_id, - comment=demisto.args()["comment"], - ) - return_results( - CommandResults( - raw_response=response, - readable_output=f"Commented added to alert {incident_id}", - ) + comment=args["comment"], ) + return_results(CommandResults(raw_response=response, readable_output=f"Comment added to alert {incident_id}")) + elif command == "reco-update-incident-timeline": + incident_id = args["incident_id"] response = reco_client.update_reco_incident_timeline( - incident_id=demisto.args()["incident_id"], comment=demisto.args()["comment"] - ) - return_results( - CommandResults( - raw_response=response, - readable_output=f"Timeline updated successfully for incident {incident_id}", - ) + incident_id=incident_id, + comment=args["comment"], ) + return_results(CommandResults(raw_response=response, readable_output=f"Timeline updated for incident {incident_id}")) + elif command == "reco-resolve-visibility-event": - entity_id = demisto.args()["entity_id"] - label_name = demisto.args()["label_name"] + entity_id = args["entity_id"] + label_name = args["label_name"] response = reco_client.resolve_visibility_event(entity_id=entity_id, label_name=label_name) - return_results( - CommandResults( - raw_response=response, - readable_output=f"Visibility event {entity_id} resolved successfully", - ) - ) + return_results(CommandResults(raw_response=response, readable_output=f"Visibility event {entity_id} resolved")) + elif command == "test-module": - test_res = reco_client.validate_api_key() - return_results(test_res) + return_results(reco_client.validate_api_key()) + elif command == "reco-get-risky-users": - result = get_risky_users_from_reco(reco_client) - return_results(result) + return_results(get_risky_users_from_reco(reco_client)) + elif command == "reco-add-risky-user-label": - email_address = demisto.args()["email_address"] - result = add_risky_user_label(reco_client, email_address) - return_results(result) + return_results(add_risky_user_label(reco_client, args["email_address"])) + elif command == "reco-add-leaving-org-user-label": - email_address = demisto.args()["email_address"] - result = add_leaving_org_user(reco_client, email_address) - return_results(result) + return_results(add_leaving_org_user(reco_client, args["email_address"])) + elif command == "reco-get-assets-user-has-access-to": - only_sensitive = demisto.args().get("only_sensitive", False) - result = get_assets_user_has_access( - reco_client, - demisto.args()["email_address"], - only_sensitive, + return_results( + get_assets_user_has_access( + reco_client, + args["email_address"], + args.get("only_sensitive", False), + ) ) - return_results(result) + elif command == "reco-get-sensitive-assets-by-name": - regex_search = demisto.args().get("regex_search", False) - result = get_sensitive_assets_by_name( - reco_client, - demisto.args()["asset_name"], - regex_search, + return_results( + get_sensitive_assets_by_name( + reco_client, + args["asset_name"], + args.get("regex_search", False), + ) ) - return_results(result) + elif command == "reco-get-sensitive-assets-by-id": - result = get_sensitive_assets_by_id(reco_client, demisto.args()["asset_id"]) - return_results(result) + return_results(get_sensitive_assets_by_id(reco_client, args["asset_id"])) + elif command == "reco-get-link-to-user-overview-page": - result = get_link_to_user_overview_page( - reco_client, - demisto.args()["entity"], - demisto.args()["param"], - ) - return_results(result) + return_results(get_link_to_user_overview_page(reco_client, args["entity"], args["param"])) + elif command == "reco-get-sensitive-assets-with-public-link": - result = get_sensitive_assets_shared_with_public_link(reco_client) - return_results(result) + return_results(get_sensitive_assets_shared_with_public_link(reco_client)) + elif command == "reco-get-3rd-parties-accessible-to-data-list": - result = get_3rd_parties_list(reco_client, int(demisto.args()["last_interaction_time_in_days"])) - return_results(result) + return_results(get_3rd_parties_list(reco_client, int(args["last_interaction_time_in_days"]))) + elif command == "reco-get-files-shared-with-3rd-parties": - result = get_files_shared_with_3rd_parties( - reco_client, demisto.args()["domain"], int(demisto.args()["last_interaction_time_in_days"]) + return_results( + get_files_shared_with_3rd_parties( + reco_client, + args["domain"], + int(args["last_interaction_time_in_days"]), + ) ) - return_results(result) + elif command == "reco-add-exclusion-filter": - result = add_exclusion_filter(reco_client, demisto.args()["key_to_add"], argToList(demisto.args()["values_to_add"])) - return_results(result) + return_results(add_exclusion_filter(reco_client, args["key_to_add"], argToList(args["values_to_add"]))) + elif command == "reco-change-alert-status": - result = change_alert_status(reco_client, demisto.args()["alert_id"], demisto.args()["status"]) - return_results(result) + return_results(change_alert_status(reco_client, args["alert_id"], args["status"])) + elif command == "reco-get-user-context-by-email-address": - result = get_user_context_by_email_address(reco_client, demisto.args()["email_address"]) - return_results(result) + return_results(get_user_context_by_email_address(reco_client, args["email_address"])) + elif command == "reco-get-files-exposed-to-email-address": - result = get_files_exposed_to_email_command(reco_client, demisto.args()["email_address"]) - return_results(result) + return_results(get_files_exposed_to_email_command(reco_client, args["email_address"])) + elif command == "reco-get-assets-shared-externally": - result = get_assets_shared_externally_command(reco_client, demisto.args()["email_address"]) - return_results(result) + return_results(get_assets_shared_externally_command(reco_client, args["email_address"])) + elif command == "reco-get-private-email-list-with-access": - result = get_private_email_list_with_access(reco_client) - return_results(result) + return_results(get_private_email_list_with_access(reco_client)) + elif command == "reco-get-assets-by-id": - result = get_assets_by_id(reco_client, demisto.args()["asset_id"]) - return_results(result) + return_results(get_assets_by_id(reco_client, args["asset_id"])) + elif command == "reco-get-alert-ai-summary": - result = get_alert_ai_summary(reco_client, demisto.args().get("alert_id", "")) - return_results(result) + return_results(get_alert_ai_summary(reco_client, args.get("alert_id", ""))) + elif command == "reco-get-apps": - # Parse datetime arguments if provided - before = None - after = None - before_str = demisto.args().get("before") - after_str = demisto.args().get("after") - if before_str: - before = dateparser.parse(before_str) - if after_str: - after = dateparser.parse(after_str) - limit = int(demisto.args().get("limit") or "1000") - result = get_apps_command(reco_client, before=before, after=after, limit=limit) - return_results(result) + before_dt = dateparser.parse(args["before"]) if args.get("before") else None + after_dt = dateparser.parse(args["after"]) if args.get("after") else None + return_results( + get_apps_command(reco_client, before=before_dt, after=after_dt, limit=int(args.get("limit") or PAGE_SIZE)) + ) + elif command == "reco-set-app-authorization-status": - app_id = demisto.args()["app_id"] - authorization_status = demisto.args()["authorization_status"] - result = set_app_authorization_status_command(reco_client, app_id, authorization_status) - return_results(result) + return_results(set_app_authorization_status_command(reco_client, args["app_id"], args["authorization_status"])) + + elif command == "reco-list-events": + return_results( + list_events_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-posture-issues": + return_results( + list_posture_issues_command( + reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE) + ) + ) + + elif command == "reco-list-accounts": + return_results( + list_accounts_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-devices": + return_results( + list_devices_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-ai-agents": + return_results( + list_ai_agents_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-groups": + return_results( + list_groups_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-saas-to-saas": + return_results( + list_saas_to_saas_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-ip-addresses": + return_results( + list_ip_addresses_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-business-units": + return_results( + list_business_units_command( + reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE) + ) + ) + + elif command == "reco-list-audit-logs": + return_results( + list_audit_logs_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-posture-checks": + return_results( + list_posture_checks_command( + reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE) + ) + ) + + elif command == "reco-list-threat-detection-policies": + return_results( + list_threat_detection_policies_command( + reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE) + ) + ) + + elif command == "reco-list-exclusions": + return_results( + list_exclusions_command(reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE)) + ) + + elif command == "reco-list-app-instances": + return_results( + list_app_instances_command( + reco_client, filters=args.get("filters", ""), limit=int(args.get("limit") or PAGE_SIZE) + ) + ) + else: raise NotImplementedError(f"{command} is not an existing reco command") + except Exception as e: demisto.error(f"Failed to execute {demisto.command()} command. Error: {str(e)}") return_error(str(e)) diff --git a/Packs/Reco/Integrations/Reco/Reco.yml b/Packs/Reco/Integrations/Reco/Reco.yml index 87a8eae70340..eb2554d5edf5 100644 --- a/Packs/Reco/Integrations/Reco/Reco.yml +++ b/Packs/Reco/Integrations/Reco/Reco.yml @@ -7,39 +7,46 @@ commonfields: id: Reco version: -1 configuration: -- display: Server URL (e.g. https://host.reco.ai/api/v1) +- additionalinfo: The base URL of your Reco instance. + display: Server URL (e.g. https://host.reco.ai/api/v1) name: url required: true type: 0 section: Connect - section: Connect + additionalinfo: The API token (Bearer) used to authenticate with the Reco External API. display: JWT app token displaypassword: API Token name: api_token type: 4 required: true hiddenusername: true -- display: Trust any certificate (not secure) +- additionalinfo: The option to skip TLS certificate verification when connecting to the Reco API. + display: Trust any certificate (not secure) name: insecure type: 8 required: false section: Connect -- display: Use system proxy settings +- additionalinfo: The option to route requests through the system proxy. + display: Use system proxy settings name: proxy type: 8 required: false section: Connect -- display: Incident type +- additionalinfo: The incident type to map Reco alerts to. + display: Incident type name: incidentType type: 13 required: false section: Collect -- display: Fetch incidents +- additionalinfo: The option to enable automatic incident fetching. + display: Fetch incidents name: isFetch type: 8 required: false section: Collect -- defaultvalue: '200' +- additionalinfo: The maximum number of incidents to fetch per run (up to 500). + defaultvalue: '200' display: Max fetch name: max_fetch type: 0 @@ -63,19 +70,20 @@ configuration: type: 0 required: false section: Collect -- additionalinfo: Risk level of the incidents to fetch - display: Risk level +- additionalinfo: 'The minimum severity threshold for fetched incidents. Accepts a single value: LOW, MEDIUM, HIGH, or CRITICAL (or numeric equivalents 10, 20, 30, 40). Alerts at or above this severity are fetched. For example, "MEDIUM" fetches medium, high, and critical severity alerts.' + display: Minimum risk level (e.g. MEDIUM fetches medium and higher) name: risk_level type: 0 required: false section: Collect -- defaultvalue: 7 days +- additionalinfo: The amount of time to look back on the first fetch run (e.g. `7 days`, `12 hours`). + defaultvalue: 7 days display: First fetch timestamp (