diff --git a/backend/Config/PermissionsTranslator.json b/backend/Config/PermissionsTranslator.json index c8a297b6e5..30c15bbb5b 100644 --- a/backend/Config/PermissionsTranslator.json +++ b/backend/Config/PermissionsTranslator.json @@ -1,4 +1,18 @@ [ + { + "description": "Allows the app to read email metadata and security detection details for all emails in your organization, without a signed-in user.", + "displayName": "Read metadata and detection details for all emails in your organization", + "id": "b48f7ac2-044d-4281-b02f-75db744d6f5f", + "origin": "Application", + "value": "SecurityAnalyzedMessage.Read.All" + }, + { + "description": "Allows the app to read email metadata and security detection details, and execute remediation actions like deleting an email, for all emails in your organization, without a signed-in user.", + "displayName": "Read metadata, detection details, and execute remediation actions on all emails in your organization", + "id": "04c55753-2244-4c25-87fc-704ab82a4f69", + "origin": "Application", + "value": "SecurityAnalyzedMessage.ReadWrite.All" + }, { "description": "Allows the app to impersonate the signed-in user to access the Partner Center API.", "displayName": "Partner Center as User", diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index e5e7d7e0a5..b45ebc9b02 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -24265,6 +24265,157 @@ "x-cipp-role": "Exchange.Mailbox.ReadWrite" } }, + "/api/ExecMailQuarantineSubmit": { + "post": { + "summary": "ExecMailQuarantineSubmit", + "operationId": "ExecMailQuarantineSubmit", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Submits a quarantined email message to Microsoft for review (threat submission) via the Graph API.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "$ref": "#/components/schemas/LabelValue" + }, + "Identity": { + "type": "string" + }, + "RecipientAddress": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "Identity", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "adminReview": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "attackSimulationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "category": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "clientSource": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "contentType": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdBy": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "internetMessageId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "originalCategory": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "receivedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "recipientEmailAddress": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "result": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "Results": { + "x-cipp-field-source": "backend" + }, + "sender": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "senderIP": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "source": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "status": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "subject": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "tenantAllowOrBlockListAction": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "tenantId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.ReadWrite" + } + }, "/api/ExecMailTest": { "get": { "summary": "ExecMailTest", @@ -28000,6 +28151,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -48631,6 +48785,15 @@ ], "description": "Lists quarantined email messages in Exchange Online Protection for a tenant.", "parameters": [ + { + "name": "EntityType", + "in": "query", + "description": "Entity type: Email (default), SharePointOnline (files) or Teams (Teams messages)", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "manualPagination", "in": "query", @@ -48667,13 +48830,16 @@ "type": "string", "x-cipp-field-source": "storage" }, + "Expires": { + "x-cipp-field-source": "frontend" + }, "Metadata": { "x-cipp-field-source": "backend" }, "PartitionKey": { "x-cipp-field-source": "storage" }, - "PolicyName": { + "PolicyType": { "x-cipp-field-source": "frontend" }, "QuarantineMessage": { @@ -48686,6 +48852,9 @@ "RecipientAddress": { "x-cipp-field-source": "frontend" }, + "ReleasedUser": { + "x-cipp-field-source": "frontend" + }, "ReleaseStatus": { "x-cipp-field-source": "frontend" }, @@ -48785,6 +48954,178 @@ "x-cipp-role": "Exchange.SpamFilter.Read" } }, + "/api/ListMailQuarantineMessageDetails": { + "get": { + "summary": "ListMailQuarantineMessageDetails", + "operationId": "ListMailQuarantineMessageDetails", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Retrieves Defender analyzed email details (threats, delivery, authentication, URLs, attachments)\nfor a quarantined message via the Graph beta security/collaboration/analyzedEmails API.\nFalls back to parsing the message headers (Authentication-Results and X-Forefront-Antispam-Report)\nfor tenants without Defender for Office 365 Plan 2.", + "parameters": [ + { + "name": "Identity", + "in": "query", + "description": "Only the quarantine Identity is trusted from the caller. NetworkMessageId, RecipientAddress and ReceivedTime are derived server-side from the quarantine message itself (see below) so this endpoint cannot be used to pull Defender analyzedEmail data for arbitrary, non-quarantined messages in the tenant.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "authenticationDetails": { + "type": "object", + "x-cipp-field-source": "graph,backend" + }, + "bulkComplaintLevel": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "compositeAuthentication": { + "x-cipp-field-source": "backend" + }, + "directionality": { + "type": "object", + "x-cipp-field-source": "graph,backend" + }, + "displayName": { + "x-cipp-field-source": "backend" + }, + "dkim": { + "x-cipp-field-source": "backend" + }, + "dmarc": { + "x-cipp-field-source": "backend" + }, + "internetMessageId": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "ipv4": { + "x-cipp-field-source": "backend" + }, + "language": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "location": { + "x-cipp-field-source": "backend" + }, + "recipientEmailAddress": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "returnPath": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "senderDetail": { + "type": "object", + "x-cipp-field-source": "graph,backend" + }, + "senderPolicyFramework": { + "x-cipp-field-source": "backend" + }, + "spamConfidenceLevel": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "threatTypes": { + "type": "array", + "x-cipp-field-source": "graph,backend" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, + "/api/ListMailQuarantineMessageHeader": { + "get": { + "summary": "ListMailQuarantineMessageHeader", + "operationId": "ListMailQuarantineMessageHeader", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Retrieves the message headers of a specific quarantined email message by its Identity.", + "parameters": [ + { + "name": "Identity", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, "/api/ListMalwareFilters": { "get": { "summary": "ListMalwareFilters", diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 index 515d2e0dd0..dbd3138340 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 @@ -11,15 +11,28 @@ Write-Host "PowerShell queue trigger function processed work item: $($Tenant.defaultDomainName)" try { - $Page = 1 $PageSize = 1000 $quarantineMessages = [System.Collections.Generic.List[object]]::new() - do { - $Results = New-ExoRequest -tenantid $domainName -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = $PageSize; Page = $Page } | Select-Object -ExcludeProperty *data.type* - if ($Results) { $quarantineMessages.AddRange(@($Results)) } - $Page++ - } while (@($Results).Count -eq $PageSize) + # Email is available everywhere; SharePointOnline/Teams quarantine requires Defender for Office 365, + # so fetch each entity type separately and tolerate per-type failures on unlicensed tenants. + # EXO REST silently ignores -EntityType SharePointOnline; the documented filter for Safe Attachments + # files is -QuarantineTypes SPOMalware. Email/Teams work fine via -EntityType. + foreach ($EntityType in @('Email', 'SharePointOnline', 'Teams')) { + $EntityTypeParams = if ($EntityType -eq 'SharePointOnline') { @{ QuarantineTypes = 'SPOMalware' } } else { @{ EntityType = $EntityType } } + try { + $Page = 1 + do { + $Results = New-ExoRequest -tenantid $domainName -cmdlet 'Get-QuarantineMessage' -cmdParams (@{ PageSize = $PageSize; Page = $Page } + $EntityTypeParams) | Select-Object -ExcludeProperty *data.type* + if ($Results) { $quarantineMessages.AddRange(@($Results)) } + $Page++ + } while (@($Results).Count -eq $PageSize) + } catch { + if ($EntityType -eq 'Email') { throw } + Write-Host "Could not get $EntityType quarantine messages for $domainName : $($_.Exception.Message)" + } + } foreach ($message in $quarantineMessages) { + Add-CIPPQuarantineMessageProperties -Message $message -Tenant $domainName -CustomerId $Tenant.customerId $messageData = @{ QuarantineMessage = [string]($message | ConvertTo-Json -Depth 10 -Compress) RowKey = [string](New-Guid).Guid diff --git a/backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 b/backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 new file mode 100644 index 0000000000..f039777f00 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 @@ -0,0 +1,21 @@ +function Add-CIPPQuarantineMessageProperties { + <# + .SYNOPSIS + Adds CIPP computed properties to a quarantine message object. + .DESCRIPTION + Enriches Get-QuarantineMessage output with Tenant, CustomerId and NetworkMessageId. + NetworkMessageId is the first half of the quarantine Identity ({NetworkMessageId}\{RecipientGuid}) + and is used by the frontend to build Microsoft Defender email entity deep links. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)]$Message, + [Parameter(Mandatory = $true)][string]$Tenant, + [string]$CustomerId + ) + $Message | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + if ($CustomerId) { + $Message | Add-Member -NotePropertyName 'CustomerId' -NotePropertyValue $CustomerId -Force + } + $Message | Add-Member -NotePropertyName 'NetworkMessageId' -NotePropertyValue ([string]($Message.Identity -split '\\')[0]) -Force +} diff --git a/backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 b/backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 new file mode 100644 index 0000000000..f9028a7f84 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 @@ -0,0 +1,363 @@ +function Read-CippMimeMessage { + <# + .SYNOPSIS + Extract URLs and attachments from a raw MIME message. + .DESCRIPTION + Pure PowerShell MIME parser for common quarantine EML structures. Handles nested + multipart messages, base64 and quoted-printable bodies, and common filename + parameters. RFC 2231 split filenames, exotic charset conversions, and TNEF + winmail.dat payloads are not fully handled. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Message + ) + + $Urls = [System.Collections.Generic.List[object]]::new() + $Attachments = [System.Collections.Generic.List[object]]::new() + $UrlKeys = @{} + + function Split-CippMimeEntity { + param([AllowEmptyString()][string]$EntityText) + + $Match = [regex]::Match($EntityText, "\r?\n\r?\n") + if ($Match.Success) { + $HeaderText = $EntityText.Substring(0, $Match.Index) + $BodyText = $EntityText.Substring($Match.Index + $Match.Length) + } else { + $HeaderText = $EntityText + $BodyText = '' + } + + $Headers = @{} + $UnfoldedHeaders = $HeaderText -replace "(?m)\r?\n[ \t]+", ' ' + foreach ($Line in ($UnfoldedHeaders -split "\r?\n")) { + if ($Line -match '^\s*([^:]+):\s*(.*)$') { + $Name = $Matches[1].Trim() + $Value = $Matches[2].Trim() + if ($Headers.ContainsKey($Name)) { + $Headers[$Name] = @($Headers[$Name], $Value) -join ', ' + } else { + $Headers[$Name] = $Value + } + } + } + + [PSCustomObject]@{ + Headers = $Headers + Body = $BodyText + } + } + + function ConvertFrom-CippMimeQuotedString { + param([AllowEmptyString()][string]$Value) + + $Trimmed = $Value.Trim() + if ($Trimmed.Length -ge 2 -and $Trimmed.StartsWith('"') -and $Trimmed.EndsWith('"')) { + $Trimmed = $Trimmed.Substring(1, $Trimmed.Length - 2) + $Trimmed = $Trimmed -replace '\\(.)', '$1' + } + + $Trimmed + } + + function ConvertFrom-CippMimeExtendedParameter { + param([AllowEmptyString()][string]$Value) + + $Decoded = ConvertFrom-CippMimeQuotedString -Value $Value + if ($Decoded -match "^([^']*)'[^']*'(.*)$") { + $Charset = $Matches[1] + $EncodedValue = $Matches[2] + try { + if (![string]::IsNullOrWhiteSpace($Charset)) { + # Decode percent-encoded bytes with the declared charset instead of letting + # UnescapeDataString assume UTF-16; this avoids double-decoding UTF-8 values. + try { + $Encoding = [System.Text.Encoding]::GetEncoding($Charset) + $PercentBytes = [System.Collections.Generic.List[byte]]::new() + $i = 0 + while ($i -lt $EncodedValue.Length) { + if ($EncodedValue[$i] -eq '%' -and ($i + 2) -lt $EncodedValue.Length) { + $Hex = $EncodedValue.Substring($i + 1, 2) + if ($Hex -match '^[0-9A-Fa-f]{2}$') { + $PercentBytes.Add([Convert]::ToByte($Hex, 16)) + $i += 3 + continue + } + } + $PercentBytes.Add([byte][char]$EncodedValue[$i]) + $i++ + } + return $Encoding.GetString($PercentBytes.ToArray()) + } catch { + return [System.Uri]::UnescapeDataString($EncodedValue) + } + } + return [System.Uri]::UnescapeDataString($EncodedValue) + } catch { + return $Decoded + } + } + + $Decoded + } + + function Split-CippMimeHeaderParameters { + param([AllowEmptyString()][string]$HeaderValue) + + $Segments = [System.Collections.Generic.List[string]]::new() + $Current = [System.Text.StringBuilder]::new() + $InQuotes = $false + $Escaped = $false + + foreach ($Char in $HeaderValue.ToCharArray()) { + if ($Escaped) { + [void]$Current.Append($Char) + $Escaped = $false + continue + } + + if ($Char -eq '\' -and $InQuotes) { + [void]$Current.Append($Char) + $Escaped = $true + continue + } + + if ($Char -eq '"') { + [void]$Current.Append($Char) + $InQuotes = !$InQuotes + continue + } + + if ($Char -eq ';' -and !$InQuotes) { + $Segments.Add($Current.ToString().Trim()) + [void]$Current.Clear() + continue + } + + [void]$Current.Append($Char) + } + $Segments.Add($Current.ToString().Trim()) + + $Parameters = @{} + for ($Index = 1; $Index -lt $Segments.Count; $Index++) { + $Key, $Value = $Segments[$Index] -split '=', 2 + if ([string]::IsNullOrWhiteSpace($Key) -or $null -eq $Value) { continue } + + $ParameterName = $Key.Trim().ToLowerInvariant() + if ($ParameterName.EndsWith('*')) { + $Parameters[$ParameterName] = ConvertFrom-CippMimeExtendedParameter -Value $Value + } else { + $Parameters[$ParameterName] = ConvertFrom-CippMimeQuotedString -Value $Value + } + } + + [PSCustomObject]@{ + Value = ($Segments[0] ?? '').Trim().ToLowerInvariant() + Parameters = $Parameters + } + } + + function Split-CippMimeMultipartBody { + param( + [AllowEmptyString()][string]$Body, + [Parameter(Mandatory = $true)][string]$Boundary + ) + + $Parts = [System.Collections.Generic.List[string]]::new() + $BoundaryPattern = '^--' + [regex]::Escape($Boundary) + '(?--)?[ \t]*$' + $CurrentLines = [System.Collections.Generic.List[string]]::new() + $InPart = $false + + foreach ($Line in ($Body -split "\r?\n")) { + $BoundaryMatch = [regex]::Match($Line, $BoundaryPattern) + if ($BoundaryMatch.Success) { + if ($InPart) { + $Parts.Add(($CurrentLines -join "`r`n")) + $CurrentLines.Clear() + } + if ($BoundaryMatch.Groups['Closing'].Success) { + break + } + $InPart = $true + continue + } + + if ($InPart) { + $CurrentLines.Add($Line) + } + } + + @($Parts) + } + + function ConvertFrom-CippMimeQuotedPrintable { + param([AllowEmptyString()][string]$Body) + + $Stream = [System.IO.MemoryStream]::new() + try { + for ($Index = 0; $Index -lt $Body.Length; $Index++) { + $Char = $Body[$Index] + if ($Char -eq '=' -and ($Index + 1) -lt $Body.Length) { + if ($Body[$Index + 1] -eq "`r" -and ($Index + 2) -lt $Body.Length -and $Body[$Index + 2] -eq "`n") { + $Index += 2 + continue + } + if ($Body[$Index + 1] -eq "`n") { + $Index += 1 + continue + } + if (($Index + 2) -lt $Body.Length) { + $Hex = $Body.Substring($Index + 1, 2) + if ($Hex -match '^[0-9A-Fa-f]{2}$') { + $Stream.WriteByte([Convert]::ToByte($Hex, 16)) + $Index += 2 + continue + } + } + } + + $Bytes = [System.Text.Encoding]::Latin1.GetBytes([string]$Char) + $Stream.Write($Bytes, 0, $Bytes.Length) + } + + $Stream.ToArray() + } finally { + $Stream.Dispose() + } + } + + function ConvertTo-CippMimeBodyBytes { + param( + [AllowEmptyString()][string]$Body, + [AllowEmptyString()][string]$TransferEncoding + ) + + switch -Regex (($TransferEncoding ?? '').Trim().ToLowerInvariant()) { + '^base64$' { + return [Convert]::FromBase64String(($Body -replace '\s+', '')) + } + '^quoted-printable$' { + return ConvertFrom-CippMimeQuotedPrintable -Body $Body + } + default { + return [System.Text.Encoding]::Latin1.GetBytes($Body) + } + } + } + + function ConvertTo-CippMimeText { + param( + [byte[]]$Bytes, + [AllowEmptyString()][string]$Charset + ) + + if ($null -eq $Bytes) { return '' } + if (![string]::IsNullOrWhiteSpace($Charset)) { + try { + return [System.Text.Encoding]::GetEncoding($Charset).GetString($Bytes) + } catch { + return [System.Text.Encoding]::UTF8.GetString($Bytes) + } + } + + [System.Text.Encoding]::UTF8.GetString($Bytes) + } + + function Add-CippMimeUrl { + param([AllowEmptyString()][string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { return } + $CleanUrl = [System.Net.WebUtility]::HtmlDecode($Url).Trim() + $CleanUrl = $CleanUrl.TrimEnd('.', ',', ';', ':', '!', '?', ')', ']', '}') + if ($CleanUrl -notmatch '^https?://') { return } + if ($UrlKeys.ContainsKey($CleanUrl)) { return } + + $UrlKeys[$CleanUrl] = $true + $Urls.Add([PSCustomObject]@{ + url = $CleanUrl + threatType = $null + detectionMethod = $null + }) + } + + function Add-CippMimeUrlsFromText { + param([AllowEmptyString()][string]$Text) + + $HrefPattern = "(?i)\bhref\s*=\s*(?:""(?https?://[^""]+)""|'(?https?://[^']+)'|(?https?://[^\s>]+))" + foreach ($Match in [regex]::Matches($Text, $HrefPattern)) { + Add-CippMimeUrl -Url $Match.Groups['url'].Value + } + + $BarePattern = '(?i)\bhttps?://[^\s<>"'']+' + foreach ($Match in [regex]::Matches($Text, $BarePattern)) { + Add-CippMimeUrl -Url $Match.Value + } + } + + function Get-CippMimeSha256 { + param([byte[]]$Bytes) + + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + ([BitConverter]::ToString($Sha256.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } finally { + $Sha256.Dispose() + } + } + + function Read-CippMimePart { + param([AllowEmptyString()][string]$EntityText) + + $Entity = Split-CippMimeEntity -EntityText $EntityText + $ContentType = Split-CippMimeHeaderParameters -HeaderValue ($Entity.Headers['Content-Type'] ?? 'text/plain') + $ContentDisposition = Split-CippMimeHeaderParameters -HeaderValue ($Entity.Headers['Content-Disposition'] ?? '') + $Boundary = $ContentType.Parameters['boundary'] + + if ($ContentType.Value -like 'multipart/*' -and ![string]::IsNullOrWhiteSpace($Boundary)) { + foreach ($Part in (Split-CippMimeMultipartBody -Body $Entity.Body -Boundary $Boundary)) { + Read-CippMimePart -EntityText $Part + } + return + } + + if ($ContentType.Value -eq 'message/rfc822') { + $Rfc822Bytes = ConvertTo-CippMimeBodyBytes -Body $Entity.Body -TransferEncoding $Entity.Headers['Content-Transfer-Encoding'] + $Rfc822Text = [System.Text.Encoding]::UTF8.GetString($Rfc822Bytes) + Read-CippMimePart -EntityText $Rfc822Text + return + } + + $FileName = $ContentDisposition.Parameters['filename*'] ?? + $ContentDisposition.Parameters['filename'] ?? + $ContentType.Parameters['name*'] ?? + $ContentType.Parameters['name'] + $IsAttachment = ![string]::IsNullOrWhiteSpace($FileName) -or $ContentDisposition.Value -eq 'attachment' + $Bytes = ConvertTo-CippMimeBodyBytes -Body $Entity.Body -TransferEncoding $Entity.Headers['Content-Transfer-Encoding'] + + if ($IsAttachment) { + $Attachments.Add([PSCustomObject]@{ + fileName = $FileName + contentType = $ContentType.Value + fileSize = $Bytes.Length + sha256 = Get-CippMimeSha256 -Bytes $Bytes + threatType = $null + }) + return + } + + if ($ContentType.Value -in @('text/plain', 'text/html')) { + $Text = ConvertTo-CippMimeText -Bytes $Bytes -Charset $ContentType.Parameters['charset'] + Add-CippMimeUrlsFromText -Text $Text + } + } + + Read-CippMimePart -EntityText $Message + + [PSCustomObject]@{ + urls = @($Urls) + attachments = @($Attachments) + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 new file mode 100644 index 0000000000..cc59181bbf --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 @@ -0,0 +1,52 @@ +function Invoke-ExecMailQuarantineSubmit { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.ReadWrite + .DESCRIPTION + Submits a quarantined email message to Microsoft for review (threat submission) via the Graph API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + try { + $TenantFilter = $Request.Body.tenantFilter | Select-Object -First 1 + $Identity = $Request.Body.Identity + $Category = $Request.Body.category.value ?? $Request.Body.category + $Recipient = @($Request.Body.RecipientAddress) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + + if ([string]::IsNullOrEmpty($Identity)) { throw 'No quarantine message Identity provided' } + if ($Category -notin @('notJunk', 'spam', 'phishing', 'malware')) { throw "Invalid submission category '$Category'" } + if ([string]::IsNullOrEmpty($Recipient)) { throw 'No recipient address provided' } + + # Export the quarantined message and submit its content to Microsoft for analysis + $Export = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Export-QuarantineMessage' -cmdParams @{ 'Identity' = $Identity } + if ([string]::IsNullOrEmpty($Export.Eml)) { throw 'Could not export the quarantined message' } + + $GraphBody = ConvertTo-Json -Depth 5 -InputObject @{ + '@odata.type' = '#microsoft.graph.security.emailContentThreatSubmission' + category = $Category + recipientEmailAddress = $Recipient + fileContent = $Export.Eml + } + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/security/threatSubmission/emailThreats' -tenantid $TenantFilter -AsApp $true -body $GraphBody + + $Message = "Successfully submitted quarantined message $Identity to Microsoft for review as '$Category'" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + $Results = [pscustomobject]@{'Results' = $Message } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Quarantine message submission failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results = [pscustomobject]@{'Results' = "Failed to submit message for review. $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Results + }) + +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 index 08f15c3e27..c388fc5259 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 @@ -9,6 +9,7 @@ function Invoke-ExecQuarantineManagement { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers # Interact with query parameters or the body of the request. try { $TenantFilter = $Request.Body.tenantFilter | Select-Object -First 1 @@ -24,28 +25,29 @@ function Invoke-ExecQuarantineManagement { ) $params = @{} - if ($ActionType -eq 'Release') { - $params['ReleaseToAll'] = $true - if ($Request.Body.Identity -is [string]) { - $params['Identity'] = $Request.Body.Identity - } else { - $params['Identities'] = $Request.Body.Identity - $params['Identity'] = '000' - } + if ($Request.Body.Identity -is [string]) { + $params['Identity'] = $Request.Body.Identity } else { - $params['ActionType'] = $ActionType - if ($Request.Body.Identity -is [string]) { - $params['Identity'] = $Request.Body.Identity + $params['Identities'] = $Request.Body.Identity + # For -Identities, Exchange requires -Identity to be present, but ignores its value. + $params['Identity'] = '000' + } + + # Delete is a separate cmdlet; Release-QuarantineMessage only accepts Release/Request/Approve/Deny. + if ($ActionType -eq 'Delete') { + $Cmdlet = 'Delete-QuarantineMessage' + } else { + $Cmdlet = 'Release-QuarantineMessage' + if ($ActionType -eq 'Release') { + $params['ReleaseToAll'] = $true } else { - $params['Identities'] = $Request.Body.Identity - # For -Identities, Exchange requires -Identity to be present, but ignores its value. - $params['Identity'] = '000' - } - if ($ActionType -eq 'Deny' -and $UserRecipients.Count -gt 0) { - $params['User'] = $UserRecipients + $params['ActionType'] = $ActionType + if ($ActionType -eq 'Deny' -and $UserRecipients.Count -gt 0) { + $params['User'] = $UserRecipients + } } } - New-ExoRequest -tenantid $TenantFilter -cmdlet 'Release-QuarantineMessage' -cmdParams $params + New-ExoRequest -tenantid $TenantFilter -cmdlet $Cmdlet -cmdParams $params # AllowSender via HostedContentFilterPolicy since -AllowSender switch fails in REST API if ($AllowSender) { @@ -69,21 +71,25 @@ function Invoke-ExecQuarantineManagement { AllowedSenders = $UpdatedSenders } } - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Added $SenderAddress to allowed senders on policy $PolicyName" -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Added $SenderAddress to allowed senders on policy $PolicyName" -Sev 'Info' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Failed to add sender to allow list: $($_.Exception.Message)" -Sev 'Error' -LogData $_ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add sender to allow list: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } $Results = [pscustomobject]@{'Results' = "Successfully processed $($Request.Body.Identity)" } - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Successfully processed Quarantine ID $($Request.Body.Identity)" -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully processed Quarantine ID $($Request.Body.Identity)" -Sev 'Info' } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Quarantine Management failed: $($_.Exception.Message)" -Sev 'Error' -LogData $_ - $Results = [pscustomobject]@{'Results' = "Failed. $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Quarantine Management failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results = [pscustomobject]@{'Results' = "Failed. $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::BadRequest } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK + StatusCode = $StatusCode Body = $Results }) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 index 973b5913d9..01d79ee71a 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 @@ -11,28 +11,40 @@ function Invoke-ListMailQuarantine { param($Request, $TriggerMetadata) # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter + # Entity type: Email (default), SharePointOnline (files) or Teams (Teams messages) + $EntityType = if ($Request.Query.EntityType -in @('Email', 'SharePointOnline', 'Teams')) { $Request.Query.EntityType } else { 'Email' } + # EXO REST silently ignores -EntityType SharePointOnline; the documented filter for Safe Attachments + # files is -QuarantineTypes SPOMalware. Email/Teams work fine via -EntityType. + $EntityTypeParams = if ($EntityType -eq 'SharePointOnline') { @{ QuarantineTypes = 'SPOMalware' } } else { @{ EntityType = $EntityType } } try { $GraphRequest = if ($TenantFilter -ne 'AllTenants') { + $CustomerId = (Get-Tenants -TenantFilter $TenantFilter).customerId $PageSize = 1000 if ($Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination)) { # Manual pagination: return one page per request. The frontend chains requests via # Metadata.nextLink, which for this endpoint is the next Get-QuarantineMessage page number. $Page = if ($Request.Query.nextLink -match '^\d+$') { [int]$Request.Query.nextLink } else { 1 } - $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = $PageSize; Page = $Page } | Select-Object -ExcludeProperty *data.type* + $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams (@{ PageSize = $PageSize; Page = $Page } + $EntityTypeParams) | Select-Object -ExcludeProperty *data.type* # Get-QuarantineMessage supports a maximum Page of 1000 if (@($Results).Count -eq $PageSize -and $Page -lt 1000) { $Metadata = [PSCustomObject]@{ nextLink = [string]($Page + 1) } } + foreach ($Message in @($Results)) { + Add-CIPPQuarantineMessageProperties -Message $Message -Tenant $TenantFilter -CustomerId $CustomerId + } $Results } else { $Page = 1 $AllMessages = [System.Collections.Generic.List[object]]::new() do { - $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = $PageSize; Page = $Page } | Select-Object -ExcludeProperty *data.type* + $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams (@{ PageSize = $PageSize; Page = $Page } + $EntityTypeParams) | Select-Object -ExcludeProperty *data.type* if ($Results) { $AllMessages.AddRange(@($Results)) } $Page++ } while (@($Results).Count -eq $PageSize) + foreach ($Message in $AllMessages) { + Add-CIPPQuarantineMessageProperties -Message $Message -Tenant $TenantFilter -CustomerId $CustomerId + } $AllMessages } } else { @@ -77,6 +89,8 @@ function Invoke-ListMailQuarantine { $Messages = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' foreach ($message in $Messages) { $messageObj = $message.QuarantineMessage | ConvertFrom-Json + # Older cache rows predate EntityType support and only contain Email entries + if (($messageObj.EntityType ?? 'Email') -ne $EntityType) { continue } $messageObj | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $message.Tenant -Force $messageObj } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 index 22a89fd7ff..d10a3436cb 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 @@ -18,8 +18,9 @@ function Invoke-ListMailQuarantineMessage { $EmlBase64 = $GraphRequest.Eml $EmlContent = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EmlBase64)) $Body = @{ - 'Identity' = $Identity - 'Message' = $EmlContent + 'Identity' = $Identity + 'Message' = $EmlContent + 'EmlBase64' = $EmlBase64 } $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 new file mode 100644 index 0000000000..6867323237 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 @@ -0,0 +1,296 @@ +function Invoke-ListMailQuarantineMessageDetails { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.Read + .DESCRIPTION + Retrieves Defender analyzed email details (threats, delivery, authentication, URLs, attachments) + for a quarantined message via the Graph beta security/collaboration/analyzedEmails API. + Falls back to parsing the message headers (Authentication-Results and X-Forefront-Antispam-Report) + for tenants without Defender for Office 365 Plan 2. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + # Only the quarantine Identity is trusted from the caller. NetworkMessageId, RecipientAddress and + # ReceivedTime are derived server-side from the quarantine message itself (see below) so this + # endpoint cannot be used to pull Defender analyzedEmail data for arbitrary, non-quarantined + # messages in the tenant. + $Identity = $Request.Query.Identity + + $Results = @() + $Metadata = @{ Available = $false } + + if ([string]::IsNullOrWhiteSpace($Identity)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @(); Metadata = @{ Available = $false; Message = 'Identity is required' } } + }) + } + + # Resolve the trusted quarantine message first. Binding the Defender lookup to a message that is + # actually quarantined for this tenant is what keeps the Exchange.SpamFilter.Read role from being + # used to investigate messages the operator was never authorized to see. + try { + $QuarantineMessage = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ Identity = $Identity } + } catch { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Results = @(); Metadata = @{ Available = $false; Message = [string](Get-NormalizedError -Message $_.Exception.Message) } } + }) + } + + if (-not $QuarantineMessage -or [string]::IsNullOrWhiteSpace($QuarantineMessage.Identity)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Results = @(); Metadata = @{ Available = $false; Message = 'Quarantined message not found' } } + }) + } + + # NetworkMessageId is the first half of the quarantine Identity ({NetworkMessageId}\{RecipientGuid}). + $NetworkMessageId = [string]($QuarantineMessage.Identity -split '\\')[0] + $RecipientAddress = @($QuarantineMessage.RecipientAddress)[0] + $ReceivedTime = $QuarantineMessage.ReceivedTime + + # Primary source: Defender analyzedEmails (requires Defender for Office 365 Plan 2). + try { + $MessageGuid = [guid]::Empty + if (-not [guid]::TryParse($NetworkMessageId, [ref]$MessageGuid)) { + throw 'NetworkMessageId must be a valid GUID' + } + + # startTime/endTime are required by the analyzedEmails API. When a received time is supplied, + # search a +/-1 day window around it; otherwise fall back to the last 15 days. + $Now = (Get-Date).ToUniversalTime() + $Received = $null + if (![string]::IsNullOrWhiteSpace($ReceivedTime)) { + try { $Received = ([datetime]$ReceivedTime).ToUniversalTime() } catch { $Received = $null } + } + if ($Received) { + $StartDate = $Received.AddDays(-1) + $EndDate = $Received.AddDays(1) + } else { + $StartDate = $Now.AddDays(-15) + $EndDate = $Now + } + if ($EndDate -gt $Now) { $EndDate = $Now } + $StartTime = $StartDate.ToString('yyyy-MM-ddTHH:mm:ssZ') + $EndTime = $EndDate.ToString('yyyy-MM-ddTHH:mm:ssZ') + + $Filter = "networkMessageId eq '$($MessageGuid.Guid)'" + if (![string]::IsNullOrWhiteSpace($RecipientAddress)) { + $Filter += " and recipientEmailAddress eq '$($RecipientAddress -replace "'", "''")'" + } + $EncodedFilter = [System.Uri]::EscapeDataString($Filter) + $Uri = "https://graph.microsoft.com/beta/security/collaboration/analyzedEmails?startTime=$StartTime&endTime=$EndTime&`$filter=$EncodedFilter" + + $GraphRequest = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true + if (@($GraphRequest | Where-Object { $_ }).Count -gt 0) { + $Results = @($GraphRequest) + $Metadata = @{ Available = $true; Source = 'Defender' } + } + } catch { + # Tenants without Defender for Office 365 Plan 2 get an 'Invalid subscription' error here. + $DefenderError = [string](Get-NormalizedError -Message $_.Exception.Message) + $Metadata.Message = $DefenderError + # A missing SecurityAnalyzedMessage.Read.All grant fails with an authorization error rather + # than the subscription error above. Flag it so the frontend can prompt to add the missing + # permission instead of silently presenting the reduced header-only fallback as success. + if ($DefenderError -match '(?i)Authorization_RequestDenied|forbidden|insufficient privileges|do(es)? not have permission|Access(Is)?Denied') { + $Metadata.PermissionError = $true + } + } + + # Fallback: parse the message headers, then enrich from the exported EML and optional ATP report. + # Shaped like a partial analyzedEmail object so the frontend can use a single mapping. + if ($Results.Count -eq 0 -and ![string]::IsNullOrWhiteSpace($Identity)) { + try { + $HeaderResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessageHeader' -cmdParams @{ 'Identity' = $Identity } + $RawHeaders = [string]($HeaderResult.Header ?? $HeaderResult) + if (![string]::IsNullOrWhiteSpace($RawHeaders)) { + # Unfold RFC 5322 continuation lines so each header occupies a single line + $HeaderLines = ($RawHeaders -replace "(?m)\r?\n[ \t]+", ' ') -split "\r?\n" + $GetHeader = { + param($Name) + $Pattern = "^(?i)$([regex]::Escape($Name)):\s*" + [string](($HeaderLines | Where-Object { $_ -match $Pattern } | Select-Object -First 1) -replace $Pattern, '') + } + + $Auth = @{} + $AuthResults = & $GetHeader 'Authentication-Results' + foreach ($Mechanism in @('spf', 'dkim', 'dmarc', 'compauth')) { + if ($AuthResults -match "(?i)\b$Mechanism=([a-z0-9]+)") { $Auth[$Mechanism] = $Matches[1] } + } + + # X-Forefront-Antispam-Report is a semicolon separated list of KEY:VALUE pairs + $Report = @{} + foreach ($Pair in ((& $GetHeader 'X-Forefront-Antispam-Report') -split ';')) { + $Key, $Value = $Pair -split ':', 2 + if ($Key -and $Value) { $Report[$Key.Trim()] = $Value.Trim() } + } + + # https://learn.microsoft.com/defender-office-365/message-headers-eop-mdo + $CategoryNames = @{ + AMP = 'Anti-malware'; BULK = 'Bulk'; DIMP = 'Domain impersonation'; FTBP = 'Common attachment filter' + GIMP = 'Mailbox intelligence impersonation'; HPHISH = 'High confidence phishing'; HPHSH = 'High confidence phishing' + HSPM = 'High confidence spam'; INTOS = 'Intra-organization phishing'; MALW = 'Malware'; OSPM = 'Outbound spam' + PHSH = 'Phishing'; SAP = 'Safe Attachments'; SPM = 'Spam'; SPOOF = 'Spoofing'; UIMP = 'User impersonation' + } + $DirectionNames = @{ INB = 'Inbound'; OUT = 'Outbound'; INT = 'Intra-org' } + + $FromHeader = & $GetHeader 'From' + $SenderDisplayName = if ($FromHeader -match '^\s*"?([^"<]*?)"?\s*<') { $Matches[1].Trim() } else { $null } + $Category = $CategoryNames[$Report['CAT']] ?? $Report['CAT'] + $InternetMessageId = & $GetHeader 'Message-ID' + + $Results = @([PSCustomObject]@{ + recipientEmailAddress = $RecipientAddress + internetMessageId = $InternetMessageId + returnPath = ((& $GetHeader 'Return-Path') -replace '[<>]', '').Trim() + directionality = $DirectionNames[$Report['DIR']] ?? $Report['DIR'] + language = $Report['LANG'] + spamConfidenceLevel = $Report['SCL'] + bulkComplaintLevel = $Report['BCL'] + threatTypes = @($Category | Where-Object { $_ }) + senderDetail = [PSCustomObject]@{ + displayName = $SenderDisplayName + ipv4 = $Report['CIP'] + location = $Report['CTRY'] + } + authenticationDetails = [PSCustomObject]@{ + dmarc = $Auth['dmarc'] + dkim = $Auth['dkim'] + senderPolicyFramework = $Auth['spf'] + compositeAuthentication = $Auth['compauth'] + } + }) + $Metadata.Available = $true + $Metadata.Source = 'Headers' + } + } catch { + $HeaderError = [string](Get-NormalizedError -Message $_.Exception.Message) + $Metadata.Message = @($Metadata.Message, $HeaderError) -ne $null -join ' | ' + } + + $FallbackResult = $Results | Select-Object -First 1 + if ($FallbackResult) { + $EmlBase64 = $null + try { + $Metadata.EmlExported = $false + $ExportResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Export-QuarantineMessage' -cmdParams @{ 'Identity' = $Identity } + $EmlBase64 = [string]$ExportResult.Eml + if (![string]::IsNullOrWhiteSpace($EmlBase64)) { + $Metadata.EmlExported = $true + } + } catch { + $Metadata.EmlExportError = [string](Get-NormalizedError -Message $_.Exception.Message) + } + + try { + $Metadata.EmlParsed = $false + $MaxEmlBytes = 25MB + if (![string]::IsNullOrWhiteSpace($EmlBase64)) { + $EmlBytes = [System.Convert]::FromBase64String($EmlBase64) + if ($EmlBytes.Length -le $MaxEmlBytes) { + $EmlContent = [System.Text.Encoding]::UTF8.GetString($EmlBytes) + $ParsedMime = Read-CippMimeMessage -Message $EmlContent + $FallbackResult | Add-Member -NotePropertyName urls -NotePropertyValue @($ParsedMime.urls) -Force + $FallbackResult | Add-Member -NotePropertyName attachments -NotePropertyValue @($ParsedMime.attachments) -Force + $Metadata.EmlParsed = $true + } else { + $Metadata.EmlSkipped = "Message export exceeds $([math]::Round($MaxEmlBytes / 1MB)) MB parser limit" + } + } + } catch { + $Metadata.EmlParseError = [string](Get-NormalizedError -Message $_.Exception.Message) + } + + try { + $Metadata.AtpReport = $false + $InternetMessageId = [string]$FallbackResult.internetMessageId + if (![string]::IsNullOrWhiteSpace($InternetMessageId)) { + $AtpReceived = $null + if (![string]::IsNullOrWhiteSpace($ReceivedTime)) { + try { $AtpReceived = ([datetime]$ReceivedTime).ToUniversalTime() } catch { $AtpReceived = $null } + } + + $Now = (Get-Date).ToUniversalTime() + if ($AtpReceived) { + $AtpStartDate = $AtpReceived.AddDays(-1) + $AtpEndDate = $AtpReceived.AddDays(1) + if ($AtpEndDate -gt $Now) { $AtpEndDate = $Now } + } else { + $AtpStartDate = $Now.AddDays(-10) + $AtpEndDate = $Now + } + + $AtpParams = @{ + MessageId = $InternetMessageId + StartDate = $AtpStartDate + EndDate = $AtpEndDate + PageSize = 5000 + } + if (![string]::IsNullOrWhiteSpace($RecipientAddress)) { + $AtpParams.RecipientAddress = $RecipientAddress + } + + $AtpReport = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailDetailATPReport' -cmdParams $AtpParams | Where-Object { $_ }) + if (($AtpReport | Measure-Object).Count -gt 0) { + $GetAtpValue = { + param($ReportEntry, [string[]]$Names) + + foreach ($Name in $Names) { + $Property = $ReportEntry.PSObject.Properties[$Name] + if ($Property -and ![string]::IsNullOrWhiteSpace([string]$Property.Value)) { + return [string]$Property.Value + } + } + + $null + } + + $AtpDetectionMethods = @($AtpReport | ForEach-Object { & $GetAtpValue $_ @('Event Type', 'EventType') } | Where-Object { $_ } | Select-Object -Unique) + $AtpThreatTypes = @($AtpReport | ForEach-Object { & $GetAtpValue $_ @('Verdict Type', 'VerdictType') } | Where-Object { $_ } | Select-Object -Unique) + + if ($AtpDetectionMethods.Count -gt 0) { + $FallbackResult | Add-Member -NotePropertyName detectionMethods -NotePropertyValue $AtpDetectionMethods -Force + } + if ($AtpThreatTypes.Count -gt 0) { + $CombinedThreatTypes = @(@($FallbackResult.threatTypes | Where-Object { $_ }) + @($AtpThreatTypes)) | Select-Object -Unique + $FallbackResult | Add-Member -NotePropertyName threatTypes -NotePropertyValue $CombinedThreatTypes -Force + } + + foreach ($AtpEntry in $AtpReport) { + $FileName = & $GetAtpValue $AtpEntry @('File Name', 'FileName') + $VerdictType = & $GetAtpValue $AtpEntry @('Verdict Type', 'VerdictType') + if ([string]::IsNullOrWhiteSpace($FileName) -or [string]::IsNullOrWhiteSpace($VerdictType)) { continue } + + foreach ($Attachment in @($FallbackResult.attachments)) { + if ($Attachment.fileName -eq $FileName) { + $Attachment.threatType = $VerdictType + } + } + } + + $Metadata.AtpReport = $true + } + } + } catch { + $Metadata.AtpError = [string](Get-NormalizedError -Message $_.Exception.Message) + } + } + } + + $Body = @{ + Results = $Results + Metadata = $Metadata + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) + +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 new file mode 100644 index 0000000000..2734bfbf58 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 @@ -0,0 +1,34 @@ +function Invoke-ListMailQuarantineMessageHeader { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.Read + .DESCRIPTION + Retrieves the message headers of a specific quarantined email message by its Identity. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + $Identity = $Request.Query.Identity + + try { + $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessageHeader' -cmdParams @{ 'Identity' = $Identity } + $Body = @{ + 'Identity' = $Identity + 'Header' = [string]($GraphRequest.Header ?? $GraphRequest) + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Body = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) + +} diff --git a/backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 b/backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 new file mode 100644 index 0000000000..88ad754748 --- /dev/null +++ b/backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 @@ -0,0 +1,183 @@ +# Pester tests for Read-CippMimeMessage +# Verifies common quarantine EML parsing cases used by the details fallback path + +Describe 'Read-CippMimeMessage' { + BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Read-CippMimeMessage.ps1' + + . $FunctionPath + + function Get-TestSha256 { + param([byte[]]$Bytes) + + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + ([BitConverter]::ToString($Sha256.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } finally { + $Sha256.Dispose() + } + } + } + + It 'extracts a base64 attachment with file name, size, and SHA256' { + $AttachmentBytes = [System.Text.Encoding]::UTF8.GetBytes('Attachment body') + $AttachmentBase64 = [Convert]::ToBase64String($AttachmentBytes) + $ExpectedHash = Get-TestSha256 -Bytes $AttachmentBytes + $Eml = @" +From: Sender +To: Recipient +Subject: Attachment test +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="mix" + +--mix +Content-Type: text/plain; charset="utf-8" + +See https://example.com/path. +--mix +Content-Type: application/pdf; name="invoice.pdf" +Content-Disposition: attachment; filename="invoice.pdf" +Content-Transfer-Encoding: base64 + +$AttachmentBase64 +--mix-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.attachments.Count | Should -Be 1 + $Result.attachments[0].fileName | Should -Be 'invoice.pdf' + $Result.attachments[0].contentType | Should -Be 'application/pdf' + $Result.attachments[0].fileSize | Should -Be $AttachmentBytes.Length + $Result.attachments[0].sha256 | Should -Be $ExpectedHash + $Result.attachments[0].threatType | Should -BeNullOrEmpty + $Result.urls.url | Should -Contain 'https://example.com/path' + } + + It 'extracts URLs from quoted-printable HTML bodies' { + $Eml = @' +From: Sender +To: Recipient +Subject: URL test +MIME-Version: 1.0 +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Open +Bare link https://tail.example/path. +'@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.urls.Count | Should -Be 2 + $Result.urls.url | Should -Contain 'https://contoso.example/login?x=1' + $Result.urls.url | Should -Contain 'https://tail.example/path' + $Result.urls[0].threatType | Should -BeNullOrEmpty + $Result.urls[0].detectionMethod | Should -BeNullOrEmpty + } + + It 'decodes RFC 2231 UTF-8 filenames without corruption' { + $AttachmentBytes = [System.Text.Encoding]::UTF8.GetBytes('cv body') + $AttachmentBase64 = [Convert]::ToBase64String($AttachmentBytes) + $Eml = @" +From: Sender +To: Recipient +Subject: UTF-8 filename test +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="mix" + +--mix +Content-Type: text/plain; charset="utf-8" + +body +--mix +Content-Type: application/pdf +Content-Disposition: attachment; filename*=utf-8''r%C3%A9sum%C3%A9.pdf +Content-Transfer-Encoding: base64 + +$AttachmentBase64 +--mix-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.attachments.Count | Should -Be 1 + $Result.attachments[0].fileName | Should -Be 'résumé.pdf' + } + + It 'decodes base64-encoded message/rfc822 parts' { + $InnerEml = @" +From: Inner +To: Outer +Subject: Forwarded +Content-Type: text/html; charset="utf-8" + +click +"@ + $InnerBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($InnerEml)) + $Eml = @" +From: Sender +To: Recipient +Subject: Attached message +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="outer" + +--outer +Content-Type: text/plain; charset="utf-8" + +See attached. +--outer +Content-Type: message/rfc822 +Content-Transfer-Encoding: base64 + +$InnerBase64 +--outer-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.urls.url | Should -Contain 'https://inner.example/link' + } + + It 'descends nested multiparts and decodes extended attachment file names' { + $AttachmentBytes = [System.Text.Encoding]::UTF8.GetBytes('nested attachment') + $AttachmentBase64 = [Convert]::ToBase64String($AttachmentBytes) + $Eml = @" +From: Sender +To: Recipient +Subject: Nested test +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="outer" + +--outer +Content-Type: multipart/alternative; boundary="inner" + +--inner +Content-Type: text/plain; charset="utf-8" + +Plain link https://nested.example/plain +--inner +Content-Type: text/html; charset="utf-8" + +HTML link +--inner-- +--outer +Content-Type: application/octet-stream +Content-Disposition: attachment; filename*=utf-8''report%20one.txt +Content-Transfer-Encoding: base64 + +$AttachmentBase64 +--outer-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.urls.url | Should -Contain 'https://nested.example/plain' + $Result.urls.url | Should -Contain 'https://nested.example/html' + $Result.attachments.Count | Should -Be 1 + $Result.attachments[0].fileName | Should -Be 'report one.txt' + $Result.attachments[0].fileSize | Should -Be $AttachmentBytes.Length + } +} + diff --git a/docs/user-documentation/email/administration/quarantine.md b/docs/user-documentation/email/administration/quarantine.md index 610dee20ca..8d4e8aa3b5 100644 --- a/docs/user-documentation/email/administration/quarantine.md +++ b/docs/user-documentation/email/administration/quarantine.md @@ -1,14 +1,27 @@ # Quarantine -This page lists the messages Microsoft Defender for Office 365 and Exchange Online Protection have quarantined for the selected tenant. From here you can read a message safely, trace how it arrived, and release or deny it without going into the Defender portal. +This page lists the messages Microsoft Defender for Office 365 and Exchange Online Protection have quarantined for the selected tenant. From here you can inspect a message safely, trace how it arrived, and release, deny, or delete it without going into the Defender portal. + +The page has three tabs, one per quarantine type: + +| Tab | What it shows | +| -------------- | --------------------------------------------------------------------- | +| Email | Quarantined email messages (Exchange Online Protection). | +| Files | Safe Attachments files quarantined from SharePoint/OneDrive. | +| Teams Messages | Quarantined Teams messages. | + +Files and Teams quarantine require Defender for Office 365, so those tabs are empty for tenants without it. Rows in the AllTenants view are tagged with their tenant, and every per-message action is executed against the tenant the message belongs to. ## Filters +The Email tab offers release-status and quarantine-reason filters: + | Filter | Shows | | ------------ | --------------------------------------------------------------------------------------------- | | Not Released | Messages still sitting in quarantine with no request against them. | | Released | Messages that have already been released to their recipients. | | Requested | Messages a recipient has asked to have released, which are the ones waiting on your decision. | +| High Confidence Phishing / Phishing / Spam / Malware / Bulk / Transport Rule | Messages quarantined for that reason. | ## Table Details @@ -16,9 +29,15 @@ The properties returned are for the Exchange Online PowerShell command `Get-Quar Messages are listed newest first. Choosing AllTenants starts a background job to gather messages from every tenant, so the table reports that it is still loading until that job finishes. +## Row Details Flyout + +Clicking a row opens a flyout with the message's full details in expandable sections: **Quarantine Details**, **Delivery Details**, **Email Details**, and **Authentication**. When Microsoft Defender for Office 365 Plan 2 is available the delivery and authentication sections are enriched with the analyzed threat data, including per-URL and per-attachment threat verdicts. Without it, CIPP falls back to parsing the message headers and contents, so the sections are populated but individual URL/attachment verdicts are not shown. The actions at the bottom of the flyout are the same as the table actions. + ## Table Actions -
ActionDescriptionBulk Action Available
View MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
+The Email tab offers the full action set: + +
ActionDescriptionBulk Action Available
Preview MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
View Message HeadersOpens a modal with the raw RFC 5322 message headers.false
Download Message (.eml)Downloads the quarantined message as a .eml file for offline analysis.false
View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
Delete from QuarantinePermanently deletes the message from quarantine. Greyed out on a message that has already been released.true
Submit to Microsoft for ReviewSubmits the quarantined message to Microsoft as a threat submission so they can review its classification. Prompts for a category (clean, spam, phishing, or malware).false
Block SenderAdds the sender to the tenant's sender block list, optionally without an expiration date or with a note.true
Open Email Entity in DefenderOpens the message's entity view in Microsoft Defender to surface the full detection details.false
More InfoOpens the Extended Info flyout with the full details for the selected row.false
The flyout highlights the message ID, recipient address, and quarantine type. @@ -26,4 +45,8 @@ The flyout highlights the message ID, recipient address, and quarantine type. **Release & Allow Sender** adds a standing allow entry to the anti-spam policy, and that entry stays until it is removed by hand. Use it for a sender that is genuinely being caught wrongly, and prefer a plain **Release** otherwise. {% endhint %} +{% hint style="info" %} +**Submit to Microsoft for Review** exports the quarantined message and submits it to Microsoft's threat submission pipeline. Submissions are analysed by Microsoft and can help correct false positives and false negatives. +{% endhint %} + {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/components/CippComponents/CippOffCanvas.jsx b/frontend/src/components/CippComponents/CippOffCanvas.jsx index 66705e0752..e37e593edf 100644 --- a/frontend/src/components/CippComponents/CippOffCanvas.jsx +++ b/frontend/src/components/CippComponents/CippOffCanvas.jsx @@ -29,6 +29,7 @@ export const CippOffCanvas = (props) => { navigationPosition, contentPadding = 2, keepMounted = false, + actionsPosition = "top", richFormatting = false, aboveModal = false, } = props; @@ -71,6 +72,19 @@ export const CippOffCanvas = (props) => { } }); + const infoCard = (extendedInfo.length > 0 || actions?.length > 0) && ( + + + + ); + const SIZE_WIDTHS = { sm: 400, md: 600, lg: 800, xl: 1000 }; const drawerWidth = mdDown ? "100%" : (SIZE_WIDTHS[size] ?? 400); // Prev/next navigation exists on this drawer (row detail view); on phones the 24px @@ -162,18 +176,7 @@ export const CippOffCanvas = (props) => { }} > - {extendedInfo.length > 0 && ( - - - - )} + {actionsPosition !== "bottom" && infoCard} { {typeof children === "function" ? children(extendedData) : children} + {actionsPosition === "bottom" && infoCard} diff --git a/frontend/src/components/CippComponents/CippQuarantineDetails.jsx b/frontend/src/components/CippComponents/CippQuarantineDetails.jsx new file mode 100644 index 0000000000..1e39e0e91f --- /dev/null +++ b/frontend/src/components/CippComponents/CippQuarantineDetails.jsx @@ -0,0 +1,424 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Chip, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material' +import { ExpandMore } from '@mui/icons-material' +import { CippPropertyList } from './CippPropertyList' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { getCippFormatting } from '../../utils/get-cipp-formatting' +import { ApiGetCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' + +// Convert camelCase/underscore Graph enum values to readable text, e.g. 'softFail' -> 'Soft fail' +const formatEnum = (value) => { + if (typeof value !== 'string' || value === '') return value + const spaced = value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase() +} + +const releaseStatusLabels = { + NOTRELEASED: 'Not released', + RELEASED: 'Released', + REQUESTED: 'Release requested', + DENIED: 'Release denied', + PREPARING: 'Preparing', + ERROR: 'Error', +} + +const joinList = (value) => + Array.isArray(value) ? value.filter(Boolean).join(', ') : value + +const threatChipColor = (threatType) => { + // Match on substrings: the same threat arrives in different forms depending on the source, + // e.g. 'HighConfPhish' (enum) vs 'High Confidence Phish' (Exchange display value). + const threat = String(threatType ?? '').toLowerCase() + if (!threat) return 'default' + if (threat.includes('malware') || threat.includes('phish')) return 'error' + if (threat.includes('spam') || threat.includes('bulk')) return 'warning' + return 'default' +} + +const formatBytes = (bytes) => { + if (typeof bytes !== 'number' || Number.isNaN(bytes)) return bytes + if (bytes < 1024) return `${bytes} B` + let value = bytes + let unit = 'B' + for (const nextUnit of ['KB', 'MB', 'GB']) { + value = value / 1024 + unit = nextUnit + if (value < 1024) break + } + return `${value.toFixed(1)} ${unit}` +} + +const hasValue = (value) => { + if (value === undefined || value === null || value === '') return false + if (Array.isArray(value) && value.length === 0) return false + return true +} + +const buildProperties = (fields) => + fields + .filter(({ value }) => hasValue(value)) + .map(({ label, value, field }) => ({ + label, + value: field ? getCippFormatting(value, field) : value, + })) + +const Section = ({ + title, + isFetching = false, + fields, + children, + defaultExpanded = true, +}) => { + // While fetching, show label-only skeleton rows; otherwise drop empty fields entirely + const propertyItems = fields + ? isFetching + ? fields.map(({ label }) => ({ label })) + : buildProperties(fields) + : [] + if (!isFetching && propertyItems.length === 0 && !children) return null + return ( + + }> + {title} + + + {children ?? ( + + )} + + + ) +} + +export const CippQuarantineDetails = ({ row }) => { + const currentTenant = useSettings().currentTenant + // The Defender lookup must target the tenant the message belongs to (AllTenants view) + const tenantFilter = row?.Tenant ?? currentTenant + const isEmail = (row?.EntityType ?? 'Email') === 'Email' + const networkMessageId = + row?.NetworkMessageId ?? row?.Identity?.split('\\')[0] + const recipient = Array.isArray(row?.RecipientAddress) + ? row.RecipientAddress[0] + : row?.RecipientAddress + + const details = ApiGetCall({ + url: '/api/ListMailQuarantineMessageDetails', + data: { + tenantFilter: tenantFilter, + NetworkMessageId: networkMessageId, + RecipientAddress: recipient, + ReceivedTime: row?.ReceivedTime, + Identity: row?.Identity, + }, + waiting: Boolean(row && isEmail && networkMessageId && tenantFilter), + queryKey: `QuarantineMessageDetails-${tenantFilter}-${networkMessageId}-${recipient}`, + }) + + if (!row) return null + + const analyzed = + details.data?.Results?.find( + (entry) => + entry.recipientEmailAddress?.toLowerCase() === recipient?.toLowerCase() + ) ?? details.data?.Results?.[0] + const isEnriching = isEmail && details.isFetching + const enrichmentUnavailable = isEmail && details.isSuccess && !analyzed + const headerFallback = isEmail && details.data?.Metadata?.Source === 'Headers' + + const quarantineFields = [ + { label: 'Received', value: row.ReceivedTime, field: 'ReceivedTime' }, + { label: 'Expires', value: row.Expires, field: 'Expires' }, + { label: 'Subject', value: row.Subject }, + { label: 'Quarantine Reason', value: row.Type }, + { label: 'Policy Type', value: row.PolicyType }, + { label: 'Policy Name', value: row.PolicyName }, + { + label: 'Release Status', + value: releaseStatusLabels[row.ReleaseStatus] ?? row.ReleaseStatus, + }, + { label: 'Released By', value: row.ReleasedUser, field: 'ReleasedUser' }, + { + label: 'Quarantined User', + value: row.QuarantinedUser, + field: 'QuarantinedUser', + }, + { label: 'Reported', value: row.Reported, field: 'Reported' }, + { + label: 'Override Sources', + value: joinList(analyzed?.overrideSources?.map(formatEnum)), + }, + ] + + const deliveryFields = [ + { + label: 'Original Threats', + value: analyzed?.originalDelivery?.originalThreats, + }, + { label: 'Latest Threats', value: analyzed?.latestDelivery?.latestThreats }, + { + label: 'Original Location', + value: formatEnum(analyzed?.originalDelivery?.location), + }, + { + label: 'Latest Delivery Location', + value: formatEnum(analyzed?.latestDelivery?.location), + }, + { + label: 'Delivery Action', + value: formatEnum(analyzed?.originalDelivery?.action), + }, + { + label: 'Latest Delivery Action', + value: formatEnum(analyzed?.latestDelivery?.action), + }, + { + label: 'Detection Technologies', + value: joinList(analyzed?.detectionMethods), + }, + { + label: 'Threat Types', + value: joinList( + analyzed?.threatTypes + ?.filter((threat) => !['none', 'unknown'].includes(threat)) + .map(formatEnum) + ), + }, + { + label: 'Primary Override Source', + value: formatEnum(analyzed?.primaryOverrideSource), + }, + { label: 'Policy Action', value: formatEnum(analyzed?.policyAction) }, + { label: 'Phish Confidence Level', value: analyzed?.phishConfidenceLevel }, + { label: 'Spam Confidence Level', value: analyzed?.spamConfidenceLevel }, + { label: 'Bulk Complaint Level', value: analyzed?.bulkComplaintLevel }, + ] + + const emailFields = [ + { + label: 'Sender Display Name', + value: analyzed?.senderDetail?.displayName, + }, + { + label: 'Sender Address', + value: analyzed?.senderDetail?.mailFromAddress ?? row.SenderAddress, + }, + { + label: 'Sender Mail From Address', + value: analyzed?.senderDetail?.fromAddress, + }, + { label: 'Return Path', value: analyzed?.returnPath }, + { label: 'Sender IP', value: analyzed?.senderDetail?.ipv4 }, + { label: 'Sender Location', value: analyzed?.senderDetail?.location }, + { + label: 'Recipient(s)', + value: row.RecipientAddress, + field: 'RecipientAddress', + }, + { label: 'Distribution List', value: analyzed?.distributionList }, + { + label: 'Direction', + value: formatEnum(analyzed?.directionality) ?? row.Direction, + }, + { label: 'Network Message ID', value: networkMessageId }, + { + label: 'Internet Message ID', + value: analyzed?.internetMessageId ?? row.MessageId, + }, + { label: 'Size', value: row.Size, field: 'Size' }, + { label: 'Language', value: analyzed?.language }, + { label: 'Entity Type', value: row.EntityType }, + { label: 'Teams Conversation Type', value: row.TeamsConversationType }, + ] + + const authenticationFields = [ + { + label: 'DMARC', + value: formatEnum(analyzed?.authenticationDetails?.dmarc), + }, + { label: 'DKIM', value: formatEnum(analyzed?.authenticationDetails?.dkim) }, + { + label: 'SPF', + value: formatEnum(analyzed?.authenticationDetails?.senderPolicyFramework), + }, + { + label: 'Composite Authentication', + value: formatEnum( + analyzed?.authenticationDetails?.compositeAuthentication + ), + }, + ] + + return ( + + + {row.Subject} + + {hasValue(row.Type) && ( + + )} + {hasValue(row.ReleaseStatus) && ( + + )} + {analyzed?.attachments?.length > 0 && ( + + )} + {analyzed?.urls?.length > 0 && ( + + )} + + {enrichmentUnavailable && ( + + Extended threat details are unavailable for this message (requires + Microsoft Defender for Office 365). + + )} + {headerFallback && ( + + Showing details parsed from the message headers and message + contents. Microsoft per-URL and per-attachment threat verdicts + require Microsoft Defender for Office 365 Plan 2. + + )} + + +
+
+
+
+ {analyzed?.urls?.length > 0 && ( +
+ + + + URL + Threat + Detection Method + + + + {analyzed.urls.map((urlEntry, index) => ( + + + {urlEntry.url} + + + + + + {urlEntry.detectionMethod} + + ))} + +
+
+ )} + {analyzed?.attachments?.length > 0 && ( +
+ + + + File Name + Threat + Malware Family + Size + SHA256 + + + + {analyzed.attachments.map((attachment, index) => ( + + + {attachment.fileName} + + + + + {attachment.malwareFamily} + {formatBytes(attachment.fileSize)} + + {attachment.sha256 && ( + + )} + + + ))} + +
+
+ )} + + + ) +} + +export default CippQuarantineDetails diff --git a/frontend/src/components/CippComponents/CippQuarantineTable.jsx b/frontend/src/components/CippComponents/CippQuarantineTable.jsx new file mode 100644 index 0000000000..0f59f695fd --- /dev/null +++ b/frontend/src/components/CippComponents/CippQuarantineTable.jsx @@ -0,0 +1,555 @@ +import { useEffect, useState } from 'react' +import { + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Skeleton, + Typography, +} from '@mui/material' +import { Block, Close, Done, DoneAll } from '@mui/icons-material' +import { + ArrowDownTrayIcon, + ArrowTopRightOnSquareIcon, + CodeBracketIcon, + DocumentTextIcon, + EyeIcon, + FlagIcon, + NoSymbolIcon, + TrashIcon, +} from '@heroicons/react/24/outline' +import { CippTablePage } from './CippTablePage.jsx' +import { CippMessageViewer } from './CippMessageViewer.jsx' +import { CippQuarantineDetails } from './CippQuarantineDetails.jsx' +import { CippDataTable } from '../CippTable/CippDataTable' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' + +const traceDetailColumns = [ + 'Received', + 'Status', + 'SenderAddress', + 'RecipientAddress', +] + +const releaseStatusFilters = [ + { + filterName: 'Not Released', + value: [{ id: 'ReleaseStatus', value: 'NOTRELEASED' }], + type: 'column', + filterType: 'equal', + }, + { + filterName: 'Released', + value: [{ id: 'ReleaseStatus', value: 'RELEASED' }], + type: 'column', + filterType: 'equal', + }, + { + filterName: 'Requested', + value: [{ id: 'ReleaseStatus', value: 'REQUESTED' }], + type: 'column', + filterType: 'equal', + }, +] + +const quarantineReasonFilters = [ + { filterName: 'High Confidence Phishing', value: 'HighConfPhish' }, + { filterName: 'Phishing', value: 'Phish' }, + { filterName: 'Spam', value: 'Spam' }, + { filterName: 'Malware', value: 'Malware' }, + { filterName: 'Bulk', value: 'Bulk' }, + { filterName: 'Transport Rule', value: 'TransportRule' }, +].map(({ filterName, value }) => ({ + filterName, + value: [{ id: 'Type', value }], + type: 'column', + filterType: 'equal', +})) + +const pageTitles = { + Email: 'Quarantine - Email', + SharePointOnline: 'Quarantine - Files', + Teams: 'Quarantine - Teams Messages', +} + +export const CippQuarantineTable = ({ entityType = 'Email' }) => { + const tenantFilter = useSettings().currentTenant + const isEmail = entityType === 'Email' + const queryKey = `MailQuarantine-${entityType}-${tenantFilter}` + + // In the AllTenants view each row belongs to a different tenant (row.Tenant); per-message + // actions must target that tenant rather than the page-level "AllTenants" selection. Falls back + // to the page tenant for the normal single-tenant view. + const resolveTenant = (row) => + tenantFilter === 'AllTenants' ? (row?.Tenant ?? tenantFilter) : tenantFilter + + // Preview message dialog + const [messageRow, setMessageRow] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + // Message headers dialog + const [headerRow, setHeaderRow] = useState(null) + const [headerDialogOpen, setHeaderDialogOpen] = useState(false) + + // Download message state + const [downloadRow, setDownloadRow] = useState(null) + + // Message trace dialog + const [traceDialogOpen, setTraceDialogOpen] = useState(false) + const [traceDetails, setTraceDetails] = useState([]) + const [traceMessageId, setTraceMessageId] = useState(null) + const [traceTenant, setTraceTenant] = useState(null) + const [messageSubject, setMessageSubject] = useState(null) + + const messageTenant = resolveTenant(messageRow) + const getMessageContents = ApiGetCall({ + url: '/api/ListMailQuarantineMessage', + data: { + tenantFilter: messageTenant, + Identity: messageRow?.Identity, + }, + waiting: Boolean(messageRow), + queryKey: `ListMailQuarantineMessage-${messageTenant}-${messageRow?.Identity}`, + }) + + const headerTenant = resolveTenant(headerRow) + const getMessageHeaders = ApiGetCall({ + url: '/api/ListMailQuarantineMessageHeader', + data: { + tenantFilter: headerTenant, + Identity: headerRow?.Identity, + }, + waiting: Boolean(headerRow), + queryKey: `ListMailQuarantineMessageHeader-${headerTenant}-${headerRow?.Identity}`, + }) + + const downloadTenant = resolveTenant(downloadRow) + const getMessageDownload = ApiGetCall({ + url: '/api/ListMailQuarantineMessage', + data: { + tenantFilter: downloadTenant, + Identity: downloadRow?.Identity, + }, + waiting: Boolean(downloadRow), + queryKey: `ListMailQuarantineMessageDownload-${downloadTenant}-${downloadRow?.Identity}`, + }) + + const getMessageTraceDetails = ApiPostCall({ + urlFromData: true, + queryKey: `MessageTraceDetail-${traceTenant}-${traceMessageId}`, + onResult: (result) => { + setTraceDetails(result) + }, + }) + + // CippPropertyListCard calls customFunction(actionItem, rowData, {}); table rows call + // customFunction(rowData). Accept both signatures by detecting which arg carries Identity. + const resolveRow = (...args) => (args[0]?.Identity ? args[0] : args[1]) + + const viewMessage = (...args) => { + const row = resolveRow(...args) + setMessageRow(row) + setDialogOpen(true) + } + + const viewHeaders = (...args) => { + const row = resolveRow(...args) + setHeaderRow(row) + setHeaderDialogOpen(true) + } + + const downloadMessage = (...args) => { + const row = resolveRow(...args) + setDownloadRow(row) + } + + const viewMessageTrace = (...args) => { + const row = resolveRow(...args) + const rowTenant = resolveTenant(row) + setTraceTenant(rowTenant) + setTraceMessageId(row.MessageId) + getMessageTraceDetails.mutate({ + url: '/api/ListMessageTrace', + data: { + tenantFilter: rowTenant, + messageId: row.MessageId, + }, + }) + setMessageSubject(row.Subject) + setTraceDialogOpen(true) + } + + const openInDefender = (...args) => { + const row = resolveRow(...args) + const networkMessageId = + row.NetworkMessageId ?? row.Identity?.split('\\')[0] + const recipient = Array.isArray(row.RecipientAddress) + ? row.RecipientAddress[0] + : row.RecipientAddress + const receivedTime = row.ReceivedTime + ? new Date(row.ReceivedTime).toISOString() + : '' + let url = + `https://security.microsoft.com/emailentityV2?id=${encodeURIComponent(networkMessageId)}` + + `&recipient=${encodeURIComponent(recipient ?? '')}` + + `&startTime=${encodeURIComponent(receivedTime)}` + + `&endTime=${encodeURIComponent(receivedTime)}` + + `&contentonly=1` + + `&subject=${encodeURIComponent(row.Subject ?? '')}` + + `&entityId=${encodeURIComponent(`${networkMessageId}_${recipient ?? ''}`)}` + if (row.CustomerId) { + url += `&tid=${row.CustomerId}` + } + window.open(url, '_blank') + } + + useEffect(() => { + if ( + downloadRow && + getMessageDownload.isSuccess && + getMessageDownload.data?.Message + ) { + const networkMessageId = + downloadRow.NetworkMessageId ?? downloadRow.Identity?.split('\\')[0] + const fileName = `${( + downloadRow.Subject || + networkMessageId || + 'quarantined-message' + ) + .replace(/[\\/:*?"<>|]/g, '_') + .slice(0, 100)}.eml` + // Use the raw base64 export when available to preserve non-UTF-8 MIME content + const emlBase64 = getMessageDownload.data.EmlBase64 + let blob + if (emlBase64) { + const bytes = Uint8Array.from(atob(emlBase64), (c) => c.charCodeAt(0)) + blob = new Blob([bytes], { type: 'message/rfc822' }) + } else { + blob = new Blob([getMessageDownload.data.Message], { + type: 'message/rfc822', + }) + } + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) + setDownloadRow(null) + } + }, [getMessageDownload.isSuccess, getMessageDownload.data, downloadRow]) + + const actions = [ + { + label: 'Release', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Release', + }, + confirmText: 'Are you sure you want to release this message?', + icon: , + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + ...(isEmail + ? [ + { + label: 'Release & Allow Sender', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Release', + AllowSender: true, + SenderAddress: 'SenderAddress', + PolicyName: 'PolicyName', + }, + confirmText: + 'Are you sure you want to release this email and add the sender to the whitelist?', + icon: , + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + { + label: 'Deny', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Deny', + RecipientAddress: 'RecipientAddress', + }, + confirmText: 'Are you sure you want to deny this message?', + icon: , + condition: (row) => row.ReleaseStatus === 'REQUESTED', + }, + ] + : []), + { + label: 'Delete from Quarantine', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Delete', + }, + confirmText: + 'Are you sure you want to permanently delete this message from quarantine? This action cannot be undone.', + icon: , + color: 'danger', + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + ...(isEmail + ? [ + { + label: 'Preview Message', + noConfirm: true, + customFunction: viewMessage, + icon: , + hideBulk: true, + }, + { + label: 'View Message Headers', + noConfirm: true, + customFunction: viewHeaders, + icon: , + hideBulk: true, + }, + { + label: 'Download Message (.eml)', + noConfirm: true, + customFunction: downloadMessage, + icon: , + hideBulk: true, + }, + { + label: 'View Message Trace', + noConfirm: true, + customFunction: viewMessageTrace, + icon: , + hideBulk: true, + }, + { + label: 'Submit to Microsoft for Review', + type: 'POST', + url: '/api/ExecMailQuarantineSubmit', + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + RecipientAddress: 'RecipientAddress', + }, + fields: [ + { + type: 'autoComplete', + name: 'category', + label: 'Submission category', + multiple: false, + creatable: false, + options: [ + { + label: 'Clean - should not have been quarantined', + value: 'notJunk', + }, + { label: 'Spam', value: 'spam' }, + { label: 'Phishing', value: 'phishing' }, + { label: 'Malware', value: 'malware' }, + ], + validators: { required: 'Please select a category' }, + }, + ], + confirmText: 'Submit "[Subject]" to Microsoft for analysis?', + icon: , + hideBulk: true, + }, + { + label: 'Block Sender', + type: 'POST', + url: '/api/AddTenantAllowBlockList', + data: { + tenantID: 'Tenant', + entries: 'SenderAddress', + listType: '!Sender', + listMethod: '!Block', + }, + fields: [ + { + type: 'switch', + name: 'NoExpiration', + label: 'Never expire (default: expires after 30 days)', + }, + { + type: 'textField', + name: 'notes', + label: 'Notes (optional)', + }, + ], + confirmText: + 'Block sender [SenderAddress] by adding an entry to the Tenant Allow/Block List?', + icon: , + }, + { + label: 'Open Email Entity in Defender', + noConfirm: true, + customFunction: openInDefender, + icon: , + hideBulk: true, + }, + ] + : []), + ] + + const offCanvas = { + size: 'lg', + actions: actions, + actionsPosition: 'bottom', + children: (row) => , + } + + const filterList = isEmail + ? [...releaseStatusFilters, ...quarantineReasonFilters] + : releaseStatusFilters + + const simpleColumns = [ + 'ReceivedTime', + 'Subject', + 'SenderAddress', + 'Type', + 'ReleaseStatus', + 'PolicyType', + 'Expires', + 'RecipientAddress', + 'ReleasedUser', + 'Tenant', + ] + + return ( + <> + + setDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Quarantine Message + setDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageContents.isSuccess ? ( + + ) : ( + + )} + + + setHeaderDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Message Headers - {headerRow?.Subject} + setHeaderDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageHeaders.isSuccess ? ( + + {getMessageHeaders?.data?.Header} + + ) : ( + + )} + + + setTraceDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Message Trace - {messageSubject} + setTraceDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageTraceDetails.isPending && ( + + {' '} + Loading message trace details... + + )} + {getMessageTraceDetails.isSuccess && ( + + getMessageTraceDetails.mutate({ + url: '/api/ListMessageTrace', + data: { + tenantFilter: traceTenant, + messageId: traceMessageId, + }, + }) + } + isFetching={getMessageTraceDetails.isPending} + /> + )} + + + + ) +} + +export default CippQuarantineTable diff --git a/frontend/src/pages/email/administration/quarantine/files.js b/frontend/src/pages/email/administration/quarantine/files.js new file mode 100644 index 0000000000..7d17fadc36 --- /dev/null +++ b/frontend/src/pages/email/administration/quarantine/files.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { CippQuarantineTable } from '../../../../components/CippComponents/CippQuarantineTable.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/src/pages/email/administration/quarantine/index.js b/frontend/src/pages/email/administration/quarantine/index.js index e34f7d57ff..693f2434fe 100644 --- a/frontend/src/pages/email/administration/quarantine/index.js +++ b/frontend/src/pages/email/administration/quarantine/index.js @@ -1,254 +1,14 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' -import { useEffect, useState } from 'react' -import { - Dialog, - DialogTitle, - DialogContent, - IconButton, - Skeleton, - Typography, - CircularProgress, -} from '@mui/material' -import { Block, Close, Done, DoneAll } from '@mui/icons-material' -import { CippMessageViewer } from '../../../../components/CippComponents/CippMessageViewer.jsx' -import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall' -import { useSettings } from '../../../../hooks/use-settings' -import { EyeIcon, DocumentTextIcon } from '@heroicons/react/24/outline' -import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { CippQuarantineTable } from '../../../../components/CippComponents/CippQuarantineTable.jsx' +import tabOptions from './tabOptions.json' -const simpleColumns = [ - 'ReceivedTime', - 'ReleaseStatus', - 'Subject', - 'SenderAddress', - 'RecipientAddress', - 'Type', - 'PolicyName', - 'Tenant', -] -const detailColumns = ['Received', 'Status', 'SenderAddress', 'RecipientAddress'] -const pageTitle = 'Quarantine Management' +const Page = () => -const Page = () => { - const tenantFilter = useSettings().currentTenant - const [dialogOpen, setDialogOpen] = useState(false) - const [dialogContent, setDialogContent] = useState(null) - const [messageId, setMessageId] = useState(null) - const [traceDialogOpen, setTraceDialogOpen] = useState(false) - const [traceDetails, setTraceDetails] = useState([]) - const [traceMessageId, setTraceMessageId] = useState(null) - const [messageSubject, setMessageSubject] = useState(null) - const [messageContentsWaiting, setMessageContentsWaiting] = useState(false) - - const getMessageContents = ApiGetCall({ - url: '/api/ListMailQuarantineMessage', - data: { - tenantFilter: tenantFilter, - Identity: messageId, - }, - waiting: messageContentsWaiting, - queryKey: `ListMailQuarantineMessage-${messageId}`, - }) - - const getMessageTraceDetails = ApiPostCall({ - urlFromData: true, - queryKey: `MessageTraceDetail-${traceMessageId}`, - onResult: (result) => { - setTraceDetails(result) - }, - }) - - const viewMessage = (row) => { - const id = row.Identity - setMessageId(id) - if (!messageContentsWaiting) { - setMessageContentsWaiting(true) - } - getMessageContents.refetch() - setDialogOpen(true) - } - - const viewMessageTrace = (row) => { - setTraceMessageId(row.MessageId) - getMessageTraceDetails.mutate({ - url: '/api/ListMessageTrace', - data: { - tenantFilter: tenantFilter, - messageId: row.MessageId, - }, - }) - setMessageSubject(row.Subject) - setTraceDialogOpen(true) - } - - useEffect(() => { - if (getMessageContents.isSuccess) { - setDialogContent() - } else { - setDialogContent() - } - }, [getMessageContents.isSuccess, getMessageContents.data]) - - const actions = [ - { - label: 'View Message', - noConfirm: true, - customFunction: viewMessage, - icon: , - hideBulk: true, - }, - { - label: 'View Message Trace', - noConfirm: true, - customFunction: viewMessageTrace, - icon: , - hideBulk: true, - }, - { - label: 'Release', - type: 'POST', - url: '/api/ExecQuarantineManagement', - multiPost: true, - data: { - Identity: 'Identity', - Type: '!Release', - }, - confirmText: 'Are you sure you want to release this message?', - icon: , - condition: (row) => row.ReleaseStatus !== 'RELEASED', - }, - { - label: 'Deny', - type: 'POST', - url: '/api/ExecQuarantineManagement', - multiPost: true, - data: { - Identity: 'Identity', - Type: '!Deny', - RecipientAddress: 'RecipientAddress', - }, - confirmText: 'Are you sure you want to deny this message?', - icon: , - condition: (row) => row.ReleaseStatus === 'REQUESTED', - }, - { - label: 'Release & Allow Sender', - type: 'POST', - url: '/api/ExecQuarantineManagement', - multiPost: true, - data: { - Identity: 'Identity', - Type: '!Release', - AllowSender: true, - SenderAddress: 'SenderAddress', - PolicyName: 'PolicyName', - }, - confirmText: - 'Are you sure you want to release this email and add the sender to the whitelist?', - icon: , - condition: (row) => row.ReleaseStatus !== 'RELEASED', - }, - ] - - const offCanvas = { - extendedInfoFields: ['MessageId', 'RecipientAddress', 'Type'], - actions: actions, - } - - const filterList = [ - { - filterName: 'Not Released', - value: [{ id: 'ReleaseStatus', value: 'NOTRELEASED' }], - type: 'column', - filterType: 'equal', - }, - { - filterName: 'Released', - value: [{ id: 'ReleaseStatus', value: 'RELEASED' }], - type: 'column', - filterType: 'equal', - }, - { - filterName: 'Requested', - value: [{ id: 'ReleaseStatus', value: 'REQUESTED' }], - type: 'column', - filterType: 'equal', - }, - ] - - return ( - <> - - setDialogOpen(false)} maxWidth="lg" fullWidth> - - Quarantine Message - setDialogOpen(false)} - sx={{ position: 'absolute', right: 8, top: 8 }} - > - - - - {dialogContent} - - setTraceDialogOpen(false)} - maxWidth="lg" - fullWidth - > - - Message Trace - {messageSubject} - setTraceDialogOpen(false)} - sx={{ position: 'absolute', right: 8, top: 8 }} - > - - - - - {getMessageTraceDetails.isPending && ( - - Loading message trace - details... - - )} - {getMessageTraceDetails.isSuccess && ( - - getMessageTraceDetails.mutate({ - url: '/api/ListMessageTrace', - data: { - tenantFilter: tenantFilter, - messageId: traceMessageId, - }, - }) - } - isFetching={getMessageTraceDetails.isPending} - /> - )} - - - - ) -} - -Page.getLayout = (page) => {page} +Page.getLayout = (page) => ( + + {page} + +) export default Page diff --git a/frontend/src/pages/email/administration/quarantine/tabOptions.json b/frontend/src/pages/email/administration/quarantine/tabOptions.json new file mode 100644 index 0000000000..b431bab37b --- /dev/null +++ b/frontend/src/pages/email/administration/quarantine/tabOptions.json @@ -0,0 +1,17 @@ +[ + { + "label": "Email", + "path": "/email/administration/quarantine", + "icon": "Email" + }, + { + "label": "Files", + "path": "/email/administration/quarantine/files", + "icon": "FilePresent" + }, + { + "label": "Teams Messages", + "path": "/email/administration/quarantine/teams", + "icon": "Groups" + } +] diff --git a/frontend/src/pages/email/administration/quarantine/teams.js b/frontend/src/pages/email/administration/quarantine/teams.js new file mode 100644 index 0000000000..4bbe6f3eda --- /dev/null +++ b/frontend/src/pages/email/administration/quarantine/teams.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { CippQuarantineTable } from '../../../../components/CippComponents/CippQuarantineTable.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx b/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx index b3f5e05961..dbe2f48737 100644 --- a/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx +++ b/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { act, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Button } from '@mui/material' import { renderWithTheme } from '../../test-utils' @@ -52,7 +52,12 @@ const mockDeviceData = { }, } -const InteractiveWrapper = ({ onClose, onNavigateUp, onNavigateDown, ...props }) => { +const InteractiveWrapper = ({ + onClose, + onNavigateUp, + onNavigateDown, + ...props +}) => { const [open, setOpen] = useState(false) return ( <> @@ -146,7 +151,9 @@ describe('CippOffCanvas', () => { expect(onClose).toHaveBeenCalledTimes(1) await waitFor(() => - expect(within(document.body).queryByText('Device Details')).not.toBeInTheDocument() + expect( + within(document.body).queryByText('Device Details') + ).not.toBeInTheDocument() ) }) @@ -188,4 +195,37 @@ describe('CippOffCanvas', () => { // field absent from extendedData renders the N/A fallback expect(root.getByText('N/A')).toBeInTheDocument() }) + + it('renders the info card above children by default and below with actionsPosition bottom', () => { + const renderCanvas = (actionsPosition) => { + renderWithTheme( + ( +
child content
+ )} + /> + ) + } + const childrenBox = () => + within(document.body).getByTestId('custom-children') + const infoValue = () => within(document.body).getByText('DESKTOP-ENTRA-01') + + renderCanvas('top') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_PRECEDING + ).toBeTruthy() + + cleanup() + renderCanvas('bottom') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) }) diff --git a/frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx b/frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx new file mode 100644 index 0000000000..cab1975d1d --- /dev/null +++ b/frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineDetails } from '../../../src/components/CippComponents/CippQuarantineDetails' + +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) + +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +try { + TimeAgo.addDefaultLocale(en) +} catch (e) { + /* already added */ +} + +// producer shapes: row is Get-QuarantineMessage output enriched by Add-CIPPQuarantineMessageProperties, +// analyzed is Invoke-ListMailQuarantineMessageDetails Results[0] (analyzedEmails or header fallback) +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + CustomerId: 'customer-1', + Subject: 'Suspicious invoice', + ReceivedTime: '2026-06-01T10:00:00Z', + Expires: '2026-07-01T10:00:00Z', + Type: 'HighConfPhish', + ReleaseStatus: 'NOTRELEASED', + PolicyType: 'AntiPhish', + PolicyName: 'Default AntiPhish', + SenderAddress: 'bad@evil.example', + RecipientAddress: ['user@fabrikam.com'], + Size: 2048, + Direction: 'Inbound', + EntityType: 'Email', + MessageId: '', + QuarantinedUser: 'user@fabrikam.com', + Reported: false, +} + +const analyzed = { + recipientEmailAddress: 'user@fabrikam.com', + internetMessageId: '', + returnPath: 'bounce@evil.example', + directionality: 'Inbound', + language: 'en', + spamConfidenceLevel: -1, + bulkComplaintLevel: 1, + threatTypes: ['Malware'], + detectionMethods: ['File detonation'], + primaryOverrideSource: 'None', + policyAction: 'Quarantine', + senderDetail: { + displayName: 'Evil Sender', + mailFromAddress: 'bad@evil.example', + fromAddress: 'bad@evil.example', + ipv4: '203.0.113.5', + location: 'US', + }, + originalDelivery: { + originalThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + latestDelivery: { + latestThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + authenticationDetails: { + dmarc: 'fail', + dkim: 'pass', + senderPolicyFramework: 'softfail', + compositeAuthentication: 'fail', + }, + urls: [ + { + url: 'https://evil.example/pay', + threatType: 'Malware', + detectionMethod: 'Detonated', + }, + ], + attachments: [ + { + fileName: 'invoice.pdf', + contentType: 'application/pdf', + fileSize: 1024, + sha256: + 'aa11bb22cc33dd44ee55ff6677889900aabbccddeeff00112233445566778899', + threatType: 'Malware', + malwareFamily: 'TestFamily', + }, + ], +} + +const detailsResult = (metadata = {}) => + getResult({ data: { Results: [analyzed], Metadata: metadata } }) + +const defaultMetadata = { Available: true, Source: 'Defender' } +const headersResult = detailsResult({ Available: true, Source: 'Headers' }) +const defenderResult = detailsResult(defaultMetadata) + +describe('CippQuarantineDetails', () => { + it('shows the header-parsed fallback notice and targets the row tenant for enrichment', () => { + let detailOpts = null + api.get = (opts) => { + if (opts.url === '/api/ListMailQuarantineMessageDetails') { + detailOpts = opts + return headersResult + } + return getResult() + } + renderWithProviders() + + expect( + screen.getByText(/Showing details parsed from the message headers/) + ).toBeInTheDocument() + expect(detailOpts.data.tenantFilter).toBe('fabrikam.com') + expect(detailOpts.data.Identity).toBe(quarantineRow.Identity) + // fallback fields render from the analyzed-shaped object + expect(screen.getAllByText('Fail').length).toBeGreaterThan(0) + expect(screen.getByText('Softfail')).toBeInTheDocument() + }) + + it('colors phishing and malware reason chips as error', () => { + api.get = () => defenderResult + renderWithProviders() + expect( + screen + .getAllByText('HighConfPhish') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Malware') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + }) + + it('colors spam and bulk reason chips as warning', () => { + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Spam') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Bulk') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + }) + + it('renders URL and attachment verdict tables from the analyzed enrichment', () => { + api.get = () => defenderResult + renderWithProviders() + + expect(screen.getByText('https://evil.example/pay')).toBeInTheDocument() + expect(screen.getByText('Detonated')).toBeInTheDocument() + expect(screen.getByText('invoice.pdf')).toBeInTheDocument() + expect(screen.getByText('TestFamily')).toBeInTheDocument() + expect(screen.getByText('1.0 KB')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx b/frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx new file mode 100644 index 0000000000..84bd7efb7c --- /dev/null +++ b/frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx @@ -0,0 +1,98 @@ +import React from 'react' +import { act, screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineTable } from '../../../src/components/CippComponents/CippQuarantineTable' + +const tableProps = vi.hoisted(() => ({ current: null })) +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) +vi.mock('../../../src/components/CippComponents/CippTablePage.jsx', () => ({ + CippTablePage: (props) => { + tableProps.current = props + return
+ }, +})) + +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + Subject: 'Suspicious invoice', + MessageId: '', + ReceivedTime: '2026-06-01T10:00:00Z', + RecipientAddress: ['user@fabrikam.com'], + ReleaseStatus: 'NOTRELEASED', +} + +describe('CippQuarantineTable', () => { + it('gates email-only actions to the Email tab and passes the entity type to the API', () => { + api.get = () => getResult() + const { unmount } = renderWithProviders( + + ) + const { actions, apiData } = tableProps.current + const labels = actions.map((action) => action.label) + + expect(labels).toContain('Release') + expect(labels).toContain('Delete from Quarantine') + expect(labels).not.toContain('Preview Message') + expect(labels).not.toContain('Deny') + expect(labels).not.toContain('Block Sender') + expect(labels).not.toContain('Submit to Microsoft for Review') + expect(labels).not.toContain('Open Email Entity in Defender') + expect(apiData.EntityType).toBe('Teams') + unmount() + + renderWithProviders() + const emailLabels = tableProps.current.actions.map((action) => action.label) + expect(emailLabels).toContain('Preview Message') + expect(emailLabels).toContain('Deny') + expect(emailLabels).toContain('Submit to Microsoft for Review') + expect(emailLabels).toContain('Block Sender') + expect(emailLabels).toContain('Open Email Entity in Defender') + }) + + it('targets the row tenant for per-message calls in the AllTenants view', async () => { + const callOpts = [] + api.get = (opts) => { + callOpts.push(opts) + return getResult() + } + renderWithProviders(, { + settings: settingsWith({ currentTenant: 'AllTenants' }), + }) + + const preview = tableProps.current.actions.find( + (action) => action.label === 'Preview Message' + ) + await act(async () => preview.customFunction(quarantineRow)) + + const contentsCall = callOpts.find( + (opts) => + opts.url === '/api/ListMailQuarantineMessage' && + opts.data?.Identity === quarantineRow.Identity + ) + expect(contentsCall).toBeTruthy() + expect(contentsCall.data.tenantFilter).toBe('fabrikam.com') + }) + + it('renders the raw message headers in the headers dialog', async () => { + const headerText = + 'Received: from mail.evil.example\r\nX-CIPP-Test: present' + api.get = (opts) => + opts.url === '/api/ListMailQuarantineMessageHeader' + ? getResult({ data: { Header: headerText } }) + : getResult() + renderWithProviders() + + const viewHeaders = tableProps.current.actions.find( + (action) => action.label === 'View Message Headers' + ) + await act(async () => viewHeaders.customFunction(quarantineRow)) + + expect(screen.getByText(/X-CIPP-Test: present/)).toBeInTheDocument() + }) +})