Microsoft 365 Defender

Microsoft 365 Defender is a unified pre- and post-breach enterprise defense suite that natively coordinates detection, prevention, investigation, and response across endpoints, identities, email, and applications to provide integrated protection against sophisticated attacks.

Network Security · Microsoft 365 Defender

Details

IDMicrosoft 365 Defender
ProviderMicrosoft
CategoryNetwork Security
From Version5.5.0
Docker Imagedemisto/crypto:1.0.0.10120494
Supported ModulesAgentix Cloud Runtime Security XSIAM EDR Cortex Cloud

README

Microsoft 365 Defender is a unified pre- and post-breach enterprise defense suite that natively coordinates detection,
prevention, investigation, and response across endpoints, identities, email, and applications to provide integrated
protection against sophisticated attacks.

Authentication Using the Device Code Flow

Use the device code flow
to link Microsoft 365 Defender with Cortex XSOAR.

To connect to the Microsoft 365 Defender:

  1. Fill in the required parameters.
  2. Run the !microsoft-365-defender-auth-start command.
  3. Follow the instructions that appear.
  4. Run the !microsoft-365-defender-auth-complete command.

At the end of the process you’ll see a message that you’ve logged in successfully.

Note: In case of a password change, the microsoft-365-defender-auth-reset command should be executed followed by the authentication process described above.

Cortex XSOAR App

In order to use the Cortex XSOAR application, use the default application ID.
9093c354-630a-47f1-b087-6768eb9427e6

Self-Deployed Application - Device Code Flow

To use a self-configured Azure application, you need to add a new Azure App Registration in the Azure Portal. For more details, follow Self Deployed Application - Device Code Flow.

Required Permissions

The required API permissions are for the Microsoft Threat Protection app.

  • offline_access - Delegate
  • AdvancedHunting.Read - Delegated
  • Incident.ReadWrite.All - Application - See section 4 in this article
  • AdvancedHunting.Read.All - Application - See section 4 in this article

Self-Deployed Application - Client Credentials Flow

Follow these steps for a self-deployed configuration:

  1. To use a self-configured Azure application, you need to add a new Azure App Registration in the Azure Portal. To add the registration, refer to the following Microsoft article steps 1-8.
  2. In the instance configuration, select the client-credentials checkbox.
  3. Enter your Client/Application ID in the Application ID parameter.
  4. Enter your Client Secret in the Client Secret parameter.
  5. Enter your Tenant ID in the Tenant ID parameter.

Required Permissions

  • AdvancedHunting.Read.All - Application
  • Incident.ReadWrite.All - Application

Configure Microsoft 365 Defender on Cortex XSOAR

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for Microsoft 365 Defender.
  3. Click Add instance to create and configure a new integration instance.
  4. To ensure that mirroring works:
    1. Select Incident Mirroring Direction. Choose the direction to mirror the incident: Incoming (from Microsoft 365 Defender to Cortex XSOAR), Outgoing (from Cortex XSOAR to Microsoft 365 Defender), or Incoming And Outgoing (from/to Cortex XSOAR and Microsoft 365 Defender).
    2. Select the Fetches incidents radio button.
    3. Under Incident type, select Microsoft 365 Defender Incident.
    4. Under Mapper (incoming), select Microsoft 365 Defender - Incoming Mapper.
    5. Under Mapper (outgoing), select Microsoft 365 Defender - Outgoing Mapper.
    6. To enable mirroring to close a ticket in Cortex XSOAR, check the Close Mirrored Cortex XSOAR Incidents checkbox.
    7. To enable mirroring to close an incident in Microsoft 365 Defender, check the Close Mirrored Microsoft 365 Defender Incidents checkbox.
Parameter Description Required
Endpoint URI The United States: api-us.security.microsoft.com
Europe: api-eu.security.microsoft.com
The United Kingdom: api-uk.security.microsoft.co
True
ID or Client ID   False
Token or Tenant ID   False
Application ID The API key to use to connect. False
Use Client Credentials Authorization Flow Use a self-deployed Azure application and authenticate using the Client Credentials flow. False
Tenant ID (for Client Credentials mode)   False
Client Secret (for Client Credentials mode)   False
Client Secret   False
Certificate Thumbprint Used for certificate authentication. As appears in the “Certificates & secrets” page of the app. False
Private Key   False
Certificate Thumbprint Used for certificate authentication. As appears in the “Certificates & secrets” page of the app. False
Private Key Used for certificate authentication. The private key of the registered certificate. False
Use Azure Managed Identities Relevant only if the integration is running on Azure VM. If selected, authenticates based on the value provided for the Azure Managed Identities Client ID field. If no value is provided for the Azure Managed Identities Client ID field, authenticates based on the System Assigned Managed Identity. For additional information, see the Help tab. False
Azure Managed Identities Client ID The Managed Identities client ID for authentication - relevant only if the integration is running on Azure VM. False
First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)   False
Fetch incidents timeout The time limit in seconds for fetch incidents to run. Leave this empty to cancel the timeout limit. False
Number of incidents for each fetch. Due to API limitations, the maximum is 100. False
Incident type   False
Fetch incidents   False
Trust any certificate (not secure)   False
Use system proxy settings   False
Application ID (Deprecated)   False
Tenant ID (for Client Credentials mode) (Deprecated)   False
Client Secret (for Client Credentials mode) (Deprecated)   False
Incidents Fetch Interval   False
Incident Mirroring Direction Choose the direction to mirror the incident: Incoming (from Microsoft 365 Defender to Cortex XSOAR), Outgoing (from Cortex XSOAR to Microsoft 365 Defender), or Incoming and Outgoing (from/to Cortex XSOAR and Microsoft 365 Defender). False
Close Mirrored Cortex XSOAR Incidents Incoming Mirroring - when selected, closing the Microsoft 365 Defender incident is mirrored in Cortex XSOAR. False
Close Mirrored Microsoft 365 Defender Incidents Outgoing Mirroring - when selected, closing the Cortex XSOAR incident is mirrored in Microsoft 365 Defender. False
Comment Entry Tag To Microsoft 365 Defender Choose a tag to add to an entry to mirror it as a comment into Microsoft 365 Defender. False
Comment Entry Tag From Microsoft 365 Defender Choose a tag to add to an entry to mirror it as a comment from Microsoft 365 Defender. False
Custom close-reason mapping (XSOAR -> Defender mirrored incident. Overwrites default close-reason mapping defined by Cortex XSOAR) Define how to close the mirrored incidents from Cortex XSOAR into Defender with a custom close reason mapping. Enter a comma-separated list of close reasons (acceptable format {Cortex XSOAR close reason}={Defender close reason}) to override the default close reason mapping defined by Cortex XSOAR. Note that the mapping must be configured accordingly with the existing close reasons in Cortex XSOAR and Defender. Not following this format will result in closing the incident with a default close reason. Example: “Resolved=Other,Duplicate=Other”. Refer to Outgoing Closing Logic (XSOAR → Microsoft 365 Defender) for possible close-reasons. False
Custom close-reason mapping (Defender -> XSOAR mirrored incident. Overwrites default close-reason mapping defined by Cortex XSOAR) Define how to close the mirrored incidents from Defender into Cortex XSOAR with a custom close reason mapping. Enter a comma-separated list of close reasons (acceptable format {Defender close reason}={Cortex XSOAR close reason}) to override the default close reason mapping defined by Cortex XSOAR. Note that the mapping must be configured accordingly with the existing close reasons in Cortex XSOAR and Defender. Not following this format will result in closing the incident with a default close reason. Example: “Known Issue=Resolved, Duplicate Incident=Other”. Refer to Incoming Closing Logic (Microsoft 365 Defender → XSOAR) for possible close-reasons. False
  1. Run the !microsoft-365-defender-auth-test command to validate the authentication process.

Incident Mirroring

Mirroring In (Microsoft 365 Defender → XSOAR)

When incidents are mirrored into XSOAR from Microsoft 365 Defender:

  1. Comments mirrored from Microsoft 365 Defender will be added to the incident as entries in XSOAR and tagged with the Comment Entry Tag From Microsoft 365 Defender.
    • By default, the tag is set to: CommentFromMicrosoft365Defender.
  2. If an incident is closed in Microsoft 365 Defender (status = Resolved) and Close Mirrored Cortex XSOAR Incidents is enabled:
    • The “classification” field in Microsoft 365 Defender will be mapped to the Close Reason field in XSOAR.

Mirroring Out (XSOAR → Microsoft 365 Defender)

When incidents are mirrored out from XSOAR to Microsoft 365 Defender:

  1. Supported Fields:
    The following fields are mirrored:
    • Microsoft 365 Defender Status
    • Assigned User
    • Microsoft 365 Defender Classification
    • Microsoft 365 Defender Tags
  2. Comments:
    • XSOAR entries with the Comment Entry Tag To Microsoft 365 Defender tag are mirrored as comments in Microsoft 365 Defender.
    • Note: Comments cannot be edited or deleted in Microsoft 365 Defender, as this functionality is not supported.
  3. If an incident is closed in XSOAR and Close Mirrored Microsoft 365 Defender Incidents is enabled:
    • The Close Reason field is mirrored to the status, classification and determination fields in Microsoft 365 Defender.

Closing Logic

Incoming Closing Logic (Microsoft 365 Defender → XSOAR)

When an incident is resolved in Microsoft 365 Defender:

  1. If the status = Resolved:
    • The “classification” field will be mapped to the Close Reason field in XSOAR using the Close Reason Mapping Table.
    • The “classification” and “determination” fields will be mirrored into the Microsoft 365 Defender Classification field in XSOAR.

Example:

  • An incident closed in Microsoft 365 Defender with:
    • status = Resolved
    • Classification = True Positive
    • Determination = Phishing

    Results in XSOAR:

    • Close Reason = Resolved
    • Microsoft 365 Defender Classification = True Positive - Phishing

Close Reason Mapping Table (Incoming):

Classification in Microsoft 365 Defender Close Reason in XSOAR
Not set Other
True Positive Resolved
False Positive False Positive
Informational / Expected Activity Resolved

Outgoing Closing Logic (XSOAR → Microsoft 365 Defender)

Disclaimer: The closing form currently does not support the Microsoft 365 Defender Classification field, and False Positive Classification is not yet enforced when selecting “False Positive” as the close reason.
If classification is required, please manually update the Microsoft 365 Defender Classification via the layout before closing the incident.

When closing incidents from XSOAR to Microsoft 365 Defender, the following logic is applied to map the Close Reason field:

  1. General Status Update:
    When an incident is marked as closed in XSOAR, the status field in Microsoft 365 Defender will always be set to Resolved.

  2. Close Reason: Resolved or False Positive
    If the Close Reason in XSOAR is Resolved, the Microsoft 365 Defender Classification field is mirrored to classification and determination fields as-is into Microsoft 365 Defender without any changes.

  3. Close Reason: Other or Duplicate
    If the Close Reason in XSOAR is either Other or Duplicate, the following updates occur:

    • classificationUnknown
    • determinationNotAvailable

Close Reason Mapping Table (Outgoing):

Close Reason in XSOAR Classification in Microsoft 365 Defender Determination in Microsoft 365 Defender Status in Defender
Resolved Mirrored as-is Mirrored as-is Resolved
FalsePositive FalsePositive Mirrored as-is \ Other Resolved
Other Unknown NotAvailable Resolved
Duplicate Unknown NotAvailable Resolved

Notes

  1. Configuration Requirements for Closing Incidents:
    To close incidents in both directions, ensure the following settings in XSOAR:
    • Close Mirrored XSOAR Incident checkbox = True
    • Close Mirrored Microsoft 365 Defender Incident checkbox = True

    If these checkboxes are False, only the Microsoft 365 Defender Status and Microsoft 365 Defender Classification fields will be updated, and incidents will not be closed.

Configure Incident Mirroring

This feature is compliant with XSOAR version 6.0 and above.
When mirroring incidents, you can make changes in Microsoft 365 Defender that will be reflected in Cortex XSOAR, or vice versa.

The following instructions include steps for configuring the integration and incoming and outgoing mappers. However, they do not cover every option available in the integration nor classification and mapping features.
For information about classification and mapping see Classification and Mapping.

Note:

  • For Cortex XSOAR version 6.1 only, the final source of truth for an incident are the values in Cortex XSOAR. For example, if you change the severity in Cortex XSOAR and then change it back in Microsoft 365 Defender, the final value that will be presented is the one in Cortex XSOAR. For versions 6.2 and later, if mirroring is in both directions then the latest update is the source of truth.
  • The mirroring settings apply only for incidents that are fetched after applying the settings.
  • To use a custom mapper, you must first duplicate the mapper and edit the field in the copy of the mapper. If you detach the out of the box mapper and make changes to it, the pack does not automatically get updates.

STEP 1 - Configure the Microsoft 365 Defender Integration Instance for Mirroring

  1. Navigate to Integrations and search for Microsoft 365 Defender.
  2. Click Add instance.
  3. Select Fetches incidents.
  4. Select the Incident Mirroring Direction:
    • Incoming - Mirrors changes on the Microsoft 365 Defender incident in to the Cortex XSOAR incident.
    • Outgoing - Mirrors changes on the Cortex XSOAR incident to the Microsoft 365 Defender incident.
    • Incoming And Outgoing - Mirrors changes both in and out on both incidents.
  5. Under Incident type, select Microsoft 365 Defender Incident.
  6. Under Mapper (incoming), for default mapping select Microsoft 365 Defender - Incoming Mapper. For custom mapping, follow the instructions in STEP 2 and then select the custom mapper name.
  7. Under Mapper (outgoing), for default mapping select Microsoft 365 Defender - Outgoing Mapper. For custom mapping, follow the instructions in STEP 3 and then select the custom mapper name.
  8. Enter the relevant Comment Entry Tag To Microsoft 365 Defender and Comment Entry Tag From Microsoft 365 Defender values.
    These values are mapped to the dbotMirrorTags incident field in Cortex XSOAR, which defines how Cortex XSOAR handles comments when you tag them in the War Room.
    Note:
    These tags work only for mirroring comments from Cortex XSOAR to Microsoft 365 Defender.
  9. To enable mirroring when closing an incident in Cortex XSOAR and Microsoft 365 Defender, select the Close Mirrored Cortex XSOAR Incidents and Close Mirrored Microsoft 365 Defender Incidents checkboxes respectively.
  10. Click Save & Exit.

STEP 2 (Optional) Configure the Incoming Mapper by Incident Type

Note:
Any modifications require that the mappers be cloned before any changes can be applied.

  1. Navigate to Classification and Mapping and for Incidents search for the Microsoft 365 Defender - Incoming Mapper.
  2. Select it and click Duplicate.
  3. Under the Incident Type dropdown, select Microsoft 365 Defender Incident.
  4. Verify the mapper has these fields mapped. They will pull the values configured on the integration instance settings at the time of ingestion.
    • dbotMirrorId - dbotMirrorId - the field used by the third-party integration to identify the incident. This should be the incidentId of the Microsoft 365 Defender Incident.
    • dbotMirrorDirection - determines whether mirroring is incoming, outgoing, or both. Default is Both. This should match the instance configuration.
    • dbotMirrorInstance - determines the Microsoft 365 Defender instance with which to mirror. This should match the instance configuration.
    • dbotMirrorLastSync - determines the field by which to indicate the last time that the systems synchronized.
    • dbotMirrorTags - determines the tags that you need to add in Cortex XSOAR for entries to be pushed to Microsoft 365 Defender. They should be copied from the tags in the instance configuration. These are also the tags that must be put on the War Room record in order for it to sync.

STEP 3 - Modify the Outgoing Mapper

Note:
Any modifications require that the mappers be cloned before any changes can be applied.

  1. Navigate to Classification and Mapping, and for Incidents search for the Microsoft 365 Defender - Outgoing Mapper.
  2. Select it and click Duplicate.
    The left side of the screen shows the Microsoft 365 Defender fields to which to map and the right side of the
    screen shows the Cortex XSOAR fields by which you are mapping.
  3. Under the Incident Type dropdown, select the relevant incident type (for example Microsoft 365 Defender Incident).
  4. Under Schema Type, select incident. The Schema Type represents the Microsoft 365 Defender entity that
    you are mapping to.
  5. On the right side of the screen, under Incident, select the incident based on which you want to
    match.
  6. Change the mapping according to your needs, including any fields you want mapped outward to Microsoft 365 Defender.
  7. Save your changes.

STEP 4 - Create an Incident in Microsoft 365 Defender

For purposes of this use case, it can be a simple incident. The new incident will be ingested in Cortex XSOAR in approximately one minute.

STEP 5 - Add a Comment from Cortex XSOAR to Microsoft 365 Defender

In the example below, we have written A comment from Cortex XSOAR to Microsoft 365 Defender.

  1. Create an entry in the incidents’ war room.
  2. Click Actions > Tags and add the Comment Entry Tag To Microsoft 365 Defender tag.
  3. Navigate back to the incident in Microsoft 365 Defender and within approximately one minute, the changes will be reflected there, too. The note is mirrored out as a comment in Microsoft 365 Defender.
    You can make additional changes like closing the incident or changing the assignee and those will be reflected in both systems.

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.

microsoft-365-defender-auth-start


Run this command to start the authorization process and follow the instructions in the command results. (for device-code mode)

Base Command

microsoft-365-defender-auth-start

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

Command Example

!microsoft-365-defender-auth-start

Human Readable Output

###Authorization instructions

  1. To sign in, use a web browser to open the page {URL}
    and enter the code {code} to authenticate.
  2. Run the !microsoft-365-defender-auth-complete command in the War Room.

microsoft-365-defender-auth-complete


Run this command to complete the authorization process. Should be used after running the microsoft-365-defender-auth-start command. (for device-code mode)

Base Command

microsoft-365-defender-auth-complete

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

Command Example

!microsoft-365-defender-auth-complete

Human Readable Output

✅ Authorization completed successfully.

microsoft-365-defender-auth-reset


Run this command if for some reason you need to rerun the authentication process.

Base Command

microsoft-365-defender-auth-reset

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

Command Example

!microsoft-365-defender-auth-reset

Human Readable Output

Authorization was reset successfully.
You can now run !microsoft-365-defender-auth-start and
!microsoft-365-defender-auth-complete.

microsoft-365-defender-auth-test


Tests the connectivity to the Microsoft 365 Defender.

Base Command

microsoft-365-defender-auth-test

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

Command Example

!microsoft-365-defender-auth-test

Human Readable Output

✅ Success!

microsoft-365-defender-incidents-list


Get the most recent incidents.

Base Command

microsoft-365-defender-incidents-list

Input

Argument Name Description Required
status Categorize incidents (as Active, Resolved, or Redirected). Possible values are: Active, Resolved, Redirected. Optional
assigned_to Owner of the incident. Optional
limit Number of incidents in the list. Maximum is 100. Default is 100. Optional
offset Number of entries to skip. Optional
timeout The time limit in seconds for the http request to run. Default is 30. Optional
odata Filter incidents using odata query: https://docs.microsoft.com/en-us/microsoft-365/security/defender/api-list-incidents?view=o365-worldwide. Example: {"$filter":"lastUpdateTime gt 2022-08-29T06:00:00.29Z"}. Optional

Context Output

Path Type Description
Microsoft365Defender.Incident.incidentId Number Incident’s ID.
Microsoft365Defender.Incident.redirectIncidentId Unknown Only populated in case an incident is grouped together with another incident, as part of the incident processing logic.
Microsoft365Defender.Incident.incidentName String The name of the incident.
Microsoft365Defender.Incident.createdTime Date The date and time (in UTC) the incident was created.
Microsoft365Defender.Incident.lastUpdateTime Date The date and time (in UTC) the incident was last updated.
Microsoft365Defender.Incident.assignedTo String Owner of the incident.
Microsoft365Defender.Incident.classification String Specification of the incident. Possible values are: Unknown, FalsePositive, and TruePositive.
Microsoft365Defender.Incident.determination String The determination of the incident. Possible values are: NotAvailable, Apt, Malware, SecurityPersonnel, SecurityTesting, UnwantedSoftware, and Other.
Microsoft365Defender.Incident.status String The current status of the incident. Possible values are: Active, Resolved, and Redirected.
Microsoft365Defender.Incident.severity String Severity of the incident. Possible values are: UnSpecified, Informational, Low, Medium, and High.
Microsoft365Defender.Incident.alerts Unknown List of alerts relevant for the incidents.
Microsoft365Defender.Incident.tags unknown List of custom tags associated with an incident, for example to flag a group of incidents with a common characteristic.
Microsoft365Defender.Incident.comments unknown List of comments created by secops when managing the incident, for example additional information about the classification selection.

Command Example

!ms-365-defender-incidents-list status=Active limit=10 assigned_to=user

Human Readable Output

Incidents

Incident name Tags Severity Incident ID Categories Impacted entities Active alerts Service sources Detection sources First activity Last activity Status Assigned to Classification Device groups
Automated investigation started manually on one endpoint tag1, tag2 Informational 263 SuspiciousActivity user 5 / 12 MicrosoftDefenderForEndpoint AutomatedInvestigation 2021-03-22T12:34:31.8123759Z 2021-03-22T12:59:07.526847Z Active email Unknown computer
Impossible travel activity involving one user   Medium 264 InitialAccess user 1 / 1 MicrosoftCloudAppSecurity MCAS 2021-04-05T06:56:06.833Z 2021-04-05T15:34:25.736Z Resolved email Unknown  

microsoft-365-defender-incident-get


Get incident with the given ID.

Base Command

microsoft-365-defender-incident-get

Input

microsoft-365-defender-incident-get


Gets the incident with the given ID.

Base Command

microsoft-365-defender-incident-get

Input

Argument Name Description Required
id Incident’s ID. Required
timeout The time limit in seconds for the http request to run. Default is 30. Optional

Context Output

Path Type Description
Microsoft365Defender.Incident.incidentId number Incident’s ID.
Microsoft365Defender.Incident.redirectIncidentId unknown Only populated in case an incident is grouped together with another incident, as part of the incident processing logic.
Microsoft365Defender.Incident.incidentName string The name of the incident.
Microsoft365Defender.Incident.createdTime date The date and time (in UTC) the incident was created.
Microsoft365Defender.Incident.tags unknown List of custom tags associated with an incident, for example to flag a group of incidents with a common characteristic.
Microsoft365Defender.Incident.lastUpdateTime date The date and time (in UTC) the incident was last updated.
Microsoft365Defender.Incident.assignedTo string Owner of the incident.
Microsoft365Defender.Incident.classification string Specification of the incident. Possible values are: Unknown, FalsePositive, and TruePositive.
Microsoft365Defender.Incident.determination string The determination of the incident. Possible values are: NotAvailable, Apt, Malware, SecurityPersonnel, SecurityTesting, UnwantedSoftware, and Other.
Microsoft365Defender.Incident.severity string Severity of the incident. Possible values are: UnSpecified, Informational, Low, Medium, and High.
Microsoft365Defender.Incident.status string The current status of the incident. Possible values are: Active, Resolved, and Redirected.
Microsoft365Defender.Incident.alerts unknown List of alerts relevant for the incidents.
Microsoft365Defender.Incident.tags unknown List of custom tags associated with an incident, for example to flag a group of incidents with a common characteristic.
Microsoft365Defender.Incident.comments unknown List of comments created by secops when managing the incident, for example additional information about the classification selection.

microsoft-365-defender-incident-update


Update the incident with the given ID.

Base Command

microsoft-365-defender-incident-update

Input

Argument Name Description Required
status Categorize incidents (as Active, Resolved, or Redirected). Possible values are: Active, Resolved, Redirected, InProgress. Optional
assigned_to Owner of the incident. Optional
id Incident’s ID. Required
classification The specification for the incident. Possible values are: Unknown, FalsePositive, TruePositive, InformationalExpectedActivity. Optional
determination Determination of the incident. Must be used with the classification field. Possible values depend on the classification field: TruePositive - MultiStagedAttack, MaliciousUserActivity, Malware, Phishing, CompromisedAccount, UnwantedSoftware, Other (default), InformationalExpectedActivity- SecurityTesting, LineOfBusinessApplication, ConfirmedActivity, Other (default), FalsePositive - NotMalicious, NoEnoughDataToValidate, Other (default), Unknown - NotAvailable. Possible values are: NotAvailable, Malware, SecurityTesting, UnwantedSoftware, MultiStagedAttack, MaliciousUserActivity, CompromisedAccount, Phishing, LineOfBusinessApplication, ConfirmedActivity, NotMalicious, Other. Optional
comment Comment to be added to the incident. Optional
tags A comma-separated list of custom tags associated with an incident. For example: tag1,tag2,tag3. Optional
timeout The time limit in seconds for the http request to run. Default is 30. Optional

Context Output

Path Type Description
Microsoft365Defender.Incident.incidentId Number Incident’s ID.
Microsoft365Defender.Incident.redirectIncidentId Unknown Only populated in case an incident is grouped together with another incident, as part of the incident processing logic.
Microsoft365Defender.Incident.incidentName String The name of the incident.
Microsoft365Defender.Incident.createdTime Date The date and time (in UTC) the incident was created.
Microsoft365Defender.Incident.lastUpdateTime Date The date and time (in UTC) the incident was last updated.
Microsoft365Defender.Incident.assignedTo String Owner of the incident.
Microsoft365Defender.Incident.classification String Specification of the incident. Possible values are: Unknown, FalsePositive, and TruePositive.
Microsoft365Defender.Incident.determination String The determination of the incident. Possible values are: NotAvailable, Apt, Malware, SecurityPersonnel, SecurityTesting, UnwantedSoftware, and Other.
Microsoft365Defender.Incident.severity String Severity of the incident. Possible values are: UnSpecified, Informational, Low, Medium, and High.
Microsoft365Defender.Incident.status String The current status of the incident. Possible values are: Active, Resolved, and Redirected.
Microsoft365Defender.Incident.alerts Unknown List of alerts relevant for the incidents.
Microsoft365Defender.Incident.tags unknown List of custom tags associated with an incident, for example to flag a group of incidents with a common characteristic.
Microsoft365Defender.Incident.comments unknown List of comments created by secops when managing the incident, for example additional information about the classification selection.

Command Example

!microsoft-365-defender-incident-update id=264 tags=test5

Human Readable Output

Updated incident No. 263

Incident name Tags Severity Incident ID Categories Impacted entities Active alerts Service sources Detection sources First activity Last activity Status Assigned to Classification Device groups
Automated investigation started manually on one endpoint test5 Informational 263 SuspiciousActivity   10 / 12 MicrosoftDefenderForEndpoint AutomatedInvestigation 2021-03-22T12:34:31.8123759Z 2021-03-22T12:59:07.526847Z Active User Unknown computer

microsoft-365-defender-advanced-hunting

NOTE: This endpoint will be deprecated by Microsoft on January 31, 2027. We recommend switching to the Microsoft Graph Security Integration and using the msg-advanced-hunting command. For more information, see the Microsoft documentation.


Advanced hunting is a threat-hunting tool that uses specially constructed queries to examine the past 30 days of event data in Microsoft 365 Defender.
Details on how to write queries you can find here.

Base Command

microsoft-365-defender-advanced-hunting

Input

Argument Name Description Required
query Advanced hunting query. Required
timeout The time limit in seconds for the http request to run. Default is 30. Optional
limit Number of entries. Enter -1 for unlimited query. Default is 50. Required

Context Output

Path Type Description
Microsoft365Defender.Hunt.query String The query used, also acted as a key.
Microsoft365Defender.Hunt.results. Unknown The results of the query.

Command Example

!microsoft-365-defender-advanced-hunting query=AlertInfo

Human Readable Output

Result of query: AlertInfo

Timestamp AlertId Title Category Severity ServiceSource DetectionSource AttackTechniques
2021-04-25T10:11:00Z alertId eDiscovery search started or exported InitialAccess Medium Microsoft Defender for Office 365 Microsoft Defender for Office 365  

get-mapping-fields


Returns the list of fields to map in outgoing mirroring. This command is only used for debugging purposes.

Base Command

get-mapping-fields

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

There is no context output for this command.

update-remote-system


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

Base Command

update-remote-system

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

There is no context output for this command.

get-remote-data


Get remote data from a remote incident. This method does not update the current incident, and should be used for debugging purposes only.

Base Command

get-remote-data

Input

Argument Name Description Required
id The remote incident ID. Required
lastUpdate The UTC timestamp in seconds of the last update. The incident is only updated if it was modified after the last update time. Default is 0. Optional

Context Output

There is no context output for this command.

get-modified-remote-data


Get the list of incidents that were modified since the last update time. This method is used for debugging purposes. The get-modified-remote-data command is used as part of the Mirroring feature that was introduced in Cortex XSOAR version 6.1.

Base Command

get-modified-remote-data

Input

Argument Name Description Required
lastUpdate Date string representing the local time. The incident is only returned if it was modified after the last update time. Optional

Context Output

There is no context output for this command.

Configuration parameters

  • base_url — Endpoint URI (required)
  • creds_client_id
  • creds_tenant_id
  • _app_id — Application ID
  • client_credentials — Use Client Credentials Authorization Flow
  • _tenant_id — Tenant ID (for Client Credentials mode)
  • credentials — Client Secret (for Client Credentials mode)
  • creds_certificate — Certificate Thumbprint
  • certificate_thumbprint — Certificate Thumbprint
  • private_key — Private Key
  • use_managed_identities — Use Azure Managed Identities
  • managed_identities_client_id
  • first_fetch — First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)
  • fetch_timeout — Fetch incidents timeout
  • max_fetch — Number of incidents for each fetch.
  • incidentType — Incident type
  • isFetch — Fetch incidents
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • app_id — Application ID (Deprecated)
  • tenant_id — Tenant ID (for Client Credentials mode) (Deprecated)
  • enc_key — Client Secret (for Client Credentials mode) (Deprecated)
  • incidentFetchInterval — Incidents Fetch Interval
  • mirror_direction — Incident Mirroring Direction
  • close_incident — Close Mirrored Cortex XSOAR Incidents
  • custom_xsoar_to_defender_close_reason — Enable custom XSOAR to Defender Close Reason
  • custom_defender_to_xsoar_close_reason — Enable custom Defender to XSOAR Close Reason
  • close_out — Close Mirrored Microsoft 365 Defender Incidents
  • comment_tag — Comment Entry Tag To Microsoft 365 Defender
  • comment_tag_from_microsoft365defender — Comment Entry Tag From Microsoft 365 Defender
  • custom_xsoar_to_defender_close_reason_mapping — Custom close-reason mapping (XSOAR -> Defender mirrored incident)
  • custom_defender_to_xsoar_close_reason_mapping — Custom close-reason mapping (Defender -> XSOAR mirrored incident)

Commands (12)

  • get-mapping-fields

    Returns the list of fields to map in outgoing mirroring. This command is only used for debugging purposes.

  • get-modified-remote-data

    Get the list of incidents that were modified since the last update time. This method is used for debugging purposes. The get-modified-remote-data command is used as part of the Mirroring feature that was introduced in Cortex XSOAR version 6.1.

  • get-remote-data

    Get remote data from a remote incident. This method does not update the current incident, and should be used for debugging purposes only.

  • microsoft-365-defender-advanced-hunting

    Advanced hunting is a threat-hunting tool that uses specially constructed queries to examine the past 30 days of event data in Microsoft 365 Defender. Details on how to write queries: https://docs.microsoft.com/en-us/microsoft-365/security/defender/advanced-hunting-query-language?view=o365-worldwide.

  • microsoft-365-defender-auth-complete

    Run this command to complete the authorization process. Should be used after running the microsoft-365-defender-auth-start command. (for device-code mode).

  • microsoft-365-defender-auth-reset

    Run this command if for some reason you need to rerun the authentication process.

  • microsoft-365-defender-auth-start

    Run this command to start the authorization process and follow the instructions in the command results. (for device-code mode).

  • microsoft-365-defender-auth-test

    Tests the connectivity to the Microsoft 365 Defender.

  • microsoft-365-defender-incident-get

    Gets the incident with the given ID.

  • microsoft-365-defender-incident-update

    Update the incident with the given ID.

  • microsoft-365-defender-incidents-list

    Get the most recent incidents.

  • update-remote-system

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

"""Base Integration for Cortex XSOAR - Unit Tests file

Pytest Unit Tests: all funcion names must start with "test_"

More details: https://xsoar.pan.dev/docs/integrations/unit-testing

You must add at least a Unit Test function for every XSOAR command
you are implementing with your integration
"""

import json
from datetime import UTC, datetime, timedelta
from unittest.mock import patch

import demistomock as demisto
import pytest
from CommonServerPython import EntryType, IncidentStatus
from Microsoft365Defender import (
    Client,
    _query_set_limit,
    _get_default_incident_close_out_or_reactivation_reason,
    _get_default_modified_incidents_close_or_reopen_entries_reason,
    fetch_incidents,
    fetch_modified_incident,
    fetch_modified_incident_ids,
    get_determination_value,
    get_modified_incidents_close_or_repopen_entries,
    get_modified_remote_data_command,
    get_remote_data_command,
    handle_incident_close_out_or_reactivation,
    main,
    mirror_out_entries,
    update_remote_system_command,
)

MOCK_MAX_ENTRIES = 2
COMMENT_TAG_FROM_MS = "CommentFromMicrosoft365Defender"
MIRRORED_OUT_XSOAR_ENTRY_TO_MICROSOFT_COMMENT_INDICATOR = "Mirrored from Cortex XSOAR"
OUTGOING_MIRRORED_FIELDS = {
    "status": "Specifies the current status of the incident.",
    "assignedTo": "Owner of the incident.",
    "classification": "Specification of the incident.",
    "determination": "Specifies the determination of the incident.",
    "tags": "List of Incident tags.",
    "comment": "Comment to be added to the incident.",
}
MICROSOFT_RESOLVED_CLASSIFICATION_TO_XSOAR_CLOSE_REASON = {
    "Unknown": "Other",
    "TruePositive": "Resolved",
    "FalsePositive": "False Positive",
    "InformationalExpectedActivity": "Resolved",
}


def util_load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.loads(f.read())


def test_convert_incident():
    from Microsoft365Defender import convert_incident_to_readable

    empty_incident = util_load_json("./test_data/empty_incident.json")
    assert convert_incident_to_readable(None) == empty_incident
    raw_incident = util_load_json("./test_data/raw_incident.json")
    converted_incident = util_load_json("./test_data/converted_incident.json")
    assert convert_incident_to_readable(raw_incident) == converted_incident


def mock_client(mocker, function: str = None, http_response=None):
    mocker.patch.object(
        demisto, "getIntegrationContext", return_value={"current_refresh_token": "refresh_token", "access_token": "access_token"}
    )
    client = Client(app_id="app_id", verify=False, proxy=False, base_url="https://api.security.microsoft.com")
    if http_response:
        mocker.patch.object(client, function, return_value=http_response)
    return client


def check_api_response(results, results_mock):
    assert results.outputs_prefix == results_mock["outputs_prefix"]
    assert results.outputs_key_field == results_mock["outputs_key_field"]
    assert results.readable_output == results_mock["readable_output"]
    assert results.outputs == results_mock["outputs"]


def test_microsoft_365_defender_incidents_list_command(mocker):
    from Microsoft365Defender import microsoft_365_defender_incidents_list_command

    client = mock_client(mocker, "incidents_list", util_load_json("./test_data/incidents_list_response.json"))
    results = microsoft_365_defender_incidents_list_command(client, {"limit": 10})
    check_api_response(results, util_load_json("./test_data/incidents_list_results.json"))


def test_microsoft_365_defender_incident_update_command(mocker):
    from Microsoft365Defender import microsoft_365_defender_incident_update_command

    client = mock_client(mocker, "update_incident", util_load_json("./test_data/incident_update_response.json"))
    args = {
        "id": "263",
        "tags": "test1,test2",
        "status": "Active",
        "classification": "Unknown",
        "determination": "NotAvailable",
        "assigned_to": "",
    }
    results = microsoft_365_defender_incident_update_command(client, args)
    check_api_response(results, util_load_json("./test_data/incident_update_results.json"))


def test_microsoft_365_defender_incident_get_command(mocker):
    from Microsoft365Defender import microsoft_365_defender_incident_get_command

    client = mock_client(mocker, "get_incident", util_load_json("./test_data/incident_get_response.json"))
    args = {"id": "263"}
    results = microsoft_365_defender_incident_get_command(client, args)
    check_api_response(results, util_load_json("./test_data/incident_get_results.json"))


def test_microsoft_365_defender_advanced_hunting_command(mocker):
    from Microsoft365Defender import microsoft_365_defender_advanced_hunting_command

    client = mock_client(mocker, "advanced_hunting", util_load_json("./test_data/advanced_hunting_response.json"))
    args = {"query": "AlertInfo"}
    results = microsoft_365_defender_advanced_hunting_command(client, args)
    check_api_response(results, util_load_json("./test_data/advanced_hunting_results.json"))


def fetch_check(mocker, client, last_run, first_fetch_time, fetch_limit, mock_results):
    mocker.patch.object(demisto, "getLastRun", return_value=last_run)
    mirroring_fields = {"mirror_direction": "Incoming", "mirror_instance": "1234"}
    results = fetch_incidents(client, mirroring_fields, first_fetch_time, fetch_limit)
    assert len(results) == len(mock_results)
    for incident, mock_incident in zip(results, mock_results):
        assert incident["name"] == mock_incident["name"]
        assert incident["occurred"] == mock_incident["occurred"]
        assert json.loads(incident["rawJSON"]) == json.loads(mock_incident["rawJSON"])


def test_fetch_incidents(mocker):
    """
    This test check for 4 fetch cycles.
        First - get all the incidents and fill the queue 127, returns 50
        Second - get 50 incidents from the queue
        Third - tries to fill the queue with new incidents but there are no new ones so returns all the remaining
                incidents in the queue
        Forth - tries to fill the queue with new incidents but there are no new ones so returns empty list
    """
    response_dict = util_load_json("./test_data/fetch_response.json")
    client = Client(app_id="app_id", verify=False, proxy=False, base_url="https://api.security.microsoft.com")
    mocker.patch.object(
        demisto, "getIntegrationContext", return_value={"current_refresh_token": "refresh_token", "access_token": "access_token"}
    )
    response_list = response_dict["response_list"]
    mocker.patch.object(client, "incidents_list", side_effect=response_list)

    first_fetch_time = "3000 days"
    fetch_limit = 50
    results = util_load_json("./test_data/fetch_results.json")

    for current_flow in ["first", "second", "third", "forth"]:
        fetch_check(
            mocker,
            client,
            response_dict[f"{current_flow}_last_run"],
            first_fetch_time,
            fetch_limit,
            results[f"{current_flow}_result"],
        )


@pytest.mark.parametrize(
    "query, limit, expected_has_limit",
    [
        ("a | b | limit 5", 10, True),
        ("a | b ", 10, True),
        ("a | b | limit 1 | take 1", 10, True),
        ('a | where Subject == "a || b" | limit  ', 10, True),
    ],
)
def test_query_set_limit(query: str, limit: int, expected_has_limit: bool):
    """Test that _query_set_limit adds or replaces limit correctly."""
    result = _query_set_limit(query, limit)

    # Verify limit is in the result
    if expected_has_limit:
        assert f"limit {limit}" in result, f"Expected 'limit {limit}' in result: {result}"

    # Verify original query components are preserved
    if "a | b" in query:
        assert "a" in result
        assert "b" in result
    if "where Subject" in query:
        assert "where Subject" in result


@pytest.mark.parametrize(
    "query, limit, expected_contains",
    [
        # Test case 1: Simple union with parentheses
        ("Table1 | take 1 | union (Table2 | where X > 5)", 10, ["Table1", "union (Table2 | where X > 5)", "limit 10"]),
        # Test case 2: Complex join from JIRA ticket (simplified)
        (
            "AADSignInEventsBeta | take 1 | join kind=somekind (DeviceLogonEvents | take 10) on DeviceName",
            20,
            ["AADSignInEventsBeta", "join kind=somekind (DeviceLogonEvents | take 10) on DeviceName", "limit 20"],
        ),
        # Test case 3: Nested parentheses
        (
            "Table1 | union (Table2 | join (Table3 | where Y == 1) on ID)",
            15,
            ["Table1", "union (Table2 | join (Table3 | where Y == 1) on ID)", "limit 15"],
        ),
        # Test case 4: Multiple joins with parentheses
        (
            "DeviceInfo | join AlertEvidence on DeviceId | join (DeviceEvents | where ActionType == 'test') on DeviceId",
            5,
            [
                "DeviceInfo",
                "join AlertEvidence on DeviceId",
                "join (DeviceEvents | where ActionType == 'test') on DeviceId",
                "limit 5",
            ],
        ),
        # Test case 5: Query already has limit at top level
        ("Table1 | union (Table2 | take 100) | limit 50", 10, ["Table1", "union (Table2 | take 100)", "limit 10"]),
        # Test case 6: Pipe in string literal (should be preserved via split/rejoin)
        ('Table | where A == "B | C"', 10, ['where A == "B | C"', "limit 10"]),
        # Test case 7: Logical OR operator || (should not be split)
        ("Table | where A == B || C == D", 10, ["where A == B || C == D", "limit 10"]),
        # Test case 8: Case-insensitive limit/take
        ("Table | TAKE 5", 10, ["limit 10"]),
        # Test case 9: Multiple top-level limits
        ("Table | limit 5 | limit 10", 20, ["limit 20", "limit 20"]),
    ],
)
def test_query_set_limit_complex_queries(query: str, limit: int, expected_contains: list):
    """
    Test _query_set_limit with complex queries containing parentheses, unions, and joins.
    This test validates the fix for XSUP-61445.

    Args:
        query: The input KQL query
        limit: The limit to apply
        expected_contains: List of strings that should be present in the result
    """
    result = _query_set_limit(query, limit)

    # Verify all expected components are in the result
    for expected in expected_contains:
        assert expected in result, f"Expected '{expected}' to be in result: {result}"

    # Verify parentheses are balanced
    assert result.count("(") == result.count(")"), f"Unbalanced parentheses in result: {result}"

    # Verify limit appears in the result
    assert f"limit {limit}" in result, f"Expected 'limit {limit}' in result: {result}"


def test_query_set_limit_negative_limit():
    """
    Test that negative limit returns the original query unchanged.
    """
    query = "DeviceEvents | where Timestamp > ago(1d)"
    result = _query_set_limit(query, -1)
    assert result == query


def test_query_set_limit_jira_ticket_example():
    """
    Test the exact query structure from JIRA ticket XSUP-61445.
    This validates that complex queries with multiple nested joins work correctly.
    """
    # Simplified version of the JIRA ticket query
    query = """AADSignInEventsBeta
| where DeviceName contains "hostname"
| extend Query = "AADSignInEventsBeta"
| take 1
| join kind=somekind (
DeviceLogonEvents
| extend Query1 = "DeviceLogonEvents"
| take 10
)
on DeviceName
| join kind=somekind (
DeviceInfo
| join AlertEvidence on DeviceId
| extend Query2 = "DeviceInfo"
| take 10
)
on DeviceName
| extend Query = strcat(Query, Query1, Query2)
| extend Timestamp = strcat(Timestamp, Timestamp1, Timestamp2)
| project Timestamp, Query
| take 21"""

    result = _query_set_limit(query, 50)

    # Verify the query structure is preserved - subqueries with pipes inside parentheses
    assert "(DeviceLogonEvents" in result or "(\nDeviceLogonEvents" in result
    assert "extend Query1" in result
    assert "(DeviceInfo" in result or "(\nDeviceInfo" in result
    assert "join AlertEvidence on DeviceId" in result

    # Verify parentheses are balanced
    assert result.count("(") == result.count(")"), "Unbalanced parentheses in result"

    # Verify limit 50 appears in the result (replaces the first top-level take)
    assert "limit 50" in result

    # Verify the inner takes (inside parentheses) are preserved
    assert result.count("take 10") == 2, "Both inner 'take 10' statements should be preserved"


def test_params(mocker):
    """
    Given:
      - Configuration parameters
    When:
      - The required parameter app_id is missed.
    Then:
      - Ensure the exception message as expected.
    """

    mocker.patch.object(demisto, "params", return_value={"_tenant_id": "_tenant_id", "credentials": {"password": "1234"}})
    mocker.patch.object(demisto, "error")
    return_error_mock = mocker.patch("Microsoft365Defender.return_error")

    main()

    assert "Application ID must be provided." in return_error_mock.call_args[0][0]


@pytest.mark.parametrize(argnames="client_id", argvalues=["test_client_id", None])
def test_test_module_command_with_managed_identities(mocker, requests_mock, client_id):
    """
    Given:
        - Managed Identities client id for authentication.
    When:
        - Calling test_module.
    Then:
        - Ensure the output are as expected.
    """

    import Microsoft365Defender
    from Microsoft365Defender import MANAGED_IDENTITIES_TOKEN_URL, Resources, main

    mock_token = {"access_token": "test_token", "expires_in": "86400"}
    get_mock = requests_mock.get(MANAGED_IDENTITIES_TOKEN_URL, json=mock_token)

    params = {
        "managed_identities_client_id": {"password": client_id},
        "use_managed_identities": "True",
        "base_url": "test_base_url",
    }
    mocker.patch.object(demisto, "params", return_value=params)
    mocker.patch.object(demisto, "command", return_value="test-module")
    mocker.patch.object(Microsoft365Defender, "return_results", return_value=params)
    mocker.patch("MicrosoftApiModule.get_integration_context", return_value={})

    main()

    assert "ok" in Microsoft365Defender.return_results.call_args[0][0]
    qs = get_mock.last_request.qs
    assert qs["resource"] == [Resources.security]
    assert (client_id and qs["client_id"] == [client_id]) or "client_id" not in qs


class MockMicrosoft365DefenderClient(Client):
    """
    A mock implementation of the Microsoft365DefenderClient for testing purposes.
    This class simulates API responses from Microsoft 365 Defender without making actual API calls.
    """

    def __init__(self, mocker, response_data: dict, app_id="app_id", verify=False, proxy=False):
        super().__init__(app_id, verify, proxy)
        self.response_data = response_data

    def incidents_list(self, *args, **kwargs) -> dict:
        """
        Simulates the `incidents_list` API call to retrieve a list of incidents.

        Args:
            *args: Positional arguments (not used in this mock implementation).
            **kwargs: Keyword arguments that can include:
                - "skip" (int): The number of entries to skip in the response data (used for pagination).

        Returns:
            dict: A simulated response containing a subset of incidents from the mock response data.
                The returned response includes:
                - "@odata.context": Context URL from the mock response data.
                - "value": A list of incidents based on the "skip" and batch size (MOCK_MAX_ENTRIES).

        Notes:
            - The response is controlled by the `MOCK_MAX_ENTRIES` constant to simulate pagination.
            - This method is useful for unit testing functions that rely on the `incidents_list` API call.
        """
        skip = kwargs.get("skip", 0)
        batch = self.response_data["value"][skip : skip + MOCK_MAX_ENTRIES]
        return {"@odata.context": self.response_data["@odata.context"], "value": batch}


@patch("Microsoft365Defender.MAX_ENTRIES", MOCK_MAX_ENTRIES)
def test_fetch_modified_incident_ids(mocker):
    """
    Given:
      - A list of mock incident responses from the Microsoft 365 Defender API.
    When:
      - Fetching modified incident IDs using a mock client.
    Then:
      - Ensure the returned incident IDs match the expected list of IDs in the mock response.
    """
    mock_responses = [
        util_load_json("./test_data/incidents_list_response.json"),
        util_load_json("./test_data/incidents_empty_list_response.json"),
    ]
    for mock_response in mock_responses:
        client = MockMicrosoft365DefenderClient(mocker, mock_response)
        result = fetch_modified_incident_ids(client, last_update_time="2021-03-01T00:00:00Z")
        expected_incidents = [str(incident["incidentId"]) for incident in mock_response["value"]]
        assert result == expected_incidents


def test_get_modified_remote_data_command(mocker):
    """
    Given:
      - Mocked arguments for fetching modified incident IDs.
    When:
      - Fetching modified remote data using a mock client.
    Then:
      - Ensure the returned modified incident IDs match the expected list.
    """
    import Microsoft365Defender

    mock_args = {"lastUpdate": "2023-01-01T12:00:00Z"}
    mocker.patch.object(Microsoft365Defender, "fetch_modified_incident_ids", return_value=["123", "456"])
    response = get_modified_remote_data_command(mock_client(mocker), mock_args)
    assert response.modified_incident_ids == ["123", "456"]


def test_get_modified_incidents_close_entries():
    """
    Given:
      - A list of resolved incidents with different classifications.
    When:
      - Generating close entries for incidents with the `close_incident` flag set to True.
    Then:
      - Ensure the correct close entries are generated with the expected contents.
    """

    resolved_incidents = [
        {"incidentId": "1234", "status": "Resolved", "classification": "TruePositive"},
        {"incidentId": "5678", "status": "Resolved", "classification": "Unknown"},
        {"incidentId": "9012", "status": "Resolved", "classification": "FalsePositive"},
        {"incidentId": "3456", "status": "Resolved", "classification": "InformationalExpectedActivity"},
    ]
    result = get_modified_incidents_close_or_repopen_entries(resolved_incidents, close_incident=True)

    assert len(result) == 4
    assert result[0]["Type"] == EntryType.NOTE
    assert result[0]["Contents"] == {
        "dbotIncidentClose": True,
        "closeReason": "Resolved",
    }
    assert result[1]["Contents"] == {
        "dbotIncidentClose": True,
        "closeReason": "Other",
    }
    assert result[2]["Contents"] == {
        "dbotIncidentClose": True,
        "closeReason": "False Positive",
    }
    assert result[3]["Contents"] == {
        "dbotIncidentClose": True,
        "closeReason": "Resolved",
    }


def test_get_modified_incidents_reopen_entries():
    """
    Given:
      - A list of unresolved incidents.
    When:
      - Generating reopen entries for incidents with the `close_incident` flag set to True.
    Then:
      - Ensure the correct reopen entries are generated with the expected contents.
    """
    unresolved_incidents = [{"incidentId": "1234", "status": "Active"}, {"incidentId": "5678", "status": "InProgress"}]
    result = get_modified_incidents_close_or_repopen_entries(unresolved_incidents, close_incident=True)
    assert len(result) == 2
    assert result[0] == {"dbotIncidentReopen": True}
    assert result[1] == {"dbotIncidentReopen": True}


def test_get_modified_incidents_close_incident_false():
    """
    Given:
      - A list of resolved incidents.
    When:
      - The `close_incident` flag is set to False.
    Then:
      - Ensure no close entries are generated.
    """

    resolved_incidents = [
        {"incidentId": "1234", "status": "Resolved", "classification": "TruePositive"},
        {"incidentId": "5678", "status": "Resolved", "classification": "Unknown"},
        {"incidentId": "9012", "status": "Resolved", "classification": "FalsePositive"},
        {"incidentId": "3456", "status": "Resolved", "classification": "InformationalExpectedActivity"},
    ]
    result = get_modified_incidents_close_or_repopen_entries(resolved_incidents, close_incident=False)
    assert result == []


def test_get_modified_incidents_empty_list():
    """
    Given:
      - An empty list of modified incidents.
    When:
      - Attempting to generate close or reopen entries.
    Then:
      - Ensure no entries are generated.
    """
    result = get_modified_incidents_close_or_repopen_entries([], close_incident=True)
    assert result == []


def test_get_entries_for_comments():
    """
    Given:
      - A list of comments with creation times and authors.
      - A last update time for filtering comments.
    When:
      - Generating entries for comments added after the last update time.
    Then:
      - Ensure the correct entries are generated with the expected contents and tags.
    """
    from Microsoft365Defender import get_entries_for_comments

    comments = [
        {"comment": "Old comment", "createdBy": "test1@gmail.com", "createdTime": "2024-01-01T10:00:00.8404534Z"},
        {"comment": "New comment", "createdBy": "test2@gmail.com", "createdTime": "2024-01-03T12:00:00.8404534Z"},
    ]
    last_update = datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)
    result = get_entries_for_comments(comments, last_update, COMMENT_TAG_FROM_MS)

    assert len(result) == 1
    assert result[0]["Contents"].startswith("Created By: test2@gmail.com")
    assert result[0]["Tags"] == [COMMENT_TAG_FROM_MS]


def test_get_entries_for_comments_ignores_mirrored_comments():
    """
    Given:
      - A list of comments, some of which are mirrored from XSOAR to Microsoft.
    When:
      - Generating entries for comments.
    Then:
      - Ensure mirrored comments are ignored, and only relevant comments are included.
    """

    from Microsoft365Defender import MIRRORED_OUT_XSOAR_ENTRY_TO_MICROSOFT_COMMENT_INDICATOR, get_entries_for_comments

    comments = [
        {
            "comment": f"Ignored comment {MIRRORED_OUT_XSOAR_ENTRY_TO_MICROSOFT_COMMENT_INDICATOR}",
            "createdBy": "test1@gmail.com",
            "createdTime": "2024-01-03T12:00:00.8404534Z",
        }
    ]

    last_update = datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)
    result = get_entries_for_comments(comments, last_update, COMMENT_TAG_FROM_MS)

    assert len(result) == 0


def test_get_entries_for_comments_empty_comments():
    """
    Given:
      - An empty list of comments.
    When:
      - Attempting to generate entries for comments.
    Then:
      - Ensure no entries are generated.
    """
    from Microsoft365Defender import get_entries_for_comments

    comments = []
    last_update = datetime.utcnow() - timedelta(days=1)
    result = get_entries_for_comments(comments, last_update, COMMENT_TAG_FROM_MS)

    assert len(result) == 0


def test_get_incident_entries(mocker):
    """
    Given:
      - A mirrored object with comments and a last update time.
      - Mocked helper functions for generating close/reopen and comment entries.
    When:
      - Generating incident entries for mirroring.
    Then:
      - Ensure the function combines the outputs of the helper functions into a single list.
    """
    mocker.patch(
        "Microsoft365Defender.get_modified_incidents_close_or_repopen_entries",
        return_value=[{"Type": 1, "Contents": "Mock close/reopen entry"}],
    )
    mocker.patch(
        "Microsoft365Defender.get_entries_for_comments",
        return_value=[{"Type": 1, "Contents": "Mock comment entry"}],
    )

    mirrored_object = {"id": "12345", "comments": [{"text": "Test comment", "timestamp": "2025-01-25T10:00:00Z"}]}
    last_update = datetime.strptime("2025-01-20T10:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
    close_incident = True

    from Microsoft365Defender import get_incident_entries

    entries = get_incident_entries(
        mirrored_object, last_update, MIRRORED_OUT_XSOAR_ENTRY_TO_MICROSOFT_COMMENT_INDICATOR, close_incident
    )

    assert len(entries) == 2  # Should combine outputs from both mocked functions
    assert {"Type": 1, "Contents": "Mock close/reopen entry"} in entries
    assert {"Type": 1, "Contents": "Mock comment entry"} in entries


def test_get_determination_value():
    """
    Given:
      - Valid and invalid combinations of classification and determination values.
    When:
      - Determining the correct determination value based on the classification.
    Then:
      - Ensure the correct value is returned or an appropriate exception is raised for invalid inputs.
    """
    # Test: Valid classification, no determination provided
    assert get_determination_value("TruePositive", None) == "Other"
    assert get_determination_value("Unknown", None) == "NotAvailable"

    # Test: Valid classification and determination
    assert get_determination_value("TruePositive", "Malware") == "Malware"
    assert get_determination_value("InformationalExpectedActivity", "ConfirmedActivity") == "ConfirmedActivity"

    # Test: Invalid classification
    with pytest.raises(Exception, match="Please provide a valid classification"):
        get_determination_value("InvalidClassification", "Malware")

    # Test: Valid classification but invalid determination
    with pytest.raises(Exception, match="Invalid determination. Please provide one of the following:"):
        get_determination_value("TruePositive", "InvalidDetermination")

    # Test: Valid classification with "Other" determination
    assert get_determination_value("TruePositive", "Other") == "Other"
    assert get_determination_value("InformationalExpectedActivity", "Other") == "Other"

    # Test: Edge case with valid classification and determination not matching any key
    with pytest.raises(Exception, match="Invalid determination. Please provide one of the following:"):
        get_determination_value("FalsePositive", "Phishing")


def test_get_meta_data_for_incident():
    """
    Given:
      - A raw incident dictionary with various fields and alerts.
    When:
      - Extracting metadata from the incident.
    Then:
      - Ensure the metadata is correctly calculated and matches the expected values.
    """
    from Microsoft365Defender import _get_meta_data_for_incident

    raw_incident = util_load_json("./test_data/raw_incident.json")
    metadata = _get_meta_data_for_incident(raw_incident)

    assert metadata["Categories"] == [
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
        "SuspiciousActivity",
    ]
    assert metadata["Impacted entities"] == []
    assert metadata["Active alerts"] == "0 / 12"
    assert metadata["Service sources"] == ["MicrosoftDefenderForEndpoint"]
    assert metadata["Detection sources"] == ["AutomatedInvestigation"]
    assert metadata["First activity"] == "2021-03-22T12:34:31.8123759Z"
    assert metadata["Last activity"] == "2021-03-22T12:59:07.526847Z"

    assert len(metadata["Devices"]) > 0
    assert metadata["Devices"][0]["device name"] == "deviceDnsName"
    assert metadata["Devices"][0]["risk level"] == "Informational"
    assert metadata["Devices"][0]["tags"] == "new test,test add tag,testing123"

    assert metadata["Mailboxes"] == []

    assert metadata["comments"] == []


def test_get_meta_data_empty_incident():
    """
    Given:
      - An empty raw incident dictionary.
    When:
      - Extracting metadata from the incident.
    Then:
      - Ensure the metadata contains empty or default values.
    """

    from Microsoft365Defender import _get_meta_data_for_incident

    raw_incident = {}
    metadata = _get_meta_data_for_incident(raw_incident)

    assert metadata["Categories"] == []
    assert metadata["Impacted entities"] == []
    assert metadata["Active alerts"] == "0 / 0"
    assert metadata["Service sources"] == []
    assert metadata["Detection sources"] == []
    assert metadata["First activity"] == ""
    assert metadata["Last activity"] == ""
    assert metadata["Devices"] == []
    assert metadata["Mailboxes"] == []
    assert metadata["comments"] == []


def test_get_meta_data_for_incident_user_entity():
    """
    Given:
      - A raw incident containing a User entity with accountName, domainName and userPrincipalName
        (reproduces XSUP-72109 "User login from a domain not seen in 60 days").
    When:
      - Extracting metadata from the incident.
    Then:
      - Ensure the full user identity is retained (userPrincipalName with the domain),
        instead of only the domain name.
    """
    from Microsoft365Defender import _get_meta_data_for_incident

    raw_incident = util_load_json("./test_data/raw_incident_with_user.json")
    metadata = _get_meta_data_for_incident(raw_incident)

    assert metadata["Impacted entities"] == ["username@domain (BOQDEVUSER.LOCAL)"]


def test_fetch_modified_incident(mocker):
    """
    Given:
      - A valid incident ID and mock metadata for the incident.
    When:
      - Fetching a modified incident.
    Then:
      - Ensure the fetched incident includes the metadata and the expected fields.
    """
    client = mock_client(mocker, "get_incident", util_load_json("./test_data/incident_get_response.json"))
    mock_meta_data = mocker.patch(
        "Microsoft365Defender._get_meta_data_for_incident",
        return_value={
            "Categories": ["SuspiciousActivity"],
            "Impacted entities": [],
            "Active alerts": "0 / 1",
            "Service sources": ["MicrosoftDefenderForEndpoint"],
            "Detection sources": ["AutomatedInvestigation"],
            "First activity": "2021-03-22T12:34:31.8123759Z",
            "Last activity": "2021-03-22T12:34:31.8123759Z",
            "Devices": [
                {"device name": "deviceDnsName", "risk level": "Informational", "tags": "new test,test add tag,testing123"}
            ],
            "Mailboxes": [],
            "comments": [],
        },
    )
    incident_id = 263
    incident = fetch_modified_incident(client, incident_id)

    assert "incidentId" in incident
    assert incident["incidentId"] == 263
    assert "@odata.context" not in incident
    assert "Categories" in incident
    assert incident["Categories"] == ["SuspiciousActivity"]
    assert "Devices" in incident
    assert incident["Devices"][0] == {
        "device name": "deviceDnsName",
        "risk level": "Informational",
        "tags": "new test,test add tag,testing123",
    }
    assert mock_meta_data.called


def test_get_remote_data_command_success(mocker):
    """
    Given:
      - Valid arguments for fetching remote data and mock metadata for the incident.
    When:
      - Fetching remote data successfully.
    Then:
      - Ensure the correct incident and entries are returned.
    """

    params = {"comment_tag_from_microsoft365defender": "CommentFromMicrosoft365Defender", "close_incident": True}
    mocker.patch.object(demisto, "params", return_value=params)

    mocker.patch(
        "Microsoft365Defender.fetch_modified_incident",
        return_value={
            "incidentId": 12345,
            "status": "Active",
            "comments": [{"comment": "Test comment", "timestamp": "2025-01-01T12:01:00Z"}],
            "alerts": [{"alertId": "alert1"}],
        },
    )

    mocker.patch("Microsoft365Defender.get_incident_entries", return_value=[{"Type": 1, "Contents": "Test entry"}])

    args = {"id": "12345", "lastUpdate": "2025-01-01T12:00:00Z"}

    response = get_remote_data_command(mock_client(mocker), args)

    assert response.mirrored_object["incidentId"] == 12345
    assert len(response.entries) == 1
    assert response.entries[0]["Contents"] == "Test entry"


def test_handle_incident_close_out_or_reactivation_close(mocker):
    """
    Given:
      - An incident with a close reason and the status set to DONE.
    When:
      - Handling the incident close-out with the `close_out` flag enabled.
    Then:
      - Ensure the incident status, classification, and determination are updated correctly.
    """

    params = {"close_out": True}
    mocker.patch.object(demisto, "params", return_value=params)

    delta = {"closeReason": "FalsePositive", "closeNotes": "This was a false positive alert", "closingUserId": "user123"}
    incident_status = IncidentStatus.DONE

    handle_incident_close_out_or_reactivation(delta, incident_status)

    assert delta["status"] == "Resolved"
    assert delta["classification"] == "FalsePositive"
    assert delta["determination"] == "Other"


def test_handle_incident_close_out_or_reactivation_close_other(mocker):
    """
    Given:
      - An incident with a close reason and the status set to DONE.
    When:
      - Handling the incident close-out with the `close_out` flag enabled.
    Then:
      - Ensure the incident status, classification, and determination are updated correctly.
    """

    params = {"close_out": True}
    mocker.patch.object(demisto, "params", return_value=params)
    delta = {"closeReason": "Other", "closeNotes": "General closure", "closingUserId": "user123"}
    incident_status = IncidentStatus.DONE

    handle_incident_close_out_or_reactivation(delta, incident_status)

    assert delta["status"] == "Resolved"
    assert delta["classification"] == "Unknown"
    assert delta["determination"] == "NotAvailable"


def test_handle_incident_close_out_or_reactivation_reopen(mocker):
    """
    Given:
      - An incident with missing close-out information.
    When:
      - Handling the incident reopen process with the `close_out` flag enabled.
    Then:
      - Ensure the incident status is set to Active.
    """
    params = {"close_out": True}
    mocker.patch.object(demisto, "params", return_value=params)
    delta = {"closeReason": "", "closeNotes": "", "closingUserId": ""}
    incident_status = IncidentStatus.ACTIVE

    handle_incident_close_out_or_reactivation(delta, incident_status)

    assert delta["status"] == "Active"


def test_handle_incident_close_out_or_reactivation_close_out_disabled(mocker):
    """
    Test that the function exits early when 'close_out' is disabled.
    """
    params = {"close_out": False}
    mocker.patch.object(demisto, "params", return_value=params)
    delta = {"closeReason": "FalsePositive", "closeNotes": "This was a false positive alert", "closingUserId": "user123"}
    incident_status = IncidentStatus.DONE

    handle_incident_close_out_or_reactivation(delta, incident_status)

    # Delta should remain unchanged
    assert "status" not in delta
    assert "classification" not in delta
    assert "determination" not in delta


def test_handle_incident_close_out_or_reactivation_no_delta_changes(mocker):
    """
    Given:
      - The `close_out` parameter is enabled in the integration's configuration.
      - An empty `delta` dictionary with no keys indicating incident changes.
      - An incident status set to DONE.
    When:
      - Attempting to handle the close-out or reactivation of the incident.
    Then:
      - Ensure the function exits early without modifying the `delta` dictionary.
      - Verify that the `status`, `classification`, and `determination` keys remain absent from the `delta`.
    """
    params = {"close_out": True}
    mocker.patch.object(demisto, "params", return_value=params)
    delta = {}
    incident_status = IncidentStatus.DONE

    handle_incident_close_out_or_reactivation(delta, incident_status)

    assert "status" not in delta
    assert "classification" not in delta
    assert "determination" not in delta


def test_mirror_out_entries_with_comment_tag(mocker):
    """
    Given:
      - A list of entries containing the comment tag for mirroring.
    When:
      - Mirroring out entries to Microsoft 365 Defender.
    Then:
      - Ensure the correct entries are mirrored, and the update_incident method is called.
    """
    client = mock_client(mocker, "update_incident", util_load_json("./test_data/incident_update_response.json"))

    comment_tag = "CommentFromXSOAR"
    entries = [{"id": 1, "type": "note", "tags": [comment_tag], "user": "user1", "contents": "Test content", "format": "text"}]
    remote_incident_id = 12345

    mirror_out_entries(client, entries, comment_tag, remote_incident_id)

    assert client.update_incident.call_count == 1
    client.update_incident.assert_any_call(
        incident_id=remote_incident_id,
        timeout=30,
        comment=f"(user1): Test content\n\n {MIRRORED_OUT_XSOAR_ENTRY_TO_MICROSOFT_COMMENT_INDICATOR}",
    )


def test_mirror_out_entries_without_comment_tag(mocker):
    """
    Given:
      - A list of entries where none contain the specified comment tag.
      - A valid client for updating incidents in Microsoft 365 Defender.
      - A remote incident ID to update.
    When:
      - Executing the `mirror_out_entries` function.
    Then:
      - Ensure that the `update_incident` method is not called since none of the entries have the required comment tag.
    """

    client = mock_client(mocker, "update_incident", util_load_json("./test_data/incident_update_response.json"))

    comment_tag = "CommentFromXSOAR"
    entries = [
        {"id": 1, "type": "note", "tags": [], "user": "user1", "contents": "Test content"},
        {"id": 2, "type": "note", "tags": ["UnrelatedTag"], "user": "user2", "contents": "Another test content"},
    ]
    remote_incident_id = 12345

    mirror_out_entries(client, entries, comment_tag, remote_incident_id)

    client.update_incident.assert_not_called()


def test_mirror_out_entries_empty_entries(mocker):
    """
    Given:
      - An empty list of entries to mirror out.
      - A valid client for updating incidents in Microsoft 365 Defender.
      - A remote incident ID to update.
    When:
      - Executing the `mirror_out_entries` function with no entries.
    Then:
      - Ensure that the `update_incident` method is not called since there are no entries to process.
    """
    client = mock_client(mocker, "update_incident", util_load_json("./test_data/incident_update_response.json"))

    comment_tag = "CommentFromXSOAR"
    entries = []
    remote_incident_id = 12345

    mirror_out_entries(client, entries, comment_tag, remote_incident_id)

    client.update_incident.assert_not_called()


def test_update_remote_system_with_incident_changes(mocker):
    """
    Given:
      - A valid client for updating incidents in Microsoft 365 Defender.
      - Arguments indicating that the incident has changes (`incidentChanged=True`) and includes details in `delta`.
    When:
      - Executing the `update_remote_system_command` function with the provided arguments.
    Then:
      - Ensure that the `update_incident` method is called with the correct parameters extracted from `delta`.
      - Ensure that the function returns the correct remote incident ID.
    """
    client = mock_client(mocker, "update_incident", {"status": "success"})
    mocker.patch.object(demisto, "params", return_value={})

    args = {
        "remoteId": "12345",
        "data": {"name": "incident"},
        "delta": {"status": "Resolved", "assignedTo": "user1", "tags": "test_tag", "comment": "Test comment"},
        "incidentChanged": True,
        "status": "DONE",
        "entries": [],
    }

    result = update_remote_system_command(client, args)

    assert result == "12345"
    client.update_incident.assert_called_once_with(
        incident_id="12345",
        status="Resolved",
        assigned_to="user1",
        classification=None,
        determination=None,
        tags=["test_tag"],
        timeout=30,
        comment="Test comment",
    )


def test_update_remote_system_without_incident_changes(mocker):
    """
    Given:
      - A valid client for updating incidents in Microsoft 365 Defender.
      - Arguments indicating that the incident has no changes (`incidentChanged=False`) and `delta` is None.
    When:
      - Executing the `update_remote_system_command` function with the provided arguments.
    Then:
      - Ensure that the `update_incident` method is not called as there are no changes to apply.
      - Ensure that the function returns the correct remote incident ID.
    """
    client = mock_client(mocker, "update_incident", {"status": "success"})

    args = {
        "remoteId": "12345",
        "data": {"name": "incident"},
        "delta": None,
        "incidentChanged": False,
        "status": "DONE",
        "entries": [],
    }

    result = update_remote_system_command(client, args)

    assert result == "12345"
    client.update_incident.assert_not_called()


def test_update_remote_system_with_entries(mocker):
    """
    Given:
      - A valid client for updating incidents in Microsoft 365 Defender.
      - Arguments with entries that need to be mirrored out, but no changes to the incident (`incidentChanged=False`).
    When:
      - Executing the `update_remote_system_command` function with entries to mirror.
    Then:
      - Ensure the `mirror_out_entries` function is called with the correct arguments.
      - Ensure that the `update_incident` method is not called as there are no changes to the incident.
      - Ensure the function returns the correct remote incident ID.
    """
    client = mock_client(mocker, "update_incident", {"status": "success"})
    mocker.patch.object(demisto, "params", return_value={})
    mocker.patch("Microsoft365Defender.mirror_out_entries", return_value=None)

    args = {
        "remoteId": "12345",
        "data": {"name": "incident"},
        "delta": None,
        "incidentChanged": False,
        "status": "ACTIVE",
        "entries": [{"id": 1, "tags": ["CommentToMicrosoft365Defender"], "contents": "Test entry"}],
    }

    result = update_remote_system_command(client, args)

    assert result == "12345"
    client.update_incident.assert_not_called()
    from Microsoft365Defender import mirror_out_entries

    mirror_out_entries.assert_called_once_with(
        client,
        [{"id": 1, "tags": ["CommentToMicrosoft365Defender"], "contents": "Test entry"}],
        "CommentToMicrosoft365Defender",
        "12345",
    )

    def test_get_modified_incidents_custom_mapping_hit(mocker):
        """
        Ensure that when custom_defender_to_xsoar_close_reason is enabled and the mapping contains
        the incident classification, the custom mapping value is used as the closeReason.
        """
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "custom_defender_to_xsoar_close_reason": True,
                "custom_defender_to_xsoar_close_reason_mapping": "TruePositive:CustomResolved,FalsePositive:CustomFP",
            },
        )

        modified_incidents = [{"incidentId": "1", "status": "Resolved", "classification": "TruePositive"}]
        result = get_modified_incidents_close_or_repopen_entries(modified_incidents, close_incident=True)

        assert len(result) == 1
        assert result[0]["Type"] == EntryType.NOTE
        assert result[0]["Contents"]["dbotIncidentClose"] is True
        assert result[0]["Contents"]["closeReason"] == "CustomResolved"

    def test_get_modified_incidents_custom_mapping_miss(mocker):
        """
        Ensure that when custom_defender_to_xsoar_close_reason is enabled but the mapping does not contain
        the incident classification, the default MICROSOFT_RESOLVED_CLASSIFICATION_TO_XSOAR_CLOSE_REASON mapping is used.
        """
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "custom_defender_to_xsoar_close_reason": True,
                # mapping intentionally does NOT include TruePositive
                "custom_defender_to_xsoar_close_reason_mapping": "Other:Something,Unknown:OtherCustom",
            },
        )

        modified_incidents = [{"incidentId": "2", "status": "Resolved", "classification": "TruePositive"}]
        result = get_modified_incidents_close_or_repopen_entries(modified_incidents, close_incident=True)

        assert len(result) == 1
        assert result[0]["Type"] == EntryType.NOTE
        assert result[0]["Contents"]["dbotIncidentClose"] is True
        # fallback to default mapping for TruePositive -> "Resolved"
        assert result[0]["Contents"]["closeReason"] == "Resolved"

    def test_default_close_reason_false_positive_updates_classification(mocker):
        """
        Given:
            closeReason is FalsePositive and classification is different
        When:
            _get_default_incident_close_out_or_reactivation_reason is called
        Then:
            classification and determination are updated to FalsePositive / Other
        """
        mocker.patch("Microsoft365Defender.demisto.debug")

        delta = {
            "closeReason": "FalsePositive",
            "classification": "TruePositive",
        }

        _get_default_incident_close_out_or_reactivation_reason(delta)

        assert delta["classification"] == "FalsePositive"
        assert delta["determination"] == "Other"

    def test_default_close_reason_other_sets_unknown(mocker):
        """
        Given:
            closeReason is Other
        Then:
            classification is Unknown and determination is NotAvailable
        """
        mocker.patch("Microsoft365Defender.demisto.debug")

        delta = {"closeReason": "Other"}

        _get_default_incident_close_out_or_reactivation_reason(delta)

        assert delta["classification"] == "Unknown"
        assert delta["determination"] == "NotAvailable"

    def test_default_close_reason_duplicate_sets_unknown(mocker):
        """
        Given:
            closeReason is Duplicate
        Then:
            classification is Unknown and determination is NotAvailable
        """
        mocker.patch("Microsoft365Defender.demisto.debug")

        delta = {"closeReason": "Duplicate"}

        _get_default_incident_close_out_or_reactivation_reason(delta)

        assert delta["classification"] == "Unknown"
        assert delta["determination"] == "NotAvailable"

    def test_default_close_reason_no_match_no_change(mocker):
        """
        Given:
            closeReason does not match any condition
        Then:
            delta is not modified
        """
        mocker.patch("Microsoft365Defender.demisto.debug")

        delta = {"closeReason": "Resolved"}

        _get_default_incident_close_out_or_reactivation_reason(delta)

        assert delta == {"closeReason": "Resolved"}

    def test_get_default_modified_incident_close_reason_known_classification():
        """
        Given:
            Incident with known classification
        Then:
            closeReason is taken from MICROSOFT_RESOLVED_CLASSIFICATION_TO_XSOAR_CLOSE_REASON
        """
        incident = {"classification": "TruePositive"}

        result = _get_default_modified_incidents_close_or_reopen_entries_reason(incident)

        assert result["Type"] == EntryType.NOTE
        assert result["Contents"]["dbotIncidentClose"] is True
        assert result["Contents"]["closeReason"] == MICROSOFT_RESOLVED_CLASSIFICATION_TO_XSOAR_CLOSE_REASON["TruePositive"]

    def test_get_default_modified_incident_close_reason_unknown_classification():
        """
        Given:
            Incident with unknown classification
        Then:
            closeReason defaults to 'Other'
        """
        incident = {"classification": "NonExistingClassification"}

        result = _get_default_modified_incidents_close_or_reopen_entries_reason(incident)

        assert result["Type"] == EntryType.NOTE
        assert result["Contents"]["dbotIncidentClose"] is True
        assert result["Contents"]["closeReason"] == "Other"

    def test_get_default_modified_incident_close_reason_missing_classification():
        """
        Given:
            Incident without classification field
        Then:
            classification defaults to Unknown and closeReason resolves accordingly
        """
        incident = {}

        result = _get_default_modified_incidents_close_or_reopen_entries_reason(incident)

        assert result["Type"] == EntryType.NOTE
        assert result["Contents"]["dbotIncidentClose"] is True
        assert result["Contents"]["closeReason"] == "Other"

    def test_handle_incident_close_out_custom_reason_valid_mapping(mocker):
        """
        Given:
            custom_xsoar_to_defender_close_reason is enabled
            mapping returns 'TruePositive-Malware'
        Then:
            classification and determination are parsed correctly
        """

        # Mock integration params
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "close_out": True,
                "custom_xsoar_to_defender_close_reason": True,
            },
        )

        # Mock mapping dict used in the function
        mocker.patch(
            "Microsoft365Defender.XSOAR_CLOSE_REASON_TO_MICROSOFT_CLASSIFICATION",
            {"Resolved": "TruePositive-Malware"},
        )

        delta = {
            "closeReason": "Resolved",
            "closeNotes": "done",
        }

        handle_incident_close_out_or_reactivation(delta, IncidentStatus.DONE)

        assert delta["classification"] == "TruePositive"
        assert delta["determination"] == "Malware"
        assert delta["status"] == "Resolved"

    def test_handle_incident_close_out_custom_reason_invalid_mapping_format(mocker):
        """
        Given:
            custom mapping returns value without hyphen
        Then:
            fallback classification/determination are used
        """

        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "close_out": True,
                "custom_xsoar_to_defender_close_reason": True,
            },
        )

        mocker.patch(
            "Microsoft365Defender.XSOAR_CLOSE_REASON_TO_MICROSOFT_CLASSIFICATION",
            {"Resolved": "BadFormatValue"},
        )

        delta = {
            "closeReason": "Resolved",
            "closeNotes": "done",
        }

        handle_incident_close_out_or_reactivation(delta, IncidentStatus.DONE)

        assert delta["classification"] == "Unknown"
        assert delta["determination"] == "NotAvailable"