SaasSecurity

SaaS Security API is a cloud-based service that you can connect directly to your sanctioned SaaS applications using the cloud app’s API to provide data classification, sharing and permission visibility, and threat detection. This Content Pack provides insights into risks posed by data exposure and policy violations and enables you to use Cortex XSOAR to effectively manage the incidents discovered by SaaS Security API.

Network Security · SaaS Security by Palo Alto Networks

Details

IDSaasSecurity
ProviderPalo Alto Networks
CategoryNetwork Security
From Version6.0.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM EDR Cloud Runtime Security

README

Use the SaaS Security integration to protect against cloud‑based threats by:

  • Scanning and analyzing all your assets.
  • Applying Security policy to identify exposures, external collaborators, risky user behavior, and sensitive documents.
  • Identifying the potential risks associated with each asset.

Configure SaaS Security on Cortex XSOAR

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for SaaS Security.
  3. Click Add instance to create and configure a new integration instance.

    Parameter Description Required
    Server URL The instance configuration URL based on the server location: https://api.aperture.paloaltonetworks.com (US)
    https://api.aperture-eu.paloaltonetworks.com (EU)
    https://api.aperture-apac.paloaltonetworks.com (APAC)
    True
    Client ID The SaaS Security Client ID. See instructions below. True
    Client Secret The SaaS Security Client Secret. See instructions below. True
    Fetch incidents If selected, fetches incidents from SaaS Security. False
    Incidents Fetch Interval Frequency (in hours and minutes) by which Cortex XSOAR fetches incidents from SaaS Security when Fetch Incidents is selected. False
    Incident type Incident type is set by this field if a classifier does not exist. If a classifier is selected, it takes precedence. False
    Incident Mirroring Direction Selects which direction you want the incidents mirrored. You can mirror Incoming only (from SaaS Security to Cortex XSOAR), Outgoing only (from Cortex XSOAR to SaaS Security), or both Incoming And Outgoing. False
    Number of incidents per fetch Important: The limit value can range from 10 to 200 and must be in multiples of 10. True
    First fetch timestamp (<number> <time unit>. For example, 12 hours, 7 days)   False
    Fetch only incidents with matching state Fetches only incidents with matching All, Closed, or Open state. If nothing is selected, All states will be used. False
    Fetch only incidents with matching severity If nothing is selected, All severities will be used. False
    Fetch only incidents with matching status If nothing is selected, All statuses will be used. False
    Fetch only incidents with matching Application IDs A comma-separated list of Application IDs. Run the saas-security-get-apps command to return the Application ID, Name, and Type for all applications. False
    Close Mirrored XSOAR Incident If selected, when the incident closes on SaaS Security, the incident closes in Cortex XSOAR. False
    Trust any certificate (not secure) By default, SSL verification is enabled. If selected, the connection isn’t secure and all requests return an SSL error because the certificate cannot be verified. False
    Use system proxy settings Uses the system proxy server to communicate with the integration. If not selected, the integration will not use the system proxy server. False
  4. Click Test to validate the URLs, token, and connection.

Configure SaaS Security Incident Mirroring

You can enable incident mirroring between Cortex XSOAR incidents and SaaS Security notables (available from Cortex XSOAR version 6.0.0).
To set up mirroring.

To configure mirroring:

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for SaaS Security and select your integration instance.
  3. Enable Fetches incidents.
  4. In the Incident Mirroring Direction integration parameter, select which direction you want the incidents to be mirrored:
    • Incoming — Any changes in the following SaaS Security incidents fields (state, category, status, assigned_to, resolved_by, asset_sha256) will be reflected in Cortex XSOAR incidents.
    • Outgoing — Any changes in the following Cortex XSOAR incidents fields (state, category) will be reflected in SaaS Security incidents.
    • Incoming And Outgoing (Recommended) — Changes in Cortex XSOAR incidents and SaaS Security incidents will be reflected in both directions.
    • None — Turns off incident mirroring.
  5. (Recommended) Select the Close Mirrored XSOAR Incident integration parameter to close the Cortex XSOAR incident when the corresponding incident is closed on SaaS Security.
    • There is no closing parameter for the opposite direction (to close incidents in SaaS Security when they are closed in XSOAR). Close Mirrored XSOAR Incident is the only use case available for mirrored out, when the state and category are updated.
      Newly fetched incidents will be mirrored in the direction you select. However, this selection does not affect existing incidents.

Important Notes

  • For mirroring to work, the Incident Mirroring Direction parameter needs to be set before the incident is fetched.
  • To ensure mirroring works as expected, mappers are required for both Incoming and Outgoing to map the expected fields in Cortex XSOAR and SaaS Security.
  • The only fields that can be mirrored in from SaaS Security to Cortex XSOAR are:
    • state
    • category
    • status
    • assigned_to
    • resolved_by
    • asset_sha256
  • The only fields that can be mirrored out from XSOAR to SaaS Security are:
    • state
    • category The supported categories for closing incidents are: “misidentified”, “no_reason”, and “business_justified”.
      Note: Mirroring out works only for closed incidents due to an API limitation.

Create the Client ID and Client Secret on SaaS Security

In the SaaS Security UI, do the following:

  1. Navigate to Settings > External Service.
  2. Click Add API Client.
  3. Specify a unique name for the API client.
  4. Authorize the API client for the required scopes. You use these scopes in the POST request to the /oauth/token endpoint. The Required Scopes are:
    • Log access — Access log files. You can either provide the client log access API or add a syslog receiver.
    • Incident management — Retrieve and change the incident status.
    • Quarantine management — Quarantine assets and restore quarantined assets.
  5. Copy the client ID and client secret.

    Tip: Record your API client secret somewhere safe. For security purposes, it’s only shown when you create or reset the API client. If you lose your secret you must reset it, which removes access for any integrations that still use the previous secret.
  6. Add the Client ID and Client Secret to Cortex XSOAR.

    Note: For more information see the SaaS Security Administrator’s Guide

Commands

You can execute these commands from the Cortex XSOAR CLI as part of an automation or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

saas-security-incidents-get


Retrieves incidents from the SaaS Security platform.

Base Command

saas-security-incidents-get

Input

Argument Name Description Required
limit The number of incidents to pull. Important: The limit value can range from 10 to 200 and must be in multiples of 10. Default is 50. Optional
from The start time of the query, filtered by the date the incident was updated,\ \ For example, 2021-08-23T09:26:25.872Z. Optional
to The end time of the query, filtered by the date the incident was updated. For example, 2021-08-23T09:26:25.872Z. Optional
app_ids Comma-separated list of application IDs. Run the ‘saas-security-get-apps’ command to return the Application ID, Name, and Type for all applications. Optional
state The state of the incidents. If empty, retrieves all states. Possible values: “All”, “Open”, and “Closed”. Possible values are: All, Open, Closed. Default is open. Optional
severity The severity of the incidents. If none is selected, all severities will be pulled. Possible values: “1.0”, “2.0”, “3.0”, “4.0”, and “5.0”. Optional
status The status of the incidents. Possible values: “New”, “Assigned”, “In Progress”, “Pending”, “No Reason”, “Business Justified”, “Misidentified”, “In The Cloud”, and “Dismiss”. Possible values are: New, Assigned, In Progress, Pending, No Reason, Business Justified, Misidentified, In The Cloud, Dismiss. Optional
next_page Get the next batch of incidents. No other argument is needed when providing this. Optional

Context Output

Path Type Description
SaasSecurity.Incident.incident_id Number The incident ID.
SaasSecurity.Incident.tenant String The tenant associated with the incident.
SaasSecurity.Incident.app_id String The application ID.
SaasSecurity.Incident.app_name String The application name.
SaasSecurity.Incident.app_type String The application type.
SaasSecurity.Incident.cloud_id String The cloud ID.
SaasSecurity.Incident.asset_name String The asset name.
SaasSecurity.Incident.asset_sha256 String The SHA256 hash value of the asset.
SaasSecurity.Incident.asset_id String The asset ID.
SaasSecurity.Incident.asset_page_uri String The asset page URI.
SaasSecurity.Incident.asset_cloud_uri String The asset cloud URI.
SaasSecurity.Incident.exposure_type Number The exposure type (Internal/External).
SaasSecurity.Incident.exposure_level String The exposure level.
SaasSecurity.Incident.policy_id String The policy ID.
SaasSecurity.Incident.policy_name String The policy name.
SaasSecurity.Incident.policy_version Number The policy version.
SaasSecurity.Incident.policy_page_uri String The policy page URI.
SaasSecurity.Incident.severity String The severity of the incident.
SaasSecurity.Incident.status String The incident status.
SaasSecurity.Incident.state String The incident state.
SaasSecurity.Incident.category String The incident category.
SaasSecurity.Incident.resolved_by String The name of the user who resolved the incident.
SaasSecurity.Incident.resolution_date Date The date the incident was resolved.
SaasSecurity.Incident.created_at Date The date the incident was created, e.g., `2021-08-23T09:26:25.872Z`.
SaasSecurity.Incident.updated_at Date The Date the incident was last updated. e.g., `2021-08-24T09:26:25.872Z`.
SaasSecurity.Incident.asset_owner_id String The ID of the asset owner.
SaasSecurity.Incident.asset_owner_name String The name of the asset owner.
SaasSecurity.Incident.asset_owner_email String The email address of the asset owner.
SaasSecurity.NextResultsPage String The URI for the next batch of incidents.

Command Example

!saas-security-incidents-get limit=11 app_ids=acf49b2389c09f26ad0ccd2b1a603328 from=2021-08-23T20:25:17.495Z state=open

Context Example

{
    "SaasSecurity": {
        "Incident": [
            {
                "app_id": "acf49b2389c09f26ad0ccd2b1a603328",
                "app_name": "Box 1",
                "app_type": "box",
                "asset_cloud_uri": "https://www.box.com/files/0/f/114948778953/1/f_675197457403",
                "asset_id": "61099dc26b544e38fa3ce06d",
                "asset_name": "SP0605 copy 6.java",
                "asset_owner_email": "xsoartest@cirrotester.com",
                "asset_owner_id": "22FD054D362DC548A9C22F25782E1DAEED03C12F3898CD0F2E2A1B4CF728D04BD644B3CC010FDAC3D10EC0D408F4F79AC147E3D56415D1052BCFCD899A8E249F",
                "asset_owner_name": "Xsoar test",
                "asset_page_uri": "https://xsoartest.staging.cirrotester.com/cloud_assets/61099dc26b544e38fa3ce06d",
                "asset_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
                "category": "business_justified",
                "cloud_id": "675197457403",
                "collaborators": [],
                "created_at": "2021-08-03T20:25:15.417Z",
                "data_patterns": [],
                "exposure_level": "internal",
                "exposure_type": 8,
                "group_ids": [],
                "incident_id": 4,
                "policy_id": "6109a5d0e64152534b240f48",
                "policy_page_uri": "https://xsoartest.staging.cirrotester.com/data_policies/6109a5d0e64152534b240f48",
                "policy_version": 1,
                "policy_name": "policy name",
                "resolution_date": "2021-08-24T07:44:21.608Z",
                "resolved_by": "api",
                "severity": "Low",
                "state": "closed",
                "status": "Closed-Business Justified",
                "tenant": "xsoartest",
                "updated_at": "2021-08-24T07:44:21.608Z"
            },
            {
                "app_id": "acf49b2389c09f26ad0ccd2b1a603328",
                "app_name": "Box 1",
                "app_type": "box",
                "asset_cloud_uri": "https://www.box.com/files/0/f/114948778953/1/f_675197556380",
                "asset_id": "61099dbe6b544e38fa3cc9b8",
                "asset_name": "SP0605 copy 2.java",
                "asset_owner_email": "xsoartest@cirrotester.com",
                "asset_owner_id": "22FD054D362DC548A9C22F25782E1DAEED03C12F3898CD0F2E2A1B4CF728D04BD644B3CC010FDAC3D10EC0D408F4F79AC147E3D56415D1052BCFCD899A8E249F",
                "asset_owner_name": "Xsoar test",
                "asset_page_uri": "https://xsoartest.staging.cirrotester.com/cloud_assets/61099dbe6b544e38fa3cc9b8",
                "asset_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
                "category": "business_justified",
                "cloud_id": "675197556380",
                "collaborators": [],
                "created_at": "2021-08-03T20:25:12.000Z",
                "data_patterns": [],
                "exposure_level": "internal",
                "exposure_type": 8,
                "group_ids": [],
                "incident_id": 1,
                "policy_id": "6109a5d0e64152534b240f48",
                "policy_page_uri": "https://xsoartest.staging.cirrotester.com/data_policies/6109a5d0e64152534b240f48",
                "policy_version": 1,
                "resolution_date": "2021-08-24T08:19:57.429Z",
                "resolved_by": "api",
                "severity": "Low",
                "status": "Closed-Business Justified",
                "tenant": "xsoartest",
                "updated_at": "2021-08-24T08:19:57.429Z"
            }
        ]
    }
}

Human Readable Output

Incidents

Incident Id App Id App Name Asset Name Exposure Level Severity Category Created At Updated At
4 acf49b2389c09f26ad0ccd2b1a603328 Box 1 SP0605 copy 6.java internal Low business_justified 2021-08-03T20:25:15.417Z 2021-08-24T07:44:21.608Z
1 acf49b2389c09f26ad0ccd2b1a603328 Box 1 SP0605 copy 2.java internal Low business_justified 2021-08-03T20:25:12.000Z 2021-08-24T08:19:57.429Z
5 acf49b2389c09f26ad0ccd2b1a603328 Box 1 SP0605 copy 7.java internal Low aperture 2021-08-03T20:25:16.842Z 2021-08-24T17:08:51.022Z
8 acf49b2389c09f26ad0ccd2b1a603328 Box 1 ml_file.java internal Low aperture 2021-08-03T20:25:17.043Z 2021-08-24T17:10:37.433Z
3 acf49b2389c09f26ad0ccd2b1a603328 Box 1 SP0605 copy 5.java internal Low misidentified 2021-08-03T20:25:13.770Z 2021-08-25T14:29:42.288Z

saas-security-incident-get-by-id


Gets an incident by its ID.

Base Command

saas-security-incident-get-by-id

Input

Argument Name Description Required
id The incident ID. Required

Context Output

Path Type Description
SaasSecurity.Incident.incident_id Number The Incident ID.
SaasSecurity.Incident.tenant String The tenant associated with the incident.
SaasSecurity.Incident.app_id String The application ID.
SaasSecurity.Incident.app_name String The application name.
SaasSecurity.Incident.app_type String The application type.
SaasSecurity.Incident.cloud_id String The cloud ID.
SaasSecurity.Incident.asset_name String The asset name.
SaasSecurity.Incident.asset_sha256 String The SHA256 hash value of the asset.
SaasSecurity.Incident.asset_id String The asset ID.
SaasSecurity.Incident.asset_page_uri String The asset page URI.
SaasSecurity.Incident.asset_cloud_uri String The asset cloud URI.
SaasSecurity.Incident.exposure_type Number The exposure type (Internal/External).
SaasSecurity.Incident.exposure_level String The exposure level.
SaasSecurity.Incident.policy_id String The policy ID.
SaasSecurity.Incident.policy_name String The policy name.
SaasSecurity.Incident.policy_version Number The policy version.
SaasSecurity.Incident.policy_page_uri String The policy page URI.
SaasSecurity.Incident.severity String The severity of the incident.
SaasSecurity.Incident.status String The incident status.
SaasSecurity.Incident.state String The incident state.
SaasSecurity.Incident.category String The incident category.
SaasSecurity.Incident.resolved_by String The name of the user who resolved the incident.
SaasSecurity.Incident.resolution_date Date The date the incident was resolved.
SaasSecurity.Incident.created_at Date The date the incident was created, e.g., `2021-08-23T09:26:25.872Z`.
SaasSecurity.Incident.updated_at Date The date the incident was last updated, e.g., `2021-08-24T09:26:25.872Z`.
SaasSecurity.Incident.asset_owner_id String The ID of the asset owner.
SaasSecurity.Incident.asset_owner_name String The name of the asset owner.
SaasSecurity.Incident.asset_owner_email String The email address of the asset owner.

Command Example

!saas-security-incident-get-by-id id=4

Context Example

{
    "SaasSecurity": {
        "Incident": {
            "app_id": "acf49b2389c09f26ad0ccd2b1a603328",
            "app_name": "Box 1",
            "app_type": "box",
            "asset_cloud_uri": "https://www.box.com/files/0/f/114948778953/1/f_675197457403",
            "asset_id": "61099dc26b544e38fa3ce06d",
            "asset_name": "SP0605 copy 6.java",
            "asset_owner_email": "xsoartest@cirrotester.com",
            "asset_owner_id": "22FD054D362DC548A9C22F25782E1DAEED03C12F3898CD0F2E2A1B4CF728D04BD644B3CC010FDAC3D10EC0D408F4F79AC147E3D56415D1052BCFCD899A8E249F",
            "asset_owner_name": "Xsoar test",
            "asset_page_uri": "https://xsoartest.staging.cirrotester.com/cloud_assets/61099dc26b544e38fa3ce06d",
            "asset_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
            "category": "business_justified",
            "cloud_id": "675197457403",
            "collaborators": [],
            "created_at": "2021-08-03T20:25:15.417Z",
            "data_patterns": [],
            "exposure_level": "internal",
            "exposure_type": 8,
            "group_ids": [],
            "incident_id": 4,
            "policy_id": "6109a5d0e64152534b240f48",
            "policy_page_uri": "https://xsoartest.staging.cirrotester.com/data_policies/6109a5d0e64152534b240f48",
            "policy_version": 1,
            "resolution_date": "2021-08-26T07:04:14.598Z",
            "resolved_by": "api",
            "severity": "Low",
            "state": "closed",
            "tenant": "xsoartest",
            "updated_at": "2021-08-26T07:04:14.598Z"
        }
    }
}

Human Readable Output

Incident 4 details

Incident Id App Id App Name Asset Name Exposure Level Severity State Category Created At Updated At
4 acf49b2389c09f26ad0ccd2b1a603328 Box 1 SP0605 copy 6.java internal 1.0 closed business_justified 2021-08-03T20:25:15.417Z 2021-08-26T07:04:14.598Z

saas-security-incident-state-update


Closes an incident and updates its category.

Base Command

saas-security-incident-state-update

Input

Argument Name Description Required
id The incident ID. Required
category The reason for closing the incident. Possible values: “Misidentified”, “No Reason”, and “Business Justified”. Possible values are: Misidentified, No Reason, Business Justified. Default is Reason for state update.. Required

Context Output

Path Type Description
SaasSecurity.IncidentState.incident_id String The incident ID.
SaasSecurity.IncidentState.state String The incident state (open/closed).
SaasSecurity.IncidentState.category String The incident category.
SaasSecurity.IncidentState.resolved_by String The name of the user who resolved the incident.
SaasSecurity.IncidentState.resolution_date Date The date when the incident was resolved.

Command Example

!saas-security-incident-state-update category="Business Justified" id=4

Context Example

{
    "SaasSecurity": {
        "IncidentState": {
            "category": "business_justified",
            "incident_id": "4",
            "resolution_date": "2021-08-26T07:04:14.598Z",
            "resolved_by": "api",
            "state": "closed"
        }
    }
}

Human Readable Output

Incident 4 status details

Category Incident Id Resolution Date Resolved By State
business_justified 4 2021-08-26T07:04:14.598Z api closed

saas-security-get-apps


Returns the Application ID, Name, and Type for all applications.

Base Command

saas-security-get-apps

Input

No inputs.

Context Output

Path Type Description
SaasSecurity.App.app_name String The application name.
SaasSecurity.App.app_id String The application ID.
SaasSecurity.App.app_type String The application type.

Command Example


#### Context Example

```json
{
    "SaasSecurity": {
        "App": [
            {
                "app_id": "acf49b2389c09f26ad0ccd2b1a603328",
                "app_name": "Box 1",
                "app_type": "box"
            },
            {
                "app_id": "2642aaa03dc6fc44496bdfffe5e1bc74",
                "app_name": "Office 365 1",
                "app_type": "office365"
            }
        ]
    }
}

Human Readable Output

Apps Info

App Id App Name App Type
acf49b2389c09f26ad0ccd2b1a603328 Box 1 box
2642aaa03dc6fc44496bdfffe5e1bc74 Office 365 1 office365

saas-security-asset-remediate


Remediates an asset.

Base Command

saas-security-asset-remediate

Input

Argument Name Description Required
asset_id The ID of the asset to remediate. Required
remediation_type The remediation action to take. Possible values: “Remove public sharing”(only for Office365, Dropbox, Box, Google Drive apps), “Quarantine”, and “Restore”. Possible values are: Remove public sharing, Quarantine, Restore. Required
remove_inherited_sharing Used when the remediation type is “Remove public sharing”. When set to true, all the parent folders with a shared URL will be removed. Possible values are: true, false. Default is false. Optional

Context Output

Path Type Description
SaasSecurity.Remediation.asset_id String The asset ID.
SaasSecurity.Remediation.remediation_type String The remediation type.
SaasSecurity.Remediation.status String The remediation action status.

Command Example

!saas-security-asset-remediate asset_id=61099dc46b544e38fa3ce89a remediation_type=Quarantine

Context Example

{
    "SaasSecurity": {
        "Remediation": {
            "asset_id": "61099dc46b544e38fa3ce89a",
            "remediation_type": "system_quarantine",
            "status": "pending"
        }
    }
}

Human Readable Output

Remediation details for asset: 61099dc46b544e38fa3ce89a

Asset Id Remediation Type Status
61099dc46b544e38fa3ce89a system_quarantine pending

saas-security-remediation-status-get


Gets the remediation status for a given asset ID.

Base Command

saas-security-remediation-status-get

Input

Argument Name Description Required
asset_id The asset ID. Required
remediation_type The remediation action that was taken. Possible values: “Remove public sharing”(only for Office365, Dropbox, Box, Google Drive apps), “Quarantine”, and “Restore”. Possible values are: Remove public sharing, Quarantine, Restore. Required

Context Output

Path Type Description
SaasSecurity.Remediation.asset_id String The asset ID.
SaasSecurity.Remediation.asset_name String The asset name.
SaasSecurity.Remediation.remediation_type String The remediation type.
SaasSecurity.Remediation.action_taker String The source of the remediation action. For example, ‘api’.
SaasSecurity.Remediation.action_date Date The date when the remediation action was taken.
SaasSecurity.Remediation.status String The remediation action status.

Command Example

!saas-security-remediation-status-get asset_id=61099dc46b544e38fa3ce89a remediation_type=Quarantine

Context Example

{
    "SaasSecurity": {
        "Remediation": {
            "action_date": "2021-08-25T21:18:37.148+0000",
            "action_taker": "api",
            "asset_id": "61099dc46b544e38fa3ce89a",
            "asset_name": "SP0605 copy.java",
            "remediation_type": "system_quarantine",
            "status": "success"
        }
    }
}

Human Readable Output

Asset 61099dc46b544e38fa3ce89a remediation details

Action Date Action Taker Asset Id Asset Name Remediation Type Status
2021-08-25T21:18:37.148+0000 api 61099dc46b544e38fa3ce89a SP0605 copy.java system_quarantine success

Configuration parameters

  • url — Server URL (required)
  • credentials — Client ID (required)
  • isFetch — Fetch incidents
  • incidentFetchInterval — Incidents Fetch Interval
  • incidentType — Incident type
  • mirror_direction — Incident Mirroring Direction
  • max_fetch — Number of incidents per fetch. (required)
  • first_fetch — First fetch timestamp (<number> <time unit>. For example, 12 hours, 7 days)
  • state — Fetch only incidents with matching state
  • severity — Fetch only incidents with matching severity
  • status — Fetch only incidents with matching status
  • app_ids — Fetch only incidents with matching Application IDs
  • close_incident — Close Mirrored XSOAR Incident
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (10)

  • get-mapping-fields

    Returns the list of fields for an incident type.

  • get-modified-remote-data

    Get the list of incidents that were modified since the last update. Note that this method is used for debugging purposes. get-modified-remote-data is used as part of a Mirroring feature, which is available since Cortex XSOAR version 6.1.

  • get-remote-data

    Get remote data from a remote incident. Note that this method will not update the current incident. It's used for debugging purposes.

  • saas-security-asset-remediate

    Remediates an asset.

  • saas-security-get-apps

    Returns the Application ID, Name, and Type for all applications.

  • saas-security-incident-get-by-id

    Gets an incident by its ID.

  • saas-security-incident-state-update

    Closes an incident and updates its category.

  • saas-security-incidents-get

    Retrieves incidents from the SaaS Security platform.

  • saas-security-remediation-status-get

    Gets the remediation status for a given asset ID.

  • update-remote-system

    Updates local incident changes in the remote incident. This method is only used for debugging purposes and will not update the current incident.

import demistomock as demisto
import urllib3
from CommonServerPython import *  # noqa # pylint: disable=unused-wildcard-import

from CommonServerUserPython import *  # noqa

# Disable insecure warnings
urllib3.disable_warnings()  # pylint: disable=no-member

""" CONSTANTS """

DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"  # ISO8601 format with UTC, default in XSOAR
SAAS_SECURITY_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"

SAAS_SECURITY_INCIDENT_TYPE_NAME = "Saas Security Incident"

CLIENT_CREDS = "client_credentials"

# Token life time is 119 minutes
TOKEN_LIFE_TIME = 117

# Actual value is 1000 but we don't want to allow it.
LIMIT_MAX = 200
LIMIT_MIN = 10
LIMIT_DEFAULT = 50

INC_HEADERS_SHORTEN = [
    "incident_id",
    "app_id",
    "app_name",
    "asset_id",
    "asset_name",
    "exposure_level",
    "severity",
    "state",
    "status",
    "category",
    "created_at",
    "updated_at",
    "policy_name",
]

REMEDIATION_MAP = {
    "Remove public sharing": "remove_public_sharing",
    "Quarantine": "system_quarantine",
    "Restore": "system_restore",
}

STATUS_MAP = {
    "New": "open-new",
    "Assigned": "open-assigned",
    "In Progress": "open-in progress",
    "Pending": "open-pending",
    "No Reason": "closed-no reason",
    "Business Justified": "closed-business justified",
    "Misidentified": "closed-misidentified",
    "In The Cloud": "closed-in the cloud",
    "Dismiss": "closed-dismiss",
    "All": "",
}

MIRROR_DIRECTION = {"None": None, "Incoming": "In", "Outgoing": "Out", "Incoming And Outgoing": "Both"}

POSSIBLE_CATEGORIES_TO_MIRROR_OUT = ["no_reason", "business_justified", "misidentified"]

OUTGOING_MIRRORED_FIELDS = ["state", "category"]
INCOMING_MIRRORED_FIELDS = ["state", "category", "status", "assigned_to", "resolved_by", "asset_sha256"]


class Scopes:
    api = "api_access"
    incidents = "incident_api"
    remediation = "remediation_api"


""" CLIENT CLASS """


class Client(BaseClient):
    """Client class to interact with the service API

    This Client implements API calls to the Saas Security platform, and does not contain any XSOAR logic.
    Handles the token retrieval.

    :param base_url (str): Saas Security server url.
    :param client_id (str): client ID.
    :param client_secret (str): client secret.
    :param verify (bool): specifies whether to verify the SSL certificate or not.
    :param proxy (bool): specifies if to use XSOAR proxy settings.
    """

    def __init__(self, base_url: str, client_id: str, client_secret: str, verify: bool, proxy: bool, **kwargs):
        self.client_id = client_id
        self.client_secret = client_secret

        super().__init__(base_url=base_url, verify=verify, proxy=proxy, **kwargs)

    def http_request(self, *args, **kwargs):
        """
        Overrides Base client request function, retrieves and adds to headers access token before sending the request.

        :return: The http response
        """
        token = self.get_access_token()
        headers = {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        }
        try:
            return super()._http_request(*args, headers=headers, **kwargs)  # type: ignore[misc]
        except Exception as e:
            if "401" in str(e) and "Unauthorized" in str(e):
                demisto.debug(f"requesting a new token since {str(e)}")
                token = self.get_access_token(True)
                headers["Authorization"] = f"Bearer {token}"
                return super()._http_request(*args, headers=headers, **kwargs)  # type: ignore[misc]
            else:
                raise e

    def get_access_token(self, force_generate: bool = False):
        """
        Obtains access and refresh token from server.
        Access token is used and stored in the integration context until expiration time.
        After expiration, new refresh token and access token are obtained and stored in the
        integration context.

        :param force_generate: Whether to generate a new token anyway, regardless of the TOKEN_LIFE_TIME.

        :return: Access token that will be added to authorization header.
        :rtype: str
        """
        now = datetime.now()
        integration_context = get_integration_context()
        access_token = integration_context.get("access_token")
        time_issued = integration_context.get("time_issued")

        if access_token and get_passed_mins(now, time_issued) < TOKEN_LIFE_TIME and not force_generate:
            demisto.debug("retrieving the token from the context.")
            return access_token

        # there's no token or it is expired
        demisto.debug(f"There's no token or it is expired, or {force_generate=}")
        access_token = self.get_token_request()
        integration_context = {"access_token": access_token, "time_issued": date_to_timestamp(now) / 1000}
        set_integration_context(integration_context)
        return access_token

    def get_token_request(self):
        """
         Sends request to retrieve token.

        :return: Access token.
        :rtype: str
        """
        base64_encoded_creds = b64_encode(f"{self.client_id}:{self.client_secret}")
        headers = {
            "accept": "application/json",
            "Content-Type": "application/x-www-form-urlencoded; charset=ISO-8859-1",
            "Authorization": f"Basic {base64_encoded_creds}",
        }
        data = {
            "grant_type": CLIENT_CREDS,
            "scope": f"{Scopes.api} {Scopes.incidents} {Scopes.remediation}",
        }
        token_response = self._http_request("POST", url_suffix="/oauth/token", data=data, headers=headers)
        return token_response.get("access_token")

    def get_incidents(
        self,
        limit: int = None,
        from_time: str = None,
        to_time: str = None,
        app_ids: str = None,
        state: str = None,
        severity: list = None,
        status: str = None,
        next_page: str = None,
    ):
        """
        :param limit: The number of incidents to pull per page. Default is 50, max is 1000, min is 10.
        :param from_time: The start time of query, filter by the incident's “updated-at” field.
        :param to_time: The end time of query, filter by the incident's “updated-at” field.
        :param app_ids: List of application id. Comma-separated.
        :param state: The state of the incidents to pull. Default is open.
        :param severity: The severity of the incidents to pull.
        :param status: The status of the incidents to pull.
        :param next_page: For pagination purposes. If provided, params should be None.
        """
        url_suffix = next_page or "/incident/api/incidents/delta"
        state = state if state != "All" else None

        severity_str = ""
        if severity:  # validate that the severities are of type double
            severity_arr_double = [f"{sev}.0" if ".0" not in sev else sev for sev in severity]
            severity_str = ",".join(severity_arr_double)

        params = (
            {
                "limit": limit,
                "from": from_time,
                "to": to_time,
                "app_ids": app_ids,
                "state": state,
                "severities": severity_str,
                "status": status,
            }
            if not next_page
            else {}
        )
        remove_nulls_from_dictionary(params)
        demisto.debug(f"Calling {url_suffix=} with {params=}")

        return self.http_request("GET", url_suffix=url_suffix, params=params)

    def get_incident_by_id(self, inc_id: str):
        """
        :param inc_id: The incident ID.
        """
        return self.http_request("GET", url_suffix=f"/incident/api/incidents/{inc_id}")

    def update_incident_state(self, inc_id: str, category: str):
        """
        :param inc_id: The incident ID.
        :param category: Closing category.
        """
        body = {
            "state": "closed",
            "category": category,
        }
        return self.http_request("POST", url_suffix=f"/incident/api/incidents/{inc_id}/state", json_data=body)

    def get_apps(self):
        return self.http_request("GET", url_suffix="/incident/api/apps")

    def remediate_asset(self, asset_id: str, remediation_type: str, remove_inherited_sharing: bool):
        """
        :param asset_id: The asset ID.
        :param remediation_type: The remediation action to take.
        :param remove_inherited_sharing: Used when remediation type is “remove_public_sharing”,
            when set to true, all the parent folder sharing url will be removed.
        """
        body = assign_params(
            remediation_type=remediation_type,
            remove_inherited_sharing=remove_inherited_sharing,
            asset_id=asset_id,
        )

        self.http_request("POST", url_suffix="/remediation/api/assets", json_data=body, resp_type="response")

    def asset_remediation_status(self, asset_id: str, remediation_type: str):
        """
        :param asset_id: The asset ID.
        :param remediation_type: The remediation action that was taken.
        """

        params = assign_params(
            remediation_type=remediation_type,
            asset_id=asset_id,
        )

        return self.http_request("GET", url_suffix="/remediation/api/assets", params=params)


""" HELPER FUNCTIONS """


def validate_limit(limit: Optional[int]) -> int:
    """
    Validate the limit according to the following rules:

    1. if the limit is 0 or negative, raise an exception.
    2. if the limit is less than 10, the limit will be equal to 10.
    3. if the limit is not dividable by 10, make sure it gets rounded down to a number that is dividable by 10.
    4. if limit > MAX_LIMIT (200) - make sure it will always be MAX_LIMIT (200).
    5. if a limit is not provided, set it up for the default limit which is 50.
    """
    demisto.debug(f"limit before validate: {limit}")
    if limit:
        if limit <= 0:
            raise DemistoException("The limit parameter cannot be negative number or zero")
        if limit < LIMIT_MIN:
            limit = LIMIT_MIN
        if limit > LIMIT_MAX:  # do not allow a limit of more than 200 to avoid timeouts
            limit = LIMIT_MAX
        if limit % 10 != 0:  # max limit must be a multiplier of 10 (SaaS API limit)
            # round down the limit
            limit = int(limit // 10) * 10
    else:
        limit = LIMIT_DEFAULT

    demisto.debug(f"limit after validate: {limit}")
    return limit


def get_passed_mins(start_time, end_time_str, tz=None):
    """
    Calculates the amount of minutes passed between 2 dates.
    :param start_time: Start time in datetime
    :param end_time_str: End time in str

    :return: The passed minutes.
    :rtype: int
    """
    time_delta = start_time - datetime.fromtimestamp(end_time_str, tz)
    return time_delta.seconds / 60


def convert_to_xsoar_incident(inc) -> dict:
    occurred = inc.get("created_at")
    return {
        "name": f'Saas Security: {inc.get("asset_name", "No asset name")}',
        "occurred": datetime.strptime(occurred, SAAS_SECURITY_DATE_FORMAT).strftime(DATE_FORMAT) if occurred else None,
        "rawJSON": json.dumps(inc),
    }


""" COMMAND FUNCTIONS """


def test_module(
    client: Client,
    is_fetch: bool = False,
    first_fetch_time: str = None,
    state: str = None,
    severity: list = None,
    status: str = None,
    app_ids: str = None,
) -> str:
    """Tests API connectivity and authentication'

    Returning 'ok' indicates that the integration works like it is supposed to.
    Connection to the service is successful.
    Raises exceptions if something goes wrong.

    When an instance was configured to fetch incident, the fetch params are tested as well.
    """
    # test with fetch parameters
    if is_fetch:
        last_fetch = dateparser.parse(first_fetch_time, settings={"TIMEZONE": "UTC"})  # type: ignore
        assert last_fetch is not None
        last_fetch = last_fetch.strftime(SAAS_SECURITY_DATE_FORMAT)[:-4] + "Z"
        assert last_fetch is not None
        client.get_incidents(from_time=last_fetch, state=state, severity=severity, status=status, app_ids=app_ids)
    else:
        client.get_incidents()
    return "ok"


def get_incidents_command(client: Client, args: dict) -> CommandResults:
    """
    List incidents with query.
    """
    limit = validate_limit(arg_to_number(args.get("limit")))
    from_time = args.get("from")
    to_time = args.get("to")
    app_ids = ",".join(argToList(args.get("app_ids", [])))
    state = args.get("state", "open")
    severity_arr = argToList(args.get("severity", []))
    status = ",".join(STATUS_MAP.get(x) for x in argToList(args.get("status", [])))  # type: ignore[misc]
    next_page = args.get("next_page")

    raw_res = client.get_incidents(limit, from_time, to_time, app_ids, state, severity_arr, status, next_page)
    incidents = raw_res.get("resources", [])

    # The API always returns the nextPage field with value in it even if there are no more incidents to retrieve.
    next_page = raw_res.get("nextPath", "").replace("severity", "severities") if len(incidents) == limit else None
    metadata = (
        "Run the following command to retrieve the next batch of incidents:\n"
        f"!saas-security-incidents-get next_page={next_page}"
        if next_page
        else None
    )

    outputs = {
        "SaasSecurity.Incident(val.incident_id && val.incident_id == obj.incident_id)": incidents,
    }
    if next_page:
        outputs["SaasSecurity.NextResultsPage"] = next_page

    human_readable = tableToMarkdown(
        "Incidents",
        incidents,
        headers=INC_HEADERS_SHORTEN,
        headerTransform=string_to_table_header,
        removeNull=True,
        metadata=metadata,
    )

    return CommandResults(readable_output=human_readable, outputs=outputs, raw_response=raw_res)


def get_incident_by_id_command(client: Client, args: dict) -> CommandResults:
    """
    Get incident by ID.
    """
    inc_id = args["id"]
    incident = client.get_incident_by_id(inc_id)
    human_readable = tableToMarkdown(
        f"Incident {inc_id} details",
        incident,
        headers=INC_HEADERS_SHORTEN,
        headerTransform=string_to_table_header,
        removeNull=True,
    )

    return CommandResults(
        outputs_prefix="SaasSecurity.Incident",
        outputs_key_field="incident_id",
        readable_output=human_readable,
        outputs=incident,
        raw_response=incident,
    )


def update_incident_state_command(client: Client, args: dict) -> CommandResults:
    """
    Changes an Incident status, can only closing due to an API limitation.
    Category can be changed multiple times.
    """
    inc_id = args["id"]
    category = args.get("category", "").replace(" ", "_").lower()

    raw_res = client.update_incident_state(inc_id, category)
    raw_res["incident_id"] = inc_id
    human_readable = tableToMarkdown(
        f"Incident {inc_id} status details", raw_res, removeNull=True, headerTransform=string_to_table_header
    )

    return CommandResults(
        outputs_prefix="SaasSecurity.IncidentState",
        outputs_key_field="incident_id",
        readable_output=human_readable,
        outputs=raw_res,
        raw_response=raw_res,
    )


def get_apps_command(client: Client, _) -> CommandResults:
    """
    Gets Apps info.
    """
    raw_res = client.get_apps()
    human_readable = tableToMarkdown("Apps Info", raw_res, removeNull=True, headerTransform=string_to_table_header)

    return CommandResults(
        outputs_prefix="SaasSecurity.App",
        outputs_key_field="app_id",
        readable_output=human_readable,
        outputs=raw_res,
        raw_response=raw_res,
    )


def remediate_asset_command(client: Client, args: dict) -> CommandResults:
    """
    Remediate as asset.
    """
    asset_id = args["asset_id"]
    remediation_type = REMEDIATION_MAP.get(args.get("remediation_type"))  # type: ignore

    if not remediation_type:
        raise DemistoException(
            f'Invalid remediation type: {args.get("remediation_type")}.\n'
            f'Must be one of the following: Remove public sharing, Quarantine, Restore'
        )

    remove_inherited_sharing = (
        argToBoolean(args.get("remove_inherited_sharing", False)) if remediation_type == "remove_public_sharing" else None
    )

    client.remediate_asset(asset_id, remediation_type, remove_inherited_sharing)  # type: ignore[arg-type]
    outputs = {
        "asset_id": asset_id,
        "remediation_type": remediation_type,
        "status": "pending",
    }
    return CommandResults(
        outputs_prefix="SaasSecurity.Remediation",
        outputs_key_field="asset_id",
        readable_output=tableToMarkdown(
            f"Remediation details for asset: {asset_id}", outputs, removeNull=True, headerTransform=string_to_table_header
        ),
        outputs=outputs,
    )


def get_remediation_status_command(client: Client, args: dict) -> CommandResults:
    """
    Get Remediation Status for a given asset ID.
    """
    asset_id = args["asset_id"]
    remediation_type = REMEDIATION_MAP.get(args.get("remediation_type"))  # type: ignore

    if not remediation_type:
        raise DemistoException(
            f"Invalid remediation type: {remediation_type}.\n"
            f"Must be one of the following: Remove public sharing, Quarantine, Restore"
        )

    raw_res = client.asset_remediation_status(asset_id, remediation_type)
    human_readable = tableToMarkdown(
        f"Asset {asset_id} remediation details", raw_res, removeNull=True, headerTransform=string_to_table_header
    )

    return CommandResults(
        outputs_prefix="SaasSecurity.Remediation",
        outputs_key_field="asset_id",
        readable_output=human_readable,
        outputs=raw_res,
        raw_response=raw_res,
    )


def fetch_incidents(
    client: Client,
    first_fetch_time,
    fetch_limit,
    fetch_state,
    fetch_severity,
    fetch_status,
    fetch_app_ids,
    mirror_direction=None,
    integration_instance="",
):
    last_run = demisto.getLastRun()
    last_fetch = last_run.get("last_run_time")

    fetch_severity_arr = fetch_severity if isinstance(fetch_severity, list) else argToList(fetch_severity)
    demisto.debug(f"{fetch_severity_arr=}")

    if last_fetch is None:
        last_fetch = dateparser.parse(first_fetch_time, settings={"TIMEZONE": "UTC"})
        last_fetch = last_fetch.strftime(SAAS_SECURITY_DATE_FORMAT)[:-4] + "Z"  # format ex: 2021-08-23T09:26:25.872Z

    demisto.debug(
        f"Calling get_incidents with {fetch_limit=}, {last_fetch=}, {fetch_state=}, {fetch_severity_arr=}, {fetch_status=}"
    )
    current_fetch = last_fetch
    results = client.get_incidents(
        limit=fetch_limit,
        from_time=last_fetch,
        state=fetch_state,
        severity=fetch_severity_arr,
        status=fetch_status,
        app_ids=fetch_app_ids,
    ).get("resources", [])

    last_fetch_datetime = datetime.strptime(last_fetch, SAAS_SECURITY_DATE_FORMAT)
    incidents = []
    for inc in results:
        date_updated = inc.get("updated_at")
        date_updated_dt = datetime.strptime(date_updated, SAAS_SECURITY_DATE_FORMAT) + timedelta(milliseconds=1)
        if date_updated_dt > datetime.strptime(current_fetch, SAAS_SECURITY_DATE_FORMAT):
            current_fetch = date_updated_dt.strftime(SAAS_SECURITY_DATE_FORMAT)[:-4] + "Z"

        # We fetch the incidents by the "updated-at" field,
        # So we need to filter the incidents created before the last_fetch
        date_created = inc.get("created_at")
        if datetime.strptime(date_created, SAAS_SECURITY_DATE_FORMAT) < last_fetch_datetime:
            continue

        inc["mirror_direction"] = mirror_direction
        inc["mirror_instance"] = integration_instance
        inc["last_mirrored_in"] = int(datetime.now().timestamp() * 1000)

        incident = convert_to_xsoar_incident(inc)
        incidents.append(incident)

    demisto.setLastRun({"last_run_time": current_fetch})
    demisto.incidents(incidents)


def get_remote_data_command(client, args):
    """
    get-remote-data command: Returns an updated remote incident.
    Args:
        client: The client object.
        args:
            id: incident id to retrieve.
            lastUpdate: when was the last time we retrieved data.

    Returns:
        GetRemoteDataResponse object, which contain the incident data to update.
    """
    remote_args = GetRemoteDataArgs(args)
    demisto.debug(
        f"Performing get-remote-data command with incident id: {remote_args.remote_incident_id}"
        f" and last_update: {remote_args.last_update}"
    )

    incident_data = {}
    try:
        incident_data = client.get_incident_by_id(remote_args.remote_incident_id)
        delta = {field: incident_data.get(field) for field in INCOMING_MIRRORED_FIELDS if incident_data.get(field)}

        last_update_date = dateparser.parse(remote_args.last_update, settings={"TIMEZONE": "UTC"})
        assert last_update_date is not None, f"could not parse {remote_args.last_update}"
        if not delta or date_to_timestamp(incident_data.get("updated_at"), "%Y-%m-%dT%H:%M:%S.%fZ") <= int(
            last_update_date.timestamp()
        ):
            demisto.debug("Nothing new in the incident.")
            delta = {"id": remote_args.remote_incident_id, "in_mirror_error": ""}

            return GetRemoteDataResponse(mirrored_object=delta, entries=[])

        entries = []

        state = delta and delta.get("state")
        if state and state.lower() == "closed" and demisto.params().get("close_incident"):
            demisto.debug(f"Incident is closed: {remote_args.remote_incident_id}")
            entries.append(
                {
                    "Type": EntryType.NOTE,
                    "Contents": {"dbotIncidentClose": True, "closeReason": f'From SaasSecurity: {delta.get("category")}'},
                    "ContentsFormat": EntryFormat.JSON,
                }
            )

        demisto.debug(f"Update incident {remote_args.remote_incident_id} with fields: {delta}")
        return GetRemoteDataResponse(mirrored_object=delta, entries=entries)

    except Exception as e:
        demisto.debug(
            f"Error in Saas Security incoming mirror for incident {remote_args.remote_incident_id} \nError message: {e!s}"
        )

        if incident_data:
            incident_data["in_mirror_error"] = str(e)

        else:
            incident_data = {"id": remote_args.remote_incident_id, "in_mirror_error": str(e)}

        return GetRemoteDataResponse(mirrored_object=incident_data, entries=[])


def get_modified_remote_data_command(client, args):
    """
    Gets the modified remote incident IDs.
    Args:
        client: The client object.
        args:
            last_update: the last time we retrieved modified incidents.

    Returns:
        GetModifiedRemoteDataResponse object, which contains a list of the retrieved incident IDs.
    """
    remote_args = GetModifiedRemoteDataArgs(args)

    last_update_utc = dateparser.parse(remote_args.last_update, settings={"TIMEZONE": "UTC"})  # convert to utc format
    assert last_update_utc is not None, f"could not parse {remote_args.last_update}"
    last_update_utc = last_update_utc.strftime(SAAS_SECURITY_DATE_FORMAT)[:-4] + "Z"  # format ex: 2021-08-23T09:26:25.872Z
    demisto.debug(f"last_update in UTC is {last_update_utc}")

    raw_incidents = client.get_incidents(from_time=last_update_utc, limit=100).get("resources", [])

    modified_incident_ids = []
    for raw_incident in raw_incidents:
        incident_id = raw_incident.get("incident_id")
        modified_incident_ids.append(str(incident_id))

    return GetModifiedRemoteDataResponse(modified_incident_ids)


def get_mapping_fields_command():
    """
    Gets a list of fields for an incident type.

    Returns: GetMappingFieldsResponse object which contain the field names.
    """
    saas_security_incident_type_scheme = SchemeTypeMapping(type_name=SAAS_SECURITY_INCIDENT_TYPE_NAME)

    for field in OUTGOING_MIRRORED_FIELDS:
        saas_security_incident_type_scheme.add_field(field)

    mapping_response = GetMappingFieldsResponse()
    mapping_response.add_scheme_type(saas_security_incident_type_scheme)

    return mapping_response


def update_remote_system_command(client: Client, args: Dict[str, Any]) -> str:
    """
    update-remote-system command: pushes local changes to the remote system.
    Since the API limitation doesn't allow to update the category when the incident state is open,
    The only use cases the update-remote-system can update are:
     1. When the incident were closed in XSOAR, so this command will close the mirror remote incident as well.
     2. If the category of an incident which was already closed in the remote and fetched was changed.

    :type client: ``Client``
    :param client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['data']`` the data to send to the remote system
        ``args['entries']`` the entries to send to the remote system
        ``args['incidentChanged']`` boolean telling us if the local incident indeed changed or not
        ``args['remoteId']`` the remote incident id

    :return:
        ``str`` containing the remote incident id - really important if the incident is newly created remotely
    :rtype: ``str``
    """
    parsed_args = UpdateRemoteSystemArgs(args)
    if parsed_args.delta:
        demisto.debug(f"Got the following delta keys {list(parsed_args.delta.keys())!s}")

    if parsed_args.incident_changed:
        # Check if the incident were closed in XSOAR,
        # or the category of an incident which was already closed in the remote was changed,
        # since these are the only use cases we wanna mirror out.
        if parsed_args.inc_status == IncidentStatus.DONE or (
            parsed_args.data.get("state") == "closed" and "category" in parsed_args.data
        ):
            category = parsed_args.data.get("category").replace(" ", "_").lower() if "category" in parsed_args.data else None
            if category in POSSIBLE_CATEGORIES_TO_MIRROR_OUT:
                try:
                    demisto.debug(f"Sending incident with remote ID {parsed_args.remote_incident_id} to remote system.")
                    result = client.update_incident_state(inc_id=parsed_args.remote_incident_id, category=category)
                    demisto.debug(f"Incident updated successfully. Result: {result}")

                except Exception as e:
                    demisto.error(
                        f"Error in Saas Security outgoing mirror for incident {parsed_args.remote_incident_id} \n"
                        f"Error message: {e!s}"
                    )
            else:
                demisto.debug(
                    f'The value of category {parsed_args.data.get("category")} is invalid.'
                    f' The category can be one of the following {POSSIBLE_CATEGORIES_TO_MIRROR_OUT}.'
                )
        else:
            demisto.debug(
                "Skipping updating the remote incident since the incident is not closed. "
                "Could not update the category for open incident due to an API limitation."
            )
    else:
        demisto.debug(
            f"Skipping updating remote incident fields [{parsed_args.remote_incident_id}] as it is not new nor changed."
        )

    return parsed_args.remote_incident_id


""" MAIN FUNCTION """


def main() -> None:
    params = demisto.params()
    client_id: str = params["credentials"]["identifier"]
    client_secret: str = params["credentials"]["password"]
    base_url: str = params["url"].rstrip("/")
    verify_certificate = not params.get("insecure", False)
    proxy = params.get("proxy", False)

    # Fetch incident related params:
    first_fetch_time = params.get("first_fetch", "3 days")
    fetch_limit = validate_limit(arg_to_number(params.get("max_fetch")))
    fetch_state = params.get("state")
    fetch_severity = params.get("severity")
    fetch_status = ",".join(STATUS_MAP.get(x) for x in argToList(params.get("status", [])))  # type: ignore[misc]
    fetch_app_ids = ",".join(argToList(params.get("app_ids", [])))

    mirror_direction = MIRROR_DIRECTION.get(params.get("mirror_direction", "None"), None)
    instance = demisto.integrationInstance()

    commands = {
        "saas-security-incidents-get": get_incidents_command,
        "saas-security-incident-get-by-id": get_incident_by_id_command,
        "saas-security-incident-state-update": update_incident_state_command,
        "saas-security-get-apps": get_apps_command,
        "saas-security-asset-remediate": remediate_asset_command,
        "saas-security-remediation-status-get": get_remediation_status_command,
        "get-remote-data": get_remote_data_command,
        "get-modified-remote-data": get_modified_remote_data_command,
        "update-remote-system": update_remote_system_command,
    }
    command = demisto.command()
    demisto.debug(f"Command being called is {command}")

    try:
        client = Client(
            base_url=base_url,
            client_id=client_id,
            client_secret=client_secret,
            verify=verify_certificate,
            proxy=proxy,
        )

        if command == "test-module":
            return_results(
                test_module(
                    client, params.get("isFetch"), first_fetch_time, fetch_state, fetch_severity, fetch_status, fetch_app_ids
                )
            )
        elif command == "fetch-incidents":
            fetch_incidents(
                client,
                first_fetch_time,
                fetch_limit,
                fetch_state,
                fetch_severity,
                fetch_status,
                fetch_app_ids,
                mirror_direction,
                instance,
            )
        elif command == "get-mapping-fields":
            return_results(get_mapping_fields_command())

        elif command in commands:
            return_results(commands[command](client, demisto.args()))

        else:
            raise NotImplementedError(f'Command "{command}" is not implemented.')

    except Exception as e:
        return_error(f"Failed to execute {command} command.\nError:\n{e!s}")


""" ENTRY POINT """

if __name__ in ("__main__", "__builtin__", "builtins"):  # pragma: no cover
    main()