Reco
Reco is the leader in SaaS & AI Security, providing full visibility and control across your SaaS ecosystem and AI agents.
Data Enrichment & Threat Intelligence · Reco
Details
| ID | Reco |
|---|---|
| Provider | Reco AI |
| Category | Data Enrichment & Threat Intelligence |
| From Version | 6.5.0 |
| Docker Image | demisto/python3:3.12.13.10404775 |
| Supported Modules | Agentix XSIAM |
README
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) | 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
reco-add-comment-to-alert
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 a comment to an incident timeline in Reco.
Base Command
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 |
|---|---|---|
| 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
| 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.RiskyUsers | Unknown | Risky Users. |
reco-add-risky-user-label
Tag a user as risky in Reco.
Base Command
reco-add-risky-user-label
Input
| Argument Name | Description | 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
| Path | Type | Description |
|---|---|---|
| Reco.Assets | Unknown | Assets the user has access to |
reco-get-sensitive-assets-by-name
Find sensitive assets by name.
Base Command
reco-get-sensitive-assets-by-name
Input
| Argument Name | Description | Required |
|---|---|---|
| asset_name | Asset name to search for. | Required |
| regex_search | Use substring/contains matching instead of exact match. | Optional |
Context Output
| 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.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.source | String | SaaS tool source of the asset. |
| Reco.SensitiveAssets.sensitivity_level | Number | The sensitivity level of the asset. |
reco-get-sensitive-assets-by-id
Find sensitive assets by ID.
Base Command
reco-get-sensitive-assets-by-id
Input
| Argument Name | Description | Required |
|---|---|---|
| asset_id | Asset ID. | Required |
Context Output
| 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.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.source | String | SaaS tool source of the asset. |
| Reco.SensitiveAssets.sensitivity_level | Number | The sensitivity level of the asset. |
reco-get-assets-by-id
Find any asset by ID.
Base Command
reco-get-assets-by-id
Input
| Argument Name | Description | Required |
|---|---|---|
| asset_id | Asset ID. | Required |
Context Output
| 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.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.source | String | SaaS tool source of the asset. |
| Reco.SensitiveAssets.sensitivity_level | Number | The sensitivity level of the asset. |
reco-get-link-to-user-overview-page
Generate a deep link to the Reco UI overview page for an entity.
Base Command
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 |
|---|---|---|
| last_interaction_time_in_days | Include domains with activity within this many days. | Required |
Context Output
| 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-sensitive-assets-with-public-link
List sensitive assets exposed via a public link.
Base Command
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.data_categories | String | Data categories of the asset |
| Reco.SensitiveAssets.location | String | Path of the asset |
| Reco.SensitiveAssets.source | String | SaaS tool source of the asset |
| 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 |
|---|---|---|
| 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.asset_id | String | Asset ID |
| Reco.Assets.location | String | File location |
| Reco.Assets.file_owner | String | File owner |
| Reco.Assets.asset | Unknown | Asset metadata |
| Reco.Assets.data_category | String | Data category of the asset |
| Reco.Assets.users | String | Users the file is shared with |
| Reco.Assets.domain | String | Third-party domain |
| Reco.Assets.last_access_date | String | Last access date |
reco-change-alert-status
Update the status of a Reco alert.
Base Command
reco-change-alert-status
Input
| Argument Name | Description | Required |
|---|---|---|
| alert_id | Alert ID. | Required |
| status | New status. Possible values: ALERT_STATUS_NEW, ALERT_STATUS_IN_PROGRESS, ALERT_STATUS_CLOSED, ALERT_STATUS_PREVIEW, ALERT_STATUS_TO_REVIEW, ALERT_STATUS_PASSED, ALERT_STATUS_RISK_ACCEPTED, ALERT_STATUS_RISK_MITIGATED, ALERT_STATUS_NA, ALERT_STATUS_NA_NEW, ALERT_STATUS_NA_NO_AD, ALERT_STATUS_NOT_APPLICABLE. |
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
| 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.User.email_account | String | The email of the user. |
| Reco.User.full_name | String | The user full name. |
| Reco.User.job_titles | String | Job Title. |
| Reco.User.category | String | Category. |
| Reco.User.groups | String | The groups user is member of. |
| Reco.User.labels | Unknown | User Labels. |
reco-get-files-exposed-to-email-address
List files accessible to a specific email address.
Base Command
reco-get-files-exposed-to-email-address
Input
| Argument Name | Description | Required |
|---|---|---|
| email_address | Email address. | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| Reco.Assets.asset_id | String | Asset ID |
| Reco.Assets.asset | Unknown | Asset metadata |
| Reco.Assets.data_category | String | Data category of the asset |
| Reco.Assets.data_categories | String | Data categories of the asset |
| Reco.Assets.location | String | File location |
| Reco.Assets.source | String | SaaS tool source of the asset |
| Reco.Assets.last_access_date | String | Last access date |
| Reco.Assets.email_account | String | Email account with access |
| Reco.Assets.file_owner | String | File owner |
reco-get-assets-shared-externally
List files an owner has shared outside the organization.
Base Command
reco-get-assets-shared-externally
Input
| Argument Name | Description | Required |
|---|---|---|
| email_address | File owner email address. | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| Reco.Assets.asset_id | String | Asset ID |
| Reco.Assets.asset | Unknown | Asset metadata |
| Reco.Assets.data_category | String | Data category of the asset |
| Reco.Assets.data_categories | String | Data categories of the asset |
| Reco.SensitiveAssets.location | String | Path of the asset |
| Reco.SensitiveAssets.source | String | SaaS tool source of the asset |
| Reco.Assets.last_access_date | String | Last access date |
| Reco.Assets.file_owner | String | File owner |
reco-get-private-email-list-with-access
List private (non-corporate) email addresses with file access.
Base Command
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 |
|---|---|---|
| alert_id | Alert ID. | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| Reco.AlertSummary.markdown | String | Markdown-formatted alert summary |
reco-get-apps
List all discovered SaaS applications (auto-paginates all results).
Base Command
reco-get-apps
Input
| Argument Name | Description | 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.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.Apps.app_name | String | The name of the application. |
| Reco.Apps.app_id | String | The unique identifier 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-set-app-authorization-status
Update the authorization status of an application.
Base Command
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.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. |
Command example
!reco-set-app-authorization-status app_id="microsoft.com" authorization_status="AUTH_STATUS_SANCTIONED"
reco-add-exclusion-filter
Add values to a Reco classifier exclusion list.
Base Command
reco-add-exclusion-filter
Input
| Argument Name | Description | 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.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-list-posture-issues
List security posture issues.
Base Command
reco-list-posture-issues
Input
| Argument Name | Description | 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
| 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-list-accounts
List SaaS accounts.
Base Command
reco-list-accounts
Input
| Argument Name | Description | 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.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-list-devices
List managed and unmanaged devices.
Base Command
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.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-list-ai-agents
List detected AI agents.
Base Command
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.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-list-groups
List SaaS groups.
Base Command
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.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-list-saas-to-saas
List SaaS-to-SaaS OAuth grants.
Base Command
reco-list-saas-to-saas
Input
| Argument Name | Description | 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.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-list-ip-addresses
List observed IP addresses.
Base Command
reco-list-ip-addresses
Input
| Argument Name | Description | 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.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-list-business-units
List external business units.
Base Command
reco-list-business-units
Input
| Argument Name | Description | Required |
|---|---|---|
| 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.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-list-audit-logs
List Reco platform audit logs.
Base Command
reco-list-audit-logs
Input
| Argument Name | Description | 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.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 |
reco-list-posture-checks
List posture check definitions.
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.
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
| 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 |
<~PLATFORM>
License Requirements
The following configuration parameters require one of these licenses: Cortex XSIAM or Agentix:
- Fetch incidents
</~PLATFORM>
Configuration parameters
url— Server URL (e.g. https://host.reco.ai/api/v1) (required)api_token— JWT app token (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingsincidentType— Incident typeisFetch— Fetch incidentsmax_fetch— Max fetchsource— Sourcebefore— Beforeafter— Afterrisk_level— Minimum risk level (e.g. MEDIUM fetches medium and higher)first_fetch— First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)incidentFetchInterval— Incidents Fetch Interval
Commands (37)
-
reco-add-comment-to-alertAdd a comment to an alert in Reco.
-
reco-add-exclusion-filterAdd exclusion filter to Reco Classifier.
-
reco-add-leaving-org-user-labelTag a user as leaving org user in Reco.
-
reco-add-risky-user-labelTag a user as risky in Reco.
-
reco-change-alert-statusupdate alert status in Reco.
-
reco-get-3rd-parties-accessible-to-data-listGet 3rd parties accessible to sensitive assets.
-
reco-get-alert-ai-summaryGet alert ai summary from Reco.
-
reco-get-appsGet app discovery data from Reco. Fetches all available apps using pagination.
-
reco-get-assets-by-idGet all assets from Reco by id.
-
reco-get-assets-shared-externallyGet files user has access to from Reco.
-
reco-get-assets-user-has-access-toGet all files user has access to from Reco.
-
reco-get-files-exposed-to-email-addressGet files user has access to from Reco.
-
reco-get-files-shared-with-3rd-partiesGet files shared with 3rd parties.
-
reco-get-link-to-user-overview-pageGenerate a magic link for reco UI (overview page).
-
reco-get-private-email-list-with-accessGet Private emails with access.
-
reco-get-risky-usersGet Risky Users from Reco.
-
reco-get-sensitive-assets-by-idGet all sensitive assets from Reco by id.
-
reco-get-sensitive-assets-by-nameGet all sensitive assets from Reco by name.
-
reco-get-sensitive-assets-with-public-linkGet all sensitive assets with public link from Reco.
-
reco-get-user-context-by-email-addressGet user context by email address from Reco.
-
reco-list-accountsLists accounts (SaaS user accounts) from Reco using the external API.
-
reco-list-ai-agentsLists AI agents detected by Reco using the external API.
-
reco-list-app-instancesLists integrated app instances (app portfolio) from Reco using the external API. Only returns instances with an active integration status.
-
reco-list-audit-logsLists Reco platform audit logs using the external API.
-
reco-list-business-unitsLists external business units from Reco using the external API.
-
reco-list-devicesLists devices from Reco using the external API.
-
reco-list-eventsLists SaaS events from Reco using the external API.
-
reco-list-exclusionsLists alert suppression exclusion rules from Reco using the external API.
-
reco-list-groupsLists SaaS groups from Reco using the external API.
-
reco-list-ip-addressesLists observed IP addresses from Reco using the external API.
-
reco-list-posture-checksLists posture check definitions from Reco using the external API.
-
reco-list-posture-issuesLists posture issues from Reco using the external API.
-
reco-list-saas-to-saasLists SaaS-to-SaaS OAuth grants and integrations from Reco using the external API.
-
reco-list-threat-detection-policiesLists threat detection policies from Reco using the external API.
-
reco-resolve-visibility-eventResolve an event in Reco Finding. Reco Findings contains aggregations of events. This command resolves the event in the Reco Finding.
-
reco-set-app-authorization-statusSet authorization status for an application in Reco.
-
reco-update-incident-timelineAdd a comment to an alert in Reco.
import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 import base64 import json import time from datetime import datetime, timedelta import dateutil.parser from typing import Any ENTRY_TYPE_USER = "ENTRY_TYPE_USER" ENTRY_TYPE_IDENTITY = "ENTRY_TYPE_IDENTITY" LABEL_STATUS_ACTIVE = "LABEL_STATUS_ACTIVE" RISKY_USER = "Risky User" LEAVING_ORG_USER = "Leaving Org User" ENTRY_TYPE_EVENT = "ENTRY_TYPE_EVENT" LABEL_STATUS_RESOLVED = "LABEL_STATUS_RESOLVED" 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 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" # 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]]: if response.get("getTableResponse") is None: demisto.error(f"got bad response, {response}") raise Exception(f"got bad response, {response}") else: 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 class RecoClient(BaseClient): def __init__(self, api_token: str, base_url: str, verify: bool, proxy): super().__init__( base_url, verify=verify, proxy=proxy, headers={ "Authorization": f"Bearer {api_token}", "User-Agent": f"xsoar/{get_pack_version()}", }, ) # --- 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, sort_by: str = "", sort_order: str = "", ) -> dict[str, Any]: """GET /external-api/{endpoint} with SCIM filter, sort, and pagination params.""" params: dict[str, Any] = {"count": count, "startIndex": start_index} if filters: params["filters"] = filters if sort_by: params["sortBy"] = sort_by if sort_order: params["sortOrder"] = sort_order 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_levels: list[str] | None = None, source: str | None = None, before: datetime | None = None, after: datetime | None = None, limit: int = PAGE_SIZE, start_index: int = 0, ) -> list[dict[str, Any]]: """List threat alerts via the external API, oldest first. risk_levels: list of severity names, e.g. ["HIGH", "CRITICAL"]. Multiple values are combined with OR so a single call fetches all matching severities. Sorted ascending by createdAt (rather than the API's default descending order) so that fetch_incidents can page through a burst via `start_index` without ever skipping alerts, regardless of how many share the same fetch window. Preview-state alerts are always excluded: a Policy in Preview state is explicitly meant to stay out of the customer's SOAR/ticketing systems while it's being evaluated (the webhook and share-service paths already enforce this upstream). """ filter_parts: list[str] = ['status ne "ALERT_STATUS_PREVIEW"'] 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: filter_parts.append(f'apps co "{source}"') if after: 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}") try: response = self._external_api_list( "alerts/list", filters=scim_filter, count=limit, start_index=start_index, sort_by="createdAt", sort_order="ascending", ) 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, ) return response.get("alert", {}) except Exception as e: demisto.error(f"get_single_alert({alert_id}) error: {str(e)}") raise 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: return self._http_request( method="GET", url_suffix=f"/alert/summarize/{alert_id}", timeout=RECO_API_TIMEOUT_IN_SECONDS, ) except Exception as e: demisto.error(f"get_alert_ai_summary error: {str(e)}") raise def change_alert_status(self, alert_id: str, status: str) -> Any: """Change alert status (internal API - no external equivalent yet).""" try: return 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 # --- Comments (external API) --- 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: 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, data=json.dumps( { "labelsRelationStatusUpdate": [ { "labelName": label_name, "entryId": f"{entity_id}_visibility", "entryType": ENTRY_TYPE_EVENT, "newStatus": LABEL_STATUS_RESOLVED, "oldStatus": LABEL_STATUS_ACTIVE, "comment": "Resolved by XSOAR Automation", } ] } ), ) except Exception as e: demisto.error(f"resolve_visibility_event error: {str(e)}") raise # --- Identities / Users (external API) --- def get_identities(self, email_address: Optional[str] = None, label: Optional[str] = None) -> list[dict[str, Any]]: """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 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 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 # --- 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: 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"get_app_discovery error: {str(e)}") raise def set_app_authorization_status(self, app_id: str, authorization_status: str) -> Any: """Update app authorization status via the external API.""" try: return self._external_api_put( f"{EXTERNAL_API_BASE}/apps/{app_id}/auth-status", {"authorizationStatus": authorization_status}, ) except Exception as 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 (internal API).""" params: Dict[str, Any] = { "getTableRequest": { "tableName": "DATA_RISK_MANAGEMENT_VIEW_BREAKDOWN_EXPOSED_PUBLICLY", "pageSize": PAGE_SIZE, "fieldSorts": {"sorts": [{"sortBy": "last_access_date", "sortDirection": "SORT_DIRECTION_DESC"}]}, "fieldFilters": { "relationship": "FILTER_RELATIONSHIP_OR", "filters": {"filters": [{"field": "data_category", "stringEquals": {"value": "ALL"}}]}, }, } } try: response = self._http_request( method="PUT", url_suffix="/risk-management/get-data-risk-management-table", timeout=RECO_API_TIMEOUT_IN_SECONDS, data=json.dumps(params), ) return extract_response(response) except Exception as 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 an email account (internal API).""" params = { "getTableRequest": { "tableName": "data_posture_view_files_by_emails_slider", "pageSize": PAGE_SIZE, "fieldSorts": {"sorts": [{"sortBy": "last_access_date", "sortDirection": "SORT_DIRECTION_DESC"}]}, "fieldFilters": { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": { "fieldFilters": [ { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": { "fieldFilters": [ { "relationship": "FILTER_RELATIONSHIP_AND", "filters": { "filters": [ {"field": "email_account", "stringEquals": {"value": f"{email_account}"}} ] }, } ] }, "forceEstimateSize": True, } ] }, "forceEstimateSize": True, }, } } try: response = self._http_request( method="PUT", url_suffix="/risk-management/get-data-risk-management-table", timeout=RECO_API_TIMEOUT_IN_SECONDS, data=json.dumps(params), ) return extract_response(response) except Exception as 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 private email addresses with file access (internal API).""" params = { "getTableRequest": { "tableName": "data_posture_view_private_email_with_access", "pageSize": PAGE_SIZE, "fieldSorts": {"sorts": [{"sortBy": "files_num", "sortDirection": "SORT_DIRECTION_DESC"}]}, "fieldFilters": { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": {"fieldFilters": []}, "forceEstimateSize": True, }, } } try: response = self._http_request( method="PUT", url_suffix="/risk-management/get-data-risk-management-table", timeout=RECO_API_TIMEOUT_IN_SECONDS, data=json.dumps(params), ) return extract_response(response) except Exception as 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": { "tableName": "data_posture_view_3rd_parties_domain", "pageSize": PAGE_SIZE, "fieldSorts": {"sorts": [{"sortBy": "files_num", "sortDirection": "SORT_DIRECTION_DESC"}]}, "fieldFilters": { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": { "fieldFilters": [ { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": { "fieldFilters": [ { "relationship": "FILTER_RELATIONSHIP_AND", "filters": { "filters": [{"field": "last_activity", "before": {"value": f"{formatted_date}"}}] }, } ] }, } ] }, "forceEstimateSize": True, }, } } try: response = self._http_request( method="PUT", url_suffix="/risk-management/get-data-risk-management-table", timeout=RECO_API_TIMEOUT_IN_SECONDS, data=json.dumps(params), ) return extract_response(response) except Exception as e: 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 a specific 3rd party domain (internal API).""" formatted_date = self.get_date_time_before_days_formatted(last_interaction_time_before_in_days) params = { "getTableRequest": { "tableName": "data_posture_view_files_by_domain_slider", "pageSize": PAGE_SIZE, "fieldSorts": {"sorts": [{"sortBy": "last_access_date", "sortDirection": "SORT_DIRECTION_ASC"}]}, "fieldFilters": { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": { "fieldFilters": [ { "relationship": "FILTER_RELATIONSHIP_AND", "filters": { "filters": [ {"field": "domain", "regexCaseInsensitive": {"value": f"{domain}"}}, {"field": "last_access_date", "before": {"value": f"{formatted_date}"}}, ] }, } ] }, "forceEstimateSize": True, }, } } try: response = self._http_request( method="PUT", url_suffix="/risk-management/get-data-risk-management-table", timeout=RECO_API_TIMEOUT_IN_SECONDS, data=json.dumps(params), ) return extract_response(response) except Exception as 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 a user has access to (internal API).""" 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": "currently_permitted_users", "regexCaseInsensitive": {"value": email_address}, } ] }, } ] }, }, } } if only_sensitive is True: 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"get_assets_user_has_access error: {str(e)}") raise def get_assets_shared_externally(self, email_address: str) -> list[dict[str, Any]]: """Get assets an owner has shared externally (internal API).""" params: dict[str, Any] = { "getTableRequest": { "tableName": "files_view", "pageSize": PAGE_SIZE, "fieldSorts": {"sorts": [{"sortBy": "last_access_date", "sortDirection": "SORT_DIRECTION_DESC"}]}, "fieldFilters": { "relationship": "FILTER_RELATIONSHIP_AND", "fieldFilterGroups": { "fieldFilters": [ { "relationship": "FILTER_RELATIONSHIP_OR", "filters": { "filters": [ { "field": "permission_visibility", "stringEquals": {"value": "PERMISSION_TYPE_SHARED_EXTERNALLY"}, } ] }, }, { "relationship": "FILTER_RELATIONSHIP_OR", "filters": { "filters": [{"field": "file_owner", "stringContains": {"value": f"{email_address}"}}] }, }, ] }, "forceEstimateSize": True, }, } } 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"get_assets_shared_externally error: {str(e)}") raise def get_link_to_user_overview_page(self, link_type: str, entity_id: str) -> str: """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, ) link = response.get("link", None) except Exception as e: 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: 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), ) except Exception as e: demisto.error(f"add_exclusion_filter error: {str(e)}") raise # --- New external API list methods --- 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 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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 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) 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) 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) # --- API key validation --- def validate_api_key(self) -> str: """Validate the API token by making a minimal external API call.""" try: self._external_api_list("alerts/list", count=1) return "ok" except Exception as e: demisto.error(f"validate_api_key error: {str(e)}") raise # --- Helpers --- def add_legacy_output_aliases(items: list[dict[str, Any]], aliases: dict[str, str]) -> list[dict[str, Any]]: """Copy renamed External API fields back onto their pre-migration key names, in place. Restores backward compatibility for playbooks/automations built against the old internal-API field names, alongside the new camelCase External API fields. """ for item in items: for old_key, new_key in aliases.items(): if new_key in item: item[old_key] = item[new_key] return items 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 or value is None: continue obj[key] = base64.b64decode(value).decode("utf-8") if key != "labels": obj[key] = obj[key].replace('"', "") if key in ["updated_at", "created_at", "event_time"]: try: parsed_time = datetime.strptime(obj[key], RECO_TIME_FORMAT) except Exception: parsed_time = datetime.strptime(obj[key], DEMISTO_OCCURRED_FORMAT) if parsed_time: obj[key] = parsed_time.strftime(DEMISTO_OCCURRED_FORMAT) if key == "risk_level": try: obj[key] = int(obj[key]) except Exception: 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_levels: list[str] | None = None, source: str | None = None, before: datetime | None = None, after: datetime | None = None, limit: int = PAGE_SIZE, start_index: int = 0, ) -> tuple[list[Any], int]: """Fetch alert summaries then enrich each with full detail (policy violations). Returns the enriched ThreatAlertDetail dicts alongside the raw page size, so callers can tell whether the page was truncated by `limit` (more alerts may remain). """ raw_alerts = reco_client.get_alerts(risk_levels, source, before, after, limit, start_index) alerts_data: list[Any] = [] 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, len(raw_alerts) # --- Score mapping --- def map_reco_score_to_demisto_score(reco_score: int) -> int | float: demisto_informational = 0.5 demisto_medium = 2 demisto_high = 3 demisto_critical = 4 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: demisto_informational = 0.5 demisto_medium = 2 demisto_high = 3 demisto_critical = 4 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 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): 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: return map_reco_score_to_demisto_score(min(40, max(10, int(raw_risk)))) except (TypeError, ValueError): pass try: 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) # --- 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 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) 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) start_index = last_run.get("startIndex", 0) alerts, raw_page_size = get_alerts(reco_client, risk_levels, source, before, after, max_fetch, start_index) 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) ] if raw_page_size < max_fetch: # The window is fully drained - safe to advance the timestamp cursor. 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["startIndex"] = 0 else: # The page was full - more alerts may remain in this window. Keep lastRun in place # and resume from start_index next cycle, so a burst larger than max_fetch is # drained over multiple cycles instead of the excess being permanently skipped. next_run["lastRun"] = last_run_time next_run["startIndex"] = start_index + raw_page_size 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: assets = reco_client.get_assets_user_has_access(email_address, only_sensitive) 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"], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_value", outputs=assets_list, raw_response=assets, ) def get_sensitive_assets_shared_with_public_link(reco_client: RecoClient) -> CommandResults: assets = reco_client.get_exposed_publicly_files_at_risk() 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"], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_id", outputs=assets_list, raw_response=assets, ) def get_assets_shared_externally_command(reco_client: RecoClient, email_address: str) -> CommandResults: assets = reco_client.get_assets_shared_externally(email_address) 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", "file_owner", ], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_id", outputs=assets_list, raw_response=assets, ) def get_files_exposed_to_email_command(reco_client: RecoClient, email_account: str) -> CommandResults: assets = reco_client.get_files_exposed_to_email(email_account) 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", "email_account", "file_owner", ], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_id", outputs=assets_list, raw_response=assets, ) def get_3rd_parties_list(reco_client: RecoClient, last_interaction_time_in_days: int) -> CommandResults: domains = reco_client.get_3rd_parties_risk_list(last_interaction_time_in_days) 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"], ), outputs_prefix="Reco.Domains", outputs_key_field="domain", outputs=domains_list, raw_response=domains, ) def get_files_shared_with_3rd_parties( reco_client: RecoClient, domain: str, last_interaction_time_before_in_days: int ) -> CommandResults: files = reco_client.get_files_shared_with_3rd_parties(domain, last_interaction_time_before_in_days) 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"], ), outputs_prefix="Reco.Assets", outputs_key_field="asset_id", outputs=files_list, raw_response=files, ) LEGACY_ASSET_FIELD_ALIASES = { "file_name": "name", "file_owner": "owner", "file_url": "url", "sensitivity_level": "sensitivityLevel", "visibility": "permissionVisibility", } def assets_to_command_result(files: list[dict[str, Any]]) -> CommandResults: """Convert File objects (external API) to CommandResults for asset commands.""" add_legacy_output_aliases(files, LEGACY_ASSET_FIELD_ALIASES) 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: 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: files = reco_client.get_sensitive_assets_information(None, asset_id, False, False) return assets_to_command_result(files) 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) LEGACY_USER_FIELD_ALIASES = { "email_account": "email", "full_name": "name", "job_titles": "jobTitles", } 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 if user_data: add_legacy_output_aliases([user_data], LEGACY_USER_FIELD_ALIASES) # "category" has no direct External API field; approximate from isInternal, matching # the old values ("internal"/"external"). "groups" and "labels" have no equivalent # data in the External API and are not populated. if "isInternal" in user_data: user_data["category"] = "internal" if user_data["isInternal"] else "external" return CommandResults( readable_output=tableToMarkdown("User", user_data, headers=list(user_data.keys()) if user_data else []), outputs_prefix="Reco.User", 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 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 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 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( "PrivateEmails", identities_list, headers=["email_account", "primary_email", "files_num", "user_category"], ), outputs_prefix="Reco.privateEmails", outputs_key_field="email_account", outputs=identities_list, raw_response=result, ) 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) LEGACY_APP_FIELD_ALIASES = { "app_id": "id", "app_name": "name", "users_count": "usersCount", "created_at": "firstSeen", } 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) # "risk_score", "data_access", "status", and "updated_at" have no equivalent field in the # External API and are not populated. add_legacy_output_aliases(apps, LEGACY_APP_FIELD_ALIASES) 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, ) 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}, ) # --- New command functions (external API) --- 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 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 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( "Accounts", accounts, headers=["id", "name", "accountEmail", "permissions", "hasMfa", "openAlerts", "isAdmin", "isRiskyUser", "lastSeen"], ), outputs_prefix="Reco.Accounts", outputs_key_field="id", outputs=accounts, raw_response=response, ) 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, ) 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( "AI Agents", agents, headers=["id", "name", "vendor", "type", "authorization", "agentStatus", "risk", "lastUsage"], ), outputs_prefix="Reco.AiAgents", outputs_key_field="id", outputs=agents, raw_response=response, ) 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, ) 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, ) 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, ) 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) proxy = params.get("proxy", False) if not api_url: raise ValueError("Server URL is required") if not api_token: raise ValueError("API Token is required") max_fetch = get_max_fetch(int(params.get("max_fetch", "200"))) reco_client = RecoClient( api_token=api_token, base_url=api_url, verify=verify_certificate, proxy=proxy, ) if command == "fetch-incidents": # 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: datetime | None = None if arg := params.get("first_fetch"): 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_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 = args["alert_id"] response = reco_client.update_reco_incident_timeline( incident_id=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=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 = 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")) elif command == "test-module": return_results(reco_client.validate_api_key()) elif command == "reco-get-risky-users": return_results(get_risky_users_from_reco(reco_client)) elif command == "reco-add-risky-user-label": return_results(add_risky_user_label(reco_client, args["email_address"])) elif command == "reco-add-leaving-org-user-label": return_results(add_leaving_org_user(reco_client, args["email_address"])) elif command == "reco-get-assets-user-has-access-to": return_results( get_assets_user_has_access( reco_client, args["email_address"], args.get("only_sensitive", False), ) ) elif command == "reco-get-sensitive-assets-by-name": return_results( get_sensitive_assets_by_name( reco_client, args["asset_name"], args.get("regex_search", False), ) ) elif command == "reco-get-sensitive-assets-by-id": return_results(get_sensitive_assets_by_id(reco_client, args["asset_id"])) elif command == "reco-get-link-to-user-overview-page": return_results(get_link_to_user_overview_page(reco_client, args["entity"], args["param"])) elif command == "reco-get-sensitive-assets-with-public-link": return_results(get_sensitive_assets_shared_with_public_link(reco_client)) elif command == "reco-get-3rd-parties-accessible-to-data-list": return_results(get_3rd_parties_list(reco_client, int(args["last_interaction_time_in_days"]))) elif command == "reco-get-files-shared-with-3rd-parties": return_results( get_files_shared_with_3rd_parties( reco_client, args["domain"], int(args["last_interaction_time_in_days"]), ) ) elif command == "reco-add-exclusion-filter": return_results(add_exclusion_filter(reco_client, args["key_to_add"], argToList(args["values_to_add"]))) elif command == "reco-change-alert-status": return_results(change_alert_status(reco_client, args["alert_id"], args["status"])) elif command == "reco-get-user-context-by-email-address": return_results(get_user_context_by_email_address(reco_client, args["email_address"])) elif command == "reco-get-files-exposed-to-email-address": return_results(get_files_exposed_to_email_command(reco_client, args["email_address"])) elif command == "reco-get-assets-shared-externally": return_results(get_assets_shared_externally_command(reco_client, args["email_address"])) elif command == "reco-get-private-email-list-with-access": return_results(get_private_email_list_with_access(reco_client)) elif command == "reco-get-assets-by-id": return_results(get_assets_by_id(reco_client, args["asset_id"])) elif command == "reco-get-alert-ai-summary": return_results(get_alert_ai_summary(reco_client, args.get("alert_id", ""))) elif command == "reco-get-apps": 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": 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)) if __name__ in ("__main__", "__builtin__", "builtins"): main()